Enable auth-service/api-gateway on production, build their images in CI
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Get, Post, Req, UseGuards } from "@nestjs/common";
|
||||
import type { AuthUser, LoginResponse } from "@duoc-thu/shared-types";
|
||||
import type { Request } from "express";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
import { RegisterDto } from "./dto/register.dto";
|
||||
import { JwtAuthGuard, JwtPayload } from "./jwt.guard";
|
||||
|
||||
@Controller("auth")
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post("register")
|
||||
register(@Body() body: RegisterDto): Promise<AuthUser> {
|
||||
return this.auth.register(body.username, body.password);
|
||||
}
|
||||
|
||||
@Post("login")
|
||||
login(@Body() body: LoginDto): Promise<LoginResponse> {
|
||||
return this.auth.login(body.username, body.password);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get("me")
|
||||
me(@Req() request: Request): AuthUser {
|
||||
const payload = (request as Request & { user: JwtPayload }).user;
|
||||
return { username: payload.username, role: payload.role };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { Pool } from "pg";
|
||||
import { createPool } from "../db";
|
||||
import { loadSettings } from "../config";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { JwtAuthGuard } from "./jwt.guard";
|
||||
|
||||
const settings = loadSettings();
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
secret: settings.jwtSecret,
|
||||
signOptions: { expiresIn: settings.jwtExpiresIn },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{ provide: Pool, useFactory: () => createPool(settings.postgresDsn) },
|
||||
AuthService,
|
||||
JwtAuthGuard,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import type { AuthUser, LoginResponse, UserRole } from "@duoc-thu/shared-types";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import { Pool } from "pg";
|
||||
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
async register(username: string, password: string): Promise<AuthUser> {
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
try {
|
||||
const result = await this.pool.query<{ username: string; role: UserRole }>(
|
||||
`INSERT INTO auth_user (username, password_hash, role)
|
||||
VALUES ($1, $2, 'user')
|
||||
RETURNING username, role`,
|
||||
[username, passwordHash]
|
||||
);
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
// Postgres unique_violation — race-safe (the DB, not a prior SELECT,
|
||||
// is the source of truth for "does this username already exist").
|
||||
if ((error as { code?: string }).code === "23505") {
|
||||
throw new ConflictException("username already taken");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<LoginResponse> {
|
||||
const result = await this.pool.query<UserRow>(
|
||||
`SELECT id, username, password_hash, role FROM auth_user WHERE username = $1`,
|
||||
[username]
|
||||
);
|
||||
const row = result.rows[0];
|
||||
// Hash a dummy value on a miss so a wrong-username response takes
|
||||
// roughly the same time as a wrong-password one — bcrypt.compare on a
|
||||
// real hash is the expensive step; skipping it entirely on a missing
|
||||
// user makes "does this username exist" a timing oracle.
|
||||
const passwordHash = row?.password_hash ?? (await bcrypt.hash("", BCRYPT_ROUNDS));
|
||||
const valid = await bcrypt.compare(password, passwordHash);
|
||||
if (!row || !valid) {
|
||||
throw new UnauthorizedException("invalid username or password");
|
||||
}
|
||||
const user: AuthUser = { username: row.username, role: row.role };
|
||||
const token = await this.jwt.signAsync({
|
||||
sub: row.id,
|
||||
username: row.username,
|
||||
role: row.role,
|
||||
});
|
||||
return { user, token };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsString, Length } from "class-validator";
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@Length(1, 64)
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(1, 200)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IsString, Length, Matches } from "class-validator";
|
||||
|
||||
export class RegisterDto {
|
||||
@IsString()
|
||||
@Length(3, 64)
|
||||
@Matches(/^[a-zA-Z0-9_.-]+$/, {
|
||||
message: "username may only contain letters, digits, _ . -",
|
||||
})
|
||||
username!: string;
|
||||
|
||||
@IsString()
|
||||
@Length(1, 200)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import type { Request } from "express";
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string;
|
||||
username: string;
|
||||
role: "user" | "admin";
|
||||
}
|
||||
|
||||
function bearerToken(request: Request): string | null {
|
||||
const header = request.headers.authorization;
|
||||
if (!header?.startsWith("Bearer ")) return null;
|
||||
return header.slice("Bearer ".length);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private readonly jwt: JwtService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const token = bearerToken(request);
|
||||
if (!token) throw new UnauthorizedException("missing bearer token");
|
||||
try {
|
||||
const payload = await this.jwt.verifyAsync<JwtPayload>(token);
|
||||
(request as Request & { user: JwtPayload }).user = payload;
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException("invalid or expired token");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Minimal env-var settings — mirrors the style of `apps/ai-service/config.py`
|
||||
* (plain, explicit fields, no framework-specific config module) rather than
|
||||
* pulling in `@nestjs/config` for four variables. */
|
||||
export interface Settings {
|
||||
port: number;
|
||||
postgresDsn: string;
|
||||
jwtSecret: string;
|
||||
jwtExpiresIn: string;
|
||||
adminSeedPassword: string;
|
||||
demoSeedPassword: string;
|
||||
}
|
||||
|
||||
export function loadSettings(): Settings {
|
||||
const jwtSecret = process.env.JWT_SECRET ?? "";
|
||||
if (!jwtSecret) {
|
||||
// Fail closed at startup, not at the first login attempt — the same
|
||||
// posture as ai-service's manifest check in bootstrap.py: a service that
|
||||
// would sign tokens with an empty/guessable secret must not start at all.
|
||||
throw new Error(
|
||||
"JWT_SECRET is required and must not be empty. Refusing to start with " +
|
||||
"no secret rather than silently signing tokens no one can trust."
|
||||
);
|
||||
}
|
||||
return {
|
||||
port: Number(process.env.PORT ?? 3010),
|
||||
postgresDsn:
|
||||
process.env.POSTGRES_DSN ??
|
||||
"postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu",
|
||||
jwtSecret,
|
||||
jwtExpiresIn: process.env.JWT_EXPIRES_IN ?? "12h",
|
||||
// Default "1" only exists for local/Compose dev, which never sets these.
|
||||
// A real deployment sets them via the Helm Secret — see
|
||||
// secret.adminSeedPassword in infra/helm/medical-chatbot/values.yaml.
|
||||
adminSeedPassword: process.env.ADMIN_SEED_PASSWORD ?? "1",
|
||||
demoSeedPassword: process.env.DEMO_SEED_PASSWORD ?? "1",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Pool } from "pg";
|
||||
|
||||
/** One pool per process, unlike ai-service's Postgres adapter (which opens a
|
||||
* connection per call — see its own docstring on why that's a known, not-yet
|
||||
* fixed gap there). Node's `pg.Pool` makes per-request pooling the default,
|
||||
* cheap way to do this correctly from the start rather than inheriting that
|
||||
* same gap in a second language. */
|
||||
export function createPool(dsn: string): Pool {
|
||||
return new Pool({ connectionString: dsn, connectionTimeoutMillis: 5000 });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { AppModule } from "./app.module";
|
||||
import { loadSettings } from "./config";
|
||||
|
||||
async function bootstrap() {
|
||||
const settings = loadSettings();
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })
|
||||
);
|
||||
await app.listen(settings.port, "0.0.0.0");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Mirrors `apps/ai-service/migrate.py`: plain SQL files, applied in sorted
|
||||
* order, no ORM/migration-framework dependency. */
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createPool } from "./db";
|
||||
import { loadSettings } from "./config";
|
||||
|
||||
async function main() {
|
||||
const settings = loadSettings();
|
||||
const pool = createPool(settings.postgresDsn);
|
||||
const migrationsDir = join(__dirname, "..", "migrations");
|
||||
const files = readdirSync(migrationsDir)
|
||||
.filter((name) => name.endsWith(".sql"))
|
||||
.sort();
|
||||
for (const file of files) {
|
||||
const statement = readFileSync(join(migrationsDir, file), "utf-8");
|
||||
await pool.query(statement);
|
||||
console.log(`Applied ${file}`);
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Seeds the two accounts requested for this first slice: `admin` (role
|
||||
* admin, unlocks /admin) and `demo` (role user, the "logged-in doctor"
|
||||
* persona). Idempotent (ON CONFLICT DO NOTHING) — safe to run on every
|
||||
* deploy alongside migrate.ts.
|
||||
*
|
||||
* Passwords come from ADMIN_SEED_PASSWORD / DEMO_SEED_PASSWORD, defaulting
|
||||
* to "1" only when unset (local/Compose dev). Because ON CONFLICT DO NOTHING
|
||||
* means whichever password lands on the first run is permanent, any
|
||||
* deployment where `/admin` is actually reachable must set both env vars to
|
||||
* real values — see secret.adminSeedPassword in
|
||||
* infra/helm/medical-chatbot/values.yaml, which the chart requires
|
||||
* explicitly once authService.seed.enabled is true.
|
||||
*/
|
||||
import * as bcrypt from "bcrypt";
|
||||
import { createPool } from "./db";
|
||||
import { loadSettings } from "./config";
|
||||
|
||||
async function main() {
|
||||
const settings = loadSettings();
|
||||
const pool = createPool(settings.postgresDsn);
|
||||
const SEED_USERS: Array<{ username: string; password: string; role: "user" | "admin" }> = [
|
||||
{ username: "admin", password: settings.adminSeedPassword, role: "admin" },
|
||||
{ username: "demo", password: settings.demoSeedPassword, role: "user" },
|
||||
];
|
||||
for (const seed of SEED_USERS) {
|
||||
const passwordHash = await bcrypt.hash(seed.password, 12);
|
||||
await pool.query(
|
||||
`INSERT INTO auth_user (username, password_hash, role)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (username) DO NOTHING`,
|
||||
[seed.username, passwordHash, seed.role]
|
||||
);
|
||||
console.log(`Seeded ${seed.username} (${seed.role})`);
|
||||
}
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user