Introduce application tokens
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
parent
39768693c5
commit
2ee329ff7b
|
@ -13,4 +13,20 @@ B9AF8A342CD7FF39E10CC10A408C28E1
|
|||
C042E87389F7BDCFF4E076E95731AE69
|
||||
C42BFAEF7100F57BED75998B217C857A
|
||||
D11958E86F1B6D37EF656B63405CA8A4
|
||||
F16F054F2628609A726B9FF2F089D484
|
||||
F16F054F2628609A726B9FF2F089D484
|
||||
26E816A7B054CB0347A2C6451F03B92B
|
||||
2B76BDDB2BB4D36D69FAE793EBD63894
|
||||
301A837DE24C6AEE1DA812DF9E5486C1
|
||||
395A2740CB468F93F6EBE6E90EE08291
|
||||
4013C9866943B9381D9F9F97027F88A9
|
||||
4C796DD588A4B1C98E86BBCD0349949A
|
||||
51289D8D7BDB59CB6473E0DED0591ED7
|
||||
5A70DC86895DB3610C605EA9F31ED300
|
||||
705C17F9C852F546D886B20DB2C4D0D1
|
||||
75D2074B6F771BA8C032008EC18CABDF
|
||||
7B1C6E35A374C38FF5F07DBF23B3EAE2
|
||||
955ACF52ADD8FCAA450FB8138CB1FD1A
|
||||
A092A563729E1F2C1C8D5D809A31F754
|
||||
BFA12FDEDEAD7DEAB6D44DF6FDFBD5E1
|
||||
D9A08930F140F9BA494BB90B3F812C87
|
||||
FE1EEB91EA633570F703B251AE2D4D4E
|
|
@ -17,6 +17,10 @@
|
|||
:title="t('Notifications')"
|
||||
:to="{ name: RouteName.NOTIFICATIONS }"
|
||||
/>
|
||||
<SettingMenuItem
|
||||
:title="t('Apps')"
|
||||
:to="{ name: RouteName.AUTHORIZED_APPS }"
|
||||
/>
|
||||
</SettingMenuSection>
|
||||
<SettingMenuSection
|
||||
:title="t('Profiles')"
|
||||
|
|
55
js/src/graphql/application.ts
Normal file
55
js/src/graphql/application.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
import gql from "graphql-tag";
|
||||
|
||||
export const AUTH_APPLICATION = gql`
|
||||
query AuthApplication($clientId: String!) {
|
||||
authApplication(clientId: $clientId) {
|
||||
clientId
|
||||
name
|
||||
website
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const AUTORIZE_APPLICATION = gql`
|
||||
mutation AuthorizeApplication(
|
||||
$applicationClientId: String!
|
||||
$redirectURI: String!
|
||||
$state: String
|
||||
$scope: String
|
||||
) {
|
||||
authorizeApplication(
|
||||
clientId: $applicationClientId
|
||||
redirectURI: $redirectURI
|
||||
state: $state
|
||||
scope: $scope
|
||||
) {
|
||||
code
|
||||
state
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const AUTH_AUTHORIZED_APPLICATIONS = gql`
|
||||
query AuthAuthorizedApplications {
|
||||
loggedUser {
|
||||
id
|
||||
authAuthorizedApplications {
|
||||
id
|
||||
application {
|
||||
name
|
||||
website
|
||||
}
|
||||
lastUsedAt
|
||||
insertedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REVOKED_AUTHORIZED_APPLICATION = gql`
|
||||
mutation RevokeApplicationToken($appTokenId: String!) {
|
||||
revokeApplicationToken(appTokenId: $appTokenId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
|
@ -1453,5 +1453,9 @@
|
|||
"Report as ham": "Report as ham",
|
||||
"Report as undetected spam": "Report as undetected spam",
|
||||
"The report contents (eventual comments and event) and the reported profile details will be transmitted to Akismet.": "The report contents (eventual comments and event) and the reported profile details will be transmitted to Akismet.",
|
||||
"Submit to Akismet": "Submit to Akismet"
|
||||
"Submit to Akismet": "Submit to Akismet",
|
||||
"Autorize this application to access your account?": "Autorize this application to access your account?",
|
||||
"This application will be able to access all of your informations and post content on your behalf. Make sure you only approve applications you trust.": "This application will be able to access all of your informations and post content on your behalf. Make sure you only approve applications you trust.",
|
||||
"Authorize application": "Authorize application",
|
||||
"Authorize": "Authorize"
|
||||
}
|
|
@ -1451,5 +1451,9 @@
|
|||
"{username} was invited to {group}": "{username} a été invité à {group}",
|
||||
"{user}'s follow request was accepted": "La demande de suivi de {user} a été acceptée",
|
||||
"{user}'s follow request was rejected": "La demande de suivi de {user} a été rejetée",
|
||||
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
|
||||
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
|
||||
"Autorize this application to access your account?": "Autoriser cette application à accéder à votre compte ?",
|
||||
"This application will be able to access all of your informations and post content on your behalf. Make sure you only approve applications you trust.": "Cette application sera capable d'accéder à toutes vos informations et poster du contenu en votre nom. Assurez-vous d'approuver uniquement des applications en lesquelles vous avez confiance.",
|
||||
"Authorize application": "Autoriser l'application",
|
||||
"Authorize": "Autoriser"
|
||||
}
|
||||
|
|
|
@ -27,6 +27,7 @@ export enum SettingsRouteName {
|
|||
CREATE_IDENTITY = "CreateIdentity",
|
||||
UPDATE_IDENTITY = "UpdateIdentity",
|
||||
IDENTITIES = "IDENTITIES",
|
||||
AUTHORIZED_APPS = "AUTHORIZED_APPS",
|
||||
}
|
||||
|
||||
export const settingsRoutes: RouteRecordRaw[] = [
|
||||
|
@ -84,6 +85,18 @@ export const settingsRoutes: RouteRecordRaw[] = [
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "authorized-apps",
|
||||
name: SettingsRouteName.AUTHORIZED_APPS,
|
||||
component: (): Promise<any> => import("@/views/Settings/AppsView.vue"),
|
||||
props: true,
|
||||
meta: {
|
||||
requiredAuth: true,
|
||||
announcer: {
|
||||
message: (): string => t("Apps") as string,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
name: SettingsRouteName.ADMIN,
|
||||
|
|
|
@ -13,6 +13,7 @@ export enum UserRouteName {
|
|||
EMAIL_VALIDATE = "EMAIL_VALIDATE",
|
||||
VALIDATE = "Validate",
|
||||
LOGIN = "Login",
|
||||
OAUTH_AUTORIZE = "OAUTH_AUTORIZE",
|
||||
}
|
||||
|
||||
export const userRoutes: RouteRecordRaw[] = [
|
||||
|
@ -108,4 +109,15 @@ export const userRoutes: RouteRecordRaw[] = [
|
|||
announcer: { message: (): string => t("Login") as string },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/oauth/autorize_approve",
|
||||
name: UserRouteName.OAUTH_AUTORIZE,
|
||||
component: (): Promise<any> => import("@/views/OAuth/AuthorizeView.vue"),
|
||||
meta: {
|
||||
requiredAuth: true,
|
||||
announcer: {
|
||||
message: (): string => t("Authorize application") as string,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
15
js/src/types/application.model.ts
Normal file
15
js/src/types/application.model.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
export interface IApplication {
|
||||
name: string;
|
||||
clientId: string;
|
||||
clientSecret?: string;
|
||||
redirectUris?: string;
|
||||
scopes: string | null;
|
||||
website: string | null;
|
||||
}
|
||||
|
||||
export interface IApplicationToken {
|
||||
id: string;
|
||||
application: IApplication;
|
||||
lastUsedAt: string;
|
||||
insertedAt: string;
|
||||
}
|
|
@ -7,6 +7,7 @@ import { IFollowedGroupEvent } from "./followedGroupEvent.model";
|
|||
import { PictureInformation } from "./picture";
|
||||
import { IMember } from "./actor/member.model";
|
||||
import { IFeedToken } from "./feedtoken.model";
|
||||
import { IApplicationToken } from "./application.model";
|
||||
|
||||
export interface ICurrentUser {
|
||||
id: string;
|
||||
|
@ -66,4 +67,5 @@ export interface IUser extends ICurrentUser {
|
|||
currentSignInAt: string;
|
||||
memberships: Paginate<IMember>;
|
||||
feedTokens: IFeedToken[];
|
||||
authAuthorizedApplications: IApplicationToken[];
|
||||
}
|
||||
|
|
191
js/src/views/OAuth/AuthorizeView.vue
Normal file
191
js/src/views/OAuth/AuthorizeView.vue
Normal file
|
@ -0,0 +1,191 @@
|
|||
<template>
|
||||
<div class="container mx-auto w-96">
|
||||
<div v-show="authApplicationLoading && !resultCode">
|
||||
<o-skeleton active size="large" class="mt-6" />
|
||||
<o-skeleton active width="80%" />
|
||||
<div
|
||||
class="rounded-lg bg-mbz-warning shadow-xl my-6 p-4 flex items-center gap-2"
|
||||
>
|
||||
<div>
|
||||
<o-skeleton circle active width="42px" height="42px" />
|
||||
</div>
|
||||
<div class="w-full">
|
||||
<o-skeleton active />
|
||||
<o-skeleton active />
|
||||
<o-skeleton active />
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg bg-white shadow-xl my-6">
|
||||
<div class="p-4 pb-0">
|
||||
<p class="text-3xl"><o-skeleton active size="large" /></p>
|
||||
<o-skeleton active width="40%" />
|
||||
</div>
|
||||
<div class="flex gap-3 p-4">
|
||||
<o-skeleton active />
|
||||
<o-skeleton active />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-show="!authApplicationLoading && !authApplicationError && !resultCode"
|
||||
>
|
||||
<h1 class="text-3xl">
|
||||
{{ t("Autorize this application to access your account?") }}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
class="rounded-lg bg-mbz-warning shadow-xl my-6 p-4 flex items-center gap-2"
|
||||
>
|
||||
<AlertCircle :size="42" />
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"This application will be able to access all of your informations and post content on your behalf. Make sure you only approve applications you trust."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-white shadow-xl my-6">
|
||||
<div class="p-4 pb-0">
|
||||
<p class="text-3xl font-bold">{{ authApplication?.name }}</p>
|
||||
<p>{{ authApplication?.website }}</p>
|
||||
</div>
|
||||
<div class="flex gap-3 p-4">
|
||||
<o-button @click="() => authorize()">{{ t("Authorize") }}</o-button>
|
||||
<o-button outlined tag="router-link" :to="{ name: RouteName.HOME }">{{
|
||||
t("Decline")
|
||||
}}</o-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="authApplicationError">
|
||||
<div
|
||||
class="rounded-lg bg-mbz-danger shadow-xl my-6 p-4 flex items-center gap-2"
|
||||
v-if="authApplicationGraphError?.status_code === 404"
|
||||
>
|
||||
<AlertCircle :size="42" />
|
||||
<div>
|
||||
<p class="font-bold">
|
||||
{{ t("Application not found") }}
|
||||
</p>
|
||||
<p>{{ t("The provided application was not found.") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<o-button
|
||||
variant="text"
|
||||
tag="router-link"
|
||||
:to="{ name: RouteName.HOME }"
|
||||
>{{ t("Back to homepage") }}</o-button
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="resultCode"
|
||||
class="rounded-lg bg-white shadow-xl my-6 p-4 flex items-center gap-2"
|
||||
>
|
||||
<div>
|
||||
<p class="font-bold">
|
||||
{{ t("Your application code") }}
|
||||
</p>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"You need to provide the following code to your application. It will only be valid for a few minutes."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<p class="text-4xl">{{ resultCode }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<o-button variant="text" tag="router-link" :to="{ name: RouteName.HOME }">{{
|
||||
t("Back to homepage")
|
||||
}}</o-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRouteQuery } from "vue-use-route-query";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { AUTH_APPLICATION, AUTORIZE_APPLICATION } from "@/graphql/application";
|
||||
import { IApplication } from "@/types/application.model";
|
||||
import AlertCircle from "vue-material-design-icons/AlertCircle.vue";
|
||||
import type { AbsintheGraphQLError } from "@/types/errors.model";
|
||||
import RouteName from "@/router/name";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const clientId = useRouteQuery("client_id", null);
|
||||
const redirectURI = useRouteQuery("redirect_uri", null);
|
||||
const state = useRouteQuery("state", null);
|
||||
const scope = useRouteQuery("scope", null);
|
||||
|
||||
const OUT_OF_BAND_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob";
|
||||
const resultCode = ref<string | null>(null);
|
||||
|
||||
const {
|
||||
result: authApplicationResult,
|
||||
loading: authApplicationLoading,
|
||||
error: authApplicationError,
|
||||
} = useQuery<{ authApplication: IApplication }, { clientId: string }>(
|
||||
AUTH_APPLICATION,
|
||||
() => ({
|
||||
clientId: clientId.value as string,
|
||||
}),
|
||||
() => ({
|
||||
enabled: clientId.value !== null,
|
||||
})
|
||||
);
|
||||
|
||||
const authApplication = computed(
|
||||
() => authApplicationResult.value?.authApplication
|
||||
);
|
||||
|
||||
const authApplicationGraphError = computed(
|
||||
() => authApplicationError.value?.graphQLErrors[0] as AbsintheGraphQLError
|
||||
);
|
||||
|
||||
const { mutate: authorizeMutation, onDone: onAuthorizeMutationDone } =
|
||||
useMutation<
|
||||
{ authorizeApplication: { code: string; state: string } },
|
||||
{
|
||||
applicationClientId: string;
|
||||
redirectURI: string;
|
||||
state?: string | null;
|
||||
scope?: string | null;
|
||||
}
|
||||
>(AUTORIZE_APPLICATION);
|
||||
|
||||
const authorize = () => {
|
||||
authorizeMutation({
|
||||
applicationClientId: clientId.value as string,
|
||||
redirectURI: redirectURI.value as string,
|
||||
state: state.value,
|
||||
scope: scope.value,
|
||||
});
|
||||
};
|
||||
|
||||
onAuthorizeMutationDone(({ data }) => {
|
||||
const code = data?.authorizeApplication?.code;
|
||||
const returnedState = data?.authorizeApplication?.state ?? "";
|
||||
|
||||
if (!code) return;
|
||||
|
||||
if (redirectURI.value !== OUT_OF_BAND_REDIRECT_URI) {
|
||||
const params = new URLSearchParams(
|
||||
Object.entries({ code, state: returnedState })
|
||||
);
|
||||
window.location.assign(
|
||||
new URL(`${redirectURI.value}?${params.toString()}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
resultCode.value = code;
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Authorize application")),
|
||||
});
|
||||
</script>
|
138
js/src/views/Settings/AppsView.vue
Normal file
138
js/src/views/Settings/AppsView.vue
Normal file
|
@ -0,0 +1,138 @@
|
|||
<template>
|
||||
<div v-if="loggedUser">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.AUTHORIZED_APPS,
|
||||
text: t('Apps'),
|
||||
},
|
||||
{
|
||||
name: RouteName.ACCOUNT_SETTINGS_GENERAL,
|
||||
text: t('General'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<section>
|
||||
<h2>{{ t("Apps") }}</h2>
|
||||
<p>
|
||||
{{
|
||||
t(
|
||||
"These apps can access your account through the API. If you see here apps that you don't recognize, that don't work as expected or that you don't use anymore, you can revoke their access."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div
|
||||
class="flex justify-between items-center rounded-lg bg-white shadow-xl my-6"
|
||||
v-for="authAuthorizedApplication in authAuthorizedApplications"
|
||||
:key="authAuthorizedApplication.id"
|
||||
>
|
||||
<div class="p-4">
|
||||
<p class="text-3xl font-bold">
|
||||
{{ authAuthorizedApplication.application.name }}
|
||||
</p>
|
||||
<a
|
||||
v-if="authAuthorizedApplication.application.website"
|
||||
target="_blank"
|
||||
:href="authAuthorizedApplication.application.website"
|
||||
>{{
|
||||
urlToHostname(authAuthorizedApplication.application.website)
|
||||
}}</a
|
||||
>
|
||||
<p>
|
||||
<span v-if="authAuthorizedApplication.lastUsedAt">{{
|
||||
t("Last used on {last_used_date}", {
|
||||
last_used_date: formatDateString(
|
||||
authAuthorizedApplication.lastUsedAt
|
||||
),
|
||||
})
|
||||
}}</span>
|
||||
<span v-else>{{ t("Never used") }}</span> ⋅
|
||||
{{
|
||||
t("Authorized on {authorization_date}", {
|
||||
authorization_date: formatDateString(
|
||||
authAuthorizedApplication.insertedAt
|
||||
),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<o-button
|
||||
@click="() => revoke({ appTokenId: authAuthorizedApplication.id })"
|
||||
variant="danger"
|
||||
>{{ t("Revoke") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useLoggedUser } from "@/composition/apollo/user";
|
||||
import {
|
||||
AUTH_AUTHORIZED_APPLICATIONS,
|
||||
REVOKED_AUTHORIZED_APPLICATION,
|
||||
} from "@/graphql/application";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import RouteName from "../../router/name";
|
||||
import { IUser } from "@/types/current-user.model";
|
||||
import { formatDateString } from "@/filters/datetime";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const { loggedUser } = useLoggedUser();
|
||||
|
||||
const { result: authAuthorizedApplicationsResult } = useQuery<{
|
||||
loggedUser: Pick<IUser, "authAuthorizedApplications">;
|
||||
}>(AUTH_AUTHORIZED_APPLICATIONS);
|
||||
|
||||
const authAuthorizedApplications = computed(
|
||||
() =>
|
||||
authAuthorizedApplicationsResult.value?.loggedUser
|
||||
?.authAuthorizedApplications
|
||||
);
|
||||
|
||||
const urlToHostname = (url: string | undefined): string | null => {
|
||||
if (!url) return null;
|
||||
try {
|
||||
return new URL(url).hostname;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const { mutate: revoke, onDone: onRevokedApplication } = useMutation<
|
||||
{ revokeApplicationToken: { id: string } },
|
||||
{ appTokenId: string }
|
||||
>(REVOKED_AUTHORIZED_APPLICATION, {
|
||||
update: (cache, { data: returnedData }) => {
|
||||
const data = cache.readQuery<{
|
||||
loggedUser: Pick<IUser, "authAuthorizedApplications">;
|
||||
}>({ query: AUTH_AUTHORIZED_APPLICATIONS });
|
||||
if (!data) return;
|
||||
if (!returnedData) return;
|
||||
const authorizedApplications =
|
||||
data.loggedUser.authAuthorizedApplications.filter(
|
||||
(app) => app.id !== returnedData.revokeApplicationToken.id
|
||||
);
|
||||
cache.writeQuery({
|
||||
query: AUTH_AUTHORIZED_APPLICATIONS,
|
||||
data: {
|
||||
...data,
|
||||
loggedUser: {
|
||||
...data.loggedUser,
|
||||
authAuthorizedApplications: authorizedApplications,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("Apps")),
|
||||
});
|
||||
</script>
|
92
lib/graphql/resolvers/application.ex
Normal file
92
lib/graphql/resolvers/application.ex
Normal file
|
@ -0,0 +1,92 @@
|
|||
defmodule Mobilizon.GraphQL.Resolvers.Application do
|
||||
@moduledoc """
|
||||
Handles the Application-related GraphQL calls.
|
||||
"""
|
||||
|
||||
alias Mobilizon.Applications, as: ApplicationManager
|
||||
alias Mobilizon.Applications.{Application, ApplicationToken}
|
||||
alias Mobilizon.Service.Auth.Applications
|
||||
alias Mobilizon.Users.User
|
||||
import Mobilizon.Web.Gettext, only: [dgettext: 2]
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Create an application
|
||||
"""
|
||||
@spec authorize(any(), map(), Absinthe.Resolution.t()) :: {:ok, map()} | {:error, String.t()}
|
||||
def authorize(
|
||||
_parent,
|
||||
%{client_id: client_id, redirect_uri: redirect_uri, scope: scope, state: state},
|
||||
%{context: %{current_user: %User{id: user_id}}}
|
||||
) do
|
||||
case Applications.autorize(client_id, redirect_uri, scope, user_id) do
|
||||
{:ok, code} ->
|
||||
{:ok, %{code: code, state: state}}
|
||||
|
||||
{:error, :application_not_found} ->
|
||||
{:error,
|
||||
dgettext(
|
||||
"errors",
|
||||
"No application with this client_id was found"
|
||||
)}
|
||||
|
||||
{:error, :redirect_uri_not_in_allowed} ->
|
||||
{:error,
|
||||
dgettext(
|
||||
"errors",
|
||||
"The given redirect_uri is not in the list of allowed redirect URIs"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
def authorize(_parent, _args, _context) do
|
||||
{:error, dgettext("errors", "You need to be logged-in to autorize applications")}
|
||||
end
|
||||
|
||||
@spec get_application(any(), map(), Absinthe.Resolution.t()) ::
|
||||
{:ok, Application.t()} | {:error, :not_found | :unauthenticated}
|
||||
def get_application(_parent, %{client_id: client_id}, %{context: %{current_user: %User{}}}) do
|
||||
case ApplicationManager.get_application_by_client_id(client_id) do
|
||||
%Application{} = application ->
|
||||
{:ok, application}
|
||||
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
def get_application(_parent, _args, _resolution) do
|
||||
{:error, :unauthenticated}
|
||||
end
|
||||
|
||||
def get_user_applications(_parent, _args, %{context: %{current_user: %User{id: user_id}}}) do
|
||||
{:ok, ApplicationManager.list_application_tokens_for_user_id(user_id)}
|
||||
end
|
||||
|
||||
def get_user_applications(_parent, _args, _resolution) do
|
||||
{:error, :unauthenticated}
|
||||
end
|
||||
|
||||
def revoke_application_token(_parent, %{app_token_id: app_token_id}, %{
|
||||
context: %{current_user: %User{id: user_id}}
|
||||
}) do
|
||||
case ApplicationManager.get_application_token(app_token_id) do
|
||||
%ApplicationToken{user_id: ^user_id} = app_token ->
|
||||
case Applications.revoke_application_token(app_token) do
|
||||
{:ok, %{delete_app_token: app_token, delete_guardian_tokens: _delete_guardian_tokens}} ->
|
||||
{:ok, %{id: app_token.id}}
|
||||
|
||||
{:error, _, _, _} ->
|
||||
{:error, dgettext("errors", "Error while revoking token")}
|
||||
end
|
||||
|
||||
_ ->
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
def revoke_application_token(_parent, _args, _resolution) do
|
||||
{:error, :unauthenticated}
|
||||
end
|
||||
end
|
|
@ -53,6 +53,7 @@ defmodule Mobilizon.GraphQL.Schema do
|
|||
import_types(Schema.Users.PushSubscription)
|
||||
import_types(Schema.Users.ActivitySetting)
|
||||
import_types(Schema.FollowedGroupActivityType)
|
||||
import_types(Schema.AuthApplicationType)
|
||||
|
||||
@desc "A struct containing the id of the deleted object"
|
||||
object :deleted_object do
|
||||
|
@ -161,6 +162,7 @@ defmodule Mobilizon.GraphQL.Schema do
|
|||
import_fields(:resource_queries)
|
||||
import_fields(:post_queries)
|
||||
import_fields(:statistics_queries)
|
||||
import_fields(:auth_application_queries)
|
||||
end
|
||||
|
||||
@desc """
|
||||
|
@ -187,6 +189,7 @@ defmodule Mobilizon.GraphQL.Schema do
|
|||
import_fields(:follower_mutations)
|
||||
import_fields(:push_mutations)
|
||||
import_fields(:activity_setting_mutations)
|
||||
import_fields(:auth_application_mutations)
|
||||
end
|
||||
|
||||
@desc """
|
||||
|
|
61
lib/graphql/schema/auth_application.ex
Normal file
61
lib/graphql/schema/auth_application.ex
Normal file
|
@ -0,0 +1,61 @@
|
|||
defmodule Mobilizon.GraphQL.Schema.AuthApplicationType do
|
||||
@moduledoc """
|
||||
Schema representation for an auth application
|
||||
"""
|
||||
use Absinthe.Schema.Notation
|
||||
alias Mobilizon.GraphQL.Resolvers.Application
|
||||
|
||||
@desc "An application"
|
||||
object :auth_application do
|
||||
field(:name, :string)
|
||||
field(:client_id, :string)
|
||||
field(:scopes, :string)
|
||||
field(:website, :string)
|
||||
end
|
||||
|
||||
@desc "An application"
|
||||
object :auth_application_token do
|
||||
field(:id, :id)
|
||||
field(:inserted_at, :string)
|
||||
field(:last_used_at, :string)
|
||||
field(:application, :auth_application)
|
||||
end
|
||||
|
||||
@desc "The informations returned after authorization"
|
||||
object :application_code_and_state do
|
||||
field(:code, :string)
|
||||
field(:state, :string)
|
||||
end
|
||||
|
||||
object :auth_application_queries do
|
||||
@desc "Get an application"
|
||||
field :auth_application, :auth_application do
|
||||
arg(:client_id, non_null(:string), description: "The application's client_id")
|
||||
resolve(&Application.get_application/3)
|
||||
end
|
||||
end
|
||||
|
||||
object :auth_application_mutations do
|
||||
@desc "Authorize an application"
|
||||
field :authorize_application, :application_code_and_state do
|
||||
arg(:client_id, non_null(:string), description: "The application's client_id")
|
||||
|
||||
arg(:redirect_uri, non_null(:string),
|
||||
description: "The URI to redirect to with the code and state"
|
||||
)
|
||||
|
||||
arg(:scope, :string, description: "The scope for the authorization")
|
||||
|
||||
arg(:state, :string,
|
||||
description: "A state parameter to check that the request wasn't altered"
|
||||
)
|
||||
|
||||
resolve(&Application.authorize/3)
|
||||
end
|
||||
|
||||
field :revoke_application_token, :deleted_object do
|
||||
arg(:app_token_id, non_null(:string), description: "The application token's ID")
|
||||
resolve(&Application.revoke_application_token/3)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -7,7 +7,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
|
|||
import Absinthe.Resolution.Helpers, only: [dataloader: 2]
|
||||
|
||||
alias Mobilizon.Events
|
||||
alias Mobilizon.GraphQL.Resolvers.{Media, User}
|
||||
alias Mobilizon.GraphQL.Resolvers.{Application, Media, User}
|
||||
alias Mobilizon.GraphQL.Resolvers.Users.ActivitySettings
|
||||
alias Mobilizon.GraphQL.Schema
|
||||
|
||||
|
@ -161,6 +161,11 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
|
|||
resolve: &ActivitySettings.user_activity_settings/3,
|
||||
description: "The user's activity settings"
|
||||
)
|
||||
|
||||
field(:auth_authorized_applications, list_of(:auth_application_token),
|
||||
resolve: &Application.get_user_applications/3,
|
||||
description: "The user's authorized authentication apps"
|
||||
)
|
||||
end
|
||||
|
||||
@desc "The list of roles an user can have"
|
||||
|
|
258
lib/mobilizon/applications.ex
Normal file
258
lib/mobilizon/applications.ex
Normal file
|
@ -0,0 +1,258 @@
|
|||
defmodule Mobilizon.Applications do
|
||||
@moduledoc """
|
||||
The Applications context.
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Ecto.Multi
|
||||
alias Mobilizon.Applications.Application
|
||||
alias Mobilizon.Storage.Repo
|
||||
|
||||
@doc """
|
||||
Returns the list of applications.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_applications()
|
||||
[%Application{}, ...]
|
||||
|
||||
"""
|
||||
def list_applications do
|
||||
Repo.all(Application)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single application.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Application does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_application!(123)
|
||||
%Application{}
|
||||
|
||||
iex> get_application!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_application!(id), do: Repo.get!(Application, id)
|
||||
|
||||
@doc """
|
||||
Gets a single application.
|
||||
|
||||
Returns nil if the Application does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_application_by_client_id(123)
|
||||
%Application{}
|
||||
|
||||
iex> get_application_by_client_id(456)
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_application_by_client_id(client_id), do: Repo.get_by(Application, client_id: client_id)
|
||||
|
||||
@doc """
|
||||
Creates a application.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_application(%{field: value})
|
||||
{:ok, %Application{}}
|
||||
|
||||
iex> create_application(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_application(attrs \\ %{}) do
|
||||
%Application{}
|
||||
|> Application.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a application.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_application(application, %{field: new_value})
|
||||
{:ok, %Application{}}
|
||||
|
||||
iex> update_application(application, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_application(%Application{} = application, attrs) do
|
||||
application
|
||||
|> Application.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a application.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_application(application)
|
||||
{:ok, %Application{}}
|
||||
|
||||
iex> delete_application(application)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_application(%Application{} = application) do
|
||||
Repo.delete(application)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking application changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_application(application)
|
||||
%Ecto.Changeset{data: %Application{}}
|
||||
|
||||
"""
|
||||
def change_application(%Application{} = application, attrs \\ %{}) do
|
||||
Application.changeset(application, attrs)
|
||||
end
|
||||
|
||||
alias Mobilizon.Applications.ApplicationToken
|
||||
|
||||
@doc """
|
||||
Returns the list of application_tokens.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_application_tokens()
|
||||
[%ApplicationToken{}, ...]
|
||||
|
||||
"""
|
||||
def list_application_tokens do
|
||||
Repo.all(ApplicationToken)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of application tokens for a given user_id
|
||||
"""
|
||||
def list_application_tokens_for_user_id(user_id) do
|
||||
ApplicationToken
|
||||
|> where(user_id: ^user_id)
|
||||
|> where([at], is_nil(at.authorization_code))
|
||||
|> preload(:application)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single application_token.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Application token does not exist.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_application_token!(123)
|
||||
%ApplicationToken{}
|
||||
|
||||
iex> get_application_token!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_application_token!(id), do: Repo.get!(ApplicationToken, id)
|
||||
|
||||
@doc """
|
||||
Gets a single application_token.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_application_token(123)
|
||||
%ApplicationToken{}
|
||||
|
||||
iex> get_application_token(456)
|
||||
nil
|
||||
|
||||
"""
|
||||
def get_application_token(application_token_id),
|
||||
do: Repo.get(ApplicationToken, application_token_id)
|
||||
|
||||
def get_application_token(app_id, user_id),
|
||||
do: Repo.get_by(ApplicationToken, application_id: app_id, user_id: user_id)
|
||||
|
||||
def get_application_token_by_authorization_code(code),
|
||||
do: Repo.get_by(ApplicationToken, authorization_code: code)
|
||||
|
||||
@doc """
|
||||
Creates a application_token.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_application_token(%{field: value})
|
||||
{:ok, %ApplicationToken{}}
|
||||
|
||||
iex> create_application_token(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_application_token(attrs \\ %{}) do
|
||||
%ApplicationToken{}
|
||||
|> ApplicationToken.changeset(attrs)
|
||||
|> Repo.insert(on_conflict: :replace_all, conflict_target: [:user_id, :application_id])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a application_token.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_application_token(application_token, %{field: new_value})
|
||||
{:ok, %ApplicationToken{}}
|
||||
|
||||
iex> update_application_token(application_token, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_application_token(%ApplicationToken{} = application_token, attrs) do
|
||||
application_token
|
||||
|> ApplicationToken.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a application_token.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_application_token(application_token)
|
||||
{:ok, %ApplicationToken{}}
|
||||
|
||||
iex> delete_application_token(application_token)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_application_token(%ApplicationToken{} = application_token) do
|
||||
Repo.delete(application_token)
|
||||
end
|
||||
|
||||
def revoke_application_token(%ApplicationToken{id: app_token_id} = application_token) do
|
||||
Multi.new()
|
||||
|> Multi.delete_all(
|
||||
:delete_guardian_tokens,
|
||||
from(gt in "guardian_tokens", where: gt.sub == ^"AppToken:#{app_token_id}")
|
||||
)
|
||||
|> Multi.delete(:delete_app_token, application_token)
|
||||
|> Repo.transaction()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking application_token changes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_application_token(application_token)
|
||||
%Ecto.Changeset{data: %ApplicationToken{}}
|
||||
|
||||
"""
|
||||
def change_application_token(%ApplicationToken{} = application_token, attrs \\ %{}) do
|
||||
ApplicationToken.changeset(application_token, attrs)
|
||||
end
|
||||
end
|
32
lib/mobilizon/applications/application.ex
Normal file
32
lib/mobilizon/applications/application.ex
Normal file
|
@ -0,0 +1,32 @@
|
|||
defmodule Mobilizon.Applications.Application do
|
||||
@moduledoc """
|
||||
Module representing an application
|
||||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@required_attrs [:name, :client_id, :client_secret, :redirect_uris]
|
||||
@optional_attrs [:scopes, :website, :owner_type, :owner_id]
|
||||
@attrs @required_attrs ++ @optional_attrs
|
||||
|
||||
schema "applications" do
|
||||
field(:name, :string)
|
||||
field(:client_id, :string)
|
||||
field(:client_secret, :string)
|
||||
field(:redirect_uris, :string)
|
||||
field(:scopes, :string)
|
||||
field(:website, :string)
|
||||
field(:owner_type, :string)
|
||||
field(:owner_id, :integer)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(application, attrs) do
|
||||
application
|
||||
|> cast(attrs, @attrs)
|
||||
|> validate_required(@required_attrs)
|
||||
end
|
||||
end
|
26
lib/mobilizon/applications/application_token.ex
Normal file
26
lib/mobilizon/applications/application_token.ex
Normal file
|
@ -0,0 +1,26 @@
|
|||
defmodule Mobilizon.Applications.ApplicationToken do
|
||||
@moduledoc """
|
||||
Module representing an application token
|
||||
"""
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "application_tokens" do
|
||||
belongs_to(:user, Mobilizon.Users.User)
|
||||
belongs_to(:application, Mobilizon.Applications.Application)
|
||||
field(:authorization_code, :string)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@required_attrs [:user_id, :application_id]
|
||||
@optional_attrs [:authorization_code]
|
||||
@attrs @required_attrs ++ @optional_attrs
|
||||
|
||||
@doc false
|
||||
def changeset(application_token, attrs) do
|
||||
application_token
|
||||
|> cast(attrs, @attrs)
|
||||
|> validate_required(@required_attrs)
|
||||
end
|
||||
end
|
130
lib/service/auth/applications.ex
Normal file
130
lib/service/auth/applications.ex
Normal file
|
@ -0,0 +1,130 @@
|
|||
defmodule Mobilizon.Service.Auth.Applications do
|
||||
@moduledoc """
|
||||
Module to handle applications management
|
||||
"""
|
||||
alias Mobilizon.Applications
|
||||
alias Mobilizon.Applications.{Application, ApplicationToken}
|
||||
alias Mobilizon.Service.Auth.Authenticator
|
||||
|
||||
@app_access_tokens_ttl {8, :hour}
|
||||
@app_refresh_tokens_ttl {26, :week}
|
||||
|
||||
@type access_token_details :: %{
|
||||
required(:access_token) => String.t(),
|
||||
required(:expires_in) => pos_integer(),
|
||||
required(:refresh_token) => String.t(),
|
||||
required(:refresh_token_expires_in) => pos_integer(),
|
||||
required(:scope) => nil,
|
||||
required(:token_type) => String.t()
|
||||
}
|
||||
|
||||
def create(name, redirect_uris, scopes, website) do
|
||||
client_id = :crypto.strong_rand_bytes(42) |> Base.encode64() |> binary_part(0, 42)
|
||||
client_secret = :crypto.strong_rand_bytes(42) |> Base.encode64() |> binary_part(0, 42)
|
||||
|
||||
Applications.create_application(%{
|
||||
name: name,
|
||||
redirect_uris: redirect_uris,
|
||||
scopes: scopes,
|
||||
website: website,
|
||||
client_id: client_id,
|
||||
client_secret: client_secret
|
||||
})
|
||||
end
|
||||
|
||||
@spec autorize(String.t(), String.t(), String.t(), integer()) ::
|
||||
{:ok, String.t()}
|
||||
| {:error, :application_not_found}
|
||||
| {:error, :redirect_uri_not_in_allowed}
|
||||
def autorize(client_id, redirect_uri, _scope, user_id) do
|
||||
with %Application{redirect_uris: redirect_uris, id: app_id} <-
|
||||
Applications.get_application_by_client_id(client_id),
|
||||
{:redirect_uri, true} <-
|
||||
{:redirect_uri, redirect_uri in String.split(redirect_uris, "\n")},
|
||||
code <- :crypto.strong_rand_bytes(16) |> Base.encode64() |> binary_part(0, 16),
|
||||
{:ok, %ApplicationToken{}} <-
|
||||
Applications.create_application_token(%{
|
||||
user_id: user_id,
|
||||
application_id: app_id,
|
||||
authorization_code: code
|
||||
}) do
|
||||
{:ok, code}
|
||||
else
|
||||
nil ->
|
||||
{:error, :application_not_found}
|
||||
|
||||
{:redirect_uri, _} ->
|
||||
{:error, :redirect_uri_not_in_allowed}
|
||||
end
|
||||
end
|
||||
|
||||
@spec generate_access_token(String.t(), String.t(), String.t(), String.t()) ::
|
||||
{:ok, access_token_details()}
|
||||
| {:error,
|
||||
:application_not_found
|
||||
| :redirect_uri_not_in_allowed
|
||||
| :provided_code_does_not_match
|
||||
| :invalid_client_secret
|
||||
| :app_token_not_found
|
||||
| any()}
|
||||
def generate_access_token(client_id, client_secret, code, redirect_uri) do
|
||||
with {:application,
|
||||
%Application{
|
||||
id: application_id,
|
||||
client_secret: app_client_secret,
|
||||
scopes: scopes,
|
||||
redirect_uris: redirect_uris
|
||||
}} <-
|
||||
{:application, Applications.get_application_by_client_id(client_id)},
|
||||
{:redirect_uri, true} <-
|
||||
{:redirect_uri, redirect_uri in String.split(redirect_uris, "\n")},
|
||||
{:app_token, %ApplicationToken{} = app_token} <-
|
||||
{:app_token, Applications.get_application_token_by_authorization_code(code)},
|
||||
{:ok, %ApplicationToken{application_id: application_id_from_token} = app_token} <-
|
||||
Applications.update_application_token(app_token, %{authorization_code: nil}),
|
||||
{:same_app, true} <- {:same_app, application_id === application_id_from_token},
|
||||
{:same_client_secret, true} <- {:same_client_secret, app_client_secret == client_secret},
|
||||
{:ok, access_token} <-
|
||||
Authenticator.generate_access_token(app_token, @app_access_tokens_ttl),
|
||||
{:ok, refresh_token} <-
|
||||
Authenticator.generate_refresh_token(app_token, @app_refresh_tokens_ttl) do
|
||||
{:ok,
|
||||
%{
|
||||
access_token: access_token,
|
||||
expires_in: ttl_to_seconds(@app_access_tokens_ttl),
|
||||
refresh_token: refresh_token,
|
||||
refresh_token_expires_in: ttl_to_seconds(@app_refresh_tokens_ttl),
|
||||
scope: scopes,
|
||||
token_type: "bearer"
|
||||
}}
|
||||
else
|
||||
{:application, nil} ->
|
||||
{:error, :application_not_found}
|
||||
|
||||
{:same_app, false} ->
|
||||
{:error, :provided_code_does_not_match}
|
||||
|
||||
{:same_client_secret, _} ->
|
||||
{:error, :invalid_client_secret}
|
||||
|
||||
{:redirect_uri, _} ->
|
||||
{:error, :redirect_uri_not_in_allowed}
|
||||
|
||||
{:app_token, _} ->
|
||||
{:error, :app_token_not_found}
|
||||
|
||||
{:error, err} ->
|
||||
{:error, err}
|
||||
end
|
||||
end
|
||||
|
||||
def revoke_application_token(%ApplicationToken{} = app_token) do
|
||||
Applications.revoke_application_token(app_token)
|
||||
end
|
||||
|
||||
@spec ttl_to_seconds({pos_integer(), :second | :minute | :hour | :week}) :: pos_integer()
|
||||
defp ttl_to_seconds({value, :second}), do: value
|
||||
defp ttl_to_seconds({value, :minute}), do: value * 60
|
||||
defp ttl_to_seconds({value, :hour}), do: value * 3600
|
||||
defp ttl_to_seconds({value, :week}), do: value * 604_800
|
||||
end
|
|
@ -17,6 +17,11 @@ defmodule Mobilizon.Service.Auth.Authenticator do
|
|||
required(:user) => User.t()
|
||||
}
|
||||
|
||||
@type ttl :: {
|
||||
pos_integer(),
|
||||
:second | :minute | :hour | :week
|
||||
}
|
||||
|
||||
def implementation do
|
||||
Mobilizon.Config.get(
|
||||
Mobilizon.Service.Auth.Authenticator,
|
||||
|
@ -55,7 +60,7 @@ defmodule Mobilizon.Service.Auth.Authenticator do
|
|||
@doc """
|
||||
Generates access token and refresh token for an user.
|
||||
"""
|
||||
@spec generate_tokens(User.t()) :: {:ok, tokens}
|
||||
@spec generate_tokens(User.t() | ApplicationToken.t()) :: {:ok, tokens} | {:error, any()}
|
||||
def generate_tokens(user) do
|
||||
with {:ok, access_token} <- generate_access_token(user),
|
||||
{:ok, refresh_token} <- generate_refresh_token(user) do
|
||||
|
@ -66,10 +71,11 @@ defmodule Mobilizon.Service.Auth.Authenticator do
|
|||
@doc """
|
||||
Generates access token for an user.
|
||||
"""
|
||||
@spec generate_access_token(User.t()) :: {:ok, String.t()}
|
||||
def generate_access_token(user) do
|
||||
@spec generate_access_token(User.t() | ApplicationToken.t(), ttl() | nil) ::
|
||||
{:ok, String.t()} | {:error, any()}
|
||||
def generate_access_token(user, ttl \\ nil) do
|
||||
with {:ok, access_token, _claims} <-
|
||||
Guardian.encode_and_sign(user, %{}, token_type: "access") do
|
||||
Guardian.encode_and_sign(user, %{}, token_type: "access", ttl: ttl) do
|
||||
{:ok, access_token}
|
||||
end
|
||||
end
|
||||
|
@ -77,10 +83,11 @@ defmodule Mobilizon.Service.Auth.Authenticator do
|
|||
@doc """
|
||||
Generates refresh token for an user.
|
||||
"""
|
||||
@spec generate_refresh_token(User.t()) :: {:ok, String.t()}
|
||||
def generate_refresh_token(user) do
|
||||
@spec generate_refresh_token(User.t() | ApplicationToken.t(), ttl() | nil) ::
|
||||
{:ok, String.t()} | {:error, any()}
|
||||
def generate_refresh_token(user, ttl \\ nil) do
|
||||
with {:ok, refresh_token, _claims} <-
|
||||
Guardian.encode_and_sign(user, %{}, token_type: "refresh") do
|
||||
Guardian.encode_and_sign(user, %{}, token_type: "refresh", ttl: ttl) do
|
||||
{:ok, refresh_token}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,6 +6,8 @@ defmodule Mobilizon.Web.Auth.Context do
|
|||
|
||||
import Plug.Conn
|
||||
|
||||
alias Mobilizon.Applications.Application, as: AuthApplication
|
||||
alias Mobilizon.Applications.ApplicationToken
|
||||
alias Mobilizon.Users.User
|
||||
|
||||
@spec init(Plug.opts()) :: Plug.opts()
|
||||
|
@ -28,18 +30,13 @@ defmodule Mobilizon.Web.Auth.Context do
|
|||
|
||||
{conn, context} =
|
||||
case Guardian.Plug.current_resource(conn) do
|
||||
%User{id: user_id, email: user_email} = user ->
|
||||
if Application.get_env(:sentry, :dsn) != nil do
|
||||
Sentry.Context.set_user_context(%{
|
||||
id: user_id,
|
||||
email: user_email,
|
||||
ip_address: context.ip
|
||||
})
|
||||
end
|
||||
%User{} = user ->
|
||||
set_user_context({conn, context}, user)
|
||||
|
||||
context = Map.put(context, :current_user, user)
|
||||
conn = assign(conn, :user_locale, user.locale)
|
||||
{conn, context}
|
||||
%ApplicationToken{user: %User{} = user} = app_token ->
|
||||
conn
|
||||
|> set_app_token_context(context, app_token)
|
||||
|> set_user_context(user)
|
||||
|
||||
nil ->
|
||||
{conn, context}
|
||||
|
@ -49,4 +46,35 @@ defmodule Mobilizon.Web.Auth.Context do
|
|||
|
||||
put_private(conn, :absinthe, %{context: context})
|
||||
end
|
||||
|
||||
defp set_user_context({conn, context}, %User{id: user_id, email: user_email} = user) do
|
||||
if Application.get_env(:sentry, :dsn) != nil do
|
||||
Sentry.Context.set_user_context(%{
|
||||
id: user_id,
|
||||
email: user_email,
|
||||
ip_address: context.ip
|
||||
})
|
||||
end
|
||||
|
||||
context = Map.put(context, :current_user, user)
|
||||
conn = assign(conn, :user_locale, user.locale)
|
||||
{conn, context}
|
||||
end
|
||||
|
||||
defp set_app_token_context(
|
||||
conn,
|
||||
context,
|
||||
%ApplicationToken{application: %AuthApplication{client_id: client_id} = app} = app_token
|
||||
) do
|
||||
if Application.get_env(:sentry, :dsn) != nil do
|
||||
Sentry.Context.set_user_context(%{
|
||||
app_token_client_id: client_id
|
||||
})
|
||||
end
|
||||
|
||||
context =
|
||||
context |> Map.put(:current_auth_app_token, app_token) |> Map.put(:current_auth_app, app)
|
||||
|
||||
{conn, context}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -10,14 +10,19 @@ defmodule Mobilizon.Web.Auth.Guardian do
|
|||
user: [:base]
|
||||
}
|
||||
|
||||
alias Mobilizon.Users
|
||||
alias Mobilizon.{Applications, Users}
|
||||
alias Mobilizon.Applications.ApplicationToken
|
||||
alias Mobilizon.Users.User
|
||||
|
||||
require Logger
|
||||
|
||||
@spec subject_for_token(any(), any()) :: {:ok, String.t()} | {:error, :unknown_resource}
|
||||
def subject_for_token(%User{} = user, _claims) do
|
||||
{:ok, "User:" <> to_string(user.id)}
|
||||
def subject_for_token(%User{id: user_id}, _claims) do
|
||||
{:ok, "User:" <> to_string(user_id)}
|
||||
end
|
||||
|
||||
def subject_for_token(%ApplicationToken{id: app_token_id}, _claims) do
|
||||
{:ok, "AppToken:" <> to_string(app_token_id)}
|
||||
end
|
||||
|
||||
def subject_for_token(_, _) do
|
||||
|
@ -42,6 +47,25 @@ defmodule Mobilizon.Web.Auth.Guardian do
|
|||
end
|
||||
end
|
||||
|
||||
def resource_from_claims(%{"sub" => "AppToken:" <> id_str}) do
|
||||
Logger.debug(fn -> "Receiving claim for app token #{id_str}" end)
|
||||
|
||||
try do
|
||||
case Integer.parse(id_str) do
|
||||
{id, ""} ->
|
||||
application_token = Applications.get_application_token!(id)
|
||||
user = Users.get_user_with_actors!(application_token.user_id)
|
||||
application = Applications.get_application!(application_token.application_id)
|
||||
{:ok, application_token |> Map.put(:user, user) |> Map.put(:application, application)}
|
||||
|
||||
_ ->
|
||||
{:error, :invalid_id}
|
||||
end
|
||||
rescue
|
||||
Ecto.NoResultsError -> {:error, :no_result}
|
||||
end
|
||||
end
|
||||
|
||||
def resource_from_claims(_) do
|
||||
{:error, :no_claims}
|
||||
end
|
||||
|
|
|
@ -4,19 +4,16 @@ defmodule Mobilizon.Web.GraphQLSocket do
|
|||
use Absinthe.Phoenix.Socket,
|
||||
schema: Mobilizon.GraphQL.Schema
|
||||
|
||||
alias Mobilizon.Applications.Application, as: AuthApplication
|
||||
alias Mobilizon.Applications.ApplicationToken
|
||||
alias Mobilizon.Users.User
|
||||
|
||||
@spec connect(map, Phoenix.Socket.t()) :: {:ok, Phoenix.Socket.t()} | :error
|
||||
def connect(%{"token" => token}, socket) do
|
||||
with {:ok, authed_socket} <-
|
||||
Guardian.Phoenix.Socket.authenticate(socket, Mobilizon.Web.Auth.Guardian, token),
|
||||
%User{} = user <- Guardian.Phoenix.Socket.current_resource(authed_socket) do
|
||||
authed_socket =
|
||||
Absinthe.Phoenix.Socket.put_options(socket,
|
||||
context: %{
|
||||
current_user: user
|
||||
}
|
||||
)
|
||||
resource <- Guardian.Phoenix.Socket.current_resource(authed_socket) do
|
||||
set_context(authed_socket, resource)
|
||||
|
||||
{:ok, authed_socket}
|
||||
else
|
||||
|
@ -29,4 +26,27 @@ defmodule Mobilizon.Web.GraphQLSocket do
|
|||
|
||||
@spec id(any) :: nil
|
||||
def id(_socket), do: nil
|
||||
|
||||
@spec set_context(Phoenix.Socket.t(), User.t() | ApplicationToken.t()) :: Phoenix.Socket.t()
|
||||
defp set_context(socket, %User{} = user) do
|
||||
Absinthe.Phoenix.Socket.put_options(socket,
|
||||
context: %{
|
||||
current_user: user
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
defp set_context(
|
||||
socket,
|
||||
%ApplicationToken{user: %User{} = user, application: %AuthApplication{} = app} =
|
||||
app_token
|
||||
) do
|
||||
Absinthe.Phoenix.Socket.put_options(socket,
|
||||
context: %{
|
||||
current_auth_app_token: app_token,
|
||||
current_auth_app: app,
|
||||
current_user: user
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
130
lib/web/controllers/application_controller.ex
Normal file
130
lib/web/controllers/application_controller.ex
Normal file
|
@ -0,0 +1,130 @@
|
|||
defmodule Mobilizon.Web.ApplicationController do
|
||||
use Mobilizon.Web, :controller
|
||||
|
||||
alias Mobilizon.Applications.Application
|
||||
alias Mobilizon.Service.Auth.Applications
|
||||
plug(:put_layout, false)
|
||||
import Mobilizon.Web.Gettext, only: [dgettext: 2]
|
||||
|
||||
@out_of_band_redirect_uri "urn:ietf:wg:oauth:2.0:oob"
|
||||
|
||||
@doc """
|
||||
Create an application
|
||||
"""
|
||||
@spec create_application(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def create_application(conn, %{"name" => name, "redirect_uris" => redirect_uris} = args) do
|
||||
case Applications.create(
|
||||
name,
|
||||
redirect_uris,
|
||||
Map.get(args, "scopes"),
|
||||
Map.get(args, "website")
|
||||
) do
|
||||
{:ok, %Application{} = app} ->
|
||||
json(
|
||||
conn,
|
||||
Map.take(app, [:name, :website, :redirect_uris, :client_id, :client_secret, :scope])
|
||||
)
|
||||
|
||||
{:error, _error} ->
|
||||
send_resp(
|
||||
conn,
|
||||
500,
|
||||
dgettext(
|
||||
"errors",
|
||||
"Impossible to create application."
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def create_application(conn, _args) do
|
||||
send_resp(
|
||||
conn,
|
||||
400,
|
||||
dgettext(
|
||||
"errors",
|
||||
"Both name and redirect_uri parameters are required to create an application"
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Authorize
|
||||
"""
|
||||
@spec authorize(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def authorize(
|
||||
conn,
|
||||
_args
|
||||
) do
|
||||
conn = fetch_query_params(conn)
|
||||
|
||||
client_id = conn.query_params["client_id"]
|
||||
redirect_uri = conn.query_params["redirect_uri"]
|
||||
state = conn.query_params["state"]
|
||||
|
||||
if is_binary(client_id) and is_binary(redirect_uri) and is_binary(state) do
|
||||
redirect(conn,
|
||||
to:
|
||||
Routes.page_path(conn, :authorize,
|
||||
client_id: client_id,
|
||||
redirect_uri: redirect_uri,
|
||||
scope: conn.query_params["scope"],
|
||||
state: state
|
||||
)
|
||||
)
|
||||
else
|
||||
send_resp(
|
||||
conn,
|
||||
400,
|
||||
dgettext(
|
||||
"errors",
|
||||
"You need to specify client_id, redirect_uri and state to autorize an application"
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@spec generate_access_token(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
def generate_access_token(conn, %{
|
||||
"client_id" => client_id,
|
||||
"client_secret" => client_secret,
|
||||
"code" => code,
|
||||
"redirect_uri" => redirect_uri
|
||||
}) do
|
||||
case Applications.generate_access_token(client_id, client_secret, code, redirect_uri) do
|
||||
{:ok, token} ->
|
||||
if redirect_uri != @out_of_band_redirect_uri do
|
||||
redirect(conn, external: generate_redirect_with_query_params(redirect_uri, token))
|
||||
else
|
||||
json(conn, token)
|
||||
end
|
||||
|
||||
{:error, :application_not_found} ->
|
||||
send_resp(conn, 400, dgettext("errors", "No application was found with this client_id"))
|
||||
|
||||
{:error, :redirect_uri_not_in_allowed} ->
|
||||
send_resp(conn, 400, dgettext("errors", "This redirect URI is not allowed"))
|
||||
|
||||
{:error, :invalid_or_expired} ->
|
||||
send_resp(conn, 400, dgettext("errors", "The provided code is invalid or expired"))
|
||||
|
||||
{:error, :invalid_client_id} ->
|
||||
send_resp(
|
||||
conn,
|
||||
400,
|
||||
dgettext("errors", "The provided client_id does not match the provided code")
|
||||
)
|
||||
|
||||
{:error, :invalid_client_secret} ->
|
||||
send_resp(conn, 400, dgettext("errors", "The provided client_secret is invalid"))
|
||||
|
||||
{:error, :user_not_found} ->
|
||||
send_resp(conn, 400, dgettext("errors", "The user for this code was not found"))
|
||||
end
|
||||
end
|
||||
|
||||
@spec generate_redirect_with_query_params(String.t(), map()) :: String.t()
|
||||
defp generate_redirect_with_query_params(redirect_uri, query_params) do
|
||||
redirect_uri |> URI.parse() |> URI.merge("?" <> URI.encode_query(query_params)) |> to_string()
|
||||
end
|
||||
end
|
|
@ -121,6 +121,9 @@ defmodule Mobilizon.Web.PageController do
|
|||
end
|
||||
end
|
||||
|
||||
@spec authorize(Plug.Conn.t(), any) :: Plug.Conn.t()
|
||||
def authorize(conn, _params), do: render(conn, :index)
|
||||
|
||||
@spec handle_collection_route(Plug.Conn.t(), collections()) :: Plug.Conn.t()
|
||||
defp handle_collection_route(conn, collection) do
|
||||
case get_format(conn) do
|
||||
|
|
|
@ -205,6 +205,11 @@ defmodule Mobilizon.Web.Router do
|
|||
# Also possible CSRF issue
|
||||
get("/auth/:provider/callback", AuthController, :callback)
|
||||
post("/auth/:provider/callback", AuthController, :callback)
|
||||
|
||||
post("/apps", ApplicationController, :create_application)
|
||||
get("/oauth/authorize", ApplicationController, :authorize)
|
||||
post("/oauth/token", ApplicationController, :generate_access_token)
|
||||
get("/oauth/autorize_approve", PageController, :authorize)
|
||||
end
|
||||
|
||||
scope "/proxy/", Mobilizon.Web do
|
||||
|
|
20
priv/repo/migrations/20230208101626_create_applications.exs
Normal file
20
priv/repo/migrations/20230208101626_create_applications.exs
Normal file
|
@ -0,0 +1,20 @@
|
|||
defmodule Mobilizon.Repo.Migrations.CreateApplications do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:applications) do
|
||||
add(:name, :string, null: false)
|
||||
add(:client_id, :string, null: false)
|
||||
add(:client_secret, :string, null: false)
|
||||
add(:redirect_uris, :string, null: false)
|
||||
add(:scopes, :string, null: true)
|
||||
add(:website, :string, null: true)
|
||||
add(:owner_type, :string, null: true)
|
||||
add(:owner_id, :integer, null: true)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create(index(:applications, [:owner_id, :owner_type]))
|
||||
end
|
||||
end
|
|
@ -0,0 +1,14 @@
|
|||
defmodule Mobilizon.Repo.Migrations.CreateApplicationTokens do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:application_tokens) do
|
||||
add(:user_id, references(:users, on_delete: :delete_all), null: false)
|
||||
add(:application_id, references(:applications, on_delete: :delete_all), null: false)
|
||||
add(:authorization_code, :string, null: true)
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create(unique_index(:application_tokens, [:user_id, :application_id]))
|
||||
end
|
||||
end
|
146
test/mobilizon/applications_test.exs
Normal file
146
test/mobilizon/applications_test.exs
Normal file
|
@ -0,0 +1,146 @@
|
|||
defmodule Mobilizon.ApplicationsTest do
|
||||
use Mobilizon.DataCase
|
||||
|
||||
alias Mobilizon.Applications
|
||||
|
||||
describe "applications" do
|
||||
alias Mobilizon.Applications.Application
|
||||
|
||||
import Mobilizon.ApplicationsFixtures
|
||||
|
||||
@invalid_attrs %{name: nil}
|
||||
|
||||
test "list_applications/0 returns all applications" do
|
||||
application = application_fixture()
|
||||
assert Applications.list_applications() == [application]
|
||||
end
|
||||
|
||||
test "get_application!/1 returns the application with given id" do
|
||||
application = application_fixture()
|
||||
assert Applications.get_application!(application.id) == application
|
||||
end
|
||||
|
||||
test "create_application/1 with valid data creates a application" do
|
||||
valid_attrs = %{
|
||||
name: "some name",
|
||||
client_id: "hello",
|
||||
client_secret: "secret",
|
||||
redirect_uris: "somewhere\nelse"
|
||||
}
|
||||
|
||||
assert {:ok, %Application{} = application} = Applications.create_application(valid_attrs)
|
||||
assert application.name == "some name"
|
||||
assert application.client_id == "hello"
|
||||
assert application.client_secret == "secret"
|
||||
assert application.redirect_uris == "somewhere\nelse"
|
||||
end
|
||||
|
||||
test "create_application/1 with invalid data returns error changeset" do
|
||||
assert {:error, %Ecto.Changeset{}} = Applications.create_application(@invalid_attrs)
|
||||
end
|
||||
|
||||
test "update_application/2 with valid data updates the application" do
|
||||
application = application_fixture()
|
||||
update_attrs = %{name: "some updated name"}
|
||||
|
||||
assert {:ok, %Application{} = application} =
|
||||
Applications.update_application(application, update_attrs)
|
||||
|
||||
assert application.name == "some updated name"
|
||||
end
|
||||
|
||||
test "update_application/2 with invalid data returns error changeset" do
|
||||
application = application_fixture()
|
||||
|
||||
assert {:error, %Ecto.Changeset{}} =
|
||||
Applications.update_application(application, @invalid_attrs)
|
||||
|
||||
assert application == Applications.get_application!(application.id)
|
||||
end
|
||||
|
||||
test "delete_application/1 deletes the application" do
|
||||
application = application_fixture()
|
||||
assert {:ok, %Application{}} = Applications.delete_application(application)
|
||||
assert_raise Ecto.NoResultsError, fn -> Applications.get_application!(application.id) end
|
||||
end
|
||||
|
||||
test "change_application/1 returns a application changeset" do
|
||||
application = application_fixture()
|
||||
assert %Ecto.Changeset{} = Applications.change_application(application)
|
||||
end
|
||||
end
|
||||
|
||||
describe "application_tokens" do
|
||||
alias Mobilizon.Applications.ApplicationToken
|
||||
|
||||
import Mobilizon.ApplicationsFixtures
|
||||
import Mobilizon.Factory
|
||||
|
||||
@invalid_attrs %{user_id: nil}
|
||||
|
||||
test "list_application_tokens/0 returns all application_tokens" do
|
||||
application_token = application_token_fixture()
|
||||
assert Applications.list_application_tokens() == [application_token]
|
||||
end
|
||||
|
||||
test "get_application_token!/1 returns the application_token with given id" do
|
||||
application_token = application_token_fixture()
|
||||
assert Applications.get_application_token!(application_token.id) == application_token
|
||||
end
|
||||
|
||||
test "create_application_token/1 with valid data creates a application_token" do
|
||||
user = insert(:user)
|
||||
application = application_fixture()
|
||||
|
||||
valid_attrs = %{
|
||||
user_id: user.id,
|
||||
application_id: application.id,
|
||||
authorization_code: "hey hello"
|
||||
}
|
||||
|
||||
assert {:ok, %ApplicationToken{} = application_token} =
|
||||
Applications.create_application_token(valid_attrs)
|
||||
|
||||
assert application_token.user_id == user.id
|
||||
assert application_token.application_id == application.id
|
||||
assert application_token.authorization_code == "hey hello"
|
||||
end
|
||||
|
||||
test "create_application_token/1 with invalid data returns error changeset" do
|
||||
assert {:error, %Ecto.Changeset{}} = Applications.create_application_token(@invalid_attrs)
|
||||
end
|
||||
|
||||
test "update_application_token/2 with valid data updates the application_token" do
|
||||
application_token = application_token_fixture()
|
||||
update_attrs = %{authorization_code: nil}
|
||||
|
||||
assert {:ok, %ApplicationToken{} = application_token} =
|
||||
Applications.update_application_token(application_token, update_attrs)
|
||||
|
||||
assert is_nil(application_token.authorization_code)
|
||||
end
|
||||
|
||||
test "update_application_token/2 with invalid data returns error changeset" do
|
||||
application_token = application_token_fixture()
|
||||
|
||||
assert {:error, %Ecto.Changeset{}} =
|
||||
Applications.update_application_token(application_token, @invalid_attrs)
|
||||
|
||||
assert application_token == Applications.get_application_token!(application_token.id)
|
||||
end
|
||||
|
||||
test "delete_application_token/1 deletes the application_token" do
|
||||
application_token = application_token_fixture()
|
||||
assert {:ok, %ApplicationToken{}} = Applications.delete_application_token(application_token)
|
||||
|
||||
assert_raise Ecto.NoResultsError, fn ->
|
||||
Applications.get_application_token!(application_token.id)
|
||||
end
|
||||
end
|
||||
|
||||
test "change_application_token/1 returns a application_token changeset" do
|
||||
application_token = application_token_fixture()
|
||||
assert %Ecto.Changeset{} = Applications.change_application_token(application_token)
|
||||
end
|
||||
end
|
||||
end
|
43
test/support/fixtures/applications_fixtures.ex
Normal file
43
test/support/fixtures/applications_fixtures.ex
Normal file
|
@ -0,0 +1,43 @@
|
|||
defmodule Mobilizon.ApplicationsFixtures do
|
||||
@moduledoc """
|
||||
This module defines test helpers for creating
|
||||
entities via the `Mobilizon.Applications` context.
|
||||
"""
|
||||
|
||||
import Mobilizon.Factory
|
||||
|
||||
@doc """
|
||||
Generate a application.
|
||||
"""
|
||||
def application_fixture(attrs \\ %{}) do
|
||||
{:ok, application} =
|
||||
attrs
|
||||
|> Enum.into(%{
|
||||
name: "some name",
|
||||
client_id: "hello",
|
||||
client_secret: "secret",
|
||||
redirect_uris: "somewhere\nelse"
|
||||
})
|
||||
|> Mobilizon.Applications.create_application()
|
||||
|
||||
application
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generate a application_token.
|
||||
"""
|
||||
def application_token_fixture(attrs \\ %{}) do
|
||||
user = insert(:user)
|
||||
|
||||
{:ok, application_token} =
|
||||
attrs
|
||||
|> Enum.into(%{
|
||||
application_id: application_fixture().id,
|
||||
user_id: user.id,
|
||||
authorization_code: "some code"
|
||||
})
|
||||
|> Mobilizon.Applications.create_application_token()
|
||||
|
||||
application_token
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue