From 4f068e673565c74e5c155f22be274da41dd042c3 Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Thu, 21 May 2026 01:26:19 +0100 Subject: [PATCH 1/2] feat(auth): add login with JWT access and refresh tokens --- .../src/domains/users/auth/auth.constants.ts | 21 ++ .../src/domains/users/auth/auth.controller.ts | 141 +++++++++- .../src/domains/users/auth/auth.module.ts | 35 ++- .../src/domains/users/auth/auth.service.ts | 258 +++++++++++++++++- .../src/domains/users/auth/auth.types.ts | 39 +++ .../src/domains/users/auth/dto/login.dto.ts | 14 + .../users/auth/dto/password-reset.dto.ts | 32 +++ .../auth/strategies/jwt-refresh.strategy.ts | 113 ++++++++ .../users/auth/strategies/jwt.strategy.ts | 79 ++++++ .../users/auth/strategies/local.strategy.ts | 4 +- apps/backend/src/modules/auth/auth.module.ts | 7 +- 11 files changed, 732 insertions(+), 11 deletions(-) create mode 100644 apps/backend/src/domains/users/auth/auth.constants.ts create mode 100644 apps/backend/src/domains/users/auth/auth.types.ts create mode 100644 apps/backend/src/domains/users/auth/dto/login.dto.ts create mode 100644 apps/backend/src/domains/users/auth/dto/password-reset.dto.ts create mode 100644 apps/backend/src/domains/users/auth/strategies/jwt-refresh.strategy.ts create mode 100644 apps/backend/src/domains/users/auth/strategies/jwt.strategy.ts diff --git a/apps/backend/src/domains/users/auth/auth.constants.ts b/apps/backend/src/domains/users/auth/auth.constants.ts new file mode 100644 index 00000000..1caab7b5 --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth.constants.ts @@ -0,0 +1,21 @@ +export const AUTH_COOKIE = { + ACCESS: "twizrr_access_token", + REFRESH: "twizrr_refresh_token", +} as const; + +export const AUTH_TTL = { + ACCESS_SECONDS: 15 * 60, + REFRESH_SECONDS: 7 * 24 * 60 * 60, + ACCESS_COOKIE_MS: 15 * 60 * 1000, + REFRESH_COOKIE_MS: 7 * 24 * 60 * 60 * 1000, +} as const; + +export const AUTH_STRATEGY = { + ACCESS: "jwt-access", + REFRESH: "jwt-refresh", +} as const; + +export const REDIRECT_TARGET = { + STORE_DASHBOARD: "/store/dashboard", + EXPLORE: "/explore", +} as const; diff --git a/apps/backend/src/domains/users/auth/auth.controller.ts b/apps/backend/src/domains/users/auth/auth.controller.ts index a6b0f94a..27b796c4 100644 --- a/apps/backend/src/domains/users/auth/auth.controller.ts +++ b/apps/backend/src/domains/users/auth/auth.controller.ts @@ -1,7 +1,22 @@ -import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common"; +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + Req, + Res, + UseGuards, +} from "@nestjs/common"; import { Throttle } from "@nestjs/throttler"; +import type { Request, Response } from "express"; +import { JwtRefreshGuard } from "../../../common/guards/jwt-refresh.guard"; +import { AUTH_COOKIE, AUTH_TTL } from "./auth.constants"; import { AuthService } from "./auth.service"; +import { RefreshRequestUser, SessionMetadata } from "./auth.types"; +import { LoginDto } from "./dto/login.dto"; +import { ForgotPasswordDto, ResetPasswordDto } from "./dto/password-reset.dto"; import { RegisterEmailDto } from "./dto/register.dto"; import { SendOtpDto, @@ -13,18 +28,142 @@ import { export class AuthController { constructor(private readonly authService: AuthService) {} + private setAuthCookies( + response: Response, + tokens: { accessToken: string; refreshToken: string }, + ): void { + const isLocal = process.env.NODE_ENV === "development"; + const secure = !isLocal; + const sameSite = isLocal ? "lax" : "none"; + + response.cookie(AUTH_COOKIE.ACCESS, tokens.accessToken, { + httpOnly: true, + secure, + sameSite, + maxAge: AUTH_TTL.ACCESS_COOKIE_MS, + path: "/", + }); + response.cookie(AUTH_COOKIE.REFRESH, tokens.refreshToken, { + httpOnly: true, + secure, + sameSite, + maxAge: AUTH_TTL.REFRESH_COOKIE_MS, + path: "/", + }); + } + + private clearAuthCookies(response: Response): void { + const isLocal = process.env.NODE_ENV === "development"; + const secure = !isLocal; + const sameSite = isLocal ? "lax" : "none"; + + response.clearCookie(AUTH_COOKIE.ACCESS, { + httpOnly: true, + secure, + sameSite, + path: "/", + }); + response.clearCookie(AUTH_COOKIE.REFRESH, { + httpOnly: true, + secure, + sameSite, + path: "/", + }); + } + + private getSessionMetadata(request: Request): SessionMetadata { + return { + userAgent: request.headers["user-agent"], + ipAddress: request.ip, + }; + } + + private getRefreshCookie(request: Request): string | undefined { + const cookies = ( + request as Request & { + cookies?: Record; + } + ).cookies; + + return cookies?.[AUTH_COOKIE.REFRESH]; + } + @Throttle({ default: { limit: 5, ttl: 3_600_000 } }) @Post("register/email") async registerEmail(@Body() dto: RegisterEmailDto) { return this.authService.registerEmail(dto); } + @Throttle({ default: { limit: 10, ttl: 900_000 } }) + @Post("login") + @HttpCode(HttpStatus.OK) + async login( + @Body() dto: LoginDto, + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + ) { + const result = await this.authService.login( + dto, + this.getSessionMetadata(request), + ); + + this.setAuthCookies(response, result); + + return { redirectTo: result.redirectTo }; + } + + @Post("logout") + @HttpCode(HttpStatus.OK) + async logout( + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + ) { + const refreshToken = this.getRefreshCookie(request); + const result = await this.authService.logout(refreshToken); + this.clearAuthCookies(response); + + return result; + } + + @UseGuards(JwtRefreshGuard) + @Post("refresh") + @HttpCode(HttpStatus.OK) + async refresh( + @Req() request: Request, + @Res({ passthrough: true }) response: Response, + ) { + const refreshUser = (request as Request & { user: RefreshRequestUser }) + .user; + const tokens = await this.authService.refresh( + refreshUser, + this.getSessionMetadata(request), + ); + + this.setAuthCookies(response, tokens); + + return { refreshed: true }; + } + @Post("email/verify") @HttpCode(HttpStatus.OK) async verifyEmail(@Body() dto: VerifyEmailDto) { return this.authService.verifyEmail(dto); } + @Throttle({ default: { limit: 3, ttl: 3_600_000 } }) + @Post("forgot-password") + @HttpCode(HttpStatus.OK) + async forgotPassword(@Body() dto: ForgotPasswordDto) { + return this.authService.forgotPassword(dto); + } + + @Throttle({ default: { limit: 3, ttl: 3_600_000 } }) + @Post("reset-password") + @HttpCode(HttpStatus.OK) + async resetPassword(@Body() dto: ResetPasswordDto) { + return this.authService.resetPassword(dto); + } + @Throttle({ default: { limit: 3, ttl: 3_600_000 } }) @Post("otp/send") @HttpCode(HttpStatus.OK) diff --git a/apps/backend/src/domains/users/auth/auth.module.ts b/apps/backend/src/domains/users/auth/auth.module.ts index 46249970..f2fca1f7 100644 --- a/apps/backend/src/domains/users/auth/auth.module.ts +++ b/apps/backend/src/domains/users/auth/auth.module.ts @@ -1,15 +1,46 @@ import { Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { + JwtModule, + type JwtModuleOptions, + type JwtSignOptions, +} from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; import { AfricasTalkingModule } from "../../../integrations/africastalking/africastalking.module"; import { ResendModule } from "../../../integrations/resend/resend.module"; import { AuthController } from "./auth.controller"; import { AuthService } from "./auth.service"; +import { JwtAccessStrategy } from "./strategies/jwt.strategy"; +import { JwtRefreshStrategy } from "./strategies/jwt-refresh.strategy"; import { LocalStrategy } from "./strategies/local.strategy"; +type JwtExpiresIn = NonNullable; + +const jwtTtl = (configService: ConfigService, key: string): JwtExpiresIn => + configService.getOrThrow(key) as JwtExpiresIn; + @Module({ - imports: [ResendModule, AfricasTalkingModule], + imports: [ + PassportModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService): JwtModuleOptions => ({ + secret: configService.getOrThrow("jwt.accessSecret"), + signOptions: { expiresIn: jwtTtl(configService, "jwt.accessTtl") }, + }), + inject: [ConfigService], + }), + ResendModule, + AfricasTalkingModule, + ], controllers: [AuthController], - providers: [AuthService, LocalStrategy], + providers: [ + AuthService, + LocalStrategy, + JwtAccessStrategy, + JwtRefreshStrategy, + ], exports: [AuthService], }) export class UsersAuthModule {} diff --git a/apps/backend/src/domains/users/auth/auth.service.ts b/apps/backend/src/domains/users/auth/auth.service.ts index 43afceed..e87ecc0d 100644 --- a/apps/backend/src/domains/users/auth/auth.service.ts +++ b/apps/backend/src/domains/users/auth/auth.service.ts @@ -3,15 +3,34 @@ import { ConflictException, Injectable, NotFoundException, + UnauthorizedException, } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; +import { JwtService } from "@nestjs/jwt"; import { OTPPurpose, User, UserRole } from "@prisma/client"; import * as bcrypt from "bcrypt"; -import { createHmac, randomInt, timingSafeEqual } from "crypto"; +import { + createHash, + createHmac, + randomBytes, + randomInt, + randomUUID, + timingSafeEqual, +} from "crypto"; import { PrismaService } from "../../../core/prisma/prisma.service"; import { AfricasTalkingClient } from "../../../integrations/africastalking/africastalking.client"; import { ResendClient } from "../../../integrations/resend/resend.client"; +import { AUTH_TTL, REDIRECT_TARGET } from "./auth.constants"; +import { + AccessTokenPayload, + AuthCookieTokens, + RefreshRequestUser, + RefreshTokenPayload, + SessionMetadata, +} from "./auth.types"; +import { LoginDto } from "./dto/login.dto"; +import { ForgotPasswordDto, ResetPasswordDto } from "./dto/password-reset.dto"; import { RegisterEmailDto } from "./dto/register.dto"; import { SendOtpDto, @@ -24,6 +43,9 @@ const OTP_TTL_MINUTES = 15; const OTP_TTL_MS = OTP_TTL_MINUTES * 60 * 1000; const MINIMUM_REGISTRATION_AGE = 13; const MAX_OTP_ATTEMPTS = 5; +const PASSWORD_RESET_TTL_MS = 15 * 60 * 1000; +const GENERIC_PASSWORD_RESET_MESSAGE = + "If an account exists for that email, a password reset link has been sent."; export interface AuthUserResponse { id: string; @@ -42,9 +64,13 @@ export interface AuthUserResponse { @Injectable() export class AuthService { private readonly otpSecret: string; + private readonly accessTokenSecret: string; + private readonly refreshTokenSecret: string; + private readonly appWebUrl: string; constructor( private readonly prisma: PrismaService, + private readonly jwtService: JwtService, private readonly resendClient: ResendClient, private readonly africasTalkingClient: AfricasTalkingClient, configService: ConfigService, @@ -59,6 +85,14 @@ export class AuthService { } this.otpSecret = configuredSecret; + this.accessTokenSecret = + configService.getOrThrow("jwt.accessSecret"); + this.refreshTokenSecret = + configService.getOrThrow("jwt.refreshSecret"); + this.appWebUrl = + configService.get("app.webUrl") || + configService.get("WEB_URL") || + "http://localhost:3000"; } async registerEmail( @@ -226,6 +260,166 @@ export class AuthService { return this.sendPhoneOtp(dto); } + async login( + dto: LoginDto, + metadata: SessionMetadata, + ): Promise { + const user = await this.prisma.user.findFirst({ + where: { + email: dto.email, + isActive: true, + deletedAt: null, + }, + include: { + storeProfile: { + select: { id: true }, + }, + }, + }); + + if (!user || !(await bcrypt.compare(dto.password, user.passwordHash))) { + throw new UnauthorizedException({ + message: "Invalid email or password", + code: "AUTH_INVALID_CREDENTIALS", + }); + } + + const tokens = await this.createSessionTokens({ + user: { + id: user.id, + email: user.email, + role: user.role, + storeId: user.storeProfile?.id, + }, + metadata, + }); + + return { + ...tokens, + redirectTo: user.storeProfile + ? REDIRECT_TARGET.STORE_DASHBOARD + : REDIRECT_TARGET.EXPLORE, + }; + } + + async refresh( + refreshUser: RefreshRequestUser, + metadata: SessionMetadata, + ): Promise { + const tokens = await this.signTokenPair({ + sub: refreshUser.user.id, + email: refreshUser.user.email, + role: refreshUser.user.role, + storeId: refreshUser.user.storeId, + }); + + await this.prisma.session.update({ + where: { id: refreshUser.sessionId }, + data: { + refreshTokenHash: this.hashToken(tokens.refreshToken), + expiresAt: new Date(Date.now() + AUTH_TTL.REFRESH_COOKIE_MS), + userAgent: metadata.userAgent, + ipAddress: metadata.ipAddress, + }, + }); + + return tokens; + } + + async logout(refreshToken: string | undefined): Promise<{ loggedOut: true }> { + if (!refreshToken) { + return { loggedOut: true }; + } + + await this.prisma.session.updateMany({ + where: { + refreshTokenHash: this.hashToken(refreshToken), + revokedAt: null, + }, + data: { revokedAt: new Date() }, + }); + + return { loggedOut: true }; + } + + async forgotPassword(dto: ForgotPasswordDto): Promise<{ message: string }> { + const user = await this.prisma.user.findFirst({ + where: { + email: dto.email, + isActive: true, + deletedAt: null, + }, + select: { id: true, email: true }, + }); + + if (!user) { + return { message: GENERIC_PASSWORD_RESET_MESSAGE }; + } + + const resetToken = randomBytes(32).toString("hex"); + const resetUrl = `${this.appWebUrl}/reset-password?token=${resetToken}`; + + await this.prisma.user.update({ + where: { id: user.id }, + data: { + resetToken: this.hashToken(resetToken), + resetTokenExpiry: new Date(Date.now() + PASSWORD_RESET_TTL_MS), + }, + }); + + await this.resendClient.sendEmail( + user.email, + "Reset your password", + `

