Browse Source

fix: Correctly implement rolling session expiry

Michael Bromley 7 years ago
parent
commit
5e851b8a04

+ 6 - 4
admin-ui/src/app/core/providers/auth/auth.service.ts

@@ -20,10 +20,7 @@ export class AuthService {
     logIn(username: string, password: string, rememberMe: boolean): Observable<SetAsLoggedIn> {
         return this.dataService.auth.attemptLogin(username, password, rememberMe).pipe(
             switchMap(response => {
-                this.localStorageService.setForSession(
-                    'activeChannelToken',
-                    response.login.user.channelTokens[0],
-                );
+                this.setChannelToken(response.login.user.channelTokens[0]);
                 return this.dataService.client.loginSuccess(username);
             }),
         );
@@ -73,10 +70,15 @@ export class AuthService {
                 if (!result.me) {
                     return of(false);
                 }
+                this.setChannelToken(result.me.channelTokens[0]);
                 return this.dataService.client.loginSuccess(result.me.identifier);
             }),
             mapTo(true),
             catchError(err => of(false)),
         );
     }
+
+    private setChannelToken(channelToken: string) {
+        this.localStorageService.set('activeChannelToken', channelToken);
+    }
 }

+ 7 - 24
server/src/api/common/auth-guard.ts

@@ -1,11 +1,11 @@
 import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
 import { Reflector } from '@nestjs/core';
 import { GqlExecutionContext } from '@nestjs/graphql';
-import { Request } from 'express';
 
 import { ConfigService } from '../../config/config.service';
 import { AuthService } from '../../service/providers/auth.service';
 
+import { extractAuthToken } from './extract-auth-token';
 import { PERMISSIONS_METADATA_KEY } from './roles-guard';
 
 /**
@@ -23,12 +23,15 @@ export class AuthGuard implements CanActivate {
     ) {}
 
     async canActivate(context: ExecutionContext): Promise<boolean> {
+        const ctx = GqlExecutionContext.create(context).getContext();
+        const authDisabled = this.configService.authOptions.disableAuth;
         const permissions = this.reflector.get<string[]>(PERMISSIONS_METADATA_KEY, context.getHandler());
-        if (this.configService.authOptions.disableAuth || !permissions) {
+
+        if (authDisabled || !permissions) {
             return true;
         }
-        const ctx = GqlExecutionContext.create(context).getContext();
-        const token = this.getToken(ctx.req);
+
+        const token = extractAuthToken(ctx.req, this.configService.authOptions.tokenMethod);
         if (!token) {
             return false;
         }
@@ -40,24 +43,4 @@ export class AuthGuard implements CanActivate {
             return false;
         }
     }
-
-    /**
-     * Get the session token from either the cookie or the Authorization header, depending
-     * on the configured tokenMethod.
-     */
-    private getToken(req: Request): string | undefined {
-        if (this.configService.authOptions.tokenMethod === 'cookie') {
-            if (req.session && req.session.token) {
-                return req.session.token;
-            }
-        } else {
-            const authHeader = req.get('Authorization');
-            if (authHeader) {
-                const matches = authHeader.match(/bearer\s+(.+)$/i);
-                if (matches) {
-                    return matches[1];
-                }
-            }
-        }
-    }
 }

+ 23 - 0
server/src/api/common/extract-auth-token.ts

@@ -0,0 +1,23 @@
+import { Request } from 'express';
+
+import { AuthOptions } from '../../config/vendure-config';
+
+/**
+ * Get the session token from either the cookie or the Authorization header, depending
+ * on the configured tokenMethod.
+ */
+export function extractAuthToken(req: Request, tokenMethod: AuthOptions['tokenMethod']): string | undefined {
+    if (tokenMethod === 'cookie') {
+        if (req.session && req.session.token) {
+            return req.session.token;
+        }
+    } else {
+        const authHeader = req.get('Authorization');
+        if (authHeader) {
+            const matches = authHeader.match(/bearer\s+(.+)$/i);
+            if (matches) {
+                return matches[1];
+            }
+        }
+    }
+}

+ 9 - 6
server/src/api/common/request-context.service.ts

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
 import { Request } from 'express';
 
 import { ConfigService } from '../../config/config.service';
+import { User } from '../../entity/user/user.entity';
 import { I18nError } from '../../i18n/i18n-error';
 import { ChannelService } from '../../service/providers/channel.service';
 
