native auth

This commit is contained in:
2026-02-11 01:09:08 +08:00
parent 7191035748
commit 94dae77ddd
34 changed files with 650 additions and 1801 deletions

103
src/auth/auth.controller.ts Normal file
View File

@@ -0,0 +1,103 @@
import {
Body,
Controller,
HttpCode,
Post,
UseGuards,
Logger,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
ApiUnauthorizedResponse,
ApiBadRequestResponse,
} from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginRequestDto } from './dto/login-request.dto';
import { RegisterRequestDto } from './dto/register-request.dto';
import { LoginResponseDto } from './dto/login-response.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import {
CurrentUser,
type AuthenticatedUser,
} from './decorators/current-user.decorator';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name);
constructor(private readonly authService: AuthService) {}
@Post('register')
@ApiOperation({ summary: 'Register a new user' })
@ApiResponse({ status: 201, description: 'User registered' })
@ApiBadRequestResponse({ description: 'Invalid registration data' })
async register(@Body() body: RegisterRequestDto) {
const user = await this.authService.register(body);
this.logger.log(`Registered user: ${user.id}`);
return { id: user.id };
}
@Post('login')
@HttpCode(200)
@ApiOperation({ summary: 'Login with email and password' })
@ApiResponse({ status: 200, type: LoginResponseDto })
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
async login(@Body() body: LoginRequestDto): Promise<LoginResponseDto> {
return this.authService.login(body.email, body.password);
}
@Post('change-password')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(204)
@ApiOperation({ summary: 'Change current user password' })
@ApiResponse({ status: 204, description: 'Password updated' })
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
async changePassword(
@CurrentUser() user: AuthenticatedUser,
@Body() body: ChangePasswordDto,
): Promise<void> {
await this.authService.changePassword(
user.userId,
body.currentPassword,
body.newPassword,
);
}
@Post('reset-password')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(204)
@ApiOperation({ summary: 'Reset password with old password' })
@ApiResponse({ status: 204, description: 'Password updated' })
@ApiUnauthorizedResponse({ description: 'Invalid credentials' })
async resetPassword(
@CurrentUser() user: AuthenticatedUser,
@Body() body: ResetPasswordDto,
): Promise<void> {
await this.authService.changePassword(
user.userId,
body.oldPassword,
body.newPassword,
);
}
@Post('refresh')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@HttpCode(200)
@ApiOperation({ summary: 'Refresh access token' })
@ApiResponse({ status: 200, type: LoginResponseDto })
@ApiUnauthorizedResponse({ description: 'Invalid token' })
async refresh(
@CurrentUser() user: AuthenticatedUser,
): Promise<LoginResponseDto> {
return this.authService.refreshToken(user);
}
}

View File

@@ -5,6 +5,7 @@ import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtVerificationService } from './services/jwt-verification.service';
import { UsersModule } from '../users/users.module';
import { AuthController } from './auth.controller';
@Module({
imports: [
@@ -12,6 +13,7 @@ import { UsersModule } from '../users/users.module';
PassportModule.register({ defaultStrategy: 'jwt' }),
forwardRef(() => UsersModule),
],
controllers: [AuthController],
providers: [JwtStrategy, AuthService, JwtVerificationService],
exports: [AuthService, PassportModule, JwtVerificationService],
})

View File

