Add user, identity, session, and auth-support data model
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 <noreply@anthropic.com>
This commit is contained in:
parent
61da1cc784
commit
36608177f6
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
@ -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:<issuer>" 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")
|
||||
}
|
||||
|
||||
@ -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) => ({
|
||||
|
||||
17
apps/api/src/testing/test-db.ts
Normal file
17
apps/api/src/testing/test-db.ts
Normal file
@ -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);
|
||||
}
|
||||
20
apps/api/src/users/password.test.ts
Normal file
20
apps/api/src/users/password.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
26
apps/api/src/users/password.ts
Normal file
26
apps/api/src/users/password.ts
Normal file
@ -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<string> {
|
||||
return argon2.hash(password, ARGON2_OPTIONS);
|
||||
}
|
||||
|
||||
export function verifyPassword(hash: string, password: string): Promise<boolean> {
|
||||
return argon2.verify(hash, password).catch(() => false);
|
||||
}
|
||||
|
||||
export function passwordNeedsRehash(hash: string): boolean {
|
||||
return argon2.needsRehash(hash, ARGON2_OPTIONS);
|
||||
}
|
||||
9
apps/api/src/users/users.module.ts
Normal file
9
apps/api/src/users/users.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
55
apps/api/src/users/users.service.db.test.ts
Normal file
55
apps/api/src/users/users.service.db.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
101
apps/api/src/users/users.service.ts
Normal file
101
apps/api/src/users/users.service.ts
Normal file
@ -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<User> {
|
||||
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<User | null> {
|
||||
return this.prisma.user.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
findByUsernameOrEmail(usernameOrEmail: string): Promise<User | null> {
|
||||
const value = usernameOrEmail.trim();
|
||||
return this.prisma.user.findFirst({
|
||||
where: value.includes('@') ? { email: value.toLowerCase() } : { username: value },
|
||||
});
|
||||
}
|
||||
|
||||
findByEmail(email: string): Promise<User | null> {
|
||||
return this.prisma.user.findUnique({ where: { email: email.toLowerCase() } });
|
||||
}
|
||||
|
||||
async checkPassword(userId: string, password: string): Promise<boolean> {
|
||||
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<void> {
|
||||
await this.prisma.userIdentity.update({
|
||||
where: { provider_subject: { provider: PASSWORD_PROVIDER, subject: userId } },
|
||||
data: { credential: await hashPassword(password) },
|
||||
});
|
||||
}
|
||||
|
||||
async markEmailVerified(userId: string): Promise<User> {
|
||||
return this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { status: 'ACTIVE', emailVerifiedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async updateProfile(
|
||||
userId: string,
|
||||
data: { displayName?: string; locale?: string },
|
||||
): Promise<User> {
|
||||
return this.prisma.user.update({ where: { id: userId }, data });
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
17
apps/api/vitest.global-setup.ts
Normal file
17
apps/api/vitest.global-setup.ts
Normal file
@ -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 },
|
||||
);
|
||||
}
|
||||
28
packages/shared/src/auth.test.ts
Normal file
28
packages/shared/src/auth.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
84
packages/shared/src/auth.ts
Normal file
84
packages/shared/src/auth.ts
Normal file
@ -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<typeof signupInputSchema>;
|
||||
|
||||
export const loginInputSchema = z.object({
|
||||
usernameOrEmail: z.string().min(1, 'validation.required'),
|
||||
password: z.string().min(1, 'validation.required'),
|
||||
});
|
||||
export type LoginInput = z.infer<typeof loginInputSchema>;
|
||||
|
||||
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;
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
export * from './api-error';
|
||||
export * from './auth';
|
||||
export * from './env';
|
||||
export * from './health';
|
||||
export * from './i18n-tools';
|
||||
|
||||
47
pnpm-lock.yaml
generated
47
pnpm-lock.yaml
generated
@ -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:
|
||||
|
||||
@ -6,5 +6,6 @@ allowBuilds:
|
||||
'@prisma/client': true
|
||||
'@prisma/engines': true
|
||||
'@swc/core': true
|
||||
argon2: true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
|
||||
Loading…
Reference in New Issue
Block a user