managarten/services/mana-core-auth/src/auth/auth.controller.ts
Till-JS ce4e982651 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>
2026-02-13 22:29:32 +01:00

554 lines
16 KiB
TypeScript

import {
Controller,
Post,
Get,
Delete,
Body,
Param,
UseGuards,
Headers,
HttpCode,
HttpStatus,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { BetterAuthService } from './services/better-auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { RefreshTokenDto } from './dto/refresh-token.dto';
import { RegisterB2BDto } from './dto/register-b2b.dto';
import { InviteEmployeeDto } from './dto/invite-employee.dto';
import { AcceptInvitationDto } from './dto/accept-invitation.dto';
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
*
* Handles authentication and organization management endpoints.
*
* B2C Endpoints:
* - POST /auth/register - Register individual user
* - POST /auth/login - Sign in with email/password
* - POST /auth/logout - Sign out
* - POST /auth/refresh - Refresh access token
* - GET /auth/session - Get current session
*
* B2B Endpoints:
* - POST /auth/register/b2b - Register organization with owner
* - GET /auth/organizations - List user's organizations
* - GET /auth/organizations/:id - Get organization details
* - POST /auth/organizations/:id/invite - Invite employee
* - POST /auth/organizations/accept-invitation - Accept invitation
* - DELETE /auth/organizations/:id/members/:memberId - Remove member
* - POST /auth/organizations/set-active - Switch active organization
*/
@ApiTags('auth')
@Controller('auth')
@UseGuards(ThrottlerGuard)
export class AuthController {
constructor(private readonly betterAuthService: BetterAuthService) {}
// =========================================================================
// B2C Authentication Endpoints
// =========================================================================
/**
* Register a new B2C user (individual)
*
* Creates a user account and initializes their credit balance.
* Rate limited to 5 requests per minute to prevent abuse.
*/
@Post('register')
@Throttle({ default: { ttl: 60000, limit: 5 } })
@ApiOperation({
summary: 'Register new user',
description: 'Create a new B2C user account. Rate limited to 5 requests/minute.',
})
@ApiBody({ type: RegisterDto })
@ApiResponse({ status: 201, description: 'User created successfully' })
@ApiResponse({ status: 400, description: 'Invalid input data' })
@ApiResponse({ status: 409, description: 'Email already exists' })
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
async register(@Body() registerDto: RegisterDto) {
return this.betterAuthService.registerB2C({
email: registerDto.email,
password: registerDto.password,
name: registerDto.name || '',
sourceAppUrl: registerDto.sourceAppUrl,
});
}
/**
* Sign in with email and password
*
* Returns user data and JWT token.
* Rate limited to 10 requests per minute to prevent brute force.
*/
@Post('login')
@Throttle({ default: { ttl: 60000, limit: 10 } })
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'User login',
description: 'Authenticate with email and password. Returns JWT access token.',
})
@ApiBody({ type: LoginDto })
@ApiResponse({
status: 200,
description: 'Login successful',
schema: {
type: 'object',
properties: {
user: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
name: { type: 'string' },
},
},
accessToken: { type: 'string' },
refreshToken: { type: 'string' },
expiresIn: { type: 'number', example: 900 },
},
},
})
@ApiResponse({ status: 401, description: 'Invalid credentials' })
@ApiResponse({ status: 429, description: 'Rate limit exceeded' })
async login(@Body() loginDto: LoginDto) {
return this.betterAuthService.signIn({
email: loginDto.email,
password: loginDto.password,
deviceId: loginDto.deviceId,
deviceName: loginDto.deviceName,
});
}
/**
* Sign out current user
*
* Invalidates the user's session.
*/
@Post('logout')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiBearerAuth('JWT-auth')
@ApiOperation({
summary: 'User logout',
description: 'Invalidate the current session',
})
@ApiResponse({ status: 200, description: 'Logout successful' })
@ApiResponse({ status: 401, description: 'Not authenticated' })
async logout(@Headers('authorization') authorization: string) {
const token = this.extractToken(authorization);
return this.betterAuthService.signOut(token);
}
/**
* Refresh access token
*
* Uses refresh token rotation to issue new access and refresh tokens.
*/
@Post('refresh')
@HttpCode(HttpStatus.OK)
async refresh(@Body() refreshTokenDto: RefreshTokenDto) {
return this.betterAuthService.refreshToken(refreshTokenDto.refreshToken);
}
/**
* Get current session
*
* Returns the current user and session data.
*/
@Get('session')
@UseGuards(JwtAuthGuard)
async getSession(@Headers('authorization') authorization: string) {
const token = this.extractToken(authorization);
return this.betterAuthService.getSession(token);
}
/**
* Validate a token
*
* Checks if a token is valid and returns the payload.
*/
@Post('validate')
@HttpCode(HttpStatus.OK)
async validate(@Body() body: { token: string }) {
return this.betterAuthService.validateToken(body.token);
}
/**
* Exchange session cookie for JWT tokens (SSO)
*
* This endpoint enables cross-domain Single Sign-On (SSO).
* If the user has a valid session cookie (from logging in on another app),
* this returns JWT tokens that the app can use for API calls.
*
* The session cookie is set on .mana.how domain, so it's shared across:
* - calendar.mana.how
* - todo.mana.how
* - contacts.mana.how
* - etc.
*/
@Post('session-to-token')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Exchange session cookie for JWT tokens',
description:
'SSO endpoint: If user has a valid session cookie, returns JWT access and refresh tokens.',
})
@ApiResponse({
status: 200,
description: 'Tokens generated successfully',
schema: {
type: 'object',
properties: {
user: {
type: 'object',
properties: {
id: { type: 'string' },
email: { type: 'string' },
name: { type: 'string' },
},
},
accessToken: { type: 'string' },
refreshToken: { type: 'string' },
expiresIn: { type: 'number', example: 900 },
},
},
})
@ApiResponse({ status: 401, description: 'No valid session cookie' })
async sessionToToken(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
return this.betterAuthService.sessionToToken(req, res);
}
/**
* Get JWKS (JSON Web Key Set)
*
* Returns public keys for JWT verification.
* This is a passthrough to Better Auth's JWKS.
*/
@Get('jwks')
async getJwks() {
return this.betterAuthService.getJwks();
}
// =========================================================================
// Password Reset Endpoints
// =========================================================================
/**
* Request password reset
*
* Initiates the password reset flow by sending an email with a reset link.
* Always returns success to prevent email enumeration attacks.
* Rate limited to 3 requests per minute to prevent abuse.
*/
@Post('forgot-password')
@Throttle({ default: { ttl: 60000, limit: 3 } })
@HttpCode(HttpStatus.OK)
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
return this.betterAuthService.requestPasswordReset(
forgotPasswordDto.email,
forgotPasswordDto.redirectTo
);
}
/**
* Reset password with token
*
* Completes the password reset using the token from the email link.
* Rate limited to 5 requests per minute.
*/
@Post('reset-password')
@Throttle({ default: { ttl: 60000, limit: 5 } })
@HttpCode(HttpStatus.OK)
async resetPassword(@Body() resetPasswordDto: ResetPasswordDto) {
return this.betterAuthService.resetPassword(
resetPasswordDto.token,
resetPasswordDto.newPassword
);
}
/**
* Resend verification email
*
* Sends a new verification email to the user.
* Always returns success to prevent email enumeration attacks.
* Rate limited to 3 requests per minute to prevent abuse.
*/
@Post('resend-verification')
@Throttle({ default: { ttl: 60000, limit: 3 } })
@HttpCode(HttpStatus.OK)
async resendVerification(@Body() resendVerificationDto: ResendVerificationDto) {
return this.betterAuthService.resendVerificationEmail(
resendVerificationDto.email,
resendVerificationDto.sourceAppUrl
);
}
// =========================================================================
// 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
// =========================================================================
/**
* Register a new B2B organization
*
* Creates an organization with the registering user as owner.
* Also creates organization credit balance.
* Rate limited to 3 requests per minute.
*/
@Post('register/b2b')
@Throttle({ default: { ttl: 60000, limit: 3 } })
async registerB2B(@Body() registerDto: RegisterB2BDto) {
return this.betterAuthService.registerB2B(registerDto);
}
// =========================================================================
// Organization Management Endpoints
// =========================================================================
/**
* List user's organizations
*
* Returns all organizations the current user is a member of.
*/
@Get('organizations')
@UseGuards(JwtAuthGuard)
async listOrganizations(@Headers('authorization') authorization: string) {
const token = this.extractToken(authorization);
return this.betterAuthService.listOrganizations(token);
}
/**
* Get organization details
*
* Returns full organization info including members.
*/
@Get('organizations/:id')
@UseGuards(JwtAuthGuard)
async getOrganization(
@Param('id') organizationId: string,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.getOrganization(organizationId, token);
}
/**
* Get organization members
*
* Returns all members of an organization with their roles.
*/
@Get('organizations/:id/members')
@UseGuards(JwtAuthGuard)
async getOrganizationMembers(@Param('id') organizationId: string) {
return this.betterAuthService.getOrganizationMembers(organizationId);
}
/**
* Invite employee to organization
*
* Sends an invitation email to join the organization.
* Requires owner or admin role.
*/
@Post('organizations/:id/invite')
@UseGuards(JwtAuthGuard)
async inviteEmployee(
@Param('id') organizationId: string,
@Body() inviteDto: InviteEmployeeDto,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.inviteEmployee({
organizationId,
employeeEmail: inviteDto.employeeEmail,
role: inviteDto.role,
inviterToken: token,
});
}
/**
* Accept organization invitation
*
* Accepts a pending invitation and adds user to organization.
*/
@Post('organizations/accept-invitation')
@UseGuards(JwtAuthGuard)
async acceptInvitation(
@Body() acceptDto: AcceptInvitationDto,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.acceptInvitation({
invitationId: acceptDto.invitationId,
userToken: token,
});
}
/**
* Remove member from organization
*
* Removes a member from the organization.
* Requires owner or admin role.
*/
@Delete('organizations/:id/members/:memberId')
@UseGuards(JwtAuthGuard)
async removeMember(
@Param('id') organizationId: string,
@Param('memberId') memberId: string,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.removeMember({
organizationId,
memberId,
removerToken: token,
});
}
/**
* Set active organization
*
* Switches the user's active organization context.
* Affects JWT claims and credit balance.
*/
@Post('organizations/set-active')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
async setActiveOrganization(
@Body() setActiveDto: SetActiveOrganizationDto,
@Headers('authorization') authorization: string
) {
const token = this.extractToken(authorization);
return this.betterAuthService.setActiveOrganization({
organizationId: setActiveDto.organizationId,
userToken: token,
});
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* Extract token from Authorization header
*/
private extractToken(authorization: string): string {
if (!authorization) {
return '';
}
// Handle both "Bearer token" and raw token formats
if (authorization.startsWith('Bearer ')) {
return authorization.substring(7);
}
return authorization;
}
}