diff --git a/README.md b/README.md index 463f4df..2c468e2 100644 --- a/README.md +++ b/README.md @@ -55,9 +55,10 @@ - OTP MFA - SMS MFA - MFA Enrollment -- Policy +- Policy [How to trigger a different policy](https://auth.valuemelody.com/q_a.html#how-to-trigger-a-different-policy) - sign_in_or_sign_up - change_password + - change_email - Mailer Option: - SendGrid - Mailgun @@ -74,6 +75,7 @@ - OTP MFA attempts - SMS MFA attempts - Email MFA attempts + - Change Email attempts - Logging: - Email Logs - SMS Logs diff --git a/admin-panel/app/[lang]/dashboard/page.tsx b/admin-panel/app/[lang]/dashboard/page.tsx index b61fd12..e0df291 100644 --- a/admin-panel/app/[lang]/dashboard/page.tsx +++ b/admin-panel/app/[lang]/dashboard/page.tsx @@ -223,6 +223,12 @@ const Page = () => { {configs.EMAIL_MFA_EMAIL_THRESHOLD} + + CHANGE_EMAIL_EMAIL_THRESHOLD + + {configs.CHANGE_EMAIL_EMAIL_THRESHOLD} + + ENFORCE_ONE_MFA_ENROLLMENT diff --git a/docs/auth-server.md b/docs/auth-server.md index 43ee4ff..a764ccf 100644 --- a/docs/auth-server.md +++ b/docs/auth-server.md @@ -319,6 +319,10 @@ npm run prod:deploy - **Default:** 10 - **Description:** Maximum number of Email MFA email requests allowed per 30 minutes for a single account based on ip address. 0 means no restriction. +### CHANGE_EMAIL_EMAIL_THRESHOLD +- **Default:** 5 +- **Description:** Maximum number of Change Email verification code requests allowed per 30 minutes for a single account. 0 means no restriction. + ### ENFORCE_ONE_MFA_ENROLLMENT - **Default:** ['otp', 'email'] - **Description:** Enforce one MFA type from the list. Available options are ‘email’, ‘otp’, and ‘sms’. This setting is only effective if OTP_MFA_IS_REQUIRED, SMS_MFA_IS_REQUIRED, and EMAIL_MFA_IS_REQUIRED are all set to false. An empty list means no MFA type will be enforced. You must enable email functionality for the email MFA option to work. diff --git a/server/src/__tests__/normal/identity-policy.test.tsx b/server/src/__tests__/normal/identity-policy.test.tsx index b093e4f..2d26b89 100644 --- a/server/src/__tests__/normal/identity-policy.test.tsx +++ b/server/src/__tests__/normal/identity-policy.test.tsx @@ -3,18 +3,23 @@ import { } from 'vitest' import { Database } from 'better-sqlite3' import { JSDOM } from 'jsdom' +import { Context } from 'hono' import app from 'index' import { migrate, mock, mockedKV, } from 'tests/mock' import { - adapterConfig, routeConfig, + adapterConfig, localeConfig, routeConfig, + typeConfig, } from 'configs' import { prepareFollowUpBody, prepareFollowUpParams, insertUsers, postSignInRequest, getApp, + postAuthorizeBody, } from 'tests/identity' +import { jwtService } from 'services' +import { cryptoUtil } from 'utils' let db: Database @@ -254,6 +259,21 @@ describe( describe( 'post /change-email-code', () => { + const sendEmailCode = async (code: string) => { + return app.request( + routeConfig.IdentityRoute.ChangeEmailCode, + { + method: 'POST', + body: JSON.stringify({ + code, + email: 'test_new@email.com', + locale: 'en', + }), + }, + mock(db), + ) + } + test( 'could send code', async () => { @@ -263,22 +283,86 @@ describe( ) const body = await prepareFollowUpBody(db) - const res = await app.request( - routeConfig.IdentityRoute.ChangeEmailCode, + const res = await sendEmailCode(body.code) + const json = await res.json() + expect(json).toStrictEqual({ success: true }) + + expect((await mockedKV.get(`${adapterConfig.BaseKVKey.ChangeEmailCode}-1-test_new@email.com`) ?? '').length).toBe(8) + }, + ) + + test( + 'should stop after reach threshold', + async () => { + global.process.env.CHANGE_EMAIL_EMAIL_THRESHOLD = 2 as unknown as string + + await insertUsers( + db, + false, + ) + const body = await prepareFollowUpBody(db) + + const res = await sendEmailCode(body.code) + const json = await res.json() + expect(json).toStrictEqual({ success: true }) + expect(await mockedKV.get(`${adapterConfig.BaseKVKey.ChangeEmailAttempts}-test@email.com`)).toBe('1') + + const res1 = await sendEmailCode(body.code) + const json1 = await res1.json() + expect(json1).toStrictEqual({ success: true }) + expect(await mockedKV.get(`${adapterConfig.BaseKVKey.ChangeEmailAttempts}-test@email.com`)).toBe('2') + + const res2 = await sendEmailCode(body.code) + expect(res2.status).toBe(400) + + global.process.env.CHANGE_EMAIL_EMAIL_THRESHOLD = 0 as unknown as string + const res3 = await sendEmailCode(body.code) + expect(res3.status).toBe(200) + const json3 = await res3.json() + expect(json3).toStrictEqual({ success: true }) + + global.process.env.CHANGE_EMAIL_EMAIL_THRESHOLD = 5 as unknown as string + }, + ) + + test( + 'should throw error for social account', + async () => { + global.process.env.GOOGLE_AUTH_CLIENT_ID = '123' + const publicKey = await mockedKV.get(adapterConfig.BaseKVKey.JwtPublicSecret) + const jwk = await cryptoUtil.secretToJwk(publicKey ?? '') + const c = { env: { KV: mockedKV } } as unknown as Context + const credential = await jwtService.signWithKid( + c, + { + iss: 'https://accounts.google.com', + email: 'test@gmail.com', + sub: 'gid123', + email_verified: true, + given_name: 'first', + family_name: 'last', + kid: jwk.kid, + }, + ) + + const appRecord = await getApp(db) + const tokenRes = await app.request( + routeConfig.IdentityRoute.AuthorizeGoogle, { method: 'POST', body: JSON.stringify({ - code: body.code, - email: 'test_new@email.com', - locale: 'en', + ...(await postAuthorizeBody(appRecord)), + credential, }), }, mock(db), ) - const json = await res.json() - expect(json).toStrictEqual({ success: true }) + const tokenJson = await tokenRes.json() as { code: string } - expect((await mockedKV.get(`${adapterConfig.BaseKVKey.ChangeEmailCode}-1-test_new@email.com`) ?? '').length).toBe(8) + const res = await sendEmailCode(tokenJson.code) + expect(res.status).toBe(400) + expect(await res.text()).toBe(localeConfig.Error.SocialAccountNotSupported) + global.process.env.GOOGLE_AUTH_CLIENT_ID = '' }, ) }, diff --git a/server/src/__tests__/normal/other.test.tsx b/server/src/__tests__/normal/other.test.tsx index 84f5e97..afb8c5e 100644 --- a/server/src/__tests__/normal/other.test.tsx +++ b/server/src/__tests__/normal/other.test.tsx @@ -61,6 +61,7 @@ describe( ENABLE_EMAIL_VERIFICATION: true, EMAIL_MFA_IS_REQUIRED: false, EMAIL_MFA_EMAIL_THRESHOLD: 10, + CHANGE_EMAIL_EMAIL_THRESHOLD: 5, OTP_MFA_IS_REQUIRED: false, SMS_MFA_IS_REQUIRED: false, SMS_MFA_MESSAGE_THRESHOLD: 5, diff --git a/server/src/configs/adapter.ts b/server/src/configs/adapter.ts index 5e79b0b..2cbd855 100644 --- a/server/src/configs/adapter.ts +++ b/server/src/configs/adapter.ts @@ -31,6 +31,7 @@ export enum BaseKVKey { EmailMfaEmailAttempts = 'EMEA', PasswordResetAttempts = 'PRA', ChangeEmailCode = 'CEC', + ChangeEmailAttempts = 'CEA', } export const getKVKey = ( diff --git a/server/src/configs/locale.ts b/server/src/configs/locale.ts index 09af453..8e09df7 100644 --- a/server/src/configs/locale.ts +++ b/server/src/configs/locale.ts @@ -7,11 +7,13 @@ export enum Error { EmailTaken = 'The email address is already in use.', WrongRedirectUri = 'Invalid redirect_uri', NoUser = 'No user found', + SocialAccountNotSupported = 'This function is unavailable for social login accounts.', AccountLocked = 'Account temporarily locked due to excessive login failures', OtpMfaLocked = 'Too many failed OTP verification attempts. Please try again after 30 minutes.', SmsMfaLocked = 'Too many SMS verification attempts. Please try again after 30 minutes.', EmailMfaLocked = 'Too many Email verification attempts. Please try again after 30 minutes.', PasswordResetLocked = 'Too many password reset email requests. Please try again tomorrow.', + ChangeEmailLocked = 'Too many password change email requests. Please try again after 30 minutes.', UserDisabled = 'This account has been disabled', EmailAlreadyVerified = 'Email already verified', OtpAlreadySet = 'OTP authentication already set', @@ -444,6 +446,14 @@ export const changeEmail = Object.freeze({ en: 'Verification Code', fr: 'Code de vérification', }, + resend: { + en: 'Resend a new code', + fr: 'Renvoyer un nouveau code', + }, + resent: { + en: 'New code sent.', + fr: 'Nouveau code envoyé.', + }, }) export const verifyEmail = Object.freeze({ diff --git a/server/src/configs/type.ts b/server/src/configs/type.ts index 17edea4..8cbe5a2 100644 --- a/server/src/configs/type.ts +++ b/server/src/configs/type.ts @@ -48,6 +48,7 @@ export type Bindings = { ENABLE_EMAIL_VERIFICATION: boolean; EMAIL_MFA_IS_REQUIRED: boolean; EMAIL_MFA_EMAIL_THRESHOLD: number; + CHANGE_EMAIL_EMAIL_THRESHOLD: number; OTP_MFA_IS_REQUIRED: boolean; GOOGLE_AUTH_CLIENT_ID: string; ENFORCE_ONE_MFA_ENROLLMENT: userModel.MfaType[]; diff --git a/server/src/handlers/identity/other.tsx b/server/src/handlers/identity/other.tsx index 55c27f7..40590f7 100644 --- a/server/src/handlers/identity/other.tsx +++ b/server/src/handlers/identity/other.tsx @@ -90,14 +90,14 @@ export const postResetCode = async (c: Context) => { if (!email) throw new errorConfig.Forbidden() const ip = requestUtil.getRequestIP(c) - const resetAttempts = await kvService.getPasswordResetAttemptsByIP( - c.env.KV, - email, - ip, - ) const { PASSWORD_RESET_EMAIL_THRESHOLD: resetThreshold } = env(c) if (resetThreshold) { + const resetAttempts = await kvService.getPasswordResetAttemptsByIP( + c.env.KV, + email, + ip, + ) if (resetAttempts >= resetThreshold) throw new errorConfig.Forbidden(localeConfig.Error.PasswordResetLocked) await kvService.setPasswordResetAttemptsByIP( diff --git a/server/src/handlers/identity/policy.tsx b/server/src/handlers/identity/policy.tsx index 3e0772b..2632f6e 100644 --- a/server/src/handlers/identity/policy.tsx +++ b/server/src/handlers/identity/policy.tsx @@ -14,6 +14,13 @@ import { validateUtil } from 'utils' import { ChangeEmail, ChangePassword, } from 'views' +import { userModel } from 'models' + +const checkAccount = (user: userModel.Record) => { + if (!user.email || user.socialAccountId) { + throw new errorConfig.Forbidden(localeConfig.Error.SocialAccountNotSupported) + } +} export const getChangePassword = async (c: Context) => { const queryDto = await identityDto.parseGetAuthorizeFollowUpReq(c) @@ -23,6 +30,7 @@ export const getChangePassword = async (c: Context) => { queryDto.code, ) if (!authInfo) return c.redirect(`${routeConfig.IdentityRoute.AuthCodeExpired}?locale=${queryDto.locale}`) + checkAccount(authInfo.user) const { COMPANY_LOGO_URL: logoUrl, @@ -49,6 +57,7 @@ export const postChangePassword = async (c: Context) => { bodyDto.code, ) if (!authInfo) return c.redirect(`${routeConfig.IdentityRoute.AuthCodeExpired}?locale=${bodyDto.locale}`) + checkAccount(authInfo.user) await userService.changeUserPassword( c, @@ -67,6 +76,7 @@ export const getChangeEmail = async (c: Context) => { queryDto.code, ) if (!authInfo) return c.redirect(`${routeConfig.IdentityRoute.AuthCodeExpired}?locale=${queryDto.locale}`) + checkAccount(authInfo.user) const { COMPANY_LOGO_URL: logoUrl, @@ -93,6 +103,7 @@ export const postChangeEmail = async (c: Context) => { bodyDto.code, ) if (!authInfo) return c.redirect(`${routeConfig.IdentityRoute.AuthCodeExpired}?locale=${bodyDto.locale}`) + checkAccount(authInfo.user) const isCorrectCode = await kvService.verifyChangeEmailCode( c.env.KV, @@ -123,6 +134,24 @@ export const postVerificationCode = async (c: Context) => { bodyDto.code, ) if (!authInfo) return c.redirect(`${routeConfig.IdentityRoute.AuthCodeExpired}?locale=${bodyDto.locale}`) + checkAccount(authInfo.user) + + const { CHANGE_EMAIL_EMAIL_THRESHOLD: emailThreshold } = env(c) + + if (emailThreshold) { + const emailAttempts = await kvService.getChangeEmailAttempts( + c.env.KV, + authInfo.user.email ?? '', + ) + + if (emailAttempts >= emailThreshold) throw new errorConfig.Forbidden(localeConfig.Error.ChangeEmailLocked) + + await kvService.setChangeEmailAttempts( + c.env.KV, + authInfo.user.email ?? '', + emailAttempts + 1, + ) + } const code = await emailService.sendChangeEmailVerificationCode( c, diff --git a/server/src/handlers/other.ts b/server/src/handlers/other.ts index a066003..5565f7d 100644 --- a/server/src/handlers/other.ts +++ b/server/src/handlers/other.ts @@ -37,6 +37,7 @@ export const getSystemInfo = async (c: Context) => { ENABLE_EMAIL_VERIFICATION: environment.ENABLE_EMAIL_VERIFICATION, EMAIL_MFA_IS_REQUIRED: environment.EMAIL_MFA_IS_REQUIRED, EMAIL_MFA_EMAIL_THRESHOLD: environment.EMAIL_MFA_EMAIL_THRESHOLD, + CHANGE_EMAIL_EMAIL_THRESHOLD: environment.CHANGE_EMAIL_EMAIL_THRESHOLD, OTP_MFA_IS_REQUIRED: environment.OTP_MFA_IS_REQUIRED, SMS_MFA_IS_REQUIRED: environment.SMS_MFA_IS_REQUIRED, SMS_MFA_MESSAGE_THRESHOLD: environment.SMS_MFA_MESSAGE_THRESHOLD, diff --git a/server/src/services/kv.ts b/server/src/services/kv.ts index f1d5273..61a2809 100644 --- a/server/src/services/kv.ts +++ b/server/src/services/kv.ts @@ -603,3 +603,31 @@ export const verifyChangeEmailCode = async ( if (isValid) await kv.delete(key) return isValid } + +export const getChangeEmailAttempts = async ( + kv: KVNamespace, + email: string, +) => { + const key = adapterConfig.getKVKey( + adapterConfig.BaseKVKey.ChangeEmailAttempts, + email, + ) + const stored = await kv.get(key) + return stored ? Number(stored) : 0 +} + +export const setChangeEmailAttempts = async ( + kv: KVNamespace, + email: string, + count: number, +) => { + const key = adapterConfig.getKVKey( + adapterConfig.BaseKVKey.ChangeEmailAttempts, + email, + ) + await kv.put( + key, + String(count), + { expirationTtl: 1800 }, + ) +} diff --git a/server/src/services/user.ts b/server/src/services/user.ts index 66408aa..996ee23 100644 --- a/server/src/services/user.ts +++ b/server/src/services/user.ts @@ -444,10 +444,6 @@ export const changeUserEmail = async ( user: userModel.Record, bodyDto: identityDto.PostChangeEmailReqDto, ): Promise => { - if (!user.email || user.socialAccountId) { - throw new errorConfig.NotFound(localeConfig.Error.NoUser) - } - const isSame = user.email === bodyDto.email if (isSame) { throw new errorConfig.Forbidden(localeConfig.Error.RequireDifferentEmail) diff --git a/server/src/views/ChangeEmail.tsx b/server/src/views/ChangeEmail.tsx index b79df43..9742f71 100644 --- a/server/src/views/ChangeEmail.tsx +++ b/server/src/views/ChangeEmail.tsx @@ -63,6 +63,14 @@ const ChangeEmail = ({ name='code' className='hidden' /> + @@ -73,6 +81,34 @@ const ChangeEmail = ({