2025-09-26 16:30:00 +09:00
|
|
|
import {
|
|
|
|
|
Controller,
|
|
|
|
|
Post,
|
|
|
|
|
Body,
|
|
|
|
|
UseGuards,
|
2025-09-27 17:51:54 +09:00
|
|
|
UseInterceptors,
|
2025-09-26 16:30:00 +09:00
|
|
|
Get,
|
|
|
|
|
Req,
|
|
|
|
|
HttpCode,
|
|
|
|
|
Res,
|
|
|
|
|
} from "@nestjs/common";
|
2025-09-26 15:51:07 +09:00
|
|
|
import type { Request, Response } from "express";
|
2025-12-11 11:25:23 +09:00
|
|
|
import { RateLimitGuard, RateLimit } from "@bff/core/rate-limiting/index.js";
|
2025-12-10 16:08:34 +09:00
|
|
|
import { AuthFacade } from "@bff/modules/auth/application/auth.facade.js";
|
|
|
|
|
import { LocalAuthGuard } from "./guards/local-auth.guard.js";
|
2025-11-05 15:47:06 +09:00
|
|
|
import {
|
|
|
|
|
FailedLoginThrottleGuard,
|
|
|
|
|
type RequestWithRateLimit,
|
2025-12-10 16:08:34 +09:00
|
|
|
} from "./guards/failed-login-throttle.guard.js";
|
|
|
|
|
import { LoginResultInterceptor } from "./interceptors/login-result.interceptor.js";
|
2026-01-14 13:54:01 +09:00
|
|
|
import { Public, OptionalAuth } from "../../decorators/public.decorator.js";
|
2025-12-26 13:04:15 +09:00
|
|
|
import { createZodDto, ZodResponse } from "nestjs-zod";
|
2025-12-10 16:08:34 +09:00
|
|
|
import type { RequestWithUser } from "@bff/modules/auth/auth.types.js";
|
|
|
|
|
import { SalesforceWriteThrottleGuard } from "@bff/integrations/salesforce/guards/salesforce-write-throttle.guard.js";
|
2025-12-11 12:03:31 +09:00
|
|
|
import { JoseJwtService } from "../../infra/token/jose-jwt.service.js";
|
2025-12-12 15:00:11 +09:00
|
|
|
import type { UserAuth } from "@customer-portal/domain/customer";
|
|
|
|
|
import { extractAccessTokenFromRequest } from "../../utils/token-from-request.util.js";
|
2026-01-19 10:40:50 +09:00
|
|
|
import {
|
|
|
|
|
setAuthCookies,
|
|
|
|
|
clearAuthCookies,
|
|
|
|
|
buildSessionInfo,
|
|
|
|
|
ACCESS_COOKIE_PATH,
|
|
|
|
|
REFRESH_COOKIE_PATH,
|
|
|
|
|
TOKEN_TYPE,
|
|
|
|
|
} from "./utils/auth-cookie.util.js";
|
2025-09-18 12:34:26 +09:00
|
|
|
|
|
|
|
|
// Import Zod schemas from domain
|
|
|
|
|
import {
|
2025-09-19 16:34:10 +09:00
|
|
|
signupRequestSchema,
|
|
|
|
|
passwordResetRequestSchema,
|
|
|
|
|
passwordResetSchema,
|
|
|
|
|
setPasswordRequestSchema,
|
|
|
|
|
linkWhmcsRequestSchema,
|
|
|
|
|
changePasswordRequestSchema,
|
|
|
|
|
accountStatusRequestSchema,
|
|
|
|
|
ssoLinkRequestSchema,
|
|
|
|
|
checkPasswordNeededRequestSchema,
|
2025-09-20 11:35:40 +09:00
|
|
|
refreshTokenRequestSchema,
|
2025-11-04 13:28:36 +09:00
|
|
|
checkPasswordNeededResponseSchema,
|
|
|
|
|
linkWhmcsResponseSchema,
|
2025-10-03 16:37:52 +09:00
|
|
|
} from "@customer-portal/domain/auth";
|
2025-09-26 15:51:07 +09:00
|
|
|
|
2025-10-22 10:23:56 +09:00
|
|
|
type CookieValue = string | undefined;
|
|
|
|
|
type RequestWithCookies = Omit<Request, "cookies"> & {
|
|
|
|
|
cookies?: Record<string, CookieValue>;
|
2025-10-03 11:29:59 +09:00
|
|
|
};
|
2025-09-26 15:51:07 +09:00
|
|
|
|
2026-01-19 10:40:50 +09:00
|
|
|
// Re-export for backward compatibility with tests
|
|
|
|
|
export { ACCESS_COOKIE_PATH, REFRESH_COOKIE_PATH, TOKEN_TYPE };
|
|
|
|
|
|
2025-12-26 13:04:15 +09:00
|
|
|
class SignupRequestDto extends createZodDto(signupRequestSchema) {}
|
|
|
|
|
class AccountStatusRequestDto extends createZodDto(accountStatusRequestSchema) {}
|
|
|
|
|
class RefreshTokenRequestDto extends createZodDto(refreshTokenRequestSchema) {}
|
|
|
|
|
class LinkWhmcsRequestDto extends createZodDto(linkWhmcsRequestSchema) {}
|
|
|
|
|
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 LinkWhmcsResponseDto extends createZodDto(linkWhmcsResponseSchema) {}
|
|
|
|
|
class CheckPasswordNeededResponseDto extends createZodDto(checkPasswordNeededResponseSchema) {}
|
|
|
|
|
|
2025-09-18 12:34:26 +09:00
|
|
|
@Controller("auth")
|
2025-09-19 17:37:46 +09:00
|
|
|
export class AuthController {
|
2025-11-17 11:49:58 +09:00
|
|
|
constructor(
|
|
|
|
|
private authFacade: AuthFacade,
|
2025-12-11 12:03:31 +09:00
|
|
|
private readonly jwtService: JoseJwtService
|
2025-11-17 11:49:58 +09:00
|
|
|
) {}
|
2025-09-18 12:34:26 +09:00
|
|
|
|
2025-11-05 15:47:06 +09:00
|
|
|
private applyAuthRateLimitHeaders(req: RequestWithRateLimit, res: Response): void {
|
|
|
|
|
FailedLoginThrottleGuard.applyRateLimitHeaders(req, res);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-18 12:34:26 +09:00
|
|
|
@Public()
|
|
|
|
|
@Get("health-check")
|
|
|
|
|
async healthCheck() {
|
2025-10-02 16:33:25 +09:00
|
|
|
return this.authFacade.healthCheck();
|
2025-09-18 12:34:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Public()
|
|
|
|
|
@Post("account-status")
|
2025-12-26 13:04:15 +09:00
|
|
|
async accountStatus(@Body() body: AccountStatusRequestDto) {
|
2025-10-02 16:33:25 +09:00
|
|
|
return this.authFacade.getAccountStatus(body.email);
|
2025-09-18 12:34:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Public()
|
|
|
|
|
@Post("signup")
|
2025-12-11 11:25:23 +09:00
|
|
|
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
|
|
|
|
@RateLimit({ limit: 5, ttl: 900 }) // 5 signups per 15 minutes per IP (reasonable for account creation)
|
2025-09-26 15:51:07 +09:00
|
|
|
async signup(
|
2025-12-26 13:04:15 +09:00
|
|
|
@Body() signupData: SignupRequestDto,
|
2025-09-26 15:51:07 +09:00
|
|
|
@Req() req: Request,
|
|
|
|
|
@Res({ passthrough: true }) res: Response
|
|
|
|
|
) {
|
2025-10-02 16:33:25 +09:00
|
|
|
const result = await this.authFacade.signup(signupData, req);
|
2026-01-19 10:40:50 +09:00
|
|
|
setAuthCookies(res, result.tokens);
|
|
|
|
|
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
2025-09-18 12:34:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Public()
|
2025-09-27 17:51:54 +09:00
|
|
|
@UseGuards(LocalAuthGuard, FailedLoginThrottleGuard)
|
|
|
|
|
@UseInterceptors(LoginResultInterceptor)
|
2025-09-18 12:34:26 +09:00
|
|
|
@Post("login")
|
2025-09-26 15:51:07 +09:00
|
|
|
async login(
|
2025-11-05 15:47:06 +09:00
|
|
|
@Req() req: RequestWithUser & RequestWithRateLimit,
|
2025-09-26 15:51:07 +09:00
|
|
|
@Res({ passthrough: true }) res: Response
|
|
|
|
|
) {
|
2025-10-02 16:33:25 +09:00
|
|
|
const result = await this.authFacade.login(req.user, req);
|
2026-01-19 10:40:50 +09:00
|
|
|
setAuthCookies(res, result.tokens);
|
2025-11-05 15:47:06 +09:00
|
|
|
this.applyAuthRateLimitHeaders(req, res);
|
2026-01-19 10:40:50 +09:00
|
|
|
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
2025-09-18 12:34:26 +09:00
|
|
|
}
|
|
|
|
|
|
2025-11-05 15:47:06 +09:00
|
|
|
@Public()
|
2025-09-18 12:34:26 +09:00
|
|
|
@Post("logout")
|
2025-09-26 15:51:07 +09:00
|
|
|
async logout(
|
2025-11-05 15:47:06 +09:00
|
|
|
@Req() req: RequestWithCookies & { user?: { id: string } },
|
2025-09-26 15:51:07 +09:00
|
|
|
@Res({ passthrough: true }) res: Response
|
|
|
|
|
) {
|
2025-12-12 15:00:11 +09:00
|
|
|
const token = extractAccessTokenFromRequest(req);
|
2025-11-05 15:47:06 +09:00
|
|
|
let userId = req.user?.id;
|
|
|
|
|
|
|
|
|
|
if (!userId && token) {
|
2025-12-11 12:03:31 +09:00
|
|
|
const payload = await this.jwtService.verifyAllowExpired<{ sub?: string }>(token);
|
|
|
|
|
if (payload?.sub) {
|
|
|
|
|
userId = payload.sub;
|
2025-11-05 15:47:06 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.authFacade.logout(userId, token, req as Request);
|
|
|
|
|
|
|
|
|
|
// Always clear cookies, even if session expired
|
2026-01-19 10:40:50 +09:00
|
|
|
clearAuthCookies(res);
|
2025-09-18 12:34:26 +09:00
|
|
|
return { message: "Logout successful" };
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-20 11:35:40 +09:00
|
|
|
@Public()
|
|
|
|
|
@Post("refresh")
|
2025-12-11 11:25:23 +09:00
|
|
|
@UseGuards(RateLimitGuard)
|
|
|
|
|
@RateLimit({ limit: 10, ttl: 300 }) // 10 attempts per 5 minutes per IP
|
2025-09-26 15:51:07 +09:00
|
|
|
async refreshToken(
|
2025-12-26 13:04:15 +09:00
|
|
|
@Body() body: RefreshTokenRequestDto,
|
2025-09-26 15:51:07 +09:00
|
|
|
@Req() req: RequestWithCookies,
|
|
|
|
|
@Res({ passthrough: true }) res: Response
|
|
|
|
|
) {
|
2026-01-15 11:28:25 +09:00
|
|
|
const refreshToken = body.refreshToken ?? req.cookies?.["refresh_token"];
|
2025-10-03 11:29:59 +09:00
|
|
|
const rawUserAgent = req.headers["user-agent"];
|
|
|
|
|
const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined;
|
2025-10-02 16:33:25 +09:00
|
|
|
const result = await this.authFacade.refreshTokens(refreshToken, {
|
2026-01-15 11:28:25 +09:00
|
|
|
deviceId: body.deviceId ?? undefined,
|
|
|
|
|
userAgent: userAgent ?? undefined,
|
2025-09-20 11:35:40 +09:00
|
|
|
});
|
2026-01-19 10:40:50 +09:00
|
|
|
setAuthCookies(res, result.tokens);
|
|
|
|
|
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
2025-09-20 11:35:40 +09:00
|
|
|
}
|
|
|
|
|
|
2025-09-18 12:34:26 +09:00
|
|
|
@Public()
|
2025-12-23 16:44:45 +09:00
|
|
|
@Post("migrate")
|
2025-12-11 11:25:23 +09:00
|
|
|
@UseGuards(RateLimitGuard, SalesforceWriteThrottleGuard)
|
|
|
|
|
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP (industry standard)
|
2025-12-26 13:04:15 +09:00
|
|
|
@ZodResponse({ status: 200, description: "Migrate/link account", type: LinkWhmcsResponseDto })
|
2025-12-26 17:27:22 +09:00
|
|
|
async migrateAccount(@Body() linkData: LinkWhmcsRequestDto) {
|
2025-11-04 13:28:36 +09:00
|
|
|
const result = await this.authFacade.linkWhmcsUser(linkData);
|
2025-12-26 13:04:15 +09:00
|
|
|
return result;
|
2025-09-18 12:34:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Public()
|
|
|
|
|
@Post("set-password")
|
2025-12-11 11:25:23 +09:00
|
|
|
@UseGuards(RateLimitGuard)
|
|
|
|
|
@RateLimit({ limit: 5, ttl: 600 }) // 5 attempts per 10 minutes per IP+UA (industry standard)
|
2025-09-26 15:51:07 +09:00
|
|
|
async setPassword(
|
2025-12-26 13:04:15 +09:00
|
|
|
@Body() setPasswordData: SetPasswordRequestDto,
|
2025-09-26 15:51:07 +09:00
|
|
|
@Req() _req: Request,
|
|
|
|
|
@Res({ passthrough: true }) res: Response
|
|
|
|
|
) {
|
2025-10-02 16:33:25 +09:00
|
|
|
const result = await this.authFacade.setPassword(setPasswordData);
|
2026-01-19 10:40:50 +09:00
|
|
|
setAuthCookies(res, result.tokens);
|
|
|
|
|
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
2025-09-18 12:34:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Public()
|
|
|
|
|
@Post("check-password-needed")
|
|
|
|
|
@HttpCode(200)
|
2025-12-26 13:04:15 +09:00
|
|
|
@ZodResponse({
|
|
|
|
|
status: 200,
|
|
|
|
|
description: "Check if password is needed",
|
|
|
|
|
type: CheckPasswordNeededResponseDto,
|
|
|
|
|
})
|
|
|
|
|
async checkPasswordNeeded(@Body() data: CheckPasswordNeededRequestDto) {
|
2025-11-04 13:28:36 +09:00
|
|
|
const response = await this.authFacade.checkPasswordNeeded(data.email);
|
2025-12-26 13:04:15 +09:00
|
|
|
return response;
|
2025-09-18 12:34:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Public()
|
|
|
|
|
@Post("request-password-reset")
|
2025-12-11 11:25:23 +09:00
|
|
|
@UseGuards(RateLimitGuard)
|
|
|
|
|
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
|
2025-12-26 13:04:15 +09:00
|
|
|
async requestPasswordReset(@Body() body: PasswordResetRequestDto, @Req() req: Request) {
|
2025-10-02 16:33:25 +09:00
|
|
|
await this.authFacade.requestPasswordReset(body.email, req);
|
2025-09-18 12:34:26 +09:00
|
|
|
return { message: "If an account exists, a reset email has been sent" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Public()
|
|
|
|
|
@Post("reset-password")
|
2025-10-03 17:33:39 +09:00
|
|
|
@HttpCode(200)
|
2025-12-12 15:00:11 +09:00
|
|
|
@UseGuards(RateLimitGuard)
|
|
|
|
|
@RateLimit({ limit: 5, ttl: 900 }) // 5 attempts per 15 minutes (standard for password operations)
|
2025-10-22 10:58:16 +09:00
|
|
|
async resetPassword(
|
2025-12-26 13:04:15 +09:00
|
|
|
@Body() body: ResetPasswordRequestDto,
|
2025-10-22 10:58:16 +09:00
|
|
|
@Res({ passthrough: true }) res: Response
|
|
|
|
|
) {
|
2025-10-03 17:33:39 +09:00
|
|
|
await this.authFacade.resetPassword(body.token, body.password);
|
|
|
|
|
|
|
|
|
|
// Clear auth cookies after password reset to force re-login
|
2026-01-19 10:40:50 +09:00
|
|
|
clearAuthCookies(res);
|
2025-10-03 17:33:39 +09:00
|
|
|
return { message: "Password reset successful" };
|
2025-09-18 12:34:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Post("change-password")
|
2025-12-11 11:25:23 +09:00
|
|
|
@UseGuards(RateLimitGuard)
|
|
|
|
|
@RateLimit({ limit: 5, ttl: 300 }) // 5 attempts per 5 minutes
|
2025-09-18 12:34:26 +09:00
|
|
|
async changePassword(
|
|
|
|
|
@Req() req: Request & { user: { id: string } },
|
2025-12-26 13:04:15 +09:00
|
|
|
@Body() body: ChangePasswordRequestDto,
|
2025-09-26 15:51:07 +09:00
|
|
|
@Res({ passthrough: true }) res: Response
|
2025-09-18 12:34:26 +09:00
|
|
|
) {
|
2025-10-08 10:33:33 +09:00
|
|
|
const result = await this.authFacade.changePassword(req.user.id, body, req);
|
2026-01-19 10:40:50 +09:00
|
|
|
setAuthCookies(res, result.tokens);
|
|
|
|
|
return { user: result.user, session: buildSessionInfo(result.tokens) };
|
2025-09-18 12:34:26 +09:00
|
|
|
}
|
|
|
|
|
|
2026-01-14 13:54:01 +09:00
|
|
|
/**
|
|
|
|
|
* 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()
|
2025-09-18 12:34:26 +09:00
|
|
|
@Get("me")
|
2026-01-14 13:54:01 +09:00
|
|
|
getAuthStatus(@Req() req: Request & { user?: UserAuth }) {
|
|
|
|
|
if (!req.user) {
|
|
|
|
|
return { isAuthenticated: false };
|
|
|
|
|
}
|
2025-12-12 15:00:11 +09:00
|
|
|
return { isAuthenticated: true, user: req.user };
|
2025-09-18 12:34:26 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Post("sso-link")
|
|
|
|
|
async createSsoLink(
|
2025-09-25 11:44:10 +09:00
|
|
|
@Req() req: Request & { user: { id: string } },
|
2025-12-26 13:04:15 +09:00
|
|
|
@Body() body: SsoLinkRequestDto
|
2025-09-18 12:34:26 +09:00
|
|
|
) {
|
|
|
|
|
const destination = body?.destination;
|
2025-10-02 16:33:25 +09:00
|
|
|
return this.authFacade.createSsoLink(req.user.id, destination);
|
2025-09-18 12:34:26 +09:00
|
|
|
}
|
|
|
|
|
}
|