chore: initial commit — monorepo ReadaBook (API NestJS, web PWA, Docker)
This commit is contained in:
43
apps/api/src/auth/auth.controller.ts
Normal file
43
apps/api/src/auth/auth.controller.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Body, Controller, Get, Post, Res, UseGuards } from "@nestjs/common";
|
||||
import "@fastify/cookie";
|
||||
import { FastifyReply } from "fastify";
|
||||
import { BootstrapAdminDto, BootstrapAdminSchema, LoginDto, LoginSchema } from "@readabook/shared";
|
||||
import { ZodValidationPipe } from "../common/zod-validation.pipe.js";
|
||||
import { AuthGuard } from "./auth.guard.js";
|
||||
import { AuthService } from "./auth.service.js";
|
||||
import { CurrentUser, CurrentUserParam } from "./current-user.js";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post("bootstrap")
|
||||
async bootstrap(@Body(new ZodValidationPipe(BootstrapAdminSchema)) body: BootstrapAdminDto) {
|
||||
return this.auth.bootstrapAdmin(body);
|
||||
}
|
||||
|
||||
@Post("login")
|
||||
async login(@Body(new ZodValidationPipe(LoginSchema)) body: LoginDto, @Res({ passthrough: true }) reply: FastifyReply) {
|
||||
const result = await this.auth.login(body);
|
||||
reply.setCookie(this.auth.cookieName, result.token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: this.auth.cookieSecure,
|
||||
path: "/",
|
||||
maxAge: 7 * 24 * 60 * 60
|
||||
});
|
||||
return { user: result.user };
|
||||
}
|
||||
|
||||
@Post("logout")
|
||||
logout(@Res({ passthrough: true }) reply: FastifyReply) {
|
||||
reply.clearCookie(this.auth.cookieName, { path: "/" });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Get("me")
|
||||
@UseGuards(AuthGuard)
|
||||
me(@CurrentUserParam() user: CurrentUser) {
|
||||
return { user };
|
||||
}
|
||||
}
|
||||
17
apps/api/src/auth/auth.guard.ts
Normal file
17
apps/api/src/auth/auth.guard.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { AuthService } from "./auth.service.js";
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = request.cookies?.[this.auth.cookieName];
|
||||
if (!token) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
request.user = await this.auth.verifySession(token);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
14
apps/api/src/auth/auth.module.ts
Normal file
14
apps/api/src/auth/auth.module.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { DatabaseModule } from "../database/database.module.js";
|
||||
import { AuthController } from "./auth.controller.js";
|
||||
import { AuthGuard } from "./auth.guard.js";
|
||||
import { AuthService } from "./auth.service.js";
|
||||
import { RolesGuard } from "./roles.guard.js";
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthGuard, RolesGuard],
|
||||
exports: [AuthService, AuthGuard, RolesGuard]
|
||||
})
|
||||
export class AuthModule {}
|
||||
128
apps/api/src/auth/auth.service.ts
Normal file
128
apps/api/src/auth/auth.service.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { ConflictException, Injectable, 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 { DatabaseService } from "../database/database.service.js";
|
||||
import { users } from "../database/schema.js";
|
||||
import { CurrentUser } from "./current-user.js";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
readonly cookieName: string;
|
||||
private readonly secret: Uint8Array;
|
||||
|
||||
constructor(private readonly database: DatabaseService) {
|
||||
this.cookieName = database.config.cookieName;
|
||||
this.secret = new TextEncoder().encode(database.config.jwtSecret);
|
||||
}
|
||||
|
||||
get cookieSecure(): boolean {
|
||||
return this.database.config.cookieSecure;
|
||||
}
|
||||
|
||||
async bootstrapAdmin(input: BootstrapAdminDto) {
|
||||
const existing = this.database.db.select({ id: users.id }).from(users).limit(1).get();
|
||||
if (existing) {
|
||||
throw new ConflictException("Bootstrap already completed");
|
||||
}
|
||||
return this.createUser({ ...input, role: "admin" });
|
||||
}
|
||||
|
||||
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))) {
|
||||
throw new UnauthorizedException("Invalid credentials");
|
||||
}
|
||||
const sessionUser = { id: user.id, email: user.email, role: user.role };
|
||||
return { token: await this.signSession(sessionUser), user: sessionUser };
|
||||
}
|
||||
|
||||
async verifySession(token: string): Promise<CurrentUser> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, this.secret);
|
||||
const id = Number(payload.sub);
|
||||
const user = this.database.db.select().from(users).where(eq(users.id, id)).get();
|
||||
if (!user) {
|
||||
throw new UnauthorizedException("Invalid session");
|
||||
}
|
||||
return { id: user.id, email: user.email, role: user.role };
|
||||
} catch {
|
||||
throw new UnauthorizedException("Invalid session");
|
||||
}
|
||||
}
|
||||
|
||||
listUsers() {
|
||||
return this.database.db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt
|
||||
})
|
||||
.from(users)
|
||||
.all();
|
||||
}
|
||||
|
||||
async createUser(input: CreateUserDto) {
|
||||
const now = this.database.now();
|
||||
const passwordHash = await argon2.hash(input.password);
|
||||
try {
|
||||
const user = this.database.db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: input.email.toLowerCase(),
|
||||
name: input.name ?? null,
|
||||
passwordHash,
|
||||
role: input.role,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
role: users.role,
|
||||
createdAt: users.createdAt
|
||||
})
|
||||
.get();
|
||||
return user;
|
||||
} catch (error) {
|
||||
throw new ConflictException("Email already exists");
|
||||
}
|
||||
}
|
||||
|
||||
async updateUser(id: number, input: UpdateUserDto) {
|
||||
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.role) values.role = input.role;
|
||||
if (input.password) values.passwordHash = await argon2.hash(input.password);
|
||||
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();
|
||||
}
|
||||
|
||||
deleteUser(id: number): void {
|
||||
this.database.db.delete(users).where(eq(users.id, id)).run();
|
||||
}
|
||||
|
||||
private async signSession(user: CurrentUser): Promise<string> {
|
||||
return new SignJWT({ email: user.email, role: user.role })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setSubject(String(user.id))
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("7d")
|
||||
.sign(this.secret);
|
||||
}
|
||||
}
|
||||
12
apps/api/src/auth/current-user.ts
Normal file
12
apps/api/src/auth/current-user.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
|
||||
|
||||
export type CurrentUser = {
|
||||
id: number;
|
||||
email: string;
|
||||
role: "admin" | "user";
|
||||
};
|
||||
|
||||
export const CurrentUserParam = createParamDecorator((_data: unknown, ctx: ExecutionContext): CurrentUser => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
});
|
||||
4
apps/api/src/auth/roles.decorator.ts
Normal file
4
apps/api/src/auth/roles.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const ROLES_KEY = "roles";
|
||||
export const Roles = (...roles: Array<"admin" | "user">) => SetMetadata(ROLES_KEY, roles);
|
||||
23
apps/api/src/auth/roles.guard.ts
Normal file
23
apps/api/src/auth/roles.guard.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { ROLES_KEY } from "./roles.decorator.js";
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const roles = this.reflector.getAllAndOverride<Array<"admin" | "user">>(ROLES_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass()
|
||||
]);
|
||||
if (!roles?.length) {
|
||||
return true;
|
||||
}
|
||||
const user = context.switchToHttp().getRequest().user;
|
||||
if (user && roles.includes(user.role)) {
|
||||
return true;
|
||||
}
|
||||
throw new ForbiddenException("Insufficient role");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user