feat: SMS verification via SMSC.ru for registration
- POST /api/auth/send-code — generates 4-digit OTP, sends SMS via SMSC.ru - Registration now requires SMS code verification (2-step flow) - Cooldown 60s between resends, 5 min expiry, max 5 attempts - Frontend: step 1 = enter phone → get code, step 2 = code + name + password - Large code input with tracking, countdown timer, resend button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,22 @@ import { pool } from '../db.js';
|
||||
|
||||
const COLORS = ['#3b82f6','#ef4444','#10b981','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#f97316'];
|
||||
|
||||
// ── OTP store ────────────────────────────────────────────────────────────────
|
||||
interface OtpEntry { code: string; expiresAt: number; attempts: number; sentAt: number }
|
||||
const otpStore = new Map<string, OtpEntry>();
|
||||
setInterval(() => { const now = Date.now(); otpStore.forEach((v, k) => { if (v.expiresAt < now) otpStore.delete(k); }); }, 60_000);
|
||||
|
||||
async function sendSmscSms(phone: string, message: string) {
|
||||
const login = process.env.SMSC_LOGIN;
|
||||
const psw = process.env.SMSC_PASSWORD;
|
||||
if (!login || !psw) throw new Error('SMSC не настроен');
|
||||
const digits = phone.replace(/\D/g, '');
|
||||
const url = `https://smsc.ru/sys/send.php?login=${encodeURIComponent(login)}&psw=${encodeURIComponent(psw)}&phones=${digits}&mes=${encodeURIComponent(message)}&charset=utf-8&fmt=3`;
|
||||
const res = await fetch(url);
|
||||
const json = await res.json() as any;
|
||||
if (json.error_code) throw new Error(json.error || 'Ошибка SMSC');
|
||||
}
|
||||
|
||||
function userDto(u: any) {
|
||||
return {
|
||||
id: u.id, username: u.username, displayName: u.display_name,
|
||||
@@ -16,20 +32,60 @@ function userDto(u: any) {
|
||||
}
|
||||
|
||||
export default async function authRoutes(app: FastifyInstance) {
|
||||
// Register
|
||||
// Send SMS verification code
|
||||
app.post('/send-code', async (req, reply) => {
|
||||
const { phone } = req.body as { phone: string };
|
||||
if (!phone) return reply.status(400).send({ error: 'Укажите телефон' });
|
||||
|
||||
const now = Date.now();
|
||||
const existing = otpStore.get(phone);
|
||||
if (existing && now - existing.sentAt < 60_000) {
|
||||
const wait = Math.ceil((60_000 - (now - existing.sentAt)) / 1000);
|
||||
return reply.status(429).send({ error: `Повторная отправка через ${wait} сек.` });
|
||||
}
|
||||
|
||||
const code = String(Math.floor(1000 + Math.random() * 9000));
|
||||
otpStore.set(phone, { code, expiresAt: now + 5 * 60_000, attempts: 0, sentAt: now });
|
||||
|
||||
try {
|
||||
await sendSmscSms(phone, `JaniChat: ваш код ${code}. Действует 5 минут.`);
|
||||
} catch (e: any) {
|
||||
otpStore.delete(phone);
|
||||
return reply.status(500).send({ error: e.message || 'Ошибка отправки SMS' });
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Register (requires SMS code)
|
||||
app.post('/register', async (req, reply) => {
|
||||
const { phone, displayName, password } = req.body as any;
|
||||
if (!phone || !displayName || !password) {
|
||||
const { phone, displayName, password, code } = req.body as any;
|
||||
if (!phone || !displayName || !password || !code) {
|
||||
return reply.status(400).send({ error: 'Заполните все поля' });
|
||||
}
|
||||
if (password.length < 6) {
|
||||
return reply.status(400).send({ error: 'Пароль минимум 6 символов' });
|
||||
}
|
||||
|
||||
// Verify OTP
|
||||
const entry = otpStore.get(phone);
|
||||
if (!entry) return reply.status(400).send({ error: 'Сначала запросите код' });
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
otpStore.delete(phone);
|
||||
return reply.status(400).send({ error: 'Код истёк. Запросите новый' });
|
||||
}
|
||||
entry.attempts++;
|
||||
if (entry.attempts > 5) {
|
||||
otpStore.delete(phone);
|
||||
return reply.status(400).send({ error: 'Слишком много попыток. Запросите новый код' });
|
||||
}
|
||||
if (entry.code !== String(code)) {
|
||||
return reply.status(400).send({ error: 'Неверный код' });
|
||||
}
|
||||
otpStore.delete(phone);
|
||||
|
||||
// Check phone uniqueness
|
||||
const { rows: [existingPhone] } = await pool.query(
|
||||
'SELECT id FROM users WHERE phone = $1', [phone]
|
||||
);
|
||||
const { rows: [existingPhone] } = await pool.query('SELECT id FROM users WHERE phone = $1', [phone]);
|
||||
if (existingPhone) return reply.status(400).send({ error: 'Этот номер уже зарегистрирован' });
|
||||
|
||||
// Auto-generate username from displayName
|
||||
@@ -38,7 +94,6 @@ export default async function authRoutes(app: FastifyInstance) {
|
||||
.replace(/_+/g, '_')
|
||||
.replace(/^_|_$/g, '')
|
||||
.slice(0, 20) || 'user';
|
||||
// transliterate basic cyrillic
|
||||
base = base.replace(/[а-яё]/gi, (c: string) => {
|
||||
const map: Record<string, string> = {а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ж:'zh',з:'z',и:'i',й:'j',к:'k',л:'l',м:'m',н:'n',о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',х:'h',ц:'ts',ч:'ch',ш:'sh',щ:'sh',ъ:'',ы:'y',ь:'',э:'e',ю:'yu',я:'ya',ё:'e'};
|
||||
return map[c.toLowerCase()] || '_';
|
||||
@@ -49,7 +104,6 @@ export default async function authRoutes(app: FastifyInstance) {
|
||||
|
||||
const hash = await bcrypt.hash(password, 10);
|
||||
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
|
||||
|
||||
const { rows: [user] } = await pool.query(
|
||||
`INSERT INTO users (username, display_name, password_hash, phone, avatar_color)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
|
||||
Reference in New Issue
Block a user