refactor identity-gateway to fastify service

This commit is contained in:
Tiago Yamamoto 2025-12-27 14:20:43 -03:00
parent 8c4235d7f9
commit 293ab34cec
36 changed files with 906 additions and 86 deletions

View file

@ -0,0 +1,10 @@
NODE_ENV=development
PORT=4000
LOG_LEVEL=info
DATABASE_URL=postgres://identity:identity@localhost:5432/identity_gateway
JWT_ACCESS_SECRET=replace_me_access
JWT_REFRESH_SECRET=replace_me_refresh
JWT_ACCESS_TTL=15m
JWT_REFRESH_TTL=30d
COOKIE_DOMAIN=localhost
COOKIE_SECURE=false

View file

@ -0,0 +1,21 @@
FROM node:20-alpine AS base
WORKDIR /app
COPY package.json tsconfig.json ./
RUN npm install
COPY src ./src
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=base /app/package.json ./package.json
COPY --from=base /app/node_modules ./node_modules
COPY --from=base /app/dist ./dist
EXPOSE 4000
CMD ["node", "dist/main.js"]

View file

@ -0,0 +1,60 @@
# identity-gateway
`identity-gateway` é a autoridade de identidade **interna** da plataforma. Ele existe para unificar
identidade, RBAC e emissão de tokens confiáveis para serviços internos. **Não é** um produto de auth
para o mercado, não compete com Auth0/Clerk e não oferece SDKs públicos ou UI de login white-label.
## Por que NÃO é Auth0
- Não é vendido como produto standalone de autenticação.
- Não é SDK-first e não prioriza experiência de dev externo.
- Tokens são internos e consumidos apenas por serviços confiáveis.
- UI de login não é foco (nem fornecida aqui).
## Papel do identity-gateway
- Centralizar autenticação e autorização.
- Emitir JWTs para serviços internos.
- Manter RBAC e permissões por tenant.
- Ser a autoridade de identidade para:
- `baas-control-plane`
- `billing-finance-core`
- `crm-core`
## Fluxo de confiança
1. Usuário autentica no `identity-gateway`.
2. O gateway valida a identidade (provider local/externo).
3. O gateway emite JWT interno com claims mínimas.
4. Serviços internos validam e confiam no token.
> Nenhum serviço externo emite tokens para o gateway.
## Modelo de tokens
Veja [`docs/token-model.md`](docs/token-model.md).
## Rodando localmente
```bash
cp .env.example .env
npm install
npm run dev
```
## Docker
```bash
docker-compose up --build
```
## Estrutura
- `src/core`: guards e serviços centrais.
- `src/modules`: auth, users, roles, permissions, sessions, providers.
- `docs`: arquitetura, segurança e modelo de tokens.
## Notas de segurança
- JWTs são internos e não devem ser expostos diretamente a apps públicos.
- Refresh tokens são armazenados com hash no banco.

View file

@ -0,0 +1,25 @@
version: "3.9"
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: identity
POSTGRES_PASSWORD: identity
POSTGRES_DB: identity_gateway
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
identity-gateway:
build: .
env_file:
- .env
ports:
- "4000:4000"
depends_on:
- postgres
volumes:
pgdata:

View file

@ -0,0 +1,31 @@
# Architecture
`identity-gateway` is an internal authority for identity across the SaaS platform. It sits between
human users and internal services, issuing trusted JWTs for service-to-service access.
## Core responsibilities
- Central authentication and authorization.
- RBAC and permission enforcement per tenant.
- Token issuance for trusted backend services.
- Provider-agnostic identity validation (local/external).
## Components
- **Auth module**: Handles login, refresh, and logout flows.
- **Users module**: Maintains internal user identities and tenant membership.
- **Roles & Permissions**: Defines RBAC primitives and tenant-specific grants.
- **Sessions**: Stores refresh token sessions.
- **Core guards**: Enforces authentication, roles, and permissions.
## Trust boundaries
- Only internal services validate JWTs issued by the gateway.
- JWTs are not intended for public client apps without a proxy.
## Data flow
1. User authenticates with `identity-gateway`.
2. Gateway validates identity via provider and maps user to tenant.
3. Gateway issues access + refresh tokens.
4. Internal services validate access token claims.

View file

@ -0,0 +1,23 @@
# Security Model
## Principles
- Tokens are internal-only and never exposed to untrusted clients directly.
- Permissions are centrally managed in the gateway.
- Providers only validate identity; they do not set sessions or permissions.
## JWTs
- Access tokens are short-lived and contain minimal claims.
- Refresh tokens are stored hashed and revocable.
## Multi-tenant isolation
- User membership is scoped by tenant.
- Roles and permissions are evaluated per tenant.
## Operational safeguards
- Rotate JWT secrets regularly.
- Use TLS in production.
- Enable HTTP-only cookies for refresh tokens when needed.

View file

@ -0,0 +1,21 @@
# Token Model
## Access token claims
```json
{
"userId": "uuid",
"tenantId": "uuid",
"roles": ["admin"],
"permissions": ["billing.read", "baas.write"]
}
```
- Access tokens are used by internal services only.
- TTL is short (default 15 minutes).
## Refresh tokens
- Stored hashed in the database.
- Used to issue new access tokens.
- Revoked on logout or compromise.

View file

@ -1,8 +0,0 @@
module identity-gateway
go 1.22
require (
github.com/appwrite/sdk-for-go v1.0.4
github.com/gin-gonic/gin v1.10.0
)

View file

@ -1,64 +0,0 @@
package auth
import (
"database/sql"
"net/http"
"strings"
"github.com/appwrite/sdk-for-go/client"
"github.com/appwrite/sdk-for-go/users"
"github.com/gin-gonic/gin"
)
type MyUser struct {
ID string
Role string
}
type AuthProvider struct {
AppwriteClient client.Client
DB *sql.DB
}
func (p *AuthProvider) ValidateSession() gin.HandlerFunc {
return func(c *gin.Context) {
tokenHeader := c.GetHeader("Authorization")
token := strings.TrimSpace(strings.TrimPrefix(tokenHeader, "Bearer"))
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Token ausente"})
return
}
p.AppwriteClient.SetJWT(token)
appwriteUsers := users.New(p.AppwriteClient)
remoteUser, err := appwriteUsers.Get()
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Sessão inválida no IDP"})
return
}
var userLocal MyUser
err = p.DB.QueryRow(
"SELECT id, role FROM users WHERE appwrite_id = $1",
remoteUser.Id,
).Scan(&userLocal.ID, &userLocal.Role)
if err == sql.ErrNoRows {
err = p.DB.QueryRow(
"INSERT INTO users (appwrite_id, email, full_name) VALUES ($1, $2, $3) RETURNING id, role",
remoteUser.Id,
remoteUser.Email,
remoteUser.Name,
).Scan(&userLocal.ID, &userLocal.Role)
}
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Erro ao sincronizar usuário"})
return
}
c.Set("user_id", userLocal.ID)
c.Set("user_role", userLocal.Role)
c.Next()
}
}

View file

@ -1,17 +1,58 @@
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
appwrite_id VARCHAR(255) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
full_name VARCHAR(255),
role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('admin', 'manager', 'user')),
is_active BOOLEAN DEFAULT true,
last_login TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE user_security_configs (
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
master_key_hint VARCHAR(100),
encryption_version INT DEFAULT 1,
PRIMARY KEY (user_id)
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
identifier TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS user_tenants (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, tenant_id)
);
CREATE TABLE IF NOT EXISTS roles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL UNIQUE,
description TEXT
);
CREATE TABLE IF NOT EXISTS permissions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL UNIQUE,
description TEXT
);
CREATE TABLE IF NOT EXISTS role_permissions (
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE IF NOT EXISTS user_roles (
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, tenant_id, role_id)
);
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
refresh_token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ
);

View file

@ -0,0 +1,32 @@
{
"name": "identity-gateway",
"version": "0.1.0",
"private": true,
"description": "Internal identity gateway for multi-service SaaS platforms",
"type": "commonjs",
"scripts": {
"dev": "ts-node-dev --respawn --transpile-only src/main.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/main.js",
"lint": "eslint . --ext .ts"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"dotenv": "^16.4.5",
"fastify": "^4.28.1",
"fastify-cookie": "^5.7.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.12.0",
"pino": "^9.3.2"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^20.14.10",
"@types/pg": "^8.11.6",
"eslint": "^9.6.0",
"pino-pretty": "^11.2.2",
"ts-node-dev": "^2.0.0",
"typescript": "^5.5.3"
}
}

View file

@ -0,0 +1,20 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { TokenService } from "./token.service";
export const authGuard = (tokenService: TokenService) => {
return async (request: FastifyRequest, reply: FastifyReply) => {
const header = request.headers.authorization;
if (!header?.startsWith("Bearer ")) {
reply.code(401).send({ message: "Missing access token" });
return;
}
const token = header.replace("Bearer ", "");
try {
request.tenantContext = tokenService.verifyAccessToken(token);
} catch (error) {
reply.code(401).send({ message: "Invalid access token" });
return;
}
};
};

View file

@ -0,0 +1,15 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { getTenantContext } from "./tenant.context";
export const permissionGuard = (permissions: string[]) => {
return async (request: FastifyRequest, reply: FastifyReply) => {
const context = getTenantContext(request);
const hasPermission = permissions.some((permission) =>
context.permissions.includes(permission)
);
if (!hasPermission) {
reply.code(403).send({ message: "Missing permission" });
return;
}
};
};

View file

@ -0,0 +1,13 @@
import { FastifyReply, FastifyRequest } from "fastify";
import { getTenantContext } from "./tenant.context";
export const rbacGuard = (roles: string[]) => {
return async (request: FastifyRequest, reply: FastifyReply) => {
const context = getTenantContext(request);
const hasRole = roles.some((role) => context.roles.includes(role));
if (!hasRole) {
reply.code(403).send({ message: "Insufficient role" });
return;
}
};
};

View file

@ -0,0 +1,21 @@
import { FastifyRequest } from "fastify";
export interface TenantContext {
userId: string;
tenantId: string;
roles: string[];
permissions: string[];
}
declare module "fastify" {
interface FastifyRequest {
tenantContext?: TenantContext;
}
}
export const getTenantContext = (request: FastifyRequest) => {
if (!request.tenantContext) {
throw new Error("Tenant context not initialized");
}
return request.tenantContext;
};

View file

@ -0,0 +1,40 @@
import jwt from "jsonwebtoken";
import { env } from "../lib/env";
import { TenantContext } from "./tenant.context";
export interface TokenPair {
accessToken: string;
refreshToken: string;
}
export class TokenService {
signAccessToken(payload: TenantContext) {
return jwt.sign(payload, env.jwtAccessSecret, {
expiresIn: env.jwtAccessTtl,
});
}
signRefreshToken(payload: TenantContext) {
return jwt.sign(
{
userId: payload.userId,
tenantId: payload.tenantId,
},
env.jwtRefreshSecret,
{
expiresIn: env.jwtRefreshTtl,
}
);
}
verifyAccessToken(token: string) {
return jwt.verify(token, env.jwtAccessSecret) as TenantContext;
}
verifyRefreshToken(token: string) {
return jwt.verify(token, env.jwtRefreshSecret) as {
userId: string;
tenantId: string;
};
}
}

View file

@ -0,0 +1,11 @@
import bcrypt from "bcryptjs";
const SALT_ROUNDS = 12;
export const hashSecret = async (value: string) => {
return bcrypt.hash(value, SALT_ROUNDS);
};
export const verifySecret = async (value: string, hash: string) => {
return bcrypt.compare(value, hash);
};

View file

@ -0,0 +1,10 @@
import { Pool } from "pg";
import { env } from "./env";
export const db = new Pool({
connectionString: env.databaseUrl,
});
export const closeDb = async () => {
await db.end();
};

View file

@ -0,0 +1,29 @@
import dotenv from "dotenv";
dotenv.config();
export const env = {
nodeEnv: process.env.NODE_ENV ?? "development",
port: Number(process.env.PORT ?? 4000),
logLevel: process.env.LOG_LEVEL ?? "info",
databaseUrl: process.env.DATABASE_URL ?? "",
jwtAccessSecret: process.env.JWT_ACCESS_SECRET ?? "",
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET ?? "",
jwtAccessTtl: process.env.JWT_ACCESS_TTL ?? "15m",
jwtRefreshTtl: process.env.JWT_REFRESH_TTL ?? "30d",
cookieDomain: process.env.COOKIE_DOMAIN ?? "",
cookieSecure: process.env.COOKIE_SECURE === "true",
};
export const assertEnv = () => {
const required = [
"DATABASE_URL",
"JWT_ACCESS_SECRET",
"JWT_REFRESH_SECRET",
];
const missing = required.filter((key) => !process.env[key]);
if (missing.length > 0) {
throw new Error(`Missing environment variables: ${missing.join(", ")}`);
}
};

View file

