From f4c0bb0b7610093e4fd009d712ed56b5d50cb5d4 Mon Sep 17 00:00:00 2001 From: Roland Schneider Date: Fri, 14 Nov 2025 08:49:23 +0100 Subject: [PATCH] add refresh token --- admin/src/app/app.ts | 5 +- admin/src/app/auth/auth.service.ts | 48 ++++++++--- admin/src/app/auth/jwt.interceptor.ts | 80 ++++++++++++++++--- server/src/auth/auth.controller.ts | 28 ++++++- server/src/auth/auth.module.ts | 9 ++- server/src/auth/auth.service.ts | 75 +++++++++++++++-- server/src/auth/jwt-refresh-auth.guard.ts | 5 ++ server/src/auth/jwt-refresh.strategy.ts | 47 +++++++++++ server/src/entity/user.ts | 3 + ...308120-add_refresh_token_to_user_object.ts | 14 ++++ server/src/types.ts | 7 +- server/src/user/user.service.ts | 16 ++++ 12 files changed, 297 insertions(+), 40 deletions(-) create mode 100644 server/src/auth/jwt-refresh-auth.guard.ts create mode 100644 server/src/auth/jwt-refresh.strategy.ts create mode 100644 server/src/migration/1763106308120-add_refresh_token_to_user_object.ts diff --git a/admin/src/app/app.ts b/admin/src/app/app.ts index 5a1e70b..b0f929f 100644 --- a/admin/src/app/app.ts +++ b/admin/src/app/app.ts @@ -16,7 +16,8 @@ export class App { constructor(private authService: AuthService, private router: Router) {} logout(): void { - this.authService.logout(); - this.router.navigate(['/login']); + this.authService.logout().subscribe(() => { + this.router.navigate(['/login']); + }); } } diff --git a/admin/src/app/auth/auth.service.ts b/admin/src/app/auth/auth.service.ts index 3b1ee26..da22cd6 100644 --- a/admin/src/app/auth/auth.service.ts +++ b/admin/src/app/auth/auth.service.ts @@ -1,36 +1,62 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, of } from 'rxjs'; import { tap } from 'rxjs/operators'; @Injectable({ providedIn: 'root', }) export class AuthService { - private readonly TOKEN_KEY = 'access_token'; + private readonly ACCESS_TOKEN_KEY = 'accessToken'; + private readonly REFRESH_TOKEN_KEY = 'refreshToken'; private apiUrl = 'http://localhost:4200/api/auth'; // Adjust if your server URL is different constructor(private http: HttpClient) {} login(credentials: { username: string; password: string }): Observable { - return this.http.post<{ access_token: string }>(`${this.apiUrl}/login`, credentials).pipe( - tap((response) => this.setToken(response.access_token)) + return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/login`, credentials).pipe( + tap((response) => this.setTokens(response.accessToken, response.refreshToken)) ); } - logout(): void { - localStorage.removeItem(this.TOKEN_KEY); + logout(): Observable { + return this.http.post(`${this.apiUrl}/logout`, {}).pipe( + tap(() => this.removeTokens()) + ); } - getToken(): string | null { - return localStorage.getItem(this.TOKEN_KEY); + refreshToken(): Observable { + const refreshToken = this.getRefreshToken(); + if (!refreshToken) { + return of(null); + } + + return this.http.post<{ accessToken: string; refreshToken: string }>(`${this.apiUrl}/refresh`, {}, { + headers: { Authorization: `Bearer ${refreshToken}` } + }).pipe( + tap((response) => this.setTokens(response.accessToken, response.refreshToken)) + ); + } + + getAccessToken(): string | null { + return localStorage.getItem(this.ACCESS_TOKEN_KEY); + } + + getRefreshToken(): string | null { + return localStorage.getItem(this.REFRESH_TOKEN_KEY); } isLoggedIn(): boolean { - return this.getToken() !== null; + return this.getAccessToken() !== null; } - private setToken(token: string): void { - localStorage.setItem(this.TOKEN_KEY, token); + private setTokens(accessToken: string, refreshToken: string): void { + localStorage.setItem(this.ACCESS_TOKEN_KEY, accessToken); + localStorage.setItem(this.REFRESH_TOKEN_KEY, refreshToken); + } + + private removeTokens(): void { + localStorage.removeItem(this.ACCESS_TOKEN_KEY); + localStorage.removeItem(this.REFRESH_TOKEN_KEY); } } diff --git a/admin/src/app/auth/jwt.interceptor.ts b/admin/src/app/auth/jwt.interceptor.ts index 96d864a..d14a214 100644 --- a/admin/src/app/auth/jwt.interceptor.ts +++ b/admin/src/app/auth/jwt.interceptor.ts @@ -4,26 +4,86 @@ import { HttpHandler, HttpInterceptor, HttpRequest, + HttpErrorResponse, } from '@angular/common/http'; -import { Observable } from 'rxjs'; +import { Observable, throwError, BehaviorSubject } from 'rxjs'; +import { catchError, switchMap, filter, take } from 'rxjs/operators'; import { AuthService } from './auth.service'; +import { Router } from '@angular/router'; @Injectable() export class JwtInterceptor implements HttpInterceptor { - constructor(private authService: AuthService) {} + private isRefreshing = false; + private refreshTokenSubject: BehaviorSubject = new BehaviorSubject(null); + + constructor(private authService: AuthService, private router: Router) {} intercept( request: HttpRequest, next: HttpHandler ): Observable> { - const token = this.authService.getToken(); - if (token) { - request = request.clone({ - setHeaders: { - Authorization: `Bearer ${token}`, - }, - }); + const accessToken = this.authService.getAccessToken(); + + if (accessToken) { + request = this.addToken(request, accessToken); } - return next.handle(request); + + return next.handle(request).pipe( + catchError((error) => { + if (error instanceof HttpErrorResponse && error.status === 401) { + return this.handle401Error(request, next); + } else { + return throwError(() => error); + } + }) + ); + } + + private handle401Error(request: HttpRequest, next: HttpHandler) { + if (!this.isRefreshing) { + this.isRefreshing = true; + + // The subject is now single-use. Re-create it for each refresh cycle. + // The initial `null` value is what makes followers wait. + this.refreshTokenSubject = new BehaviorSubject(null); + + return this.authService.refreshToken().pipe( + switchMap((token: any) => { + this.isRefreshing = false; + this.refreshTokenSubject.next(token.accessToken); + return next.handle(this.addToken(request, token.accessToken)); + }), + catchError((err) => { + this.isRefreshing = false; + + // Propagate the error to all waiting followers and kill the subject. + this.refreshTokenSubject.error(err); + + // Perform the logout and redirect + this.authService.logout().subscribe(() => { + this.router.navigate(['/login']); + }); + + // Also ensure the original caller gets the error + return throwError(() => err); + }) + ); + } else { + return this.refreshTokenSubject.pipe( + filter((token) => token != null), + take(1), + switchMap((jwt) => { + return next.handle(this.addToken(request, jwt)); + }) + ); + } + } + + private addToken(request: HttpRequest, token: string) { + return request.clone({ + setHeaders: { + Authorization: `Bearer ${token}`, + }, + }); } } diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index 98fd47b..5e8c7ab 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -1,6 +1,16 @@ -import { Controller, Post, Body, ValidationPipe } from '@nestjs/common'; -import { AuthService } from './auth.service'; +import { + Controller, + Post, + Body, + ValidationPipe, + UseGuards, + Req, +} from '@nestjs/common'; +import AuthService from './auth.service'; import { LoginRequestDto } from './dto/login-request.dto'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { JwtRefreshAuthGuard } from './jwt-refresh-auth.guard'; +import express from 'express'; @Controller('auth') export class AuthController { @@ -10,4 +20,18 @@ export class AuthController { async login(@Body(new ValidationPipe()) body: LoginRequestDto) { return await this.authService.login(body); } + + @UseGuards(JwtAuthGuard) + @Post('logout') + async logout(@Req() req: express.Request) { + const user = req.user as { sub: number }; + return await this.authService.logout(user.sub); + } + + @UseGuards(JwtRefreshAuthGuard) + @Post('refresh') + async refresh(@Req() req: express.Request) { + const user = req.user as { sub: number; refreshToken: string }; + return await this.authService.refreshToken(user.sub, user.refreshToken); + } } diff --git a/server/src/auth/auth.module.ts b/server/src/auth/auth.module.ts index d7164d0..71ec5a6 100644 --- a/server/src/auth/auth.module.ts +++ b/server/src/auth/auth.module.ts @@ -2,14 +2,15 @@ import { Module } from '@nestjs/common'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import { UserModule } from '../user/user.module'; -import { AuthService } from './auth.service'; +import AuthService from './auth.service'; import { AuthController } from './auth.controller'; import { JwtStrategy } from './jwt.strategy'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtRefreshTokenStrategy } from './jwt-refresh.strategy'; @Module({ imports: [ - ConfigModule, // <--- Import ConfigModule here + ConfigModule, UserModule, PassportModule, JwtModule.registerAsync({ @@ -17,11 +18,11 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; inject: [ConfigService], useFactory: (configService: ConfigService) => ({ secret: configService.get('JWT_SECRET'), - signOptions: { expiresIn: '60m' }, + signOptions: { expiresIn: '2m' }, }), }), ], - providers: [AuthService, JwtStrategy], + providers: [AuthService, JwtStrategy, JwtRefreshTokenStrategy], controllers: [AuthController], }) export class AuthModule {} diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index c5d76df..fcbad3c 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -4,12 +4,15 @@ import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { User } from '../entity/user'; import { LoginRequest, LoginResponse } from '../types'; +import { ConfigService } from '@nestjs/config'; +import type { StringValue } from 'ms'; @Injectable() -export class AuthService { +class AuthService { constructor( private userService: UserService, private jwtService: JwtService, + private configService: ConfigService, ) {} async validateUser(username: string, pass: string): Promise { @@ -34,19 +37,77 @@ export class AuthService { throw new UnauthorizedException(); } + const tokens = await this.getTokens(user); + await this.userService.setRefreshToken(user.id, tokens.refreshToken); + return tokens; + } + + async logout(userId: number): Promise { + await this.userService.setRefreshToken(userId, null); + } + + async refreshToken( + userId: number, + refreshToken: string, + ): Promise { + const user = await this.userService.findOne(userId); + if (!user || !user.hashedRefreshToken) { + throw new UnauthorizedException('Access Denied'); + } + + const refreshTokenMatches = await bcrypt.compare( + refreshToken, + user.hashedRefreshToken, + ); + + if (!refreshTokenMatches) { + throw new UnauthorizedException('Access Denied'); + } + + const tokens = await this.getTokens(user); + await this.userService.setRefreshToken(user.id, tokens.refreshToken); + return tokens; + } + + private async getTokens(user: User): Promise { const roles: Set = new Set(); for (const group of user.groups ?? []) { for (const role of group.roles ?? []) { roles.add(role.name); } } - const payload = { - username: user.username, - sub: user.id, - roles: Array.from(roles), - }; + + // let accessToken: string, refreshToken: string; + const [accessToken, refreshToken] = await Promise.all([ + this.jwtService.signAsync( + { + username: user.username, + sub: user.id, + roles: Array.from(roles), + }, + { + secret: this.configService.get('JWT_SECRET'), + expiresIn: this.configService.get('JWT_EXPIRATION_TIME'), + }, + ), + this.jwtService.signAsync( + { + sub: user.id, + }, + { + secret: this.configService.get('JWT_REFRESH_SECRET'), + expiresIn: this.configService.get( + 'JWT_REFRESH_EXPIRATION_TIME', + ), + }, + ), + ]); + return { - access_token: this.jwtService.sign(payload), + accessToken, + refreshToken, }; } } + +export default AuthService; diff --git a/server/src/auth/jwt-refresh-auth.guard.ts b/server/src/auth/jwt-refresh-auth.guard.ts new file mode 100644 index 0000000..093b8b0 --- /dev/null +++ b/server/src/auth/jwt-refresh-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtRefreshAuthGuard extends AuthGuard('jwt-refresh') {} diff --git a/server/src/auth/jwt-refresh.strategy.ts b/server/src/auth/jwt-refresh.strategy.ts new file mode 100644 index 0000000..45ad701 --- /dev/null +++ b/server/src/auth/jwt-refresh.strategy.ts @@ -0,0 +1,47 @@ +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy, StrategyOptionsWithRequest } from 'passport-jwt'; +import { Request } from 'express'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +// Define the shape of the JWT payload +interface JwtPayload { + sub: number; + // iat and exp are automatically added by passport-jwt + iat: number; + exp: number; +} + +// Define the shape of the object returned by the validate function +interface JwtPayloadWithRefreshToken extends JwtPayload { + refreshToken: string; +} + +@Injectable() +export class JwtRefreshTokenStrategy extends PassportStrategy( + Strategy, + 'jwt-refresh', +) { + constructor(private configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: configService.get('JWT_REFRESH_SECRET'), + passReqToCallback: true, + } as StrategyOptionsWithRequest); + } + + validate(req: Request, payload: JwtPayload): JwtPayloadWithRefreshToken { + const refreshToken = req.get('Authorization')?.replace('Bearer', '').trim(); + + // Ensure refreshToken is not undefined before returning + if (!refreshToken) { + // This case should be rare given the guard is used, but it's good practice + // to handle it. Depending on strictness, you might throw an error. + // For now, we'll proceed, but in a real-world scenario, logging or an error + // might be better. + return { ...payload, refreshToken: '' }; + } + + return { ...payload, refreshToken }; + } +} diff --git a/server/src/entity/user.ts b/server/src/entity/user.ts index 7fdead2..9ec1168 100644 --- a/server/src/entity/user.ts +++ b/server/src/entity/user.ts @@ -24,4 +24,7 @@ export class User { @ManyToMany(() => UserGroup) @JoinTable() groups: UserGroup[]; + + @Column({ nullable: true }) + hashedRefreshToken?: string; } diff --git a/server/src/migration/1763106308120-add_refresh_token_to_user_object.ts b/server/src/migration/1763106308120-add_refresh_token_to_user_object.ts new file mode 100644 index 0000000..2e7ceb3 --- /dev/null +++ b/server/src/migration/1763106308120-add_refresh_token_to_user_object.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddRefreshTokenToUserObject1763106308120 implements MigrationInterface { + name = 'AddRefreshTokenToUserObject1763106308120' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" ADD "hashedRefreshToken" character varying`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "hashedRefreshToken"`); + } + +} diff --git a/server/src/types.ts b/server/src/types.ts index 6c4a74a..8e708e2 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -4,8 +4,7 @@ export interface LoginRequest{ password: string; } -export interface LoginResponse{ - access_token: string; +export interface LoginResponse { + accessToken: string; + refreshToken: string; } - - diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index ab9af0c..6b14d5b 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -54,4 +54,20 @@ export class UserService { this.logger.log(`Removing user with id: ${id}`, 'UserService'); await this.usersRepository.delete(id); } + + async setRefreshToken( + id: number, + refreshToken: string | null, + ): Promise { + this.logger.log( + `Updating refresh token for user with id: ${id}`, + 'UserService', + ); + if (refreshToken) { + const hashedRefreshToken = await bcrypt.hash(refreshToken, 10); + await this.usersRepository.update(id, { hashedRefreshToken }); + } else { + await this.usersRepository.update(id, { hashedRefreshToken: undefined }); + } + } }