init user system with keycloak

This commit is contained in:
2025-11-23 00:17:27 +08:00
parent f1d3ead212
commit d88c2057c0
22 changed files with 2546 additions and 18 deletions

51
src/auth/auth.module.ts Normal file
View File

@@ -0,0 +1,51 @@
import { Module, forwardRef } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { UsersModule } from '../users/users.module';
/**
* Authentication Module
*
* Provides Keycloak OpenID Connect authentication using JWT tokens.
* This module configures:
* - Passport for authentication strategies
* - JWT strategy for validating Keycloak tokens
* - Integration with UsersModule for user synchronization
*
* The module requires the following environment variables:
* - KEYCLOAK_AUTH_SERVER_URL: Base URL of Keycloak server
* - KEYCLOAK_REALM: Keycloak realm name
* - KEYCLOAK_CLIENT_ID: Client ID registered in Keycloak
* - JWT_ISSUER: Expected JWT issuer
* - JWT_AUDIENCE: Expected JWT audience
* - JWKS_URI: URI for fetching Keycloak's public keys
*/
@Module({
imports: [
// Import ConfigModule to access environment variables
ConfigModule,
// Import PassportModule for authentication strategies
PassportModule.register({ defaultStrategy: 'jwt' }),
// Import UsersModule to enable user synchronization (with forwardRef to avoid circular dependency)
forwardRef(() => UsersModule),
],
providers: [
// Register the JWT strategy for validating Keycloak tokens
JwtStrategy,
// Register the auth service for business logic
AuthService,
],
exports: [
// Export AuthService so other modules can use it
AuthService,
// Export PassportModule so guards can be used in other modules
PassportModule,
],
})
export class AuthModule {}

View File

