Skip to content
Open
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
15 changes: 15 additions & 0 deletions backend/src/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,21 @@ or by seeding a user with `role = 'admin'` (see `backend/scripts/demo-data.js`).
After changing the role, the user must log in again so a fresh JWT carries the
new `role` claim (the token embeds the role at issue time).

## Account and password recovery

Authenticated users can read their own profile at `GET /auth/me`, change a
password at `POST /auth/change-password`, or revoke every session at
`POST /auth/logout-all`. Password changes retain the current session with a
fresh token and revoke all other sessions.

`POST /auth/forgot-password` responds generically whether or not an email is
registered. For registered accounts, it sends a one-hour reset link using
`SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, `SMTP_PASS`, and
`SMTP_FROM_EMAIL`; `FRONTEND_URL` determines the link origin. Reset tokens are
stored only as SHA-256 hashes and can be used once. Run the
`AddPasswordResetTokens1790008000000` database migration before deploying this
flow. Completing a reset revokes all existing sessions.

## Sequence diagram

```mermaid
Expand Down
25 changes: 25 additions & 0 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Body,
Controller,
Get,
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Expand All @@ -14,6 +15,8 @@ import { AuthService } from './auth.service';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { UpdateUserRoleDto } from './dto/update-user-role.dto';
import { AuthResponseDto, AuthUserDto } from './dto/auth-response.dto';
import { Response, CookieOptions } from 'express';
Expand All @@ -23,6 +26,7 @@ import { Roles } from './decorators/roles.decorator';
import { CurrentUser } from './decorators/current-user.decorator';
import { RequestUser } from './interfaces/authenticated-request.interface';
import { UserRole } from './enums/user-role.enum';
import { Throttle } from '@nestjs/throttler';

const COOKIE_OPTIONS: CookieOptions = {
httpOnly: true,
Expand Down Expand Up @@ -84,6 +88,7 @@ export class AuthController {
}

@Post('logout-all')
@HttpCode(204)
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Sign out everywhere: revoke every token issued to this user',
Expand Down Expand Up @@ -112,6 +117,26 @@ export class AuthController {
return result;
}

@Post('forgot-password')
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: 'Email a single-use password reset link when the account exists' })
async forgotPassword(@Body() dto: ForgotPasswordDto): Promise<{ message: string }> {
await this.authService.requestPasswordReset(dto.email);
return { message: 'If an account exists for that email, a reset link has been sent.' };
}

@Post('reset-password')
@HttpCode(204)
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: 'Reset a password using a single-use email token' })
async resetPassword(
@Body() dto: ResetPasswordDto,
@Res({ passthrough: true }) res: Response,
): Promise<void> {
await this.authService.resetPassword(dto);
res.clearCookie('accessToken', COOKIE_OPTIONS);
}

