refactor: Update email regex to improve validation accuracy

This commit is contained in:
Simon Larsen 2024-05-04 20:02:17 +01:00
parent 766f1f6178
commit f793f7dd16
No known key found for this signature in database
GPG Key ID: 96C5DCA24769DBCA
7 changed files with 114 additions and 85 deletions

View File

@ -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",

View File

@ -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<boolean>(false);
const [initialValues, setInitialValues] = React.useState<JSONObject>({});
useAsyncEffect(async () => {
if (Navigation.getQueryStringByName('email')) {
setInitialValues({
email: Navigation.getQueryStringByName('email'),
});
}
}, []);
return (
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="">
@ -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 && (
<div className="text-gray-500 text-sm">
Please sign in with your username
and password. Once you have signed
in, you&apos;ll be able to sign in
via SSO that&apos;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.
</div>
)}
</div>

View File

@ -100,24 +100,39 @@ router.get(
);
router.get(
'/idp-login/:projectId/:projectSsoId',
'/idp-login/:projectId/:projectSsoId',
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
return await loginUserWithSso(req, res);
});
}
);
router.post(
'/idp-login/:projectId/:projectSsoId',
async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
return await loginUserWithSso(req, res);
}
}
);
const loginUserWithSso = async (req: ExpressRequest, res: ExpressResponse): Promise<void> => {
try {
type LoginUserWithSsoFunction = (
req: ExpressRequest,
res: ExpressResponse
) => Promise<void>;
debugger;
const loginUserWithSso: LoginUserWithSsoFunction = async (
req: ExpressRequest,
res: ExpressResponse
): Promise<void> => {
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;

View File

@ -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');

View File

@ -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;

View File

@ -92,8 +92,11 @@ export class TeamMemberService extends DatabaseService<TeamMember> {
if (!user) {
isNewUser = true;
user = await UserService.createByEmail(email, {
isRoot: true,
user = await UserService.createByEmail({
email,
props: {
isRoot: true,
},
});
}

View File

@ -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<Model> {
public constructor(postgresDatabase?: PostgresDatabase) {
@ -226,12 +228,21 @@ export class Service extends DatabaseService<Model> {
return onUpdate;
}
public async createByEmail(
email: Email,
props: DatabaseCommonInteractionProps
): Promise<Model> {
public async createByEmail(data: {
email: Email;
isEmailVerified?: boolean;
generateRandomPassword?: boolean;
props: DatabaseCommonInteractionProps;
}): Promise<Model> {
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.