210 lines
5.5 KiB
TypeScript
210 lines
5.5 KiB
TypeScript
|
|
/**
|
||
|
|
* 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}...`;
|
||
|
|
}
|
||
|
|
}
|