Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 48 additions & 7 deletions api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from "@nestjs/common"
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
} from "@nestjs/common"
import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from "@nestjs/swagger"
ApiCreatedResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
} from "@nestjs/swagger"
import { AuthResponse, AuthService } from "./auth.service"
import { LoginDto } from "./dto/login.dto"
import { RegisterDto } from "./dto/register.dto"
import { ForgotPasswordDto } from "./dto/forgot-password.dto"
import { ResetPasswordDto } from "./dto/reset-password.dto"

@ApiTags("auth")
@Controller("auth")
Expand Down Expand Up @@ -43,4 +44,44 @@ export class AuthController {
login(@Body() dto: LoginDto): Promise<AuthResponse> {
return this.authService.login(dto)
}

@Post("forgot-password")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Request a password reset",
description:
"Accepts an email address and sends password reset instructions if the account exists. Always returns success to avoid email enumeration.",
})
@ApiOkResponse({
description:
"Password reset instructions will be sent if the email exists.",
})
async forgotPassword(
@Body() dto: ForgotPasswordDto,
): Promise<{ message: string }> {
await this.authService.forgotPassword(dto)
return {
message:
"If the email address exists, password reset instructions have been sent.",
}
}

@Post("reset-password")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Reset a forgotten password",
description:
"Accepts a password reset token and a new password. Rejects invalid, expired, or already-used tokens.",
})
@ApiOkResponse({
description: "Password reset successful.",
})
async resetPassword(
@Body() dto: ResetPasswordDto,
): Promise<{ message: string }> {
await this.authService.resetPassword(dto)
return {
message: "Password has been reset successfully.",
}
}
}
8 changes: 7 additions & 1 deletion api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
import { Module } from "@nestjs/common"
import { CacheModule } from "@nestjs/cache-manager"
import { JwtModule } from "@nestjs/jwt"
import { AuthController } from "./auth.controller"
import { AuthService } from "./auth.service"
import { UsersRepository } from "./users.repository"
import { PasswordResetService } from "./password-reset.service"

const JWT_EXPIRES_IN = "15m"

