Add minimal SSH key management

This commit is contained in:
Timothée Floure 2020-12-23 12:51:05 +01:00
parent 061a748c2a
commit c2faafe3a3
Signed by: tfloure
GPG key ID: 4502C902C00A1E12
9 changed files with 208 additions and 5 deletions

View file

@ -5,7 +5,7 @@ defmodule RecycledCloud.Accounts do
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias RecycledCloud.Repo alias RecycledCloud.Repo
alias RecycledCloud.Accounts.{User, UserToken, UserNotifier} alias RecycledCloud.Accounts.{User, Key, UserToken, UserNotifier}
alias RecycledCloud.LDAP alias RecycledCloud.LDAP
## Database getters ## Database getters
@ -356,4 +356,27 @@ defmodule RecycledCloud.Accounts do
{:error, :user, changeset, _} -> {:error, changeset} {:error, :user, changeset, _} -> {:error, changeset}
end end
end end
## Keys
def get_key!(id), do: Repo.get!(Key, id)
def get_keys_for(%User{} = user) do
Repo.preload(user, :keys).keys
end
def change_user_key(user, attrs \\ %{}) do
Key.changeset(user, attrs)
end
def add_key(%User{} = user, value, comment \\ nil) do
%Key{}
|> Key.changeset(%{value: value, comment: comment})
|> Ecto.Changeset.put_assoc(:user, user)
|> Repo.insert()
end
def remove_key!(%Key{} = key) do
Repo.delete!(key)
end
end end

View file

@ -0,0 +1,48 @@
defmodule RecycledCloud.Accounts.Key do
use Ecto.Schema
import Ecto.Changeset
schema "keys" do
field :comment, :string
field :value, :string
field :fingerprint, :string
belongs_to :user, RecycledCloud.Accounts.User
timestamps()
end
def process_ssh_key(changeset, value_field, fingerprint_field, comment_field) do
raw_value = get_field(changeset, value_field)
comment = get_field(changeset, comment_field)
with {:is_valid, true} <- {:is_valid, changeset.valid?},
[{pk, attrs}] <- :public_key.ssh_decode(raw_value, :public_key) do
fingerprint = :public_key.ssh_hostkey_fingerprint(pk) |> List.to_string
changeset = put_change(changeset, fingerprint_field, fingerprint)
# Fetch comment from key, if not provided.
embedded_comment = attrs
|> Enum.into(%{})
|> Map.get(:comment)
case {comment, embedded_comment} do
{nil, nil} -> changeset
{nil, new_comment} ->
put_change(changeset, comment_field, List.to_string(new_comment))
{_, _} -> changeset
end
else
{:is_valid, false} -> changeset
_ -> add_error(changeset, value_field, "key is invalid")
end
end
@doc false
def changeset(key, attrs) do
out = key
|> cast(attrs, [:comment, :value])
|> validate_required([:value])
|> process_ssh_key(:value, :fingerprint, :comment)
end
end

View file

@ -15,6 +15,8 @@ defmodule RecycledCloud.Accounts.User do
field :email, :string, virtual: true field :email, :string, virtual: true
field :confirmed_at, :naive_datetime field :confirmed_at, :naive_datetime
has_many :keys, Accounts.Key
timestamps() timestamps()
end end

View file

@ -0,0 +1,53 @@
defmodule RecycledCloudWeb.UserKeysController do
use RecycledCloudWeb, :controller
alias RecycledCloud.Accounts
def index(conn, %{"username" => username}) do
case Accounts.get_user_by_username(username) do
nil ->
conn
|> put_status(:not_found)
|> text("")
user ->
keys = Accounts.get_keys_for(user)
|> Enum.map(fn k -> k.value end)
|> Enum.join("\n")
body = "# Keys for #{user.username}\n" <> keys
conn |> text(body)
end
end
def new(conn, %{"key" => key} = params) do
%{"value" => value, "comment" => comment} = key
user = conn.assigns.current_user
case Accounts.add_key(user, value, comment) do
{:ok, _} ->
conn
|> put_flash(:info, "Key added successfully.")
|> redirect(to: Routes.user_settings_path(conn, :edit))
{:error, _} ->
conn
|> put_flash(:error, "Submitted key is invalid.")
|> redirect(to: Routes.user_settings_path(conn, :edit))
end
end
def delete(conn, %{"key_id" => id}) do
user = conn.assigns.current_user
key = Accounts.get_key!(id)
if key.user_id == user.id do
Accounts.remove_key!(key)
conn
|> put_flash(:info, "Key removed.")
|> redirect(to: Routes.user_settings_path(conn, :edit))
end
end
end