@@ -17,16 +18,18 @@ export class RequestContextService {
     /**
      * Creates a new RequestContext based on an Express request object.
      */
-    fromRequest(req: Request): RequestContext {
+    fromRequest(req: Request & { user?: User }): RequestContext {
         const tokenKey = this.configService.channelTokenKey;
-        let token = '';
+        let channelTokenKey = '';
         if (req && req.query && req.query[tokenKey]) {
-            token = req.query[tokenKey];
+            channelTokenKey = req.query[tokenKey];
         } else if (req && req.headers && req.headers[tokenKey]) {
-            token = req.headers[tokenKey] as string;
+            channelTokenKey = req.headers[tokenKey] as string;
+        } else if (req && req.user) {
+            channelTokenKey = req.user.roles[0].channels[0].token;
         }
-        if (token) {
-            const channel = this.channelService.getChannelFromToken(token);
+        if (channelTokenKey) {
+            const channel = this.channelService.getChannelFromToken(channelTokenKey);
             return new RequestContext(channel);
         }
         throw new I18nError(`error.unexpected-request-context`);

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

@@ -7,6 +7,7 @@ import { ConfigService } from '../../config/config.service';
 import { User } from '../../entity/user/user.entity';
 import { AuthService } from '../../service/providers/auth.service';
 import { ChannelService } from '../../service/providers/channel.service';
+import { extractAuthToken } from '../common/extract-auth-token';
 import { Allow } from '../common/roles-guard';
 
 @Resolver('Auth')
@@ -45,9 +46,12 @@ export class AuthResolver {
     }
 
     @Mutation()
-    @Allow(Permission.Authenticated)
-    async logout(@Context('req') request: Request & { user: User }) {
-        await this.authService.invalidateUserSessions(request.user);
+    async logout(@Context('req') request: Request) {
+        const token = extractAuthToken(request, this.configService.authOptions.tokenMethod);
+        if (!token) {
+            return false;
+        }
+        await this.authService.invalidateSessionByToken(token);
         if (request.session) {
             request.session = undefined;
         }

+ 42 - 2
server/src/service/providers/auth.service.ts

@@ -13,11 +13,15 @@ import { PasswordService } from './password.service';
 
 @Injectable()
 export class AuthService {
+    private readonly sessionDurationInMs;
+
     constructor(
         private passwordService: PasswordService,
         @InjectConnection() private connection: Connection,
         private configService: ConfigService,
-    ) {}
+    ) {
+        this.sessionDurationInMs = ms(this.configService.authOptions.sessionDuration);
+    }
 
     /**
      * Authenticates a user's credentials and if okay, creates a new session.
@@ -32,7 +36,7 @@ export class AuthService {
         const session = new Session({
             token,
             user,
-            expires: new Date(Date.now() + ms(this.configService.authOptions.sessionDuration)),
+            expires: this.getExpiryDate(),
             invalidated: false,
         });
         await this.invalidateUserSessions(user);
@@ -50,6 +54,7 @@ export class AuthService {
             relations: ['user', 'user.roles', 'user.roles.channels'],
         });
         if (session && session.expires > new Date()) {
+            await this.updateSessionExpiry(session);
             return session;
         }
     }
@@ -61,6 +66,19 @@ export class AuthService {
         await this.connection.getRepository(Session).update({ user }, { invalidated: true });
     }
 
+    /**
+     * Invalidates all sessions for the user associated with the given session token.
+     */
+    async invalidateSessionByToken(token: string): Promise<void> {
+        const session = await this.connection.getRepository(Session).findOne({
+            where: { token },
+            relations: ['user'],
+        });
+        if (session) {
+            return this.invalidateUserSessions(session.user);
+        }
+    }
+
     async getUserById(userId: ID): Promise<User | undefined> {
         return this.connection.getRepository(User).findOne(userId, {
             relations: ['roles', 'roles.channels'],
@@ -91,4 +109,26 @@ export class AuthService {
             });
         });
     }
+
+    /**
+     * If we are over half way to the current session's expiry date, then we update it.
+     *
+     * This ensures that the session will not expire when in active use, but prevents us from
+     * needing to run an update query on *every* request.
+     */
+    private async updateSessionExpiry(session: Session) {
+        const now = new Date().getTime();
+        if (session.expires.getTime() - now < this.sessionDurationInMs / 2) {
+            await this.connection
+                .getRepository(Session)
+                .update({ id: session.id }, { expires: this.getExpiryDate() });
+        }
+    }
+
+    /**
+     * Returns a future expiry date according to the configured sessionDuration.
+     */
+    private getExpiryDate(): Date {
+        return new Date(Date.now() + this.sessionDurationInMs);
+    }
 }