220 lines
4.9 KiB
JavaScript
220 lines
4.9 KiB
JavaScript
const bcrypt = require('bcryptjs');
|
|
const crypto = require('crypto');
|
|
const jwt = require('jsonwebtoken');
|
|
|
|
const { getDb } = require('../config/db');
|
|
const { AppError } = require('../middleware/error-handler');
|
|
|
|
const ACCESS_TOKEN_TTL = '15m';
|
|
const REFRESH_TOKEN_BYTES = 48;
|
|
const RESET_TOKEN_BYTES = 32;
|
|
const RESET_TOKEN_TTL_MINUTES = 30;
|
|
|
|
function getJwtSecret() {
|
|
return process.env.JWT_SECRET || 'development_only_change_me';
|
|
}
|
|
|
|
function hashToken(token) {
|
|
return crypto.createHash('sha256').update(token).digest('hex');
|
|
}
|
|
|
|
function addMinutes(date, minutes) {
|
|
return new Date(date.getTime() + minutes * 60 * 1000);
|
|
}
|
|
|
|
function addDays(date, days) {
|
|
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
|
|
}
|
|
|
|
function sanitizeUser(user) {
|
|
return {
|
|
id: user.id,
|
|
username: user.username,
|
|
email: user.email,
|
|
role: user.role,
|
|
status: user.status,
|
|
balance: user.balance
|
|
};
|
|
}
|
|
|
|
function createAccessToken(user) {
|
|
return jwt.sign(
|
|
{
|
|
user_id: user.id,
|
|
role: user.role
|
|
},
|
|
getJwtSecret(),
|
|
{
|
|
expiresIn: ACCESS_TOKEN_TTL
|
|
}
|
|
);
|
|
}
|
|
|
|
async function createRefreshToken(userId, trx = getDb()) {
|
|
const refreshToken = crypto.randomBytes(REFRESH_TOKEN_BYTES).toString('base64url');
|
|
const tokenHash = hashToken(refreshToken);
|
|
|
|
await trx('refresh_tokens').insert({
|
|
user_id: userId,
|
|
token_hash: tokenHash,
|
|
expires_at: addDays(new Date(), 30)
|
|
});
|
|
|
|
return refreshToken;
|
|
}
|
|
|
|
async function register(input) {
|
|
const db = getDb();
|
|
const passwordHash = await bcrypt.hash(input.password, 12);
|
|
|
|
try {
|
|
const [userId] = await db('users').insert({
|
|
username: input.username,
|
|
email: input.email,
|
|
password_hash: passwordHash,
|
|
balance: '0.0000'
|
|
});
|
|
|
|
const user = await db('users').where({ id: userId }).first();
|
|
|
|
return {
|
|
user: sanitizeUser(user)
|
|
};
|
|
} catch (error) {
|
|
if (error.code === 'ER_DUP_ENTRY') {
|
|
throw new AppError('Username or email is already in use', 409, 'ACCOUNT_EXISTS');
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function login(input) {
|
|
const db = getDb();
|
|
const genericError = new AppError('Invalid email or password', 401, 'INVALID_CREDENTIALS');
|
|
const user = await db('users').where({ email: input.email }).first();
|
|
|
|
if (!user) {
|
|
throw genericError;
|
|
}
|
|
|
|
const passwordMatches = await bcrypt.compare(input.password, user.password_hash);
|
|
|
|
if (!passwordMatches) {
|
|
throw genericError;
|
|
}
|
|
|
|
if (user.status !== 'active') {
|
|
throw new AppError('Account is not active', 403, 'ACCOUNT_INACTIVE');
|
|
}
|
|
|
|
const accessToken = createAccessToken(user);
|
|
const refreshToken = await createRefreshToken(user.id);
|
|
|
|
return {
|
|
accessToken,
|
|
refreshToken,
|
|
tokenType: 'Bearer',
|
|
expiresIn: 15 * 60,
|
|
user: sanitizeUser(user)
|
|
};
|
|
}
|
|
|
|
async function logout(refreshToken) {
|
|
if (!refreshToken) {
|
|
return;
|
|
}
|
|
|
|
const db = getDb();
|
|
|
|
await db('refresh_tokens')
|
|
.where({ token_hash: hashToken(refreshToken) })
|
|
.whereNull('revoked_at')
|
|
.update({ revoked_at: new Date() });
|
|
}
|
|
|
|
async function forgotPassword(email) {
|
|
const db = getDb();
|
|
const user = await db('users').select('id').where({ email }).first();
|
|
const response = {
|
|
status: 'ok',
|
|
message: 'If the account exists, a reset link will be sent'
|
|
};
|
|
|
|
if (!user) {
|
|
return response;
|
|
}
|
|
|
|
const token = crypto.randomBytes(RESET_TOKEN_BYTES).toString('base64url');
|
|
|
|
await db('password_reset_tokens').insert({
|
|
user_id: user.id,
|
|
token_hash: hashToken(token),
|
|
expires_at: addMinutes(new Date(), RESET_TOKEN_TTL_MINUTES)
|
|
});
|
|
|
|
if (process.env.NODE_ENV !== 'production') {
|
|
response.resetToken = token;
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
async function resetPassword(input) {
|
|
const db = getDb();
|
|
const tokenHash = hashToken(input.token);
|
|
|
|
await db.transaction(async (trx) => {
|
|
const resetToken = await trx('password_reset_tokens')
|
|
.where({ token_hash: tokenHash })
|
|
.whereNull('used_at')
|
|
.where('expires_at', '>', new Date())
|
|
.forUpdate()
|
|
.first();
|
|
|
|
if (!resetToken) {
|
|
throw new AppError('Invalid or expired reset token', 400, 'INVALID_RESET_TOKEN');
|
|
}
|
|
|
|
const passwordHash = await bcrypt.hash(input.password, 12);
|
|
|
|
await trx('users').where({ id: resetToken.user_id }).update({
|
|
password_hash: passwordHash
|
|
});
|
|
|
|
await trx('password_reset_tokens').where({ id: resetToken.id }).update({
|
|
used_at: new Date()
|
|
});
|
|
|
|
await trx('refresh_tokens')
|
|
.where({ user_id: resetToken.user_id })
|
|
.whereNull('revoked_at')
|
|
.update({ revoked_at: new Date() });
|
|
});
|
|
}
|
|
|
|
async function getMe(userId) {
|
|
const db = getDb();
|
|
const user = await db('users')
|
|
.select('id', 'username', 'email', 'role', 'status', 'balance')
|
|
.where({ id: userId })
|
|
.first();
|
|
|
|
if (!user) {
|
|
throw new AppError('User not found', 404, 'USER_NOT_FOUND');
|
|
}
|
|
|
|
return {
|
|
user: sanitizeUser(user)
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
forgotPassword,
|
|
getMe,
|
|
login,
|
|
logout,
|
|
register,
|
|
resetPassword
|
|
};
|