@@ -1,449 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { URLSearchParams } from 'url';
import axios from 'axios';
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 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'),
activeDollId: null,
};
const mockUsersService: jest.Mocked<
Pick<UsersService, 'createFromToken' | 'findByKeycloakSub' | 'findOrCreate'>
> = {
createFromToken: jest.fn().mockResolvedValue(mockUser),
findByKeycloakSub: jest.fn().mockResolvedValue(null),
findOrCreate: jest.fn().mockResolvedValue(mockUser),
};
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'],
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AuthService,
{
provide: UsersService,
useValue: mockUsersService,
},
{
provide: ConfigService,
useValue: {
get: (key: string) => {
if (key === 'JWT_ISSUER') return 'https://auth.example.com';
if (key === 'KEYCLOAK_CLIENT_ID') return 'friendolls-client';
if (key === 'KEYCLOAK_CLIENT_SECRET') return 'secret';
return undefined;
},
},
},
],
}).compile();
service = module.get<AuthService>(AuthService);
jest.clearAllMocks();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('revokeToken', () => {
beforeEach(() => {
jest.spyOn(axios, 'post').mockReset();
});
it('should skip when config missing', async () => {
const missingConfigService = new ConfigService({});
const localService = new AuthService(
mockUsersService as unknown as UsersService,
missingConfigService,
);
const warnSpy = jest.spyOn<any, any>(localService['logger'], 'warn');
const result = await localService.revokeToken('rt');
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
});
it('should return true on successful revocation', async () => {
jest.spyOn(axios, 'post').mockResolvedValue({ status: 200 });
const result = await service.revokeToken('rt-success');
expect(result).toBe(true);
expect(axios.post).toHaveBeenCalledWith(
'https://auth.example.com/protocol/openid-connect/revoke',
expect.any(URLSearchParams),
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it('should return false on non-2xx response', async () => {
jest.spyOn(axios, 'post').mockResolvedValue({ status: 400 });
const warnSpy = jest.spyOn<any, any>(service['logger'], 'warn');
const result = await service.revokeToken('rt-fail');
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
});
it('should return false on error', async () => {
jest.spyOn(axios, 'post').mockRejectedValue({ message: 'boom' });
const warnSpy = jest.spyOn<any, any>(service['logger'], 'warn');
const result = await service.revokeToken('rt-error');
expect(result).toBe(false);
expect(warnSpy).toHaveBeenCalled();
});
});
describe('syncUserFromToken', () => {
it('should create a new user if user does not exist', async () => {
mockUsersService.createFromToken.mockResolvedValue(mockUser);
const result = await service.syncUserFromToken(mockAuthUser);
expect(result).toEqual(mockUser);
expect(mockUsersService.createFromToken).toHaveBeenCalledWith({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
});
});
it('should handle existing user via upsert', async () => {
const updatedUser = { ...mockUser, lastLoginAt: new Date('2024-02-01') };
mockUsersService.createFromToken.mockResolvedValue(updatedUser);
const result = await service.syncUserFromToken(mockAuthUser);
expect(result).toEqual(updatedUser);
expect(mockUsersService.createFromToken).toHaveBeenCalledWith({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
});
});
it('should handle user with no email by using empty string', async () => {
const authUserNoEmail: AuthenticatedUser = {
keycloakSub: 'f:realm:user456',
name: 'No Email User',
};
mockUsersService.createFromToken.mockResolvedValue({
...mockUser,
email: '',
name: 'No Email User',
});
await service.syncUserFromToken(authUserNoEmail);
expect(mockUsersService.createFromToken).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: 'fallbackuser',
};
mockUsersService.createFromToken.mockResolvedValue({
...mockUser,
name: 'fallbackuser',
});
await service.syncUserFromToken(authUserNoName);
expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
expect.objectContaining({
name: 'fallbackuser',
}),
);
});
it('should use "Unknown User" when no name or username is available', async () => {
const authUserMinimal: AuthenticatedUser = {
keycloakSub: 'f:realm:minimal',
};
mockUsersService.createFromToken.mockResolvedValue({
...mockUser,
name: 'Unknown User',
});
await service.syncUserFromToken(authUserMinimal);
expect(mockUsersService.createFromToken).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',
};
mockUsersService.createFromToken.mockResolvedValue({
...mockUser,
keycloakSub: '',
email: 'empty@example.com',
name: 'Empty Sub User',
});
await service.syncUserFromToken(authUserEmptySub);
expect(mockUsersService.createFromToken).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',
};
mockUsersService.createFromToken.mockResolvedValue({
...mockUser,
keycloakSub: 'invalid-format',
email: 'malformed@example.com',
name: 'Malformed User',
});
const result = await service.syncUserFromToken(authUserMalformed);
expect(mockUsersService.createFromToken).toHaveBeenCalledWith(
expect.objectContaining({
keycloakSub: 'invalid-format',
email: 'malformed@example.com',
name: 'Malformed User',
}),
);
expect(result.keycloakSub).toBe('invalid-format');
});
});
describe('ensureUserExists', () => {
it('should call findOrCreate with correct params', async () => {
mockUsersService.findOrCreate.mockResolvedValue(mockUser);
const result = await service.ensureUserExists(mockAuthUser);
expect(result).toEqual(mockUser);
expect(mockUsersService.findOrCreate).toHaveBeenCalledWith({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
});
});
it('should handle missing username gracefully', async () => {
mockUsersService.findOrCreate.mockResolvedValue(mockUser);
const result = await service.ensureUserExists({
...mockAuthUser,
username: undefined,
});
expect(result).toEqual(mockUser);
expect(mockUsersService.findOrCreate).toHaveBeenCalledWith({
keycloakSub: 'f:realm:user123',
email: 'test@example.com',
name: 'Test User',
username: undefined,
picture: 'https://example.com/avatar.jpg',
roles: ['user', 'premium'],
});
});
});
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);
});
});
});

