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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user