diff --git a/lib/matrix_server.ex b/lib/matrix_server.ex index 8bec7df..84e8b25 100644 --- a/lib/matrix_server.ex +++ b/lib/matrix_server.ex @@ -1,9 +1,50 @@ defmodule MatrixServer do - @moduledoc """ - MatrixServer keeps the contexts that define your domain - and business logic. + import Ecto.Changeset + alias Ecto.Changeset - Contexts are also responsible for managing your data, regardless - if it comes from the database, an external API or others. - """ + def convert_change(changeset, old_name, new_name) do + convert_change(changeset, old_name, new_name, &Function.identity/1) + end + + def convert_change(changeset, old_name, new_name, f) do + case changeset do + %Changeset{valid?: true, changes: changes} -> + case Map.fetch(changes, old_name) do + {:ok, value} -> + changeset + |> put_change(new_name, f.(value)) + |> delete_change(old_name) + + :error -> + changeset + end + + _ -> + changeset + end + end + + def validate_api_schema(params, {types, allowed, required}) do + {%{}, types} + |> cast(params, allowed) + |> validate_required(required) + end + + def generate_error(errcode) do + {get_errcode_status(errcode), %{errcode: errcode, error: get_errcode_error(errcode)}} + end + + def get_errcode_error("M_BAD_JSON"), do: "Bad request." + def get_errcode_error("M_USER_IN_USE"), do: "Username is already taken." + def get_errcode_error("M_INVALID_USERNAME"), do: "Invalid username." + + def get_errcode_status(_), do: 400 + + def get_mxid(localpart) when is_binary(localpart) do + "@#{localpart}:#{server_name()}" + end + + def server_name do + Application.get_env(:matrix_server, :server_name) + end end diff --git a/lib/matrix_server/account.ex b/lib/matrix_server/account.ex index fd17395..95cdd91 100644 --- a/lib/matrix_server/account.ex +++ b/lib/matrix_server/account.ex @@ -1,7 +1,11 @@ defmodule MatrixServer.Account do use Ecto.Schema + + import MatrixServer import Ecto.{Changeset, Query} - alias MatrixServer.{Repo, Account} + + alias MatrixServer.{Repo, Account, Device} + alias Ecto.Multi @max_mxid_length 255 @localpart_regex ~r/^([a-z0-9\._=\/])+$/ @@ -9,7 +13,7 @@ defmodule MatrixServer.Account do @primary_key {:localpart, :string, []} schema "accounts" do field :password_hash, :string, redact: true - + has_many :devices, Device, foreign_key: :localpart timestamps(updated_at: false) end @@ -30,9 +34,19 @@ defmodule MatrixServer.Account do end end - def changeset(%Account{} = account, attrs) do + def register(params) do + Multi.new() + |> Multi.insert(:account, changeset(%Account{}, params)) + |> Multi.insert(:device, fn %{account: account} -> + Ecto.build_assoc(account, :devices) + |> Device.changeset(params) + end) + |> Multi.run(:device_with_access_token, &Device.generate_access_token/2) + end + + def changeset(account, params \\ %{}) do account - |> cast(attrs, [:localpart, :password_hash]) + |> cast(params, [:localpart, :password_hash]) |> validate_required([:localpart, :password_hash]) |> validate_length(:password_hash, max: 60) |> validate_format(:localpart, @localpart_regex) @@ -44,8 +58,4 @@ defmodule MatrixServer.Account do # Subtract the "@" and ":" in the MXID. @max_mxid_length - 2 - String.length(server_name()) end - - defp server_name do - Application.get_env(:matrix_server, :server_name) - end end diff --git a/lib/matrix_server/device.ex b/lib/matrix_server/device.ex new file mode 100644 index 0000000..6c27065 --- /dev/null +++ b/lib/matrix_server/device.ex @@ -0,0 +1,32 @@ +defmodule MatrixServer.Device do + use Ecto.Schema + import Ecto.Changeset + alias MatrixServer.{Account, Device} + + @primary_key false + schema "devices" do + field :device_id, :string, primary_key: true + field :access_token, :string + field :display_name, :string + + belongs_to :account, Account, + foreign_key: :localpart, + references: :localpart, + type: :string, + primary_key: true + end + + def changeset(device, params \\ %{}) do + device + |> cast(params, [:localpart, :device_id, :access_token, :display_name]) + |> validate_required([:localpart, :device_id]) + |> unique_constraint([:localpart, :device_id], name: :devices_pkey) + end + + def generate_access_token(repo, %{device: %Device{localpart: localpart, device_id: device_id} = device}) do + access_token = Phoenix.Token.encrypt(MatrixServerWeb.Endpoint, "access_token", {localpart, device_id}) + device + |> change(%{access_token: access_token}) + |> repo.update() + end +end diff --git a/lib/matrix_server_web/controllers/account_controller.ex b/lib/matrix_server_web/controllers/account_controller.ex index 6e8c7aa..8b1c010 100644 --- a/lib/matrix_server_web/controllers/account_controller.ex +++ b/lib/matrix_server_web/controllers/account_controller.ex @@ -2,10 +2,6 @@ defmodule MatrixServerWeb.AccountController do use MatrixServerWeb, :controller alias MatrixServer.Account - def register(conn, _params) do - conn - end - def available(conn, params) do localpart = Map.get(params, "username", "") diff --git a/lib/matrix_server_web/controllers/auth_controller.ex b/lib/matrix_server_web/controllers/auth_controller.ex new file mode 100644 index 0000000..4c486a0 --- /dev/null +++ b/lib/matrix_server_web/controllers/auth_controller.ex @@ -0,0 +1,112 @@ +defmodule MatrixServerWeb.AuthController do + use MatrixServerWeb, :controller + + import MatrixServer + + alias MatrixServer.{Repo, Account} + alias Ecto.Changeset + + @login_type "m.login.dummy" + + def register(conn, %{"auth" => %{"type" => @login_type}} = params) do + # User has started an auth flow. + result = + case sanitize_register_params(params) do + {:ok, params} -> + case Repo.transaction(Account.register(params)) do + {:ok, changeset} -> {:ok, changeset} + {:error, _, changeset, _} -> {:error, get_register_error(changeset)} + end + + {:error, changeset} -> + {:error, get_register_error(changeset)} + end + + {status, data} = + case result do + {:ok, %{device_with_access_token: device}} -> + data = %{user_id: get_mxid(device.localpart)} + + data = + if Map.get(params, "inhibit_login", false) == false do + extra = %{device_id: device.device_id, access_token: device.access_token} + Map.merge(data, extra) + else + data + end + + {200, data} + + {:error, error} -> + generate_error(error) + end + + conn + |> put_status(status) + |> json(data) + end + + def register(conn, %{"auth" => _}) do + # Other login types are unsupported for now. + data = %{errcode: "M_FORBIDDEN", error: "Login type not supported"} + + conn + |> put_status(400) + |> json(data) + end + + def register(conn, _params) do + # User has not started an auth flow. + data = %{ + flows: [%{stages: [@login_type]}], + params: %{} + } + + conn + |> put_status(401) + |> json(data) + end + + defp sanitize_register_params(params) do + changeset = + validate_api_schema(params, register_schema()) + |> convert_change(:username, :localpart) + |> convert_change(:password, :password_hash, &Bcrypt.hash_pwd_salt/1) + + case changeset do + %Changeset{valid?: true, changes: changes} -> {:ok, changes} + _ -> {:error, changeset} + end + end + + defp get_register_error(%Changeset{errors: [error | _]}), do: get_register_error(error) + defp get_register_error({:localpart, {_, [{:constraint, :unique} | _]}}), do: "M_USER_IN_USE" + defp get_register_error({:localpart, {_, [{:validation, _} | _]}}), do: "M_INVALID_USERNAME" + defp get_register_error(_), do: "M_BAD_JSON" + + defp register_schema do + types = %{ + device_id: :string, + initial_device_display_name: :string, + display_name: :string, + password: :string, + username: :string, + localpart: :string, + password_hash: :string, + access_token: :string + } + + allowed = [:device_id, :initial_device_display_name, :username, :password] + required = [:username, :password] + + {types, allowed, required} + end + + def login(conn, _params) do + data = %{flows: [%{type: @login_type}]} + + conn + |> put_status(200) + |> json(data) + end +end diff --git a/lib/matrix_server_web/router.ex b/lib/matrix_server_web/router.ex index 376c80a..4bb1044 100644 --- a/lib/matrix_server_web/router.ex +++ b/lib/matrix_server_web/router.ex @@ -9,7 +9,8 @@ defmodule MatrixServerWeb.Router do pipe_through :api scope "/client/r0", as: :client do - post "/register", AccountController, :register + post "/register", AuthController, :register + get "/login", AuthController, :login get "/register/available", AccountController, :available end diff --git a/priv/repo/migrations/20210623145023_add_devices_table.exs b/priv/repo/migrations/20210623145023_add_devices_table.exs new file mode 100644 index 0000000..631079b --- /dev/null +++ b/priv/repo/migrations/20210623145023_add_devices_table.exs @@ -0,0 +1,18 @@ +defmodule MatrixServer.Repo.Migrations.AddDevicesTable do + use Ecto.Migration + + def change do + create table(:devices, primary_key: false) do + add :device_id, :string, primary_key: true, null: false + add :access_token, :string + add :display_name, :string + + add :localpart, + references(:accounts, column: :localpart, on_delete: :delete_all, type: :string), + primary_key: true, + null: false + end + + create index(:devices, [:localpart]) + end +end