init user system with keycloak
This commit is contained in:
@@ -1,9 +1,50 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { UsersModule } from './users/users.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
|
||||
/**
|
||||
* Validates required environment variables.
|
||||
* Throws an error if any required variables are missing or invalid.
|
||||
* Returns the validated config.
|
||||
*/
|
||||
function validateEnvironment(config: Record<string, any>): Record<string, any> {
|
||||
const requiredVars = ['JWKS_URI', 'JWT_ISSUER', 'JWT_AUDIENCE'];
|
||||
|
||||
const missingVars = requiredVars.filter((varName) => !config[varName]);
|
||||
|
||||
if (missingVars.length > 0) {
|
||||
throw new Error(
|
||||
`Missing required environment variables: ${missingVars.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate PORT if provided
|
||||
if (config.PORT && isNaN(Number(config.PORT))) {
|
||||
throw new Error('PORT must be a valid number');
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root Application Module
|
||||
*
|
||||
* Imports and configures all feature modules and global configuration.
|
||||
*/
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [
|
||||
// Configure global environment variables with validation
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true, // Make ConfigService available throughout the app
|
||||
envFilePath: '.env', // Load from .env file
|
||||
validate: validateEnvironment, // Validate required environment variables
|
||||
}),
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
|
||||
51
src/auth/auth.module.ts
Normal file
51
src/auth/auth.module.ts
Normal 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 {}
|
||||
365
src/auth/auth.service.spec.ts
Normal file
365
src/auth/auth.service.spec.ts
Normal 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
98
src/auth/auth.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
51
src/auth/decorators/current-user.decorator.ts
Normal file
51
src/auth/decorators/current-user.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
78
src/auth/guards/jwt-auth.guard.ts
Normal file
78
src/auth/guards/jwt-auth.guard.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
127
src/auth/strategies/jwt.strategy.ts
Normal file
127
src/auth/strategies/jwt.strategy.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
82
src/common/filters/all-exceptions.filter.ts
Normal file
82
src/common/filters/all-exceptions.filter.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Global Exception Filter
|
||||
*
|
||||
* Catches all exceptions thrown within the application and formats
|
||||
* them into consistent HTTP responses. Provides proper error logging
|
||||
* while avoiding exposure of sensitive internal information.
|
||||
*/
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(AllExceptionsFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
let status: HttpStatus;
|
||||
let message: string;
|
||||
let error: string;
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
// Handle known HTTP exceptions (e.g., NotFoundException, ForbiddenException)
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
error = exception.name;
|
||||
} else if (
|
||||
typeof exceptionResponse === 'object' &&
|
||||
exceptionResponse !== null &&
|
||||
'message' in exceptionResponse &&
|
||||
'error' in exceptionResponse
|
||||
) {
|
||||
const responseObj = exceptionResponse as {
|
||||
message: string;
|
||||
error: string;
|
||||
};
|
||||
message = responseObj.message;
|
||||
error = responseObj.error;
|
||||
} else {
|
||||
message = exception.message;
|
||||
error = exception.name;
|
||||
}
|
||||
} else {
|
||||
// Handle unknown exceptions (programming errors, etc.)
|
||||
status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
message = 'Internal server error';
|
||||
error = 'InternalServerError';
|
||||
|
||||
// Log the actual error for debugging (don't expose to client)
|
||||
this.logger.error(
|
||||
`Unhandled exception: ${exception instanceof Error ? exception.message : String(exception)}`,
|
||||
exception instanceof Error ? exception.stack : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
// Log the response being sent
|
||||
this.logger.warn(
|
||||
`HTTP ${status} Error: ${message} - ${request.method} ${request.url}`,
|
||||
);
|
||||
|
||||
// Send consistent error response
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
message,
|
||||
error,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
4
src/config/config.module.ts
Normal file
4
src/config/config.module.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
export class ConfigModule {}
|
||||
57
src/main.ts
57
src/main.ts
@@ -1,9 +1,64 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('Bootstrap');
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
|
||||
// Enable global exception filter for consistent error responses
|
||||
app.useGlobalFilters(new AllExceptionsFilter());
|
||||
|
||||
// Enable global validation pipe for DTO validation
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
// Strip properties that are not in the DTO
|
||||
whitelist: true,
|
||||
// Throw error if non-whitelisted properties are present
|
||||
forbidNonWhitelisted: true,
|
||||
// Automatically transform payloads to DTO instances
|
||||
transform: true,
|
||||
// Provide detailed error messages
|
||||
disableErrorMessages: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// Configure Swagger documentation
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Friendolls API')
|
||||
.setDescription(
|
||||
'API for managing users in Friendolls application.\n\n' +
|
||||
'Authentication is handled via Keycloak OpenID Connect.\n' +
|
||||
'Users must authenticate via Keycloak to obtain a JWT token.\n\n' +
|
||||
'Include the JWT token in the Authorization header as: `Bearer <token>`',
|
||||
)
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
name: 'Authorization',
|
||||
description: 'Enter JWT token obtained from Keycloak',
|
||||
in: 'header',
|
||||
},
|
||||
'bearer',
|
||||
)
|
||||
.addTag('users', 'User profile management endpoints')
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api', app, document);
|
||||
|
||||
const port = process.env.PORT ?? 3000;
|
||||
await app.listen(port);
|
||||
|
||||
logger.log(`Application is running on: http://localhost:${port}`);
|
||||
logger.log(
|
||||
`Swagger documentation available at: http://localhost:${port}/api`,
|
||||
);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
25
src/users/dto/update-user.dto.ts
Normal file
25
src/users/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MinLength, MaxLength } from 'class-validator';
|
||||
|
||||
/**
|
||||
* DTO for updating user profile.
|
||||
* Only allows updating safe, user-controlled fields.
|
||||
* Security-sensitive fields (keycloakSub, roles, email, etc.) are managed by Keycloak.
|
||||
*/
|
||||
export class UpdateUserDto {
|
||||
/**
|
||||
* User's display name
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: "User's display name",
|
||||
example: 'John Doe',
|
||||
required: false,
|
||||
minLength: 1,
|
||||
maxLength: 100,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1, { message: 'Name must not be empty' })
|
||||
@MaxLength(100, { message: 'Name must not exceed 100 characters' })
|
||||
name?: string;
|
||||
}
|
||||
234
src/users/users.controller.spec.ts
Normal file
234
src/users/users.controller.spec.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { User } from './users.entity';
|
||||
import type { UpdateUserDto } from './dto/update-user.dto';
|
||||
import type { AuthenticatedUser } from '../auth/decorators/current-user.decorator';
|
||||
|
||||
describe('UsersController', () => {
|
||||
let controller: UsersController;
|
||||
|
||||
const mockFindOne = jest.fn();
|
||||
const mockUpdate = jest.fn();
|
||||
const mockDelete = jest.fn();
|
||||
const mockFindByKeycloakSub = jest.fn();
|
||||
|
||||
const mockSyncUserFromToken = jest.fn();
|
||||
|
||||
const mockUsersService = {
|
||||
findOne: mockFindOne,
|
||||
update: mockUpdate,
|
||||
delete: mockDelete,
|
||||
findByKeycloakSub: mockFindByKeycloakSub,
|
||||
};
|
||||
|
||||
const mockAuthService = {
|
||||
syncUserFromToken: mockSyncUserFromToken,
|
||||
};
|
||||
|
||||
const mockAuthUser: AuthenticatedUser = {
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
roles: ['user'],
|
||||
};
|
||||
|
||||
const mockUser: User = {
|
||||
id: 'uuid-123',
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
roles: ['user'],
|
||||
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({
|
||||
controllers: [UsersController],
|
||||
providers: [
|
||||
{
|
||||
provide: UsersService,
|
||||
useValue: mockUsersService,
|
||||
},
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: mockAuthService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<UsersController>(UsersController);
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getCurrentUser', () => {
|
||||
it('should return current user profile and sync from token', async () => {
|
||||
mockSyncUserFromToken.mockResolvedValue(mockUser);
|
||||
|
||||
const result = await controller.getCurrentUser(mockAuthUser);
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCurrentUser', () => {
|
||||
it('should update current user profile', async () => {
|
||||
const updateDto: UpdateUserDto = { name: 'Updated Name' };
|
||||
const updatedUser: User = { ...mockUser, name: 'Updated Name' };
|
||||
|
||||
mockSyncUserFromToken.mockResolvedValue(mockUser);
|
||||
mockUpdate.mockResolvedValue(updatedUser);
|
||||
|
||||
const result = await controller.updateCurrentUser(
|
||||
mockAuthUser,
|
||||
updateDto,
|
||||
);
|
||||
|
||||
expect(result).toEqual(updatedUser);
|
||||
expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
updateDto,
|
||||
mockAuthUser.keycloakSub,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a user by id', async () => {
|
||||
mockFindOne.mockReturnValue(mockUser);
|
||||
|
||||
const result = await controller.findOne('uuid-123', mockAuthUser);
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
expect(mockFindOne).toHaveBeenCalledWith('uuid-123');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if user not found', async () => {
|
||||
mockFindOne.mockImplementation(() => {
|
||||
throw new NotFoundException('User with ID non-existent not found');
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.findOne('non-existent', mockAuthUser),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if id is empty', async () => {
|
||||
mockFindOne.mockImplementation(() => {
|
||||
throw new NotFoundException('User with ID not found');
|
||||
});
|
||||
|
||||
await expect(controller.findOne('', mockAuthUser)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update a user by id', async () => {
|
||||
const updateDto: UpdateUserDto = { name: 'Updated Name' };
|
||||
const updatedUser: User = { ...mockUser, name: 'Updated Name' };
|
||||
|
||||
mockUpdate.mockReturnValue(updatedUser);
|
||||
|
||||
const result = await controller.update(
|
||||
'uuid-123',
|
||||
updateDto,
|
||||
mockAuthUser,
|
||||
);
|
||||
|
||||
expect(result).toEqual(updatedUser);
|
||||
expect(mockUpdate).toHaveBeenCalledWith(
|
||||
'uuid-123',
|
||||
updateDto,
|
||||
mockAuthUser.keycloakSub,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when trying to update another user', async () => {
|
||||
const updateDto: UpdateUserDto = { name: 'Updated Name' };
|
||||
|
||||
mockUpdate.mockImplementation(() => {
|
||||
throw new ForbiddenException('You can only update your own profile');
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.update('different-uuid', updateDto, mockAuthUser),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if user not found', async () => {
|
||||
const updateDto: UpdateUserDto = { name: 'Updated' };
|
||||
|
||||
mockUpdate.mockImplementation(() => {
|
||||
throw new NotFoundException('User with ID non-existent not found');
|
||||
});
|
||||
|
||||
await expect(
|
||||
controller.update('non-existent', updateDto, mockAuthUser),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCurrentUser', () => {
|
||||
it('should delete current user account', async () => {
|
||||
mockSyncUserFromToken.mockResolvedValue(mockUser);
|
||||
mockDelete.mockReturnValue(undefined);
|
||||
|
||||
await controller.deleteCurrentUser(mockAuthUser);
|
||||
|
||||
expect(mockSyncUserFromToken).toHaveBeenCalledWith(mockAuthUser);
|
||||
expect(mockDelete).toHaveBeenCalledWith(
|
||||
mockUser.id,
|
||||
mockAuthUser.keycloakSub,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a user by id', () => {
|
||||
mockDelete.mockReturnValue(undefined);
|
||||
|
||||
controller.delete('uuid-123', mockAuthUser);
|
||||
|
||||
expect(mockDelete).toHaveBeenCalledWith(
|
||||
'uuid-123',
|
||||
mockAuthUser.keycloakSub,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when trying to delete another user', () => {
|
||||
mockDelete.mockImplementation(() => {
|
||||
throw new ForbiddenException('You can only delete your own account');
|
||||
});
|
||||
|
||||
expect(() => controller.delete('different-uuid', mockAuthUser)).toThrow(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if user not found', () => {
|
||||
mockDelete.mockImplementation(() => {
|
||||
throw new NotFoundException('User with ID non-existent not found');
|
||||
});
|
||||
|
||||
expect(() => controller.delete('non-existent', mockAuthUser)).toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
267
src/users/users.controller.ts
Normal file
267
src/users/users.controller.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
HttpCode,
|
||||
UseGuards,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiResponse,
|
||||
ApiParam,
|
||||
ApiBearerAuth,
|
||||
ApiUnauthorizedResponse,
|
||||
ApiForbiddenResponse,
|
||||
} from '@nestjs/swagger';
|
||||
import { UsersService } from './users.service';
|
||||
import { User } from './users.entity';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import {
|
||||
CurrentUser,
|
||||
type AuthenticatedUser,
|
||||
} from '../auth/decorators/current-user.decorator';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
/**
|
||||
* Users Controller
|
||||
*
|
||||
* Handles user-related HTTP endpoints.
|
||||
* All endpoints require authentication via Keycloak JWT token.
|
||||
*
|
||||
* Note: User creation is handled automatically during authentication flow.
|
||||
* Users cannot be created directly via API - they must authenticate via Keycloak.
|
||||
*/
|
||||
@ApiTags('users')
|
||||
@Controller('users')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class UsersController {
|
||||
private readonly logger = new Logger(UsersController.name);
|
||||
|
||||
constructor(
|
||||
private readonly usersService: UsersService,
|
||||
private readonly authService: AuthService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get current authenticated user's profile.
|
||||
* This endpoint syncs the user from Keycloak token on each request.
|
||||
*/
|
||||
@Get('me')
|
||||
@ApiOperation({
|
||||
summary: 'Get current user profile',
|
||||
description:
|
||||
'Returns the authenticated user profile. Automatically syncs data from Keycloak token.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Current user profile',
|
||||
type: User,
|
||||
})
|
||||
@ApiUnauthorizedResponse({
|
||||
description: 'Invalid or missing JWT token',
|
||||
})
|
||||
async getCurrentUser(
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<User> {
|
||||
this.logger.debug(`Get current user: ${authUser.keycloakSub}`);
|
||||
|
||||
// Sync user from token (creates if doesn't exist, updates if exists)
|
||||
const user = await this.authService.syncUserFromToken(authUser);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current authenticated user's profile.
|
||||
*/
|
||||
@Put('me')
|
||||
@ApiOperation({
|
||||
summary: 'Update current user profile',
|
||||
description:
|
||||
'Updates the authenticated user profile. Users can only update their own profile.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'User profile updated successfully',
|
||||
type: User,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: 'Invalid request data',
|
||||
})
|
||||
@ApiUnauthorizedResponse({
|
||||
description: 'Invalid or missing JWT token',
|
||||
})
|
||||
async updateCurrentUser(
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
): Promise<User> {
|
||||
this.logger.log(`Update current user: ${authUser.keycloakSub}`);
|
||||
|
||||
// First ensure user exists in our system
|
||||
const user = await this.authService.syncUserFromToken(authUser);
|
||||
|
||||
// Update the user's profile
|
||||
return Promise.resolve(
|
||||
this.usersService.update(user.id, updateUserDto, authUser.keycloakSub),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a user by their ID.
|
||||
* Currently allows any authenticated user to view other users.
|
||||
* Consider adding additional authorization if needed.
|
||||
*/
|
||||
@Get(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Get a user by ID',
|
||||
description: 'Retrieves a user profile by their internal ID.',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
description: 'User internal UUID',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'User found',
|
||||
type: User,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: 'User not found',
|
||||
})
|
||||
@ApiUnauthorizedResponse({
|
||||
description: 'Invalid or missing JWT token',
|
||||
})
|
||||
async findOne(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<User> {
|
||||
this.logger.debug(
|
||||
`Get user by ID: ${id} (requested by ${authUser.keycloakSub})`,
|
||||
);
|
||||
return Promise.resolve(this.usersService.findOne(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a user by their ID.
|
||||
* Users can only update their own profile (enforced by service layer).
|
||||
*/
|
||||
@Put(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Update a user by ID',
|
||||
description:
|
||||
'Updates a user profile. Users can only update their own profile.',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
description: 'User internal UUID',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'User updated successfully',
|
||||
type: User,
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: 'Invalid request data',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: 'User not found',
|
||||
})
|
||||
@ApiForbiddenResponse({
|
||||
description: 'Cannot update another user profile',
|
||||
})
|
||||
@ApiUnauthorizedResponse({
|
||||
description: 'Invalid or missing JWT token',
|
||||
})
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() updateUserDto: UpdateUserDto,
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<User> {
|
||||
this.logger.log(`Update user ${id} (requested by ${authUser.keycloakSub})`);
|
||||
return Promise.resolve(
|
||||
this.usersService.update(id, updateUserDto, authUser.keycloakSub),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete current authenticated user's account.
|
||||
* Note: This only deletes the local user record.
|
||||
* The user still exists in Keycloak and can re-authenticate.
|
||||
*/
|
||||
@Delete('me')
|
||||
@ApiOperation({
|
||||
summary: 'Delete current user account',
|
||||
description:
|
||||
'Deletes the authenticated user account. Only removes local data; user still exists in Keycloak.',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 204,
|
||||
description: 'User account deleted successfully',
|
||||
})
|
||||
@ApiUnauthorizedResponse({
|
||||
description: 'Invalid or missing JWT token',
|
||||
})
|
||||
@HttpCode(204)
|
||||
async deleteCurrentUser(
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): Promise<void> {
|
||||
this.logger.log(`Delete current user: ${authUser.keycloakSub}`);
|
||||
|
||||
// First ensure user exists in our system
|
||||
const user = await this.authService.syncUserFromToken(authUser);
|
||||
|
||||
// Delete the user's account
|
||||
this.usersService.delete(user.id, authUser.keycloakSub);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user by their ID.
|
||||
* Users can only delete their own account (enforced by service layer).
|
||||
*/
|
||||
@Delete(':id')
|
||||
@ApiOperation({
|
||||
summary: 'Delete a user by ID',
|
||||
description:
|
||||
'Deletes a user account. Users can only delete their own account. Only removes local data; user still exists in Keycloak.',
|
||||
})
|
||||
@ApiParam({
|
||||
name: 'id',
|
||||
description: 'User internal UUID',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 204,
|
||||
description: 'User deleted successfully',
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 404,
|
||||
description: 'User not found',
|
||||
})
|
||||
@ApiForbiddenResponse({
|
||||
description: 'Cannot delete another user account',
|
||||
})
|
||||
@ApiUnauthorizedResponse({
|
||||
description: 'Invalid or missing JWT token',
|
||||
})
|
||||
@HttpCode(204)
|
||||
delete(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser() authUser: AuthenticatedUser,
|
||||
): void {
|
||||
this.logger.log(`Delete user ${id} (requested by ${authUser.keycloakSub})`);
|
||||
this.usersService.delete(id, authUser.keycloakSub);
|
||||
}
|
||||
}
|
||||
102
src/users/users.entity.ts
Normal file
102
src/users/users.entity.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
/**
|
||||
* User entity representing a user in the system.
|
||||
* Users are synced from Keycloak via OIDC authentication.
|
||||
*/
|
||||
export class User {
|
||||
/**
|
||||
* Internal unique identifier (UUID)
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: 'Internal unique identifier',
|
||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
||||
})
|
||||
id: string;
|
||||
|
||||
/**
|
||||
* Keycloak subject identifier (unique per user in Keycloak)
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: 'Keycloak subject identifier from the JWT token',
|
||||
example: 'f:a1b2c3d4-e5f6-7890-abcd-ef1234567890:johndoe',
|
||||
})
|
||||
keycloakSub: string;
|
||||
|
||||
/**
|
||||
* User's display name
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: "User's display name",
|
||||
example: 'John Doe',
|
||||
})
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* User's email address
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: "User's email address",
|
||||
example: 'john.doe@example.com',
|
||||
})
|
||||
email: string;
|
||||
|
||||
/**
|
||||
* User's preferred username from Keycloak
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: "User's preferred username from Keycloak",
|
||||
example: 'johndoe',
|
||||
required: false,
|
||||
})
|
||||
username?: string;
|
||||
|
||||
/**
|
||||
* URL to user's profile picture
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: "URL to user's profile picture",
|
||||
example: 'https://example.com/avatars/johndoe.jpg',
|
||||
required: false,
|
||||
})
|
||||
picture?: string;
|
||||
|
||||
/**
|
||||
* User's roles from Keycloak
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: "User's roles from Keycloak",
|
||||
example: ['user', 'premium'],
|
||||
type: [String],
|
||||
required: false,
|
||||
})
|
||||
roles?: string[];
|
||||
|
||||
/**
|
||||
* Timestamp when the user was first created in the system
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: 'Timestamp when the user was first created',
|
||||
example: '2024-01-15T10:30:00.000Z',
|
||||
})
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* Timestamp when the user profile was last updated
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: 'Timestamp when the user was last updated',
|
||||
example: '2024-01-20T14:45:00.000Z',
|
||||
})
|
||||
updatedAt: Date;
|
||||
|
||||
/**
|
||||
* Timestamp of last login
|
||||
*/
|
||||
@ApiProperty({
|
||||
description: 'Timestamp of last login',
|
||||
example: '2024-01-20T14:45:00.000Z',
|
||||
required: false,
|
||||
})
|
||||
lastLoginAt?: Date;
|
||||
}
|
||||
21
src/users/users.module.ts
Normal file
21
src/users/users.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
/**
|
||||
* Users Module
|
||||
*
|
||||
* Manages user-related functionality including user profile management
|
||||
* and synchronization with Keycloak OIDC.
|
||||
*
|
||||
* The module exports UsersService to allow other modules (like AuthModule)
|
||||
* to access user data and perform synchronization.
|
||||
*/
|
||||
@Module({
|
||||
imports: [forwardRef(() => AuthModule)],
|
||||
providers: [UsersService],
|
||||
controllers: [UsersController],
|
||||
exports: [UsersService], // Export so AuthModule can use it
|
||||
})
|
||||
export class UsersModule {}
|
||||
287
src/users/users.service.spec.ts
Normal file
287
src/users/users.service.spec.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import type { UpdateUserDto } from './dto/update-user.dto';
|
||||
|
||||
describe('UsersService', () => {
|
||||
let service: UsersService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [UsersService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<UsersService>(UsersService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('createFromToken', () => {
|
||||
it('should create a new user from Keycloak token data', () => {
|
||||
const tokenData = {
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'john@example.com',
|
||||
name: 'John Doe',
|
||||
username: 'johndoe',
|
||||
picture: 'https://example.com/avatar.jpg',
|
||||
roles: ['user', 'premium'],
|
||||
};
|
||||
|
||||
const user = service.createFromToken(tokenData);
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user.id).toBeDefined();
|
||||
expect(typeof user.id).toBe('string');
|
||||
expect(user.keycloakSub).toBe('f:realm:user123');
|
||||
expect(user.email).toBe('john@example.com');
|
||||
expect(user.name).toBe('John Doe');
|
||||
expect(user.username).toBe('johndoe');
|
||||
expect(user.picture).toBe('https://example.com/avatar.jpg');
|
||||
expect(user.roles).toEqual(['user', 'premium']);
|
||||
expect(user.createdAt).toBeInstanceOf(Date);
|
||||
expect(user.updatedAt).toBeInstanceOf(Date);
|
||||
expect(user.lastLoginAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should return existing user if keycloakSub already exists', () => {
|
||||
const tokenData = {
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'john@example.com',
|
||||
name: 'John Doe',
|
||||
};
|
||||
|
||||
const user1 = service.createFromToken(tokenData);
|
||||
const user2 = service.createFromToken(tokenData);
|
||||
|
||||
expect(user1.id).toBe(user2.id);
|
||||
expect(user1.keycloakSub).toBe(user2.keycloakSub);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByKeycloakSub', () => {
|
||||
it('should return the user if found by keycloakSub', () => {
|
||||
const createdUser = service.createFromToken({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
});
|
||||
|
||||
const user = service.findByKeycloakSub('f:realm:user123');
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user?.id).toBe(createdUser.id);
|
||||
expect(user?.keycloakSub).toBe('f:realm:user123');
|
||||
});
|
||||
|
||||
it('should return null if user not found by keycloakSub', () => {
|
||||
const user = service.findByKeycloakSub('non-existent-sub');
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return the user if found by ID', () => {
|
||||
const createdUser = service.createFromToken({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
});
|
||||
|
||||
const user = service.findOne(createdUser.id);
|
||||
|
||||
expect(user).toEqual(createdUser);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if user not found by ID', () => {
|
||||
expect(() => service.findOne('non-existent-id')).toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
expect(() => service.findOne('non-existent-id')).toThrow(
|
||||
'User with ID non-existent-id not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateFromToken', () => {
|
||||
it('should update user data from token and set lastLoginAt', async () => {
|
||||
const createdUser = service.createFromToken({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'old@example.com',
|
||||
name: 'Old Name',
|
||||
});
|
||||
|
||||
const originalUpdatedAt = createdUser.updatedAt;
|
||||
|
||||
// Wait a bit to ensure timestamp difference
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const updatedUser = service.updateFromToken('f:realm:user123', {
|
||||
email: 'new@example.com',
|
||||
name: 'New Name',
|
||||
username: 'newusername',
|
||||
roles: ['admin'],
|
||||
lastLoginAt: new Date(),
|
||||
});
|
||||
|
||||
expect(updatedUser.id).toBe(createdUser.id);
|
||||
expect(updatedUser.email).toBe('new@example.com');
|
||||
expect(updatedUser.name).toBe('New Name');
|
||||
expect(updatedUser.username).toBe('newusername');
|
||||
expect(updatedUser.roles).toEqual(['admin']);
|
||||
expect(updatedUser.lastLoginAt).toBeDefined();
|
||||
expect(updatedUser.updatedAt.getTime()).toBeGreaterThan(
|
||||
originalUpdatedAt.getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if user not found', () => {
|
||||
expect(() =>
|
||||
service.updateFromToken('non-existent-sub', {
|
||||
email: 'test@example.com',
|
||||
}),
|
||||
).toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should only update provided fields', () => {
|
||||
const createdUser = service.createFromToken({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'original@example.com',
|
||||
name: 'Original Name',
|
||||
username: 'original',
|
||||
});
|
||||
|
||||
const updatedUser = service.updateFromToken('f:realm:user123', {
|
||||
email: 'updated@example.com',
|
||||
// name and username not provided
|
||||
});
|
||||
|
||||
expect(updatedUser.email).toBe('updated@example.com');
|
||||
expect(updatedUser.name).toBe('Original Name'); // unchanged
|
||||
expect(updatedUser.username).toBe('original'); // unchanged
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update user profile when user updates their own profile', () => {
|
||||
const createdUser = service.createFromToken({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Old Name',
|
||||
});
|
||||
|
||||
const updateUserDto: UpdateUserDto = {
|
||||
name: 'New Name',
|
||||
};
|
||||
|
||||
const updatedUser = service.update(
|
||||
createdUser.id,
|
||||
updateUserDto,
|
||||
'f:realm:user123',
|
||||
);
|
||||
|
||||
expect(updatedUser.id).toBe(createdUser.id);
|
||||
expect(updatedUser.name).toBe('New Name');
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when user tries to update another user', () => {
|
||||
const createdUser = service.createFromToken({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
});
|
||||
|
||||
const updateUserDto: UpdateUserDto = { name: 'New Name' };
|
||||
|
||||
expect(() =>
|
||||
service.update(createdUser.id, updateUserDto, 'f:realm:differentuser'),
|
||||
).toThrow(ForbiddenException);
|
||||
|
||||
expect(() =>
|
||||
service.update(createdUser.id, updateUserDto, 'f:realm:differentuser'),
|
||||
).toThrow('You can only update your own profile');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if user not found', () => {
|
||||
const updateUserDto: UpdateUserDto = { name: 'New Name' };
|
||||
|
||||
expect(() =>
|
||||
service.update('non-existent-id', updateUserDto, 'f:realm:user123'),
|
||||
).toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should update user profile with empty name', () => {
|
||||
const createdUser = service.createFromToken({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Old Name',
|
||||
});
|
||||
|
||||
const updateUserDto: UpdateUserDto = { name: '' };
|
||||
|
||||
const updatedUser = service.update(
|
||||
createdUser.id,
|
||||
updateUserDto,
|
||||
'f:realm:user123',
|
||||
);
|
||||
|
||||
expect(updatedUser.name).toBe('');
|
||||
});
|
||||
|
||||
it('should update user profile with very long name', () => {
|
||||
const createdUser = service.createFromToken({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Old Name',
|
||||
});
|
||||
|
||||
const longName = 'A'.repeat(200); // Exceeds typical limits
|
||||
const updateUserDto: UpdateUserDto = { name: longName };
|
||||
|
||||
const updatedUser = service.update(
|
||||
createdUser.id,
|
||||
updateUserDto,
|
||||
'f:realm:user123',
|
||||
);
|
||||
|
||||
expect(updatedUser.name).toBe(longName);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete user when user deletes their own account', () => {
|
||||
const createdUser = service.createFromToken({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'delete@example.com',
|
||||
name: 'To Delete',
|
||||
});
|
||||
|
||||
service.delete(createdUser.id, 'f:realm:user123');
|
||||
|
||||
expect(() => service.findOne(createdUser.id)).toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException when user tries to delete another user', () => {
|
||||
const createdUser = service.createFromToken({
|
||||
keycloakSub: 'f:realm:user123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
service.delete(createdUser.id, 'f:realm:differentuser'),
|
||||
).toThrow(ForbiddenException);
|
||||
|
||||
expect(() =>
|
||||
service.delete(createdUser.id, 'f:realm:differentuser'),
|
||||
).toThrow('You can only delete your own account');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if user not found', () => {
|
||||
expect(() =>
|
||||
service.delete('non-existent-id', 'f:realm:user123'),
|
||||
).toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
218
src/users/users.service.ts
Normal file
218
src/users/users.service.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { User } from './users.entity';
|
||||
import type { UpdateUserDto } from './dto/update-user.dto';
|
||||
|
||||
/**
|
||||
* Interface for creating a user from Keycloak token
|
||||
*/
|
||||
export interface CreateUserFromTokenDto {
|
||||
keycloakSub: string;
|
||||
email: string;
|
||||
name: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for updating a user from Keycloak token
|
||||
*/
|
||||
export interface UpdateUserFromTokenDto {
|
||||
email?: string;
|
||||
name?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
roles?: string[];
|
||||
lastLoginAt?: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Users Service
|
||||
*
|
||||
* Manages user data synchronized from Keycloak OIDC.
|
||||
* Users are created automatically when they first authenticate via Keycloak.
|
||||
* Direct user creation is not allowed - users must authenticate via Keycloak first.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
private readonly logger = new Logger(UsersService.name);
|
||||
private users: User[] = [];
|
||||
|
||||
/**
|
||||
* Creates a new user from Keycloak token data.
|
||||
* This method is called automatically during authentication flow.
|
||||
*
|
||||
* @param createDto - User data extracted from Keycloak JWT token
|
||||
* @returns The newly created user
|
||||
*/
|
||||
createFromToken(createDto: CreateUserFromTokenDto): User {
|
||||
const existingUser = this.users.find(
|
||||
(u) => u.keycloakSub === createDto.keycloakSub,
|
||||
);
|
||||
|
||||
if (existingUser) {
|
||||
this.logger.warn(
|
||||
`Attempted to create duplicate user with keycloakSub: ${createDto.keycloakSub}`,
|
||||
);
|
||||
return existingUser;
|
||||
}
|
||||
|
||||
const newUser = new User();
|
||||
newUser.id = randomUUID();
|
||||
newUser.keycloakSub = createDto.keycloakSub;
|
||||
newUser.email = createDto.email;
|
||||
newUser.name = createDto.name;
|
||||
newUser.username = createDto.username;
|
||||
newUser.picture = createDto.picture;
|
||||
newUser.roles = createDto.roles;
|
||||
newUser.createdAt = new Date();
|
||||
newUser.updatedAt = new Date();
|
||||
newUser.lastLoginAt = new Date();
|
||||
|
||||
this.users.push(newUser);
|
||||
|
||||
this.logger.log(`Created new user: ${newUser.id} (${newUser.keycloakSub})`);
|
||||
|
||||
return newUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a user by their Keycloak subject identifier.
|
||||
*
|
||||
* @param keycloakSub - The Keycloak subject (sub claim from JWT)
|
||||
* @returns The user if found, null otherwise
|
||||
*/
|
||||
findByKeycloakSub(keycloakSub: string): User | null {
|
||||
const user = this.users.find((u) => u.keycloakSub === keycloakSub);
|
||||
return user || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a user by their internal ID.
|
||||
*
|
||||
* @param id - The user's internal UUID
|
||||
* @returns The user entity
|
||||
* @throws NotFoundException if the user is not found
|
||||
*/
|
||||
findOne(id: string): User {
|
||||
const user = this.users.find((u) => u.id === id);
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with ID ${id} not found`);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a user's profile from Keycloak token data.
|
||||
* This syncs the user's data from Keycloak during authentication.
|
||||
*
|
||||
* @param keycloakSub - The Keycloak subject identifier
|
||||
* @param updateDto - Updated user data from token
|
||||
* @returns The updated user
|
||||
* @throws NotFoundException if the user is not found
|
||||
*/
|
||||
updateFromToken(
|
||||
keycloakSub: string,
|
||||
updateDto: UpdateUserFromTokenDto,
|
||||
): User {
|
||||
const user = this.findByKeycloakSub(keycloakSub);
|
||||
if (!user) {
|
||||
throw new NotFoundException(
|
||||
`User with keycloakSub ${keycloakSub} not found`,
|
||||
);
|
||||
}
|
||||
|
||||
// Update user properties from token
|
||||
if (updateDto.email !== undefined) user.email = updateDto.email;
|
||||
if (updateDto.name !== undefined) user.name = updateDto.name;
|
||||
if (updateDto.username !== undefined) user.username = updateDto.username;
|
||||
if (updateDto.picture !== undefined) user.picture = updateDto.picture;
|
||||
if (updateDto.roles !== undefined) user.roles = updateDto.roles;
|
||||
if (updateDto.lastLoginAt !== undefined)
|
||||
user.lastLoginAt = updateDto.lastLoginAt;
|
||||
|
||||
user.updatedAt = new Date();
|
||||
|
||||
this.logger.debug(`Synced user from token: ${user.id} (${keycloakSub})`);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a user's profile.
|
||||
* Users can only update their own profile (enforced by controller).
|
||||
*
|
||||
* @param id - The user's internal ID
|
||||
* @param updateUserDto - The fields to update
|
||||
* @param requestingUserKeycloakSub - The Keycloak sub of the requesting user
|
||||
* @returns The updated user
|
||||
* @throws NotFoundException if the user is not found
|
||||
* @throws ForbiddenException if the user tries to update someone else's profile
|
||||
*/
|
||||
update(
|
||||
id: string,
|
||||
updateUserDto: UpdateUserDto,
|
||||
requestingUserKeycloakSub: string,
|
||||
): User {
|
||||
const user = this.findOne(id);
|
||||
|
||||
// Verify the user is updating their own profile
|
||||
if (user.keycloakSub !== requestingUserKeycloakSub) {
|
||||
this.logger.warn(
|
||||
`User ${requestingUserKeycloakSub} attempted to update user ${id}`,
|
||||
);
|
||||
throw new ForbiddenException('You can only update your own profile');
|
||||
}
|
||||
|
||||
// Only allow updating specific fields via the public API
|
||||
// Security-sensitive fields (keycloakSub, roles, etc.) cannot be updated
|
||||
if (updateUserDto.name !== undefined) {
|
||||
user.name = updateUserDto.name;
|
||||
}
|
||||
|
||||
user.updatedAt = new Date();
|
||||
|
||||
this.logger.log(`User ${id} updated their profile`);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a user from the system.
|
||||
* Note: This only removes the local user record.
|
||||
* The user still exists in Keycloak and can re-authenticate.
|
||||
*
|
||||
* @param id - The user's internal ID
|
||||
* @param requestingUserKeycloakSub - The Keycloak sub of the requesting user
|
||||
* @throws NotFoundException if the user is not found
|
||||
* @throws ForbiddenException if the user tries to delete someone else's account
|
||||
*/
|
||||
delete(id: string, requestingUserKeycloakSub: string): void {
|
||||
const user = this.findOne(id);
|
||||
|
||||
// Verify the user is deleting their own account
|
||||
if (user.keycloakSub !== requestingUserKeycloakSub) {
|
||||
this.logger.warn(
|
||||
`User ${requestingUserKeycloakSub} attempted to delete user ${id}`,
|
||||
);
|
||||
throw new ForbiddenException('You can only delete your own account');
|
||||
}
|
||||
|
||||
const index = this.users.findIndex((u) => u.id === id);
|
||||
if (index === -1) {
|
||||
throw new NotFoundException(`User with ID ${id} not found`);
|
||||
}
|
||||
|
||||
this.users.splice(index, 1);
|
||||
|
||||
this.logger.log(
|
||||
`User ${id} deleted their account (Keycloak: ${requestingUserKeycloakSub})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user