From 9a080c1f10cf71730d5e539ef51e4d24b41de2e6 Mon Sep 17 00:00:00 2001 From: Thomas Citharel Date: Sat, 27 Jun 2020 19:12:45 +0200 Subject: [PATCH] Introduce support for 3rd-party auth (OAuth2 & LDAP) Signed-off-by: Thomas Citharel --- config/config.exs | 24 ++ js/src/components/User/AuthProvider.vue | 26 ++ js/src/components/User/AuthProviders.vue | 26 ++ js/src/graphql/config.ts | 7 + js/src/graphql/user.ts | 11 +- js/src/i18n/en_US.json | 7 +- js/src/i18n/fr_FR.json | 7 +- js/src/router/index.ts | 5 + js/src/types/config.model.ts | 9 + js/src/types/current-user.model.ts | 24 +- js/src/types/login-error-code.model.ts | 2 + js/src/utils/auth.ts | 11 + js/src/views/Settings/AccountSettings.vue | 59 ++++- js/src/views/Settings/Notifications.vue | 7 +- js/src/views/Settings/Preferences.vue | 6 +- js/src/views/User/Login.vue | 29 +++ js/src/views/User/ProviderValidation.vue | 64 +++++ js/src/views/User/Register.vue | 9 +- js/src/views/User/Validate.vue | 3 +- lib/graphql/resolvers/config.ex | 6 +- lib/graphql/resolvers/person.ex | 6 +- lib/graphql/resolvers/user.ex | 68 +++-- lib/graphql/schema/config.ex | 11 + lib/graphql/schema/user.ex | 2 + lib/mobilizon/actors/actors.ex | 12 +- lib/mobilizon/config.ex | 18 ++ lib/mobilizon/users/user.ex | 17 +- lib/mobilizon/users/users.ex | 75 ++---- lib/service/auth/authenticator.ex | 93 +++++++ lib/service/auth/ldap_authenticator.ex | 180 +++++++++++++ lib/service/auth/mobilizon_authenticator.ex | 39 +++ lib/web/controllers/auth_controller.ex | 82 ++++++ lib/web/router.ex | 4 + lib/web/views/auth_view.ex | 29 +++ mix.exs | 29 ++- mix.lock | 19 +- priv/gettext/en/LC_MESSAGES/default.po | 4 +- ...er_to_user_and_make_password_mandatory.exs | 17 ++ test/graphql/resolvers/participant_test.exs | 2 +- test/graphql/resolvers/report_test.exs | 2 +- test/graphql/resolvers/user_test.exs | 186 ++++++-------- test/mobilizon/users/users_test.exs | 8 - test/service/auth/authentificator_test.exs | 34 +++ .../auth/ldap_authentificator_test.exs | 238 ++++++++++++++++++ .../auth/mobilizon_authentificator_test.exs | 29 +++ test/support/factory.ex | 3 +- test/support/helpers.ex | 17 +- test/web/controllers/auth_controller_test.exs | 54 ++++ 48 files changed, 1380 insertions(+), 240 deletions(-) create mode 100644 js/src/components/User/AuthProvider.vue create mode 100644 js/src/components/User/AuthProviders.vue create mode 100644 js/src/views/User/ProviderValidation.vue create mode 100644 lib/service/auth/authenticator.ex create mode 100644 lib/service/auth/ldap_authenticator.ex create mode 100644 lib/service/auth/mobilizon_authenticator.ex create mode 100644 lib/web/controllers/auth_controller.ex create mode 100644 lib/web/views/auth_view.ex create mode 100644 priv/repo/migrations/20200630123819_add_provider_to_user_and_make_password_mandatory.exs create mode 100644 test/service/auth/authentificator_test.exs create mode 100644 test/service/auth/ldap_authentificator_test.exs create mode 100644 test/service/auth/mobilizon_authentificator_test.exs create mode 100644 test/web/controllers/auth_controller_test.exs diff --git a/config/config.exs b/config/config.exs index 012953b09..e4e4aaad2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -118,6 +118,30 @@ config :guardian, Guardian.DB, config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase +config :mobilizon, + Mobilizon.Service.Auth.Authenticator, + Mobilizon.Service.Auth.MobilizonAuthenticator + +config :ueberauth, + Ueberauth, + providers: [] + +config :mobilizon, :auth, oauth_consumer_strategies: [] + +config :mobilizon, :ldap, + enabled: System.get_env("LDAP_ENABLED") == "true", + host: System.get_env("LDAP_HOST") || "localhost", + port: String.to_integer(System.get_env("LDAP_PORT") || "389"), + ssl: System.get_env("LDAP_SSL") == "true", + sslopts: [], + tls: System.get_env("LDAP_TLS") == "true", + tlsopts: [], + base: System.get_env("LDAP_BASE") || "dc=example,dc=com", + uid: System.get_env("LDAP_UID") || "cn", + require_bind_for_search: !(System.get_env("LDAP_REQUIRE_BIND_FOR_SEARCH") == "false"), + bind_uid: System.get_env("LDAP_BIND_UID"), + bind_password: System.get_env("LDAP_BIND_PASSWORD") + config :geolix, databases: [ %{ diff --git a/js/src/components/User/AuthProvider.vue b/js/src/components/User/AuthProvider.vue new file mode 100644 index 000000000..79e48ef6b --- /dev/null +++ b/js/src/components/User/AuthProvider.vue @@ -0,0 +1,26 @@ + + diff --git a/js/src/components/User/AuthProviders.vue b/js/src/components/User/AuthProviders.vue new file mode 100644 index 000000000..cd04d09d8 --- /dev/null +++ b/js/src/components/User/AuthProviders.vue @@ -0,0 +1,26 @@ + + diff --git a/js/src/graphql/config.ts b/js/src/graphql/config.ts index 90ab6ef8e..44471990c 100644 --- a/js/src/graphql/config.ts +++ b/js/src/graphql/config.ts @@ -62,6 +62,13 @@ export const CONFIG = gql` features { groups } + auth { + ldap + oauthProviders { + id + label + } + } } } `; diff --git a/js/src/graphql/user.ts b/js/src/graphql/user.ts index 91d7e14af..f5fdd350d 100644 --- a/js/src/graphql/user.ts +++ b/js/src/graphql/user.ts @@ -35,6 +35,15 @@ export const LOGGED_USER = gql` loggedUser { id email + defaultActor { + id + preferredUsername + name + avatar { + url + } + } + provider } } `; @@ -64,7 +73,7 @@ export const VALIDATE_EMAIL = gql` `; export const DELETE_ACCOUNT = gql` - mutation DeleteAccount($password: String, $userId: ID!) { + mutation DeleteAccount($password: String, $userId: ID) { deleteAccount(password: $password, userId: $userId) { id } diff --git a/js/src/i18n/en_US.json b/js/src/i18n/en_US.json index 0f8cad665..95070573f 100644 --- a/js/src/i18n/en_US.json +++ b/js/src/i18n/en_US.json @@ -703,5 +703,10 @@ "New discussion": "New discussion", "Create a discussion": "Create a discussion", "Create the discussion": "Create the discussion", - "View all discussions": "View all discussions" + "View all discussions": "View all discussions", + "Sign in with": "Sign in with", + "Your email address was automatically set based on your {provider} account.": "Your email address was automatically set based on your {provider} account.", + "You can't change your password because you are registered through {provider}.": "You can't change your password because you are registered through {provider}.", + "Error while login with {provider}. Retry or login another way.": "Error while login with {provider}. Retry or login another way.", + "Error while login with {provider}. This login provider doesn't exist.": "Error while login with {provider}. This login provider doesn't exist." } diff --git a/js/src/i18n/fr_FR.json b/js/src/i18n/fr_FR.json index 12204305f..b0ad62388 100644 --- a/js/src/i18n/fr_FR.json +++ b/js/src/i18n/fr_FR.json @@ -703,5 +703,10 @@ "{number} participations": "Aucune participation|Une participation|{number} participations", "{profile} (by default)": "{profile} (par défault)", "{title} ({count} todos)": "{title} ({count} todos)", - "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap" + "© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap", + "Sign in with": "Se connecter avec", + "Your email address was automatically set based on your {provider} account.": "Votre adresse email a été définie automatiquement en se basant sur votre compte {provider}.", + "You can't change your password because you are registered through {provider}.": "Vous ne pouvez pas changer votre mot de passe car vous vous êtes enregistré via {provider}.", + "Error while login with {provider}. Retry or login another way.": "Erreur lors de la connexion avec {provider}. Réessayez ou bien connectez vous autrement.", + "Error while login with {provider}. This login provider doesn't exist.": "Erreur lors de la connexion avec {provider}. Cette méthode de connexion n'existe pas." } diff --git a/js/src/router/index.ts b/js/src/router/index.ts index e7cce702c..52e64663f 100644 --- a/js/src/router/index.ts +++ b/js/src/router/index.ts @@ -112,6 +112,11 @@ const router = new Router({ component: () => import(/* webpackChunkName: "cookies" */ "@/views/Interact.vue"), meta: { requiredAuth: false }, }, + { + path: "/auth/:provider/callback", + name: "auth-callback", + component: () => import("@/views/User/ProviderValidation.vue"), + }, { path: "/404", name: RouteName.PAGE_NOT_FOUND, diff --git a/js/src/types/config.model.ts b/js/src/types/config.model.ts index 61f0aaad9..3e53bf3bf 100644 --- a/js/src/types/config.model.ts +++ b/js/src/types/config.model.ts @@ -74,4 +74,13 @@ export interface IConfig { }; federating: boolean; version: string; + auth: { + ldap: boolean; + oauthProviders: IOAuthProvider[]; + }; +} + +export interface IOAuthProvider { + id: string; + label: string; } diff --git a/js/src/types/current-user.model.ts b/js/src/types/current-user.model.ts index 5eb7a2034..574ecd078 100644 --- a/js/src/types/current-user.model.ts +++ b/js/src/types/current-user.model.ts @@ -9,15 +9,11 @@ export enum ICurrentUserRole { } export interface ICurrentUser { - id: number; + id: string; email: string; isLoggedIn: boolean; role: ICurrentUserRole; - participations: Paginate; - defaultActor: IPerson; - drafts: IEvent[]; - settings: IUserSettings; - locale: string; + defaultActor?: IPerson; } export interface IUser extends ICurrentUser { @@ -25,6 +21,22 @@ export interface IUser extends ICurrentUser { confirmationSendAt: Date; actors: IPerson[]; disabled: boolean; + participations: Paginate; + drafts: IEvent[]; + settings: IUserSettings; + locale: string; + provider?: string; +} + +export enum IAuthProvider { + LDAP = "ldap", + GOOGLE = "google", + DISCORD = "discord", + GITHUB = "github", + KEYCLOAK = "keycloak", + FACEBOOK = "facebook", + GITLAB = "gitlab", + TWITTER = "twitter", } export enum INotificationPendingParticipationEnum { diff --git a/js/src/types/login-error-code.model.ts b/js/src/types/login-error-code.model.ts index c12c4c5c9..e2752030b 100644 --- a/js/src/types/login-error-code.model.ts +++ b/js/src/types/login-error-code.model.ts @@ -6,4 +6,6 @@ export enum LoginError { USER_NOT_CONFIRMED = "User account not confirmed", USER_DOES_NOT_EXIST = "No user with this email was found", USER_EMAIL_PASSWORD_INVALID = "Impossible to authenticate, either your email or password are invalid.", + LOGIN_PROVIDER_ERROR = "Error with Login Provider", + LOGIN_PROVIDER_NOT_FOUND = "Login Provider not found", } diff --git a/js/src/utils/auth.ts b/js/src/utils/auth.ts index 9d8a91c4e..f18f6104a 100644 --- a/js/src/utils/auth.ts +++ b/js/src/utils/auth.ts @@ -94,3 +94,14 @@ export async function logout(apollo: ApolloClient) { deleteUserData(); } + +export const SELECTED_PROVIDERS: { [key: string]: string } = { + twitter: "Twitter", + discord: "Discord", + facebook: "Facebook", + github: "Github", + gitlab: "Gitlab", + google: "Google", + keycloak: "Keycloak", + ldap: "LDAP", +}; diff --git a/js/src/views/Settings/AccountSettings.vue b/js/src/views/Settings/AccountSettings.vue index a1f320cc7..80168b738 100644 --- a/js/src/views/Settings/AccountSettings.vue +++ b/js/src/views/Settings/AccountSettings.vue @@ -1,5 +1,5 @@ + diff --git a/js/src/views/User/Register.vue b/js/src/views/User/Register.vue index abe9ce3a0..3df320bdc 100644 --- a/js/src/views/User/Register.vue +++ b/js/src/views/User/Register.vue @@ -96,6 +96,7 @@ {{ $t("Register") }}

+

{{ $t("Login") }}

+ +
+
+ +
@@ -131,9 +137,10 @@ import RouteName from "../../router/name"; import { IConfig } from "../../types/config.model"; import { CONFIG } from "../../graphql/config"; import Subtitle from "../../components/Utils/Subtitle.vue"; +import AuthProviders from "../../components/User/AuthProviders.vue"; @Component({ - components: { Subtitle }, + components: { Subtitle, AuthProviders }, metaInfo() { return { // if no subcomponents specify a metaInfo.title, this title will be used diff --git a/js/src/views/User/Validate.vue b/js/src/views/User/Validate.vue index c7a7ef183..f6df0fe0f 100644 --- a/js/src/views/User/Validate.vue +++ b/js/src/views/User/Validate.vue @@ -18,7 +18,7 @@ import { Component, Prop, Vue } from "vue-property-decorator"; import { VALIDATE_USER, UPDATE_CURRENT_USER_CLIENT } from "../../graphql/user"; import RouteName from "../../router/name"; -import { saveUserData, changeIdentity } from "../../utils/auth"; +import { saveUserData, saveTokenData, changeIdentity } from "../../utils/auth"; import { ILogin } from "../../types/login.model"; import { ICurrentUserRole } from "../../types/current-user.model"; @@ -45,6 +45,7 @@ export default class Validate extends Vue { if (data) { saveUserData(data.validateUser); + saveTokenData(data.validateUser); const { user } = data.validateUser; diff --git a/lib/graphql/resolvers/config.ex b/lib/graphql/resolvers/config.ex index 0e6aadf4f..21f536f7e 100644 --- a/lib/graphql/resolvers/config.ex +++ b/lib/graphql/resolvers/config.ex @@ -124,7 +124,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do }, rules: Config.instance_rules(), version: Config.instance_version(), - federating: Config.instance_federating() + federating: Config.instance_federating(), + auth: %{ + ldap: Config.ldap_enabled?(), + oauth_providers: Config.oauth_consumer_strategies() + } } end end diff --git a/lib/graphql/resolvers/person.ex b/lib/graphql/resolvers/person.ex index e6446f185..472d86669 100644 --- a/lib/graphql/resolvers/person.ex +++ b/lib/graphql/resolvers/person.ex @@ -202,10 +202,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do """ def register_person(_parent, args, _resolution) do with {:ok, %User{} = user} <- Users.get_user_by_email(args.email), - {:no_actor, nil} <- {:no_actor, Users.get_actor_for_user(user)}, + user_actor <- Users.get_actor_for_user(user), + no_actor <- is_nil(user_actor), + {:no_actor, true} <- {:no_actor, no_actor}, args <- Map.put(args, :user_id, user.id), args <- save_attached_pictures(args), - {:ok, %Actor{} = new_person} <- Actors.new_person(args) do + {:ok, %Actor{} = new_person} <- Actors.new_person(args, true) do {:ok, new_person} else {:error, :user_not_found} -> diff --git a/lib/graphql/resolvers/user.ex b/lib/graphql/resolvers/user.ex index 7c2cd5ec4..b3043b97c 100644 --- a/lib/graphql/resolvers/user.ex +++ b/lib/graphql/resolvers/user.ex @@ -9,6 +9,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do alias Mobilizon.Actors.Actor alias Mobilizon.Crypto alias Mobilizon.Federation.ActivityPub + alias Mobilizon.Service.Auth.Authenticator alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Users.{Setting, User} @@ -59,18 +60,16 @@ defmodule Mobilizon.GraphQL.Resolvers.User do Login an user. Returns a token and the user """ def login_user(_parent, %{email: email, password: password}, _resolution) do - with {:ok, %User{confirmed_at: %DateTime{}} = user} <- Users.get_user_by_email(email), - {:ok, %{access_token: access_token, refresh_token: refresh_token}} <- - Users.authenticate(%{user: user, password: password}) do - {:ok, %{access_token: access_token, refresh_token: refresh_token, user: user}} - else - {:ok, %User{confirmed_at: nil} = _user} -> - {:error, "User account not confirmed"} + case Authenticator.authenticate(email, password) do + {:ok, + %{access_token: _access_token, refresh_token: _refresh_token, user: _user} = + user_and_tokens} -> + {:ok, user_and_tokens} {:error, :user_not_found} -> {:error, "No user with this email was found"} - {:error, :unauthorized} -> + {:error, _error} -> {:error, "Impossible to authenticate, either your email or password are invalid."} end end @@ -82,7 +81,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do with {:ok, user, _claims} <- Auth.Guardian.resource_from_token(refresh_token), {:ok, _old, {exchanged_token, _claims}} <- Auth.Guardian.exchange(refresh_token, ["access", "refresh"], "access"), - {:ok, refresh_token} <- Users.generate_refresh_token(user) do + {:ok, refresh_token} <- Authenticator.generate_refresh_token(user) do {:ok, %{access_token: exchanged_token, refresh_token: refresh_token}} else {:error, message} -> @@ -151,7 +150,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do {:check_confirmation_token, Email.User.check_confirmation_token(token)}, {:get_actor, actor} <- {:get_actor, Users.get_actor_for_user(user)}, {:ok, %{access_token: access_token, refresh_token: refresh_token}} <- - Users.generate_tokens(user) do + Authenticator.generate_tokens(user) do {:ok, %{ access_token: access_token, @@ -192,10 +191,15 @@ defmodule Mobilizon.GraphQL.Resolvers.User do def send_reset_password(_parent, args, _resolution) do with email <- Map.get(args, :email), {:ok, %User{locale: locale} = user} <- Users.get_user_by_email(email, true), + {:can_reset_password, true} <- + {:can_reset_password, Authenticator.can_reset_password?(user)}, {:ok, %Bamboo.Email{} = _email_html} <- Email.User.send_password_reset_email(user, Map.get(args, :locale, locale)) do {:ok, email} else + {:can_reset_password, false} -> + {:error, "This user can't reset their password"} + {:error, :user_not_found} -> # TODO : implement rate limits for this endpoint {:error, "No user with this email was found"} @@ -209,10 +213,10 @@ defmodule Mobilizon.GraphQL.Resolvers.User do Reset the password from an user """ def reset_password(_parent, %{password: password, token: token}, _resolution) do - with {:ok, %User{} = user} <- + with {:ok, %User{email: email} = user} <- Email.User.check_reset_password_token(password, token), {:ok, %{access_token: access_token, refresh_token: refresh_token}} <- - Users.authenticate(%{user: user, password: password}) do + Authenticator.authenticate(email, password) do {:ok, %{access_token: access_token, refresh_token: refresh_token, user: user}} end end @@ -295,10 +299,12 @@ defmodule Mobilizon.GraphQL.Resolvers.User do def change_password( _parent, %{old_password: old_password, new_password: new_password}, - %{context: %{current_user: %User{password_hash: old_password_hash} = user}} + %{context: %{current_user: %User{} = user}} ) do - with {:current_password, true} <- - {:current_password, Argon2.verify_pass(old_password, old_password_hash)}, + with {:can_change_password, true} <- + {:can_change_password, Authenticator.can_change_password?(user)}, + {:current_password, {:ok, %User{}}} <- + {:current_password, Authenticator.login(user.email, old_password)}, {:same_password, false} <- {:same_password, old_password == new_password}, {:ok, %User{} = user} <- user @@ -306,7 +312,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do |> Repo.update() do {:ok, user} else - {:current_password, false} -> + {:current_password, _} -> {:error, "The current password is invalid"} {:same_password, true} -> @@ -323,10 +329,12 @@ defmodule Mobilizon.GraphQL.Resolvers.User do end def change_email(_parent, %{email: new_email, password: password}, %{ - context: %{current_user: %User{email: old_email, password_hash: password_hash} = user} + context: %{current_user: %User{email: old_email} = user} }) do - with {:current_password, true} <- - {:current_password, Argon2.verify_pass(password, password_hash)}, + with {:can_change_password, true} <- + {:can_change_password, Authenticator.can_change_email?(user)}, + {:current_password, {:ok, %User{}}} <- + {:current_password, Authenticator.login(user.email, password)}, {:same_email, false} <- {:same_email, new_email == old_email}, {:email_valid, true} <- {:email_valid, Email.Checker.valid?(new_email)}, {:ok, %User{} = user} <- @@ -347,7 +355,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do {:ok, user} else - {:current_password, false} -> + {:current_password, _} -> {:error, "The password provided is invalid"} {:same_email, true} -> @@ -377,14 +385,24 @@ defmodule Mobilizon.GraphQL.Resolvers.User do end end - def delete_account(_parent, %{password: password}, %{ - context: %{current_user: %User{password_hash: password_hash} = user} + def delete_account(_parent, args, %{ + context: %{current_user: %User{email: email} = user} }) do - case {:current_password, Argon2.verify_pass(password, password_hash)} do - {:current_password, true} -> + with {:user_has_password, true} <- {:user_has_password, Authenticator.has_password?(user)}, + {:confirmation_password, password} when not is_nil(password) <- + {:confirmation_password, Map.get(args, :password)}, + {:current_password, {:ok, _}} <- + {:current_password, Authenticator.authenticate(email, password)} do + do_delete_account(user) + else + # If the user hasn't got any password (3rd-party auth) + {:user_has_password, false} -> do_delete_account(user) - {:current_password, false} -> + {:confirmation_password, nil} -> + {:error, "The password provided is invalid"} + + {:current_password, _} -> {:error, "The password provided is invalid"} end end diff --git a/lib/graphql/schema/config.ex b/lib/graphql/schema/config.ex index b9cbf36da..e8070cbe6 100644 --- a/lib/graphql/schema/config.ex +++ b/lib/graphql/schema/config.ex @@ -39,6 +39,7 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do end field(:rules, :string, description: "The instance's rules") + field(:auth, :auth, description: "The instance auth methods") end object :terms do @@ -132,6 +133,16 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do field(:groups, :boolean) end + object :auth do + field(:ldap, :boolean, description: "Whether or not LDAP auth is enabled") + field(:oauth_providers, list_of(:oauth_provider), description: "List of oauth providers") + end + + object :oauth_provider do + field(:id, :string, description: "The provider ID") + field(:label, :string, description: "The label for the auth provider") + end + object :config_queries do @desc "Get the instance config" field :config, :config do diff --git a/lib/graphql/schema/user.ex b/lib/graphql/schema/user.ex index a41317b7d..511f94f45 100644 --- a/lib/graphql/schema/user.ex +++ b/lib/graphql/schema/user.ex @@ -52,6 +52,8 @@ defmodule Mobilizon.GraphQL.Schema.UserType do field(:locale, :string, description: "The user's locale") + field(:provider, :string, description: "The user's login provider") + field(:disabled, :boolean, description: "Whether the user is disabled") field(:participations, :paginated_participant_list, diff --git a/lib/mobilizon/actors/actors.ex b/lib/mobilizon/actors/actors.ex index 64dc74603..757b0eedc 100644 --- a/lib/mobilizon/actors/actors.ex +++ b/lib/mobilizon/actors/actors.ex @@ -13,6 +13,7 @@ defmodule Mobilizon.Actors do alias Mobilizon.Media.File alias Mobilizon.Service.Workers alias Mobilizon.Storage.{Page, Repo} + alias Mobilizon.Users alias Mobilizon.Federation.ActivityPub @@ -189,14 +190,19 @@ defmodule Mobilizon.Actors do Creates a new person actor. """ @spec new_person(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} - def new_person(args) do + def new_person(args, default_actor \\ false) do args = Map.put(args, :keys, Crypto.generate_rsa_2048_private_key()) - with {:ok, %Actor{} = person} <- + with {:ok, %Actor{id: person_id} = person} <- %Actor{} |> Actor.registration_changeset(args) |> Repo.insert() do - Events.create_feed_token(%{user_id: args["user_id"], actor_id: person.id}) + Events.create_feed_token(%{user_id: args.user_id, actor_id: person.id}) + + if default_actor do + user = Users.get_user!(args.user_id) + Users.update_user(user, %{default_actor_id: person_id}) + end {:ok, person} end diff --git a/lib/mobilizon/config.ex b/lib/mobilizon/config.ex index 2b8ab92ce..77e1ffadb 100644 --- a/lib/mobilizon/config.ex +++ b/lib/mobilizon/config.ex @@ -186,6 +186,24 @@ defmodule Mobilizon.Config do def anonymous_reporting?, do: Application.get_env(:mobilizon, :anonymous)[:reports][:allowed] + @spec oauth_consumer_strategies() :: list({atom(), String.t()}) + def oauth_consumer_strategies do + [:auth, :oauth_consumer_strategies] + |> get([]) + |> Enum.map(fn strategy -> + case strategy do + {id, label} when is_atom(id) -> %{id: id, label: label} + id when is_atom(id) -> %{id: id, label: nil} + end + end) + end + + @spec oauth_consumer_enabled? :: boolean() + def oauth_consumer_enabled?, do: oauth_consumer_strategies() != [] + + @spec ldap_enabled? :: boolean() + def ldap_enabled?, do: get([:ldap, :enabled], false) + def instance_resource_providers do types = get_in(Application.get_env(:mobilizon, Mobilizon.Service.ResourceProviders), [:types]) diff --git a/lib/mobilizon/users/user.ex b/lib/mobilizon/users/user.ex index 31a493a86..51b737626 100644 --- a/lib/mobilizon/users/user.ex +++ b/lib/mobilizon/users/user.ex @@ -40,14 +40,18 @@ defmodule Mobilizon.Users.User do :confirmation_token, :reset_password_sent_at, :reset_password_token, + :default_actor_id, :locale, :unconfirmed_email, - :disabled + :disabled, + :provider ] @attrs @required_attrs ++ @optional_attrs @registration_required_attrs @required_attrs ++ [:password] + @auth_provider_required_attrs @required_attrs ++ [:provider] + @password_change_required_attrs [:password] @password_reset_required_attrs @password_change_required_attrs ++ [:reset_password_token, :reset_password_sent_at] @@ -67,6 +71,7 @@ defmodule Mobilizon.Users.User do field(:unconfirmed_email, :string) field(:locale, :string, default: "en") field(:disabled, :boolean, default: false) + field(:provider, :string) belongs_to(:default_actor, Actor) has_many(:actors, Actor) @@ -116,6 +121,16 @@ defmodule Mobilizon.Users.User do ) end + @doc false + @spec auth_provider_changeset(t, map) :: Ecto.Changeset.t() + def auth_provider_changeset(%__MODULE__{} = user, attrs) do + user + |> changeset(attrs) + |> cast_assoc(:default_actor) + |> put_change(:confirmed_at, DateTime.utc_now() |> DateTime.truncate(:second)) + |> validate_required(@auth_provider_required_attrs) + end + @doc false @spec send_password_reset_changeset(t, map) :: Ecto.Changeset.t() def send_password_reset_changeset(%__MODULE__{} = user, attrs) do diff --git a/lib/mobilizon/users/users.ex b/lib/mobilizon/users/users.ex index 0ee54fd09..21bc20c9b 100644 --- a/lib/mobilizon/users/users.ex +++ b/lib/mobilizon/users/users.ex @@ -15,13 +15,6 @@ defmodule Mobilizon.Users do alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Users.{Setting, User} - alias Mobilizon.Web.Auth - - @type tokens :: %{ - required(:access_token) => String.t(), - required(:refresh_token) => String.t() - } - defenum(UserRole, :user_role, [:administrator, :moderator, :user]) defenum(NotificationPendingNotificationDelay, none: 0, direct: 1, one_hour: 5, one_day: 10) @@ -41,6 +34,18 @@ defmodule Mobilizon.Users do end end + @spec create_external(String.t(), String.t()) :: {:ok, User.t()} | {:error, Ecto.Changeset.t()} + def create_external(email, provider) do + with {:ok, %User{} = user} <- + %User{} + |> User.auth_provider_changeset(%{email: email, provider: provider}) + |> Repo.insert() do + Events.create_feed_token(%{user_id: user.id}) + + {:ok, user} + end + end + @doc """ Gets a single user. Raises `Ecto.NoResultsError` if the user does not exist. @@ -75,6 +80,16 @@ defmodule Mobilizon.Users do end end + @doc """ + Gets an user by its email. + """ + @spec get_user_by_email!(String.t(), boolean | nil) :: User.t() + def get_user_by_email!(email, activated \\ nil) do + email + |> user_by_email_query(activated) + |> Repo.one!() + end + @doc """ Get an user by its activation token. """ @@ -267,52 +282,6 @@ defmodule Mobilizon.Users do @spec count_users :: integer def count_users, do: Repo.one(from(u in User, select: count(u.id))) - @doc """ - Authenticate an user. - """ - @spec authenticate(User.t()) :: {:ok, tokens} | {:error, :unauthorized} - def authenticate(%{user: %User{password_hash: password_hash} = user, password: password}) do - # Does password match the one stored in the database? - if Argon2.verify_pass(password, password_hash) do - {:ok, _tokens} = generate_tokens(user) - else - {:error, :unauthorized} - end - end - - @doc """ - Generates access token and refresh token for an user. - """ - @spec generate_tokens(User.t()) :: {:ok, tokens} - def generate_tokens(user) do - with {:ok, access_token} <- generate_access_token(user), - {:ok, refresh_token} <- generate_refresh_token(user) do - {:ok, %{access_token: access_token, refresh_token: refresh_token}} - end - end - - @doc """ - Generates access token for an user. - """ - @spec generate_access_token(User.t()) :: {:ok, String.t()} - def generate_access_token(user) do - with {:ok, access_token, _claims} <- - Auth.Guardian.encode_and_sign(user, %{}, token_type: "access") do - {:ok, access_token} - end - end - - @doc """ - Generates refresh token for an user. - """ - @spec generate_refresh_token(User.t()) :: {:ok, String.t()} - def generate_refresh_token(user) do - with {:ok, refresh_token, _claims} <- - Auth.Guardian.encode_and_sign(user, %{}, token_type: "refresh") do - {:ok, refresh_token} - end - end - @doc """ Gets a settings for an user. diff --git a/lib/service/auth/authenticator.ex b/lib/service/auth/authenticator.ex new file mode 100644 index 000000000..15b8cec25 --- /dev/null +++ b/lib/service/auth/authenticator.ex @@ -0,0 +1,93 @@ +defmodule Mobilizon.Service.Auth.Authenticator do + @moduledoc """ + Module to handle authentification (currently through database or LDAP) + """ + alias Mobilizon.Users + alias Mobilizon.Users.User + alias Mobilizon.Web.Auth.Guardian + + @type tokens :: %{ + required(:access_token) => String.t(), + required(:refresh_token) => String.t() + } + + @type tokens_with_user :: %{ + required(:access_token) => String.t(), + required(:refresh_token) => String.t(), + required(:user) => User.t() + } + + def implementation do + Mobilizon.Config.get( + Mobilizon.Service.Auth.Authenticator, + Mobilizon.Service.Auth.MobilizonAuthenticator + ) + end + + @callback login(String.t(), String.t()) :: {:ok, User.t()} | {:error, any()} + @spec login(String.t(), String.t()) :: {:ok, User.t()} | {:error, any()} + def login(email, password), do: implementation().login(email, password) + + @callback can_change_email?(User.t()) :: boolean + def can_change_email?(%User{} = user), do: implementation().can_change_email?(user) + + @callback can_change_password?(User.t()) :: boolean + def can_change_password?(%User{} = user), do: implementation().can_change_password?(user) + + @spec has_password?(User.t()) :: boolean() + def has_password?(%User{provider: provider}), do: is_nil(provider) or provider == "ldap" + + @spec can_reset_password?(User.t()) :: boolean() + def can_reset_password?(%User{} = user), do: has_password?(user) && can_change_password?(user) + + @spec authenticate(String.t(), String.t()) :: {:ok, tokens_with_user()} + def authenticate(email, password) do + with {:ok, %User{} = user} <- login(email, password), + {:ok, %{access_token: access_token, refresh_token: refresh_token}} <- + generate_tokens(user) do + {:ok, %{access_token: access_token, refresh_token: refresh_token, user: user}} + end + end + + @doc """ + Generates access token and refresh token for an user. + """ + @spec generate_tokens(User.t()) :: {:ok, tokens} + def generate_tokens(user) do + with {:ok, access_token} <- generate_access_token(user), + {:ok, refresh_token} <- generate_refresh_token(user) do + {:ok, %{access_token: access_token, refresh_token: refresh_token}} + end + end + + @doc """ + Generates access token for an user. + """ + @spec generate_access_token(User.t()) :: {:ok, String.t()} + def generate_access_token(user) do + with {:ok, access_token, _claims} <- + Guardian.encode_and_sign(user, %{}, token_type: "access") do + {:ok, access_token} + end + end + + @doc """ + Generates refresh token for an user. + """ + @spec generate_refresh_token(User.t()) :: {:ok, String.t()} + def generate_refresh_token(user) do + with {:ok, refresh_token, _claims} <- + Guardian.encode_and_sign(user, %{}, token_type: "refresh") do + {:ok, refresh_token} + end + end + + @spec fetch_user(String.t()) :: User.t() | {:error, :user_not_found} + def fetch_user(nil), do: {:error, :user_not_found} + + def fetch_user(email) when not is_nil(email) do + with {:ok, %User{} = user} <- Users.get_user_by_email(email, true) do + user + end + end +end diff --git a/lib/service/auth/ldap_authenticator.ex b/lib/service/auth/ldap_authenticator.ex new file mode 100644 index 000000000..6db154376 --- /dev/null +++ b/lib/service/auth/ldap_authenticator.ex @@ -0,0 +1,180 @@ +# Portions of this file are derived from Pleroma: +# Pleroma: A lightweight social networking server +# Copyright © 2017-2020 Pleroma Authors +# SPDX-License-Identifier: AGPL-3.0-only + +defmodule Mobilizon.Service.Auth.LDAPAuthenticator do + @moduledoc """ + Authenticate Mobilizon users through LDAP accounts + """ + alias Mobilizon.Service.Auth.{Authenticator, MobilizonAuthenticator} + alias Mobilizon.Users + alias Mobilizon.Users.User + + require Logger + + import Authenticator, + only: [fetch_user: 1] + + @behaviour Authenticator + @base MobilizonAuthenticator + + @connection_timeout 10_000 + @search_timeout 10_000 + + def login(email, password) do + with {:ldap, true} <- {:ldap, Mobilizon.Config.get([:ldap, :enabled])}, + %User{} = user <- ldap_user(email, password) do + {:ok, user} + else + {:error, {:ldap_connection_error, _}} -> + # When LDAP is unavailable, try default authenticator + @base.login(email, password) + + {:ldap, _} -> + @base.login(email, password) + + error -> + error + end + end + + def can_change_email?(%User{provider: provider}), do: provider != "ldap" + + def can_change_password?(%User{provider: provider}), do: provider != "ldap" + + defp ldap_user(email, password) do + ldap = Mobilizon.Config.get(:ldap, []) + host = Keyword.get(ldap, :host, "localhost") + port = Keyword.get(ldap, :port, 389) + ssl = Keyword.get(ldap, :ssl, false) + sslopts = Keyword.get(ldap, :sslopts, []) + + options = + [{:port, port}, {:ssl, ssl}, {:timeout, @connection_timeout}] ++ + if sslopts != [], do: [{:sslopts, sslopts}], else: [] + + case :eldap.open([to_charlist(host)], options) do + {:ok, connection} -> + try do + ensure_eventual_tls(connection, ldap) + + base = Keyword.get(ldap, :base) + uid_field = Keyword.get(ldap, :uid, "cn") + + # We first need to find the LDAP UID/CN for this specif email + with uid when is_binary(uid) <- search_user(connection, ldap, base, uid_field, email), + # Then we can verify the user's password + :ok <- bind_user(connection, base, uid_field, uid, password) do + case fetch_user(email) do + %User{} = user -> + user + + _ -> + register_user(email) + end + else + {:error, error} -> + {:error, error} + + error -> + {:error, error} + end + after + :eldap.close(connection) + end + + {:error, error} -> + Logger.error("Could not open LDAP connection: #{inspect(error)}") + {:error, {:ldap_connection_error, error}} + end + end + + @spec bind_user(any(), String.t(), String.t(), String.t(), String.t()) :: + User.t() | any() + defp bind_user(connection, base, uid, field, password) do + bind = "#{uid}=#{field},#{base}" + Logger.debug("Binding to LDAP with \"#{bind}\"") + :eldap.simple_bind(connection, bind, password) + end + + @spec search_user(any(), Keyword.t(), String.t(), String.t(), String.t()) :: + String.t() | {:error, :ldap_registration_missing_attributes} | any() + defp search_user(connection, ldap, base, uid, email) do + # We may need to bind before performing the search + res = + if Keyword.get(ldap, :require_bind_for_search, true) do + admin_field = Keyword.get(ldap, :bind_uid) + admin_password = Keyword.get(ldap, :bind_password) + bind_user(connection, base, uid, admin_field, admin_password) + else + :ok + end + + if res == :ok do + do_search_user(connection, base, uid, email) + else + res + end + end + + # Search an user by uid to find their CN + @spec do_search_user(any(), String.t(), String.t(), String.t()) :: + String.t() | {:error, :ldap_registration_missing_attributes} | any() + defp do_search_user(connection, base, uid, email) do + with {:ok, {:eldap_search_result, [{:eldap_entry, _, attributes}], _}} <- + :eldap.search(connection, [ + {:base, to_charlist(base)}, + {:filter, :eldap.equalityMatch(to_charlist("mail"), to_charlist(email))}, + {:scope, :eldap.wholeSubtree()}, + {:attributes, [to_charlist(uid)]}, + {:timeout, @search_timeout} + ]), + {:uid, {_, [uid]}} <- {:uid, List.keyfind(attributes, to_charlist(uid), 0)} do + :erlang.list_to_binary(uid) + else + {:ok, {:eldap_search_result, [], []}} -> + Logger.info("Unable to find user with email #{email}") + {:error, :ldap_search_email_not_found} + + {:cn, err} -> + Logger.error("Could not find LDAP attribute CN: #{inspect(err)}") + {:error, :ldap_searcy_missing_attributes} + + error -> + error + end + end + + @spec register_user(String.t()) :: User.t() | any() + defp register_user(email) do + case Users.create_external(email, "ldap") do + {:ok, %User{} = user} -> + user + + error -> + error + end + end + + @spec ensure_eventual_tls(any(), Keyword.t()) :: :ok + defp ensure_eventual_tls(connection, ldap) do + if Keyword.get(ldap, :tls, false) do + :application.ensure_all_started(:ssl) + + case :eldap.start_tls( + connection, + Keyword.get(ldap, :tlsopts, []), + @connection_timeout + ) do + :ok -> + :ok + + error -> + Logger.error("Could not start TLS: #{inspect(error)}") + end + end + + :ok + end +end diff --git a/lib/service/auth/mobilizon_authenticator.ex b/lib/service/auth/mobilizon_authenticator.ex new file mode 100644 index 000000000..bec126901 --- /dev/null +++ b/lib/service/auth/mobilizon_authenticator.ex @@ -0,0 +1,39 @@ +defmodule Mobilizon.Service.Auth.MobilizonAuthenticator do + @moduledoc """ + Authenticate Mobilizon users through database accounts + """ + alias Mobilizon.Users.User + + alias Mobilizon.Service.Auth.Authenticator + + import Authenticator, + only: [fetch_user: 1] + + @behaviour Authenticator + + def login(email, password) do + require Logger + + with {:user, %User{password_hash: password_hash, provider: nil} = user} + when not is_nil(password_hash) <- + {:user, fetch_user(email)}, + {:acceptable_password, true} <- + {:acceptable_password, not (is_nil(password) || password == "")}, + {:checkpw, true} <- {:checkpw, Argon2.verify_pass(password, password_hash)} do + {:ok, user} + else + {:user, {:error, :user_not_found}} -> + {:error, :user_not_found} + + {:acceptable_password, false} -> + {:error, :bad_password} + + {:checkpw, false} -> + {:error, :bad_password} + end + end + + def can_change_email?(%User{provider: provider}), do: is_nil(provider) + + def can_change_password?(%User{provider: provider}), do: is_nil(provider) +end diff --git a/lib/web/controllers/auth_controller.ex b/lib/web/controllers/auth_controller.ex new file mode 100644 index 000000000..9e6c9d62c --- /dev/null +++ b/lib/web/controllers/auth_controller.ex @@ -0,0 +1,82 @@ +defmodule Mobilizon.Web.AuthController do + use Mobilizon.Web, :controller + + alias Mobilizon.Service.Auth.Authenticator + alias Mobilizon.Users + alias Mobilizon.Users.User + require Logger + plug(:put_layout, false) + + plug(Ueberauth) + + def request(conn, %{"provider" => provider} = _params) do + redirect(conn, to: "/login?code=Login Provider not found&provider=#{provider}") + end + + def callback( + %{assigns: %{ueberauth_failure: fails}} = conn, + %{"provider" => provider} = _params + ) do + Logger.warn("Unable to login user with #{provider} #{inspect(fails)}") + + redirect(conn, to: "/login?code=Error with Login Provider&provider=#{provider}") + end + + def callback( + %{assigns: %{ueberauth_auth: %Ueberauth.Auth{strategy: strategy} = auth}} = conn, + _params + ) do + email = email_from_ueberauth(auth) + [_, _, _, strategy] = strategy |> to_string() |> String.split(".") + strategy = String.downcase(strategy) + + user = + with {:valid_email, false} <- {:valid_email, is_nil(email) or email == ""}, + {:error, :user_not_found} <- Users.get_user_by_email(email), + {:ok, %User{} = user} <- Users.create_external(email, strategy) do + user + else + {:ok, %User{} = user} -> + user + + {:error, error} -> + {:error, error} + + error -> + {:error, error} + end + + with %User{} = user <- user, + {:ok, %{access_token: access_token, refresh_token: refresh_token}} <- + Authenticator.generate_tokens(user) do + Logger.info("Logged-in user \"#{email}\" through #{strategy}") + + render(conn, "callback.html", %{ + access_token: access_token, + refresh_token: refresh_token, + user: user + }) + else + err -> + Logger.warn("Unable to login user \"#{email}\" #{inspect(err)}") + redirect(conn, to: "/login?code=Error with Login Provider&provider=#{strategy}") + end + end + + # Github only give public emails as part of the user profile, + # so we explicitely request all user emails and filter on the primary one + defp email_from_ueberauth(%Ueberauth.Auth{ + strategy: Ueberauth.Strategy.Github, + extra: %Ueberauth.Auth.Extra{raw_info: %{user: %{"emails" => emails}}} + }) + when length(emails) > 0, + do: emails |> Enum.find(& &1["primary"]) |> (& &1["email"]).() + + defp email_from_ueberauth(%Ueberauth.Auth{ + extra: %Ueberauth.Auth.Extra{raw_info: %{user: %{"email" => email}}} + }) + when not is_nil(email) and email != "", + do: email + + defp email_from_ueberauth(_), do: nil +end diff --git a/lib/web/router.ex b/lib/web/router.ex index d7a565186..725afa2d8 100644 --- a/lib/web/router.ex +++ b/lib/web/router.ex @@ -150,6 +150,10 @@ defmodule Mobilizon.Web.Router do get("/groups/me", PageController, :index, as: "my_groups") get("/interact", PageController, :interact) + + get("/auth/:provider", AuthController, :request) + get("/auth/:provider/callback", AuthController, :callback) + post("/auth/:provider/callback", AuthController, :callback) end scope "/proxy/", Mobilizon.Web do diff --git a/lib/web/views/auth_view.ex b/lib/web/views/auth_view.ex new file mode 100644 index 000000000..1f94a3407 --- /dev/null +++ b/lib/web/views/auth_view.ex @@ -0,0 +1,29 @@ +defmodule Mobilizon.Web.AuthView do + @moduledoc """ + View for the auth routes + """ + + use Mobilizon.Web, :view + alias Mobilizon.Service.Metadata.Instance + alias Phoenix.HTML.Tag + import Mobilizon.Web.Views.Utils + + def render("callback.html", %{ + conn: conn, + access_token: access_token, + refresh_token: refresh_token, + user: %{id: user_id, email: user_email, role: user_role, default_actor_id: user_actor_id} + }) do + info_tags = [ + Tag.tag(:meta, name: "auth-access-token", content: access_token), + Tag.tag(:meta, name: "auth-refresh-token", content: refresh_token), + Tag.tag(:meta, name: "auth-user-id", content: user_id), + Tag.tag(:meta, name: "auth-user-email", content: user_email), + Tag.tag(:meta, name: "auth-user-role", content: user_role), + Tag.tag(:meta, name: "auth-user-actor-id", content: user_actor_id) + ] + + tags = Instance.build_tags() ++ info_tags + inject_tags(tags, get_locale(conn)) + end +end diff --git a/mix.exs b/mix.exs index 745298465..9b4cf27e2 100644 --- a/mix.exs +++ b/mix.exs @@ -46,6 +46,23 @@ defmodule Mobilizon.Mixfile do defp elixirc_paths(:dev), do: ["lib", "test/support/factory.ex"] defp elixirc_paths(_), do: ["lib"] + # Specifies OAuth dependencies. + defp oauth_deps do + oauth_strategy_packages = + System.get_env("OAUTH_CONSUMER_STRATEGIES") + |> to_string() + |> String.split() + |> Enum.map(fn strategy_entry -> + with [_strategy, dependency] <- String.split(strategy_entry, ":") do + dependency + else + [strategy] -> "ueberauth_#{strategy}" + end + end) + + for s <- oauth_strategy_packages, do: {String.to_atom(s), ">= 0.0.0"} + end + # Specifies your project dependencies. # # Type `mix help deps` for examples and options. @@ -104,6 +121,16 @@ defmodule Mobilizon.Mixfile do {:floki, "~> 0.26.0"}, {:ip_reserved, "~> 0.1.0"}, {:fast_sanitize, "~> 0.1"}, + {:ueberauth, "~> 0.6"}, + {:ueberauth_twitter, "~> 0.3"}, + {:ueberauth_github, "~> 0.7"}, + {:ueberauth_facebook, "~> 0.8"}, + {:ueberauth_discord, "~> 0.5"}, + {:ueberauth_google, "~> 0.9"}, + {:ueberauth_keycloak_strategy, + git: "https://github.com/tcitworld/ueberauth_keycloak.git", branch: "upgrade-deps"}, + {:ueberauth_gitlab_strategy, + git: "https://github.com/tcitworld/ueberauth_gitlab.git", branch: "upgrade-deps"}, # Dev and test dependencies {:phoenix_live_reload, "~> 1.2", only: [:dev, :e2e]}, {:ex_machina, "~> 2.3", only: [:dev, :test]}, @@ -116,7 +143,7 @@ defmodule Mobilizon.Mixfile do {:credo, "~> 1.4.0", only: [:dev, :test], runtime: false}, {:mock, "~> 0.3.4", only: :test}, {:elixir_feed_parser, "~> 2.1.0", only: :test} - ] + ] ++ oauth_deps() end # Aliases are shortcuts or tasks specific to the current project. diff --git a/mix.lock b/mix.lock index 13a34361a..4949dd0c5 100644 --- a/mix.lock +++ b/mix.lock @@ -31,12 +31,13 @@ "elixir_feed_parser": {:hex, :elixir_feed_parser, "2.1.0", "bb96fb6422158dc7ad59de62ef211cc69d264acbbe63941a64a5dce97bbbc2e6", [:mix], [{:timex, "~> 3.4", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "2d3c62fe7b396ee3b73d7160bc8fadbd78bfe9597c98c7d79b3f1038d9cba28f"}, "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "esaml": {:git, "git://github.com/wrren/esaml.git", "2cace5778e4323216bcff2085ca9739e42a68a42", [branch: "ueberauth_saml"]}, "eternal": {:hex, :eternal, "1.2.1", "d5b6b2499ba876c57be2581b5b999ee9bdf861c647401066d3eeed111d096bc4", [:mix], [], "hexpm", "b14f1dc204321429479c569cfbe8fb287541184ed040956c8862cb7a677b8406"}, "ex_cldr": {:hex, :ex_cldr, "2.16.1", "905b03c38b5fb51668a347f2e6b586bcb2c0816cd98f7d913104872c43cbc61f", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: true]}, {:certifi, "~> 2.5", [hex: :certifi, repo: "hexpm", optional: true]}, {:cldr_utils, "~> 2.9", [hex: :cldr_utils, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:gettext, "~> 0.13", [hex: :gettext, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "006e500769982e57e6f3e32cbc4664345f78b014bb5ff48ddc394d67c86c1a8d"}, "ex_cldr_calendars": {:hex, :ex_cldr_calendars, "1.9.0", "ace1c57ba3850753c9ac6ddb89dc0c9a9e5e1c57ecad587e21c8925ad30a3838", [:mix], [{:calendar_interval, "~> 0.2", [hex: :calendar_interval, repo: "hexpm", optional: true]}, {:earmark, "~> 1.0", [hex: :earmark, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.13", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:ex_cldr_units, "~> 3.0", [hex: :ex_cldr_units, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a4b07773e2a326474f44a6bc51fffbec634859a1bad5cc6e6eb55eba45115541"}, "ex_cldr_currencies": {:hex, :ex_cldr_currencies, "2.5.0", "e369ae3c1cd5cd20aa20988b153fd2902b4ab08aec63ca8757d7104bdb79f867", [:mix], [{:ex_cldr, "~> 2.14", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ba16b1df60bcec52c986481bbdfa7cfaec899b610f869d2b3c5a9a8149f67668"}, "ex_cldr_dates_times": {:hex, :ex_cldr_dates_times, "2.5.1", "9439d1c40cfd03c3d8f3f60f5d3e3f2c6eaf0fd714541d687531cce78cfb9909", [:mix], [{:calendar_interval, "~> 0.2", [hex: :calendar_interval, repo: "hexpm", optional: true]}, {:ex_cldr_calendars, "~> 1.8", [hex: :ex_cldr_calendars, repo: "hexpm", optional: false]}, {:ex_cldr_numbers, "~> 2.15", [hex: :ex_cldr_numbers, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "62a2f8d41ec6e789137bbf3ac7c944885a8ef6b7ce475905d056d1805b482427"}, - "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.15.0", "207843c6ddae802a2b5fd43eb95c4b65eae8a0a876ce23ae4413eb098b222977", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.15", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.5", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "3c6c220e03590f08e2f3cb4f3e0c2e1a78fe56a12229331edb952cbdc67935e1"}, + "ex_cldr_numbers": {:hex, :ex_cldr_numbers, "2.15.1", "dced7ffee69c4830593258b69b294adb4c65cf539e1d8ae0a4de31cfc8aa56a0", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ex_cldr, "~> 2.15", [hex: :ex_cldr, repo: "hexpm", optional: false]}, {:ex_cldr_currencies, "~> 2.5", [hex: :ex_cldr_currencies, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "c6a4b69ef80b8ffbb6c8fb69a2b365ba542580e0f76a15d8c6ee9142bd1b97ea"}, "ex_crypto": {:hex, :ex_crypto, "0.10.0", "af600a89b784b36613a989da6e998c1b200ff1214c3cfbaf8deca4aa2f0a1739", [:mix], [{:poison, ">= 2.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm", "ccc7472cfe8a0f4565f97dce7e9280119bf15a5ea51c6535e5b65f00660cde1c"}, "ex_doc": {:hex, :ex_doc, "0.22.1", "9bb6d51508778193a4ea90fa16eac47f8b67934f33f8271d5e1edec2dc0eee4c", [:mix], [{:earmark, "~> 1.4.0", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "d957de1b75cb9f78d3ee17820733dc4460114d8b1e11f7ee4fd6546e69b1db60"}, "ex_ical": {:hex, :ex_ical, "0.2.0", "4b928b554614704016cc0c9ee226eb854da9327a1cc460457621ceacb1ac29a6", [:mix], [{:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "db76473b2ae0259e6633c6c479a5a4d8603f09497f55c88f9ef4d53d2b75befb"}, @@ -91,7 +92,10 @@ "mock": {:hex, :mock, "0.3.5", "feb81f52b8dcf0a0d65001d2fec459f6b6a8c22562d94a965862f6cc066b5431", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "6fae404799408300f863550392635d8f7e3da6b71abdd5c393faf41b131c8728"}, "mogrify": {:hex, :mogrify, "0.7.4", "9b2496dde44b1ce12676f85d7dc531900939e6367bc537c7243a1b089435b32d", [:mix], [], "hexpm", "50d79e337fba6bc95bfbef918058c90f50b17eed9537771e61d4619488f099c3"}, "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, + "oauth2": {:hex, :oauth2, "2.0.0", "338382079fe16c514420fa218b0903f8ad2d4bfc0ad0c9f988867dfa246731b0", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "881b8364ac7385f9fddc7949379cbe3f7081da37233a1aa7aab844670a91e7e7"}, + "oauther": {:hex, :oauther, "1.1.1", "7d8b16167bb587ecbcddd3f8792beb9ec3e7b65c1f8ebd86b8dd25318d535752", [:mix], [], "hexpm", "9374f4302045321874cccdc57eb975893643bd69c3b22bf1312dab5f06e5788e"}, "oban": {:hex, :oban, "1.2.0", "7cca94d341be43d220571e28f69131c4afc21095b25257397f50973d3fc59b07", [:mix], [{:ecto_sql, "~> 3.1", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ba5f8b3f7d76967b3e23cf8014f6a13e4ccb33431e4808f036709a7f822362ee"}, + "paddle": {:hex, :paddle, "0.1.4", "3697996d79e3d771d6f7560a23e4bad1ed7b7f7fd3e784f97bc39565963b2b13", [:mix], [], "hexpm", "fc719a9e7c86f319b9f4bf413d6f0f326b0c4930d5bc6630d074598ed38e2143"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, "phoenix": {:hex, :phoenix, "1.5.3", "bfe0404e48ea03dfe17f141eff34e1e058a23f15f109885bbdcf62be303b49ff", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.2", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.1.2 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8e16febeb9640d8b33895a691a56481464b82836d338bb3a23125cd7b6157c25"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.1.0", "a044d0756d0464c5a541b4a0bf4bcaf89bffcaf92468862408290682c73ae50d", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "c5e666a341ff104d0399d8f0e4ff094559b2fde13a5985d4cb5023b2c2ac558b"}, @@ -110,10 +114,21 @@ "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, "slugger": {:hex, :slugger, "0.3.0", "efc667ab99eee19a48913ccf3d038b1fb9f165fa4fbf093be898b8099e61b6ed", [:mix], [], "hexpm", "20d0ded0e712605d1eae6c5b4889581c3460d92623a930ddda91e0e609b5afba"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "telemetry": {:hex, :telemetry, "0.4.1", "ae2718484892448a24470e6aa341bc847c3277bfb8d4e9289f7474d752c09c7f", [:rebar3], [], "hexpm", "4738382e36a0a9a2b6e25d67c960e40e1a2c95560b9f936d8e29de8cd858480f"}, + "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, "timex": {:hex, :timex, "3.6.2", "845cdeb6119e2fef10751c0b247b6c59d86d78554c83f78db612e3290f819bc2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "26030b46199d02a590be61c2394b37ea25a3664c02fafbeca0b24c972025d47a"}, "tzdata": {:hex, :tzdata, "1.0.3", "73470ad29dde46e350c60a66e6b360d3b99d2d18b74c4c349dbebbc27a09a3eb", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a6e1ee7003c4d04ecbd21dd3ec690d4c6662db5d3bbdd7262d53cdf5e7c746c1"}, + "ueberauth": {:hex, :ueberauth, "0.6.3", "d42ace28b870e8072cf30e32e385579c57b9cc96ec74fa1f30f30da9c14f3cc0", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "afc293d8a1140d6591b53e3eaf415ca92842cb1d32fad3c450c6f045f7f91b60"}, + "ueberauth_discord": {:hex, :ueberauth_discord, "0.5.0", "52421277b93fda769b51636e542b5085f3861efdc7fa48ac4bedb6dae0b645e1", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.6.3", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "9a3808baf44297e26bd5042ba9ea5398aa60023e054eb9a5ac8a4eacd0467a78"}, + "ueberauth_facebook": {:hex, :ueberauth_facebook, "0.8.1", "c254be4ab367c276773c2e41d3c0fe343ae118e244afc8d5a4e3e5c438951fdc", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.6.0", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "c2cf210ef45bd20611234ef17517f9d1dff6b31d3fb6ad96789143eb0943f540"}, + "ueberauth_github": {:hex, :ueberauth_github, "0.8.0", "2216c8cdacee0de6245b422fb397921b64a29416526985304e345dab6a799d17", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.6.0", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "b65ccc001a7b0719ba069452f3333d68891f4613ae787a340cce31e2a43307a3"}, + "ueberauth_gitlab_strategy": {:git, "https://github.com/tcitworld/ueberauth_gitlab.git", "9fc5d30b5d87ff7cdef293a1c128f25777dcbe59", [branch: "upgrade-deps"]}, + "ueberauth_google": {:hex, :ueberauth_google, "0.9.0", "e098e1d6df647696b858b0289eae7e4dc8c662abee9e309d64bc115192c51bf5", [:mix], [{:oauth2, "~> 1.0 or ~> 2.0", [hex: :oauth2, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.6.0", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "5453ba074df7ee14fb5b121bb04a64cda5266cd23b28af8a2fdf02dd40959ab4"}, + "ueberauth_keycloak": {:git, "https://github.com/tcitworld/ueberauth_keycloak.git", "02447d8a75bd36ba26c17c7b1b8bab3538bb2e7a", [branch: "upgrade-deps"]}, + "ueberauth_keycloak_strategy": {:git, "https://github.com/tcitworld/ueberauth_keycloak.git", "d892f0f9daf9e0023319b69ac2f7c2c6edff2b14", [branch: "upgrade-deps"]}, + "ueberauth_saml": {:git, "https://github.com/wrren/ueberauth_saml.git", "dfcb4ae3f509afec0f442ce455c41feacac24511", []}, + "ueberauth_twitter": {:hex, :ueberauth_twitter, "0.4.0", "4b98620341bc91bac90459093bba093c650823b6e2df35b70255c493c17e9227", [:mix], [{:httpoison, "~> 1.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:oauther, "~> 1.1", [hex: :oauther, repo: "hexpm", optional: false]}, {:ueberauth, "~> 0.6", [hex: :ueberauth, repo: "hexpm", optional: false]}], "hexpm", "fb29c9047ca263038c0c61f5a0ec8597e8564aba3f2b4cb02704b60205fd4468"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, "unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, + "uuid": {:git, "git://github.com/botsunit/erlang-uuid", "1effbbbd200f9f5d9d5154e81b83fe8e4c3fe714", [branch: "master"]}, "xml_builder": {:hex, :xml_builder, "2.0.0", "371ed27bb63bf0598dbaf3f0c466e5dc7d16cb4ecb68f06a67f953654062e21b", [:mix], [], "hexpm", "baeb5c8d42204bac2b856ffd50e8cda42d63b622984538d18d92733e4e790fbd"}, } diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 6f944ba9d..dbbd40d98 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -1177,12 +1177,12 @@ msgstr "If you didn't request this, please ignore this email." #, elixir-format, fuzzy #: lib/web/templates/email/email.text.eex:10 msgid "In the meantime, please consider this the software as not (yet) finished. Read more on the Framasoft blog:" -msgstr "In the meantime, please consider that the software is not (yet) finished. More information %{a_start}on our blog%{a_end}." +msgstr "In the meantime, please consider that the software is not (yet) finished. More information on our blog." #, elixir-format, fuzzy #: lib/web/templates/email/email.text.eex:9 msgid "Mobilizon is still under development, we will add new features along the updates, until the release of version 1 of the software in the fall of 2020." -msgstr "Mobilizon is under development, we will add new features to this site during regular updates, until the release of %{b_start}version 1 of the software in the first half of 2020%{b_end}." +msgstr "Mobilizon is under development, we will add new features to this site during regular updates, until the release of version 1 of the software in the first half of 2020." #, elixir-format, fuzzy #: lib/web/templates/email/email.text.eex:7 diff --git a/priv/repo/migrations/20200630123819_add_provider_to_user_and_make_password_mandatory.exs b/priv/repo/migrations/20200630123819_add_provider_to_user_and_make_password_mandatory.exs new file mode 100644 index 000000000..3eb536019 --- /dev/null +++ b/priv/repo/migrations/20200630123819_add_provider_to_user_and_make_password_mandatory.exs @@ -0,0 +1,17 @@ +defmodule Mobilizon.Storage.Repo.Migrations.AddProviderToUserAndMakePasswordMandatory do + use Ecto.Migration + + def up do + alter table(:users) do + add(:provider, :string, null: true) + modify(:password_hash, :string, null: true) + end + end + + def down do + alter table(:users) do + remove(:provider) + modify(:password_hash, :string, null: false) + end + end +end diff --git a/test/graphql/resolvers/participant_test.exs b/test/graphql/resolvers/participant_test.exs index 5d12cab22..3290c9f1d 100644 --- a/test/graphql/resolvers/participant_test.exs +++ b/test/graphql/resolvers/participant_test.exs @@ -991,7 +991,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ParticipantTest do } """ - clear_config([:anonymous, :participation]) + setup do: clear_config([:anonymous, :participation]) setup %{conn: conn, actor: actor, user: user} do Mobilizon.Config.clear_config_cache() diff --git a/test/graphql/resolvers/report_test.exs b/test/graphql/resolvers/report_test.exs index 7f7e209ec..7063f3197 100644 --- a/test/graphql/resolvers/report_test.exs +++ b/test/graphql/resolvers/report_test.exs @@ -33,7 +33,7 @@ defmodule Mobilizon.GraphQL.Resolvers.ReportTest do } """ - clear_config([:anonymous, :reports]) + setup do: clear_config([:anonymous, :reports]) setup %{conn: conn} do Mobilizon.Config.clear_config_cache() diff --git a/test/graphql/resolvers/user_test.exs b/test/graphql/resolvers/user_test.exs index 9e8fc06cc..477e9fed3 100644 --- a/test/graphql/resolvers/user_test.exs +++ b/test/graphql/resolvers/user_test.exs @@ -9,6 +9,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do alias Mobilizon.Actors.Actor alias Mobilizon.Conversations.Comment alias Mobilizon.Events.{Event, Participant} + alias Mobilizon.Service.Auth.Authenticator alias Mobilizon.Users.User alias Mobilizon.GraphQL.AbsintheHelpers @@ -45,8 +46,14 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do } """ + @send_reset_password_mutation """ + mutation SendResetPassword($email: String!) { + sendResetPassword(email: $email) + } + """ + @delete_user_account_mutation """ - mutation DeleteAccount($password: String!) { + mutation DeleteAccount($password: String) { deleteAccount (password: $password) { id } @@ -712,45 +719,50 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do end describe "Resolver: Send reset password" do - test "test send_reset_password/3 with valid email", context do - user = insert(:user) - - mutation = """ - mutation { - sendResetPassword( - email: "#{user.email}" - ) - } - """ + test "test send_reset_password/3 with valid email", %{conn: conn} do + %User{email: email} = insert(:user) res = - context.conn - |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + conn + |> AbsintheHelpers.graphql_query( + query: @send_reset_password_mutation, + variables: %{email: email} + ) - assert json_response(res, 200)["data"]["sendResetPassword"] == user.email + assert res["data"]["sendResetPassword"] == email end - test "test send_reset_password/3 with invalid email", context do - mutation = """ - mutation { - sendResetPassword( - email: "oh no" - ) - } - """ + test "test send_reset_password/3 with invalid email", %{conn: conn} do + res = + conn + |> AbsintheHelpers.graphql_query( + query: @send_reset_password_mutation, + variables: %{email: "not an email"} + ) + + assert hd(res["errors"])["message"] == + "No user with this email was found" + end + + test "test send_reset_password/3 for an LDAP user", %{conn: conn} do + {:ok, %User{email: email}} = Users.create_external("some@users.com", "ldap") res = - context.conn - |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + conn + |> AbsintheHelpers.graphql_query( + query: @send_reset_password_mutation, + variables: %{email: email} + ) - assert hd(json_response(res, 200)["errors"])["message"] == - "No user with this email was found" + assert hd(res["errors"])["message"] == + "This user can't reset their password" end end describe "Resolver: Reset user's password" do test "test reset_password/3 with valid email", context do {:ok, %User{} = user} = Users.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) + Users.update_user(user, %{confirmed_at: DateTime.utc_now()}) %Actor{} = insert(:actor, user: user) {:ok, _email_sent} = Email.User.send_password_reset_email(user) %User{reset_password_token: reset_password_token} = Users.get_user!(user.id) @@ -772,6 +784,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do context.conn |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + assert is_nil(json_response(res, 200)["errors"]) assert json_response(res, 200)["data"]["resetPassword"]["user"]["id"] == to_string(user.id) end @@ -829,7 +842,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do end describe "Resolver: Login a user" do - test "test login_user/3 with valid credentials", context do + test "test login_user/3 with valid credentials", %{conn: conn} do {:ok, %User{} = user} = Users.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) {:ok, %User{} = _user} = @@ -839,30 +852,18 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do "confirmation_token" => nil }) - mutation = """ - mutation { - login( - email: "#{user.email}", - password: "#{user.password}", - ) { - accessToken, - refreshToken, - user { - id - } - } - } - """ - res = - context.conn - |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + conn + |> AbsintheHelpers.graphql_query( + query: @login_mutation, + variables: %{email: user.email, password: user.password} + ) - assert login = json_response(res, 200)["data"]["login"] + assert login = res["data"]["login"] assert Map.has_key?(login, "accessToken") && not is_nil(login["accessToken"]) end - test "test login_user/3 with invalid password", context do + test "test login_user/3 with invalid password", %{conn: conn} do {:ok, %User{} = user} = Users.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) {:ok, %User{} = _user} = @@ -872,79 +873,40 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do "confirmation_token" => nil }) - mutation = """ - mutation { - login( - email: "#{user.email}", - password: "bad password", - ) { - accessToken, - user { - default_actor { - preferred_username, - } - } - } - } - """ - res = - context.conn - |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + conn + |> AbsintheHelpers.graphql_query( + query: @login_mutation, + variables: %{email: user.email, password: "bad password"} + ) - assert hd(json_response(res, 200)["errors"])["message"] == + assert hd(res["errors"])["message"] == "Impossible to authenticate, either your email or password are invalid." end - test "test login_user/3 with invalid email", context do - mutation = """ - mutation { - login( - email: "bad email", - password: "bad password", - ) { - accessToken, - user { - default_actor { - preferred_username, - } - } - } - } - """ - + test "test login_user/3 with invalid email", %{conn: conn} do res = - context.conn - |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + conn + |> AbsintheHelpers.graphql_query( + query: @login_mutation, + variables: %{email: "bad email", password: "bad password"} + ) - assert hd(json_response(res, 200)["errors"])["message"] == + assert hd(res["errors"])["message"] == "No user with this email was found" end - test "test login_user/3 with unconfirmed user", context do + test "test login_user/3 with unconfirmed user", %{conn: conn} do {:ok, %User{} = user} = Users.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) - mutation = """ - mutation { - login( - email: "#{user.email}", - password: "#{user.password}", - ) { - accessToken, - user { - default_actor { - preferred_username, - } - } - } - } - """ - res = - context.conn - |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) + conn + |> AbsintheHelpers.graphql_query( + query: @login_mutation, + variables: %{email: user.email, password: user.password} + ) - assert hd(json_response(res, 200)["errors"])["message"] == "User account not confirmed" + assert hd(res["errors"])["message"] == "No user with this email was found" end end @@ -970,7 +932,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do test "test refresh_token/3 with an appropriate token", context do user = insert(:user) - {:ok, refresh_token} = Users.generate_refresh_token(user) + {:ok, refresh_token} = Authenticator.generate_refresh_token(user) mutation = """ mutation { @@ -1441,6 +1403,18 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do assert is_nil(Events.get_participant(participant_id)) end + test "delete_account/3 with 3rd-party auth login", %{conn: conn} do + {:ok, %User{} = user} = Users.create_external(@email, "keycloak") + + res = + conn + |> auth_conn(user) + |> AbsintheHelpers.graphql_query(query: @delete_user_account_mutation) + + assert is_nil(res["errors"]) + assert res["data"]["deleteAccount"]["id"] == to_string(user.id) + end + test "delete_account/3 with invalid password", %{conn: conn} do {:ok, %User{} = user} = Users.register(%{email: @email, password: @password}) diff --git a/test/mobilizon/users/users_test.exs b/test/mobilizon/users/users_test.exs index 01b1520e2..3f06ae066 100644 --- a/test/mobilizon/users/users_test.exs +++ b/test/mobilizon/users/users_test.exs @@ -72,14 +72,6 @@ defmodule Mobilizon.UsersTest do @email "email@domain.tld" @password "password" - test "authenticate/1 checks the user's password" do - {:ok, %User{} = user} = Users.register(%{email: @email, password: @password}) - - assert {:ok, _} = Users.authenticate(%{user: user, password: @password}) - - assert {:error, :unauthorized} == - Users.authenticate(%{user: user, password: "bad password"}) - end test "get_user_by_email/1 finds an user by its email" do {:ok, %User{email: email} = user} = Users.register(%{email: @email, password: @password}) diff --git a/test/service/auth/authentificator_test.exs b/test/service/auth/authentificator_test.exs new file mode 100644 index 000000000..e0d768c99 --- /dev/null +++ b/test/service/auth/authentificator_test.exs @@ -0,0 +1,34 @@ +defmodule Mobilizon.Service.Auth.AuthenticatorTest do + use Mobilizon.DataCase + + alias Mobilizon.Service.Auth.Authenticator + alias Mobilizon.Users + alias Mobilizon.Users.User + import Mobilizon.Factory + + @email "email@domain.tld" + @password "password" + + describe "test authentification" do + test "authenticate/1 checks the user's password" do + {:ok, %User{} = user} = Users.register(%{email: @email, password: @password}) + Users.update_user(user, %{confirmed_at: DateTime.utc_now()}) + + assert {:ok, _} = Authenticator.authenticate(@email, @password) + + assert {:error, :bad_password} == + Authenticator.authenticate(@email, "completely wrong password") + end + end + + describe "fetch_user/1" do + test "returns user by email" do + user = insert(:user) + assert Authenticator.fetch_user(user.email).id == user.id + end + + test "returns nil" do + assert Authenticator.fetch_user("email") == {:error, :user_not_found} + end + end +end diff --git a/test/service/auth/ldap_authentificator_test.exs b/test/service/auth/ldap_authentificator_test.exs new file mode 100644 index 000000000..6168f8594 --- /dev/null +++ b/test/service/auth/ldap_authentificator_test.exs @@ -0,0 +1,238 @@ +defmodule Mobilizon.Service.Auth.LDAPAuthenticatorTest do + use Mobilizon.Web.ConnCase + use Mobilizon.Tests.Helpers + + alias Mobilizon.GraphQL.AbsintheHelpers + alias Mobilizon.Service.Auth.{Authenticator, LDAPAuthenticator} + alias Mobilizon.Users.User + alias Mobilizon.Web.Auth.Guardian + + import Mobilizon.Factory + import ExUnit.CaptureLog + import Mock + + @skip if !Code.ensure_loaded?(:eldap), do: :skip + @admin_password "admin_password" + + setup_all do + clear_config([:ldap, :enabled], true) + clear_config([:ldap, :bind_uid], "admin") + clear_config([:ldap, :bind_password], @admin_password) + end + + setup_all do: + clear_config( + Authenticator, + LDAPAuthenticator + ) + + @login_mutation """ + mutation Login($email: String!, $password: String!) { + login(email: $email, password: $password) { + accessToken, + refreshToken, + user { + id + } + } + } + """ + + describe "login" do + @tag @skip + test "authorizes the existing user using LDAP credentials", %{conn: conn} do + user_password = "testpassword" + admin_password = "admin_password" + user = insert(:user, password_hash: Argon2.hash_pwd_salt(user_password)) + + host = [:ldap, :host] |> Mobilizon.Config.get() |> to_charlist + port = Mobilizon.Config.get([:ldap, :port]) + + with_mocks [ + {:eldap, [], + [ + open: fn [^host], [{:port, ^port}, {:ssl, false} | _] -> {:ok, self()} end, + simple_bind: fn _connection, _dn, password -> + case password do + ^admin_password -> :ok + ^user_password -> :ok + end + end, + equalityMatch: fn _type, _value -> :ok end, + wholeSubtree: fn -> :ok end, + search: fn _connection, _options -> + {:ok, + {:eldap_search_result, [{:eldap_entry, '', [{'cn', [to_charlist("MyUser")]}]}], []}} + end, + close: fn _connection -> + send(self(), :close_connection) + :ok + end + ]} + ] do + res = + conn + |> AbsintheHelpers.graphql_query( + query: @login_mutation, + variables: %{email: user.email, password: user_password} + ) + + assert is_nil(res["error"]) + assert token = res["data"]["login"]["accessToken"] + + {:ok, %User{} = user_from_token, _claims} = Guardian.resource_from_token(token) + + assert user_from_token.id == user.id + assert_received :close_connection + end + end + + @tag @skip + test "creates a new user after successful LDAP authorization", %{conn: conn} do + user_password = "testpassword" + admin_password = "admin_password" + user = build(:user) + + host = [:ldap, :host] |> Mobilizon.Config.get() |> to_charlist + port = Mobilizon.Config.get([:ldap, :port]) + + with_mocks [ + {:eldap, [], + [ + open: fn [^host], [{:port, ^port}, {:ssl, false} | _] -> {:ok, self()} end, + simple_bind: fn _connection, _dn, password -> + case password do + ^admin_password -> :ok + ^user_password -> :ok + end + end, + equalityMatch: fn _type, _value -> :ok end, + wholeSubtree: fn -> :ok end, + search: fn _connection, _options -> + {:ok, + {:eldap_search_result, [{:eldap_entry, '', [{'cn', [to_charlist("MyUser")]}]}], []}} + end, + close: fn _connection -> + send(self(), :close_connection) + :ok + end + ]} + ] do + res = + conn + |> AbsintheHelpers.graphql_query( + query: @login_mutation, + variables: %{email: user.email, password: user_password} + ) + + assert is_nil(res["error"]) + assert token = res["data"]["login"]["accessToken"] + + {:ok, %User{} = user_from_token, _claims} = Guardian.resource_from_token(token) + + assert user_from_token.email == user.email + assert_received :close_connection + end + end + + @tag @skip + test "falls back to the default authorization when LDAP is unavailable", %{conn: conn} do + user_password = "testpassword" + admin_password = "admin_password" + user = insert(:user, password_hash: Argon2.hash_pwd_salt(user_password)) + + host = [:ldap, :host] |> Mobilizon.Config.get() |> to_charlist + port = Mobilizon.Config.get([:ldap, :port]) + + with_mocks [ + {:eldap, [], + [ + open: fn [^host], [{:port, ^port}, {:ssl, false} | _] -> {:error, 'connect failed'} end, + simple_bind: fn _connection, _dn, password -> + case password do + ^admin_password -> :ok + ^user_password -> :ok + end + end, + equalityMatch: fn _type, _value -> :ok end, + wholeSubtree: fn -> :ok end, + search: fn _connection, _options -> + {:ok, + {:eldap_search_result, [{:eldap_entry, '', [{'cn', [to_charlist("MyUser")]}]}], []}} + end, + close: fn _connection -> + send(self(), :close_connection) + :ok + end + ]} + ] do + log = + capture_log(fn -> + res = + conn + |> AbsintheHelpers.graphql_query( + query: @login_mutation, + variables: %{email: user.email, password: user_password} + ) + + assert is_nil(res["error"]) + assert token = res["data"]["login"]["accessToken"] + + {:ok, %User{} = user_from_token, _claims} = Guardian.resource_from_token(token) + + assert user_from_token.email == user.email + end) + + assert log =~ "Could not open LDAP connection: 'connect failed'" + refute_received :close_connection + end + end + + @tag @skip + test "disallow authorization for wrong LDAP credentials", %{conn: conn} do + user_password = "testpassword" + user = insert(:user, password_hash: Argon2.hash_pwd_salt(user_password)) + + host = [:ldap, :host] |> Mobilizon.Config.get() |> to_charlist + port = Mobilizon.Config.get([:ldap, :port]) + + with_mocks [ + {:eldap, [], + [ + open: fn [^host], [{:port, ^port}, {:ssl, false} | _] -> {:ok, self()} end, + simple_bind: fn _connection, _dn, _password -> {:error, :invalidCredentials} end, + close: fn _connection -> + send(self(), :close_connection) + :ok + end + ]} + ] do + res = + conn + |> AbsintheHelpers.graphql_query( + query: @login_mutation, + variables: %{email: user.email, password: user_password} + ) + + refute is_nil(res["errors"]) + + assert assert hd(res["errors"])["message"] == + "Impossible to authenticate, either your email or password are invalid." + + assert_received :close_connection + end + end + end + + describe "can change" do + test "password" do + assert LDAPAuthenticator.can_change_password?(%User{provider: "ldap"}) == false + assert LDAPAuthenticator.can_change_password?(%User{provider: nil}) == true + end + + test "email" do + assert LDAPAuthenticator.can_change_password?(%User{provider: "ldap"}) == false + assert LDAPAuthenticator.can_change_password?(%User{provider: nil}) == true + end + end +end diff --git a/test/service/auth/mobilizon_authentificator_test.exs b/test/service/auth/mobilizon_authentificator_test.exs new file mode 100644 index 000000000..4019f17b3 --- /dev/null +++ b/test/service/auth/mobilizon_authentificator_test.exs @@ -0,0 +1,29 @@ +defmodule Mobilizon.Service.Auth.MobilizonAuthenticatorTest do + use Mobilizon.DataCase + + alias Mobilizon.Service.Auth.MobilizonAuthenticator + alias Mobilizon.Users.User + import Mobilizon.Factory + + setup do + password = "testpassword" + email = "someone@somewhere.tld" + user = insert(:user, email: email, password_hash: Argon2.hash_pwd_salt(password)) + {:ok, [user: user, email: email, password: password]} + end + + test "login", %{email: email, password: password, user: user} do + assert {:ok, %User{} = returned_user} = MobilizonAuthenticator.login(email, password) + assert returned_user.id == user.id + end + + test "login with invalid password", %{email: email} do + assert {:error, :bad_password} == MobilizonAuthenticator.login(email, "invalid") + assert {:error, :bad_password} == MobilizonAuthenticator.login(email, nil) + end + + test "login with no credentials" do + assert {:error, :user_not_found} == MobilizonAuthenticator.login("some@email.com", nil) + assert {:error, :user_not_found} == MobilizonAuthenticator.login(nil, nil) + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex index 184b78813..220e7755d 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -18,7 +18,8 @@ defmodule Mobilizon.Factory do role: :user, confirmed_at: DateTime.utc_now() |> DateTime.truncate(:second), confirmation_sent_at: nil, - confirmation_token: nil + confirmation_token: nil, + provider: nil } end diff --git a/test/support/helpers.ex b/test/support/helpers.ex index a58f382d8..ffa59e4a4 100644 --- a/test/support/helpers.ex +++ b/test/support/helpers.ex @@ -7,6 +7,7 @@ defmodule Mobilizon.Tests.Helpers do @moduledoc """ Helpers for use in tests. """ + alias Mobilizon.Config defmacro clear_config(config_path) do quote do @@ -17,11 +18,17 @@ defmodule Mobilizon.Tests.Helpers do defmacro clear_config(config_path, do: yield) do quote do - setup do - initial_setting = Mobilizon.Config.get(unquote(config_path)) - unquote(yield) - on_exit(fn -> Mobilizon.Config.put(unquote(config_path), initial_setting) end) - :ok + initial_setting = Config.get(unquote(config_path)) + unquote(yield) + on_exit(fn -> Config.put(unquote(config_path), initial_setting) end) + :ok + end + end + + defmacro clear_config(config_path, temp_setting) do + quote do + clear_config(unquote(config_path)) do + Config.put(unquote(config_path), unquote(temp_setting)) end end end diff --git a/test/web/controllers/auth_controller_test.exs b/test/web/controllers/auth_controller_test.exs new file mode 100644 index 000000000..ca2bad443 --- /dev/null +++ b/test/web/controllers/auth_controller_test.exs @@ -0,0 +1,54 @@ +defmodule Mobilizon.Web.AuthControllerTest do + use Mobilizon.Web.ConnCase + alias Mobilizon.Service.Auth.Authenticator + alias Mobilizon.Users.User + + @email "someone@somewhere.tld" + + test "login and registration", + %{conn: conn} do + conn = + conn + |> assign(:ueberauth_auth, %Ueberauth.Auth{ + strategy: Ueberauth.Strategy.Twitter, + extra: %Ueberauth.Auth.Extra{raw_info: %{user: %{"email" => @email}}} + }) + |> get("/auth/twitter/callback") + + assert html_response(conn, 200) =~ "auth-access-token" + + assert %User{confirmed_at: confirmed_at, email: @email} = Authenticator.fetch_user(@email) + + refute is_nil(confirmed_at) + end + + test "on bad provider error", %{ + conn: conn + } do + conn = + conn + |> assign(:ueberauth_failure, %{errors: [%{message: "Some error"}]}) + |> get("/auth/nothing") + + assert "/login?code=Login Provider not found&provider=nothing" = + redirection = redirected_to(conn, 302) + + conn = get(recycle(conn), redirection) + assert html_response(conn, 200) + end + + test "on authentication error", %{ + conn: conn + } do + conn = + conn + |> assign(:ueberauth_failure, %{errors: [%{message: "Some error"}]}) + |> get("/auth/twitter/callback") + + assert "/login?code=Error with Login Provider&provider=twitter" = + redirection = redirected_to(conn, 302) + + conn = get(recycle(conn), redirection) + assert html_response(conn, 200) + end +end