mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
✨ 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:
parent
ae30ce3323
commit
ce4e982651
7 changed files with 469 additions and 80 deletions
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
15
services/mana-core-auth/src/auth/dto/change-password.dto.ts
Normal file
15
services/mana-core-auth/src/auth/dto/change-password.dto.ts
Normal 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;
|
||||
}
|
||||
20
services/mana-core-auth/src/auth/dto/delete-account.dto.ts
Normal file
20
services/mana-core-auth/src/auth/dto/delete-account.dto.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
19
services/mana-core-auth/src/auth/dto/update-profile.dto.ts
Normal file
19
services/mana-core-auth/src/auth/dto/update-profile.dto.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue