From 6b010f66ba5e6569227d3c517a07c4b19e012204 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 17 Jun 2025 16:38:51 +0000 Subject: [PATCH] Removed renewing refresh token. Added validate endpoint for tokens. Refresh token is given only if 'remember me' option is enabled on login. --- .../src/auth/auth.access.service.ts | 8 ++ .../src/auth/auth.controller.ts | 57 ++++++-- .../src/auth/auth.refresh.service.ts | 131 ++++++------------ .../src/auth/auth.service.ts | 103 ++++++++++++-- .../src/auth/dto/authentication.dto.ts | 2 +- .../src/auth/dto/login.dto.ts | 7 + .../src/logging.serializers.ts | 1 + 7 files changed, 195 insertions(+), 114 deletions(-) create mode 100644 backend/nestjs-seshat-api/src/auth/dto/login.dto.ts diff --git a/backend/nestjs-seshat-api/src/auth/auth.access.service.ts b/backend/nestjs-seshat-api/src/auth/auth.access.service.ts index ba9729f..cea666f 100644 --- a/backend/nestjs-seshat-api/src/auth/auth.access.service.ts +++ b/backend/nestjs-seshat-api/src/auth/auth.access.service.ts @@ -45,4 +45,12 @@ export class AuthAccessService { exp: expiration.getTime(), } } + + async verify(token: string) { + return await this.jwts.verifyAsync(token, + { + secret: this.config.getOrThrow('AUTH_JWT_ACCESS_TOKEN_SECRET') + } + ); + } } diff --git a/backend/nestjs-seshat-api/src/auth/auth.controller.ts b/backend/nestjs-seshat-api/src/auth/auth.controller.ts index c7b9410..12fa87a 100644 --- a/backend/nestjs-seshat-api/src/auth/auth.controller.ts +++ b/backend/nestjs-seshat-api/src/auth/auth.controller.ts @@ -10,6 +10,8 @@ import { UserEntity } from 'src/users/entities/users.entity'; import { QueryFailedError } from 'typeorm'; import { PinoLogger } from 'nestjs-pino'; import { JwtAccessGuard } from './guards/jwt-access.guard'; +import { LoginDto } from './dto/login.dto'; +import { AuthenticationDto } from './dto/authentication.dto'; @Controller('auth') export class AuthController { @@ -24,11 +26,12 @@ export class AuthController { async login( @Request() req, @Res({ passthrough: true }) response: Response, + @Body() body: LoginDto, ) { let data: AuthenticationDto | null; try { - data = await this.auth.login(req.user); - if (!data.access_token || !data.refresh_token || !data.refresh_exp) { + data = await this.auth.login(req.user, body.remember_me); + if (!data.access_token || body.remember_me && (!data.refresh_token || !data.refresh_exp)) { response.statusCode = 500; return { success: false, @@ -58,12 +61,14 @@ export class AuthController { sameSite: 'strict', }); - response.cookie('Refresh', data.refresh_token, { - httpOnly: true, - secure: true, - expires: new Date(data.refresh_exp), - sameSite: 'strict', - }); + if (body.remember_me) { + response.cookie('Refresh', data.refresh_token, { + httpOnly: true, + secure: true, + expires: new Date(data.refresh_exp), + sameSite: 'strict', + }); + } this.logger.info({ class: AuthController.name, @@ -71,6 +76,7 @@ export class AuthController { user: req.user, access_token: data.access_token, refresh_token: data.refresh_token, + remember_me: body.remember_me, msg: 'User logged in.', }); @@ -85,7 +91,6 @@ export class AuthController { @Request() req, @Res({ passthrough: true }) response: Response, ) { - const accessToken = req.cookies?.Authentication; const refreshToken = req.cookies?.Refresh; response.clearCookie('Authentication'); @@ -97,7 +102,7 @@ export class AuthController { class: AuthController.name, method: this.login.name, user: req.user, - msg: 'User has already logged off via ' + (!refreshToken ? 'cookies' : 'database'), + msg: 'User has already logged off based on ' + (!refreshToken ? 'cookies' : 'database'), }); response.statusCode = 400; @@ -133,8 +138,7 @@ export class AuthController { msg: 'User logged in.', }); - const refreshToken = req.cookies.Refresh; - const data = await this.auth.renew(req.user, refreshToken); + const data = await this.auth.renew(req.user); response.cookie('Authentication', data.access_token, { httpOnly: true, @@ -150,7 +154,7 @@ export class AuthController { msg: 'Updated Authentication cookie for access token.', }); - if (data.refresh_token != refreshToken) { + if (data.refresh_token) { response.cookie('Refresh', data.refresh_token, { httpOnly: true, secure: true, @@ -220,8 +224,8 @@ export class AuthController { } try { - data = await this.auth.login(user); - if (!data.access_token || !data.refresh_token || !data.refresh_exp) { + data = await this.auth.login(user, false); + if (!data.access_token) { this.logger.error({ class: AuthController.name, method: this.register.name, @@ -271,4 +275,27 @@ export class AuthController { success: true, }; } + + @Post('validate') + async validate( + @Request() req, + @Res({ passthrough: true }) response: Response, + ) { + try { + const accessToken = req.cookies['Authentication']; + const refreshToken = req.cookies['Refresh']; + const verification = await this.auth.verify(accessToken, refreshToken); + + return { + success: true, + ...verification, + }; + } catch (err) { + response.statusCode = 500; + return { + success: false, + error_message: err, + }; + } + } } \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/auth/auth.refresh.service.ts b/backend/nestjs-seshat-api/src/auth/auth.refresh.service.ts index 3802dfc..494f3ef 100644 --- a/backend/nestjs-seshat-api/src/auth/auth.refresh.service.ts +++ b/backend/nestjs-seshat-api/src/auth/auth.refresh.service.ts @@ -1,6 +1,6 @@ import * as crypto from 'crypto'; import * as moment from "moment"; -import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { InjectRepository } from '@nestjs/typeorm'; @@ -20,97 +20,46 @@ export class AuthRefreshService { private logger: PinoLogger, ) { } - async generate(user: UserEntity, refreshToken?: string) { - let expiration: Date | null = null; - if (refreshToken) { - const token = await this.get(refreshToken, user.userId); - if (!token) { - this.logger.warn({ - class: AuthRefreshService.name, - method: this.generate.name, - user, - refresh_token: refreshToken, - msg: 'Refresh token given is invalid.', - }); - throw new UnauthorizedException('Invalid refresh token.'); - } - if (token.exp.getTime() < new Date().getTime()) { - this.logger.warn({ - class: AuthRefreshService.name, - method: this.generate.name, - user, - refresh_token: refreshToken, - exp: expiration, - msg: 'Refresh token given has expired.', - }); - throw new UnauthorizedException('Invalid refresh token.'); - } - - expiration = token.exp; - } - - // Generate new refresh token if either: - // - no previous token exists; - // - token has reached expiration threshold; - // - token has expired. + async generate(user: UserEntity) { const now = new Date(); const expirationTime = parseInt(this.config.getOrThrow('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_MS')); - const threshhold = parseInt(this.config.getOrThrow('AUTH_JWT_REFRESH_TOKEN_EXPIRATION_THRESHHOLD_MS')); - if (!refreshToken || expirationTime - (expiration.getTime() - now.getTime()) > threshhold) { - let deletionTask = null; - if (refreshToken) { - deletionTask = this.revoke(user.userId, refreshToken); - - this.logger.debug({ - class: AuthRefreshService.name, - method: this.generate.name, - user, - refresh_token: refreshToken, - exp: expiration, - msg: 'Deleted previous refresh token.', - }); + const expiration = moment(now).add(expirationTime, 'ms').toDate(); + const refreshToken = await this.jwts.signAsync( + { + username: user.userLogin, + sub: user.userId, + iat: Math.floor(now.getTime() / 1000), + nbf: Math.floor(now.getTime() / 1000) - 5 * 60, + exp: Math.floor(expiration.getTime() / 1000), + }, + { + secret: this.config.getOrThrow('AUTH_JWT_REFRESH_TOKEN_SECRET'), } + ); - expiration = moment(now).add(expirationTime, 'ms').toDate(); - refreshToken = await this.jwts.signAsync( - { - username: user.userLogin, - sub: user.userId, - iat: Math.floor(now.getTime() / 1000), - nbf: Math.floor(now.getTime() / 1000) - 5 * 60, - exp: Math.floor(expiration.getTime() / 1000), - }, - { - secret: this.config.getOrThrow('AUTH_JWT_REFRESH_TOKEN_SECRET'), - } - ); + this.logger.debug({ + class: AuthRefreshService.name, + method: this.generate.name, + user, + refresh_token: refreshToken, + exp: expiration, + msg: 'Generated a new refresh token.', + }); - this.logger.debug({ - class: AuthRefreshService.name, - method: this.generate.name, - user, - refresh_token: refreshToken, - exp: expiration, - msg: 'Generated a new refresh token.', - }); + this.authRefreshTokenRepository.insert({ + tokenHash: this.hash(refreshToken), + userId: user.userId, + exp: expiration + }); - this.authRefreshTokenRepository.insert({ - tokenHash: this.hash(refreshToken), - userId: user.userId, - exp: expiration - }); - - this.logger.debug({ - class: AuthRefreshService.name, - method: this.generate.name, - user, - refresh_token: refreshToken, - exp: expiration, - msg: 'Inserted the new refresh token into the database.', - }); - - await deletionTask; - } + this.logger.debug({ + class: AuthRefreshService.name, + method: this.generate.name, + user, + refresh_token: refreshToken, + exp: expiration, + msg: 'Inserted the new refresh token into the database.', + }); return { refresh_token: refreshToken, @@ -155,4 +104,14 @@ export class AuthRefreshService { const refresh = await this.get(refreshToken, userId); return refresh && refresh.exp.getTime() > new Date().getTime(); } + + async verify( + refreshToken: string + ): Promise { + return await this.jwts.verifyAsync(refreshToken, + { + secret: this.config.getOrThrow('AUTH_JWT_REFRESH_TOKEN_SECRET'), + } + ); + } } diff --git a/backend/nestjs-seshat-api/src/auth/auth.service.ts b/backend/nestjs-seshat-api/src/auth/auth.service.ts index bcc4300..c0e076d 100644 --- a/backend/nestjs-seshat-api/src/auth/auth.service.ts +++ b/backend/nestjs-seshat-api/src/auth/auth.service.ts @@ -14,22 +14,26 @@ export class AuthService { ) { } - async login(user: UserEntity): Promise { - return this.renew(user, null); - } + async login( + user: UserEntity, + withRefresh: boolean + ): Promise { + if (withRefresh) { + return this.renew(user); + } - async validate( - username: string, - password: string, - ): Promise { - return await this.users.findOne({ username, password }); + const access_token = await this.accessTokens.generate(user); + return { + ...access_token, + refresh_token: null, + refresh_exp: null, + } } async renew( user: UserEntity, - refresh_token: string | null ): Promise { - const new_refresh_data = await this.refreshTokens.generate(user, refresh_token); + const new_refresh_data = await this.refreshTokens.generate(user); const access_token = await this.accessTokens.generate(user); return { @@ -39,8 +43,83 @@ export class AuthService { } } - async revoke(userId: UUID, refreshToken: string): Promise { + async validate( + username: string, + password: string, + ): Promise { + return await this.users.findOne({ username, password }); + } + + async verify( + accessToken: string, + refreshToken: string + ): Promise<{ validation: boolean, userId: UUID | null, username: string | null }> { + if (!accessToken) { + if (!refreshToken) { + return { + validation: false, + userId: null, + username: null, + } + } + + const refresh = await this.refreshTokens.verify(refreshToken); + if (refresh.message || !refresh.exp || refresh.exp * 1000 <= new Date().getTime()) { + return { + validation: false, + userId: null, + username: null, + }; + } + return { + validation: null, + userId: refresh.sub, + username: refresh.username, + }; + } + const access = await this.accessTokens.verify(accessToken); + const refresh = await this.refreshTokens.verify(refreshToken); + if (!access.username || !refresh.username || access.username != refresh.username) { + return { + validation: false, + userId: null, + username: null, + }; + } + if (!access.sub || !refresh.sub || access.sub != refresh.sub) { + return { + validation: false, + userId: null, + username: null, + }; + } + + if (access.message || !access.exp || access.exp * 1000 <= new Date().getTime()) { + if (refresh.message || !refresh.exp || refresh.exp * 1000 <= new Date().getTime()) { + return { + validation: false, + userId: null, + username: null, + }; + } + return { + validation: null, + userId: access.sub, + username: access.username, + }; + } + return { + validation: true, + userId: access.sub, + username: access.username, + }; + } + + async revoke( + userId: UUID, + refreshToken: string + ): Promise { const res = await this.refreshTokens.revoke(userId, refreshToken); - return res?.affected === 1 + return res?.affected === 1; } } diff --git a/backend/nestjs-seshat-api/src/auth/dto/authentication.dto.ts b/backend/nestjs-seshat-api/src/auth/dto/authentication.dto.ts index 2d54147..a97bdee 100644 --- a/backend/nestjs-seshat-api/src/auth/dto/authentication.dto.ts +++ b/backend/nestjs-seshat-api/src/auth/dto/authentication.dto.ts @@ -1,4 +1,4 @@ -class AuthenticationDto { +export class AuthenticationDto { access_token: string; exp: number; refresh_token: string | null; diff --git a/backend/nestjs-seshat-api/src/auth/dto/login.dto.ts b/backend/nestjs-seshat-api/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..4924078 --- /dev/null +++ b/backend/nestjs-seshat-api/src/auth/dto/login.dto.ts @@ -0,0 +1,7 @@ +import { IsBoolean, IsOptional } from 'class-validator'; + +export class LoginDto { + @IsBoolean() + @IsOptional() + readonly remember_me: boolean; +} \ No newline at end of file diff --git a/backend/nestjs-seshat-api/src/logging.serializers.ts b/backend/nestjs-seshat-api/src/logging.serializers.ts index b0d0eff..a086f14 100644 --- a/backend/nestjs-seshat-api/src/logging.serializers.ts +++ b/backend/nestjs-seshat-api/src/logging.serializers.ts @@ -21,6 +21,7 @@ export function serialize_user_long(value: UserEntity) { } export function serialize_token(value: string) { + if (!value) return null; return '...' + value.substring(Math.max(value.length - 12, value.length / 2) | 0); }