diff --git a/backend/src/auth/README.md b/backend/src/auth/README.md index 277d1bcb..ed39849a 100644 --- a/backend/src/auth/README.md +++ b/backend/src/auth/README.md @@ -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 diff --git a/backend/src/auth/auth.controller.ts b/backend/src/auth/auth.controller.ts index 653b069c..eb3d9aef 100644 --- a/backend/src/auth/auth.controller.ts +++ b/backend/src/auth/auth.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, + HttpCode, Param, ParseUUIDPipe, Patch, @@ -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'; @@ -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, @@ -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', @@ -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 { + await this.authService.resetPassword(dto); + res.clearCookie('accessToken', COOKIE_OPTIONS); + } + @Patch('users/:id/role') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN) diff --git a/backend/src/auth/auth.service.spec.ts b/backend/src/auth/auth.service.spec.ts index cf461c85..5ac5c6dc 100644 --- a/backend/src/auth/auth.service.spec.ts +++ b/backend/src/auth/auth.service.spec.ts @@ -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(); diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 258940eb..12694ec9 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -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'; @@ -43,10 +48,13 @@ function getDummyPasswordHash(): Promise { @Injectable() export class AuthService { + private readonly logger = new Logger(AuthService.name); + constructor( @InjectRepository(User) private readonly users: Repository, private readonly jwtService: JwtService, + private readonly config: ConfigService, ) {} async register(dto: RegisterDto): Promise { @@ -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 { + 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('SMTP_HOST'); + const username = this.config.get('SMTP_USER'); + const password = this.config.get('SMTP_PASS'); + const from = this.config.get('SMTP_FROM_EMAIL'); + if (!host || !username || !password || !from) return; + + const transport = createTransport({ + host, + port: this.config.get('SMTP_PORT', 587), + secure: this.config.get('SMTP_SECURE', 'false') === 'true', + auth: { user: username, pass: password }, + }); + const frontendUrl = this.config.get( + '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 { + 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 diff --git a/backend/src/auth/dto/change-password.dto.spec.ts b/backend/src/auth/dto/change-password.dto.spec.ts new file mode 100644 index 00000000..803a578a --- /dev/null +++ b/backend/src/auth/dto/change-password.dto.spec.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/backend/src/auth/dto/change-password.dto.ts b/backend/src/auth/dto/change-password.dto.ts index c894966b..9205fc3e 100644 --- a/backend/src/auth/dto/change-password.dto.ts +++ b/backend/src/auth/dto/change-password.dto.ts @@ -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' }) @@ -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; } diff --git a/backend/src/auth/dto/forgot-password.dto.ts b/backend/src/auth/dto/forgot-password.dto.ts new file mode 100644 index 00000000..dff5e356 --- /dev/null +++ b/backend/src/auth/dto/forgot-password.dto.ts @@ -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; +} \ No newline at end of file diff --git a/backend/src/auth/dto/reset-password.dto.ts b/backend/src/auth/dto/reset-password.dto.ts new file mode 100644 index 00000000..4344ed75 --- /dev/null +++ b/backend/src/auth/dto/reset-password.dto.ts @@ -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; +} \ No newline at end of file diff --git a/backend/src/auth/entities/user.entity.ts b/backend/src/auth/entities/user.entity.ts index 6e74f108..56347f7b 100644 --- a/backend/src/auth/entities/user.entity.ts +++ b/backend/src/auth/entities/user.entity.ts @@ -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 diff --git a/backend/src/database/migrations/1790008000000-AddPasswordResetTokens.ts b/backend/src/database/migrations/1790008000000-AddPasswordResetTokens.ts new file mode 100644 index 00000000..f5611ec8 --- /dev/null +++ b/backend/src/database/migrations/1790008000000-AddPasswordResetTokens.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddPasswordResetTokens1790008000000 implements MigrationInterface { + name = 'AddPasswordResetTokens1790008000000'; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + await queryRunner.query(` + ALTER TABLE "users" + DROP COLUMN "password_reset_expires_at", + DROP COLUMN "password_reset_token_hash" + `); + } +} \ No newline at end of file diff --git a/frontend/app/account/page.tsx b/frontend/app/account/page.tsx new file mode 100644 index 00000000..da9c7b47 --- /dev/null +++ b/frontend/app/account/page.tsx @@ -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 ( +
+
+

ManageHub

+

Account

+
+ +
+ ); +} \ No newline at end of file diff --git a/frontend/app/forgot-password/page.tsx b/frontend/app/forgot-password/page.tsx new file mode 100644 index 00000000..99acd2bd --- /dev/null +++ b/frontend/app/forgot-password/page.tsx @@ -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 ; +} \ No newline at end of file diff --git a/frontend/app/reset-password/page.tsx b/frontend/app/reset-password/page.tsx new file mode 100644 index 00000000..5bef0b88 --- /dev/null +++ b/frontend/app/reset-password/page.tsx @@ -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 ( + Loading...}> + + + ); +} \ No newline at end of file diff --git a/frontend/components/auth/account-settings.tsx b/frontend/components/auth/account-settings.tsx new file mode 100644 index 00000000..8da0e283 --- /dev/null +++ b/frontend/components/auth/account-settings.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { Button, inputClass } from "@/components/app-ui"; +import { changePassword, logoutEverywhere } from "@/lib/auth-api"; +import { useSessionBootstrap } from "@/lib/stores/use-session-bootstrap"; +import { useSessionStore } from "@/lib/stores/session-store"; + +export function AccountSettings() { + useSessionBootstrap(); + const router = useRouter(); + const queryClient = useQueryClient(); + const user = useSessionStore((state) => state.user); + const status = useSessionStore((state) => state.status); + const setSession = useSessionStore((state) => state.setSession); + const clearSession = useSessionStore((state) => state.clearSession); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + const [busy, setBusy] = useState(false); + + async function submitPassword(event: React.FormEvent) { + event.preventDefault(); + setError(null); + setMessage(null); + if (newPassword !== confirmPassword) { + setError("New passwords do not match."); + return; + } + setBusy(true); + try { + const result = await changePassword(currentPassword, newPassword); + setSession(result.user, result.accessToken); + setCurrentPassword(""); + setNewPassword(""); + setConfirmPassword(""); + setMessage("Password changed. Other sessions have been signed out."); + } catch (submitError) { + setError(submitError instanceof Error ? submitError.message : "Unable to change password."); + } finally { + setBusy(false); + } + } + + async function signOutEverywhere() { + setBusy(true); + setError(null); + try { + await logoutEverywhere(); + queryClient.clear(); + clearSession(); + router.replace("/login"); + } catch (signOutError) { + setError(signOutError instanceof Error ? signOutError.message : "Unable to sign out everywhere."); + } finally { + setBusy(false); + } + } + + if (status !== "authenticated" || !user) { + return

Loading account...

; + } + + return ( +
+
+

Profile

+
+
+
Email
+
{user.email}
+
+
+
Role
+
{user.role === "user" ? "Member" : user.role}
+
+
+
+ +
+

Change password

+

Use at least 8 characters and avoid common passwords.

+
+ {error &&

{error}

} + {message &&

{message}

} + + + + +
+
+ +
+

Sessions

+

Sign out this account on every device, including this one.

+ +
+
+ ); +} \ No newline at end of file diff --git a/frontend/components/auth/auth-form.tsx b/frontend/components/auth/auth-form.tsx index 1b329e1a..5c58c7ea 100644 --- a/frontend/components/auth/auth-form.tsx +++ b/frontend/components/auth/auth-form.tsx @@ -168,6 +168,17 @@ export function AuthForm({ mode }: { mode: Mode }) { )} + {isLogin && ( +
+ + Forgot password? + +
+ )} + {!isLogin && (