@@ -0,0 +1,365 @@
import { Test, TestingModule } from '@nestjs/testing';
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';
describe('AuthService', () => {
let service: AuthService;
const mockFindByKeycloakSub = jest.fn();
const mockUpdateFromToken = jest.fn();
const mockCreateFromToken = jest.fn();
const mockUsersService = {
findByKeycloakSub: mockFindByKeycloakSub,
updateFromToken: mockUpdateFromToken,
createFromToken: mockCreateFromToken,
};
const mockAuthUser: AuthenticatedUser = {
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
};
const mockUser: User = {
id: 'uuid-123',
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
createdAt: new Date('2024-01-01'),
updatedAt: new Date('2024-01-01'),
lastLoginAt: new Date('2024-01-01'),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{
provide: UsersService,
useValue: mockUsersService,
},
],
}).compile();
service = module.get<AuthService>(AuthService);
// Reset mocks
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('syncUserFromToken', () => {
it('should create a new user if user does not exist', async () => {
mockFindByKeycloakSub.mockReturnValue(null);
mockCreateFromToken.mockReturnValue(mockUser);
const result = await service.syncUserFromToken(mockAuthUser);
expect(result).toEqual(mockUser);
expect(mockFindByKeycloakSub).toHaveBeenCalledWith('f:realm:user123');
expect(mockCreateFromToken).toHaveBeenCalledWith({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
});
expect(mockUpdateFromToken).not.toHaveBeenCalled();
});
it('should update existing user if user exists', async () => {
const updatedUser = { ...mockUser, lastLoginAt: new Date('2024-02-01') };
mockFindByKeycloakSub.mockReturnValue(mockUser);
mockUpdateFromToken.mockReturnValue(updatedUser);
const result = await service.syncUserFromToken(mockAuthUser);
expect(result).toEqual(updatedUser);
expect(mockFindByKeycloakSub).toHaveBeenCalledWith('f:realm:user123');
expect(mockUpdateFromToken).toHaveBeenCalledWith(
'f:realm:user123',
expect.objectContaining({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
lastLoginAt: expect.any(Date),
}),
);
expect(mockCreateFromToken).not.toHaveBeenCalled();
});
it('should handle user with no email by using empty string', async () => {
const authUserNoEmail: AuthenticatedUser = {
keycloakSub: 'f:realm:user456',
name: 'No Email User',
};
mockFindByKeycloakSub.mockReturnValue(null);
mockCreateFromToken.mockReturnValue({
...mockUser,
email: '',
name: 'No Email User',
});
await service.syncUserFromToken(authUserNoEmail);
expect(mockCreateFromToken).toHaveBeenCalledWith(
expect.objectContaining({
email: '',
name: 'No Email User',
}),
);
});
it('should handle user with no name by using username or fallback', async () => {
const authUserNoName: AuthenticatedUser = {
keycloakSub: 'f:realm:user789',
username: 'someusername',
};
mockFindByKeycloakSub.mockReturnValue(null);
mockCreateFromToken.mockReturnValue({
...mockUser,
name: 'someusername',
});
await service.syncUserFromToken(authUserNoName);
expect(mockCreateFromToken).toHaveBeenCalledWith(
expect.objectContaining({
name: 'someusername',
}),
);
});
it('should use "Unknown User" when no name or username is available', async () => {
const authUserMinimal: AuthenticatedUser = {
keycloakSub: 'f:realm:user000',
};
mockFindByKeycloakSub.mockReturnValue(null);
mockCreateFromToken.mockReturnValue({
...mockUser,
name: 'Unknown User',
});
await service.syncUserFromToken(authUserMinimal);
expect(mockCreateFromToken).toHaveBeenCalledWith(
expect.objectContaining({
name: 'Unknown User',
}),
);
});
it('should handle empty keycloakSub gracefully', async () => {
const authUserEmptySub: AuthenticatedUser = {
keycloakSub: '',
email: 'empty@example.com',
name: 'Empty Sub User',
};
mockFindByKeycloakSub.mockReturnValue(null);
mockCreateFromToken.mockReturnValue({
...mockUser,
keycloakSub: '',
email: 'empty@example.com',
name: 'Empty Sub User',
});
const result = await service.syncUserFromToken(authUserEmptySub);
expect(mockFindByKeycloakSub).toHaveBeenCalledWith('');
expect(mockCreateFromToken).toHaveBeenCalledWith(
expect.objectContaining({
keycloakSub: '',
email: 'empty@example.com',
name: 'Empty Sub User',
}),
);
});
it('should handle malformed keycloakSub', async () => {
const authUserMalformed: AuthenticatedUser = {
keycloakSub: 'invalid-format',
email: 'malformed@example.com',
name: 'Malformed User',
};
mockFindByKeycloakSub.mockReturnValue(null);
mockCreateFromToken.mockReturnValue({
...mockUser,
keycloakSub: 'invalid-format',
email: 'malformed@example.com',
name: 'Malformed User',
});
const result = await service.syncUserFromToken(authUserMalformed);
expect(mockFindByKeycloakSub).toHaveBeenCalledWith('invalid-format');
expect(mockCreateFromToken).toHaveBeenCalledWith(
expect.objectContaining({
keycloakSub: 'invalid-format',
email: 'malformed@example.com',
name: 'Malformed User',
}),
);
});
});
describe('hasRole', () => {
it('should return true if user has the required role', () => {
const result = service.hasRole(mockAuthUser, 'user');
expect(result).toBe(true);
});
it('should return false if user does not have the required role', () => {
const result = service.hasRole(mockAuthUser, 'admin');
expect(result).toBe(false);
});
it('should return false if user has no roles', () => {
const authUserNoRoles: AuthenticatedUser = {
keycloakSub: 'f:realm:noroles',
email: 'noroles@example.com',
name: 'No Roles User',
};
const result = service.hasRole(authUserNoRoles, 'user');
expect(result).toBe(false);
});
it('should return false if user roles is empty array', () => {
const authUserEmptyRoles: AuthenticatedUser = {
keycloakSub: 'f:realm:emptyroles',
email: 'empty@example.com',
name: 'Empty Roles User',
roles: [],
};
const result = service.hasRole(authUserEmptyRoles, 'user');
expect(result).toBe(false);
});
});
describe('hasAnyRole', () => {
it('should return true if user has at least one of the required roles', () => {
const result = service.hasAnyRole(mockAuthUser, ['admin', 'premium']);
expect(result).toBe(true);
});
it('should return false if user has none of the required roles', () => {
const result = service.hasAnyRole(mockAuthUser, ['admin', 'moderator']);
expect(result).toBe(false);
});
it('should return false if user has no roles', () => {
const authUserNoRoles: AuthenticatedUser = {
keycloakSub: 'f:realm:noroles',
email: 'noroles@example.com',
name: 'No Roles User',
};
const result = service.hasAnyRole(authUserNoRoles, ['admin', 'user']);
expect(result).toBe(false);
});
it('should return false if user roles is empty array', () => {
const authUserEmptyRoles: AuthenticatedUser = {
keycloakSub: 'f:realm:emptyroles',
email: 'empty@example.com',
name: 'Empty Roles User',
roles: [],
};
const result = service.hasAnyRole(authUserEmptyRoles, ['admin', 'user']);
expect(result).toBe(false);
});
it('should handle multiple matching roles', () => {
const result = service.hasAnyRole(mockAuthUser, ['user', 'premium']);
expect(result).toBe(true);
});
});
describe('hasAllRoles', () => {
it('should return true if user has all of the required roles', () => {
const result = service.hasAllRoles(mockAuthUser, ['user', 'premium']);
expect(result).toBe(true);
});
it('should return false if user has only some of the required roles', () => {
const result = service.hasAllRoles(mockAuthUser, [
'user',
'premium',
'admin',
]);
expect(result).toBe(false);
});
it('should return false if user has none of the required roles', () => {
const result = service.hasAllRoles(mockAuthUser, ['admin', 'moderator']);
expect(result).toBe(false);
});
it('should return false if user has no roles', () => {
const authUserNoRoles: AuthenticatedUser = {
keycloakSub: 'f:realm:noroles',
email: 'noroles@example.com',
name: 'No Roles User',
};
const result = service.hasAllRoles(authUserNoRoles, ['user']);
expect(result).toBe(false);
});
it('should return false if user roles is empty array', () => {
const authUserEmptyRoles: AuthenticatedUser = {
keycloakSub: 'f:realm:emptyroles',
email: 'empty@example.com',
name: 'Empty Roles User',
roles: [],
};
const result = service.hasAllRoles(authUserEmptyRoles, ['user']);
expect(result).toBe(false);
});
it('should return true for single role check', () => {
const result = service.hasAllRoles(mockAuthUser, ['user']);
expect(result).toBe(true);
});
});
});

