243 lines
7.1 KiB
JavaScript
243 lines
7.1 KiB
JavaScript
// services/auth.service.js
|
|
const bcrypt = require("bcryptjs")
|
|
const jwt = require("jsonwebtoken")
|
|
const crypto = require("crypto")
|
|
|
|
const authDb = require("../db/auth.db")
|
|
const refreshTokenDb = require("../db/refreshToken.db")
|
|
const { queueAuditEvent } = require("./redis/dbSync.service")
|
|
const { AUDIT_ACTIONS } = require("./auditActions")
|
|
const { buildAuditMeta } = require("./audit.service")
|
|
|
|
const REUSE_GRACE_MS = Number(process.env.REFRESH_REUSE_GRACE_MS || 10000)
|
|
|
|
function httpError(statusCode, message) {
|
|
const err = new Error(message)
|
|
err.statusCode = statusCode
|
|
return err
|
|
}
|
|
|
|
// Access token: kisa ömür
|
|
function signAccessToken(user) {
|
|
const jti = crypto.randomUUID()
|
|
const payload = {
|
|
sub: String(user.id),
|
|
role: user.role, // USER|MOD|ADMIN
|
|
jti,
|
|
}
|
|
|
|
const expiresIn = process.env.ACCESS_TOKEN_EXPIRES_IN || "15m"
|
|
const token = jwt.sign(payload, process.env.JWT_ACCESS_SECRET, { expiresIn })
|
|
return { token, jti }
|
|
}
|
|
|
|
// Refresh token: opaque (JWT degil) + DB'de hash
|
|
function generateRefreshToken() {
|
|
// 64 byte -> url-safe base64
|
|
return crypto.randomBytes(64).toString("base64url")
|
|
}
|
|
|
|
function hashToken(token) {
|
|
return crypto.createHash("sha256").update(token).digest("hex")
|
|
}
|
|
|
|
function refreshExpiresAt() {
|
|
const days = Number(process.env.REFRESH_TOKEN_DAYS || 30)
|
|
return new Date(Date.now() + days * 24 * 60 * 60 * 1000)
|
|
}
|
|
|
|
function mapUserPublic(user) {
|
|
return {
|
|
id: user.id,
|
|
username: user.username,
|
|
email: user.email,
|
|
avatarUrl: user.avatarUrl ?? null,
|
|
role: user.role,
|
|
}
|
|
}
|
|
|
|
async function login({ email, password, meta = {} }) {
|
|
const user = await authDb.findUserByEmail(email)
|
|
if (!user) throw httpError(400, "Kullanici bulunamadi.")
|
|
if (user.disabledAt) throw httpError(403, "Hesap devre disi.")
|
|
|
|
const isMatch = await bcrypt.compare(password, user.passwordHash)
|
|
if (!isMatch) throw httpError(401, "Sifre hatali.")
|
|
|
|
const { token: accessToken } = signAccessToken(user)
|
|
|
|
const refreshToken = generateRefreshToken()
|
|
const tokenHash = hashToken(refreshToken)
|
|
const familyId = crypto.randomUUID()
|
|
const jti = crypto.randomUUID()
|
|
|
|
await refreshTokenDb.createRefreshToken(user.id, {
|
|
tokenHash,
|
|
familyId,
|
|
jti,
|
|
expiresAt: refreshExpiresAt(),
|
|
createdByIp: meta.ip ?? null,
|
|
userAgent: meta.userAgent ?? null,
|
|
})
|
|
|
|
queueAuditEvent({
|
|
userId: user.id,
|
|
action: AUDIT_ACTIONS.AUTH.LOGIN_SUCCESS,
|
|
ip: meta.ip ?? null,
|
|
userAgent: meta.userAgent ?? null,
|
|
meta: buildAuditMeta({ entityType: "USER", entityId: user.id }),
|
|
createdAt: new Date().toISOString(),
|
|
}).catch((err) => console.error("Audit queue login failed:", err?.message || err))
|
|
|
|
return {
|
|
accessToken,
|
|
refreshToken,
|
|
user: mapUserPublic(user),
|
|
}
|
|
}
|
|
|
|
async function register({ username, email, password, meta = {} }) {
|
|
const existingUser = await authDb.findUserByEmail(email)
|
|
if (existingUser) throw httpError(400, "Bu e-posta zaten kayitli.")
|
|
|
|
const passwordHash = await bcrypt.hash(password, 10)
|
|
const user = await authDb.createUser({ username, email, passwordHash })
|
|
|
|
const { token: accessToken } = signAccessToken(user)
|
|
|
|
const refreshToken = generateRefreshToken()
|
|
const tokenHash = hashToken(refreshToken)
|
|
const familyId = crypto.randomUUID()
|
|
const jti = crypto.randomUUID()
|
|
|
|
await refreshTokenDb.createRefreshToken(user.id, {
|
|
tokenHash,
|
|
familyId,
|
|
jti,
|
|
expiresAt: refreshExpiresAt(),
|
|
createdByIp: meta.ip ?? null,
|
|
userAgent: meta.userAgent ?? null,
|
|
})
|
|
|
|
queueAuditEvent({
|
|
userId: user.id,
|
|
action: AUDIT_ACTIONS.AUTH.REGISTER,
|
|
ip: meta.ip ?? null,
|
|
userAgent: meta.userAgent ?? null,
|
|
meta: buildAuditMeta({ entityType: "USER", entityId: user.id }),
|
|
createdAt: new Date().toISOString(),
|
|
}).catch((err) => console.error("Audit queue register failed:", err?.message || err))
|
|
|
|
return {
|
|
accessToken,
|
|
refreshToken,
|
|
user: mapUserPublic(user),
|
|
}
|
|
}
|
|
|
|
// Refresh: rotate + reuse tespiti
|
|
async function refresh({ refreshToken, meta = {} }) {
|
|
if (!refreshToken) throw httpError(401, "Refresh token yok")
|
|
|
|
const tokenHash = hashToken(refreshToken)
|
|
const existing = await refreshTokenDb.findRefreshTokenByHash(tokenHash, {
|
|
include: { user: true },
|
|
})
|
|
|
|
if (!existing) throw httpError(401, "Refresh token geçersiz")
|
|
|
|
// süresi geçmis
|
|
if (existing.expiresAt && existing.expiresAt.getTime() < Date.now()) {
|
|
await refreshTokenDb.revokeRefreshTokenById(existing.id)
|
|
throw httpError(401, "Refresh token süresi dolmus")
|
|
}
|
|
|
|
// reuse tespiti: revoke edilmis token tekrar gelirse -> tüm aileyi kapat
|
|
if (existing.revokedAt) {
|
|
const revokedAt = existing.revokedAt instanceof Date ? existing.revokedAt : new Date(existing.revokedAt)
|
|
const withinGrace =
|
|
existing.replacedById &&
|
|
revokedAt &&
|
|
Date.now() - revokedAt.getTime() <= REUSE_GRACE_MS
|
|
|
|
if (!withinGrace) {
|
|
await refreshTokenDb.revokeRefreshTokenFamily(existing.familyId)
|
|
throw httpError(401, "Refresh token reuse tespit edildi")
|
|
}
|
|
}
|
|
|
|
const user = existing.user
|
|
if (user?.disabledAt) throw httpError(403, "Hesap devre disi.")
|
|
const { token: accessToken } = signAccessToken(user)
|
|
|
|
const newRefreshToken = generateRefreshToken()
|
|
const newTokenHash = hashToken(newRefreshToken)
|
|
const newJti = crypto.randomUUID()
|
|
|
|
await refreshTokenDb.rotateRefreshToken({
|
|
oldId: existing.id,
|
|
newToken: {
|
|
userId: user.id,
|
|
tokenHash: newTokenHash,
|
|
familyId: existing.familyId, // ayni aile
|
|
jti: newJti,
|
|
expiresAt: refreshExpiresAt(),
|
|
},
|
|
meta: { ip: meta.ip ?? null, userAgent: meta.userAgent ?? null },
|
|
})
|
|
|
|
queueAuditEvent({
|
|
userId: user.id,
|
|
action: AUDIT_ACTIONS.AUTH.REFRESH,
|
|
ip: meta.ip ?? null,
|
|
userAgent: meta.userAgent ?? null,
|
|
meta: buildAuditMeta({ entityType: "USER", entityId: user.id }),
|
|
createdAt: new Date().toISOString(),
|
|
}).catch((err) => console.error("Audit queue refresh failed:", err?.message || err))
|
|
|
|
return {
|
|
accessToken,
|
|
refreshToken: newRefreshToken,
|
|
user: mapUserPublic(user),
|
|
}
|
|
}
|
|
|
|
async function logout({ refreshToken, meta = {} }) {
|
|
if (!refreshToken) return
|
|
const tokenHash = hashToken(refreshToken)
|
|
|
|
// token yoksa sessiz geçmek genelde daha iyi (idempotent logout)
|
|
try {
|
|
const existing = await refreshTokenDb.findRefreshTokenByHash(tokenHash, {
|
|
select: { userId: true },
|
|
})
|
|
await refreshTokenDb.revokeRefreshTokenByHash(tokenHash)
|
|
if (existing?.userId) {
|
|
queueAuditEvent({
|
|
userId: existing.userId,
|
|
action: AUDIT_ACTIONS.AUTH.LOGOUT,
|
|
ip: meta.ip ?? null,
|
|
userAgent: meta.userAgent ?? null,
|
|
meta: buildAuditMeta({ entityType: "USER", entityId: existing.userId }),
|
|
createdAt: new Date().toISOString(),
|
|
}).catch((err) => console.error("Audit queue logout failed:", err?.message || err))
|
|
}
|
|
} catch (_) {}
|
|
}
|
|
|
|
async function getMe(userId) {
|
|
const user = await authDb.findUserById(Number(userId), {
|
|
select: { id: true, username: true, email: true, avatarUrl: true, role: true },
|
|
})
|
|
if (!user) throw httpError(404, "Kullanici bulunamadi")
|
|
return user
|
|
}
|
|
|
|
module.exports = {
|
|
login,
|
|
register,
|
|
refresh,
|
|
logout,
|
|
getMe,
|
|
}
|