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:
yeuimu
2026-08-22 12:04:56 +08:00
parent 755b40aded
commit be0b90e68f
16 changed files with 503 additions and 85 deletions
+95 -6
View File
@@ -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(),
};
}