alepha 0.19.3 → 0.19.4
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/assets/swagger-ui/swagger-ui-bundle.js +1 -1
- package/dist/api/audits/index.d.ts +8 -8
- package/dist/api/invitations/index.d.ts +790 -0
- package/dist/api/invitations/index.d.ts.map +1 -0
- package/dist/api/invitations/index.js +665 -0
- package/dist/api/invitations/index.js.map +1 -0
- package/dist/api/jobs/index.browser.js +8 -9
- package/dist/api/jobs/index.browser.js.map +1 -1
- package/dist/api/jobs/index.d.ts +99 -43
- package/dist/api/jobs/index.d.ts.map +1 -1
- package/dist/api/jobs/index.js +257 -40
- package/dist/api/jobs/index.js.map +1 -1
- package/dist/api/keys/index.d.ts +5 -5
- package/dist/api/notifications/index.browser.js +0 -1
- package/dist/api/notifications/index.browser.js.map +1 -1
- package/dist/api/notifications/index.d.ts +3 -3
- package/dist/api/notifications/index.d.ts.map +1 -1
- package/dist/api/notifications/index.js +0 -1
- package/dist/api/notifications/index.js.map +1 -1
- package/dist/api/parameters/index.browser.js +112 -1
- package/dist/api/parameters/index.browser.js.map +1 -1
- package/dist/api/parameters/index.d.ts +90 -3
- package/dist/api/parameters/index.d.ts.map +1 -1
- package/dist/api/parameters/index.js +79 -12
- package/dist/api/parameters/index.js.map +1 -1
- package/dist/{billing → api/payments}/index.d.ts +67 -49
- package/dist/api/payments/index.d.ts.map +1 -0
- package/dist/{billing → api/payments}/index.js +108 -74
- package/dist/api/payments/index.js.map +1 -0
- package/dist/api/subscriptions/index.d.ts +1692 -0
- package/dist/api/subscriptions/index.d.ts.map +1 -0
- package/dist/api/subscriptions/index.js +1870 -0
- package/dist/api/subscriptions/index.js.map +1 -0
- package/dist/api/users/index.d.ts +18 -2
- package/dist/api/users/index.d.ts.map +1 -1
- package/dist/api/users/index.js +167 -34
- package/dist/api/users/index.js.map +1 -1
- package/dist/api/verifications/index.d.ts +13 -13
- package/dist/api/workflows/index.browser.js +246 -0
- package/dist/api/workflows/index.browser.js.map +1 -0
- package/dist/api/workflows/index.d.ts +1618 -0
- package/dist/api/workflows/index.d.ts.map +1 -0
- package/dist/api/workflows/index.js +1504 -0
- package/dist/api/workflows/index.js.map +1 -0
- package/dist/cli/core/index.d.ts +44 -28
- package/dist/cli/core/index.d.ts.map +1 -1
- package/dist/cli/core/index.js +16 -61
- package/dist/cli/core/index.js.map +1 -1
- package/dist/cli/vendor/index.d.ts +31 -8
- package/dist/cli/vendor/index.d.ts.map +1 -1
- package/dist/cli/vendor/index.js +79 -24
- package/dist/cli/vendor/index.js.map +1 -1
- package/dist/core/index.browser.js +21 -2
- package/dist/core/index.browser.js.map +1 -1
- package/dist/core/index.d.ts +33 -2
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +21 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.native.js +21 -2
- package/dist/core/index.native.js.map +1 -1
- package/dist/core/index.workerd.js +21 -2
- package/dist/core/index.workerd.js.map +1 -1
- package/dist/email/smtp/index.js +24 -8
- package/dist/email/smtp/index.js.map +1 -1
- package/dist/orm/core/index.browser.js +0 -18
- package/dist/orm/core/index.browser.js.map +1 -1
- package/dist/orm/core/index.bun.js +0 -17
- package/dist/orm/core/index.bun.js.map +1 -1
- package/dist/orm/core/index.d.ts +1 -13
- package/dist/orm/core/index.d.ts.map +1 -1
- package/dist/orm/core/index.js +0 -17
- package/dist/orm/core/index.js.map +1 -1
- package/dist/orm/postgres/index.bun.js +3 -3
- package/dist/orm/postgres/index.bun.js.map +1 -1
- package/dist/orm/postgres/index.d.ts.map +1 -1
- package/dist/orm/postgres/index.js +3 -3
- package/dist/orm/postgres/index.js.map +1 -1
- package/dist/react/router/index.browser.js +25 -3
- package/dist/react/router/index.browser.js.map +1 -1
- package/dist/react/router/index.d.ts +16 -1
- package/dist/react/router/index.d.ts.map +1 -1
- package/dist/react/router/index.js +25 -3
- package/dist/react/router/index.js.map +1 -1
- package/dist/security/index.d.ts +28 -0
- package/dist/security/index.d.ts.map +1 -1
- package/dist/security/index.js +28 -0
- package/dist/security/index.js.map +1 -1
- package/package.json +37 -20
- package/src/api/invitations/__tests__/InvitationService.spec.ts +439 -0
- package/src/api/invitations/controllers/AdminInvitationController.ts +86 -0
- package/src/api/invitations/controllers/InvitationController.ts +84 -0
- package/src/api/invitations/entities/invitations.ts +33 -0
- package/src/api/invitations/index.ts +65 -0
- package/src/api/invitations/jobs/InvitationJobs.ts +37 -0
- package/src/api/invitations/providers/InvitationProvider.ts +45 -0
- package/src/api/invitations/schemas/createInvitationSchema.ts +12 -0
- package/src/api/invitations/schemas/invitationConfigAtom.ts +20 -0
- package/src/api/invitations/schemas/invitationQuerySchema.ts +15 -0
- package/src/api/invitations/schemas/invitationResourceSchema.ts +6 -0
- package/src/api/invitations/schemas/invitationWithResourceInfoSchema.ts +22 -0
- package/src/api/invitations/schemas/myInvitationsQuerySchema.ts +10 -0
- package/src/api/invitations/services/InvitationService.ts +556 -0
- package/src/api/jobs/__tests__/$job.spec.ts +876 -0
- package/src/api/jobs/controllers/AdminJobController.ts +44 -0
- package/src/api/jobs/entities/jobExecutionEntity.ts +0 -2
- package/src/api/jobs/index.ts +0 -3
- package/src/api/jobs/primitives/$job.ts +22 -11
- package/src/api/jobs/providers/JobProvider.ts +229 -19
- package/src/api/jobs/schemas/jobConfigAtom.ts +4 -0
- package/src/api/jobs/schemas/jobCronInfoSchema.ts +1 -0
- package/src/api/jobs/schemas/jobExecutionQuerySchema.ts +0 -1
- package/src/api/jobs/schemas/jobQueueDepthSchema.ts +1 -0
- package/src/api/jobs/schemas/jobRegistrationSchema.ts +1 -6
- package/src/api/jobs/services/JobService.ts +51 -12
- package/src/api/notifications/schemas/notificationQuerySchema.ts +0 -1
- package/src/api/parameters/__tests__/$parameter.spec.ts +327 -0
- package/src/api/parameters/controllers/AdminParameterController.ts +29 -3
- package/src/api/parameters/index.browser.ts +12 -0
- package/src/api/parameters/primitives/$parameter.ts +20 -3
- package/src/api/parameters/services/ParameterProvider.ts +48 -7
- package/src/{billing → api/payments}/__tests__/PaymentMethodService.spec.ts +32 -6
- package/src/api/payments/__tests__/PaymentService.spec.ts +279 -0
- package/src/{billing/controllers/AdminBillingController.ts → api/payments/controllers/AdminPaymentController.ts} +26 -21
- package/src/{billing/controllers/BillingController.ts → api/payments/controllers/PaymentController.ts} +23 -11
- package/src/{billing → api/payments}/entities/paymentIntents.ts +1 -0
- package/src/{billing/errors/BillingError.ts → api/payments/errors/PaymentError.ts} +1 -1
- package/src/{billing → api/payments}/index.ts +31 -25
- package/src/{billing/providers/MemoryBillingProvider.ts → api/payments/providers/MemoryPaymentProvider.ts} +4 -4
- package/src/{billing/providers/BillingProvider.ts → api/payments/providers/PaymentProvider.ts} +9 -2
- package/src/{billing → api/payments}/services/PaymentMethodService.ts +5 -5
- package/src/{billing/services/BillingService.ts → api/payments/services/PaymentService.ts} +94 -18
- package/src/api/subscriptions/__tests__/BillingService.spec.ts +218 -0
- package/src/api/subscriptions/__tests__/SubscriptionService.spec.ts +278 -0
- package/src/api/subscriptions/controllers/AdminSubscriptionController.ts +212 -0
- package/src/api/subscriptions/controllers/SubscriptionController.ts +189 -0
- package/src/api/subscriptions/entities/subscriptionEvents.ts +54 -0
- package/src/api/subscriptions/entities/subscriptions.ts +68 -0
- package/src/api/subscriptions/index.ts +144 -0
- package/src/api/subscriptions/jobs/SubscriptionJobs.ts +382 -0
- package/src/api/subscriptions/middleware/$requireLimit.ts +50 -0
- package/src/api/subscriptions/middleware/$requirePlan.ts +49 -0
- package/src/api/subscriptions/notifications/SubscriptionNotifications.ts +110 -0
- package/src/api/subscriptions/schemas/cancelSubscriptionSchema.ts +8 -0
- package/src/api/subscriptions/schemas/changePlanSchema.ts +9 -0
- package/src/api/subscriptions/schemas/createSubscriptionSchema.ts +11 -0
- package/src/api/subscriptions/schemas/entitlementsSchema.ts +21 -0
- package/src/api/subscriptions/schemas/mrrSchema.ts +13 -0
- package/src/api/subscriptions/schemas/planDefinitionSchema.ts +71 -0
- package/src/api/subscriptions/schemas/planResourceSchema.ts +25 -0
- package/src/api/subscriptions/schemas/subscriptionEventResourceSchema.ts +8 -0
- package/src/api/subscriptions/schemas/subscriptionQuerySchema.ts +19 -0
- package/src/api/subscriptions/schemas/subscriptionResourceSchema.ts +6 -0
- package/src/api/subscriptions/schemas/subscriptionSettingsSchema.ts +32 -0
- package/src/api/subscriptions/schemas/subscriptionStatsSchema.ts +23 -0
- package/src/api/subscriptions/services/BillingService.ts +437 -0
- package/src/api/subscriptions/services/SubscriptionConfig.ts +56 -0
- package/src/api/subscriptions/services/SubscriptionService.ts +867 -0
- package/src/api/subscriptions/services/UsageService.ts +118 -0
- package/src/api/users/__tests__/AdminUserController.spec.ts +80 -1
- package/src/api/users/__tests__/CredentialService.spec.ts +177 -0
- package/src/api/users/__tests__/EmailVerification.spec.ts +29 -18
- package/src/api/users/__tests__/PasswordReset.spec.ts +3 -0
- package/src/api/users/__tests__/RegistrationService.spec.ts +148 -1
- package/src/api/users/__tests__/SessionService.spec.ts +142 -1
- package/src/api/users/atoms/realmAuthSettingsAtom.ts +10 -1
- package/src/api/users/controllers/UserController.ts +3 -8
- package/src/api/users/notifications/UserNotifications.ts +23 -0
- package/src/api/users/schemas/loginSchema.ts +1 -1
- package/src/api/users/services/CredentialService.ts +51 -4
- package/src/api/users/services/RegistrationService.ts +38 -9
- package/src/api/users/services/SessionService.ts +62 -9
- package/src/api/users/services/UserService.ts +21 -12
- package/src/api/workflows/__tests__/$workflow.spec.ts +616 -0
- package/src/api/workflows/controllers/AdminWorkflowController.ts +191 -0
- package/src/api/workflows/entities/workflowExecutions.ts +74 -0
- package/src/api/workflows/entities/workflowStepExecutions.ts +74 -0
- package/src/api/workflows/entities/workflowStepLogs.ts +13 -0
- package/src/api/workflows/index.browser.ts +22 -0
- package/src/api/workflows/index.ts +124 -0
- package/src/api/workflows/jobs/WorkflowJobs.ts +77 -0
- package/src/api/workflows/primitives/$workflow.ts +202 -0
- package/src/api/workflows/providers/WorkflowProvider.ts +1284 -0
- package/src/api/workflows/schemas/workflowActivitySchema.ts +15 -0
- package/src/api/workflows/schemas/workflowConfigAtom.ts +51 -0
- package/src/api/workflows/schemas/workflowExecutionDetailSchema.ts +18 -0
- package/src/api/workflows/schemas/workflowExecutionQuerySchema.ts +26 -0
- package/src/api/workflows/schemas/workflowExecutionResourceSchema.ts +30 -0
- package/src/api/workflows/schemas/workflowRegistrationSchema.ts +26 -0
- package/src/api/workflows/schemas/workflowStatsSchema.ts +16 -0
- package/src/api/workflows/schemas/workflowStepExecutionResourceSchema.ts +15 -0
- package/src/api/workflows/services/WorkflowService.ts +382 -0
- package/src/cli/core/templates/webAppRouterTs.ts +5 -58
- package/src/cli/vendor/__tests__/VendorService.spec.ts +283 -178
- package/src/cli/vendor/services/VendorService.ts +126 -27
- package/src/core/__tests__/TypeProvider.spec.ts +4 -2
- package/src/core/providers/SchemaValidator.ts +1 -1
- package/src/core/providers/TypeProvider.ts +46 -3
- package/src/orm/__tests__/enums.spec.ts +22 -29
- package/src/orm/__tests__/orm-showcase-tests.ts +430 -0
- package/src/orm/__tests__/orm-showcase.spec.ts +167 -0
- package/src/orm/core/providers/DatabaseTypeProvider.ts +0 -29
- package/src/orm/postgres/services/PostgresModelBuilder.ts +3 -6
- package/src/react/router/__tests__/$page.browser.spec.tsx +157 -0
- package/src/react/router/providers/ReactBrowserProvider.ts +39 -0
- package/src/react/router/providers/ReactBrowserRouterProvider.ts +22 -0
- package/src/security/__tests__/$secure-combinations.spec.ts +945 -0
- package/src/security/primitives/$secure.ts +28 -0
- package/dist/billing/index.d.ts.map +0 -1
- package/dist/billing/index.js.map +0 -1
- package/src/billing/__tests__/BillingService.spec.ts +0 -136
- /package/src/{billing → api/payments}/entities/paymentMethods.ts +0 -0
- /package/src/{billing → api/payments}/entities/refunds.ts +0 -0
- /package/src/{billing → api/payments}/schemas/intentSchemas.ts +0 -0
- /package/src/{billing → api/payments}/schemas/paymentMethodSchemas.ts +0 -0
- /package/src/{billing → api/payments}/schemas/refundSchemas.ts +0 -0
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
CryptoProvider,
|
|
8
8
|
InvalidCredentialsError,
|
|
9
9
|
} from "alepha/security";
|
|
10
|
-
import { BadRequestError } from "alepha/server";
|
|
10
|
+
import { BadRequestError, UnauthorizedError } from "alepha/server";
|
|
11
11
|
import { describe, it } from "vitest";
|
|
12
12
|
import {
|
|
13
13
|
AlephaApiUsers,
|
|
@@ -320,6 +320,32 @@ describe("alepha/api/users - SessionService.login", () => {
|
|
|
320
320
|
).rejects.toThrowError(InvalidCredentialsError);
|
|
321
321
|
});
|
|
322
322
|
|
|
323
|
+
it("should reject login for disabled user", async ({ expect }) => {
|
|
324
|
+
const { sessionService, userService, cryptoProvider, identities } =
|
|
325
|
+
await setup();
|
|
326
|
+
|
|
327
|
+
const password = "disabledUserPass123!";
|
|
328
|
+
const hashedPassword = await cryptoProvider.hashPassword(password);
|
|
329
|
+
|
|
330
|
+
const user = await userService.users().create({
|
|
331
|
+
username: "disableduser",
|
|
332
|
+
email: "disabled@example.com",
|
|
333
|
+
roles: ["user"],
|
|
334
|
+
enabled: false,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await identities.create({
|
|
338
|
+
provider: "local",
|
|
339
|
+
providerUserId: "disabled@example.com",
|
|
340
|
+
userId: user.id,
|
|
341
|
+
password: hashedPassword,
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
await expect(
|
|
345
|
+
sessionService.login("local", "disabled@example.com", password),
|
|
346
|
+
).rejects.toThrowError(InvalidCredentialsError);
|
|
347
|
+
});
|
|
348
|
+
|
|
323
349
|
describe("login rate limiting", () => {
|
|
324
350
|
it("should block account after max failed attempts", async ({ expect }) => {
|
|
325
351
|
const { sessionService, userService, cryptoProvider, identities } =
|
|
@@ -775,4 +801,119 @@ describe("alepha/api/users - SessionService.link", () => {
|
|
|
775
801
|
|
|
776
802
|
expect(result.id).toBe(user.id);
|
|
777
803
|
});
|
|
804
|
+
|
|
805
|
+
it("should refuse auto-link when OAuth provider says email_verified is false", async ({
|
|
806
|
+
expect,
|
|
807
|
+
}) => {
|
|
808
|
+
const { sessionService, userService } = await setup();
|
|
809
|
+
|
|
810
|
+
await userService.users().create({
|
|
811
|
+
username: "verifieduser",
|
|
812
|
+
email: "verified@example.com",
|
|
813
|
+
roles: ["user"],
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
await expect(
|
|
817
|
+
sessionService.link("google", {
|
|
818
|
+
sub: "google-unverified",
|
|
819
|
+
email: "verified@example.com",
|
|
820
|
+
name: "Unverified User",
|
|
821
|
+
email_verified: false,
|
|
822
|
+
}),
|
|
823
|
+
).rejects.toThrowError(BadRequestError);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it("should allow auto-link when email_verified is true or undefined", async ({
|
|
827
|
+
expect,
|
|
828
|
+
}) => {
|
|
829
|
+
const { sessionService, userService } = await setup();
|
|
830
|
+
|
|
831
|
+
const user = await userService.users().create({
|
|
832
|
+
username: "linkableuser",
|
|
833
|
+
email: "linkable@example.com",
|
|
834
|
+
roles: ["user"],
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
const result = await sessionService.link("google", {
|
|
838
|
+
sub: "google-verified",
|
|
839
|
+
email: "linkable@example.com",
|
|
840
|
+
name: "Verified User",
|
|
841
|
+
email_verified: true,
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
expect(result.id).toBe(user.id);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it("should use defaultRoles from realm settings for new OAuth users", async ({
|
|
848
|
+
expect,
|
|
849
|
+
}) => {
|
|
850
|
+
const { sessionService, alepha } = await setup();
|
|
851
|
+
|
|
852
|
+
const realmProvider = alepha.inject(RealmProvider);
|
|
853
|
+
realmProvider.register("custom-roles", {
|
|
854
|
+
settings: {
|
|
855
|
+
registrationAllowed: true,
|
|
856
|
+
defaultRoles: ["member", "viewer"],
|
|
857
|
+
} as never,
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
const result = await sessionService.link(
|
|
861
|
+
"google",
|
|
862
|
+
{
|
|
863
|
+
sub: "google-newuser-roles",
|
|
864
|
+
email: "newuser-roles@example.com",
|
|
865
|
+
name: "New Role User",
|
|
866
|
+
},
|
|
867
|
+
"custom-roles",
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
expect("roles" in result && result.roles).toEqual(["member", "viewer"]);
|
|
871
|
+
});
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
describe("alepha/api/users - SessionService.refreshSession", () => {
|
|
875
|
+
it("should reject refresh for disabled user and delete session", async ({
|
|
876
|
+
expect,
|
|
877
|
+
}) => {
|
|
878
|
+
const { sessionService, userService, cryptoProvider, identities } =
|
|
879
|
+
await setup();
|
|
880
|
+
|
|
881
|
+
const password = "refreshDisabledPass!";
|
|
882
|
+
const hashedPassword = await cryptoProvider.hashPassword(password);
|
|
883
|
+
|
|
884
|
+
const user = await userService.users().create({
|
|
885
|
+
username: "refreshdisableduser",
|
|
886
|
+
email: "refresh-disabled@example.com",
|
|
887
|
+
roles: ["user"],
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
await identities.create({
|
|
891
|
+
provider: "local",
|
|
892
|
+
providerUserId: "refresh-disabled@example.com",
|
|
893
|
+
userId: user.id,
|
|
894
|
+
password: hashedPassword,
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// Login to get a session with refresh token
|
|
898
|
+
await sessionService.login(
|
|
899
|
+
"local",
|
|
900
|
+
"refresh-disabled@example.com",
|
|
901
|
+
password,
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
const { refreshToken } = await sessionService.createSession(user, 3600);
|
|
905
|
+
|
|
906
|
+
// Disable the user
|
|
907
|
+
await userService.users().updateById(user.id, { enabled: false });
|
|
908
|
+
|
|
909
|
+
// Refresh should fail with UnauthorizedError
|
|
910
|
+
await expect(
|
|
911
|
+
sessionService.refreshSession(refreshToken),
|
|
912
|
+
).rejects.toThrowError(UnauthorizedError);
|
|
913
|
+
|
|
914
|
+
// Session should be deleted — a second refresh should also fail
|
|
915
|
+
await expect(
|
|
916
|
+
sessionService.refreshSession(refreshToken),
|
|
917
|
+
).rejects.toThrowError();
|
|
918
|
+
});
|
|
778
919
|
});
|
|
@@ -70,6 +70,15 @@ export const realmAuthSettingsAtom = $atom({
|
|
|
70
70
|
description:
|
|
71
71
|
"List of usernames that are automatically promoted to admin role on login",
|
|
72
72
|
}),
|
|
73
|
+
defaultRoles: t.array(t.string(), {
|
|
74
|
+
description: "Default roles assigned to newly registered users",
|
|
75
|
+
}),
|
|
76
|
+
verifyEmailUrl: t.optional(
|
|
77
|
+
t.string({
|
|
78
|
+
description:
|
|
79
|
+
"Base URL for email verification links (used when verification method is 'link'). Token and email are appended as query params.",
|
|
80
|
+
}),
|
|
81
|
+
),
|
|
73
82
|
passwordPolicy: t.object({
|
|
74
83
|
minLength: t.integer({
|
|
75
84
|
description: "Minimum password length",
|
|
@@ -122,7 +131,7 @@ export const realmAuthSettingsAtom = $atom({
|
|
|
122
131
|
firstNameLastName: "none" as FieldRequirement,
|
|
123
132
|
adminEmails: [],
|
|
124
133
|
adminUsernames: [],
|
|
125
|
-
|
|
134
|
+
defaultRoles: ["user"],
|
|
126
135
|
passwordPolicy: {
|
|
127
136
|
minLength: 8,
|
|
128
137
|
requireUppercase: true,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { $inject, t } from "alepha";
|
|
2
|
+
import { $secure } from "alepha/security";
|
|
2
3
|
import { $action, okSchema } from "alepha/server";
|
|
3
4
|
import { completePasswordResetRequestSchema } from "../schemas/completePasswordResetRequestSchema.ts";
|
|
4
5
|
import { completeRegistrationRequestSchema } from "../schemas/completeRegistrationRequestSchema.ts";
|
|
@@ -216,13 +217,7 @@ export class UserController {
|
|
|
216
217
|
t.enum(["code", "link"], {
|
|
217
218
|
default: "code",
|
|
218
219
|
description:
|
|
219
|
-
'Verification method: "code" sends a 6-digit code, "link" sends a clickable verification link.',
|
|
220
|
-
}),
|
|
221
|
-
),
|
|
222
|
-
verifyUrl: t.optional(
|
|
223
|
-
t.string({
|
|
224
|
-
description:
|
|
225
|
-
'Base URL for verification link. Required when method is "link". Token and email will be appended as query params.',
|
|
220
|
+
'Verification method: "code" sends a 6-digit code, "link" sends a clickable verification link. When using "link", configure verifyEmailUrl in realm settings.',
|
|
226
221
|
}),
|
|
227
222
|
),
|
|
228
223
|
}),
|
|
@@ -240,7 +235,6 @@ export class UserController {
|
|
|
240
235
|
body.email,
|
|
241
236
|
query.userRealmName,
|
|
242
237
|
method,
|
|
243
|
-
query.verifyUrl,
|
|
244
238
|
);
|
|
245
239
|
|
|
246
240
|
return {
|
|
@@ -293,6 +287,7 @@ export class UserController {
|
|
|
293
287
|
public checkEmailVerification = $action({
|
|
294
288
|
path: "/users/email-verification/check",
|
|
295
289
|
group: this.group,
|
|
290
|
+
use: [$secure()],
|
|
296
291
|
schema: {
|
|
297
292
|
query: t.object({
|
|
298
293
|
email: t.email(),
|
|
@@ -109,6 +109,29 @@ export class UserNotifications {
|
|
|
109
109
|
}),
|
|
110
110
|
});
|
|
111
111
|
|
|
112
|
+
public readonly accountLockout = $notification({
|
|
113
|
+
category: "security",
|
|
114
|
+
description:
|
|
115
|
+
"Email sent to users when their account is temporarily locked due to too many failed login attempts.",
|
|
116
|
+
critical: true,
|
|
117
|
+
sensitive: true,
|
|
118
|
+
email: {
|
|
119
|
+
subject: "Account temporarily locked",
|
|
120
|
+
body: (it) => `
|
|
121
|
+
<h1>Account Temporarily Locked</h1>
|
|
122
|
+
<p>Hi ${it.email},</p>
|
|
123
|
+
<p>Your account has been temporarily locked due to too many failed login attempts.</p>
|
|
124
|
+
<p>If this was you, please wait ${it.lockoutMinutes} minutes before trying again. If you've forgotten your password, you can reset it using the password reset feature.</p>
|
|
125
|
+
<p>If this wasn't you, someone may be trying to access your account. We recommend changing your password as soon as possible.</p>
|
|
126
|
+
<p>Best regards,<br>The Team</p>
|
|
127
|
+
`,
|
|
128
|
+
},
|
|
129
|
+
schema: t.object({
|
|
130
|
+
email: t.string({ format: "email" }),
|
|
131
|
+
lockoutMinutes: t.number(),
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
|
|
112
135
|
public readonly emailVerificationLink = $notification({
|
|
113
136
|
category: "security",
|
|
114
137
|
description:
|
|
@@ -7,6 +7,7 @@ import { $logger } from "alepha/logger";
|
|
|
7
7
|
import { CryptoProvider } from "alepha/security";
|
|
8
8
|
import { BadRequestError, HttpError } from "alepha/server";
|
|
9
9
|
import { $client } from "alepha/server/links";
|
|
10
|
+
import type { RealmAuthSettings } from "../atoms/realmAuthSettingsAtom.ts";
|
|
10
11
|
import { UserAudits } from "../audits/UserAudits.ts";
|
|
11
12
|
import { UserNotifications } from "../notifications/UserNotifications.ts";
|
|
12
13
|
import { RealmProvider } from "../providers/RealmProvider.ts";
|
|
@@ -67,6 +68,38 @@ export class CredentialService {
|
|
|
67
68
|
return this.realmProvider.identityRepository(userRealmName);
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Validate a password against the realm's password policy.
|
|
73
|
+
*/
|
|
74
|
+
public validatePasswordPolicy(
|
|
75
|
+
password: string,
|
|
76
|
+
policy: RealmAuthSettings["passwordPolicy"],
|
|
77
|
+
): void {
|
|
78
|
+
if (password.length < policy.minLength) {
|
|
79
|
+
throw new BadRequestError(
|
|
80
|
+
`Password must be at least ${policy.minLength} characters`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (policy.requireUppercase && !/[A-Z]/.test(password)) {
|
|
84
|
+
throw new BadRequestError(
|
|
85
|
+
"Password must contain at least one uppercase letter",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (policy.requireLowercase && !/[a-z]/.test(password)) {
|
|
89
|
+
throw new BadRequestError(
|
|
90
|
+
"Password must contain at least one lowercase letter",
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
if (policy.requireNumbers && !/\d/.test(password)) {
|
|
94
|
+
throw new BadRequestError("Password must contain at least one number");
|
|
95
|
+
}
|
|
96
|
+
if (policy.requireSpecialCharacters && !/[^a-zA-Z0-9]/.test(password)) {
|
|
97
|
+
throw new BadRequestError(
|
|
98
|
+
"Password must contain at least one special character",
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
70
103
|
/**
|
|
71
104
|
* Phase 1: Create a password reset intent.
|
|
72
105
|
*
|
|
@@ -90,6 +123,14 @@ export class CredentialService {
|
|
|
90
123
|
.add(INTENT_TTL_MINUTES, "minutes")
|
|
91
124
|
.toISOString();
|
|
92
125
|
|
|
126
|
+
// Check if password reset is allowed for this realm
|
|
127
|
+
const realm = this.realmProvider.getRealm(userRealmName);
|
|
128
|
+
const realmSettings = await realm.getSettings();
|
|
129
|
+
if (realmSettings.resetPasswordAllowed === false) {
|
|
130
|
+
this.log.debug("Password reset not allowed for realm", { userRealmName });
|
|
131
|
+
return { intentId, expiresAt };
|
|
132
|
+
}
|
|
133
|
+
|
|
93
134
|
// Find user by email (silent fail for security)
|
|
94
135
|
const user = await this.users(userRealmName).findOne({
|
|
95
136
|
where: { email: { eq: email } },
|
|
@@ -187,6 +228,11 @@ export class CredentialService {
|
|
|
187
228
|
});
|
|
188
229
|
}
|
|
189
230
|
|
|
231
|
+
// Validate password against realm policy before consuming the verification code
|
|
232
|
+
const realm = this.realmProvider.getRealm(intent.realmName);
|
|
233
|
+
const realmSettings = await realm.getSettings();
|
|
234
|
+
this.validatePasswordPolicy(body.newPassword, realmSettings.passwordPolicy);
|
|
235
|
+
|
|
190
236
|
// Verify code using verification controller
|
|
191
237
|
const result = await this.verificationController
|
|
192
238
|
.validateVerificationCode({
|
|
@@ -233,8 +279,6 @@ export class CredentialService {
|
|
|
233
279
|
email: intent.email,
|
|
234
280
|
});
|
|
235
281
|
|
|
236
|
-
const realm = this.realmProvider.getRealm(intent.realmName);
|
|
237
|
-
|
|
238
282
|
// Audit: password reset
|
|
239
283
|
await this.userAudits(intent.realmName)?.recordUser("update", {
|
|
240
284
|
userId: intent.userId,
|
|
@@ -320,6 +364,11 @@ export class CredentialService {
|
|
|
320
364
|
throw new BadRequestError("Invalid or expired reset token");
|
|
321
365
|
}
|
|
322
366
|
|
|
367
|
+
// Validate password against realm policy
|
|
368
|
+
const realm = this.realmProvider.getRealm(userRealmName);
|
|
369
|
+
const realmSettings = await realm.getSettings();
|
|
370
|
+
this.validatePasswordPolicy(newPassword, realmSettings.passwordPolicy);
|
|
371
|
+
|
|
323
372
|
// Find user and identity
|
|
324
373
|
const user = await this.users(userRealmName).getOne({
|
|
325
374
|
where: { email: { eq: email } },
|
|
@@ -345,8 +394,6 @@ export class CredentialService {
|
|
|
345
394
|
userId: { eq: user.id },
|
|
346
395
|
});
|
|
347
396
|
|
|
348
|
-
const realm = this.realmProvider.getRealm(userRealmName);
|
|
349
|
-
|
|
350
397
|
// Audit: password reset (legacy method)
|
|
351
398
|
await this.userAudits(userRealmName)?.recordUser("update", {
|
|
352
399
|
userId: user.id,
|
|
@@ -14,6 +14,7 @@ import { RealmProvider } from "../providers/RealmProvider.ts";
|
|
|
14
14
|
import type { CompleteRegistrationRequest } from "../schemas/completeRegistrationRequestSchema.ts";
|
|
15
15
|
import type { RegisterRequest } from "../schemas/registerRequestSchema.ts";
|
|
16
16
|
import type { RegistrationIntentResponse } from "../schemas/registrationIntentResponseSchema.ts";
|
|
17
|
+
import { CredentialService } from "./CredentialService.ts";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Intent stored in cache during the registration flow.
|
|
@@ -46,12 +47,18 @@ export class RegistrationService {
|
|
|
46
47
|
protected readonly cryptoProvider = $inject(CryptoProvider);
|
|
47
48
|
protected readonly verificationController = $client<VerificationController>();
|
|
48
49
|
protected readonly realmProvider = $inject(RealmProvider);
|
|
50
|
+
protected readonly credentialService = $inject(CredentialService);
|
|
49
51
|
|
|
50
52
|
protected readonly intentCache = $cache<RegistrationIntent>({
|
|
51
53
|
name: "api:users:registrations",
|
|
52
54
|
ttl: [INTENT_TTL_MINUTES, "minutes"],
|
|
53
55
|
});
|
|
54
56
|
|
|
57
|
+
protected readonly rateLimitCache = $cache<number>({
|
|
58
|
+
name: "api:users:registration-rate-limit",
|
|
59
|
+
ttl: [15, "minutes"],
|
|
60
|
+
});
|
|
61
|
+
|
|
55
62
|
protected userAudits(realmName?: string) {
|
|
56
63
|
const realm = this.realmProvider.getRealm(realmName);
|
|
57
64
|
if (realm.features.audits) {
|
|
@@ -88,6 +95,20 @@ export class RegistrationService {
|
|
|
88
95
|
userRealmName,
|
|
89
96
|
});
|
|
90
97
|
|
|
98
|
+
// IP rate limiting
|
|
99
|
+
const request = this.alepha.store.get("alepha.http.request");
|
|
100
|
+
const ipKey = request?.ip ? `register:ip:${request.ip}` : undefined;
|
|
101
|
+
if (ipKey) {
|
|
102
|
+
const count = (await this.rateLimitCache.get(ipKey)) ?? 0;
|
|
103
|
+
if (count >= 10) {
|
|
104
|
+
this.log.warn("Registration rate limit exceeded", { ip: request?.ip });
|
|
105
|
+
throw new BadRequestError(
|
|
106
|
+
"Too many registration attempts, please try again later",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
await this.rateLimitCache.set(ipKey, count + 1);
|
|
110
|
+
}
|
|
111
|
+
|
|
91
112
|
const realm = this.realmProvider.getRealm(userRealmName);
|
|
92
113
|
const realmSettings = await realm.getSettings();
|
|
93
114
|
|
|
@@ -138,6 +159,12 @@ export class RegistrationService {
|
|
|
138
159
|
// Check for existing users (username, email, phone)
|
|
139
160
|
await this.checkUserAvailability(body, userRealmName);
|
|
140
161
|
|
|
162
|
+
// Validate password against realm policy
|
|
163
|
+
this.credentialService.validatePasswordPolicy(
|
|
164
|
+
body.password,
|
|
165
|
+
realmSettings.passwordPolicy,
|
|
166
|
+
);
|
|
167
|
+
|
|
141
168
|
// Hash the password
|
|
142
169
|
const passwordHash = await this.cryptoProvider.hashPassword(body.password);
|
|
143
170
|
|
|
@@ -279,23 +306,26 @@ export class RegistrationService {
|
|
|
279
306
|
userRealmName,
|
|
280
307
|
);
|
|
281
308
|
|
|
282
|
-
|
|
283
|
-
await
|
|
309
|
+
const realm = this.realmProvider.getRealm(userRealmName);
|
|
310
|
+
const realmSettings = await realm.getSettings();
|
|
284
311
|
|
|
285
312
|
// Create the user
|
|
286
313
|
const user = await userRepository.create({
|
|
287
|
-
realm:
|
|
314
|
+
realm: realm.name,
|
|
288
315
|
username: intent.data.username,
|
|
289
316
|
email: intent.data.email,
|
|
290
317
|
phoneNumber: intent.data.phoneNumber,
|
|
291
318
|
firstName: intent.data.firstName,
|
|
292
319
|
lastName: intent.data.lastName,
|
|
293
320
|
picture: intent.data.picture,
|
|
294
|
-
roles:
|
|
321
|
+
roles: realmSettings.defaultRoles,
|
|
295
322
|
enabled: true,
|
|
296
323
|
emailVerified: intent.requirements.email, // Marked as verified if we verified during registration
|
|
297
324
|
});
|
|
298
325
|
|
|
326
|
+
// Invalidate intent after successful creation to allow retry on failure
|
|
327
|
+
await this.intentCache.invalidate(body.intentId);
|
|
328
|
+
|
|
299
329
|
// Create credentials identity
|
|
300
330
|
await identityRepository.create({
|
|
301
331
|
userId: user.id,
|
|
@@ -309,8 +339,6 @@ export class RegistrationService {
|
|
|
309
339
|
username: user.username,
|
|
310
340
|
});
|
|
311
341
|
|
|
312
|
-
const realm = this.realmProvider.getRealm(userRealmName);
|
|
313
|
-
|
|
314
342
|
await this.userAudits(userRealmName)?.recordUser("create", {
|
|
315
343
|
userId: user.id,
|
|
316
344
|
userEmail: user.email ?? undefined,
|
|
@@ -335,11 +363,12 @@ export class RegistrationService {
|
|
|
335
363
|
body: Pick<RegisterRequest, "username" | "email" | "phoneNumber">,
|
|
336
364
|
userRealmName?: string,
|
|
337
365
|
): Promise<void> {
|
|
366
|
+
const realm = this.realmProvider.getRealm(userRealmName);
|
|
338
367
|
const userRepository = this.realmProvider.userRepository(userRealmName);
|
|
339
368
|
|
|
340
369
|
if (body.username) {
|
|
341
370
|
const existingUser = await userRepository.findOne({
|
|
342
|
-
where: { username: { ilike: body.username } },
|
|
371
|
+
where: { realm: realm.name, username: { ilike: body.username } },
|
|
343
372
|
});
|
|
344
373
|
if (existingUser) {
|
|
345
374
|
this.log.debug("Username already taken", { username: body.username });
|
|
@@ -349,7 +378,7 @@ export class RegistrationService {
|
|
|
349
378
|
|
|
350
379
|
if (body.email) {
|
|
351
380
|
const existingUser = await userRepository.findOne({
|
|
352
|
-
where: { email: { eq: body.email } },
|
|
381
|
+
where: { realm: realm.name, email: { eq: body.email } },
|
|
353
382
|
});
|
|
354
383
|
if (existingUser) {
|
|
355
384
|
this.log.debug("Email already taken", { email: body.email });
|
|
@@ -359,7 +388,7 @@ export class RegistrationService {
|
|
|
359
388
|
|
|
360
389
|
if (body.phoneNumber) {
|
|
361
390
|
const existingUser = await userRepository.findOne({
|
|
362
|
-
where: { phoneNumber: { eq: body.phoneNumber } },
|
|
391
|
+
where: { realm: realm.name, phoneNumber: { eq: body.phoneNumber } },
|
|
363
392
|
});
|
|
364
393
|
if (existingUser) {
|
|
365
394
|
this.log.debug("Phone number already taken", {
|
|
@@ -15,6 +15,7 @@ import { $client } from "alepha/server/links";
|
|
|
15
15
|
import { FileSystemProvider } from "alepha/system";
|
|
16
16
|
import { UserAudits } from "../audits/UserAudits.ts";
|
|
17
17
|
import type { UserEntity } from "../entities/users.ts";
|
|
18
|
+
import { UserNotifications } from "../notifications/UserNotifications.ts";
|
|
18
19
|
import { RealmProvider } from "../providers/RealmProvider.ts";
|
|
19
20
|
|
|
20
21
|
export class SessionService {
|
|
@@ -35,6 +36,14 @@ export class SessionService {
|
|
|
35
36
|
return undefined;
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
protected userNotifications(realmName?: string) {
|
|
40
|
+
const realm = this.realmProvider.getRealm(realmName);
|
|
41
|
+
if (realm.features.notifications) {
|
|
42
|
+
return this.alepha.inject(UserNotifications);
|
|
43
|
+
}
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
38
47
|
public users(userRealmName?: string) {
|
|
39
48
|
return this.realmProvider.userRepository(userRealmName);
|
|
40
49
|
}
|
|
@@ -141,16 +150,12 @@ export class SessionService {
|
|
|
141
150
|
// Truncate to leave room for suffix
|
|
142
151
|
candidate = candidate.slice(0, maxLength - 2);
|
|
143
152
|
|
|
144
|
-
// Check uniqueness (case-insensitive)
|
|
153
|
+
// Check uniqueness (case-insensitive exact match)
|
|
145
154
|
const isAvailable = async (name: string) => {
|
|
146
|
-
const existing = await users.
|
|
147
|
-
where: { username: {
|
|
148
|
-
limit: 1,
|
|
155
|
+
const existing = await users.findOne({
|
|
156
|
+
where: { username: { ilike: name } },
|
|
149
157
|
});
|
|
150
|
-
|
|
151
|
-
return !existing.some(
|
|
152
|
-
(u: any) => u.username?.toLowerCase() === name.toLowerCase(),
|
|
153
|
-
);
|
|
158
|
+
return !existing;
|
|
154
159
|
};
|
|
155
160
|
|
|
156
161
|
if (await isAvailable(candidate)) {
|
|
@@ -354,6 +359,23 @@ export class SessionService {
|
|
|
354
359
|
throw new InvalidCredentialsError();
|
|
355
360
|
}
|
|
356
361
|
|
|
362
|
+
// Check if user account is enabled
|
|
363
|
+
if (!user.enabled) {
|
|
364
|
+
this.log.warn("Login attempt for disabled account", {
|
|
365
|
+
userId: user.id,
|
|
366
|
+
realm: name,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
await this.userAudits(userRealmName)?.recordAuth("login_failed", {
|
|
370
|
+
userRealm: name,
|
|
371
|
+
resourceId: user.id,
|
|
372
|
+
description: "Login attempt for disabled account",
|
|
373
|
+
metadata: { provider, username },
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
throw new InvalidCredentialsError();
|
|
377
|
+
}
|
|
378
|
+
|
|
357
379
|
// Account rate limit check (per-realm)
|
|
358
380
|
const accountKey = `login:account:${name}:${user.id}`;
|
|
359
381
|
const accountLocked = await this.isLoginLocked(
|
|
@@ -445,6 +467,15 @@ export class SessionService {
|
|
|
445
467
|
metadata: { userId: user.id },
|
|
446
468
|
},
|
|
447
469
|
);
|
|
470
|
+
|
|
471
|
+
// Notify user about account lockout
|
|
472
|
+
if (user.email) {
|
|
473
|
+
const lockoutMinutes = Math.round(loginRateLimit.windowMs / 60_000);
|
|
474
|
+
await this.userNotifications(userRealmName)?.accountLockout.push({
|
|
475
|
+
contact: user.email,
|
|
476
|
+
variables: { email: user.email, lockoutMinutes },
|
|
477
|
+
});
|
|
478
|
+
}
|
|
448
479
|
}
|
|
449
480
|
|
|
450
481
|
throw new InvalidCredentialsError();
|
|
@@ -536,6 +567,16 @@ export class SessionService {
|
|
|
536
567
|
},
|
|
537
568
|
});
|
|
538
569
|
|
|
570
|
+
// Check if user account is still enabled
|
|
571
|
+
if (!user.enabled) {
|
|
572
|
+
this.log.warn("Session refresh for disabled account", {
|
|
573
|
+
userId: user.id,
|
|
574
|
+
sessionId: session.id,
|
|
575
|
+
});
|
|
576
|
+
await this.sessions(userRealmName).deleteById(session.id);
|
|
577
|
+
throw new UnauthorizedError("Account disabled");
|
|
578
|
+
}
|
|
579
|
+
|
|
539
580
|
// Auto-promote to admin if configured (handles "I promote you admin" case)
|
|
540
581
|
await this.ensureAdminRole(user, userRealmName);
|
|
541
582
|
|
|
@@ -636,11 +677,23 @@ export class SessionService {
|
|
|
636
677
|
|
|
637
678
|
const existing = await users.findOne({
|
|
638
679
|
where: {
|
|
680
|
+
realm: realm.name,
|
|
639
681
|
email: profile.email,
|
|
640
682
|
},
|
|
641
683
|
});
|
|
642
684
|
|
|
643
685
|
if (existing) {
|
|
686
|
+
// Refuse auto-link if the OAuth provider explicitly says email is not verified
|
|
687
|
+
if (profile.email_verified === false) {
|
|
688
|
+
this.log.warn(
|
|
689
|
+
"OAuth2 profile email not verified by provider, refusing auto-link",
|
|
690
|
+
{ provider, email: profile.email, userId: existing.id },
|
|
691
|
+
);
|
|
692
|
+
throw new BadRequestError(
|
|
693
|
+
"Cannot link account: email not verified by provider",
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
644
697
|
this.log.debug("Linking OAuth2 profile to existing user by email", {
|
|
645
698
|
provider,
|
|
646
699
|
profileSub: profile.sub,
|
|
@@ -692,7 +745,7 @@ export class SessionService {
|
|
|
692
745
|
email: profile.email,
|
|
693
746
|
// we trust the OAuth2 provider
|
|
694
747
|
emailVerified: true,
|
|
695
|
-
roles:
|
|
748
|
+
roles: realmSettings.defaultRoles,
|
|
696
749
|
});
|
|
697
750
|
|
|
698
751
|
if (profile.picture) {
|