482 lines
16 KiB
Plaintext
482 lines
16 KiB
Plaintext
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,
|
||
};
|
||
}
|