fase(9): auth module with casl rbac and session management
This commit is contained in:
@@ -5,10 +5,61 @@ import { Router } from 'express';
|
||||
import { createCrawlingRouter } from '../modules/crawling/infrastructure/http/CrawlingController';
|
||||
import { createFindingsRouter } from '../modules/findings/infrastructure/http/FindingsController';
|
||||
import { createFuzzingRouter } from '../modules/fuzzing/infrastructure/http/FuzzingController';
|
||||
import { createAuthController } from '../modules/auth/infrastructure/http/AuthController';
|
||||
import { createAuthMiddleware } from '../modules/auth/application/middleware/AuthMiddleware';
|
||||
import { ServerDependencies } from './server';
|
||||
import { RegisterCommand } from '../modules/auth/application/commands/RegisterCommand';
|
||||
import { LoginCommand } from '../modules/auth/application/commands/LoginCommand';
|
||||
import { CreateOrganizationCommand } from '../modules/auth/application/commands/CreateOrganizationCommand';
|
||||
import { InviteMemberCommand } from '../modules/auth/application/commands/InviteMemberCommand';
|
||||
import { CreateApiKeyCommand } from '../modules/auth/application/commands/CreateApiKeyCommand';
|
||||
import { GetUserQuery } from '../modules/auth/application/queries/GetUserQuery';
|
||||
import { ListOrgMembersQuery } from '../modules/auth/application/queries/ListOrgMembersQuery';
|
||||
import { IUserRepository } from '../modules/auth/domain/ports/IUserRepository';
|
||||
import { ISessionRepository } from '../modules/auth/domain/ports/ISessionRepository';
|
||||
import { IApiKeyRepository } from '../modules/auth/domain/ports/IApiKeyRepository';
|
||||
|
||||
export interface AuthControllerDeps {
|
||||
registerCommand: RegisterCommand;
|
||||
loginCommand: LoginCommand;
|
||||
createOrgCommand: CreateOrganizationCommand;
|
||||
inviteMemberCommand: InviteMemberCommand;
|
||||
createApiKeyCommand: CreateApiKeyCommand;
|
||||
getUserQuery: GetUserQuery;
|
||||
listOrgMembersQuery: ListOrgMembersQuery;
|
||||
sessionRepository: ISessionRepository;
|
||||
apiKeyRepository: IApiKeyRepository;
|
||||
userRepository: IUserRepository;
|
||||
}
|
||||
|
||||
export function createRouter(deps: ServerDependencies): Router {
|
||||
const router = Router();
|
||||
const { authDeps } = deps;
|
||||
|
||||
// Auth routes — public (no auth middleware)
|
||||
router.use(
|
||||
'/auth',
|
||||
createAuthController(
|
||||
authDeps.registerCommand,
|
||||
authDeps.loginCommand,
|
||||
authDeps.createOrgCommand,
|
||||
authDeps.inviteMemberCommand,
|
||||
authDeps.createApiKeyCommand,
|
||||
authDeps.getUserQuery,
|
||||
authDeps.listOrgMembersQuery,
|
||||
authDeps.sessionRepository,
|
||||
authDeps.apiKeyRepository,
|
||||
authDeps.userRepository
|
||||
)
|
||||
);
|
||||
|
||||
// Apply auth middleware to all routes below
|
||||
const authMiddleware = createAuthMiddleware(
|
||||
authDeps.userRepository,
|
||||
authDeps.sessionRepository,
|
||||
authDeps.apiKeyRepository
|
||||
);
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.use('/sessions', createCrawlingRouter(deps.crawlingDeps));
|
||||
router.use('/findings', createFindingsRouter(deps.findingsDeps));
|
||||
|
||||
@@ -6,6 +6,7 @@ import express, { Express, Request, Response } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { Kysely } from 'kysely';
|
||||
import { AppConfig } from '../shared/infrastructure/Config';
|
||||
import { Logger } from '../shared/infrastructure/Logger';
|
||||
@@ -17,6 +18,7 @@ import { createRouter } from './router';
|
||||
import { CrawlingControllerDeps } from '../modules/crawling/infrastructure/http/CrawlingController';
|
||||
import { FindingsControllerDeps } from '../modules/findings/infrastructure/http/FindingsController';
|
||||
import { FuzzingControllerDeps } from '../modules/fuzzing/infrastructure/http/FuzzingController';
|
||||
import { AuthControllerDeps } from './router';
|
||||
|
||||
export interface ServerDependencies {
|
||||
config: AppConfig;
|
||||
@@ -25,6 +27,7 @@ export interface ServerDependencies {
|
||||
crawlingDeps: CrawlingControllerDeps;
|
||||
findingsDeps: FindingsControllerDeps;
|
||||
fuzzingDeps: FuzzingControllerDeps;
|
||||
authDeps: AuthControllerDeps;
|
||||
}
|
||||
|
||||
export function createServer(deps: ServerDependencies): Express {
|
||||
@@ -59,8 +62,9 @@ export function createServer(deps: ServerDependencies): Express {
|
||||
}),
|
||||
);
|
||||
|
||||
// 5. Body parsing
|
||||
// 5. Body parsing + cookies
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// 6. Health endpoints — no auth required
|
||||
app.get('/health/live', (_req: Request, res: Response) => {
|
||||
|
||||
86
src/db/migrations/004_auth_tables.ts
Normal file
86
src/db/migrations/004_auth_tables.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Kysely } from 'kysely';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('users')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||
.addColumn('email', 'text', (col) => col.notNull().unique())
|
||||
.addColumn('name', 'text', (col) => col.notNull())
|
||||
.addColumn('password_hash', 'text', (col) => col.notNull())
|
||||
.addColumn('role', 'text', (col) => col.notNull().defaultTo('member'))
|
||||
.addColumn('org_id', 'text')
|
||||
.addColumn('created_at', 'integer', (col) => col.notNull())
|
||||
.addColumn('updated_at', 'integer', (col) => col.notNull())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('organizations')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||
.addColumn('name', 'text', (col) => col.notNull())
|
||||
.addColumn('slug', 'text', (col) => col.notNull().unique())
|
||||
.addColumn('created_at', 'integer', (col) => col.notNull())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('org_members')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||
.addColumn('org_id', 'text', (col) => col.notNull().references('organizations.id'))
|
||||
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
|
||||
.addColumn('role', 'text', (col) => col.notNull().defaultTo('member'))
|
||||
.addColumn('joined_at', 'integer', (col) => col.notNull())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('api_keys')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
|
||||
.addColumn('org_id', 'text', (col) => col.notNull())
|
||||
.addColumn('name', 'text', (col) => col.notNull())
|
||||
.addColumn('key_hash', 'text', (col) => col.notNull().unique())
|
||||
.addColumn('key_prefix', 'text', (col) => col.notNull())
|
||||
.addColumn('permissions', 'text', (col) => col.notNull().defaultTo('["member"]'))
|
||||
.addColumn('expires_at', 'integer')
|
||||
.addColumn('last_used_at', 'integer')
|
||||
.addColumn('created_at', 'integer', (col) => col.notNull())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createTable('auth_sessions')
|
||||
.ifNotExists()
|
||||
.addColumn('id', 'text', (col) => col.primaryKey())
|
||||
.addColumn('user_id', 'text', (col) => col.notNull().references('users.id'))
|
||||
.addColumn('token', 'text', (col) => col.notNull().unique())
|
||||
.addColumn('expires_at', 'integer', (col) => col.notNull())
|
||||
.addColumn('created_at', 'integer', (col) => col.notNull())
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_auth_sessions_token')
|
||||
.ifNotExists()
|
||||
.on('auth_sessions')
|
||||
.columns(['token'])
|
||||
.execute();
|
||||
|
||||
await db.schema
|
||||
.createIndex('idx_users_email')
|
||||
.ifNotExists()
|
||||
.on('users')
|
||||
.columns(['email'])
|
||||
.execute();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropIndex('idx_users_email').ifExists().execute();
|
||||
await db.schema.dropIndex('idx_auth_sessions_token').ifExists().execute();
|
||||
await db.schema.dropTable('auth_sessions').ifExists().execute();
|
||||
await db.schema.dropTable('api_keys').ifExists().execute();
|
||||
await db.schema.dropTable('org_members').ifExists().execute();
|
||||
await db.schema.dropTable('organizations').ifExists().execute();
|
||||
await db.schema.dropTable('users').ifExists().execute();
|
||||
}
|
||||
42
src/main.ts
42
src/main.ts
@@ -37,6 +37,20 @@ import { RunFuzzCommand } from './modules/fuzzing/application/commands/RunFuzzCo
|
||||
import { OnActionExecuted } from './modules/fuzzing/application/event-handlers/OnActionExecuted';
|
||||
import { InMemoryFuzzSessionRepository } from './modules/fuzzing/infrastructure/repositories/InMemoryFuzzSessionRepository';
|
||||
|
||||
// Auth module
|
||||
import { KyselyUserRepository } from './modules/auth/infrastructure/repositories/KyselyUserRepository';
|
||||
import { KyselyOrganizationRepository } from './modules/auth/infrastructure/repositories/KyselyOrganizationRepository';
|
||||
import { KyselyApiKeyRepository } from './modules/auth/infrastructure/repositories/KyselyApiKeyRepository';
|
||||
import { KyselySessionRepository } from './modules/auth/infrastructure/repositories/KyselySessionRepository';
|
||||
import { RegisterCommand } from './modules/auth/application/commands/RegisterCommand';
|
||||
import { LoginCommand } from './modules/auth/application/commands/LoginCommand';
|
||||
import { CreateOrganizationCommand } from './modules/auth/application/commands/CreateOrganizationCommand';
|
||||
import { InviteMemberCommand } from './modules/auth/application/commands/InviteMemberCommand';
|
||||
import { CreateApiKeyCommand } from './modules/auth/application/commands/CreateApiKeyCommand';
|
||||
import { GetUserQuery } from './modules/auth/application/queries/GetUserQuery';
|
||||
import { ListOrgMembersQuery } from './modules/auth/application/queries/ListOrgMembersQuery';
|
||||
import { hashPassword, verifyPassword } from './modules/auth/infrastructure/auth/PasswordService';
|
||||
|
||||
// Job queue
|
||||
import { SQLiteJobQueue } from './jobs/SQLiteJobQueue';
|
||||
import { createExplorationJobHandler, EXPLORATION_JOB_TYPE } from './jobs/workers/ExplorationWorker';
|
||||
@@ -97,7 +111,21 @@ async function bootstrap(): Promise<void> {
|
||||
const onActionExecuted = new OnActionExecuted(runFuzz);
|
||||
eventBus.subscribe('crawling.action_executed', onActionExecuted);
|
||||
|
||||
// 10. HTTP server
|
||||
// 10. Auth module
|
||||
const userRepo = new KyselyUserRepository(db);
|
||||
const orgRepo = new KyselyOrganizationRepository(db);
|
||||
const apiKeyRepo = new KyselyApiKeyRepository(db);
|
||||
const authSessionRepo = new KyselySessionRepository(db);
|
||||
|
||||
const registerCommand = new RegisterCommand(userRepo, eventBus, hashPassword);
|
||||
const loginCommand = new LoginCommand(userRepo, authSessionRepo, eventBus, verifyPassword);
|
||||
const createOrgCommand = new CreateOrganizationCommand(orgRepo, userRepo, eventBus);
|
||||
const inviteMemberCommand = new InviteMemberCommand(orgRepo, userRepo, eventBus);
|
||||
const createApiKeyCommand = new CreateApiKeyCommand(apiKeyRepo, userRepo);
|
||||
const getUserQuery = new GetUserQuery(userRepo);
|
||||
const listOrgMembersQuery = new ListOrgMembersQuery(orgRepo, userRepo);
|
||||
|
||||
// 11. HTTP server
|
||||
const app = createServer({
|
||||
config,
|
||||
logger,
|
||||
@@ -105,6 +133,18 @@ async function bootstrap(): Promise<void> {
|
||||
crawlingDeps: { startCrawl, stopCrawl, getSession, listSessions },
|
||||
findingsDeps: { getFinding, listFindings, findingStats, resolveFinding, enrichFinding },
|
||||
fuzzingDeps: { runFuzz, repository: fuzzRepo },
|
||||
authDeps: {
|
||||
registerCommand,
|
||||
loginCommand,
|
||||
createOrgCommand,
|
||||
inviteMemberCommand,
|
||||
createApiKeyCommand,
|
||||
getUserQuery,
|
||||
listOrgMembersQuery,
|
||||
sessionRepository: authSessionRepo,
|
||||
apiKeyRepository: apiKeyRepo,
|
||||
userRepository: userRepo,
|
||||
},
|
||||
});
|
||||
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
62
src/modules/auth/application/commands/CreateApiKeyCommand.ts
Normal file
62
src/modules/auth/application/commands/CreateApiKeyCommand.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { UseCase } from '../../../../shared/application/UseCase';
|
||||
import { Result, Ok, Err } from '../../../../shared/domain/Result';
|
||||
import { ApiKey } from '../../domain/entities/ApiKey';
|
||||
import { IApiKeyRepository } from '../../domain/ports/IApiKeyRepository';
|
||||
import { IUserRepository } from '../../domain/ports/IUserRepository';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
|
||||
export interface CreateApiKeyRequest {
|
||||
userId: string;
|
||||
orgId: string;
|
||||
name: string;
|
||||
permissions?: string[];
|
||||
expiresAt?: Date;
|
||||
}
|
||||
|
||||
export interface CreateApiKeyResponse {
|
||||
id: string;
|
||||
key: string;
|
||||
keyPrefix: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class CreateApiKeyCommand implements UseCase<CreateApiKeyRequest, CreateApiKeyResponse, string> {
|
||||
constructor(
|
||||
private readonly apiKeyRepository: IApiKeyRepository,
|
||||
private readonly userRepository: IUserRepository
|
||||
) {}
|
||||
|
||||
async execute(request: CreateApiKeyRequest): Promise<Result<CreateApiKeyResponse, string>> {
|
||||
const user = await this.userRepository.findById(request.userId);
|
||||
if (!user) {
|
||||
return Err('User not found');
|
||||
}
|
||||
|
||||
if (!request.name.trim()) {
|
||||
return Err('API key name is required');
|
||||
}
|
||||
|
||||
const rawKey = `abe_${randomBytes(32).toString('hex')}`;
|
||||
const keyHash = createHash('sha256').update(rawKey).digest('hex');
|
||||
const keyPrefix = rawKey.substring(0, 12);
|
||||
|
||||
const apiKey = ApiKey.create({
|
||||
userId: request.userId,
|
||||
orgId: request.orgId,
|
||||
name: request.name.trim(),
|
||||
keyHash,
|
||||
keyPrefix,
|
||||
permissions: request.permissions ?? ['member'],
|
||||
expiresAt: request.expiresAt,
|
||||
});
|
||||
|
||||
await this.apiKeyRepository.save(apiKey);
|
||||
|
||||
return Ok({
|
||||
id: apiKey.id.toString(),
|
||||
key: rawKey,
|
||||
keyPrefix,
|
||||
name: apiKey.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { UseCase } from '../../../../shared/application/UseCase';
|
||||
import { EventBus } from '../../../../shared/application/EventBus';
|
||||
import { Result, Ok, Err } from '../../../../shared/domain/Result';
|
||||
import { Organization } from '../../domain/entities/Organization';
|
||||
import { IOrganizationRepository } from '../../domain/ports/IOrganizationRepository';
|
||||
import { IUserRepository } from '../../domain/ports/IUserRepository';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface CreateOrganizationRequest {
|
||||
name: string;
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface CreateOrganizationResponse {
|
||||
orgId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export class CreateOrganizationCommand implements UseCase<CreateOrganizationRequest, CreateOrganizationResponse, string> {
|
||||
constructor(
|
||||
private readonly orgRepository: IOrganizationRepository,
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly eventBus: EventBus
|
||||
) {}
|
||||
|
||||
async execute(request: CreateOrganizationRequest): Promise<Result<CreateOrganizationResponse, string>> {
|
||||
const user = await this.userRepository.findById(request.ownerId);
|
||||
if (!user) {
|
||||
return Err('User not found');
|
||||
}
|
||||
|
||||
const slug = Organization.slugify(request.name);
|
||||
if (!slug) {
|
||||
return Err('Invalid organization name');
|
||||
}
|
||||
|
||||
const existing = await this.orgRepository.findBySlug(slug);
|
||||
if (existing) {
|
||||
return Err('Organization name already taken');
|
||||
}
|
||||
|
||||
const org = Organization.create({ name: request.name, slug });
|
||||
await this.orgRepository.save(org);
|
||||
|
||||
await this.orgRepository.addMember({
|
||||
id: randomUUID(),
|
||||
orgId: org.id.toString(),
|
||||
userId: request.ownerId,
|
||||
role: 'owner',
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
|
||||
user.assignToOrg(org.id.toString());
|
||||
await this.userRepository.save(user);
|
||||
|
||||
for (const event of org.domainEvents) {
|
||||
await this.eventBus.publish(event);
|
||||
}
|
||||
org.clearEvents();
|
||||
|
||||
return Ok({
|
||||
orgId: org.id.toString(),
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
});
|
||||
}
|
||||
}
|
||||
83
src/modules/auth/application/commands/InviteMemberCommand.ts
Normal file
83
src/modules/auth/application/commands/InviteMemberCommand.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { UseCase } from '../../../../shared/application/UseCase';
|
||||
import { EventBus } from '../../../../shared/application/EventBus';
|
||||
import { Result, Ok, Err } from '../../../../shared/domain/Result';
|
||||
import { Email } from '../../domain/value-objects/Email';
|
||||
import { Role } from '../../domain/value-objects/Role';
|
||||
import { MemberInvited } from '../../domain/events/MemberInvited';
|
||||
import { IOrganizationRepository } from '../../domain/ports/IOrganizationRepository';
|
||||
import { IUserRepository } from '../../domain/ports/IUserRepository';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface InviteMemberRequest {
|
||||
orgId: string;
|
||||
inviterUserId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface InviteMemberResponse {
|
||||
memberId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export class InviteMemberCommand implements UseCase<InviteMemberRequest, InviteMemberResponse, string> {
|
||||
constructor(
|
||||
private readonly orgRepository: IOrganizationRepository,
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly eventBus: EventBus
|
||||
) {}
|
||||
|
||||
async execute(request: InviteMemberRequest): Promise<Result<InviteMemberResponse, string>> {
|
||||
const org = await this.orgRepository.findById(request.orgId);
|
||||
if (!org) {
|
||||
return Err('Organization not found');
|
||||
}
|
||||
|
||||
let email: Email;
|
||||
try {
|
||||
email = Email.create(request.email);
|
||||
} catch {
|
||||
return Err('Invalid email address');
|
||||
}
|
||||
|
||||
let role: Role;
|
||||
try {
|
||||
role = Role.create(request.role);
|
||||
} catch {
|
||||
return Err('Invalid role');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findByEmail(email.value);
|
||||
if (!user) {
|
||||
return Err('User with this email not found. They must register first.');
|
||||
}
|
||||
|
||||
const existing = await this.orgRepository.getMember(request.orgId, user.id.toString());
|
||||
if (existing) {
|
||||
return Err('User is already a member of this organization');
|
||||
}
|
||||
|
||||
const memberId = randomUUID();
|
||||
await this.orgRepository.addMember({
|
||||
id: memberId,
|
||||
orgId: request.orgId,
|
||||
userId: user.id.toString(),
|
||||
role: role.value,
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
|
||||
const event = new MemberInvited(request.orgId, {
|
||||
email: email.value,
|
||||
role: role.value,
|
||||
inviterUserId: request.inviterUserId,
|
||||
});
|
||||
await this.eventBus.publish(event);
|
||||
|
||||
return Ok({
|
||||
memberId,
|
||||
email: email.value,
|
||||
role: role.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
77
src/modules/auth/application/commands/LoginCommand.ts
Normal file
77
src/modules/auth/application/commands/LoginCommand.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { UseCase } from '../../../../shared/application/UseCase';
|
||||
import { EventBus } from '../../../../shared/application/EventBus';
|
||||
import { Result, Ok, Err } from '../../../../shared/domain/Result';
|
||||
import { Email } from '../../domain/value-objects/Email';
|
||||
import { IUserRepository } from '../../domain/ports/IUserRepository';
|
||||
import { ISessionRepository, AuthSession } from '../../domain/ports/ISessionRepository';
|
||||
import { UserLoggedIn } from '../../domain/events/UserLoggedIn';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
userId: string;
|
||||
sessionToken: string;
|
||||
expiresAt: Date;
|
||||
role: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class LoginCommand implements UseCase<LoginRequest, LoginResponse, string> {
|
||||
constructor(
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly sessionRepository: ISessionRepository,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly verifyPassword: (password: string, hash: string) => Promise<boolean>,
|
||||
private readonly sessionMaxAgeSeconds: number = 7 * 24 * 60 * 60
|
||||
) {}
|
||||
|
||||
async execute(request: LoginRequest): Promise<Result<LoginResponse, string>> {
|
||||
let email: Email;
|
||||
try {
|
||||
email = Email.create(request.email);
|
||||
} catch {
|
||||
return Err('Invalid credentials');
|
||||
}
|
||||
|
||||
const user = await this.userRepository.findByEmail(email.value);
|
||||
if (!user) {
|
||||
return Err('Invalid credentials');
|
||||
}
|
||||
|
||||
const valid = await this.verifyPassword(request.password, user.passwordHash);
|
||||
if (!valid) {
|
||||
return Err('Invalid credentials');
|
||||
}
|
||||
|
||||
const token = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + this.sessionMaxAgeSeconds * 1000);
|
||||
|
||||
const session: AuthSession = {
|
||||
id: randomUUID(),
|
||||
userId: user.id.toString(),
|
||||
token,
|
||||
expiresAt,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await this.sessionRepository.save(session);
|
||||
|
||||
const event = new UserLoggedIn(user.id.toString(), {
|
||||
email: user.email.value,
|
||||
sessionId: session.id,
|
||||
});
|
||||
await this.eventBus.publish(event);
|
||||
|
||||
return Ok({
|
||||
userId: user.id.toString(),
|
||||
sessionToken: token,
|
||||
expiresAt,
|
||||
role: user.role.value,
|
||||
name: user.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
71
src/modules/auth/application/commands/RegisterCommand.ts
Normal file
71
src/modules/auth/application/commands/RegisterCommand.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { UseCase } from '../../../../shared/application/UseCase';
|
||||
import { EventBus } from '../../../../shared/application/EventBus';
|
||||
import { Result, Ok, Err } from '../../../../shared/domain/Result';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import { Email } from '../../domain/value-objects/Email';
|
||||
import { Role } from '../../domain/value-objects/Role';
|
||||
import { IUserRepository } from '../../domain/ports/IUserRepository';
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export class RegisterCommand implements UseCase<RegisterRequest, RegisterResponse, string> {
|
||||
constructor(
|
||||
private readonly userRepository: IUserRepository,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly hashPassword: (password: string) => Promise<string>
|
||||
) {}
|
||||
|
||||
async execute(request: RegisterRequest): Promise<Result<RegisterResponse, string>> {
|
||||
let email: Email;
|
||||
try {
|
||||
email = Email.create(request.email);
|
||||
} catch {
|
||||
return Err('Invalid email address');
|
||||
}
|
||||
|
||||
const existing = await this.userRepository.findByEmail(email.value);
|
||||
if (existing) {
|
||||
return Err('Email already registered');
|
||||
}
|
||||
|
||||
if (request.password.length < 8) {
|
||||
return Err('Password must be at least 8 characters');
|
||||
}
|
||||
|
||||
let role: Role;
|
||||
try {
|
||||
role = request.role ? Role.create(request.role) : Role.member();
|
||||
} catch {
|
||||
return Err('Invalid role');
|
||||
}
|
||||
|
||||
const passwordHash = await this.hashPassword(request.password);
|
||||
const user = User.create({ email, name: request.name, passwordHash, role });
|
||||
|
||||
await this.userRepository.save(user);
|
||||
|
||||
for (const event of user.domainEvents) {
|
||||
await this.eventBus.publish(event);
|
||||
}
|
||||
user.clearEvents();
|
||||
|
||||
return Ok({
|
||||
userId: user.id.toString(),
|
||||
email: user.email.value,
|
||||
name: user.name,
|
||||
role: user.role.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
96
src/modules/auth/application/middleware/AuthMiddleware.ts
Normal file
96
src/modules/auth/application/middleware/AuthMiddleware.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { IUserRepository } from '../../domain/ports/IUserRepository';
|
||||
import { ISessionRepository } from '../../domain/ports/ISessionRepository';
|
||||
import { IApiKeyRepository } from '../../domain/ports/IApiKeyRepository';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
orgId?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createAuthMiddleware(
|
||||
userRepository: IUserRepository,
|
||||
sessionRepository: ISessionRepository,
|
||||
apiKeyRepository: IApiKeyRepository
|
||||
) {
|
||||
return async function authMiddleware(req: Request, res: Response, next: NextFunction): Promise<void> {
|
||||
try {
|
||||
// 1. Check session cookie
|
||||
const sessionToken = req.cookies?.['abe_session'];
|
||||
if (sessionToken) {
|
||||
const session = await sessionRepository.findByToken(sessionToken);
|
||||
if (session && session.expiresAt > new Date()) {
|
||||
const user = await userRepository.findById(session.userId);
|
||||
if (user) {
|
||||
req.user = {
|
||||
id: user.id.toString(),
|
||||
email: user.email.value,
|
||||
name: user.name,
|
||||
role: user.role.value,
|
||||
orgId: user.orgId,
|
||||
};
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check Bearer JWT (session token in header)
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
const session = await sessionRepository.findByToken(token);
|
||||
if (session && session.expiresAt > new Date()) {
|
||||
const user = await userRepository.findById(session.userId);
|
||||
if (user) {
|
||||
req.user = {
|
||||
id: user.id.toString(),
|
||||
email: user.email.value,
|
||||
name: user.name,
|
||||
role: user.role.value,
|
||||
orgId: user.orgId,
|
||||
};
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Check API key
|
||||
const apiKeyHeader = req.headers['x-abe-api-key'];
|
||||
if (apiKeyHeader && typeof apiKeyHeader === 'string') {
|
||||
const keyHash = createHash('sha256').update(apiKeyHeader).digest('hex');
|
||||
const apiKey = await apiKeyRepository.findByHash(keyHash);
|
||||
if (apiKey && !apiKey.isExpired()) {
|
||||
const user = await userRepository.findById(apiKey.userId);
|
||||
if (user) {
|
||||
await apiKeyRepository.updateLastUsed(apiKey.id.toString(), new Date());
|
||||
req.user = {
|
||||
id: user.id.toString(),
|
||||
email: user.email.value,
|
||||
name: user.name,
|
||||
role: user.role.value,
|
||||
orgId: user.orgId,
|
||||
};
|
||||
return next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
};
|
||||
}
|
||||
25
src/modules/auth/application/middleware/RBACMiddleware.ts
Normal file
25
src/modules/auth/application/middleware/RBACMiddleware.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { defineAbilityFor } from '../../infrastructure/casl/AbilityFactory';
|
||||
import { RoleValue } from '../../domain/value-objects/Role';
|
||||
import { PermissionSubject } from '../../domain/value-objects/Permission';
|
||||
|
||||
export function requirePermission(action: string, subject: PermissionSubject) {
|
||||
return function rbacMiddleware(req: Request, res: Response, next: NextFunction): void {
|
||||
if (!req.user) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ability = defineAbilityFor(req.user.role as RoleValue);
|
||||
|
||||
if (!ability.can(action, subject)) {
|
||||
res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: `You do not have permission to ${action} ${subject}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
35
src/modules/auth/application/queries/GetUserQuery.ts
Normal file
35
src/modules/auth/application/queries/GetUserQuery.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { UseCase } from '../../../../shared/application/UseCase';
|
||||
import { Result, Ok, Err } from '../../../../shared/domain/Result';
|
||||
import { IUserRepository } from '../../domain/ports/IUserRepository';
|
||||
|
||||
export interface GetUserRequest {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface GetUserResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
orgId?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class GetUserQuery implements UseCase<GetUserRequest, GetUserResponse, string> {
|
||||
constructor(private readonly userRepository: IUserRepository) {}
|
||||
|
||||
async execute(request: GetUserRequest): Promise<Result<GetUserResponse, string>> {
|
||||
const user = await this.userRepository.findById(request.userId);
|
||||
if (!user) {
|
||||
return Err('User not found');
|
||||
}
|
||||
return Ok({
|
||||
id: user.id.toString(),
|
||||
email: user.email.value,
|
||||
name: user.name,
|
||||
role: user.role.value,
|
||||
orgId: user.orgId,
|
||||
createdAt: user.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
55
src/modules/auth/application/queries/ListOrgMembersQuery.ts
Normal file
55
src/modules/auth/application/queries/ListOrgMembersQuery.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { UseCase } from '../../../../shared/application/UseCase';
|
||||
import { Result, Ok, Err } from '../../../../shared/domain/Result';
|
||||
import { IOrganizationRepository } from '../../domain/ports/IOrganizationRepository';
|
||||
import { IUserRepository } from '../../domain/ports/IUserRepository';
|
||||
|
||||
export interface ListOrgMembersRequest {
|
||||
orgId: string;
|
||||
}
|
||||
|
||||
export interface OrgMemberDTO {
|
||||
id: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: string;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
export interface ListOrgMembersResponse {
|
||||
members: OrgMemberDTO[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export class ListOrgMembersQuery implements UseCase<ListOrgMembersRequest, ListOrgMembersResponse, string> {
|
||||
constructor(
|
||||
private readonly orgRepository: IOrganizationRepository,
|
||||
private readonly userRepository: IUserRepository
|
||||
) {}
|
||||
|
||||
async execute(request: ListOrgMembersRequest): Promise<Result<ListOrgMembersResponse, string>> {
|
||||
const org = await this.orgRepository.findById(request.orgId);
|
||||
if (!org) {
|
||||
return Err('Organization not found');
|
||||
}
|
||||
|
||||
const members = await this.orgRepository.listMembers(request.orgId);
|
||||
|
||||
const dtos: OrgMemberDTO[] = [];
|
||||
for (const member of members) {
|
||||
const user = await this.userRepository.findById(member.userId);
|
||||
if (user) {
|
||||
dtos.push({
|
||||
id: member.id,
|
||||
userId: member.userId,
|
||||
email: user.email.value,
|
||||
name: user.name,
|
||||
role: member.role,
|
||||
joinedAt: member.joinedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Ok({ members: dtos, total: dtos.length });
|
||||
}
|
||||
}
|
||||
50
src/modules/auth/domain/entities/ApiKey.ts
Normal file
50
src/modules/auth/domain/entities/ApiKey.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { AggregateRoot } from '../../../../shared/domain/AggregateRoot';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
|
||||
export interface ApiKeyProps {
|
||||
userId: string;
|
||||
orgId: string;
|
||||
name: string;
|
||||
keyHash: string;
|
||||
keyPrefix: string;
|
||||
permissions: string[];
|
||||
expiresAt?: Date;
|
||||
lastUsedAt?: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class ApiKey extends AggregateRoot<ApiKeyProps> {
|
||||
static create(props: Omit<ApiKeyProps, 'createdAt' | 'lastUsedAt'>, id?: UniqueId): ApiKey {
|
||||
const keyId = id ?? UniqueId.create();
|
||||
return new ApiKey(
|
||||
{
|
||||
...props,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
keyId
|
||||
);
|
||||
}
|
||||
|
||||
static reconstitute(props: ApiKeyProps, id: UniqueId): ApiKey {
|
||||
return new ApiKey(props, id);
|
||||
}
|
||||
|
||||
get userId(): string { return this.props.userId; }
|
||||
get orgId(): string { return this.props.orgId; }
|
||||
get name(): string { return this.props.name; }
|
||||
get keyHash(): string { return this.props.keyHash; }
|
||||
get keyPrefix(): string { return this.props.keyPrefix; }
|
||||
get permissions(): string[] { return this.props.permissions; }
|
||||
get expiresAt(): Date | undefined { return this.props.expiresAt; }
|
||||
get lastUsedAt(): Date | undefined { return this.props.lastUsedAt; }
|
||||
get createdAt(): Date { return this.props.createdAt; }
|
||||
|
||||
isExpired(): boolean {
|
||||
if (!this.props.expiresAt) return false;
|
||||
return new Date() > this.props.expiresAt;
|
||||
}
|
||||
|
||||
markUsed(): void {
|
||||
this.props.lastUsedAt = new Date();
|
||||
}
|
||||
}
|
||||
44
src/modules/auth/domain/entities/Organization.ts
Normal file
44
src/modules/auth/domain/entities/Organization.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { AggregateRoot } from '../../../../shared/domain/AggregateRoot';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
import { OrgCreated } from '../events/OrgCreated';
|
||||
|
||||
export interface OrganizationProps {
|
||||
name: string;
|
||||
slug: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export class Organization extends AggregateRoot<OrganizationProps> {
|
||||
static create(props: Omit<OrganizationProps, 'createdAt'>, id?: UniqueId): Organization {
|
||||
const orgId = id ?? UniqueId.create();
|
||||
const org = new Organization(
|
||||
{
|
||||
...props,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
orgId
|
||||
);
|
||||
org.addDomainEvent(
|
||||
new OrgCreated(orgId.toString(), {
|
||||
name: props.name,
|
||||
slug: props.slug,
|
||||
})
|
||||
);
|
||||
return org;
|
||||
}
|
||||
|
||||
static reconstitute(props: OrganizationProps, id: UniqueId): Organization {
|
||||
return new Organization(props, id);
|
||||
}
|
||||
|
||||
static slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
get name(): string { return this.props.name; }
|
||||
get slug(): string { return this.props.slug; }
|
||||
get createdAt(): Date { return this.props.createdAt; }
|
||||
}
|
||||
60
src/modules/auth/domain/entities/User.ts
Normal file
60
src/modules/auth/domain/entities/User.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { AggregateRoot } from '../../../../shared/domain/AggregateRoot';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
import { Email } from '../value-objects/Email';
|
||||
import { Role } from '../value-objects/Role';
|
||||
import { UserCreated } from '../events/UserCreated';
|
||||
|
||||
export interface UserProps {
|
||||
email: Email;
|
||||
name: string;
|
||||
passwordHash: string;
|
||||
role: Role;
|
||||
orgId?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class User extends AggregateRoot<UserProps> {
|
||||
static create(props: Omit<UserProps, 'createdAt' | 'updatedAt'>, id?: UniqueId): User {
|
||||
const userId = id ?? UniqueId.create();
|
||||
const now = new Date();
|
||||
const user = new User(
|
||||
{
|
||||
...props,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
userId
|
||||
);
|
||||
user.addDomainEvent(
|
||||
new UserCreated(userId.toString(), {
|
||||
email: props.email.value,
|
||||
name: props.name,
|
||||
role: props.role.value,
|
||||
})
|
||||
);
|
||||
return user;
|
||||
}
|
||||
|
||||
static reconstitute(props: UserProps, id: UniqueId): User {
|
||||
return new User(props, id);
|
||||
}
|
||||
|
||||
get email(): Email { return this.props.email; }
|
||||
get name(): string { return this.props.name; }
|
||||
get passwordHash(): string { return this.props.passwordHash; }
|
||||
get role(): Role { return this.props.role; }
|
||||
get orgId(): string | undefined { return this.props.orgId; }
|
||||
get createdAt(): Date { return this.props.createdAt; }
|
||||
get updatedAt(): Date { return this.props.updatedAt; }
|
||||
|
||||
assignToOrg(orgId: string): void {
|
||||
this.props.orgId = orgId;
|
||||
this.props.updatedAt = new Date();
|
||||
}
|
||||
|
||||
changeRole(role: Role): void {
|
||||
this.props.role = role;
|
||||
this.props.updatedAt = new Date();
|
||||
}
|
||||
}
|
||||
13
src/modules/auth/domain/events/MemberInvited.ts
Normal file
13
src/modules/auth/domain/events/MemberInvited.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
|
||||
|
||||
export class MemberInvited implements DomainEvent {
|
||||
readonly eventId = randomUUID();
|
||||
readonly eventName = 'auth.member.invited';
|
||||
readonly occurredOn = new Date();
|
||||
|
||||
constructor(
|
||||
readonly aggregateId: string,
|
||||
readonly payload: Record<string, unknown>
|
||||
) {}
|
||||
}
|
||||
13
src/modules/auth/domain/events/OrgCreated.ts
Normal file
13
src/modules/auth/domain/events/OrgCreated.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
|
||||
|
||||
export class OrgCreated implements DomainEvent {
|
||||
readonly eventId = randomUUID();
|
||||
readonly eventName = 'auth.org.created';
|
||||
readonly occurredOn = new Date();
|
||||
|
||||
constructor(
|
||||
readonly aggregateId: string,
|
||||
readonly payload: Record<string, unknown>
|
||||
) {}
|
||||
}
|
||||
13
src/modules/auth/domain/events/UserCreated.ts
Normal file
13
src/modules/auth/domain/events/UserCreated.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
|
||||
|
||||
export class UserCreated implements DomainEvent {
|
||||
readonly eventId = randomUUID();
|
||||
readonly eventName = 'auth.user.created';
|
||||
readonly occurredOn = new Date();
|
||||
|
||||
constructor(
|
||||
readonly aggregateId: string,
|
||||
readonly payload: Record<string, unknown>
|
||||
) {}
|
||||
}
|
||||
13
src/modules/auth/domain/events/UserLoggedIn.ts
Normal file
13
src/modules/auth/domain/events/UserLoggedIn.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DomainEvent } from '../../../../shared/domain/DomainEvent';
|
||||
|
||||
export class UserLoggedIn implements DomainEvent {
|
||||
readonly eventId = randomUUID();
|
||||
readonly eventName = 'auth.user.logged_in';
|
||||
readonly occurredOn = new Date();
|
||||
|
||||
constructor(
|
||||
readonly aggregateId: string,
|
||||
readonly payload: Record<string, unknown>
|
||||
) {}
|
||||
}
|
||||
10
src/modules/auth/domain/ports/IApiKeyRepository.ts
Normal file
10
src/modules/auth/domain/ports/IApiKeyRepository.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { ApiKey } from '../entities/ApiKey';
|
||||
|
||||
export interface IApiKeyRepository {
|
||||
save(apiKey: ApiKey): Promise<void>;
|
||||
findById(id: string): Promise<ApiKey | undefined>;
|
||||
findByHash(keyHash: string): Promise<ApiKey | undefined>;
|
||||
listByUser(userId: string): Promise<ApiKey[]>;
|
||||
delete(id: string): Promise<void>;
|
||||
updateLastUsed(id: string, lastUsedAt: Date): Promise<void>;
|
||||
}
|
||||
21
src/modules/auth/domain/ports/IOrganizationRepository.ts
Normal file
21
src/modules/auth/domain/ports/IOrganizationRepository.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Organization } from '../entities/Organization';
|
||||
|
||||
export interface OrgMember {
|
||||
id: string;
|
||||
orgId: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
export interface IOrganizationRepository {
|
||||
save(org: Organization): Promise<void>;
|
||||
findById(id: string): Promise<Organization | undefined>;
|
||||
findBySlug(slug: string): Promise<Organization | undefined>;
|
||||
findAll(): Promise<Organization[]>;
|
||||
addMember(member: OrgMember): Promise<void>;
|
||||
getMember(orgId: string, userId: string): Promise<OrgMember | undefined>;
|
||||
listMembers(orgId: string): Promise<OrgMember[]>;
|
||||
updateMemberRole(orgId: string, userId: string, role: string): Promise<void>;
|
||||
removeMember(orgId: string, userId: string): Promise<void>;
|
||||
}
|
||||
14
src/modules/auth/domain/ports/ISessionRepository.ts
Normal file
14
src/modules/auth/domain/ports/ISessionRepository.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export interface AuthSession {
|
||||
id: string;
|
||||
userId: string;
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface ISessionRepository {
|
||||
save(session: AuthSession): Promise<void>;
|
||||
findByToken(token: string): Promise<AuthSession | undefined>;
|
||||
deleteByToken(token: string): Promise<void>;
|
||||
deleteExpired(): Promise<void>;
|
||||
}
|
||||
9
src/modules/auth/domain/ports/IUserRepository.ts
Normal file
9
src/modules/auth/domain/ports/IUserRepository.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { User } from '../entities/User';
|
||||
|
||||
export interface IUserRepository {
|
||||
save(user: User): Promise<void>;
|
||||
findById(id: string): Promise<User | undefined>;
|
||||
findByEmail(email: string): Promise<User | undefined>;
|
||||
findAll(): Promise<User[]>;
|
||||
count(): Promise<number>;
|
||||
}
|
||||
21
src/modules/auth/domain/value-objects/Email.ts
Normal file
21
src/modules/auth/domain/value-objects/Email.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ValueObject } from '../../../../shared/domain/ValueObject';
|
||||
|
||||
interface EmailProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export class Email extends ValueObject<EmailProps> {
|
||||
private static readonly EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
static create(value: string): Email {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!Email.EMAIL_REGEX.test(normalized)) {
|
||||
throw new Error(`Invalid email address: ${value}`);
|
||||
}
|
||||
return new Email({ value: normalized });
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
}
|
||||
28
src/modules/auth/domain/value-objects/Permission.ts
Normal file
28
src/modules/auth/domain/value-objects/Permission.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ValueObject } from '../../../../shared/domain/ValueObject';
|
||||
|
||||
export type PermissionAction = 'create' | 'read' | 'update' | 'delete' | 'manage';
|
||||
export type PermissionSubject =
|
||||
| 'Session'
|
||||
| 'Finding'
|
||||
| 'Report'
|
||||
| 'Integration'
|
||||
| 'Organization'
|
||||
| 'User'
|
||||
| 'Settings'
|
||||
| 'License'
|
||||
| 'ApiKey'
|
||||
| 'all';
|
||||
|
||||
interface PermissionProps {
|
||||
action: PermissionAction;
|
||||
subject: PermissionSubject;
|
||||
}
|
||||
|
||||
export class Permission extends ValueObject<PermissionProps> {
|
||||
static create(action: PermissionAction, subject: PermissionSubject): Permission {
|
||||
return new Permission({ action, subject });
|
||||
}
|
||||
|
||||
get action(): PermissionAction { return this.props.action; }
|
||||
get subject(): PermissionSubject { return this.props.subject; }
|
||||
}
|
||||
37
src/modules/auth/domain/value-objects/Role.ts
Normal file
37
src/modules/auth/domain/value-objects/Role.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { ValueObject } from '../../../../shared/domain/ValueObject';
|
||||
|
||||
export type RoleValue = 'owner' | 'admin' | 'member' | 'viewer';
|
||||
|
||||
interface RoleProps {
|
||||
value: RoleValue;
|
||||
}
|
||||
|
||||
export class Role extends ValueObject<RoleProps> {
|
||||
static readonly OWNER: RoleValue = 'owner';
|
||||
static readonly ADMIN: RoleValue = 'admin';
|
||||
static readonly MEMBER: RoleValue = 'member';
|
||||
static readonly VIEWER: RoleValue = 'viewer';
|
||||
|
||||
private static readonly VALID_ROLES: RoleValue[] = ['owner', 'admin', 'member', 'viewer'];
|
||||
|
||||
static create(value: string): Role {
|
||||
if (!Role.VALID_ROLES.includes(value as RoleValue)) {
|
||||
throw new Error(`Invalid role: ${value}. Must be one of: ${Role.VALID_ROLES.join(', ')}`);
|
||||
}
|
||||
return new Role({ value: value as RoleValue });
|
||||
}
|
||||
|
||||
static owner(): Role { return new Role({ value: 'owner' }); }
|
||||
static admin(): Role { return new Role({ value: 'admin' }); }
|
||||
static member(): Role { return new Role({ value: 'member' }); }
|
||||
static viewer(): Role { return new Role({ value: 'viewer' }); }
|
||||
|
||||
get value(): RoleValue {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
isOwner(): boolean { return this.props.value === 'owner'; }
|
||||
isAdmin(): boolean { return this.props.value === 'admin'; }
|
||||
isMember(): boolean { return this.props.value === 'member'; }
|
||||
isViewer(): boolean { return this.props.value === 'viewer'; }
|
||||
}
|
||||
28
src/modules/auth/index.ts
Normal file
28
src/modules/auth/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export { User } from './domain/entities/User';
|
||||
export { Organization } from './domain/entities/Organization';
|
||||
export { ApiKey } from './domain/entities/ApiKey';
|
||||
export { Email } from './domain/value-objects/Email';
|
||||
export { Role } from './domain/value-objects/Role';
|
||||
export type { RoleValue } from './domain/value-objects/Role';
|
||||
export { Permission } from './domain/value-objects/Permission';
|
||||
export type { IUserRepository } from './domain/ports/IUserRepository';
|
||||
export type { IOrganizationRepository, OrgMember } from './domain/ports/IOrganizationRepository';
|
||||
export type { IApiKeyRepository } from './domain/ports/IApiKeyRepository';
|
||||
export type { ISessionRepository, AuthSession } from './domain/ports/ISessionRepository';
|
||||
export { RegisterCommand } from './application/commands/RegisterCommand';
|
||||
export { LoginCommand } from './application/commands/LoginCommand';
|
||||
export { CreateOrganizationCommand } from './application/commands/CreateOrganizationCommand';
|
||||
export { InviteMemberCommand } from './application/commands/InviteMemberCommand';
|
||||
export { CreateApiKeyCommand } from './application/commands/CreateApiKeyCommand';
|
||||
export { GetUserQuery } from './application/queries/GetUserQuery';
|
||||
export { ListOrgMembersQuery } from './application/queries/ListOrgMembersQuery';
|
||||
export { createAuthMiddleware } from './application/middleware/AuthMiddleware';
|
||||
export type { AuthenticatedUser } from './application/middleware/AuthMiddleware';
|
||||
export { requirePermission } from './application/middleware/RBACMiddleware';
|
||||
export { hashPassword, verifyPassword } from './infrastructure/auth/PasswordService';
|
||||
export { defineAbilityFor } from './infrastructure/casl/AbilityFactory';
|
||||
export { KyselyUserRepository } from './infrastructure/repositories/KyselyUserRepository';
|
||||
export { KyselyOrganizationRepository } from './infrastructure/repositories/KyselyOrganizationRepository';
|
||||
export { KyselyApiKeyRepository } from './infrastructure/repositories/KyselyApiKeyRepository';
|
||||
export { KyselySessionRepository } from './infrastructure/repositories/KyselySessionRepository';
|
||||
export { createAuthController } from './infrastructure/http/AuthController';
|
||||
9
src/modules/auth/infrastructure/auth/PasswordService.ts
Normal file
9
src/modules/auth/infrastructure/auth/PasswordService.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import argon2 from 'argon2';
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return argon2.verify(hash, password);
|
||||
}
|
||||
37
src/modules/auth/infrastructure/casl/AbilityFactory.ts
Normal file
37
src/modules/auth/infrastructure/casl/AbilityFactory.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { AbilityBuilder, createMongoAbility, MongoAbility } from '@casl/ability';
|
||||
import { RoleValue } from '../../domain/value-objects/Role';
|
||||
import { PermissionSubject } from '../../domain/value-objects/Permission';
|
||||
|
||||
export type AppAbility = MongoAbility;
|
||||
|
||||
export function defineAbilityFor(role: RoleValue): AppAbility {
|
||||
const { can, cannot, build } = new AbilityBuilder<AppAbility>(createMongoAbility);
|
||||
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
can('manage', 'all');
|
||||
break;
|
||||
|
||||
case 'admin':
|
||||
can('manage', 'all');
|
||||
cannot('delete', 'Organization' as PermissionSubject);
|
||||
cannot('manage', 'License' as PermissionSubject);
|
||||
can('read', 'License' as PermissionSubject);
|
||||
break;
|
||||
|
||||
case 'member':
|
||||
can('create', ['Session', 'Finding', 'Report'] as PermissionSubject[]);
|
||||
can('read', 'all');
|
||||
can('update', 'Finding' as PermissionSubject);
|
||||
break;
|
||||
|
||||
case 'viewer':
|
||||
can('read', 'all');
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return build();
|
||||
}
|
||||
210
src/modules/auth/infrastructure/http/AuthController.ts
Normal file
210
src/modules/auth/infrastructure/http/AuthController.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { RegisterCommand } from '../../application/commands/RegisterCommand';
|
||||
import { LoginCommand } from '../../application/commands/LoginCommand';
|
||||
import { CreateOrganizationCommand } from '../../application/commands/CreateOrganizationCommand';
|
||||
import { InviteMemberCommand } from '../../application/commands/InviteMemberCommand';
|
||||
import { CreateApiKeyCommand } from '../../application/commands/CreateApiKeyCommand';
|
||||
import { GetUserQuery } from '../../application/queries/GetUserQuery';
|
||||
import { ListOrgMembersQuery } from '../../application/queries/ListOrgMembersQuery';
|
||||
import { ISessionRepository } from '../../domain/ports/ISessionRepository';
|
||||
import { IApiKeyRepository } from '../../domain/ports/IApiKeyRepository';
|
||||
import { IUserRepository } from '../../domain/ports/IUserRepository';
|
||||
import { createAuthMiddleware } from '../../application/middleware/AuthMiddleware';
|
||||
|
||||
export function createAuthController(
|
||||
registerCommand: RegisterCommand,
|
||||
loginCommand: LoginCommand,
|
||||
createOrgCommand: CreateOrganizationCommand,
|
||||
inviteMemberCommand: InviteMemberCommand,
|
||||
createApiKeyCommand: CreateApiKeyCommand,
|
||||
getUserQuery: GetUserQuery,
|
||||
listOrgMembersQuery: ListOrgMembersQuery,
|
||||
sessionRepository: ISessionRepository,
|
||||
apiKeyRepository: IApiKeyRepository,
|
||||
userRepository: IUserRepository
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
const authMiddleware = createAuthMiddleware(userRepository, sessionRepository, apiKeyRepository);
|
||||
|
||||
// POST /api/auth/register
|
||||
router.post('/register', async (req: Request, res: Response) => {
|
||||
const result = await registerCommand.execute({
|
||||
email: req.body.email,
|
||||
password: req.body.password,
|
||||
name: req.body.name,
|
||||
role: req.body.role,
|
||||
});
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.status(201).json(result.value);
|
||||
});
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
const result = await loginCommand.execute({
|
||||
email: req.body.email,
|
||||
password: req.body.password,
|
||||
});
|
||||
if (!result.ok) {
|
||||
res.status(401).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
const { sessionToken, expiresAt, ...userData } = result.value;
|
||||
res.cookie('abe_session', sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env['NODE_ENV'] === 'production',
|
||||
sameSite: 'lax',
|
||||
expires: expiresAt,
|
||||
});
|
||||
res.json({ ...userData, sessionToken });
|
||||
});
|
||||
|
||||
// POST /api/auth/logout
|
||||
router.post('/logout', authMiddleware, async (req: Request, res: Response) => {
|
||||
const token = req.cookies?.['abe_session'] ?? req.headers.authorization?.substring(7);
|
||||
if (token) {
|
||||
await sessionRepository.deleteByToken(token);
|
||||
}
|
||||
res.clearCookie('abe_session');
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
router.get('/me', authMiddleware, async (req: Request, res: Response) => {
|
||||
const result = await getUserQuery.execute({ userId: req.user!.id });
|
||||
if (!result.ok) {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json(result.value);
|
||||
});
|
||||
|
||||
// GET /api/auth/setup-required
|
||||
router.get('/setup-required', async (_req: Request, res: Response) => {
|
||||
const count = await userRepository.count();
|
||||
res.json({ required: count === 0 });
|
||||
});
|
||||
|
||||
// POST /api/auth/setup — first-run setup
|
||||
router.post('/setup', async (req: Request, res: Response) => {
|
||||
const count = await userRepository.count();
|
||||
if (count > 0) {
|
||||
res.status(400).json({ error: 'Setup already completed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const registerResult = await registerCommand.execute({
|
||||
email: req.body.email,
|
||||
password: req.body.password,
|
||||
name: req.body.name,
|
||||
role: 'owner',
|
||||
});
|
||||
|
||||
if (!registerResult.ok) {
|
||||
res.status(400).json({ error: registerResult.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const createOrgResult = await createOrgCommand.execute({
|
||||
name: req.body.orgName ?? 'My Organization',
|
||||
ownerId: registerResult.value.userId,
|
||||
});
|
||||
|
||||
if (!createOrgResult.ok) {
|
||||
res.status(400).json({ error: createOrgResult.error });
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
user: registerResult.value,
|
||||
organization: createOrgResult.value,
|
||||
});
|
||||
});
|
||||
|
||||
// POST /api/auth/organizations — create org
|
||||
router.post('/organizations', authMiddleware, async (req: Request, res: Response) => {
|
||||
const result = await createOrgCommand.execute({
|
||||
name: req.body.name,
|
||||
ownerId: req.user!.id,
|
||||
});
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.status(201).json(result.value);
|
||||
});
|
||||
|
||||
// POST /api/auth/organizations/:orgId/members — invite member
|
||||
router.post('/organizations/:orgId/members', authMiddleware, async (req: Request, res: Response) => {
|
||||
const result = await inviteMemberCommand.execute({
|
||||
orgId: String(req.params['orgId']),
|
||||
inviterUserId: req.user!.id,
|
||||
email: req.body.email,
|
||||
role: req.body.role ?? 'member',
|
||||
});
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.status(201).json(result.value);
|
||||
});
|
||||
|
||||
// GET /api/auth/organizations/:orgId/members
|
||||
router.get('/organizations/:orgId/members', authMiddleware, async (req: Request, res: Response) => {
|
||||
const result = await listOrgMembersQuery.execute({ orgId: String(req.params['orgId']) });
|
||||
if (!result.ok) {
|
||||
res.status(404).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.json(result.value);
|
||||
});
|
||||
|
||||
// POST /api/auth/api-keys — create API key
|
||||
router.post('/api-keys', authMiddleware, async (req: Request, res: Response) => {
|
||||
const result = await createApiKeyCommand.execute({
|
||||
userId: req.user!.id,
|
||||
orgId: req.user!.orgId ?? 'default',
|
||||
name: req.body.name,
|
||||
permissions: req.body.permissions,
|
||||
expiresAt: req.body.expiresAt ? new Date(req.body.expiresAt) : undefined,
|
||||
});
|
||||
if (!result.ok) {
|
||||
res.status(400).json({ error: result.error });
|
||||
return;
|
||||
}
|
||||
res.status(201).json(result.value);
|
||||
});
|
||||
|
||||
// GET /api/auth/api-keys — list API keys
|
||||
router.get('/api-keys', authMiddleware, async (req: Request, res: Response) => {
|
||||
const keys = await apiKeyRepository.listByUser(req.user!.id);
|
||||
res.json(
|
||||
keys.map((k) => ({
|
||||
id: k.id.toString(),
|
||||
name: k.name,
|
||||
keyPrefix: k.keyPrefix,
|
||||
permissions: k.permissions,
|
||||
expiresAt: k.expiresAt,
|
||||
lastUsedAt: k.lastUsedAt,
|
||||
createdAt: k.createdAt,
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
// DELETE /api/auth/api-keys/:id — revoke API key
|
||||
router.delete('/api-keys/:id', authMiddleware, async (req: Request, res: Response) => {
|
||||
const keyId = String(req.params['id']);
|
||||
const key = await apiKeyRepository.findById(keyId);
|
||||
if (!key || key.userId !== req.user!.id) {
|
||||
res.status(404).json({ error: 'API key not found' });
|
||||
return;
|
||||
}
|
||||
await apiKeyRepository.delete(keyId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { Database } from '../../../../shared/infrastructure/DatabaseConnection';
|
||||
import { IApiKeyRepository } from '../../domain/ports/IApiKeyRepository';
|
||||
import { ApiKey } from '../../domain/entities/ApiKey';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
|
||||
export class KyselyApiKeyRepository implements IApiKeyRepository {
|
||||
constructor(private readonly db: Kysely<Database>) {}
|
||||
|
||||
async save(apiKey: ApiKey): Promise<void> {
|
||||
await this.db
|
||||
.insertInto('api_keys')
|
||||
.values({
|
||||
id: apiKey.id.toString(),
|
||||
user_id: apiKey.userId,
|
||||
org_id: apiKey.orgId,
|
||||
name: apiKey.name,
|
||||
key_hash: apiKey.keyHash,
|
||||
key_prefix: apiKey.keyPrefix,
|
||||
permissions: JSON.stringify(apiKey.permissions),
|
||||
expires_at: apiKey.expiresAt ? apiKey.expiresAt.getTime() : null,
|
||||
last_used_at: apiKey.lastUsedAt ? apiKey.lastUsedAt.getTime() : null,
|
||||
created_at: apiKey.createdAt.getTime(),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<ApiKey | undefined> {
|
||||
const row = await this.db
|
||||
.selectFrom('api_keys')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
return row ? this.toDomain(row) : undefined;
|
||||
}
|
||||
|
||||
async findByHash(keyHash: string): Promise<ApiKey | undefined> {
|
||||
const row = await this.db
|
||||
.selectFrom('api_keys')
|
||||
.selectAll()
|
||||
.where('key_hash', '=', keyHash)
|
||||
.executeTakeFirst();
|
||||
return row ? this.toDomain(row) : undefined;
|
||||
}
|
||||
|
||||
async listByUser(userId: string): Promise<ApiKey[]> {
|
||||
const rows = await this.db
|
||||
.selectFrom('api_keys')
|
||||
.selectAll()
|
||||
.where('user_id', '=', userId)
|
||||
.execute();
|
||||
return rows.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await this.db.deleteFrom('api_keys').where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
async updateLastUsed(id: string, lastUsedAt: Date): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('api_keys')
|
||||
.set({ last_used_at: lastUsedAt.getTime() })
|
||||
.where('id', '=', id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
private toDomain(row: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
org_id: string;
|
||||
name: string;
|
||||
key_hash: string;
|
||||
key_prefix: string;
|
||||
permissions: string;
|
||||
expires_at: number | null;
|
||||
last_used_at: number | null;
|
||||
created_at: number;
|
||||
}): ApiKey {
|
||||
return ApiKey.reconstitute(
|
||||
{
|
||||
userId: row.user_id,
|
||||
orgId: row.org_id,
|
||||
name: row.name,
|
||||
keyHash: row.key_hash,
|
||||
keyPrefix: row.key_prefix,
|
||||
permissions: JSON.parse(row.permissions) as string[],
|
||||
expiresAt: row.expires_at ? new Date(row.expires_at) : undefined,
|
||||
lastUsedAt: row.last_used_at ? new Date(row.last_used_at) : undefined,
|
||||
createdAt: new Date(row.created_at),
|
||||
},
|
||||
UniqueId.from(row.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { Database } from '../../../../shared/infrastructure/DatabaseConnection';
|
||||
import { IOrganizationRepository, OrgMember } from '../../domain/ports/IOrganizationRepository';
|
||||
import { Organization } from '../../domain/entities/Organization';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
|
||||
export class KyselyOrganizationRepository implements IOrganizationRepository {
|
||||
constructor(private readonly db: Kysely<Database>) {}
|
||||
|
||||
async save(org: Organization): Promise<void> {
|
||||
await this.db
|
||||
.insertInto('organizations')
|
||||
.values({
|
||||
id: org.id.toString(),
|
||||
name: org.name,
|
||||
slug: org.slug,
|
||||
created_at: org.createdAt.getTime(),
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.column('id').doUpdateSet({ name: org.name })
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Organization | undefined> {
|
||||
const row = await this.db
|
||||
.selectFrom('organizations')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
return row ? this.toDomain(row) : undefined;
|
||||
}
|
||||
|
||||
async findBySlug(slug: string): Promise<Organization | undefined> {
|
||||
const row = await this.db
|
||||
.selectFrom('organizations')
|
||||
.selectAll()
|
||||
.where('slug', '=', slug)
|
||||
.executeTakeFirst();
|
||||
return row ? this.toDomain(row) : undefined;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Organization[]> {
|
||||
const rows = await this.db.selectFrom('organizations').selectAll().execute();
|
||||
return rows.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
async addMember(member: OrgMember): Promise<void> {
|
||||
await this.db
|
||||
.insertInto('org_members')
|
||||
.values({
|
||||
id: member.id,
|
||||
org_id: member.orgId,
|
||||
user_id: member.userId,
|
||||
role: member.role,
|
||||
joined_at: member.joinedAt.getTime(),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async getMember(orgId: string, userId: string): Promise<OrgMember | undefined> {
|
||||
const row = await this.db
|
||||
.selectFrom('org_members')
|
||||
.selectAll()
|
||||
.where('org_id', '=', orgId)
|
||||
.where('user_id', '=', userId)
|
||||
.executeTakeFirst();
|
||||
return row
|
||||
? { id: row.id, orgId: row.org_id, userId: row.user_id, role: row.role, joinedAt: new Date(row.joined_at) }
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async listMembers(orgId: string): Promise<OrgMember[]> {
|
||||
const rows = await this.db
|
||||
.selectFrom('org_members')
|
||||
.selectAll()
|
||||
.where('org_id', '=', orgId)
|
||||
.execute();
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
orgId: r.org_id,
|
||||
userId: r.user_id,
|
||||
role: r.role,
|
||||
joinedAt: new Date(r.joined_at),
|
||||
}));
|
||||
}
|
||||
|
||||
async updateMemberRole(orgId: string, userId: string, role: string): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('org_members')
|
||||
.set({ role })
|
||||
.where('org_id', '=', orgId)
|
||||
.where('user_id', '=', userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async removeMember(orgId: string, userId: string): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('org_members')
|
||||
.where('org_id', '=', orgId)
|
||||
.where('user_id', '=', userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
private toDomain(row: { id: string; name: string; slug: string; created_at: number }): Organization {
|
||||
return Organization.reconstitute(
|
||||
{ name: row.name, slug: row.slug, createdAt: new Date(row.created_at) },
|
||||
UniqueId.from(row.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { Database } from '../../../../shared/infrastructure/DatabaseConnection';
|
||||
import { ISessionRepository, AuthSession } from '../../domain/ports/ISessionRepository';
|
||||
|
||||
export class KyselySessionRepository implements ISessionRepository {
|
||||
constructor(private readonly db: Kysely<Database>) {}
|
||||
|
||||
async save(session: AuthSession): Promise<void> {
|
||||
await this.db
|
||||
.insertInto('auth_sessions')
|
||||
.values({
|
||||
id: session.id,
|
||||
user_id: session.userId,
|
||||
token: session.token,
|
||||
expires_at: session.expiresAt.getTime(),
|
||||
created_at: session.createdAt.getTime(),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findByToken(token: string): Promise<AuthSession | undefined> {
|
||||
const row = await this.db
|
||||
.selectFrom('auth_sessions')
|
||||
.selectAll()
|
||||
.where('token', '=', token)
|
||||
.executeTakeFirst();
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
token: row.token,
|
||||
expiresAt: new Date(row.expires_at),
|
||||
createdAt: new Date(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteByToken(token: string): Promise<void> {
|
||||
await this.db.deleteFrom('auth_sessions').where('token', '=', token).execute();
|
||||
}
|
||||
|
||||
async deleteExpired(): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('auth_sessions')
|
||||
.where('expires_at', '<', Date.now())
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { Database } from '../../../../shared/infrastructure/DatabaseConnection';
|
||||
import { IUserRepository } from '../../domain/ports/IUserRepository';
|
||||
import { User } from '../../domain/entities/User';
|
||||
import { UniqueId } from '../../../../shared/domain/UniqueId';
|
||||
import { Email } from '../../domain/value-objects/Email';
|
||||
import { Role } from '../../domain/value-objects/Role';
|
||||
|
||||
export class KyselyUserRepository implements IUserRepository {
|
||||
constructor(private readonly db: Kysely<Database>) {}
|
||||
|
||||
async save(user: User): Promise<void> {
|
||||
const row = {
|
||||
id: user.id.toString(),
|
||||
email: user.email.value,
|
||||
name: user.name,
|
||||
password_hash: user.passwordHash,
|
||||
role: user.role.value,
|
||||
org_id: user.orgId ?? null,
|
||||
created_at: user.createdAt.getTime(),
|
||||
updated_at: user.updatedAt.getTime(),
|
||||
};
|
||||
|
||||
await this.db
|
||||
.insertInto('users')
|
||||
.values(row)
|
||||
.onConflict((oc) =>
|
||||
oc.column('id').doUpdateSet({
|
||||
name: row.name,
|
||||
role: row.role,
|
||||
org_id: row.org_id,
|
||||
updated_at: row.updated_at,
|
||||
})
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<User | undefined> {
|
||||
const row = await this.db
|
||||
.selectFrom('users')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.executeTakeFirst();
|
||||
return row ? this.toDomain(row) : undefined;
|
||||
}
|
||||
|
||||
async findByEmail(email: string): Promise<User | undefined> {
|
||||
const row = await this.db
|
||||
.selectFrom('users')
|
||||
.selectAll()
|
||||
.where('email', '=', email.toLowerCase())
|
||||
.executeTakeFirst();
|
||||
return row ? this.toDomain(row) : undefined;
|
||||
}
|
||||
|
||||
async findAll(): Promise<User[]> {
|
||||
const rows = await this.db.selectFrom('users').selectAll().execute();
|
||||
return rows.map((r) => this.toDomain(r));
|
||||
}
|
||||
|
||||
async count(): Promise<number> {
|
||||
const result = await this.db
|
||||
.selectFrom('users')
|
||||
.select((eb) => eb.fn.count('id').as('count'))
|
||||
.executeTakeFirstOrThrow();
|
||||
return Number(result.count);
|
||||
}
|
||||
|
||||
private toDomain(row: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
org_id: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}): User {
|
||||
return User.reconstitute(
|
||||
{
|
||||
email: Email.create(row.email),
|
||||
name: row.name,
|
||||
passwordHash: row.password_hash,
|
||||
role: Role.create(row.role),
|
||||
orgId: row.org_id ?? undefined,
|
||||
createdAt: new Date(row.created_at),
|
||||
updatedAt: new Date(row.updated_at),
|
||||
},
|
||||
UniqueId.from(row.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,53 @@ export interface JobTable {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface UserTable {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
password_hash: string;
|
||||
role: string;
|
||||
org_id: string | null;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface OrganizationTable {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface OrgMemberTable {
|
||||
id: string;
|
||||
org_id: string;
|
||||
user_id: string;
|
||||
role: string;
|
||||
joined_at: number;
|
||||
}
|
||||
|
||||
export interface ApiKeyTable {
|
||||
id: string;
|
||||
user_id: string;
|
||||
org_id: string;
|
||||
name: string;
|
||||
key_hash: string;
|
||||
key_prefix: string;
|
||||
permissions: string;
|
||||
expires_at: number | null;
|
||||
last_used_at: number | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface AuthSessionTable {
|
||||
id: string;
|
||||
user_id: string;
|
||||
token: string;
|
||||
expires_at: number;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface Database {
|
||||
sessions: SessionTable;
|
||||
states: StateTable;
|
||||
@@ -166,6 +213,11 @@ export interface Database {
|
||||
performance_metrics: PerformanceMetricTable;
|
||||
findings: FindingTable;
|
||||
jobs: JobTable;
|
||||
users: UserTable;
|
||||
organizations: OrganizationTable;
|
||||
org_members: OrgMemberTable;
|
||||
api_keys: ApiKeyTable;
|
||||
auth_sessions: AuthSessionTable;
|
||||
}
|
||||
|
||||
export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely<Database> {
|
||||
|
||||
Reference in New Issue
Block a user