@oneuptime/common 12.0.21 → 12.0.23

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.
Files changed (94) hide show
  1. package/Models/DatabaseModels/Index.ts +4 -0
  2. package/Models/DatabaseModels/NetworkDevice.ts +44 -0
  3. package/Models/DatabaseModels/NetworkDeviceAutoImportRule.ts +564 -0
  4. package/Models/DatabaseModels/NetworkDeviceDiscoveryScan.ts +38 -0
  5. package/Models/DatabaseModels/UserTwoFactorBackupCode.ts +262 -0
  6. package/Server/API/UserTwoFactorBackupCodeAPI.ts +258 -0
  7. package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddNetworkDeviceAutoImportRule.ts +60 -0
  8. package/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.ts +63 -0
  9. package/Server/Infrastructure/Postgres/SchemaMigrations/1789200000000-AddSysObjectIdPatternToAutoImportRule.ts +26 -0
  10. package/Server/Infrastructure/Postgres/SchemaMigrations/1789300000000-AddAutoApplyVendorHealthTemplate.ts +26 -0
  11. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  12. package/Server/Services/Index.ts +4 -0
  13. package/Server/Services/NetworkDeviceAutoImportRuleEngineService.ts +917 -0
  14. package/Server/Services/NetworkDeviceAutoImportRuleService.ts +181 -0
  15. package/Server/Services/UserService.ts +42 -0
  16. package/Server/Services/UserTwoFactorBackupCodeService.ts +355 -0
  17. package/Server/Utils/Monitor/NetworkInventoryUtil.ts +39 -1
  18. package/Server/Utils/TwoFactorBackupCode.ts +266 -0
  19. package/Tests/Server/API/UserAuthenticationAPI.test.ts +16 -3
  20. package/Tests/Server/API/UserTwoFactorAuthAdminAPI.test.ts +18 -2
  21. package/Tests/Server/API/UserTwoFactorBackupCodeAPI.test.ts +1390 -0
  22. package/Tests/Server/Services/AutoImportScanCredentialSelect.test.ts +205 -0
  23. package/Tests/Server/Services/NetworkDeviceAutoImportRuleEngineService.test.ts +933 -0
  24. package/Tests/Server/Services/UserAuthenticationService.test.ts +23 -1
  25. package/Tests/Server/Services/UserTwoFactorAuthAdmin.test.ts +21 -0
  26. package/Tests/Server/Services/UserTwoFactorBackupCodeAdminSurface.test.ts +919 -0
  27. package/Tests/Server/Services/UserTwoFactorBackupCodeService.test.ts +862 -0
  28. package/Tests/Server/Utils/Monitor/NetworkInventoryUtil.test.ts +97 -1
  29. package/Tests/Server/Utils/TwoFactorBackupCode.test.ts +475 -0
  30. package/Tests/Utils/NetworkDiscovery/AutoImportRuleMatcher.test.ts +516 -0
  31. package/Tests/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.test.ts +272 -0
  32. package/Tests/Utils/NetworkDiscovery/ScanTargetUtil.test.ts +164 -0
  33. package/Types/Email/EmailTemplateType.ts +2 -0
  34. package/Types/NetworkAutomation/RuleRunResult.ts +84 -0
  35. package/Types/Permission.ts +47 -0
  36. package/Types/UserAuthenticationStatus.ts +16 -0
  37. package/Utils/NetworkDiscovery/AutoImportRuleMatcher.ts +257 -0
  38. package/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.ts +183 -0
  39. package/Utils/NetworkDiscovery/DiscoveredHostUtil.ts +95 -0
  40. package/Utils/NetworkDiscovery/DiscoveryImportEligibility.ts +66 -0
  41. package/Utils/NetworkDiscovery/ScanTargetUtil.ts +82 -0
  42. package/build/dist/Models/DatabaseModels/Index.js +4 -0
  43. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  44. package/build/dist/Models/DatabaseModels/NetworkDevice.js +45 -0
  45. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  46. package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js +589 -0
  47. package/build/dist/Models/DatabaseModels/NetworkDeviceAutoImportRule.js.map +1 -0
  48. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js +28 -0
  49. package/build/dist/Models/DatabaseModels/NetworkDeviceDiscoveryScan.js.map +1 -1
  50. package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js +277 -0
  51. package/build/dist/Models/DatabaseModels/UserTwoFactorBackupCode.js.map +1 -0
  52. package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js +201 -0
  53. package/build/dist/Server/API/UserTwoFactorBackupCodeAPI.js.map +1 -0
  54. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddNetworkDeviceAutoImportRule.js +35 -0
  55. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddNetworkDeviceAutoImportRule.js.map +1 -0
  56. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js +46 -0
  57. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789100000000-AddUserTwoFactorBackupCode.js.map +1 -0
  58. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789200000000-AddSysObjectIdPatternToAutoImportRule.js +19 -0
  59. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789200000000-AddSysObjectIdPatternToAutoImportRule.js.map +1 -0
  60. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789300000000-AddAutoApplyVendorHealthTemplate.js +19 -0
  61. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789300000000-AddAutoApplyVendorHealthTemplate.js.map +1 -0
  62. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  63. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  64. package/build/dist/Server/Services/Index.js +4 -0
  65. package/build/dist/Server/Services/Index.js.map +1 -1
  66. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js +711 -0
  67. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleEngineService.js.map +1 -0
  68. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js +155 -0
  69. package/build/dist/Server/Services/NetworkDeviceAutoImportRuleService.js.map +1 -0
  70. package/build/dist/Server/Services/UserService.js +39 -0
  71. package/build/dist/Server/Services/UserService.js.map +1 -1
  72. package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js +327 -0
  73. package/build/dist/Server/Services/UserTwoFactorBackupCodeService.js.map +1 -0
  74. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js +26 -0
  75. package/build/dist/Server/Utils/Monitor/NetworkInventoryUtil.js.map +1 -1
  76. package/build/dist/Server/Utils/TwoFactorBackupCode.js +269 -0
  77. package/build/dist/Server/Utils/TwoFactorBackupCode.js.map +1 -0
  78. package/build/dist/Types/Email/EmailTemplateType.js +2 -0
  79. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  80. package/build/dist/Types/NetworkAutomation/RuleRunResult.js +24 -0
  81. package/build/dist/Types/NetworkAutomation/RuleRunResult.js.map +1 -1
  82. package/build/dist/Types/Permission.js +41 -0
  83. package/build/dist/Types/Permission.js.map +1 -1
  84. package/build/dist/Utils/NetworkDiscovery/AutoImportRuleMatcher.js +149 -0
  85. package/build/dist/Utils/NetworkDiscovery/AutoImportRuleMatcher.js.map +1 -0
  86. package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js +121 -0
  87. package/build/dist/Utils/NetworkDiscovery/DiscoveredDeviceBuilder.js.map +1 -0
  88. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js +75 -0
  89. package/build/dist/Utils/NetworkDiscovery/DiscoveredHostUtil.js.map +1 -0
  90. package/build/dist/Utils/NetworkDiscovery/DiscoveryImportEligibility.js +59 -0
  91. package/build/dist/Utils/NetworkDiscovery/DiscoveryImportEligibility.js.map +1 -0
  92. package/build/dist/Utils/NetworkDiscovery/ScanTargetUtil.js +60 -0
  93. package/build/dist/Utils/NetworkDiscovery/ScanTargetUtil.js.map +1 -1
  94. package/package.json +1 -1
