@oneuptime/common 12.0.20 → 12.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Models/AnalyticsModels/ChangeEvent.ts +331 -0
- package/Models/AnalyticsModels/Index.ts +2 -0
- package/Models/DatabaseModels/Index.ts +2 -0
- package/Models/DatabaseModels/UserTwoFactorBackupCode.ts +262 -0
- package/Server/API/UserTwoFactorBackupCodeAPI.ts +258 -0
- package/Server/EnvironmentConfig.ts +1 -8
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.ts +63 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -2
- package/Server/Services/ChangeEventService.ts +11 -0
- package/Server/Services/EnterpriseLicenseService.ts +3 -3
- package/Server/Services/Index.ts +4 -0
- package/Server/Services/UserService.ts +42 -0
- package/Server/Services/UserTwoFactorBackupCodeService.ts +355 -0
- package/Server/Utils/Attribution.ts +11 -11
- package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
- package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
- package/Server/Utils/TwoFactorBackupCode.ts +266 -0
- package/Server/Views/Partials/AnalyticsConsent.ejs +3 -95
- package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +64 -0
- package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
- package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
- package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
- package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
- package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
- package/Tests/Server/API/UserAuthenticationAPI.test.ts +16 -3
- package/Tests/Server/API/UserTwoFactorAuthAdminAPI.test.ts +18 -2
- package/Tests/Server/API/UserTwoFactorBackupCodeAPI.test.ts +1390 -0
- package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
- package/Tests/Server/Services/UserAuthenticationService.test.ts +23 -1
- package/Tests/Server/Services/UserTwoFactorAuthAdmin.test.ts +21 -0
- package/Tests/Server/Services/UserTwoFactorBackupCodeAdminSurface.test.ts +919 -0
- package/Tests/Server/Services/UserTwoFactorBackupCodeService.test.ts +862 -0
- package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
- package/Tests/Server/Utils/Attribution.test.ts +4 -5
- package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
- package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
- package/Tests/Server/Utils/TwoFactorBackupCode.test.ts +475 -0
- package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
- package/Tests/UI/Components/Charts/TooltipEntries.test.ts +44 -8
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
- package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
- package/Types/Email/EmailTemplateType.ts +2 -0
- package/Types/Marketing/Attribution.ts +9 -15
- package/Types/Marketing/MarketingEvent.ts +10 -46
- package/Types/UserAuthenticationStatus.ts +16 -0
- package/UI/Components/Charts/Area/AreaChart.tsx +15 -0
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +189 -93
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +6 -1
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +213 -114
- package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +42 -27
- package/UI/Components/Charts/Line/LineChart.tsx +15 -0
- package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
- package/Utils/Telemetry/CrossSignalScope.ts +19 -10
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
- package/build/dist/Models/AnalyticsModels/Index.js +2 -0
- package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/Index.js +2 -0
- package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
- package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js +277 -0
- package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js.map +1 -0
- package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js +201 -0
- package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js.map +1 -0
- package/build/dist/Server/EnvironmentConfig.js +1 -7
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js +46 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js.map +1 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -2
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/ChangeEventService.js +9 -0
- package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
- package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
- package/build/dist/Server/Services/Index.js +4 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/UserService.js +39 -0
- package/build/dist/Server/Services/UserService.js.map +1 -1
- package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js +327 -0
- package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js.map +1 -0
- package/build/dist/Server/Utils/Attribution.js +6 -6
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
- package/build/dist/Server/Utils/TwoFactorBackupCode.js +269 -0
- package/build/dist/Server/Utils/TwoFactorBackupCode.js.map +1 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
- package/build/dist/Types/Email/EmailTemplateType.js +2 -0
- package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
- package/build/dist/Types/Marketing/Attribution.js +9 -15
- package/build/dist/Types/Marketing/Attribution.js.map +1 -1
- package/build/dist/Types/Marketing/MarketingEvent.js +2 -33
- package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +92 -38
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +8 -8
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +95 -37
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +39 -24
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js +1 -1
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
- package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
- package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
- package/package.json +1 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import ObjectID from "../../Types/ObjectID";
|
|
2
|
+
import Hostname from "../../Types/API/Hostname";
|
|
3
|
+
import Protocol from "../../Types/API/Protocol";
|
|
4
|
+
import Route from "../../Types/API/Route";
|
|
5
|
+
import URL from "../../Types/API/URL";
|
|
6
|
+
import OneUptimeDate from "../../Types/Date";
|
|
7
|
+
import EmailTemplateType from "../../Types/Email/EmailTemplateType";
|
|
8
|
+
import { DashboardRoute } from "../../ServiceRoute";
|
|
9
|
+
import DatabaseConfig from "../DatabaseConfig";
|
|
10
|
+
import MailService from "../Services/MailService";
|
|
11
|
+
import UserService from "../Services/UserService";
|
|
12
|
+
import User from "../../Models/DatabaseModels/User";
|
|
13
|
+
import UserMiddleware from "../Middleware/UserAuthorization";
|
|
14
|
+
import UserTwoFactorBackupCodeService, {
|
|
15
|
+
Service as UserTwoFactorBackupCodeServiceType,
|
|
16
|
+
TwoFactorBackupCodeStatus,
|
|
17
|
+
} from "../Services/UserTwoFactorBackupCodeService";
|
|
18
|
+
import TwoFactorBackupCode from "../Utils/TwoFactorBackupCode";
|
|
19
|
+
import {
|
|
20
|
+
ExpressRequest,
|
|
21
|
+
ExpressResponse,
|
|
22
|
+
NextFunction,
|
|
23
|
+
OneUptimeRequest,
|
|
24
|
+
} from "../Utils/Express";
|
|
25
|
+
import BaseAPI from "./BaseAPI";
|
|
26
|
+
import UserTwoFactorBackupCode from "../../Models/DatabaseModels/UserTwoFactorBackupCode";
|
|
27
|
+
import NotAuthenticatedException from "../../Types/Exception/NotAuthenticatedException";
|
|
28
|
+
import Response from "../Utils/Response";
|
|
29
|
+
import logger from "../Utils/Logger";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The two routes a signed-in user needs to manage their recovery codes.
|
|
33
|
+
*
|
|
34
|
+
* Both act on the CALLER's own codes and nobody else's. There is deliberately
|
|
35
|
+
* no `:userId` parameter anywhere here: the owner is read from the validated
|
|
36
|
+
* access token, so there is no id for a caller to substitute and no ownership
|
|
37
|
+
* check to get wrong. `UserMiddleware.getUserMiddleware` is what puts it
|
|
38
|
+
* there.
|
|
39
|
+
*/
|
|
40
|
+
export default class UserTwoFactorBackupCodeAPI extends BaseAPI<
|
|
41
|
+
UserTwoFactorBackupCode,
|
|
42
|
+
UserTwoFactorBackupCodeServiceType
|
|
43
|
+
> {
|
|
44
|
+
public constructor() {
|
|
45
|
+
super(UserTwoFactorBackupCode, UserTwoFactorBackupCodeService);
|
|
46
|
+
|
|
47
|
+
const crudApiPath: string =
|
|
48
|
+
new this.entityType().getCrudApiPath()?.toString() || "";
|
|
49
|
+
|
|
50
|
+
/*
|
|
51
|
+
* Mint a fresh set and return the plaintext codes.
|
|
52
|
+
*
|
|
53
|
+
* THIS RESPONSE IS THE ONLY COPY. Nothing is stored but keyed digests, so
|
|
54
|
+
* if the user closes the tab the codes are gone -- not recoverable by
|
|
55
|
+
* them, by an operator, or by anybody with the database. The UI is
|
|
56
|
+
* expected to make that unmissable, and the count of a previous set is
|
|
57
|
+
* returned alongside so the page can say what was just invalidated.
|
|
58
|
+
*
|
|
59
|
+
* It is a POST because it is destructive: calling it throws away every
|
|
60
|
+
* code the user was previously holding. A GET that silently voided a
|
|
61
|
+
* printed list -- on a browser prefetch, say -- would be a lockout with no
|
|
62
|
+
* user action behind it.
|
|
63
|
+
*
|
|
64
|
+
* NOT gated on `enableTwoFactorAuth`. Generating codes before turning two
|
|
65
|
+
* factor auth on is the sensible order to do it in, and refusing here
|
|
66
|
+
* would push users into the exact sequence -- enable first, find recovery
|
|
67
|
+
* later -- that produces the lost-device support ticket this whole feature
|
|
68
|
+
* exists to prevent.
|
|
69
|
+
*/
|
|
70
|
+
this.router.post(
|
|
71
|
+
`${crudApiPath}/generate`,
|
|
72
|
+
UserMiddleware.getUserMiddleware,
|
|
73
|
+
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
|
|
74
|
+
try {
|
|
75
|
+
const userId: ObjectID =
|
|
76
|
+
UserTwoFactorBackupCodeAPI.getCurrentUserId(req);
|
|
77
|
+
|
|
78
|
+
const previousStatus: TwoFactorBackupCodeStatus =
|
|
79
|
+
await UserTwoFactorBackupCodeService.getStatusForUser({
|
|
80
|
+
userId: userId,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const codes: Array<string> =
|
|
84
|
+
await UserTwoFactorBackupCodeService.regenerateForUser({
|
|
85
|
+
userId: userId,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/*
|
|
89
|
+
* The codes themselves are NEVER logged -- they are sign-in
|
|
90
|
+
* credentials, and logger output reaches stdout, the recent-log
|
|
91
|
+
* buffer and telemetry at once. The fact that a set was minted is
|
|
92
|
+
* worth recording: it is how an operator later reconstructs "who
|
|
93
|
+
* invalidated the codes this user says they had".
|
|
94
|
+
*/
|
|
95
|
+
logger.info(
|
|
96
|
+
`Two factor backup codes regenerated for user: ${userId.toString()}`,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
* Tell the account holder, at their address rather than in this
|
|
101
|
+
* browser.
|
|
102
|
+
*
|
|
103
|
+
* This route is the destructive one and it is the one with the least
|
|
104
|
+
* standing in front of it: a session alone -- no password, no second
|
|
105
|
+
* factor -- silently voids every recovery code the user is holding.
|
|
106
|
+
* Somebody who has stolen a session can therefore remove the owner's
|
|
107
|
+
* way back in and leave no trace the owner would ever look at, and
|
|
108
|
+
* the owner finds out at the next sign-in they cannot complete.
|
|
109
|
+
*
|
|
110
|
+
* The mail does not prevent that; it makes it visible, which is the
|
|
111
|
+
* same bargain `sendTwoFactorBackupCodeUsedEmail` strikes on the
|
|
112
|
+
* login path. Re-prompting for the password here would be stronger
|
|
113
|
+
* still, and is the obvious next step -- it needs a re-authentication
|
|
114
|
+
* mechanism this codebase does not have yet, so it is deliberately
|
|
115
|
+
* not invented on this route alone.
|
|
116
|
+
*
|
|
117
|
+
* Detached and never awaited: an unreachable mail server must not
|
|
118
|
+
* fail a regeneration that has already replaced the user's codes,
|
|
119
|
+
* because the response carries the only copy of the new ones.
|
|
120
|
+
*/
|
|
121
|
+
UserTwoFactorBackupCodeAPI.notifyCodesRegenerated({
|
|
122
|
+
userId: userId,
|
|
123
|
+
newCodeCount: codes.length,
|
|
124
|
+
}).catch((err: Error) => {
|
|
125
|
+
logger.error(err);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
return Response.sendJsonObjectResponse(req, res, {
|
|
129
|
+
/*
|
|
130
|
+
* Hyphenated for the page to render as-is. The verify route
|
|
131
|
+
* normalizes whatever comes back, so a user may type them with
|
|
132
|
+
* the hyphen, without it, or with whatever spacing their password
|
|
133
|
+
* manager pasted in.
|
|
134
|
+
*/
|
|
135
|
+
codes: codes.map((code: string) => {
|
|
136
|
+
return TwoFactorBackupCode.formatForDisplay(code);
|
|
137
|
+
}),
|
|
138
|
+
replacedCodeCount: previousStatus.total,
|
|
139
|
+
});
|
|
140
|
+
} catch (err) {
|
|
141
|
+
return next(err);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
/*
|
|
147
|
+
* How many codes the caller has left.
|
|
148
|
+
*
|
|
149
|
+
* Carries no code material of any kind -- three numbers and a date -- so
|
|
150
|
+
* it is safe for the profile page to poll and safe to render anywhere a
|
|
151
|
+
* session already exists.
|
|
152
|
+
*/
|
|
153
|
+
this.router.get(
|
|
154
|
+
`${crudApiPath}/status`,
|
|
155
|
+
UserMiddleware.getUserMiddleware,
|
|
156
|
+
async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
|
|
157
|
+
try {
|
|
158
|
+
const userId: ObjectID =
|
|
159
|
+
UserTwoFactorBackupCodeAPI.getCurrentUserId(req);
|
|
160
|
+
|
|
161
|
+
const status: TwoFactorBackupCodeStatus =
|
|
162
|
+
await UserTwoFactorBackupCodeService.getStatusForUser({
|
|
163
|
+
userId: userId,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
/*
|
|
167
|
+
* A stale "you have 8 codes left" served out of a browser cache
|
|
168
|
+
* after the user has just regenerated is a user who thinks the
|
|
169
|
+
* button did nothing.
|
|
170
|
+
*/
|
|
171
|
+
Response.setNoCacheHeaders(res);
|
|
172
|
+
|
|
173
|
+
return Response.sendJsonObjectResponse(req, res, {
|
|
174
|
+
total: status.total,
|
|
175
|
+
unused: status.unused,
|
|
176
|
+
generatedAt: status.generatedAt
|
|
177
|
+
? status.generatedAt.toISOString()
|
|
178
|
+
: null,
|
|
179
|
+
});
|
|
180
|
+
} catch (err) {
|
|
181
|
+
return next(err);
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Mail the owner that their backup codes were replaced.
|
|
189
|
+
*
|
|
190
|
+
* Separate from the handler so the handler stays readable, and `private
|
|
191
|
+
* static` because nothing outside this route should be announcing a
|
|
192
|
+
* regeneration that did not happen here.
|
|
193
|
+
*
|
|
194
|
+
* Reads the address itself rather than taking it from the request: the only
|
|
195
|
+
* thing the caller proved is which user they are, and the address this goes
|
|
196
|
+
* to has to be the one on the account, not one they supplied.
|
|
197
|
+
*/
|
|
198
|
+
private static async notifyCodesRegenerated(data: {
|
|
199
|
+
userId: ObjectID;
|
|
200
|
+
newCodeCount: number;
|
|
201
|
+
}): Promise<void> {
|
|
202
|
+
const user: User | null = await UserService.findOneById({
|
|
203
|
+
id: data.userId,
|
|
204
|
+
select: {
|
|
205
|
+
email: true,
|
|
206
|
+
},
|
|
207
|
+
props: {
|
|
208
|
+
isRoot: true,
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (!user || !user.email) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const host: Hostname = await DatabaseConfig.getHost();
|
|
217
|
+
const httpProtocol: Protocol = await DatabaseConfig.getHttpProtocol();
|
|
218
|
+
|
|
219
|
+
await MailService.sendMail({
|
|
220
|
+
toEmail: user.email,
|
|
221
|
+
subject: "Your OneUptime two factor backup codes were replaced",
|
|
222
|
+
templateType: EmailTemplateType.TwoFactorBackupCodesRegenerated,
|
|
223
|
+
vars: {
|
|
224
|
+
generatedAt: OneUptimeDate.getCurrentDateAsFormattedString(),
|
|
225
|
+
newCodeCount: data.newCodeCount.toString(),
|
|
226
|
+
twoFactorAuthUrl: new URL(
|
|
227
|
+
httpProtocol,
|
|
228
|
+
host,
|
|
229
|
+
new Route(DashboardRoute.toString()).addRoute(
|
|
230
|
+
"/user-profile/two-factor-auth",
|
|
231
|
+
),
|
|
232
|
+
).toString(),
|
|
233
|
+
homeUrl: new URL(httpProtocol, host).toString(),
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The signed-in caller, from the token the middleware validated.
|
|
240
|
+
*
|
|
241
|
+
* Throws rather than returning null: every route in this file acts on the
|
|
242
|
+
* caller's own credentials, so "no caller" is not a case any of them can
|
|
243
|
+
* meaningfully proceed with. The middleware ahead of them should already
|
|
244
|
+
* have refused, which makes this a second lock rather than the first.
|
|
245
|
+
*/
|
|
246
|
+
private static getCurrentUserId(req: ExpressRequest): ObjectID {
|
|
247
|
+
const userId: ObjectID | undefined = (req as OneUptimeRequest)
|
|
248
|
+
.userAuthorization?.userId;
|
|
249
|
+
|
|
250
|
+
if (!userId) {
|
|
251
|
+
throw new NotAuthenticatedException(
|
|
252
|
+
"You are not authenticated. Please sign in.",
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return userId;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
@@ -385,17 +385,10 @@ export const AnalyticsHost: string = process.env["ANALYTICS_HOST"] || "";
|
|
|
385
385
|
export const GoogleTagManagerEnabled: boolean =
|
|
386
386
|
IsBillingEnabled && process.env["GOOGLE_TAG_MANAGER_ENABLED"] !== "false";
|
|
387
387
|
|
|
388
|
-
/*
|
|
389
|
-
* Shared secret Cal.com signs booking webhooks with (App/API/CalWebhook.ts).
|
|
390
|
-
* Server-only — must never be added to FRONTEND_ENV_ALLOW_LIST. Empty means
|
|
391
|
-
* the webhook endpoint is disabled and answers 503.
|
|
392
|
-
*/
|
|
393
|
-
export const CalWebhookSecret: string = process.env["CAL_WEBHOOK_SECRET"] || "";
|
|
394
|
-
|
|
395
388
|
/*
|
|
396
389
|
* Outbound marketing conversion webhooks.
|
|
397
390
|
*
|
|
398
|
-
* OneUptime does not store conversions. Signups
|
|
391
|
+
* OneUptime does not store conversions. Signups and plan
|
|
399
392
|
* upgrades/downgrades are POSTed to this endpoint as they happen and kept
|
|
400
393
|
* nowhere afterwards, so an unset URL means those moments are simply not
|
|
401
394
|
* measured — nothing accumulates waiting for one to be configured.
|
|
@@ -14,6 +14,10 @@ export class AddUserSlackAndMicrosoftTeams1789000000000
|
|
|
14
14
|
public name = "AddUserSlackAndMicrosoftTeams1789000000000";
|
|
15
15
|
|
|
16
16
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
17
|
+
if (await this.isAlreadyApplied(queryRunner)) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
17
21
|
await queryRunner.query(
|
|
18
22
|
`CREATE TABLE "UserSlack" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "projectId" uuid NOT NULL, "slackUserId" character varying(100), "slackUserName" character varying(100), "userId" uuid, "createdByUserId" uuid, "deletedByUserId" uuid, "isVerified" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_86de4cd76b41a08811fc403e23a" PRIMARY KEY ("_id"))`,
|
|
19
23
|
);
|
|
@@ -106,6 +110,44 @@ export class AddUserSlackAndMicrosoftTeams1789000000000
|
|
|
106
110
|
);
|
|
107
111
|
}
|
|
108
112
|
|
|
113
|
+
/*
|
|
114
|
+
* Did everything in `up` already run, under this migration's OLD name?
|
|
115
|
+
*
|
|
116
|
+
* This shipped as AddUserSlackAndMicrosoftTeams1788800000000 and was
|
|
117
|
+
* renumbered to 1789000000000 to clear a timestamp collision with
|
|
118
|
+
* DropMarketingConversionAddEnterpriseLicenseEmail. TypeORM identifies a
|
|
119
|
+
* migration by class NAME rather than by what it does, so every database
|
|
120
|
+
* that had already recorded the old name — anything tracking master between
|
|
121
|
+
* the two commits, which is every test and staging cluster — sees the
|
|
122
|
+
* renamed class as pending and runs it a second time, where
|
|
123
|
+
* `CREATE TABLE "UserSlack"` fails on the table it created itself. The
|
|
124
|
+
* runner stops at the first failure, so that single error holds back this
|
|
125
|
+
* migration and every migration added after it, on every boot.
|
|
126
|
+
*
|
|
127
|
+
* The probe is the table because the earlier run was all-or-nothing:
|
|
128
|
+
* migrations run one transaction each (DataSourceOptions sets
|
|
129
|
+
* migrationsTransactionMode: "each") and Postgres DDL is transactional, so
|
|
130
|
+
* "UserSlack" existing means every statement in `up` already committed.
|
|
131
|
+
* Nothing is left to do but let TypeORM record the new name.
|
|
132
|
+
*
|
|
133
|
+
* The superseded row in "migrations" is deliberately left alone. Deleting it
|
|
134
|
+
* would make this migration pending again for anyone who rolls the image
|
|
135
|
+
* BACK to a build that still registers the old name — the same failure, in
|
|
136
|
+
* the other direction. An unrecognised name in that table is inert.
|
|
137
|
+
*/
|
|
138
|
+
private async isAlreadyApplied(queryRunner: QueryRunner): Promise<boolean> {
|
|
139
|
+
const userSlackTableExists: Array<{ exists: boolean }> =
|
|
140
|
+
await queryRunner.query(`
|
|
141
|
+
SELECT EXISTS (
|
|
142
|
+
SELECT FROM information_schema.tables
|
|
143
|
+
WHERE table_schema = 'public'
|
|
144
|
+
AND table_name = 'UserSlack'
|
|
145
|
+
)
|
|
146
|
+
`);
|
|
147
|
+
|
|
148
|
+
return Boolean(userSlackTableExists[0]?.exists);
|
|
149
|
+
}
|
|
150
|
+
|
|
109
151
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
110
152
|
await queryRunner.query(
|
|
111
153
|
`ALTER TABLE "UserOnCallLogTimeline" DROP CONSTRAINT "FK_a7206c19df5cdfe02cf3215a64c"`,
|
package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { MigrationInterface, QueryRunner } from "typeorm";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Single-use two factor backup codes, so losing an authenticator app or a
|
|
5
|
+
* security key stops being an account lockout that only a master admin can
|
|
6
|
+
* undo.
|
|
7
|
+
*
|
|
8
|
+
* One row per code rather than an array column on User: consuming a code is
|
|
9
|
+
* then one conditional UPDATE that Postgres serialises for us, instead of a
|
|
10
|
+
* read-modify-write of an array where two simultaneous sign-ins would each
|
|
11
|
+
* write back a copy missing only their own code.
|
|
12
|
+
*
|
|
13
|
+
* `codeHash` holds an HMAC-SHA256 keyed by the instance EncryptionSecret and
|
|
14
|
+
* domain separated by the owning user -- never a code in the clear. It is a
|
|
15
|
+
* plain varchar rather than a `hashed: true` column because the write path's
|
|
16
|
+
* hashing lanes do neither of those things: the salted lane is scrypt (right
|
|
17
|
+
* for a guessable password, pure cost for a 2^50 random code) and the unsalted
|
|
18
|
+
* lane is a bare SHA-256 with no per-user binding. See
|
|
19
|
+
* Common/Server/Utils/TwoFactorBackupCode.ts.
|
|
20
|
+
*
|
|
21
|
+
* The (userId, codeHash) index is the sign-in lookup, which happens while
|
|
22
|
+
* somebody is locked out and waiting. `usedAt` is deliberately not in it: a
|
|
23
|
+
* user holds ten rows, so Postgres filters those in memory for nothing.
|
|
24
|
+
*/
|
|
25
|
+
export class AddUserTwoFactorBackupCode1789100000000
|
|
26
|
+
implements MigrationInterface
|
|
27
|
+
{
|
|
28
|
+
public name: string = "AddUserTwoFactorBackupCode1789100000000";
|
|
29
|
+
|
|
30
|
+
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
31
|
+
await queryRunner.query(
|
|
32
|
+
`CREATE TABLE "UserTwoFactorBackupCode" ("_id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deletedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL, "codeHash" character varying(100) NOT NULL, "usedAt" TIMESTAMP WITH TIME ZONE, "deletedByUserId" uuid, "userId" uuid, CONSTRAINT "PK_8007aa681bcad47b88b906319ed" PRIMARY KEY ("_id"))`,
|
|
33
|
+
);
|
|
34
|
+
await queryRunner.query(
|
|
35
|
+
`CREATE INDEX "IDX_19a5937a4f6bd993383b31f3a2" ON "UserTwoFactorBackupCode" ("userId", "codeHash") `,
|
|
36
|
+
);
|
|
37
|
+
await queryRunner.query(
|
|
38
|
+
`ALTER TABLE "UserTwoFactorBackupCode" ADD CONSTRAINT "FK_b6f9bfb29ab554076a2d8dde717" FOREIGN KEY ("deletedByUserId") REFERENCES "User"("_id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
|
39
|
+
);
|
|
40
|
+
/*
|
|
41
|
+
* CASCADE, not SET NULL. An orphaned row would be a live sign-in
|
|
42
|
+
* credential for an account that no longer exists -- and, with `userId`
|
|
43
|
+
* null, invisible to every query in the service, which all key on the
|
|
44
|
+
* owner.
|
|
45
|
+
*/
|
|
46
|
+
await queryRunner.query(
|
|
47
|
+
`ALTER TABLE "UserTwoFactorBackupCode" ADD CONSTRAINT "FK_dd1f93f94cd3a4f39435b56d944" FOREIGN KEY ("userId") REFERENCES "User"("_id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
52
|
+
await queryRunner.query(
|
|
53
|
+
`ALTER TABLE "UserTwoFactorBackupCode" DROP CONSTRAINT "FK_dd1f93f94cd3a4f39435b56d944"`,
|
|
54
|
+
);
|
|
55
|
+
await queryRunner.query(
|
|
56
|
+
`ALTER TABLE "UserTwoFactorBackupCode" DROP CONSTRAINT "FK_b6f9bfb29ab554076a2d8dde717"`,
|
|
57
|
+
);
|
|
58
|
+
await queryRunner.query(
|
|
59
|
+
`DROP INDEX "public"."IDX_19a5937a4f6bd993383b31f3a2"`,
|
|
60
|
+
);
|
|
61
|
+
await queryRunner.query(`DROP TABLE "UserTwoFactorBackupCode"`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -542,8 +542,8 @@ import { RemoveLlmCostBudgetAlertColumns1788500000000 } from "./1788500000000-Re
|
|
|
542
542
|
import { AddDetectionRuleIncidentColumns1788600000000 } from "./1788600000000-AddDetectionRuleIncidentColumns";
|
|
543
543
|
import { RemoveMarketingConversionUploadState1788700000000 } from "./1788700000000-RemoveMarketingConversionUploadState";
|
|
544
544
|
import { DropMarketingConversionAddEnterpriseLicenseEmail1788800000000 } from "./1788800000000-DropMarketingConversionAddEnterpriseLicenseEmail";
|
|
545
|
-
import { RedactStoredMonitorIngestSecrets1788900000000 } from "./1788900000000-RedactStoredMonitorIngestSecrets";
|
|
546
545
|
import { AddUserSlackAndMicrosoftTeams1789000000000 } from "./1789000000000-AddUserSlackAndMicrosoftTeams";
|
|
546
|
+
import { AddUserTwoFactorBackupCode1789100000000 } from "./1789100000000-AddUserTwoFactorBackupCode";
|
|
547
547
|
import { MigrationName1787142779538 } from "./1787142779538-MigrationName";
|
|
548
548
|
import { MigrationName1787156982416 } from "./1787156982416-MigrationName";
|
|
549
549
|
|
|
@@ -1094,6 +1094,6 @@ export default [
|
|
|
1094
1094
|
AddDetectionRuleIncidentColumns1788600000000,
|
|
1095
1095
|
RemoveMarketingConversionUploadState1788700000000,
|
|
1096
1096
|
DropMarketingConversionAddEnterpriseLicenseEmail1788800000000,
|
|
1097
|
-
RedactStoredMonitorIngestSecrets1788900000000,
|
|
1098
1097
|
AddUserSlackAndMicrosoftTeams1789000000000,
|
|
1098
|
+
AddUserTwoFactorBackupCode1789100000000,
|
|
1099
1099
|
];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import ClickhouseDatabase from "../Infrastructure/ClickhouseDatabase";
|
|
2
|
+
import AnalyticsDatabaseService from "./AnalyticsDatabaseService";
|
|
3
|
+
import ChangeEvent from "../../Models/AnalyticsModels/ChangeEvent";
|
|
4
|
+
|
|
5
|
+
export class ChangeEventService extends AnalyticsDatabaseService<ChangeEvent> {
|
|
6
|
+
public constructor(clickhouseDatabase?: ClickhouseDatabase | undefined) {
|
|
7
|
+
super({ modelType: ChangeEvent, database: clickhouseDatabase });
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default new ChangeEventService();
|
|
@@ -37,9 +37,9 @@ export class Service extends DatabaseService<EnterpriseLicense> {
|
|
|
37
37
|
email: createdItem.email?.toString(),
|
|
38
38
|
/*
|
|
39
39
|
* No attribution source. A licence row is typed in by a human and
|
|
40
|
-
* carries no session, so its campaign is whatever the
|
|
41
|
-
* sharing its email carried — the join the receiver makes
|
|
42
|
-
* emailHash, not something OneUptime can restate here.
|
|
40
|
+
* carries no session, so its campaign is whatever the earlier
|
|
41
|
+
* conversion sharing its email carried — the join the receiver makes
|
|
42
|
+
* on emailHash, not something OneUptime can restate here.
|
|
43
43
|
*/
|
|
44
44
|
data: {
|
|
45
45
|
enterpriseLicenseId: createdItem.id?.toString() || "",
|
package/Server/Services/Index.ts
CHANGED
|
@@ -77,6 +77,7 @@ import DataSourceService from "./DataSourceService";
|
|
|
77
77
|
import AuditLogService from "./AuditLogService";
|
|
78
78
|
import LogService from "./LogService";
|
|
79
79
|
import SecurityEventService from "./SecurityEventService";
|
|
80
|
+
import ChangeEventService from "./ChangeEventService";
|
|
80
81
|
import MailService from "./MailService";
|
|
81
82
|
import MetricService from "./MetricService";
|
|
82
83
|
import MetricItemAggMV1mService from "./MetricItemAggMV1mService";
|
|
@@ -207,6 +208,7 @@ import UserOnCallLogTimelineService from "./UserOnCallLogTimelineService";
|
|
|
207
208
|
import UserService from "./UserService";
|
|
208
209
|
import UserSessionService from "./UserSessionService";
|
|
209
210
|
import UserTotpAuthService from "./UserTotpAuthService";
|
|
211
|
+
import UserTwoFactorBackupCodeService from "./UserTwoFactorBackupCodeService";
|
|
210
212
|
import UserWebAuthnService from "./UserWebAuthnService";
|
|
211
213
|
import UserSmsService from "./UserSmsService";
|
|
212
214
|
import UserIncomingCallNumberService from "./UserIncomingCallNumberService";
|
|
@@ -494,6 +496,7 @@ const services: Array<BaseService> = [
|
|
|
494
496
|
UserSlackService,
|
|
495
497
|
UserMicrosoftTeamsService,
|
|
496
498
|
UserTotpAuthService,
|
|
499
|
+
UserTwoFactorBackupCodeService,
|
|
497
500
|
UserWebAuthnService,
|
|
498
501
|
|
|
499
502
|
WorkflowLogService,
|
|
@@ -641,6 +644,7 @@ export const AnalyticsServices: Array<
|
|
|
641
644
|
RumSessionChunkService,
|
|
642
645
|
AuditLogService,
|
|
643
646
|
SecurityEventService,
|
|
647
|
+
ChangeEventService,
|
|
644
648
|
];
|
|
645
649
|
|
|
646
650
|
export default services;
|
|
@@ -45,6 +45,7 @@ import ProductAnalytics from "../Utils/ProductAnalytics";
|
|
|
45
45
|
import MarketingEventUtil from "../Utils/Marketing/MarketingEventUtil";
|
|
46
46
|
import { MarketingEventType } from "../../Types/Marketing/MarketingEvent";
|
|
47
47
|
import UserTotpAuthService from "./UserTotpAuthService";
|
|
48
|
+
import UserTwoFactorBackupCodeService from "./UserTwoFactorBackupCodeService";
|
|
48
49
|
import UserWebAuthnService from "./UserWebAuthnService";
|
|
49
50
|
import BadDataException from "../../Types/Exception/BadDataException";
|
|
50
51
|
import NotFoundException from "../../Types/Exception/NotFoundException";
|
|
@@ -819,6 +820,18 @@ export class Service extends DatabaseService<Model> {
|
|
|
819
820
|
const verifiedTwoFactorAuthMethodCount: number =
|
|
820
821
|
await this.countVerifiedTwoFactorAuthMethods(userId);
|
|
821
822
|
|
|
823
|
+
/*
|
|
824
|
+
* Read separately from the method count above, and NOT added to it. See
|
|
825
|
+
* the note on `deriveTwoFactorAuthStatus`: folding recovery codes into the
|
|
826
|
+
* configured-method count would report an account whose authenticator is
|
|
827
|
+
* gone as fully set up, and login would keep sending it to the challenge
|
|
828
|
+
* screen until the last code was spent.
|
|
829
|
+
*/
|
|
830
|
+
const unusedTwoFactorBackupCodeCount: number =
|
|
831
|
+
await UserTwoFactorBackupCodeService.countUnusedForUser({
|
|
832
|
+
userId: userId,
|
|
833
|
+
});
|
|
834
|
+
|
|
822
835
|
return {
|
|
823
836
|
hasPassword: Boolean(user.password),
|
|
824
837
|
isEmailVerified: Boolean(user.isEmailVerified),
|
|
@@ -828,6 +841,7 @@ export class Service extends DatabaseService<Model> {
|
|
|
828
841
|
verifiedMethodCount: verifiedTwoFactorAuthMethodCount,
|
|
829
842
|
}),
|
|
830
843
|
verifiedTwoFactorAuthMethodCount: verifiedTwoFactorAuthMethodCount,
|
|
844
|
+
unusedTwoFactorBackupCodeCount: unusedTwoFactorBackupCodeCount,
|
|
831
845
|
hasPendingPasswordResetLink: hasPendingPasswordResetLink,
|
|
832
846
|
};
|
|
833
847
|
}
|
|
@@ -845,6 +859,17 @@ export class Service extends DatabaseService<Model> {
|
|
|
845
859
|
* Counting unverified rows would therefore report a user who has never once
|
|
846
860
|
* typed a code as fully configured -- and that user is precisely the one an
|
|
847
861
|
* operator is looking at the page to help.
|
|
862
|
+
*
|
|
863
|
+
* BACKUP CODES ARE NOT COUNTED HERE, for a related reason one step further
|
|
864
|
+
* on. This number decides `deriveTwoFactorAuthStatus`, which decides whether
|
|
865
|
+
* login shows the two factor CHALLENGE or sends the account through
|
|
866
|
+
* enrolment. An account whose only remaining material was recovery codes
|
|
867
|
+
* would, if they counted, be shown a challenge it cannot answer except by
|
|
868
|
+
* spending a code -- one per sign-in, with no way to enrol a new
|
|
869
|
+
* authenticator, until the last one is gone. Recovery codes are the way back
|
|
870
|
+
* in to an account with a factor it cannot reach; they are not a factor.
|
|
871
|
+
* `UserAuthenticationStatus.unusedTwoFactorBackupCodeCount` reports them
|
|
872
|
+
* separately.
|
|
848
873
|
*/
|
|
849
874
|
@CaptureSpan()
|
|
850
875
|
private async countVerifiedTwoFactorAuthMethods(
|
|
@@ -1036,6 +1061,23 @@ export class Service extends DatabaseService<Model> {
|
|
|
1036
1061
|
},
|
|
1037
1062
|
});
|
|
1038
1063
|
|
|
1064
|
+
/*
|
|
1065
|
+
* The recovery codes go with the authenticators, and this is not
|
|
1066
|
+
* bookkeeping. A backup code signs somebody in on its own, so a reset that
|
|
1067
|
+
* cleared the TOTP secret but left a printed list of codes alive would
|
|
1068
|
+
* have revoked nothing that an attacker who took the phone -- and the
|
|
1069
|
+
* paper next to it -- still holds. "Reset two factor auth" has to mean all
|
|
1070
|
+
* of the second-factor material, not the part that is easiest to see.
|
|
1071
|
+
*
|
|
1072
|
+
* It also matters in the harmless direction: the user is about to be sent
|
|
1073
|
+
* through enrolment at their next sign-in, and codes minted against an
|
|
1074
|
+
* authenticator that no longer exists are dead weight nobody would think
|
|
1075
|
+
* to clear.
|
|
1076
|
+
*/
|
|
1077
|
+
await UserTwoFactorBackupCodeService.deleteAllForUser({
|
|
1078
|
+
userId: user.id!,
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1039
1081
|
/*
|
|
1040
1082
|
* Ordered after the deletes, not before. A session that survives a reset
|
|
1041
1083
|
* is a session that never has to prove the second factor again, which
|