feat(auth): add profile management endpoints

Add backend endpoints for user profile management:
- GET /auth/profile - retrieve user profile data
- POST /auth/profile - update name and profile image
- POST /auth/change-password - change password (requires current)
- DELETE /auth/account - soft-delete account (requires password)

Security features:
- Password verification before sensitive actions
- Soft-delete preserves data for retention
- Security events logged for audit trail
- Rate limiting on sensitive endpoints

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-13 22:29:32 +01:00
parent ae30ce3323
commit ce4e982651
7 changed files with 469 additions and 80 deletions

View file

@ -26,7 +26,12 @@ import { SetActiveOrganizationDto } from './dto/set-active-organization.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { ResendVerificationDto } from './dto/resend-verification.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { DeleteAccountDto } from './dto/delete-account.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { CurrentUserData } from '../common/decorators/current-user.decorator';
/**
* Auth Controller
@ -294,6 +299,101 @@ export class AuthController {
);
}
// =========================================================================
// Profile Management Endpoints
// =========================================================================
/**
* Get current user profile
*
* Returns the authenticated user's profile data.
*/
@Get('profile')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Get current user profile' })
@ApiResponse({
status: 200,
description: 'Returns user profile',
schema: {
type: 'object',
properties: {
id: { type: 'string' },
name: { type: 'string' },
email: { type: 'string' },
emailVerified: { type: 'boolean' },
image: { type: 'string' },
role: { type: 'string' },
createdAt: { type: 'string', format: 'date-time' },
},
},
})
@ApiResponse({ status: 401, description: 'Not authenticated' })
async getProfile(@CurrentUser() user: CurrentUserData) {
return this.betterAuthService.getProfile(user.userId);
}
/**
* Update user profile
*
* Updates the user's name and/or profile image.
*/
@Post('profile')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Update user profile' })
@ApiBody({ type: UpdateProfileDto })
@ApiResponse({ status: 200, description: 'Profile updated successfully' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
async updateProfile(@CurrentUser() user: CurrentUserData, @Body() updateDto: UpdateProfileDto) {
return this.betterAuthService.updateProfile(user.userId, {
name: updateDto.name,
image: updateDto.image,
});
}
/**
* Change password
*
* Changes the user's password. Requires current password for verification.
* Rate limited to 5 requests per minute.
*/
@Post('change-password')
@UseGuards(JwtAuthGuard)
@Throttle({ default: { ttl: 60000, limit: 5 } })
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Change password' })
@ApiBody({ type: ChangePasswordDto })
@ApiResponse({ status: 200, description: 'Password changed successfully' })
@ApiResponse({ status: 401, description: 'Current password is incorrect' })
async changePassword(@CurrentUser() user: CurrentUserData, @Body() changeDto: ChangePasswordDto) {
return this.betterAuthService.changePassword(
user.userId,
changeDto.currentPassword,
changeDto.newPassword
);
}
/**
* Delete account
*
* Soft-deletes the user's account. Requires password confirmation.
* Rate limited to 3 requests per minute.
*/
@Delete('account')
@UseGuards(JwtAuthGuard)
@Throttle({ default: { ttl: 60000, limit: 3 } })
@ApiBearerAuth('JWT-auth')
@ApiOperation({ summary: 'Delete user account' })
@ApiBody({ type: DeleteAccountDto })
@ApiResponse({ status: 200, description: 'Account deleted' })
@ApiResponse({ status: 401, description: 'Password is incorrect' })
async deleteAccount(@CurrentUser() user: CurrentUserData, @Body() deleteDto: DeleteAccountDto) {
return this.betterAuthService.deleteAccount(user.userId, deleteDto.password, deleteDto.reason);
}
// =========================================================================
// B2B Registration
// =========================================================================

View file

@ -0,0 +1,15 @@
import { IsString, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ChangePasswordDto {
@ApiProperty({ description: 'Current password', example: 'currentPassword123' })
@IsString()
@MinLength(1)
currentPassword: string;
@ApiProperty({ description: 'New password (min 8 characters)', example: 'newSecurePassword456' })
@IsString()
@MinLength(8)
@MaxLength(128)
newPassword: string;
}

View file

@ -0,0 +1,20 @@
import { IsString, IsOptional, MinLength } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class DeleteAccountDto {
@ApiProperty({
description: 'Current password to confirm account deletion',
example: 'myPassword123',
})
@IsString()
@MinLength(1)
password: string;
@ApiPropertyOptional({
description: 'Optional reason for leaving',
example: 'I found a better service',
})
@IsOptional()
@IsString()
reason?: string;
}

View file

@ -14,3 +14,13 @@ export { RegisterB2BDto } from './register-b2b.dto';
export { InviteEmployeeDto } from './invite-employee.dto';
export { AcceptInvitationDto } from './accept-invitation.dto';
export { SetActiveOrganizationDto } from './set-active-organization.dto';
// Password management DTOs
export { ForgotPasswordDto } from './forgot-password.dto';
export { ResetPasswordDto } from './reset-password.dto';
export { ResendVerificationDto } from './resend-verification.dto';
// Profile management DTOs
export { UpdateProfileDto } from './update-profile.dto';
export { ChangePasswordDto } from './change-password.dto';
export { DeleteAccountDto } from './delete-account.dto';

View file

@ -0,0 +1,19 @@
import { IsString, IsOptional, IsEmail, MinLength, MaxLength, IsUrl } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class UpdateProfileDto {
@ApiPropertyOptional({ description: 'New display name', example: 'Max Mustermann' })
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(100)
name?: string;
@ApiPropertyOptional({
description: 'Profile image URL',
example: 'https://example.com/avatar.jpg',
})
@IsOptional()
@IsUrl()
image?: string;
}

View file

@ -1126,6 +1126,250 @@ export class BetterAuthService {
return sourceAppStore.getAndDelete(email);
}
// =========================================================================
// Profile Management Methods
// =========================================================================
/**
* Update user profile
*
* Updates the user's name and/or image.
*
* @param userId - User ID
* @param updates - Fields to update (name, image)
* @returns Updated user data
*/
async updateProfile(
userId: string,
updates: { name?: string; image?: string }
): Promise<{
success: boolean;
user: { id: string; name: string; email: string; image?: string };
}> {
const db = getDb(this.databaseUrl);
const { users } = await import('../../db/schema/auth.schema');
const { eq } = await import('drizzle-orm');
// Get current user
const [currentUser] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!currentUser || currentUser.deletedAt) {
throw new NotFoundException('User not found');
}
// Build update object
const updateData: Partial<{ name: string; image: string; updatedAt: Date }> = {
updatedAt: new Date(),
};
if (updates.name !== undefined) {
updateData.name = updates.name;
}
if (updates.image !== undefined) {
updateData.image = updates.image;
}
// Update user
const [updatedUser] = await db
.update(users)
.set(updateData)
.where(eq(users.id, userId))
.returning();
this.logger.log('Profile updated', { userId });
return {
success: true,
user: {
id: updatedUser.id,
name: updatedUser.name,
email: updatedUser.email,
image: updatedUser.image || undefined,
},
};
}
/**
* Change user password
*
* Verifies the current password and updates to the new one.
* Requires the user to be authenticated.
*
* @param userId - User ID
* @param currentPassword - Current password for verification
* @param newPassword - New password to set
* @returns Success status
* @throws UnauthorizedException if current password is incorrect
*/
async changePassword(
userId: string,
currentPassword: string,
newPassword: string
): Promise<{ success: boolean; message: string }> {
const db = getDb(this.databaseUrl);
const { accounts } = await import('../../db/schema/auth.schema');
const { eq, and } = await import('drizzle-orm');
const bcrypt = await import('bcrypt');
// Get credential account (where password is stored)
const [account] = await db
.select()
.from(accounts)
.where(and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential')))
.limit(1);
if (!account || !account.password) {
throw new NotFoundException('No password credential found for this account');
}
// Verify current password
const isValid = await bcrypt.compare(currentPassword, account.password);
if (!isValid) {
throw new UnauthorizedException('Current password is incorrect');
}
// Hash new password
const hashedPassword = await bcrypt.hash(newPassword, 10);
// Update password
await db
.update(accounts)
.set({
password: hashedPassword,
updatedAt: new Date(),
})
.where(eq(accounts.id, account.id));
this.logger.log('Password changed', { userId });
// Log security event
try {
const { securityEvents } = await import('../../db/schema/auth.schema');
await db.insert(securityEvents).values({
userId,
eventType: 'password_changed',
metadata: { changedAt: new Date().toISOString() },
});
} catch {
// Non-critical - just log
this.logger.warn('Failed to log security event for password change');
}
return {
success: true,
message: 'Password changed successfully',
};
}
/**
* Delete user account
*
* Soft-deletes the user account after password verification.
* Sets deletedAt timestamp instead of hard delete for data retention.
*
* @param userId - User ID
* @param password - Password for verification
* @param reason - Optional reason for deletion
* @returns Success status
* @throws UnauthorizedException if password is incorrect
*/
async deleteAccount(
userId: string,
password: string,
reason?: string
): Promise<{ success: boolean; message: string }> {
const db = getDb(this.databaseUrl);
const { accounts, users, sessions } = await import('../../db/schema/auth.schema');
const { eq, and } = await import('drizzle-orm');
const bcrypt = await import('bcrypt');
// Get credential account
const [account] = await db
.select()
.from(accounts)
.where(and(eq(accounts.userId, userId), eq(accounts.providerId, 'credential')))
.limit(1);
if (!account || !account.password) {
throw new NotFoundException('No password credential found for this account');
}
// Verify password
const isValid = await bcrypt.compare(password, account.password);
if (!isValid) {
throw new UnauthorizedException('Password is incorrect');
}
const now = new Date();
// Soft delete user
await db.update(users).set({ deletedAt: now, updatedAt: now }).where(eq(users.id, userId));
// Revoke all sessions
await db.update(sessions).set({ revokedAt: now }).where(eq(sessions.userId, userId));
this.logger.log('Account deleted', { userId, reason });
// Log security event
try {
const { securityEvents } = await import('../../db/schema/auth.schema');
await db.insert(securityEvents).values({
userId,
eventType: 'account_deleted',
metadata: { reason, deletedAt: now.toISOString() },
});
} catch {
// Non-critical
this.logger.warn('Failed to log security event for account deletion');
}
return {
success: true,
message: 'Account has been deleted',
};
}
/**
* Get user profile
*
* Returns the full user profile data.
*
* @param userId - User ID
* @returns User profile data
*/
async getProfile(userId: string): Promise<{
id: string;
name: string;
email: string;
emailVerified: boolean;
image?: string;
role: string;
createdAt: Date;
}> {
const db = getDb(this.databaseUrl);
const { users } = await import('../../db/schema/auth.schema');
const { eq } = await import('drizzle-orm');
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user || user.deletedAt) {
throw new NotFoundException('User not found');
}
return {
id: user.id,
name: user.name,
email: user.email,
emailVerified: user.emailVerified,
image: user.image || undefined,
role: user.role,
createdAt: user.createdAt,
};
}
// =========================================================================
// Private Helper Methods
// =========================================================================