From eede789d5437ea2de0679ad7fe9280459ea41458 Mon Sep 17 00:00:00 2001 From: Nikolai Giman Date: Sat, 16 May 2026 14:56:27 +0200 Subject: [PATCH] fix: login by code --- api/src/tv-modules/auth/AuthController.ts | 52 ++++++++++++------- api/src/tv-modules/auth/mail/login-code-en.ts | 45 ++++++++++++++++ .../components/features/auth/LoginByCode.vue | 44 ++++++++++------ web/src/locales/de.ts | 1 + web/src/locales/en.ts | 1 + web/src/locales/ru.ts | 1 + 6 files changed, 109 insertions(+), 35 deletions(-) create mode 100644 api/src/tv-modules/auth/mail/login-code-en.ts diff --git a/api/src/tv-modules/auth/AuthController.ts b/api/src/tv-modules/auth/AuthController.ts index 0c34f7c..d5f2356 100644 --- a/api/src/tv-modules/auth/AuthController.ts +++ b/api/src/tv-modules/auth/AuthController.ts @@ -17,9 +17,12 @@ import { import { generateString, isEmail, time } from '../../utils/helpers'; import EnEmailTemplate from './mail/confirm-email-en'; import RuEmailTemplate from './mail/confirm-email-ru'; +import LoginCodeEmailTemplate from './mail/login-code-en'; import type { ExternalAuthUser } from './strategies/external-auth.types'; import { OrganizationRepository } from '../organizations/OrganizationRepository'; +const LOGIN_CODE_TTL_MS = 5 * 60 * 1000; + export default class AuthController { private readonly jwtAlg: Algorithm = process.env.JWT_ALG as Algorithm; private readonly jwtExp: string = process.env.ACCESS_LIFE_TIME!; @@ -104,7 +107,8 @@ export default class AuthController { } generateLoginCode() { - return `${this.makeidLogin(12)}:${Date.now()}`.toLocaleLowerCase(); + const code = String(randomInt(100000, 1000000)); + return `${code}:${Date.now()}`; } /** * Register user by email and send login code to the email @@ -165,26 +169,36 @@ export default class AuthController { const lastUpdate = userData.remember_token?.split(':')[1]; const now = Date.now(); + const RESEND_COOLDOWN_MS = 60 * 1000; - if (!lastUpdate || (lastUpdate && now - +lastUpdate > 60 * 1000)) { - $logger.info(`[AuthController:sendLoginCode] updating login code for user`); - - await req.appUser.authManager.repository.updateLoginCode(code, email); - - $logger.info(`[AuthController:sendLoginCode] sending code by email to`); - - await this.sendCodeByEmail(code.split(':')[0], email); + if (lastUpdate && now - +lastUpdate < RESEND_COOLDOWN_MS) { + return res.status(429).send({ + message: 'Please wait before requesting another code.', + retryAfter: Math.ceil((RESEND_COOLDOWN_MS - (now - +lastUpdate)) / 1000), + }); } + + $logger.info(`[AuthController:sendLoginCode] updating login code for user`); + await req.appUser.authManager.repository.updateLoginCode(code, email); + $logger.info(`[AuthController:sendLoginCode] sending code by email to`); + this.sendCodeByEmail(code.split(':')[0], email) + .then((ok) => { + if (!ok) $logger.error({ email }, 'Failed to send login code email'); + }) + .catch((err) => $logger.error({ err, email }, 'Failed to send login code email')); return res.status(200).end(); }; async sendCodeByEmail(code: string, email: string) { + const text = `Your TaskView verification code is ${code}\n\nUse this code to sign in. The code expires in 5 minutes.\n\nIf you didn't request this code, ignore this email.`; + const html = LoginCodeEmailTemplate.replace('{code}', code); + return await Email.send({ - text: null, + text, to: email, - subject: 'Code', + subject: `Your TaskView code: ${code}`, from: process.env.SMTP_FROM_EMAIL as string, - attachment: [{ data: `Code ${code}`, alternative: true }], + attachment: [{ data: html, alternative: true }], }); } @@ -295,8 +309,8 @@ export default class AuthController { loginByCode = async (req: Request, res: Response) => { const schema = z.object({ - email: z.string().email().toLowerCase(), - code: z.string().min(12).toLowerCase(), + email: z.string().trim().email().toLowerCase(), + code: z.string().trim().regex(/^\d{6}$/, '6-digit code'), }); const data = schema.safeParse(req.body); @@ -323,7 +337,7 @@ export default class AuthController { return res.status(400).send({ message: 'Invalid code' }); } - if (tokenFromDb[1] && Date.now() - +tokenFromDb[1] > 60 * 1000) { + if (tokenFromDb[1] && Date.now() - +tokenFromDb[1] > LOGIN_CODE_TTL_MS) { await req.appUser.authManager.repository.updateLoginCode(null, userData.email); return res.status(400).send({ message: 'Code expired, get new code' }); } @@ -538,9 +552,11 @@ export default class AuthController { subject: 'Remind password!', from: process.env.SMTP_FROM_EMAIL as string, attachment: [{ data: remindPasswordBody, alternative: true }], - }).catch((err) => { - $logger.error({ err, to: userData.email }, 'Failed to send remind password email'); - }); + }) + .then((ok) => { + if (!ok) $logger.error({ to: userData.email }, 'Failed to send remind password email'); + }) + .catch((err) => $logger.error({ err, to: userData.email }, 'Failed to send remind password email')); return respond(); }; diff --git a/api/src/tv-modules/auth/mail/login-code-en.ts b/api/src/tv-modules/auth/mail/login-code-en.ts new file mode 100644 index 0000000..7f3cc63 --- /dev/null +++ b/api/src/tv-modules/auth/mail/login-code-en.ts @@ -0,0 +1,45 @@ +export default ` + + + + + + TaskView verification code + + + + + + +
+ + + + + + + + + + + + + + + + +
+
TaskView
+
+

Your verification code

+
+

Use the code below to sign in. It expires in 5 minutes.

+
+
{code}
+
+

If you didn't request this code, you can safely ignore this email.

+
+

© TaskView

+
+ +` diff --git a/web/src/components/features/auth/LoginByCode.vue b/web/src/components/features/auth/LoginByCode.vue index 03e5f5a..c462240 100644 --- a/web/src/components/features/auth/LoginByCode.vue +++ b/web/src/components/features/auth/LoginByCode.vue @@ -29,6 +29,13 @@ placeholder="000000" icon="i-lucide-key-round" class="w-full text-center tracking-widest" + inputmode="numeric" + autocomplete="one-time-code" + autocapitalize="off" + autocorrect="off" + spellcheck="false" + maxlength="6" + pattern="[0-9]{6}" /> @@ -94,7 +101,7 @@ const showCodeField = ref(false) const isLoading = ref(false) const emailType = type('string.email').configure({ message: t('auth.invalidEmail') }) -const codeType = type('string > 0').configure({ message: t('auth.codeMustBe6') }) +const codeType = type('/^\\d{6}$/').configure({ message: t('auth.codeMustBe6') }) const EmailSchema = type({ email: emailType, @@ -130,8 +137,8 @@ async function handleSubmit() { try { if (showCodeField.value) { const result = await $api.post('/module/auth/login-by-code', { - code: state.code, - email: state.email, + code: state.code.trim(), + email: state.email.trim(), }) if (result.data.access) { @@ -149,12 +156,13 @@ async function handleSubmit() { await redirectToUser(router) } } else { - await $api.post('/module/auth/send-login-code', { email: state.email }) - await $ls.setValue('user-email', state.email) + const email = state.email.trim() + await $api.post('/module/auth/send-login-code', { email }) + await $ls.setValue('user-email', email) toast.add({ title: t('auth.codeSent'), - description: t('auth.checkInbox', { email: state.email }), + description: t('auth.checkInbox', { email }), color: 'success', }) @@ -162,16 +170,16 @@ async function handleSubmit() { } } catch (error: unknown) { const axiosError = error as { response?: { status?: number } } + const status = axiosError.response?.status if (showCodeField.value) { - toast.add({ - title: t('auth.error'), - description: axiosError.response?.status === 403 ? t('auth.invalidCode') : t('auth.loginFailed'), - color: 'error', - }) + let description = t('auth.loginFailed') + if (status === 429) description = t('auth.tooManyAttempts') + else if (status === 400 || status === 403) description = t('auth.invalidCode') + toast.add({ title: t('auth.error'), description, color: 'error' }) } else { toast.add({ title: t('auth.error'), - description: t('auth.failedToSendCode'), + description: status === 429 ? t('auth.tooManyAttempts') : t('auth.failedToSendCode'), color: 'error', }) } @@ -186,22 +194,24 @@ function goBack() { } async function resendCode() { - if (!state.email) return + const email = state.email.trim() + if (!email) return isLoading.value = true try { - await $api.post('/module/auth/send-login-code', { email: state.email }) + await $api.post('/module/auth/send-login-code', { email }) toast.add({ title: t('auth.codeResent'), - description: t('auth.newCodeSent', { email: state.email }), + description: t('auth.newCodeSent', { email }), color: 'success', }) - } catch { + } catch (error: unknown) { + const status = (error as { response?: { status?: number } }).response?.status toast.add({ title: t('auth.error'), - description: t('auth.failedToResendCode'), + description: status === 429 ? t('auth.tooManyAttempts') : t('auth.failedToResendCode'), color: 'error', }) } finally { diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index 08d030d..883fa23 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -71,6 +71,7 @@ export default { failedToSendCode: 'Code konnte nicht gesendet werden. Bitte versuchen Sie es erneut.', failedToResendCode: 'Code konnte nicht erneut gesendet werden', failedToSendResetLink: 'Reset-Link konnte nicht gesendet werden. Bitte versuchen Sie es erneut.', + tooManyAttempts: 'Zu viele fehlgeschlagene Versuche. Bitte warten Sie einige Minuten und versuchen Sie es erneut.', // SSO ssoSignIn: 'Mit SSO anmelden', diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 36db839..b33f643 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -71,6 +71,7 @@ export default { failedToSendCode: 'Failed to send code. Please try again.', failedToResendCode: 'Failed to resend code', failedToSendResetLink: 'Failed to send reset link. Please try again.', + tooManyAttempts: 'Too many failed attempts. Please wait a few minutes before trying again.', // SSO ssoSignIn: 'Sign in with SSO', diff --git a/web/src/locales/ru.ts b/web/src/locales/ru.ts index c127944..4175af7 100644 --- a/web/src/locales/ru.ts +++ b/web/src/locales/ru.ts @@ -71,6 +71,7 @@ export default { failedToSendCode: 'Не удалось отправить код. Попробуйте снова.', failedToResendCode: 'Не удалось отправить код повторно', failedToSendResetLink: 'Не удалось отправить ссылку. Попробуйте снова.', + tooManyAttempts: 'Слишком много неверных попыток. Подождите несколько минут и попробуйте снова.', // SSO ssoSignIn: 'Войти через SSO',