feat(identity): redesign identity module and introduce RBAC foundation
- Redesign the Identity module with a richer domain model. - Extend the User entity to support username, Authentik integration, activity tracking, and storage information. - Add Role and Permission domain models with many-to-many relationships. - Implement RBAC foundation using UserRole, RolePermission, and UserPermission mappings. - Add user storage quota and usage fields with default values. - Introduce Authentik identifiers and synchronization metadata. - Refactor user domain logic for role and permission management. - Update Prisma schema to support the new identity architecture. - Improve JWT authentication and permission guard integration. - Update repositories, handlers, controllers, mappers, DTOs, and Swagger configuration. - Refresh environment configuration and project dependencies.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { Injectable, UnauthorizedException, Inject } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { SyncIdentityHandler } from '../identity/application/handlers/user/sync-identity.handler';
|
||||
import { IUser } from '../identity/domain/repositories/user.interface';
|
||||
import { PrismaService } from '../../shared/prisma.service';
|
||||
import { RedisPkceStore } from './pkce/redis-pkce.store';
|
||||
import { InMemoryRefreshStore } from './refresh/inmemory-refresh.store';
|
||||
|
||||
import { Issuer, generators, TokenSet } from 'openid-client';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prisma: PrismaService,
|
||||
@Inject(IUser) private readonly userRepository: IUser,
|
||||
private readonly syncIdentityHandler: SyncIdentityHandler,
|
||||
private readonly pkceStore: RedisPkceStore,
|
||||
private readonly refreshStore: InMemoryRefreshStore,
|
||||
) {}
|
||||
|
||||
private issuer: any = null;
|
||||
private client: any = null;
|
||||
|
||||
private async getIssuer() {
|
||||
if (this.issuer) return this.issuer;
|
||||
const issuerUrl = this.config.get<string>('AUTHENTIK_ISSUER');
|
||||
if (!issuerUrl) throw new Error('AUTHENTIK_ISSUER not configured');
|
||||
this.issuer = await Issuer.discover(issuerUrl);
|
||||
return this.issuer;
|
||||
}
|
||||
|
||||
private async getClient() {
|
||||
if (this.client) return this.client;
|
||||
const issuer = await this.getIssuer();
|
||||
const clientId = this.config.get<string>('AUTHENTIK_CLIENT_ID');
|
||||
const clientSecret = this.config.get<string>('AUTHENTIK_CLIENT_SECRET');
|
||||
if (!clientId) throw new Error('AUTHENTIK_CLIENT_ID not configured');
|
||||
this.client = new issuer.Client({ client_id: clientId, client_secret: clientSecret });
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async createAuthorizationRedirect(returnTo?: string) {
|
||||
const client = await this.getClient();
|
||||
const redirectUri = this.config.get<string>('AUTH_CALLBACK_URL') || `${this.config.get('APP_URL') || ''}/auth/callback`;
|
||||
|
||||
const state = require('crypto').randomUUID();
|
||||
const code_verifier = generators.codeVerifier();
|
||||
const code_challenge = await generators.codeChallenge(code_verifier);
|
||||
const nonce = generators.nonce();
|
||||
|
||||
await this.pkceStore.save(state, { code_verifier, nonce, returnTo }, 300);
|
||||
|
||||
const url = client.authorizationUrl({
|
||||
redirect_uri: redirectUri,
|
||||
scope: this.config.get<string>('AUTHENTIK_DEFAULT_SCOPE') || 'openid email profile',
|
||||
response_type: 'code',
|
||||
code_challenge,
|
||||
code_challenge_method: 'S256',
|
||||
state,
|
||||
nonce,
|
||||
});
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async handleCallback(code: string, state: string) {
|
||||
const client = await this.getClient();
|
||||
const pkce = await this.pkceStore.get(state);
|
||||
if (!pkce) throw new UnauthorizedException('Invalid or expired state');
|
||||
|
||||
// remove one-time state
|
||||
await this.pkceStore.remove(state);
|
||||
|
||||
const redirectUri = this.config.get<string>('AUTH_CALLBACK_URL') || `${this.config.get('APP_URL') || ''}/auth/callback`;
|
||||
|
||||
// exchange code
|
||||
const tokenSet: TokenSet = await client.callback(redirectUri, { code, state }, { code_verifier: pkce.code_verifier, nonce: pkce.nonce });
|
||||
|
||||
// verify id_token and get claims
|
||||
const claims = tokenSet.claims();
|
||||
|
||||
// fetch userinfo if available
|
||||
let userInfo = null;
|
||||
try {
|
||||
if (tokenSet.access_token && client.userinfo) {
|
||||
userInfo = await client.userinfo(tokenSet.access_token);
|
||||
}
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const identity = {
|
||||
sub: userInfo?.sub || claims.sub || null,
|
||||
preferred_username: userInfo?.preferred_username || userInfo?.username || userInfo?.email || claims.preferred_username || claims.email,
|
||||
email: userInfo?.email || claims.email,
|
||||
raw: { tokenSet, userInfo, claims },
|
||||
} as any;
|
||||
|
||||
// Sync identity to local user (create if needed)
|
||||
const domainUser = await this.syncIdentityHandler.execute(identity as any);
|
||||
|
||||
// ensure active/not deleted
|
||||
if (!domainUser.isActive) throw new UnauthorizedException('User is not active');
|
||||
if (domainUser.deletedAt) throw new UnauthorizedException('User is deleted');
|
||||
|
||||
// create internal JWT
|
||||
const jwtPayload = {
|
||||
sub: domainUser.id,
|
||||
preferred_username: domainUser.username,
|
||||
email: domainUser.email,
|
||||
} as any;
|
||||
|
||||
const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600);
|
||||
const access = this.jwtService.sign(jwtPayload, { expiresIn });
|
||||
|
||||
// create internal refresh token
|
||||
const refreshToken = require('crypto').randomUUID();
|
||||
const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600); // default 30 days
|
||||
|
||||
await this.refreshStore.set(refreshToken, { userId: domainUser.id }, refreshTtl);
|
||||
|
||||
return {
|
||||
accessToken: access,
|
||||
refreshToken,
|
||||
expiresIn,
|
||||
refreshTtl,
|
||||
user: {
|
||||
id: domainUser.id,
|
||||
username: domainUser.username,
|
||||
email: domainUser.email,
|
||||
roles: domainUser.roles || [],
|
||||
},
|
||||
returnTo: pkce.returnTo,
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(refreshToken?: string) {
|
||||
if (!refreshToken) throw new UnauthorizedException('Missing refresh token');
|
||||
const data = await this.refreshStore.get(refreshToken);
|
||||
if (!data) throw new UnauthorizedException('Invalid refresh token');
|
||||
|
||||
const userId = data.userId;
|
||||
// load user
|
||||
const domainUser = await this.userRepository.findById(userId);
|
||||
if (!domainUser) throw new UnauthorizedException('User not found');
|
||||
if (!domainUser.isActive) throw new UnauthorizedException('User is not active');
|
||||
|
||||
// rotate refresh token
|
||||
await this.refreshStore.del(refreshToken);
|
||||
const newRefresh = require('crypto').randomUUID();
|
||||
const refreshTtl = Number(this.config.get('RAYLAB_REFRESH_EXPIRES_IN') || 30 * 24 * 3600);
|
||||
await this.refreshStore.set(newRefresh, { userId }, refreshTtl);
|
||||
|
||||
const expiresIn = Number(this.config.get('RAYLAB_JWT_EXPIRES_IN') || 3600);
|
||||
const access = this.jwtService.sign({ sub: domainUser.id, preferred_username: domainUser.username, email: domainUser.email }, { expiresIn });
|
||||
|
||||
return { accessToken: access, refreshToken: newRefresh, expiresIn, refreshTtl };
|
||||
}
|
||||
|
||||
async logout(refreshToken?: string) {
|
||||
if (refreshToken) {
|
||||
await this.refreshStore.del(refreshToken);
|
||||
}
|
||||
|
||||
const issuer = await this.getIssuer();
|
||||
const endSession = issuer.metadata.end_session_endpoint;
|
||||
const postLogout = this.config.get<string>('APP_URL') || '/';
|
||||
|
||||
if (endSession) {
|
||||
// Redirect to identity provider logout
|
||||
const url = new URL(endSession);
|
||||
if (postLogout) url.searchParams.set('post_logout_redirect_uri', postLogout);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
return postLogout;
|
||||
}
|
||||
|
||||
async me(token?: string) {
|
||||
if (!token) throw new UnauthorizedException('Missing token');
|
||||
try {
|
||||
const payload: any = this.jwtService.verify(token);
|
||||
const user = await this.userRepository.findById(payload.sub);
|
||||
if (!user) throw new UnauthorizedException('User not found');
|
||||
return { id: user.id, username: user.username, email: user.email, roles: user.roles || [] };
|
||||
} catch (e) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user