Enable auth-service/api-gateway on production, build their images in CI
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# apps/api-gateway configuration. Copy to `.env` and edit for local dev:
|
||||
# cp apps/api-gateway/.env.example apps/api-gateway/.env
|
||||
|
||||
PORT=3000
|
||||
AUTH_SERVICE_URL=http://localhost:3010
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../packages/config/eslint-preset/index.js"
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
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/api-gateway/package.json apps/api-gateway/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/api-gateway/ apps/api-gateway/
|
||||
RUN pnpm --filter @duoc-thu/api-gateway build
|
||||
|
||||
FROM base AS runtime
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /repo /repo
|
||||
WORKDIR /repo/apps/api-gateway
|
||||
EXPOSE 3000
|
||||
CMD ["node", "dist/main.js"]
|
||||
@@ -0,0 +1,7 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "node",
|
||||
rootDir: ".",
|
||||
testMatch: ["<rootDir>/test/**/*.spec.ts"],
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,34 @@
|
||||
{
|
||||
"name": "@duoc-thu/api-gateway",
|
||||
"private": true,
|
||||
"version": "0.0.0"
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main.js",
|
||||
"lint": "eslint \"src/**/*.ts\" --max-warnings=0",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@nestjs/platform-express": "^10.4.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/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.14.0",
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ProxyModule } from "./proxy/proxy.module";
|
||||
|
||||
@Module({
|
||||
imports: [ProxyModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface Settings {
|
||||
port: number;
|
||||
authServiceUrl: string;
|
||||
}
|
||||
|
||||
export function loadSettings(): Settings {
|
||||
return {
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
authServiceUrl: process.env.AUTH_SERVICE_URL ?? "http://localhost:3010",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import "reflect-metadata";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { AppModule } from "./app.module";
|
||||
import { loadSettings } from "./config";
|
||||
|
||||
async function bootstrap() {
|
||||
const settings = loadSettings();
|
||||
const app = await NestFactory.create(AppModule);
|
||||
await app.listen(settings.port, "0.0.0.0");
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,38 @@
|
||||
import { All, Controller, Req, Res } from "@nestjs/common";
|
||||
import type { Request, Response } from "express";
|
||||
import { loadSettings } from "../config";
|
||||
|
||||
const settings = loadSettings();
|
||||
|
||||
/**
|
||||
* Thin proxy: forwards `/auth/*` to auth-service verbatim (method, body,
|
||||
* `Authorization` header) and relays the response back unchanged. This is
|
||||
* intentionally the ENTIRE gateway surface for this pass — chat/history/
|
||||
* feedback/sections keep going straight from `apps/web`'s BFF to
|
||||
* `ai-service`, not through here (see the plan this was built from). Growing
|
||||
* this into the README's full "single public entry point" is future work,
|
||||
* not done by accident just because this file exists.
|
||||
*/
|
||||
@Controller("auth")
|
||||
export class AuthProxyController {
|
||||
@All("*")
|
||||
async proxy(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
const target = `${settings.authServiceUrl}${req.originalUrl}`;
|
||||
const hasBody = req.method !== "GET" && req.method !== "HEAD";
|
||||
const upstream = await fetch(target, {
|
||||
method: req.method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(req.headers.authorization
|
||||
? { Authorization: req.headers.authorization }
|
||||
: {}),
|
||||
},
|
||||
body: hasBody ? JSON.stringify(req.body ?? {}) : undefined,
|
||||
});
|
||||
const payload = await upstream.text();
|
||||
res
|
||||
.status(upstream.status)
|
||||
.setHeader("Content-Type", upstream.headers.get("content-type") ?? "application/json")
|
||||
.send(payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthProxyController } from "./auth-proxy.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [AuthProxyController],
|
||||
})
|
||||
export class ProxyModule {}
|
||||
@@ -0,0 +1,81 @@
|
||||
import "reflect-metadata";
|
||||
|
||||
describe("AuthProxyController", () => {
|
||||
const originalFetch = global.fetch;
|
||||
let AuthProxyController: typeof import("../src/proxy/auth-proxy.controller").AuthProxyController;
|
||||
|
||||
beforeAll(async () => {
|
||||
// `../src/config.ts` reads this at module-import time, so it must be set
|
||||
// before the dynamic import below, not in a plain top-of-file import.
|
||||
process.env.AUTH_SERVICE_URL = "http://auth-service.test";
|
||||
({ AuthProxyController } = await import("../src/proxy/auth-proxy.controller"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("forwards method, body, and Authorization header to auth-service and relays the response verbatim", async () => {
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
status: 201,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
text: async () => JSON.stringify({ username: "demo", role: "user" }),
|
||||
});
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
const controller = new AuthProxyController();
|
||||
const req = {
|
||||
method: "POST",
|
||||
originalUrl: "/auth/register",
|
||||
headers: { authorization: "Bearer abc" },
|
||||
body: { username: "demo", password: "1" },
|
||||
} as unknown as import("express").Request;
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
} as unknown as import("express").Response;
|
||||
|
||||
await controller.proxy(req, res);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://auth-service.test/auth/register",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({ Authorization: "Bearer abc" }),
|
||||
body: JSON.stringify({ username: "demo", password: "1" }),
|
||||
})
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(201);
|
||||
expect(res.send).toHaveBeenCalledWith(JSON.stringify({ username: "demo", role: "user" }));
|
||||
});
|
||||
|
||||
it("sends no body for a GET (e.g. /auth/me)", async () => {
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
status: 200,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
text: async () => JSON.stringify({ username: "demo", role: "user" }),
|
||||
});
|
||||
global.fetch = mockFetch as unknown as typeof fetch;
|
||||
|
||||
const controller = new AuthProxyController();
|
||||
const req = {
|
||||
method: "GET",
|
||||
originalUrl: "/auth/me",
|
||||
headers: { authorization: "Bearer abc" },
|
||||
body: {},
|
||||
} as unknown as import("express").Request;
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
send: jest.fn(),
|
||||
} as unknown as import("express").Response;
|
||||
|
||||
await controller.proxy(req, res);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://auth-service.test/auth/me",
|
||||
expect.objectContaining({ method: "GET", body: undefined })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": "../../packages/config/eslint-preset/index.js"
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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()
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogIn, LogOut, UserRound } from "lucide-react";
|
||||
import type { AuthUser } from "@duoc-thu/shared-types";
|
||||
import { logout, me } from "@duoc-thu/api-client";
|
||||
|
||||
/** Public — visible on every page, not just `/admin`. Chat itself never
|
||||
* requires this: an anonymous visitor keeps working exactly as before
|
||||
* regardless of what this renders. Optional login exists here so `demo`
|
||||
* (the logged-in "bác sĩ" persona) can sign in from the normal chat UI. */
|
||||
export function AccountMenu() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
me()
|
||||
.then(setUser)
|
||||
.finally(() => setLoaded(true));
|
||||
}, []);
|
||||
|
||||
if (!loaded) return null;
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<Link
|
||||
href="/admin/login"
|
||||
className="flex items-center gap-1.5 rounded-full border border-border-subtle bg-surface px-3 py-1.5 text-xs font-semibold text-txt-secondary hover:bg-surface-hover"
|
||||
>
|
||||
<LogIn className="h-3.5 w-3.5" />
|
||||
<span>Đăng nhập</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 rounded-full border border-border-subtle bg-surface px-3 py-1.5 text-xs font-semibold text-txt-secondary">
|
||||
<UserRound className="h-3.5 w-3.5" />
|
||||
<span>{user.username}</span>
|
||||
<button
|
||||
aria-label="Đăng xuất"
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
setUser(null);
|
||||
router.refresh();
|
||||
}}
|
||||
className="ml-1 text-txt-muted hover:text-txt-primary"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { login, AuthError } from "@duoc-thu/api-client";
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
async function onSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setPending(true);
|
||||
try {
|
||||
const user = await login({ username, password });
|
||||
if (user.role !== "admin") {
|
||||
setError("Tài khoản này không có quyền quản trị.");
|
||||
return;
|
||||
}
|
||||
router.push("/admin");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof AuthError && err.status === 401
|
||||
? "Sai tên đăng nhập hoặc mật khẩu."
|
||||
: "Không thể đăng nhập lúc này. Vui lòng thử lại."
|
||||
);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="w-full max-w-sm space-y-4 rounded-2xl border border-border-subtle bg-surface p-6 shadow-sm"
|
||||
>
|
||||
<h1 className="text-lg font-bold text-txt-primary">Đăng nhập quản trị</h1>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-txt-secondary" htmlFor="username">
|
||||
Tên đăng nhập
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
className="w-full rounded-lg border border-border-subtle bg-app px-3 py-2 text-sm text-txt-primary"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-semibold text-txt-secondary" htmlFor="password">
|
||||
Mật khẩu
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
className="w-full rounded-lg border border-border-subtle bg-app px-3 py-2 text-sm text-txt-primary"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-xs font-medium text-status-danger">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="w-full rounded-lg bg-accent-primary py-2 text-sm font-semibold text-txt-inverse disabled:opacity-60"
|
||||
>
|
||||
{pending ? "Đang đăng nhập..." : "Đăng nhập"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { AuthUser } from "@duoc-thu/shared-types";
|
||||
import { logout, me } from "@duoc-thu/api-client";
|
||||
|
||||
export default function AdminPage() {
|
||||
const router = useRouter();
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// The middleware guard already redirected anyone without a valid
|
||||
// admin session before this component ever rendered — this fetch is
|
||||
// for display, not the access-control decision itself.
|
||||
me().then(setUser);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 p-6">
|
||||
<h1 className="text-lg font-bold text-txt-primary">Khu vực quản trị</h1>
|
||||
<p className="text-sm text-txt-secondary">
|
||||
Đăng nhập với: <strong>{user?.username ?? "..."}</strong> (
|
||||
{user?.role ?? "..."})
|
||||
</p>
|
||||
<p className="max-w-xl text-xs text-txt-muted">
|
||||
Đây là bằng chứng cơ chế phân quyền hoạt động đúng — chưa có tính
|
||||
năng quản trị cụ thể nào ở đây, vì chưa có yêu cầu nào được nêu ra.
|
||||
</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
router.push("/admin/login");
|
||||
router.refresh();
|
||||
}}
|
||||
className="w-fit rounded-lg border border-border-subtle px-4 py-2 text-sm font-semibold text-txt-primary hover:bg-surface-hover"
|
||||
>
|
||||
Đăng xuất
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { LoginResponse } from "@duoc-thu/shared-types";
|
||||
import { SESSION_COOKIE, SESSION_MAX_AGE_SECONDS } from "../session";
|
||||
|
||||
const GATEWAY_URL = process.env.API_GATEWAY_URL ?? "http://localhost:3000";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
let username: string;
|
||||
let password: string;
|
||||
try {
|
||||
const body = await request.json();
|
||||
username = typeof body?.username === "string" ? body.username : "";
|
||||
password = typeof body?.password === "string" ? body.password : "";
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid_body" }, { status: 400 });
|
||||
}
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: "missing_credentials" }, { status: 400 });
|
||||
}
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(`${GATEWAY_URL}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "gateway_unreachable" }, { status: 502 });
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "invalid_credentials" },
|
||||
{ status: upstream.status === 401 ? 401 : 502 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await upstream.json()) as LoginResponse;
|
||||
const response = NextResponse.json({ user: data.user });
|
||||
// httpOnly: never readable by client-side JS (XSS can't exfiltrate it).
|
||||
// `secure` only outside local dev — Compose/k3s both terminate TLS in
|
||||
// front of `web`, so the cookie is only ever sent in the clear on
|
||||
// localhost, matching how every other secret in this repo treats
|
||||
// local vs. deployed differently.
|
||||
response.cookies.set(SESSION_COOKIE, data.token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: SESSION_MAX_AGE_SECONDS,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SESSION_COOKIE } from "../session";
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.delete(SESSION_COOKIE);
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
import type { AuthUser } from "@duoc-thu/shared-types";
|
||||
import { SESSION_COOKIE } from "../session";
|
||||
|
||||
const GATEWAY_URL = process.env.API_GATEWAY_URL ?? "http://localhost:3000";
|
||||
|
||||
export async function GET() {
|
||||
const token = cookies().get(SESSION_COOKIE)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "not_authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(`${GATEWAY_URL}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json({ error: "gateway_unreachable" }, { status: 502 });
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ error: "not_authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = (await upstream.json()) as AuthUser;
|
||||
return NextResponse.json(user);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Shared between the login/logout/me route handlers and `middleware.ts` —
|
||||
* kept dependency-free (no Node-only imports) so `middleware.ts` can import
|
||||
* it too; Next's Edge runtime middleware can't use arbitrary Node APIs. */
|
||||
export const SESSION_COOKIE = "dt_session";
|
||||
export const SESSION_MAX_AGE_SECONDS = 12 * 60 * 60; // matches auth-service's default JWT_EXPIRES_IN
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { ThemeProvider, ThemeScript, ThemeSelector, DisclaimerBanner } from "@duoc-thu/ui";
|
||||
import { NavTabs } from "./_components/NavTabs";
|
||||
import { AccountMenu } from "./_components/AccountMenu";
|
||||
import { Pill, ShieldCheck, Cpu } from "lucide-react";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -45,6 +46,8 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
{/* Theme Mode Selector (Auto, Light, Dark, Heavy Glass) */}
|
||||
<ThemeSelector />
|
||||
|
||||
<AccountMenu />
|
||||
|
||||
{/* System Status Pill */}
|
||||
<div className="hidden items-center gap-1.5 rounded-full border border-border-subtle bg-surface-elevated px-3 py-1 text-xs font-semibold text-accent-primary backdrop-blur-md md:flex shadow-sm">
|
||||
<Cpu className="h-3.5 w-3.5 text-accent-primary animate-pulse" />
|
||||
|
||||
+33
-2
@@ -1,5 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { jwtVerify } from "jose";
|
||||
import { SESSION_COOKIE } from "./app/api/auth/session";
|
||||
|
||||
/**
|
||||
* Rate limiting for the public API surface.
|
||||
@@ -96,7 +98,36 @@ function matchRules(pathname: string) {
|
||||
return RULES.find((entry) => pathname.startsWith(entry.prefix))?.rules;
|
||||
}
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
/** `/admin/**` (except the login page itself) requires a valid session
|
||||
* cookie carrying `role: "admin"`. Verified with the same `JWT_SECRET`
|
||||
* auth-service signs with — Edge middleware can't call out to auth-service
|
||||
* per request without adding real latency to every admin page load, and
|
||||
* `jose` (unlike `jsonwebtoken`) works in the Edge runtime this file runs
|
||||
* under, so local verification is both correct and the only option here. */
|
||||
async function guardAdmin(request: NextRequest): Promise<NextResponse | null> {
|
||||
const { pathname } = request.nextUrl;
|
||||
if (!pathname.startsWith("/admin") || pathname === "/admin/login") return null;
|
||||
|
||||
const token = request.cookies.get(SESSION_COOKIE)?.value;
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!token || !secret) {
|
||||
return NextResponse.redirect(new URL("/admin/login", request.url));
|
||||
}
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, new TextEncoder().encode(secret));
|
||||
if (payload.role !== "admin") {
|
||||
return NextResponse.redirect(new URL("/admin/login", request.url));
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return NextResponse.redirect(new URL("/admin/login", request.url));
|
||||
}
|
||||
}
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
const adminRedirect = await guardAdmin(request);
|
||||
if (adminRedirect) return adminRedirect;
|
||||
|
||||
const rules = matchRules(request.nextUrl.pathname);
|
||||
if (!rules) return NextResponse.next();
|
||||
|
||||
@@ -146,5 +177,5 @@ export function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/api/:path*"],
|
||||
matcher: ["/api/:path*", "/admin/:path*"],
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^13.0.0",
|
||||
"jose": "^5.9.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"next": "^14.2.0",
|
||||
"react": "^18.3.0",
|
||||
|
||||
Reference in New Issue
Block a user