@ -0,0 +1,13 @@
import pino from "pino";
import { env } from "./env";
export const logger = pino({
level: env.logLevel,
transport:
env.nodeEnv === "production"
? undefined
: {
target: "pino-pretty",
options: { colorize: true },
},
});

View file

@ -0,0 +1,43 @@
import Fastify from "fastify";
import cookie from "fastify-cookie";
import { assertEnv, env } from "./lib/env";
import { logger } from "./lib/logger";
import { TokenService } from "./core/token.service";
import { UserService } from "./modules/users/user.service";
import { SessionService } from "./modules/sessions/session.service";
import { AuthService } from "./modules/auth/auth.service";
import { LocalProvider } from "./modules/providers/local.provider";
import { AuthProvider } from "./modules/providers/provider.interface";
import { registerAuthRoutes } from "./modules/auth/auth.controller";
import { registerUserRoutes } from "./modules/users/user.controller";
const bootstrap = async () => {
assertEnv();
const app = Fastify({ logger });
app.register(cookie);
const tokenService = new TokenService();
const userService = new UserService();
const sessionService = new SessionService();
const providers = new Map<string, AuthProvider>();
providers.set("local", new LocalProvider(userService));
const authService = new AuthService(
tokenService,
userService,
sessionService,
providers
);
registerAuthRoutes(app, authService);
registerUserRoutes(app, userService, tokenService);
await app.listen({ port: env.port, host: "0.0.0.0" });
};
bootstrap().catch((error) => {
logger.error(error, "Failed to start identity-gateway");
process.exit(1);
});

View file

@ -0,0 +1,62 @@
import { FastifyInstance } from "fastify";
import { AuthService } from "./auth.service";
import { env } from "../../lib/env";
export const registerAuthRoutes = (app: FastifyInstance, authService: AuthService) => {
app.post("/auth/login", async (request, reply) => {
const body = request.body as {
provider?: string;
identifier: string;
secret: string;
tenantId: string;
};
const provider = body.provider ?? "local";
const { accessToken, refreshToken } = await authService.authenticate(
provider,
body.identifier,
body.secret,
body.tenantId
);
if (env.cookieDomain) {
reply.setCookie("refresh_token", refreshToken, {
httpOnly: true,
secure: env.cookieSecure,
sameSite: "strict",
domain: env.cookieDomain,
path: "/",
});
}
return { accessToken, refreshToken };
});
app.post("/auth/refresh", async (request, reply) => {
const body = request.body as { refreshToken?: string };
const refreshToken = body.refreshToken ?? request.cookies.refresh_token;
if (!refreshToken) {
reply.code(400).send({ message: "Missing refresh token" });
return;
}
const { accessToken } = await authService.refresh(refreshToken);
return { accessToken };
});
app.post("/auth/logout", async (request, reply) => {
const body = request.body as { refreshToken?: string };
const refreshToken = body.refreshToken ?? request.cookies.refresh_token;
if (!refreshToken) {
reply.code(400).send({ message: "Missing refresh token" });
return;
}
await authService.revoke(refreshToken);
reply.clearCookie("refresh_token", {
domain: env.cookieDomain || undefined,
path: "/",
});
return { success: true };
});
};

View file

@ -0,0 +1,78 @@
import { TokenService } from "../../core/token.service";
import { AuthProvider } from "../providers/provider.interface";
import { SessionService } from "../sessions/session.service";
import { UserService } from "../users/user.service";
export class AuthService {
constructor(
private readonly tokenService: TokenService,
private readonly userService: UserService,
private readonly sessionService: SessionService,
private readonly providers: Map<string, AuthProvider>
) {}
async authenticate(providerName: string, identifier: string, secret: string, tenantId: string) {
const provider = this.providers.get(providerName);
if (!provider) {
throw new Error("Auth provider not supported");
}
const identity = await provider.validate({ identifier, secret }, tenantId);
const roles = await this.userService.listRolesForTenant(identity.userId, tenantId);
const permissions = await this.userService.listPermissionsForTenant(identity.userId, tenantId);
const context = {
userId: identity.userId,
tenantId,
roles,
permissions,
};
const accessToken = this.tokenService.signAccessToken(context);
const refreshToken = this.tokenService.signRefreshToken(context);
const refreshExpiry = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30);
await this.sessionService.createSession(identity.userId, tenantId, refreshToken, refreshExpiry);
return { accessToken, refreshToken, context };
}
async refresh(refreshToken: string) {
const payload = this.tokenService.verifyRefreshToken(refreshToken);
const session = await this.sessionService.findValidSession(
payload.userId,
payload.tenantId,
refreshToken
);
if (!session) {
throw new Error("Invalid refresh token");
}
const roles = await this.userService.listRolesForTenant(payload.userId, payload.tenantId);
const permissions = await this.userService.listPermissionsForTenant(payload.userId, payload.tenantId);
const context = {
userId: payload.userId,
tenantId: payload.tenantId,
roles,
permissions,
};
const accessToken = this.tokenService.signAccessToken(context);
return { accessToken, context };
}
async revoke(refreshToken: string) {
const payload = this.tokenService.verifyRefreshToken(refreshToken);
const session = await this.sessionService.findValidSession(
payload.userId,
payload.tenantId,
refreshToken
);
if (session) {
await this.sessionService.revokeSession(session.id);
}
}
}

