406 lines
10 KiB
TypeScript
406 lines
10 KiB
TypeScript
|
|
/**
|
||
|
|
* 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;
|
||
|
|
}
|
||
|
|
}
|