trading-platform-backend/src/modules/auth/services/token.service.ts

212 lines
6.2 KiB
TypeScript

// ============================================================================
// OrbiQuant IA - Token Service
// ============================================================================
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import crypto from 'crypto';
import { config } from '../../../config';
import { db } from '../../../shared/database';
import type {
User,
AuthTokens,
JWTPayload,
JWTRefreshPayload,
Session,
} from '../types/auth.types';
export class TokenService {
private readonly accessTokenSecret: string;
private readonly refreshTokenSecret: string;
private readonly accessTokenExpiry: string;
private readonly refreshTokenExpiry: string;
private readonly refreshTokenExpiryMs: number;
constructor() {
this.accessTokenSecret = config.jwt.accessSecret;
this.refreshTokenSecret = config.jwt.refreshSecret;
this.accessTokenExpiry = config.jwt.accessExpiry;
this.refreshTokenExpiry = config.jwt.refreshExpiry;
this.refreshTokenExpiryMs = this.parseExpiry(config.jwt.refreshExpiry);
}
private parseExpiry(expiry: string): number {
const match = expiry.match(/^(\d+)([smhd])$/);
if (!match) return 7 * 24 * 60 * 60 * 1000; // default 7 days
const value = parseInt(match[1], 10);
const unit = match[2];
switch (unit) {
case 's': return value * 1000;
case 'm': return value * 60 * 1000;
case 'h': return value * 60 * 60 * 1000;
case 'd': return value * 24 * 60 * 60 * 1000;
default: return 7 * 24 * 60 * 60 * 1000;
}
}
generateAccessToken(user: User): string {
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
sub: user.id,
email: user.email,
role: user.role,
provider: user.primaryAuthProvider,
};
return jwt.sign(payload, this.accessTokenSecret, {
expiresIn: this.accessTokenExpiry as jwt.SignOptions['expiresIn'],
});
}
generateRefreshToken(userId: string, sessionId: string): string {
const payload: Omit<JWTRefreshPayload, 'iat' | 'exp'> = {
sub: userId,
sessionId,
};
return jwt.sign(payload, this.refreshTokenSecret, {
expiresIn: this.refreshTokenExpiry as jwt.SignOptions['expiresIn'],
});
}
verifyAccessToken(token: string): JWTPayload | null {
try {
return jwt.verify(token, this.accessTokenSecret) as JWTPayload;
} catch {
return null;
}
}
verifyRefreshToken(token: string): JWTRefreshPayload | null {
try {
return jwt.verify(token, this.refreshTokenSecret) as JWTRefreshPayload;
} catch {
return null;
}
}
async createSession(
userId: string,
userAgent?: string,
ipAddress?: string,
deviceInfo?: Record<string, unknown>
): Promise<{ session: Session; tokens: AuthTokens }> {
const sessionId = uuidv4();
const refreshTokenValue = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + this.refreshTokenExpiryMs);
const result = await db.query<Session>(
`INSERT INTO sessions (id, user_id, refresh_token, user_agent, ip_address, device_info, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[sessionId, userId, refreshTokenValue, userAgent, ipAddress, JSON.stringify(deviceInfo), expiresAt]
);
const session = result.rows[0];
// Get user for access token
const userResult = await db.query<User>(
'SELECT * FROM users WHERE id = $1',
[userId]
);
const user = userResult.rows[0];
const accessToken = this.generateAccessToken(user);
const refreshToken = this.generateRefreshToken(userId, sessionId);
return {
session,
tokens: {
accessToken,
refreshToken,
expiresIn: this.parseExpiry(this.accessTokenExpiry) / 1000,
tokenType: 'Bearer',
},
};
}
async refreshSession(refreshToken: string): Promise<AuthTokens | null> {
const decoded = this.verifyRefreshToken(refreshToken);
if (!decoded) return null;
// Check session exists and is valid
const sessionResult = await db.query<Session>(
`SELECT * FROM sessions
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > NOW()`,
[decoded.sessionId, decoded.sub]
);
if (sessionResult.rows.length === 0) return null;
// Update last active
await db.query(
'UPDATE sessions SET last_active_at = NOW() WHERE id = $1',
[decoded.sessionId]
);
// Get user
const userResult = await db.query<User>(
'SELECT * FROM users WHERE id = $1',
[decoded.sub]
);
if (userResult.rows.length === 0) return null;
const user = userResult.rows[0];
const newAccessToken = this.generateAccessToken(user);
const newRefreshToken = this.generateRefreshToken(user.id, decoded.sessionId);
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiresIn: this.parseExpiry(this.accessTokenExpiry) / 1000,
tokenType: 'Bearer',
};
}
async revokeSession(sessionId: string, userId: string): Promise<boolean> {
const result = await db.query(
`UPDATE sessions SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL`,
[sessionId, userId]
);
return (result.rowCount ?? 0) > 0;
}
async revokeAllUserSessions(userId: string, exceptSessionId?: string): Promise<number> {
let query = 'UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL';
const params: (string | undefined)[] = [userId];
if (exceptSessionId) {
query += ' AND id != $2';
params.push(exceptSessionId);
}
const result = await db.query(query, params);
return result.rowCount ?? 0;
}
async getActiveSessions(userId: string): Promise<Session[]> {
const result = await db.query<Session>(
`SELECT * FROM sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
ORDER BY last_active_at DESC`,
[userId]
);
return result.rows;
}
generateEmailToken(): string {
return crypto.randomBytes(32).toString('hex');
}
hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex');
}
}
export const tokenService = new TokenService();