View file

@ -0,0 +1,5 @@
export interface PermissionEntity {
id: string;
name: string;
description?: string;
}

View file

@ -0,0 +1,19 @@
import { db } from "../../lib/db";
import { PermissionEntity } from "./permission.entity";
export class PermissionService {
async createPermission(name: string, description?: string) {
const result = await db.query<PermissionEntity>(
"INSERT INTO permissions (name, description) VALUES ($1, $2) RETURNING id, name, description",
[name, description ?? null]
);
return result.rows[0];
}
async listPermissions() {
const result = await db.query<PermissionEntity>(
"SELECT id, name, description FROM permissions ORDER BY name"
);
return result.rows;
}
}

View file

@ -0,0 +1,12 @@
import { AuthProvider, ProviderCredentials, ProviderIdentity } from "./provider.interface";
export class ExternalProvider implements AuthProvider {
name = "external";
async validate(
_credentials: ProviderCredentials,
_tenantId: string
): Promise<ProviderIdentity> {
throw new Error("External provider not configured");
}
}

View file

@ -0,0 +1,25 @@
import { AuthProvider, ProviderCredentials, ProviderIdentity } from "./provider.interface";
import { UserService } from "../users/user.service";
export class LocalProvider implements AuthProvider {
name = "local";
constructor(private readonly userService: UserService) {}
async validate(
credentials: ProviderCredentials,
tenantId: string
): Promise<ProviderIdentity> {
const user = await this.userService.verifyCredentials(
credentials.identifier,
credentials.secret
);
const isMember = await this.userService.isMemberOfTenant(user.id, tenantId);
if (!isMember) {
throw new Error("User is not assigned to tenant");
}
return { userId: user.id, tenantId };
}
}

View file

@ -0,0 +1,14 @@
export interface ProviderIdentity {
userId: string;
tenantId: string;
}
export interface ProviderCredentials {
identifier: string;
secret: string;
}
export interface AuthProvider {
name: string;
validate(credentials: ProviderCredentials, tenantId: string): Promise<ProviderIdentity>;
}

View file

@ -0,0 +1,5 @@
export interface RoleEntity {
id: string;
name: string;
description?: string;
}

View file

@ -0,0 +1,19 @@
import { db } from "../../lib/db";
import { RoleEntity } from "./role.entity";
export class RoleService {
async createRole(name: string, description?: string) {
const result = await db.query<RoleEntity>(
"INSERT INTO roles (name, description) VALUES ($1, $2) RETURNING id, name, description",
[name, description ?? null]
);
return result.rows[0];
}
async listRoles() {
const result = await db.query<RoleEntity>(
"SELECT id, name, description FROM roles ORDER BY name"
);
return result.rows;
}
}

View file

@ -0,0 +1,9 @@
export interface SessionEntity {
id: string;
userId: string;
tenantId: string;
refreshTokenHash: string;
expiresAt: string;
createdAt: string;
revokedAt?: string | null;
}

View file

