Merge branch 'ldap' into 'master'

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

Closes #28

See merge request framasoft/mobilizon!502
This commit is contained in:
Thomas Citharel 2020-07-06 15:51:33 +02:00
commit 1d2038c9a0
52 changed files with 2193 additions and 880 deletions

View file

@ -6,6 +6,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Possibility to login using LDAP
- Possibility to login using OAuth providers
### Changed ### Changed
- Completely replaced HTMLSanitizeEx with FastSanitize [!490](https://framagit.org/framasoft/mobilizon/-/merge_requests/490) - Completely replaced HTMLSanitizeEx with FastSanitize [!490](https://framagit.org/framasoft/mobilizon/-/merge_requests/490)

View file

@ -118,6 +118,30 @@ config :guardian, Guardian.DB,
config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase 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, config :geolix,
databases: [ databases: [
%{ %{

View file

@ -0,0 +1,113 @@
# Authentification
## LDAP
Use LDAP for user authentication. When a user logs in to the Mobilizon instance, the email and password will be verified by trying to authenticate
(bind) to an LDAP server. If a user exists in the LDAP directory but there is no account with the same email yet on the Mobilizon instance then a new
Mobilizon account will be created (without needing email confirmation) with the same email as the LDAP email name.
!!! tip
As Mobilizon uses email for login and LDAP bind is often done with account UID/CN, we need to start by searching for LDAP account matching with this email. LDAP search without bind is often disallowed, so you'll probably need an admin LDAP user.
Change authentification method:
```elixir
config :mobilizon,
Mobilizon.Service.Auth.Authenticator,
Mobilizon.Service.Auth.LDAPAuthenticator
```
LDAP configuration under `:mobilizon, :ldap`:
* `enabled`: enables LDAP authentication
* `host`: LDAP server hostname
* `port`: LDAP port, e.g. 389 or 636
* `ssl`: true to use SSL, usually implies the port 636
* `sslopts`: additional SSL options
* `tls`: true to start TLS, usually implies the port 389
* `tlsopts`: additional TLS options
* `base`: LDAP base, e.g. "dc=example,dc=com"
* `uid`: LDAP attribute name to authenticate the user, e.g. when "cn", the filter will be "cn=username,base"
* `require_bind_for_search` whether admin bind is required to perform search
* `bind_uid` the admin uid/cn for binding before searching
* `bind_password` the admin password for binding before searching
Example:
```elixir
config :mobilizon, :ldap,
enabled: true,
host: "localhost",
port: 636,
ssl: true,
sslopts: [],
tls: true,
tlsopts: [],
base: "ou=users,dc=example,dc=local",
uid: "cn",
require_bind_for_search: true,
bind_uid: "admin_account",
bind_password: "some_admin_password"
```
## OAuth
Mobilizon currently supports the following providers:
* [Discord](https://github.com/schwarz/ueberauth_discord)
* [Facebook](https://github.com/ueberauth/ueberauth_facebook)
* [Github](https://github.com/ueberauth/ueberauth_github)
* [Gitlab](https://github.com/mtchavez/ueberauth_gitlab) (including self-hosted)
* [Google](https://github.com/ueberauth/ueberauth_google)
* [Keycloak](https://github.com/Rukenshia/ueberauth_keycloak) (through OpenID Connect)
* [Twitter](https://github.com/Rukenshia/ueberauth_keycloak)
Support for [other providers](https://github.com/ueberauth/ueberauth/wiki/List-of-Strategies) can easily be added if requested.
!!! tip
We advise to look at each provider's README file for eventual specific instructions.
You'll have to start by registering an app at the provider. Be sure to activate features like "Sign-in with" and "emails" scope, as Mobilizon needs users emails to register them.
Add the configured providers to configuration (you may find the appropriate scopes on the provider's API documentation):
```elixir
config :ueberauth,
Ueberauth,
providers: [
gitlab: {Ueberauth.Strategy.Gitlab, [default_scope: "read_user"]},
keycloak: {Ueberauth.Strategy.Keycloak, [default_scope: "email"]}
# ...
]
```
In order for the « Sign-in with » buttons to be added on Register and Login pages, list your providers:
```elixir
config :mobilizon, :auth,
oauth_consumer_strategies: [
:gitlab,
{:keycloak, "My corporate account"}
# ...
]
```
!!! note
If you use the `{:provider_id, "Some label"}` form, the label will be used inside the buttons on Register and Login pages.
Finally add the configuration for each specific provider. The Client ID and Client Secret are at least required:
```elixir
config :ueberauth, Ueberauth.Strategy.Facebook.OAuth,
client_id: "some_numeric_id",
client_secret: "some_secret"
keycloak_url = "https://some-keycloak-instance.org"
# Realm may be something else than master
config :ueberauth, Ueberauth.Strategy.Keycloak.OAuth,
client_id: "some_id",
client_secret: "some_hexadecimal_secret",
site: keycloak_url,
authorize_url: "#{keycloak_url}/auth/realms/master/protocol/openid-connect/auth",
token_url: "#{keycloak_url}/auth/realms/master/protocol/openid-connect/token",
userinfo_url: "#{keycloak_url}/auth/realms/master/protocol/openid-connect/userinfo",
token_method: :post
```

View file

@ -90,7 +90,7 @@
"prettier-eslint": "^10.1.1", "prettier-eslint": "^10.1.1",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"typescript": "~3.9.3", "typescript": "~3.9.3",
"vue-cli-plugin-styleguidist": "^4.25.0", "vue-cli-plugin-styleguidist": "~4.26.0",
"vue-cli-plugin-svg": "~0.1.3", "vue-cli-plugin-svg": "~0.1.3",
"vue-i18n-extract": "^1.0.2", "vue-i18n-extract": "^1.0.2",
"vue-template-compiler": "^2.6.11", "vue-template-compiler": "^2.6.11",

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 { features {
groups groups
} }
auth {
ldap
oauthProviders {
id
label
}
}
} }
} }
`; `;

View file

@ -35,6 +35,15 @@ export const LOGGED_USER = gql`
loggedUser { loggedUser {
id id
email email
defaultActor {
id
preferredUsername
name
avatar {
url
}
}
provider
} }
} }
`; `;
@ -64,7 +73,7 @@ export const VALIDATE_EMAIL = gql`
`; `;
export const DELETE_ACCOUNT = gql` export const DELETE_ACCOUNT = gql`
mutation DeleteAccount($password: String, $userId: ID!) { mutation DeleteAccount($password: String, $userId: ID) {
deleteAccount(password: $password, userId: $userId) { deleteAccount(password: $password, userId: $userId) {
id id
} }

View file

@ -703,5 +703,10 @@
"New discussion": "New discussion", "New discussion": "New discussion",
"Create a discussion": "Create a discussion", "Create a discussion": "Create a discussion",
"Create the discussion": "Create the 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", "{number} participations": "Aucune participation|Une participation|{number} participations",
"{profile} (by default)": "{profile} (par défault)", "{profile} (by default)": "{profile} (par défault)",
"{title} ({count} todos)": "{title} ({count} todos)", "{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"), component: () => import(/* webpackChunkName: "cookies" */ "@/views/Interact.vue"),
meta: { requiredAuth: false }, meta: { requiredAuth: false },
}, },
{
path: "/auth/:provider/callback",
name: "auth-callback",
component: () => import("@/views/User/ProviderValidation.vue"),
},
{ {
path: "/404", path: "/404",
name: RouteName.PAGE_NOT_FOUND, name: RouteName.PAGE_NOT_FOUND,

View file

@ -74,4 +74,13 @@ export interface IConfig {
}; };
federating: boolean; federating: boolean;
version: string; 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 { export interface ICurrentUser {
id: number; id: string;
email: string; email: string;
isLoggedIn: boolean; isLoggedIn: boolean;
role: ICurrentUserRole; role: ICurrentUserRole;
participations: Paginate<IParticipant>; defaultActor?: IPerson;
defaultActor: IPerson;
drafts: IEvent[];
settings: IUserSettings;
locale: string;
} }
export interface IUser extends ICurrentUser { export interface IUser extends ICurrentUser {
@ -25,6 +21,22 @@ export interface IUser extends ICurrentUser {
confirmationSendAt: Date; confirmationSendAt: Date;
actors: IPerson[]; actors: IPerson[];
disabled: boolean; 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 { export enum INotificationPendingParticipationEnum {

View file

@ -6,4 +6,6 @@ export enum LoginError {
USER_NOT_CONFIRMED = "User account not confirmed", USER_NOT_CONFIRMED = "User account not confirmed",
USER_DOES_NOT_EXIST = "No user with this email was found", 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.", 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(); 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> <template>
<div> <div v-if="loggedUser">
<nav class="breadcrumb" aria-label="breadcrumbs"> <nav class="breadcrumb" aria-label="breadcrumbs">
<ul> <ul>
<li> <li>
@ -24,6 +24,13 @@
> >
<b slot="email">{{ loggedUser.email }}</b> <b slot="email">{{ loggedUser.email }}</b>
</i18n> </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 <b-notification
type="is-danger" type="is-danger"
has-icon has-icon
@ -33,7 +40,7 @@
v-for="error in changeEmailErrors" v-for="error in changeEmailErrors"
>{{ error }}</b-notification >{{ 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-field :label="$t('New email')">
<b-input aria-required="true" required type="email" v-model="newEmail" /> <b-input aria-required="true" required type="email" v-model="newEmail" />
</b-field> </b-field>
@ -58,6 +65,13 @@
<div class="setting-title"> <div class="setting-title">
<h2>{{ $t("Password") }}</h2> <h2>{{ $t("Password") }}</h2>
</div> </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 <b-notification
type="is-danger" type="is-danger"
has-icon has-icon
@ -67,7 +81,12 @@
v-for="error in changePasswordErrors" v-for="error in changePasswordErrors"
>{{ error }}</b-notification >{{ 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-field :label="$t('Old password')">
<b-input <b-input
aria-required="true" aria-required="true"
@ -124,11 +143,11 @@
<br /> <br />
<b>{{ $t("There will be no way to recover your data.") }}</b> <b>{{ $t("There will be no way to recover your data.") }}</b>
</p> </p>
<p class="content"> <p class="content" v-if="hasUserGotAPassword">
{{ $t("Please enter your password to confirm this action.") }} {{ $t("Please enter your password to confirm this action.") }}
</p> </p>
<form @submit.prevent="deleteAccount"> <form @submit.prevent="deleteAccount">
<b-field> <b-field v-if="hasUserGotAPassword">
<b-input <b-input
type="password" type="password"
v-model="passwordForAccountDeletion" v-model="passwordForAccountDeletion"
@ -160,8 +179,8 @@
import { Component, Vue, Ref } from "vue-property-decorator"; import { Component, Vue, Ref } from "vue-property-decorator";
import { CHANGE_EMAIL, CHANGE_PASSWORD, DELETE_ACCOUNT, LOGGED_USER } from "../../graphql/user"; import { CHANGE_EMAIL, CHANGE_PASSWORD, DELETE_ACCOUNT, LOGGED_USER } from "../../graphql/user";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { ICurrentUser } from "../../types/current-user.model"; import { IUser, IAuthProvider } from "../../types/current-user.model";
import { logout } from "../../utils/auth"; import { logout, SELECTED_PROVIDERS } from "../../utils/auth";
@Component({ @Component({
apollo: { apollo: {
@ -171,7 +190,7 @@ import { logout } from "../../utils/auth";
export default class AccountSettings extends Vue { export default class AccountSettings extends Vue {
@Ref("passwordForm") readonly passwordForm!: HTMLElement; @Ref("passwordForm") readonly passwordForm!: HTMLElement;
loggedUser!: ICurrentUser; loggedUser!: IUser;
passwordForEmailChange = ""; passwordForEmailChange = "";
@ -243,7 +262,7 @@ export default class AccountSettings extends Vue {
await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: DELETE_ACCOUNT, mutation: DELETE_ACCOUNT,
variables: { variables: {
password: this.passwordForAccountDeletion, password: this.hasUserGotAPassword ? this.passwordForAccountDeletion : null,
}, },
}); });
await logout(this.$apollo.provider.defaultClient); 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) { private handleErrors(type: string, err: any) {
console.error(err); console.error(err);

View file

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

View file

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

View file

@ -10,6 +10,26 @@
:aria-close-label="$t('Close')" :aria-close-label="$t('Close')"
>{{ $t("You need to login.") }}</b-message >{{ $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"> <b-message title="Error" type="is-danger" v-for="error in errors" :key="error">
<span v-if="error === LoginError.USER_NOT_CONFIRMED"> <span v-if="error === LoginError.USER_NOT_CONFIRMED">
<span> <span>
@ -60,6 +80,11 @@
<p class="control has-text-centered"> <p class="control has-text-centered">
<button class="button is-primary is-large">{{ $t("Login") }}</button> <button class="button is-primary is-large">{{ $t("Login") }}</button>
</p> </p>
<div class="control" v-if="config && config.auth.oauthProviders.length > 0">
<auth-providers :oauthProviders="config.auth.oauthProviders" />
</div>
<p class="control"> <p class="control">
<router-link <router-link
class="button is-text" 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 { ICurrentUser } from "../../types/current-user.model";
import { CONFIG } from "../../graphql/config"; import { CONFIG } from "../../graphql/config";
import { IConfig } from "../../types/config.model"; import { IConfig } from "../../types/config.model";
import AuthProviders from "../../components/User/AuthProviders.vue";
@Component({ @Component({
apollo: { apollo: {
@ -113,6 +139,9 @@ import { IConfig } from "../../types/config.model";
query: CURRENT_USER_CLIENT, query: CURRENT_USER_CLIENT,
}, },
}, },
components: {
AuthProviders,
},
metaInfo() { metaInfo() {
return { return {
// if no subcomponents specify a metaInfo.title, this title will be used // 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") }} {{ $t("Register") }}
</b-button> </b-button>
</p> </p>
<p class="control"> <p class="control">
<router-link <router-link
class="button is-text" class="button is-text"
@ -113,6 +114,11 @@
>{{ $t("Login") }}</router-link >{{ $t("Login") }}</router-link
> >
</p> </p>
<hr />
<div class="control" v-if="config && config.auth.oauthProviders.length > 0">
<auth-providers :oauthProviders="config.auth.oauthProviders" />
</div>
</form> </form>
<div v-if="errors.length > 0"> <div v-if="errors.length > 0">
@ -131,9 +137,10 @@ import RouteName from "../../router/name";
import { IConfig } from "../../types/config.model"; import { IConfig } from "../../types/config.model";
import { CONFIG } from "../../graphql/config"; import { CONFIG } from "../../graphql/config";
import Subtitle from "../../components/Utils/Subtitle.vue"; import Subtitle from "../../components/Utils/Subtitle.vue";
import AuthProviders from "../../components/User/AuthProviders.vue";
@Component({ @Component({
components: { Subtitle }, components: { Subtitle, AuthProviders },
metaInfo() { metaInfo() {
return { return {
// if no subcomponents specify a metaInfo.title, this title will be used // 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 { Component, Prop, Vue } from "vue-property-decorator";
import { VALIDATE_USER, UPDATE_CURRENT_USER_CLIENT } from "../../graphql/user"; import { VALIDATE_USER, UPDATE_CURRENT_USER_CLIENT } from "../../graphql/user";
import RouteName from "../../router/name"; 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 { ILogin } from "../../types/login.model";
import { ICurrentUserRole } from "../../types/current-user.model"; import { ICurrentUserRole } from "../../types/current-user.model";
@ -45,6 +45,7 @@ export default class Validate extends Vue {
if (data) { if (data) {
saveUserData(data.validateUser); saveUserData(data.validateUser);
saveTokenData(data.validateUser);
const { user } = data.validateUser; const { user } = data.validateUser;

File diff suppressed because it is too large Load diff

View file

@ -124,7 +124,11 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
}, },
rules: Config.instance_rules(), rules: Config.instance_rules(),
version: Config.instance_version(), 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
end end

View file

@ -202,10 +202,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
""" """
def register_person(_parent, args, _resolution) do def register_person(_parent, args, _resolution) do
with {:ok, %User{} = user} <- Users.get_user_by_email(args.email), 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 <- Map.put(args, :user_id, user.id),
args <- save_attached_pictures(args), 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} {:ok, new_person}
else else
{:error, :user_not_found} -> {:error, :user_not_found} ->

View file

@ -9,6 +9,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Crypto alias Mobilizon.Crypto
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Service.Auth.Authenticator
alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.{Setting, User} alias Mobilizon.Users.{Setting, User}
@ -59,18 +60,16 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
Login an user. Returns a token and the user Login an user. Returns a token and the user
""" """
def login_user(_parent, %{email: email, password: password}, _resolution) do def login_user(_parent, %{email: email, password: password}, _resolution) do
with {:ok, %User{confirmed_at: %DateTime{}} = user} <- Users.get_user_by_email(email), case Authenticator.authenticate(email, password) do
{:ok, %{access_token: access_token, refresh_token: refresh_token}} <- {:ok,
Users.authenticate(%{user: user, password: password}) do %{access_token: _access_token, refresh_token: _refresh_token, user: _user} =
{:ok, %{access_token: access_token, refresh_token: refresh_token, user: user}} user_and_tokens} ->
else {:ok, user_and_tokens}
{:ok, %User{confirmed_at: nil} = _user} ->
{:error, "User account not confirmed"}
{:error, :user_not_found} -> {:error, :user_not_found} ->
{:error, "No user with this email was found"} {:error, "No user with this email was found"}
{:error, :unauthorized} -> {:error, _error} ->
{:error, "Impossible to authenticate, either your email or password are invalid."} {:error, "Impossible to authenticate, either your email or password are invalid."}
end end
end end
@ -82,7 +81,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
with {:ok, user, _claims} <- Auth.Guardian.resource_from_token(refresh_token), with {:ok, user, _claims} <- Auth.Guardian.resource_from_token(refresh_token),
{:ok, _old, {exchanged_token, _claims}} <- {:ok, _old, {exchanged_token, _claims}} <-
Auth.Guardian.exchange(refresh_token, ["access", "refresh"], "access"), 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}} {:ok, %{access_token: exchanged_token, refresh_token: refresh_token}}
else else
{:error, message} -> {:error, message} ->
@ -151,7 +150,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
{:check_confirmation_token, Email.User.check_confirmation_token(token)}, {:check_confirmation_token, Email.User.check_confirmation_token(token)},
{:get_actor, actor} <- {:get_actor, Users.get_actor_for_user(user)}, {:get_actor, actor} <- {:get_actor, Users.get_actor_for_user(user)},
{:ok, %{access_token: access_token, refresh_token: refresh_token}} <- {:ok, %{access_token: access_token, refresh_token: refresh_token}} <-
Users.generate_tokens(user) do Authenticator.generate_tokens(user) do
{:ok, {:ok,
%{ %{
access_token: access_token, access_token: access_token,
@ -192,10 +191,15 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
def send_reset_password(_parent, args, _resolution) do def send_reset_password(_parent, args, _resolution) do
with email <- Map.get(args, :email), with email <- Map.get(args, :email),
{:ok, %User{locale: locale} = user} <- Users.get_user_by_email(email, true), {: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} <- {:ok, %Bamboo.Email{} = _email_html} <-
Email.User.send_password_reset_email(user, Map.get(args, :locale, locale)) do Email.User.send_password_reset_email(user, Map.get(args, :locale, locale)) do
{:ok, email} {:ok, email}
else else
{:can_reset_password, false} ->
{:error, "This user can't reset their password"}
{:error, :user_not_found} -> {:error, :user_not_found} ->
# TODO : implement rate limits for this endpoint # TODO : implement rate limits for this endpoint
{:error, "No user with this email was found"} {:error, "No user with this email was found"}
@ -209,10 +213,10 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
Reset the password from an user Reset the password from an user
""" """
def reset_password(_parent, %{password: password, token: token}, _resolution) do 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), Email.User.check_reset_password_token(password, token),
{:ok, %{access_token: access_token, refresh_token: refresh_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}} {:ok, %{access_token: access_token, refresh_token: refresh_token, user: user}}
end end
end end
@ -295,10 +299,12 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
def change_password( def change_password(
_parent, _parent,
%{old_password: old_password, new_password: new_password}, %{old_password: old_password, new_password: new_password},
%{context: %{current_user: %User{password_hash: old_password_hash} = user}} %{context: %{current_user: %User{} = user}}
) do ) do
with {:current_password, true} <- with {:can_change_password, true} <-
{:current_password, Argon2.verify_pass(old_password, old_password_hash)}, {: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}, {:same_password, false} <- {:same_password, old_password == new_password},
{:ok, %User{} = user} <- {:ok, %User{} = user} <-
user user
@ -306,7 +312,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
|> Repo.update() do |> Repo.update() do
{:ok, user} {:ok, user}
else else
{:current_password, false} -> {:current_password, _} ->
{:error, "The current password is invalid"} {:error, "The current password is invalid"}
{:same_password, true} -> {:same_password, true} ->
@ -323,10 +329,12 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
end end
def change_email(_parent, %{email: new_email, password: password}, %{ 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 }) do
with {:current_password, true} <- with {:can_change_password, true} <-
{:current_password, Argon2.verify_pass(password, password_hash)}, {: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}, {:same_email, false} <- {:same_email, new_email == old_email},
{:email_valid, true} <- {:email_valid, Email.Checker.valid?(new_email)}, {:email_valid, true} <- {:email_valid, Email.Checker.valid?(new_email)},
{:ok, %User{} = user} <- {:ok, %User{} = user} <-
@ -347,7 +355,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
{:ok, user} {:ok, user}
else else
{:current_password, false} -> {:current_password, _} ->
{:error, "The password provided is invalid"} {:error, "The password provided is invalid"}
{:same_email, true} -> {:same_email, true} ->
@ -377,14 +385,24 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
end end
end end
def delete_account(_parent, %{password: password}, %{ def delete_account(_parent, args, %{
context: %{current_user: %User{password_hash: password_hash} = user} context: %{current_user: %User{email: email} = user}
}) do }) do
case {:current_password, Argon2.verify_pass(password, password_hash)} do with {:user_has_password, true} <- {:user_has_password, Authenticator.has_password?(user)},
{:current_password, true} -> {: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) do_delete_account(user)
{:current_password, false} -> {:confirmation_password, nil} ->
{:error, "The password provided is invalid"}
{:current_password, _} ->
{:error, "The password provided is invalid"} {:error, "The password provided is invalid"}
end end
end end

View file

@ -39,6 +39,7 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
end end
field(:rules, :string, description: "The instance's rules") field(:rules, :string, description: "The instance's rules")
field(:auth, :auth, description: "The instance auth methods")
end end
object :terms do object :terms do
@ -132,6 +133,16 @@ defmodule Mobilizon.GraphQL.Schema.ConfigType do
field(:groups, :boolean) field(:groups, :boolean)
end 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 object :config_queries do
@desc "Get the instance config" @desc "Get the instance config"
field :config, :config do 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(: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(:disabled, :boolean, description: "Whether the user is disabled")
field(:participations, :paginated_participant_list, field(:participations, :paginated_participant_list,

View file

@ -13,6 +13,7 @@ defmodule Mobilizon.Actors do
alias Mobilizon.Media.File alias Mobilizon.Media.File
alias Mobilizon.Service.Workers alias Mobilizon.Service.Workers
alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
@ -189,14 +190,19 @@ defmodule Mobilizon.Actors do
Creates a new person actor. Creates a new person actor.
""" """
@spec new_person(map) :: {:ok, Actor.t()} | {:error, Ecto.Changeset.t()} @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()) args = Map.put(args, :keys, Crypto.generate_rsa_2048_private_key())
with {:ok, %Actor{} = person} <- with {:ok, %Actor{id: person_id} = person} <-
%Actor{} %Actor{}
|> Actor.registration_changeset(args) |> Actor.registration_changeset(args)
|> Repo.insert() do |> 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} {:ok, person}
end end

View file

@ -186,6 +186,24 @@ defmodule Mobilizon.Config do
def anonymous_reporting?, def anonymous_reporting?,
do: Application.get_env(:mobilizon, :anonymous)[:reports][:allowed] 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 def instance_resource_providers do
types = get_in(Application.get_env(:mobilizon, Mobilizon.Service.ResourceProviders), [:types]) types = get_in(Application.get_env(:mobilizon, Mobilizon.Service.ResourceProviders), [:types])

View file

@ -40,14 +40,18 @@ defmodule Mobilizon.Users.User do
:confirmation_token, :confirmation_token,
:reset_password_sent_at, :reset_password_sent_at,
:reset_password_token, :reset_password_token,
:default_actor_id,
:locale, :locale,
:unconfirmed_email, :unconfirmed_email,
:disabled :disabled,
:provider
] ]
@attrs @required_attrs ++ @optional_attrs @attrs @required_attrs ++ @optional_attrs
@registration_required_attrs @required_attrs ++ [:password] @registration_required_attrs @required_attrs ++ [:password]
@auth_provider_required_attrs @required_attrs ++ [:provider]
@password_change_required_attrs [:password] @password_change_required_attrs [:password]
@password_reset_required_attrs @password_change_required_attrs ++ @password_reset_required_attrs @password_change_required_attrs ++
[:reset_password_token, :reset_password_sent_at] [:reset_password_token, :reset_password_sent_at]
@ -67,6 +71,7 @@ defmodule Mobilizon.Users.User do
field(:unconfirmed_email, :string) field(:unconfirmed_email, :string)
field(:locale, :string, default: "en") field(:locale, :string, default: "en")
field(:disabled, :boolean, default: false) field(:disabled, :boolean, default: false)
field(:provider, :string)
belongs_to(:default_actor, Actor) belongs_to(:default_actor, Actor)
has_many(:actors, Actor) has_many(:actors, Actor)
@ -116,6 +121,16 @@ defmodule Mobilizon.Users.User do
) )
end 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 @doc false
@spec send_password_reset_changeset(t, map) :: Ecto.Changeset.t() @spec send_password_reset_changeset(t, map) :: Ecto.Changeset.t()
def send_password_reset_changeset(%__MODULE__{} = user, attrs) do 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.Storage.{Page, Repo}
alias Mobilizon.Users.{Setting, User} 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(UserRole, :user_role, [:administrator, :moderator, :user])
defenum(NotificationPendingNotificationDelay, none: 0, direct: 1, one_hour: 5, one_day: 10) defenum(NotificationPendingNotificationDelay, none: 0, direct: 1, one_hour: 5, one_day: 10)
@ -41,6 +34,18 @@ defmodule Mobilizon.Users do
end end
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 """ @doc """
Gets a single user. Gets a single user.
Raises `Ecto.NoResultsError` if the user does not exist. Raises `Ecto.NoResultsError` if the user does not exist.
@ -75,6 +80,16 @@ defmodule Mobilizon.Users do
end end
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 """ @doc """
Get an user by its activation token. Get an user by its activation token.
""" """
@ -267,52 +282,6 @@ defmodule Mobilizon.Users do
@spec count_users :: integer @spec count_users :: integer
def count_users, do: Repo.one(from(u in User, select: count(u.id))) 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 """ @doc """
Gets a settings for an user. 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("/groups/me", PageController, :index, as: "my_groups")
get("/interact", PageController, :interact) get("/interact", PageController, :interact)
get("/auth/:provider", AuthController, :request)
get("/auth/:provider/callback", AuthController, :callback)
post("/auth/:provider/callback", AuthController, :callback)
end end
scope "/proxy/", Mobilizon.Web do 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

31
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(:dev), do: ["lib", "test/support/factory.ex"]
defp elixirc_paths(_), do: ["lib"] 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. # Specifies your project dependencies.
# #
# Type `mix help deps` for examples and options. # Type `mix help deps` for examples and options.
@ -81,7 +98,7 @@ defmodule Mobilizon.Mixfile do
{:bamboo_smtp, "~> 2.0"}, {:bamboo_smtp, "~> 2.0"},
{:geolix, "~> 1.0"}, {:geolix, "~> 1.0"},
{:geolix_adapter_mmdb2, "~> 0.5.0"}, {:geolix_adapter_mmdb2, "~> 0.5.0"},
{:absinthe, "~> 1.5.1"}, {:absinthe, "~> 1.5.2"},
{:absinthe_phoenix, "~> 2.0.0"}, {:absinthe_phoenix, "~> 2.0.0"},
{:absinthe_plug, "~> 1.5.0"}, {:absinthe_plug, "~> 1.5.0"},
{:dataloader, "~> 1.0.6"}, {:dataloader, "~> 1.0.6"},
@ -104,6 +121,16 @@ defmodule Mobilizon.Mixfile do
{:floki, "~> 0.26.0"}, {:floki, "~> 0.26.0"},
{:ip_reserved, "~> 0.1.0"}, {:ip_reserved, "~> 0.1.0"},
{:fast_sanitize, "~> 0.1"}, {: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 # Dev and test dependencies
{:phoenix_live_reload, "~> 1.2", only: [:dev, :e2e]}, {:phoenix_live_reload, "~> 1.2", only: [:dev, :e2e]},
{:ex_machina, "~> 2.3", only: [:dev, :test]}, {:ex_machina, "~> 2.3", only: [:dev, :test]},
@ -116,7 +143,7 @@ defmodule Mobilizon.Mixfile do
{:credo, "~> 1.4.0", only: [:dev, :test], runtime: false}, {:credo, "~> 1.4.0", only: [:dev, :test], runtime: false},
{:mock, "~> 0.3.4", only: :test}, {:mock, "~> 0.3.4", only: :test},
{:elixir_feed_parser, "~> 2.1.0", only: :test} {:elixir_feed_parser, "~> 2.1.0", only: :test}
] ] ++ oauth_deps()
end end
# Aliases are shortcuts or tasks specific to the current project. # Aliases are shortcuts or tasks specific to the current project.

View file

@ -1,5 +1,5 @@
%{ %{
"absinthe": {:hex, :absinthe, "1.5.1", "2f462f5849b2a4f72889d5a131ca6760b47ca8c5de2ba21c1dca3889634f2277", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b264eeb69605c6012f563e240edcca3d8abc5a725cab6f58ad82510a0283618b"}, "absinthe": {:hex, :absinthe, "1.5.2", "2f9449b0c135ea61c09c11968d3d4fe6abd5bed38cf9be1c6d6b7c5ec858cfa0", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "669c84879629b7fffdc6cda9361ab9c81c9c7691e65418ba089b912a227963ac"},
"absinthe_ecto": {:hex, :absinthe_ecto, "0.1.3", "420b68129e79fe4571a4838904ba03e282330d335da47729ad52ffd7b8c5fcb1", [:mix], [{:absinthe, "~> 1.3.0 or ~> 1.4.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "355b9db34abfab96ae1e025434b66e11002babcf4fe6b7144d26ff7548985f52"}, "absinthe_ecto": {:hex, :absinthe_ecto, "0.1.3", "420b68129e79fe4571a4838904ba03e282330d335da47729ad52ffd7b8c5fcb1", [:mix], [{:absinthe, "~> 1.3.0 or ~> 1.4.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "355b9db34abfab96ae1e025434b66e11002babcf4fe6b7144d26ff7548985f52"},
"absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.0", "01c6a90af0ca12ee08d0fb93e23f9890d75bb6d3027f49ee4383bc03058ef5c3", [:mix], [{:absinthe, "~> 1.5.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5.0", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "7ffbfe9fb82a14cafb78885cc2cef4f9d454bbbe2c95eec12b5463f5a20d1020"}, "absinthe_phoenix": {:hex, :absinthe_phoenix, "2.0.0", "01c6a90af0ca12ee08d0fb93e23f9890d75bb6d3027f49ee4383bc03058ef5c3", [:mix], [{:absinthe, "~> 1.5.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:absinthe_plug, "~> 1.5.0", [hex: :absinthe_plug, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.5", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.13", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}], "hexpm", "7ffbfe9fb82a14cafb78885cc2cef4f9d454bbbe2c95eec12b5463f5a20d1020"},
"absinthe_plug": {:hex, :absinthe_plug, "1.5.0", "018ef544cf577339018d1f482404b4bed762e1b530c78be9de4bbb88a6f3a805", [:mix], [{:absinthe, "~> 1.5.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.2 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c160f4ce9a1233a4219a42de946e4e05d0e8733537cd5d8d20e7d4ef8d4b7c7"}, "absinthe_plug": {:hex, :absinthe_plug, "1.5.0", "018ef544cf577339018d1f482404b4bed762e1b530c78be9de4bbb88a6f3a805", [:mix], [{:absinthe, "~> 1.5.0", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.2 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "4c160f4ce9a1233a4219a42de946e4e05d0e8733537cd5d8d20e7d4ef8d4b7c7"},
@ -23,7 +23,8 @@
"db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"}, "db_connection": {:hex, :db_connection, "2.2.2", "3bbca41b199e1598245b716248964926303b5d4609ff065125ce98bcd368939e", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm", "642af240d8a8affb93b4ba5a6fcd2bbcbdc327e1a524b825d383711536f8070c"},
"decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"},
"dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"},
"earmark": {:hex, :earmark, "1.4.5", "62ffd3bd7722fb7a7b1ecd2419ea0b458c356e7168c1f5d65caf09b4fbdd13c8", [:mix], [], "hexpm", "b7d0e6263d83dc27141a523467799a685965bf8b13b6743413f19a7079843f4f"}, "earmark": {:hex, :earmark, "1.4.9", "837e4c1c5302b3135e9955f2bbf52c6c52e950c383983942b68b03909356c0d9", [:mix], [{:earmark_parser, ">= 1.4.9", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "0d72df7d13a3dc8422882bed5263fdec5a773f56f7baeb02379361cb9e5b0d8e"},
"earmark_parser": {:hex, :earmark_parser, "1.4.9", "819bda2049e6ee1365424e4ced1ba65806eacf0d2867415f19f3f80047f8037b", [:mix], [], "hexpm", "8bf54fddabf2d7e137a0c22660e71b49d5a0a82d1fb05b5af62f2761cd6485c4"},
"ecto": {:hex, :ecto, "3.4.5", "2bcd262f57b2c888b0bd7f7a28c8a48aa11dc1a2c6a858e45dd8f8426d504265", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8c6d1d4d524559e9b7a062f0498e2c206122552d63eacff0a6567ffe7a8e8691"}, "ecto": {:hex, :ecto, "3.4.5", "2bcd262f57b2c888b0bd7f7a28c8a48aa11dc1a2c6a858e45dd8f8426d504265", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8c6d1d4d524559e9b7a062f0498e2c206122552d63eacff0a6567ffe7a8e8691"},
"ecto_autoslug_field": {:hex, :ecto_autoslug_field, "2.0.1", "2177c1c253f6dd3efd4b56d1cb76104d0a6ef044c6b9a7a0ad6d32665c4111e5", [:mix], [{:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:slugger, ">= 0.2.0", [hex: :slugger, repo: "hexpm", optional: false]}], "hexpm", "a3cc73211f2e75b89a03332183812ebe1ac08be2e25a1df5aa3d1422f92c45c3"}, "ecto_autoslug_field": {:hex, :ecto_autoslug_field, "2.0.1", "2177c1c253f6dd3efd4b56d1cb76104d0a6ef044c6b9a7a0ad6d32665c4111e5", [:mix], [{:ecto, ">= 2.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:slugger, ">= 0.2.0", [hex: :slugger, repo: "hexpm", optional: false]}], "hexpm", "a3cc73211f2e75b89a03332183812ebe1ac08be2e25a1df5aa3d1422f92c45c3"},
"ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"}, "ecto_enum": {:hex, :ecto_enum, "1.4.0", "d14b00e04b974afc69c251632d1e49594d899067ee2b376277efd8233027aec8", [:mix], [{:ecto, ">= 3.0.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "> 3.0.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:mariaex, ">= 0.0.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "8fb55c087181c2b15eee406519dc22578fa60dd82c088be376d0010172764ee4"},
@ -31,12 +32,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_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"}, "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "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"}, "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": {: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_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_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_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_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_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"}, "ex_ical": {:hex, :ex_ical, "0.2.0", "4b928b554614704016cc0c9ee226eb854da9327a1cc460457621ceacb1ac29a6", [:mix], [{:timex, "~> 3.1", [hex: :timex, repo: "hexpm", optional: false]}], "hexpm", "db76473b2ae0259e6633c6c479a5a4d8603f09497f55c88f9ef4d53d2b75befb"},
@ -91,7 +93,10 @@
"mock": {:hex, :mock, "0.3.5", "feb81f52b8dcf0a0d65001d2fec459f6b6a8c22562d94a965862f6cc066b5431", [:mix], [{:meck, "~> 0.8.13", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "6fae404799408300f863550392635d8f7e3da6b71abdd5c393faf41b131c8728"}, "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"}, "mogrify": {:hex, :mogrify, "0.7.4", "9b2496dde44b1ce12676f85d7dc531900939e6367bc537c7243a1b089435b32d", [:mix], [], "hexpm", "50d79e337fba6bc95bfbef918058c90f50b17eed9537771e61d4619488f099c3"},
"nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, "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"}, "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"}, "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": {: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"}, "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 +115,21 @@
"sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"}, "sleeplocks": {:hex, :sleeplocks, "1.1.1", "3d462a0639a6ef36cc75d6038b7393ae537ab394641beb59830a1b8271faeed3", [:rebar3], [], "hexpm", "84ee37aeff4d0d92b290fff986d6a95ac5eedf9b383fadfd1d88e9b84a1c02e1"},
"slugger": {:hex, :slugger, "0.3.0", "efc667ab99eee19a48913ccf3d038b1fb9f165fa4fbf093be898b8099e61b6ed", [:mix], [], "hexpm", "20d0ded0e712605d1eae6c5b4889581c3460d92623a930ddda91e0e609b5afba"}, "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"}, "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"}, "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"}, "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"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"},
"unsafe": {:hex, :unsafe, "1.0.1", "a27e1874f72ee49312e0a9ec2e0b27924214a05e3ddac90e91727bc76f8613d8", [:mix], [], "hexpm", "6c7729a2d214806450d29766abc2afaa7a2cbecf415be64f36a6691afebb50e5"}, "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"}, "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 #, elixir-format, fuzzy
#: lib/web/templates/email/email.text.eex:10 #: 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:" 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 #, elixir-format, fuzzy
#: lib/web/templates/email/email.text.eex:9 #: 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." 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 #, elixir-format, fuzzy
#: lib/web/templates/email/email.text.eex:7 #: 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 setup %{conn: conn, actor: actor, user: user} do
Mobilizon.Config.clear_config_cache() 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 setup %{conn: conn} do
Mobilizon.Config.clear_config_cache() Mobilizon.Config.clear_config_cache()

View file

@ -9,6 +9,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.Comment alias Mobilizon.Conversations.Comment
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Service.Auth.Authenticator
alias Mobilizon.Users.User alias Mobilizon.Users.User
alias Mobilizon.GraphQL.AbsintheHelpers 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 """ @delete_user_account_mutation """
mutation DeleteAccount($password: String!) { mutation DeleteAccount($password: String) {
deleteAccount (password: $password) { deleteAccount (password: $password) {
id id
} }
@ -712,45 +719,50 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
end end
describe "Resolver: Send reset password" do describe "Resolver: Send reset password" do
test "test send_reset_password/3 with valid email", context do test "test send_reset_password/3 with valid email", %{conn: conn} do
user = insert(:user) %User{email: email} = insert(:user)
mutation = """
mutation {
sendResetPassword(
email: "#{user.email}"
)
}
"""
res = res =
context.conn conn
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> 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 end
test "test send_reset_password/3 with invalid email", context do test "test send_reset_password/3 with invalid email", %{conn: conn} do
mutation = """ res =
mutation { conn
sendResetPassword( |> AbsintheHelpers.graphql_query(
email: "oh no" 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 = res =
context.conn conn
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> AbsintheHelpers.graphql_query(
query: @send_reset_password_mutation,
variables: %{email: email}
)
assert hd(json_response(res, 200)["errors"])["message"] == assert hd(res["errors"])["message"] ==
"No user with this email was found" "This user can't reset their password"
end end
end end
describe "Resolver: Reset user's password" do describe "Resolver: Reset user's password" do
test "test reset_password/3 with valid email", context do test "test reset_password/3 with valid email", context do
{:ok, %User{} = user} = Users.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) {: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) %Actor{} = insert(:actor, user: user)
{:ok, _email_sent} = Email.User.send_password_reset_email(user) {:ok, _email_sent} = Email.User.send_password_reset_email(user)
%User{reset_password_token: reset_password_token} = Users.get_user!(user.id) %User{reset_password_token: reset_password_token} = Users.get_user!(user.id)
@ -772,6 +784,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
context.conn context.conn
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> 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) assert json_response(res, 200)["data"]["resetPassword"]["user"]["id"] == to_string(user.id)
end end
@ -829,7 +842,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
end end
describe "Resolver: Login a user" do 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} = Users.register(%{email: "toto@tata.tld", password: "p4ssw0rd"})
{:ok, %User{} = _user} = {:ok, %User{} = _user} =
@ -839,30 +852,18 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
"confirmation_token" => nil "confirmation_token" => nil
}) })
mutation = """
mutation {
login(
email: "#{user.email}",
password: "#{user.password}",
) {
accessToken,
refreshToken,
user {
id
}
}
}
"""
res = res =
context.conn conn
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> 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"]) assert Map.has_key?(login, "accessToken") && not is_nil(login["accessToken"])
end 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} = Users.register(%{email: "toto@tata.tld", password: "p4ssw0rd"})
{:ok, %User{} = _user} = {:ok, %User{} = _user} =
@ -872,79 +873,40 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
"confirmation_token" => nil "confirmation_token" => nil
}) })
mutation = """
mutation {
login(
email: "#{user.email}",
password: "bad password",
) {
accessToken,
user {
default_actor {
preferred_username,
}
}
}
}
"""
res = res =
context.conn conn
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> 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." "Impossible to authenticate, either your email or password are invalid."
end end
test "test login_user/3 with invalid email", context do test "test login_user/3 with invalid email", %{conn: conn} do
mutation = """
mutation {
login(
email: "bad email",
password: "bad password",
) {
accessToken,
user {
default_actor {
preferred_username,
}
}
}
}
"""
res = res =
context.conn conn
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> 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" "No user with this email was found"
end 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"}) {: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 = res =
context.conn conn
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> 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
end end
@ -970,7 +932,7 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
test "test refresh_token/3 with an appropriate token", context do test "test refresh_token/3 with an appropriate token", context do
user = insert(:user) user = insert(:user)
{:ok, refresh_token} = Users.generate_refresh_token(user) {:ok, refresh_token} = Authenticator.generate_refresh_token(user)
mutation = """ mutation = """
mutation { mutation {
@ -1441,6 +1403,18 @@ defmodule Mobilizon.GraphQL.Resolvers.UserTest do
assert is_nil(Events.get_participant(participant_id)) assert is_nil(Events.get_participant(participant_id))
end 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 test "delete_account/3 with invalid password", %{conn: conn} do
{:ok, %User{} = user} = Users.register(%{email: @email, password: @password}) {:ok, %User{} = user} = Users.register(%{email: @email, password: @password})

View file

@ -72,14 +72,6 @@ defmodule Mobilizon.UsersTest do
@email "email@domain.tld" @email "email@domain.tld"
@password "password" @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 test "get_user_by_email/1 finds an user by its email" do
{:ok, %User{email: email} = user} = Users.register(%{email: @email, password: @password}) {: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, role: :user,
confirmed_at: DateTime.utc_now() |> DateTime.truncate(:second), confirmed_at: DateTime.utc_now() |> DateTime.truncate(:second),
confirmation_sent_at: nil, confirmation_sent_at: nil,
confirmation_token: nil confirmation_token: nil,
provider: nil
} }
end end

View file

@ -7,6 +7,7 @@ defmodule Mobilizon.Tests.Helpers do
@moduledoc """ @moduledoc """
Helpers for use in tests. Helpers for use in tests.
""" """
alias Mobilizon.Config
defmacro clear_config(config_path) do defmacro clear_config(config_path) do
quote do quote do
@ -17,13 +18,19 @@ defmodule Mobilizon.Tests.Helpers do
defmacro clear_config(config_path, do: yield) do defmacro clear_config(config_path, do: yield) do
quote do quote do
setup do initial_setting = Config.get(unquote(config_path))
initial_setting = Mobilizon.Config.get(unquote(config_path))
unquote(yield) unquote(yield)
on_exit(fn -> Mobilizon.Config.put(unquote(config_path), initial_setting) end) on_exit(fn -> Config.put(unquote(config_path), initial_setting) end)
:ok :ok
end end
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 end
defmacro __using__(_opts) do defmacro __using__(_opts) do

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