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:
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