From be0b90e68ff9da50fc333d2a245ce16355fcbe46 Mon Sep 17 00:00:00 2001 From: yeuimu <2197651308@qq.com> Date: Sat, 22 Aug 2026 12:04:56 +0800 Subject: [PATCH] feat(security): HttpOnly cookie sessions, token revocation, and RBAC - Add User.role (enum Role/ADMIN) and User.tokenVersion with migration - Login now issues short-lived access token (30m default) + 7d refresh token, both embedding tokenVersion and a typ discriminator - Tokens delivered via HttpOnly SameSite cookies (ir_at, ir_rt scoped to /auth); refresh token never leaves the cookie - New endpoints: POST /auth/refresh (rotation), GET /auth/me, POST /auth/logout (bumps tokenVersion, revoking all tokens) - JWT strategy accepts bearer or cookie, rejects refresh tokens, and verifies tokenVersion + user existence on every request - Global RolesGuard: authenticated routes require ADMIN unless widened via @Roles(...) - Admin SPA: session fully cookie-based, no token in localStorage; router guard restores session via /auth/me; axios auto-refreshes once on 401; stale localStorage keys cleaned up --- apps/admin/src/api/request.ts | 47 ++++---- apps/admin/src/stores/auth.ts | 55 ++++----- apps/api/.env.example | 7 +- apps/api/package.json | 2 + .../migration.sql | 8 ++ apps/api/prisma/schema.prisma | 9 +- apps/api/src/app.module.ts | 7 ++ apps/api/src/auth/auth.controller.ts | 106 +++++++++++++++++- apps/api/src/auth/auth.service.spec.ts | 86 ++++++++++++-- apps/api/src/auth/auth.service.ts | 101 ++++++++++++++++- .../src/auth/decorators/roles.decorator.ts | 9 ++ apps/api/src/auth/guards/roles.guard.spec.ts | 32 ++++++ apps/api/src/auth/guards/roles.guard.ts | 33 ++++++ apps/api/src/auth/strategies/jwt.strategy.ts | 39 ++++++- apps/api/src/main.ts | 18 ++- pnpm-lock.yaml | 29 +++++ 16 files changed, 503 insertions(+), 85 deletions(-) create mode 100644 apps/api/prisma/migrations/20260824000000_add_user_role_token_version/migration.sql create mode 100644 apps/api/src/auth/decorators/roles.decorator.ts create mode 100644 apps/api/src/auth/guards/roles.guard.spec.ts create mode 100644 apps/api/src/auth/guards/roles.guard.ts diff --git a/apps/admin/src/api/request.ts b/apps/admin/src/api/request.ts index c71db2e..83da7e4 100644 --- a/apps/admin/src/api/request.ts +++ b/apps/admin/src/api/request.ts @@ -1,5 +1,5 @@ import axios from 'axios' -import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios' +import type { AxiosInstance, AxiosResponse, AxiosError, InternalAxiosRequestConfig } from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' @@ -9,22 +9,10 @@ const request: AxiosInstance = axios.create({ headers: { 'Content-Type': 'application/json', }, + // Session tokens live in HttpOnly cookies — send them along. + withCredentials: true, }) -// Request interceptor -request.interceptors.request.use( - (config: AxiosRequestConfig) => { - const token = localStorage.getItem('token') - if (token && config.headers) { - config.headers.Authorization = `Bearer ${token}` - } - return config - }, - (error: AxiosError) => { - return Promise.reject(error) - } -) - // Response interceptor request.interceptors.response.use( (response: AxiosResponse) => { @@ -35,15 +23,36 @@ request.interceptors.response.use( } return body }, - (error: AxiosError) => { + async (error: AxiosError) => { + // Access token expired: try the refresh cookie once, then retry the + // original request. A second 401 (refresh failed) logs the user out. + const config = error.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined + if ( + error.response?.status === 401 && + config && + !config._retried && + !config.url?.includes('/auth/login') && + !config.url?.includes('/auth/refresh') + ) { + config._retried = true + try { + await axios.post( + `${import.meta.env.VITE_API_BASE || '/api'}/auth/refresh`, + {}, + { withCredentials: true }, + ) + return request.request(config) + } catch { + // fall through to the 401 handling below + } + } + if (error.response) { const { status, data } = error.response switch (status) { case 401: ElMessage.error('Unauthorized, please login') - localStorage.removeItem('token') - localStorage.removeItem('user') router.push('/login') break case 403: @@ -69,4 +78,4 @@ request.interceptors.response.use( } ) -export default request \ No newline at end of file +export default request diff --git a/apps/admin/src/stores/auth.ts b/apps/admin/src/stores/auth.ts index 0e7046d..a2ac177 100644 --- a/apps/admin/src/stores/auth.ts +++ b/apps/admin/src/stores/auth.ts @@ -4,49 +4,41 @@ import type { LoginRequest, User } from '@/types' import { authApi } from '@/api/auth' export const useAuthStore = defineStore('auth', () => { - // Token persisted to localStorage - const token = ref(localStorage.getItem('token') || '') + // The session lives in HttpOnly cookies set by the API; nothing + // security-relevant is stored client-side. `user` is just UI state, + // restored from the server via /auth/me on app start. + const user = ref(null) + const sessionChecked = ref(false) - // User persisted to localStorage (parsed if available) - const user = ref(loadUser()) + // Tokens moved to HttpOnly cookies; clean up any stale values from the + // previous localStorage-based session. + localStorage.removeItem('token') + localStorage.removeItem('user') - const isLoggedIn = computed(() => !!token.value) + const isLoggedIn = computed(() => !!user.value) - function loadUser(): User | null { - const raw = localStorage.getItem('user') - if (!raw) return null + // Restore the session once per app start. The router guard awaits this + // so a page refresh on a protected route does not bounce to /login. + async function ensureSessionChecked() { + if (sessionChecked.value) return + sessionChecked.value = true try { - return JSON.parse(raw) as User + user.value = await authApi.getCurrentUser() } catch { - return null - } - } - - function setToken(newToken: string) { - token.value = newToken - if (newToken) { - localStorage.setItem('token', newToken) - } else { - localStorage.removeItem('token') + user.value = null } } function setUser(newUser: User | null) { user.value = newUser - if (newUser) { - localStorage.setItem('user', JSON.stringify(newUser)) - } else { - localStorage.removeItem('user') - } } async function login(payload: LoginRequest) { - const res = await authApi.login(payload) as any; - const accessToken: string = res.accessToken ?? res.data?.accessToken ?? ''; - const userData: User | null = res.user ?? res.data?.user ?? null; - if (accessToken) setToken(accessToken); - if (userData) setUser(userData); - return res; + const res = await authApi.login(payload) as any + const userData: User | null = res.user ?? res.data?.user ?? null + sessionChecked.value = true + setUser(userData) + return res } async function fetchCurrentUser() { @@ -61,14 +53,13 @@ export const useAuthStore = defineStore('auth', () => { } catch { // Ignore network errors during logout } - setToken('') setUser(null) } return { - token, user, isLoggedIn, + ensureSessionChecked, login, fetchCurrentUser, logout, diff --git a/apps/api/.env.example b/apps/api/.env.example index 4ffd602..a611e62 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -5,8 +5,11 @@ DATABASE_URL=postgresql://postgres:CHANGE_ME@localhost:5432/inkreach-official-we # Must be at least 32 characters. JWT_SECRET=CHANGE_ME_TO_A_STRONG_RANDOM_SECRET -# Access token lifetime (jwt-rest compatible, e.g. 30m, 12h, 7d) -TOKEN_EXPIRES_IN=7d +# Access token lifetime (e.g. 30m, 12h); short-lived, rotated via /auth/refresh +TOKEN_EXPIRES_IN=30m + +# Refresh token lifetime (HttpOnly cookie) +REFRESH_TOKEN_EXPIRES_IN=7d # Comma-separated list of allowed CORS origins (leave empty to disable CORS) CORS_ORIGINS=http://localhost:5173 diff --git a/apps/api/package.json b/apps/api/package.json index 7dc870f..221d99c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -41,6 +41,7 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "cookie-parser": "^1.4.7", "express": "^4.21.0", "helmet": "^8.3.0", "multer": "^2.2.0", @@ -54,6 +55,7 @@ "@nestjs/schematics": "^10.0.3", "@nestjs/testing": "^10.3.0", "@types/bcrypt": "^5.0.2", + "@types/cookie-parser": "^1.4.10", "@types/express": "^4.17.21", "@types/jest": "^29.5.11", "@types/node": "^20.10.6", diff --git a/apps/api/prisma/migrations/20260824000000_add_user_role_token_version/migration.sql b/apps/api/prisma/migrations/20260824000000_add_user_role_token_version/migration.sql new file mode 100644 index 0000000..d88b84c --- /dev/null +++ b/apps/api/prisma/migrations/20260824000000_add_user_role_token_version/migration.sql @@ -0,0 +1,8 @@ +-- Create enum for user roles +CREATE TYPE "Role" AS ENUM ('ADMIN'); + +-- Add role column, existing users become ADMIN +ALTER TABLE "users" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'ADMIN'; + +-- Token version for JWT revocation (logout bumps it) +ALTER TABLE "users" ADD COLUMN "token_version" INTEGER NOT NULL DEFAULT 0; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 62c28ce..586ca94 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -241,11 +241,18 @@ model GoodTag { } // ---------- Users (admin authentication) ---------- +enum Role { + ADMIN +} + model User { id BigInt @id @default(autoincrement()) username String @unique passwordHash String @map("password_hash") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + role Role @default(ADMIN) + // Bumped on logout / revocation; JWTs carrying an older version are rejected. + tokenVersion Int @default(0) @map("token_version") + createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) @@map("users") diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 7dfc00e..ebfe7b8 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -4,6 +4,7 @@ import { APP_GUARD } from '@nestjs/core'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; +import { RolesGuard } from './auth/guards/roles.guard'; import { CountriesModule } from './countries/countries.module'; import { CategoriesModule } from './categories/categories.module'; import { TagsModule } from './tags/tags.module'; @@ -46,6 +47,12 @@ import { UploadModule } from './upload/upload.module'; provide: APP_GUARD, useClass: ThrottlerGuard, }, + { + // Enforces the ADMIN role on every authenticated route unless the + // route widens access with @Roles(...). + provide: APP_GUARD, + useClass: RolesGuard, + }, ], }) export class AppModule {} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index 55c919d..7357245 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -1,10 +1,28 @@ -import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Post, + Req, + Res, + UnauthorizedException, + UseGuards, +} from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Request, Response } from 'express'; import { AuthService } from './auth.service'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; import { LoginResponseDto, UserPublicDto } from './dto/auth-response.dto'; +import type { AuthenticatedUser } from './strategies/jwt.strategy'; + +const ACCESS_TOKEN_COOKIE = 'ir_at'; +const REFRESH_TOKEN_COOKIE = 'ir_rt'; +const isProd = process.env.NODE_ENV === 'production'; @ApiTags('auth') @Controller('auth') @@ -25,10 +43,90 @@ export class AuthController { @Post('login') @HttpCode(HttpStatus.OK) @Throttle({ default: { limit: 5, ttl: 60_000 } }) - @ApiOperation({ summary: 'Login and obtain a JWT' }) + @ApiOperation({ summary: 'Login and obtain access + refresh tokens' }) @ApiResponse({ status: 200, type: LoginResponseDto }) @ApiResponse({ status: 401, description: 'Invalid credentials' }) - login(@Body() dto: LoginDto): Promise { - return this.authService.login(dto) as unknown as Promise; + async login( + @Body() dto: LoginDto, + @Res({ passthrough: true }) res: Response, + ): Promise { + const result = await this.authService.login(dto); + // HttpOnly cookies are the primary session channel for the admin SPA + // (XSS cannot read them). The access token is also returned in the + // body for non-browser API clients. + res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, { + httpOnly: true, + sameSite: 'lax', + secure: isProd, + path: '/', + }); + res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, { + httpOnly: true, + sameSite: 'lax', + secure: isProd, + // Only ever sent to /auth/refresh and /auth/logout + path: '/auth', + }); + // The refresh token deliberately stays HttpOnly-only. + return { + accessToken: result.accessToken, + user: result.user, + } as unknown as LoginResponseDto; + } + + @Post('refresh') + @HttpCode(HttpStatus.OK) + @Throttle({ default: { limit: 10, ttl: 60_000 } }) + @ApiOperation({ summary: 'Rotate the refresh token cookie' }) + @ApiResponse({ status: 200, type: LoginResponseDto }) + @ApiResponse({ status: 401, description: 'Invalid refresh token' }) + async refresh( + @Req() req: Request, + @Res({ passthrough: true }) res: Response, + ): Promise { + const token = req.cookies?.[REFRESH_TOKEN_COOKIE]; + if (!token) { + res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' }); + throw new UnauthorizedException('Missing refresh token'); + } + const result = await this.authService.refresh(token); + res.cookie(ACCESS_TOKEN_COOKIE, result.accessToken, { + httpOnly: true, + sameSite: 'lax', + secure: isProd, + path: '/', + }); + res.cookie(REFRESH_TOKEN_COOKIE, result.refreshToken, { + httpOnly: true, + sameSite: 'lax', + secure: isProd, + path: '/auth', + }); + return { + accessToken: result.accessToken, + user: result.user, + } as unknown as LoginResponseDto; + } + + @Get('me') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Current authenticated user' }) + @ApiResponse({ status: 200, type: UserPublicDto }) + me(@Req() req: Request & { user: AuthenticatedUser }): Promise { + return this.authService.me(req.user.id) as unknown as Promise; + } + + @Post('logout') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Revoke all tokens of the current user' }) + async logout( + @Req() req: Request & { user: AuthenticatedUser }, + @Res({ passthrough: true }) res: Response, + ): Promise<{ success: true }> { + await this.authService.logout(req.user.id); + res.clearCookie(ACCESS_TOKEN_COOKIE, { path: '/' }); + res.clearCookie(REFRESH_TOKEN_COOKIE, { path: '/auth' }); + return { success: true }; } } diff --git a/apps/api/src/auth/auth.service.spec.ts b/apps/api/src/auth/auth.service.spec.ts index 23e3529..372af74 100644 --- a/apps/api/src/auth/auth.service.spec.ts +++ b/apps/api/src/auth/auth.service.spec.ts @@ -1,5 +1,5 @@ import { Test } from '@nestjs/testing'; -import { JwtModule } from '@nestjs/jwt'; +import { JwtModule, JwtService } from '@nestjs/jwt'; import { ConflictException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { AuthService } from './auth.service'; @@ -7,11 +7,13 @@ import { PrismaService } from '../prisma/prisma.service'; describe('AuthService', () => { let service: AuthService; + let jwt: JwtService; let prisma: { user: { count: jest.Mock; findUnique: jest.Mock; create: jest.Mock; + update: jest.Mock; }; }; @@ -20,12 +22,19 @@ describe('AuthService', () => { id: 1n, username: 'alice', passwordHash: HASH, + role: 'ADMIN' as const, + tokenVersion: 0, createdAt: new Date('2026-01-01T00:00:00Z'), }; beforeAll(async () => { prisma = { - user: { count: jest.fn(), findUnique: jest.fn(), create: jest.fn() }, + user: { + count: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, }; const moduleRef = await Test.createTestingModule({ imports: [ @@ -37,6 +46,7 @@ describe('AuthService', () => { providers: [AuthService, { provide: PrismaService, useValue: prisma }], }).compile(); service = moduleRef.get(AuthService); + jwt = moduleRef.get(JwtService); }); beforeEach(() => { @@ -49,10 +59,7 @@ describe('AuthService', () => { prisma.user.findUnique.mockResolvedValueOnce(null); prisma.user.create.mockResolvedValueOnce(dbUser); - const user = await service.register({ - username: 'alice', - password: 'plain-pwd', - }); + const user = await service.register({ username: 'alice', password: 'plain-pwd' }); expect(user.username).toBe('alice'); const created = prisma.user.create.mock.calls[0][0].data; @@ -78,14 +85,17 @@ describe('AuthService', () => { }); describe('login', () => { - it('returns an access token for valid credentials', async () => { + it('returns access + refresh tokens with tokenVersion and type', async () => { prisma.user.findUnique.mockResolvedValueOnce(dbUser); - const result = await service.login({ - username: 'alice', - password: 'plain-pwd', - }); - expect(result.accessToken.split('.').length).toBe(3); + const result = await service.login({ username: 'alice', password: 'plain-pwd' }); + expect(result.user.username).toBe('alice'); + const access = jwt.decode(result.accessToken) as Record; + expect(access.typ).toBe('access'); + expect(access.tv).toBe(0); + const refresh = jwt.decode(result.refreshToken) as Record; + expect(refresh.typ).toBe('refresh'); + expect(refresh.tv).toBe(0); }); it('throws UnauthorizedException for wrong password', async () => { @@ -102,4 +112,56 @@ describe('AuthService', () => { ).rejects.toBeInstanceOf(UnauthorizedException); }); }); + + describe('refresh', () => { + it('rotates a valid refresh token', async () => { + const refreshToken = await jwt.signAsync({ + sub: '1', + username: 'alice', + role: 'ADMIN', + tv: 0, + typ: 'refresh', + }); + prisma.user.findUnique.mockResolvedValueOnce(dbUser); + + const result = await service.refresh(refreshToken); + expect(result.user.username).toBe('alice'); + expect(result.accessToken).not.toBe(refreshToken); + }); + + it('rejects access tokens used as refresh tokens', async () => { + const accessToken = await jwt.signAsync({ + sub: '1', + username: 'alice', + role: 'ADMIN', + tv: 0, + typ: 'access', + }); + await expect(service.refresh(accessToken)).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('rejects refresh tokens with a stale tokenVersion (revoked)', async () => { + const refreshToken = await jwt.signAsync({ + sub: '1', + username: 'alice', + role: 'ADMIN', + tv: 0, + typ: 'refresh', + }); + // User logged out elsewhere: tokenVersion bumped to 1 + prisma.user.findUnique.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 }); + await expect(service.refresh(refreshToken)).rejects.toBeInstanceOf(UnauthorizedException); + }); + }); + + describe('logout', () => { + it('bumps tokenVersion to revoke all tokens', async () => { + prisma.user.update.mockResolvedValueOnce({ ...dbUser, tokenVersion: 1 }); + await service.logout(1n); + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: 1n }, + data: { tokenVersion: { increment: 1 } }, + }); + }); + }); }); diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts index 910d80b..3e49f90 100644 --- a/apps/api/src/auth/auth.service.ts +++ b/apps/api/src/auth/auth.service.ts @@ -14,16 +14,19 @@ import type { JwtPayload } from './strategies/jwt.strategy'; export interface PublicUser { id: string; username: string; + role: string; createdAt: string; } export interface LoginResult { accessToken: string; + refreshToken: string; user: PublicUser; } const BCRYPT_ROUNDS = 10; -const TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN ?? '7d'; +const ACCESS_TOKEN_EXPIRES_IN = process.env.TOKEN_EXPIRES_IN ?? '30m'; +const REFRESH_TOKEN_EXPIRES_IN = process.env.REFRESH_TOKEN_EXPIRES_IN ?? '7d'; /** * Compared against when the username does not exist so that login takes @@ -62,7 +65,9 @@ export class AuthService { } /** - * Verifies credentials and returns a signed JWT. + * Verifies credentials and returns signed access + refresh tokens. + * Both tokens embed the user's tokenVersion so bumping it on the user + * row (logout / revocation) invalidates them immediately. */ async login(dto: LoginDto): Promise { const user = await this.prisma.user.findUnique({ @@ -74,20 +79,104 @@ export class AuthService { if (!user || !ok) { throw new UnauthorizedException('Invalid credentials'); } + return { + accessToken: await this.signAccessToken(user), + refreshToken: await this.signRefreshToken(user), + user: this.toPublic(user), + }; + } + + /** + * Rotates a refresh token: the old refresh token becomes invalid as + * soon as tokenVersion is bumped (logout, revocation). + */ + async refresh(refreshToken: string): Promise { + let payload: JwtPayload; + try { + payload = await this.jwt.verifyAsync(refreshToken); + } catch { + throw new UnauthorizedException('Invalid refresh token'); + } + if (payload.typ !== 'refresh') { + throw new UnauthorizedException('Invalid refresh token'); + } + const user = await this.prisma.user + .findUnique({ where: { id: BigInt(payload.sub) } }) + .catch(() => null); + if (!user || user.tokenVersion !== payload.tv) { + throw new UnauthorizedException('Invalid refresh token'); + } + return { + accessToken: await this.signAccessToken(user), + refreshToken: await this.signRefreshToken(user), + user: this.toPublic(user), + }; + } + + /** + * Revokes all tokens of a user by bumping tokenVersion. + */ + async logout(userId: bigint): Promise { + await this.prisma.user.update({ + where: { id: userId }, + data: { tokenVersion: { increment: 1 } }, + }); + } + + async me(userId: bigint): Promise { + const user = await this.prisma.user.findUnique({ where: { id: userId } }); + if (!user) { + throw new UnauthorizedException(); + } + return this.toPublic(user); + } + + private async signAccessToken(user: { + id: bigint; + username: string; + role: string; + tokenVersion: number; + }): Promise { const payload: JwtPayload = { sub: user.id.toString(), username: user.username, + role: user.role, + tv: user.tokenVersion, + typ: 'access', }; - const accessToken = await this.jwt.signAsync(payload, { - expiresIn: TOKEN_EXPIRES_IN, + return this.jwt.signAsync(payload, { + expiresIn: ACCESS_TOKEN_EXPIRES_IN, }); - return { accessToken, user: this.toPublic(user) }; } - private toPublic(user: { id: bigint; username: string; createdAt: Date }): PublicUser { + private async signRefreshToken(user: { + id: bigint; + username: string; + role: string; + tokenVersion: number; + }): Promise { + const payload: JwtPayload = { + sub: user.id.toString(), + username: user.username, + role: user.role, + tv: user.tokenVersion, + typ: 'refresh', + }; + return this.jwt.signAsync(payload, { + expiresIn: REFRESH_TOKEN_EXPIRES_IN, + }); + } + + private toPublic(user: { + id: bigint; + username: string; + role: string; + createdAt: Date; + }): PublicUser { return { id: user.id.toString(), username: user.username, + role: user.role, createdAt: user.createdAt.toISOString(), }; } diff --git a/apps/api/src/auth/decorators/roles.decorator.ts b/apps/api/src/auth/decorators/roles.decorator.ts new file mode 100644 index 0000000..ead1965 --- /dev/null +++ b/apps/api/src/auth/decorators/roles.decorator.ts @@ -0,0 +1,9 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ROLES_KEY = 'roles'; + +/** + * Restricts a route to the given roles. When omitted, any + * authenticated user with an ADMIN role passes the RolesGuard. + */ +export const Roles = (...roles: string[]) => SetMetadata(ROLES_KEY, roles); diff --git a/apps/api/src/auth/guards/roles.guard.spec.ts b/apps/api/src/auth/guards/roles.guard.spec.ts new file mode 100644 index 0000000..9f7401e --- /dev/null +++ b/apps/api/src/auth/guards/roles.guard.spec.ts @@ -0,0 +1,32 @@ +import { ForbiddenException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RolesGuard } from './roles.guard'; + +describe('RolesGuard', () => { + let guard: RolesGuard; + + beforeEach(() => { + guard = new RolesGuard(new Reflector()); + }); + + const context = (user: unknown, roles?: string[]) => + ({ + switchToHttp: () => ({ getRequest: () => ({ user }) }), + getHandler: () => (roles ? { __roles: roles } : {}), + getClass: () => ({}), + }) as never; + + it('passes public routes (no authenticated user)', () => { + expect(guard.canActivate(context(undefined))).toBe(true); + }); + + it('passes ADMIN users by default', () => { + expect(guard.canActivate(context({ id: 1n, username: 'a', role: 'ADMIN' }))).toBe(true); + }); + + it('blocks users without the ADMIN role', () => { + expect(() => guard.canActivate(context({ id: 1n, username: 'a', role: 'VIEWER' }))).toThrow( + ForbiddenException, + ); + }); +}); diff --git a/apps/api/src/auth/guards/roles.guard.ts b/apps/api/src/auth/guards/roles.guard.ts new file mode 100644 index 0000000..b7e1b23 --- /dev/null +++ b/apps/api/src/auth/guards/roles.guard.ts @@ -0,0 +1,33 @@ +import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import type { AuthenticatedUser } from '../strategies/jwt.strategy'; + +/** + * Role-based access control. Applied globally: any authenticated user + * reaching a protected route must hold the ADMIN role unless the route + * declares a wider set with @Roles(...). Routes without a JwtAuthGuard + * (public endpoints) have no `request.user` and are skipped here — their + * openness is decided by the controller's own guards. + */ +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ + user?: AuthenticatedUser; + }>(); + if (!request.user) { + return true; // public route — no JwtAuthGuard in front + } + const required = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]) ?? ['ADMIN']; + if (!required.includes(request.user.role)) { + throw new ForbiddenException('Insufficient role'); + } + return true; + } +} diff --git a/apps/api/src/auth/strategies/jwt.strategy.ts b/apps/api/src/auth/strategies/jwt.strategy.ts index b10b9cb..609240a 100644 --- a/apps/api/src/auth/strategies/jwt.strategy.ts +++ b/apps/api/src/auth/strategies/jwt.strategy.ts @@ -4,14 +4,27 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../../prisma/prisma.service'; +export const ACCESS_TOKEN_COOKIE = 'ir_at'; + +export interface AuthenticatedUser { + id: bigint; + username: string; + role: string; +} + /** * Shape of the JWT we issue. * * `sub` is the user ID as a string (bigints are serialized to strings in JSON). + * `typ` distinguishes access tokens from refresh tokens; `tv` is the user's + * tokenVersion and `role` drives the RolesGuard. */ export interface JwtPayload { sub: string; username: string; + role?: string; + tv?: number; + typ?: 'access' | 'refresh'; } @Injectable() @@ -28,7 +41,12 @@ export class JwtStrategy extends PassportStrategy(Strategy) { throw new Error('JWT_SECRET must be at least 32 characters'); } super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + // Access tokens are accepted from the HttpOnly cookie (browser) or + // the Authorization header (non-browser API clients). + jwtFromRequest: ExtractJwt.fromExtractors([ + ExtractJwt.fromAuthHeaderAsBearerToken(), + (req) => req?.cookies?.[ACCESS_TOKEN_COOKIE] ?? null, + ]), ignoreExpiration: false, secretOrKey: secret, }); @@ -36,19 +54,28 @@ export class JwtStrategy extends PassportStrategy(Strategy) { /** * Runs on every authenticated request. The returned object becomes - * `request.user` for downstream controllers. The user is re-checked in - * the database so tokens of deleted users stop working immediately. + * `request.user` for downstream controllers. The user and its + * tokenVersion are re-checked in the database so tokens of deleted + * users, logged-out users, or refresh tokens stop working immediately. */ - async validate(payload: JwtPayload): Promise<{ id: bigint; username: string }> { + async validate(payload: JwtPayload): Promise { if (!payload?.sub || !payload.username) { throw new UnauthorizedException('Invalid token payload'); } + // Refresh tokens must never be accepted as API credentials. + if (payload.typ === 'refresh') { + throw new UnauthorizedException('Invalid token type'); + } const user = await this.prisma.user .findUnique({ where: { id: BigInt(payload.sub) } }) .catch(() => null); - if (!user || user.username !== payload.username) { + if ( + !user || + user.username !== payload.username || + (payload.tv !== undefined && user.tokenVersion !== payload.tv) + ) { throw new UnauthorizedException('Invalid token'); } - return { id: user.id, username: user.username }; + return { id: user.id, username: user.username, role: user.role }; } } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 689aa05..12bee0a 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -3,6 +3,7 @@ import { NestExpressApplication } from '@nestjs/platform-express'; import { ValidationPipe } from '@nestjs/common'; import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import helmet from 'helmet'; +import * as cookieParserModule from 'cookie-parser'; import { json } from 'express'; import { join } from 'path'; import { AppModule } from './app.module'; @@ -32,13 +33,24 @@ async function bootstrap() { // Security headers (X-Content-Type-Options, X-Frame-Options, CSP, HSTS, ...) app.use(helmet()); - // CORS: only origins listed in CORS_ORIGINS (comma-separated) are allowed. - // Authentication uses Bearer headers, so credentialed CORS is not needed. + // Parse auth cookies (HttpOnly access/refresh tokens). Resolve both the + // namespace and its `default` interop shape so it works regardless of + // the compiled module interop mode. + const cookieParser = ( + cookieParserModule as unknown as { + default?: typeof cookieParserModule; + } + ).default ?? cookieParserModule; + app.use(cookieParser()); + + // CORS: only origins listed in CORS_ORIGINS (comma-separated) are + // allowed. Credentials are enabled because the session lives in + // HttpOnly cookies. const corsOrigins = (process.env.CORS_ORIGINS ?? '') .split(',') .map((o) => o.trim()) .filter(Boolean); - app.enableCors(corsOrigins.length > 0 ? { origin: corsOrigins } : undefined); + app.enableCors(corsOrigins.length > 0 ? { origin: corsOrigins, credentials: true } : undefined); // Serve uploaded files. nosniff prevents browsers from sniffing a // non-image content type out of an uploaded file. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9f44a5..87be024 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ importers: class-validator: specifier: ^0.14.0 version: 0.14.4 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 express: specifier: ^4.21.0 version: 4.22.1 @@ -154,6 +157,9 @@ importers: '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 + '@types/cookie-parser': + specifier: ^1.4.10 + version: 1.4.10(@types/express@4.17.25) '@types/express': specifier: ^4.17.21 version: 4.17.25 @@ -3385,6 +3391,11 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie-parser@1.4.10': + resolution: {integrity: sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==} + peerDependencies: + '@types/express': '*' + '@types/cookiejar@2.1.5': resolution: {integrity: sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==} @@ -4555,6 +4566,13 @@ packages: cookie-es@3.1.1: resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.0.7: resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} @@ -11797,6 +11815,10 @@ snapshots: dependencies: '@types/node': 20.19.43 + '@types/cookie-parser@1.4.10(@types/express@4.17.25)': + dependencies: + '@types/express': 4.17.25 + '@types/cookiejar@2.1.5': {} '@types/deep-eql@4.0.2': {} @@ -13159,6 +13181,13 @@ snapshots: cookie-es@3.1.1: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + cookie-signature@1.0.7: {} cookie@0.7.2: {}