Introduce support for 3rd-party auth (OAuth2 & LDAP)

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2020-06-27 19:12:45 +02:00
parent 0f8c154d4b
commit 7302ecbc23
48 changed files with 1380 additions and 240 deletions

View File

@ -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: [
%{

View File

@ -0,0 +1,26 @@
<template>
<a
class="button is-light"
v-if="Object.keys(SELECTED_PROVIDERS).includes(oauthProvider.id)"
:href="`/auth/${oauthProvider.id}`"
>
<b-icon :icon="oauthProvider.id" />
<span>{{ SELECTED_PROVIDERS[oauthProvider.id] }}</span></a
>
<a class="button is-light" :href="`/auth/${oauthProvider.id}`" v-else>
<b-icon icon="lock" />
<span>{{ oauthProvider.label }}</span>
</a>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { IOAuthProvider } from "../../types/config.model";
import { SELECTED_PROVIDERS } from "../../utils/auth";
@Component
export default class AuthProvider extends Vue {
@Prop({ required: true, type: Object }) oauthProvider!: IOAuthProvider;
SELECTED_PROVIDERS = SELECTED_PROVIDERS;
}
</script>

View File

@ -0,0 +1,26 @@
<template>
<div>
<b>{{ $t("Sign in with") }}</b>
<div class="buttons">
<auth-provider
v-for="provider in oauthProviders"
:oauthProvider="provider"
:key="provider.id"
/>
</div>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { IOAuthProvider } from "../../types/config.model";
import AuthProvider from "./AuthProvider.vue";
@Component({
components: {
AuthProvider,
},
})
export default class AuthProviders extends Vue {
@Prop({ required: true, type: Array }) oauthProviders!: IOAuthProvider[];
}
</script>

View File

@ -62,6 +62,13 @@ export const CONFIG = gql`
features {
groups
}
auth {
ldap
oauthProviders {
id
label
}
}
}
}
`;

View File

@ -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
}

View File

@ -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."
}

View File

@ -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."
}

View File

@ -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,

View File

@ -74,4 +74,13 @@ export interface IConfig {
};
federating: boolean;
version: string;
auth: {
ldap: boolean;
oauthProviders: IOAuthProvider[];
};
}
export interface IOAuthProvider {
id: string;
label: string;
}

View File

