From b77f4b6336464ac7801f564bb3236a1c34c5cf00 Mon Sep 17 00:00:00 2001 From: byn9826 Date: Wed, 13 Nov 2024 20:57:42 -0500 Subject: [PATCH 1/3] save --- admin-panel/app/[lang]/account/page.tsx | 11 +- admin-panel/translations/en.json | 3 +- admin-panel/translations/fr.json | 3 +- .../__tests__/normal/identity-main.test.tsx | 8 + .../__tests__/normal/identity-mfa.test.tsx | 8 + .../__tests__/normal/identity-policy.test.tsx | 209 +++++++++++++++++- .../__tests__/normal/identity-social.test.tsx | 2 + server/src/__tests__/normal/oauth.test.tsx | 50 +++++ server/src/configs/adapter.ts | 1 + server/src/configs/locale.ts | 43 ++++ server/src/configs/route.ts | 2 + server/src/dtos/identity.ts | 25 +++ server/src/dtos/oauth.ts | 1 + server/src/handlers/identity/policy.tsx | 89 +++++++- server/src/handlers/oauth.ts | 3 + server/src/models/user.ts | 3 +- server/src/routes/identity.tsx | 18 ++ server/src/services/email.tsx | 28 +++ server/src/services/kv.ts | 34 +++ server/src/services/user.ts | 22 ++ .../src/templates/ChangeEmailVerification.tsx | 52 +++++ server/src/templates/index.ts | 4 +- server/src/utils/identity.ts | 15 +- server/src/views/AuthorizeReset.tsx | 2 +- server/src/views/ChangeEmail.tsx | 140 ++++++++++++ server/src/views/index.tsx | 2 + server/src/views/scripts/response.ts | 3 + 27 files changed, 769 insertions(+), 12 deletions(-) create mode 100644 server/src/templates/ChangeEmailVerification.tsx create mode 100644 server/src/views/ChangeEmail.tsx diff --git a/admin-panel/app/[lang]/account/page.tsx b/admin-panel/app/[lang]/account/page.tsx index 099281d6..a4e8ff69 100644 --- a/admin-panel/app/[lang]/account/page.tsx +++ b/admin-panel/app/[lang]/account/page.tsx @@ -17,11 +17,20 @@ const Page = () => { }) } + const handleChangeEmail = () => { + loginRedirect({ + locale: locale || undefined, policy: 'change_email', + }) + } + return ( -
+
+
) } diff --git a/admin-panel/translations/en.json b/admin-panel/translations/en.json index 54abc83d..177f3aa4 100644 --- a/admin-panel/translations/en.json +++ b/admin-panel/translations/en.json @@ -12,7 +12,8 @@ "account": "Account" }, "account": { - "changePassword": "Change Password" + "changePassword": "Change Password", + "changeEmail": "Change Email" }, "common": { "enable": "Enable", diff --git a/admin-panel/translations/fr.json b/admin-panel/translations/fr.json index 72002f09..cf908609 100644 --- a/admin-panel/translations/fr.json +++ b/admin-panel/translations/fr.json @@ -12,7 +12,8 @@ "account": "Compte" }, "account": { - "changePassword": "Changer le mot de passe" + "changePassword": "Changer le mot de passe", + "changeEmail": "Changer l'email" }, "common": { "enable": "Activer", diff --git a/server/src/__tests__/normal/identity-main.test.tsx b/server/src/__tests__/normal/identity-main.test.tsx index dd61cf27..d80acb45 100644 --- a/server/src/__tests__/normal/identity-main.test.tsx +++ b/server/src/__tests__/normal/identity-main.test.tsx @@ -341,6 +341,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) const { code } = json as { code: string } const codeStore = JSON.parse(await mockedKV.get(`AC-${code}`) ?? '') @@ -692,6 +693,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) const appRecord = await getApp(db) const { code } = json as { code: string } @@ -755,6 +757,7 @@ describe( requireOtpMfa: true, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) global.process.env.OTP_MFA_IS_REQUIRED = false as unknown as string }, @@ -778,6 +781,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) global.process.env.EMAIL_MFA_IS_REQUIRED = false as unknown as string }, @@ -801,6 +805,7 @@ describe( requireOtpMfa: false, requireSmsMfa: true, requireChangePassword: false, + requireChangeEmail: false, }) global.process.env.SMS_MFA_IS_REQUIRED = false as unknown as string }, @@ -824,6 +829,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) global.process.env.ENFORCE_ONE_MFA_ENROLLMENT = ['email', 'otp'] as unknown as string }, @@ -847,6 +853,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) global.process.env.ENABLE_USER_APP_CONSENT = true as unknown as string }, @@ -1014,6 +1021,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) const consent = db.prepare('SELECT * from user_app_consent WHERE "userId" = 1 AND "appId" = 1').get() expect(consent).toBeTruthy() diff --git a/server/src/__tests__/normal/identity-mfa.test.tsx b/server/src/__tests__/normal/identity-mfa.test.tsx index 5d40bb52..8d071e31 100644 --- a/server/src/__tests__/normal/identity-mfa.test.tsx +++ b/server/src/__tests__/normal/identity-mfa.test.tsx @@ -232,6 +232,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) const user = await db.prepare('SELECT * from "user" WHERE id = 1').get() as userModel.Raw @@ -324,6 +325,7 @@ describe( requireOtpMfa: true, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) const user = await db.prepare('SELECT * from "user" WHERE id = 1').get() as userModel.Raw @@ -547,6 +549,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) expect(await mockedKV.get(`${adapterConfig.BaseKVKey.OtpMfaCode}-${json.code}`)).toBe('1') }, @@ -1520,6 +1523,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) expect(await mockedKV.get(`${adapterConfig.BaseKVKey.SmsMfaCode}-${json.code}`)).toBe('1') @@ -1943,6 +1947,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) expect(await mockedKV.get(`${adapterConfig.BaseKVKey.EmailMfaCode}-${json.code}`)).toBe('1') }, @@ -2042,6 +2047,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) expect(await mockedKV.get(`${adapterConfig.BaseKVKey.OtpMfaCode}-${json.code}`)).toBe('1') }, @@ -2104,6 +2110,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) expect(await mockedKV.get(`${adapterConfig.BaseKVKey.SmsMfaCode}-${json.code}`)).toBe('1') }, @@ -2285,6 +2292,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) expect(await mockedKV.get(`${adapterConfig.BaseKVKey.EmailMfaCode}-${json.code}`)).toBe('1') }, diff --git a/server/src/__tests__/normal/identity-policy.test.tsx b/server/src/__tests__/normal/identity-policy.test.tsx index 763bce82..b093e4f1 100644 --- a/server/src/__tests__/normal/identity-policy.test.tsx +++ b/server/src/__tests__/normal/identity-policy.test.tsx @@ -8,7 +8,9 @@ import { migrate, mock, mockedKV, } from 'tests/mock' -import { routeConfig } from 'configs' +import { + adapterConfig, routeConfig, +} from 'configs' import { prepareFollowUpBody, prepareFollowUpParams, insertUsers, postSignInRequest, getApp, @@ -99,7 +101,7 @@ describe( ) describe( - 'post /authorize-consent', + 'post /change-password', () => { test( 'should change password', @@ -143,6 +145,7 @@ describe( requireOtpSetup: false, requireOtpMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) }, ) @@ -174,3 +177,205 @@ describe( ) }, ) + +describe( + 'get /change-email', + () => { + test( + 'should show change email page', + async () => { + await insertUsers( + db, + false, + ) + const params = await prepareFollowUpParams(db) + + const res = await app.request( + `${routeConfig.IdentityRoute.ChangeEmail}${params}`, + {}, + mock(db), + ) + + const html = await res.text() + const dom = new JSDOM(html) + const document = dom.window.document + expect(document.getElementsByName('email').length).toBe(1) + expect(document.getElementsByName('code').length).toBe(1) + expect(document.getElementsByTagName('form').length).toBe(1) + expect(document.getElementsByTagName('select').length).toBe(1) + }, + ) + + test( + 'should redirect if use wrong auth code', + async () => { + await insertUsers( + db, + false, + ) + await prepareFollowUpParams(db) + + const res = await app.request( + `${routeConfig.IdentityRoute.ChangeEmail}?locale=en&code=abc`, + {}, + mock(db), + ) + expect(res.status).toBe(302) + expect(res.headers.get('Location')).toBe(`${routeConfig.IdentityRoute.AuthCodeExpired}?locale=en`) + }, + ) + + test( + 'could disable locale selector', + async () => { + global.process.env.ENABLE_LOCALE_SELECTOR = false as unknown as string + await insertUsers( + db, + false, + ) + const params = await prepareFollowUpParams(db) + + const res = await app.request( + `${routeConfig.IdentityRoute.ChangeEmail}${params}`, + {}, + mock(db), + ) + + const html = await res.text() + const dom = new JSDOM(html) + const document = dom.window.document + expect(document.getElementsByTagName('select').length).toBe(0) + global.process.env.ENABLE_LOCALE_SELECTOR = true as unknown as string + }, + ) + }, +) + +describe( + 'post /change-email-code', + () => { + test( + 'could send code', + async () => { + await insertUsers( + db, + false, + ) + const body = await prepareFollowUpBody(db) + + const res = await app.request( + routeConfig.IdentityRoute.ChangeEmailCode, + { + method: 'POST', + body: JSON.stringify({ + code: body.code, + email: 'test_new@email.com', + locale: 'en', + }), + }, + mock(db), + ) + 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) + }, + ) + }, +) + +describe( + 'post /change-email', + () => { + test( + 'could send code', + async () => { + await insertUsers( + db, + false, + ) + const body = await prepareFollowUpBody(db) + + await app.request( + routeConfig.IdentityRoute.ChangeEmailCode, + { + method: 'POST', + body: JSON.stringify({ + code: body.code, + email: 'test_new@email.com', + locale: 'en', + }), + }, + mock(db), + ) + const verificationCode = await mockedKV.get(`${adapterConfig.BaseKVKey.ChangeEmailCode}-1-test_new@email.com`) + + const res = await app.request( + routeConfig.IdentityRoute.ChangeEmail, + { + method: 'POST', + body: JSON.stringify({ + code: body.code, + email: 'test_new@email.com', + locale: 'en', + verificationCode, + }), + }, + mock(db), + ) + + const resJson = await res.json() + expect(resJson).toStrictEqual({ success: true }) + + const appRecord = await getApp(db) + const reLoginRes = await postSignInRequest( + db, + appRecord, + { email: 'test_new@email.com' }, + ) + const loginResJson = await reLoginRes.json() as { code: string } + expect(loginResJson).toStrictEqual({ + code: expect.any(String), + redirectUri: 'http://localhost:3000/en/dashboard', + state: '123', + scopes: ['profile', 'openid', 'offline_access'], + requireConsent: true, + requireMfaEnroll: true, + requireEmailMfa: false, + requireSmsMfa: false, + requireOtpSetup: false, + requireOtpMfa: false, + requireChangePassword: false, + requireChangeEmail: false, + }) + }, + ) + + test( + 'should redirect if use wrong auth code', + async () => { + await insertUsers( + db, + false, + ) + await prepareFollowUpBody(db) + + const res = await app.request( + routeConfig.IdentityRoute.ChangeEmail, + { + method: 'POST', + body: JSON.stringify({ + locale: 'en', + code: 'abc', + email: 'test@email.com', + verificationCode: '12345678', + }), + }, + mock(db), + ) + expect(res.status).toBe(302) + expect(res.headers.get('Location')).toBe(`${routeConfig.IdentityRoute.AuthCodeExpired}?locale=en`) + }, + ) + }, +) diff --git a/server/src/__tests__/normal/identity-social.test.tsx b/server/src/__tests__/normal/identity-social.test.tsx index 7eda6974..967bd5ac 100644 --- a/server/src/__tests__/normal/identity-social.test.tsx +++ b/server/src/__tests__/normal/identity-social.test.tsx @@ -93,6 +93,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) } @@ -283,6 +284,7 @@ describe( requireOtpMfa: false, requireSmsMfa: false, requireChangePassword: false, + requireChangeEmail: false, }) } diff --git a/server/src/__tests__/normal/oauth.test.tsx b/server/src/__tests__/normal/oauth.test.tsx index 03e6a81f..0dfc430d 100644 --- a/server/src/__tests__/normal/oauth.test.tsx +++ b/server/src/__tests__/normal/oauth.test.tsx @@ -74,6 +74,29 @@ describe( }, ) + test( + 'could redirect to sign in for change email', + async () => { + global.process.env.ENFORCE_ONE_MFA_ENROLLMENT = [] as unknown as string + const appRecord = await getApp(db) + await insertUsers(db) + + const url = routeConfig.OauthRoute.Authorize + const res = await getSignInRequest( + db, + url, + appRecord, + '&policy=change_email', + ) + expect(res.status).toBe(302) + const path = res.headers.get('Location') + expect(path).toContain(`${routeConfig.IdentityRoute.AuthorizePassword}`) + expect(path).toContain('&policy=change_email') + + global.process.env.ENFORCE_ONE_MFA_ENROLLMENT = ['email', 'otp'] as unknown as string + }, + ) + test( 'should throw error if no enough params provided', async () => { @@ -220,6 +243,33 @@ describe( }, ) + test( + 'could redirect to change email through session', + async () => { + global.process.env.ENFORCE_ONE_MFA_ENROLLMENT = [] as unknown as string + const appRecord = await getApp(db) + await insertUsers(db) + await postSignInRequest( + db, + appRecord, + ) + + const url = routeConfig.OauthRoute.Authorize + const res = await getSignInRequest( + db, + url, + appRecord, + '&policy=change_email', + ) + expect(res.status).toBe(302) + const path = res.headers.get('Location') + expect(path).toContain(`${routeConfig.IdentityRoute.ChangeEmail}`) + expect(path).toContain('&code=') + + global.process.env.ENFORCE_ONE_MFA_ENROLLMENT = ['email', 'otp'] as unknown as string + }, + ) + test( 'could login through session and bypass mfa', async () => { diff --git a/server/src/configs/adapter.ts b/server/src/configs/adapter.ts index 62f158b2..5e79b0b3 100644 --- a/server/src/configs/adapter.ts +++ b/server/src/configs/adapter.ts @@ -30,6 +30,7 @@ export enum BaseKVKey { SmsMfaMessageAttempts = 'SMMA', EmailMfaEmailAttempts = 'EMEA', PasswordResetAttempts = 'PRA', + ChangeEmailCode = 'CEC', } export const getKVKey = ( diff --git a/server/src/configs/locale.ts b/server/src/configs/locale.ts index 3a85f110..9fd791ca 100644 --- a/server/src/configs/locale.ts +++ b/server/src/configs/locale.ts @@ -21,6 +21,7 @@ export enum Error { WrongCode = 'Invalid code', WrongMfaCode = 'Invalid MFA code', RequireDifferentPassword = 'New password same as old password', + RequireDifferentEmail = 'New email address same as old email address', MfaNotVerified = 'MFA code not verified', WrongCodeVerifier = 'Invalid code_verifier', WrongGrantType = 'Invalid grant_type', @@ -418,6 +419,33 @@ export const changePassword = Object.freeze({ }, }) +export const changeEmail = Object.freeze({ + title: { + en: 'Change your email', + fr: '', + }, + email: { + en: 'Email Address', + fr: '', + }, + confirm: { + en: 'Confirm', + fr: 'Confirmer', + }, + redirect: { + en: 'Redirect back', + fr: 'Rediriger en arrière', + }, + sendCode: { + en: 'Send Verification Code', + fr: '', + }, + code: { + en: 'Verification Code', + fr: '', + }, +}) + export const verifyEmail = Object.freeze({ title: { en: 'Verify your email', @@ -475,6 +503,21 @@ export const passwordResetEmail = Object.freeze({ }, }) +export const changeEmailVerificationEmail = Object.freeze({ + subject: { + en: 'Verify your email', + fr: '', + }, + title: { + en: 'Verify your email', + fr: '', + }, + desc: { + en: 'Here is your verification code, this code will be expired after 2 hour', + fr: '', + }, +}) + export const emailMfaEmail = Object.freeze({ subject: { en: 'Account verification code', diff --git a/server/src/configs/route.ts b/server/src/configs/route.ts index f4faa137..ff5af186 100644 --- a/server/src/configs/route.ts +++ b/server/src/configs/route.ts @@ -38,4 +38,6 @@ export enum IdentityRoute { VerifyEmail = `${InternalRoute.Identity}/verify-email`, Logout = `${InternalRoute.Identity}/logout`, ChangePassword = `${InternalRoute.Identity}/change-password`, + ChangeEmail = `${InternalRoute.Identity}/change-email`, + ChangeEmailCode = `${InternalRoute.Identity}/change-email-code`, } diff --git a/server/src/dtos/identity.ts b/server/src/dtos/identity.ts index 1a2b8cb8..4e2e2dce 100644 --- a/server/src/dtos/identity.ts +++ b/server/src/dtos/identity.ts @@ -156,6 +156,31 @@ export class PostChangePasswordReqDto extends GetAuthorizeFollowUpReqDto { } } +export class PostChangeEmailCodeReqDto extends GetAuthorizeFollowUpReqDto { + @IsEmail() + @IsNotEmpty() + email: string + + constructor (dto: PostChangeEmailCodeReqDto) { + super(dto) + this.email = dto.email.trim().toLowerCase() + } +} + +export class PostChangeEmailReqDto extends PostChangeEmailCodeReqDto { + @IsString() + @Length( + 8, + 8, + ) + verificationCode: string + + constructor (dto: PostChangeEmailReqDto) { + super(dto) + this.verificationCode = dto.verificationCode.trim() + } +} + export class PostLogoutReqDto { @IsString() @IsNotEmpty() diff --git a/server/src/dtos/oauth.ts b/server/src/dtos/oauth.ts index 5526ba6d..b4a8d64d 100644 --- a/server/src/dtos/oauth.ts +++ b/server/src/dtos/oauth.ts @@ -22,6 +22,7 @@ export enum TokenGrantType { export enum Policy { SignInOrSignUp = 'sign_in_or_sign_up', ChangePassword = 'change_password', + ChangeEmail = 'change_email', } const parseScopes = (scopes: string[]) => scopes.map((s) => s.trim().toLowerCase()) diff --git a/server/src/handlers/identity/policy.tsx b/server/src/handlers/identity/policy.tsx index 3727ab68..5ea4aebd 100644 --- a/server/src/handlers/identity/policy.tsx +++ b/server/src/handlers/identity/policy.tsx @@ -1,14 +1,19 @@ import { Context } from 'hono' import { env } from 'hono/adapter' import { + errorConfig, + localeConfig, routeConfig, typeConfig, } from 'configs' import { identityDto } from 'dtos' import { + emailService, kvService, userService, } from 'services' import { validateUtil } from 'utils' -import { ChangePassword } from 'views' +import { + ChangeEmail, ChangePassword, +} from 'views' export const getChangePassword = async (c: Context) => { const queryDto = await identityDto.parseGetAuthorizeFollowUpReq(c) @@ -53,3 +58,85 @@ export const postChangePassword = async (c: Context) => { return c.json({ success: true }) } + +export const getChangeEmail = async (c: Context) => { + const queryDto = await identityDto.parseGetAuthorizeFollowUpReq(c) + + const authInfo = await kvService.getAuthCodeBody( + c.env.KV, + queryDto.code, + ) + if (!authInfo) return c.redirect(`${routeConfig.IdentityRoute.AuthCodeExpired}?locale=${queryDto.locale}`) + + const { + COMPANY_LOGO_URL: logoUrl, + SUPPORTED_LOCALES: locales, + ENABLE_LOCALE_SELECTOR: enableLocaleSelector, + } = env(c) + + return c.html() +} + +export const postChangeEmail = async (c: Context) => { + const reqBody = await c.req.json() + + const bodyDto = new identityDto.PostChangeEmailReqDto(reqBody) + await validateUtil.dto(bodyDto) + + const authInfo = await kvService.getAuthCodeBody( + c.env.KV, + bodyDto.code, + ) + if (!authInfo) return c.redirect(`${routeConfig.IdentityRoute.AuthCodeExpired}?locale=${bodyDto.locale}`) + + const isCorrectCode = await kvService.verifyChangeEmailCode( + c.env.KV, + authInfo.user.id, + bodyDto.email, + bodyDto.verificationCode, + ) + + if (!isCorrectCode) throw new errorConfig.Forbidden(localeConfig.Error.WrongCode) + + await userService.changeUserEmail( + c, + authInfo.user, + bodyDto, + ) + + return c.json({ success: true }) +} + +export const postVerificationCode = async (c: Context) => { + const reqBody = await c.req.json() + + const bodyDto = new identityDto.PostChangeEmailCodeReqDto(reqBody) + await validateUtil.dto(bodyDto) + + const authInfo = await kvService.getAuthCodeBody( + c.env.KV, + bodyDto.code, + ) + if (!authInfo) return c.redirect(`${routeConfig.IdentityRoute.AuthCodeExpired}?locale=${queryDto.locale}`) + + const code = await emailService.sendChangeEmailVerificationCode( + c, + bodyDto.email, + bodyDto.locale, + ) + if (code) { + await kvService.storeChangeEmailCode( + c.env.KV, + authInfo.user.id, + bodyDto.email, + code, + ) + } + + return c.json({ success: true }) +} diff --git a/server/src/handlers/oauth.ts b/server/src/handlers/oauth.ts index 654df48c..125e440c 100644 --- a/server/src/handlers/oauth.ts +++ b/server/src/handlers/oauth.ts @@ -111,6 +111,9 @@ export const getAuthorize = async (c: Context) => { } else if (queryDto.policy === Policy.ChangePassword) { const url = `${routeConfig.IdentityRoute.ChangePassword}?state=${queryDto.state}&code=${authCode}&locale=${queryDto.locale}&redirect_uri=${queryDto.redirectUri}` return c.redirect(url) + } else if (queryDto.policy === Policy.ChangeEmail) { + const url = `${routeConfig.IdentityRoute.ChangeEmail}?state=${queryDto.state}&code=${authCode}&locale=${queryDto.locale}&redirect_uri=${queryDto.redirectUri}` + return c.redirect(url) } } diff --git a/server/src/models/user.ts b/server/src/models/user.ts index b15a1f94..83c266ad 100644 --- a/server/src/models/user.ts +++ b/server/src/models/user.ts @@ -93,6 +93,7 @@ export interface Create { } export interface Update { + email?: string; password?: string | null; otpSecret?: string; smsPhoneNumber?: string | null; @@ -301,7 +302,7 @@ export const update = async ( const updateKeys: (keyof Update)[] = [ 'password', 'firstName', 'lastName', 'deletedAt', 'updatedAt', 'isActive', 'emailVerified', 'loginCount', 'locale', 'otpSecret', 'mfaTypes', 'otpVerified', - 'smsPhoneNumber', 'smsPhoneNumberVerified', + 'smsPhoneNumber', 'smsPhoneNumberVerified', 'email', ] const stmt = dbUtil.d1UpdateQuery( db, diff --git a/server/src/routes/identity.tsx b/server/src/routes/identity.tsx index 8958a139..e343a862 100644 --- a/server/src/routes/identity.tsx +++ b/server/src/routes/identity.tsx @@ -203,3 +203,21 @@ identityRoutes.post( configMiddleware.enablePasswordReset, identityHandler.postChangePassword, ) + +identityRoutes.get( + routeConfig.IdentityRoute.ChangeEmail, + configMiddleware.enableEmailVerification, + identityHandler.getChangeEmail, +) + +identityRoutes.post( + routeConfig.IdentityRoute.ChangeEmailCode, + configMiddleware.enableEmailVerification, + identityHandler.postVerificationCode, +) + +identityRoutes.post( + routeConfig.IdentityRoute.ChangeEmail, + configMiddleware.enableEmailVerification, + identityHandler.postChangeEmail, +) diff --git a/server/src/services/email.tsx b/server/src/services/email.tsx index 438e6d51..fafa6aeb 100644 --- a/server/src/services/email.tsx +++ b/server/src/services/email.tsx @@ -10,6 +10,7 @@ import { } from 'models' import { EmailVerificationTemplate, PasswordResetTemplate, EmailMfaTemplate, + ChangeEmailVerificationTemplate, } from 'templates' import { cryptoUtil } from 'utils' @@ -253,6 +254,33 @@ export const sendPasswordReset = async ( return res ? resetCode : null } +export const sendChangeEmailVerificationCode = async ( + c: Context, + email: string, + locale: typeConfig.Locale, +) => { + const { COMPANY_LOGO_URL: logoUrl } = env(c) + + if (!email) return null + checkEmailSetup(c) + + const verificationCode = cryptoUtil.genRandom8DigitString() + const content = ().toString() + + const res = await sendEmail( + c, + email, + localeConfig.changeEmailVerificationEmail.subject[locale], + content, + ) + + return res ? verificationCode : null +} + export const sendEmailMfa = async ( c: Context, user: userModel.Record, diff --git a/server/src/services/kv.ts b/server/src/services/kv.ts index 4f520581..f1d5273d 100644 --- a/server/src/services/kv.ts +++ b/server/src/services/kv.ts @@ -569,3 +569,37 @@ export const deleteLockedIPsByEmail = async ( await kv.delete(key.name) } } + +export const storeChangeEmailCode = async ( + kv: KVNamespace, + userId: number, + email: string, + code: string, +) => { + await kv.put( + adapterConfig.getKVKey( + adapterConfig.BaseKVKey.ChangeEmailCode, + String(userId), + email, + ), + code, + { expirationTtl: 7200 }, + ) +} + +export const verifyChangeEmailCode = async ( + kv: KVNamespace, + userId: number, + email: string, + code: string, +) => { + const key = adapterConfig.getKVKey( + adapterConfig.BaseKVKey.ChangeEmailCode, + String(userId), + email, + ) + const storedCode = await kv.get(key) + const isValid = storedCode && storedCode === code + if (isValid) await kv.delete(key) + return isValid +} diff --git a/server/src/services/user.ts b/server/src/services/user.ts index 55a3807d..66408aae 100644 --- a/server/src/services/user.ts +++ b/server/src/services/user.ts @@ -439,6 +439,28 @@ export const changeUserPassword = async ( return true } +export const changeUserEmail = async ( + c: Context, + 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) + } + + await userModel.update( + c.env.DB, + user.id, + { email: bodyDto.email }, + ) + return true +} + export const enrollUserMfa = async ( c: Context, authId: string, diff --git a/server/src/templates/ChangeEmailVerification.tsx b/server/src/templates/ChangeEmailVerification.tsx new file mode 100644 index 00000000..5ed573d8 --- /dev/null +++ b/server/src/templates/ChangeEmailVerification.tsx @@ -0,0 +1,52 @@ +import { + localeConfig, typeConfig, +} from 'configs' +import Layout from 'templates/components/Layout' + +const ChangeEmailVerification = ({ + logoUrl, verificationCode, locale, +}: { + logoUrl: string; + verificationCode: string; + locale: typeConfig.Locale; +}) => { + return ( + + + + + +
+ + + + + + + +
+

+ {localeConfig.changeEmailVerificationEmail.title[locale]} +

+
+

+ {localeConfig.changeEmailVerificationEmail.desc[locale]}:  + {verificationCode} +

+
+
+
+ ) +} + +export default ChangeEmailVerification diff --git a/server/src/templates/index.ts b/server/src/templates/index.ts index 3385a072..7355860d 100644 --- a/server/src/templates/index.ts +++ b/server/src/templates/index.ts @@ -1,7 +1,9 @@ import EmailVerificationTemplate from 'templates/EmailVerification' +import ChangeEmailVerificationTemplate from 'templates/ChangeEmailVerification' import PasswordResetTemplate from 'templates/PasswordReset' import EmailMfaTemplate from 'templates/EmailMfa' export { - EmailVerificationTemplate, PasswordResetTemplate, EmailMfaTemplate, + EmailVerificationTemplate, ChangeEmailVerificationTemplate, + PasswordResetTemplate, EmailMfaTemplate, } diff --git a/server/src/utils/identity.ts b/server/src/utils/identity.ts index a9a42f44..089c9a2d 100644 --- a/server/src/utils/identity.ts +++ b/server/src/utils/identity.ts @@ -18,6 +18,7 @@ export enum AuthorizeStep { SmsMfa = 4, EmailMfa = 5, ChangePassword = 6, + ChangeEmail = 6, } export const processPostAuthorize = async ( @@ -33,7 +34,6 @@ export const processPostAuthorize = async ( ) const isSocialLogin = !!authCodeBody.user.socialAccountId - const isChangePasswordPolicy = authCodeBody.request.policy === Policy.ChangePassword const { EMAIL_MFA_IS_REQUIRED: enableEmailMfa, @@ -41,6 +41,7 @@ export const processPostAuthorize = async ( SMS_MFA_IS_REQUIRED: enableSmsMfa, ENFORCE_ONE_MFA_ENROLLMENT: enforceMfa, ENABLE_PASSWORD_RESET: enablePasswordReset, + ENABLE_EMAIL_VERIFICATION: enableEmailVerification, } = env(c) const requireMfaEnroll = @@ -72,10 +73,17 @@ export const processPostAuthorize = async ( step < 6 && !isSocialLogin && enablePasswordReset && - isChangePasswordPolicy + authCodeBody.request.policy === Policy.ChangePassword + + const requireChangeEmail = + step < 6 && + !isSocialLogin && + enableEmailVerification && + authCodeBody.request.policy === Policy.ChangeEmail if ( - !isChangePasswordPolicy && !requireConsent && !requireMfaEnroll && + !requireChangePassword && !requireChangeEmail && + !requireConsent && !requireMfaEnroll && !requireOtpMfa && !requireEmailMfa && !requireSmsMfa ) { sessionService.setAuthInfoSession( @@ -99,5 +107,6 @@ export const processPostAuthorize = async ( requireOtpSetup, requireOtpMfa, requireChangePassword, + requireChangeEmail, } } diff --git a/server/src/views/AuthorizeReset.tsx b/server/src/views/AuthorizeReset.tsx index 9324e727..37361a16 100644 --- a/server/src/views/AuthorizeReset.tsx +++ b/server/src/views/AuthorizeReset.tsx @@ -166,7 +166,7 @@ const AuthorizeReset = ({ ${responseScript.handleSubmitError(queryDto.locale)} }); } else { - fetch('${routeConfig.IdentityRoute.AuthorizeReset}', { + fetch('${routeConfig.IdentityRoute.AuthorizeReset}', { method: 'POST', headers: { 'Accept': 'application/json', diff --git a/server/src/views/ChangeEmail.tsx b/server/src/views/ChangeEmail.tsx new file mode 100644 index 00000000..b79df43b --- /dev/null +++ b/server/src/views/ChangeEmail.tsx @@ -0,0 +1,140 @@ +import { html } from 'hono/html' +import SubmitError from './components/SubmitError' +import { + localeConfig, + routeConfig, + typeConfig, +} from 'configs' +import Layout from 'views/components/Layout' +import { + resetErrorScript, responseScript, validateScript, +} from 'views/scripts' +import Title from 'views/components/Title' +import Field from 'views/components/Field' +import { identityDto } from 'dtos' +import SubmitButton from 'views/components/SubmitButton' + +const ChangeEmail = ({ + logoUrl, queryDto, locales, redirectUri, +}: { + logoUrl: string; + queryDto: identityDto.GetAuthorizeFollowUpReqDto; + locales: typeConfig.Locale[]; + redirectUri: string; +}) => { + return ( + + +
+ + <SubmitError /> + <form + onsubmit='return handleSubmit(event)' + > + <section class='flex-col gap-4'> + <Field + label={localeConfig.changeEmail.email[queryDto.locale]} + type='email' + required + name='email' + /> + <Field + label={localeConfig.changeEmail.code[queryDto.locale]} + type='text' + required + name='code' + className='hidden' + /> + <SubmitButton + title={localeConfig.changeEmail.sendCode[queryDto.locale]} + /> + </section> + </form> + </section> + {html` + <script> + ${resetErrorScript.resetEmailError()} + ${resetErrorScript.resetCodeError()} + function handleSubmit (e) { + e.preventDefault(); + ${validateScript.email(queryDto.locale)} + var containCode = !document.getElementById('code-row').classList.contains('hidden') + if (containCode) { + ${validateScript.verificationCode(queryDto.locale)} + } + e.preventDefault(); + if (!containCode) { + fetch('${routeConfig.IdentityRoute.ChangeEmailCode}', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + locale: "${queryDto.locale}", + email: document.getElementById('form-email').value, + code: "${queryDto.code}", + }) + }) + .then((response) => { + ${responseScript.parseRes()} + }) + .then((data) => { + document.getElementById('code-row').classList.remove('hidden'); + document.getElementById('submit-button').innerHTML = '${localeConfig.changeEmail.confirm[queryDto.locale]}' + }) + .catch((error) => { + ${responseScript.handleSubmitError(queryDto.locale)} + }); + } else { + fetch('${routeConfig.IdentityRoute.ChangeEmail}', { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + email: document.getElementById('form-email').value, + verificationCode: document.getElementById('form-code').value, + code: "${queryDto.code}", + locale: "${queryDto.locale}", + }) + }) + .then((response) => { + ${responseScript.parseRes()} + }) + .then((data) => { + document.getElementById('submit-form').classList.add('hidden'); + document.getElementById('success-message').classList.remove('hidden'); + }) + .catch((error) => { + ${responseScript.handleSubmitError(queryDto.locale)} + }); + } + return false; + } + </script> + `} + </Layout> + ) +} + +export default ChangeEmail diff --git a/server/src/views/index.tsx b/server/src/views/index.tsx index 55afb7cb..7e2dd7b2 100644 --- a/server/src/views/index.tsx +++ b/server/src/views/index.tsx @@ -9,6 +9,7 @@ import AuthorizeMfaEnrollView from 'views/AuthorizeMfaEnroll' import VerifyEmailView from 'views/VerifyEmail' import AuthCodeExpired from 'views/AuthCodeExpired' import ChangePassword from 'views/ChangePassword' +import ChangeEmail from 'views/ChangeEmail' export { AuthorizeAccountView, @@ -22,4 +23,5 @@ export { AuthorizeMfaEnrollView, AuthCodeExpired, ChangePassword, + ChangeEmail, } diff --git a/server/src/views/scripts/response.ts b/server/src/views/scripts/response.ts index f782b09d..c08e6152 100644 --- a/server/src/views/scripts/response.ts +++ b/server/src/views/scripts/response.ts @@ -37,6 +37,9 @@ export const handleAuthorizeFormRedirect = (locale: typeConfig.Locale) => html` } else if (data.requireChangePassword) { var url = "${routeConfig.IdentityRoute.ChangePassword}" + innerQueryString window.location.href = url; + } else if (data.requireChangeEmail) { + var url = "${routeConfig.IdentityRoute.ChangeEmail}" + innerQueryString + window.location.href = url; } else { var url = data.redirectUri + queryString; window.location.href = url; From 63740b9d5455d1d97cc8c8f6a89de73c9c09ff70 Mon Sep 17 00:00:00 2001 From: byn9826 <byn9826@gmail.com> Date: Wed, 13 Nov 2024 21:01:00 -0500 Subject: [PATCH 2/3] add translation --- server/src/configs/locale.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/server/src/configs/locale.ts b/server/src/configs/locale.ts index 9fd791ca..09af453d 100644 --- a/server/src/configs/locale.ts +++ b/server/src/configs/locale.ts @@ -422,11 +422,11 @@ export const changePassword = Object.freeze({ export const changeEmail = Object.freeze({ title: { en: 'Change your email', - fr: '', + fr: 'Changer votre adresse e-mail', }, email: { en: 'Email Address', - fr: '', + fr: 'Adresse e-mail', }, confirm: { en: 'Confirm', @@ -434,15 +434,15 @@ export const changeEmail = Object.freeze({ }, redirect: { en: 'Redirect back', - fr: 'Rediriger en arrière', + fr: 'Retourner en arrière', }, sendCode: { en: 'Send Verification Code', - fr: '', + fr: 'Envoyer le code de vérification', }, code: { en: 'Verification Code', - fr: '', + fr: 'Code de vérification', }, }) @@ -506,15 +506,15 @@ export const passwordResetEmail = Object.freeze({ export const changeEmailVerificationEmail = Object.freeze({ subject: { en: 'Verify your email', - fr: '', + fr: 'Vérifiez votre adresse e-mail', }, title: { en: 'Verify your email', - fr: '', + fr: 'Vérifiez votre adresse e-mail', }, desc: { - en: 'Here is your verification code, this code will be expired after 2 hour', - fr: '', + en: 'Here is your verification code, this code will be expired after 2 hours', + fr: 'Voici votre code de vérification, ce code expirera après 2 heures', }, }) From 45ee3db845f4a581b13fc593f010b3c1543d63d6 Mon Sep 17 00:00:00 2001 From: byn9826 <byn9826@gmail.com> Date: Wed, 13 Nov 2024 21:03:05 -0500 Subject: [PATCH 3/3] fix type --- server/src/handlers/identity/policy.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/handlers/identity/policy.tsx b/server/src/handlers/identity/policy.tsx index 5ea4aebd..3e0772b3 100644 --- a/server/src/handlers/identity/policy.tsx +++ b/server/src/handlers/identity/policy.tsx @@ -122,7 +122,7 @@ export const postVerificationCode = async (c: Context<typeConfig.Context>) => { c.env.KV, bodyDto.code, ) - if (!authInfo) return c.redirect(`${routeConfig.IdentityRoute.AuthCodeExpired}?locale=${queryDto.locale}`) + if (!authInfo) return c.redirect(`${routeConfig.IdentityRoute.AuthCodeExpired}?locale=${bodyDto.locale}`) const code = await emailService.sendChangeEmailVerificationCode( c,