Implement master password feature for private status pages

This commit is contained in:
Nawaz Dhandala
2025-11-20 12:51:11 +00:00
parent 9deaf19d4c
commit 4b619eadc0
12 changed files with 552 additions and 0 deletions

View File

@@ -30,6 +30,7 @@ import { JSONObject } from "../../Types/JSON";
import ObjectID from "../../Types/ObjectID";
import Permission from "../../Types/Permission";
import Timezone from "../../Types/Timezone";
import HashedString from "../../Types/HashedString";
import {
Column,
Entity,
@@ -883,6 +884,71 @@ export default class StatusPage extends BaseModel {
})
public isPublicStatusPage?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateProjectStatusPage,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadProjectStatusPage,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditProjectStatusPage,
],
})
@TableColumn({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Enable Master Password",
description:
"Require visitors to enter a master password before viewing a private status page.",
defaultValue: false,
})
@Column({
type: ColumnType.Boolean,
default: false,
})
public enableMasterPassword?: boolean = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateProjectStatusPage,
],
read: [],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditProjectStatusPage,
],
})
@TableColumn({
title: "Master Password",
description:
"Password required to unlock a private status page. This value is stored as a secure hash.",
hashed: true,
type: TableColumnType.HashedString,
placeholder: "Enter a new master password",
})
@Column({
type: ColumnType.HashedString,
length: ColumnLength.HashedString,
nullable: true,
transformer: HashedString.getDatabaseTransformer(),
})
public masterPassword?: HashedString = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,

View File

@@ -49,6 +49,7 @@ import JSONFunctions from "../../Types/JSONFunctions";
import ObjectID from "../../Types/ObjectID";
import Phone from "../../Types/Phone";
import PositiveNumber from "../../Types/PositiveNumber";
import HashedString from "../../Types/HashedString";
import AcmeChallenge from "../../Models/DatabaseModels/AcmeChallenge";
import Incident from "../../Models/DatabaseModels/Incident";
import IncidentPublicNote from "../../Models/DatabaseModels/IncidentPublicNote";
@@ -87,10 +88,13 @@ import EmailTemplateType from "../../Types/Email/EmailTemplateType";
import Hostname from "../../Types/API/Hostname";
import Protocol from "../../Types/API/Protocol";
import DatabaseConfig from "../DatabaseConfig";
import CookieUtil from "../Utils/Cookie";
import { EncryptionSecret } from "../EnvironmentConfig";
import { StatusPageApiRoute } from "../../ServiceRoute";
import ProjectSmtpConfigService from "../Services/ProjectSmtpConfigService";
import ForbiddenException from "../../Types/Exception/ForbiddenException";
import SlackUtil from "../Utils/Workspace/Slack/Slack";
import { MASTER_PASSWORD_INVALID_MESSAGE } from "../../Types/StatusPage/MasterPassword";
type ResolveStatusPageIdOrThrowFunction = (
statusPageIdOrDomain: string,
@@ -798,6 +802,7 @@ export default class StatusPageAPI extends BaseAPI<
enableMicrosoftTeamsSubscribers: true,
enableSmsSubscribers: true,
isPublicStatusPage: true,
enableMasterPassword: true,
allowSubscribersToChooseResources: true,
allowSubscribersToChooseEventTypes: true,
requireSsoForLogin: true,
@@ -910,6 +915,82 @@ export default class StatusPageAPI extends BaseAPI<
},
);
this.router.post(
`${new this.entityType()
.getCrudApiPath()
?.toString()}/master-password/:statusPageId`,
UserMiddleware.getUserMiddleware,
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
try {
if (!req.params["statusPageId"]) {
throw new BadDataException("Status Page ID not found");
}
const statusPageId: ObjectID = new ObjectID(
req.params["statusPageId"] as string,
);
const password: string | undefined =
req.body && (req.body["password"] as string);
if (!password) {
throw new BadDataException("Master password is required.");
}
const statusPage: StatusPage | null =
await StatusPageService.findOneById({
id: statusPageId,
select: {
_id: true,
projectId: true,
enableMasterPassword: true,
masterPassword: true,
isPublicStatusPage: true,
},
props: {
isRoot: true,
},
});
if (!statusPage) {
throw new NotFoundException("Status Page not found");
}
if (statusPage.isPublicStatusPage) {
throw new BadDataException(
"This status page is already visible to everyone.",
);
}
if (!statusPage.enableMasterPassword || !statusPage.masterPassword) {
throw new BadDataException(
"Master password has not been configured for this status page.",
);
}
const hashedInput: string = await HashedString.hashValue(
password,
EncryptionSecret,
);
if (hashedInput !== statusPage.masterPassword.toString()) {
throw new NotAuthenticatedException(
MASTER_PASSWORD_INVALID_MESSAGE,
);
}
CookieUtil.setStatusPageMasterPasswordCookie({
expressResponse: res,
statusPageId,
});
return Response.sendEmptySuccessResponse(req, res);
} catch (err) {
next(err);
}
},
);
this.router.post(
`${new this.entityType().getCrudApiPath()?.toString()}/sso/:statusPageId`,
UserMiddleware.getUserMiddleware,

View File

@@ -47,6 +47,7 @@ import ProjectSMTPConfigService from "./ProjectSmtpConfigService";
import StatusPageResource from "../../Models/DatabaseModels/StatusPageResource";
import StatusPageResourceService from "./StatusPageResourceService";
import Dictionary from "../../Types/Dictionary";
import { JSONObject } from "../../Types/JSON";
import MonitorGroupResource from "../../Models/DatabaseModels/MonitorGroupResource";
import MonitorGroupResourceService from "./MonitorGroupResourceService";
import QueryHelper from "../Types/Database/QueryHelper";
@@ -61,6 +62,11 @@ import IP from "../../Types/IP/IP";
import NotAuthenticatedException from "../../Types/Exception/NotAuthenticatedException";
import ForbiddenException from "../../Types/Exception/ForbiddenException";
import CommonAPI from "../API/CommonAPI";
import MasterPasswordRequiredException from "../../Types/Exception/MasterPasswordRequiredException";
import {
MASTER_PASSWORD_COOKIE_IDENTIFIER,
MASTER_PASSWORD_REQUIRED_MESSAGE,
} from "../../Types/StatusPage/MasterPassword";
export interface StatusPageReportItem {
resourceName: string;
@@ -389,6 +395,8 @@ export class Service extends DatabaseService<StatusPage> {
_id: true,
isPublicStatusPage: true,
ipWhitelist: true,
enableMasterPassword: true,
masterPassword: true,
},
});
@@ -481,6 +489,34 @@ export class Service extends DatabaseService<StatusPage> {
hasReadAccess: true,
};
}
const shouldEnforceMasterPassword: boolean = Boolean(
statusPage &&
statusPage.enableMasterPassword &&
statusPage.masterPassword &&
!statusPage.isPublicStatusPage,
);
if (shouldEnforceMasterPassword) {
const hasValidMasterPassword: boolean =
this.hasValidMasterPasswordCookie({
req,
statusPageId,
});
if (hasValidMasterPassword) {
return {
hasReadAccess: true,
};
}
return {
hasReadAccess: false,
error: new MasterPasswordRequiredException(
MASTER_PASSWORD_REQUIRED_MESSAGE,
),
};
}
} catch (err) {
logger.error(err);
}
@@ -493,6 +529,33 @@ export class Service extends DatabaseService<StatusPage> {
};
}
private hasValidMasterPasswordCookie(data: {
req: ExpressRequest;
statusPageId: ObjectID;
}): boolean {
const token: string | undefined = CookieUtil.getCookieFromExpressRequest(
data.req,
CookieUtil.getStatusPageMasterPasswordKey(data.statusPageId),
);
if (!token) {
return false;
}
try {
const payload: JSONObject = JSONWebToken.decodeJsonPayload(token);
return (
payload["statusPageId"] === data.statusPageId.toString() &&
payload["type"] === MASTER_PASSWORD_COOKIE_IDENTIFIER
);
} catch (err) {
logger.error(err);
}
return false;
}
@CaptureSpan()
public async getMonitorStatusTimelineForStatusPage(data: {
monitorIds: Array<ObjectID>;

View File

@@ -8,6 +8,10 @@ import StatusPagePrivateUser from "../../Models/DatabaseModels/StatusPagePrivate
import OneUptimeDate from "../../Types/Date";
import PositiveNumber from "../../Types/PositiveNumber";
import CookieName from "../../Types/CookieName";
import {
MASTER_PASSWORD_COOKIE_IDENTIFIER,
MASTER_PASSWORD_COOKIE_MAX_AGE_IN_DAYS,
} from "../../Types/StatusPage/MasterPassword";
import CaptureSpan from "./Telemetry/CaptureSpan";
export default class CookieUtil {
@@ -233,6 +237,34 @@ export default class CookieUtil {
return token;
}
@CaptureSpan()
public static setStatusPageMasterPasswordCookie(data: {
expressResponse: ExpressResponse;
statusPageId: ObjectID;
}): void {
const expiresInDays: PositiveNumber = new PositiveNumber(
MASTER_PASSWORD_COOKIE_MAX_AGE_IN_DAYS,
);
const token: string = JSONWebToken.signJsonPayload(
{
statusPageId: data.statusPageId.toString(),
type: MASTER_PASSWORD_COOKIE_IDENTIFIER,
},
OneUptimeDate.getSecondsInDays(expiresInDays),
);
CookieUtil.setCookie(
data.expressResponse,
CookieUtil.getStatusPageMasterPasswordKey(data.statusPageId),
token,
{
maxAge: OneUptimeDate.getMillisecondsInDays(expiresInDays),
httpOnly: true,
},
);
}
@CaptureSpan()
public static setCookie(
res: ExpressResponse,
@@ -280,6 +312,17 @@ export default class CookieUtil {
});
}
@CaptureSpan()
public static removeStatusPageMasterPasswordCookie(
res: ExpressResponse,
statusPageId: ObjectID,
): void {
CookieUtil.removeCookie(
res,
CookieUtil.getStatusPageMasterPasswordKey(statusPageId),
);
}
// get all cookies with express request
@CaptureSpan()
public static getAllCookies(req: ExpressRequest): Dictionary<string> {
@@ -304,6 +347,11 @@ export default class CookieUtil {
return `${CookieName.RefreshToken}-${id.toString()}`;
}
@CaptureSpan()
public static getStatusPageMasterPasswordKey(id: ObjectID): string {
return `${CookieName.StatusPageMasterPassword}-${id.toString()}`;
}
@CaptureSpan()
public static getUserSSOKey(id: ObjectID): string {
return `${this.getSSOKey()}${id.toString()}`;

View File

@@ -7,6 +7,7 @@ enum CookieName {
Timezone = "user-timezone",
IsMasterAdmin = "user-is-master-admin",
ProfilePicID = "user-profile-pic-id",
StatusPageMasterPassword = "status-page-master-password",
}
export default CookieName;

View File

@@ -0,0 +1,7 @@
import NotAuthenticatedException from "./NotAuthenticatedException";
export default class MasterPasswordRequiredException extends NotAuthenticatedException {
public constructor(message: string) {
super(message);
}
}

View File

@@ -0,0 +1,10 @@
export const MASTER_PASSWORD_REQUIRED_MESSAGE: string =
"Master password required";
export const MASTER_PASSWORD_INVALID_MESSAGE: string =
"Invalid master password. Please try again.";
export const MASTER_PASSWORD_COOKIE_IDENTIFIER: string =
"status-page-master-password";
export const MASTER_PASSWORD_COOKIE_MAX_AGE_IN_DAYS: number = 7;

View File

@@ -32,6 +32,27 @@ const StatusPageDelete: FunctionComponent<
required: false,
placeholder: "Is this status page visible to public",
},
{
field: {
enableMasterPassword: true,
},
title: "Require Master Password",
fieldType: FormFieldSchemaType.Toggle,
required: false,
description:
"When enabled, visitors must enter the master password before viewing a private status page.",
},
{
field: {
masterPassword: true,
},
title: "Master Password",
fieldType: FormFieldSchemaType.Password,
required: false,
placeholder: "Enter a new master password",
description:
"Updating this value immediately replaces the existing master password.",
},
]}
modelDetailProps={{
showDetailsInNumberOfColumns: 1,
@@ -45,6 +66,14 @@ const StatusPageDelete: FunctionComponent<
fieldType: FieldType.Boolean,
title: "Is Visible to Public",
},
{
field: {
enableMasterPassword: true,
},
fieldType: FieldType.Boolean,
title: "Require Master Password",
placeholder: "No",
},
],
modelId: modelId,
}}

View File

@@ -0,0 +1,193 @@
import { STATUS_PAGE_API_URL } from "../../Utils/Config";
import StatusPageUtil from "../../Utils/StatusPage";
import UserUtil from "../../Utils/User";
import Route from "Common/Types/API/Route";
import URL from "Common/Types/API/URL";
import BadDataException from "Common/Types/Exception/BadDataException";
import ObjectID from "Common/Types/ObjectID";
import Alert, {
AlertType,
} from "Common/UI/Components/Alerts/Alert";
import Button, {
ButtonSize,
ButtonStyleType,
} from "Common/UI/Components/Button/Button";
import ButtonType from "Common/UI/Components/Button/ButtonTypes";
import API from "../../Utils/API";
import Navigation from "Common/UI/Utils/Navigation";
import PageLoader from "Common/UI/Components/Loader/PageLoader";
import React, { FormEvent, FunctionComponent, useEffect, useState } from "react";
export interface ComponentProps {
statusPageName: string;
logoFileId: ObjectID;
}
const MasterPasswordPage: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): JSX.Element => {
const [password, setPassword] = useState<string>("");
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId();
const redirectToOverview = (): void => {
const path: string = StatusPageUtil.isPreviewPage()
? `/status-page/${StatusPageUtil.getStatusPageId()?.toString()}`
: "/";
Navigation.navigate(new Route(path), { forceNavigate: true });
};
useEffect(() => {
if (!StatusPageUtil.isPrivateStatusPage()) {
Navigation.navigate(new Route("/"), { forceNavigate: true });
return;
}
if (!StatusPageUtil.requiresMasterPassword()) {
redirectToOverview();
return;
}
if (
statusPageId &&
UserUtil.isLoggedIn(statusPageId)
) {
redirectToOverview();
return;
}
if (StatusPageUtil.isMasterPasswordValidated()) {
redirectToOverview();
return;
}
}, [statusPageId]);
if (!statusPageId || !StatusPageUtil.requiresMasterPassword()) {
return <PageLoader isVisible={true} />;
}
const logoUrl: string | null =
props.logoFileId && props.logoFileId.toString()
? URL.fromString(STATUS_PAGE_API_URL.toString())
.addRoute(`/logo/${statusPageId.toString()}`)
.toString()
: null;
const onSubmit = async (): Promise<void> => {
if (!password) {
setError("Master password is required.");
return;
}
if (!statusPageId) {
throw new BadDataException("Status Page ID not found");
}
const url: URL = URL.fromString(STATUS_PAGE_API_URL.toString()).addRoute(
`/master-password/${statusPageId.toString()}`,
);
setIsSubmitting(true);
setError(null);
try {
await API.post({
url,
data: {
password,
},
});
StatusPageUtil.setMasterPasswordValidated(true);
const redirectUrl: string | null =
Navigation.getQueryStringByName("redirectUrl");
if (redirectUrl) {
Navigation.navigate(new Route(redirectUrl), { forceNavigate: true });
} else {
redirectToOverview();
}
} catch (err) {
setError(API.getFriendlyMessage(err));
} finally {
setIsSubmitting(false);
setPassword("");
}
};
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
event.preventDefault();
void onSubmit();
};
return (
<div className="flex min-h-full flex-col justify-center py-12 sm:px-6 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-md">
{logoUrl ? (
<img style={{ height: "70px", margin: "auto" }} src={logoUrl} />
) : null}
<h2 className="mt-6 text-center text-2xl tracking-tight text-gray-900">
Enter Master Password
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
This status page is private. Please enter the master password to
continue.
</p>
</div>
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
<div className="bg-white py-8 px-4 shadow sm:rounded-lg sm:px-10">
{error ? (
<Alert
className="mb-4"
strongTitle="Unable to unlock status page"
title={error}
type={AlertType.DANGER}
/>
) : null}
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<label
htmlFor="master-password"
className="block text-sm font-medium text-gray-700"
>
Master Password
</label>
<div className="mt-1">
<input
id="master-password"
name="master-password"
type="password"
autoComplete="current-password"
required={true}
value={password}
onChange={(event) => {
setPassword(event.target.value);
}}
className="block w-full appearance-none rounded-md border border-gray-300 px-3 py-2 shadow-sm placeholder:text-gray-400 focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm"
/>
</div>
</div>
<div>
<Button
buttonStyle={ButtonStyleType.PRIMARY}
buttonSize={ButtonSize.Large}
type={ButtonType.Submit}
title={isSubmitting ? "Unlocking..." : "Unlock Status Page"}
isLoading={isSubmitting}
disabled={isSubmitting}
/>
</div>
</form>
</div>
</div>
</div>
);
};
export default MasterPasswordPage;

View File

@@ -51,6 +51,9 @@ enum PageMap {
PREVIEW_LOGOUT = "PREVIEW_LOGOUT",
PREVIEW_FORBIDDEN = "PREVIEW_FORBIDDEN",
MASTER_PASSWORD = "MASTER_PASSWORD",
PREVIEW_MASTER_PASSWORD = "PREVIEW_MASTER_PASSWORD",
}
export default PageMap;

View File

@@ -28,6 +28,7 @@ const RouteMap: Dictionary<Route> = {
[PageMap.LOGOUT]: new Route(`/logout`),
[PageMap.FORGOT_PASSWORD]: new Route(`/forgot-password`),
[PageMap.RESET_PASSWORD]: new Route(`/reset-password/:token`),
[PageMap.MASTER_PASSWORD]: new Route(`/master-password`),
// forbidden page
[PageMap.FORBIDDEN]: new Route(`/forbidden`),
@@ -91,6 +92,9 @@ const RouteMap: Dictionary<Route> = {
[PageMap.PREVIEW_RESET_PASSWORD]: new Route(
`/status-page/${RouteParams.StatusPageId}/reset-password/:token`,
),
[PageMap.PREVIEW_MASTER_PASSWORD]: new Route(
`/status-page/${RouteParams.StatusPageId}/master-password`,
),
[PageMap.PREVIEW_SSO]: new Route(
`/status-page/${RouteParams.StatusPageId}/sso`,

View File

@@ -31,6 +31,25 @@ export default class StatusPageUtil {
return Boolean(LocalStorage.getItem("isPrivateStatusPage"));
}
public static setRequiresMasterPassword(value: boolean): void {
LocalStorage.setItem("requiresMasterPassword", value);
if (!value) {
StatusPageUtil.setMasterPasswordValidated(false);
}
}
public static requiresMasterPassword(): boolean {
return Boolean(LocalStorage.getItem("requiresMasterPassword"));
}
public static setMasterPasswordValidated(value: boolean): void {
LocalStorage.setItem("masterPasswordValidated", value);
}
public static isMasterPasswordValidated(): boolean {
return Boolean(LocalStorage.getItem("masterPasswordValidated"));
}
public static isPreviewPage(): boolean {
return Navigation.containsInPath("/status-page/");
}
@@ -45,9 +64,37 @@ export default class StatusPageUtil {
Navigation.navigate(route, { forceNavigate: true });
}
public static navigateToMasterPasswordPage(): void {
if (Navigation.getCurrentRoute().toString().includes("master-password")) {
return;
}
const basePath: string = StatusPageUtil.isPreviewPage()
? `/status-page/${StatusPageUtil.getStatusPageId()?.toString()}`
: "";
const route: Route = new Route(
`${basePath}/master-password?redirectUrl=${Navigation.getCurrentPath()}`,
);
Navigation.navigate(route, { forceNavigate: true });
}
public static checkIfUserHasLoggedIn(): void {
const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId();
if (
statusPageId &&
StatusPageUtil.isPrivateStatusPage() &&
StatusPageUtil.requiresMasterPassword() &&
!UserUtil.isLoggedIn(statusPageId)
) {
if (!StatusPageUtil.isMasterPasswordValidated()) {
StatusPageUtil.navigateToMasterPasswordPage();
}
return;
}
if (
statusPageId &&
StatusPageUtil.isPrivateStatusPage() &&