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
This commit is contained in:
@@ -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
|
||||
export default request
|
||||
|
||||
@@ -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<string>(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<User | null>(null)
|
||||
const sessionChecked = ref(false)
|
||||
|
||||
// User persisted to localStorage (parsed if available)
|
||||
const user = ref<User | null>(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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<LoginResponseDto> {
|
||||
return this.authService.login(dto) as unknown as Promise<LoginResponseDto>;
|
||||
async login(
|
||||
@Body() dto: LoginDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<LoginResponseDto> {
|
||||
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<LoginResponseDto> {
|
||||
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<UserPublicDto> {
|
||||
return this.authService.me(req.user.id) as unknown as Promise<UserPublicDto>;
|
||||
}
|
||||
|
||||
@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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
expect(access.typ).toBe('access');
|
||||
expect(access.tv).toBe(0);
|
||||
const refresh = jwt.decode(result.refreshToken) as Record<string, unknown>;
|
||||
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 } },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<LoginResult> {
|
||||
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<LoginResult> {
|
||||
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<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { tokenVersion: { increment: 1 } },
|
||||
});
|
||||
}
|
||||
|
||||
async me(userId: bigint): Promise<PublicUser> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string[]>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]) ?? ['ADMIN'];
|
||||
if (!required.includes(request.user.role)) {
|
||||
throw new ForbiddenException('Insufficient role');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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<AuthenticatedUser> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
+15
-3
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user