create-warlock 4.2.6 → 4.2.8
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/CHANGELOG.md +6 -0
- package/package.json +2 -2
- package/templates/warlock/.env.example +36 -0
- package/templates/warlock/.gitattributes +1 -0
- package/templates/warlock/.husky/pre-commit +4 -0
- package/templates/warlock/.prettierignore +4 -0
- package/templates/warlock/.prettierrc.json +10 -0
- package/templates/warlock/.vscode/settings.json +41 -0
- package/templates/warlock/README.md +57 -0
- package/templates/warlock/_.gitignore +6 -0
- package/templates/warlock/docs/new-module.md +551 -0
- package/templates/warlock/eslint.config.js +98 -0
- package/templates/warlock/package.json +74 -0
- package/templates/warlock/public/home.css +523 -0
- package/templates/warlock/skills/api-design/SKILL.md +461 -0
- package/templates/warlock/skills/code-standards/SKILL.md +595 -0
- package/templates/warlock/skills/data-and-persistence/SKILL.md +330 -0
- package/templates/warlock/skills/git-workflow/SKILL.md +282 -0
- package/templates/warlock/skills/module-boundaries/SKILL.md +283 -0
- package/templates/warlock/skills/observability-and-resilience/SKILL.md +306 -0
- package/templates/warlock/skills/security-baseline/SKILL.md +352 -0
- package/templates/warlock/skills/testing-strategy/SKILL.md +323 -0
- package/templates/warlock/src/app/auth/controllers/forgot-password.controller.ts +28 -0
- package/templates/warlock/src/app/auth/controllers/login.controller.ts +22 -0
- package/templates/warlock/src/app/auth/controllers/logout-all.controller.ts +16 -0
- package/templates/warlock/src/app/auth/controllers/logout.controller.ts +16 -0
- package/templates/warlock/src/app/auth/controllers/me.controller.ts +13 -0
- package/templates/warlock/src/app/auth/controllers/refresh-token.controller.ts +29 -0
- package/templates/warlock/src/app/auth/controllers/reset-password.controller.ts +23 -0
- package/templates/warlock/src/app/auth/main.ts +9 -0
- package/templates/warlock/src/app/auth/models/otp/index.ts +1 -0
- package/templates/warlock/src/app/auth/models/otp/migrations/22-12-2025_10-30-20.otp-migration.ts +30 -0
- package/templates/warlock/src/app/auth/models/otp/otp.model.ts +69 -0
- package/templates/warlock/src/app/auth/requests/guarded.request.ts +10 -0
- package/templates/warlock/src/app/auth/routes.ts +22 -0
- package/templates/warlock/src/app/auth/schema/login.schema.ts +8 -0
- package/templates/warlock/src/app/auth/schema/reset-password.schema.ts +9 -0
- package/templates/warlock/src/app/auth/services/auth.service.ts +66 -0
- package/templates/warlock/src/app/auth/services/forgot-password.service.ts +28 -0
- package/templates/warlock/src/app/auth/services/otp.service.ts +173 -0
- package/templates/warlock/src/app/auth/services/reset-password.service.ts +39 -0
- package/templates/warlock/src/app/auth/utils/auth-error-code.ts +6 -0
- package/templates/warlock/src/app/auth/utils/locales.ts +89 -0
- package/templates/warlock/src/app/auth/utils/types.ts +14 -0
- package/templates/warlock/src/app/posts/controllers/create-new-post.controller.ts +21 -0
- package/templates/warlock/src/app/posts/controllers/update-post.controller.ts +30 -0
- package/templates/warlock/src/app/posts/models/post/migrations/09-01-2026_02-07-51-post.migration.ts +15 -0
- package/templates/warlock/src/app/posts/models/post/post.model.ts +23 -0
- package/templates/warlock/src/app/posts/resources/post.resource.ts +14 -0
- package/templates/warlock/src/app/posts/routes.ts +8 -0
- package/templates/warlock/src/app/posts/schema/create-post.schema.ts +9 -0
- package/templates/warlock/src/app/posts/schema/update-post.schema.ts +9 -0
- package/templates/warlock/src/app/shared/components/HomePageComponent.tsx +229 -0
- package/templates/warlock/src/app/shared/controllers/home-page.controller.ts +18 -0
- package/templates/warlock/src/app/shared/controllers/home-page.controller.tsx +8 -0
- package/templates/warlock/src/app/shared/routes.ts +4 -0
- package/templates/warlock/src/app/shared/services/scheduler.service.ts +3 -0
- package/templates/warlock/src/app/shared/tests/infrastructure.test.ts +22 -0
- package/templates/warlock/src/app/shared/utils/global-columns-schema.ts +8 -0
- package/templates/warlock/src/app/shared/utils/locales.ts +766 -0
- package/templates/warlock/src/app/shared/utils/router.ts +30 -0
- package/templates/warlock/src/app/uploads/controllers/fetch-uploaded-file.controller.ts +33 -0
- package/templates/warlock/src/app/uploads/routes.ts +4 -0
- package/templates/warlock/src/app/users/commands/hello-world.command.ts +8 -0
- package/templates/warlock/src/app/users/controllers/create-new-user.controller.ts +27 -0
- package/templates/warlock/src/app/users/controllers/list-users.controller.ts +12 -0
- package/templates/warlock/src/app/users/events/inject-created-by-user.into-model.event.ts +32 -0
- package/templates/warlock/src/app/users/events/sync.ts +5 -0
- package/templates/warlock/src/app/users/main.ts +5 -0
- package/templates/warlock/src/app/users/models/user/index.ts +1 -0
- package/templates/warlock/src/app/users/models/user/migrations/11-12-2025_23-58-03-user.migration.ts +15 -0
- package/templates/warlock/src/app/users/models/user/user.model.ts +64 -0
- package/templates/warlock/src/app/users/repositories/users.repository.ts +23 -0
- package/templates/warlock/src/app/users/resources/user.resource.ts +14 -0
- package/templates/warlock/src/app/users/routes.ts +8 -0
- package/templates/warlock/src/app/users/schema/create-user.schema.ts +11 -0
- package/templates/warlock/src/app/users/seeds/users.seed.ts +21 -0
- package/templates/warlock/src/app/users/services/get-users.service.ts +5 -0
- package/templates/warlock/src/app/users/services/list-users.service.ts +17 -0
- package/templates/warlock/src/app/users/services/login-social.ts +19 -0
- package/templates/warlock/src/config/app.ts +12 -0
- package/templates/warlock/src/config/auth.ts +20 -0
- package/templates/warlock/src/config/cache.ts +59 -0
- package/templates/warlock/src/config/database.ts +65 -0
- package/templates/warlock/src/config/http.ts +23 -0
- package/templates/warlock/src/config/log.ts +22 -0
- package/templates/warlock/src/config/mail.ts +16 -0
- package/templates/warlock/src/config/repository.ts +11 -0
- package/templates/warlock/src/config/storage.ts +34 -0
- package/templates/warlock/src/config/tests.ts +5 -0
- package/templates/warlock/src/config/validation.ts +7 -0
- package/templates/warlock/storage/.gitignore +2 -0
- package/templates/warlock/tsconfig.json +27 -0
- package/templates/warlock/warlock.config.ts +15 -0
- package/templates/warlock/yarn.lock +2332 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { t, type Request, type RequestHandler, type Response } from "@warlock.js/core";
|
|
2
|
+
import { v } from "@warlock.js/seal";
|
|
3
|
+
import { forgotPasswordService } from "../services/forgot-password.service";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Forgot password controller
|
|
7
|
+
* POST /auth/forgot-password
|
|
8
|
+
*/
|
|
9
|
+
export const forgotPasswordController: RequestHandler = async (
|
|
10
|
+
request: Request,
|
|
11
|
+
response: Response,
|
|
12
|
+
) => {
|
|
13
|
+
const { email } = request.validated();
|
|
14
|
+
|
|
15
|
+
await forgotPasswordService(email);
|
|
16
|
+
|
|
17
|
+
return response.success({
|
|
18
|
+
message: t("auth.otpSent"),
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
forgotPasswordController.description = "Request password reset";
|
|
23
|
+
|
|
24
|
+
forgotPasswordController.validation = {
|
|
25
|
+
schema: v.object({
|
|
26
|
+
email: v.email().required(),
|
|
27
|
+
}),
|
|
28
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Request, type RequestHandler } from "@warlock.js/core";
|
|
2
|
+
import { type LoginSchema, loginSchema } from "../schema/login.schema";
|
|
3
|
+
import { loginService } from "../services/auth.service";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Login controller
|
|
7
|
+
* POST /auth/login
|
|
8
|
+
*/
|
|
9
|
+
export const loginController: RequestHandler<Request<LoginSchema>> = async (request, response) => {
|
|
10
|
+
const result = await loginService(request.validated(), {
|
|
11
|
+
userAgent: request.userAgent,
|
|
12
|
+
ip: request.ip,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
return response.success(result);
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
loginController.description = "User Login";
|
|
19
|
+
|
|
20
|
+
loginController.validation = {
|
|
21
|
+
schema: loginSchema,
|
|
22
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { t, type Request, type RequestHandler, type Response } from "@warlock.js/core";
|
|
2
|
+
import { logoutAllService } from "../services/auth.service";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Logout from all devices controller
|
|
6
|
+
* POST /auth/logout-all
|
|
7
|
+
*/
|
|
8
|
+
export const logoutAllController: RequestHandler = async (request: Request, response: Response) => {
|
|
9
|
+
await logoutAllService(request.user);
|
|
10
|
+
|
|
11
|
+
return response.success({
|
|
12
|
+
message: t("auth.loggedOutAll"),
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
logoutAllController.description = "Logout from all devices";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { t, type Request, type RequestHandler, type Response } from "@warlock.js/core";
|
|
2
|
+
import { logoutService } from "../services/auth.service";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Logout controller
|
|
6
|
+
* POST /auth/logout
|
|
7
|
+
*/
|
|
8
|
+
export const logoutController: RequestHandler = async (request: Request, response: Response) => {
|
|
9
|
+
await logoutService(request.user);
|
|
10
|
+
|
|
11
|
+
return response.success({
|
|
12
|
+
message: t("auth.loggedOut"),
|
|
13
|
+
});
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
logoutController.description = "User Logout";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Request, type RequestHandler, type Response } from "@warlock.js/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Get current user controller
|
|
5
|
+
* GET /auth/me
|
|
6
|
+
*/
|
|
7
|
+
export const meController: RequestHandler = async (request: Request, response: Response) => {
|
|
8
|
+
return response.success({
|
|
9
|
+
user: request.user,
|
|
10
|
+
});
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
meController.description = "Get Current User";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type Request, type RequestHandler, type Response } from "@warlock.js/core";
|
|
2
|
+
import { v } from "@warlock.js/seal";
|
|
3
|
+
import { refreshTokensService } from "../services/auth.service";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Refresh token controller
|
|
7
|
+
* POST /auth/refresh-token
|
|
8
|
+
*/
|
|
9
|
+
export const refreshTokenController: RequestHandler = async (
|
|
10
|
+
request: Request,
|
|
11
|
+
response: Response,
|
|
12
|
+
) => {
|
|
13
|
+
const token = request.input("refreshToken");
|
|
14
|
+
|
|
15
|
+
const result = await refreshTokensService(token, {
|
|
16
|
+
userAgent: request.userAgent,
|
|
17
|
+
ip: request.ip,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
return response.success(result);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
refreshTokenController.description = "Refresh Access Token";
|
|
24
|
+
|
|
25
|
+
refreshTokenController.validation = {
|
|
26
|
+
schema: v.object({
|
|
27
|
+
refreshToken: v.string().required(),
|
|
28
|
+
}),
|
|
29
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { t, type Request, type RequestHandler } from "@warlock.js/core";
|
|
2
|
+
import { resetPasswordSchema, type ResetPasswordSchema } from "../schema/reset-password.schema";
|
|
3
|
+
import { resetPasswordService } from "../services/reset-password.service";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reset password controller
|
|
7
|
+
*/
|
|
8
|
+
export const resetPasswordController: RequestHandler<Request<ResetPasswordSchema>> = async (
|
|
9
|
+
request,
|
|
10
|
+
response,
|
|
11
|
+
) => {
|
|
12
|
+
await resetPasswordService(request.validated());
|
|
13
|
+
|
|
14
|
+
return response.success({
|
|
15
|
+
message: t("auth.passwordResetSuccess"),
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
resetPasswordController.description = "Reset password with OTP";
|
|
20
|
+
|
|
21
|
+
resetPasswordController.validation = {
|
|
22
|
+
schema: resetPasswordSchema,
|
|
23
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { authService } from "@warlock.js/auth";
|
|
2
|
+
import { scheduler } from "app/shared/services/scheduler.service";
|
|
3
|
+
import { cleanupExpiredOtpsService } from "./services/otp.service";
|
|
4
|
+
|
|
5
|
+
// Cleanup expired OTPs every hour
|
|
6
|
+
scheduler.newJob("cleanup-expired-otps", cleanupExpiredOtpsService).everyHour();
|
|
7
|
+
|
|
8
|
+
// Cleanup expired refresh tokens every hour
|
|
9
|
+
scheduler.newJob("cleanup-expired-tokens", () => authService.cleanupExpiredTokens()).everyHour();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./otp.model";
|
package/templates/warlock/src/app/auth/models/otp/migrations/22-12-2025_10-30-20.otp-migration.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { bool, integer, json, Migration, string, timestamp } from "@warlock.js/cascade";
|
|
2
|
+
import { OTP } from "../otp.model";
|
|
3
|
+
|
|
4
|
+
export default Migration.create(
|
|
5
|
+
OTP,
|
|
6
|
+
{
|
|
7
|
+
code: string(20).index(),
|
|
8
|
+
type: string(50),
|
|
9
|
+
target: string(255),
|
|
10
|
+
channel: string(50),
|
|
11
|
+
userId: integer().index(),
|
|
12
|
+
userType: string(50),
|
|
13
|
+
expiresAt: timestamp().index(),
|
|
14
|
+
usedAt: timestamp().nullable(),
|
|
15
|
+
attempts: integer(),
|
|
16
|
+
maxAttempts: integer(),
|
|
17
|
+
metadata: json().nullable(),
|
|
18
|
+
isActive: bool(),
|
|
19
|
+
createdBy: json().nullable(),
|
|
20
|
+
updatedBy: json().nullable(),
|
|
21
|
+
deletedBy: json().nullable(),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
index: [
|
|
25
|
+
{
|
|
26
|
+
columns: ["target", "type"],
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Model, RegisterModel } from "@warlock.js/cascade";
|
|
2
|
+
import { v, type Infer } from "@warlock.js/seal";
|
|
3
|
+
|
|
4
|
+
const otpSchema = v.object({
|
|
5
|
+
code: v.string().required(),
|
|
6
|
+
type: v.string().required(),
|
|
7
|
+
target: v.string().required(),
|
|
8
|
+
channel: v.string().required(),
|
|
9
|
+
userId: v.number().required(),
|
|
10
|
+
userType: v.string().required(),
|
|
11
|
+
expiresAt: v.date().required(),
|
|
12
|
+
usedAt: v.date().optional(),
|
|
13
|
+
attempts: v.number().default(0),
|
|
14
|
+
maxAttempts: v.number().default(5),
|
|
15
|
+
metadata: v.record(v.any()).optional(),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
type OTPSchema = Infer<typeof otpSchema>;
|
|
19
|
+
|
|
20
|
+
@RegisterModel()
|
|
21
|
+
export class OTP extends Model<OTPSchema> {
|
|
22
|
+
/**
|
|
23
|
+
* Table name
|
|
24
|
+
*/
|
|
25
|
+
public static table = "otps";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Model schema
|
|
29
|
+
*/
|
|
30
|
+
public static schema = otpSchema;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Check if OTP is valid (not expired, not used, not max attempts)
|
|
34
|
+
*/
|
|
35
|
+
public get isValid(): boolean {
|
|
36
|
+
if (this.get("usedAt")) return false;
|
|
37
|
+
if ((this.get("attempts") ?? 0) >= (this.get("maxAttempts") ?? 0)) return false;
|
|
38
|
+
if (new Date() > new Date(this.get("expiresAt"))) return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check if OTP is expired
|
|
44
|
+
*/
|
|
45
|
+
public get isExpired(): boolean {
|
|
46
|
+
return new Date() > new Date(this.get("expiresAt"));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Check if max attempts exceeded
|
|
51
|
+
*/
|
|
52
|
+
public get isMaxAttemptsExceeded(): boolean {
|
|
53
|
+
return (this.get("attempts") ?? 0) >= (this.get("maxAttempts") ?? 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Mark OTP as used
|
|
58
|
+
*/
|
|
59
|
+
public async markAsUsed(): Promise<this> {
|
|
60
|
+
return this.set("usedAt", new Date()).save();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Increment failed attempt
|
|
65
|
+
*/
|
|
66
|
+
public async incrementAttempt(): Promise<this> {
|
|
67
|
+
return this.set("attempts", (this.get("attempts") ?? 0) + 1).save();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Request, RequestHandler } from "@warlock.js/core";
|
|
2
|
+
import type { User } from "app/users/models/user";
|
|
3
|
+
|
|
4
|
+
export type GuardedRequest<RequestPayload = unknown> = Request<RequestPayload> & {
|
|
5
|
+
user: User;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type GuardedRequestHandler<RequestPayload = unknown> = RequestHandler<
|
|
9
|
+
GuardedRequest<RequestPayload>
|
|
10
|
+
>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { router } from "@warlock.js/core";
|
|
2
|
+
import { guarded } from "app/shared/utils/router";
|
|
3
|
+
import { forgotPasswordController } from "./controllers/forgot-password.controller";
|
|
4
|
+
import { loginController } from "./controllers/login.controller";
|
|
5
|
+
import { logoutAllController } from "./controllers/logout-all.controller";
|
|
6
|
+
import { logoutController } from "./controllers/logout.controller";
|
|
7
|
+
import { meController } from "./controllers/me.controller";
|
|
8
|
+
import { refreshTokenController } from "./controllers/refresh-token.controller";
|
|
9
|
+
import { resetPasswordController } from "./controllers/reset-password.controller";
|
|
10
|
+
|
|
11
|
+
// Auth routes
|
|
12
|
+
router.prefix("/auth", () => {
|
|
13
|
+
router.post("/login", loginController);
|
|
14
|
+
router.post("/refresh-token", refreshTokenController);
|
|
15
|
+
router.post("/forgot-password", forgotPasswordController);
|
|
16
|
+
router.post("/reset-password", resetPasswordController);
|
|
17
|
+
guarded(() => {
|
|
18
|
+
router.post("/logout", logoutController);
|
|
19
|
+
router.post("/logout-all", logoutAllController);
|
|
20
|
+
router.get("/me", meController);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { v, type Infer } from "@warlock.js/seal";
|
|
2
|
+
|
|
3
|
+
export const resetPasswordSchema = v.object({
|
|
4
|
+
email: v.string().email().required(),
|
|
5
|
+
code: v.string().required(),
|
|
6
|
+
newPassword: v.string().min(8).required(),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export type ResetPasswordSchema = Infer<typeof resetPasswordSchema>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AccessTokenOutput, Auth } from "@warlock.js/auth";
|
|
2
|
+
import { authService, type DeviceInfo, type TokenPair } from "@warlock.js/auth";
|
|
3
|
+
import { t, UnAuthorizedError } from "@warlock.js/core";
|
|
4
|
+
import { User } from "app/users/models/user/user.model";
|
|
5
|
+
|
|
6
|
+
export type LoginCredentials = {
|
|
7
|
+
email: string;
|
|
8
|
+
password: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type LoginResult =
|
|
12
|
+
| {
|
|
13
|
+
user: User;
|
|
14
|
+
tokens: TokenPair;
|
|
15
|
+
}
|
|
16
|
+
| null
|
|
17
|
+
| {
|
|
18
|
+
user: User;
|
|
19
|
+
accessToken: AccessTokenOutput;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Login with email and password
|
|
24
|
+
*/
|
|
25
|
+
export async function loginService(
|
|
26
|
+
credentials: LoginCredentials,
|
|
27
|
+
deviceInfo?: DeviceInfo,
|
|
28
|
+
): Promise<LoginResult> {
|
|
29
|
+
const result = await authService.login(User, credentials, deviceInfo);
|
|
30
|
+
|
|
31
|
+
if (!result) {
|
|
32
|
+
throw new UnAuthorizedError(t("auth.invalidCredentials"));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Logout user
|
|
40
|
+
*/
|
|
41
|
+
export async function logoutService(user: Auth): Promise<void> {
|
|
42
|
+
return authService.logout(user);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Logout from all devices
|
|
47
|
+
*/
|
|
48
|
+
export async function logoutAllService(user: Auth): Promise<void> {
|
|
49
|
+
return authService.revokeAllTokens(user);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Refresh tokens
|
|
54
|
+
*/
|
|
55
|
+
export async function refreshTokensService(
|
|
56
|
+
refreshToken: string,
|
|
57
|
+
deviceInfo?: DeviceInfo,
|
|
58
|
+
): Promise<TokenPair> {
|
|
59
|
+
const result = await authService.refreshTokens(refreshToken, deviceInfo);
|
|
60
|
+
|
|
61
|
+
if (!result) {
|
|
62
|
+
throw new UnAuthorizedError(t("auth.invalidRefreshToken"));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getFirstUserService } from "app/users/services/list-users.service";
|
|
2
|
+
import { createOtpService } from "./otp.service";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Example Usage:
|
|
6
|
+
* await forgotPasswordService("user@example.com");
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Handle forgot password request
|
|
11
|
+
*
|
|
12
|
+
* @param email - User email address
|
|
13
|
+
* @returns Promise<void>
|
|
14
|
+
*/
|
|
15
|
+
export async function forgotPasswordService(email: string): Promise<void> {
|
|
16
|
+
// Find user by email (silent fail for security)
|
|
17
|
+
const user = await getFirstUserService({ email });
|
|
18
|
+
|
|
19
|
+
// Create a password-reset OTP. Wire a mail service to deliver its code —
|
|
20
|
+
// e.g. `sendPasswordResetEmail(user, otp.get("code"))`.
|
|
21
|
+
await createOtpService({
|
|
22
|
+
target: email,
|
|
23
|
+
channel: "email",
|
|
24
|
+
type: "password-reset",
|
|
25
|
+
userId: user.id,
|
|
26
|
+
userType: user.userType,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { ForbiddenError, t } from "@warlock.js/core";
|
|
2
|
+
import { OTP } from "../models/otp";
|
|
3
|
+
import { AuthErrorCode } from "../utils/auth-error-code";
|
|
4
|
+
import type { OTPChannel, OTPType } from "../utils/types";
|
|
5
|
+
|
|
6
|
+
/** Human-friendly duration parts, summed to milliseconds by `parseDurationToMs`. */
|
|
7
|
+
type Duration = {
|
|
8
|
+
milliseconds?: number;
|
|
9
|
+
seconds?: number;
|
|
10
|
+
minutes?: number;
|
|
11
|
+
hours?: number;
|
|
12
|
+
days?: number;
|
|
13
|
+
weeks?: number;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Default OTP expiration
|
|
17
|
+
const DEFAULT_OTP_EXPIRATION: Duration = { minutes: 15 };
|
|
18
|
+
|
|
19
|
+
// Parse duration to milliseconds
|
|
20
|
+
function parseDurationToMs(duration: Duration): number {
|
|
21
|
+
let ms = 0;
|
|
22
|
+
if (duration.milliseconds) ms += duration.milliseconds;
|
|
23
|
+
if (duration.seconds) ms += duration.seconds * 1000;
|
|
24
|
+
if (duration.minutes) ms += duration.minutes * 60 * 1000;
|
|
25
|
+
if (duration.hours) ms += duration.hours * 60 * 60 * 1000;
|
|
26
|
+
if (duration.days) ms += duration.days * 24 * 60 * 60 * 1000;
|
|
27
|
+
if (duration.weeks) ms += duration.weeks * 7 * 24 * 60 * 60 * 1000;
|
|
28
|
+
return ms;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type CreateOTPOptions = {
|
|
32
|
+
target: string;
|
|
33
|
+
channel: OTPChannel;
|
|
34
|
+
type: OTPType;
|
|
35
|
+
userId?: number | string;
|
|
36
|
+
userType?: string;
|
|
37
|
+
expiresIn?: Duration;
|
|
38
|
+
length?: number;
|
|
39
|
+
alphanumeric?: boolean;
|
|
40
|
+
maxAttempts?: number;
|
|
41
|
+
metadata?: object;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generate OTP code
|
|
46
|
+
*/
|
|
47
|
+
function generateCode(length: number = 6, alphanumeric: boolean = false): string {
|
|
48
|
+
if (alphanumeric) {
|
|
49
|
+
// Generate alphanumeric code
|
|
50
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
51
|
+
let code = "";
|
|
52
|
+
for (let i = 0; i < length; i++) {
|
|
53
|
+
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
54
|
+
}
|
|
55
|
+
return code;
|
|
56
|
+
}
|
|
57
|
+
// Generate numeric code
|
|
58
|
+
const min = Math.pow(10, length - 1);
|
|
59
|
+
const max = Math.pow(10, length) - 1;
|
|
60
|
+
return Math.floor(min + Math.random() * (max - min + 1)).toString();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Create a new OTP
|
|
65
|
+
*/
|
|
66
|
+
export async function createOtpService(options: CreateOTPOptions): Promise<OTP> {
|
|
67
|
+
const {
|
|
68
|
+
target,
|
|
69
|
+
channel,
|
|
70
|
+
type,
|
|
71
|
+
userId,
|
|
72
|
+
userType,
|
|
73
|
+
expiresIn = DEFAULT_OTP_EXPIRATION,
|
|
74
|
+
length = 6,
|
|
75
|
+
alphanumeric = false,
|
|
76
|
+
maxAttempts = 5,
|
|
77
|
+
metadata,
|
|
78
|
+
} = options;
|
|
79
|
+
|
|
80
|
+
// Invalidate any existing unused OTPs for this target+type
|
|
81
|
+
await cleanupOtpService(target, type);
|
|
82
|
+
|
|
83
|
+
const code = generateCode(length, alphanumeric);
|
|
84
|
+
const expiresAt = new Date(Date.now() + parseDurationToMs(expiresIn));
|
|
85
|
+
|
|
86
|
+
return OTP.create({
|
|
87
|
+
code,
|
|
88
|
+
type,
|
|
89
|
+
target,
|
|
90
|
+
channel,
|
|
91
|
+
userId,
|
|
92
|
+
userType,
|
|
93
|
+
expiresAt,
|
|
94
|
+
maxAttempts,
|
|
95
|
+
metadata,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Verify an OTP
|
|
101
|
+
*/
|
|
102
|
+
export async function verifyOtpService(
|
|
103
|
+
code: string,
|
|
104
|
+
target: string,
|
|
105
|
+
type: OTPType,
|
|
106
|
+
): Promise<OTP | never> {
|
|
107
|
+
const otp = await OTP.first({
|
|
108
|
+
code,
|
|
109
|
+
target,
|
|
110
|
+
type,
|
|
111
|
+
usedAt: null,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (!otp) {
|
|
115
|
+
throw new ForbiddenError(t("auth.missingOtp"), {
|
|
116
|
+
errorCode: AuthErrorCode.OTP_NOT_FOUND,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (otp.isExpired) {
|
|
121
|
+
throw new ForbiddenError(t("auth.otpExpired"), {
|
|
122
|
+
errorCode: AuthErrorCode.OTP_EXPIRED,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (otp.isMaxAttemptsExceeded) {
|
|
127
|
+
throw new ForbiddenError(t("auth.otpMaxAttempts"), {
|
|
128
|
+
errorCode: AuthErrorCode.OTP_MAX_ATTEMPTS,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Mark as used
|
|
133
|
+
await otp.markAsUsed();
|
|
134
|
+
|
|
135
|
+
return otp;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export async function cleanupOtpService(target: string, type: OTPType): Promise<void> {
|
|
139
|
+
await OTP.delete({
|
|
140
|
+
target,
|
|
141
|
+
type,
|
|
142
|
+
usedAt: null,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resend OTP (invalidate old and create new)
|
|
148
|
+
*/
|
|
149
|
+
export async function resendOtpService(
|
|
150
|
+
target: string,
|
|
151
|
+
type: OTPType,
|
|
152
|
+
channel: OTPChannel,
|
|
153
|
+
options?: Partial<CreateOTPOptions>,
|
|
154
|
+
): Promise<OTP> {
|
|
155
|
+
await cleanupOtpService(target, type);
|
|
156
|
+
return createOtpService({
|
|
157
|
+
target,
|
|
158
|
+
type,
|
|
159
|
+
channel,
|
|
160
|
+
...options,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Cleanup expired OTPs
|
|
166
|
+
*/
|
|
167
|
+
export async function cleanupExpiredOtpsService(): Promise<number> {
|
|
168
|
+
const expiredOtps = await OTP.query().where("expiresAt", "<", new Date()).get();
|
|
169
|
+
|
|
170
|
+
await Promise.all(expiredOtps.map((otp) => otp.destroy()));
|
|
171
|
+
|
|
172
|
+
return expiredOtps.length;
|
|
173
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { authService } from "@warlock.js/auth";
|
|
2
|
+
import { BadRequestError, t } from "@warlock.js/core";
|
|
3
|
+
import { User } from "app/users/models/user";
|
|
4
|
+
import { AuthErrorCode } from "../utils/auth-error-code";
|
|
5
|
+
import { verifyOtpService } from "./otp.service";
|
|
6
|
+
|
|
7
|
+
type ResetPasswordOptions = {
|
|
8
|
+
email: string;
|
|
9
|
+
code: string;
|
|
10
|
+
newPassword: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reset user password using OTP verification
|
|
15
|
+
*/
|
|
16
|
+
export async function resetPasswordService(options: ResetPasswordOptions): Promise<User> {
|
|
17
|
+
const { email, code, newPassword } = options;
|
|
18
|
+
|
|
19
|
+
// Verify OTP
|
|
20
|
+
const otp = await verifyOtpService(code, email, "password-reset");
|
|
21
|
+
|
|
22
|
+
// Find user
|
|
23
|
+
const user = await User.find(otp.get("userId"));
|
|
24
|
+
|
|
25
|
+
if (!user) {
|
|
26
|
+
throw new BadRequestError(t("auth.otpInvalid"), {
|
|
27
|
+
errorCode: AuthErrorCode.OTP_INVALID,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Update password
|
|
32
|
+
await user.set("password", newPassword).save();
|
|
33
|
+
|
|
34
|
+
// Revoke all tokens (force re-login)
|
|
35
|
+
// Or make it an option through user decision.
|
|
36
|
+
await authService.revokeAllTokens(user);
|
|
37
|
+
|
|
38
|
+
return user;
|
|
39
|
+
}
|