@Module({
imports: [
CacheModule.register({
ttl: 3600,
max: 1024,
}),
JwtModule.register({
secret: process.env.JWT_SECRET ?? "dev-secret-change-me",
signOptions: { expiresIn: JWT_EXPIRES_IN },
}),
],
controllers: [AuthController],
providers: [AuthService, UsersRepository],
providers: [AuthService, PasswordResetService, UsersRepository],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
80 changes: 73 additions & 7 deletions api/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@
create: jest.Mock<Promise<User>>
}

interface MockPasswordResetService {
sendResetToken: jest.Mock<Promise<void>>
resetPassword: jest.Mock<Promise<void>>
}

function mockJwtService(): MockJwtService {
return {
sign: jest.fn(),
Expand All @@ -37,13 +42,22 @@
}
}

function mockPasswordResetService(): MockPasswordResetService {
return {
sendResetToken: jest.fn(),
resetPassword: jest.fn(),
}
}

function makeService(
jwt: MockJwtService,
users: MockUsersRepository,
passwordReset: MockPasswordResetService,
): AuthService {
return new AuthService(
jwt as unknown as JwtService,
users as unknown as UsersRepository,
passwordReset as unknown as any,

Check warning on line 60 in api/src/auth/auth.service.spec.ts

View workflow job for this annotation

GitHub Actions / quality

Unexpected any. Specify a different type
)
}

Expand All @@ -66,12 +80,14 @@
describe("AuthService", () => {
let jwt: MockJwtService
let users: MockUsersRepository
let passwordReset: MockPasswordResetService
let service: AuthService

beforeEach(() => {
jwt = mockJwtService()
users = mockUsersRepository()
service = makeService(jwt, users)
passwordReset = mockPasswordResetService()
service = makeService(jwt, users, passwordReset)
jest.clearAllMocks()
})

Expand All @@ -87,7 +103,9 @@
it("creates a user and returns an access token with user profile", async () => {
users.findByEmail.mockResolvedValue(null)
users.findByUsername.mockResolvedValue(null)
users.create.mockResolvedValue(dummyUser({ email: dto.email, username: dto.username }))
users.create.mockResolvedValue(
dummyUser({ email: dto.email, username: dto.username }),
)
jwt.sign.mockReturnValue("jwt.token.here")
;(bcrypt.hash as jest.Mock).mockResolvedValue("$2b$10$hashed")

Expand All @@ -104,6 +122,7 @@
sub: 1,
email: dto.email,
username: dto.username,
passwordChangedAt: expect.any(Number),
})
expect(result.accessToken).toBe("jwt.token.here")
expect(result.user).toEqual({
Expand All @@ -123,7 +142,9 @@

it("throws ConflictException when the username is already taken", async () => {
users.findByEmail.mockResolvedValue(null)
users.findByUsername.mockResolvedValue(dummyUser({ username: dto.username }))
users.findByUsername.mockResolvedValue(
dummyUser({ username: dto.username }),
)

await expect(service.register(dto)).rejects.toThrow(ConflictException)
expect(users.create).not.toHaveBeenCalled()
Expand All @@ -139,7 +160,8 @@
await service.register(dto)

expect(bcrypt.hash).toHaveBeenCalledWith(dto.password, 12)
const [storedUsername, storedEmail, storedHash] = users.create.mock.calls[0]
const [storedUsername, storedEmail, storedHash] =
users.create.mock.calls[0]
expect(storedUsername).toBe(dto.username)
expect(storedEmail).toBe(dto.email)
expect(storedHash).toBe("$2b$10$hashed")
Expand All @@ -149,11 +171,47 @@
users.findByEmail.mockResolvedValue(dummyUser({ email: "dup@x.com" }))

await expect(
service.register({ username: "dupuser", email: "dup@x.com", password: "someOtherPassword" }),
service.register({
username: "dupuser",
email: "dup@x.com",
password: "someOtherPassword",
}),
).rejects.toThrow(ConflictException)
})
})

// -- forgot password --------------------------------------------------

describe("forgotPassword", () => {
it("delegates reset requests to the password reset service", async () => {
const dto = { email: "user@x.com" }
passwordReset.sendResetToken.mockResolvedValue(undefined)

await service.forgotPassword(dto)

expect(passwordReset.sendResetToken).toHaveBeenCalledWith(dto.email)
})
})

// -- reset password ---------------------------------------------------

describe("resetPassword", () => {
it("delegates password resets to the password reset service", async () => {
const dto = {
token: "reset-token",
password: "NewP4ssw0rd!",
}
passwordReset.resetPassword.mockResolvedValue(undefined)

await service.resetPassword(dto)

expect(passwordReset.resetPassword).toHaveBeenCalledWith(
dto.token,
dto.password,
)
})
})

// -- login -------------------------------------------------------------

describe("login", () => {
Expand All @@ -168,11 +226,15 @@
const result = await service.login(dto)

expect(users.findByEmail).toHaveBeenCalledWith(dto.email)
expect(bcrypt.compare).toHaveBeenCalledWith(dto.password, user.password_hash)
expect(bcrypt.compare).toHaveBeenCalledWith(
dto.password,
user.password_hash,
)
expect(jwt.sign).toHaveBeenCalledWith({
sub: user.id,
email: dto.email,
username: user.username,
passwordChangedAt: expect.any(Number),
})
expect(result.accessToken).toBe("jwt.token.here")
expect(result.user).toEqual({
Expand Down Expand Up @@ -229,11 +291,15 @@

await service.login(dto)

expect(bcrypt.compare).toHaveBeenCalledWith(dto.password, user.password_hash)
expect(bcrypt.compare).toHaveBeenCalledWith(
dto.password,
user.password_hash,
)
expect(jwt.sign).toHaveBeenCalledWith({
sub: user.id,
email: dto.email,
username: user.username,
passwordChangedAt: expect.any(Number),
})
})
})
Expand Down
16 changes: 15 additions & 1 deletion api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { JwtService } from "@nestjs/jwt"
import * as bcrypt from "bcrypt"
import { RegisterDto } from "./dto/register.dto"
import { LoginDto } from "./dto/login.dto"
import { ForgotPasswordDto } from "./dto/forgot-password.dto"
import { ResetPasswordDto } from "./dto/reset-password.dto"
import { User, UsersRepository } from "./users.repository"
import { PasswordResetService } from "./password-reset.service"

/** Rounds for bcrypt key derivation (auto-salt). */
const BCRYPT_ROUNDS = 12
Expand All @@ -30,6 +33,7 @@ export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly usersRepository: UsersRepository,
private readonly passwordResetService: PasswordResetService,
) {}

/**
Expand Down Expand Up @@ -88,18 +92,28 @@ export class AuthService {
}
}

async forgotPassword(dto: ForgotPasswordDto): Promise<void> {
await this.passwordResetService.sendResetToken(dto.email)
}

async resetPassword(dto: ResetPasswordDto): Promise<void> {
await this.passwordResetService.resetPassword(dto.token, dto.password)
}

/** Create a short-lived JWT access token for the given user. */
private signToken(user: User): string {
return this.jwtService.sign({
sub: user.id,
email: user.email,
username: user.username,
passwordChangedAt:
user.password_changed_at?.getTime() ?? user.created_at.getTime(),
})
}
}

/** Strip the password hash from a user row before returning to clients. */
function toSafeUser(row: User): SafeUser {
export function toSafeUser(row: User): SafeUser {
return {
id: row.id,
username: row.username,
Expand Down
12 changes: 12 additions & 0 deletions api/src/auth/dto/forgot-password.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { IsEmail } from "class-validator"
import { ApiProperty } from "@nestjs/swagger"

/** Payload accepted by `POST /auth/forgot-password`. */
export class ForgotPasswordDto {
@ApiProperty({
description: "Registered email address for account recovery.",
example: "user@example.com",
})
@IsEmail()
email!: string
}
27 changes: 27 additions & 0 deletions api/src/auth/dto/reset-password.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { IsString, Length, Matches } from "class-validator"
import { ApiProperty } from "@nestjs/swagger"

/** Payload accepted by `POST /auth/reset-password`. */
export class ResetPasswordDto {
@ApiProperty({
description: "Password reset token received in the email.",
example: "4hB8r9v0Q2uLmT...",
})
@IsString()
token!: string

@ApiProperty({
description:
"New password (minimum 8 characters, at least one letter and one digit).",
example: "NewP4ssw0rd!",
})
@IsString()
@Length(8, 128)
@Matches(/[A-Za-z]/, {
message: "password must contain at least one letter",
})
@Matches(/[0-9]/, {
message: "password must contain at least one digit",
})
password!: string
}
Loading
Loading