This commit is contained in:
2025-12-31 21:24:26 +08:00
parent 3134737c11
commit 2f51a0498f
9 changed files with 207 additions and 40 deletions

View File

@@ -3,6 +3,9 @@ import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import type { AuthenticatedUser } from './decorators/current-user.decorator';
import { User } from '../users/users.entity';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { URLSearchParams } from 'url';
describe('AuthService', () => {
let service: AuthService;
@@ -48,12 +51,22 @@ describe('AuthService', () => {
provide: UsersService,
useValue: mockUsersService,
},
{
provide: ConfigService,
useValue: {
get: (key: string) => {
if (key === 'JWT_ISSUER') return 'https://auth.example.com';
if (key === 'KEYCLOAK_CLIENT_ID') return 'friendolls-client';
if (key === 'KEYCLOAK_CLIENT_SECRET') return 'secret';
return undefined;
},
},
},
],
}).compile();
service = module.get<AuthService>(AuthService);
// Reset mocks
jest.clearAllMocks();
});
@@ -61,6 +74,57 @@ describe('AuthService', () => {
expect(service).toBeDefined();
});
describe('revokeToken', () => {
beforeEach(() => {
jest.spyOn(axios, 'post').mockReset();
});
it('should skip when config missing', async () => {
const missingConfigService = new ConfigService({});
const localService = new AuthService(
mockUsersService as any,
missingConfigService,
);
const warnSpy = jest.spyOn<any, any>(localService['logger'], 'warn');
const result = await localService.revokeToken('rt');
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
});
it('should return true on successful revocation', async () => {
jest.spyOn(axios, 'post').mockResolvedValue({ status: 200 });
const result = await service.revokeToken('rt-success');
expect(result).toBe(true);
expect(axios.post).toHaveBeenCalledWith(
'https://auth.example.com/protocol/openid-connect/revoke',
expect.any(URLSearchParams),
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it('should return false on non-2xx response', async () => {
jest.spyOn(axios, 'post').mockResolvedValue({ status: 400 });
const warnSpy = jest.spyOn<any, any>(service['logger'], 'warn');
const result = await service.revokeToken('rt-fail');
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
});
it('should return false on error', async () => {
jest.spyOn(axios, 'post').mockRejectedValue({ message: 'boom' });
const warnSpy = jest.spyOn<any, any>(service['logger'], 'warn');
const result = await service.revokeToken('rt-error');
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
});
});
describe('syncUserFromToken', () => {
it('should create a new user if user does not exist', async () => {
mockCreateFromToken.mockReturnValue(mockUser);
@@ -170,7 +234,7 @@ describe('AuthService', () => {
name: 'Empty Sub User',
});
const result = await service.syncUserFromToken(authUserEmptySub);
await service.syncUserFromToken(authUserEmptySub);
expect(mockCreateFromToken).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -1,4 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios';
import { URLSearchParams } from 'url';
import { UsersService } from '../users/users.service';
import type { AuthenticatedUser } from './decorators/current-user.decorator';
import { User } from '../users/users.entity';
@@ -34,7 +37,63 @@ import { User } from '../users/users.entity';
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(private readonly usersService: UsersService) {}
constructor(
private readonly usersService: UsersService,
private readonly configService: ConfigService,
) {}
/**
* Revoke refresh token via Keycloak token revocation endpoint, if configured.
* Returns true on success; false on missing config or failure.
*/
async revokeToken(refreshToken: string): Promise<boolean> {
const issuer = this.configService.get<string>('JWT_ISSUER');
const clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID');
const clientSecret = this.configService.get<string>(
'KEYCLOAK_CLIENT_SECRET',
);
if (!issuer || !clientId) {
this.logger.warn(
'JWT issuer or client id missing, skipping token revocation',
);
return false;
}
const revokeUrl = `${issuer}/protocol/openid-connect/revoke`;
try {
const params = new URLSearchParams({
client_id: clientId,
token: refreshToken,
token_type_hint: 'refresh_token',
});
if (clientSecret) {
params.set('client_secret', clientSecret);
}
const response = await axios.post(revokeUrl, params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 5000,
validateStatus: (status) => status >= 200 && status < 500,
});
if (response.status >= 200 && response.status < 300) {
this.logger.log('Refresh token revoked');
return true;
}
this.logger.warn(
`Token revocation failed with status ${response.status}`,
);
return false;
} catch (error) {
const err = error as AxiosError;
this.logger.warn(
`Failed to revoke token: ${err.response?.status} ${err.response?.statusText ?? err.message}`,
);
return false;
}
}
/**
* Handles user login and profile synchronization from Keycloak token.
@@ -52,8 +111,6 @@ export class AuthService {
const { keycloakSub, email, name, username, picture, roles } =
authenticatedUser;
// Use createFromToken which handles upsert atomically
// This prevents race conditions when multiple requests arrive simultaneously
const user = await this.usersService.createFromToken({
keycloakSub,
email: email || '',
@@ -70,23 +127,11 @@ export class AuthService {
* Ensures a user exists in the local database without updating profile data.
* This is optimized for regular API calls that need the user record but don't
* need to sync profile data from Keycloak on every request.
*
* - For new users: Creates with full profile data
* - For existing users: Returns existing record WITHOUT updating (read-only)
*
* Use this for most API endpoints. Only use syncUserFromToken() for actual
* login events (WebSocket connections, /users/me endpoint).
*
* @param authenticatedUser - User data extracted from JWT token
* @returns The user entity
*/
async ensureUserExists(authenticatedUser: AuthenticatedUser): Promise<User> {
const { keycloakSub, email, name, username, picture, roles } =
authenticatedUser;
// Use optimized findOrCreate method
// This returns existing users immediately (1 read)
// And creates new users if needed (1 read + 1 write)
return await this.usersService.findOrCreate({
keycloakSub,
email: email || '',
@@ -97,24 +142,10 @@ export class AuthService {
});
}
/**
* Validates if a user has a specific role.
*
* @param user - The authenticated user
* @param requiredRole - The role to check for
* @returns True if the user has the role, false otherwise
*/
hasRole(user: AuthenticatedUser, requiredRole: string): boolean {
return user.roles?.includes(requiredRole) ?? false;
}
/**
* Validates if a user has any of the specified roles.
*
* @param user - The authenticated user
* @param requiredRoles - Array of roles to check for
* @returns True if the user has at least one of the roles, false otherwise
*/
hasAnyRole(user: AuthenticatedUser, requiredRoles: string[]): boolean {
if (!user.roles || user.roles.length === 0) {
return false;
@@ -122,13 +153,6 @@ export class AuthService {
return requiredRoles.some((role) => user.roles!.includes(role));
}
/**
* Validates if a user has all of the specified roles.
*
* @param user - The authenticated user
* @param requiredRoles - Array of roles to check for
* @returns True if the user has all of the roles, false otherwise
*/
hasAllRoles(user: AuthenticatedUser, requiredRoles: string[]): boolean {
if (!user.roles || user.roles.length === 0) {
return false;

View File

@@ -11,6 +11,7 @@ export interface AuthenticatedUser {
username?: string;
picture?: string;
roles?: string[];
sessionState?: string;
}
/**

View File

@@ -22,6 +22,7 @@ export interface JwtPayload {
roles: string[];
};
};
session_state?: string;
iss: string; // Issuer
aud: string | string[]; // Audience
exp: number; // Expiration time
@@ -96,6 +97,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
username?: string;
picture?: string;
roles?: string[];
sessionState?: string;
}> {
this.logger.debug(`Validating JWT token payload`);
this.logger.debug(` Issuer: ${payload.iss}`);
@@ -132,6 +134,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
username: payload.preferred_username,
picture: payload.picture,
roles: roles.length > 0 ? roles : undefined,
sessionState: payload.session_state,
};
this.logger.log(

View File

@@ -0,0 +1,14 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class LogoutRequestDto {
@ApiProperty({ description: 'Refresh token to revoke' })
@IsString()
@IsNotEmpty()
refreshToken!: string;
@ApiPropertyOptional({ description: 'Session state identifier' })
@IsOptional()
@IsString()
sessionState?: string;
}

View File

@@ -8,6 +8,7 @@ import {
HttpCode,
UseGuards,
Logger,
Post,
} from '@nestjs/common';
import {
ApiTags,
@@ -17,6 +18,7 @@ import {
ApiBearerAuth,
ApiUnauthorizedResponse,
ApiForbiddenResponse,
ApiNoContentResponse,
} from '@nestjs/swagger';
import { UsersService } from './users.service';
import { User, UserResponseDto } from './users.entity';
@@ -27,6 +29,7 @@ import {
type AuthenticatedUser,
} from '../auth/decorators/current-user.decorator';
import { AuthService } from '../auth/auth.service';
import { LogoutRequestDto } from './dto/logout-request.dto';
/**
* Users Controller
@@ -49,6 +52,28 @@ export class UsersController {
private readonly authService: AuthService,
) {}
/**
* Logout current authenticated session by revoking the refresh token.
* Falls back to local cleanup only if revocation fails.
*/
@Post('logout')
@HttpCode(204)
@ApiOperation({ summary: 'Logout current session' })
@ApiNoContentResponse({ description: 'Session logged out' })
@ApiUnauthorizedResponse({ description: 'Invalid or missing JWT token' })
async logout(
@CurrentUser() authUser: AuthenticatedUser,
@Body() body: LogoutRequestDto,
): Promise<void> {
this.logger.log(`Logout requested for ${authUser.keycloakSub}`);
const refreshToken = body.refreshToken;
const revoked = await this.authService.revokeToken(refreshToken);
if (!revoked) {
this.logger.warn('Token revocation failed or was skipped');
}
}
/**
* Get current authenticated user's profile.
* This endpoint syncs the user from Keycloak token to ensure profile data