120 lines
4.0 KiB
TypeScript
120 lines
4.0 KiB
TypeScript
import crypto from 'crypto';
|
|
import bcrypt from 'bcryptjs';
|
|
import { ApiToken } from '../models/ApiToken';
|
|
import { User } from '../models/User';
|
|
import logger from '../utils/logger';
|
|
|
|
export class ApiTokenService {
|
|
private static readonly TOKEN_PREFIX = 're_';
|
|
private static readonly TOKEN_LENGTH = 32;
|
|
|
|
/**
|
|
* Generate a new API token for a user
|
|
*/
|
|
async createToken(userId: string, name: string, expiresInDays?: number): Promise<{ token: string; apiToken: ApiToken }> {
|
|
try {
|
|
// 1. Generate secure random token
|
|
const randomBytes = crypto.randomBytes(ApiTokenService.TOKEN_LENGTH);
|
|
const randomString = randomBytes.toString('hex');
|
|
const token = `${ApiTokenService.TOKEN_PREFIX}${randomString}`;
|
|
|
|
// 2. Hash the token
|
|
const tokenHash = await bcrypt.hash(token, 10);
|
|
|
|
// 3. Calculate expiry
|
|
let expiresAt: Date | undefined;
|
|
if (expiresInDays) {
|
|
expiresAt = new Date();
|
|
expiresAt.setDate(expiresAt.getDate() + expiresInDays);
|
|
}
|
|
|
|
// 4. Save to DB
|
|
const apiToken = await ApiToken.create({
|
|
userId,
|
|
name,
|
|
prefix: token.substring(0, 8), // Store first 8 chars (prefix + 5 chars) for lookup
|
|
tokenHash,
|
|
expiresAt,
|
|
isActive: true,
|
|
});
|
|
|
|
logger.info(`API Token created for user ${userId}: ${name}`);
|
|
|
|
// Return raw token ONLY here
|
|
return { token, apiToken };
|
|
} catch (error) {
|
|
logger.error('Error creating API token:', error);
|
|
throw new Error('Failed to create API token');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List all active tokens for a user
|
|
*/
|
|
async listTokens(userId: string): Promise<ApiToken[]> {
|
|
return await ApiToken.findAll({
|
|
where: { userId, isActive: true },
|
|
order: [['createdAt', 'DESC']],
|
|
attributes: { exclude: ['tokenHash'] } // Never return hash
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Revoke (delete) a token
|
|
*/
|
|
async revokeToken(userId: string, tokenId: string): Promise<boolean> {
|
|
const result = await ApiToken.destroy({
|
|
where: { id: tokenId, userId }
|
|
});
|
|
return result > 0;
|
|
}
|
|
|
|
/**
|
|
* Verify an API token and return the associated user
|
|
*/
|
|
async verifyToken(rawToken: string): Promise<User | null> {
|
|
try {
|
|
if (!rawToken.startsWith(ApiTokenService.TOKEN_PREFIX)) {
|
|
return null;
|
|
}
|
|
|
|
// 1. Find potential tokens by prefix (optimization)
|
|
const prefix = rawToken.substring(0, 8);
|
|
const tokens = await ApiToken.findAll({
|
|
where: {
|
|
prefix,
|
|
isActive: true
|
|
},
|
|
include: [{
|
|
model: User,
|
|
as: 'user',
|
|
where: { isActive: true } // Ensure user is also active
|
|
}]
|
|
});
|
|
|
|
// 2. check hash for each candidate
|
|
for (const tokenRecord of tokens) {
|
|
const isValid = await bcrypt.compare(rawToken, tokenRecord.tokenHash);
|
|
if (isValid) {
|
|
// 3. Check expiry
|
|
if (tokenRecord.expiresAt && new Date() > tokenRecord.expiresAt) {
|
|
logger.warn(`Expired API token used: ${tokenRecord.id}`);
|
|
return null;
|
|
}
|
|
|
|
// 4. Update last used
|
|
await tokenRecord.update({ lastUsedAt: new Date() });
|
|
|
|
// Return the user (casted as any because include types can be tricky)
|
|
return (tokenRecord as any).user;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
logger.error('Error verifying API token:', error);
|
|
return null;
|
|
}
|
|
}
|
|
}
|