Complete SaaS self-service onboarding sprint: - Stripe-powered signup flow: pricing page → checkout → provisioning → activation - Refresh token infrastructure: 1h access tokens + 30-day httpOnly cookie refresh - TOTP MFA with QR setup, recovery codes, and login challenge flow - Google + Azure AD SSO (conditional on env vars) with account linking - WebAuthn passkey registration and passwordless login - Guided onboarding checklist with server-side progress tracking - Stubbed email service (console + DB logging, ready for real provider) - Settings page with tabbed security settings (MFA, passkeys, linked accounts) - Login page enhanced with MFA verification, SSO buttons, passkey login - Database migration 015 with all new tables and columns - Version bump to 2026.03.17 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
113 lines
3.4 KiB
TypeScript
113 lines
3.4 KiB
TypeScript
import {
|
|
Controller,
|
|
Post,
|
|
Get,
|
|
Delete,
|
|
Param,
|
|
Body,
|
|
UseGuards,
|
|
Request,
|
|
Res,
|
|
BadRequestException,
|
|
} from '@nestjs/common';
|
|
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
|
import { Throttle } from '@nestjs/throttler';
|
|
import { Response } from 'express';
|
|
import { PasskeyService } from './passkey.service';
|
|
import { AuthService } from './auth.service';
|
|
import { UsersService } from '../users/users.service';
|
|
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
|
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
|
|
|
|
const COOKIE_NAME = 'ledgeriq_rt';
|
|
const isProduction = process.env.NODE_ENV === 'production';
|
|
|
|
@ApiTags('auth')
|
|
@Controller('auth/passkeys')
|
|
export class PasskeyController {
|
|
constructor(
|
|
private passkeyService: PasskeyService,
|
|
private authService: AuthService,
|
|
private usersService: UsersService,
|
|
) {}
|
|
|
|
@Post('register-options')
|
|
@ApiOperation({ summary: 'Get passkey registration options' })
|
|
@ApiBearerAuth()
|
|
@UseGuards(JwtAuthGuard)
|
|
async getRegistrationOptions(@Request() req: any) {
|
|
return this.passkeyService.generateRegistrationOptions(req.user.sub);
|
|
}
|
|
|
|
@Post('register')
|
|
@ApiOperation({ summary: 'Register a new passkey' })
|
|
@ApiBearerAuth()
|
|
@UseGuards(JwtAuthGuard)
|
|
async register(
|
|
@Request() req: any,
|
|
@Body() body: { response: any; deviceName?: string },
|
|
) {
|
|
if (!body.response) throw new BadRequestException('Attestation response required');
|
|
return this.passkeyService.verifyRegistration(req.user.sub, body.response, body.deviceName);
|
|
}
|
|
|
|
@Post('login-options')
|
|
@ApiOperation({ summary: 'Get passkey login options' })
|
|
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
|
async getLoginOptions(@Body() body: { email?: string }) {
|
|
return this.passkeyService.generateAuthenticationOptions(body.email);
|
|
}
|
|
|
|
@Post('login')
|
|
@ApiOperation({ summary: 'Authenticate with passkey' })
|
|
@Throttle({ default: { limit: 5, ttl: 60000 } })
|
|
async login(
|
|
@Body() body: { response: any; challenge: string },
|
|
@Res({ passthrough: true }) res: Response,
|
|
) {
|
|
if (!body.response || !body.challenge) {
|
|
throw new BadRequestException('Assertion response and challenge required');
|
|
}
|
|
|
|
const { userId } = await this.passkeyService.verifyAuthentication(body.response, body.challenge);
|
|
|
|
// Get user with orgs and generate session
|
|
const user = await this.usersService.findByIdWithOrgs(userId);
|
|
if (!user) throw new BadRequestException('User not found');
|
|
|
|
await this.usersService.updateLastLogin(userId);
|
|
const result = await this.authService.generateTokenResponse(user);
|
|
|
|
if (result.refreshToken) {
|
|
res.cookie(COOKIE_NAME, result.refreshToken, {
|
|
httpOnly: true,
|
|
secure: isProduction,
|
|
sameSite: 'strict',
|
|
path: '/api/auth',
|
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
|
});
|
|
}
|
|
|
|
const { refreshToken: _rt, ...response } = result;
|
|
return response;
|
|
}
|
|
|
|
@Get()
|
|
@ApiOperation({ summary: 'List registered passkeys' })
|
|
@ApiBearerAuth()
|
|
@UseGuards(JwtAuthGuard)
|
|
@AllowViewer()
|
|
async list(@Request() req: any) {
|
|
return this.passkeyService.listPasskeys(req.user.sub);
|
|
}
|
|
|
|
@Delete(':id')
|
|
@ApiOperation({ summary: 'Remove a passkey' })
|
|
@ApiBearerAuth()
|
|
@UseGuards(JwtAuthGuard)
|
|
async remove(@Request() req: any, @Param('id') passkeyId: string) {
|
|
await this.passkeyService.removePasskey(req.user.sub, passkeyId);
|
|
return { success: true };
|
|
}
|
|
}
|