From cfa1754ab5940904ad0a48aec14a0593854fc4b9 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Wed, 8 Feb 2023 13:32:40 +0100 Subject: [PATCH 01/12] Fix warnings in akismet service Signed-off-by: Thomas Citharel --- lib/service/akismet.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/service/akismet.ex b/lib/service/akismet.ex index 8fb009e8e..0b1b4dc9f 100644 --- a/lib/service/akismet.ex +++ b/lib/service/akismet.ex @@ -192,7 +192,7 @@ defmodule Mobilizon.Service.Akismet do {email, ip} -> {preferred_username, email, ip} - err -> + _ -> {:error, :invalid_actor} end end @@ -205,7 +205,7 @@ defmodule Mobilizon.Service.Akismet do {nil, preferred_username, "127.0.0.1"} end - defp actor_details(err) do + defp actor_details(_) do {:error, :invalid_actor} end From 39768693c5976d885ef2affbe08b97fc5d264a21 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Thu, 16 Feb 2023 14:50:12 +0100 Subject: [PATCH 02/12] Only show report as spam/ham buttons if antispam feature is enabled Signed-off-by: Thomas Citharel --- js/src/views/Moderation/ReportView.vue | 2 ++ 1 file changed, 2 insertions(+) diff --git a/js/src/views/Moderation/ReportView.vue b/js/src/views/Moderation/ReportView.vue index b44bbb6e2..221d88824 100644 --- a/js/src/views/Moderation/ReportView.vue +++ b/js/src/views/Moderation/ReportView.vue @@ -46,6 +46,7 @@ >{{ t("Close") }} {{ t("Report as spam") }} Date: Wed, 15 Feb 2023 19:31:23 +0100 Subject: [PATCH 03/12] Introduce application tokens Signed-off-by: Thomas Citharel --- .sobelow-skips | 18 +- js/src/components/Settings/SettingsMenu.vue | 4 + js/src/graphql/application.ts | 55 ++++ js/src/i18n/en_US.json | 6 +- js/src/i18n/fr_FR.json | 6 +- js/src/router/settings.ts | 13 + js/src/router/user.ts | 12 + js/src/types/application.model.ts | 15 + js/src/types/current-user.model.ts | 2 + js/src/views/OAuth/AuthorizeView.vue | 191 +++++++++++++ js/src/views/Settings/AppsView.vue | 138 ++++++++++ lib/graphql/resolvers/application.ex | 92 +++++++ lib/graphql/schema.ex | 3 + lib/graphql/schema/auth_application.ex | 61 +++++ lib/graphql/schema/user.ex | 7 +- lib/mobilizon/applications.ex | 258 ++++++++++++++++++ lib/mobilizon/applications/application.ex | 32 +++ .../applications/application_token.ex | 26 ++ lib/service/auth/applications.ex | 130 +++++++++ lib/service/auth/authenticator.ex | 21 +- lib/web/auth/context.ex | 50 +++- lib/web/auth/guardian.ex | 30 +- lib/web/channels/graphql_socket.ex | 34 ++- lib/web/controllers/application_controller.ex | 130 +++++++++ lib/web/controllers/page_controller.ex | 3 + lib/web/router.ex | 5 + .../20230208101626_create_applications.exs | 20 ++ ...230215125801_create_application_tokens.exs | 14 + test/mobilizon/applications_test.exs | 146 ++++++++++ .../support/fixtures/applications_fixtures.ex | 43 +++ 30 files changed, 1533 insertions(+), 32 deletions(-) create mode 100644 js/src/graphql/application.ts create mode 100644 js/src/types/application.model.ts create mode 100644 js/src/views/OAuth/AuthorizeView.vue create mode 100644 js/src/views/Settings/AppsView.vue create mode 100644 lib/graphql/resolvers/application.ex create mode 100644 lib/graphql/schema/auth_application.ex create mode 100644 lib/mobilizon/applications.ex create mode 100644 lib/mobilizon/applications/application.ex create mode 100644 lib/mobilizon/applications/application_token.ex create mode 100644 lib/service/auth/applications.ex create mode 100644 lib/web/controllers/application_controller.ex create mode 100644 priv/repo/migrations/20230208101626_create_applications.exs create mode 100644 priv/repo/migrations/20230215125801_create_application_tokens.exs create mode 100644 test/mobilizon/applications_test.exs create mode 100644 test/support/fixtures/applications_fixtures.ex diff --git a/.sobelow-skips b/.sobelow-skips index 3eabeecae..91f6f52c7 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 90e3ecbb9..6064f762b 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 85a1dd96b..612c4cce6 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 000000000..c473a370d --- /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 4c4e57cd9..33504ecf2 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 000000000..618ce49eb --- /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 000000000..35ef4ccf2 --- /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 000000000..a546148d9 --- /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 8306808d1..0756b5637 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 000000000..5cdda92a3 --- /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 ce139cf97..f49301442 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 000000000..b0df25395 --- /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 000000000..df155125d --- /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 000000000..90d94b42e --- /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 000000000..252f5c257 --- /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 ba39ca52a..fea00568d 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 48bbe295a..50ad94d50 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 446fb5dc9..e2a4ea673 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 0bf0e7244..7677104c0 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 000000000..63e0bfb25 --- /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 9afb1f779..5243eec68 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 0ec8c53c2..c85314471 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 000000000..2e29a6d55 --- /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 000000000..a2da53e9a --- /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 000000000..b1553393e --- /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 000000000..10f341098 --- /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 From b6875f6a4beba3174b8522f8ef79ac1f48470e52 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Tue, 21 Feb 2023 14:50:09 +0100 Subject: [PATCH 04/12] Introduce device flow Signed-off-by: Thomas Citharel --- .../components/OAuth/AuthorizeApplication.vue | 91 +++++++++++ js/src/graphql/application.ts | 18 ++- js/src/router/user.ts | 13 ++ js/src/views/OAuth/AuthorizeView.vue | 40 +---- js/src/views/OAuth/DeviceActivationView.vue | 149 ++++++++++++++++++ js/src/views/Settings/AppsView.vue | 11 +- lib/graphql/resolvers/application.ex | 31 +++- lib/graphql/schema/auth_application.ex | 25 +++ lib/mobilizon/applications.ex | 139 ++++++++++++++++ .../application_device_activation.ex | 32 ++++ .../applications/application_token.ex | 10 +- lib/service/auth/applications.ex | 142 ++++++++++++++++- lib/web/controllers/application_controller.ex | 53 ++++++- lib/web/controllers/page_controller.ex | 3 + lib/web/router.ex | 11 ++ ...20230216151638_add_device_flow_support.exs | 10 ++ ...3_create_application_device_activation.exs | 16 ++ test/mobilizon/applications_test.exs | 74 +++++++++ .../support/fixtures/applications_fixtures.ex | 12 ++ 19 files changed, 833 insertions(+), 47 deletions(-) create mode 100644 js/src/components/OAuth/AuthorizeApplication.vue create mode 100644 js/src/views/OAuth/DeviceActivationView.vue create mode 100644 lib/mobilizon/applications/application_device_activation.ex create mode 100644 priv/repo/migrations/20230216151638_add_device_flow_support.exs create mode 100644 priv/repo/migrations/20230217084253_create_application_device_activation.exs diff --git a/js/src/components/OAuth/AuthorizeApplication.vue b/js/src/components/OAuth/AuthorizeApplication.vue new file mode 100644 index 000000000..913ef0441 --- /dev/null +++ b/js/src/components/OAuth/AuthorizeApplication.vue @@ -0,0 +1,91 @@ + + + diff --git a/js/src/graphql/application.ts b/js/src/graphql/application.ts index 11e9849b9..086a8579d 100644 --- a/js/src/graphql/application.ts +++ b/js/src/graphql/application.ts @@ -3,6 +3,7 @@ import gql from "graphql-tag"; export const AUTH_APPLICATION = gql` query AuthApplication($clientId: String!) { authApplication(clientId: $clientId) { + id clientId name website @@ -13,7 +14,7 @@ export const AUTH_APPLICATION = gql` export const AUTORIZE_APPLICATION = gql` mutation AuthorizeApplication( $applicationClientId: String! - $redirectURI: String! + $redirectURI: String $state: String $scope: String ) { @@ -53,3 +54,18 @@ export const REVOKED_AUTHORIZED_APPLICATION = gql` } } `; + +export const DEVICE_ACTIVATION = gql` + mutation DeviceActivation($userCode: String!) { + deviceActivation(userCode: $userCode) { + id + application { + id + clientId + name + website + } + scope + } + } +`; diff --git a/js/src/router/user.ts b/js/src/router/user.ts index 612c4cce6..cd80c2973 100644 --- a/js/src/router/user.ts +++ b/js/src/router/user.ts @@ -14,6 +14,7 @@ export enum UserRouteName { VALIDATE = "Validate", LOGIN = "Login", OAUTH_AUTORIZE = "OAUTH_AUTORIZE", + OAUTH_LOGIN_DEVICE = "OAUTH_LOGIN_DEVICE", } export const userRoutes: RouteRecordRaw[] = [ @@ -120,4 +121,16 @@ export const userRoutes: RouteRecordRaw[] = [ }, }, }, + { + path: "/login/device", + name: UserRouteName.OAUTH_LOGIN_DEVICE, + component: (): Promise => + import("@/views/OAuth/DeviceActivationView.vue"), + meta: { + requiredAuth: true, + announcer: { + message: (): string => t("Device activation") as string, + }, + }, + }, ]; diff --git a/js/src/views/OAuth/AuthorizeView.vue b/js/src/views/OAuth/AuthorizeView.vue index 618ce49eb..184c5fdca 100644 --- a/js/src/views/OAuth/AuthorizeView.vue +++ b/js/src/views/OAuth/AuthorizeView.vue @@ -26,39 +26,14 @@ -
-

- {{ t("Autorize this application to access your account?") }} -

- -
- -

- {{ - t( - "This application will be able to access all of your informations and post content on your behalf. Make sure you only approve applications you trust." - ) - }} -

-
- -
-
-

{{ authApplication?.name }}

-

{{ authApplication?.website }}

-
-
- {{ t("Authorize") }} - {{ - t("Decline") - }} -
-
-
+ :auth-application="authApplication" + :redirectURI="redirectURI" + :state="state" + :scope="scope" + />
+
+
+

+ {{ t("Device activation") }} +

+

+ {{ t("Enter the code displayed on your device") }} +

+ +
+
+ - + +
+
+ +
+ +
+

{{ error }}

+
+
+ +
+ {{ t("Continue") }} +
+
+ +
+ + + diff --git a/js/src/views/Settings/AppsView.vue b/js/src/views/Settings/AppsView.vue index 35ef4ccf2..21bf2c3d4 100644 --- a/js/src/views/Settings/AppsView.vue +++ b/js/src/views/Settings/AppsView.vue @@ -76,11 +76,12 @@ import { } from "@/graphql/application"; import { useMutation, useQuery } from "@vue/apollo-composable"; import { useHead } from "@vueuse/head"; -import { computed } from "vue"; +import { computed, inject } from "vue"; import { useI18n } from "vue-i18n"; import RouteName from "../../router/name"; import { IUser } from "@/types/current-user.model"; import { formatDateString } from "@/filters/datetime"; +import { Notifier } from "@/plugins/notifier"; const { t } = useI18n({ useScope: "global" }); @@ -132,6 +133,14 @@ const { mutate: revoke, onDone: onRevokedApplication } = useMutation< }, }); +const notifier = inject("notifier"); + +onRevokedApplication(() => { + notifier?.success( + t("Application was revoked") + ); +}) + useHead({ title: computed(() => t("Apps")), }); diff --git a/lib/graphql/resolvers/application.ex b/lib/graphql/resolvers/application.ex index a546148d9..0b17a580f 100644 --- a/lib/graphql/resolvers/application.ex +++ b/lib/graphql/resolvers/application.ex @@ -4,7 +4,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Application do """ alias Mobilizon.Applications, as: ApplicationManager - alias Mobilizon.Applications.{Application, ApplicationToken} + alias Mobilizon.Applications.{Application, ApplicationDeviceActivation, ApplicationToken} alias Mobilizon.Service.Auth.Applications alias Mobilizon.Users.User import Mobilizon.Web.Gettext, only: [dgettext: 2] @@ -89,4 +89,33 @@ defmodule Mobilizon.GraphQL.Resolvers.Application do def revoke_application_token(_parent, _args, _resolution) do {:error, :unauthenticated} end + + def activate_device(_parent, %{user_code: user_code}, %{ + context: %{current_user: %User{} = user} + }) do + with {:ok, %ApplicationDeviceActivation{} = app_device_activation} <- + Applications.activate_device(user_code, user) do + {:ok, app_device_activation |> Map.from_struct() |> Map.take([:application, :id, :scope])} + end + end + + @spec authorize_device_application(any(), map(), Absinthe.Resolution.t()) :: + {:ok, map()} | {:error, String.t()} + def authorize_device_application( + _parent, + %{client_id: client_id, user_code: user_code}, + %{context: %{current_user: %User{id: user_id}}} + ) do + case Applications.autorize_device_application(client_id, user_code, user_id) do + {:ok, %Application{} = app} -> + {:ok, app} + + {:error, :application_not_found} -> + {:error, + dgettext( + "errors", + "No application with this client_id was found" + )} + end + end end diff --git a/lib/graphql/schema/auth_application.ex b/lib/graphql/schema/auth_application.ex index 5cdda92a3..37fba37a9 100644 --- a/lib/graphql/schema/auth_application.ex +++ b/lib/graphql/schema/auth_application.ex @@ -7,6 +7,7 @@ defmodule Mobilizon.GraphQL.Schema.AuthApplicationType do @desc "An application" object :auth_application do + field(:id, :id) field(:name, :string) field(:client_id, :string) field(:scopes, :string) @@ -27,6 +28,12 @@ defmodule Mobilizon.GraphQL.Schema.AuthApplicationType do field(:state, :string) end + object :application_device_activation do + field(:id, :id) + field(:application, :auth_application) + field(:scope, :string) + end + object :auth_application_queries do @desc "Get an application" field :auth_application, :auth_application do @@ -53,9 +60,27 @@ defmodule Mobilizon.GraphQL.Schema.AuthApplicationType do resolve(&Application.authorize/3) end + @desc "Revoke an authorized application" 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 + + @desc "Activate an user device" + field :device_activation, :application_device_activation do + arg(:user_code, non_null(:string), + description: "The code provided by the application entered by the user" + ) + + resolve(&Application.activate_device/3) + end + + @desc "Activate an user device" + field :authorize_device_application, :auth_application do + arg(:client_id, non_null(:string), description: "The application's client_id") + arg(:scope, :string, description: "The scope for the authorization") + + resolve(&Application.authorize_device_application/3) + end end end diff --git a/lib/mobilizon/applications.ex b/lib/mobilizon/applications.ex index b0df25395..60425097c 100644 --- a/lib/mobilizon/applications.ex +++ b/lib/mobilizon/applications.ex @@ -4,10 +4,24 @@ defmodule Mobilizon.Applications do """ import Ecto.Query, warn: false + import EctoEnum alias Ecto.Multi alias Mobilizon.Applications.Application alias Mobilizon.Storage.Repo + defenum(ApplicationDeviceActivationStatus, [ + "success", + "pending", + "incorrect_device_code", + "access_denied" + ]) + + defenum(ApplicationTokenStatus, [ + "success", + "pending", + "access_denied" + ]) + @doc """ Returns the list of applications. @@ -255,4 +269,129 @@ defmodule Mobilizon.Applications do def change_application_token(%ApplicationToken{} = application_token, attrs \\ %{}) do ApplicationToken.changeset(application_token, attrs) end + + alias Mobilizon.Applications.ApplicationDeviceActivation + + @doc """ + Returns the list of application_device_activation. + + ## Examples + + iex> list_application_device_activation() + [%ApplicationDeviceActivation{}, ...] + + """ + def list_application_device_activation do + Repo.all(ApplicationDeviceActivation) + end + + @doc """ + Gets a single application_device_activation. + + Raises `Ecto.NoResultsError` if the Application device activation does not exist. + + ## Examples + + iex> get_application_device_activation!(123) + %ApplicationDeviceActivation{} + + iex> get_application_device_activation!(456) + ** (Ecto.NoResultsError) + + """ + def get_application_device_activation!(id), do: Repo.get!(ApplicationDeviceActivation, id) + + def get_application_device_activation(id), do: Repo.get(ApplicationDeviceActivation, id) + + def get_application_device_activation_by_user_code(user_code), + do: Repo.get_by(ApplicationDeviceActivation, user_code: user_code) + + def get_application_device_activation(client_id, device_code) do + ApplicationDeviceActivation + |> join(:left, [ada], a in assoc(ada, :application)) + |> where([_, a], a.client_id == ^client_id) + |> where([ada], ada.device_code == ^device_code) + |> select([ada], ada) + |> Repo.one() + end + + @doc """ + Creates a application_device_activation. + + ## Examples + + iex> create_application_device_activation(%{field: value}) + {:ok, %ApplicationDeviceActivation{}} + + iex> create_application_device_activation(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_application_device_activation(attrs \\ %{}) do + %ApplicationDeviceActivation{} + |> ApplicationDeviceActivation.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a application_device_activation. + + ## Examples + + iex> update_application_device_activation(application_device_activation, %{field: new_value}) + {:ok, %ApplicationDeviceActivation{}} + + iex> update_application_device_activation(application_device_activation, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_application_device_activation( + %ApplicationDeviceActivation{} = application_device_activation, + attrs + ) do + application_device_activation + |> ApplicationDeviceActivation.changeset(attrs) + |> Repo.update() + |> case do + {:ok, application_device_activation} -> + {:ok, Repo.preload(application_device_activation, :application)} + + error -> + error + end + end + + @doc """ + Deletes a application_device_activation. + + ## Examples + + iex> delete_application_device_activation(application_device_activation) + {:ok, %ApplicationDeviceActivation{}} + + iex> delete_application_device_activation(application_device_activation) + {:error, %Ecto.Changeset{}} + + """ + def delete_application_device_activation( + %ApplicationDeviceActivation{} = application_device_activation + ) do + Repo.delete(application_device_activation) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking application_device_activation changes. + + ## Examples + + iex> change_application_device_activation(application_device_activation) + %Ecto.Changeset{data: %ApplicationDeviceActivation{}} + + """ + def change_application_device_activation( + %ApplicationDeviceActivation{} = application_device_activation, + attrs \\ %{} + ) do + ApplicationDeviceActivation.changeset(application_device_activation, attrs) + end end diff --git a/lib/mobilizon/applications/application_device_activation.ex b/lib/mobilizon/applications/application_device_activation.ex new file mode 100644 index 000000000..27f0de0ae --- /dev/null +++ b/lib/mobilizon/applications/application_device_activation.ex @@ -0,0 +1,32 @@ +defmodule Mobilizon.Applications.ApplicationDeviceActivation do + @moduledoc """ + Module representing a application device activation + """ + use Ecto.Schema + import Ecto.Changeset + alias Mobilizon.Applications.{Application, ApplicationDeviceActivationStatus} + alias Mobilizon.Users.User + + schema "application_device_activation" do + field(:user_code, :string) + field(:device_code, :string) + field(:scope, :string) + field(:expires_in, :integer) + field(:status, ApplicationDeviceActivationStatus, default: :pending) + belongs_to(:user, User) + belongs_to(:application, Application) + + timestamps() + end + + @required_attrs [:user_code, :device_code, :expires_in, :application_id] + @optional_attrs [:status, :user_id] + @attrs @required_attrs ++ @optional_attrs + + @doc false + def changeset(application_device_activation, attrs) do + application_device_activation + |> 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 index 90d94b42e..bcf3575c3 100644 --- a/lib/mobilizon/applications/application_token.ex +++ b/lib/mobilizon/applications/application_token.ex @@ -4,16 +4,20 @@ defmodule Mobilizon.Applications.ApplicationToken do """ use Ecto.Schema import Ecto.Changeset + alias Mobilizon.Applications.{Application, ApplicationTokenStatus} + alias Mobilizon.Users.User schema "application_tokens" do - belongs_to(:user, Mobilizon.Users.User) - belongs_to(:application, Mobilizon.Applications.Application) + belongs_to(:user, User) + belongs_to(:application, Application) field(:authorization_code, :string) + field(:status, ApplicationTokenStatus) + field(:scope, :string) timestamps() end - @required_attrs [:user_id, :application_id] + @required_attrs [:user_id, :application_id, :scope] @optional_attrs [:authorization_code] @attrs @required_attrs ++ @optional_attrs diff --git a/lib/service/auth/applications.ex b/lib/service/auth/applications.ex index 252f5c257..c77538f19 100644 --- a/lib/service/auth/applications.ex +++ b/lib/service/auth/applications.ex @@ -3,8 +3,10 @@ defmodule Mobilizon.Service.Auth.Applications do Module to handle applications management """ alias Mobilizon.Applications - alias Mobilizon.Applications.{Application, ApplicationToken} + alias Mobilizon.Applications.{Application, ApplicationDeviceActivation, ApplicationToken} alias Mobilizon.Service.Auth.Authenticator + alias Mobilizon.Users.User + alias Mobilizon.Web.Router.Helpers, as: Routes @app_access_tokens_ttl {8, :hour} @app_refresh_tokens_ttl {26, :week} @@ -58,6 +60,15 @@ defmodule Mobilizon.Service.Auth.Applications do end end + def autorize_device_application(client_id, user_code) do + case Applications.get_application_device_activation(client_id, user_code) do + %ApplicationDeviceActivation{status: :confirmed} = app_device_activation -> + Applications.update_application_device_activation(app_device_activation, %{ + status: :success + }) + end + end + @spec generate_access_token(String.t(), String.t(), String.t(), String.t()) :: {:ok, access_token_details()} | {:error, @@ -118,6 +129,126 @@ defmodule Mobilizon.Service.Auth.Applications do end end + def generate_access_token(client_id, device_code) do + case Applications.get_application_device_activation(client_id, device_code) do + %ApplicationDeviceActivation{status: :success, scope: scope, user_id: user_id} = + app_device_activation -> + if device_activation_expired?(app_device_activation) do + {:error, :expired} + else + %Application{id: app_id} = Applications.get_application_by_client_id(client_id) + + {:ok, %ApplicationToken{} = app_token} = + Applications.create_application_token(%{ + user_id: user_id, + application_id: app_id, + authorization_code: nil, + scope: scope + }) + + {: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) + + {: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: scope, + token_type: "bearer" + }} + end + + %ApplicationDeviceActivation{status: :incorrect_device_code} -> + {:error, :incorrect_device_code} + + %ApplicationDeviceActivation{status: :access_denied} -> + {:error, :access_denied} + + err -> + require Logger + Logger.error(inspect(err)) + {:error, :incorrect_device_code} + end + end + + @chars "ABCDEFGHIJKLMNOPQRSTUVWXYZ" |> String.split("", trim: true) + + defp string_of_length(length) do + 1..length + |> Enum.reduce([], fn _i, acc -> + [Enum.random(@chars) | acc] + end) + |> Enum.join("") + end + + @expires_in 900 + @interval 5 + + @spec register_device_code(String.t(), String.t() | nil) :: + {:ok, ApplicationDeviceActivation.t()} + | {:error, Ecto.Changeset.t()} + def register_device_code(client_id, scope) do + %Application{} = application = Applications.get_application_by_client_id(client_id) + device_code = string_of_length(40) + user_code = string_of_length(8) + verification_uri = Routes.page_url(Mobilizon.Web.Endpoint, :auth_device) + expires_in = @expires_in + interval = @interval + + case Applications.create_application_device_activation(%{ + device_code: device_code, + user_code: user_code, + expires_in: expires_in, + application_id: application.id, + scope: scope + }) do + {:ok, %ApplicationDeviceActivation{} = application_device_activation} -> + {:ok, + application_device_activation + |> Map.from_struct() + |> Map.take([:device_code, :user_code, :expires_in]) + |> Map.update!(:user_code, &user_code_displayed/1) + |> Map.merge(%{ + interval: interval, + verification_uri: verification_uri + })} + + {:error, %Ecto.Changeset{} = err} -> + {:error, err} + end + end + + @spec activate_device(String.t(), User.t()) :: + {:ok, ApplicationDeviceActivation.t()} + | {:error, Ecto.Changeset.t()} + | {:error, :not_found} + | {:error, :expired} + def activate_device(user_code, user) do + case Applications.get_application_device_activation_by_user_code(user_code) do + %ApplicationDeviceActivation{} = app_device_activation -> + if device_activation_expired?(app_device_activation) do + {:error, :expired} + else + Applications.update_application_device_activation(app_device_activation, %{ + status: :confirmed, + user_id: user.id + }) + end + + _ -> + {:error, :not_found} + end + end + + defp user_code_displayed(user_code) do + String.slice(user_code, 0..3) <> "-" <> String.slice(user_code, 4..7) + end + def revoke_application_token(%ApplicationToken{} = app_token) do Applications.revoke_application_token(app_token) end @@ -127,4 +258,13 @@ defmodule Mobilizon.Service.Auth.Applications do 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 + + @spec device_activation_expired?(ApplicationDeviceActivation.t()) :: boolean() + defp device_activation_expired?(%ApplicationDeviceActivation{ + inserted_at: inserted_at, + expires_in: expires_in + }) do + NaiveDateTime.compare(NaiveDateTime.add(inserted_at, expires_in), NaiveDateTime.utc_now()) == + :gt + end end diff --git a/lib/web/controllers/application_controller.ex b/lib/web/controllers/application_controller.ex index 63e0bfb25..df26c99bb 100644 --- a/lib/web/controllers/application_controller.ex +++ b/lib/web/controllers/application_controller.ex @@ -1,12 +1,11 @@ defmodule Mobilizon.Web.ApplicationController do use Mobilizon.Web, :controller - alias Mobilizon.Applications.Application + alias Mobilizon.Applications.{Application, ApplicationDeviceActivation} 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" + require Logger @doc """ Create an application @@ -84,6 +83,27 @@ defmodule Mobilizon.Web.ApplicationController do end end + def device_code(conn, %{"client_id" => client_id} = args) do + case Applications.register_device_code(client_id, Map.get(args, "scope")) do + {:ok, res} when is_map(res) -> + case get_format(conn) do + "json" -> + json(conn, res) + + _ -> + send_resp(conn, 200, URI.encode_query(res)) + end + + {:error, %Ecto.Changeset{} = err} -> + Logger.error(inspect(err)) + send_resp(conn, 500, "Unable to produce device code") + end + end + + def device_code(conn, _args) do + send_resp(conn, 400, "You need to send to send at least client_id to obtain a device code") + end + @spec generate_access_token(Plug.Conn.t(), map()) :: Plug.Conn.t() def generate_access_token(conn, %{ "client_id" => client_id, @@ -93,11 +113,7 @@ defmodule Mobilizon.Web.ApplicationController do }) 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 + redirect(conn, external: generate_redirect_with_query_params(redirect_uri, token)) {:error, :application_not_found} -> send_resp(conn, 400, dgettext("errors", "No application was found with this client_id")) @@ -123,6 +139,27 @@ defmodule Mobilizon.Web.ApplicationController do end end + def generate_access_token(conn, %{ + "client_id" => client_id, + "device_code" => device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code", + "_format" => "json" + }) do + json(conn, Applications.generate_access_token(client_id, device_code)) + end + + def generate_access_token(conn, %{ + "client_id" => client_id, + "device_code" => device_code, + "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" + }) do + send_resp( + conn, + 200, + URI.encode_query(Applications.generate_access_token(client_id, device_code)) + ) + 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() diff --git a/lib/web/controllers/page_controller.ex b/lib/web/controllers/page_controller.ex index 5243eec68..798d91aaa 100644 --- a/lib/web/controllers/page_controller.ex +++ b/lib/web/controllers/page_controller.ex @@ -124,6 +124,9 @@ defmodule Mobilizon.Web.PageController do @spec authorize(Plug.Conn.t(), any) :: Plug.Conn.t() def authorize(conn, _params), do: render(conn, :index) + @spec auth_device(Plug.Conn.t(), any) :: Plug.Conn.t() + def auth_device(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 c85314471..97907aed9 100644 --- a/lib/web/router.ex +++ b/lib/web/router.ex @@ -210,6 +210,17 @@ defmodule Mobilizon.Web.Router do get("/oauth/authorize", ApplicationController, :authorize) post("/oauth/token", ApplicationController, :generate_access_token) get("/oauth/autorize_approve", PageController, :authorize) + get("/login/device", PageController, :auth_device) + end + + pipeline :login do + plug(:accepts, ["html", "json"]) + end + + scope "/login", Mobilizon.Web do + pipe_through(:login) + + post("/device/code", ApplicationController, :device_code) end scope "/proxy/", Mobilizon.Web do diff --git a/priv/repo/migrations/20230216151638_add_device_flow_support.exs b/priv/repo/migrations/20230216151638_add_device_flow_support.exs new file mode 100644 index 000000000..2e83dc59c --- /dev/null +++ b/priv/repo/migrations/20230216151638_add_device_flow_support.exs @@ -0,0 +1,10 @@ +defmodule Mobilizon.Storage.Repo.Migrations.AddDeviceFlowSupport do + use Ecto.Migration + + def change do + alter table(:application_tokens) do + add(:status, :string, default: :pending, null: false) + add(:scope, :string) + end + end +end diff --git a/priv/repo/migrations/20230217084253_create_application_device_activation.exs b/priv/repo/migrations/20230217084253_create_application_device_activation.exs new file mode 100644 index 000000000..40e28bcdb --- /dev/null +++ b/priv/repo/migrations/20230217084253_create_application_device_activation.exs @@ -0,0 +1,16 @@ +defmodule Mobilizon.Repo.Migrations.CreateApplicationDeviceActivation do + use Ecto.Migration + + def change do + create table(:application_device_activation) do + add(:user_code, :string) + add(:device_code, :string) + add(:scope, :string) + add(:expires_in, :integer) + add(:status, :string, default: "pending") + add(:user_id, references(:users, on_delete: :delete_all), null: true) + add(:application_id, references(:applications, on_delete: :delete_all), null: false) + timestamps() + end + end +end diff --git a/test/mobilizon/applications_test.exs b/test/mobilizon/applications_test.exs index b1553393e..01fbc41ed 100644 --- a/test/mobilizon/applications_test.exs +++ b/test/mobilizon/applications_test.exs @@ -143,4 +143,78 @@ defmodule Mobilizon.ApplicationsTest do assert %Ecto.Changeset{} = Applications.change_application_token(application_token) end end + + describe "application_device_activation" do + alias Mobilizon.Applications.ApplicationDeviceActivation + + import Mobilizon.ApplicationsFixtures + + @invalid_attrs %{} + + test "list_application_device_activation/0 returns all application_device_activation" do + application_device_activation = application_device_activation_fixture() + assert Applications.list_application_device_activation() == [application_device_activation] + end + + test "get_application_device_activation!/1 returns the application_device_activation with given id" do + application_device_activation = application_device_activation_fixture() + + assert Applications.get_application_device_activation!(application_device_activation.id) == + application_device_activation + end + + test "create_application_device_activation/1 with valid data creates a application_device_activation" do + valid_attrs = %{} + + assert {:ok, %ApplicationDeviceActivation{} = application_device_activation} = + Applications.create_application_device_activation(valid_attrs) + end + + test "create_application_device_activation/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = + Applications.create_application_device_activation(@invalid_attrs) + end + + test "update_application_device_activation/2 with valid data updates the application_device_activation" do + application_device_activation = application_device_activation_fixture() + update_attrs = %{} + + assert {:ok, %ApplicationDeviceActivation{} = application_device_activation} = + Applications.update_application_device_activation( + application_device_activation, + update_attrs + ) + end + + test "update_application_device_activation/2 with invalid data returns error changeset" do + application_device_activation = application_device_activation_fixture() + + assert {:error, %Ecto.Changeset{}} = + Applications.update_application_device_activation( + application_device_activation, + @invalid_attrs + ) + + assert application_device_activation == + Applications.get_application_device_activation!(application_device_activation.id) + end + + test "delete_application_device_activation/1 deletes the application_device_activation" do + application_device_activation = application_device_activation_fixture() + + assert {:ok, %ApplicationDeviceActivation{}} = + Applications.delete_application_device_activation(application_device_activation) + + assert_raise Ecto.NoResultsError, fn -> + Applications.get_application_device_activation!(application_device_activation.id) + end + end + + test "change_application_device_activation/1 returns a application_device_activation changeset" do + application_device_activation = application_device_activation_fixture() + + assert %Ecto.Changeset{} = + Applications.change_application_device_activation(application_device_activation) + end + end end diff --git a/test/support/fixtures/applications_fixtures.ex b/test/support/fixtures/applications_fixtures.ex index 10f341098..0607e6981 100644 --- a/test/support/fixtures/applications_fixtures.ex +++ b/test/support/fixtures/applications_fixtures.ex @@ -40,4 +40,16 @@ defmodule Mobilizon.ApplicationsFixtures do application_token end + + @doc """ + Generate a application_device_activation. + """ + def application_device_activation_fixture(attrs \\ %{}) do + {:ok, application_device_activation} = + attrs + |> Enum.into(%{}) + |> Mobilizon.Applications.create_application_device_activation() + + application_device_activation + end end From 8984bd76367e70cdecc79344be30302616d14c46 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Fri, 17 Mar 2023 18:10:59 +0100 Subject: [PATCH 05/12] Introduce authorizations with Rajska Signed-off-by: Thomas Citharel --- config/test.exs | 2 +- js/package.json | 23 +- js/src/components/Event/EventMetadataList.vue | 17 +- .../Event/OrganizerPickerWrapper.story.vue | 2 +- .../components/OAuth/AuthorizeApplication.vue | 135 +- js/src/components/OAuth/scopes.ts | 283 +++ js/src/components/core/MaterialIcon.vue | 8 + js/src/composition/apollo/actor.ts | 5 +- js/src/composition/apollo/user.ts | 10 +- js/src/graphql/actor.ts | 8 +- js/src/graphql/application.ts | 21 +- js/src/graphql/config.ts | 1 + js/src/graphql/group.ts | 10 +- js/src/i18n/en_US.json | 98 +- js/src/i18n/fr_FR.json | 96 +- js/src/types/application.model.ts | 2 +- js/src/types/current-user.model.ts | 1 + js/src/utils/identity.ts | 5 +- .../views/Account/children/EditIdentity.vue | 25 +- js/src/views/Group/TimelineView.vue | 45 +- js/src/views/OAuth/AuthorizeView.vue | 50 +- js/src/views/Settings/AppsView.vue | 6 +- js/vite.config.js | 7 + js/yarn.lock | 1818 ++++++++++------- lib/graphql/authorization.ex | 86 + lib/graphql/authorization/app_scope.ex | 118 ++ lib/graphql/error.ex | 19 + lib/graphql/resolvers/application.ex | 62 +- lib/graphql/resolvers/media.ex | 4 +- lib/graphql/schema.ex | 25 +- lib/graphql/schema/activity.ex | 4 + lib/graphql/schema/actor.ex | 4 + lib/graphql/schema/actors/application.ex | 1 + lib/graphql/schema/actors/follower.ex | 4 + lib/graphql/schema/actors/group.ex | 61 +- lib/graphql/schema/actors/member.ex | 91 +- lib/graphql/schema/actors/person.ex | 78 +- lib/graphql/schema/address.ex | 8 +- lib/graphql/schema/admin.ex | 27 +- lib/graphql/schema/auth_application.ex | 52 +- lib/graphql/schema/config.ex | 45 + lib/graphql/schema/discussions/comment.ex | 24 + lib/graphql/schema/discussions/discussion.ex | 39 + lib/graphql/schema/event.ex | 45 +- lib/graphql/schema/events/feed_token.ex | 17 + lib/graphql/schema/events/participant.ex | 24 +- lib/graphql/schema/followed_group_activity.ex | 2 + lib/graphql/schema/media.ex | 20 + lib/graphql/schema/post.ex | 37 +- lib/graphql/schema/report.ex | 36 +- lib/graphql/schema/resource.ex | 38 + lib/graphql/schema/search.ex | 13 +- lib/graphql/schema/statistics.ex | 4 + lib/graphql/schema/tag.ex | 2 + lib/graphql/schema/todos/todo.ex | 6 +- lib/graphql/schema/todos/todo_list.ex | 4 + lib/graphql/schema/user.ex | 64 +- lib/graphql/schema/users/activity_setting.ex | 8 + lib/graphql/schema/users/push_subscription.ex | 14 + lib/mobilizon/applications.ex | 29 +- lib/mobilizon/applications/application.ex | 8 +- .../application_device_activation.ex | 2 +- .../applications/application_token.ex | 2 +- lib/service/auth/applications.ex | 223 +- lib/web/auth/context.ex | 2 +- lib/web/auth/error_handler.ex | 5 +- lib/web/auth/guardian.ex | 19 +- lib/web/controllers/application_controller.ex | 173 +- lib/web/router.ex | 6 +- mix.exs | 1 + mix.lock | 1 + .../20230208101626_create_applications.exs | 4 +- ...230215125801_create_application_tokens.exs | 2 + ...20230216151638_add_device_flow_support.exs | 10 - test/graphql/resolvers/activity_test.exs | 3 +- test/graphql/resolvers/admin_test.exs | 107 +- test/graphql/resolvers/application_test.exs | 533 +++++ test/graphql/resolvers/comment_test.exs | 13 +- test/graphql/resolvers/event_test.exs | 97 +- test/graphql/resolvers/feed_token_test.exs | 176 +- test/graphql/resolvers/follower_test.exs | 5 +- test/graphql/resolvers/group_test.exs | 48 +- test/graphql/resolvers/media_test.exs | 2 +- test/graphql/resolvers/member_test.exs | 6 +- test/graphql/resolvers/person_test.exs | 3 +- test/graphql/resolvers/report_test.exs | 8 +- test/graphql/resolvers/resource_test.exs | 8 +- test/graphql/resolvers/search_test.exs | 49 +- test/graphql/resolvers/tag_test.exs | 24 +- test/graphql/resolvers/user_test.exs | 78 +- test/mobilizon/applications_test.exs | 33 +- test/support/conn_case.ex | 10 +- test/support/factory.ex | 33 + .../support/fixtures/applications_fixtures.ex | 15 +- .../application_controller_test.exs | 563 +++++ 95 files changed, 4560 insertions(+), 1505 deletions(-) create mode 100644 js/src/components/OAuth/scopes.ts create mode 100644 lib/graphql/authorization.ex create mode 100644 lib/graphql/authorization/app_scope.ex delete mode 100644 priv/repo/migrations/20230216151638_add_device_flow_support.exs create mode 100644 test/graphql/resolvers/application_test.exs create mode 100644 test/web/controllers/application_controller_test.exs diff --git a/config/test.exs b/config/test.exs index 62901358f..b5995e94a 100644 --- a/config/test.exs +++ b/config/test.exs @@ -78,7 +78,7 @@ config :tesla, Mobilizon.Service.HTTP.HostMetaClient, config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Mock -config :mobilizon, Oban, queues: false, plugins: false +config :mobilizon, Oban, testing: :manual config :mobilizon, Mobilizon.Web.Auth.Guardian, secret_key: "some secret" diff --git a/js/package.json b/js/package.json index d73a875ce..40b802bad 100644 --- a/js/package.json +++ b/js/package.json @@ -45,6 +45,7 @@ "@tiptap/extension-strike": "^2.0.0-beta.26", "@tiptap/extension-text": "^2.0.0-beta.15", "@tiptap/extension-underline": "^2.0.0-beta.7", + "@tiptap/pm": "^2.0.0-beta.220", "@tiptap/suggestion": "^2.0.0-beta.195", "@tiptap/vue-3": "^2.0.0-beta.96", "@vue-a11y/announcer": "^2.1.0", @@ -59,7 +60,7 @@ "autoprefixer": "^10", "blurhash": "^2.0.0", "date-fns": "^2.16.0", - "date-fns-tz": "^1.1.6", + "date-fns-tz": "^2.0.0", "floating-vue": "^2.0.0-beta.17", "graphql": "^15.8.0", "graphql-tag": "^2.10.3", @@ -74,16 +75,6 @@ "p-debounce": "^4.0.0", "phoenix": "^1.6", "postcss": "^8", - "prosemirror-commands": "^1.5.0", - "prosemirror-dropcursor": "^1.6.1", - "prosemirror-gapcursor": "^1.3.1", - "prosemirror-history": "^1.3.0", - "prosemirror-keymap": "^1.2.0", - "prosemirror-model": "^1.19.0", - "prosemirror-schema-list": "^1.2.2", - "prosemirror-state": "^1.4.2", - "prosemirror-transform": "^1.7.1", - "prosemirror-view": "^1.30.0", "register-service-worker": "^1.7.2", "sanitize-html": "^2.5.3", "tailwindcss": "^3", @@ -100,7 +91,7 @@ "zhyswan-vuedraggable": "^4.1.3" }, "devDependencies": { - "@histoire/plugin-vue": "^0.12.4", + "@histoire/plugin-vue": "^0.15.8", "@playwright/test": "^1.25.1", "@rushstack/eslint-patch": "^1.1.4", "@tailwindcss/forms": "^0.5.2", @@ -114,8 +105,8 @@ "@types/phoenix": "^1.5.2", "@types/sanitize-html": "^2.5.0", "@vitejs/plugin-vue": "^4.0.0", - "@vitest/coverage-c8": "^0.28.2", - "@vitest/ui": "^0.28.2", + "@vitest/coverage-c8": "^0.29.2", + "@vitest/ui": "^0.29.2", "@vue/eslint-config-prettier": "^7.0.0", "@vue/eslint-config-typescript": "^11.0.0", "@vue/test-utils": "^2.0.2", @@ -125,7 +116,7 @@ "eslint-plugin-prettier": "^4.0.0", "eslint-plugin-vue": "^9.3.0", "flush-promises": "^1.0.2", - "histoire": "^0.12.4", + "histoire": "^0.15.8", "jsdom": "^21.1.0", "mock-apollo-client": "^1.1.0", "prettier": "^2.2.1", @@ -135,7 +126,7 @@ "typescript": "~4.9.4", "vite": "^4.0.4", "vite-plugin-pwa": "^0.14.1", - "vitest": "^0.28.2", + "vitest": "^0.29.2", "vue-i18n-extract": "^2.0.4" } } diff --git a/js/src/components/Event/EventMetadataList.vue b/js/src/components/Event/EventMetadataList.vue index d3fb4abd0..1b5041513 100644 --- a/js/src/components/Event/EventMetadataList.vue +++ b/js/src/components/Event/EventMetadataList.vue @@ -65,18 +65,15 @@ -