From ebcb06a1f05f9717c0097c8c82a6347de5956c83 Mon Sep 17 00:00:00 2001 From: armorbreak001 Date: Tue, 14 Apr 2026 19:27:52 +0800 Subject: [PATCH] feat(backend): implement secure refresh token rotation mechanism Fixes #294 - Add RefreshToken model to Prisma schema (opaque tokens, not JWTs) - Create RefreshTokenService with full rotation logic: - Generate: random 64-byte hex strings, SHA-256 hashed in DB - Rotate: issue new token, invalidate old one (prevents replay attacks) - Replay detection: if revoked token is reused, revoke entire token chain - Revoke single or all tokens for a user - Cleanup expired tokens - Update AuthService to use RefreshTokenService: - Login/Register/WalletAuth now generate opaque refresh tokens - POST /auth/refresh rotates tokens (new token issued, old invalidated) - Access token TTL: 15 minutes, Refresh token TTL: 7 days - Tokens stored in dedicated refresh_tokens table, not on User model --- backend/prisma/schema.prisma | 17 ++ backend/src/auth/auth.module.ts | 16 +- backend/src/auth/auth.service.ts | 249 ++++++---------------- backend/src/auth/refresh-token.service.ts | 181 ++++++++++++++++ 4 files changed, 275 insertions(+), 188 deletions(-) create mode 100644 backend/src/auth/refresh-token.service.ts diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 102256e..0fe7a2a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -284,6 +284,23 @@ model Notification { metadata Json? } +/// Refresh tokens for secure session management +/// Opaque random byte strings (not JWTs) mapped to users +model RefreshToken { + id String @id @default(cuid()) + token String @unique /// Opaque hashed token stored + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + expiresAt DateTime + createdAt DateTime @default(now()) + replacedBy String? /// Points to the new token after rotation (for replay detection) + revokedAt DateTime? + + @@index([userId]) + @@index([token]) + @@map("refresh_tokens") +} + model PrivacySettings { id String @id @default(cuid()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index 9d84a1c..b98f762 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -8,6 +8,7 @@ import { JwtStrategy } from './strategies/jwt.strategy'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { RoleGuard } from './guards/role.guard'; import { PrismaModule } from '../prisma/prisma.module'; +import { RefreshTokenService } from './refresh-token.service'; @Module({ imports: [ @@ -19,14 +20,23 @@ import { PrismaModule } from '../prisma/prisma.module'; useFactory: async (configService: ConfigService) => ({ secret: configService.get('JWT_SECRET') || 'your-secret-key', signOptions: { - expiresIn: 900, // 15 minutes in seconds (default) + expiresIn: '15m', // 15 minutes — short-lived access token }, }), inject: [ConfigService], }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, JwtAuthGuard, RoleGuard], - exports: [AuthService, JwtStrategy, JwtAuthGuard, RoleGuard, PassportModule], + providers: [ + AuthService, + JwtStrategy, + JwtAuthGuard, + RoleGuard, + RefreshTokenService, + ], + exports: [ + AuthService, JwtStrategy, JwtAuthGuard, RoleGuard, + PassportModule, RefreshTokenService, + ], }) export class AuthModule {} diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 9bacec3..77fd19d 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -15,22 +15,20 @@ import { WalletAuthDto, RefreshTokenDto, } from './dto/auth.dto'; +import { RefreshTokenService } from './refresh-token.service'; @Injectable() export class AuthService { private readonly jwtSecret: string; - private readonly refreshTokenSecret: string; constructor( private jwtService: JwtService, private configService: ConfigService, private prisma: PrismaService, + private refreshTokenService: RefreshTokenService, ) { this.jwtSecret = this.configService.get('JWT_SECRET') || 'your-secret-key'; - this.refreshTokenSecret = - this.configService.get('REFRESH_TOKEN_SECRET') || - 'your-refresh-secret-key'; } /** @@ -40,11 +38,8 @@ export class AuthService { const { email, username, password, firstName, lastName, walletAddress } = registerDto; - // Check if user already exists const existingUser = await this.prisma.user.findFirst({ - where: { - OR: [{ email }, { username }], - }, + where: { OR: [{ email }, { username }] }, }); if (existingUser) { @@ -53,15 +48,12 @@ export class AuthService { ); } - // Validate wallet address if provided if (walletAddress && !this.isValidWalletAddress(walletAddress)) { throw new BadRequestException('Invalid wallet address'); } - // Hash password const hashedPassword = await bcrypt.hash(password, 10); - // Create user const user = await this.prisma.user.create({ data: { email, @@ -73,30 +65,10 @@ export class AuthService { }, }); - // Generate tokens - const { accessToken, refreshToken } = await this.generateTokens( - user.id, - user.email, - user.walletAddress || undefined, - user.role, - ); - - // Save refresh token to database - await this.prisma.user.update({ - where: { id: user.id }, - data: { refreshToken }, - }); + const accessToken = this.signAccessToken(user); + const refreshToken = await this.refreshTokenService.generate(user.id); - return { - accessToken, - refreshToken, - user: { - id: user.id, - email: user.email, - username: user.username, - walletAddress: user.walletAddress || undefined, - }, - }; + return { accessToken, refreshToken, user: this.sanitizeUser(user) }; } /** @@ -105,83 +77,51 @@ export class AuthService { async login(loginDto: LoginDto) { const { email, password } = loginDto; - // Find user - const user = await this.prisma.user.findUnique({ - where: { email }, - }); - + const user = await this.prisma.user.findUnique({ where: { email } }); if (!user) { throw new UnauthorizedException('Invalid email or password'); } - // Verify password const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { throw new UnauthorizedException('Invalid email or password'); } - // Update last login await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); - // Generate tokens - const { accessToken, refreshToken } = await this.generateTokens( - user.id, - user.email, - user.walletAddress || undefined, - user.role, - ); - - // Save refresh token to database - await this.prisma.user.update({ - where: { id: user.id }, - data: { refreshToken }, - }); + const accessToken = this.signAccessToken(user); + const refreshToken = await this.refreshTokenService.generate(user.id); - return { - accessToken, - refreshToken, - user: { - id: user.id, - email: user.email, - username: user.username, - walletAddress: user.walletAddress || undefined, - }, - }; + return { accessToken, refreshToken, user: this.sanitizeUser(user) }; } /** - * Authenticate user using wallet signature (Web3) + * Authenticate using wallet signature (Web3) */ async walletAuth(walletAuthDto: WalletAuthDto) { const { walletAddress, message, signature } = walletAuthDto; - // Validate wallet address format if (!this.isValidWalletAddress(walletAddress)) { throw new BadRequestException('Invalid wallet address'); } - // Verify signature const signerAddress = await this.verifySignature(message, signature); if (signerAddress.toLowerCase() !== walletAddress.toLowerCase()) { throw new UnauthorizedException('Invalid signature'); } - // Find or create user - let user = await this.prisma.user.findFirst({ - where: { walletAddress }, - }); + let user = await this.prisma.user.findFirst({ where: { walletAddress } }); if (!user) { - // Create new user with wallet const username = `user_${walletAddress.slice(-6).toLowerCase()}`; user = await this.prisma.user.create({ data: { email: `${username}@wallet.local`, username, - password: await bcrypt.hash(Math.random().toString(), 10), // Random password for wallet users + password: await bcrypt.hash(Math.random().toString(), 10), firstName: 'Wallet', lastName: 'User', walletAddress, @@ -189,172 +129,111 @@ export class AuthService { }); } - // Update last login await this.prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() }, }); - // Generate tokens - const { accessToken, refreshToken } = await this.generateTokens( - user.id, - user.email, - user.walletAddress || undefined, - user.role, - ); - - // Save refresh token to database - await this.prisma.user.update({ - where: { id: user.id }, - data: { refreshToken }, - }); + const accessToken = this.signAccessToken(user); + const refreshToken = await this.refreshTokenService.generate(user.id); - return { - accessToken, - refreshToken, - user: { - id: user.id, - email: user.email, - username: user.username, - walletAddress: user.walletAddress || undefined, - }, - }; + return { accessToken, refreshToken, user: this.sanitizeUser(user) }; } /** - * Refresh access token using refresh token + * Refresh access token using opaque refresh token (with rotation) + * + * Security: Each refresh issues a NEW token and INVALIDATES the old one. + * Reusing an old token triggers a replay detection → all sessions revoked. */ async refreshToken(refreshTokenDto: RefreshTokenDto) { - const { refreshToken } = refreshTokenDto; + const { refreshToken: rawToken } = refreshTokenDto; try { - // Verify refresh token - const payload = this.jwtService.verify(refreshToken, { - secret: this.refreshTokenSecret, - }); - - // Find user and verify refresh token - const user = await this.prisma.user.findUnique({ - where: { id: payload.sub }, - }); + // Rotate the token — validates, invalidates old, issues new + const { newToken, userId } = + await this.refreshTokenService.rotate(rawToken); - if (!user || user.refreshToken !== refreshToken) { - throw new UnauthorizedException('Invalid refresh token'); + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user || !user.isActive) { + throw new UnauthorizedException('User not found or inactive'); } - // Generate new tokens - const { accessToken, refreshToken: newRefreshToken } = - await this.generateTokens( - user.id, - user.email, - user.walletAddress || undefined, - user.role, - ); - - // Save new refresh token - await this.prisma.user.update({ - where: { id: user.id }, - data: { refreshToken: newRefreshToken }, - }); + const accessToken = this.signAccessToken(user); return { accessToken, - refreshToken: newRefreshToken, - user: { - id: user.id, - email: user.email, - username: user.username, - walletAddress: user.walletAddress || undefined, - }, + refreshToken: newToken, + user: this.sanitizeUser(user), }; } catch (error) { - throw new UnauthorizedException('Invalid refresh token'); + if (error instanceof UnauthorizedException) throw error; + throw new UnauthorizedException('Invalid or expired refresh token'); } } /** - * Logout user by invalidating refresh token + * Logout — revoke the refresh token */ - async logout(userId: string) { - await this.prisma.user.update({ - where: { id: userId }, - data: { refreshToken: null }, - }); - + async logout(userId: string, rawRefreshToken?: string) { + if (rawRefreshToken) { + await this.refreshTokenService.revoke(rawRefreshToken); + } return { message: 'Logged out successfully' }; } - /** - * Generate access and refresh tokens - */ - private async generateTokens( - userId: string, - email: string, - walletAddress?: string, - role?: string, - ) { + /* ---- Token helpers ---- */ + + private signAccessToken(user: { id: string; email: string; role: string; walletAddress?: string | null }): string { const payload = { - sub: userId, - email, - role: role || 'USER', - ...(walletAddress && { walletAddress }), + sub: user.id, + email: user.email, + role: user.role, + ...(user.walletAddress && { walletAddress: user.walletAddress }), }; - const accessToken = this.jwtService.sign(payload, { + return this.jwtService.sign(payload, { secret: this.jwtSecret, - expiresIn: 900, // 15 minutes in seconds - }); - - const refreshToken = this.jwtService.sign(payload, { - secret: this.refreshTokenSecret, - expiresIn: 604800, // 7 days in seconds + expiresIn: '15m', // 15 minutes — short-lived for security }); + } - return { accessToken, refreshToken }; + private sanitizeUser(user: { id: string; email: string; username: string; walletAddress?: string | null }) { + return { + id: user.id, + email: user.email, + username: user.username, + walletAddress: user.walletAddress || undefined, + }; } - /** - * Verify wallet signature using ethers.js - */ + /* ---- Wallet helpers ---- */ + private verifySignature(message: string, signature: string): string { try { - const signerAddress = ethers.verifyMessage(message, signature); - return signerAddress; - } catch (error) { + return ethers.verifyMessage(message, signature); + } catch { throw new UnauthorizedException('Invalid signature'); } } - /** - * Validate Ethereum wallet address format - */ private isValidWalletAddress(address: string): boolean { return /^0x[a-fA-F0-9]{40}$/.test(address); } - /** - * Get current user by ID - */ + /* ---- User lookup ---- */ + async getCurrentUser(userId: string) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { - id: true, - email: true, - username: true, - firstName: true, - lastName: true, - walletAddress: true, - role: true, - isActive: true, - createdAt: true, + id: true, email: true, username: true, firstName: true, + lastName: true, walletAddress: true, role: true, + isActive: true, createdAt: true, }, }); - if (!user) { - throw new UnauthorizedException('User not found'); - } - + if (!user) throw new UnauthorizedException('User not found'); return user; } } diff --git a/backend/src/auth/refresh-token.service.ts b/backend/src/auth/refresh-token.service.ts new file mode 100644 index 0000000..6b5e5d9 --- /dev/null +++ b/backend/src/auth/refresh-token.service.ts @@ -0,0 +1,181 @@ +import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import * as crypto from 'crypto'; + +/** + * Refresh Token Service + * + * Manages opaque refresh tokens with rotation to prevent replay attacks. + * + * Security model: + * - Tokens are random 64-byte hex strings (not JWTs) + * - Stored hashed in the database (SHA-256) + * - On each refresh: issue new token, invalidate old one + * - Old token's `replacedBy` field points to new token (replay detection) + * - If a revoked token is used again → reject all tokens in chain (possible theft) + */ +@Injectable() +export class RefreshTokenService { + constructor(private prisma: PrismaService) {} + + /** Token byte length before hex encoding */ + private readonly TOKEN_BYTES = 32; // 64 hex chars + + /** Token TTL in seconds (7 days) */ + private readonly TOKEN_TTL = 7 * 24 * 60 * 60; + + /** + * Generate a new opaque refresh token for a user and persist it. + * Returns the raw token string (to be sent to client). + */ + async generate(userId: string): Promise { + const rawToken = this.randomToken(); + const hashed = this.hash(rawToken); + const expiresAt = new Date(Date.now() + this.TOKEN_TTL * 1000); + + await this.prisma.refreshToken.create({ + data: { + token: hashed, + userId, + expiresAt, + }, + }); + + return rawToken; + } + + /** + * Rotate a refresh token: + * 1. Validate the existing token + * 2. Mark it as replaced (link to new token) + * 3. Issue a brand new token + * 4. Return the new token + access token payload + * + * This prevents replay attacks — using an old token twice triggers a security alert. + */ + async rotate(rawToken: string): Promise<{ newToken: string; userId: string }> { + const hashed = this.hash(rawToken); + + // Find the stored token + const stored = await this.prisma.refreshToken.findUnique({ + where: { token: hashed }, + }); + + if (!stored) { + throw new UnauthorizedException('Invalid refresh token'); + } + + if (stored.revokedAt) { + // Token was already used/replaced — possible replay attack! + // Revoke the entire chain as a security measure + await this.revokeChain(stored); + throw new UnauthorizedException( + 'Token reuse detected. All sessions have been revoked. Please log in again.', + 403, + ); + } + + if (new Date() > stored.expiresAt) { + throw new UnauthorizedException('Refresh token has expired'); + } + + // Generate new token + const newRawToken = this.generateSync(); + const newHashed = this.hash(newRawToken); + const newExpiresAt = new Date(Date.now() + this.TOKEN_TTL * 1000); + + // Atomically: mark old as replaced, create new one + await this.prisma.$transaction([ + this.prisma.refreshToken.update({ + where: { id: stored.id }, + data: { + replacedBy: newHashed, + revokedAt: new Date(), + }, + }), + this.prisma.refreshToken.create({ + data: { + token: newHashed, + userId: stored.userId, + expiresAt: newExpiresAt, + }, + }), + ]); + + return { newToken: newRawToken, userId: stored.userId }; + } + + /** + * Revoke a specific refresh token (on logout) + */ + async revoke(rawToken: string): Promise { + const hashed = this.hash(rawToken); + await this.prisma.refreshToken.updateMany({ + where: { token: hashed, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + /** + * Revoke ALL refresh tokens for a user (password change, security event) + */ + async revokeAllForUser(userId: string): Promise { + await this.prisma.refreshToken.updateMany({ + where: { userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + /** + * Clean up expired tokens (call from a scheduled job) + */ + async cleanupExpired(): Promise { + const result = await this.prisma.refreshToken.deleteMany({ + where: { + expiresAt: { lt: new Date() }, + }, + }); + return result.count; + } + + /* ---- Internal helpers ---- */ + + private randomToken(): string { + return crypto.randomBytes(this.TOKEN_BYTES).toString('hex'); + } + + private generateSync(): string { + return crypto.randomBytes(this.TOKEN_BYTES).toString('hex'); + } + + private hash(token: string): string { + return crypto.createHash('sha256').update(token).digest('hex'); + } + + /** + * Revoke the entire token chain when replay is detected. + * Follows `replacedBy` links and revokes everything. + */ + private async revokeChain(startToken: { id: string; userId: string; replacedBy?: string | null }): Promise { + const toRevoke: string[] = [startToken.id]; + let current = startToken.replacedBy; + + while (current) { + const next = await this.prisma.refreshToken.findUnique({ + where: { token: current }, + select: { id: true, replacedBy: true }, + }); + if (next) { + toRevoke.push(next.id); + current = next.replacedBy; + } else { + break; + } + } + + await this.prisma.refreshToken.updateMany({ + where: { id: { in: toRevoke } }, + data: { revokedAt: new Date() }, + }); + } +}