Use this secure link to reset your twizrr password:

Reset password

This link expires in 15 minutes.

`, + ); + + return { message: GENERIC_PASSWORD_RESET_MESSAGE }; + } + + async resetPassword(dto: ResetPasswordDto): Promise<{ reset: true }> { + const user = await this.prisma.user.findFirst({ + where: { + resetToken: this.hashToken(dto.token), + resetTokenExpiry: { gt: new Date() }, + isActive: true, + deletedAt: null, + }, + select: { id: true }, + }); + + if (!user) { + throw new BadRequestException({ + message: "Invalid or expired password reset token", + code: "PASSWORD_RESET_INVALID", + }); + } + + const passwordHash = await bcrypt.hash( + dto.newPassword, + PASSWORD_SALT_ROUNDS, + ); + + await this.prisma.$transaction([ + this.prisma.user.update({ + where: { id: user.id }, + data: { + passwordHash, + resetToken: null, + resetTokenExpiry: null, + }, + }), + this.prisma.session.updateMany({ + where: { + userId: user.id, + revokedAt: null, + }, + data: { revokedAt: new Date() }, + }), + ]); + + return { reset: true }; + } + private async assertUniqueRegistrationFields( email: string, phone: string, @@ -276,6 +470,68 @@ export class AuthService { } } + private async createSessionTokens(params: { + user: { + id: string; + email: string; + role: UserRole; + storeId?: string; + }; + metadata: SessionMetadata; + }): Promise { + const tokens = await this.signTokenPair({ + sub: params.user.id, + email: params.user.email, + role: params.user.role, + storeId: params.user.storeId, + }); + + await this.prisma.session.create({ + data: { + userId: params.user.id, + refreshTokenHash: this.hashToken(tokens.refreshToken), + userAgent: params.metadata.userAgent, + ipAddress: params.metadata.ipAddress, + expiresAt: new Date(Date.now() + AUTH_TTL.REFRESH_COOKIE_MS), + }, + }); + + return tokens; + } + + private async signTokenPair(params: { + sub: string; + email: string; + role: UserRole; + storeId?: string; + }): Promise { + const accessPayload: AccessTokenPayload = { + ...params, + jti: randomUUID(), + }; + const refreshPayload: RefreshTokenPayload = { + ...params, + jti: randomUUID(), + }; + + const [accessToken, refreshToken] = await Promise.all([ + this.jwtService.signAsync(accessPayload, { + secret: this.accessTokenSecret, + expiresIn: AUTH_TTL.ACCESS_SECONDS, + }), + this.jwtService.signAsync(refreshPayload, { + secret: this.refreshTokenSecret, + expiresIn: AUTH_TTL.REFRESH_SECONDS, + }), + ]); + + return { accessToken, refreshToken }; + } + + private hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); + } + private assertAgeAllowed(dateOfBirth: Date): void { if (Number.isNaN(dateOfBirth.getTime()) || dateOfBirth > new Date()) { throw new BadRequestException({ diff --git a/apps/backend/src/domains/users/auth/auth.types.ts b/apps/backend/src/domains/users/auth/auth.types.ts new file mode 100644 index 00000000..cd6cc8fa --- /dev/null +++ b/apps/backend/src/domains/users/auth/auth.types.ts @@ -0,0 +1,39 @@ +import { UserRole } from "@prisma/client"; + +export interface AccessTokenPayload { + sub: string; + email: string; + role: UserRole; + storeId?: string; + jti: string; +} + +export interface RefreshTokenPayload extends AccessTokenPayload {} + +export interface AuthenticatedRequestUser { + id: string; + sub: string; + email: string; + role: UserRole; + storeId?: string; + username: string | null; + displayName: string | null; + emailVerified: boolean; + phoneVerified: boolean; +} + +export interface RefreshRequestUser { + sessionId: string; + refreshToken: string; + user: AuthenticatedRequestUser; +} + +export interface SessionMetadata { + userAgent?: string; + ipAddress?: string; +} + +export interface AuthCookieTokens { + accessToken: string; + refreshToken: string; +} diff --git a/apps/backend/src/domains/users/auth/dto/login.dto.ts b/apps/backend/src/domains/users/auth/dto/login.dto.ts new file mode 100644 index 00000000..27c4bb0e --- /dev/null +++ b/apps/backend/src/domains/users/auth/dto/login.dto.ts @@ -0,0 +1,14 @@ +import { Transform } from "class-transformer"; +import { IsEmail, IsString, MinLength } from "class-validator"; + +export class LoginDto { + @Transform(({ value }) => + typeof value === "string" ? value.trim().toLowerCase() : value, + ) + @IsEmail() + email!: string; + + @IsString() + @MinLength(1) + password!: string; +} diff --git a/apps/backend/src/domains/users/auth/dto/password-reset.dto.ts b/apps/backend/src/domains/users/auth/dto/password-reset.dto.ts new file mode 100644 index 00000000..13d6ca25 --- /dev/null +++ b/apps/backend/src/domains/users/auth/dto/password-reset.dto.ts @@ -0,0 +1,32 @@ +import { Transform } from "class-transformer"; +import { + IsEmail, + IsString, + Matches, + MaxLength, + MinLength, +} from "class-validator"; + +export class ForgotPasswordDto { + @Transform(({ value }) => + typeof value === "string" ? value.trim().toLowerCase() : value, + ) + @IsEmail() + email!: string; +} + +export class ResetPasswordDto { + @IsString() + @MinLength(32) + @MaxLength(256) + token!: string; + + @IsString() + @MinLength(8) + @MaxLength(128) + @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d]).+$/, { + message: + "newPassword must contain uppercase, lowercase, number, and special character", + }) + newPassword!: string; +} diff --git a/apps/backend/src/domains/users/auth/strategies/jwt-refresh.strategy.ts b/apps/backend/src/domains/users/auth/strategies/jwt-refresh.strategy.ts new file mode 100644 index 00000000..98f013a5 --- /dev/null +++ b/apps/backend/src/domains/users/auth/strategies/jwt-refresh.strategy.ts @@ -0,0 +1,113 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; +import type { Request } from "express"; +import { createHash } from "crypto"; + +import { PrismaService } from "../../../../core/prisma/prisma.service"; +import { AUTH_COOKIE, AUTH_STRATEGY } from "../auth.constants"; +import { + AuthenticatedRequestUser, + RefreshRequestUser, + RefreshTokenPayload, +} from "../auth.types"; + +type RequestWithCookies = Request & { + cookies?: Record; +}; + +const refreshTokenExtractor = (request: RequestWithCookies): string | null => { + return request.cookies?.[AUTH_COOKIE.REFRESH] || null; +}; + +@Injectable() +export class JwtRefreshStrategy extends PassportStrategy( + Strategy, + AUTH_STRATEGY.REFRESH, +) { + constructor( + configService: ConfigService, + private readonly prisma: PrismaService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([refreshTokenExtractor]), + ignoreExpiration: false, + passReqToCallback: true, + secretOrKey: configService.getOrThrow("jwt.refreshSecret"), + }); + } + + async validate( + request: RequestWithCookies, + payload: RefreshTokenPayload, + ): Promise { + const refreshToken = refreshTokenExtractor(request); + + if (!refreshToken) { + throw new UnauthorizedException({ + message: "Refresh token required", + code: "AUTH_REFRESH_REQUIRED", + }); + } + + const session = await this.prisma.session.findFirst({ + where: { + userId: payload.sub, + refreshTokenHash: this.hashToken(refreshToken), + revokedAt: null, + expiresAt: { gt: new Date() }, + user: { + isActive: true, + deletedAt: null, + }, + }, + include: { + user: { + select: { + id: true, + email: true, + role: true, + username: true, + displayName: true, + emailVerified: true, + phoneVerified: true, + storeProfile: { + select: { id: true }, + }, + }, + }, + }, + }); + + if (!session) { + throw new UnauthorizedException({ + message: "Invalid refresh token", + code: "AUTH_REFRESH_INVALID", + }); + } + + const storeId = session.user.storeProfile?.id; + const user: AuthenticatedRequestUser = { + id: session.user.id, + sub: session.user.id, + email: session.user.email, + role: session.user.role, + storeId, + username: session.user.username, + displayName: session.user.displayName, + emailVerified: session.user.emailVerified, + phoneVerified: session.user.phoneVerified, + }; + + return { + sessionId: session.id, + refreshToken, + user, + }; + } + + private hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); + } +} diff --git a/apps/backend/src/domains/users/auth/strategies/jwt.strategy.ts b/apps/backend/src/domains/users/auth/strategies/jwt.strategy.ts new file mode 100644 index 00000000..f3e64e48 --- /dev/null +++ b/apps/backend/src/domains/users/auth/strategies/jwt.strategy.ts @@ -0,0 +1,79 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; +import type { Request } from "express"; + +import { PrismaService } from "../../../../core/prisma/prisma.service"; +import { AUTH_COOKIE, AUTH_STRATEGY } from "../auth.constants"; +import { AccessTokenPayload, AuthenticatedRequestUser } from "../auth.types"; + +type RequestWithCookies = Request & { + cookies?: Record; +}; + +const accessTokenExtractor = (request: RequestWithCookies): string | null => { + return request.cookies?.[AUTH_COOKIE.ACCESS] || null; +}; + +@Injectable() +export class JwtAccessStrategy extends PassportStrategy( + Strategy, + AUTH_STRATEGY.ACCESS, +) { + constructor( + configService: ConfigService, + private readonly prisma: PrismaService, + ) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([accessTokenExtractor]), + ignoreExpiration: false, + secretOrKey: configService.getOrThrow("jwt.accessSecret"), + }); + } + + async validate( + payload: AccessTokenPayload, + ): Promise { + const user = await this.prisma.user.findFirst({ + where: { + id: payload.sub, + isActive: true, + deletedAt: null, + }, + select: { + id: true, + email: true, + role: true, + username: true, + displayName: true, + emailVerified: true, + phoneVerified: true, + storeProfile: { + select: { id: true }, + }, + }, + }); + + if (!user) { + throw new UnauthorizedException({ + message: "Invalid token", + code: "AUTH_INVALID_TOKEN", + }); + } + + const storeId = user.storeProfile?.id; + + return { + id: user.id, + sub: user.id, + email: user.email, + role: user.role, + storeId, + username: user.username, + displayName: user.displayName, + emailVerified: user.emailVerified, + phoneVerified: user.phoneVerified, + }; + } +} diff --git a/apps/backend/src/domains/users/auth/strategies/local.strategy.ts b/apps/backend/src/domains/users/auth/strategies/local.strategy.ts index 8034dbb4..9068ad2c 100644 --- a/apps/backend/src/domains/users/auth/strategies/local.strategy.ts +++ b/apps/backend/src/domains/users/auth/strategies/local.strategy.ts @@ -4,8 +4,8 @@ import { Injectable, UnauthorizedException } from "@nestjs/common"; export class LocalStrategy { validate(): never { throw new UnauthorizedException({ - message: "Password login is not available in this task", - code: "LOGIN_NOT_IMPLEMENTED", + message: "Use POST /auth/login for password authentication", + code: "AUTH_ROUTE_REQUIRED", }); } } diff --git a/apps/backend/src/modules/auth/auth.module.ts b/apps/backend/src/modules/auth/auth.module.ts index 4f45008a..76d88c23 100644 --- a/apps/backend/src/modules/auth/auth.module.ts +++ b/apps/backend/src/modules/auth/auth.module.ts @@ -6,11 +6,8 @@ import { type JwtSignOptions, } from "@nestjs/jwt"; import { ConfigModule, ConfigService } from "@nestjs/config"; -import { AuthController } from "./auth.controller"; import { InternalAuthController } from "./internal-auth.controller"; import { AuthService } from "./auth.service"; -import { JwtAccessStrategy } from "./strategies/jwt-access.strategy"; -import { JwtRefreshStrategy } from "./strategies/jwt-refresh.strategy"; import { PrismaModule } from "../../prisma/prisma.module"; import { RedisModule } from "../../redis/redis.module"; import { AdminModule } from "../admin/admin.module"; @@ -42,8 +39,8 @@ const jwtTtl = (configService: ConfigService, key: string): JwtExpiresIn => forwardRef(() => AdminModule), forwardRef(() => WhatsAppModule), ], - controllers: [AuthController, InternalAuthController], - providers: [AuthService, JwtAccessStrategy, JwtRefreshStrategy], + controllers: [InternalAuthController], + providers: [AuthService], exports: [AuthService], }) export class AuthModule {} From 7ecd6ec0467081a2a852c347a06f06e8faa392db Mon Sep 17 00:00:00 2001 From: AliameenXBT Date: Thu, 21 May 2026 01:46:02 +0100 Subject: [PATCH 2/2] fix(auth): address jwt review feedback --- .../src/domains/users/auth/auth.controller.ts | 16 +++++++++++----- .../backend/src/domains/users/auth/auth.types.ts | 9 +++++++-- .../auth/strategies/jwt-refresh.strategy.ts | 10 +++------- .../users/auth/strategies/jwt.strategy.ts | 15 +++++++-------- 4 files changed, 28 insertions(+), 22 deletions(-) diff --git a/apps/backend/src/domains/users/auth/auth.controller.ts b/apps/backend/src/domains/users/auth/auth.controller.ts index 27b796c4..0eb94e09 100644 --- a/apps/backend/src/domains/users/auth/auth.controller.ts +++ b/apps/backend/src/domains/users/auth/auth.controller.ts @@ -28,6 +28,10 @@ import { export class AuthController { constructor(private readonly authService: AuthService) {} + private successEnvelope(data: T): { success: true; data: T } { + return { success: true, data }; + } + private setAuthCookies( response: Response, tokens: { accessToken: string; refreshToken: string }, @@ -109,7 +113,7 @@ export class AuthController { this.setAuthCookies(response, result); - return { redirectTo: result.redirectTo }; + return this.successEnvelope({ redirectTo: result.redirectTo }); } @Post("logout") @@ -122,7 +126,7 @@ export class AuthController { const result = await this.authService.logout(refreshToken); this.clearAuthCookies(response); - return result; + return this.successEnvelope(result); } @UseGuards(JwtRefreshGuard) @@ -141,7 +145,7 @@ export class AuthController { this.setAuthCookies(response, tokens); - return { refreshed: true }; + return this.successEnvelope({ refreshed: true }); } @Post("email/verify") @@ -154,14 +158,16 @@ export class AuthController { @Post("forgot-password") @HttpCode(HttpStatus.OK) async forgotPassword(@Body() dto: ForgotPasswordDto) { - return this.authService.forgotPassword(dto); + const result = await this.authService.forgotPassword(dto); + return this.successEnvelope(result); } @Throttle({ default: { limit: 3, ttl: 3_600_000 } }) @Post("reset-password") @HttpCode(HttpStatus.OK) async resetPassword(@Body() dto: ResetPasswordDto) { - return this.authService.resetPassword(dto); + const result = await this.authService.resetPassword(dto); + return this.successEnvelope(result); } @Throttle({ default: { limit: 3, ttl: 3_600_000 } }) diff --git a/apps/backend/src/domains/users/auth/auth.types.ts b/apps/backend/src/domains/users/auth/auth.types.ts index cd6cc8fa..0acf5499 100644 --- a/apps/backend/src/domains/users/auth/auth.types.ts +++ b/apps/backend/src/domains/users/auth/auth.types.ts @@ -1,4 +1,5 @@ import { UserRole } from "@prisma/client"; +import type { Request } from "express"; export interface AccessTokenPayload { sub: string; @@ -18,8 +19,8 @@ export interface AuthenticatedRequestUser { storeId?: string; username: string | null; displayName: string | null; - emailVerified: boolean; - phoneVerified: boolean; + isEmailVerified: boolean; + isPhoneVerified: boolean; } export interface RefreshRequestUser { @@ -37,3 +38,7 @@ export interface AuthCookieTokens { accessToken: string; refreshToken: string; } + +export type RequestWithCookies = Request & { + cookies?: Record; +}; diff --git a/apps/backend/src/domains/users/auth/strategies/jwt-refresh.strategy.ts b/apps/backend/src/domains/users/auth/strategies/jwt-refresh.strategy.ts index 98f013a5..c90d671c 100644 --- a/apps/backend/src/domains/users/auth/strategies/jwt-refresh.strategy.ts +++ b/apps/backend/src/domains/users/auth/strategies/jwt-refresh.strategy.ts @@ -2,7 +2,6 @@ import { Injectable, UnauthorizedException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { PassportStrategy } from "@nestjs/passport"; import { ExtractJwt, Strategy } from "passport-jwt"; -import type { Request } from "express"; import { createHash } from "crypto"; import { PrismaService } from "../../../../core/prisma/prisma.service"; @@ -11,12 +10,9 @@ import { AuthenticatedRequestUser, RefreshRequestUser, RefreshTokenPayload, + RequestWithCookies, } from "../auth.types"; -type RequestWithCookies = Request & { - cookies?: Record; -}; - const refreshTokenExtractor = (request: RequestWithCookies): string | null => { return request.cookies?.[AUTH_COOKIE.REFRESH] || null; }; @@ -96,8 +92,8 @@ export class JwtRefreshStrategy extends PassportStrategy( storeId, username: session.user.username, displayName: session.user.displayName, - emailVerified: session.user.emailVerified, - phoneVerified: session.user.phoneVerified, + isEmailVerified: session.user.emailVerified, + isPhoneVerified: session.user.phoneVerified, }; return { diff --git a/apps/backend/src/domains/users/auth/strategies/jwt.strategy.ts b/apps/backend/src/domains/users/auth/strategies/jwt.strategy.ts index f3e64e48..d802e451 100644 --- a/apps/backend/src/domains/users/auth/strategies/jwt.strategy.ts +++ b/apps/backend/src/domains/users/auth/strategies/jwt.strategy.ts @@ -2,15 +2,14 @@ import { Injectable, UnauthorizedException } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; import { PassportStrategy } from "@nestjs/passport"; import { ExtractJwt, Strategy } from "passport-jwt"; -import type { Request } from "express"; import { PrismaService } from "../../../../core/prisma/prisma.service"; import { AUTH_COOKIE, AUTH_STRATEGY } from "../auth.constants"; -import { AccessTokenPayload, AuthenticatedRequestUser } from "../auth.types"; - -type RequestWithCookies = Request & { - cookies?: Record; -}; +import { + AccessTokenPayload, + AuthenticatedRequestUser, + RequestWithCookies, +} from "../auth.types"; const accessTokenExtractor = (request: RequestWithCookies): string | null => { return request.cookies?.[AUTH_COOKIE.ACCESS] || null; @@ -72,8 +71,8 @@ export class JwtAccessStrategy extends PassportStrategy( storeId, username: user.username, displayName: user.displayName, - emailVerified: user.emailVerified, - phoneVerified: user.phoneVerified, + isEmailVerified: user.emailVerified, + isPhoneVerified: user.phoneVerified, }; } }