Enable auth-service/api-gateway on production, build their images in CI

This commit is contained in:
2026-08-18 14:11:00 +07:00
parent e5afedfa2f
commit b68005be1c
70 changed files with 6781 additions and 263 deletions
+11
View File
@@ -0,0 +1,11 @@
# apps/auth-service configuration. Copy to `.env` and edit for local dev:
# cp apps/auth-service/.env.example apps/auth-service/.env
# Never commit a real JWT_SECRET.
PORT=3010
POSTGRES_DSN=postgresql://duoc_thu:duoc_thu@localhost:5432/duoc_thu
# Required — the service refuses to start without one (see config.ts).
# Any long random string is fine for local dev; never reuse this value
# anywhere real.
JWT_SECRET=local-dev-only-change-me
JWT_EXPIRES_IN=12h
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "../../packages/config/eslint-preset/index.js"
}
+22
View File
@@ -0,0 +1,22 @@
FROM node:20-slim AS base
RUN corepack enable
WORKDIR /repo
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/auth-service/package.json apps/auth-service/package.json
COPY packages/shared-types/package.json packages/shared-types/package.json
COPY packages/config/package.json packages/config/package.json
RUN pnpm install --frozen-lockfile
FROM deps AS build
COPY packages/ packages/
COPY apps/auth-service/ apps/auth-service/
RUN pnpm --filter @duoc-thu/auth-service build
FROM base AS runtime
ENV NODE_ENV=production
COPY --from=build /repo /repo
WORKDIR /repo/apps/auth-service
EXPOSE 3010
CMD ["node", "dist/main.js"]
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('jest').Config} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
rootDir: ".",
testMatch: ["<rootDir>/test/**/*.spec.ts"],
};
@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS auth_user (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
username text UNIQUE NOT NULL,
password_hash text NOT NULL,
role text NOT NULL CHECK (role IN ('user', 'admin')),
created_at timestamptz NOT NULL DEFAULT now()
);
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}
+40 -1
View File
@@ -1,5 +1,44 @@
{
"name": "@duoc-thu/auth-service",
"private": true,
"version": "0.0.0"
"version": "0.0.0",
"scripts": {
"build": "nest build",
"dev": "nest start --watch",
"start": "node dist/main.js",
"migrate": "node dist/migrate.js",
"seed": "node dist/seed.js",
"lint": "eslint \"src/**/*.ts\" --max-warnings=0",
"test": "jest"
},
"dependencies": {
"@duoc-thu/shared-types": "workspace:*",
"@nestjs/common": "^10.4.0",
"@nestjs/core": "^10.4.0",
"@nestjs/jwt": "^10.2.0",
"@nestjs/platform-express": "^10.4.0",
"bcrypt": "^5.1.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"pg": "^8.12.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@duoc-thu/config": "workspace:*",
"@nestjs/cli": "^10.4.0",
"@nestjs/testing": "^10.4.0",
"@types/bcrypt": "^5.0.2",
"@types/express": "^4.17.21",
"@types/jest": "^29.5.12",
"@types/node": "^20.14.0",
"@types/pg": "^8.11.6",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.0",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.5.0"
}
}
+7
View File
@@ -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 };
}
}
+26
View File
@@ -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;
}
+38
View File
@@ -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");
}
}
}
+37
View File
@@ -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",
};
}
+10
View File
@@ -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 });
}
+16
View File
@@ -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();
+26
View File
@@ -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);
});
+41
View File
@@ -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);
});
@@ -0,0 +1,72 @@
import "reflect-metadata";
import { ConflictException, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import * as bcrypt from "bcrypt";
import { AuthService } from "../src/auth/auth.service";
/** A fake `pg.Pool` — the goal here is auth logic (hashing, verification,
* conflict handling, JWT claims), not exercising a real Postgres, which
* `test_live_datastores.py`'s equivalent role covers for ai-service. */
function fakePool(rows: unknown[] = [], rejectCode?: string) {
return {
query: jest.fn().mockImplementation(async () => {
if (rejectCode) {
const error = new Error("duplicate") as Error & { code: string };
error.code = rejectCode;
throw error;
}
return { rows };
}),
} as unknown as import("pg").Pool;
}
const jwt = new JwtService({ secret: "test-secret-not-for-real-use" });
describe("AuthService", () => {
it("registers a new user and returns username/role", async () => {
const pool = fakePool([{ username: "demo", role: "user" }]);
const service = new AuthService(pool, jwt);
const result = await service.register("demo", "1");
expect(result).toEqual({ username: "demo", role: "user" });
});
it("rejects registering a username that already exists", async () => {
const pool = fakePool([], "23505");
const service = new AuthService(pool, jwt);
await expect(service.register("admin", "1")).rejects.toBeInstanceOf(
ConflictException
);
});
it("logs in with the correct password and issues a JWT carrying the role", async () => {
const passwordHash = await bcrypt.hash("1", 12);
const pool = fakePool([
{ id: "u-1", username: "admin", password_hash: passwordHash, role: "admin" },
]);
const service = new AuthService(pool, jwt);
const result = await service.login("admin", "1");
expect(result.user).toEqual({ username: "admin", role: "admin" });
const decoded = jwt.verify(result.token) as { sub: string; role: string };
expect(decoded.sub).toBe("u-1");
expect(decoded.role).toBe("admin");
});
it("rejects a wrong password", async () => {
const passwordHash = await bcrypt.hash("1", 12);
const pool = fakePool([
{ id: "u-1", username: "admin", password_hash: passwordHash, role: "admin" },
]);
const service = new AuthService(pool, jwt);
await expect(service.login("admin", "wrong")).rejects.toBeInstanceOf(
UnauthorizedException
);
});
it("rejects a username that does not exist without leaking that distinction", async () => {
const pool = fakePool([]);
const service = new AuthService(pool, jwt);
await expect(service.login("nobody", "1")).rejects.toBeInstanceOf(
UnauthorizedException
);
});
});
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../../packages/config/tsconfig-base.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "dist",
"rootDir": "src",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"strictPropertyInitialization": false
},
"include": ["src"]
}