feat(api,web): auth et first-run — bootstrap admin, login, session, gardes
- api: auth controller/service, config env - web: App/LoginPage/ProfilePage, garde d'auth, client API, styles - shared: types/contrats auth - compose: variables d'environnement first-run Refs: #14
This commit is contained in:
@ -1,7 +1,14 @@
|
||||
import { Body, Controller, Get, Post, Res, UseGuards } from "@nestjs/common";
|
||||
import { Body, Controller, Get, Patch, Post, Res, UseGuards } from "@nestjs/common";
|
||||
import "@fastify/cookie";
|
||||
import { FastifyReply } from "fastify";
|
||||
import { BootstrapAdminDto, BootstrapAdminSchema, LoginDto, LoginSchema } from "@readabook/shared";
|
||||
import {
|
||||
BootstrapAdminDto,
|
||||
BootstrapAdminSchema,
|
||||
LoginDto,
|
||||
LoginSchema,
|
||||
UpdateAccountDto,
|
||||
UpdateAccountSchema
|
||||
} from "@readabook/shared";
|
||||
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
|
||||
import { AuthGuard } from "./auth.guard.js";
|
||||
import { AuthService } from "./auth.service.js";
|
||||
@ -11,6 +18,11 @@ import { CurrentUser, CurrentUserParam } from "./current-user.js";
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Get("status")
|
||||
status() {
|
||||
return this.auth.status();
|
||||
}
|
||||
|
||||
@Post("bootstrap")
|
||||
async bootstrap(@Body(new ZodValidationPipe(BootstrapAdminSchema)) body: BootstrapAdminDto) {
|
||||
return this.auth.bootstrapAdmin(body);
|
||||
@ -40,4 +52,13 @@ export class AuthController {
|
||||
me(@CurrentUserParam() user: CurrentUser) {
|
||||
return { user };
|
||||
}
|
||||
|
||||
@Patch("me")
|
||||
@UseGuards(AuthGuard)
|
||||
updateMe(
|
||||
@CurrentUserParam() user: CurrentUser,
|
||||
@Body(new ZodValidationPipe(UpdateAccountSchema)) body: UpdateAccountDto
|
||||
) {
|
||||
return this.auth.updateOwnAccount(user.id, body);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { ConflictException, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConflictException, Injectable, OnModuleInit, UnauthorizedException } from "@nestjs/common";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import argon2 from "argon2";
|
||||
import { BootstrapAdminDto, CreateUserDto, LoginDto, UpdateUserDto } from "@readabook/shared";
|
||||
import { BootstrapAdminDto, CreateUserDto, LoginDto, UpdateAccountDto, UpdateUserDto } from "@readabook/shared";
|
||||
import { DatabaseService } from "../database/database.service.js";
|
||||
import { users } from "../database/schema.js";
|
||||
import { CurrentUser } from "./current-user.js";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
export class AuthService implements OnModuleInit {
|
||||
readonly cookieName: string;
|
||||
private readonly secret: Uint8Array;
|
||||
|
||||
@ -17,6 +17,10 @@ export class AuthService {
|
||||
this.secret = new TextEncoder().encode(database.config.jwtSecret);
|
||||
}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.ensureInitialAdmin();
|
||||
}
|
||||
|
||||
get cookieSecure(): boolean {
|
||||
return this.database.config.cookieSecure;
|
||||
}
|
||||
@ -29,6 +33,15 @@ export class AuthService {
|
||||
return this.createUser({ ...input, role: "admin" });
|
||||
}
|
||||
|
||||
status() {
|
||||
const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get();
|
||||
return {
|
||||
hasUsers: Boolean(existing),
|
||||
initialAdminEmail: this.database.config.initialAdminEmail.toLowerCase(),
|
||||
initialAdminPasswordIsDefault: this.database.config.initialAdminPasswordIsDefault
|
||||
};
|
||||
}
|
||||
|
||||
async login(input: LoginDto): Promise<{ token: string; user: CurrentUser }> {
|
||||
const user = this.database.db.select().from(users).where(eq(users.email, input.email.toLowerCase())).get();
|
||||
if (!user || !(await argon2.verify(user.passwordHash, input.password))) {
|
||||
@ -65,6 +78,35 @@ export class AuthService {
|
||||
.all();
|
||||
}
|
||||
|
||||
async updateOwnAccount(id: number, input: UpdateAccountDto) {
|
||||
const user = this.database.db.select().from(users).where(eq(users.id, id)).get();
|
||||
if (!user || !(await argon2.verify(user.passwordHash, input.currentPassword))) {
|
||||
throw new UnauthorizedException("Current password is invalid");
|
||||
}
|
||||
|
||||
const values: Partial<typeof users.$inferInsert> = { updatedAt: this.database.now() };
|
||||
if (input.email) values.email = input.email.toLowerCase();
|
||||
if (input.name !== undefined) values.name = input.name;
|
||||
if (input.newPassword) values.passwordHash = await argon2.hash(input.newPassword);
|
||||
|
||||
try {
|
||||
return this.database.db
|
||||
.update(users)
|
||||
.set(values)
|
||||
.where(eq(users.id, id))
|
||||
.returning({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt
|
||||
})
|
||||
.get();
|
||||
} catch {
|
||||
throw new ConflictException("Email already exists");
|
||||
}
|
||||
}
|
||||
|
||||
async createUser(input: CreateUserDto) {
|
||||
const now = this.database.now();
|
||||
const passwordHash = await argon2.hash(input.password);
|
||||
@ -125,4 +167,22 @@ export class AuthService {
|
||||
.setExpirationTime("7d")
|
||||
.sign(this.secret);
|
||||
}
|
||||
|
||||
private async ensureInitialAdmin(): Promise<void> {
|
||||
const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get();
|
||||
if (existing) return;
|
||||
|
||||
const now = this.database.now();
|
||||
this.database.db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: this.database.config.initialAdminEmail.toLowerCase(),
|
||||
name: "Initial administrator",
|
||||
passwordHash: await argon2.hash(this.database.config.initialAdminPassword),
|
||||
role: "admin",
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,8 +11,14 @@ export type AppConfig = {
|
||||
cookieName: string;
|
||||
cookieSecure: boolean;
|
||||
openLibraryEnabled: boolean;
|
||||
initialAdminEmail: string;
|
||||
initialAdminPassword: string;
|
||||
initialAdminPasswordIsDefault: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_INITIAL_ADMIN_EMAIL = "admin@readabook.local";
|
||||
const DEFAULT_INITIAL_ADMIN_PASSWORD = "readabook-admin-change-me";
|
||||
|
||||
export function loadConfig(): AppConfig {
|
||||
const databasePath = resolve(process.env.DATABASE_PATH ?? "./data/readabook.sqlite");
|
||||
const storageDir = resolve(process.env.STORAGE_DIR ?? "./data/storage");
|
||||
@ -29,6 +35,9 @@ export function loadConfig(): AppConfig {
|
||||
jwtSecret: process.env.JWT_SECRET ?? "dev-change-me-readabook",
|
||||
cookieName: process.env.AUTH_COOKIE_NAME ?? "readabook_session",
|
||||
cookieSecure: process.env.COOKIE_SECURE === "true",
|
||||
openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false"
|
||||
openLibraryEnabled: process.env.OPEN_LIBRARY_ENABLED !== "false",
|
||||
initialAdminEmail: process.env.INITIAL_ADMIN_EMAIL ?? DEFAULT_INITIAL_ADMIN_EMAIL,
|
||||
initialAdminPassword: process.env.INITIAL_ADMIN_PASSWORD ?? DEFAULT_INITIAL_ADMIN_PASSWORD,
|
||||
initialAdminPasswordIsDefault: !process.env.INITIAL_ADMIN_PASSWORD
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user