@ -9,15 +9,11 @@ export enum ICurrentUserRole {
}
export interface ICurrentUser {
id: number;
id: string;
email: string;
isLoggedIn: boolean;
role: ICurrentUserRole;
participations: Paginate<IParticipant>;
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<IParticipant>;
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 {

View File

@ -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",
}

View File

@ -94,3 +94,14 @@ export async function logout(apollo: ApolloClient<NormalizedCacheObject>) {
deleteUserData();
}
export const SELECTED_PROVIDERS: { [key: string]: string } = {
twitter: "Twitter",
discord: "Discord",
facebook: "Facebook",
github: "Github",
gitlab: "Gitlab",
google: "Google",
keycloak: "Keycloak",
ldap: "LDAP",
};

View File

@ -1,5 +1,5 @@
<template>
<div>
<div v-if="loggedUser">
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<li>
@ -24,6 +24,13 @@
>
<b slot="email">{{ loggedUser.email }}</b>
</i18n>
<b-message v-if="!canChangeEmail" type="is-warning" :closable="false">
{{
$t("Your email address was automatically set based on your {provider} account.", {
provider: providerName(loggedUser.provider),
})
}}
</b-message>
<b-notification
type="is-danger"
has-icon
@ -33,7 +40,7 @@
v-for="error in changeEmailErrors"
>{{ error }}</b-notification
>
<form @submit.prevent="resetEmailAction" ref="emailForm" class="form">
<form @submit.prevent="resetEmailAction" ref="emailForm" class="form" v-if="canChangeEmail">
<b-field :label="$t('New email')">
<b-input aria-required="true" required type="email" v-model="newEmail" />
</b-field>
@ -58,6 +65,13 @@
<div class="setting-title">
<h2>{{ $t("Password") }}</h2>
</div>
<b-message v-if="!canChangePassword" type="is-warning" :closable="false">
{{
$t("You can't change your password because you are registered through {provider}.", {
provider: providerName(loggedUser.provider),
})
}}
</b-message>
<b-notification
type="is-danger"
has-icon
@ -67,7 +81,12 @@
v-for="error in changePasswordErrors"
>{{ error }}</b-notification
>
<form @submit.prevent="resetPasswordAction" ref="passwordForm" class="form">
<form
@submit.prevent="resetPasswordAction"
ref="passwordForm"
class="form"
v-if="canChangePassword"
>
<b-field :label="$t('Old password')">
<b-input
aria-required="true"
@ -124,11 +143,11 @@
<br />
<b>{{ $t("There will be no way to recover your data.") }}</b>
</p>
<p class="content">
<p class="content" v-if="hasUserGotAPassword">
{{ $t("Please enter your password to confirm this action.") }}
</p>
<form @submit.prevent="deleteAccount">
<b-field>
<b-field v-if="hasUserGotAPassword">
<b-input
type="password"
v-model="passwordForAccountDeletion"
@ -160,8 +179,8 @@
import { Component, Vue, Ref } from "vue-property-decorator";
import { CHANGE_EMAIL, CHANGE_PASSWORD, DELETE_ACCOUNT, LOGGED_USER } from "../../graphql/user";
import RouteName from "../../router/name";
import { ICurrentUser } from "../../types/current-user.model";
import { logout } from "../../utils/auth";
import { IUser, IAuthProvider } from "../../types/current-user.model";
import { logout, SELECTED_PROVIDERS } from "../../utils/auth";
@Component({
apollo: {
@ -171,7 +190,7 @@ import { logout } from "../../utils/auth";
export default class AccountSettings extends Vue {
@Ref("passwordForm") readonly passwordForm!: HTMLElement;
loggedUser!: ICurrentUser;
loggedUser!: IUser;
passwordForEmailChange = "";
@ -243,7 +262,7 @@ export default class AccountSettings extends Vue {
await this.$apollo.mutate({
mutation: DELETE_ACCOUNT,
variables: {
password: this.passwordForAccountDeletion,
password: this.hasUserGotAPassword ? this.passwordForAccountDeletion : null,
},
});
await logout(this.$apollo.provider.defaultClient);
@ -260,6 +279,28 @@ export default class AccountSettings extends Vue {
}
}
get canChangePassword() {
return !this.loggedUser.provider;
}
get canChangeEmail() {
return !this.loggedUser.provider;
}
providerName(id: string) {
if (SELECTED_PROVIDERS[id]) {
return SELECTED_PROVIDERS[id];
}
return id;
}
get hasUserGotAPassword(): boolean {
return (
this.loggedUser &&
(this.loggedUser.provider == null || this.loggedUser.provider == IAuthProvider.LDAP)
);
}
private handleErrors(type: string, err: any) {
console.error(err);

View File

@ -95,10 +95,7 @@
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import { USER_SETTINGS, SET_USER_SETTINGS } from "../../graphql/user";
import {
ICurrentUser,
INotificationPendingParticipationEnum,
} from "../../types/current-user.model";
import { IUser, INotificationPendingParticipationEnum } from "../../types/current-user.model";
import RouteName from "../../router/name";
@Component({
@ -107,7 +104,7 @@ import RouteName from "../../router/name";
},
})
export default class Notifications extends Vue {
loggedUser!: ICurrentUser;
loggedUser!: IUser;
notificationOnDay = true;

View File

@ -52,7 +52,7 @@ import { Component, Vue, Watch } from "vue-property-decorator";
import { TIMEZONES } from "../../graphql/config";
import { USER_SETTINGS, SET_USER_SETTINGS, UPDATE_USER_LOCALE } from "../../graphql/user";
import { IConfig } from "../../types/config.model";
import { ICurrentUser } from "../../types/current-user.model";
import { IUser } from "../../types/current-user.model";
import langs from "../../i18n/langs.json";
import RouteName from "../../router/name";
@ -65,7 +65,7 @@ import RouteName from "../../router/name";
export default class Preferences extends Vue {
config!: IConfig;
loggedUser!: ICurrentUser;
loggedUser!: IUser;
selectedTimezone: string | null = null;
@ -74,7 +74,7 @@ export default class Preferences extends Vue {
RouteName = RouteName;
@Watch("loggedUser")
setSavedTimezone(loggedUser: ICurrentUser) {
setSavedTimezone(loggedUser: IUser) {
if (loggedUser && loggedUser.settings.timezone) {
this.selectedTimezone = loggedUser.settings.timezone;
} else {

View File

@ -10,6 +10,26 @@
:aria-close-label="$t('Close')"
>{{ $t("You need to login.") }}</b-message
>
<b-message
v-else-if="errorCode === LoginError.LOGIN_PROVIDER_ERROR"
type="is-danger"
:aria-close-label="$t('Close')"
>{{
$t("Error while login with {provider}. Retry or login another way.", {
provider: $route.query.provider,
})
}}</b-message
>
<b-message
v-else-if="errorCode === LoginError.LOGIN_PROVIDER_NOT_FOUND"
type="is-danger"
:aria-close-label="$t('Close')"
>{{
$t("Error while login with {provider}. This login provider doesn't exist.", {
provider: $route.query.provider,
})
}}</b-message
>
<b-message title="Error" type="is-danger" v-for="error in errors" :key="error">
<span v-if="error === LoginError.USER_NOT_CONFIRMED">
<span>
@ -60,6 +80,11 @@
<p class="control has-text-centered">
<button class="button is-primary is-large">{{ $t("Login") }}</button>
</p>
<div class="control" v-if="config && config.auth.oauthProviders.length > 0">
<auth-providers :oauthProviders="config.auth.oauthProviders" />
</div>
<p class="control">
<router-link
class="button is-text"
@ -103,6 +128,7 @@ import { LoginErrorCode, LoginError } from "../../types/login-error-code.model";
import { ICurrentUser } from "../../types/current-user.model";
import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model";
import AuthProviders from "../../components/User/AuthProviders.vue";
@Component({
apollo: {
@ -113,6 +139,9 @@ import { IConfig } from "../../types/config.model";
query: CURRENT_USER_CLIENT,
},
},
components: {
AuthProviders,
},
metaInfo() {
return {
// if no subcomponents specify a metaInfo.title, this title will be used

View File

@ -0,0 +1,64 @@
<template> </template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { VALIDATE_USER, UPDATE_CURRENT_USER_CLIENT, LOGGED_USER } from "../../graphql/user";
import RouteName from "../../router/name";
import { saveUserData, changeIdentity } from "../../utils/auth";
import { ILogin } from "../../types/login.model";
import { ICurrentUserRole, ICurrentUser, IUser } from "../../types/current-user.model";
import { IDENTITIES } from "../../graphql/actor";
@Component
export default class ProviderValidate extends Vue {
async mounted() {
const accessToken = this.getValueFromMeta("auth-access-token");
const refreshToken = this.getValueFromMeta("auth-refresh-token");
const userId = this.getValueFromMeta("auth-user-id");
const userEmail = this.getValueFromMeta("auth-user-email");
const userRole = this.getValueFromMeta("auth-user-role") as ICurrentUserRole;
const userActorId = this.getValueFromMeta("auth-user-actor-id");
if (!(userId && userEmail && userRole && accessToken && refreshToken)) {
return this.$router.push("/");
}
const login = {
user: { id: userId, email: userEmail, role: userRole, isLoggedIn: true },
accessToken,
refreshToken,
};
saveUserData(login);
await this.$apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT,
variables: {
id: userId,
email: userEmail,
isLoggedIn: true,
role: ICurrentUserRole.USER,
},
});
const { data } = await this.$apollo.query<{ loggedUser: IUser }>({
query: LOGGED_USER,
});
const { loggedUser } = data;
if (loggedUser.defaultActor) {
await changeIdentity(this.$apollo.provider.defaultClient, loggedUser.defaultActor);
await this.$router.push({ name: RouteName.HOME });
} else {
// If the user didn't register any profile yet, let's create one for them
await this.$router.push({
name: RouteName.REGISTER_PROFILE,
params: { email: loggedUser.email, userAlreadyActivated: "true" },
});
}
}
getValueFromMeta(name: string) {
const element = document.querySelector(`meta[name="${name}"]`);
if (element && element.getAttribute("content")) {
return element.getAttribute("content");
}
return null;
}
}
</script>

View File

@ -96,6 +96,7 @@
{{ $t("Register") }}
</b-button>
</p>
<p class="control">
<router-link
class="button is-text"
@ -113,6 +114,11 @@
>{{ $t("Login") }}</router-link
>
</p>
<hr />
<div class="control" v-if="config && config.auth.oauthProviders.length > 0">
<auth-providers :oauthProviders="config.auth.oauthProviders" />
</div>
</form>
<div v-if="errors.length > 0">
@ -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

View File

@ -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;

View File

@ -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

View File

@ -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} ->

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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])

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -0,0 +1,180 @@
# Portions of this file are derived from Pleroma:
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

29
mix.exs
View File

@ -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.

View File

@ -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"},
}

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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()

View File

@ -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})

View File

@ -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})

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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