From f793f7dd16df90aeeb7fe7f679f82da943b5584f Mon Sep 17 00:00:00 2001 From: Simon Larsen Date: Sat, 4 May 2024 20:02:17 +0100 Subject: [PATCH] refactor: Update email regex to improve validation accuracy --- .eslintrc.json | 1 + Accounts/src/Pages/Login.tsx | 29 ++++-- App/FeatureSet/Identity/API/SSO.ts | 108 ++++++++++----------- App/FeatureSet/Identity/Utils/SSO.ts | 30 +++--- Common/Types/Email.ts | 5 +- CommonServer/Services/TeamMemberService.ts | 7 +- CommonServer/Services/UserService.ts | 19 +++- 7 files changed, 114 insertions(+), 85 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 1797a9ae12..1863d3e14a 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -113,6 +113,7 @@ "no-console": "error", "no-undef": "error", "no-empty": "error", + "no-control-regex": "off", "prefer-arrow-callback": "error", "constructor-super": "error", "no-case-declarations": "error", diff --git a/Accounts/src/Pages/Login.tsx b/Accounts/src/Pages/Login.tsx index e89a651653..5f301296d2 100644 --- a/Accounts/src/Pages/Login.tsx +++ b/Accounts/src/Pages/Login.tsx @@ -14,6 +14,7 @@ import Navigation from 'CommonUI/src/Utils/Navigation'; import { DASHBOARD_URL } from 'CommonUI/src/Config'; import Alert, { AlertType } from 'CommonUI/src/Components/Alerts/Alert'; import UiAnalytics from 'CommonUI/src/Utils/Analytics'; +import useAsyncEffect from 'use-async-effect'; const LoginPage: () => JSX.Element = () => { const apiUrl: URL = LOGIN_API_URL; @@ -28,6 +29,16 @@ const LoginPage: () => JSX.Element = () => { const [showSsoTip, setShowSSOTip] = useState(false); + const [initialValues, setInitialValues] = React.useState({}); + + useAsyncEffect(async () => { + if (Navigation.getQueryStringByName('email')) { + setInitialValues({ + email: Navigation.getQueryStringByName('email'), + }); + } + }, []); + return (
@@ -66,9 +77,13 @@ const LoginPage: () => JSX.Element = () => { field: { email: true, }, - title: 'Email', fieldType: FormFieldSchemaType.Email, + placeholder: 'jeff@example.com', required: true, + disabled: Boolean( + initialValues && initialValues['email'] + ), + title: 'Email', dataTestId: 'email', }, { @@ -123,11 +138,13 @@ const LoginPage: () => JSX.Element = () => { {showSsoTip && (
- Please sign in with your username - and password. Once you have signed - in, you'll be able to sign in - via SSO that's configured for - your project. + Please log in from your SSO provider + (like Okta / Entra ID, etc). We + support login from the identity + provider you have configured for + your project. If you have not + configured any SSO provider, please + use the form above to log in.
)}
diff --git a/App/FeatureSet/Identity/API/SSO.ts b/App/FeatureSet/Identity/API/SSO.ts index c84f0363ac..4958b2d216 100644 --- a/App/FeatureSet/Identity/API/SSO.ts +++ b/App/FeatureSet/Identity/API/SSO.ts @@ -100,24 +100,39 @@ router.get( ); router.get( - '/idp-login/:projectId/:projectSsoId', + '/idp-login/:projectId/:projectSsoId', async (req: ExpressRequest, res: ExpressResponse): Promise => { return await loginUserWithSso(req, res); - }); + } +); router.post( '/idp-login/:projectId/:projectSsoId', async (req: ExpressRequest, res: ExpressResponse): Promise => { return await loginUserWithSso(req, res); - } + } ); -const loginUserWithSso = async (req: ExpressRequest, res: ExpressResponse): Promise => { - try { +type LoginUserWithSsoFunction = ( + req: ExpressRequest, + res: ExpressResponse +) => Promise; - debugger; +const loginUserWithSso: LoginUserWithSsoFunction = async ( + req: ExpressRequest, + res: ExpressResponse +): Promise => { + try { const samlResponseBase64: string = req.body.SAMLResponse; + if (!samlResponseBase64) { + return Response.sendErrorResponse( + req, + res, + new BadRequestException('SAMLResponse not found') + ); + } + const samlResponse: string = Buffer.from( samlResponseBase64, 'base64' @@ -146,8 +161,8 @@ const loginUserWithSso = async (req: ExpressRequest, res: ExpressResponse): Prom ); } - const projectSSO: ProjectSSO | null = - await ProjectSSOService.findOneBy({ + const projectSSO: ProjectSSO | null = await ProjectSSOService.findOneBy( + { query: { projectId: new ObjectID(req.params['projectId']), _id: req.params['projectSsoId'], @@ -164,7 +179,8 @@ const loginUserWithSso = async (req: ExpressRequest, res: ExpressResponse): Prom props: { isRoot: true, }, - }); + } + ); if (!projectSSO) { return Response.sendErrorResponse( @@ -226,15 +242,16 @@ const loginUserWithSso = async (req: ExpressRequest, res: ExpressResponse): Prom if (err instanceof Exception) { return Response.sendErrorResponse(req, res, err); } - return Response.sendErrorResponse( - req, - res, - new ServerException() - ); + return Response.sendErrorResponse(req, res, new ServerException()); } if (projectSSO.issuerURL.toString() !== issuerUrl) { - logger.error("Issuer URL does not match. It should be "+projectSSO.issuerURL.toString()+" but it is "+issuerUrl.toString()); + logger.error( + 'Issuer URL does not match. It should be ' + + projectSSO.issuerURL.toString() + + ' but it is ' + + issuerUrl.toString() + ); return Response.sendErrorResponse( req, res, @@ -266,18 +283,22 @@ const loginUserWithSso = async (req: ExpressRequest, res: ExpressResponse): Prom /// Create a user. - alreadySavedUser = await UserService.createByEmail(email, { - isRoot: true, + alreadySavedUser = await UserService.createByEmail({ + email, + isEmailVerified: true, + generateRandomPassword: true, + props: { + isRoot: true, + }, }); isNewUser = true; } // If he does not then add him to teams that he should belong and log in. + // This should never happen because email is verified before he logs in with SSO. if (!alreadySavedUser.isEmailVerified && !isNewUser) { - await AuthenticationEmail.sendVerificationEmail( - alreadySavedUser! - ); + await AuthenticationEmail.sendVerificationEmail(alreadySavedUser!); return Response.render( req, @@ -292,18 +313,17 @@ const loginUserWithSso = async (req: ExpressRequest, res: ExpressResponse): Prom } // check if the user already belongs to the project - const teamMemberCount: PositiveNumber = - await TeamMemberService.countBy({ + const teamMemberCount: PositiveNumber = await TeamMemberService.countBy( + { query: { - projectId: new ObjectID( - req.params['projectId'] as string - ), + projectId: new ObjectID(req.params['projectId'] as string), userId: alreadySavedUser!.id!, }, props: { isRoot: true, }, - }); + } + ); if (teamMemberCount.toNumber() === 0) { // user not in project, add him to default teams. @@ -343,21 +363,6 @@ const loginUserWithSso = async (req: ExpressRequest, res: ExpressResponse): Prom } } - if (isNewUser) { - return Response.render( - req, - res, - '/usr/src/app/FeatureSet/Identity/Views/Message.ejs', - { - title: 'You have not signed up so far.', - message: - 'You need to sign up for an account on OneUptime with this email:' + - email.toString() + - '. Once you have signed up, you can use SSO to log in to your project.', - } - ); - } - const projectId: ObjectID = new ObjectID( req.params['projectId'] as string ); @@ -378,20 +383,12 @@ const loginUserWithSso = async (req: ExpressRequest, res: ExpressResponse): Prom ); const host: Hostname = await DatabaseConfig.getHost(); - const httpProtocol: Protocol = - await DatabaseConfig.getHttpProtocol(); + const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol(); - CookieUtil.setCookie( - res, - CookieUtil.getUserSSOKey(projectId), - token, - { - maxAge: OneUptimeDate.getMillisecondsInDays( - new PositiveNumber(30) - ), - httpOnly: true, - } - ); + CookieUtil.setCookie(res, CookieUtil.getUserSSOKey(projectId), token, { + maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)), + httpOnly: true, + }); return Response.redirect( req, @@ -409,7 +406,6 @@ const loginUserWithSso = async (req: ExpressRequest, res: ExpressResponse): Prom logger.error(err); Response.sendErrorResponse(req, res, new ServerException()); } -} - +}; export default router; diff --git a/App/FeatureSet/Identity/Utils/SSO.ts b/App/FeatureSet/Identity/Utils/SSO.ts index 357aa44826..cabf4c4c96 100644 --- a/App/FeatureSet/Identity/Utils/SSO.ts +++ b/App/FeatureSet/Identity/Utils/SSO.ts @@ -23,7 +23,7 @@ export default class SSOUtil { const issuers: JSONArray = (payload['saml2:Issuer'] as JSONArray) || - (payload['saml:Issuer'] as JSONArray) || + (payload['saml:Issuer'] as JSONArray) || (payload['Issuer'] as JSONArray); if (issuers.length === 0) { @@ -58,7 +58,7 @@ export default class SSOUtil { const samlSubject: JSONArray = ((samlAssertion[0] as JSONObject)['saml2:Subject'] as JSONArray) || - ((samlAssertion[0] as JSONObject)['saml:Subject'] as JSONArray) || + ((samlAssertion[0] as JSONObject)['saml:Subject'] as JSONArray) || ((samlAssertion[0] as JSONObject)['Subject'] as JSONArray); if (!samlSubject || samlSubject.length === 0) { @@ -67,8 +67,8 @@ export default class SSOUtil { const samlNameId: JSONArray = ((samlSubject[0] as JSONObject)['saml2:NameID'] as JSONArray) || - ((samlSubject[0] as JSONObject)['saml:NameID'] as JSONArray) || - ((samlSubject[0] as JSONObject)['NameID'] as JSONArray) + ((samlSubject[0] as JSONObject)['saml:NameID'] as JSONArray) || + ((samlSubject[0] as JSONObject)['NameID'] as JSONArray); if (!samlNameId || samlNameId.length === 0) { throw new BadRequestException('SAML NAME ID not found'); @@ -125,13 +125,13 @@ export default class SSOUtil { payload = (payload['saml2p:Response'] as JSONObject) || - (payload['samlp:Response'] as JSONObject) || - (payload['Response'] as JSONObject) + (payload['samlp:Response'] as JSONObject) || + (payload['Response'] as JSONObject); const samlAssertion: JSONArray = (payload['saml2:Assertion'] as JSONArray) || - (payload['saml:Assertion'] as JSONArray) || - (payload['Assertion'] as JSONArray) + (payload['saml:Assertion'] as JSONArray) || + (payload['Assertion'] as JSONArray); if (!samlAssertion || samlAssertion.length === 0) { throw new BadRequestException('SAML Assertion not found'); @@ -139,8 +139,8 @@ export default class SSOUtil { const samlSubject: JSONArray = ((samlAssertion[0] as JSONObject)['saml2:Subject'] as JSONArray) || - ((samlAssertion[0] as JSONObject)['saml:Subject'] as JSONArray) || - ((samlAssertion[0] as JSONObject)['Subject'] as JSONArray) + ((samlAssertion[0] as JSONObject)['saml:Subject'] as JSONArray) || + ((samlAssertion[0] as JSONObject)['Subject'] as JSONArray); if (!samlSubject || samlSubject.length === 0) { throw new BadRequestException('SAML Subject not found'); @@ -148,7 +148,7 @@ export default class SSOUtil { const samlNameId: JSONArray = ((samlSubject[0] as JSONObject)['saml2:NameID'] as JSONArray) || - ((samlSubject[0] as JSONObject)['saml:NameID'] as JSONArray) || + ((samlSubject[0] as JSONObject)['saml:NameID'] as JSONArray) || ((samlSubject[0] as JSONObject)['NameID'] as JSONArray); if (!samlNameId || samlNameId.length === 0) { @@ -169,13 +169,13 @@ export default class SSOUtil { payload = (payload['saml2p:Response'] as JSONObject) || - (payload['samlp:Response'] as JSONObject) || - (payload['Response'] as JSONObject) + (payload['samlp:Response'] as JSONObject) || + (payload['Response'] as JSONObject); const issuers: JSONArray = (payload['saml2:Issuer'] as JSONArray) || - (payload['saml:Issuer'] as JSONArray) || - (payload['Issuer'] as JSONArray) + (payload['saml:Issuer'] as JSONArray) || + (payload['Issuer'] as JSONArray); if (issuers.length === 0) { throw new BadRequestException('Issuers not found'); diff --git a/Common/Types/Email.ts b/Common/Types/Email.ts index 9be8d017f9..575b7fee39 100644 --- a/Common/Types/Email.ts +++ b/Common/Types/Email.ts @@ -53,9 +53,10 @@ export default class Email extends DatabaseProperty { } public static isValid(value: string): boolean { - // from https://datatracker.ietf.org/doc/html/rfc5322 - const re: RegExp = /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/i; + + const re: RegExp = + /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/i; const isValid: boolean = re.test(value); if (!isValid) { return false; diff --git a/CommonServer/Services/TeamMemberService.ts b/CommonServer/Services/TeamMemberService.ts index 21d7a898dc..1aa4beda7a 100644 --- a/CommonServer/Services/TeamMemberService.ts +++ b/CommonServer/Services/TeamMemberService.ts @@ -92,8 +92,11 @@ export class TeamMemberService extends DatabaseService { if (!user) { isNewUser = true; - user = await UserService.createByEmail(email, { - isRoot: true, + user = await UserService.createByEmail({ + email, + props: { + isRoot: true, + }, }); } diff --git a/CommonServer/Services/UserService.ts b/CommonServer/Services/UserService.ts index 3cc25a3375..e72489afa6 100755 --- a/CommonServer/Services/UserService.ts +++ b/CommonServer/Services/UserService.ts @@ -24,6 +24,8 @@ import UserNotificationSettingService from './UserNotificationSettingService'; import Hostname from 'Common/Types/API/Hostname'; import Protocol from 'Common/Types/API/Protocol'; import { IsBillingEnabled } from '../EnvironmentConfig'; +import Text from 'Common/Types/Text'; +import HashedString from 'Common/Types/HashedString'; export class Service extends DatabaseService { public constructor(postgresDatabase?: PostgresDatabase) { @@ -226,12 +228,21 @@ export class Service extends DatabaseService { return onUpdate; } - public async createByEmail( - email: Email, - props: DatabaseCommonInteractionProps - ): Promise { + public async createByEmail(data: { + email: Email; + isEmailVerified?: boolean; + generateRandomPassword?: boolean; + props: DatabaseCommonInteractionProps; + }): Promise { + const { email, props } = data; + const user: Model = new Model(); user.email = email; + user.isEmailVerified = data.isEmailVerified || false; + + if (data.generateRandomPassword) { + user.password = new HashedString(Text.generateRandomText(20)); + } if (!IsBillingEnabled) { // if billing is not enabled, then email is verified by default.