feat: passwordless SMS-only auth (login + registration)

- Single unified flow: phone → SMS code → (if new: enter name)
- POST /api/auth/complete — verifies OTP, logs in or registers
- Removed password from login/register completely
- New users get random password_hash (not used for auth)
- Removed password change section from ProfileModal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ai
2026-05-25 15:54:05 +03:00
parent dbf328b197
commit b095d20852
3 changed files with 119 additions and 240 deletions

View File

@@ -57,43 +57,50 @@ export default async function authRoutes(app: FastifyInstance) {
return { ok: true };
});
// Register (requires SMS code)
app.post('/register', async (req, reply) => {
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 символов' });
}
// Unified: verify code → login existing user OR register new user
// If new user and no displayName provided → returns { isNew: true }
app.post('/complete', async (req, reply) => {
const { phone, code, displayName } = req.body as any;
if (!phone || !code) return reply.status(400).send({ error: 'Укажите телефон и код' });
// 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)) {
entry.attempts++;
if (entry.attempts >= 5) {
otpStore.delete(phone);
return reply.status(400).send({ error: 'Слишком много попыток. Запросите новый код' });
}
return reply.status(400).send({ error: 'Неверный код' });
}
// Code correct — check if user exists
const { rows: [existing] } = await pool.query(
'SELECT * FROM users WHERE phone = $1 AND is_active = TRUE', [phone]
);
if (existing) {
// Login
otpStore.delete(phone);
await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [existing.id]);
const token = app.jwt.sign({ id: existing.id, isAdmin: false }, { expiresIn: '30d' });
return { token, user: userDto(existing) };
}
// New user — need a name
if (!displayName?.trim()) {
return reply.status(422).send({ isNew: true, error: 'Введите ваше имя' });
}
otpStore.delete(phone);
// Check phone uniqueness
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
// Generate username
let base = displayName.toLowerCase()
.replace(/[^a-z0-9а-яё]/gi, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '')
.slice(0, 20) || 'user';
.replace(/[^a-z0-9а-яё]/gi, '_').replace(/_+/g, '_').replace(/^_|_$/g, '').slice(0, 20) || 'user';
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()] || '_';
@@ -102,37 +109,16 @@ export default async function authRoutes(app: FastifyInstance) {
const { rows: [taken] } = await pool.query('SELECT id FROM users WHERE username = $1', [username]);
if (taken) username = base + Math.floor(Math.random() * 9000 + 1000);
const hash = await bcrypt.hash(password, 10);
const randomHash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), 4);
const color = COLORS[Math.floor(Math.random() * COLORS.length)];
const { rows: [user] } = await pool.query(
const { rows: [newUser] } = await pool.query(
`INSERT INTO users (username, display_name, password_hash, phone, avatar_color)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[username, displayName, hash, phone, color]
[username, displayName.trim(), randomHash, phone, color]
);
const token = app.jwt.sign({ id: user.id, isAdmin: false }, { expiresIn: '30d' });
return reply.status(201).send({ token, user: userDto(user) });
});
app.post('/login', async (req, reply) => {
const { username, phone, password } = req.body as { username?: string; phone?: string; password: string };
const login = phone || username || '';
const { rows: [user] } = await pool.query(
`SELECT * FROM users WHERE is_active = TRUE AND (
(phone != '' AND phone = $1) OR username = $1
)`, [login]
);
if (!user) return reply.status(401).send({ error: 'Неверный логин или пароль' });
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return reply.status(401).send({ error: 'Неверный логин или пароль' });
await pool.query('UPDATE users SET last_seen = NOW() WHERE id = $1', [user.id]);
const token = app.jwt.sign({ id: user.id, isAdmin: user.is_admin }, { expiresIn: '30d' });
return { token, user: userDto(user) };
const token = app.jwt.sign({ id: newUser.id, isAdmin: false }, { expiresIn: '30d' });
return reply.status(201).send({ token, user: userDto(newUser) });
});
app.get('/me', { preHandler: [app.authenticate] }, async (req) => {
@@ -145,15 +131,7 @@ export default async function authRoutes(app: FastifyInstance) {
app.put('/me', { preHandler: [app.authenticate] }, async (req, reply) => {
const { id } = req.user as { id: string };
const { displayName, bio, phone, position, password, newPassword, username } = req.body as any;
if (newPassword) {
const { rows: [user] } = await pool.query('SELECT password_hash FROM users WHERE id = $1', [id]);
const ok = await bcrypt.compare(password, user.password_hash);
if (!ok) return reply.status(400).send({ error: 'Неверный текущий пароль' });
const hash = await bcrypt.hash(newPassword, 10);
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, id]);
}
const { displayName, bio, phone, position, username } = req.body as any;
if (username) {
if (!/^[a-z0-9_]{3,30}$/.test(username)) {
@@ -173,7 +151,7 @@ export default async function authRoutes(app: FastifyInstance) {
position = COALESCE($4, position),
username = COALESCE($5, username)
WHERE id = $6`,
[displayName, bio, phone, position, username || null, id]
[displayName || null, bio || null, phone || null, position || null, username || null, id]
);
const { rows: [user] } = await pool.query(