Skip to content

Commit

Permalink
Support enforce one mfa enrollment (#103)
Browse files Browse the repository at this point in the history
  • Loading branch information
byn9826 authored Aug 15, 2024
1 parent 41edb5a commit 8dbba51
Show file tree
Hide file tree
Showing 18 changed files with 268 additions and 22 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

## Features Supported
- OAuth 2.0 Support (Authorize, Token Exchange, Token Revoke, App Consent, App Scopes, RSA256 based JWT Authentication)
- User Authorization (Sign In, Sign Up, Sign Out, Email Verification, Password Reset, Email MFA, OTP MFA, Brute-force Protection, Role-Based Access Control, Localization) [Screenshots](https://auth.valuemelody.com/screenshots.html#identity-pages-and-emails)
- User Authorization (Sign In, Sign Up, Sign Out, Email Verification, Password Reset, Email MFA, OTP MFA, MFA Enrollment, Brute-force Protection, Role-Based Access Control, Localization) [Screenshots](https://auth.valuemelody.com/screenshots.html#identity-pages-and-emails)
- S2S REST API & Admin Panel (Manage Users, Manage Apps, Manage Scopes, Manage Roles) [Screenshots](https://auth.valuemelody.com/screenshots.html#admin-panel-pages)

## Why Melody Auth?
Expand Down
6 changes: 6 additions & 0 deletions admin-panel/app/[lang]/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,12 @@ const Page = () => {
<ConfigBooleanValue config={configs.EMAIL_MFA_IS_REQUIRED} />
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>ENFORCE_ONE_MFA_ENROLLMENT</Table.Cell>
<Table.Cell>
<ConfigBooleanValue config={configs.ENFORCE_ONE_MFA_ENROLLMENT} />
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>ACCOUNT_LOCKOUT_THRESHOLD</Table.Cell>
<Table.Cell>{configs.ACCOUNT_LOCKOUT_THRESHOLD}</Table.Cell>
Expand Down
10 changes: 9 additions & 1 deletion docs/auth-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,11 @@ npm run prod:deploy

### EMAIL_MFA_IS_REQUIRED
- **Default:** false
- **Description:** Controls email-based multi-factor authentication (MFA) for user sign-in. If set to true, users receive an MFA code via email to confirm their login. `SENDGRID_API_KEY` and `SENDGRID_SENDER_ADDRESS` environment variables first.)
- **Description:** Controls email-based multi-factor authentication (MFA) for user sign-in. If set to true, users receive an MFA code via email to confirm their login. (Email functionality required. To enable email functionality, you need to set valid `SENDGRID_API_KEY` and `SENDGRID_SENDER_ADDRESS` environment variables first.)

### ENFORCE_ONE_MFA_ENROLLMENT
- **Default:** true
- **Description:** This setting requires that users enroll in at least one form of Multi-Factor Authentication (MFA). This setting is only effective if both OTP_MFA_IS_REQUIRED and EMAIL_MFA_IS_REQUIRED are set to false. (Email functionality required. To enable email functionality, you need to set valid `SENDGRID_API_KEY` and `SENDGRID_SENDER_ADDRESS` environment variables first.)

### ACCOUNT_LOCKOUT_THRESHOLD
- **Default:** 5
Expand All @@ -199,6 +203,10 @@ npm run prod:deploy
- **Default:** 86400 (1 day)
- **Description:** Duration (in seconds) for which the account remains locked after reaching the lockout threshold. Set to 0 for indefinite lockout until manual intervention.

## UNLOCK_ACCOUNT_VIA_PASSWORD_RESET
- **Default:** true
- **Description:** User can unlock their account by reset password. (Email functionality required. To enable email functionality, you need to set valid `SENDGRID_API_KEY` and `SENDGRID_SENDER_ADDRESS` environment variables first.)

### SUPPORTED_LOCALES
- **Default:** ['en', 'fr']
- **Description:** Specifies the locales supported for identity pages and emails.
Expand Down
16 changes: 16 additions & 0 deletions server/src/configs/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export enum Error {
UserDisabled = 'This account has been disabled',
EmailAlreadyVerified = 'Email already verified',
OtpAlreadySet = 'OTP authentication already set',
MfaEnrolled = 'User already enrolled with MFA',
NoConsent = 'User consent required',
WrongCode = 'Invalid code',
WrongMfaCode = 'Invalid MFA code',
Expand Down Expand Up @@ -198,6 +199,21 @@ export const authorizeConsent = Object.freeze({
},
})

export const authorizeMfaEnroll = Object.freeze({
title: {
en: 'Select one of the MFA type',
fr: 'Sélectionnez un type de MFA',
},
email: {
en: 'Email',
fr: 'E-mail',
},
otp: {
en: 'Authenticator',
fr: 'Authentificateur',
},
})

export const authorizeEmailMfa = Object.freeze({
title: {
en: 'A verification code has been sent to your email.',
Expand Down
1 change: 1 addition & 0 deletions server/src/configs/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type Bindings = {
ENABLE_EMAIL_VERIFICATION: boolean;
EMAIL_MFA_IS_REQUIRED: boolean;
OTP_MFA_IS_REQUIRED: boolean;
ENFORCE_ONE_MFA_ENROLLMENT: boolean;
ACCOUNT_LOCKOUT_THRESHOLD: number;
ACCOUNT_LOCKOUT_EXPIRES_IN: number;
UNLOCK_ACCOUNT_VIA_PASSWORD_RESET: boolean;
Expand Down
14 changes: 13 additions & 1 deletion server/src/dtos/identity.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import {
IsEmail, IsNotEmpty, IsOptional, IsString, IsStrongPassword, Length,
IsEmail, IsEnum, IsNotEmpty, IsOptional, IsString, IsStrongPassword, Length,
} from 'class-validator'
import { Context } from 'hono'
import { typeConfig } from 'configs'
import { oauthDto } from 'dtos'
import {
formatUtil, validateUtil,
} from 'utils'
import { userModel } from 'models'

export class PostAuthorizeReqWithPasswordDto extends oauthDto.GetAuthorizeReqDto {
@IsEmail()
Expand Down Expand Up @@ -131,6 +132,17 @@ export class PostAuthorizeMfaReqDto extends GetAuthorizeFollowUpReqDto {
}
}

export class PostAuthorizeEnrollReqDto extends GetAuthorizeFollowUpReqDto {
@IsEnum(userModel.MfaType)
@IsNotEmpty()
type: userModel.MfaType

constructor (dto: PostAuthorizeEnrollReqDto) {
super(dto)
this.type = dto.type
}
}

export class PostLogoutReqDto {
@IsString()
@IsNotEmpty()
Expand Down
88 changes: 82 additions & 6 deletions server/src/handlers/identity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
AuthorizePasswordView, AuthorizeConsentView, AuthorizeAccountView,
VerifyEmailView, AuthorizeEmailMfaView,
AuthorizeResetView, AuthorizeOtpMfaView,
AuthorizeMfaEnrollView,
} from 'views'
import { AuthCodeBody } from 'configs/type'
import { userModel } from 'models'
Expand All @@ -25,8 +26,9 @@ enum AuthorizeStep {
Account = 0,
Password = 0,
Consent = 1,
OtpMfa = 2,
OtpEmail = 3,
MfaEnroll = 2,
OtpMfa = 3,
OtpEmail = 4,
}

const handlePostAuthorize = async (
Expand All @@ -46,13 +48,25 @@ const handlePostAuthorize = async (
EMAIL_MFA_IS_REQUIRED: enableEmailMfa,
OTP_MFA_IS_REQUIRED: enableOtpMfa,
AUTHORIZATION_CODE_EXPIRES_IN: codeExpiresIn,
ENFORCE_ONE_MFA_ENROLLMENT: enforceMfa,
SENDGRID_API_KEY: sendgridKey,
SENDGRID_SENDER_ADDRESS: sendgridSender,
} = env(c)

const requireOtpMfa = step < 2 && (enableOtpMfa || authCodeBody.user.mfaTypes.includes(userModel.MfaType.Otp))
const requireMfaEnroll =
step < 2 &&
enforceMfa &&
!enableEmailMfa &&
!enableOtpMfa &&
!authCodeBody.user.mfaTypes.length &&
sendgridKey &&
sendgridSender

const requireOtpMfa = step < 3 && (enableOtpMfa || authCodeBody.user.mfaTypes.includes(userModel.MfaType.Otp))
const requireOtpSetup = requireOtpMfa && !authCodeBody.user.otpVerified

const requireEmailMfa = step < 3 && (enableEmailMfa || authCodeBody.user.mfaTypes.includes(userModel.MfaType.Email))
if (requireEmailMfa && !requireConsent && !requireOtpMfa) {
const requireEmailMfa = step < 4 && (enableEmailMfa || authCodeBody.user.mfaTypes.includes(userModel.MfaType.Email))
if (requireEmailMfa && !requireMfaEnroll && !requireConsent && !requireOtpMfa) {
const mfaCode = await emailService.sendEmailMfa(
c,
authCodeBody.user,
Expand All @@ -68,7 +82,7 @@ const handlePostAuthorize = async (
}
}

if (!requireConsent && !requireOtpMfa && !requireEmailMfa) {
if (!requireConsent && !requireMfaEnroll && !requireOtpMfa && !requireEmailMfa) {
sessionService.setAuthInfoSession(
c,
authCodeBody.appId,
Expand All @@ -84,6 +98,7 @@ const handlePostAuthorize = async (
state: authCodeBody.request.state,
scopes: authCodeBody.request.scopes,
requireConsent,
requireMfaEnroll,
requireEmailMfa,
requireOtpSetup,
requireOtpMfa,
Expand Down Expand Up @@ -336,6 +351,67 @@ export const postAuthorizeConsent = async (c: Context<typeConfig.Context>) => {
)
}

export const getAuthorizeMfaEnroll = async (c: Context<typeConfig.Context>) => {
const queryDto = await identityDto.parseGetAuthorizeFollowUpReq(c)

const authCodeStore = await kvService.getAuthCodeBody(
c.env.KV,
queryDto.code,
)

if (authCodeStore.user.mfaTypes.length) throw new errorConfig.Forbidden(localeConfig.Error.MfaEnrolled)

const {
COMPANY_LOGO_URL: logoUrl,
SUPPORTED_LOCALES: locales,
ENABLE_LOCALE_SELECTOR: enableLocaleSelector,
} = env(c)

return c.html(<AuthorizeMfaEnrollView
logoUrl={logoUrl}
queryDto={queryDto}
locales={enableLocaleSelector ? locales : [queryDto.locale]}
/>)
}

export const postAuthorizeMfaEnroll = async (c: Context<typeConfig.Context>) => {
const reqBody = await c.req.json()

const bodyDto = new identityDto.PostAuthorizeEnrollReqDto(reqBody)
await validateUtil.dto(bodyDto)

const authCodeStore = await kvService.getAuthCodeBody(
c.env.KV,
bodyDto.code,
)
if (authCodeStore.user.mfaTypes.length) throw new errorConfig.Forbidden()

const user = await userService.enrollUserMfa(
c,
authCodeStore.user.authId,
bodyDto.type,
)
const { AUTHORIZATION_CODE_EXPIRES_IN: codeExpiresIn } = env(c)
const newAuthCodeStore = {
...authCodeStore,
user,
}
await kvService.storeAuthCode(
c.env.KV,
bodyDto.code,
newAuthCodeStore,
codeExpiresIn,
)

return handlePostAuthorize(
c,
AuthorizeStep.MfaEnroll,
bodyDto.code,
newAuthCodeStore,
bodyDto.locale,
)
}

export const getAuthorizeOtpSetup = async (c: Context<typeConfig.Context>) => {
const queryDto = await identityDto.parseGetAuthorizeFollowUpReq(c)

Expand Down
7 changes: 7 additions & 0 deletions server/src/handlers/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,15 @@ export const postTokenAuthCode = async (c: Context<typeConfig.Context>) => {
const {
EMAIL_MFA_IS_REQUIRED: requireEmailMfa,
OTP_MFA_IS_REQUIRED: requireOtpMfa,
ENFORCE_ONE_MFA_ENROLLMENT: enforceMfa,
SENDGRID_API_KEY: sendgridKey,
SENDGRID_SENDER_ADDRESS: sendgridSender,
} = env(c)

if (enforceMfa && !requireEmailMfa && !requireOtpMfa && sendgridKey && sendgridSender) {
if (!authInfo.user.mfaTypes.length) throw new errorConfig.UnAuthorized(localeConfig.Error.MfaNotVerified)
}

if (requireOtpMfa || authInfo.user.mfaTypes.includes(userModel.MfaType.Otp)) {
const isVerified = await kvService.optMfaCodeVerified(
c.env.KV,
Expand Down
1 change: 1 addition & 0 deletions server/src/handlers/other.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const getSystemInfo = async (c: Context<typeConfig.Context>) => {
ENABLE_NAMES: environment.ENABLE_NAMES,
NAMES_IS_REQUIRED: environment.NAMES_IS_REQUIRED,
ENABLE_USER_APP_CONSENT: environment.ENABLE_USER_APP_CONSENT,
ENFORCE_ONE_MFA_ENROLLMENT: environment.ENFORCE_ONE_MFA_ENROLLMENT,
ENABLE_EMAIL_VERIFICATION: environment.ENABLE_EMAIL_VERIFICATION,
EMAIL_MFA_IS_REQUIRED: environment.EMAIL_MFA_IS_REQUIRED,
OTP_MFA_IS_REQUIRED: environment.OTP_MFA_IS_REQUIRED,
Expand Down
18 changes: 18 additions & 0 deletions server/src/middlewares/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,21 @@ export const enableEmail = async (

await next()
}

export const enableMfaEnroll = async (
c: Context<typeConfig.Context>, next: Next,
) => {
const {
ENFORCE_ONE_MFA_ENROLLMENT: enforceMfa,
EMAIL_MFA_IS_REQUIRED: requireEmailMfa,
OTP_MFA_IS_REQUIRED: requireOtpMfa,
SENDGRID_API_KEY: sendgridKey,
SENDGRID_SENDER_ADDRESS: sendgridSender,
} = env(c)

if (!enforceMfa || requireEmailMfa || requireOtpMfa || !sendgridKey || !sendgridSender) {
throw new errorConfig.Forbidden()
}

await next()
}
12 changes: 12 additions & 0 deletions server/src/routes/identity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ identityRoutes.post(
identityHandler.postAuthorizeOtpMfa,
)

identityRoutes.get(
`${BaseRoute}/authorize-mfa-enroll`,
configMiddleware.enableMfaEnroll,
identityHandler.getAuthorizeMfaEnroll,
)

identityRoutes.post(
`${BaseRoute}/authorize-mfa-enroll`,
configMiddleware.enableMfaEnroll,
identityHandler.postAuthorizeMfaEnroll,
)

identityRoutes.get(
`${BaseRoute}/authorize-email-mfa`,
configMiddleware.enableEmail,
Expand Down
27 changes: 15 additions & 12 deletions server/src/services/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ export const createAccountWithPassword = async (
const password = await cryptoUtil.bcryptText(bodyDto.password)

const { OTP_MFA_IS_REQUIRED: enableOtp } = env(c)
const otpSecret = enableOtp ? await cryptoUtil.genOtpSecret() : undefined
const otpSecret = enableOtp ? cryptoUtil.genOtpSecret() : undefined
const newUser = await userModel.create(
c.env.DB,
{
Expand Down Expand Up @@ -298,13 +298,11 @@ export const enrollUserMfa = async (
c: Context<typeConfig.Context>,
authId: string,
mfaType: userModel.MfaType,
): Promise<true> => {
): Promise<userModel.Record> => {
const {
OTP_MFA_IS_REQUIRED: otpMfaRequired,
EMAIL_MFA_IS_REQUIRED: emailMfaRequired,
} = env(c)
if (mfaType === userModel.MfaType.Otp && otpMfaRequired) return true
if (mfaType === userModel.MfaType.Email && emailMfaRequired) return true

const user = await userModel.getByAuthId(
c.env.DB,
Expand All @@ -318,19 +316,23 @@ export const enrollUserMfa = async (
throw new errorConfig.Forbidden(localeConfig.Error.UserDisabled)
}

if (user.mfaTypes.includes(mfaType)) return true
const isOtp = mfaType === userModel.MfaType.Otp
if (isOtp && otpMfaRequired) return user
if (mfaType === userModel.MfaType.Email && emailMfaRequired) return user

await userModel.update(
if (user.mfaTypes.includes(mfaType)) return user

const newUser = await userModel.update(
c.env.DB,
user.id,
{
mfaTypes: [...user.mfaTypes, mfaType].join(','),
otpVerified: 0,
otpSecret: '',
otpVerified: isOtp ? 0 : undefined,
otpSecret: isOtp ? cryptoUtil.genOtpSecret() : undefined,
},
)

return true
return newUser
}

export const resetUserMfa = async (
Expand All @@ -350,8 +352,9 @@ export const resetUserMfa = async (
throw new errorConfig.Forbidden(localeConfig.Error.UserDisabled)
}

const isOtp = mfaType === userModel.MfaType.Otp
if (
mfaType === userModel.MfaType.Otp &&
isOtp &&
!user.mfaTypes.includes(userModel.MfaType.Otp) &&
!user.otpVerified && !user.otpSecret
) return true
Expand All @@ -362,8 +365,8 @@ export const resetUserMfa = async (
user.id,
{
mfaTypes: user.mfaTypes.filter((type) => type !== mfaType).join(','),
otpVerified: 0,
otpSecret: '',
otpVerified: isOtp ? 0 : undefined,
otpSecret: isOtp ? '' : undefined,
},
)

Expand Down
Loading

0 comments on commit 8dbba51

Please sign in to comment.