372 lines
13 KiB
TypeScript
Raw Normal View History

import {
Controller,
Post,
Body,
UseGuards,
UseInterceptors,
Get,
Req,
HttpCode,
Res,
UnauthorizedException,
} from "@nestjs/common";
import type { Request, Response } from "express";
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
import { AuthOrchestrator } from "@bff/modules/auth/application/auth-orchestrator.service.js";
import { AuthHealthService } from "@bff/modules/auth/application/auth-health.service.js";
import { LocalAuthGuard } from "./guards/local-auth.guard.js";
import {
FailedLoginThrottleGuard,
type RequestWithRateLimit,
} from "./guards/failed-login-throttle.guard.js";
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js";
import { Public, OptionalAuth } from "../../decorators/public.decorator.js";
import { createZodDto, ZodResponse } from "nestjs-zod";
import type { RequestWithUser, RequestWithCookies } from "@bff/modules/auth/auth.types.js";
import { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
import { LoginOtpWorkflowService } from "../../infra/workflows/login-otp-workflow.service.js";
import { PasswordWorkflowService } from "../../infra/workflows/password-workflow.service.js";
import type { UserAuth } from "@customer-portal/domain/customer";
import { extractAccessTokenFromRequest } from "../../utils/token-from-request.util.js";
import { getRequestFingerprint } from "@bff/core/http/request-context.util.js";
import {
setAuthCookies,
clearAuthCookies,
buildSessionInfo,
ACCESS_COOKIE_PATH,
REFRESH_COOKIE_PATH,
TOKEN_TYPE,
} from "./utils/auth-cookie.util.js";
import {
setTrustedDeviceCookie,
clearTrustedDeviceCookie,
getTrustedDeviceToken,
} from "./utils/trusted-device-cookie.util.js";
import { getDevAuthConfig } from "@bff/core/config/auth-dev.config.js";
import { TrustedDeviceService } from "../../infra/trusted-device/trusted-device.service.js";
// Import Zod schemas from domain
import {
passwordResetRequestSchema,
passwordResetSchema,
setPasswordRequestSchema,
changePasswordRequestSchema,
accountStatusRequestSchema,
ssoLinkRequestSchema,
checkPasswordNeededRequestSchema,
refreshTokenRequestSchema,
checkPasswordNeededResponseSchema,
loginVerifyOtpRequestSchema,
} from "@customer-portal/domain/auth";
// Re-export for backward compatibility with tests
export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE };
class AccountStatusRequestDto extends createZodDto(accountStatusRequestSchema) {}
class RefreshTokenRequestDto extends createZodDto(refreshTokenRequestSchema) {}
class SetPasswordRequestDto extends createZodDto(setPasswordRequestSchema) {}
class CheckPasswordNeededRequestDto extends createZodDto(checkPasswordNeededRequestSchema) {}
class PasswordResetRequestDto extends createZodDto(passwordResetRequestSchema) {}
class ResetPasswordRequestDto extends createZodDto(passwordResetSchema) {}
class ChangePasswordRequestDto extends createZodDto(changePasswordRequestSchema) {}
class SsoLinkRequestDto extends createZodDto(ssoLinkRequestSchema) {}
class CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededResponseSchema) {}
class LoginVerifyOtpRequestDto extends createZodDto(loginVerifyOtpRequestSchema) {}
@Controller("auth")
export class AuthController {
constructor(
private authOrchestrator: AuthOrchestrator,
private readonly jwtService: JoseJwtService,
private readonly loginOtpWorkflow: LoginOtpWorkflowService,
private readonly trustedDeviceService: TrustedDeviceService,
private readonly passwordWorkflow: PasswordWorkflowService,
private readonly healthService: AuthHealthService
) {}
private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void {
FailedLoginThrottleGuard.applyRateLimitHeaders(req, res);
}
@Public()
@Get("health-check")
async healthCheck() {
return this.healthService.check();
}
@Public()
@Post("account-status")
async accountStatus(@Body() body: AccountStatusRequestDto) {
return this.authOrchestrator.getAccountStatus(body.email);
}
/**
* POST /auth/login - Initiate login with credentials
*
* After valid credential check:
* 1. Check if device is trusted (has valid trusted device cookie for this user)
* 2. If trusted, skip OTP and complete login directly
* 3. Otherwise, generate OTP and send to user's email
* 4. Return session token for OTP verification
* 5. User must call /auth/login/verify-otp to complete login
*/
@Public()
@UseGuards(LocalAuthGuard, FailedLoginThrottleGuard)
@UseInterceptors(LoginResultInterceptor)
@Post("login")
async login(
@Req() req: RequestWithUser & RequestWithRateLimit & RequestWithCookies,
@Res({ passthrough: true }) res: Response
) {
this.applyAuthRateLimitHeaders(req, res);
// In dev mode with SKIP_OTP=true, skip OTP and complete login directly
if (getDevAuthConfig().skipOtp) {
const loginResult = await this.authOrchestrator.completeLogin(
{ id: req.user.id, email: req.user.email, role: req.user.role ?? "USER" },
req
);
setAuthCookies(res, loginResult.tokens);
return {
user: loginResult.user,
session: buildSessionInfo(loginResult.tokens),
};
}
// Check if this is a trusted device for the authenticated user
const trustedDeviceToken = getTrustedDeviceToken(req);
const trustedDeviceResult = await this.trustedDeviceService.validateTrustedDevice(
trustedDeviceToken,
req.user.id
);
// If device is trusted for this user, skip OTP and complete login
if (trustedDeviceResult.valid) {
const loginResult = await this.authOrchestrator.completeLogin(
{ id: req.user.id, email: req.user.email, role: req.user.role ?? "USER" },
req
);
setAuthCookies(res, loginResult.tokens);
return {
user: loginResult.user,
session: buildSessionInfo(loginResult.tokens),
};
}
// Credentials validated by LocalAuthGuard - now initiate OTP
const fingerprint = getRequestFingerprint(req);
const otpResult = await this.loginOtpWorkflow.initiateOtp(
{
id: req.user.id,
email: req.user.email,
role: req.user.role ?? "USER",
},
fingerprint
);
// Return OTP required response - no tokens issued yet
return {
requiresOtp: true,
sessionToken: otpResult.sessionToken,
maskedEmail: otpResult.maskedEmail,
expiresAt: otpResult.expiresAt,
};
}
/**
* POST /auth/login/verify-otp - Complete login with OTP verification
*
* Verifies the OTP code and issues auth tokens on success.
* If rememberDevice is true, sets a trusted device cookie to skip OTP on future logins.
*/
@Public()
@Post("login/verify-otp")
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP
@HttpCode(200)
async verifyLoginOtp(
@Body() body: LoginVerifyOtpRequestDto,
@Req() req: Request,
@Res({ passthrough: true }) res: Response
) {
const fingerprint = getRequestFingerprint(req);
const result = await this.loginOtpWorkflow.verifyOtp(body.sessionToken, body.code, fingerprint);
if (!result.success) {
throw new UnauthorizedException(result.error);
}
// OTP verified - complete login with token generation
const loginResult = await this.authOrchestrator.completeLogin(
{ id: result.userId, email: result.email, role: result.role },
req
);
setAuthCookies(res, loginResult.tokens);
// If user wants to remember this device, create and set trusted device cookie
if (body.rememberDevice) {
const rawUserAgent = req.headers["user-agent"];
const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined;
const trustedDeviceToken = await this.trustedDeviceService.createTrustedDevice(
result.userId,
userAgent
);
setTrustedDeviceCookie(res, trustedDeviceToken, this.trustedDeviceService.getTtlMs());
}
return {
user: loginResult.user,
session: buildSessionInfo(loginResult.tokens),
};
}
@Public()
@Post("logout")
async logout(
@Req() req: RequestWithCookies & { user?: { id: string } },
@Res({ passthrough: true }) res: Response
) {
const token = extractAccessTokenFromRequest(req);
let userId = req.user?.id;
if (!userId && token) {
const payload = await this.jwtService.verifyAllowExpired<{ sub?: string }>(token);
if (payload?.sub) {
userId = payload.sub;
}
}
// Revoke trusted device for this user if they have one
const trustedDeviceToken = getTrustedDeviceToken(req);
if (trustedDeviceToken && userId) {
// Validate to get device ID, then revoke
const validation = await this.trustedDeviceService.validateTrustedDevice(
trustedDeviceToken,
userId
);
if (validation.deviceId) {
await this.trustedDeviceService.revokeDevice(validation.deviceId);
}
}
await this.authOrchestrator.logout(userId, token, req as Request);
// Always clear cookies, even if session expired
clearAuthCookies(res);
clearTrustedDeviceCookie(res);
return { message: "Logout successful" };
}
@Public()
@Post("refresh")
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP
async refreshToken(
@Body() body: RefreshTokenRequestDto,
@Req() req: RequestWithCookies,
@Res({ passthrough: true }) res: Response
) {
const refreshToken = body.refreshToken ?? req.cookies?.["refresh_token"];
const rawUserAgent = req.headers["user-agent"];
const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined;
const result = await this.authOrchestrator.refreshTokens(refreshToken, {
deviceId: body.deviceId ?? undefined,
userAgent: userAgent ?? undefined,
});
setAuthCookies(res, result.tokens);
return { user: result.user, session: buildSessionInfo(result.tokens) };
}
@Public()
@Post("set-password")
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP+UA (industry standard)
async setPassword(
@Body() setPasswordData: SetPasswordRequestDto,
@Req() _req: Request,
@Res({ passthrough: true }) res: Response
) {
const result = await this.passwordWorkflow.setPassword(
setPasswordData.email,
setPasswordData.password
);
setAuthCookies(res, result.tokens);
return { user: result.user, session: buildSessionInfo(result.tokens) };
}
@Public()
@Post("check-password-needed")
@HttpCode(200)
@ZodResponse({
status: 200,
description: "Check if password is needed",
type: CheckPasswordNeededResponseDto,
})
async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestDto) {
return this.passwordWorkflow.checkPasswordNeeded(data.email);
}
@Public()
@Post("request-password-reset")
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
async requestPasswordReset(@Body() body: PasswordResetRequestDto, @Req() req: Request) {
await this.passwordWorkflow.requestPasswordReset(body.email, req);
return { message: "If an account exists, a reset email has been sent" };
}
@Public()
@Post("reset-password")
@HttpCode(200)
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
async resetPassword(
@Body() body: ResetPasswordRequestDto,
@Res({ passthrough: true }) res: Response
) {
await this.passwordWorkflow.resetPassword(body.token, body.password);
// Clear auth cookies after password reset to force re-login
clearAuthCookies(res);
return { message: "Password reset successful" };
}
@Post("change-password")
@UseGuards(RateLimitGuard)
@RateLimit({ limit: 5, ttl: 300 }) // 5 attempts per 5 minutes
async changePassword(
@Req() req: Request & { user: { id: string } },
@Body() body: ChangePasswordRequestDto,
@Res({ passthrough: true }) res: Response
) {
const result = await this.passwordWorkflow.changePassword(req.user.id, body, req);
setAuthCookies(res, result.tokens);
return { user: result.user, session: buildSessionInfo(result.tokens) };
}
/**
* GET /auth/me - Check authentication status
*
* Uses @OptionalAuth: returns isAuthenticated: false if not logged in,
* 401 only if session cookie is present but expired/invalid
*/
@OptionalAuth()
@Get("me")
getAuthStatus(@Req() req: Request & { user?: UserAuth }) {
if (!req.user) {
return { isAuthenticated: false };
}
return { isAuthenticated: true, user: req.user };
}
@Post("sso-link")
async createSsoLink(
@Req() req: Request & { user: { id: string } },
@Body() body: SsoLinkRequestDto
) {
const destination = body?.destination;
return this.authOrchestrator.createSsoLink(req.user.id, destination);
}
}