Remove apollo link state

This commit is contained in:
Chocobozzz 2019-08-12 16:04:16 +02:00
parent 3fa2bd35d8
commit 6d221212ef
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
22 changed files with 415 additions and 148 deletions

View File

@ -17,7 +17,6 @@
"apollo-client": "2.5.1", "apollo-client": "2.5.1",
"apollo-link": "^1.2.11", "apollo-link": "^1.2.11",
"apollo-link-http": "^1.5.14", "apollo-link-http": "^1.5.14",
"apollo-link-state": "^0.4.2",
"buefy": "^0.7.3", "buefy": "^0.7.3",
"easygettext": "^2.7.0", "easygettext": "^2.7.0",
"graphql": "^14.2.1", "graphql": "^14.2.1",
@ -53,6 +52,7 @@
"@vue/cli-service": "^3.6.0", "@vue/cli-service": "^3.6.0",
"@vue/eslint-config-typescript": "^4.0.0", "@vue/eslint-config-typescript": "^4.0.0",
"@vue/test-utils": "^1.0.0-beta.29", "@vue/test-utils": "^1.0.0-beta.29",
"apollo-link-error": "^1.1.11",
"chai": "^4.2.0", "chai": "^4.2.0",
"dotenv-webpack": "^1.7.0", "dotenv-webpack": "^1.7.0",
"eslint": "^6.0.1", "eslint": "^6.0.1",

View File