98
src/auth/auth.service.ts Normal file
View File

@@ -0,0 +1,98 @@
import { Injectable, Logger } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import type { AuthenticatedUser } from './decorators/current-user.decorator';
import { User } from '../users/users.entity';
/**
* Authentication Service
*
* Handles authentication-related business logic including:
* - User synchronization from Keycloak tokens
* - Profile updates for authenticated users
*/
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(private readonly usersService: UsersService) {}
/**
* Synchronizes a user from Keycloak token to local database.
* Creates a new user if they don't exist, or updates their last login time.
*
* @param authenticatedUser - User data extracted from JWT token
* @returns The synchronized user entity
*/
async syncUserFromToken(authenticatedUser: AuthenticatedUser): Promise<User> {
const { keycloakSub, email, name, username, picture, roles } =
authenticatedUser;
// Try to find existing user by Keycloak subject
let user = this.usersService.findByKeycloakSub(keycloakSub);
if (user) {
// User exists - update last login and sync profile data
this.logger.debug(`Syncing existing user: ${keycloakSub}`);
user = this.usersService.updateFromToken(keycloakSub, {
email,
name,
username,
picture,
roles,
lastLoginAt: new Date(),
});
} else {
// New user - create from token data
this.logger.log(`Creating new user from token: ${keycloakSub}`);
user = this.usersService.createFromToken({
keycloakSub,
email: email || '',
name: name || username || 'Unknown User',
username,
picture,
roles,
});
}
return Promise.resolve(user);
}
/**
* 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;
}
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;
}
return requiredRoles.every((role) => user.roles!.includes(role));
}
}

View File

@@ -0,0 +1,51 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* Interface representing the authenticated user from JWT token.
* This matches the object returned by JwtStrategy.validate()
*/
export interface AuthenticatedUser {
keycloakSub: string;
email?: string;
name?: string;
username?: string;
picture?: string;
roles?: string[];
}
/**
* CurrentUser Decorator
*
* Extracts the authenticated user from the request object.
* Must be used in conjunction with JwtAuthGuard.
*
* @example
* ```typescript
* @Get('me')
* @UseGuards(JwtAuthGuard)
* async getCurrentUser(@CurrentUser() user: AuthenticatedUser) {
* return user;
* }
* ```
*
* @example
* Extract specific property:
* ```typescript
* @Get('profile')
* @UseGuards(JwtAuthGuard)
* async getProfile(@CurrentUser('keycloakSub') sub: string) {
* return { sub };
* }
* ```
*/
export const CurrentUser = createParamDecorator(
(data: keyof AuthenticatedUser | undefined, ctx: ExecutionContext) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const request = ctx.switchToHttp().getRequest();
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const user = request.user as AuthenticatedUser;
// If a specific property is requested, return only that property
return data ? user?.[data] : user;
},
);

View File