@@ -0,0 +1,181 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import Model from "../../Models/DatabaseModels/NetworkDeviceAutoImportRule";
3
+ import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
4
+ import CreateBy from "../Types/Database/CreateBy";
5
+ import UpdateBy from "../Types/Database/UpdateBy";
6
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
7
+ import LIMIT_MAX from "../../Types/Database/LimitMax";
8
+ import BadDataException from "../../Types/Exception/BadDataException";
9
+ import RulePatternMatchUtil from "../../Utils/Rules/RulePatternMatchUtil";
10
+ import ScanTargetUtil from "../../Utils/NetworkDiscovery/ScanTargetUtil";
11
+
12
+ /*
13
+ * Write-time validation for auto-import rules, following the
14
+ * NetworkSiteAssignmentRuleService contract: a condition that can only ever
15
+ * match nothing is rejected where the user can see it, not logged about by
16
+ * the engine long after they left the form.
17
+ */
18
+
19
+ /*
20
+ * What a sysObjectID condition may contain: an optional leading dot, then
21
+ * digits, dots and '*' wildcards. Hoisted so the literal is not the object
22
+ * of a member expression, which `wrap-regex` and Prettier cannot agree on —
23
+ * same reason as CidrMatchUtil.
24
+ */
25
+ const OID_PATTERN_SHAPE: RegExp = /^\.?[\d.*]+$/;
26
+ export class Service extends DatabaseService<Model> {
27
+ public constructor() {
28
+ super(Model);
29
+ }
30
+
31
+ @CaptureSpan()
32
+ protected override async onBeforeCreate(
33
+ createBy: CreateBy<Model>,
34
+ ): Promise<OnCreate<Model>> {
35
+ this.validateCriteria({
36
+ ipMatchTarget: createBy.data.ipMatchTarget,
37
+ sysNamePattern: createBy.data.sysNamePattern,
38
+ sysDescrPattern: createBy.data.sysDescrPattern,
39
+ sysObjectIdPattern: createBy.data.sysObjectIdPattern,
40
+ });
41
+
42
+ return { createBy, carryForward: null };
43
+ }
44
+
45
+ @CaptureSpan()
46
+ protected override async onBeforeUpdate(
47
+ updateBy: UpdateBy<Model>,
48
+ ): Promise<OnUpdate<Model>> {
49
+ const dataKeys: Array<string> = Object.keys(updateBy.data || {});
50
+
51
+ const isCriteriaChange: boolean =
52
+ dataKeys.includes("ipMatchTarget") ||
53
+ dataKeys.includes("sysNamePattern") ||
54
+ dataKeys.includes("sysDescrPattern") ||
55
+ dataKeys.includes("sysObjectIdPattern");
56
+
57
+ if (!isCriteriaChange) {
58
+ return { updateBy, carryForward: null };
59
+ }
60
+
61
+ /*
62
+ * The update may clear one criterion while another only exists on the
63
+ * stored row, so validate the RESULTING state of every matched row.
64
+ */
65
+ const existingRules: Array<Model> = await this.findBy({
66
+ query: updateBy.query,
67
+ select: {
68
+ _id: true,
69
+ ipMatchTarget: true,
70
+ sysNamePattern: true,
71
+ sysDescrPattern: true,
72
+ sysObjectIdPattern: true,
73
+ },
74
+ limit: LIMIT_MAX,
75
+ skip: 0,
76
+ props: {
77
+ isRoot: true,
78
+ },
79
+ });
80
+
81
+ const data: Record<string, unknown> = updateBy.data as unknown as Record<
82
+ string,
83
+ unknown
84
+ >;
85
+
86
+ for (const existingRule of existingRules) {
87
+ this.validateCriteria({
88
+ ipMatchTarget: dataKeys.includes("ipMatchTarget")
89
+ ? (data["ipMatchTarget"] as string | null)
90
+ : existingRule.ipMatchTarget,
91
+ sysNamePattern: dataKeys.includes("sysNamePattern")
92
+ ? (data["sysNamePattern"] as string | null)
93
+ : existingRule.sysNamePattern,
94
+ sysDescrPattern: dataKeys.includes("sysDescrPattern")
95
+ ? (data["sysDescrPattern"] as string | null)
96
+ : existingRule.sysDescrPattern,
97
+ sysObjectIdPattern: dataKeys.includes("sysObjectIdPattern")
98
+ ? (data["sysObjectIdPattern"] as string | null)
99
+ : existingRule.sysObjectIdPattern,
100
+ });
101
+ }
102
+
103
+ return { updateBy, carryForward: null };
104
+ }
105
+
106
+ private validateCriteria(data: {
107
+ ipMatchTarget?: string | null | undefined;
108
+ sysNamePattern?: string | null | undefined;
109
+ sysDescrPattern?: string | null | undefined;
110
+ sysObjectIdPattern?: string | null | undefined;
111
+ }): void {
112
+ const ipMatchTarget: string = (data.ipMatchTarget || "").trim();
113
+ const sysNamePattern: string = (data.sysNamePattern || "").trim();
114
+ const sysDescrPattern: string = (data.sysDescrPattern || "").trim();
115
+ const sysObjectIdPattern: string = (data.sysObjectIdPattern || "").trim();
116
+
117
+ /*
118
+ * A rule with no conditions matches nothing (see AutoImportRuleMatcher) —
119
+ * and would read as "match everything" to whoever finds it later.
120
+ */
121
+ if (
122
+ !ipMatchTarget &&
123
+ !sysNamePattern &&
124
+ !sysDescrPattern &&
125
+ !sysObjectIdPattern
126
+ ) {
127
+ throw new BadDataException(
128
+ "At least one of Host IP Is In, System Name Pattern, System Description Pattern, or System Object ID Pattern is required.",
129
+ );
130
+ }
131
+
132
+ /*
133
+ * Well-formedness only, deliberately NOT the scan-size ceiling: matching
134
+ * an address against 10.0.0.0/8 is a containment check, not a sweep, so
135
+ * a condition covering a huge block is legitimate here even though a
136
+ * scan target that size is not.
137
+ */
138
+ if (ipMatchTarget && !ScanTargetUtil.isValid(ipMatchTarget)) {
139
+ throw new BadDataException(
140
+ `"${ipMatchTarget}" is not a valid Host IP condition. ${ScanTargetUtil.getSyntaxHint()}`,
141
+ );
142
+ }
143
+
144
+ this.validatePattern("System Name Pattern", sysNamePattern);
145
+ this.validatePattern("System Description Pattern", sysDescrPattern);
146
+ this.validateOidPattern(sysObjectIdPattern);
147
+ }
148
+
149
+ private validatePattern(title: string, pattern: string): void {
150
+ if (!pattern || RulePatternMatchUtil.isSupportedPattern(pattern)) {
151
+ return;
152
+ }
153
+
154
+ throw new BadDataException(
155
+ `${title} is neither a valid regular expression nor a '*' wildcard pattern, so it would never match: ${pattern}`,
156
+ );
157
+ }
158
+
159
+ /*
160
+ * The sysObjectID condition is NOT free-text: an OID is a dotted numeric
161
+ * arc, matched as a literal-dot '*' glob or an arc prefix (see
162
+ * AutoImportRuleMatcher.matchesOidPattern — regex-first matching would make
163
+ * "1.3.6.1.4.1.9.*" match enterprise 94 too). So the only characters that
164
+ * can ever match anything are digits, dots and '*'; anything else is a
165
+ * pattern that silently never fires, which is exactly what this service
166
+ * exists to reject at the write.
167
+ */
168
+ private validateOidPattern(pattern: string): void {
169
+ if (!pattern) {
170
+ return;
171
+ }
172
+
173
+ if (!OID_PATTERN_SHAPE.test(pattern)) {
174
+ throw new BadDataException(
175
+ `System Object ID Pattern must be an OID prefix (1.3.6.1.4.1.9) or a '*' wildcard OID pattern (1.3.6.1.4.1.9.*) — digits, dots and '*' only: ${pattern}`,
176
+ );
177
+ }
178
+ }
179
+ }
180
+
181
+ export default new Service();
@@ -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
@@ -0,0 +1,355 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import Model from "../../Models/DatabaseModels/UserTwoFactorBackupCode";
3
+ import TwoFactorBackupCode, {
4
+ BackupCodeSetSize,
5
+ } from "../Utils/TwoFactorBackupCode";
6
+ import CreateBy from "../Types/Database/CreateBy";
7
+ import { OnCreate } from "../Types/Database/Hooks";
8
+ import QueryHelper from "../Types/Database/QueryHelper";
9
+ import BadDataException from "../../Types/Exception/BadDataException";
10
+ import LIMIT_MAX from "../../Types/Database/LimitMax";
11
+ import SortOrder from "../../Types/BaseDatabase/SortOrder";
12
+ import ObjectID from "../../Types/ObjectID";
13
+ import PositiveNumber from "../../Types/PositiveNumber";
14
+ import OneUptimeDate from "../../Types/Date";
15
+ import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
16
+ import logger from "../Utils/Logger";
17
+
18
+ /**
19
+ * How many of a user's backup codes are left, for the profile page and the
20
+ * admin's account view.
21
+ *
22
+ * `total` and `unused` are both reported rather than just the remaining count
23
+ * because they answer different questions: "have you set backup codes up at
24
+ * all" and "how many can you still use". A user with ten codes and a user who
25
+ * has spent all ten both have a `total` of ten, and only the second needs to
26
+ * be told to regenerate.
27
+ */
28
+ export interface TwoFactorBackupCodeStatus {
29
+ total: number;
30
+ unused: number;
31
+
32
+ /*
33
+ * When the current set was minted, or null if there are none. Read off the
34
+ * newest row rather than stored separately -- regeneration replaces the
35
+ * whole set in one call, so every row in a set shares a creation time to
36
+ * within a few milliseconds.
37
+ */
38
+ generatedAt: Date | null;
39
+ }
40
+
41
+ export class Service extends DatabaseService<Model> {
42
+ public constructor() {
43
+ super(Model);
44
+ }
45
+
46
+ /*
47
+ * The model denies create to everyone, so the only way a row is written is
48
+ * `regenerateForUser` below, as root. This hook is the second lock on the
49
+ * same door: it refuses any create that did not come through there.
50
+ *
51
+ * Worth having both because the two guards fail differently. The table
52
+ * permission is enforced by the CRUD API layer and is bypassed wholesale by
53
+ * `isRoot`, which every internal caller uses -- so a future service that
54
+ * reaches for `UserTwoFactorBackupCodeService.create()` with a plaintext
55
+ * code, or with no owner, would sail past it. What lands in `codeHash` is
56
+ * the credential; there is no recovering from writing the wrong thing there.
57
+ */
58
+ @CaptureSpan()
59
+ protected override async onBeforeCreate(
60
+ createBy: CreateBy<Model>,
61
+ ): Promise<OnCreate<Model>> {
62
+ if (!createBy.data.userId) {
63
+ throw new BadDataException("User id is required");
64
+ }
65
+
66
+ if (!createBy.data.codeHash) {
67
+ throw new BadDataException("Backup code hash is required");
68
+ }
69
+
70
+ /*
71
+ * A code that arrives already spent is a caller confusing itself about
72
+ * which end of the lifecycle it is at. Codes are minted usable and are
73
+ * spent only by `consumeCode`.
74
+ *
75
+ * Deleted rather than set to undefined: `exactOptionalPropertyTypes` is on,
76
+ * so the property being ABSENT and the property holding `undefined` are
77
+ * different things to the compiler, and only the first is allowed here.
78
+ */
79
+ delete createBy.data.usedAt;
80
+
81
+ return {
82
+ createBy: createBy,
83
+ carryForward: {},
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Mint a fresh set of backup codes for one user, replacing whatever they
89
+ * had, and return the PLAINTEXT codes.
90
+ *
91
+ * This is the only moment the plaintext exists anywhere. The caller shows it
92
+ * to the user once and then it is gone -- only the keyed digests are stored,
93
+ * so nothing (not this service, not a master admin, not a database dump) can
94
+ * produce the codes again. That is the property the feature is worth having
95
+ * for, and it is why the API route wraps this in a response the UI is
96
+ * expected to make the user acknowledge.
97
+ *
98
+ * REPLACING rather than adding is deliberate. "Generate more codes" would
99
+ * leave the codes from a list the user printed, lost and then regenerated
100
+ * over still working, which defeats the point of regenerating after a
101
+ * suspected compromise.
102
+ *
103
+ * ALL OR NOTHING, and this is the part that needs care. The old set is
104
+ * deleted first, then the new rows are written one at a time -- so a failure
105
+ * partway through the loop would otherwise leave the account holding a few
106
+ * rows that WERE written and that the caller, having thrown, never showed to
107
+ * anybody. `getStatusForUser` would then report "4 backup codes" to a user
108
+ * who has never seen one of them: codes that are unusable in practice and
109
+ * that hide the fact that they have no recovery route left. That is the
110
+ * worst state this feature can produce, because it looks exactly like the
111
+ * good one.
112
+ *
113
+ * So a failure is compensated: everything written for this user is removed,
114
+ * and the account ends with NO codes and an error on screen. "You have no
115
+ * backup codes" is a state the profile page already tells the user to fix;
116
+ * "you have four codes you have never seen" is not.
117
+ *
118
+ * The compensating delete is itself best-effort -- if it also fails there is
119
+ * nothing further to try -- but it turns a silent, permanent trap into two
120
+ * consecutive infrastructure failures.
121
+ */
122
+ @CaptureSpan()
123
+ public async regenerateForUser(data: {
124
+ userId: ObjectID;
125
+ count?: number | undefined;
126
+ }): Promise<Array<string>> {
127
+ const count: number = data.count || BackupCodeSetSize;
128
+
129
+ await this.deleteAllForUser({ userId: data.userId });
130
+
131
+ const codes: Array<string> = TwoFactorBackupCode.generateCodeSet(count);
132
+
133
+ try {
134
+ for (const code of codes) {
135
+ const backupCode: Model = new Model();
136
+ backupCode.userId = data.userId;
137
+ backupCode.codeHash = TwoFactorBackupCode.hashCode({
138
+ code: code,
139
+ userId: data.userId,
140
+ });
141
+
142
+ await this.create({
143
+ data: backupCode,
144
+ props: {
145
+ isRoot: true,
146
+ },
147
+ });
148
+ }
149
+ } catch (err) {
150
+ try {
151
+ await this.deleteAllForUser({ userId: data.userId });
152
+ } catch (cleanupError) {
153
+ /*
154
+ * Swallowed so the ORIGINAL failure is what the caller sees. The
155
+ * cleanup error is the less useful of the two -- it explains why the
156
+ * rollback did not happen, not why the write did not.
157
+ */
158
+ logger.error(cleanupError);
159
+ }
160
+
161
+ throw err;
162
+ }
163
+
164
+ return codes;
165
+ }
166
+
167
+ /**
168
+ * Spend one of this user's backup codes, if the submitted code is one of
169
+ * them and has not been used already.
170
+ *
171
+ * ONE STATEMENT, ON PURPOSE
172
+ *
173
+ * The obvious shape -- find the row, check `usedAt`, then update it -- has a
174
+ * window between the read and the write, and "single use" is the entire
175
+ * guarantee a backup code offers. Two sign-in attempts carrying the same
176
+ * code that arrive together would both read a null `usedAt` and both be let
177
+ * in, which is precisely the property an attacker who has watched somebody
178
+ * type a code off a printed list would exploit.
179
+ *
180
+ * `usedAt IS NULL` in the WHERE clause moves the decision inside Postgres,
181
+ * where the row lock settles it: the first statement to reach the row
182
+ * updates it, the second matches nothing. `RETURNING "_id"` is what turns
183
+ * that into an answer for the caller -- an UPDATE that matched no rows and
184
+ * an UPDATE that matched one are otherwise indistinguishable from here.
185
+ *
186
+ * Written as raw parameterized SQL rather than through the ORM because no
187
+ * write path on DatabaseService both takes a non-primary-key predicate and
188
+ * reports what it matched. Column and table names are literals in this file,
189
+ * never caller input, and all three values are bound parameters.
190
+ *
191
+ * `deletedAt IS NULL` is included because soft-deleted rows are still
192
+ * physically present; without it, a code from a set that regeneration
193
+ * replaced would still sign somebody in.
194
+ *
195
+ * @returns true when a code was spent, false when the code was wrong,
196
+ * already used, or belongs to somebody else.
197
+ */
198
+ @CaptureSpan()
199
+ public async consumeCode(data: {
200
+ userId: ObjectID;
201
+ code: string;
202
+ }): Promise<boolean> {
203
+ const normalizedCode: string = TwoFactorBackupCode.normalizeCode(data.code);
204
+
205
+ /*
206
+ * Refused before the query rather than hashed and looked up. An empty
207
+ * submission cannot be anybody's code, and letting it through would mean
208
+ * one round trip per empty request on a route an attacker can call.
209
+ */
210
+ if (!normalizedCode) {
211
+ return false;
212
+ }
213
+
214
+ const codeHash: string = TwoFactorBackupCode.hashCode({
215
+ code: normalizedCode,
216
+ userId: data.userId,
217
+ });
218
+
219
+ const rows: Array<{ _id: string }> = await this.getRepository()
220
+ .manager.query(
221
+ `UPDATE "UserTwoFactorBackupCode"
222
+ SET "usedAt" = $1, "updatedAt" = CURRENT_TIMESTAMP
223
+ WHERE "userId" = $2
224
+ AND "codeHash" = $3
225
+ AND "usedAt" IS NULL
226
+ AND "deletedAt" IS NULL
227
+ RETURNING "_id"`,
228
+ [OneUptimeDate.getCurrentDate(), data.userId.toString(), codeHash],
229
+ )
230
+ /*
231
+ * For an UPDATE the postgres driver hands back `[rows, rowCount]` rather
232
+ * than a bare row array, so the rows have to be unwrapped. Written
233
+ * defensively: a driver that returns the bare array instead must read as
234
+ * "no code was spent", never as a silent success.
235
+ */
236
+ .then((result: unknown): Array<{ _id: string }> => {
237
+ if (!Array.isArray(result)) {
238
+ return [];
239
+ }
240
+
241
+ const first: unknown = result[0];
242
+
243
+ return Array.isArray(first) ? (first as Array<{ _id: string }>) : [];
244
+ });
245
+
246
+ return rows.length > 0;
247
+ }
248
+
249
+ /**
250
+ * How many codes this user has, and how many are still spendable.
251
+ *
252
+ * Counted rather than fetched: the rows carry a credential digest and there
253
+ * is no caller that needs them, so nothing is loaded that a stray log line
254
+ * could then print.
255
+ */
256
+ @CaptureSpan()
257
+ public async getStatusForUser(data: {
258
+ userId: ObjectID;
259
+ }): Promise<TwoFactorBackupCodeStatus> {
260
+ const total: PositiveNumber = await this.countBy({
261
+ query: {
262
+ userId: data.userId,
263
+ },
264
+ props: {
265
+ isRoot: true,
266
+ },
267
+ });
268
+
269
+ if (total.toNumber() === 0) {
270
+ return {
271
+ total: 0,
272
+ unused: 0,
273
+ generatedAt: null,
274
+ };
275
+ }
276
+
277
+ const unused: number = await this.countUnusedForUser({
278
+ userId: data.userId,
279
+ });
280
+
281
+ const newest: Model | null = await this.findOneBy({
282
+ query: {
283
+ userId: data.userId,
284
+ },
285
+ select: {
286
+ createdAt: true,
287
+ },
288
+ sort: {
289
+ createdAt: SortOrder.Descending,
290
+ },
291
+ props: {
292
+ isRoot: true,
293
+ },
294
+ });
295
+
296
+ return {
297
+ total: total.toNumber(),
298
+ unused: unused,
299
+ generatedAt: newest?.createdAt || null,
300
+ };
301
+ }
302
+
303
+ /**
304
+ * How many unused codes this user has left.
305
+ *
306
+ * Split out from `getStatusForUser` because the login path needs exactly
307
+ * this number and nothing else: it decides whether the two factor challenge
308
+ * screen offers "use a backup code" at all, and it is answered on every
309
+ * two-factor sign-in.
310
+ *
311
+ * `usedAt: QueryHelper.isNull()` rather than `usedAt: null`. A bare null
312
+ * predicate is dropped by TypeORM rather than compiled to `IS NULL`, so the
313
+ * count would silently include spent codes and the login page would offer a
314
+ * recovery route to a user with nothing left to recover with.
315
+ */
316
+ @CaptureSpan()
317
+ public async countUnusedForUser(data: { userId: ObjectID }): Promise<number> {
318
+ const unused: PositiveNumber = await this.countBy({
319
+ query: {
320
+ userId: data.userId,
321
+ usedAt: QueryHelper.isNull(),
322
+ },
323
+ props: {
324
+ isRoot: true,
325
+ },
326
+ });
327
+
328
+ return unused.toNumber();
329
+ }
330
+
331
+ /**
332
+ * Drop every backup code this user has.
333
+ *
334
+ * Called by regeneration, and by UserService.resetTwoFactorAuth -- an
335
+ * operator resetting two factor auth for somebody who lost a device must
336
+ * take the recovery codes with it. Leaving them behind would mean the reset
337
+ * did not actually revoke the account's second-factor material, which is the
338
+ * one thing the operator pressed the button to do.
339
+ */
340
+ @CaptureSpan()
341
+ public async deleteAllForUser(data: { userId: ObjectID }): Promise<void> {
342
+ await this.deleteBy({
343
+ query: {
344
+ userId: data.userId,
345
+ },
346
+ limit: LIMIT_MAX,
347
+ skip: 0,
348
+ props: {
349
+ isRoot: true,
350
+ },
351
+ });
352
+ }
353
+ }
354
+
355
+ export default new Service();
@@ -15,7 +15,9 @@ import EndpointAttachmentUtil, {
15
15
  } from "../../../Utils/Monitor/EndpointAttachmentUtil";
16
16
  import SnmpSystemInfo from "../../../Types/Monitor/SnmpMonitor/SnmpSystemInfo";
17
17
  import SnmpEntityInfo from "../../../Types/Monitor/SnmpMonitor/SnmpEntityInfo";
18
- import SnmpVendorTemplateUtil from "../../../Types/Monitor/SnmpMonitor/SnmpVendorTemplate";
18
+ import SnmpVendorTemplateUtil, {
19
+ SnmpVendorTemplate,
20
+ } from "../../../Types/Monitor/SnmpMonitor/SnmpVendorTemplate";
19
21
  import ObjectID from "../../../Types/ObjectID";
20
22
  import OneUptimeDate from "../../../Types/Date";
21
23
  import logger from "../Logger";
@@ -55,6 +57,9 @@ export default class NetworkInventoryUtil {
55
57
  select: {
56
58
  _id: true,
57
59
  siteId: true,
60
+ // For the vendor-template auto-apply below.
61
+ autoApplyVendorHealthTemplate: true,
62
+ snmpOids: true,
58
63
  },
59
64
  props: {
60
65
  isRoot: true,
@@ -150,6 +155,39 @@ export default class NetworkInventoryUtil {
150
155
  if (vendor) {
151
156
  deviceUpdate["vendor"] = vendor.substring(0, 100);
152
157
  }
158
+
159
+ /*
160
+ * Vendor health template auto-apply — the automatic counterpart of the
161
+ * dashboard's vendor-template banner, for devices that opted in
162
+ * (auto-imported devices do; hand-made ones keep the manual flow).
163
+ *
164
+ * Deliberately narrow: only when the device has NO health OIDs at all.
165
+ * An existing list — template-seeded and pruned, or hand-built — is
166
+ * the operator's, and a poll must never edit it. That also makes this
167
+ * one-shot in practice: the first poll that fingerprints the vendor
168
+ * seeds the list, and every later poll sees a non-empty list and
169
+ * leaves it alone (including after the operator empties it on purpose
170
+ * AND turns the toggle off; with the toggle still on, an emptied list
171
+ * re-seeds next poll, which is what "auto-apply" says on the tin).
172
+ */
173
+ if (
174
+ ownedDevice.autoApplyVendorHealthTemplate &&
175
+ (ownedDevice.snmpOids || []).length === 0 &&
176
+ systemInfo?.sysObjectId
177
+ ) {
178
+ const vendorTemplate: SnmpVendorTemplate | undefined =
179
+ SnmpVendorTemplateUtil.matchBySysObjectId(systemInfo.sysObjectId);
180
+
181
+ if (vendorTemplate) {
182
+ deviceUpdate["snmpOids"] = SnmpVendorTemplateUtil.mergeOids(
183
+ [],
184
+ vendorTemplate.id,
185
+ );
186
+ logger.debug(
187
+ `Auto-applied the "${vendorTemplate.label}" vendor health template to network device ${deviceId.toString()} (sysObjectID ${systemInfo.sysObjectId}).`,
188
+ );
189
+ }
190
+ }
153
191
  if (entityInfo?.model) {
154
192
  deviceUpdate["deviceModel"] = entityInfo.model.substring(0, 100);
155
193
  }