diff --git a/lib/comfycamp/accounts.ex b/lib/comfycamp/accounts.ex index db4b61e..e4ec82a 100644 --- a/lib/comfycamp/accounts.ex +++ b/lib/comfycamp/accounts.ex @@ -241,6 +241,18 @@ defmodule Comfycamp.Accounts do token end + @doc """ + Generate a pair of bearer and refresh tokens. + """ + def generate_oauth_tokens(user) do + {bearer_token_value, bearer_token} = UserToken.build_bearer_token(user) + Repo.insert!(bearer_token) + {refresh_token_value, refresh_token} = UserToken.build_refresh_token(user) + Repo.insert!(refresh_token) + + {bearer_token_value, refresh_token_value} + end + @doc """ Gets the user with the given signed token. """ diff --git a/lib/comfycamp/accounts/user_token.ex b/lib/comfycamp/accounts/user_token.ex index 418b8eb..6ff4abd 100644 --- a/lib/comfycamp/accounts/user_token.ex +++ b/lib/comfycamp/accounts/user_token.ex @@ -46,6 +46,22 @@ defmodule Comfycamp.Accounts.UserToken do {token, %UserToken{token: token, context: "session", user_id: user.id}} end + @doc """ + Generate a bearer token for oauth. + """ + def build_bearer_token(user) do + token = :crypto.strong_rand_bytes(@rand_size) + {token, %UserToken{token: token, context: "bearer", user_id: user.id}} + end + + @doc """ + Generate a refresh token that may be exchanged for a new bearer token. + """ + def build_refresh_token(user) do + token = :crypto.strong_rand_bytes(@rand_size) + {token, %UserToken{token: token, context: "refresh", user_id: user.id}} + end + @doc """ Checks if the token is valid and returns its underlying lookup query. diff --git a/lib/comfycamp/sso.ex b/lib/comfycamp/sso.ex index 8cb5090..357391c 100644 --- a/lib/comfycamp/sso.ex +++ b/lib/comfycamp/sso.ex @@ -5,9 +5,10 @@ defmodule Comfycamp.SSO do import Ecto.Query, warn: false alias Comfycamp.Repo - alias Comfycamp.Rand alias Comfycamp.SSO.OIDCApp + alias Comfycamp.SSO.OIDCCode + alias Comfycamp.SSO.OIDCRedirectURI @doc """ Returns the list of oidc_apps. @@ -36,7 +37,44 @@ defmodule Comfycamp.SSO do ** (Ecto.NoResultsError) """ - def get_oidc_app!(id), do: Repo.get!(OIDCApp, id) + def get_oidc_app!(id) do + query = + from a in OIDCApp, + preload: [:redirect_uris], + where: a.client_id == ^id + + Repo.one!(query) + end + + def get_oidc_app_by_secret!(client_secret) do + query = + from a in OIDCApp, + where: a.client_secret == ^client_secret + + Repo.one!(query) + end + + def has_redirect_uri?(client_id, redirect_uri) do + query = + from a in OIDCApp, + join: u in assoc(a, :redirect_uris), + where: u.uri == ^redirect_uri and a.client_id == ^client_id + + Repo.aggregate(query, :count) >= 1 + end + + def get_oidc_redirect_uri!(id), do: Repo.get(OIDCRedirectURI, id) + + def get_oidc_code!(value) do + ten_minutes_ago = DateTime.utc_now() |> DateTime.add(-600, :second) + + query = + from c in OIDCCode, + preload: [:oidc_app], + where: c.value == ^value and c.inserted_at >= ^ten_minutes_ago + + Repo.one!(query) + end @doc """ Creates a oidc_app. @@ -51,13 +89,24 @@ defmodule Comfycamp.SSO do """ def create_oidc_app(attrs \\ %{}) do - app = %OIDCApp{ - client_id: Rand.get_random_string(20), - client_secret: Rand.get_random_string(32) - } + %OIDCApp{} + |> OIDCApp.creation_changeset(attrs) + |> Repo.insert() + end - app - |> OIDCApp.changeset(attrs) + @doc """ + Create a temporary code for OIDC app + that may be exchanged for an access token. + """ + def create_oidc_code(attrs \\ %{}) do + %OIDCCode{} + |> OIDCCode.changeset(attrs) + |> Repo.insert() + end + + def create_oidc_redirect_uri(attrs \\ %{}) do + %OIDCRedirectURI{} + |> OIDCRedirectURI.changeset(attrs) |> Repo.insert() end @@ -75,7 +124,7 @@ defmodule Comfycamp.SSO do """ def update_oidc_app(%OIDCApp{} = oidc_app, attrs) do oidc_app - |> OIDCApp.changeset(attrs) + |> OIDCApp.update_changeset(attrs) |> Repo.update() end @@ -95,6 +144,14 @@ defmodule Comfycamp.SSO do Repo.delete(oidc_app) end + def delete_oidc_code(%OIDCCode{} = code) do + Repo.delete(code) + end + + def delete_oidc_redirect_uri(%OIDCRedirectURI{} = uri) do + Repo.delete(uri) + end + @doc """ Returns an `%Ecto.Changeset{}` for tracking oidc_app changes. @@ -105,6 +162,10 @@ defmodule Comfycamp.SSO do """ def change_oidc_app(%OIDCApp{} = oidc_app, attrs \\ %{}) do - OIDCApp.changeset(oidc_app, attrs) + OIDCApp.update_changeset(oidc_app, attrs) + end + + def change_oidc_redirect_uri(%OIDCRedirectURI{} = oidc_redirect_uri, attrs \\ %{}) do + OIDCRedirectURI.changeset(oidc_redirect_uri, attrs) end end diff --git a/lib/comfycamp/sso/id_token.ex b/lib/comfycamp/sso/id_token.ex new file mode 100644 index 0000000..d79da48 --- /dev/null +++ b/lib/comfycamp/sso/id_token.ex @@ -0,0 +1,21 @@ +defmodule Comfycamp.SSO.IDToken do + defstruct [:iss, :sub, :aud, :exp, :iat] + + def build_id_token(user, client_id) do + {_, now} = DateTime.now("Etc/UTC") + issued_at = DateTime.to_unix(now) + + expires_at = + now + |> DateTime.add(1, :day) + |> DateTime.to_unix() + + %Comfycamp.SSO.IDToken{ + iss: "https://" <> System.get_env("PHX_HOST"), + sub: Integer.to_string(user.id), + aud: client_id, + exp: expires_at, + iat: issued_at + } + end +end diff --git a/lib/comfycamp/sso/oidc_app.ex b/lib/comfycamp/sso/oidc_app.ex index d0dc376..53bbfa4 100644 --- a/lib/comfycamp/sso/oidc_app.ex +++ b/lib/comfycamp/sso/oidc_app.ex @@ -2,21 +2,35 @@ defmodule Comfycamp.SSO.OIDCApp do use Ecto.Schema import Ecto.Changeset + alias Comfycamp.SSO.OIDCCode + alias Comfycamp.SSO.OIDCRedirectURI + alias Comfycamp.Rand + @derive {Phoenix.Param, key: :client_id} @primary_key {:client_id, :string, autogenerate: false} schema "oidc_apps" do - field :enabled, :boolean, default: false - field :name, :string field :client_secret, :string + field :name, :string + field :enabled, :boolean, default: false + + has_many :codes, OIDCCode, foreign_key: :oidc_app_id + has_many :redirect_uris, OIDCRedirectURI, foreign_key: :oidc_app_id timestamps(type: :utc_datetime) end @doc false - def changeset(oidc_app, attrs) do + def update_changeset(oidc_app, attrs) do oidc_app |> cast(attrs, [:name, :enabled]) |> validate_required([:name, :enabled]) |> validate_length(:name, min: 2, max: 48) end + + def creation_changeset(oidc_app, attrs) do + oidc_app + |> update_changeset(attrs) + |> change(client_id: Rand.get_random_string(20)) + |> change(client_secret: Rand.get_random_string(32)) + end end diff --git a/lib/comfycamp/sso/oidc_code.ex b/lib/comfycamp/sso/oidc_code.ex new file mode 100644 index 0000000..86e2af1 --- /dev/null +++ b/lib/comfycamp/sso/oidc_code.ex @@ -0,0 +1,31 @@ +defmodule Comfycamp.SSO.OIDCCode do + use Ecto.Schema + import Ecto.Changeset + + alias Comfycamp.Accounts.User + alias Comfycamp.SSO.OIDCApp + alias Comfycamp.Rand + + @derive {Phoenix.Param, key: :value} + @primary_key {:value, :string, autogenerate: false} + schema "oidc_codes" do + field :redirect_uri, :string + belongs_to :user, User + + belongs_to :oidc_app, OIDCApp, + type: :string, + foreign_key: :oidc_app_id, + references: :client_id + + timestamps(type: :utc_datetime, updated_at: false) + end + + @doc false + def changeset(oidc_code, attrs) do + oidc_code + |> cast(attrs, [:user_id, :oidc_app_id, :redirect_uri]) + |> change(value: Rand.get_random_string(12)) + |> validate_required([:value, :user_id, :oidc_app_id, :redirect_uri]) + |> validate_length(:value, min: 6, max: 255) + end +end diff --git a/lib/comfycamp/sso/oidc_redirect_uri.ex b/lib/comfycamp/sso/oidc_redirect_uri.ex new file mode 100644 index 0000000..e91993c --- /dev/null +++ b/lib/comfycamp/sso/oidc_redirect_uri.ex @@ -0,0 +1,22 @@ +defmodule Comfycamp.SSO.OIDCRedirectURI do + use Ecto.Schema + import Ecto.Changeset + + alias Comfycamp.SSO.OIDCApp + + schema "oidc_redirect_uris" do + field :uri, :string + + belongs_to :oidc_app, OIDCApp, + type: :string, + foreign_key: :oidc_app_id, + references: :client_id + end + + @doc false + def changeset(uri, attrs) do + uri + |> cast(attrs, [:uri, :oidc_app_id]) + |> validate_required([:uri, :oidc_app_id]) + end +end diff --git a/lib/comfycamp/token.ex b/lib/comfycamp/token.ex new file mode 100644 index 0000000..6ce97c7 --- /dev/null +++ b/lib/comfycamp/token.ex @@ -0,0 +1,3 @@ +defmodule Comfycamp.Token do + use Joken.Config +end diff --git a/lib/comfycamp_web/controllers/oauth_controller.ex b/lib/comfycamp_web/controllers/oauth_controller.ex index d64eeba..e0392be 100644 --- a/lib/comfycamp_web/controllers/oauth_controller.ex +++ b/lib/comfycamp_web/controllers/oauth_controller.ex @@ -1,24 +1,110 @@ defmodule ComfycampWeb.OauthController do use ComfycampWeb, :controller + alias Comfycamp.Accounts alias Comfycamp.SSO alias Comfycamp.SSO.OIDCApp + alias Comfycamp.SSO.OIDCCode + alias Comfycamp.SSO.IDToken + alias Comfycamp.Token - def confirm(conn, %{"client_id" => client_id, "response_type" => "code"} = params) do - app = %OIDCApp{enabled: true} = SSO.get_oidc_app!(client_id) + @doc """ + Check the request parameters and current user status, + then ask the user to confirm that he wants to share his info with Relying Party. + """ + def authorize(conn, %{"client_id" => client_id, "redirect_uri" => redirect_uri} = params) do + app = SSO.get_oidc_app!(client_id) + current_user = conn.assigns.current_user - render(conn, :confirm, - page_title: "Подтвердите вход", - app_name: app.name, - params: URI.encode_query(params) - ) + with {:is_approved, true} <- {:is_approved, current_user.is_approved}, + {:response_type, "code"} <- {:response_type, params["response_type"]}, + {:is_enabled, true} <- {:is_enabled, app.enabled}, + {:has_uri, true} <- {:has_uri, SSO.has_redirect_uri?(client_id, redirect_uri)} do + render(conn, :authorize, + page_title: "Подтвердите вход", + app_name: app.name, + params: URI.encode_query(params) + ) + else + {:is_approved, false} -> + render(conn, :error, + description: + "Ваш аккаунт ещё не был одобрен, подождите немного или свяжитесь с администратором" + ) + + {:response_type, response_type} -> + render(conn, :error, description: "Неподдерживаемый response type: #{response_type}") + + {:is_enabled, false} -> + render(conn, :error, description: "Приложение отключено") + + {:has_uri, false} -> + render(conn, :error, description: "Redirect URI не зарегистрирован или отсутствует") + end end + @doc """ + Generate an Authorization Code and redirect the user back to Relying Party. + """ def generate_code(conn, %{"client_id" => client_id, "redirect_uri" => redirect_uri} = params) do - %OIDCApp{enabled: true} = SSO.get_oidc_app!(client_id) + app = SSO.get_oidc_app!(client_id) + current_user = conn.assigns.current_user - uri = build_redirect_uri(redirect_uri, "test_code", params["state"]) - redirect(conn, external: uri) + with {:is_approved, true} <- {:is_approved, current_user.is_approved}, + {:is_enabled, true} <- {:is_enabled, app.enabled}, + {:has_uri, true} <- {:has_uri, SSO.has_redirect_uri?(client_id, redirect_uri)} do + {:ok, code} = + SSO.create_oidc_code(%{ + oidc_app_id: client_id, + user_id: conn.assigns.current_user.id, + redirect_uri: redirect_uri + }) + + uri = build_redirect_uri(redirect_uri, code.value, params["state"]) + redirect(conn, external: uri) + else + {:is_approved, false} -> + render(conn, :error, + description: + "Ваш аккаунт ещё не был одобрен, подождите немного или свяжитесь с администратором" + ) + + {:is_enabled, false} -> + render(conn, :error, description: "Приложение отключено") + + {:has_uri, false} -> + render(conn, :error, description: "Redirect URI не зарегистрирован или отсутствует") + end + end + + def token(conn, %{"code" => code_value, "redirect_uri" => redirect_uri}) do + # Check that code is still valid and redirect uri has not been altered. + %OIDCCode{redirect_uri: ^redirect_uri} = code = SSO.get_oidc_code!(code_value) + + # Get client secret. + [auth_header] = Plug.Conn.get_req_header(conn, "authorization") + "Basic " <> client_secret = auth_header + + # Check that client provided a valid secret for an active OIDC app. + %OIDCApp{enabled: true} = oidc_app = SSO.get_oidc_app_by_secret!(client_secret) + + # Check that OIDC app is referenced by provided code. + app_client_id = oidc_app.client_id + ^app_client_id = code.oidc_app.client_id + + # Delete the code. + SSO.delete_oidc_code(code) + + {access_token, refresh_token} = Accounts.generate_oauth_tokens(conn.assigns.current_user) + + id_token = IDToken.build_id_token(conn.assigns.current_user, oidc_app.client_id) + {:ok, signed_id_token, _claims} = Token.generate_and_sign(id_token) + + render(conn, :token, + access_token: access_token, + refresh_token: refresh_token, + id_token: signed_id_token + ) end defp build_redirect_uri(redirect_uri, code, state) do diff --git a/lib/comfycamp_web/controllers/oauth_html.ex b/lib/comfycamp_web/controllers/oauth_html.ex index 1836192..4564efe 100644 --- a/lib/comfycamp_web/controllers/oauth_html.ex +++ b/lib/comfycamp_web/controllers/oauth_html.ex @@ -1,7 +1,7 @@ defmodule ComfycampWeb.OauthHTML do use ComfycampWeb, :html - def confirm(assigns) do + def authorize(assigns) do ~H"""
Приложению "<%= @app_name %>" будут доступны:
@@ -13,4 +13,11 @@ defmodule ComfycampWeb.OauthHTML do <.link href={"/oauth/generate_code?#{@params}"} method="POST">Разрешить доступ """ end + + def error(assigns) do + ~H""" +<%= @description %>
+ """ + end end diff --git a/lib/comfycamp_web/controllers/oauth_json.ex b/lib/comfycamp_web/controllers/oauth_json.ex new file mode 100644 index 0000000..6a93c9c --- /dev/null +++ b/lib/comfycamp_web/controllers/oauth_json.ex @@ -0,0 +1,10 @@ +defmodule ComfycampWeb.OauthJSON do + def token(%{access_token: access_token, refresh_token: refresh_token, id_token: id_token}) do + %{ + access_token: access_token, + token_type: "Bearer", + refresh_token: refresh_token, + id_token: id_token + } + end +end diff --git a/lib/comfycamp_web/controllers/oidc_app_html/show.html.heex b/lib/comfycamp_web/controllers/oidc_app_html/show.html.heex index 38c08d6..fe017ee 100644 --- a/lib/comfycamp_web/controllers/oidc_app_html/show.html.heex +++ b/lib/comfycamp_web/controllers/oidc_app_html/show.html.heex @@ -16,5 +16,26 @@ <:item title="Enabled"><%= @oidc_app.enabled %> +