Files
registry/ts/services/auth.service.ts

406 lines
10 KiB
TypeScript
Raw Normal View History

/**
* 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;
}
}