@ -0,0 +1,40 @@
import { db } from "../../lib/db";
import { hashSecret, verifySecret } from "../../lib/crypto";
import { SessionEntity } from "./session.entity";
export class SessionService {
async createSession(userId: string, tenantId: string, refreshToken: string, expiresAt: Date) {
const refreshTokenHash = await hashSecret(refreshToken);
const result = await db.query<SessionEntity>(
`INSERT INTO sessions (user_id, tenant_id, refresh_token_hash, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id as "userId", tenant_id as "tenantId", refresh_token_hash as "refreshTokenHash", expires_at as "expiresAt", created_at as "createdAt", revoked_at as "revokedAt"`,
[userId, tenantId, refreshTokenHash, expiresAt]
);
return result.rows[0];
}
async findValidSession(userId: string, tenantId: string, refreshToken: string) {
const result = await db.query<SessionEntity>(
`SELECT id, user_id as "userId", tenant_id as "tenantId", refresh_token_hash as "refreshTokenHash", expires_at as "expiresAt", created_at as "createdAt", revoked_at as "revokedAt"
FROM sessions
WHERE user_id = $1 AND tenant_id = $2 AND revoked_at IS NULL AND expires_at > NOW()
ORDER BY created_at DESC
LIMIT 5`,
[userId, tenantId]
);
for (const session of result.rows) {
const valid = await verifySecret(refreshToken, session.refreshTokenHash);
if (valid) {
return session;
}
}
return null;
}
async revokeSession(sessionId: string) {
await db.query("UPDATE sessions SET revoked_at = NOW() WHERE id = $1", [sessionId]);
}
}

View file

@ -0,0 +1,25 @@
import { FastifyInstance } from "fastify";
import { UserService } from "./user.service";
import { authGuard } from "../../core/auth.guard";
import { TokenService } from "../../core/token.service";
import { getTenantContext } from "../../core/tenant.context";
export const registerUserRoutes = (
app: FastifyInstance,
userService: UserService,
tokenService: TokenService
) => {
app.get(
"/users/me",
{ preHandler: authGuard(tokenService) },
async (request) => {
const context = getTenantContext(request);
const user = await userService.findById(context.userId);
return {
id: user.id,
identifier: user.identifier,
status: user.status,
};
}
);
};

View file

@ -0,0 +1,7 @@
export interface UserEntity {
id: string;
identifier: string;
passwordHash: string;
status: "active" | "disabled";
createdAt: string;
}

View file

@ -0,0 +1,79 @@
import { db } from "../../lib/db";
import { hashSecret, verifySecret } from "../../lib/crypto";
import { UserEntity } from "./user.entity";
export class UserService {
async createUser(identifier: string, password: string) {
const passwordHash = await hashSecret(password);
const result = await db.query<UserEntity>(
"INSERT INTO users (identifier, password_hash) VALUES ($1, $2) RETURNING id, identifier, password_hash as \"passwordHash\", status, created_at as \"createdAt\"",
[identifier, passwordHash]
);
return result.rows[0];
}
async findByIdentifier(identifier: string) {
const result = await db.query<UserEntity>(
"SELECT id, identifier, password_hash as \"passwordHash\", status, created_at as \"createdAt\" FROM users WHERE identifier = $1",
[identifier]
);
if (result.rowCount === 0) {
throw new Error("User not found");
}
return result.rows[0];
}
async findById(userId: string) {
const result = await db.query<UserEntity>(
"SELECT id, identifier, password_hash as \"passwordHash\", status, created_at as \"createdAt\" FROM users WHERE id = $1",
[userId]
);
if (result.rowCount === 0) {
throw new Error("User not found");
}
return result.rows[0];
}
async verifyCredentials(identifier: string, password: string) {
const user = await this.findByIdentifier(identifier);
if (user.status !== "active") {
throw new Error("User disabled");
}
const valid = await verifySecret(password, user.passwordHash);
if (!valid) {
throw new Error("Invalid credentials");
}
return user;
}
async isMemberOfTenant(userId: string, tenantId: string) {
const result = await db.query<{ exists: boolean }>(
"SELECT EXISTS (SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2) as \"exists\"",
[userId, tenantId]
);
return result.rows[0]?.exists ?? false;
}
async listRolesForTenant(userId: string, tenantId: string) {
const result = await db.query<{ name: string }>(
`SELECT roles.name
FROM user_roles
JOIN roles ON roles.id = user_roles.role_id
WHERE user_roles.user_id = $1 AND user_roles.tenant_id = $2`,
[userId, tenantId]
);
return result.rows.map((row) => row.name);
}
async listPermissionsForTenant(userId: string, tenantId: string) {
const result = await db.query<{ name: string }>(
`SELECT permissions.name
FROM user_roles
JOIN role_permissions ON role_permissions.role_id = user_roles.role_id
JOIN permissions ON permissions.id = role_permissions.permission_id
WHERE user_roles.user_id = $1 AND user_roles.tenant_id = $2`,
[userId, tenantId]
);
return result.rows.map((row) => row.name);
}
}

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}