From 36608177f696934099d026c9207a1e3162ab4b2e Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sun, 5 Jul 2026 00:42:22 +0200 Subject: [PATCH] Add user, identity, session, and auth-support data model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prisma models per data-model.md: users (status enum, site-admin flag), user_identities (password provider now, OIDC later — subject is the stable user id), sessions (hashed ids), auth_tokens (hashed, single- use), plus rate_limits and mail_outbox for the upcoming M1 stories. UsersService creates accounts transactionally with Argon2id-hashed password identities (OWASP parameters, rehash detection) and maps uniqueness violations to field-level conflicts. Database-backed suites run when TEST_DATABASE_URL is set — locally against the dev db, in CI via a new postgres service container; shared auth schemas (username, password policy incl. common-password blocklist) ship with tests. Closes #10 Co-Authored-By: Claude Fable 5 --- .gitea/workflows/ci.yml | 10 ++ apps/api/package.json | 1 + .../20260704224132_auth_models/migration.sql | 123 ++++++++++++++++++ apps/api/prisma/schema.prisma | 118 +++++++++++++++++ apps/api/src/app.module.ts | 2 + apps/api/src/testing/test-db.ts | 17 +++ apps/api/src/users/password.test.ts | 20 +++ apps/api/src/users/password.ts | 26 ++++ apps/api/src/users/users.module.ts | 9 ++ apps/api/src/users/users.service.db.test.ts | 55 ++++++++ apps/api/src/users/users.service.ts | 101 ++++++++++++++ apps/api/vitest.config.ts | 3 + apps/api/vitest.global-setup.ts | 17 +++ packages/shared/src/auth.test.ts | 28 ++++ packages/shared/src/auth.ts | 84 ++++++++++++ packages/shared/src/index.ts | 1 + pnpm-lock.yaml | 47 +++++++ pnpm-workspace.yaml | 1 + 18 files changed, 663 insertions(+) create mode 100644 apps/api/prisma/migrations/20260704224132_auth_models/migration.sql create mode 100644 apps/api/src/testing/test-db.ts create mode 100644 apps/api/src/users/password.test.ts create mode 100644 apps/api/src/users/password.ts create mode 100644 apps/api/src/users/users.module.ts create mode 100644 apps/api/src/users/users.service.db.test.ts create mode 100644 apps/api/src/users/users.service.ts create mode 100644 apps/api/vitest.global-setup.ts create mode 100644 packages/shared/src/auth.test.ts create mode 100644 packages/shared/src/auth.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 0f45217..7400695 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -16,6 +16,16 @@ jobs: checks: name: Lint, typecheck, test runs-on: ubuntu-latest + services: + postgres: + image: postgres:17.5-alpine + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test + env: + # Enables the database-backed test suites (vitest.global-setup.ts). + TEST_DATABASE_URL: postgresql://test:test@postgres:5432/test steps: - name: Check out repository uses: actions/checkout@v4 diff --git a/apps/api/package.json b/apps/api/package.json index 5f8e0cc..abceeb5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -19,6 +19,7 @@ "@nestjs/core": "^11.0.0", "@nestjs/platform-express": "^11.0.0", "@prisma/client": "^6.3.0", + "argon2": "^0.44.0", "i18next": "^26.3.4", "nestjs-pino": "^4.3.0", "pino": "^9.6.0", diff --git a/apps/api/prisma/migrations/20260704224132_auth_models/migration.sql b/apps/api/prisma/migrations/20260704224132_auth_models/migration.sql new file mode 100644 index 0000000..e302810 --- /dev/null +++ b/apps/api/prisma/migrations/20260704224132_auth_models/migration.sql @@ -0,0 +1,123 @@ +-- CreateEnum +CREATE TYPE "UserStatus" AS ENUM ('PENDING_VERIFICATION', 'ACTIVE', 'DISABLED'); + +-- CreateEnum +CREATE TYPE "AuthTokenPurpose" AS ENUM ('EMAIL_VERIFICATION', 'PASSWORD_RESET'); + +-- CreateEnum +CREATE TYPE "MailStatus" AS ENUM ('PENDING', 'SENT', 'FAILED'); + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "username" TEXT NOT NULL, + "email" TEXT NOT NULL, + "display_name" TEXT NOT NULL, + "locale" TEXT NOT NULL DEFAULT 'en', + "is_site_admin" BOOLEAN NOT NULL DEFAULT false, + "status" "UserStatus" NOT NULL DEFAULT 'PENDING_VERIFICATION', + "email_verified_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_login_at" TIMESTAMP(3), + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "user_identities" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "credential" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_identities_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sessions" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMP(3) NOT NULL, + "last_seen_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_agent" TEXT, + + CONSTRAINT "sessions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "auth_tokens" ( + "id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "purpose" "AuthTokenPurpose" NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "consumed_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "auth_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "rate_limits" ( + "key" TEXT NOT NULL, + "window_start" TIMESTAMP(3) NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "rate_limits_pkey" PRIMARY KEY ("key") +); + +-- CreateTable +CREATE TABLE "mail_outbox" ( + "id" TEXT NOT NULL, + "to_address" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "text_body" TEXT NOT NULL, + "html_body" TEXT NOT NULL, + "status" "MailStatus" NOT NULL DEFAULT 'PENDING', + "attempts" INTEGER NOT NULL DEFAULT 0, + "next_attempt_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "last_error" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "sent_at" TIMESTAMP(3), + + CONSTRAINT "mail_outbox_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_username_key" ON "users"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE INDEX "user_identities_user_id_idx" ON "user_identities"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_identities_provider_subject_key" ON "user_identities"("provider", "subject"); + +-- CreateIndex +CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id"); + +-- CreateIndex +CREATE INDEX "sessions_expires_at_idx" ON "sessions"("expires_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "auth_tokens_token_hash_key" ON "auth_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "auth_tokens_user_id_purpose_idx" ON "auth_tokens"("user_id", "purpose"); + +-- CreateIndex +CREATE INDEX "mail_outbox_status_next_attempt_at_idx" ON "mail_outbox"("status", "next_attempt_at"); + +-- AddForeignKey +ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "auth_tokens" ADD CONSTRAINT "auth_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 0118d21..57ad530 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -21,3 +21,121 @@ model InstanceSetting { @@map("instance_settings") } + +enum UserStatus { + PENDING_VERIFICATION + ACTIVE + DISABLED +} + +/// Account profile. Login methods live in UserIdentity (OIDC-ready, +/// ADR 0007); Site Admin is a user flag, all other roles are grants. +model User { + id String @id @default(uuid()) + username String @unique + email String @unique + displayName String @map("display_name") + locale String @default("en") + isSiteAdmin Boolean @default(false) @map("is_site_admin") + status UserStatus @default(PENDING_VERIFICATION) + emailVerifiedAt DateTime? @map("email_verified_at") + createdAt DateTime @default(now()) @map("created_at") + lastLoginAt DateTime? @map("last_login_at") + + identities UserIdentity[] + sessions Session[] + authTokens AuthToken[] + + @@map("users") +} + +/// One row per login method. `provider` is "password" today and +/// "oidc:" later; `credential` holds the Argon2id hash for +/// password identities. +model UserIdentity { + id String @id @default(uuid()) + userId String @map("user_id") + provider String + subject String + credential String? + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([provider, subject]) + @@index([userId]) + @@map("user_identities") +} + +/// Server-side browser sessions (ADR 0007). `id` is the SHA-256 hash of +/// the opaque cookie token — the raw token is never stored. +model Session { + id String @id + userId String @map("user_id") + createdAt DateTime @default(now()) @map("created_at") + expiresAt DateTime @map("expires_at") + lastSeenAt DateTime @default(now()) @map("last_seen_at") + userAgent String? @map("user_agent") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([expiresAt]) + @@map("sessions") +} + +enum AuthTokenPurpose { + EMAIL_VERIFICATION + PASSWORD_RESET +} + +/// Single-use, expiring tokens for e-mail flows. Stored hashed; consuming +/// sets `consumedAt` so replays are detectable. +model AuthToken { + id String @id @default(uuid()) + tokenHash String @unique @map("token_hash") + userId String @map("user_id") + purpose AuthTokenPurpose + expiresAt DateTime @map("expires_at") + consumedAt DateTime? @map("consumed_at") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, purpose]) + @@map("auth_tokens") +} + +/// Fixed-window rate-limit counters (ADR 0002: no Redis). `key` encodes +/// scope and subject, e.g. "login:ip:203.0.113.7". +model RateLimit { + key String @id + windowStart DateTime @map("window_start") + count Int @default(0) + + @@map("rate_limits") +} + +enum MailStatus { + PENDING + SENT + FAILED +} + +/// Outbox for reliable e-mail delivery with retry (issue #12). +model MailOutbox { + id String @id @default(uuid()) + toAddress String @map("to_address") + subject String + textBody String @map("text_body") + htmlBody String @map("html_body") + status MailStatus @default(PENDING) + attempts Int @default(0) + nextAttemptAt DateTime @default(now()) @map("next_attempt_at") + lastError String? @map("last_error") + createdAt DateTime @default(now()) @map("created_at") + sentAt DateTime? @map("sent_at") + + @@index([status, nextAttemptAt]) + @@map("mail_outbox") +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 8709a91..620fc8f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,11 +7,13 @@ import { AppConfig } from './config/app-config.service'; import { ConfigModule } from './config/config.module'; import { HealthModule } from './health/health.module'; import { PrismaModule } from './prisma/prisma.module'; +import { UsersModule } from './users/users.module'; @Module({ imports: [ ConfigModule, PrismaModule, + UsersModule, LoggerModule.forRootAsync({ inject: [AppConfig], useFactory: (config: AppConfig) => ({ diff --git a/apps/api/src/testing/test-db.ts b/apps/api/src/testing/test-db.ts new file mode 100644 index 0000000..6e45f2d --- /dev/null +++ b/apps/api/src/testing/test-db.ts @@ -0,0 +1,17 @@ +import { PrismaClient } from '@prisma/client'; + +/** True when database-backed tests can run (see vitest.global-setup.ts). */ +export const hasTestDb = Boolean(process.env.TEST_DATABASE_URL); + +/** Prisma client bound to the test database. Callers own the lifecycle. */ +export function createTestPrisma(): PrismaClient { + if (!process.env.TEST_DATABASE_URL) { + throw new Error('TEST_DATABASE_URL is not set — guard the suite with hasTestDb'); + } + return new PrismaClient({ datasourceUrl: process.env.TEST_DATABASE_URL }); +} + +/** Unique suffix so suites never collide on unique columns. */ +export function uniqueSuffix(): string { + return Math.random().toString(36).slice(2, 10); +} diff --git a/apps/api/src/users/password.test.ts b/apps/api/src/users/password.test.ts new file mode 100644 index 0000000..cb4894e --- /dev/null +++ b/apps/api/src/users/password.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { hashPassword, passwordNeedsRehash, verifyPassword } from './password'; + +describe('password hashing', () => { + it('hashes with Argon2id and verifies the roundtrip', async () => { + const hash = await hashPassword('korrekt pferd batterie'); + expect(hash).toMatch(/^\$argon2id\$/); + expect(await verifyPassword(hash, 'korrekt pferd batterie')).toBe(true); + expect(await verifyPassword(hash, 'falsches passwort')).toBe(false); + }); + + it('treats malformed hashes as non-matching instead of throwing', async () => { + expect(await verifyPassword('not-a-hash', 'whatever')).toBe(false); + }); + + it('does not demand a rehash for freshly created hashes', async () => { + expect(passwordNeedsRehash(await hashPassword('korrekt pferd batterie'))).toBe(false); + }); +}); diff --git a/apps/api/src/users/password.ts b/apps/api/src/users/password.ts new file mode 100644 index 0000000..4eaff35 --- /dev/null +++ b/apps/api/src/users/password.ts @@ -0,0 +1,26 @@ +import * as argon2 from 'argon2'; + +/** + * Argon2id parameters following the OWASP password-storage cheat sheet + * (2024 baseline): 19 MiB memory, 2 iterations, single lane. Raising them + * later is safe — verify() reads parameters from the stored hash, and + * needsRehash() flags outdated hashes at login time. + */ +const ARGON2_OPTIONS: argon2.Options = { + type: argon2.argon2id, + memoryCost: 19 * 1024, + timeCost: 2, + parallelism: 1, +}; + +export function hashPassword(password: string): Promise { + return argon2.hash(password, ARGON2_OPTIONS); +} + +export function verifyPassword(hash: string, password: string): Promise { + return argon2.verify(hash, password).catch(() => false); +} + +export function passwordNeedsRehash(hash: string): boolean { + return argon2.needsRehash(hash, ARGON2_OPTIONS); +} diff --git a/apps/api/src/users/users.module.ts b/apps/api/src/users/users.module.ts new file mode 100644 index 0000000..0a2bea4 --- /dev/null +++ b/apps/api/src/users/users.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { UsersService } from './users.service'; + +@Module({ + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/apps/api/src/users/users.service.db.test.ts b/apps/api/src/users/users.service.db.test.ts new file mode 100644 index 0000000..6b027c9 --- /dev/null +++ b/apps/api/src/users/users.service.db.test.ts @@ -0,0 +1,55 @@ +import { ConflictException } from '@nestjs/common'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db'; +import { PrismaService } from '../prisma/prisma.service'; +import { UsersService } from './users.service'; + +describe.skipIf(!hasTestDb)('UsersService (database)', () => { + const prisma = hasTestDb ? (createTestPrisma() as unknown as PrismaService) : null!; + const users = hasTestDb ? new UsersService(prisma) : null!; + const suffix = uniqueSuffix(); + + const input = { + username: `uma-${suffix}`, + email: `uma-${suffix}@example.org`, + displayName: 'Uma Test', + password: 'korrekt pferd batterie', + locale: 'de', + }; + + afterAll(async () => { + if (!hasTestDb) return; + await prisma.user.deleteMany({ where: { username: { contains: suffix } } }); + await prisma.$disconnect(); + }); + + it('creates a user with a password identity and verifies the password', async () => { + const user = await users.createUser(input); + expect(user.status).toBe('PENDING_VERIFICATION'); + expect(await users.checkPassword(user.id, input.password)).toBe(true); + expect(await users.checkPassword(user.id, 'wrong')).toBe(false); + }); + + it('rejects duplicate usernames with a field-level conflict', async () => { + await expect( + users.createUser({ ...input, email: `other-${suffix}@example.org` }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('rejects duplicate e-mail addresses case-insensitively', async () => { + await expect( + users.createUser({ + ...input, + username: `other-${suffix}`, + email: input.email.toUpperCase(), + }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it('normalizes lookup by e-mail or username', async () => { + const byEmail = await users.findByUsernameOrEmail(input.email.toUpperCase()); + const byName = await users.findByUsernameOrEmail(input.username); + expect(byEmail?.id).toBe(byName?.id); + }); +}); diff --git a/apps/api/src/users/users.service.ts b/apps/api/src/users/users.service.ts new file mode 100644 index 0000000..f3cd300 --- /dev/null +++ b/apps/api/src/users/users.service.ts @@ -0,0 +1,101 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { Prisma, User } from '@prisma/client'; + +import { PrismaService } from '../prisma/prisma.service'; +import { hashPassword, verifyPassword } from './password'; + +export const PASSWORD_PROVIDER = 'password'; + +export interface CreateUserInput { + username: string; + email: string; + displayName: string; + password: string; + locale: string; +} + +@Injectable() +export class UsersService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Creates the account plus its password identity in one transaction. + * Uniqueness violations surface as field-level conflicts so the client + * can highlight the right input. + */ + async createUser(input: CreateUserInput): Promise { + try { + return await this.prisma.$transaction(async (tx) => { + const user = await tx.user.create({ + data: { + username: input.username, + email: input.email.toLowerCase(), + displayName: input.displayName, + locale: input.locale, + }, + }); + await tx.userIdentity.create({ + data: { + userId: user.id, + provider: PASSWORD_PROVIDER, + // The user id (not the username) is the stable subject: a + // future username change must not orphan the identity. + subject: user.id, + credential: await hashPassword(input.password), + }, + }); + return user; + }); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + const target = (error.meta?.target as string[] | undefined)?.[0] ?? 'username'; + throw new ConflictException({ field: target }); + } + throw error; + } + } + + findById(id: string): Promise { + return this.prisma.user.findUnique({ where: { id } }); + } + + findByUsernameOrEmail(usernameOrEmail: string): Promise { + const value = usernameOrEmail.trim(); + return this.prisma.user.findFirst({ + where: value.includes('@') ? { email: value.toLowerCase() } : { username: value }, + }); + } + + findByEmail(email: string): Promise { + return this.prisma.user.findUnique({ where: { email: email.toLowerCase() } }); + } + + async checkPassword(userId: string, password: string): Promise { + const identity = await this.prisma.userIdentity.findUnique({ + where: { provider_subject: { provider: PASSWORD_PROVIDER, subject: userId } }, + }); + if (!identity?.credential) return false; + return verifyPassword(identity.credential, password); + } + + async setPassword(userId: string, password: string): Promise { + await this.prisma.userIdentity.update({ + where: { provider_subject: { provider: PASSWORD_PROVIDER, subject: userId } }, + data: { credential: await hashPassword(password) }, + }); + } + + async markEmailVerified(userId: string): Promise { + return this.prisma.user.update({ + where: { id: userId }, + data: { status: 'ACTIVE', emailVerifiedAt: new Date() }, + }); + } + + async updateProfile( + userId: string, + data: { displayName?: string; locale?: string }, + ): Promise { + return this.prisma.user.update({ where: { id: userId }, data }); + } +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts index 03c4fe6..d13697c 100644 --- a/apps/api/vitest.config.ts +++ b/apps/api/vitest.config.ts @@ -7,5 +7,8 @@ export default defineConfig({ plugins: [swc.vite({ module: { type: 'es6' } })], test: { environment: 'node', + globalSetup: './vitest.global-setup.ts', + // DB-backed suites share one database; parallel files would race. + fileParallelism: false, }, }); diff --git a/apps/api/vitest.global-setup.ts b/apps/api/vitest.global-setup.ts new file mode 100644 index 0000000..14d3562 --- /dev/null +++ b/apps/api/vitest.global-setup.ts @@ -0,0 +1,17 @@ +import { execFileSync } from 'node:child_process'; + +/** + * Database-backed tests run only when TEST_DATABASE_URL is set (locally: + * the compose dev db on port 5434; in CI: the postgres service container). + * This setup pushes the current Prisma schema into that database once per + * test run; tests skip themselves when the variable is absent. + */ +export default function globalSetup(): void { + const url = process.env.TEST_DATABASE_URL; + if (!url) return; + execFileSync( + process.execPath, + [require.resolve('prisma/build/index.js'), 'db', 'push', '--skip-generate'], + { env: { ...process.env, DATABASE_URL: url }, stdio: 'inherit', cwd: __dirname }, + ); +} diff --git a/packages/shared/src/auth.test.ts b/packages/shared/src/auth.test.ts new file mode 100644 index 0000000..084f067 --- /dev/null +++ b/packages/shared/src/auth.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { passwordSchema, signupInputSchema, usernameSchema } from './auth'; + +describe('auth schemas', () => { + it('accepts sensible usernames and rejects unsafe ones', () => { + expect(usernameSchema.safeParse('stefan-w').success).toBe(true); + expect(usernameSchema.safeParse('ab').success).toBe(false); + expect(usernameSchema.safeParse('-leading').success).toBe(false); + expect(usernameSchema.safeParse('has space').success).toBe(false); + }); + + it('enforces password length and the common-password blocklist', () => { + expect(passwordSchema.safeParse('korrekt pferd batterie').success).toBe(true); + expect(passwordSchema.safeParse('short').success).toBe(false); + expect(passwordSchema.safeParse('Password123').success).toBe(false); + }); + + it('parses a complete signup payload with locale default', () => { + const parsed = signupInputSchema.parse({ + username: 'uma', + email: 'uma@example.org', + displayName: 'Uma', + password: 'ein sehr gutes passwort', + }); + expect(parsed.locale).toBe('en'); + }); +}); diff --git a/packages/shared/src/auth.ts b/packages/shared/src/auth.ts new file mode 100644 index 0000000..e749929 --- /dev/null +++ b/packages/shared/src/auth.ts @@ -0,0 +1,84 @@ +import { z } from 'zod'; + +/** + * Auth-related schemas shared between api (runtime validation) and web + * (form validation). Field error messages are i18n keys resolved by the + * client; the api returns them inside ApiErrorBody.details. + */ + +export const usernameSchema = z + .string() + .min(3, 'validation.username.tooShort') + .max(32, 'validation.username.tooLong') + .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i, 'validation.username.charset'); + +/** + * Password policy per issue #13: length over composition rules, plus a + * small blocklist of the most common passwords (full breach-list checks + * are deliberately out of scope for v1). + */ +const COMMON_PASSWORDS = new Set([ + '1234567890', + 'qwertyuiop', + 'password12', + 'password123', + 'passwort123', + '1q2w3e4r5t', + 'iloveyou12', + 'sonnenschein', + 'schalke04!', + 'aaaaaaaaaa', + '1234567890a', + 'qwertz1234', +]); + +export const passwordSchema = z + .string() + .min(10, 'validation.password.tooShort') + .max(128, 'validation.password.tooLong') + .refine((value) => !COMMON_PASSWORDS.has(value.toLowerCase()), 'validation.password.tooCommon'); + +export const signupInputSchema = z.object({ + username: usernameSchema, + email: z.string().email('validation.email.invalid').max(254), + displayName: z.string().trim().min(1, 'validation.displayName.required').max(80), + password: passwordSchema, + locale: z.enum(['de', 'en']).default('en'), +}); +export type SignupInput = z.infer; + +export const loginInputSchema = z.object({ + usernameOrEmail: z.string().min(1, 'validation.required'), + password: z.string().min(1, 'validation.required'), +}); +export type LoginInput = z.infer; + +export const verifyEmailInputSchema = z.object({ token: z.string().min(16).max(256) }); +export const resendVerificationInputSchema = z.object({ + email: z.string().email('validation.email.invalid'), +}); +export const forgotPasswordInputSchema = z.object({ + email: z.string().email('validation.email.invalid'), +}); +export const resetPasswordInputSchema = z.object({ + token: z.string().min(16).max(256), + password: passwordSchema, +}); +export const updateProfileInputSchema = z.object({ + displayName: z.string().trim().min(1, 'validation.displayName.required').max(80).optional(), + locale: z.enum(['de', 'en']).optional(), +}); +export const changePasswordInputSchema = z.object({ + currentPassword: z.string().min(1, 'validation.required'), + newPassword: passwordSchema, +}); + +/** Public shape of the signed-in user, returned by /auth/me. */ +export interface CurrentUser { + id: string; + username: string; + email: string; + displayName: string; + locale: 'de' | 'en'; + isSiteAdmin: boolean; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ae0698b..b8d3fcf 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,4 +1,5 @@ export * from './api-error'; +export * from './auth'; export * from './env'; export * from './health'; export * from './i18n-tools'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f6953e..5d3463d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@prisma/client': specifier: ^6.3.0 version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3) + argon2: + specifier: ^0.44.0 + version: 0.44.0 i18next: specifier: ^26.3.4 version: 26.3.4(typescript@5.9.3) @@ -292,6 +295,9 @@ packages: resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -1063,6 +1069,10 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1664,6 +1674,10 @@ packages: append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + argon2@0.44.0: + resolution: {integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==} + engines: {node: '>=16.17.0'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1902,6 +1916,11 @@ packages: typescript: optional: true + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2593,12 +2612,20 @@ packages: node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-addon-api@8.9.0: + resolution: {integrity: sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==} + engines: {node: ^18 || ^20 || >= 21} + node-emoji@1.11.0: resolution: {integrity: sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==} node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-releases@2.0.50: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} @@ -3672,6 +3699,8 @@ snapshots: '@colors/colors@1.5.0': optional: true + '@epic-web/invariant@1.0.0': {} + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -4236,6 +4265,8 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@phc/format@1.0.0': {} + '@pinojs/redact@0.4.0': {} '@playwright/test@1.61.1': @@ -4839,6 +4870,13 @@ snapshots: append-field@1.0.0: {} + argon2@0.44.0: + dependencies: + '@phc/format': 1.0.0 + cross-env: 10.1.0 + node-addon-api: 8.9.0 + node-gyp-build: 4.8.4 + argparse@2.0.1: {} array-timsort@1.0.3: {} @@ -5058,6 +5096,11 @@ snapshots: optionalDependencies: typescript: 5.9.3 + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -5785,12 +5828,16 @@ snapshots: node-abort-controller@3.1.1: {} + node-addon-api@8.9.0: {} + node-emoji@1.11.0: dependencies: lodash: 4.18.1 node-fetch-native@1.6.7: {} + node-gyp-build@4.8.4: {} + node-releases@2.0.50: {} nypm@0.6.8: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9bbc86c..c6f1a8e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,5 +6,6 @@ allowBuilds: '@prisma/client': true '@prisma/engines': true '@swc/core': true + argon2: true esbuild: true prisma: true