feat: implement account settings and API tokens management
- Added SettingsComponent for user profile management, including display name and password change functionality. - Introduced TokensComponent for managing API tokens, including creation and revocation. - Created LayoutComponent for consistent application layout with navigation and user information. - Established main application structure in index.html and main.ts. - Integrated Tailwind CSS for styling and responsive design. - Configured TypeScript settings for strict type checking and module resolution.
This commit is contained in:
197
ts/services/audit.service.ts
Normal file
197
ts/services/audit.service.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* AuditService - Centralized audit logging
|
||||
*/
|
||||
|
||||
import type { TAuditAction, TAuditResourceType } from '../interfaces/audit.interfaces.ts';
|
||||
import { AuditLog } from '../models/index.ts';
|
||||
|
||||
export interface IAuditContext {
|
||||
actorId?: string;
|
||||
actorType?: 'user' | 'api_token' | 'system' | 'anonymous';
|
||||
actorTokenId?: string;
|
||||
actorIp?: string;
|
||||
actorUserAgent?: string;
|
||||
organizationId?: string;
|
||||
repositoryId?: string;
|
||||
}
|
||||
|
||||
export class AuditService {
|
||||
private context: IAuditContext;
|
||||
|
||||
constructor(context: IAuditContext = {}) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new audit service with context
|
||||
*/
|
||||
public static withContext(context: IAuditContext): AuditService {
|
||||
return new AuditService(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an audit event
|
||||
*/
|
||||
public async log(
|
||||
action: TAuditAction,
|
||||
resourceType: TAuditResourceType,
|
||||
options: {
|
||||
resourceId?: string;
|
||||
resourceName?: string;
|
||||
organizationId?: string;
|
||||
repositoryId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
success?: boolean;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
durationMs?: number;
|
||||
} = {}
|
||||
): Promise<AuditLog> {
|
||||
return await AuditLog.log({
|
||||
actorId: this.context.actorId,
|
||||
actorType: this.context.actorType,
|
||||
actorTokenId: this.context.actorTokenId,
|
||||
actorIp: this.context.actorIp,
|
||||
actorUserAgent: this.context.actorUserAgent,
|
||||
action,
|
||||
resourceType,
|
||||
resourceId: options.resourceId,
|
||||
resourceName: options.resourceName,
|
||||
organizationId: options.organizationId || this.context.organizationId,
|
||||
repositoryId: options.repositoryId || this.context.repositoryId,
|
||||
metadata: options.metadata,
|
||||
success: options.success,
|
||||
errorCode: options.errorCode,
|
||||
errorMessage: options.errorMessage,
|
||||
durationMs: options.durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a successful action
|
||||
*/
|
||||
public async logSuccess(
|
||||
action: TAuditAction,
|
||||
resourceType: TAuditResourceType,
|
||||
resourceId?: string,
|
||||
resourceName?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<AuditLog> {
|
||||
return await this.log(action, resourceType, {
|
||||
resourceId,
|
||||
resourceName,
|
||||
metadata,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a failed action
|
||||
*/
|
||||
public async logFailure(
|
||||
action: TAuditAction,
|
||||
resourceType: TAuditResourceType,
|
||||
errorCode: string,
|
||||
errorMessage: string,
|
||||
resourceId?: string,
|
||||
metadata?: Record<string, unknown>
|
||||
): Promise<AuditLog> {
|
||||
return await this.log(action, resourceType, {
|
||||
resourceId,
|
||||
metadata,
|
||||
success: false,
|
||||
errorCode,
|
||||
errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
// Convenience methods for common actions
|
||||
|
||||
public async logUserLogin(userId: string, success: boolean, errorMessage?: string): Promise<AuditLog> {
|
||||
if (success) {
|
||||
return await this.logSuccess('USER_LOGIN', 'user', userId);
|
||||
}
|
||||
return await this.logFailure('USER_LOGIN', 'user', 'LOGIN_FAILED', errorMessage || 'Login failed', userId);
|
||||
}
|
||||
|
||||
public async logUserLogout(userId: string): Promise<AuditLog> {
|
||||
return await this.logSuccess('USER_LOGOUT', 'user', userId);
|
||||
}
|
||||
|
||||
public async logTokenCreated(tokenId: string, tokenName: string): Promise<AuditLog> {
|
||||
return await this.logSuccess('TOKEN_CREATED', 'api_token', tokenId, tokenName);
|
||||
}
|
||||
|
||||
public async logTokenRevoked(tokenId: string, tokenName: string): Promise<AuditLog> {
|
||||
return await this.logSuccess('TOKEN_REVOKED', 'api_token', tokenId, tokenName);
|
||||
}
|
||||
|
||||
public async logPackagePublished(
|
||||
packageId: string,
|
||||
packageName: string,
|
||||
version: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PACKAGE_PUBLISHED', 'package', {
|
||||
resourceId: packageId,
|
||||
resourceName: packageName,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
metadata: { version },
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
public async logPackageDownloaded(
|
||||
packageId: string,
|
||||
packageName: string,
|
||||
version: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PACKAGE_DOWNLOADED', 'package', {
|
||||
resourceId: packageId,
|
||||
resourceName: packageName,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
metadata: { version },
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
public async logOrganizationCreated(orgId: string, orgName: string): Promise<AuditLog> {
|
||||
return await this.logSuccess('ORGANIZATION_CREATED', 'organization', orgId, orgName);
|
||||
}
|
||||
|
||||
public async logRepositoryCreated(
|
||||
repoId: string,
|
||||
repoName: string,
|
||||
organizationId: string
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('REPOSITORY_CREATED', 'repository', {
|
||||
resourceId: repoId,
|
||||
resourceName: repoName,
|
||||
organizationId,
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
|
||||
public async logPermissionChanged(
|
||||
resourceType: TAuditResourceType,
|
||||
resourceId: string,
|
||||
targetUserId: string,
|
||||
oldRole: string | null,
|
||||
newRole: string | null
|
||||
): Promise<AuditLog> {
|
||||
return await this.log('PERMISSION_CHANGED', resourceType, {
|
||||
resourceId,
|
||||
metadata: {
|
||||
targetUserId,
|
||||
oldRole,
|
||||
newRole,
|
||||
},
|
||||
success: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
405
ts/services/auth.service.ts
Normal file
405
ts/services/auth.service.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* AuthService - JWT-based authentication for UI sessions
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import { User, Session } from '../models/index.ts';
|
||||
import { AuditService } from './audit.service.ts';
|
||||
|
||||
export interface IJwtPayload {
|
||||
sub: string; // User ID
|
||||
email: string;
|
||||
sessionId: string;
|
||||
type: 'access' | 'refresh';
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface IAuthResult {
|
||||
success: boolean;
|
||||
user?: User;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
sessionId?: string;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface IAuthConfig {
|
||||
jwtSecret: string;
|
||||
accessTokenExpiresIn: number; // seconds (default: 15 minutes)
|
||||
refreshTokenExpiresIn: number; // seconds (default: 7 days)
|
||||
issuer: string;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private config: IAuthConfig;
|
||||
private auditService: AuditService;
|
||||
|
||||
constructor(config: Partial<IAuthConfig> = {}) {
|
||||
this.config = {
|
||||
jwtSecret: config.jwtSecret || Deno.env.get('JWT_SECRET') || 'change-me-in-production',
|
||||
accessTokenExpiresIn: config.accessTokenExpiresIn || 15 * 60, // 15 minutes
|
||||
refreshTokenExpiresIn: config.refreshTokenExpiresIn || 7 * 24 * 60 * 60, // 7 days
|
||||
issuer: config.issuer || 'stack.gallery',
|
||||
};
|
||||
this.auditService = new AuditService({ actorType: 'system' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Login with email and password
|
||||
*/
|
||||
public async login(
|
||||
email: string,
|
||||
password: string,
|
||||
options: { userAgent?: string; ipAddress?: string } = {}
|
||||
): Promise<IAuthResult> {
|
||||
const auditContext = AuditService.withContext({
|
||||
actorIp: options.ipAddress,
|
||||
actorUserAgent: options.userAgent,
|
||||
actorType: 'anonymous',
|
||||
});
|
||||
|
||||
// Find user by email
|
||||
const user = await User.findByEmail(email);
|
||||
if (!user) {
|
||||
await auditContext.logUserLogin('', false, 'User not found');
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_CREDENTIALS',
|
||||
errorMessage: 'Invalid email or password',
|
||||
};
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValid = await user.verifyPassword(password);
|
||||
if (!isValid) {
|
||||
await auditContext.logUserLogin(user.id, false, 'Invalid password');
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_CREDENTIALS',
|
||||
errorMessage: 'Invalid email or password',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user is active
|
||||
if (!user.isActive) {
|
||||
await auditContext.logUserLogin(user.id, false, 'Account inactive');
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'ACCOUNT_INACTIVE',
|
||||
errorMessage: 'Account is inactive',
|
||||
};
|
||||
}
|
||||
|
||||
// Create session
|
||||
const session = await Session.createSession({
|
||||
userId: user.id,
|
||||
userAgent: options.userAgent || '',
|
||||
ipAddress: options.ipAddress || '',
|
||||
});
|
||||
|
||||
// Generate tokens
|
||||
const accessToken = await this.generateAccessToken(user, session.id);
|
||||
const refreshToken = await this.generateRefreshToken(user, session.id);
|
||||
|
||||
// Update user last login
|
||||
user.lastLoginAt = new Date();
|
||||
await user.save();
|
||||
|
||||
// Audit log
|
||||
await AuditService.withContext({
|
||||
actorId: user.id,
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
actorUserAgent: options.userAgent,
|
||||
}).logUserLogin(user.id, true);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
public async refresh(refreshToken: string): Promise<IAuthResult> {
|
||||
// Verify refresh token
|
||||
const payload = await this.verifyToken(refreshToken);
|
||||
if (!payload) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_TOKEN',
|
||||
errorMessage: 'Invalid refresh token',
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.type !== 'refresh') {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'INVALID_TOKEN_TYPE',
|
||||
errorMessage: 'Not a refresh token',
|
||||
};
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const session = await Session.findValidSession(payload.sessionId);
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'SESSION_INVALID',
|
||||
errorMessage: 'Session is invalid or expired',
|
||||
};
|
||||
}
|
||||
|
||||
// Get user
|
||||
const user = await User.findById(payload.sub);
|
||||
if (!user || !user.isActive) {
|
||||
return {
|
||||
success: false,
|
||||
errorCode: 'USER_INVALID',
|
||||
errorMessage: 'User not found or inactive',
|
||||
};
|
||||
}
|
||||
|
||||
// Update session activity
|
||||
await session.touchActivity();
|
||||
|
||||
// Generate new access token
|
||||
const accessToken = await this.generateAccessToken(user, session.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
accessToken,
|
||||
sessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout - invalidate session
|
||||
*/
|
||||
public async logout(
|
||||
sessionId: string,
|
||||
options: { userId?: string; ipAddress?: string } = {}
|
||||
): Promise<boolean> {
|
||||
const session = await Session.findValidSession(sessionId);
|
||||
if (!session) return false;
|
||||
|
||||
await session.invalidate('logout');
|
||||
|
||||
if (options.userId) {
|
||||
await AuditService.withContext({
|
||||
actorId: options.userId,
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
}).logUserLogout(options.userId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout all sessions for a user
|
||||
*/
|
||||
public async logoutAll(
|
||||
userId: string,
|
||||
options: { ipAddress?: string } = {}
|
||||
): Promise<number> {
|
||||
const count = await Session.invalidateAllUserSessions(userId, 'logout_all');
|
||||
|
||||
await AuditService.withContext({
|
||||
actorId: userId,
|
||||
actorType: 'user',
|
||||
actorIp: options.ipAddress,
|
||||
}).log('USER_LOGOUT', 'user', {
|
||||
resourceId: userId,
|
||||
metadata: { sessionsInvalidated: count },
|
||||
success: true,
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate access token and return user
|
||||
*/
|
||||
public async validateAccessToken(accessToken: string): Promise<{ user: User; sessionId: string } | null> {
|
||||
const payload = await this.verifyToken(accessToken);
|
||||
if (!payload || payload.type !== 'access') return null;
|
||||
|
||||
// Validate session is still valid
|
||||
const session = await Session.findValidSession(payload.sessionId);
|
||||
if (!session) return null;
|
||||
|
||||
const user = await User.findById(payload.sub);
|
||||
if (!user || !user.isActive) return null;
|
||||
|
||||
return { user, sessionId: payload.sessionId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate access token
|
||||
*/
|
||||
private async generateAccessToken(user: User, sessionId: string): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload: IJwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
sessionId,
|
||||
type: 'access',
|
||||
iat: now,
|
||||
exp: now + this.config.accessTokenExpiresIn,
|
||||
};
|
||||
|
||||
return await this.signToken(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate refresh token
|
||||
*/
|
||||
private async generateRefreshToken(user: User, sessionId: string): Promise<string> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload: IJwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
sessionId,
|
||||
type: 'refresh',
|
||||
iat: now,
|
||||
exp: now + this.config.refreshTokenExpiresIn,
|
||||
};
|
||||
|
||||
return await this.signToken(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign a JWT token
|
||||
*/
|
||||
private async signToken(payload: IJwtPayload): Promise<string> {
|
||||
const header = { alg: 'HS256', typ: 'JWT' };
|
||||
|
||||
const encodedHeader = this.base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = this.base64UrlEncode(JSON.stringify(payload));
|
||||
|
||||
const data = `${encodedHeader}.${encodedPayload}`;
|
||||
const signature = await this.hmacSign(data);
|
||||
|
||||
return `${data}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify and decode a JWT token
|
||||
*/
|
||||
private async verifyToken(token: string): Promise<IJwtPayload | null> {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
|
||||
const [encodedHeader, encodedPayload, signature] = parts;
|
||||
const data = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
// Verify signature
|
||||
const expectedSignature = await this.hmacSign(data);
|
||||
if (signature !== expectedSignature) return null;
|
||||
|
||||
// Decode payload
|
||||
const payload: IJwtPayload = JSON.parse(this.base64UrlDecode(encodedPayload));
|
||||
|
||||
// Check expiration
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (payload.exp < now) return null;
|
||||
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 sign
|
||||
*/
|
||||
private async hmacSign(data: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(this.config.jwtSecret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(data));
|
||||
return this.base64UrlEncode(String.fromCharCode(...new Uint8Array(signature)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL encode
|
||||
*/
|
||||
private base64UrlEncode(str: string): string {
|
||||
const base64 = btoa(str);
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Base64 URL decode
|
||||
*/
|
||||
private base64UrlDecode(str: string): string {
|
||||
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (base64.length % 4) {
|
||||
base64 += '=';
|
||||
}
|
||||
return atob(base64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a password using bcrypt-like approach with Web Crypto
|
||||
* Note: In production, use a proper bcrypt library
|
||||
*/
|
||||
public static async hashPassword(password: string): Promise<string> {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const saltHex = Array.from(salt)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(saltHex + password);
|
||||
|
||||
// Multiple rounds of hashing for security
|
||||
let hash = data;
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
hash = new Uint8Array(await crypto.subtle.digest('SHA-256', hash));
|
||||
}
|
||||
|
||||
const hashHex = Array.from(hash)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return `${saltHex}:${hashHex}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a password against a hash
|
||||
*/
|
||||
public static async verifyPassword(password: string, storedHash: string): Promise<boolean> {
|
||||
const [saltHex, expectedHash] = storedHash.split(':');
|
||||
if (!saltHex || !expectedHash) return false;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(saltHex + password);
|
||||
|
||||
let hash = data;
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
hash = new Uint8Array(await crypto.subtle.digest('SHA-256', hash));
|
||||
}
|
||||
|
||||
const hashHex = Array.from(hash)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
|
||||
return hashHex === expectedHash;
|
||||
}
|
||||
}
|
||||
22
ts/services/index.ts
Normal file
22
ts/services/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Service exports
|
||||
*/
|
||||
|
||||
export { AuditService, type IAuditContext } from './audit.service.ts';
|
||||
export {
|
||||
TokenService,
|
||||
type ICreateTokenOptions,
|
||||
type ITokenValidationResult,
|
||||
} from './token.service.ts';
|
||||
export {
|
||||
PermissionService,
|
||||
type TAction,
|
||||
type IPermissionContext,
|
||||
type IResolvedPermissions,
|
||||
} from './permission.service.ts';
|
||||
export {
|
||||
AuthService,
|
||||
type IJwtPayload,
|
||||
type IAuthResult,
|
||||
type IAuthConfig,
|
||||
} from './auth.service.ts';
|
||||
307
ts/services/permission.service.ts
Normal file
307
ts/services/permission.service.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* PermissionService - RBAC resolution across org → team → repo hierarchy
|
||||
*/
|
||||
|
||||
import type {
|
||||
TOrganizationRole,
|
||||
TTeamRole,
|
||||
TRepositoryRole,
|
||||
TRegistryProtocol,
|
||||
} from '../interfaces/auth.interfaces.ts';
|
||||
import {
|
||||
User,
|
||||
Organization,
|
||||
OrganizationMember,
|
||||
Team,
|
||||
TeamMember,
|
||||
Repository,
|
||||
RepositoryPermission,
|
||||
} from '../models/index.ts';
|
||||
|
||||
export type TAction = 'read' | 'write' | 'delete' | 'admin';
|
||||
|
||||
export interface IPermissionContext {
|
||||
userId: string;
|
||||
organizationId?: string;
|
||||
repositoryId?: string;
|
||||
protocol?: TRegistryProtocol;
|
||||
}
|
||||
|
||||
export interface IResolvedPermissions {
|
||||
canRead: boolean;
|
||||
canWrite: boolean;
|
||||
canDelete: boolean;
|
||||
canAdmin: boolean;
|
||||
effectiveRole: TRepositoryRole | null;
|
||||
organizationRole: TOrganizationRole | null;
|
||||
teamRoles: Array<{ teamId: string; role: TTeamRole }>;
|
||||
repositoryRole: TRepositoryRole | null;
|
||||
}
|
||||
|
||||
export class PermissionService {
|
||||
/**
|
||||
* Resolve all permissions for a user in a specific context
|
||||
*/
|
||||
public async resolvePermissions(context: IPermissionContext): Promise<IResolvedPermissions> {
|
||||
const result: IResolvedPermissions = {
|
||||
canRead: false,
|
||||
canWrite: false,
|
||||
canDelete: false,
|
||||
canAdmin: false,
|
||||
effectiveRole: null,
|
||||
organizationRole: null,
|
||||
teamRoles: [],
|
||||
repositoryRole: null,
|
||||
};
|
||||
|
||||
// Get user
|
||||
const user = await User.findById(context.userId);
|
||||
if (!user || !user.isActive) return result;
|
||||
|
||||
// System admins have full access
|
||||
if (user.isSystemAdmin) {
|
||||
result.canRead = true;
|
||||
result.canWrite = true;
|
||||
result.canDelete = true;
|
||||
result.canAdmin = true;
|
||||
result.effectiveRole = 'admin';
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!context.organizationId) return result;
|
||||
|
||||
// Get organization membership
|
||||
const orgMember = await OrganizationMember.findMembership(context.organizationId, context.userId);
|
||||
if (orgMember) {
|
||||
result.organizationRole = orgMember.role;
|
||||
|
||||
// Organization owners have full access to everything in the org
|
||||
if (orgMember.role === 'owner') {
|
||||
result.canRead = true;
|
||||
result.canWrite = true;
|
||||
result.canDelete = true;
|
||||
result.canAdmin = true;
|
||||
result.effectiveRole = 'admin';
|
||||
return result;
|
||||
}
|
||||
|
||||
// Organization admins have admin access to all repos
|
||||
if (orgMember.role === 'admin') {
|
||||
result.canRead = true;
|
||||
result.canWrite = true;
|
||||
result.canDelete = true;
|
||||
result.canAdmin = true;
|
||||
result.effectiveRole = 'admin';
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// If no repository specified, check org-level permissions
|
||||
if (!context.repositoryId) {
|
||||
if (orgMember) {
|
||||
result.canRead = true; // Members can read org info
|
||||
result.effectiveRole = 'reader';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get repository
|
||||
const repository = await Repository.findById(context.repositoryId);
|
||||
if (!repository) return result;
|
||||
|
||||
// Check if repository is public
|
||||
if (repository.isPublic) {
|
||||
result.canRead = true;
|
||||
}
|
||||
|
||||
// Get team memberships that grant access to this repository
|
||||
if (orgMember) {
|
||||
const teams = await Team.getOrgTeams(context.organizationId);
|
||||
for (const team of teams) {
|
||||
const teamMember = await TeamMember.findMembership(team.id, context.userId);
|
||||
if (teamMember) {
|
||||
result.teamRoles.push({ teamId: team.id, role: teamMember.role });
|
||||
|
||||
// Check if team has access to this repository
|
||||
if (team.repositoryIds.includes(context.repositoryId)) {
|
||||
// Team maintainers get maintainer access to repos
|
||||
if (teamMember.role === 'maintainer') {
|
||||
this.applyRole(result, 'maintainer');
|
||||
} else {
|
||||
// Team members get developer access
|
||||
this.applyRole(result, 'developer');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get direct repository permission (highest priority)
|
||||
const repoPerm = await RepositoryPermission.findPermission(context.repositoryId, context.userId);
|
||||
if (repoPerm) {
|
||||
result.repositoryRole = repoPerm.role;
|
||||
this.applyRole(result, repoPerm.role);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can perform a specific action
|
||||
*/
|
||||
public async checkPermission(
|
||||
context: IPermissionContext,
|
||||
action: TAction
|
||||
): Promise<boolean> {
|
||||
const permissions = await this.resolvePermissions(context);
|
||||
|
||||
switch (action) {
|
||||
case 'read':
|
||||
return permissions.canRead;
|
||||
case 'write':
|
||||
return permissions.canWrite;
|
||||
case 'delete':
|
||||
return permissions.canDelete;
|
||||
case 'admin':
|
||||
return permissions.canAdmin;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access a package
|
||||
*/
|
||||
public async canAccessPackage(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
repositoryId: string,
|
||||
action: 'read' | 'write' | 'delete'
|
||||
): Promise<boolean> {
|
||||
return await this.checkPermission(
|
||||
{ userId, organizationId, repositoryId },
|
||||
action
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can manage organization
|
||||
*/
|
||||
public async canManageOrganization(userId: string, organizationId: string): Promise<boolean> {
|
||||
const user = await User.findById(userId);
|
||||
if (!user || !user.isActive) return false;
|
||||
if (user.isSystemAdmin) return true;
|
||||
|
||||
const orgMember = await OrganizationMember.findMembership(organizationId, userId);
|
||||
return orgMember?.role === 'owner' || orgMember?.role === 'admin';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can manage repository
|
||||
*/
|
||||
public async canManageRepository(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
repositoryId: string
|
||||
): Promise<boolean> {
|
||||
const permissions = await this.resolvePermissions({
|
||||
userId,
|
||||
organizationId,
|
||||
repositoryId,
|
||||
});
|
||||
return permissions.canAdmin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all repositories a user can access in an organization
|
||||
*/
|
||||
public async getAccessibleRepositories(
|
||||
userId: string,
|
||||
organizationId: string
|
||||
): Promise<Repository[]> {
|
||||
const user = await User.findById(userId);
|
||||
if (!user || !user.isActive) return [];
|
||||
|
||||
// System admins and org owners/admins can access all repos
|
||||
if (user.isSystemAdmin) {
|
||||
return await Repository.getOrgRepositories(organizationId);
|
||||
}
|
||||
|
||||
const orgMember = await OrganizationMember.findMembership(organizationId, userId);
|
||||
if (orgMember?.role === 'owner' || orgMember?.role === 'admin') {
|
||||
return await Repository.getOrgRepositories(organizationId);
|
||||
}
|
||||
|
||||
const allRepos = await Repository.getOrgRepositories(organizationId);
|
||||
const accessibleRepos: Repository[] = [];
|
||||
|
||||
for (const repo of allRepos) {
|
||||
// Public repos are always accessible
|
||||
if (repo.isPublic) {
|
||||
accessibleRepos.push(repo);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check direct permission
|
||||
const directPerm = await RepositoryPermission.findPermission(repo.id, userId);
|
||||
if (directPerm) {
|
||||
accessibleRepos.push(repo);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check team access
|
||||
const teams = await Team.getOrgTeams(organizationId);
|
||||
for (const team of teams) {
|
||||
if (team.repositoryIds.includes(repo.id)) {
|
||||
const teamMember = await TeamMember.findMembership(team.id, userId);
|
||||
if (teamMember) {
|
||||
accessibleRepos.push(repo);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accessibleRepos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a role's permissions to the result
|
||||
*/
|
||||
private applyRole(result: IResolvedPermissions, role: TRepositoryRole): void {
|
||||
const roleHierarchy: Record<TRepositoryRole, number> = {
|
||||
reader: 1,
|
||||
developer: 2,
|
||||
maintainer: 3,
|
||||
admin: 4,
|
||||
};
|
||||
|
||||
const currentLevel = result.effectiveRole ? roleHierarchy[result.effectiveRole] : 0;
|
||||
const newLevel = roleHierarchy[role];
|
||||
|
||||
if (newLevel > currentLevel) {
|
||||
result.effectiveRole = role;
|
||||
}
|
||||
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
result.canRead = true;
|
||||
result.canWrite = true;
|
||||
result.canDelete = true;
|
||||
result.canAdmin = true;
|
||||
break;
|
||||
case 'maintainer':
|
||||
result.canRead = true;
|
||||
result.canWrite = true;
|
||||
result.canDelete = true;
|
||||
break;
|
||||
case 'developer':
|
||||
result.canRead = true;
|
||||
result.canWrite = true;
|
||||
break;
|
||||
case 'reader':
|
||||
result.canRead = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
209
ts/services/token.service.ts
Normal file
209
ts/services/token.service.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* TokenService - API token management with secure hashing
|
||||
*/
|
||||
|
||||
import * as plugins from '../plugins.ts';
|
||||
import type { ITokenScope, TRegistryProtocol } from '../interfaces/auth.interfaces.ts';
|
||||
import { ApiToken, User } from '../models/index.ts';
|
||||
import { AuditService } from './audit.service.ts';
|
||||
|
||||
export interface ICreateTokenOptions {
|
||||
userId: string;
|
||||
name: string;
|
||||
protocols: TRegistryProtocol[];
|
||||
scopes: ITokenScope[];
|
||||
expiresInDays?: number;
|
||||
createdIp?: string;
|
||||
}
|
||||
|
||||
export interface ITokenValidationResult {
|
||||
valid: boolean;
|
||||
token?: ApiToken;
|
||||
user?: User;
|
||||
errorCode?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export class TokenService {
|
||||
private auditService: AuditService;
|
||||
|
||||
constructor(auditService?: AuditService) {
|
||||
this.auditService = auditService || new AuditService({ actorType: 'system' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new API token
|
||||
* Returns the raw token (only shown once) and the saved token record
|
||||
*/
|
||||
public async createToken(options: ICreateTokenOptions): Promise<{ rawToken: string; token: ApiToken }> {
|
||||
// Generate secure random token: srg_{64 hex chars}
|
||||
const randomBytes = new Uint8Array(32);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
const hexToken = Array.from(randomBytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
const rawToken = `srg_${hexToken}`;
|
||||
|
||||
// Hash the token for storage
|
||||
const tokenHash = await this.hashToken(rawToken);
|
||||
const tokenPrefix = rawToken.substring(0, 12); // "srg_" + first 8 hex chars
|
||||
|
||||
// Create token record
|
||||
const token = new ApiToken();
|
||||
token.id = await ApiToken.getNewId();
|
||||
token.userId = options.userId;
|
||||
token.name = options.name;
|
||||
token.tokenHash = tokenHash;
|
||||
token.tokenPrefix = tokenPrefix;
|
||||
token.protocols = options.protocols;
|
||||
token.scopes = options.scopes;
|
||||
token.createdAt = new Date();
|
||||
token.createdIp = options.createdIp;
|
||||
token.usageCount = 0;
|
||||
token.isRevoked = false;
|
||||
|
||||
if (options.expiresInDays) {
|
||||
token.expiresAt = new Date(Date.now() + options.expiresInDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
await token.save();
|
||||
|
||||
// Audit log
|
||||
await this.auditService.logTokenCreated(token.id, token.name);
|
||||
|
||||
return { rawToken, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a raw token and return the token record and user
|
||||
*/
|
||||
public async validateToken(rawToken: string, ip?: string): Promise<ITokenValidationResult> {
|
||||
// Check token format
|
||||
if (!rawToken || !rawToken.startsWith('srg_') || rawToken.length !== 68) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'INVALID_TOKEN_FORMAT',
|
||||
errorMessage: 'Invalid token format',
|
||||
};
|
||||
}
|
||||
|
||||
// Hash and lookup
|
||||
const tokenHash = await this.hashToken(rawToken);
|
||||
const token = await ApiToken.findByHash(tokenHash);
|
||||
|
||||
if (!token) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'TOKEN_NOT_FOUND',
|
||||
errorMessage: 'Token not found',
|
||||
};
|
||||
}
|
||||
|
||||
// Check validity
|
||||
if (!token.isValid()) {
|
||||
if (token.isRevoked) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'TOKEN_REVOKED',
|
||||
errorMessage: 'Token has been revoked',
|
||||
};
|
||||
}
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'TOKEN_EXPIRED',
|
||||
errorMessage: 'Token has expired',
|
||||
};
|
||||
}
|
||||
|
||||
// Get user
|
||||
const user = await User.findById(token.userId);
|
||||
if (!user) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'USER_NOT_FOUND',
|
||||
errorMessage: 'Token owner not found',
|
||||
};
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return {
|
||||
valid: false,
|
||||
errorCode: 'USER_INACTIVE',
|
||||
errorMessage: 'Token owner account is inactive',
|
||||
};
|
||||
}
|
||||
|
||||
// Record usage
|
||||
await token.recordUsage(ip);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
token,
|
||||
user,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tokens for a user (without sensitive data)
|
||||
*/
|
||||
public async getUserTokens(userId: string): Promise<ApiToken[]> {
|
||||
return await ApiToken.getUserTokens(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a token
|
||||
*/
|
||||
public async revokeToken(tokenId: string, reason?: string): Promise<boolean> {
|
||||
const token = await ApiToken.getInstance({ id: tokenId });
|
||||
if (!token) return false;
|
||||
|
||||
await token.revoke(reason);
|
||||
await this.auditService.logTokenRevoked(token.id, token.name);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all tokens for a user
|
||||
*/
|
||||
public async revokeAllUserTokens(userId: string, reason?: string): Promise<number> {
|
||||
const tokens = await ApiToken.getUserTokens(userId);
|
||||
for (const token of tokens) {
|
||||
await token.revoke(reason);
|
||||
await this.auditService.logTokenRevoked(token.id, token.name);
|
||||
}
|
||||
return tokens.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if token has permission for a specific action
|
||||
*/
|
||||
public checkTokenPermission(
|
||||
token: ApiToken,
|
||||
protocol: TRegistryProtocol,
|
||||
organizationId?: string,
|
||||
repositoryId?: string,
|
||||
action?: string
|
||||
): boolean {
|
||||
if (!token.hasProtocol(protocol)) return false;
|
||||
return token.hasScope(protocol, organizationId, repositoryId, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash a token using SHA-256
|
||||
*/
|
||||
private async hashToken(rawToken: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(rawToken);
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate token prefix for display
|
||||
*/
|
||||
public static getTokenDisplay(tokenPrefix: string): string {
|
||||
return `${tokenPrefix}...`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user