View File

@@ -1,159 +1,173 @@
import { Injectable, Logger } from '@nestjs/common';
import {
Injectable,
Logger,
UnauthorizedException,
BadRequestException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios, { AxiosError } from 'axios';
import { URLSearchParams } from 'url';
import { sign } from 'jsonwebtoken';
import { compare, hash } from 'bcryptjs';
import { UsersService } from '../users/users.service';
import type { AuthenticatedUser } from './decorators/current-user.decorator';
import { User } from '../users/users.entity';
import type { AuthenticatedUser } from './decorators/current-user.decorator';
/**
* Authentication Service
*
* Handles authentication-related business logic including:
* - User login tracking from Keycloak tokens
* - Profile synchronization from Keycloak
* - Role-based authorization checks
*
* ## User Sync Strategy
*
* On every authentication:
* - Creates new users with full profile data from Keycloak
* - For existing users, compares Keycloak data with local database:
* - If profile changed: Updates all fields (email, name, username, picture, roles, lastLoginAt)
* - If profile unchanged: Only updates lastLoginAt (lightweight operation)
*
* This optimizes database performance since reads are cheaper than writes in PostgreSQL.
* Most logins only update lastLoginAt, but profile changes sync automatically.
*
* For explicit profile sync (webhooks, admin operations), use:
* - UsersService.syncProfileFromToken() - force sync regardless of changes
*
* ## Usage Guidelines
*
* - Use `syncUserFromToken()` for actual login events (WebSocket connections, /users/me)
* - Use `ensureUserExists()` for regular API calls that just need the user record
* Handles native authentication:
* - User registration
* - Login with email/password
* - JWT issuance
* - Password changes
*/
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
private readonly jwtSecret: string;
private readonly jwtIssuer: string;
private readonly jwtAudience?: string;
private readonly jwtExpiresInSeconds: number;
constructor(
private readonly usersService: UsersService,
private readonly configService: ConfigService,
) {}
/**
* Revoke refresh token via Keycloak token revocation endpoint, if configured.
* Returns true on success; false on missing config or failure.
*/
async revokeToken(refreshToken: string): Promise<boolean> {
const issuer = this.configService.get<string>('JWT_ISSUER');
const clientId = this.configService.get<string>('KEYCLOAK_CLIENT_ID');
const clientSecret = this.configService.get<string>(
'KEYCLOAK_CLIENT_SECRET',
) {
this.jwtSecret = this.configService.get<string>('JWT_SECRET') || '';
if (!this.jwtSecret) {
throw new Error('JWT_SECRET must be configured');
}
this.jwtIssuer =
this.configService.get<string>('JWT_ISSUER') || 'friendolls';
this.jwtAudience = this.configService.get<string>('JWT_AUDIENCE');
this.jwtExpiresInSeconds = Number(
this.configService.get<string>('JWT_EXPIRES_IN_SECONDS') || '3600',
);
if (!issuer || !clientId) {
this.logger.warn(
'JWT issuer or client id missing, skipping token revocation',
);
return false;
}
const revokeUrl = `${issuer}/protocol/openid-connect/revoke`;
try {
const params = new URLSearchParams({
client_id: clientId,
token: refreshToken,
token_type_hint: 'refresh_token',
});
if (clientSecret) {
params.set('client_secret', clientSecret);
}
const response = await axios.post(revokeUrl, params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
timeout: 5000,
validateStatus: (status) => status >= 200 && status < 500,
});
if (response.status >= 200 && response.status < 300) {
this.logger.log('Refresh token revoked');
return true;
}
this.logger.warn(
`Token revocation failed with status ${response.status}`,
);
return false;
} catch (error) {
const err = error as AxiosError;
this.logger.warn(
`Failed to revoke token: ${err.response?.status} ${err.response?.statusText ?? err.message}`,
);
return false;
}
}
/**
* Handles user login and profile synchronization from Keycloak token.
* Creates new users with full profile data.
* For existing users, intelligently syncs only changed fields to optimize performance.
*
* The service compares Keycloak data with local database and:
* - Updates profile fields only if they changed from Keycloak
* - Always updates lastLoginAt to track login activity
*
* @param authenticatedUser - User data extracted from JWT token
* @returns The user entity
*/
async syncUserFromToken(authenticatedUser: AuthenticatedUser): Promise<User> {
const { keycloakSub, email, name, username, picture, roles } =
authenticatedUser;
async register(data: {
email: string;
password: string;
name?: string;
username?: string;
}): Promise<User> {
const { email, password, name, username } = data;
const user = await this.usersService.createFromToken({
keycloakSub,
email: email || '',
const existing = await this.usersService.findByEmail(email);
if (existing) {
throw new BadRequestException('Email already registered');
}
const passwordHash = await hash(password, 12);
return this.usersService.createLocalUser({
email,
passwordHash,
name: name || username || 'Unknown User',
username,
picture,
roles,
});
return user;
}
/**
* Ensures a user exists in the local database without updating profile data.
* This is optimized for regular API calls that need the user record but don't
* need to sync profile data from Keycloak on every request.
*/
async ensureUserExists(authenticatedUser: AuthenticatedUser): Promise<User> {
const { keycloakSub, email, name, username, picture, roles } =
authenticatedUser;
return await this.usersService.findOrCreate({
keycloakSub,
email: email || '',
name: name || username || 'Unknown User',
username,
picture,
roles,
});
}
hasRole(user: AuthenticatedUser, requiredRole: string): boolean {
async login(
email: string,
password: string,
): Promise<{
accessToken: string;
expiresIn: number;
}> {
const user = await this.usersService.findByEmail(email);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
}
const passwordOk = await this.verifyPassword(user, password);
if (!passwordOk) {
throw new UnauthorizedException('Invalid credentials');
}
await this.usersService.updateLastLogin(user.id);
const accessToken = this.issueToken({
userId: user.id,
email: user.email,
roles: user.roles,
});
return { accessToken, expiresIn: this.jwtExpiresInSeconds };
}
async changePassword(
userId: string,
currentPassword: string,
newPassword: string,
): Promise<void> {
const user = await this.usersService.findOne(userId);
const passwordOk = await this.verifyPassword(user, currentPassword);
if (!passwordOk) {
throw new UnauthorizedException('Invalid credentials');
}
const passwordHash = await hash(newPassword, 12);
await this.usersService.updatePasswordHash(userId, passwordHash);
}
async refreshToken(user: AuthenticatedUser): Promise<{
accessToken: string;
expiresIn: number;
}> {
const existingUser = await this.usersService.findOne(user.userId);
const accessToken = this.issueToken({
userId: existingUser.id,
email: existingUser.email,
roles: existingUser.roles,
});
return { accessToken, expiresIn: this.jwtExpiresInSeconds };
}
private issueToken(payload: {
userId: string;
email: string;
roles: string[];
}): string {
return sign(
{
sub: payload.userId,
email: payload.email,
roles: payload.roles,
},
this.jwtSecret,
{
issuer: this.jwtIssuer,
audience: this.jwtAudience,
expiresIn: this.jwtExpiresInSeconds,
algorithm: 'HS256',
},
);
}
private async verifyPassword(user: User, password: string): Promise<boolean> {
const userWithPassword = user as unknown as {
passwordHash?: string | null;
};
if (userWithPassword.passwordHash) {
return compare(password, userWithPassword.passwordHash);
}
return false;
}
hasRole(user: { roles?: string[] }, requiredRole: string): boolean {
return user.roles?.includes(requiredRole) ?? false;
}
hasAnyRole(user: AuthenticatedUser, requiredRoles: string[]): boolean {
hasAnyRole(user: { roles?: string[] }, requiredRoles: string[]): boolean {
if (!user.roles || user.roles.length === 0) {
return false;
}
return requiredRoles.some((role) => user.roles!.includes(role));
}
hasAllRoles(user: AuthenticatedUser, requiredRoles: string[]): boolean {
hasAllRoles(user: { roles?: string[] }, requiredRoles: string[]): boolean {
if (!user.roles || user.roles.length === 0) {
return false;
}

View File

@@ -5,13 +5,9 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common';
* This matches the object returned by JwtStrategy.validate()
*/
export interface AuthenticatedUser {
keycloakSub: string;
email?: string;
name?: string;
username?: string;
picture?: string;
userId: string;
email: string;
roles?: string[];
sessionState?: string;
}
/**
@@ -34,8 +30,8 @@ export interface AuthenticatedUser {
* ```typescript
* @Get('profile')
* @UseGuards(JwtAuthGuard)
* async getProfile(@CurrentUser('keycloakSub') sub: string) {
* return { sub };
* async getProfile(@CurrentUser('userId') userId: string) {
* return { userId };
* }
* ```
*/

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class ChangePasswordDto {
@ApiProperty({ example: 'old password' })
@IsString()
@IsNotEmpty()
currentPassword!: string;
@ApiProperty({ example: 'new strong password' })
@IsString()
@IsNotEmpty()
@MinLength(8)
newPassword!: string;
}

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
export class LoginRequestDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email!: string;
@ApiProperty({ example: 'correct horse battery staple' })
@IsString()
@IsNotEmpty()
@MinLength(3)
password!: string;
}

View File

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
export class LoginResponseDto {
@ApiProperty({ description: 'JWT access token' })
accessToken: string;
@ApiProperty({ description: 'Access token expiration in seconds' })
expiresIn: number;
}

View File

@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
IsEmail,
IsNotEmpty,
IsOptional,
IsString,
MinLength,
} from 'class-validator';
export class RegisterRequestDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email!: string;
@ApiProperty({ example: 'correct horse battery staple' })
@IsString()
@IsNotEmpty()
@MinLength(8)
password!: string;
@ApiPropertyOptional({ example: 'Jane Doe' })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({ example: 'janedoe' })
@IsOptional()
@IsString()
username?: string;
}

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
export class ResetPasswordDto {
@ApiProperty({ example: 'old password' })
@IsString()
@IsNotEmpty()
oldPassword!: string;
@ApiProperty({ example: 'new strong password' })
@IsString()
@IsNotEmpty()
@MinLength(8)
newPassword!: string;
}

View File

@@ -2,12 +2,12 @@ import { ExecutionContext, Injectable, Logger } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';
import type { Request } from 'express';
import { User } from 'src/users/users.entity';
import type { AuthenticatedUser } from '../decorators/current-user.decorator';
/**
* JWT Authentication Guard
*
* This guard protects routes by requiring a valid JWT token from Keycloak.
* This guard protects routes by requiring a valid JWT token.
* It uses the JwtStrategy to validate the token and attach user info to the request.
*
* Usage:
@@ -57,7 +57,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
handleRequest(
err: any,
user: User,
user: AuthenticatedUser,
info: any,
context: ExecutionContext,
status?: any,
@@ -82,7 +82,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
}
} else {
this.logger.debug(
`✅ JWT Authentication successful for user: ${user.keycloakSub || 'unknown'}`,
`✅ JWT Authentication successful for user: ${user.userId || 'unknown'}`,
);
}

View File

@@ -9,7 +9,7 @@ describe('JwtVerificationService', () => {
const mockConfigService = {
get: jest.fn((key: string) => {
const config: Record<string, string> = {
JWKS_URI: 'https://test.com/.well-known/jwks.json',
JWT_SECRET: 'test-secret',
JWT_ISSUER: 'https://test.com',
JWT_AUDIENCE: 'test-audience',
};

View File

@@ -1,78 +1,36 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { verify, type JwtHeader } from 'jsonwebtoken';
import { JwksClient, type SigningKey } from 'jwks-rsa';
import { verify } from 'jsonwebtoken';
import type { JwtPayload } from '../strategies/jwt.strategy';
const JWT_ALGORITHM = 'RS256';
const JWT_ALGORITHM = 'HS256';
const BEARER_PREFIX = 'Bearer ';
@Injectable()
export class JwtVerificationService {
private readonly logger = new Logger(JwtVerificationService.name);
private readonly jwksClient: JwksClient;
private readonly jwtSecret: string;
private readonly issuer: string;
private readonly audience: string | undefined;
constructor(private readonly configService: ConfigService) {
const jwksUri = this.configService.get<string>('JWKS_URI');
this.issuer = this.configService.get<string>('JWT_ISSUER') || '';
this.jwtSecret = this.configService.get<string>('JWT_SECRET') || '';
this.issuer = this.configService.get<string>('JWT_ISSUER') || 'friendolls';
this.audience = this.configService.get<string>('JWT_AUDIENCE');
if (!jwksUri) {
throw new Error('JWKS_URI must be configured');
if (!this.jwtSecret) {
throw new Error('JWT_SECRET must be configured');
}
if (!this.issuer) {
throw new Error('JWT_ISSUER must be configured');
}
this.jwksClient = new JwksClient({
jwksUri,
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
});
this.logger.log('JWT Verification Service initialized');
}
async verifyToken(token: string): Promise<JwtPayload> {
return new Promise((resolve, reject) => {
const getKey = (
header: JwtHeader,
callback: (err: Error | null, signingKey?: string | Buffer) => void,
) => {
this.jwksClient.getSigningKey(
header.kid,
(err: Error | null, key?: SigningKey) => {
if (err) {
callback(err);
return;
}
const signingKey = key?.getPublicKey();
callback(null, signingKey);
},
);
};
verify(
token,
getKey,
{
issuer: this.issuer,
audience: this.audience,
algorithms: [JWT_ALGORITHM],
},
(err, decoded) => {
if (err) {
reject(err);
return;
}
resolve(decoded as JwtPayload);
},
);
});
verifyToken(token: string): JwtPayload {
return verify(token, this.jwtSecret, {
issuer: this.issuer,
audience: this.audience,
algorithms: [JWT_ALGORITHM],
}) as JwtPayload;
}
extractToken(handshake: {

View File

@@ -1,83 +1,48 @@
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';
import { ConfigService } from '@nestjs/config';
/**
* JWT payload interface representing the decoded token from Keycloak
* JWT payload interface representing the decoded token
*/
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[];
};
};
session_state?: string;
iss: string; // Issuer
aud: string | string[]; // Audience
exp: number; // Expiration time
iat: number; // Issued at
sub: string; // User ID
email: string;
roles?: string[];
iss: string;
aud?: string;
exp: number;
iat: number;
}
/**
* 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.
* JWT Strategy for validating locally issued JWT tokens.
*/
@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 jwtSecret = configService.get<string>('JWT_SECRET');
const issuer = configService.get<string>('JWT_ISSUER') || 'friendolls';
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');
if (!jwtSecret) {
throw new Error('JWT_SECRET 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
secretOrKey: jwtSecret,
issuer,
// Verify the audience matches our client ID
audience,
// Automatically reject expired tokens
ignoreExpiration: false,
// Use RS256 algorithm (Keycloak's default)
algorithms: ['RS256'],
algorithms: ['HS256'],
});
this.logger.log(`JWT Strategy initialized`);
this.logger.log(` JWKS URI: ${jwksUri}`);
this.logger.log(` Issuer: ${issuer}`);
this.logger.log(` Audience: ${audience || 'NOT SET'}`);
}
@@ -91,19 +56,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
* @throws UnauthorizedException if the payload is invalid
*/
async validate(payload: JwtPayload): Promise<{
keycloakSub: string;
email?: string;
name?: string;
username?: string;
picture?: string;
userId: string;
email: string;
roles?: string[];
sessionState?: string;
}> {
this.logger.debug(`Validating JWT token payload`);
this.logger.debug(` Issuer: ${payload.iss}`);
this.logger.debug(
` Audience: ${Array.isArray(payload.aud) ? payload.aud.join(',') : payload.aud}`,
);
if (payload.aud) {
this.logger.debug(` Audience: ${payload.aud}`);
}
this.logger.debug(` Subject: ${payload.sub}`);
this.logger.debug(
` Expires: ${new Date(payload.exp * 1000).toISOString()}`,
@@ -114,31 +75,15 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
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,
userId: payload.sub,
email: payload.email,
name: payload.name,
username: payload.preferred_username,
picture: payload.picture,
roles: roles.length > 0 ? roles : undefined,
sessionState: payload.session_state,
roles:
payload.roles && payload.roles.length > 0 ? payload.roles : undefined,
};
this.logger.log(
`✅ Successfully validated token for user: ${payload.sub} (${payload.email ?? payload.preferred_username ?? 'no email'})`,
`✅ Successfully validated token for user: ${payload.sub} (${payload.email})`,
);
return Promise.resolve(user);