import { findUserByEmailAndPassword, touchLastLogin, findUserById, createUser, userExistsByEmail } from '../repositories/user.repository.js'; import { createAuthSession, revokeSession, getAuthSession, rotateSessionTokens } from '../repositories/session.repository.js'; import { generateRefreshToken, generateCsrfToken, hashToken, signAccessToken, verifyRefreshToken, signRefreshToken, } from './token.service.js'; const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 30); function buildSessionExpiresAt() { const dt = new Date(); dt.setDate(dt.getDate() + REFRESH_TTL_DAYS); return dt; } export async function loginUser({ email, password, userAgent, ipAddress, }) { const user = await findUserByEmailAndPassword(email, password); if (!user) { const err = new Error('Invalid email or password'); err.statusCode = 401; err.code = 'INVALID_CREDENTIALS'; throw err; } const refreshToken = generateRefreshToken(); const csrfToken = generateCsrfToken(); const refreshTokenHash = hashToken(refreshToken); const csrfTokenHash = hashToken(csrfToken); const session = await createAuthSession({ userId: user.id, refreshTokenHash, csrfTokenHash, userAgent, ipAddress, expiresAt: buildSessionExpiresAt(), }); const accessToken = signAccessToken(user, session.id); await touchLastLogin(user.id); return { user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, status: user.status, }, session: { id: session.id, expiresAt: session.expires_at, }, accessToken, refreshToken, csrfToken, }; } export async function logoutUser({ sessionId }) { if (!sessionId) { return; } await revokeSession(sessionId); } export async function refreshUserSession({ refreshToken, userAgent, ipAddress }) { let payload; try { payload = verifyRefreshToken(refreshToken); } catch (err) { const error = new Error('Invalid or expired refresh token'); error.code = 'INVALID_REFRESH_TOKEN'; error.status = 401; throw error; } const session = await getAuthSession(payload.sid); if (!session) { const error = new Error('Session not found or expired'); error.code = 'SESSION_NOT_FOUND'; error.status = 401; throw error; } const user = await findUserById(session.user_id); if (!user) { const error = new Error('User not found'); error.code = 'USER_NOT_FOUND'; error.status = 401; throw error; } const newRefreshToken = generateRefreshToken(); const newCsrfToken = generateCsrfToken(); const newRefreshTokenHash = hashToken(newRefreshToken); const newCsrfTokenHash = hashToken(newCsrfToken); const updatedSession = await rotateSessionTokens( session.id, newRefreshTokenHash, newCsrfTokenHash ); if (!updatedSession) { const error = new Error('Failed to rotate session tokens'); error.code = 'SESSION_ROTATION_FAILED'; error.status = 500; throw error; } const accessToken = signAccessToken(user, session.id); const refreshTokenJwt = signRefreshToken({ sub: user.id, sid: session.id, }); return { user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, status: user.status, }, accessToken, refreshToken: refreshTokenJwt, csrfToken: newCsrfToken, }; } export async function registerUser({ email, password, displayName, userAgent, ipAddress }) { const { userExistsByEmail, createUser } = await import('../repositories/user.repository.js'); if (!email || !password) { const err = new Error('Email and password are required'); err.statusCode = 400; err.code = 'VALIDATION_ERROR'; throw err; } if (password.length < 6) { const err = new Error('Password must be at least 6 characters'); err.statusCode = 400; err.code = 'PASSWORD_TOO_SHORT'; throw err; } const exists = await userExistsByEmail(email); if (exists) { const err = new Error('User with this email already exists'); err.statusCode = 409; err.code = 'EMAIL_ALREADY_EXISTS'; throw err; } const user = await createUser({ email, password, displayName }); const refreshToken = generateRefreshToken(); const csrfToken = generateCsrfToken(); const refreshTokenHash = hashToken(refreshToken); const csrfTokenHash = hashToken(csrfToken); const session = await createAuthSession({ userId: user.id, refreshTokenHash, csrfTokenHash, userAgent, ipAddress, expiresAt: buildSessionExpiresAt(), }); const accessToken = signAccessToken(user, session.id); return { user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, status: user.status, }, session: { id: session.id, expiresAt: session.expires_at }, accessToken, refreshToken, csrfToken, }; } export async function loginWithTelegram({ telegramData, userAgent, ipAddress }) { const crypto = await import("crypto"); const { hash, ...fields } = telegramData; const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; if (!BOT_TOKEN) throw Object.assign(new Error("Telegram not configured"), { statusCode: 500, code: "CONFIG_ERROR" }); // Verify auth_date freshness (max 1 day) const authDate = Number(fields.auth_date); if (!authDate || Date.now() / 1000 - authDate > 86400) { throw Object.assign(new Error("Telegram auth expired"), { statusCode: 401, code: "AUTH_EXPIRED" }); } // Antifraud: require Telegram username (filters out throwaway / bot accounts) if (!fields.username || String(fields.username).trim().length < 3) { throw Object.assign(new Error("Telegram username required"), { statusCode: 400, code: "TELEGRAM_USERNAME_REQUIRED" }); } // Verify HMAC-SHA256 signature const dataCheckString = Object.keys(fields) .sort() .filter(k => fields[k] !== undefined && fields[k] !== null) .map(k => `${k}=${fields[k]}`) .join("\n"); const secretKey = crypto.createHash("sha256").update(BOT_TOKEN).digest(); const hmac = crypto.createHmac("sha256", secretKey).update(dataCheckString).digest("hex"); if (hmac !== hash) { throw Object.assign(new Error("Telegram signature invalid"), { statusCode: 401, code: "INVALID_SIGNATURE" }); } const { findUserByTelegramId, createUserFromTelegram, countTelegramSignupsByIpLast24h, recordSignupAttempt } = await import("../repositories/user.repository.js"); const telegramId = Number(fields.id); let user = await findUserByTelegramId(telegramId); if (!user) { // Antifraud: limit 3 new Telegram signups per IP per 24h const recentCount = await countTelegramSignupsByIpLast24h(ipAddress); if (recentCount >= 3) { throw Object.assign(new Error("Too many signups from this IP"), { statusCode: 429, code: "SIGNUP_RATE_LIMIT" }); } const displayName = [fields.first_name, fields.last_name].filter(Boolean).join(" ") || fields.username || null; const email = `tg_${telegramId}@telegram.local`; user = await createUserFromTelegram({ telegramId, displayName, email }); await recordSignupAttempt({ ipAddress, provider: 'telegram', telegramId, userAgent }); } if (user.status !== "active") { throw Object.assign(new Error("Account is blocked"), { statusCode: 403, code: "ACCOUNT_BLOCKED" }); } const refreshToken = generateRefreshToken(); const csrfToken = generateCsrfToken(); const refreshTokenHash = hashToken(refreshToken); const csrfTokenHash = hashToken(csrfToken); const session = await createAuthSession({ userId: user.id, refreshTokenHash, csrfTokenHash, userAgent, ipAddress, expiresAt: buildSessionExpiresAt(), }); const accessToken = signAccessToken(user, session.id); return { user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, status: user.status, }, session: { id: session.id, expiresAt: session.expires_at }, accessToken, refreshToken, csrfToken, }; } export async function linkTelegramAccount({ userId, telegramData }) { const crypto = await import("crypto"); const { hash, ...fields } = telegramData || {}; const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; if (!BOT_TOKEN) throw Object.assign(new Error("Telegram not configured"), { statusCode: 500, code: "CONFIG_ERROR" }); const authDate = Number(fields.auth_date); if (!authDate || Date.now() / 1000 - authDate > 86400) { throw Object.assign(new Error("Telegram auth expired"), { statusCode: 401, code: "AUTH_EXPIRED" }); } if (!fields.username || String(fields.username).trim().length < 3) { throw Object.assign(new Error("Telegram username required"), { statusCode: 400, code: "TELEGRAM_USERNAME_REQUIRED" }); } const dataCheckString = Object.keys(fields) .sort() .filter(k => fields[k] !== undefined && fields[k] !== null) .map(k => `${k}=${fields[k]}`) .join("\n"); const secretKey = crypto.createHash("sha256").update(BOT_TOKEN).digest(); const hmac = crypto.createHmac("sha256", secretKey).update(dataCheckString).digest("hex"); if (hmac !== hash) { throw Object.assign(new Error("Telegram signature invalid"), { statusCode: 401, code: "INVALID_SIGNATURE" }); } const { findTelegramOwner, linkTelegramToUser } = await import("../repositories/user.repository.js"); const telegramId = Number(fields.id); const owner = await findTelegramOwner(telegramId); if (owner && owner.id !== userId) { throw Object.assign(new Error("Telegram already linked to another account"), { statusCode: 409, code: "TELEGRAM_ALREADY_LINKED" }); } await linkTelegramToUser(userId, telegramId); return { ok: true, telegramId }; } export async function unlinkTelegramAccount(userId) { const { unlinkTelegramFromUser } = await import("../repositories/user.repository.js"); await unlinkTelegramFromUser(userId); return { ok: true }; } // ─── Bot deeplink login flow ───────────────────────────────────────────────── export async function startTelegramLoginFlow({ intent, userId, ipAddress, userAgent }) { const crypto = await import("crypto"); const { createTgLoginToken, countTgStartsByIpLastMinutes } = await import("../repositories/tg-login.repository.js"); // Антифрод: 10 стартов с одного IP за 10 минут const recent = await countTgStartsByIpLastMinutes(ipAddress, 10); if (recent >= 10) { throw Object.assign(new Error("Too many login attempts. Try again later."), { statusCode: 429, code: "TG_START_RATE_LIMIT" }); } // Telegram /start payload: до 64 base64url-символов const token = crypto.randomBytes(24).toString("base64url"); const created = await createTgLoginToken({ token, intent, userId, ipAddress, userAgent }); const botUsername = process.env.TELEGRAM_BOT_USERNAME || "One_Click_Auth_bot"; const url = `https://t.me/${botUsername}?start=${token}`; return { token, url, expiresAt: created.expiresAt, ttlSec: created.ttlSec }; } export async function confirmTelegramFromBot({ token, telegramUser }) { const { findTgLoginToken, markTgTokenConfirmed, markTgTokenError } = await import("../repositories/tg-login.repository.js"); const { findUserByTelegramId, createUserFromTelegram, countTelegramSignupsByIpLast24h, recordSignupAttempt, findUserById, findTelegramOwner, linkTelegramToUser, } = await import("../repositories/user.repository.js"); const row = await findTgLoginToken(token); if (!row) return { ok: false, code: "TOKEN_NOT_FOUND", message: "Ссылка не найдена или устарела." }; if (row.status !== "pending") return { ok: false, code: "TOKEN_NOT_PENDING", message: "Эта ссылка уже использована. Запросите новую на сайте." }; if (new Date(row.expires_at) < new Date()) { await markTgTokenError(token, "EXPIRED"); return { ok: false, code: "EXPIRED", message: "Ссылка истекла. Запросите новую на сайте." }; } // Антифрод: требуем username if (!telegramUser.username || String(telegramUser.username).trim().length < 3) { await markTgTokenError(token, "USERNAME_REQUIRED"); return { ok: false, code: "USERNAME_REQUIRED", message: "У вашего Telegram-аккаунта должно быть @username." }; } const telegramId = Number(telegramUser.id); if (row.intent === "link") { if (!row.user_id) { await markTgTokenError(token, "NO_USER"); return { ok: false, code: "NO_USER", message: "Авторизация не найдена. Войдите на сайт и попробуйте снова." }; } const owner = await findTelegramOwner(telegramId); if (owner && owner.id !== row.user_id) { await markTgTokenError(token, "TELEGRAM_ALREADY_LINKED"); return { ok: false, code: "TELEGRAM_ALREADY_LINKED", message: "Этот Telegram уже привязан к другому аккаунту." }; } await linkTelegramToUser(row.user_id, telegramId); await markTgTokenConfirmed(token, { telegramId, telegramUsername: telegramUser.username, telegramFirstName: telegramUser.first_name || null, telegramLastName: telegramUser.last_name || null, userId: row.user_id, }); return { ok: true, message: "Готово! Telegram привязан. Возвращайтесь на сайт." }; } // intent === "login" let user = await findUserByTelegramId(telegramId); if (!user) { const recent = await countTelegramSignupsByIpLast24h(row.ip_address); if (recent >= 3) { await markTgTokenError(token, "SIGNUP_RATE_LIMIT"); return { ok: false, code: "SIGNUP_RATE_LIMIT", message: "Слишком много регистраций с этого IP. Попробуйте позже." }; } const displayName = [telegramUser.first_name, telegramUser.last_name].filter(Boolean).join(" ") || telegramUser.username || null; const email = `tg_${telegramId}@telegram.local`; user = await createUserFromTelegram({ telegramId, displayName, email }); await recordSignupAttempt({ ipAddress: row.ip_address, provider: "telegram", telegramId, userAgent: row.user_agent }); } if (user.status !== "active") { await markTgTokenError(token, "ACCOUNT_BLOCKED"); return { ok: false, code: "ACCOUNT_BLOCKED", message: "Аккаунт заблокирован." }; } await markTgTokenConfirmed(token, { telegramId, telegramUsername: telegramUser.username, telegramFirstName: telegramUser.first_name || null, telegramLastName: telegramUser.last_name || null, userId: user.id, }); return { ok: true, message: "Готово! Можете возвращаться на сайт — вы уже вошли." }; } export async function pollTelegramLoginFlow({ token, userAgent, ipAddress }) { const { findTgLoginToken, consumeTgToken } = await import("../repositories/tg-login.repository.js"); const { findUserById } = await import("../repositories/user.repository.js"); const row = await findTgLoginToken(token); if (!row) return { status: "not_found" }; if (row.status === "pending") { if (new Date(row.expires_at) < new Date()) return { status: "expired" }; return { status: "pending" }; } if (row.status === "error") { return { status: "error", code: row.error_code }; } if (row.status === "consumed") { return { status: "consumed" }; } if (row.status !== "confirmed") { return { status: row.status }; } // confirmed → consume + maybe issue session const consumed = await consumeTgToken(token); if (!consumed) return { status: "consumed" }; if (row.intent === "link") { return { status: "linked" }; } // login: создаём сессию const user = await findUserById(row.user_id); if (!user) return { status: "error", code: "USER_NOT_FOUND" }; const refreshToken = generateRefreshToken(); const csrfToken = generateCsrfToken(); const session = await createAuthSession({ userId: user.id, refreshTokenHash: hashToken(refreshToken), csrfTokenHash: hashToken(csrfToken), userAgent, ipAddress, expiresAt: buildSessionExpiresAt(), }); const accessToken = signAccessToken(user, session.id); return { status: "authenticated", user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, status: user.status, }, accessToken, refreshToken, csrfToken, sessionExpiresAt: session.expires_at, }; } // ─── Telegram Mini App (Web App) login by initData ─────────────────────────── // Спека: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app // Алгоритм: secret = HMAC_SHA256("WebAppData", BOT_TOKEN); // expected = HMAC_SHA256(secret, data_check_string).hex(); // data_check_string = отсортированные по ключу пары "k=v", разделённые "\n", без поля "hash". export async function loginWithTelegramInitData({ initData, userAgent, ipAddress }) { const crypto = await import("crypto"); const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN; if (!BOT_TOKEN) { throw Object.assign(new Error("Telegram not configured"), { statusCode: 500, code: "CONFIG_ERROR" }); } if (!initData || typeof initData !== "string") { throw Object.assign(new Error("initData is required"), { statusCode: 400, code: "INIT_DATA_REQUIRED" }); } // Парсим как URLSearchParams const params = new URLSearchParams(initData); const hash = params.get("hash"); if (!hash) { throw Object.assign(new Error("hash missing"), { statusCode: 401, code: "INVALID_INIT_DATA" }); } params.delete("hash"); const pairs = []; for (const [k, v] of params.entries()) pairs.push([k, v]); pairs.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); const dataCheckString = pairs.map(([k, v]) => `${k}=${v}`).join("\n"); const secretKey = crypto.createHmac("sha256", "WebAppData").update(BOT_TOKEN).digest(); const expected = crypto.createHmac("sha256", secretKey).update(dataCheckString).digest("hex"); // Константно-временное сравнение const a = Buffer.from(expected, "hex"); const b = Buffer.from(hash, "hex"); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { throw Object.assign(new Error("Telegram signature invalid"), { statusCode: 401, code: "INVALID_INIT_DATA" }); } // Свежесть auth_date (24 часа, как в widget-flow) const authDate = Number(params.get("auth_date")); if (!authDate || Date.now() / 1000 - authDate > 86400) { throw Object.assign(new Error("Telegram auth expired"), { statusCode: 401, code: "AUTH_EXPIRED" }); } // Парсим user let tgUser; try { tgUser = JSON.parse(params.get("user") || "null"); } catch { tgUser = null; } if (!tgUser || !tgUser.id) { throw Object.assign(new Error("Telegram user missing"), { statusCode: 400, code: "TELEGRAM_USER_MISSING" }); } // Антифрод: требуем username if (!tgUser.username || String(tgUser.username).trim().length < 3) { throw Object.assign(new Error("Telegram username required"), { statusCode: 400, code: "TELEGRAM_USERNAME_REQUIRED" }); } const { findUserByTelegramId, createUserFromTelegram, countTelegramSignupsByIpLast24h, recordSignupAttempt } = await import("../repositories/user.repository.js"); const telegramId = Number(tgUser.id); let user = await findUserByTelegramId(telegramId); if (!user) { // Антифрод: 3 регистрации с одного IP за 24 часа (общий счётчик с widget/bot-flow) const recentCount = await countTelegramSignupsByIpLast24h(ipAddress); if (recentCount >= 3) { throw Object.assign(new Error("Too many signups from this IP"), { statusCode: 429, code: "SIGNUP_RATE_LIMIT" }); } const displayName = [tgUser.first_name, tgUser.last_name].filter(Boolean).join(" ") || tgUser.username || null; const email = `tg_${telegramId}@telegram.local`; user = await createUserFromTelegram({ telegramId, displayName, email }); await recordSignupAttempt({ ipAddress, provider: "telegram_webapp", telegramId, userAgent }); } if (user.status !== "active") { throw Object.assign(new Error("Account is blocked"), { statusCode: 403, code: "ACCOUNT_BLOCKED" }); } const refreshToken = generateRefreshToken(); const csrfToken = generateCsrfToken(); const refreshTokenHash = hashToken(refreshToken); const csrfTokenHash = hashToken(csrfToken); const session = await createAuthSession({ userId: user.id, refreshTokenHash, csrfTokenHash, userAgent, ipAddress, expiresAt: buildSessionExpiresAt(), }); const accessToken = signAccessToken(user, session.id); return { user: { id: user.id, email: user.email, displayName: user.display_name, role: user.role, status: user.status, }, session: { id: session.id, expiresAt: session.expires_at }, accessToken, refreshToken, csrfToken, }; }