@@ -0,0 +1,78 @@
import { ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
/**
* JWT Authentication Guard
*
* This guard protects routes by requiring a valid JWT token from Keycloak.
* It uses the JwtStrategy to validate the token and attach user info to the request.
*
* Usage:
* @UseGuards(JwtAuthGuard)
* async protectedRoute(@Request() req) {
* const user = req.user; // Contains validated user info from JWT
* }
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
private readonly logger = new Logger(JwtAuthGuard.name);
/**
* Determines if the request can proceed.
* Automatically validates the JWT token using JwtStrategy.
*
* @param context - The execution context
* @returns Boolean indicating if the request is authorized
*/
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
// Log the authentication attempt
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const request = context.switchToHttp().getRequest();
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const authHeader = request.headers.authorization;
if (!authHeader) {
this.logger.warn('Authentication attempt without Authorization header');
}
return super.canActivate(context);
}
/**
* Handles errors during authentication.
* This method is called when the JWT validation fails.
*
* @param err - The error that occurred
*/
handleRequest(
err: any,
user: any,
info: any,
context: ExecutionContext,
status?: any,
): any {
if (err || !user) {
const infoMessage =
info && typeof info === 'object' && 'message' in info
? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
String(info.message)
: '';
const errMessage =
err && typeof err === 'object' && 'message' in err
? // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
String(err.message)
: '';
this.logger.warn(
`Authentication failed: ${infoMessage || errMessage || 'Unknown error'}`,
);
}
// Let passport handle the error (will throw UnauthorizedException)
return super.handleRequest(err, user, info, context, status);
}
}

View File

@@ -0,0 +1,127 @@
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { passportJwtSecret } from 'jwks-rsa';
/**
* JWT payload interface representing the decoded token from Keycloak
*/
export interface JwtPayload {
sub: string; // Subject (user identifier in Keycloak)
email?: string;
name?: string;
preferred_username?: string;
picture?: string;
realm_access?: {
roles: string[];
};
resource_access?: {
[key: string]: {
roles: string[];
};
};
iss: string; // Issuer
aud: string | string[]; // Audience
exp: number; // Expiration time
iat: number; // Issued at
}
/**
* JWT Strategy for validating Keycloak-issued JWT tokens.
* This strategy validates tokens against Keycloak's public keys (JWKS)
* and extracts user information from the token payload.
*/
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
private readonly logger = new Logger(JwtStrategy.name);
constructor(private configService: ConfigService) {
const jwksUri = configService.get<string>('JWKS_URI');
const issuer = configService.get<string>('JWT_ISSUER');
const audience = configService.get<string>('JWT_AUDIENCE');
if (!jwksUri) {
throw new Error('JWKS_URI must be configured in environment variables');
}
if (!issuer) {
throw new Error('JWT_ISSUER must be configured in environment variables');
}
super({
// Extract JWT from Authorization header as Bearer token
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
// Use JWKS to fetch and cache Keycloak's public keys for signature verification
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri,
}),
// Verify the issuer matches our Keycloak realm
issuer,
// Verify the audience matches our client ID
audience,
// Automatically reject expired tokens
ignoreExpiration: false,
// Use RS256 algorithm (Keycloak's default)
algorithms: ['RS256'],
});
this.logger.log(`JWT Strategy initialized with issuer: ${issuer}`);
}
/**
* Validates the JWT payload after signature verification.
* This method is called automatically by Passport after the token is verified.
*
* @param payload - The decoded JWT payload
* @returns The validated user object to be attached to the request
* @throws UnauthorizedException if the payload is invalid
*/
async validate(payload: JwtPayload): Promise<{
keycloakSub: string;
email?: string;
name?: string;
username?: string;
picture?: string;
roles?: string[];
}> {
if (!payload.sub) {
this.logger.warn('JWT token missing required "sub" claim');
throw new UnauthorizedException('Invalid token: missing subject');
}
// Extract roles from Keycloak's realm_access and resource_access
const roles: string[] = [];
if (payload.realm_access?.roles) {
roles.push(...payload.realm_access.roles);
}
const clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID');
if (clientId && payload.resource_access?.[clientId]?.roles) {
roles.push(...payload.resource_access[clientId].roles);
}
// Return user object that will be attached to request.user
const user = {
keycloakSub: payload.sub,
email: payload.email,
name: payload.name,
username: payload.preferred_username,
picture: payload.picture,
roles: roles.length > 0 ? roles : undefined,
};
this.logger.debug(`Validated token for user: ${payload.sub}`);
return Promise.resolve(user);
}
}