diff --git a/js/package.json b/js/package.json index 45be10602..3250a61dc 100644 --- a/js/package.json +++ b/js/package.json @@ -17,7 +17,6 @@ "apollo-client": "2.5.1", "apollo-link": "^1.2.11", "apollo-link-http": "^1.5.14", - "apollo-link-state": "^0.4.2", "buefy": "^0.7.3", "easygettext": "^2.7.0", "graphql": "^14.2.1", @@ -53,6 +52,7 @@ "@vue/cli-service": "^3.6.0", "@vue/eslint-config-typescript": "^4.0.0", "@vue/test-utils": "^1.0.0-beta.29", + "apollo-link-error": "^1.1.11", "chai": "^4.2.0", "dotenv-webpack": "^1.7.0", "eslint": "^6.0.1", diff --git a/js/src/App.vue b/js/src/App.vue index 183adb48c..2be5576e2 100644 --- a/js/src/App.vue +++ b/js/src/App.vue @@ -11,7 +11,7 @@ <script lang="ts"> import NavBar from '@/components/NavBar.vue'; 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 { ICurrentUser } from '@/types/current-user.model'; import Footer from '@/components/Footer.vue'; @@ -34,20 +34,20 @@ export default class App extends Vue { actor = localStorage.getItem(AUTH_USER_ACTOR); - async mounted () { + async mounted() { await this.initializeCurrentUser(); } - getUser (): ICurrentUser|false { + getUser(): ICurrentUser | false { return this.currentUser.id ? this.currentUser : false; } private initializeCurrentUser() { const userId = localStorage.getItem(AUTH_USER_ID); 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({ mutation: UPDATE_CURRENT_USER_CLIENT, variables: { @@ -62,42 +62,42 @@ export default class App extends Vue { </script> <style lang="scss"> - @import "variables"; +@import "variables"; - /* Bulma imports */ - @import "~bulma/sass/base/_all.sass"; - @import "~bulma/sass/components/card.sass"; - @import "~bulma/sass/components/media.sass"; - @import "~bulma/sass/components/message.sass"; - @import "~bulma/sass/components/modal.sass"; - @import "~bulma/sass/components/navbar.sass"; - @import "~bulma/sass/components/pagination.sass"; - @import "~bulma/sass/components/dropdown.sass"; - @import "~bulma/sass/elements/box.sass"; - @import "~bulma/sass/elements/button.sass"; - @import "~bulma/sass/elements/container.sass"; - @import "~bulma/sass/form/_all"; - @import "~bulma/sass/elements/icon.sass"; - @import "~bulma/sass/elements/image.sass"; - @import "~bulma/sass/elements/other.sass"; - @import "~bulma/sass/elements/tag.sass"; - @import "~bulma/sass/elements/title.sass"; - @import "~bulma/sass/elements/notification"; - @import "~bulma/sass/grid/_all.sass"; - @import "~bulma/sass/layout/_all.sass"; - @import "~bulma/sass/utilities/_all"; +/* Bulma imports */ +@import "~bulma/sass/base/_all.sass"; +@import "~bulma/sass/components/card.sass"; +@import "~bulma/sass/components/media.sass"; +@import "~bulma/sass/components/message.sass"; +@import "~bulma/sass/components/modal.sass"; +@import "~bulma/sass/components/navbar.sass"; +@import "~bulma/sass/components/pagination.sass"; +@import "~bulma/sass/components/dropdown.sass"; +@import "~bulma/sass/elements/box.sass"; +@import "~bulma/sass/elements/button.sass"; +@import "~bulma/sass/elements/container.sass"; +@import "~bulma/sass/form/_all"; +@import "~bulma/sass/elements/icon.sass"; +@import "~bulma/sass/elements/image.sass"; +@import "~bulma/sass/elements/other.sass"; +@import "~bulma/sass/elements/tag.sass"; +@import "~bulma/sass/elements/title.sass"; +@import "~bulma/sass/elements/notification"; +@import "~bulma/sass/grid/_all.sass"; +@import "~bulma/sass/layout/_all.sass"; +@import "~bulma/sass/utilities/_all"; - /* Buefy imports */ - @import "~buefy/src/scss/utils/_all"; - @import "~buefy/src/scss/components/datepicker"; - @import "~buefy/src/scss/components/notices"; - @import "~buefy/src/scss/components/dropdown"; - @import "~buefy/src/scss/components/autocomplete"; - @import "~buefy/src/scss/components/form"; - @import "~buefy/src/scss/components/modal"; - @import "~buefy/src/scss/components/tag"; - @import "~buefy/src/scss/components/taginput"; - @import "~buefy/src/scss/components/upload"; +/* Buefy imports */ +@import "~buefy/src/scss/utils/_all"; +@import "~buefy/src/scss/components/datepicker"; +@import "~buefy/src/scss/components/notices"; +@import "~buefy/src/scss/components/dropdown"; +@import "~buefy/src/scss/components/autocomplete"; +@import "~buefy/src/scss/components/form"; +@import "~buefy/src/scss/components/modal"; +@import "~buefy/src/scss/components/tag"; +@import "~buefy/src/scss/components/taginput"; +@import "~buefy/src/scss/components/upload"; .router-enter-active, .router-leave-active { diff --git a/js/src/apollo/user.ts b/js/src/apollo/user.ts index fdf1804bd..9bdf94446 100644 --- a/js/src/apollo/user.ts +++ b/js/src/apollo/user.ts @@ -1,27 +1,32 @@ -export const currentUser = { - defaults: { - currentUser: { - __typename: 'CurrentUser', - id: null, - email: null, - isLoggedIn: false, - }, - }, +import { ApolloCache } from 'apollo-cache'; +import { NormalizedCacheObject } from 'apollo-cache-inmemory'; - resolvers: { - Mutation: { - updateCurrentUser: (_, { id, email, isLoggedIn }, { cache }) => { - const data = { +export function buildCurrentUserResolver(cache: ApolloCache<NormalizedCacheObject>) { + cache.writeData({ + data: { + currentUser: { + __typename: 'CurrentUser', + id: null, + email: null, + isLoggedIn: false, + }, + }, + }); + + return { + updateCurrentUser: (_, { id, email, isLoggedIn }, { cache }) => { + const data = { + Mutation: { currentUser: { id, email, isLoggedIn, __typename: 'CurrentUser', }, - }; + }, + }; - cache.writeData({ data }); - }, + cache.writeData({ data }); }, - }, -}; + }; +} diff --git a/js/src/components/Event/AddressAutoComplete.vue b/js/src/components/Event/AddressAutoComplete.vue index 023ef7963..b3362a61c 100644 --- a/js/src/components/Event/AddressAutoComplete.vue +++ b/js/src/components/Event/AddressAutoComplete.vue @@ -45,7 +45,7 @@ export default class AddressAutoComplete extends Vue { this.data = result.data.searchAddress as IAddress[]; } - @Watch("selected") + @Watch('selected') updateSelected() { this.$emit('input', this.selected); } diff --git a/js/src/components/NavBar.vue b/js/src/components/NavBar.vue index 727325053..972a55dfa 100644 --- a/js/src/components/NavBar.vue +++ b/js/src/components/NavBar.vue @@ -61,9 +61,8 @@ <script lang="ts"> import { Component, Vue, Watch } from 'vue-property-decorator'; -import { CURRENT_USER_CLIENT, UPDATE_CURRENT_USER_CLIENT } from '@/graphql/user'; -import { onLogout } from '@/vue-apollo'; -import { deleteUserData } from '@/utils/auth'; +import { CURRENT_USER_CLIENT } from '@/graphql/user'; +import { logout } from '@/utils/auth'; import { LOGGED_PERSON } from '@/graphql/actor'; import { IPerson } from '@/types/actor'; import { CONFIG } from '@/graphql/config'; @@ -89,7 +88,7 @@ import SearchField from '@/components/SearchField.vue'; export default class NavBar extends Vue { notifications = [ { header: 'Coucou' }, - { title: "T'as une notification", subtitle: 'Et elle est cool' }, + { title: 'T\'as une notification', subtitle: 'Et elle est cool' }, ]; loggedPerson: IPerson | null = null; config!: IConfig; @@ -111,31 +110,20 @@ export default class NavBar extends Vue { } async logout() { - await this.$apollo.mutate({ - mutation: UPDATE_CURRENT_USER_CLIENT, - variables: { - id: null, - email: null, - isLoggedIn: false, - }, - }); - - deleteUserData(); - - onLogout(this.$apollo); + await logout(this.$apollo.provider.defaultClient); return this.$router.push({ path: '/' }); } } </script> <style lang="scss" scoped> - @import "../variables.scss"; +@import "../variables.scss"; - nav { - border-bottom: solid 1px #0a0a0a; +nav { + border-bottom: solid 1px #0a0a0a; - .navbar-item img { - max-height: 2.5em; - } + .navbar-item img { + max-height: 2.5em; } +} </style> diff --git a/js/src/constants.ts b/js/src/constants.ts index 9ccb54d2b..79dbe270f 100644 --- a/js/src/constants.ts +++ b/js/src/constants.ts @@ -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_EMAIL = 'auth-user-email'; export const AUTH_USER_ACTOR = 'auth-user-actor'; diff --git a/js/src/graphql/auth.ts b/js/src/graphql/auth.ts index 8b757ee59..e491a9c11 100644 --- a/js/src/graphql/auth.ts +++ b/js/src/graphql/auth.ts @@ -3,7 +3,8 @@ import gql from 'graphql-tag'; export const LOGIN = gql` mutation Login($email: String!, $password: String!) { login(email: $email, password: $password) { - token, + accessToken, + refreshToken, user { id, } @@ -33,3 +34,12 @@ mutation ResendConfirmationEmail($email: String!) { resendConfirmationEmail(email: $email) } `; + +export const REFRESH_TOKEN = gql` + mutation RefreshToken($refreshToken: String!) { + refreshToken(refreshToken: $refreshToken) { + accessToken, + refreshToken, + } + } +`; diff --git a/js/src/graphql/user.ts b/js/src/graphql/user.ts index 06c300404..b23b5da32 100644 --- a/js/src/graphql/user.ts +++ b/js/src/graphql/user.ts @@ -12,7 +12,8 @@ mutation CreateUser($email: String!, $password: String!) { export const VALIDATE_USER = gql` mutation ValidateUser($token: String!) { validateUser(token: $token) { - token, + accessToken, + refreshToken, user { id, email, diff --git a/js/src/router/guards/auth-guard.ts b/js/src/router/guards/auth-guard.ts index fb1c0c9ec..1f6bf82b4 100644 --- a/js/src/router/guards/auth-guard.ts +++ b/js/src/router/guards/auth-guard.ts @@ -1,13 +1,13 @@ import { NavigationGuard } from 'vue-router'; import { UserRouteName } from '@/router/user'; 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) { 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 - if (!localStorage.getItem(AUTH_TOKEN)) { + if (!localStorage.getItem(AUTH_ACCESS_TOKEN)) { return next({ name: UserRouteName.LOGIN, query: { diff --git a/js/src/types/apollo.ts b/js/src/types/apollo.ts new file mode 100644 index 000000000..a35cb8307 --- /dev/null +++ b/js/src/types/apollo.ts @@ -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 }; diff --git a/js/src/types/login.model.ts b/js/src/types/login.model.ts index 73289a066..3807b150b 100644 --- a/js/src/types/login.model.ts +++ b/js/src/types/login.model.ts @@ -1,7 +1,10 @@ import { ICurrentUser } from '@/types/current-user.model'; -export interface ILogin { - user: ICurrentUser; - - token: string; +export interface IToken { + accessToken: string; + refreshToken: string; +} + +export interface ILogin extends IToken { + user: ICurrentUser; } diff --git a/js/src/utils/auth.ts b/js/src/utils/auth.ts index c2cb8010a..29698bf7a 100644 --- a/js/src/utils/auth.ts +++ b/js/src/utils/auth.ts @@ -1,14 +1,38 @@ -import { AUTH_TOKEN, AUTH_USER_EMAIL, AUTH_USER_ID } from '@/constants'; -import { ILogin } from '@/types/login.model'; +import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN, AUTH_USER_EMAIL, AUTH_USER_ID } from '@/constants'; +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) { localStorage.setItem(AUTH_USER_ID, `${obj.user.id}`); 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() { - 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); } } + +export function logout(apollo: ApolloClient<any>) { + apollo.mutate({ + mutation: UPDATE_CURRENT_USER_CLIENT, + variables: { + id: null, + email: null, + isLoggedIn: false, + }, + }); + + deleteUserData(); + + onLogout(); +} diff --git a/js/src/views/Event/Create.vue b/js/src/views/Event/Create.vue index 856432c05..0de44411e 100644 --- a/js/src/views/Event/Create.vue +++ b/js/src/views/Event/Create.vue @@ -137,9 +137,9 @@ export default class CreateEvent extends Vue { const obj = { organizerActorId: this.loggedPerson.id, beginsOn: this.event.beginsOn.toISOString(), - tags: this.event.tags.map((tag: ITag) => tag.title) + tags: this.event.tags.map((tag: ITag) => tag.title), }; - let res = Object.assign({}, this.event, obj); + const res = Object.assign({}, this.event, obj); if (this.event.physicalAddress) { delete this.event.physicalAddress['__typename']; diff --git a/js/src/views/Home.vue b/js/src/views/Home.vue index 77970c1c5..2185c2c44 100644 --- a/js/src/views/Home.vue +++ b/js/src/views/Home.vue @@ -1,5 +1,5 @@ <template> - <div class="container"> + <div class="container" v-if="config"> <section class="hero is-link" v-if="!currentUser.id || !loggedPerson"> <div class="hero-body"> <div class="container"> diff --git a/js/src/views/User/Validate.vue b/js/src/views/User/Validate.vue index 7ac0a216e..76b3237d8 100644 --- a/js/src/views/User/Validate.vue +++ b/js/src/views/User/Validate.vue @@ -19,9 +19,10 @@ <script lang="ts"> import { VALIDATE_USER } from '@/graphql/user'; 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 { UserRouteName } from '@/router/user'; +import { saveTokenData } from '@/utils/auth'; @Component export default class Validate extends Vue { @@ -62,7 +63,8 @@ export default class Validate extends Vue { saveUserData({ validateUser: login }) { localStorage.setItem(AUTH_USER_ID, login.user.id); - localStorage.setItem(AUTH_TOKEN, login.token); + + saveTokenData(login); } } </script> diff --git a/js/src/vue-apollo.ts b/js/src/vue-apollo.ts index 69b2ef726..770e90b08 100644 --- a/js/src/vue-apollo.ts +++ b/js/src/vue-apollo.ts @@ -1,15 +1,18 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { ApolloLink } from 'apollo-link'; +import { ApolloLink, Observable } from 'apollo-link'; import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; +import { onError } from 'apollo-link-error'; import { createLink } from 'apollo-absinthe-upload-link'; -import { AUTH_TOKEN } from './constants'; 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 { 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 Vue.use(VueApollo); @@ -44,14 +47,11 @@ const fragmentMatcher = new IntrospectionFragmentMatcher({ }, }); -const cache = new InMemoryCache({ fragmentMatcher }); - const authMiddleware = new ApolloLink((operation, forward) => { // add the authorization to the headers - const token = localStorage.getItem(AUTH_TOKEN); operation.setContext({ headers: { - authorization: token ? `Bearer ${token}` : null, + authorization: generateTokenHeader(), }, }); @@ -64,21 +64,54 @@ const uploadLink = createLink({ uri: httpEndpoint, }); -const stateLink = withClientState({ - ...merge(currentUser), - cache, +let refreshingTokenPromise: Promise<boolean> | undefined; +let alreadyRefreshedToken = false; +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({ cache, link, connectToDevTools: true, + resolvers: { + currentUser: buildCurrentUserResolver(cache), + }, }); -apolloClient.onResetStore(stateLink.writeDefaults as any); - export const apolloProvider = new VueApollo({ defaultClient: apolloClient, errorHandler(error) { @@ -93,13 +126,65 @@ export function onLogin(apolloClient) { } // Manually call this when user log out -export async function onLogout(apolloClient: DollarApollo<any>) { +export async function onLogout() { // if (apolloClient.wsClient) restartWebsockets(apolloClient.wsClient); try { - await apolloClient.provider.defaultClient.resetStore(); + await apolloClient.resetStore(); } catch (e) { // eslint-disable-next-line no-console 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); + }, + ); + }); +}; diff --git a/js/yarn.lock b/js/yarn.lock index 0d77013df..d449524c3 100644 --- a/js/yarn.lock +++ b/js/yarn.lock @@ -1845,6 +1845,15 @@ apollo-link-dedup@^1.0.0: apollo-link "^1.2.12" 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: version "0.2.14" 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" 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: version "1.2.12" 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" 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" resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.2.tgz#8cbdcf8b012f664cd6cb5767f6130f5aed9115c9" 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" 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: version "2.10.1" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.10.1.tgz#10aa41f1cd8fae5373eaf11f1f67260a3cad5e02" @@ -10196,13 +10188,6 @@ ts-invariant@^0.2.1: dependencies: 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: version "0.4.4" resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" diff --git a/lib/mobilizon/users/users.ex b/lib/mobilizon/users/users.ex index 0883f5d75..92697d931 100644 --- a/lib/mobilizon/users/users.ex +++ b/lib/mobilizon/users/users.ex @@ -27,7 +27,9 @@ defmodule Mobilizon.Users do @spec register(map()) :: {:ok, User.t()} | {:error, String.t()} def register(%{email: _email, password: _password} = args) do 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}) {:ok, user} end @@ -51,13 +53,15 @@ defmodule Mobilizon.Users do from(u in User, where: u.email == ^email, preload: :default_actor) true -> - from(u in User, + from( + u in User, where: u.email == ^email and not is_nil(u.confirmed_at), preload: :default_actor ) false -> - from(u in User, + from( + u in User, where: u.email == ^email and is_nil(u.confirmed_at), preload: :default_actor ) @@ -75,7 +79,8 @@ defmodule Mobilizon.Users do @spec get_user_by_activation_token(String.t()) :: Actor.t() def get_user_by_activation_token(token) do Repo.one( - from(u in User, + from( + u in User, where: u.confirmation_token == ^token, preload: [:default_actor] ) @@ -88,7 +93,8 @@ defmodule Mobilizon.Users do @spec get_user_by_reset_password_token(String.t()) :: Actor.t() def get_user_by_reset_password_token(token) do Repo.one( - from(u in User, + from( + u in User, where: u.reset_password_token == ^token, preload: [:default_actor] ) @@ -197,14 +203,16 @@ defmodule Mobilizon.Users do @spec get_actor_for_user(Mobilizon.Users.User.t()) :: Mobilizon.Actors.Actor.t() def get_actor_for_user(%Mobilizon.Users.User{} = user) do case Repo.one( - from(a in Actor, + from( + a in Actor, join: u in User, on: u.default_actor_id == a.id, where: u.id == ^user.id ) ) do nil -> - case user |> get_actors_for_user() do + case user + |> get_actors_for_user() do [] -> nil actors -> hd(actors) end @@ -223,22 +231,55 @@ defmodule Mobilizon.Users do """ def authenticate(%{user: user, password: password}) do # Does password match the one stored in the database? - case Argon2.verify_pass(password, user.password_hash) do - true -> - # Yes, create and return the token - MobilizonWeb.Guardian.encode_and_sign(user) - + with true <- Argon2.verify_pass(password, user.password_hash), + # Yes, create and return the token + {:ok, tokens} <- generate_tokens(user) do + {:ok, tokens} + else _ -> # No, return an error {:error, :unauthorized} 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 + + defp generate_access_token(user) do + with {:ok, access_token, _claims} <- + MobilizonWeb.Guardian.encode_and_sign(user, %{}, token_type: "access") 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") do + {:ok, refresh_token} + end + end + def update_user_default_actor(user_id, actor_id) do 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.get!(User, user_id) |> Repo.preload([:default_actor]) + Repo.get!(User, user_id) + |> Repo.preload([:default_actor]) end end diff --git a/lib/mobilizon_web/context.ex b/lib/mobilizon_web/context.ex index be5834d04..7721162d4 100644 --- a/lib/mobilizon_web/context.ex +++ b/lib/mobilizon_web/context.ex @@ -17,7 +17,8 @@ defmodule MobilizonWeb.Context do context = case Guardian.Plug.current_resource(conn) do %User{} = user -> - Map.put(context, :current_user, user) + context + |> Map.put(:current_user, user) nil -> context diff --git a/lib/mobilizon_web/guardian.ex b/lib/mobilizon_web/guardian.ex index 465178f1b..8fd954c6d 100644 --- a/lib/mobilizon_web/guardian.ex +++ b/lib/mobilizon_web/guardian.ex @@ -61,6 +61,14 @@ defmodule MobilizonWeb.Guardian do end end + def on_refresh({old_token, old_claims}, {new_token, new_claims}, _options) do + with {:ok, _, _} <- Guardian.DB.on_refresh({old_token, old_claims}, {new_token, new_claims}) do + {:ok, {old_token, old_claims}, {new_token, new_claims}} + end + end + + def on_exchange(old_stuff, new_stuff, options), do: on_refresh(old_stuff, new_stuff, options) + # def build_claims(claims, _resource, opts) do # claims = claims # |> encode_permissions_into_claims!(Keyword.get(opts, :permissions)) diff --git a/lib/mobilizon_web/resolvers/user.ex b/lib/mobilizon_web/resolvers/user.ex index fed884d19..500036fa3 100644 --- a/lib/mobilizon_web/resolvers/user.ex +++ b/lib/mobilizon_web/resolvers/user.ex @@ -20,7 +20,15 @@ defmodule MobilizonWeb.Resolvers.User do @doc """ 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} end @@ -35,7 +43,11 @@ defmodule MobilizonWeb.Resolvers.User do _parent, %{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 @@ -53,8 +65,9 @@ defmodule MobilizonWeb.Resolvers.User do """ def login_user(_parent, %{email: email, password: password}, _resolution) do with {:ok, %User{} = user} <- Users.get_user_by_email(email, true), - {:ok, token, _} <- Users.authenticate(%{user: user, password: password}) do - {:ok, %{token: token, user: user}} + {:ok, %{access_token: access_token, refresh_token: refresh_token}} <- + Users.authenticate(%{user: user, password: password}) do + {:ok, %{access_token: access_token, refresh_token: refresh_token, user: user}} else {:error, :user_not_found} -> {:error, "User with email not found"} @@ -64,6 +77,31 @@ defmodule MobilizonWeb.Resolvers.User do end end + @doc """ + Refresh a token + """ + def refresh_token( + _parent, + %{ + refresh_token: refresh_token + }, + _context + ) do + with {:ok, user, _claims} <- MobilizonWeb.Guardian.resource_from_token(refresh_token), + {:ok, _old, {exchanged_token, _claims}} <- + MobilizonWeb.Guardian.exchange(refresh_token, ["access", "refresh"], "access"), + {: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 """ Register an user: - check registrations are enabled @@ -92,9 +130,14 @@ defmodule MobilizonWeb.Resolvers.User do with {:check_confirmation_token, {:ok, %User{} = user}} <- {:check_confirmation_token, Activation.check_confirmation_token(token)}, {:get_actor, actor} <- {:get_actor, Users.get_actor_for_user(user)}, - {:guardian_encode_and_sign, {:ok, token, _}} <- - {:guardian_encode_and_sign, MobilizonWeb.Guardian.encode_and_sign(user)} do - {:ok, %{token: token, user: Map.put(user, :default_actor, actor)}} + {:ok, %{access_token: access_token, refresh_token: refresh_token}} <- + Users.generate_tokens(user) do + {:ok, + %{ + access_token: access_token, + refresh_token: refresh_token, + user: Map.put(user, :default_actor, actor) + }} else err -> Logger.info("Unable to validate user with token #{token}") @@ -145,15 +188,22 @@ defmodule MobilizonWeb.Resolvers.User do def reset_password(_parent, %{password: password, token: token}, _resolution) do with {:ok, %User{} = user} <- ResetPassword.check_reset_password_token(password, token), - {:ok, token, _} <- MobilizonWeb.Guardian.encode_and_sign(user) do - {:ok, %{token: token, user: user}} + {:ok, %{access_token: access_token, refresh_token: refresh_token}} <- + Users.authenticate(%{user: user, password: password}) do + {:ok, %{access_token: access_token, refresh_token: refresh_token, user: user}} end end @doc "Change an user default actor" - def change_default_actor(_parent, %{preferred_username: username}, %{ - context: %{current_user: user} - }) do + def change_default_actor( + _parent, + %{preferred_username: username}, + %{ + context: %{ + current_user: user + } + } + ) do with %Actor{id: actor_id} <- Actors.get_local_actor_by_name(username), {:user_actor, true} <- {:user_actor, actor_id in Enum.map(Users.get_actors_for_user(user), & &1.id)}, diff --git a/lib/mobilizon_web/schema.ex b/lib/mobilizon_web/schema.ex index 38d223007..c3819ab75 100644 --- a/lib/mobilizon_web/schema.ex +++ b/lib/mobilizon_web/schema.ex @@ -31,7 +31,12 @@ defmodule MobilizonWeb.Schema do @desc "A JWT and the associated user ID" 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") end diff --git a/lib/mobilizon_web/schema/user.ex b/lib/mobilizon_web/schema/user.ex index 86bbf8d73..1b3a525e6 100644 --- a/lib/mobilizon_web/schema/user.ex +++ b/lib/mobilizon_web/schema/user.ex @@ -45,6 +45,12 @@ defmodule MobilizonWeb.Schema.UserType do ) 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" object :users do field(:total, non_null(:integer), description: "Total elements") @@ -118,12 +124,18 @@ defmodule MobilizonWeb.Schema.UserType do end @desc "Login an user" - field :login, :login do + field :login, type: :login do arg(:email, non_null(:string)) arg(:password, non_null(:string)) resolve(&User.login_user/3) 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" field :change_default_actor, :user do arg(:preferred_username, non_null(:string)) diff --git a/mix.exs b/mix.exs index a3e2c19d8..4357ae930 100644 --- a/mix.exs +++ b/mix.exs @@ -7,7 +7,7 @@ defmodule Mobilizon.Mixfile do [ app: :mobilizon, version: @version, - elixir: "~> 1.9", + elixir: "~> 1.8", elixirc_paths: elixirc_paths(Mix.env()), compilers: [:phoenix, :gettext] ++ Mix.compilers(), start_permanent: Mix.env() == :prod, diff --git a/test/mobilizon/users/users_test.exs b/test/mobilizon/users/users_test.exs index a7ebceeb9..bb43805ab 100644 --- a/test/mobilizon/users/users_test.exs +++ b/test/mobilizon/users/users_test.exs @@ -68,7 +68,7 @@ defmodule Mobilizon.UsersTest do test "authenticate/1 checks the user's password" do {:ok, %User{} = user} = Users.register(%{email: @email, password: @password}) - assert {:ok, _, _} = Users.authenticate(%{user: user, password: @password}) + assert {:ok, _} = Users.authenticate(%{user: user, password: @password}) assert {:error, :unauthorized} == Users.authenticate(%{user: user, password: "bad password"}) diff --git a/test/mobilizon_web/resolvers/user_resolver_test.exs b/test/mobilizon_web/resolvers/user_resolver_test.exs index dd96bd98c..b996d7ea9 100644 --- a/test/mobilizon_web/resolvers/user_resolver_test.exs +++ b/test/mobilizon_web/resolvers/user_resolver_test.exs @@ -3,6 +3,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do alias Mobilizon.{Actors, Users, CommonConfig} alias Mobilizon.Actors.Actor alias Mobilizon.Users.User + alias Mobilizon.Users alias MobilizonWeb.AbsintheHelpers alias Mobilizon.Service.Users.ResetPassword import Mobilizon.Factory @@ -433,7 +434,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do validateUser( token: "#{user.confirmation_token}" ) { - token, + accessToken, user { id, }, @@ -456,7 +457,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do validateUser( token: "no pass" ) { - token, + accessToken, user { id }, @@ -641,7 +642,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do end end - describe "Resolver: Login an user" do + describe "Resolver: Login a user" do test "test login_user/3 with valid credentials", context do {:ok, %User{} = user} = Users.register(%{email: "toto@tata.tld", password: "p4ssw0rd"}) @@ -658,7 +659,8 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do email: "#{user.email}", password: "#{user.password}", ) { - token, + accessToken, + refreshToken, user { id } @@ -671,7 +673,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do |> post("/api", AbsintheHelpers.mutation_skeleton(mutation)) 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 test "test login_user/3 with invalid password", context do @@ -690,7 +692,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do email: "#{user.email}", password: "bad password", ) { - token, + accessToken, user { default_actor { preferred_username, @@ -715,7 +717,7 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do email: "bad email", password: "bad password", ) { - token, + accessToken, user { default_actor { preferred_username, @@ -733,6 +735,66 @@ defmodule MobilizonWeb.Resolvers.UserResolverTest do 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 test "test change_default_actor/3 with valid actor", context do # Prepare user with two actors