Prechádzať zdrojové kódy

feat: Implement refresh tokens

Closes #16, relates to #19. Still using local/session storage. Next task is to look into using cookies to store tokens. If implemented with CSRF mitigation, it should be more secure and potentially require less work for clients to implement auth.
Michael Bromley 7 rokov pred
rodič
commit
f0f39caa9a

+ 0 - 1
admin-ui/src/app/core/providers/auth/auth.service.ts

@@ -21,7 +21,6 @@ export class AuthService {
     logIn(username: string, password: string): Observable<SetAsLoggedIn> {
         return this.dataService.auth.attemptLogin(username, password).pipe(
             switchMap(response => {
-                this.localStorageService.setForSession('authToken', response.login.authToken);
                 this.localStorageService.setForSession(
                     'activeChannelToken',
                     response.login.user.channelTokens[0],

+ 1 - 1
admin-ui/src/app/core/providers/local-storage/local-storage.service.ts

@@ -1,6 +1,6 @@
 import { Injectable } from '@angular/core';
 
-export type LocalStorageKey = 'authToken' | 'activeChannelToken';
+export type LocalStorageKey = 'refreshToken' | 'authToken' | 'activeChannelToken';
 const PREFIX = 'vnd_';
 
 /**

+ 6 - 3
admin-ui/src/app/data/data.module.ts

@@ -7,7 +7,7 @@ import { ApolloLink } from 'apollo-link';
 import { setContext } from 'apollo-link-context';
 import { withClientState } from 'apollo-link-state';
 import { createUploadLink } from 'apollo-upload-client';
-import { API_PATH } from 'shared/shared-constants';
+import { API_PATH, REFRESH_TOKEN_KEY } from 'shared/shared-constants';
 
 import { environment } from '../../environments/environment';
 import { API_URL } from '../app.config';
@@ -47,14 +47,17 @@ export function createApollo(
                 // Add JWT auth token & channel token to all requests.
                 const channelToken = localStorageService.get('activeChannelToken');
                 const authToken = localStorageService.get('authToken') || '';
+                const refreshToken = localStorageService.get('refreshToken') || '';
                 if (!authToken) {
                     return {};
                 } else {
                     return {
+                        // prettier-ignore
                         headers: {
-                            Authorization: `Bearer ${authToken}`,
+                            'Authorization': `Bearer ${authToken}`,
                             'vendure-token': channelToken,
-                        },
+                            [REFRESH_TOKEN_KEY]: refreshToken,
+                        }
                     };
                 }
             }),

+ 0 - 1
admin-ui/src/app/data/definitions/auth-definitions.ts

@@ -15,7 +15,6 @@ export const ATTEMPT_LOGIN = gql`
             user {
                 ...CurrentUser
             }
-            authToken
         }
     }
     ${CURRENT_USER_FRAGMENT}

+ 15 - 0
admin-ui/src/app/data/providers/interceptor.ts

@@ -10,10 +10,12 @@ import { Injectable, Injector } from '@angular/core';
 import { Router } from '@angular/router';
 import { Observable } from 'rxjs';
 import { tap } from 'rxjs/operators';
+import { AUTH_TOKEN_KEY, REFRESH_TOKEN_KEY } from 'shared/shared-constants';
 
 import { API_URL } from '../../app.config';
 import { AuthService } from '../../core/providers/auth/auth.service';
 import { _ } from '../../core/providers/i18n/mark-for-extraction';
+import { LocalStorageService } from '../../core/providers/local-storage/local-storage.service';
 import { NotificationService } from '../../core/providers/notification/notification.service';
 
 import { DataService } from './data.service';
@@ -31,6 +33,7 @@ export class DefaultInterceptor implements HttpInterceptor {
         private injector: Injector,
         private authService: AuthService,
         private router: Router,
+        private localStorageService: LocalStorageService,
     ) {}
 
     intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
@@ -39,6 +42,18 @@ export class DefaultInterceptor implements HttpInterceptor {
             tap(
                 event => {
                     if (event instanceof HttpResponse) {
+                        if (event.headers.get(AUTH_TOKEN_KEY)) {
+                            this.localStorageService.setForSession(
+                                'authToken',
+                                event.headers.get(AUTH_TOKEN_KEY),
+                            );
+                        }
+                        if (event.headers.get(REFRESH_TOKEN_KEY)) {
+                            this.localStorageService.set(
+                                'refreshToken',
+                                event.headers.get(REFRESH_TOKEN_KEY),
+                            );
+                        }
                         this.notifyOnError(event);
                         this.dataService.client.completeRequest().subscribe();
                     }

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 0 - 0
schema.json


+ 4 - 3
server/dev-config.ts

@@ -8,11 +8,12 @@ import { DefaultAssetServerPlugin } from './src/plugin/default-asset-server/defa
  * Config settings used during development
  */
 export const devConfig: VendureConfig = {
-    disableAuth: false,
+    authOptions: {
+        disableAuth: false,
+        jwtSecret: 'some-secret',
+    },
     port: API_PORT,
     apiPath: API_PATH,
-    cors: true,
-    jwtSecret: 'some-secret',
     dbConnectionOptions: {
         type: 'mysql',
         synchronize: true,

+ 3 - 1
server/e2e/config/test-config.ts

@@ -15,7 +15,9 @@ export const testConfig: VendureConfig = {
     port: 3050,
     apiPath: API_PATH,
     cors: true,
-    jwtSecret: 'some-secret',
+    authOptions: {
+        jwtSecret: 'some-secret',
+    },
     dbConnectionOptions: {
         type: 'sqljs',
         database: new Uint8Array([]),

+ 9 - 5
server/mock-data/simple-graphql-client.ts

@@ -3,7 +3,11 @@ import { GraphQLClient } from 'graphql-request';
 import { print } from 'graphql/language/printer';
 import { Curl } from 'node-libcurl';
 import { AttemptLogin, AttemptLoginVariables, CreateAssets } from 'shared/generated-types';
-import { SUPER_ADMIN_USER_IDENTIFIER, SUPER_ADMIN_USER_PASSWORD } from 'shared/shared-constants';
+import {
+    AUTH_TOKEN_KEY,
+    SUPER_ADMIN_USER_IDENTIFIER,
+    SUPER_ADMIN_USER_PASSWORD,
+} from 'shared/shared-constants';
 
 import { ATTEMPT_LOGIN } from '../../admin-ui/src/app/data/definitions/auth-definitions';
 import { CREATE_ASSETS } from '../../admin-ui/src/app/data/definitions/product-definitions';
@@ -103,12 +107,12 @@ export class SimpleGraphQLClient {
     }
 
     async asUserWithCredentials(username: string, password: string) {
-        const result = await this.query<AttemptLogin, AttemptLoginVariables>(ATTEMPT_LOGIN, {
+        const result = await this.client.rawRequest<AttemptLogin>(print(ATTEMPT_LOGIN), {
             username,
             password,
-        });
-        if (result.login) {
-            this.setAuthToken(result.login.authToken);
+        } as AttemptLoginVariables);
+        if (result.data && result.data.login) {
+            this.setAuthToken(result.headers.get(AUTH_TOKEN_KEY));
         } else {
             console.error(result);
         }

+ 5 - 0
server/src/api/api.module.ts

@@ -12,6 +12,7 @@ import { GraphqlConfigService } from './common/graphql-config.service';
 import { IdInterceptor } from './common/id-interceptor';
 import { RequestContextService } from './common/request-context.service';
 import { RolesGuard } from './common/roles-guard';
+import { TokenInterceptor } from './common/token-interceptor';
 import { AdministratorResolver } from './resolvers/administrator.resolver';
 import { AssetResolver } from './resolvers/asset.resolver';
 import { AuthResolver } from './resolvers/auth.resolver';
@@ -60,6 +61,10 @@ const exportedProviders = [
             provide: APP_GUARD,
             useClass: RolesGuard,
         },
+        {
+            provide: APP_INTERCEPTOR,
+            useClass: TokenInterceptor,
+        },
         {
             provide: APP_INTERCEPTOR,
             useClass: AssetInterceptor,

+ 46 - 11
server/src/api/common/auth-guard.ts

@@ -1,7 +1,10 @@
 import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
 import { Reflector } from '@nestjs/core';
 import { GqlExecutionContext } from '@nestjs/graphql';
+import { Request, Response } from 'express';
+import { TokenExpiredError } from 'jsonwebtoken';
 import { ExtractJwt, Strategy } from 'passport-jwt';
+import { AUTH_TOKEN_KEY, REFRESH_TOKEN_KEY } from 'shared/shared-constants';
 
 import { JwtPayload } from '../../common/types/auth-types';
 import { ConfigService } from '../../config/config.service';
@@ -11,9 +14,6 @@ import { PERMISSIONS_METADATA_KEY } from './roles-guard';
 
 /**
  * A guard which uses passport.js & the passport-jwt strategy to authenticate incoming GraphQL requests.
- * At this time, the Nest AuthGuard is not working with Apollo Server 2, see https://github.com/nestjs/graphql/issues/48
- *
- * If the above issue is fixed, it may make sense to switch to the Nest AuthGuard.
  */
 @Injectable()
 export class AuthGuard implements CanActivate {
@@ -26,11 +26,11 @@ export class AuthGuard implements CanActivate {
     ) {
         this.strategy = new Strategy(
             {
-                secretOrKey: configService.jwtSecret,
+                secretOrKey: configService.authOptions.jwtSecret,
+                algorithms: ['HS256'],
                 jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
-                // TODO: make this configurable. See https://github.com/vendure-ecommerce/vendure/issues/16
                 jsonWebTokenOptions: {
-                    expiresIn: '2 days',
+                    expiresIn: this.configService.authOptions.expiresIn,
                 },
             },
             (payload: any, done: () => void) => this.validate(payload, done),
@@ -39,11 +39,11 @@ export class AuthGuard implements CanActivate {
 
     async canActivate(context: ExecutionContext): Promise<boolean> {
         const permissions = this.reflector.get<string[]>(PERMISSIONS_METADATA_KEY, context.getHandler());
-        if (this.configService.disableAuth || !permissions) {
+        if (this.configService.authOptions.disableAuth || !permissions) {
             return true;
         }
         const ctx = GqlExecutionContext.create(context).getContext();
-        return this.authenticate(ctx.req);
+        return this.authenticate(ctx.req, ctx.res);
     }
 
     async validate(payload: JwtPayload, done: (err: Error | null, user: any) => void) {
@@ -59,13 +59,35 @@ export class AuthGuard implements CanActivate {
      * the methods which it expects to exist because it is designed to run as
      * an Express middleware function.
      */
-    private authenticate(request: any): Promise<boolean> {
+    private authenticate(request: Request, response: Response): Promise<boolean> {
         return new Promise((resolve, reject) => {
-            this.strategy.fail = info => {
+            this.strategy.fail = async info => {
+                if (info instanceof TokenExpiredError) {
+                    const currentAuthToken = this.extractAuthToken(request);
+                    const currentRefreshToken = request.get(REFRESH_TOKEN_KEY);
+                    if (currentAuthToken && currentRefreshToken) {
+                        try {
+                            const result = await this.authService.refreshTokens(
+                                currentAuthToken,
+                                currentRefreshToken,
+                            );
+                            if (result) {
+                                // set the new token headers
+                                response.set(AUTH_TOKEN_KEY, result.authToken);
+                                response.set(REFRESH_TOKEN_KEY, result.refreshToken);
+                                (request as any).user = result.user;
+                                resolve(true);
+                                return;
+                            }
+                        } catch (e) {
+                            // fall through
+                        }
+                    }
+                }
                 resolve(false);
             };
             this.strategy.success = (user, info) => {
-                request.user = user;
+                (request as any).user = user;
                 resolve(true);
             };
             this.strategy.error = err => {
@@ -74,4 +96,17 @@ export class AuthGuard implements CanActivate {
             this.strategy.authenticate(request);
         });
     }
+
+    /**
+     * Extract the token from the 'Authorization: Bearer <token>' header
+     */
+    private extractAuthToken(req: Request): string | undefined {
+        const authHeader = req.get('authorization');
+        if (authHeader) {
+            const matches = authHeader.match(/bearer\s+(.+)/i);
+            if (matches && matches[1]) {
+                return matches[1];
+            }
+        }
+    }
 }

+ 1 - 1
server/src/api/common/roles-guard.ts

@@ -36,7 +36,7 @@ export class RolesGuard {
 
     canActivate(context: ExecutionContext): boolean {
         const permissions = this.reflector.get<string[]>(PERMISSIONS_METADATA_KEY, context.getHandler());
-        if (this.configService.disableAuth || !permissions) {
+        if (this.configService.authOptions.disableAuth || !permissions) {
             return true;
         }
         const req = context.getArgByIndex(2).req;

+ 39 - 0
server/src/api/common/token-interceptor.ts

@@ -0,0 +1,39 @@
+import { ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
+import { GqlExecutionContext } from '@nestjs/graphql';
+import { Response } from 'express';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+import { AUTH_TOKEN_KEY, REFRESH_TOKEN_KEY } from 'shared/shared-constants';
+
+import { ConfigService } from '../../config/config.service';
+
+/**
+ * Transfers auth tokens from response body to headers.
+ */
+@Injectable()
+export class TokenInterceptor implements NestInterceptor {
+    constructor(private configService: ConfigService) {}
+
+    /**
+     * If a resolver returns an object with keys matching the token keys, these values
+     * are removed from the response and transferred to the header.
+     */
+    intercept(context: ExecutionContext, call$: Observable<any>): Observable<any> {
+        return call$.pipe(
+            map(data => {
+                const ctx = GqlExecutionContext.create(context).getContext();
+                this.transferKeyToResponseHeader(AUTH_TOKEN_KEY, data, ctx.res);
+                this.transferKeyToResponseHeader(REFRESH_TOKEN_KEY, data, ctx.res);
+                return data;
+            }),
+        );
+    }
+
+    private transferKeyToResponseHeader(key: string, data: any | null, response: Response) {
+        if (data && data.hasOwnProperty(key)) {
+            const authToken = data[key];
+            delete data[key];
+            response.set(key, authToken);
+        }
+    }
+}

+ 8 - 3
server/src/api/resolvers/auth.resolver.ts

@@ -2,6 +2,7 @@ import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
 import { Request } from 'express';
 import { Permission } from 'shared/generated-types';
 
+import { AUTH_TOKEN_KEY, REFRESH_TOKEN_KEY } from '../../../../shared/shared-constants';
 import { User } from '../../entity/user/user.entity';
 import { AuthService } from '../../service/providers/auth.service';
 import { ChannelService } from '../../service/providers/channel.service';
@@ -17,11 +18,15 @@ export class AuthResolver {
      */
     @Mutation()
     async login(@Args() args: { username: string; password: string }) {
-        const { user, token } = await this.authService.createToken(args.username, args.password);
+        const { user, authToken, refreshToken } = await this.authService.createTokens(
+            args.username,
+            args.password,
+        );
 
-        if (token) {
+        if (authToken) {
             return {
-                authToken: token,
+                [AUTH_TOKEN_KEY]: authToken,
+                [REFRESH_TOKEN_KEY]: refreshToken,
                 user: this.publiclyAccessibleUser(user),
             };
         }

+ 0 - 1
server/src/api/types/auth.api.graphql

@@ -7,7 +7,6 @@ type Mutation {
 }
 
 type LoginResult {
-    authToken: String!
     user: CurrentUser!
 }
 

+ 1 - 2
server/src/config/config.service.mock.ts

@@ -5,12 +5,11 @@ import { ConfigService } from './config.service';
 import { EntityIdStrategy, PrimaryKeyType } from './entity-id-strategy/entity-id-strategy';
 
 export class MockConfigService implements MockClass<ConfigService> {
-    disableAuth = false;
+    authOptions: {};
     channelTokenKey: 'vendure-token';
     apiPath = 'api';
     port = 3000;
     cors = false;
-    jwtSecret = 'secret';
     defaultLanguageCode: jest.Mock<any>;
     entityIdStrategy = new MockIdStrategy();
     assetNamingStrategy = {} as any;

+ 4 - 8
server/src/config/config.service.ts

@@ -11,7 +11,7 @@ import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strate
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
 import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
-import { getConfig, VendureConfig } from './vendure-config';
+import { AuthOptions, getConfig, VendureConfig } from './vendure-config';
 import { VendurePlugin } from './vendure-plugin/vendure-plugin';
 
 @Injectable()
@@ -20,7 +20,7 @@ export class ConfigService implements VendureConfig {
 
     constructor() {
         this.activeConfig = getConfig();
-        if (this.activeConfig.disableAuth) {
+        if (this.activeConfig.authOptions.disableAuth) {
             // tslint:disable-next-line
             console.warn(
                 'WARNING: auth has been disabled. This should never be the case for a production system!',
@@ -28,8 +28,8 @@ export class ConfigService implements VendureConfig {
         }
     }
 
-    get disableAuth(): boolean {
-        return this.activeConfig.disableAuth;
+    get authOptions(): AuthOptions {
+        return this.activeConfig.authOptions;
     }
 
     get channelTokenKey(): string {
@@ -52,10 +52,6 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.cors;
     }
 
-    get jwtSecret(): string {
-        return this.activeConfig.jwtSecret;
-    }
-
     get entityIdStrategy(): EntityIdStrategy {
         return this.activeConfig.entityIdStrategy;
     }

+ 52 - 0
server/src/config/default-config.ts

@@ -0,0 +1,52 @@
+import { LanguageCode } from 'shared/generated-types';
+import { API_PATH, API_PORT, AUTH_TOKEN_KEY, REFRESH_TOKEN_KEY } from 'shared/shared-constants';
+import { CustomFields } from 'shared/shared-types';
+
+import { ReadOnlyRequired } from '../common/types/common-types';
+
+import { DefaultAssetNamingStrategy } from './asset-naming-strategy/default-asset-naming-strategy';
+import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-preview-strategy';
+import { NoAssetStorageStrategy } from './asset-storage-strategy/no-asset-storage-strategy';
+import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
+import { VendureConfig } from './vendure-config';
+
+/**
+ * The default configuration settings which are used if not explicitly overridden in the bootstrap() call.
+ */
+export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
+    channelTokenKey: 'vendure-token',
+    defaultLanguageCode: LanguageCode.en,
+    port: API_PORT,
+    cors: {
+        origin: true,
+        exposedHeaders: [AUTH_TOKEN_KEY, REFRESH_TOKEN_KEY],
+    },
+    authOptions: {
+        disableAuth: false,
+        jwtSecret: 'jwt-secret',
+        expiresIn: '5m',
+        refreshEvery: '7d',
+    },
+    apiPath: API_PATH,
+    entityIdStrategy: new AutoIncrementIdStrategy(),
+    assetNamingStrategy: new DefaultAssetNamingStrategy(),
+    assetStorageStrategy: new NoAssetStorageStrategy(),
+    assetPreviewStrategy: new NoAssetPreviewStrategy(),
+    dbConnectionOptions: {
+        type: 'mysql',
+    },
+    uploadMaxFileSize: 20971520,
+    customFields: {
+        Address: [],
+        Customer: [],
+        Facet: [],
+        FacetValue: [],
+        Product: [],
+        ProductOption: [],
+        ProductOptionGroup: [],
+        ProductVariant: [],
+        User: [],
+    } as ReadOnlyRequired<CustomFields>,
+    middleware: [],
+    plugins: [],
+};

+ 30 - 44
server/src/config/vendure-config.ts

@@ -1,30 +1,51 @@
 import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface';
 import { RequestHandler } from 'express';
 import { LanguageCode } from 'shared/generated-types';
-import { API_PATH, API_PORT } from 'shared/shared-constants';
 import { CustomFields, DeepPartial } from 'shared/shared-types';
 import { ConnectionOptions } from 'typeorm';
 
 import { ReadOnlyRequired } from '../common/types/common-types';
 
 import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strategy';
-import { DefaultAssetNamingStrategy } from './asset-naming-strategy/default-asset-naming-strategy';
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
-import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-preview-strategy';
 import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
-import { NoAssetStorageStrategy } from './asset-storage-strategy/no-asset-storage-strategy';
-import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
+import { defaultConfig } from './default-config';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { mergeConfig } from './merge-config';
 import { VendurePlugin } from './vendure-plugin/vendure-plugin';
 
-export interface VendureConfig {
+export interface AuthOptions {
     /**
      * Disable authentication & permissions checks.
      * NEVER set the to true in production. It exists
-     * only to aid the ease of development.
+     * only to aid certain development tasks.
      */
     disableAuth?: boolean;
+    /**
+     * The secret used for signing each JWT used in authenticating users.
+     * In production applications, this should not be stored as a string in
+     * source control for security reasons, but may be loaded from an external
+     * file not under source control, or from an environment variable, for example.
+     * See https://stackoverflow.com/a/30090120/772859
+     */
+    jwtSecret: string;
+    /**
+     * Auth token duration. Typically this should be short-lived (on the order of minutes) to allow the
+     * revocation of tokens.
+     * Expressed in seconds or a string describing a time span
+     * [zeit/ms](https://github.com/zeit/ms.js).  Eg: 60, "2 days", "10h", "7d"
+     */
+    expiresIn?: string | number;
+    /**
+     * Refresh token duration. This shoud be on the order of days or weeks, depending on how long a user
+     * should be able to remain logged in without having to re-authenticate.
+     * Expressed in seconds or a string describing a time span
+     * [zeit/ms](https://github.com/zeit/ms.js).  Eg: 60, "2 days", "10h", "7d"
+     */
+    refreshEvery?: string | number;
+}
+
+export interface VendureConfig {
     /**
      * The name of the property which contains the token of the
      * active channel. This property can be included either in
@@ -48,13 +69,9 @@ export interface VendureConfig {
      */
     port: number;
     /**
-     * The secret used for signing each JWT used in authenticating users.
-     * In production applications, this should not be stored as a string in
-     * source control for security reasons, but may be loaded from an external
-     * file not under source control, or from an environment variable, for example.
-     * See https://stackoverflow.com/a/30090120/772859
+     * Configuration for authorization.
      */
-    jwtSecret: string;
+    authOptions: AuthOptions;
     /**
      * Defines the strategy used for both storing the primary keys of entities
      * in the database, and the encoding & decoding of those ids when exposing
@@ -99,37 +116,6 @@ export interface VendureConfig {
     plugins?: VendurePlugin[];
 }
 
-const defaultConfig: ReadOnlyRequired<VendureConfig> = {
-    disableAuth: false,
-    channelTokenKey: 'vendure-token',
-    defaultLanguageCode: LanguageCode.en,
-    port: API_PORT,
-    cors: false,
-    jwtSecret: 'secret',
-    apiPath: API_PATH,
-    entityIdStrategy: new AutoIncrementIdStrategy(),
-    assetNamingStrategy: new DefaultAssetNamingStrategy(),
-    assetStorageStrategy: new NoAssetStorageStrategy(),
-    assetPreviewStrategy: new NoAssetPreviewStrategy(),
-    dbConnectionOptions: {
-        type: 'mysql',
-    },
-    uploadMaxFileSize: 20971520,
-    customFields: {
-        Address: [],
-        Customer: [],
-        Facet: [],
-        FacetValue: [],
-        Product: [],
-        ProductOption: [],
-        ProductOptionGroup: [],
-        ProductVariant: [],
-        User: [],
-    } as ReadOnlyRequired<CustomFields>,
-    middleware: [],
-    plugins: [],
-};
-
 let activeConfig = defaultConfig;
 
 /**

+ 71 - 15
server/src/service/providers/auth.service.ts

@@ -18,34 +18,90 @@ export class AuthService {
         private configService: ConfigService,
     ) {}
 
-    async createToken(identifier: string, password: string): Promise<{ user: User; token: string }> {
-        const user = await this.connection.getRepository(User).findOne({
-            where: { identifier },
-            relations: ['roles', 'roles.channels'],
-        });
-
-        if (!user) {
+    /**
+     * Creates auth & refresh tokens after user login.
+     */
+    async createTokens(
+        identifier: string,
+        password: string,
+    ): Promise<{ user: User; authToken: string; refreshToken: string }> {
+        const user = await this.getUserFromIdentifier(identifier);
+        const passwordMatches = await this.passwordService.check(password, user.passwordHash);
+        if (!passwordMatches) {
             throw new UnauthorizedException();
         }
+        const { authToken, refreshToken } = this.createTokensForUser(user);
+        return { user, authToken, refreshToken };
+    }
 
-        const passwordMatches = await this.passwordService.check(password, user.passwordHash);
+    /**
+     * Attempts to refresh the authToken based on the provided refreshToken.
+     */
+    async refreshTokens(
+        token: string,
+        refreshToken: string,
+    ): Promise<{ user: User; authToken: string; refreshToken: string } | null> {
+        const jwtPayload = jwt.decode(token) as JwtPayload | null;
 
-        if (!passwordMatches) {
-            throw new UnauthorizedException();
+        if (jwtPayload) {
+            const user = await this.getUserFromIdentifier(jwtPayload.identifier);
+
+            try {
+                jwt.verify(refreshToken, this.getRefreshTokenSecret(user));
+            } catch (e) {
+                throw new UnauthorizedException();
+            }
+
+            const newTokens = this.createTokensForUser(user);
+            return {
+                user,
+                authToken: newTokens.authToken,
+                refreshToken: newTokens.refreshToken,
+            };
+        } else {
+            return null;
         }
+    }
+
+    async validateUser(identifier: string): Promise<User | undefined> {
+        return await this.connection.getRepository(User).findOne({
+            where: { identifier },
+            relations: ['roles', 'roles.channels'],
+        });
+    }
+
+    private createTokensForUser(user: User): { authToken: string; refreshToken: string } {
         const payload: JwtPayload = {
-            identifier,
+            identifier: user.identifier,
             roles: user.roles.reduce((roles, r) => [...roles, ...r.permissions], [] as Permission[]),
         };
-        const token = jwt.sign(payload, this.configService.jwtSecret, { expiresIn: 3600 });
+        const authToken = jwt.sign(payload, this.configService.authOptions.jwtSecret, {
+            expiresIn: this.configService.authOptions.expiresIn,
+            algorithm: 'HS256',
+        });
+
+        // The refreshToken is signed with a combination of the JWT secret and the user's password hash.
+        // This means that changing the password will invalidate any active refresh tokens automatically.
+        const refreshToken = jwt.sign({ identifier: user.identifier }, this.getRefreshTokenSecret(user), {
+            expiresIn: this.configService.authOptions.refreshEvery,
+            algorithm: 'HS256',
+        });
 
-        return { user, token };
+        return { authToken, refreshToken };
     }
 
-    async validateUser(identifier: string): Promise<User | undefined> {
-        return await this.connection.getRepository(User).findOne({
+    private async getUserFromIdentifier(identifier: string): Promise<User> {
+        const user = await this.connection.getRepository(User).findOne({
             where: { identifier },
             relations: ['roles', 'roles.channels'],
         });
+        if (!user) {
+            throw new UnauthorizedException();
+        }
+        return user;
+    }
+
+    private getRefreshTokenSecret(user: User): string {
+        return this.configService.authOptions.jwtSecret + '_' + user.passwordHash;
     }
 }

+ 0 - 1
shared/generated-types.ts

@@ -368,7 +368,6 @@ export interface AttemptLogin_login_user {
 export interface AttemptLogin_login {
   __typename: "LoginResult";
   user: AttemptLogin_login_user;
-  authToken: string;
 }
 
 export interface AttemptLogin {

+ 2 - 0
shared/shared-constants.ts

@@ -4,6 +4,8 @@
  */
 export const API_PORT = 3000;
 export const API_PATH = 'api';
+export const AUTH_TOKEN_KEY = 'vendure-auth-token';
+export const REFRESH_TOKEN_KEY = 'vendure-refresh-token';
 export const DEFAULT_CHANNEL_CODE = '__default_channel__';
 export const SUPER_ADMIN_ROLE_CODE = '__super_admin_role__';
 export const SUPER_ADMIN_ROLE_DESCRIPTION = 'SuperAdmin';

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov