mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 08:42:13 +02:00
Compare commits
6 Commits
9.2.10
...
refresh-se
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e0d8b487c | ||
|
|
c41b53dd2a | ||
|
|
5fe445330b | ||
|
|
38c744ce8c | ||
|
|
b16743a669 | ||
|
|
286c639857 |
@@ -1,3 +1,4 @@
|
||||
import "./Utils/API";
|
||||
import App from "./App";
|
||||
import Telemetry from "Common/UI/Utils/Telemetry/Telemetry";
|
||||
import React from "react";
|
||||
|
||||
42
AdminDashboard/src/Utils/API.ts
Normal file
42
AdminDashboard/src/Utils/API.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import BaseAPI from "Common/UI/Utils/API/API";
|
||||
import { IDENTITY_URL } from "Common/UI/Config";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import { Logger } from "Common/UI/Utils/Logger";
|
||||
|
||||
const registerAdminDashboardAuthRefresh = (): void => {
|
||||
const refreshSession = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await BaseAPI.post<JSONObject>({
|
||||
url: URL.fromURL(IDENTITY_URL).addRoute("/refresh-session"),
|
||||
options: {
|
||||
skipAuthRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
Logger.warn(
|
||||
`Admin dashboard session refresh failed with status ${response.statusCode}.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return response.isSuccess();
|
||||
} catch (err) {
|
||||
Logger.error("Admin dashboard session refresh request failed.");
|
||||
Logger.error(err as Error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
BaseAPI.setRefreshSessionHandler(refreshSession);
|
||||
|
||||
BaseAPI.setRefreshFailureHandler(() => {
|
||||
Logger.warn("Admin dashboard session refresh failed; falling back to logout.");
|
||||
});
|
||||
};
|
||||
|
||||
registerAdminDashboardAuthRefresh();
|
||||
|
||||
export default BaseAPI;
|
||||
@@ -26,6 +26,9 @@ import MailService from "Common/Server/Services/MailService";
|
||||
import UserService from "Common/Server/Services/UserService";
|
||||
import UserTotpAuthService from "Common/Server/Services/UserTotpAuthService";
|
||||
import CookieUtil from "Common/Server/Utils/Cookie";
|
||||
import JSONWebToken, {
|
||||
RefreshTokenData,
|
||||
} from "Common/Server/Utils/JsonWebToken";
|
||||
import Express, {
|
||||
ExpressRequest,
|
||||
ExpressResponse,
|
||||
@@ -40,6 +43,10 @@ import User from "Common/Models/DatabaseModels/User";
|
||||
import UserTotpAuth from "Common/Models/DatabaseModels/UserTotpAuth";
|
||||
import UserWebAuthn from "Common/Models/DatabaseModels/UserWebAuthn";
|
||||
import UserWebAuthnService from "Common/Server/Services/UserWebAuthnService";
|
||||
import HashedString from "Common/Types/HashedString";
|
||||
import NotAuthenticatedException from "Common/Types/Exception/NotAuthenticatedException";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
import JSONWebTokenData from "Common/Types/JsonWebTokenData";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
@@ -186,12 +193,29 @@ router.post(
|
||||
// Refresh Permissions for this user here.
|
||||
await AccessTokenService.refreshUserAllPermissions(savedUser.id!);
|
||||
|
||||
CookieUtil.setUserCookie({
|
||||
const session = CookieUtil.setUserCookie({
|
||||
expressResponse: res,
|
||||
user: savedUser,
|
||||
isGlobalLogin: true,
|
||||
});
|
||||
|
||||
const hashedSessionId: string = await HashedString.hashValue(
|
||||
session.sessionId,
|
||||
EncryptionSecret,
|
||||
);
|
||||
|
||||
await UserService.updateOneBy({
|
||||
query: {
|
||||
_id: savedUser.id!,
|
||||
},
|
||||
data: {
|
||||
jwtRefreshToken: hashedSessionId,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("User signed up: " + savedUser.email?.toString());
|
||||
|
||||
return Response.sendEntityResponse(req, res, savedUser, User);
|
||||
@@ -495,6 +519,67 @@ router.post(
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const refreshToken: string | undefined =
|
||||
CookieUtil.getCookieFromExpressRequest(
|
||||
req,
|
||||
CookieUtil.getRefreshTokenKey(),
|
||||
);
|
||||
|
||||
let userIdToInvalidate: ObjectID | null = null;
|
||||
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const refreshTokenData: RefreshTokenData =
|
||||
JSONWebToken.decodeRefreshToken(refreshToken);
|
||||
userIdToInvalidate = refreshTokenData.userId;
|
||||
} catch (err) {
|
||||
const error: Error = err as Error;
|
||||
logger.warn(
|
||||
`Failed to decode refresh token during logout: ${
|
||||
error.message || "unknown error"
|
||||
}`,
|
||||
);
|
||||
logger.debug(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!userIdToInvalidate) {
|
||||
const accessToken: string | undefined =
|
||||
CookieUtil.getCookieFromExpressRequest(
|
||||
req,
|
||||
CookieUtil.getUserTokenKey(),
|
||||
);
|
||||
|
||||
if (accessToken) {
|
||||
try {
|
||||
const decoded: JSONWebTokenData = JSONWebToken.decode(accessToken);
|
||||
userIdToInvalidate = decoded.userId;
|
||||
} catch (err) {
|
||||
const error: Error = err as Error;
|
||||
logger.warn(
|
||||
`Failed to decode access token during logout: ${
|
||||
error.message || "unknown error"
|
||||
}`,
|
||||
);
|
||||
logger.debug(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userIdToInvalidate) {
|
||||
await UserService.updateOneBy({
|
||||
query: {
|
||||
_id: userIdToInvalidate,
|
||||
},
|
||||
data: {
|
||||
jwtRefreshToken: null!,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
CookieUtil.removeAllCookies(req, res);
|
||||
|
||||
return Response.sendEmptySuccessResponse(req, res);
|
||||
@@ -555,6 +640,122 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/refresh-session",
|
||||
async (
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const refreshToken: string | undefined =
|
||||
CookieUtil.getCookieFromExpressRequest(
|
||||
req,
|
||||
CookieUtil.getRefreshTokenKey(),
|
||||
);
|
||||
|
||||
if (!refreshToken) {
|
||||
CookieUtil.removeAllCookies(req, res);
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new NotAuthenticatedException("Refresh token missing"),
|
||||
);
|
||||
}
|
||||
|
||||
let refreshTokenData: RefreshTokenData;
|
||||
try {
|
||||
refreshTokenData = JSONWebToken.decodeRefreshToken(refreshToken);
|
||||
} catch (err) {
|
||||
const error: Error = err as Error;
|
||||
logger.warn(
|
||||
`Failed to decode refresh token: ${error.message || "unknown error"}`,
|
||||
);
|
||||
logger.debug(error);
|
||||
CookieUtil.removeAllCookies(req, res);
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new NotAuthenticatedException("Refresh token is invalid"),
|
||||
);
|
||||
}
|
||||
|
||||
const hashedSessionId: string = await HashedString.hashValue(
|
||||
refreshTokenData.sessionId,
|
||||
EncryptionSecret,
|
||||
);
|
||||
|
||||
const user: User | null = await UserService.findOneBy({
|
||||
query: {
|
||||
_id: refreshTokenData.userId,
|
||||
jwtRefreshToken: hashedSessionId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
isMasterAdmin: true,
|
||||
profilePictureId: true,
|
||||
timezone: true,
|
||||
enableTwoFactorAuth: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
CookieUtil.removeAllCookies(req, res);
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new NotAuthenticatedException("Refresh token does not match"),
|
||||
);
|
||||
}
|
||||
|
||||
const session = CookieUtil.setUserCookie({
|
||||
expressResponse: res,
|
||||
user: user,
|
||||
isGlobalLogin: refreshTokenData.isGlobalLogin,
|
||||
});
|
||||
|
||||
if (!req.cookies) {
|
||||
req.cookies = {} as Dictionary<string>;
|
||||
}
|
||||
|
||||
req.cookies[CookieUtil.getUserTokenKey()] = session.accessToken;
|
||||
req.cookies[CookieUtil.getRefreshTokenKey()] = session.refreshToken;
|
||||
|
||||
const hashedNewSessionId: string = await HashedString.hashValue(
|
||||
session.sessionId,
|
||||
EncryptionSecret,
|
||||
);
|
||||
|
||||
await UserService.updateOneBy({
|
||||
query: {
|
||||
_id: user.id!,
|
||||
},
|
||||
data: {
|
||||
jwtRefreshToken: hashedNewSessionId,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`User session refreshed: ${
|
||||
user.email?.toString() || user.id?.toString() || "unknown"
|
||||
}`,
|
||||
);
|
||||
|
||||
return Response.sendEntityResponse(req, res, user, User);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
type FetchTotpAuthListFunction = (userId: ObjectID) => Promise<{
|
||||
totpAuthList: Array<UserTotpAuth>;
|
||||
webAuthnList: Array<UserWebAuthn>;
|
||||
@@ -788,12 +989,29 @@ const login: LoginFunction = async (options: {
|
||||
if (alreadySavedUser.password.toString() === user.password!.toString()) {
|
||||
logger.info("User logged in: " + alreadySavedUser.email?.toString());
|
||||
|
||||
CookieUtil.setUserCookie({
|
||||
const session = CookieUtil.setUserCookie({
|
||||
expressResponse: res,
|
||||
user: alreadySavedUser,
|
||||
isGlobalLogin: true,
|
||||
});
|
||||
|
||||
const hashedSessionId: string = await HashedString.hashValue(
|
||||
session.sessionId,
|
||||
EncryptionSecret,
|
||||
);
|
||||
|
||||
await UserService.updateOneBy({
|
||||
query: {
|
||||
_id: alreadySavedUser.id!,
|
||||
},
|
||||
data: {
|
||||
jwtRefreshToken: hashedSessionId,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.sendEntityResponse(req, res, alreadySavedUser, User);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { JSONObject } from "Common/Types/JSON";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import PositiveNumber from "Common/Types/PositiveNumber";
|
||||
import DatabaseConfig from "Common/Server/DatabaseConfig";
|
||||
import { Host, HttpProtocol } from "Common/Server/EnvironmentConfig";
|
||||
import { EncryptionSecret, Host, HttpProtocol } from "Common/Server/EnvironmentConfig";
|
||||
import AccessTokenService from "Common/Server/Services/AccessTokenService";
|
||||
import ProjectSSOService from "Common/Server/Services/ProjectSsoService";
|
||||
import TeamMemberService from "Common/Server/Services/TeamMemberService";
|
||||
@@ -37,6 +37,7 @@ import TeamMember from "Common/Models/DatabaseModels/TeamMember";
|
||||
import User from "Common/Models/DatabaseModels/User";
|
||||
import xml2js from "xml2js";
|
||||
import Name from "Common/Types/Name";
|
||||
import HashedString from "Common/Types/HashedString";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
@@ -539,12 +540,29 @@ const loginUserWithSso: LoginUserWithSsoFunction = async (
|
||||
expressResponse: res,
|
||||
});
|
||||
|
||||
CookieUtil.setUserCookie({
|
||||
const session = CookieUtil.setUserCookie({
|
||||
expressResponse: res,
|
||||
user: alreadySavedUser,
|
||||
isGlobalLogin: false,
|
||||
});
|
||||
|
||||
const hashedSessionId: string = await HashedString.hashValue(
|
||||
session.sessionId,
|
||||
EncryptionSecret,
|
||||
);
|
||||
|
||||
await UserService.updateOneBy({
|
||||
query: {
|
||||
_id: alreadySavedUser.id!,
|
||||
},
|
||||
data: {
|
||||
jwtRefreshToken: hashedSessionId,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Refresh Permissions for this user here.
|
||||
await AccessTokenService.refreshUserAllPermissions(alreadySavedUser.id!);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import URL from "Common/Types/API/URL";
|
||||
import OneUptimeDate from "Common/Types/Date";
|
||||
import EmailTemplateType from "Common/Types/Email/EmailTemplateType";
|
||||
import BadDataException from "Common/Types/Exception/BadDataException";
|
||||
import NotAuthenticatedException from "Common/Types/Exception/NotAuthenticatedException";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import JSONFunctions from "Common/Types/JSONFunctions";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
@@ -22,11 +23,16 @@ import Express, {
|
||||
ExpressRouter,
|
||||
NextFunction,
|
||||
} from "Common/Server/Utils/Express";
|
||||
import JSONWebToken from "Common/Server/Utils/JsonWebToken";
|
||||
import JSONWebToken, {
|
||||
RefreshTokenData,
|
||||
} from "Common/Server/Utils/JsonWebToken";
|
||||
import logger from "Common/Server/Utils/Logger";
|
||||
import Response from "Common/Server/Utils/Response";
|
||||
import StatusPage from "Common/Models/DatabaseModels/StatusPage";
|
||||
import StatusPagePrivateUser from "Common/Models/DatabaseModels/StatusPagePrivateUser";
|
||||
import HashedString from "Common/Types/HashedString";
|
||||
import Dictionary from "Common/Types/Dictionary";
|
||||
import JSONWebTokenData from "Common/Types/JsonWebTokenData";
|
||||
|
||||
const router: ExpressRouter = Express.getRouter();
|
||||
|
||||
@@ -46,7 +52,79 @@ router.post(
|
||||
req.params["statuspageid"].toString(),
|
||||
);
|
||||
|
||||
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey(statusPageId)); // remove the cookie.
|
||||
const refreshTokenKey: string = CookieUtil.getRefreshTokenKey(statusPageId);
|
||||
const accessTokenKey: string = CookieUtil.getUserTokenKey(statusPageId);
|
||||
|
||||
const refreshToken: string | undefined =
|
||||
CookieUtil.getCookieFromExpressRequest(req, refreshTokenKey);
|
||||
|
||||
let userIdToInvalidate: ObjectID | null = null;
|
||||
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const refreshData: RefreshTokenData =
|
||||
JSONWebToken.decodeRefreshToken(refreshToken);
|
||||
|
||||
if (
|
||||
refreshData.statusPageId &&
|
||||
refreshData.statusPageId.toString() === statusPageId.toString()
|
||||
) {
|
||||
userIdToInvalidate = refreshData.userId;
|
||||
}
|
||||
} catch (err) {
|
||||
const error: Error = err as Error;
|
||||
logger.warn(
|
||||
`Failed to decode status page refresh token during logout: ${
|
||||
error.message || "unknown error"
|
||||
}`,
|
||||
);
|
||||
logger.debug(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!userIdToInvalidate) {
|
||||
const accessToken: string | undefined =
|
||||
CookieUtil.getCookieFromExpressRequest(req, accessTokenKey);
|
||||
|
||||
if (accessToken) {
|
||||
try {
|
||||
const decoded: JSONWebTokenData = JSONWebToken.decode(accessToken);
|
||||
|
||||
if (
|
||||
decoded.statusPageId &&
|
||||
decoded.statusPageId.toString() === statusPageId.toString()
|
||||
) {
|
||||
userIdToInvalidate = decoded.userId;
|
||||
}
|
||||
} catch (err) {
|
||||
const error: Error = err as Error;
|
||||
logger.warn(
|
||||
`Failed to decode status page access token during logout: ${
|
||||
error.message || "unknown error"
|
||||
}`,
|
||||
);
|
||||
logger.debug(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (userIdToInvalidate) {
|
||||
await StatusPagePrivateUserService.updateOneBy({
|
||||
query: {
|
||||
_id: userIdToInvalidate,
|
||||
statusPageId: statusPageId,
|
||||
},
|
||||
data: {
|
||||
jwtRefreshToken: null!,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
CookieUtil.removeCookie(res, accessTokenKey);
|
||||
CookieUtil.removeCookie(res, refreshTokenKey);
|
||||
|
||||
return Response.sendEmptySuccessResponse(req, res);
|
||||
} catch (err) {
|
||||
@@ -383,23 +461,41 @@ router.post(
|
||||
});
|
||||
|
||||
if (alreadySavedUser) {
|
||||
const token: string = JSONWebToken.sign({
|
||||
data: alreadySavedUser,
|
||||
expiresInSeconds: OneUptimeDate.getSecondsInDays(
|
||||
new PositiveNumber(30),
|
||||
),
|
||||
const session = CookieUtil.setStatusPageUserCookie({
|
||||
expressResponse: res,
|
||||
user: alreadySavedUser,
|
||||
statusPageId: alreadySavedUser.statusPageId!,
|
||||
});
|
||||
|
||||
CookieUtil.setCookie(
|
||||
res,
|
||||
CookieUtil.getUserTokenKey(alreadySavedUser.statusPageId!),
|
||||
token,
|
||||
{
|
||||
httpOnly: true,
|
||||
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
|
||||
},
|
||||
if (!req.cookies) {
|
||||
req.cookies = {} as Dictionary<string>;
|
||||
}
|
||||
|
||||
req.cookies[CookieUtil.getUserTokenKey(alreadySavedUser.statusPageId!)] =
|
||||
session.accessToken;
|
||||
req.cookies[
|
||||
CookieUtil.getRefreshTokenKey(alreadySavedUser.statusPageId!)
|
||||
] = session.refreshToken;
|
||||
|
||||
const hashedSessionId: string = await HashedString.hashValue(
|
||||
session.sessionId,
|
||||
EncryptionSecret,
|
||||
);
|
||||
|
||||
await StatusPagePrivateUserService.updateOneBy({
|
||||
query: {
|
||||
_id: alreadySavedUser.id!,
|
||||
statusPageId: alreadySavedUser.statusPageId!,
|
||||
},
|
||||
data: {
|
||||
jwtRefreshToken: hashedSessionId,
|
||||
lastActive: OneUptimeDate.getCurrentDate(),
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.sendEntityResponse(
|
||||
req,
|
||||
res,
|
||||
@@ -407,7 +503,11 @@ router.post(
|
||||
StatusPagePrivateUser,
|
||||
{
|
||||
miscData: {
|
||||
token: token,
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
accessTokenExpiresInSeconds: session.accessTokenExpiresInSeconds,
|
||||
refreshTokenExpiresInSeconds:
|
||||
session.refreshTokenExpiresInSeconds,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -421,4 +521,160 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/refresh-session/:statuspageid",
|
||||
async (
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
next: NextFunction,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
if (!req.params["statuspageid"]) {
|
||||
throw new BadDataException("Status Page ID is required.");
|
||||
}
|
||||
|
||||
const statusPageId: ObjectID = new ObjectID(
|
||||
req.params["statuspageid"].toString(),
|
||||
);
|
||||
|
||||
const refreshTokenKey: string = CookieUtil.getRefreshTokenKey(statusPageId);
|
||||
const accessTokenKey: string = CookieUtil.getUserTokenKey(statusPageId);
|
||||
|
||||
const refreshToken: string | undefined =
|
||||
CookieUtil.getCookieFromExpressRequest(req, refreshTokenKey);
|
||||
|
||||
if (!refreshToken) {
|
||||
CookieUtil.removeCookie(res, refreshTokenKey);
|
||||
CookieUtil.removeCookie(res, accessTokenKey);
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new NotAuthenticatedException("Refresh token missing."),
|
||||
);
|
||||
}
|
||||
|
||||
let refreshTokenData: RefreshTokenData;
|
||||
|
||||
try {
|
||||
refreshTokenData = JSONWebToken.decodeRefreshToken(refreshToken);
|
||||
} catch (err) {
|
||||
const error: Error = err as Error;
|
||||
logger.warn(
|
||||
`Failed to decode status page refresh token: ${
|
||||
error.message || "unknown error"
|
||||
}`,
|
||||
);
|
||||
logger.debug(error);
|
||||
CookieUtil.removeCookie(res, refreshTokenKey);
|
||||
CookieUtil.removeCookie(res, accessTokenKey);
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new NotAuthenticatedException("Refresh token is invalid."),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!refreshTokenData.statusPageId ||
|
||||
refreshTokenData.statusPageId.toString() !== statusPageId.toString()
|
||||
) {
|
||||
CookieUtil.removeCookie(res, refreshTokenKey);
|
||||
CookieUtil.removeCookie(res, accessTokenKey);
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new NotAuthenticatedException("Refresh token status page mismatch."),
|
||||
);
|
||||
}
|
||||
|
||||
const hashedSessionId: string = await HashedString.hashValue(
|
||||
refreshTokenData.sessionId,
|
||||
EncryptionSecret,
|
||||
);
|
||||
|
||||
const user: StatusPagePrivateUser | null =
|
||||
await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
_id: refreshTokenData.userId,
|
||||
statusPageId: statusPageId,
|
||||
jwtRefreshToken: hashedSessionId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
statusPageId: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
CookieUtil.removeCookie(res, refreshTokenKey);
|
||||
CookieUtil.removeCookie(res, accessTokenKey);
|
||||
return Response.sendErrorResponse(
|
||||
req,
|
||||
res,
|
||||
new NotAuthenticatedException("Refresh token does not match."),
|
||||
);
|
||||
}
|
||||
|
||||
const session = CookieUtil.setStatusPageUserCookie({
|
||||
expressResponse: res,
|
||||
user: user,
|
||||
statusPageId: statusPageId,
|
||||
});
|
||||
|
||||
if (!req.cookies) {
|
||||
req.cookies = {} as Dictionary<string>;
|
||||
}
|
||||
|
||||
req.cookies[accessTokenKey] = session.accessToken;
|
||||
req.cookies[refreshTokenKey] = session.refreshToken;
|
||||
|
||||
const hashedNewSessionId: string = await HashedString.hashValue(
|
||||
session.sessionId,
|
||||
EncryptionSecret,
|
||||
);
|
||||
|
||||
await StatusPagePrivateUserService.updateOneBy({
|
||||
query: {
|
||||
_id: user.id!,
|
||||
statusPageId: statusPageId,
|
||||
},
|
||||
data: {
|
||||
jwtRefreshToken: hashedNewSessionId,
|
||||
lastActive: OneUptimeDate.getCurrentDate(),
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Status page session refreshed: ${
|
||||
user.email?.toString() || user.id?.toString() || "unknown"
|
||||
} for status page ${statusPageId.toString()}`,
|
||||
);
|
||||
|
||||
return Response.sendEntityResponse(
|
||||
req,
|
||||
res,
|
||||
user,
|
||||
StatusPagePrivateUser,
|
||||
{
|
||||
miscData: {
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
accessTokenExpiresInSeconds: session.accessTokenExpiresInSeconds,
|
||||
refreshTokenExpiresInSeconds: session.refreshTokenExpiresInSeconds,
|
||||
},
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
return next(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -290,6 +290,21 @@ export default class StatusPagePrivateUser extends BaseModel {
|
||||
nullable: true,
|
||||
unique: false,
|
||||
})
|
||||
public jwtRefreshToken?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
create: [],
|
||||
read: [],
|
||||
|
||||
update: [],
|
||||
})
|
||||
@TableColumn({ type: TableColumnType.ShortText })
|
||||
@Column({
|
||||
type: ColumnType.ShortText,
|
||||
length: ColumnLength.ShortText,
|
||||
nullable: true,
|
||||
unique: false,
|
||||
})
|
||||
public resetPasswordToken?: string = undefined;
|
||||
|
||||
@ColumnAccessControl({
|
||||
|
||||
@@ -809,6 +809,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
await this.checkHasReadAccess({
|
||||
statusPageId: statusPageId,
|
||||
req: req,
|
||||
res: res,
|
||||
});
|
||||
|
||||
const resources: Array<StatusPageResource> =
|
||||
@@ -869,6 +870,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
await this.checkHasReadAccess({
|
||||
statusPageId: statusPageId,
|
||||
req: req,
|
||||
res: res,
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -1161,6 +1163,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
await this.checkHasReadAccess({
|
||||
statusPageId: statusPageId,
|
||||
req: req,
|
||||
res: res,
|
||||
});
|
||||
|
||||
const startDate: Date = OneUptimeDate.getSomeDaysAgo(90);
|
||||
@@ -1608,7 +1611,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
|
||||
try {
|
||||
await this.subscribeToStatusPage(req);
|
||||
await this.subscribeToStatusPage(req, res);
|
||||
return Response.sendEmptySuccessResponse(req, res);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -1645,7 +1648,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
|
||||
try {
|
||||
await this.subscribeToStatusPage(req);
|
||||
await this.subscribeToStatusPage(req, res);
|
||||
|
||||
return Response.sendEmptySuccessResponse(req, res);
|
||||
} catch (err) {
|
||||
@@ -1661,7 +1664,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
UserMiddleware.getUserMiddleware,
|
||||
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
|
||||
try {
|
||||
await this.manageExistingSubscription(req);
|
||||
await this.manageExistingSubscription(req, res);
|
||||
|
||||
return Response.sendEmptySuccessResponse(req, res);
|
||||
} catch (err) {
|
||||
@@ -1685,6 +1688,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
objectId,
|
||||
null,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, response);
|
||||
@@ -1708,8 +1712,8 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
const response: JSONObject = await this.getScheduledMaintenanceEvents(
|
||||
objectId,
|
||||
null,
|
||||
|
||||
req,
|
||||
res,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, response);
|
||||
@@ -1733,8 +1737,8 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
const response: JSONObject = await this.getAnnouncements(
|
||||
objectId,
|
||||
null,
|
||||
|
||||
req,
|
||||
res,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, response);
|
||||
@@ -1763,6 +1767,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
objectId,
|
||||
incidentId,
|
||||
req,
|
||||
res,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, response);
|
||||
@@ -1790,8 +1795,8 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
const response: JSONObject = await this.getScheduledMaintenanceEvents(
|
||||
objectId,
|
||||
scheduledMaintenanceId,
|
||||
|
||||
req,
|
||||
res,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, response);
|
||||
@@ -1819,8 +1824,8 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
const response: JSONObject = await this.getAnnouncements(
|
||||
objectId,
|
||||
announcementId,
|
||||
|
||||
req,
|
||||
res,
|
||||
);
|
||||
|
||||
return Response.sendJsonObjectResponse(req, res, response);
|
||||
@@ -1836,10 +1841,12 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
statusPageId: ObjectID,
|
||||
scheduledMaintenanceId: ObjectID | null,
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
): Promise<JSONObject> {
|
||||
await this.checkHasReadAccess({
|
||||
statusPageId: statusPageId,
|
||||
req: req,
|
||||
res: res,
|
||||
});
|
||||
|
||||
const statusPage: StatusPage | null = await StatusPageService.findOneBy({
|
||||
@@ -2153,10 +2160,12 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
statusPageId: ObjectID,
|
||||
announcementId: ObjectID | null,
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
): Promise<JSONObject> {
|
||||
await this.checkHasReadAccess({
|
||||
statusPageId: statusPageId,
|
||||
req: req,
|
||||
res: res,
|
||||
});
|
||||
|
||||
const statusPage: StatusPage | null = await StatusPageService.findOneBy({
|
||||
@@ -2328,7 +2337,10 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async manageExistingSubscription(req: ExpressRequest): Promise<void> {
|
||||
public async manageExistingSubscription(
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
): Promise<void> {
|
||||
const statusPageId: ObjectID = new ObjectID(
|
||||
req.params["statusPageId"] as string,
|
||||
);
|
||||
@@ -2340,6 +2352,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
await this.checkHasReadAccess({
|
||||
statusPageId: statusPageId,
|
||||
req: req,
|
||||
res: res,
|
||||
});
|
||||
|
||||
const statusPage: StatusPage | null = await StatusPageService.findOneBy({
|
||||
@@ -2603,7 +2616,10 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async subscribeToStatusPage(req: ExpressRequest): Promise<void> {
|
||||
public async subscribeToStatusPage(
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
): Promise<void> {
|
||||
const objectId: ObjectID = new ObjectID(
|
||||
req.params["statusPageId"] as string,
|
||||
);
|
||||
@@ -2613,6 +2629,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
await this.checkHasReadAccess({
|
||||
statusPageId: objectId,
|
||||
req: req,
|
||||
res: res,
|
||||
});
|
||||
|
||||
const statusPage: StatusPage | null = await StatusPageService.findOneBy({
|
||||
@@ -2980,10 +2997,12 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
statusPageId: ObjectID,
|
||||
incidentId: ObjectID | null,
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
): Promise<JSONObject> {
|
||||
await this.checkHasReadAccess({
|
||||
statusPageId: statusPageId,
|
||||
req: req,
|
||||
res: res,
|
||||
});
|
||||
|
||||
const statusPage: StatusPage | null = await StatusPageService.findOneBy({
|
||||
@@ -3492,6 +3511,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
public async checkHasReadAccess(data: {
|
||||
statusPageId: ObjectID;
|
||||
req: ExpressRequest;
|
||||
res: ExpressResponse;
|
||||
}): Promise<void> {
|
||||
const accessResult: {
|
||||
hasReadAccess: boolean;
|
||||
@@ -3499,6 +3519,7 @@ export default class StatusPageAPI extends BaseAPI<
|
||||
} = await this.service.hasReadAccess({
|
||||
statusPageId: data.statusPageId,
|
||||
req: data.req,
|
||||
res: data.res,
|
||||
});
|
||||
|
||||
if (!accessResult.hasReadAccess) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class MigrationName1762430566091 implements MigrationInterface {
|
||||
public name = 'MigrationName1762430566091'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "StatusPagePrivateUser" ADD "jwtRefreshToken" character varying(100)`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "StatusPagePrivateUser" DROP COLUMN "jwtRefreshToken"`);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -180,6 +180,7 @@ import { MigrationName1760357680881 } from "./1760357680881-MigrationName";
|
||||
import { MigrationName1761232578396 } from "./1761232578396-MigrationName";
|
||||
import { MigrationName1761834523183 } from "./1761834523183-MigrationName";
|
||||
import { MigrationName1762181014879 } from "./1762181014879-MigrationName";
|
||||
import { MigrationName1762430566091 } from "./1762430566091-MigrationName";
|
||||
|
||||
export default [
|
||||
InitialMigration,
|
||||
@@ -364,4 +365,5 @@ export default [
|
||||
MigrationName1761232578396,
|
||||
MigrationName1761834523183,
|
||||
MigrationName1762181014879,
|
||||
MigrationName1762430566091
|
||||
];
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
OneUptimeRequest,
|
||||
} from "../Utils/Express";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import JSONWebToken from "../Utils/JsonWebToken";
|
||||
import JSONWebToken, {
|
||||
RefreshTokenData,
|
||||
} from "../Utils/JsonWebToken";
|
||||
import logger from "../Utils/Logger";
|
||||
import Response from "../Utils/Response";
|
||||
import ProjectMiddleware from "./ProjectAuthorization";
|
||||
@@ -33,6 +35,8 @@ import {
|
||||
import UserType from "../../Types/UserType";
|
||||
import Project from "../../Models/DatabaseModels/Project";
|
||||
import UserPermissionUtil from "../Utils/UserPermission/UserPermission";
|
||||
import User from "../../Models/DatabaseModels/User";
|
||||
import { EncryptionSecret } from "../EnvironmentConfig";
|
||||
|
||||
export default class UserMiddleware {
|
||||
/*
|
||||
@@ -161,22 +165,44 @@ export default class UserMiddleware {
|
||||
);
|
||||
}
|
||||
|
||||
const accessToken: string | undefined =
|
||||
let accessToken: string | undefined =
|
||||
UserMiddleware.getAccessTokenFromExpressRequest(req);
|
||||
let userAuthorization: JSONWebTokenData | null = null;
|
||||
|
||||
if (!accessToken) {
|
||||
if (accessToken) {
|
||||
try {
|
||||
userAuthorization = JSONWebToken.decode(accessToken);
|
||||
} catch (err) {
|
||||
const error: Error = err as Error;
|
||||
logger.warn(
|
||||
`Invalid access token, attempting refresh: ${
|
||||
error.message || "unknown error"
|
||||
}`,
|
||||
);
|
||||
logger.debug(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!userAuthorization) {
|
||||
const refreshedSession:
|
||||
| {
|
||||
accessToken: string;
|
||||
userAuthorization: JSONWebTokenData;
|
||||
}
|
||||
| null = await UserMiddleware.tryRefreshSession(req, res);
|
||||
|
||||
if (refreshedSession) {
|
||||
accessToken = refreshedSession.accessToken;
|
||||
userAuthorization = refreshedSession.userAuthorization;
|
||||
}
|
||||
}
|
||||
|
||||
if (!userAuthorization) {
|
||||
oneuptimeRequest.userType = UserType.Public;
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
oneuptimeRequest.userAuthorization = JSONWebToken.decode(accessToken);
|
||||
} catch (err) {
|
||||
// if the token is invalid or expired, it'll throw this error.
|
||||
logger.error(err);
|
||||
oneuptimeRequest.userType = UserType.Public;
|
||||
return next();
|
||||
}
|
||||
oneuptimeRequest.userAuthorization = userAuthorization;
|
||||
|
||||
if (oneuptimeRequest.userAuthorization.isMasterAdmin) {
|
||||
oneuptimeRequest.userType = UserType.MasterAdmin;
|
||||
@@ -184,7 +210,7 @@ export default class UserMiddleware {
|
||||
oneuptimeRequest.userType = UserType.User;
|
||||
}
|
||||
|
||||
const userId: string = oneuptimeRequest.userAuthorization.userId.toString();
|
||||
const userId: string = userAuthorization.userId.toString();
|
||||
|
||||
await UserService.updateOneBy({
|
||||
query: {
|
||||
@@ -290,6 +316,113 @@ export default class UserMiddleware {
|
||||
return next();
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private static async tryRefreshSession(
|
||||
req: ExpressRequest,
|
||||
res: ExpressResponse,
|
||||
): Promise<
|
||||
| {
|
||||
accessToken: string;
|
||||
userAuthorization: JSONWebTokenData;
|
||||
}
|
||||
| null
|
||||
> {
|
||||
const refreshToken: string | undefined =
|
||||
CookieUtil.getCookieFromExpressRequest(
|
||||
req,
|
||||
CookieUtil.getRefreshTokenKey(),
|
||||
);
|
||||
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let refreshTokenData: RefreshTokenData;
|
||||
|
||||
try {
|
||||
refreshTokenData = JSONWebToken.decodeRefreshToken(refreshToken);
|
||||
} catch (err) {
|
||||
const error: Error = err as Error;
|
||||
logger.warn(
|
||||
`Failed to decode refresh token during middleware refresh: ${
|
||||
error.message || "unknown error"
|
||||
}`,
|
||||
);
|
||||
logger.debug(error);
|
||||
CookieUtil.removeCookie(res, CookieUtil.getRefreshTokenKey());
|
||||
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey());
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashedSessionId: string = await HashedString.hashValue(
|
||||
refreshTokenData.sessionId,
|
||||
EncryptionSecret,
|
||||
);
|
||||
|
||||
const user: User | null = await UserService.findOneBy({
|
||||
query: {
|
||||
_id: refreshTokenData.userId,
|
||||
jwtRefreshToken: hashedSessionId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
isMasterAdmin: true,
|
||||
profilePictureId: true,
|
||||
timezone: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
CookieUtil.removeCookie(res, CookieUtil.getRefreshTokenKey());
|
||||
CookieUtil.removeCookie(res, CookieUtil.getUserTokenKey());
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = CookieUtil.setUserCookie({
|
||||
expressResponse: res,
|
||||
user: user,
|
||||
isGlobalLogin: refreshTokenData.isGlobalLogin,
|
||||
});
|
||||
|
||||
if (!req.cookies) {
|
||||
req.cookies = {} as Dictionary<string>;
|
||||
}
|
||||
|
||||
req.cookies[CookieUtil.getUserTokenKey()] = session.accessToken;
|
||||
req.cookies[CookieUtil.getRefreshTokenKey()] = session.refreshToken;
|
||||
|
||||
const hashedNewSessionId: string = await HashedString.hashValue(
|
||||
session.sessionId,
|
||||
EncryptionSecret,
|
||||
);
|
||||
|
||||
await UserService.updateOneBy({
|
||||
query: {
|
||||
_id: user.id!,
|
||||
},
|
||||
data: {
|
||||
jwtRefreshToken: hashedNewSessionId,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
const userAuthorization: JSONWebTokenData = JSONWebToken.decode(
|
||||
session.accessToken,
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken: session.accessToken,
|
||||
userAuthorization,
|
||||
};
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static async getUserTenantAccessPermissionWithTenantId(data: {
|
||||
req: ExpressRequest;
|
||||
|
||||
@@ -3,8 +3,10 @@ import CreateBy from "../Types/Database/CreateBy";
|
||||
import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
|
||||
import UpdateBy from "../Types/Database/UpdateBy";
|
||||
import CookieUtil from "../Utils/Cookie";
|
||||
import { ExpressRequest } from "../Utils/Express";
|
||||
import JSONWebToken from "../Utils/JsonWebToken";
|
||||
import { ExpressRequest, ExpressResponse } from "../Utils/Express";
|
||||
import JSONWebToken, {
|
||||
RefreshTokenData,
|
||||
} from "../Utils/JsonWebToken";
|
||||
import logger from "../Utils/Logger";
|
||||
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
||||
import DatabaseService from "./DatabaseService";
|
||||
@@ -24,6 +26,7 @@ import BadDataException from "../../Types/Exception/BadDataException";
|
||||
import JSONWebTokenData from "../../Types/JsonWebTokenData";
|
||||
import ObjectID from "../../Types/ObjectID";
|
||||
import PositiveNumber from "../../Types/PositiveNumber";
|
||||
import HashedString from "../../Types/HashedString";
|
||||
import Typeof from "../../Types/Typeof";
|
||||
import MonitorStatus from "../../Models/DatabaseModels/MonitorStatus";
|
||||
import StatusPage from "../../Models/DatabaseModels/StatusPage";
|
||||
@@ -61,6 +64,9 @@ import IP from "../../Types/IP/IP";
|
||||
import NotAuthenticatedException from "../../Types/Exception/NotAuthenticatedException";
|
||||
import ForbiddenException from "../../Types/Exception/ForbiddenException";
|
||||
import CommonAPI from "../API/CommonAPI";
|
||||
import StatusPagePrivateUserService from "./StatusPagePrivateUserService";
|
||||
import StatusPagePrivateUser from "../../Models/DatabaseModels/StatusPagePrivateUser";
|
||||
import { EncryptionSecret } from "../EnvironmentConfig";
|
||||
|
||||
export interface StatusPageReportItem {
|
||||
resourceName: string;
|
||||
@@ -369,12 +375,14 @@ export class Service extends DatabaseService<StatusPage> {
|
||||
public async hasReadAccess(data: {
|
||||
statusPageId: ObjectID;
|
||||
req: ExpressRequest;
|
||||
res: ExpressResponse;
|
||||
}): Promise<{
|
||||
hasReadAccess: boolean;
|
||||
error?: NotAuthenticatedException | ForbiddenException;
|
||||
}> {
|
||||
const statusPageId: ObjectID = data.statusPageId;
|
||||
const req: ExpressRequest = data.req;
|
||||
const res: ExpressResponse = data.res;
|
||||
|
||||
const props: DatabaseCommonInteractionProps =
|
||||
await CommonAPI.getDatabaseCommonInteractionProps(req);
|
||||
@@ -446,20 +454,37 @@ export class Service extends DatabaseService<StatusPage> {
|
||||
CookieUtil.getUserTokenKey(statusPageId),
|
||||
);
|
||||
|
||||
let decoded: JSONWebTokenData | null = null;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const decoded: JSONWebTokenData = JSONWebToken.decode(
|
||||
token as string,
|
||||
);
|
||||
|
||||
if (decoded.statusPageId?.toString() === statusPageId.toString()) {
|
||||
return {
|
||||
hasReadAccess: true,
|
||||
};
|
||||
}
|
||||
decoded = JSONWebToken.decode(token as string);
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
const error: Error = err as Error;
|
||||
logger.warn(
|
||||
`Invalid status page access token, attempting refresh: ${
|
||||
error.message || "unknown error"
|
||||
}`,
|
||||
);
|
||||
logger.debug(error);
|
||||
decoded = await this.tryRefreshStatusPageSession({
|
||||
statusPageId,
|
||||
req,
|
||||
res,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
decoded = await this.tryRefreshStatusPageSession({
|
||||
statusPageId,
|
||||
req,
|
||||
res,
|
||||
});
|
||||
}
|
||||
|
||||
if (decoded && decoded.statusPageId?.toString() === statusPageId.toString()) {
|
||||
return {
|
||||
hasReadAccess: true,
|
||||
};
|
||||
}
|
||||
|
||||
// if it does not have public access, check if this user has access.
|
||||
@@ -493,6 +518,121 @@ export class Service extends DatabaseService<StatusPage> {
|
||||
};
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
private async tryRefreshStatusPageSession(data: {
|
||||
statusPageId: ObjectID;
|
||||
req: ExpressRequest;
|
||||
res: ExpressResponse;
|
||||
}): Promise<JSONWebTokenData | null> {
|
||||
const { statusPageId, req, res } = data;
|
||||
|
||||
const refreshTokenKey: string = CookieUtil.getRefreshTokenKey(statusPageId);
|
||||
const accessTokenKey: string = CookieUtil.getUserTokenKey(statusPageId);
|
||||
|
||||
const refreshToken: string | undefined = CookieUtil.getCookieFromExpressRequest(
|
||||
req,
|
||||
refreshTokenKey,
|
||||
);
|
||||
|
||||
if (!refreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let refreshTokenData: RefreshTokenData;
|
||||
|
||||
try {
|
||||
refreshTokenData = JSONWebToken.decodeRefreshToken(refreshToken);
|
||||
} catch (err) {
|
||||
const error: Error = err as Error;
|
||||
logger.warn(
|
||||
`Failed to decode status page refresh token during middleware refresh: ${
|
||||
error.message || "unknown error"
|
||||
}`,
|
||||
);
|
||||
logger.debug(error);
|
||||
CookieUtil.removeCookie(res, refreshTokenKey);
|
||||
CookieUtil.removeCookie(res, accessTokenKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
!refreshTokenData.statusPageId ||
|
||||
refreshTokenData.statusPageId.toString() !== statusPageId.toString()
|
||||
) {
|
||||
CookieUtil.removeCookie(res, refreshTokenKey);
|
||||
CookieUtil.removeCookie(res, accessTokenKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashedSessionId: string = await HashedString.hashValue(
|
||||
refreshTokenData.sessionId,
|
||||
EncryptionSecret,
|
||||
);
|
||||
|
||||
const user: StatusPagePrivateUser | null =
|
||||
await StatusPagePrivateUserService.findOneBy({
|
||||
query: {
|
||||
_id: refreshTokenData.userId,
|
||||
statusPageId: statusPageId,
|
||||
jwtRefreshToken: hashedSessionId,
|
||||
},
|
||||
select: {
|
||||
_id: true,
|
||||
email: true,
|
||||
statusPageId: true,
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
CookieUtil.removeCookie(res, refreshTokenKey);
|
||||
CookieUtil.removeCookie(res, accessTokenKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = CookieUtil.setStatusPageUserCookie({
|
||||
expressResponse: res,
|
||||
user: user,
|
||||
statusPageId: statusPageId,
|
||||
});
|
||||
|
||||
if (!req.cookies) {
|
||||
req.cookies = {} as Dictionary<string>;
|
||||
}
|
||||
|
||||
req.cookies[accessTokenKey] = session.accessToken;
|
||||
req.cookies[refreshTokenKey] = session.refreshToken;
|
||||
|
||||
const hashedNewSessionId: string = await HashedString.hashValue(
|
||||
session.sessionId,
|
||||
EncryptionSecret,
|
||||
);
|
||||
|
||||
await StatusPagePrivateUserService.updateOneBy({
|
||||
query: {
|
||||
_id: user.id!,
|
||||
statusPageId: statusPageId,
|
||||
},
|
||||
data: {
|
||||
jwtRefreshToken: hashedNewSessionId,
|
||||
lastActive: OneUptimeDate.getCurrentDate(),
|
||||
},
|
||||
props: {
|
||||
isRoot: true,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Status page session refreshed automatically for ${
|
||||
user.email?.toString() || user.id?.toString() || "unknown"
|
||||
} on status page ${statusPageId.toString()}`,
|
||||
);
|
||||
|
||||
return JSONWebToken.decode(session.accessToken);
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public async getMonitorStatusTimelineForStatusPage(data: {
|
||||
monitorIds: Array<ObjectID>;
|
||||
|
||||
@@ -4,10 +4,20 @@ import ObjectID from "../../Types/ObjectID";
|
||||
import { CookieOptions } from "express";
|
||||
import JSONWebToken from "./JsonWebToken";
|
||||
import User from "../../Models/DatabaseModels/User";
|
||||
import StatusPagePrivateUser from "../../Models/DatabaseModels/StatusPagePrivateUser";
|
||||
import OneUptimeDate from "../../Types/Date";
|
||||
import PositiveNumber from "../../Types/PositiveNumber";
|
||||
import CookieName from "../../Types/CookieName";
|
||||
import CaptureSpan from "./Telemetry/CaptureSpan";
|
||||
import { IsProduction } from "../EnvironmentConfig";
|
||||
|
||||
export interface UserSessionCookieResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
sessionId: string;
|
||||
accessTokenExpiresInSeconds: number;
|
||||
refreshTokenExpiresInSeconds: number;
|
||||
}
|
||||
|
||||
export default class CookieUtil {
|
||||
// set cookie with express response
|
||||
@@ -58,9 +68,16 @@ export default class CookieUtil {
|
||||
expressResponse: ExpressResponse;
|
||||
user: User;
|
||||
isGlobalLogin: boolean;
|
||||
}): void {
|
||||
}): UserSessionCookieResult {
|
||||
const { expressResponse: res, user, isGlobalLogin } = data;
|
||||
|
||||
const accessTokenExpiresInSeconds: number = 15 * 60; // 15 minutes
|
||||
const refreshTokenExpiresInSeconds: number = OneUptimeDate.getSecondsInDays(
|
||||
new PositiveNumber(30),
|
||||
);
|
||||
|
||||
const sessionId: string = ObjectID.generate().toString();
|
||||
|
||||
const token: string = JSONWebToken.signUserLoginToken({
|
||||
tokenData: {
|
||||
userId: user.id!,
|
||||
@@ -70,19 +87,39 @@ export default class CookieUtil {
|
||||
isMasterAdmin: user.isMasterAdmin!,
|
||||
isGlobalLogin: isGlobalLogin, // This is a general login without SSO. So, we will set this to true. This will give access to all the projects that dont require SSO.
|
||||
},
|
||||
expiresInSeconds: OneUptimeDate.getSecondsInDays(new PositiveNumber(30)),
|
||||
expiresInSeconds: accessTokenExpiresInSeconds,
|
||||
});
|
||||
|
||||
const refreshToken: string = JSONWebToken.signRefreshToken({
|
||||
userId: user.id!,
|
||||
sessionId: sessionId,
|
||||
isGlobalLogin: isGlobalLogin,
|
||||
statusPageId: null,
|
||||
expiresInSeconds: refreshTokenExpiresInSeconds,
|
||||
});
|
||||
|
||||
// Set a cookie with token.
|
||||
CookieUtil.setCookie(res, CookieUtil.getUserTokenKey(), token, {
|
||||
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
|
||||
maxAge: accessTokenExpiresInSeconds * 1000,
|
||||
httpOnly: true,
|
||||
});
|
||||
|
||||
CookieUtil.setCookie(
|
||||
res,
|
||||
CookieUtil.getRefreshTokenKey(),
|
||||
refreshToken,
|
||||
{
|
||||
maxAge: refreshTokenExpiresInSeconds * 1000,
|
||||
httpOnly: true,
|
||||
},
|
||||
);
|
||||
|
||||
const persistentCookieMaxAge: number = refreshTokenExpiresInSeconds * 1000;
|
||||
|
||||
if (user.id) {
|
||||
// set user id cookie
|
||||
CookieUtil.setCookie(res, CookieName.UserID, user.id!.toString(), {
|
||||
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
|
||||
maxAge: persistentCookieMaxAge,
|
||||
httpOnly: false,
|
||||
});
|
||||
}
|
||||
@@ -94,7 +131,7 @@ export default class CookieUtil {
|
||||
CookieName.Email,
|
||||
user.email?.toString() || "",
|
||||
{
|
||||
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
|
||||
maxAge: persistentCookieMaxAge,
|
||||
httpOnly: false,
|
||||
},
|
||||
);
|
||||
@@ -103,7 +140,7 @@ export default class CookieUtil {
|
||||
if (user.name) {
|
||||
// set user name cookie
|
||||
CookieUtil.setCookie(res, CookieName.Name, user.name?.toString() || "", {
|
||||
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
|
||||
maxAge: persistentCookieMaxAge,
|
||||
httpOnly: false,
|
||||
});
|
||||
}
|
||||
@@ -115,7 +152,7 @@ export default class CookieUtil {
|
||||
CookieName.Timezone,
|
||||
user.timezone?.toString() || "",
|
||||
{
|
||||
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
|
||||
maxAge: persistentCookieMaxAge,
|
||||
httpOnly: false,
|
||||
},
|
||||
);
|
||||
@@ -128,7 +165,7 @@ export default class CookieUtil {
|
||||
CookieName.IsMasterAdmin,
|
||||
user.isMasterAdmin?.toString() || "",
|
||||
{
|
||||
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
|
||||
maxAge: persistentCookieMaxAge,
|
||||
httpOnly: false,
|
||||
},
|
||||
);
|
||||
@@ -141,11 +178,78 @@ export default class CookieUtil {
|
||||
CookieName.ProfilePicID,
|
||||
user.profilePictureId?.toString() || "",
|
||||
{
|
||||
maxAge: OneUptimeDate.getMillisecondsInDays(new PositiveNumber(30)),
|
||||
maxAge: persistentCookieMaxAge,
|
||||
httpOnly: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: token,
|
||||
refreshToken: refreshToken,
|
||||
sessionId: sessionId,
|
||||
accessTokenExpiresInSeconds,
|
||||
refreshTokenExpiresInSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static setStatusPageUserCookie(data: {
|
||||
expressResponse: ExpressResponse;
|
||||
user: StatusPagePrivateUser;
|
||||
statusPageId: ObjectID;
|
||||
}): UserSessionCookieResult {
|
||||
const { expressResponse: res, user, statusPageId } = data;
|
||||
|
||||
const accessTokenExpiresInSeconds: number = 15 * 60; // 15 minutes
|
||||
const refreshTokenExpiresInSeconds: number = OneUptimeDate.getSecondsInDays(
|
||||
new PositiveNumber(30),
|
||||
);
|
||||
|
||||
const sessionId: string = ObjectID.generate().toString();
|
||||
|
||||
const accessToken: string = JSONWebToken.signStatusPageUserLoginToken({
|
||||
userId: user.id!,
|
||||
email: user.email!,
|
||||
statusPageId: statusPageId,
|
||||
expiresInSeconds: accessTokenExpiresInSeconds,
|
||||
});
|
||||
|
||||
const refreshToken: string = JSONWebToken.signRefreshToken({
|
||||
userId: user.id!,
|
||||
sessionId: sessionId,
|
||||
isGlobalLogin: false,
|
||||
statusPageId: statusPageId,
|
||||
expiresInSeconds: refreshTokenExpiresInSeconds,
|
||||
});
|
||||
|
||||
CookieUtil.setCookie(
|
||||
res,
|
||||
CookieUtil.getUserTokenKey(statusPageId),
|
||||
accessToken,
|
||||
{
|
||||
maxAge: accessTokenExpiresInSeconds * 1000,
|
||||
httpOnly: true,
|
||||
},
|
||||
);
|
||||
|
||||
CookieUtil.setCookie(
|
||||
res,
|
||||
CookieUtil.getRefreshTokenKey(statusPageId),
|
||||
refreshToken,
|
||||
{
|
||||
maxAge: refreshTokenExpiresInSeconds * 1000,
|
||||
httpOnly: true,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
sessionId: sessionId,
|
||||
accessTokenExpiresInSeconds,
|
||||
refreshTokenExpiresInSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
@@ -155,7 +259,27 @@ export default class CookieUtil {
|
||||
value: string,
|
||||
options: CookieOptions,
|
||||
): void {
|
||||
res.cookie(name, value, options);
|
||||
const finalOptions: CookieOptions = {
|
||||
...options,
|
||||
};
|
||||
|
||||
if (finalOptions.path === undefined) {
|
||||
finalOptions.path = "/";
|
||||
}
|
||||
|
||||
if (finalOptions.sameSite === undefined) {
|
||||
finalOptions.sameSite = "lax";
|
||||
}
|
||||
|
||||
if (finalOptions.secure === undefined) {
|
||||
finalOptions.secure = IsProduction;
|
||||
}
|
||||
|
||||
if (finalOptions.httpOnly === undefined) {
|
||||
finalOptions.httpOnly = true;
|
||||
}
|
||||
|
||||
res.cookie(name, value, finalOptions);
|
||||
}
|
||||
|
||||
// get cookie with express request
|
||||
@@ -190,6 +314,15 @@ export default class CookieUtil {
|
||||
return `${CookieName.Token}-${id.toString()}`;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static getRefreshTokenKey(id?: ObjectID): string {
|
||||
if (!id) {
|
||||
return CookieName.RefreshToken;
|
||||
}
|
||||
|
||||
return `${CookieName.RefreshToken}-${id.toString()}`;
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static getUserSSOKey(id: ObjectID): string {
|
||||
return `${this.getSSOKey()}${id.toString()}`;
|
||||
|
||||
@@ -13,6 +13,13 @@ import jwt from "jsonwebtoken";
|
||||
import logger from "./Logger";
|
||||
import CaptureSpan from "./Telemetry/CaptureSpan";
|
||||
|
||||
export interface RefreshTokenData {
|
||||
userId: ObjectID;
|
||||
sessionId: string;
|
||||
isGlobalLogin: boolean;
|
||||
statusPageId?: ObjectID;
|
||||
}
|
||||
|
||||
class JSONWebToken {
|
||||
@CaptureSpan()
|
||||
public static signUserLoginToken(data: {
|
||||
@@ -33,6 +40,46 @@ class JSONWebToken {
|
||||
});
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static signRefreshToken(data: {
|
||||
userId: ObjectID;
|
||||
sessionId: string;
|
||||
isGlobalLogin: boolean;
|
||||
statusPageId?: ObjectID | null;
|
||||
expiresInSeconds: number;
|
||||
}): string {
|
||||
const payload: JSONObject = {
|
||||
tokenType: "refresh",
|
||||
userId: data.userId.toString(),
|
||||
sessionId: data.sessionId,
|
||||
isGlobalLogin: data.isGlobalLogin,
|
||||
};
|
||||
|
||||
if (data.statusPageId) {
|
||||
payload["statusPageId"] = data.statusPageId.toString();
|
||||
}
|
||||
|
||||
return JSONWebToken.signJsonPayload(payload, data.expiresInSeconds);
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static signStatusPageUserLoginToken(data: {
|
||||
userId: ObjectID;
|
||||
email: Email;
|
||||
statusPageId: ObjectID;
|
||||
expiresInSeconds: number;
|
||||
}): string {
|
||||
return JSONWebToken.signJsonPayload(
|
||||
{
|
||||
userId: data.userId.toString(),
|
||||
email: data.email.toString(),
|
||||
statusPageId: data.statusPageId.toString(),
|
||||
isMasterAdmin: false,
|
||||
},
|
||||
data.expiresInSeconds,
|
||||
);
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static sign(props: {
|
||||
data: JSONWebTokenData | User | StatusPagePrivateUser | string | JSONObject;
|
||||
@@ -124,6 +171,42 @@ class JSONWebToken {
|
||||
throw new BadDataException("AccessToken is invalid or expired");
|
||||
}
|
||||
}
|
||||
|
||||
@CaptureSpan()
|
||||
public static decodeRefreshToken(token: string): RefreshTokenData {
|
||||
try {
|
||||
const decoded: JSONObject = JSONWebToken.decodeJsonPayload(token);
|
||||
|
||||
if (decoded["tokenType"] !== "refresh") {
|
||||
throw new BadDataException("Invalid refresh token");
|
||||
}
|
||||
|
||||
if (!decoded["sessionId"] || typeof decoded["sessionId"] !== "string") {
|
||||
throw new BadDataException("Invalid refresh token session");
|
||||
}
|
||||
|
||||
if (!decoded["userId"] || typeof decoded["userId"] !== "string") {
|
||||
throw new BadDataException("Invalid refresh token user");
|
||||
}
|
||||
|
||||
const refreshData: RefreshTokenData = {
|
||||
userId: new ObjectID(decoded["userId"] as string),
|
||||
sessionId: decoded["sessionId"] as string,
|
||||
isGlobalLogin: Boolean(decoded["isGlobalLogin"]),
|
||||
};
|
||||
|
||||
if (decoded["statusPageId"]) {
|
||||
refreshData.statusPageId = new ObjectID(
|
||||
decoded["statusPageId"] as string,
|
||||
);
|
||||
}
|
||||
|
||||
return refreshData;
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
throw new BadDataException("RefreshToken is invalid or expired");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default JSONWebToken;
|
||||
|
||||
@@ -38,7 +38,11 @@ describe("CookieUtils", () => {
|
||||
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||
cookie["name"] as string,
|
||||
cookie["value"] as string,
|
||||
cookie["options"] as JSONObject,
|
||||
expect.objectContaining({
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure: expect.any(Boolean),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -88,6 +92,15 @@ describe("CookieUtils", () => {
|
||||
expect(keyWithoutId).toBe("user-token");
|
||||
});
|
||||
|
||||
test("Should return refresh token key", () => {
|
||||
const baseKey: string = CookieUtil.getRefreshTokenKey();
|
||||
const id: ObjectID = ObjectID.generate();
|
||||
const namespacedKey: string = CookieUtil.getRefreshTokenKey(id);
|
||||
|
||||
expect(baseKey).toBe("user-refresh-token");
|
||||
expect(namespacedKey).toBe(`user-refresh-token-${id.toString()}`);
|
||||
});
|
||||
|
||||
test("Should return SSO key", () => {
|
||||
const ssoKey: string = CookieUtil.getSSOKey();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ enum CookieName {
|
||||
UserID = "user-id",
|
||||
Email = "user-email",
|
||||
Token = "user-token",
|
||||
RefreshToken = "user-refresh-token",
|
||||
Name = "user-name",
|
||||
Timezone = "user-timezone",
|
||||
IsMasterAdmin = "user-is-master-admin",
|
||||
|
||||
@@ -16,9 +16,23 @@ import {
|
||||
UserGlobalAccessPermission,
|
||||
UserTenantAccessPermission,
|
||||
} from "../../../Types/Permission";
|
||||
import API from "../../../Utils/API";
|
||||
import API, { APIErrorRetryContext } from "../../../Utils/API";
|
||||
|
||||
type RefreshSessionHandler = () => Promise<boolean>;
|
||||
type ShouldAttemptRefreshHandler = (
|
||||
error: HTTPErrorResponse,
|
||||
context: APIErrorRetryContext,
|
||||
) => boolean | Promise<boolean>;
|
||||
type RefreshFailureHandler = (error: HTTPErrorResponse) => void;
|
||||
|
||||
class BaseAPI extends API {
|
||||
private static refreshSessionHandler: RefreshSessionHandler | null = null;
|
||||
private static shouldAttemptRefreshHandler:
|
||||
| ShouldAttemptRefreshHandler
|
||||
| null = null;
|
||||
private static refreshFailureHandler: RefreshFailureHandler | null = null;
|
||||
private static refreshSessionPromise: Promise<boolean> | null = null;
|
||||
|
||||
public constructor(protocol: Protocol, hostname: Hostname, route?: Route) {
|
||||
super(protocol, hostname, route);
|
||||
}
|
||||
@@ -27,6 +41,24 @@ class BaseAPI extends API {
|
||||
return new BaseAPI(url.protocol, url.hostname, url.route);
|
||||
}
|
||||
|
||||
public static setRefreshSessionHandler(
|
||||
handler: RefreshSessionHandler | null,
|
||||
): void {
|
||||
this.refreshSessionHandler = handler;
|
||||
}
|
||||
|
||||
public static setShouldAttemptRefreshHandler(
|
||||
handler: ShouldAttemptRefreshHandler | null,
|
||||
): void {
|
||||
this.shouldAttemptRefreshHandler = handler;
|
||||
}
|
||||
|
||||
public static setRefreshFailureHandler(
|
||||
handler: RefreshFailureHandler | null,
|
||||
): void {
|
||||
this.refreshFailureHandler = handler;
|
||||
}
|
||||
|
||||
protected static override async onResponseSuccessHeaders(
|
||||
headers: Dictionary<string>,
|
||||
): Promise<Dictionary<string>> {
|
||||
@@ -95,9 +127,35 @@ class BaseAPI extends API {
|
||||
return User.logout();
|
||||
}
|
||||
|
||||
public static override handleError(
|
||||
protected static override async shouldRetryAfterError(
|
||||
error: HTTPErrorResponse,
|
||||
context: APIErrorRetryContext,
|
||||
): Promise<boolean> {
|
||||
if (context.options?.skipAuthRefresh) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.retryCount > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(await this.shouldAttemptAuthRefresh(error, context))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const refreshed: boolean = await this.refreshAuthSession();
|
||||
|
||||
if (refreshed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.handleRefreshFailure(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
public static override async handleError(
|
||||
error: HTTPErrorResponse | APIException,
|
||||
): HTTPErrorResponse | APIException {
|
||||
): Promise<HTTPErrorResponse | APIException> {
|
||||
/*
|
||||
* 405 Status - Tenant not found. If Project was deleted.
|
||||
* 401 Status - User is not logged in.
|
||||
@@ -134,6 +192,60 @@ class BaseAPI extends API {
|
||||
return error;
|
||||
}
|
||||
|
||||
private static async shouldAttemptAuthRefresh(
|
||||
error: HTTPErrorResponse,
|
||||
context: APIErrorRetryContext,
|
||||
): Promise<boolean> {
|
||||
if (!this.refreshSessionHandler) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.shouldAttemptRefreshHandler) {
|
||||
try {
|
||||
return await this.shouldAttemptRefreshHandler(error, context);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return error.statusCode === 401 || error.statusCode === 405;
|
||||
}
|
||||
|
||||
private static async refreshAuthSession(): Promise<boolean> {
|
||||
if (!this.refreshSessionHandler) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.refreshSessionPromise) {
|
||||
this.refreshSessionPromise = this.refreshSessionHandler()
|
||||
.then((result: boolean) => {
|
||||
return result;
|
||||
})
|
||||
.catch(() => {
|
||||
return false;
|
||||
})
|
||||
.finally(() => {
|
||||
this.refreshSessionPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.refreshSessionPromise;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static handleRefreshFailure(error: HTTPErrorResponse): void {
|
||||
if (this.refreshFailureHandler) {
|
||||
try {
|
||||
this.refreshFailureHandler(error);
|
||||
} catch {
|
||||
// no-op if handler throws
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected static getLoginRoute(): Route {
|
||||
return new Route("/accounts/login");
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface RequestOptions {
|
||||
exponentialBackoff?: boolean | undefined;
|
||||
timeout?: number | undefined;
|
||||
doNotFollowRedirects?: boolean | undefined;
|
||||
skipAuthRefresh?: boolean | undefined;
|
||||
// Per-request proxy agent support (Probe supplies these instead of mutating global axios defaults)
|
||||
httpAgent?: HttpAgent | undefined;
|
||||
httpsAgent?: HttpsAgent | undefined;
|
||||
@@ -43,6 +44,17 @@ export interface APIFetchOptions {
|
||||
options?: RequestOptions;
|
||||
}
|
||||
|
||||
export interface APIErrorRetryContext {
|
||||
method: HTTPMethod;
|
||||
url: URL;
|
||||
data?: JSONObject | JSONArray | undefined;
|
||||
headers?: Headers | undefined;
|
||||
resolvedHeaders: Headers;
|
||||
params?: Dictionary<string> | undefined;
|
||||
options?: RequestOptions | undefined;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
export default class API {
|
||||
private _protocol: Protocol = Protocol.HTTPS;
|
||||
public get protocol(): Protocol {
|
||||
@@ -113,12 +125,19 @@ export default class API {
|
||||
return await API.patch<T>(options);
|
||||
}
|
||||
|
||||
public static handleError(
|
||||
public static async handleError(
|
||||
error: HTTPErrorResponse | APIException,
|
||||
): HTTPErrorResponse | APIException {
|
||||
): Promise<HTTPErrorResponse | APIException> {
|
||||
return error;
|
||||
}
|
||||
|
||||
protected static async shouldRetryAfterError(
|
||||
_error: HTTPErrorResponse,
|
||||
_context: APIErrorRetryContext,
|
||||
): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected static async onResponseSuccessHeaders(
|
||||
headers: Dictionary<string>,
|
||||
): Promise<Dictionary<string>> {
|
||||
@@ -330,24 +349,25 @@ export default class API {
|
||||
headers?: Headers,
|
||||
params?: Dictionary<string>,
|
||||
options?: RequestOptions,
|
||||
retryCount: number = 0,
|
||||
): Promise<HTTPResponse<T> | HTTPErrorResponse> {
|
||||
const baseUrl: URL = URL.fromURL(url);
|
||||
const requestUrl: URL = URL.fromURL(url);
|
||||
|
||||
const apiHeaders: Headers = this.getHeaders(headers);
|
||||
|
||||
if (params) {
|
||||
url.addQueryParams(params);
|
||||
requestUrl.addQueryParams(params);
|
||||
}
|
||||
|
||||
let finalHeaders: Dictionary<string> = {
|
||||
...apiHeaders,
|
||||
...(headers || {}),
|
||||
};
|
||||
|
||||
let finalBody: JSONObject | JSONArray | URLSearchParams | undefined = data;
|
||||
|
||||
try {
|
||||
const finalHeaders: Dictionary<string> = {
|
||||
...apiHeaders,
|
||||
...headers,
|
||||
};
|
||||
|
||||
let finalBody: JSONObject | JSONArray | URLSearchParams | undefined =
|
||||
data;
|
||||
|
||||
// if content-type is form-url-encoded, then stringify the data
|
||||
|
||||
if (
|
||||
finalHeaders["Content-Type"] === "application/x-www-form-urlencoded" &&
|
||||
data
|
||||
@@ -366,7 +386,7 @@ export default class API {
|
||||
try {
|
||||
const axiosOptions: AxiosRequestConfig = {
|
||||
method: method,
|
||||
url: url.toString(),
|
||||
url: requestUrl.toString(),
|
||||
headers: finalHeaders,
|
||||
data: finalBody,
|
||||
};
|
||||
@@ -429,7 +449,32 @@ export default class API {
|
||||
throw new APIException(error.message);
|
||||
}
|
||||
|
||||
this.handleError(errorResponse);
|
||||
const shouldRetry: boolean =
|
||||
errorResponse instanceof HTTPErrorResponse &&
|
||||
(await this.shouldRetryAfterError(errorResponse, {
|
||||
method,
|
||||
url: baseUrl,
|
||||
data,
|
||||
headers,
|
||||
resolvedHeaders: finalHeaders as Headers,
|
||||
params,
|
||||
options,
|
||||
retryCount,
|
||||
}));
|
||||
|
||||
if (shouldRetry) {
|
||||
return await this.fetchInternal(
|
||||
method,
|
||||
URL.fromURL(baseUrl),
|
||||
data,
|
||||
headers,
|
||||
params,
|
||||
options,
|
||||
retryCount + 1,
|
||||
);
|
||||
}
|
||||
|
||||
await this.handleError(errorResponse);
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./Utils/API";
|
||||
import App from "./App";
|
||||
import Telemetry from "Common/UI/Utils/Telemetry/Telemetry";
|
||||
import React from "react";
|
||||
|
||||
42
Dashboard/src/Utils/API.ts
Normal file
42
Dashboard/src/Utils/API.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import BaseAPI from "Common/UI/Utils/API/API";
|
||||
import { IDENTITY_URL } from "Common/UI/Config";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import { Logger } from "Common/UI/Utils/Logger";
|
||||
|
||||
const registerDashboardAuthRefresh = (): void => {
|
||||
const refreshSession = async (): Promise<boolean> => {
|
||||
try {
|
||||
const response = await BaseAPI.post<JSONObject>({
|
||||
url: URL.fromURL(IDENTITY_URL).addRoute("/refresh-session"),
|
||||
options: {
|
||||
skipAuthRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
Logger.warn(
|
||||
`Dashboard session refresh failed with status ${response.statusCode}.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return response.isSuccess();
|
||||
} catch (err) {
|
||||
Logger.error("Dashboard session refresh request failed.");
|
||||
Logger.error(err as Error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
BaseAPI.setRefreshSessionHandler(refreshSession);
|
||||
|
||||
BaseAPI.setRefreshFailureHandler(() => {
|
||||
Logger.warn("Dashboard session refresh failed. Logging out user.");
|
||||
});
|
||||
};
|
||||
|
||||
registerDashboardAuthRefresh();
|
||||
|
||||
export default BaseAPI;
|
||||
@@ -1,3 +1,4 @@
|
||||
import "./Utils/API";
|
||||
import App from "./App";
|
||||
import Telemetry from "Common/UI/Utils/Telemetry/Telemetry";
|
||||
import React from "react";
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import StatusPageUtil from "./StatusPage";
|
||||
import Headers from "Common/Types/API/Headers";
|
||||
import HTTPErrorResponse from "Common/Types/API/HTTPErrorResponse";
|
||||
import Route from "Common/Types/API/Route";
|
||||
import URL from "Common/Types/API/URL";
|
||||
import ObjectID from "Common/Types/ObjectID";
|
||||
import { JSONObject } from "Common/Types/JSON";
|
||||
import { IDENTITY_URL } from "Common/UI/Config";
|
||||
import BaseAPI from "Common/UI/Utils/API/API";
|
||||
import { Logger } from "Common/UI/Utils/Logger";
|
||||
import UserUtil from "./User";
|
||||
|
||||
export default class API extends BaseAPI {
|
||||
@@ -25,7 +30,13 @@ export default class API extends BaseAPI {
|
||||
}
|
||||
|
||||
public static override logoutUser(): void {
|
||||
UserUtil.logout(StatusPageUtil.getStatusPageId()!);
|
||||
const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId();
|
||||
|
||||
if (!statusPageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void UserUtil.logout(statusPageId);
|
||||
}
|
||||
|
||||
public static override getForbiddenRoute(): Route {
|
||||
@@ -36,3 +47,50 @@ export default class API extends BaseAPI {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const registerStatusPageAuthRefresh = (): void => {
|
||||
const refreshSession = async (): Promise<boolean> => {
|
||||
const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId();
|
||||
|
||||
if (!statusPageId) {
|
||||
Logger.warn("Skipping status page session refresh: missing status page id.");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await API.post<JSONObject>({
|
||||
url: URL.fromURL(IDENTITY_URL)
|
||||
.addRoute("/status-page/refresh-session")
|
||||
.addRoute(`/${statusPageId.toString()}`),
|
||||
options: {
|
||||
skipAuthRefresh: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (response instanceof HTTPErrorResponse) {
|
||||
Logger.warn(
|
||||
`Status page session refresh failed with status ${response.statusCode}.`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return response.isSuccess();
|
||||
} catch (err) {
|
||||
Logger.error("Status page session refresh request failed.");
|
||||
Logger.error(err as Error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
API.setRefreshSessionHandler(refreshSession);
|
||||
|
||||
API.setRefreshFailureHandler(() => {
|
||||
const statusPageId: ObjectID | null = StatusPageUtil.getStatusPageId();
|
||||
|
||||
if (statusPageId) {
|
||||
void UserUtil.logout(statusPageId);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
registerStatusPageAuthRefresh();
|
||||
|
||||
Reference in New Issue
Block a user