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:
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user