@ -9,15 +9,15 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import NavBar from '@/components/NavBar.vue'; import NavBar from '@/components/NavBar.vue';
import { Component, Vue } from 'vue-property-decorator'; import { Component, Vue } from 'vue-property-decorator';
import { AUTH_TOKEN, AUTH_USER_ACTOR, AUTH_USER_EMAIL, AUTH_USER_ID } from '@/constants'; import { AUTH_ACCESS_TOKEN, AUTH_USER_ACTOR, AUTH_USER_EMAIL, AUTH_USER_ID } from '@/constants';
import { CURRENT_USER_CLIENT, UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user'; import { CURRENT_USER_CLIENT, UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user';
import { ICurrentUser } from '@/types/current-user.model'; import { ICurrentUser } from '@/types/current-user.model';
import Footer from '@/components/Footer.vue'; import Footer from '@/components/Footer.vue';
import Logo from '@/components/Logo.vue'; import Logo from '@/components/Logo.vue';
@Component({ @Component({
apollo: { apollo: {
currentUser: { currentUser: {
query: CURRENT_USER_CLIENT, query: CURRENT_USER_CLIENT,
@ -45,9 +45,9 @@ export default class App extends Vue {
private initializeCurrentUser() { private initializeCurrentUser() {
const userId = localStorage.getItem(AUTH_USER_ID); const userId = localStorage.getItem(AUTH_USER_ID);
const userEmail = localStorage.getItem(AUTH_USER_EMAIL); const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
const token = localStorage.getItem(AUTH_TOKEN); const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
if (userId && userEmail && token) { if (userId && userEmail && accessToken) {
return this.$apollo.mutate({ return this.$apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT, mutation: UPDATE_CURRENT_USER_CLIENT,
variables: { variables: {

View File

@ -1,27 +1,32 @@
export const currentUser = { import { ApolloCache } from 'apollo-cache';
defaults: { import { NormalizedCacheObject } from 'apollo-cache-inmemory';
currentUser: {
__typename: 'CurrentUser',
id: null,
email: null,
isLoggedIn: false,
},
},
resolvers: { export function buildCurrentUserResolver(cache: ApolloCache<NormalizedCacheObject>) {
Mutation: { cache.writeData({
updateCurrentUser: (_, { id, email, isLoggedIn }, { cache }) => { data: {
const data = { currentUser: {
__typename: 'CurrentUser',
id: null,
email: null,
isLoggedIn: false,
},
},
});
return {
updateCurrentUser: (_, { id, email, isLoggedIn }, { cache }) => {
const data = {
Mutation: {
currentUser: { currentUser: {
id, id,
email, email,
isLoggedIn, isLoggedIn,
__typename: 'CurrentUser', __typename: 'CurrentUser',
}, },
}; },
};
cache.writeData({ data }); cache.writeData({ data });
},
}, },
}, };
}; };

View File

@ -60,19 +60,18 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator'; import { Component, Vue, Watch } from 'vue-property-decorator';
import { CURRENT_USER_CLIENT, UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user'; import { CURRENT_USER_CLIENT } from '@/graphql/user';
import { onLogout } from '@/vue-apollo'; import { logout } from '@/utils/auth';
import { deleteUserData } from '@/utils/auth'; import { LOGGED_PERSON } from '@/graphql/actor';
import { LOGGED_PERSON } from '@/graphql/actor'; import { IPerson } from '@/types/actor';
import { IPerson } from '@/types/actor'; import { CONFIG } from '@/graphql/config';
import { CONFIG } from '@/graphql/config'; import { IConfig } from '@/types/config.model';
import { IConfig } from '@/types/config.model'; import { ICurrentUser } from '@/types/current-user.model';
import { ICurrentUser } from '@/types/current-user.model'; import Logo from '@/components/Logo.vue';
import Logo from '@/components/Logo.vue'; import SearchField from '@/components/SearchField.vue';
import SearchField from '@/components/SearchField.vue';
@Component({ @Component({
apollo: { apollo: {
currentUser: { currentUser: {
query: CURRENT_USER_CLIENT, query: CURRENT_USER_CLIENT,
@ -111,18 +110,7 @@ export default class NavBar extends Vue {
} }
async logout() { async logout() {
await this.$apollo.mutate({ await logout(this.$apollo.provider.defaultClient);
mutation: UPDATE_CURRENT_USER_CLIENT,
variables: {
id: null,
email: null,
isLoggedIn: false,
},
});
deleteUserData();
onLogout(this.$apollo);
return this.$router.push({ path: '/' }); return this.$router.push({ path: '/' });
} }

View File

@ -1,4 +1,5 @@
export const AUTH_TOKEN = 'auth-token'; export const AUTH_ACCESS_TOKEN = 'auth-access-token';
export const AUTH_REFRESH_TOKEN = 'auth-refresh-token';
export const AUTH_USER_ID = 'auth-user-id'; export const AUTH_USER_ID = 'auth-user-id';
export const AUTH_USER_EMAIL = 'auth-user-email'; export const AUTH_USER_EMAIL = 'auth-user-email';
export const AUTH_USER_ACTOR = 'auth-user-actor'; export const AUTH_USER_ACTOR = 'auth-user-actor';

View File

@ -3,7 +3,8 @@ import gql from 'graphql-tag';
export const LOGIN = gql` export const LOGIN = gql`
mutation Login($email: String!, $password: String!) { mutation Login($email: String!, $password: String!) {
login(email: $email, password: $password) { login(email: $email, password: $password) {
token, accessToken,
refreshToken,
user { user {
id, id,
} }
@ -33,3 +34,12 @@ mutation ResendConfirmationEmail($email: String!) {
resendConfirmationEmail(email: $email) resendConfirmationEmail(email: $email)
} }
`; `;
export const REFRESH_TOKEN = gql`
mutation RefreshToken($refreshToken: String!) {
refreshToken(refreshToken: $refreshToken) {
accessToken,
refreshToken,
}
}
`;

View File

@ -12,7 +12,8 @@ mutation CreateUser($email: String!, $password: String!) {
export const VALIDATE_USER = gql` export const VALIDATE_USER = gql`
mutation ValidateUser($token: String!) { mutation ValidateUser($token: String!) {
validateUser(token: $token) { validateUser(token: $token) {
token, accessToken,
refreshToken,
user { user {
id, id,
email, email,

View File

@ -1,13 +1,13 @@
import { NavigationGuard } from 'vue-router'; import { NavigationGuard } from 'vue-router';
import { UserRouteName } from '@/router/user'; import { UserRouteName } from '@/router/user';
import { LoginErrorCode } from '@/types/login-error-code.model'; import { LoginErrorCode } from '@/types/login-error-code.model';
import { AUTH_TOKEN } from '@/constants'; import { AUTH_ACCESS_TOKEN } from '@/constants';
export const authGuardIfNeeded: NavigationGuard = async function (to, from, next) { export const authGuardIfNeeded: NavigationGuard = async function (to, from, next) {
if (to.meta.requiredAuth !== true) return next(); if (to.meta.requiredAuth !== true) return next();
// We can't use "currentUser" from apollo here because we may not have loaded the user from the local storage yet // We can't use "currentUser" from apollo here because we may not have loaded the user from the local storage yet
if (!localStorage.getItem(AUTH_TOKEN)) { if (!localStorage.getItem(AUTH_ACCESS_TOKEN)) {
return next({ return next({
name: UserRouteName.LOGIN, name: UserRouteName.LOGIN,
query: { query: {

7
js/src/types/apollo.ts Normal file
View File

@ -0,0 +1,7 @@
import { ServerError, ServerParseError } from 'apollo-link-http-common';
function isServerError(err: Error | ServerError | ServerParseError | undefined): err is ServerError {
return !!err && (err as ServerError).statusCode !== undefined;
}
export { isServerError };

View File

@ -1,7 +1,10 @@
import { ICurrentUser } from '@/types/current-user.model'; import { ICurrentUser } from '@/types/current-user.model';
export interface ILogin { export interface IToken {
user: ICurrentUser; accessToken: string;
refreshToken: string;
token: string; }
export interface ILogin extends IToken {
user: ICurrentUser;
} }

View File

@ -1,14 +1,38 @@
import { AUTH_TOKEN, AUTH_USER_EMAIL, AUTH_USER_ID } from '@/constants'; import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN, AUTH_USER_EMAIL, AUTH_USER_ID } from '@/constants';
import { ILogin } from '@/types/login.model'; import { ILogin, IToken } from '@/types/login.model';
import { UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user';
import { onLogout } from '@/vue-apollo';
import ApolloClient from 'apollo-client';
export function saveUserData(obj: ILogin) { export function saveUserData(obj: ILogin) {
localStorage.setItem(AUTH_USER_ID, `${obj.user.id}`); localStorage.setItem(AUTH_USER_ID, `${obj.user.id}`);
localStorage.setItem(AUTH_USER_EMAIL, obj.user.email); localStorage.setItem(AUTH_USER_EMAIL, obj.user.email);
localStorage.setItem(AUTH_TOKEN, obj.token);
saveTokenData(obj);
}
export function saveTokenData(obj: IToken) {
localStorage.setItem(AUTH_ACCESS_TOKEN, obj.accessToken);
localStorage.setItem(AUTH_REFRESH_TOKEN, obj.refreshToken);
} }
export function deleteUserData() { export function deleteUserData() {
for (const key of [AUTH_USER_ID, AUTH_USER_EMAIL, AUTH_TOKEN]) { for (const key of [AUTH_USER_ID, AUTH_USER_EMAIL, AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN]) {
localStorage.removeItem(key); localStorage.removeItem(key);
} }
} }
export function logout(apollo: ApolloClient<any>) {
apollo.mutate({
mutation: UPDATE_CURRENT_USER_CLIENT,
variables: {
id: null,
email: null,
isLoggedIn: false,
},
});
deleteUserData();
onLogout();
}

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="container"> <div class="container" v-if="config">
<section class="hero is-link" v-if="!currentUser.id || !loggedPerson"> <section class="hero is-link" v-if="!currentUser.id || !loggedPerson">
<div class="hero-body"> <div class="hero-body">
<div class="container"> <div class="container">

View File

@ -17,13 +17,14 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { VALIDATE_USER } from '@/graphql/user'; import { VALIDATE_USER } from '@/graphql/user';
import { Component, Prop, Vue } from 'vue-property-decorator'; import { Component, Prop, Vue } from 'vue-property-decorator';
import { AUTH_TOKEN, AUTH_USER_ID } from '@/constants'; import { AUTH_USER_ID } from '@/constants';
import { RouteName } from '@/router'; import { RouteName } from '@/router';
import { UserRouteName } from '@/router/user'; import { UserRouteName } from '@/router/user';
import { saveTokenData } from '@/utils/auth';
@Component @Component
export default class Validate extends Vue { export default class Validate extends Vue {
@Prop({ type: String, required: true }) token!: string; @Prop({ type: String, required: true }) token!: string;
@ -62,7 +63,8 @@ export default class Validate extends Vue {
saveUserData({ validateUser: login }) { saveUserData({ validateUser: login }) {
localStorage.setItem(AUTH_USER_ID, login.user.id); localStorage.setItem(AUTH_USER_ID, login.user.id);
localStorage.setItem(AUTH_TOKEN, login.token);
saveTokenData(login)
} }
} }
</script> </script>

View File

@ -1,15 +1,18 @@
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { ApolloLink } from 'apollo-link'; import { ApolloLink, Observable } from 'apollo-link';
import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import { onError } from 'apollo-link-error';
import { createLink } from 'apollo-absinthe-upload-link'; import { createLink } from 'apollo-absinthe-upload-link';
import { AUTH_TOKEN } from './constants';
import { GRAPHQL_API_ENDPOINT, GRAPHQL_API_FULL_PATH } from './api/_entrypoint'; import { GRAPHQL_API_ENDPOINT, GRAPHQL_API_FULL_PATH } from './api/_entrypoint';
import { withClientState } from 'apollo-link-state';
import { currentUser } from '@/apollo/user';
import merge from 'lodash/merge';
import { ApolloClient } from 'apollo-client'; import { ApolloClient } from 'apollo-client';
import { DollarApollo } from 'vue-apollo/types/vue-apollo'; import { DollarApollo } from 'vue-apollo/types/vue-apollo';
import { buildCurrentUserResolver } from '@/apollo/user';
import { isServerError } from '@/types/apollo';
import { inspect } from 'util';
import { REFRESH_TOKEN } from '@/graphql/auth';
import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN } from '@/constants';
import { logout, saveTokenData } from '@/utils/auth';
// Install the vue plugin // Install the vue plugin
Vue.use(VueApollo); Vue.use(VueApollo);
@ -44,14 +47,11 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({
}, },
}); });
const cache = new InMemoryCache({ fragmentMatcher });
const authMiddleware = new ApolloLink((operation, forward) => { const authMiddleware = new ApolloLink((operation, forward) => {
// add the authorization to the headers // add the authorization to the headers
const token = localStorage.getItem(AUTH_TOKEN);
operation.setContext({ operation.setContext({
headers: { headers: {
authorization: token ? `Bearer ${token}` : null, authorization: generateTokenHeader(),
}, },
}); });
@ -64,21 +64,54 @@ const uploadLink = createLink({
uri: httpEndpoint, uri: httpEndpoint,
}); });
const stateLink = withClientState({ let refreshingTokenPromise: Promise<boolean> | undefined;
...merge(currentUser), let alreadyRefreshedToken = false;
cache, const errorLink = onError(({ graphQLErrors, networkError, forward, operation }) => {
if (isServerError(networkError) && networkError.statusCode === 401 && !alreadyRefreshedToken) {
if (!refreshingTokenPromise) refreshingTokenPromise = refreshAccessToken();
return promiseToObservable(refreshingTokenPromise).flatMap(() => {
refreshingTokenPromise = undefined;
alreadyRefreshedToken = true;
const context = operation.getContext();
const oldHeaders = context.headers;
operation.setContext({
headers: {
...oldHeaders,
authorization: generateTokenHeader(),
},
});
return forward(operation);
});
}
if (graphQLErrors) {
graphQLErrors.forEach(({ message, locations, path }) =>
console.log(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`),
);
}
if (networkError) console.log(`[Network error]: ${networkError}`);
}); });
const link = stateLink.concat(authMiddleware).concat(uploadLink); const link = authMiddleware
.concat(errorLink)
.concat(uploadLink);
const cache = new InMemoryCache({ fragmentMatcher });
const apolloClient = new ApolloClient({ const apolloClient = new ApolloClient({
cache, cache,
link, link,
connectToDevTools: true, connectToDevTools: true,
resolvers: {
currentUser: buildCurrentUserResolver(cache),
},
}); });
apolloClient.onResetStore(stateLink.writeDefaults as any);
export const apolloProvider = new VueApollo({ export const apolloProvider = new VueApollo({
defaultClient: apolloClient, defaultClient: apolloClient,
errorHandler(error) { errorHandler(error) {
@ -93,13 +126,65 @@ export function onLogin(apolloClient) {
} }
// Manually call this when user log out // Manually call this when user log out
export async function onLogout(apolloClient: DollarApollo<any>) { export async function onLogout() {
// if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient); // if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient);
try { try {
await apolloClient.provider.defaultClient.resetStore(); await apolloClient.resetStore();
} catch (e) { } catch (e) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('%cError on cache reset (logout)', 'color: orange;', e.message); console.log('%cError on cache reset (logout)', 'color: orange;', e.message);
} }
} }
async function refreshAccessToken() {
// Remove invalid access token, so the next request is not authenticated
localStorage.removeItem(AUTH_ACCESS_TOKEN);
const refreshToken = localStorage.getItem(AUTH_REFRESH_TOKEN);
console.log('Refreshing access token.');
try {
const res = await apolloClient.mutate({
mutation: REFRESH_TOKEN,
variables: {
refreshToken,
},
});
saveTokenData(res.data.refreshToken);
return true;
} catch (err) {
return false;
}
}
function generateTokenHeader() {
const token = localStorage.getItem(AUTH_ACCESS_TOKEN);
return token ? `Bearer ${token}` : null;
}
// Thanks: https://github.com/apollographql/apollo-link/issues/747#issuecomment-502676676
const promiseToObservable = <T> (promise: Promise<T>) => {
return new Observable<T>((subscriber) => {
promise.then(
(value) => {
if (subscriber.closed) {
return;
}
subscriber.next(value);
subscriber.complete();
},
(err) => {
console.error('Cannot refresh token.', err);
subscriber.error(err);
logout(apolloClient);
},
);
});
};

View File

@ -1845,6 +1845,15 @@ apollo-link-dedup@^1.0.0:
apollo-link "^1.2.12" apollo-link "^1.2.12"
tslib "^1.9.3" tslib "^1.9.3"
apollo-link-error@^1.1.11:
version "1.1.11"
resolved "https://registry.yarnpkg.com/apollo-link-error/-/apollo-link-error-1.1.11.tgz#7cd363179616fb90da7866cee85cb00ee45d2f3b"
integrity sha512-442DNqn3CNRikDaenMMkoDmCRmkoUx/XyUMlRTZBEFdTw3FYPQLsmDO3hzzC4doY5/BHcn9/jdYh9EeLx4HPsA==
dependencies:
apollo-link "^1.2.12"
apollo-link-http-common "^0.2.14"
tslib "^1.9.3"
apollo-link-http-common@^0.2.14, apollo-link-http-common@^0.2.4: apollo-link-http-common@^0.2.14, apollo-link-http-common@^0.2.4:
version "0.2.14" version "0.2.14"
resolved "https://registry.yarnpkg.com/apollo-link-http-common/-/apollo-link-http-common-0.2.14.tgz#d3a195c12e00f4e311c417f121181dcc31f7d0c8" resolved "https://registry.yarnpkg.com/apollo-link-http-common/-/apollo-link-http-common-0.2.14.tgz#d3a195c12e00f4e311c417f121181dcc31f7d0c8"
@ -1863,14 +1872,6 @@ apollo-link-http@^1.3.2, apollo-link-http@^1.5.14:
apollo-link-http-common "^0.2.14" apollo-link-http-common "^0.2.14"
tslib "^1.9.3" tslib "^1.9.3"
apollo-link-state@^0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/apollo-link-state/-/apollo-link-state-0.4.2.tgz#ac00e9be9b0ca89eae0be6ba31fe904b80bbe2e8"
integrity sha512-xMPcAfuiPVYXaLwC6oJFIZrKgV3GmdO31Ag2eufRoXpvT0AfJZjdaPB4450Nu9TslHRePN9A3quxNueILlQxlw==
dependencies:
apollo-utilities "^1.0.8"
graphql-anywhere "^4.1.0-alpha.0"
apollo-link@^1.0.0, apollo-link@^1.0.7, apollo-link@^1.2.11, apollo-link@^1.2.12: apollo-link@^1.0.0, apollo-link@^1.0.7, apollo-link@^1.2.11, apollo-link@^1.2.12:
version "1.2.12" version "1.2.12"
resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.12.tgz#014b514fba95f1945c38ad4c216f31bcfee68429" resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.12.tgz#014b514fba95f1945c38ad4c216f31bcfee68429"
@ -1890,7 +1891,7 @@ apollo-utilities@1.2.1:
ts-invariant "^0.2.1" ts-invariant "^0.2.1"
tslib "^1.9.3" tslib "^1.9.3"
apollo-utilities@1.3.2, apollo-utilities@^1.0.8, apollo-utilities@^1.2.1, apollo-utilities@^1.3.0, apollo-utilities@^1.3.2: apollo-utilities@1.3.2, apollo-utilities@^1.2.1, apollo-utilities@^1.3.0, apollo-utilities@^1.3.2:
version "1.3.2" version "1.3.2"
resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.2.tgz#8cbdcf8b012f664cd6cb5767f6130f5aed9115c9" resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.2.tgz#8cbdcf8b012f664cd6cb5767f6130f5aed9115c9"
integrity sha512-JWNHj8XChz7S4OZghV6yc9FNnzEXj285QYp/nLNh943iObycI5GTDO3NGR9Dth12LRrSFMeDOConPfPln+WGfg== integrity sha512-JWNHj8XChz7S4OZghV6yc9FNnzEXj285QYp/nLNh943iObycI5GTDO3NGR9Dth12LRrSFMeDOConPfPln+WGfg==
@ -4931,15 +4932,6 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6
resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725"
integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU= integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU=
graphql-anywhere@^4.1.0-alpha.0:
version "4.2.4"
resolved "https://registry.yarnpkg.com/graphql-anywhere/-/graphql-anywhere-4.2.4.tgz#7f1c08c9348c730c6bb5e818c81f0b72c13696a8"
integrity sha512-rN6Op5vle0Ucqo8uOVPuFzRz1L/MB+ZVa+XezhFcQ6iP13vy95HOXRysrRtWcu2kQQTLyukSGmfU08D8LXWSIw==
dependencies:
apollo-utilities "^1.3.2"
ts-invariant "^0.3.2"
tslib "^1.9.3"
graphql-tag@^2.10.1: graphql-tag@^2.10.1:
version "2.10.1" version "2.10.1"
resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.10.1.tgz#10aa41f1cd8fae5373eaf11f1f67260a3cad5e02" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.10.1.tgz#10aa41f1cd8fae5373eaf11f1f67260a3cad5e02"
@ -10196,13 +10188,6 @@ ts-invariant@^0.2.1:
dependencies: dependencies:
tslib "^1.9.3" tslib "^1.9.3"
ts-invariant@^0.3.2:
version "0.3.3"
resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.3.3.tgz#b5742b1885ecf9e29c31a750307480f045ec0b16"
integrity sha512-UReOKsrJFGC9tUblgSRWo+BsVNbEd77Cl6WiV/XpMlkifXwNIJbknViCucHvVZkXSC/mcWeRnIGdY7uprcwvdQ==
dependencies:
tslib "^1.9.3"
ts-invariant@^0.4.0: ts-invariant@^0.4.0:
version "0.4.4" version "0.4.4"
resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86"

View File

@ -27,7 +27,9 @@ defmodule Mobilizon.Users do
@spec register(map()) :: {:ok, User.t()} | {:error, String.t()} @spec register(map()) :: {:ok, User.t()} | {:error, String.t()}
def register(%{email: _email, password: _password} = args) do def register(%{email: _email, password: _password} = args) do
with {:ok, %User{} = user} <- with {:ok, %User{} = user} <-
%User{} |> User.registration_changeset(args) |> Mobilizon.Repo.insert() do %User{}
|> User.registration_changeset(args)
|> Mobilizon.Repo.insert() do
Mobilizon.Events.create_feed_token(%{"user_id" => user.id}) Mobilizon.Events.create_feed_token(%{"user_id" => user.id})
{:ok, user} {:ok, user}
end end
@ -51,13 +53,15 @@ defmodule Mobilizon.Users do
from(u in User, where: u.email == ^email, preload: :default_actor) from(u in User, where: u.email == ^email, preload: :default_actor)
true -> true ->
from(u in User, from(
u in User,
where: u.email == ^email and not is_nil(u.confirmed_at), where: u.email == ^email and not is_nil(u.confirmed_at),
preload: :default_actor preload: :default_actor
) )
false -> false ->
from(u in User, from(
u in User,
where: u.email == ^email and is_nil(u.confirmed_at), where: u.email == ^email and is_nil(u.confirmed_at),
preload: :default_actor preload: :default_actor
) )
@ -75,7 +79,8 @@ defmodule Mobilizon.Users do
@spec get_user_by_activation_token(String.t()) :: Actor.t() @spec get_user_by_activation_token(String.t()) :: Actor.t()
def get_user_by_activation_token(token) do def get_user_by_activation_token(token) do
Repo.one( Repo.one(
from(u in User, from(
u in User,
where: u.confirmation_token == ^token, where: u.confirmation_token == ^token,
preload: [:default_actor] preload: [:default_actor]
) )
@ -88,7 +93,8 @@ defmodule Mobilizon.Users do
@spec get_user_by_reset_password_token(String.t()) :: Actor.t() @spec get_user_by_reset_password_token(String.t()) :: Actor.t()
def get_user_by_reset_password_token(token) do def get_user_by_reset_password_token(token) do
Repo.one( Repo.one(
from(u in User, from(
u in User,
where: u.reset_password_token == ^token, where: u.reset_password_token == ^token,
preload: [:default_actor] preload: [:default_actor]
) )
@ -197,14 +203,16 @@ defmodule Mobilizon.Users do
@spec get_actor_for_user(Mobilizon.Users.User.t()) :: Mobilizon.Actors.Actor.t() @spec get_actor_for_user(Mobilizon.Users.User.t()) :: Mobilizon.Actors.Actor.t()
def get_actor_for_user(%Mobilizon.Users.User{} = user) do def get_actor_for_user(%Mobilizon.Users.User{} = user) do
case Repo.one( case Repo.one(
from(a in Actor, from(
a in Actor,
join: u in User, join: u in User,
on: u.default_actor_id == a.id, on: u.default_actor_id == a.id,
where: u.id == ^user.id where: u.id == ^user.id
) )
) do ) do
nil -> nil ->
case user |> get_actors_for_user() do case user
|> get_actors_for_user() do
[] -> nil [] -> nil
actors -> hd(actors) actors -> hd(actors)
end end
@ -226,7 +234,7 @@ defmodule Mobilizon.Users do
case Argon2.verify_pass(password, user.password_hash) do case Argon2.verify_pass(password, user.password_hash) do
true -> true ->
# Yes, create and return the token # Yes, create and return the token
MobilizonWeb.Guardian.encode_and_sign(user) with {:ok, tokens} <- generate_tokens(user), do: {:ok, tokens}
_ -> _ ->
# No, return an error # No, return an error
@ -234,11 +242,42 @@ defmodule Mobilizon.Users do
end end
end end
@doc """
Generate access token and refresh token
"""
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
def generate_access_token(user) do
with {:ok, access_token, _claims} <- MobilizonWeb.Guardian.encode_and_sign(user, %{}, token_type: "access", ttl: {5, :seconds}) do
{:ok, access_token}
end
end
def generate_refresh_token(user) do
with {:ok, refresh_token, _claims} <- MobilizonWeb.Guardian.encode_and_sign(user, %{}, token_type: "refresh", ttl: {30, :days}) do
{:ok, refresh_token}
end
end
def update_user_default_actor(user_id, actor_id) do def update_user_default_actor(user_id, actor_id) do
with _ <- with _ <-
from(u in User, where: u.id == ^user_id, update: [set: [default_actor_id: ^actor_id]]) from(
u in User,
where: u.id == ^user_id,
update: [
set: [
default_actor_id: ^actor_id
]
]
)
|> Repo.update_all([]) do |> Repo.update_all([]) do
Repo.get!(User, user_id) |> Repo.preload([:default_actor]) Repo.get!(User, user_id)
|> Repo.preload([:default_actor])
end end
end end

View File

@ -17,7 +17,8 @@ defmodule MobilizonWeb.Context do
context = context =
case Guardian.Plug.current_resource(conn) do case Guardian.Plug.current_resource(conn) do
%User{} = user -> %User{} = user ->
Map.put(context, :current_user, user) context
|> Map.put(:current_user, user)
nil -> nil ->
context context

View File

@ -20,7 +20,15 @@ defmodule MobilizonWeb.Resolvers.User do
@doc """ @doc """
Return current logged-in user Return current logged-in user
""" """
def get_current_user(_parent, _args, %{context: %{current_user: user}}) do def get_current_user(
_parent,
_args,
%{
context: %{
current_user: user
}
}
) do
{:ok, user} {:ok, user}
end end
@ -35,7 +43,11 @@ defmodule MobilizonWeb.Resolvers.User do
_parent, _parent,
%{page: page, limit: limit, sort: sort, direction: direction}, %{page: page, limit: limit, sort: sort, direction: direction},
%{ %{
context: %{current_user: %User{role: role}} context: %{
current_user: %User{
role: role
}
}
} }
) )
when is_moderator(role) do when is_moderator(role) do
@ -53,8 +65,8 @@ defmodule MobilizonWeb.Resolvers.User do
""" """
def login_user(_parent, %{email: email, password: password}, _resolution) do def login_user(_parent, %{email: email, password: password}, _resolution) do
with {:ok, %User{} = user} <- Users.get_user_by_email(email, true), with {:ok, %User{} = user} <- Users.get_user_by_email(email, true),
{:ok, token, _} <- Users.authenticate(%{user: user, password: password}) do {:ok, %{access_token: access_token, refresh_token: refresh_token}} <- Users.authenticate(%{user: user, password: password}) do
{:ok, %{token: token, user: user}} {:ok, %{access_token: access_token, refresh_token: refresh_token, user: user}}
else else
{:error, :user_not_found} -> {:error, :user_not_found} ->
{:error, "User with email not found"} {:error, "User with email not found"}
@ -64,6 +76,30 @@ defmodule MobilizonWeb.Resolvers.User do
end end
end end
@doc """
Refresh a token
"""
def refresh_token(
_parent,
%{
refresh_token: refresh_token
},
_context
) do
with {:ok, _old, {exchanged_token, _claims}} <- MobilizonWeb.Guardian.exchange(refresh_token, "refresh", "access", ttl: { 1, :days}),
{:ok, user, _claims} <- MobilizonWeb.Guardian.resource_from_token(refresh_token),
{:ok, refresh_token} <- Users.generate_refresh_token(user) do
{:ok, %{access_token: exchanged_token, refresh_token: refresh_token}}
else
{:error, message} ->
Logger.debug("Cannot refresh user token: #{inspect(message)}")
{:error, "Cannot refresh the token"}
end
end
def refresh_token(_parent, _params, _context),
do: {:error, "You need to have an existing token to get a refresh token"}
@doc """ @doc """
Register an user: Register an user:
- check registrations are enabled - check registrations are enabled
@ -92,9 +128,8 @@ defmodule MobilizonWeb.Resolvers.User do
with {:check_confirmation_token, {:ok, %User{} = user}} <- with {:check_confirmation_token, {:ok, %User{} = user}} <-
{:check_confirmation_token, Activation.check_confirmation_token(token)}, {:check_confirmation_token, Activation.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)},
{:guardian_encode_and_sign, {:ok, token, _}} <- {:ok, %{access_token: access_token, refresh_token: refresh_token}} <- Users.generate_tokens(user) do
{:guardian_encode_and_sign, MobilizonWeb.Guardian.encode_and_sign(user)} do {:ok, %{access_token: access_token, refresh_token: refresh_token, user: Map.put(user, :default_actor, actor)}}
{:ok, %{token: token, user: Map.put(user, :default_actor, actor)}}
else else
err -> err ->
Logger.info("Unable to validate user with token #{token}") Logger.info("Unable to validate user with token #{token}")
@ -145,15 +180,21 @@ defmodule MobilizonWeb.Resolvers.User do
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{} = user} <-
ResetPassword.check_reset_password_token(password, token), ResetPassword.check_reset_password_token(password, token),
{:ok, token, _} <- MobilizonWeb.Guardian.encode_and_sign(user) do {:ok, %{access_token: access_token, refresh_token: refresh_token}} <- Users.authenticate(%{user: user, password: password}) do
{:ok, %{token: token, user: user}} {:ok, %{access_token: access_token, refresh_token: refresh_token, user: user}}
end end
end end
@doc "Change an user default actor" @doc "Change an user default actor"
def change_default_actor(_parent, %{preferred_username: username}, %{ def change_default_actor(
context: %{current_user: user} _parent,
}) do %{preferred_username: username},
%{
context: %{
current_user: user
}
}
) do
with %Actor{id: actor_id} <- Actors.get_local_actor_by_name(username), with %Actor{id: actor_id} <- Actors.get_local_actor_by_name(username),
{:user_actor, true} <- {:user_actor, true} <-
{:user_actor, actor_id in Enum.map(Users.get_actors_for_user(user), & &1.id)}, {:user_actor, actor_id in Enum.map(Users.get_actors_for_user(user), & &1.id)},

View File

@ -31,7 +31,8 @@ defmodule MobilizonWeb.Schema do
@desc "A JWT and the associated user ID" @desc "A JWT and the associated user ID"
object :login do object :login do
field(:token, non_null(:string), description: "A JWT Token for this session") field(:access_token, non_null(:string), description: "A JWT Token for this session")
field(:refresh_token, non_null(:string), description: "A JWT Token to refresh the access token")
field(:user, non_null(:user), description: "The user associated to this session") field(:user, non_null(:user), description: "The user associated to this session")
end end

View File

@ -45,6 +45,12 @@ defmodule MobilizonWeb.Schema.UserType do
) )
end end
@desc "Token"
object :refreshed_token do
field(:access_token, non_null(:string), description: "Generated access token")
field(:refresh_token, non_null(:string), description: "Generated refreshed token")
end
@desc "Users list" @desc "Users list"
object :users do object :users do
field(:total, non_null(:integer), description: "Total elements") field(:total, non_null(:integer), description: "Total elements")
@ -118,12 +124,18 @@ defmodule MobilizonWeb.Schema.UserType do
end end
@desc "Login an user" @desc "Login an user"
field :login, :login do field :login, type: :login do
arg(:email, non_null(:string)) arg(:email, non_null(:string))
arg(:password, non_null(:string)) arg(:password, non_null(:string))
resolve(&User.login_user/3) resolve(&User.login_user/3)
end end
@desc "Refresh a token"
field :refresh_token, type: :refreshed_token do
arg(:refresh_token, non_null(:string))
resolve(&User.refresh_token/3)
end
@desc "Change default actor for user" @desc "Change default actor for user"
field :change_default_actor, :user do field :change_default_actor, :user do
arg(:preferred_username, non_null(:string)) arg(:preferred_username, non_null(:string))

View File

@ -7,7 +7,7 @@ defmodule Mobilizon.Mixfile do
[ [
app: :mobilizon, app: :mobilizon,
version: @version, version: @version,
elixir: "~> 1.9", elixir: "~> 1.8",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
compilers: [:phoenix, :gettext] ++ Mix.compilers(), compilers: [:phoenix, :gettext] ++ Mix.compilers(),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,

View File

@ -3,6 +3,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
alias Mobilizon.{Actors, Users, CommonConfig} alias Mobilizon.{Actors, Users, CommonConfig}
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Users.User alias Mobilizon.Users.User
alias Mobilizon.Users
alias MobilizonWeb.AbsintheHelpers alias MobilizonWeb.AbsintheHelpers
alias Mobilizon.Service.Users.ResetPassword alias Mobilizon.Service.Users.ResetPassword
import Mobilizon.Factory import Mobilizon.Factory
@ -433,7 +434,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
validateUser( validateUser(
token: "#{user.confirmation_token}" token: "#{user.confirmation_token}"
) { ) {
token, accessToken,
user { user {
id, id,
}, },
@ -456,7 +457,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
validateUser( validateUser(
token: "no pass" token: "no pass"
) { ) {
token, accessToken,
user { user {
id id
}, },
@ -641,7 +642,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
end end
end end
describe "Resolver: Login an 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", context do
{:ok, %User{} = user} = Users.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) {:ok, %User{} = user} = Users.register(%{email: "toto@tata.tld", password: "p4ssw0rd"})
@ -658,7 +659,8 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
email: "#{user.email}", email: "#{user.email}",
password: "#{user.password}", password: "#{user.password}",
) { ) {
token, accessToken,
refreshToken,
user { user {
id id
} }
@ -671,7 +673,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) |> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert login = json_response(res, 200)["data"]["login"] assert login = json_response(res, 200)["data"]["login"]
assert Map.has_key?(login, "token") && not is_nil(login["token"]) 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", context do
@ -690,7 +692,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
email: "#{user.email}", email: "#{user.email}",
password: "bad password", password: "bad password",
) { ) {
token, accessToken,
user { user {
default_actor { default_actor {
preferred_username, preferred_username,
@ -715,7 +717,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
email: "bad email", email: "bad email",
password: "bad password", password: "bad password",
) { ) {
token, accessToken,
user { user {
default_actor { default_actor {
preferred_username, preferred_username,
@ -733,6 +735,66 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do
end end
end end
describe "Resolver: Refresh a token" do
test "test refresh_token/3 with a bad token", context do
mutation = """
mutation {
refreshToken(
refreshToken: "bad_token"
) {
accessToken
}
}
"""
res =
context.conn
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert hd(json_response(res, 200)["errors"])["message"] ==
"Cannot refresh the token"
end
test "test refresh_token/3 with an appropriate token", context do
user = insert(:user)
{:ok, refresh_token} = Users.generate_refresh_token(user)
mutation = """
mutation {
refreshToken(
refreshToken: "#{refresh_token}"
) {
accessToken
}
}
"""
res =
context.conn
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
assert json_response(res, 200)["errors"] == nil
access_token = json_response(res, 200)["data"]["refreshToken"]["accessToken"]
assert String.length(access_token) > 10
query = """
{
loggedPerson {
preferredUsername,
}
}
"""
res =
context.conn
|> Plug.Conn.put_req_header("authorization", "Bearer #{access_token}")
|> post("/api", AbsintheHelpers.query_skeleton(query, "logged_person"))
assert json_response(res, 200)["errors"] == nil
end
end
describe "Resolver: change default actor for user" do describe "Resolver: change default actor for user" do
test "test change_default_actor/3 with valid actor", context do test "test change_default_actor/3 with valid actor", context do
# Prepare user with two actors # Prepare user with two actors