diff --git a/.sobelow-skips b/.sobelow-skips index 3eabeeca..91f6f52c 100644 --- a/.sobelow-skips +++ b/.sobelow-skips @@ -13,4 +13,20 @@ B9AF8A342CD7FF39E10CC10A408C28E1 C042E87389F7BDCFF4E076E95731AE69 C42BFAEF7100F57BED75998B217C857A D11958E86F1B6D37EF656B63405CA8A4 -F16F054F2628609A726B9FF2F089D484 \ No newline at end of file +F16F054F2628609A726B9FF2F089D484 +26E816A7B054CB0347A2C6451F03B92B +2B76BDDB2BB4D36D69FAE793EBD63894 +301A837DE24C6AEE1DA812DF9E5486C1 +395A2740CB468F93F6EBE6E90EE08291 +4013C9866943B9381D9F9F97027F88A9 +4C796DD588A4B1C98E86BBCD0349949A +51289D8D7BDB59CB6473E0DED0591ED7 +5A70DC86895DB3610C605EA9F31ED300 +705C17F9C852F546D886B20DB2C4D0D1 +75D2074B6F771BA8C032008EC18CABDF +7B1C6E35A374C38FF5F07DBF23B3EAE2 +955ACF52ADD8FCAA450FB8138CB1FD1A +A092A563729E1F2C1C8D5D809A31F754 +BFA12FDEDEAD7DEAB6D44DF6FDFBD5E1 +D9A08930F140F9BA494BB90B3F812C87 +FE1EEB91EA633570F703B251AE2D4D4E \ No newline at end of file diff --git a/js/src/components/Settings/SettingsMenu.vue b/js/src/components/Settings/SettingsMenu.vue index 90e3ecbb..6064f762 100644 --- a/js/src/components/Settings/SettingsMenu.vue +++ b/js/src/components/Settings/SettingsMenu.vue @@ -17,6 +17,10 @@ :title="t('Notifications')" :to="{ name: RouteName.NOTIFICATIONS }" /> + => import("@/views/Settings/AppsView.vue"), + props: true, + meta: { + requiredAuth: true, + announcer: { + message: (): string => t("Apps") as string, + }, + }, + }, { path: "admin", name: SettingsRouteName.ADMIN, diff --git a/js/src/router/user.ts b/js/src/router/user.ts index 85a1dd96..612c4cce 100644 --- a/js/src/router/user.ts +++ b/js/src/router/user.ts @@ -13,6 +13,7 @@ export enum UserRouteName { EMAIL_VALIDATE = "EMAIL_VALIDATE", VALIDATE = "Validate", LOGIN = "Login", + OAUTH_AUTORIZE = "OAUTH_AUTORIZE", } export const userRoutes: RouteRecordRaw[] = [ @@ -108,4 +109,15 @@ export const userRoutes: RouteRecordRaw[] = [ announcer: { message: (): string => t("Login") as string }, }, }, + { + path: "/oauth/autorize_approve", + name: UserRouteName.OAUTH_AUTORIZE, + component: (): Promise => import("@/views/OAuth/AuthorizeView.vue"), + meta: { + requiredAuth: true, + announcer: { + message: (): string => t("Authorize application") as string, + }, + }, + }, ]; diff --git a/js/src/types/application.model.ts b/js/src/types/application.model.ts new file mode 100644 index 00000000..c473a370 --- /dev/null +++ b/js/src/types/application.model.ts @@ -0,0 +1,15 @@ +export interface IApplication { + name: string; + clientId: string; + clientSecret?: string; + redirectUris?: string; + scopes: string | null; + website: string | null; +} + +export interface IApplicationToken { + id: string; + application: IApplication; + lastUsedAt: string; + insertedAt: string; +} diff --git a/js/src/types/current-user.model.ts b/js/src/types/current-user.model.ts index 4c4e57cd..33504ecf 100644 --- a/js/src/types/current-user.model.ts +++ b/js/src/types/current-user.model.ts @@ -7,6 +7,7 @@ import { IFollowedGroupEvent } from "./followedGroupEvent.model"; import { PictureInformation } from "./picture"; import { IMember } from "./actor/member.model"; import { IFeedToken } from "./feedtoken.model"; +import { IApplicationToken } from "./application.model"; export interface ICurrentUser { id: string; @@ -66,4 +67,5 @@ export interface IUser extends ICurrentUser { currentSignInAt: string; memberships: Paginate; feedTokens: IFeedToken[]; + authAuthorizedApplications: IApplicationToken[]; } diff --git a/js/src/views/OAuth/AuthorizeView.vue b/js/src/views/OAuth/AuthorizeView.vue new file mode 100644 index 00000000..618ce49e --- /dev/null +++ b/js/src/views/OAuth/AuthorizeView.vue @@ -0,0 +1,191 @@ + + + diff --git a/js/src/views/Settings/AppsView.vue b/js/src/views/Settings/AppsView.vue new file mode 100644 index 00000000..35ef4ccf --- /dev/null +++ b/js/src/views/Settings/AppsView.vue @@ -0,0 +1,138 @@ + + + diff --git a/lib/graphql/resolvers/application.ex b/lib/graphql/resolvers/application.ex new file mode 100644 index 00000000..a546148d --- /dev/null +++ b/lib/graphql/resolvers/application.ex @@ -0,0 +1,92 @@ +defmodule Mobilizon.GraphQL.Resolvers.Application do + @moduledoc """ + Handles the Application-related GraphQL calls. + """ + + alias Mobilizon.Applications, as: ApplicationManager + alias Mobilizon.Applications.{Application, ApplicationToken} + alias Mobilizon.Service.Auth.Applications + alias Mobilizon.Users.User + import Mobilizon.Web.Gettext, only: [dgettext: 2] + + require Logger + + @doc """ + Create an application + """ + @spec authorize(any(), map(), Absinthe.Resolution.t()) :: {:ok, map()} | {:error, String.t()} + def authorize( + _parent, + %{client_id: client_id, redirect_uri: redirect_uri, scope: scope, state: state}, + %{context: %{current_user: %User{id: user_id}}} + ) do + case Applications.autorize(client_id, redirect_uri, scope, user_id) do + {:ok, code} -> + {:ok, %{code: code, state: state}} + + {:error, :application_not_found} -> + {:error, + dgettext( + "errors", + "No application with this client_id was found" + )} + + {:error, :redirect_uri_not_in_allowed} -> + {:error, + dgettext( + "errors", + "The given redirect_uri is not in the list of allowed redirect URIs" + )} + end + end + + def authorize(_parent, _args, _context) do + {:error, dgettext("errors", "You need to be logged-in to autorize applications")} + end + + @spec get_application(any(), map(), Absinthe.Resolution.t()) :: + {:ok, Application.t()} | {:error, :not_found | :unauthenticated} + def get_application(_parent, %{client_id: client_id}, %{context: %{current_user: %User{}}}) do + case ApplicationManager.get_application_by_client_id(client_id) do + %Application{} = application -> + {:ok, application} + + nil -> + {:error, :not_found} + end + end + + def get_application(_parent, _args, _resolution) do + {:error, :unauthenticated} + end + + def get_user_applications(_parent, _args, %{context: %{current_user: %User{id: user_id}}}) do + {:ok, ApplicationManager.list_application_tokens_for_user_id(user_id)} + end + + def get_user_applications(_parent, _args, _resolution) do + {:error, :unauthenticated} + end + + def revoke_application_token(_parent, %{app_token_id: app_token_id}, %{ + context: %{current_user: %User{id: user_id}} + }) do + case ApplicationManager.get_application_token(app_token_id) do + %ApplicationToken{user_id: ^user_id} = app_token -> + case Applications.revoke_application_token(app_token) do + {:ok, %{delete_app_token: app_token, delete_guardian_tokens: _delete_guardian_tokens}} -> + {:ok, %{id: app_token.id}} + + {:error, _, _, _} -> + {:error, dgettext("errors", "Error while revoking token")} + end + + _ -> + {:error, :not_found} + end + end + + def revoke_application_token(_parent, _args, _resolution) do + {:error, :unauthenticated} + end +end diff --git a/lib/graphql/schema.ex b/lib/graphql/schema.ex index 8306808d..0756b563 100644 --- a/lib/graphql/schema.ex +++ b/lib/graphql/schema.ex @@ -53,6 +53,7 @@ defmodule Mobilizon.GraphQL.Schema do import_types(Schema.Users.PushSubscription) import_types(Schema.Users.ActivitySetting) import_types(Schema.FollowedGroupActivityType) + import_types(Schema.AuthApplicationType) @desc "A struct containing the id of the deleted object" object :deleted_object do @@ -161,6 +162,7 @@ defmodule Mobilizon.GraphQL.Schema do import_fields(:resource_queries) import_fields(:post_queries) import_fields(:statistics_queries) + import_fields(:auth_application_queries) end @desc """ @@ -187,6 +189,7 @@ defmodule Mobilizon.GraphQL.Schema do import_fields(:follower_mutations) import_fields(:push_mutations) import_fields(:activity_setting_mutations) + import_fields(:auth_application_mutations) end @desc """ diff --git a/lib/graphql/schema/auth_application.ex b/lib/graphql/schema/auth_application.ex new file mode 100644 index 00000000..5cdda92a --- /dev/null +++ b/lib/graphql/schema/auth_application.ex @@ -0,0 +1,61 @@ +defmodule Mobilizon.GraphQL.Schema.AuthApplicationType do + @moduledoc """ + Schema representation for an auth application + """ + use Absinthe.Schema.Notation + alias Mobilizon.GraphQL.Resolvers.Application + + @desc "An application" + object :auth_application do + field(:name, :string) + field(:client_id, :string) + field(:scopes, :string) + field(:website, :string) + end + + @desc "An application" + object :auth_application_token do + field(:id, :id) + field(:inserted_at, :string) + field(:last_used_at, :string) + field(:application, :auth_application) + end + + @desc "The informations returned after authorization" + object :application_code_and_state do + field(:code, :string) + field(:state, :string) + end + + object :auth_application_queries do + @desc "Get an application" + field :auth_application, :auth_application do + arg(:client_id, non_null(:string), description: "The application's client_id") + resolve(&Application.get_application/3) + end + end + + object :auth_application_mutations do + @desc "Authorize an application" + field :authorize_application, :application_code_and_state do + arg(:client_id, non_null(:string), description: "The application's client_id") + + arg(:redirect_uri, non_null(:string), + description: "The URI to redirect to with the code and state" + ) + + arg(:scope, :string, description: "The scope for the authorization") + + arg(:state, :string, + description: "A state parameter to check that the request wasn't altered" + ) + + resolve(&Application.authorize/3) + end + + field :revoke_application_token, :deleted_object do + arg(:app_token_id, non_null(:string), description: "The application token's ID") + resolve(&Application.revoke_application_token/3) + end + end +end diff --git a/lib/graphql/schema/user.ex b/lib/graphql/schema/user.ex index ce139cf9..f4930144 100644 --- a/lib/graphql/schema/user.ex +++ b/lib/graphql/schema/user.ex @@ -7,7 +7,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do import Absinthe.Resolution.Helpers, only: [dataloader: 2] alias Mobilizon.Events - alias Mobilizon.GraphQL.Resolvers.{Media, User} + alias Mobilizon.GraphQL.Resolvers.{Application, Media, User} alias Mobilizon.GraphQL.Resolvers.Users.ActivitySettings alias Mobilizon.GraphQL.Schema @@ -161,6 +161,11 @@ defmodule Mobilizon.GraphQL.Schema.UserType do resolve: &ActivitySettings.user_activity_settings/3, description: "The user's activity settings" ) + + field(:auth_authorized_applications, list_of(:auth_application_token), + resolve: &Application.get_user_applications/3, + description: "The user's authorized authentication apps" + ) end @desc "The list of roles an user can have" diff --git a/lib/mobilizon/applications.ex b/lib/mobilizon/applications.ex new file mode 100644 index 00000000..b0df2539 --- /dev/null +++ b/lib/mobilizon/applications.ex @@ -0,0 +1,258 @@ +defmodule Mobilizon.Applications do + @moduledoc """ + The Applications context. + """ + + import Ecto.Query, warn: false + alias Ecto.Multi + alias Mobilizon.Applications.Application + alias Mobilizon.Storage.Repo + + @doc """ + Returns the list of applications. + + ## Examples + + iex> list_applications() + [%Application{}, ...] + + """ + def list_applications do + Repo.all(Application) + end + + @doc """ + Gets a single application. + + Raises `Ecto.NoResultsError` if the Application does not exist. + + ## Examples + + iex> get_application!(123) + %Application{} + + iex> get_application!(456) + ** (Ecto.NoResultsError) + + """ + def get_application!(id), do: Repo.get!(Application, id) + + @doc """ + Gets a single application. + + Returns nil if the Application does not exist. + + ## Examples + + iex> get_application_by_client_id(123) + %Application{} + + iex> get_application_by_client_id(456) + nil + + """ + def get_application_by_client_id(client_id), do: Repo.get_by(Application, client_id: client_id) + + @doc """ + Creates a application. + + ## Examples + + iex> create_application(%{field: value}) + {:ok, %Application{}} + + iex> create_application(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_application(attrs \\ %{}) do + %Application{} + |> Application.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a application. + + ## Examples + + iex> update_application(application, %{field: new_value}) + {:ok, %Application{}} + + iex> update_application(application, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_application(%Application{} = application, attrs) do + application + |> Application.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a application. + + ## Examples + + iex> delete_application(application) + {:ok, %Application{}} + + iex> delete_application(application) + {:error, %Ecto.Changeset{}} + + """ + def delete_application(%Application{} = application) do + Repo.delete(application) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking application changes. + + ## Examples + + iex> change_application(application) + %Ecto.Changeset{data: %Application{}} + + """ + def change_application(%Application{} = application, attrs \\ %{}) do + Application.changeset(application, attrs) + end + + alias Mobilizon.Applications.ApplicationToken + + @doc """ + Returns the list of application_tokens. + + ## Examples + + iex> list_application_tokens() + [%ApplicationToken{}, ...] + + """ + def list_application_tokens do + Repo.all(ApplicationToken) + end + + @doc """ + Returns the list of application tokens for a given user_id + """ + def list_application_tokens_for_user_id(user_id) do + ApplicationToken + |> where(user_id: ^user_id) + |> where([at], is_nil(at.authorization_code)) + |> preload(:application) + |> Repo.all() + end + + @doc """ + Gets a single application_token. + + Raises `Ecto.NoResultsError` if the Application token does not exist. + + ## Examples + + iex> get_application_token!(123) + %ApplicationToken{} + + iex> get_application_token!(456) + ** (Ecto.NoResultsError) + + """ + def get_application_token!(id), do: Repo.get!(ApplicationToken, id) + + @doc """ + Gets a single application_token. + + ## Examples + + iex> get_application_token(123) + %ApplicationToken{} + + iex> get_application_token(456) + nil + + """ + def get_application_token(application_token_id), + do: Repo.get(ApplicationToken, application_token_id) + + def get_application_token(app_id, user_id), + do: Repo.get_by(ApplicationToken, application_id: app_id, user_id: user_id) + + def get_application_token_by_authorization_code(code), + do: Repo.get_by(ApplicationToken, authorization_code: code) + + @doc """ + Creates a application_token. + + ## Examples + + iex> create_application_token(%{field: value}) + {:ok, %ApplicationToken{}} + + iex> create_application_token(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_application_token(attrs \\ %{}) do + %ApplicationToken{} + |> ApplicationToken.changeset(attrs) + |> Repo.insert(on_conflict: :replace_all, conflict_target: [:user_id, :application_id]) + end + + @doc """ + Updates a application_token. + + ## Examples + + iex> update_application_token(application_token, %{field: new_value}) + {:ok, %ApplicationToken{}} + + iex> update_application_token(application_token, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_application_token(%ApplicationToken{} = application_token, attrs) do + application_token + |> ApplicationToken.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a application_token. + + ## Examples + + iex> delete_application_token(application_token) + {:ok, %ApplicationToken{}} + + iex> delete_application_token(application_token) + {:error, %Ecto.Changeset{}} + + """ + def delete_application_token(%ApplicationToken{} = application_token) do + Repo.delete(application_token) + end + + def revoke_application_token(%ApplicationToken{id: app_token_id} = application_token) do + Multi.new() + |> Multi.delete_all( + :delete_guardian_tokens, + from(gt in "guardian_tokens", where: gt.sub == ^"AppToken:#{app_token_id}") + ) + |> Multi.delete(:delete_app_token, application_token) + |> Repo.transaction() + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking application_token changes. + + ## Examples + + iex> change_application_token(application_token) + %Ecto.Changeset{data: %ApplicationToken{}} + + """ + def change_application_token(%ApplicationToken{} = application_token, attrs \\ %{}) do + ApplicationToken.changeset(application_token, attrs) + end +end diff --git a/lib/mobilizon/applications/application.ex b/lib/mobilizon/applications/application.ex new file mode 100644 index 00000000..df155125 --- /dev/null +++ b/lib/mobilizon/applications/application.ex @@ -0,0 +1,32 @@ +defmodule Mobilizon.Applications.Application do + @moduledoc """ + Module representing an application + """ + + use Ecto.Schema + import Ecto.Changeset + + @required_attrs [:name, :client_id, :client_secret, :redirect_uris] + @optional_attrs [:scopes, :website, :owner_type, :owner_id] + @attrs @required_attrs ++ @optional_attrs + + schema "applications" do + field(:name, :string) + field(:client_id, :string) + field(:client_secret, :string) + field(:redirect_uris, :string) + field(:scopes, :string) + field(:website, :string) + field(:owner_type, :string) + field(:owner_id, :integer) + + timestamps() + end + + @doc false + def changeset(application, attrs) do + application + |> cast(attrs, @attrs) + |> validate_required(@required_attrs) + end +end diff --git a/lib/mobilizon/applications/application_token.ex b/lib/mobilizon/applications/application_token.ex new file mode 100644 index 00000000..90d94b42 --- /dev/null +++ b/lib/mobilizon/applications/application_token.ex @@ -0,0 +1,26 @@ +defmodule Mobilizon.Applications.ApplicationToken do + @moduledoc """ + Module representing an application token + """ + use Ecto.Schema + import Ecto.Changeset + + schema "application_tokens" do + belongs_to(:user, Mobilizon.Users.User) + belongs_to(:application, Mobilizon.Applications.Application) + field(:authorization_code, :string) + + timestamps() + end + + @required_attrs [:user_id, :application_id] + @optional_attrs [:authorization_code] + @attrs @required_attrs ++ @optional_attrs + + @doc false + def changeset(application_token, attrs) do + application_token + |> cast(attrs, @attrs) + |> validate_required(@required_attrs) + end +end diff --git a/lib/service/auth/applications.ex b/lib/service/auth/applications.ex new file mode 100644 index 00000000..252f5c25 --- /dev/null +++ b/lib/service/auth/applications.ex @@ -0,0 +1,130 @@ +defmodule Mobilizon.Service.Auth.Applications do + @moduledoc """ + Module to handle applications management + """ + alias Mobilizon.Applications + alias Mobilizon.Applications.{Application, ApplicationToken} + alias Mobilizon.Service.Auth.Authenticator + + @app_access_tokens_ttl {8, :hour} + @app_refresh_tokens_ttl {26, :week} + + @type access_token_details :: %{ + required(:access_token) => String.t(), + required(:expires_in) => pos_integer(), + required(:refresh_token) => String.t(), + required(:refresh_token_expires_in) => pos_integer(), + required(:scope) => nil, + required(:token_type) => String.t() + } + + def create(name, redirect_uris, scopes, website) do + client_id = :crypto.strong_rand_bytes(42) |> Base.encode64() |> binary_part(0, 42) + client_secret = :crypto.strong_rand_bytes(42) |> Base.encode64() |> binary_part(0, 42) + + Applications.create_application(%{ + name: name, + redirect_uris: redirect_uris, + scopes: scopes, + website: website, + client_id: client_id, + client_secret: client_secret + }) + end + + @spec autorize(String.t(), String.t(), String.t(), integer()) :: + {:ok, String.t()} + | {:error, :application_not_found} + | {:error, :redirect_uri_not_in_allowed} + def autorize(client_id, redirect_uri, _scope, user_id) do + with %Application{redirect_uris: redirect_uris, id: app_id} <- + Applications.get_application_by_client_id(client_id), + {:redirect_uri, true} <- + {:redirect_uri, redirect_uri in String.split(redirect_uris, "\n")}, + code <- :crypto.strong_rand_bytes(16) |> Base.encode64() |> binary_part(0, 16), + {:ok, %ApplicationToken{}} <- + Applications.create_application_token(%{ + user_id: user_id, + application_id: app_id, + authorization_code: code + }) do + {:ok, code} + else + nil -> + {:error, :application_not_found} + + {:redirect_uri, _} -> + {:error, :redirect_uri_not_in_allowed} + end + end + + @spec generate_access_token(String.t(), String.t(), String.t(), String.t()) :: + {:ok, access_token_details()} + | {:error, + :application_not_found + | :redirect_uri_not_in_allowed + | :provided_code_does_not_match + | :invalid_client_secret + | :app_token_not_found + | any()} + def generate_access_token(client_id, client_secret, code, redirect_uri) do + with {:application, + %Application{ + id: application_id, + client_secret: app_client_secret, + scopes: scopes, + redirect_uris: redirect_uris + }} <- + {:application, Applications.get_application_by_client_id(client_id)}, + {:redirect_uri, true} <- + {:redirect_uri, redirect_uri in String.split(redirect_uris, "\n")}, + {:app_token, %ApplicationToken{} = app_token} <- + {:app_token, Applications.get_application_token_by_authorization_code(code)}, + {:ok, %ApplicationToken{application_id: application_id_from_token} = app_token} <- + Applications.update_application_token(app_token, %{authorization_code: nil}), + {:same_app, true} <- {:same_app, application_id === application_id_from_token}, + {:same_client_secret, true} <- {:same_client_secret, app_client_secret == client_secret}, + {:ok, access_token} <- + Authenticator.generate_access_token(app_token, @app_access_tokens_ttl), + {:ok, refresh_token} <- + Authenticator.generate_refresh_token(app_token, @app_refresh_tokens_ttl) do + {:ok, + %{ + access_token: access_token, + expires_in: ttl_to_seconds(@app_access_tokens_ttl), + refresh_token: refresh_token, + refresh_token_expires_in: ttl_to_seconds(@app_refresh_tokens_ttl), + scope: scopes, + token_type: "bearer" + }} + else + {:application, nil} -> + {:error, :application_not_found} + + {:same_app, false} -> + {:error, :provided_code_does_not_match} + + {:same_client_secret, _} -> + {:error, :invalid_client_secret} + + {:redirect_uri, _} -> + {:error, :redirect_uri_not_in_allowed} + + {:app_token, _} -> + {:error, :app_token_not_found} + + {:error, err} -> + {:error, err} + end + end + + def revoke_application_token(%ApplicationToken{} = app_token) do + Applications.revoke_application_token(app_token) + end + + @spec ttl_to_seconds({pos_integer(), :second | :minute | :hour | :week}) :: pos_integer() + defp ttl_to_seconds({value, :second}), do: value + defp ttl_to_seconds({value, :minute}), do: value * 60 + defp ttl_to_seconds({value, :hour}), do: value * 3600 + defp ttl_to_seconds({value, :week}), do: value * 604_800 +end diff --git a/lib/service/auth/authenticator.ex b/lib/service/auth/authenticator.ex index ba39ca52..fea00568 100644 --- a/lib/service/auth/authenticator.ex +++ b/lib/service/auth/authenticator.ex @@ -17,6 +17,11 @@ defmodule Mobilizon.Service.Auth.Authenticator do required(:user) => User.t() } + @type ttl :: { + pos_integer(), + :second | :minute | :hour | :week + } + def implementation do Mobilizon.Config.get( Mobilizon.Service.Auth.Authenticator, @@ -55,7 +60,7 @@ defmodule Mobilizon.Service.Auth.Authenticator do @doc """ Generates access token and refresh token for an user. """ - @spec generate_tokens(User.t()) :: {:ok, tokens} + @spec generate_tokens(User.t() | ApplicationToken.t()) :: {:ok, tokens} | {:error, any()} def generate_tokens(user) do with {:ok, access_token} <- generate_access_token(user), {:ok, refresh_token} <- generate_refresh_token(user) do @@ -66,10 +71,11 @@ defmodule Mobilizon.Service.Auth.Authenticator do @doc """ Generates access token for an user. """ - @spec generate_access_token(User.t()) :: {:ok, String.t()} - def generate_access_token(user) do + @spec generate_access_token(User.t() | ApplicationToken.t(), ttl() | nil) :: + {:ok, String.t()} | {:error, any()} + def generate_access_token(user, ttl \\ nil) do with {:ok, access_token, _claims} <- - Guardian.encode_and_sign(user, %{}, token_type: "access") do + Guardian.encode_and_sign(user, %{}, token_type: "access", ttl: ttl) do {:ok, access_token} end end @@ -77,10 +83,11 @@ defmodule Mobilizon.Service.Auth.Authenticator do @doc """ Generates refresh token for an user. """ - @spec generate_refresh_token(User.t()) :: {:ok, String.t()} - def generate_refresh_token(user) do + @spec generate_refresh_token(User.t() | ApplicationToken.t(), ttl() | nil) :: + {:ok, String.t()} | {:error, any()} + def generate_refresh_token(user, ttl \\ nil) do with {:ok, refresh_token, _claims} <- - Guardian.encode_and_sign(user, %{}, token_type: "refresh") do + Guardian.encode_and_sign(user, %{}, token_type: "refresh", ttl: ttl) do {:ok, refresh_token} end end diff --git a/lib/web/auth/context.ex b/lib/web/auth/context.ex index 48bbe295..50ad94d5 100644 --- a/lib/web/auth/context.ex +++ b/lib/web/auth/context.ex @@ -6,6 +6,8 @@ defmodule Mobilizon.Web.Auth.Context do import Plug.Conn + alias Mobilizon.Applications.Application, as: AuthApplication + alias Mobilizon.Applications.ApplicationToken alias Mobilizon.Users.User @spec init(Plug.opts()) :: Plug.opts() @@ -28,18 +30,13 @@ defmodule Mobilizon.Web.Auth.Context do {conn, context} = case Guardian.Plug.current_resource(conn) do - %User{id: user_id, email: user_email} = user -> - if Application.get_env(:sentry, :dsn) != nil do - Sentry.Context.set_user_context(%{ - id: user_id, - email: user_email, - ip_address: context.ip - }) - end + %User{} = user -> + set_user_context({conn, context}, user) - context = Map.put(context, :current_user, user) - conn = assign(conn, :user_locale, user.locale) - {conn, context} + %ApplicationToken{user: %User{} = user} = app_token -> + conn + |> set_app_token_context(context, app_token) + |> set_user_context(user) nil -> {conn, context} @@ -49,4 +46,35 @@ defmodule Mobilizon.Web.Auth.Context do put_private(conn, :absinthe, %{context: context}) end + + defp set_user_context({conn, context}, %User{id: user_id, email: user_email} = user) do + if Application.get_env(:sentry, :dsn) != nil do + Sentry.Context.set_user_context(%{ + id: user_id, + email: user_email, + ip_address: context.ip + }) + end + + context = Map.put(context, :current_user, user) + conn = assign(conn, :user_locale, user.locale) + {conn, context} + end + + defp set_app_token_context( + conn, + context, + %ApplicationToken{application: %AuthApplication{client_id: client_id} = app} = app_token + ) do + if Application.get_env(:sentry, :dsn) != nil do + Sentry.Context.set_user_context(%{ + app_token_client_id: client_id + }) + end + + context = + context |> Map.put(:current_auth_app_token, app_token) |> Map.put(:current_auth_app, app) + + {conn, context} + end end diff --git a/lib/web/auth/guardian.ex b/lib/web/auth/guardian.ex index 446fb5dc..e2a4ea67 100644 --- a/lib/web/auth/guardian.ex +++ b/lib/web/auth/guardian.ex @@ -10,14 +10,19 @@ defmodule Mobilizon.Web.Auth.Guardian do user: [:base] } - alias Mobilizon.Users + alias Mobilizon.{Applications, Users} + alias Mobilizon.Applications.ApplicationToken alias Mobilizon.Users.User require Logger @spec subject_for_token(any(), any()) :: {:ok, String.t()} | {:error, :unknown_resource} - def subject_for_token(%User{} = user, _claims) do - {:ok, "User:" <> to_string(user.id)} + def subject_for_token(%User{id: user_id}, _claims) do + {:ok, "User:" <> to_string(user_id)} + end + + def subject_for_token(%ApplicationToken{id: app_token_id}, _claims) do + {:ok, "AppToken:" <> to_string(app_token_id)} end def subject_for_token(_, _) do @@ -42,6 +47,25 @@ defmodule Mobilizon.Web.Auth.Guardian do end end + def resource_from_claims(%{"sub" => "AppToken:" <> id_str}) do + Logger.debug(fn -> "Receiving claim for app token #{id_str}" end) + + try do + case Integer.parse(id_str) do + {id, ""} -> + application_token = Applications.get_application_token!(id) + user = Users.get_user_with_actors!(application_token.user_id) + application = Applications.get_application!(application_token.application_id) + {:ok, application_token |> Map.put(:user, user) |> Map.put(:application, application)} + + _ -> + {:error, :invalid_id} + end + rescue + Ecto.NoResultsError -> {:error, :no_result} + end + end + def resource_from_claims(_) do {:error, :no_claims} end diff --git a/lib/web/channels/graphql_socket.ex b/lib/web/channels/graphql_socket.ex index 0bf0e724..7677104c 100644 --- a/lib/web/channels/graphql_socket.ex +++ b/lib/web/channels/graphql_socket.ex @@ -4,19 +4,16 @@ defmodule Mobilizon.Web.GraphQLSocket do use Absinthe.Phoenix.Socket, schema: Mobilizon.GraphQL.Schema + alias Mobilizon.Applications.Application, as: AuthApplication + alias Mobilizon.Applications.ApplicationToken alias Mobilizon.Users.User @spec connect(map, Phoenix.Socket.t()) :: {:ok, Phoenix.Socket.t()} | :error def connect(%{"token" => token}, socket) do with {:ok, authed_socket} <- Guardian.Phoenix.Socket.authenticate(socket, Mobilizon.Web.Auth.Guardian, token), - %User{} = user <- Guardian.Phoenix.Socket.current_resource(authed_socket) do - authed_socket = - Absinthe.Phoenix.Socket.put_options(socket, - context: %{ - current_user: user - } - ) + resource <- Guardian.Phoenix.Socket.current_resource(authed_socket) do + set_context(authed_socket, resource) {:ok, authed_socket} else @@ -29,4 +26,27 @@ defmodule Mobilizon.Web.GraphQLSocket do @spec id(any) :: nil def id(_socket), do: nil + + @spec set_context(Phoenix.Socket.t(), User.t() | ApplicationToken.t()) :: Phoenix.Socket.t() + defp set_context(socket, %User{} = user) do + Absinthe.Phoenix.Socket.put_options(socket, + context: %{ + current_user: user + } + ) + end + + defp set_context( + socket, + %ApplicationToken{user: %User{} = user, application: %AuthApplication{} = app} = + app_token + ) do + Absinthe.Phoenix.Socket.put_options(socket, + context: %{ + current_auth_app_token: app_token, + current_auth_app: app, + current_user: user + } + ) + end end diff --git a/lib/web/controllers/application_controller.ex b/lib/web/controllers/application_controller.ex new file mode 100644 index 00000000..63e0bfb2 --- /dev/null +++ b/lib/web/controllers/application_controller.ex @@ -0,0 +1,130 @@ +defmodule Mobilizon.Web.ApplicationController do + use Mobilizon.Web, :controller + + alias Mobilizon.Applications.Application + alias Mobilizon.Service.Auth.Applications + plug(:put_layout, false) + import Mobilizon.Web.Gettext, only: [dgettext: 2] + + @out_of_band_redirect_uri "urn:ietf:wg:oauth:2.0:oob" + + @doc """ + Create an application + """ + @spec create_application(Plug.Conn.t(), map()) :: Plug.Conn.t() + def create_application(conn, %{"name" => name, "redirect_uris" => redirect_uris} = args) do + case Applications.create( + name, + redirect_uris, + Map.get(args, "scopes"), + Map.get(args, "website") + ) do + {:ok, %Application{} = app} -> + json( + conn, + Map.take(app, [:name, :website, :redirect_uris, :client_id, :client_secret, :scope]) + ) + + {:error, _error} -> + send_resp( + conn, + 500, + dgettext( + "errors", + "Impossible to create application." + ) + ) + end + end + + def create_application(conn, _args) do + send_resp( + conn, + 400, + dgettext( + "errors", + "Both name and redirect_uri parameters are required to create an application" + ) + ) + end + + @doc """ + Authorize + """ + @spec authorize(Plug.Conn.t(), map()) :: Plug.Conn.t() + def authorize( + conn, + _args + ) do + conn = fetch_query_params(conn) + + client_id = conn.query_params["client_id"] + redirect_uri = conn.query_params["redirect_uri"] + state = conn.query_params["state"] + + if is_binary(client_id) and is_binary(redirect_uri) and is_binary(state) do + redirect(conn, + to: + Routes.page_path(conn, :authorize, + client_id: client_id, + redirect_uri: redirect_uri, + scope: conn.query_params["scope"], + state: state + ) + ) + else + send_resp( + conn, + 400, + dgettext( + "errors", + "You need to specify client_id, redirect_uri and state to autorize an application" + ) + ) + end + end + + @spec generate_access_token(Plug.Conn.t(), map()) :: Plug.Conn.t() + def generate_access_token(conn, %{ + "client_id" => client_id, + "client_secret" => client_secret, + "code" => code, + "redirect_uri" => redirect_uri + }) do + case Applications.generate_access_token(client_id, client_secret, code, redirect_uri) do + {:ok, token} -> + if redirect_uri != @out_of_band_redirect_uri do + redirect(conn, external: generate_redirect_with_query_params(redirect_uri, token)) + else + json(conn, token) + end + + {:error, :application_not_found} -> + send_resp(conn, 400, dgettext("errors", "No application was found with this client_id")) + + {:error, :redirect_uri_not_in_allowed} -> + send_resp(conn, 400, dgettext("errors", "This redirect URI is not allowed")) + + {:error, :invalid_or_expired} -> + send_resp(conn, 400, dgettext("errors", "The provided code is invalid or expired")) + + {:error, :invalid_client_id} -> + send_resp( + conn, + 400, + dgettext("errors", "The provided client_id does not match the provided code") + ) + + {:error, :invalid_client_secret} -> + send_resp(conn, 400, dgettext("errors", "The provided client_secret is invalid")) + + {:error, :user_not_found} -> + send_resp(conn, 400, dgettext("errors", "The user for this code was not found")) + end + end + + @spec generate_redirect_with_query_params(String.t(), map()) :: String.t() + defp generate_redirect_with_query_params(redirect_uri, query_params) do + redirect_uri |> URI.parse() |> URI.merge("?" <> URI.encode_query(query_params)) |> to_string() + end +end diff --git a/lib/web/controllers/page_controller.ex b/lib/web/controllers/page_controller.ex index 9afb1f77..5243eec6 100644 --- a/lib/web/controllers/page_controller.ex +++ b/lib/web/controllers/page_controller.ex @@ -121,6 +121,9 @@ defmodule Mobilizon.Web.PageController do end end + @spec authorize(Plug.Conn.t(), any) :: Plug.Conn.t() + def authorize(conn, _params), do: render(conn, :index) + @spec handle_collection_route(Plug.Conn.t(), collections()) :: Plug.Conn.t() defp handle_collection_route(conn, collection) do case get_format(conn) do diff --git a/lib/web/router.ex b/lib/web/router.ex index 0ec8c53c..c8531447 100644 --- a/lib/web/router.ex +++ b/lib/web/router.ex @@ -205,6 +205,11 @@ defmodule Mobilizon.Web.Router do # Also possible CSRF issue get("/auth/:provider/callback", AuthController, :callback) post("/auth/:provider/callback", AuthController, :callback) + + post("/apps", ApplicationController, :create_application) + get("/oauth/authorize", ApplicationController, :authorize) + post("/oauth/token", ApplicationController, :generate_access_token) + get("/oauth/autorize_approve", PageController, :authorize) end scope "/proxy/", Mobilizon.Web do diff --git a/priv/repo/migrations/20230208101626_create_applications.exs b/priv/repo/migrations/20230208101626_create_applications.exs new file mode 100644 index 00000000..2e29a6d5 --- /dev/null +++ b/priv/repo/migrations/20230208101626_create_applications.exs @@ -0,0 +1,20 @@ +defmodule Mobilizon.Repo.Migrations.CreateApplications do + use Ecto.Migration + + def change do + create table(:applications) do + add(:name, :string, null: false) + add(:client_id, :string, null: false) + add(:client_secret, :string, null: false) + add(:redirect_uris, :string, null: false) + add(:scopes, :string, null: true) + add(:website, :string, null: true) + add(:owner_type, :string, null: true) + add(:owner_id, :integer, null: true) + + timestamps() + end + + create(index(:applications, [:owner_id, :owner_type])) + end +end diff --git a/priv/repo/migrations/20230215125801_create_application_tokens.exs b/priv/repo/migrations/20230215125801_create_application_tokens.exs new file mode 100644 index 00000000..a2da53e9 --- /dev/null +++ b/priv/repo/migrations/20230215125801_create_application_tokens.exs @@ -0,0 +1,14 @@ +defmodule Mobilizon.Repo.Migrations.CreateApplicationTokens do + use Ecto.Migration + + def change do + create table(:application_tokens) do + add(:user_id, references(:users, on_delete: :delete_all), null: false) + add(:application_id, references(:applications, on_delete: :delete_all), null: false) + add(:authorization_code, :string, null: true) + timestamps() + end + + create(unique_index(:application_tokens, [:user_id, :application_id])) + end +end diff --git a/test/mobilizon/applications_test.exs b/test/mobilizon/applications_test.exs new file mode 100644 index 00000000..b1553393 --- /dev/null +++ b/test/mobilizon/applications_test.exs @@ -0,0 +1,146 @@ +defmodule Mobilizon.ApplicationsTest do + use Mobilizon.DataCase + + alias Mobilizon.Applications + + describe "applications" do + alias Mobilizon.Applications.Application + + import Mobilizon.ApplicationsFixtures + + @invalid_attrs %{name: nil} + + test "list_applications/0 returns all applications" do + application = application_fixture() + assert Applications.list_applications() == [application] + end + + test "get_application!/1 returns the application with given id" do + application = application_fixture() + assert Applications.get_application!(application.id) == application + end + + test "create_application/1 with valid data creates a application" do + valid_attrs = %{ + name: "some name", + client_id: "hello", + client_secret: "secret", + redirect_uris: "somewhere\nelse" + } + + assert {:ok, %Application{} = application} = Applications.create_application(valid_attrs) + assert application.name == "some name" + assert application.client_id == "hello" + assert application.client_secret == "secret" + assert application.redirect_uris == "somewhere\nelse" + end + + test "create_application/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Applications.create_application(@invalid_attrs) + end + + test "update_application/2 with valid data updates the application" do + application = application_fixture() + update_attrs = %{name: "some updated name"} + + assert {:ok, %Application{} = application} = + Applications.update_application(application, update_attrs) + + assert application.name == "some updated name" + end + + test "update_application/2 with invalid data returns error changeset" do + application = application_fixture() + + assert {:error, %Ecto.Changeset{}} = + Applications.update_application(application, @invalid_attrs) + + assert application == Applications.get_application!(application.id) + end + + test "delete_application/1 deletes the application" do + application = application_fixture() + assert {:ok, %Application{}} = Applications.delete_application(application) + assert_raise Ecto.NoResultsError, fn -> Applications.get_application!(application.id) end + end + + test "change_application/1 returns a application changeset" do + application = application_fixture() + assert %Ecto.Changeset{} = Applications.change_application(application) + end + end + + describe "application_tokens" do + alias Mobilizon.Applications.ApplicationToken + + import Mobilizon.ApplicationsFixtures + import Mobilizon.Factory + + @invalid_attrs %{user_id: nil} + + test "list_application_tokens/0 returns all application_tokens" do + application_token = application_token_fixture() + assert Applications.list_application_tokens() == [application_token] + end + + test "get_application_token!/1 returns the application_token with given id" do + application_token = application_token_fixture() + assert Applications.get_application_token!(application_token.id) == application_token + end + + test "create_application_token/1 with valid data creates a application_token" do + user = insert(:user) + application = application_fixture() + + valid_attrs = %{ + user_id: user.id, + application_id: application.id, + authorization_code: "hey hello" + } + + assert {:ok, %ApplicationToken{} = application_token} = + Applications.create_application_token(valid_attrs) + + assert application_token.user_id == user.id + assert application_token.application_id == application.id + assert application_token.authorization_code == "hey hello" + end + + test "create_application_token/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Applications.create_application_token(@invalid_attrs) + end + + test "update_application_token/2 with valid data updates the application_token" do + application_token = application_token_fixture() + update_attrs = %{authorization_code: nil} + + assert {:ok, %ApplicationToken{} = application_token} = + Applications.update_application_token(application_token, update_attrs) + + assert is_nil(application_token.authorization_code) + end + + test "update_application_token/2 with invalid data returns error changeset" do + application_token = application_token_fixture() + + assert {:error, %Ecto.Changeset{}} = + Applications.update_application_token(application_token, @invalid_attrs) + + assert application_token == Applications.get_application_token!(application_token.id) + end + + test "delete_application_token/1 deletes the application_token" do + application_token = application_token_fixture() + assert {:ok, %ApplicationToken{}} = Applications.delete_application_token(application_token) + + assert_raise Ecto.NoResultsError, fn -> + Applications.get_application_token!(application_token.id) + end + end + + test "change_application_token/1 returns a application_token changeset" do + application_token = application_token_fixture() + assert %Ecto.Changeset{} = Applications.change_application_token(application_token) + end + end +end diff --git a/test/support/fixtures/applications_fixtures.ex b/test/support/fixtures/applications_fixtures.ex new file mode 100644 index 00000000..10f34109 --- /dev/null +++ b/test/support/fixtures/applications_fixtures.ex @@ -0,0 +1,43 @@ +defmodule Mobilizon.ApplicationsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Mobilizon.Applications` context. + """ + + import Mobilizon.Factory + + @doc """ + Generate a application. + """ + def application_fixture(attrs \\ %{}) do + {:ok, application} = + attrs + |> Enum.into(%{ + name: "some name", + client_id: "hello", + client_secret: "secret", + redirect_uris: "somewhere\nelse" + }) + |> Mobilizon.Applications.create_application() + + application + end + + @doc """ + Generate a application_token. + """ + def application_token_fixture(attrs \\ %{}) do + user = insert(:user) + + {:ok, application_token} = + attrs + |> Enum.into(%{ + application_id: application_fixture().id, + user_id: user.id, + authorization_code: "some code" + }) + |> Mobilizon.Applications.create_application_token() + + application_token + end +end