@Patch('users/:id/role')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
Expand Down
7 changes: 6 additions & 1 deletion backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ describe('AuthService', () => {
const jwtService = {
signAsync: jest.fn(async (payload) => `jwt:${payload.sub}:${payload.role}`),
};
const service = new AuthService(users as any, jwtService as any);
const configService = { get: jest.fn() };
const service = new AuthService(
users as any,
jwtService as any,
configService as any,
);

beforeEach(() => {
jest.clearAllMocks();
Expand Down
88 changes: 87 additions & 1 deletion backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import {
ConflictException,
Injectable,
Logger,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { compare, hash } from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { JwtService } from '@nestjs/jwt';
import { Repository } from 'typeorm';
import { MoreThan, Repository } from 'typeorm';
import { createTransport } from 'nodemailer';
import { User } from './entities/user.entity';
import { UserRole } from './enums/user-role.enum';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { ChangePasswordDto } from './dto/change-password.dto';
import { ResetPasswordDto } from './dto/reset-password.dto';
import { AuthResponseDto, AuthUserDto } from './dto/auth-response.dto';
import { RequestUser } from './interfaces/authenticated-request.interface';

Expand Down Expand Up @@ -43,10 +48,13 @@ function getDummyPasswordHash(): Promise<string> {

@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);

constructor(
@InjectRepository(User)
private readonly users: Repository<User>,
private readonly jwtService: JwtService,
private readonly config: ConfigService,
) {}

async register(dto: RegisterDto): Promise<AuthResponseDto> {
Expand Down Expand Up @@ -146,10 +154,88 @@ export class AuthService {

user.passwordHash = await hash(dto.newPassword, 12);
user.tokenVersion = (user.tokenVersion ?? 0) + 1;
user.passwordResetTokenHash = null;
user.passwordResetExpiresAt = null;
const saved = await this.users.save(user);
return this.issueToken(saved);
}

async requestPasswordReset(emailAddress: string): Promise<void> {
const email = emailAddress.trim().toLowerCase();
const user = await this.users.findOne({ where: { email } });
if (!user) return;

const token = randomBytes(32).toString('hex');
user.passwordResetTokenHash = this.hashResetToken(token);
user.passwordResetExpiresAt = new Date(Date.now() + 60 * 60 * 1000);
await this.users.save(user);

const host = this.config.get<string>('SMTP_HOST');
const username = this.config.get<string>('SMTP_USER');
const password = this.config.get<string>('SMTP_PASS');
const from = this.config.get<string>('SMTP_FROM_EMAIL');
if (!host || !username || !password || !from) return;

const transport = createTransport({
host,
port: this.config.get<number>('SMTP_PORT', 587),
secure: this.config.get<string>('SMTP_SECURE', 'false') === 'true',
auth: { user: username, pass: password },
});
const frontendUrl = this.config.get<string>(
'FRONTEND_URL',
'http://localhost:3000',
);
const resetUrl = new URL('/reset-password', frontendUrl);
resetUrl.searchParams.set('token', token);
try {
await transport.sendMail({
from,
to: user.email,
subject: 'Reset your ManageHub password',
text: `Use this link to reset your password. It expires in one hour: ${resetUrl.toString()}`,
});
} catch (error) {
this.logger.warn(
`Unable to send password reset email: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

async resetPassword(dto: ResetPasswordDto): Promise<void> {
const tokenHash = this.hashResetToken(dto.token);
const user = await this.users.findOne({
where: {
passwordResetTokenHash: tokenHash,
passwordResetExpiresAt: MoreThan(new Date()),
},
});
if (!user) {
throw new UnauthorizedException('Password reset link is invalid or expired');
}

const result = await this.users.update(
{
id: user.id,
passwordResetTokenHash: tokenHash,
passwordResetExpiresAt: MoreThan(new Date()),
},
{
passwordHash: await hash(dto.newPassword, BCRYPT_ROUNDS),
tokenVersion: () => '"token_version" + 1',
passwordResetTokenHash: null,
passwordResetExpiresAt: null,
},
);
if (!result.affected) {
throw new UnauthorizedException('Password reset link is invalid or expired');
}
}

private hashResetToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}

/**
* Admin role change. A role is baked into the token as a claim, so the
* version is bumped in the same write to force affected users to
Expand Down
33 changes: 33 additions & 0 deletions backend/src/auth/dto/change-password.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { ChangePasswordDto } from './change-password.dto';

async function validateChangePassword(newPassword: string) {
return validate(
plainToInstance(ChangePasswordDto, {
currentPassword: 'current password',
newPassword,
}),
);
}

describe('ChangePasswordDto password validation', () => {
it('accepts a strong password within bcrypt limits', async () => {
await expect(validateChangePassword('a suitably long new passphrase')).resolves.toHaveLength(0);
});

it('rejects passwords shorter than eight characters', async () => {
const errors = await validateChangePassword('short');
expect(errors[0].constraints).toHaveProperty('minLength');
});

it('rejects passwords longer than bcrypt can use', async () => {
const errors = await validateChangePassword('a'.repeat(73));
expect(errors[0].constraints).toHaveProperty('maxLength');
});

it('rejects common passwords', async () => {
const errors = await validateChangePassword('Password123');
expect(errors[0].constraints).toHaveProperty('isNotCommonPassword');
});
});
11 changes: 9 additions & 2 deletions backend/src/auth/dto/change-password.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
import {
IsNotCommonPassword,
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
} from './password-policy';

export class ChangePasswordDto {
@ApiProperty({ example: 'correct horse battery staple' })
Expand All @@ -9,6 +14,8 @@ export class ChangePasswordDto {

@ApiProperty({ example: 'a new correct horse battery staple' })
@IsString()
@MinLength(8)
@MinLength(PASSWORD_MIN_LENGTH)
@MaxLength(PASSWORD_MAX_LENGTH)
@IsNotCommonPassword()
newPassword: string;
}
8 changes: 8 additions & 0 deletions backend/src/auth/dto/forgot-password.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail } from 'class-validator';

export class ForgotPasswordDto {
@ApiProperty({ example: 'user@example.com' })
@IsEmail()
email: string;
}
21 changes: 21 additions & 0 deletions backend/src/auth/dto/reset-password.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MaxLength, MinLength } from 'class-validator';
import {
IsNotCommonPassword,
PASSWORD_MAX_LENGTH,
PASSWORD_MIN_LENGTH,
} from './password-policy';

export class ResetPasswordDto {
@ApiProperty()
@IsString()
@MinLength(32)
token: string;

@ApiProperty({ example: 'a new correct horse battery staple' })
@IsString()
@MinLength(PASSWORD_MIN_LENGTH)
@MaxLength(PASSWORD_MAX_LENGTH)
@IsNotCommonPassword()
newPassword: string;
}
6 changes: 6 additions & 0 deletions backend/src/auth/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export class User {
@Column({ type: 'int', name: 'token_version', default: 0 })
tokenVersion: number;

@Column({ type: 'varchar', name: 'password_reset_token_hash', nullable: true })
passwordResetTokenHash: string | null;

@Column({ type: 'timestamptz', name: 'password_reset_expires_at', nullable: true })
passwordResetExpiresAt: Date | null;

/**
* Timestamp of the most recent successful password login. This is written
* only by AuthService.login after password comparison succeeds — never on
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddPasswordResetTokens1790008000000 implements MigrationInterface {
name = 'AddPasswordResetTokens1790008000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "users"
ADD COLUMN "password_reset_token_hash" varchar,
ADD COLUMN "password_reset_expires_at" TIMESTAMPTZ
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE "users"
DROP COLUMN "password_reset_expires_at",
DROP COLUMN "password_reset_token_hash"
`);
}
}
16 changes: 16 additions & 0 deletions frontend/app/account/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
import { AccountSettings } from "@/components/auth/account-settings";

export const metadata: Metadata = { title: "Account" };

export default function AccountPage() {
return (
<main className="mx-auto max-w-4xl px-6 py-10">
<header className="mb-8 border-b border-gray-200 pb-5 dark:border-gray-800">
<p className="text-xs font-semibold uppercase text-blue-700 dark:text-blue-400">ManageHub</p>
<h1 className="mt-2 text-2xl font-semibold">Account</h1>
</header>
<AccountSettings />
</main>
);
}
8 changes: 8 additions & 0 deletions frontend/app/forgot-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { Metadata } from "next";
import { ForgotPasswordForm } from "@/components/auth/forgot-password-form";

export const metadata: Metadata = { title: "Reset password" };

export default function ForgotPasswordPage() {
return <ForgotPasswordForm />;
}
13 changes: 13 additions & 0 deletions frontend/app/reset-password/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
import { Suspense } from "react";
import { ResetPasswordForm } from "@/components/auth/reset-password-form";

export const metadata: Metadata = { title: "Choose a new password" };

export default function ResetPasswordPage() {
return (
<Suspense fallback={<main className="mx-auto max-w-md px-6 py-16">Loading...</main>}>
<ResetPasswordForm />
</Suspense>
);
}
Loading
Loading