View file

@ -70,5 +70,7 @@ defmodule RecycledCloudWeb.UserSettingsController do
conn conn
|> assign(:email_changeset, Accounts.change_user_email(user)) |> assign(:email_changeset, Accounts.change_user_email(user))
|> assign(:password_changeset, Accounts.change_user_password(user)) |> assign(:password_changeset, Accounts.change_user_password(user))
|> assign(:key_changeset, Accounts.change_user_key(%Accounts.Key{}))
|> assign(:keys, Accounts.get_keys_for(user))
end end
end end

View file

@ -16,16 +16,21 @@ defmodule RecycledCloudWeb.Router do
plug :accepts, ["json"] plug :accepts, ["json"]
end end
pipeline :plain do
plug :accepts, ["text"]
end
scope "/", RecycledCloudWeb do scope "/", RecycledCloudWeb do
pipe_through :browser pipe_through :browser
get "/", PageController, :index get "/", PageController, :index
end end
# Other scopes may use custom stacks. scope "/", RecycledCloudWeb do
# scope "/api", RecycledCloudWeb do pipe_through :plain
# pipe_through :api
# end get "/keys/:username", UserKeysController, :index
end
## Authentication routes ## Authentication routes
@ -48,6 +53,8 @@ defmodule RecycledCloudWeb.Router do
get "/users/settings", UserSettingsController, :edit get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email get "/users/settings/confirm_email/:token", UserSettingsController, :confirm_email
post "/users/settings/keys/new", UserKeysController, :new
get "/users/settings/keys/:key_id/delete", UserKeysController, :delete
end end
scope "/", RecycledCloudWeb do scope "/", RecycledCloudWeb do

View file

@ -58,3 +58,46 @@ method.
<%= submit "Change password" %> <%= submit "Change password" %>
</div> </div>
<% end %> <% end %>
<h3>SSH Keys</h3>
<%= form_for @key_changeset, Routes.user_keys_path(@conn, :new), fn f -> %>
<%= if @key_changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
<%= text_input f, :comment, placeholder: "comment" %>
<%= error_tag f, :comment %>
<br />
<%= textarea f, :value, required: true, placeholder: "value" %>
<%= error_tag f, :value %>
<div>
<%= submit "Add key" %>
</div>
<% end %>
<table>
<thead>
<tr>
<th>Comment</th>
<th>Imported on</th>
<th>Fingerprint</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<%= for key <- @keys do %>
<tr>
<td><%= key.comment %></td>
<td><%= key.inserted_at %></td>
<td><%= key.fingerprint %></td>
<td><%= link "Delete", to: Routes.user_keys_path(@conn, :delete, key.id) %></td>
</tr>
<% end %>
</tbody>
</table>

View file

@ -0,0 +1,15 @@
defmodule RecycledCloud.Repo.Migrations.CreateKeys do
use Ecto.Migration
def change do
create table(:keys) do
add :comment, :string
add :value, :text
add :user_id, references(:users, on_delete: :nothing)
timestamps()
end
create index(:keys, [:user_id])
end
end

View file

@ -0,0 +1,10 @@
defmodule RecycledCloud.Repo.Migrations.AddKeyFingerprint do
use Ecto.Migration
def change do
alter table(:keys) do
add :fingerprint, :string
end
end
end