create-warlock 4.0.29 → 4.0.31
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/package.json +1 -1
- package/templates/warlock/package.json +7 -7
- package/templates/warlock/src/app/auth/controllers/forgot-password.controller.ts +46 -0
- package/templates/warlock/src/app/auth/controllers/login.controller.ts +31 -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 +31 -0
- package/templates/warlock/src/app/auth/controllers/reset-password.controller.ts +25 -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 +22 -0
- package/templates/warlock/src/app/auth/models/otp/otp.model.ts +75 -0
- package/templates/warlock/src/app/auth/requests/login.request.ts +10 -0
- package/templates/warlock/src/app/auth/requests/reset-password.request.ts +11 -0
- package/templates/warlock/src/app/auth/routes.ts +22 -0
- package/templates/warlock/src/app/auth/services/auth.service.ts +47 -0
- package/templates/warlock/src/app/auth/services/otp.service.ts +174 -0
- package/templates/warlock/src/app/auth/services/reset-password.service.ts +35 -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/shared/services/scheduler.service.ts +3 -0
- package/templates/warlock/src/app/shared/utils/locales.ts +728 -0
- package/templates/warlock/src/app/users/commands/hello-world.command.ts +8 -0
- package/templates/warlock/src/app/users/controllers/get-users.controller.ts +10 -0
- package/templates/warlock/src/app/users/main.ts +0 -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 +14 -0
- package/templates/warlock/src/app/users/models/user/user.model.ts +46 -0
- package/templates/warlock/src/app/users/repositories/users-repository.ts +66 -0
- package/templates/warlock/src/app/users/repositories/users.repository.ts +27 -0
- package/templates/warlock/src/app/users/routes.ts +4 -0
- package/templates/warlock/src/app/users/services/get-new-customers.ts +5 -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 +7 -0
- package/templates/warlock/src/app/users/services/login-social.ts +19 -0
- package/templates/warlock/src/app/utils/output.ts +5 -0
- package/templates/warlock/src/app/utils/router.ts +30 -0
- package/templates/warlock/src/config/app.ts +12 -0
- package/templates/warlock/src/config/auth.ts +18 -0
- package/templates/warlock/src/config/cache.ts +60 -0
- package/templates/warlock/src/config/database.ts +19 -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/notifications.ts +11 -0
- package/templates/warlock/src/config/storage.ts +21 -0
- package/templates/warlock/src/config/tests.ts +5 -0
- package/templates/warlock/src/config/validation.ts +7 -0
package/package.json
CHANGED
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
"@mongez/reinforcements": "^2.3.12",
|
|
28
28
|
"@mongez/localization": "^3.2.1",
|
|
29
29
|
"@mongez/supportive-is": "^2.0.4",
|
|
30
|
-
"@warlock.js/auth": "4.0.
|
|
31
|
-
"@warlock.js/cache": "4.0.
|
|
32
|
-
"@warlock.js/cascade": "4.0.
|
|
33
|
-
"@warlock.js/scheduler": "4.0.
|
|
34
|
-
"@warlock.js/core": "4.0.
|
|
35
|
-
"@warlock.js/logger": "4.0.
|
|
36
|
-
"@warlock.js/seal": "4.0.
|
|
30
|
+
"@warlock.js/auth": "4.0.31",
|
|
31
|
+
"@warlock.js/cache": "4.0.31",
|
|
32
|
+
"@warlock.js/cascade": "4.0.31",
|
|
33
|
+
"@warlock.js/scheduler": "4.0.31",
|
|
34
|
+
"@warlock.js/core": "4.0.31",
|
|
35
|
+
"@warlock.js/logger": "4.0.31",
|
|
36
|
+
"@warlock.js/seal": "4.0.31",
|
|
37
37
|
"dayjs": "^1.11.13"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { t, v, type Request, type RequestHandler, type Response } from "@warlock.js/core";
|
|
2
|
+
import { usersRepository } from "app/users/repositories/users.repository";
|
|
3
|
+
import { createOtpService } from "../services/otp.service";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Forgot password controller
|
|
7
|
+
* POST /auth/forgot-password
|
|
8
|
+
*/
|
|
9
|
+
export const forgotPassword: RequestHandler = async (request: Request, response: Response) => {
|
|
10
|
+
const { email } = request.validated();
|
|
11
|
+
|
|
12
|
+
// Find user by email (silent fail for security)
|
|
13
|
+
const user = await usersRepository.first({ email });
|
|
14
|
+
|
|
15
|
+
if (!user) {
|
|
16
|
+
return response.notFound({
|
|
17
|
+
error: t("auth.userNotFound"),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Create OTP
|
|
22
|
+
const otp = await createOtpService({
|
|
23
|
+
target: email,
|
|
24
|
+
channel: "email",
|
|
25
|
+
type: "password-reset",
|
|
26
|
+
userId: user.id,
|
|
27
|
+
userType: user.userType,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// TODO: Send email with OTP code
|
|
31
|
+
// await sendPasswordResetEmail(user, otp.get("code"));
|
|
32
|
+
console.log(`[DEV] Password reset OTP for ${email}: ${otp.get("code")}`);
|
|
33
|
+
|
|
34
|
+
// Always return success for security (don't reveal if email exists)
|
|
35
|
+
return response.success({
|
|
36
|
+
message: t("auth.otpSent"),
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
forgotPassword.description = "Request password reset";
|
|
41
|
+
|
|
42
|
+
forgotPassword.validation = {
|
|
43
|
+
schema: v.object({
|
|
44
|
+
email: v.string().email().required(),
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { t, type RequestHandler, type Response } from "@warlock.js/core";
|
|
2
|
+
import { loginSchema, type LoginRequest } from "../requests/login.request";
|
|
3
|
+
import { loginService } from "../services/auth.service";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Login controller
|
|
7
|
+
* POST /auth/login
|
|
8
|
+
*/
|
|
9
|
+
export const login: RequestHandler = async (request: LoginRequest, response: Response) => {
|
|
10
|
+
const result = await loginService(request.validated(), {
|
|
11
|
+
userAgent: request.userAgent,
|
|
12
|
+
ip: request.ip,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
if (!result) {
|
|
16
|
+
return response.unauthorized({
|
|
17
|
+
error: t("auth.invalidCredentials"),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return response.success({
|
|
22
|
+
user: result.user,
|
|
23
|
+
...result.tokens,
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
login.description = "User Login";
|
|
28
|
+
|
|
29
|
+
login.validation = {
|
|
30
|
+
schema: loginSchema,
|
|
31
|
+
};
|
|
@@ -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 logoutAll: 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
|
+
logoutAll.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 logout: 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
|
+
logout.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 me: RequestHandler = async (request: Request, response: Response) => {
|
|
8
|
+
return response.success({
|
|
9
|
+
user: request.user,
|
|
10
|
+
});
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
me.description = "Get Current User";
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { t, v, type Request, type RequestHandler, type Response } from "@warlock.js/core";
|
|
2
|
+
import { refreshTokensService } from "../services/auth.service";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Refresh token controller
|
|
6
|
+
* POST /auth/refresh-token
|
|
7
|
+
*/
|
|
8
|
+
export const refreshToken: RequestHandler = async (request: Request, response: Response) => {
|
|
9
|
+
const token = request.input("refreshToken");
|
|
10
|
+
|
|
11
|
+
const result = await refreshTokensService(token, {
|
|
12
|
+
userAgent: request.userAgent,
|
|
13
|
+
ip: request.ip,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if (!result) {
|
|
17
|
+
return response.unauthorized({
|
|
18
|
+
error: t("auth.invalidRefreshToken"),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return response.success(result);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
refreshToken.description = "Refresh Access Token";
|
|
26
|
+
|
|
27
|
+
refreshToken.validation = {
|
|
28
|
+
schema: v.object({
|
|
29
|
+
refreshToken: v.string().required(),
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { t, type Response } from "@warlock.js/core";
|
|
2
|
+
import { resetPasswordSchema, type ResetPasswordRequest } from "../requests/reset-password.request";
|
|
3
|
+
import { resetPasswordService } from "../services/reset-password.service";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reset password controller
|
|
7
|
+
*/
|
|
8
|
+
export const resetPasswordController = async (
|
|
9
|
+
request: ResetPasswordRequest,
|
|
10
|
+
response: Response,
|
|
11
|
+
) => {
|
|
12
|
+
const { email, code, newPassword } = request.validated();
|
|
13
|
+
|
|
14
|
+
await resetPasswordService(email, code, newPassword);
|
|
15
|
+
|
|
16
|
+
return response.success({
|
|
17
|
+
message: t("auth.passwordResetSuccess"),
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
resetPasswordController.description = "Reset password with OTP";
|
|
22
|
+
|
|
23
|
+
resetPasswordController.validation = {
|
|
24
|
+
schema: resetPasswordSchema,
|
|
25
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { onceConnected } from "@warlock.js/cascade";
|
|
2
|
+
import { Job } from "@warlock.js/scheduler";
|
|
3
|
+
import { scheduler } from "app/shared/services/scheduler.service";
|
|
4
|
+
import { cleanupExpiredOtpsService } from "./services/otp.service";
|
|
5
|
+
|
|
6
|
+
onceConnected(() => {
|
|
7
|
+
const cleanupJob = new Job("cleanup-expired-otps", cleanupExpiredOtpsService).everyHour();
|
|
8
|
+
scheduler.addJob(cleanupJob);
|
|
9
|
+
});
|
|
@@ -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,22 @@
|
|
|
1
|
+
import { migrationOffice } from "@warlock.js/cascade";
|
|
2
|
+
import { OTP } from "../otp.model";
|
|
3
|
+
|
|
4
|
+
const otpBlueprint = OTP.blueprint();
|
|
5
|
+
|
|
6
|
+
export default migrationOffice.register({
|
|
7
|
+
name: "otp",
|
|
8
|
+
createdAt: "22-12-2025_10-30-20",
|
|
9
|
+
blueprint: otpBlueprint,
|
|
10
|
+
up: async () => {
|
|
11
|
+
await otpBlueprint.index("code");
|
|
12
|
+
await otpBlueprint.index(["target", "type"]);
|
|
13
|
+
await otpBlueprint.index("expiresAt");
|
|
14
|
+
await otpBlueprint.index("userId");
|
|
15
|
+
},
|
|
16
|
+
down: async () => {
|
|
17
|
+
await otpBlueprint.dropIndex("code");
|
|
18
|
+
await otpBlueprint.dropIndex("target", "type");
|
|
19
|
+
await otpBlueprint.dropIndex("expiresAt");
|
|
20
|
+
await otpBlueprint.dropIndex("userId");
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Model, type Casts, type Document } from "@warlock.js/cascade";
|
|
2
|
+
|
|
3
|
+
export class OTP extends Model {
|
|
4
|
+
/**
|
|
5
|
+
* Collection name
|
|
6
|
+
*/
|
|
7
|
+
public static collection = "otps";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Default value for model data
|
|
11
|
+
*/
|
|
12
|
+
public defaultValue: Document = {
|
|
13
|
+
attempts: 0,
|
|
14
|
+
maxAttempts: 5,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Cast data types before saving
|
|
19
|
+
*/
|
|
20
|
+
protected casts: Casts = {
|
|
21
|
+
code: "string",
|
|
22
|
+
type: "string",
|
|
23
|
+
target: "string",
|
|
24
|
+
channel: "string",
|
|
25
|
+
userId: "number",
|
|
26
|
+
userType: "string",
|
|
27
|
+
expiresAt: "date",
|
|
28
|
+
usedAt: "date",
|
|
29
|
+
attempts: "number",
|
|
30
|
+
maxAttempts: "number",
|
|
31
|
+
metadata: "object",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Check if OTP is valid (not expired, not used, not max attempts)
|
|
36
|
+
*/
|
|
37
|
+
public get isValid(): boolean {
|
|
38
|
+
if (this.get("usedAt")) return false;
|
|
39
|
+
if (this.get("attempts") >= this.get("maxAttempts")) return false;
|
|
40
|
+
if (new Date() > new Date(this.get("expiresAt"))) return false;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Check if OTP is expired
|
|
46
|
+
*/
|
|
47
|
+
public get isExpired(): boolean {
|
|
48
|
+
return new Date() > new Date(this.get("expiresAt"));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check if max attempts exceeded
|
|
53
|
+
*/
|
|
54
|
+
public get isMaxAttemptsExceeded(): boolean {
|
|
55
|
+
return this.get("attempts") >= this.get("maxAttempts");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Mark OTP as used
|
|
60
|
+
*/
|
|
61
|
+
public async markAsUsed(): Promise<this> {
|
|
62
|
+
return this.save({
|
|
63
|
+
usedAt: new Date(),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Increment failed attempt
|
|
69
|
+
*/
|
|
70
|
+
public async incrementAttempt(): Promise<this> {
|
|
71
|
+
return this.save({
|
|
72
|
+
attempts: this.get("attempts") + 1,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { v, type Infer, type Request } from "@warlock.js/core";
|
|
2
|
+
|
|
3
|
+
export const loginSchema = v.object({
|
|
4
|
+
email: v.string().email().required(),
|
|
5
|
+
password: v.string().required(),
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export type LoginSchema = Infer<typeof loginSchema>;
|
|
9
|
+
|
|
10
|
+
export type LoginRequest = Request<undefined, LoginSchema>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { v, type Infer, type Request } from "@warlock.js/core";
|
|
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>;
|
|
10
|
+
|
|
11
|
+
export type ResetPasswordRequest = Request<undefined, ResetPasswordSchema>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { router } from "@warlock.js/core";
|
|
2
|
+
import { guarded } from "app/utils/router";
|
|
3
|
+
import { forgotPassword } from "./controllers/forgot-password.controller";
|
|
4
|
+
import { login } from "./controllers/login.controller";
|
|
5
|
+
import { logoutAll } from "./controllers/logout-all.controller";
|
|
6
|
+
import { logout } from "./controllers/logout.controller";
|
|
7
|
+
import { me } from "./controllers/me.controller";
|
|
8
|
+
import { refreshToken } 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", login);
|
|
14
|
+
router.post("/refresh-token", refreshToken);
|
|
15
|
+
router.post("/forgot-password", forgotPassword);
|
|
16
|
+
router.post("/reset-password", resetPasswordController);
|
|
17
|
+
guarded(() => {
|
|
18
|
+
router.post("/logout", logout);
|
|
19
|
+
router.post("/logout-all", logoutAll);
|
|
20
|
+
router.get("/me", me);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Auth } from "@warlock.js/auth";
|
|
2
|
+
import { authService, type DeviceInfo, type TokenPair } from "@warlock.js/auth";
|
|
3
|
+
import { User } from "app/users/models/user";
|
|
4
|
+
|
|
5
|
+
export type LoginCredentials = {
|
|
6
|
+
email: string;
|
|
7
|
+
password: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type LoginResult = {
|
|
11
|
+
user: Auth;
|
|
12
|
+
tokens: TokenPair;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Login with email and password
|
|
17
|
+
*/
|
|
18
|
+
export async function loginService(
|
|
19
|
+
credentials: LoginCredentials,
|
|
20
|
+
deviceInfo?: DeviceInfo,
|
|
21
|
+
): Promise<LoginResult | null> {
|
|
22
|
+
return authService.login(User, credentials, deviceInfo);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Logout user
|
|
27
|
+
*/
|
|
28
|
+
export async function logoutService(user: Auth): Promise<void> {
|
|
29
|
+
return authService.logout(user);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Logout from all devices
|
|
34
|
+
*/
|
|
35
|
+
export async function logoutAllService(user: Auth): Promise<void> {
|
|
36
|
+
return authService.revokeAllTokens(user);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Refresh tokens
|
|
41
|
+
*/
|
|
42
|
+
export async function refreshTokensService(
|
|
43
|
+
refreshToken: string,
|
|
44
|
+
deviceInfo?: DeviceInfo,
|
|
45
|
+
): Promise<TokenPair | null> {
|
|
46
|
+
return authService.refreshTokens(refreshToken, deviceInfo);
|
|
47
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { Duration } from "@warlock.js/auth";
|
|
2
|
+
import { ForbiddenError, t } from "@warlock.js/core";
|
|
3
|
+
import { OTP } from "../models/otp";
|
|
4
|
+
import { AuthErrorCode } from "../utils/auth-error-code";
|
|
5
|
+
import type { OTPChannel, OTPType } from "../utils/types";
|
|
6
|
+
|
|
7
|
+
// Default OTP expiration
|
|
8
|
+
const DEFAULT_OTP_EXPIRATION: Duration = { minutes: 15 };
|
|
9
|
+
|
|
10
|
+
// Parse duration to milliseconds
|
|
11
|
+
function parseDurationToMs(duration: Duration): number {
|
|
12
|
+
let ms = 0;
|
|
13
|
+
if (duration.milliseconds) ms += duration.milliseconds;
|
|
14
|
+
if (duration.seconds) ms += duration.seconds * 1000;
|
|
15
|
+
if (duration.minutes) ms += duration.minutes * 60 * 1000;
|
|
16
|
+
if (duration.hours) ms += duration.hours * 60 * 60 * 1000;
|
|
17
|
+
if (duration.days) ms += duration.days * 24 * 60 * 60 * 1000;
|
|
18
|
+
if (duration.weeks) ms += duration.weeks * 7 * 24 * 60 * 60 * 1000;
|
|
19
|
+
return ms;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CreateOTPOptions = {
|
|
23
|
+
target: string;
|
|
24
|
+
channel: OTPChannel;
|
|
25
|
+
type: OTPType;
|
|
26
|
+
userId?: number;
|
|
27
|
+
userType?: string;
|
|
28
|
+
expiresIn?: Duration;
|
|
29
|
+
length?: number;
|
|
30
|
+
alphanumeric?: boolean;
|
|
31
|
+
maxAttempts?: number;
|
|
32
|
+
metadata?: object;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Generate OTP code
|
|
37
|
+
*/
|
|
38
|
+
function generateCode(length: number = 6, alphanumeric: boolean = false): string {
|
|
39
|
+
if (alphanumeric) {
|
|
40
|
+
// Generate alphanumeric code
|
|
41
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
42
|
+
let code = "";
|
|
43
|
+
for (let i = 0; i < length; i++) {
|
|
44
|
+
code += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
45
|
+
}
|
|
46
|
+
return code;
|
|
47
|
+
}
|
|
48
|
+
// Generate numeric code
|
|
49
|
+
const min = Math.pow(10, length - 1);
|
|
50
|
+
const max = Math.pow(10, length) - 1;
|
|
51
|
+
return Math.floor(min + Math.random() * (max - min + 1)).toString();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create a new OTP
|
|
56
|
+
*/
|
|
57
|
+
export async function createOtpService(options: CreateOTPOptions): Promise<OTP> {
|
|
58
|
+
const {
|
|
59
|
+
target,
|
|
60
|
+
channel,
|
|
61
|
+
type,
|
|
62
|
+
userId,
|
|
63
|
+
userType,
|
|
64
|
+
expiresIn = DEFAULT_OTP_EXPIRATION,
|
|
65
|
+
length = 6,
|
|
66
|
+
alphanumeric = false,
|
|
67
|
+
maxAttempts = 5,
|
|
68
|
+
metadata,
|
|
69
|
+
} = options;
|
|
70
|
+
|
|
71
|
+
// Invalidate any existing unused OTPs for this target+type
|
|
72
|
+
await cleanupOtpService(target, type);
|
|
73
|
+
|
|
74
|
+
const code = generateCode(length, alphanumeric);
|
|
75
|
+
const expiresAt = new Date(Date.now() + parseDurationToMs(expiresIn));
|
|
76
|
+
|
|
77
|
+
return OTP.create({
|
|
78
|
+
code,
|
|
79
|
+
type,
|
|
80
|
+
target,
|
|
81
|
+
channel,
|
|
82
|
+
userId,
|
|
83
|
+
userType,
|
|
84
|
+
expiresAt,
|
|
85
|
+
maxAttempts,
|
|
86
|
+
metadata,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Verify an OTP
|
|
92
|
+
*/
|
|
93
|
+
export async function verifyOtpService(
|
|
94
|
+
code: string,
|
|
95
|
+
target: string,
|
|
96
|
+
type: OTPType,
|
|
97
|
+
): Promise<OTP | never> {
|
|
98
|
+
const otp = await OTP.first({
|
|
99
|
+
code,
|
|
100
|
+
target,
|
|
101
|
+
type,
|
|
102
|
+
usedAt: null,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (!otp) {
|
|
106
|
+
throw new ForbiddenError(t("auth.missingOtp"), {
|
|
107
|
+
errorCode: AuthErrorCode.OTP_NOT_FOUND,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (otp.isExpired) {
|
|
112
|
+
throw new ForbiddenError(t("auth.otpExpired"), {
|
|
113
|
+
errorCode: AuthErrorCode.OTP_EXPIRED,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (otp.isMaxAttemptsExceeded) {
|
|
118
|
+
throw new ForbiddenError(t("auth.otpMaxAttempts"), {
|
|
119
|
+
errorCode: AuthErrorCode.OTP_MAX_ATTEMPTS,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Verify code matches
|
|
124
|
+
if (otp.get("code") !== code) {
|
|
125
|
+
await otp.incrementAttempt();
|
|
126
|
+
throw new ForbiddenError(t("auth.otpInvalid"), {
|
|
127
|
+
errorCode: AuthErrorCode.OTP_INVALID,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Mark as used
|
|
132
|
+
await otp.markAsUsed();
|
|
133
|
+
|
|
134
|
+
return otp;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function cleanupOtpService(target: string, type: OTPType): Promise<void> {
|
|
138
|
+
await OTP.delete({
|
|
139
|
+
target,
|
|
140
|
+
type,
|
|
141
|
+
usedAt: null,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resend OTP (invalidate old and create new)
|
|
147
|
+
*/
|
|
148
|
+
export async function resendOtpService(
|
|
149
|
+
target: string,
|
|
150
|
+
type: OTPType,
|
|
151
|
+
channel: OTPChannel,
|
|
152
|
+
options?: Partial<CreateOTPOptions>,
|
|
153
|
+
): Promise<OTP> {
|
|
154
|
+
await cleanupOtpService(target, type);
|
|
155
|
+
return createOtpService({
|
|
156
|
+
target,
|
|
157
|
+
type,
|
|
158
|
+
channel,
|
|
159
|
+
...options,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Cleanup expired OTPs
|
|
165
|
+
*/
|
|
166
|
+
export async function cleanupExpiredOtpsService(): Promise<number> {
|
|
167
|
+
const expiredOtps = await OTP.aggregate().where("expiresAt", "<", new Date()).get();
|
|
168
|
+
|
|
169
|
+
for (const otp of expiredOtps) {
|
|
170
|
+
await otp.destroy();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return expiredOtps.length;
|
|
174
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
/**
|
|
8
|
+
* Reset user password using OTP verification
|
|
9
|
+
*/
|
|
10
|
+
export async function resetPasswordService(
|
|
11
|
+
email: string,
|
|
12
|
+
code: string,
|
|
13
|
+
newPassword: string,
|
|
14
|
+
): Promise<User> {
|
|
15
|
+
// Verify OTP
|
|
16
|
+
const otp = await verifyOtpService(code, email, "password-reset");
|
|
17
|
+
|
|
18
|
+
// Find user
|
|
19
|
+
const user = await User.find(otp.get("userId"));
|
|
20
|
+
|
|
21
|
+
if (!user) {
|
|
22
|
+
throw new BadRequestError(t("auth.otpInvalid"), {
|
|
23
|
+
errorCode: AuthErrorCode.OTP_INVALID,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Update password
|
|
28
|
+
await user.save({ password: newPassword });
|
|
29
|
+
|
|
30
|
+
// Revoke all tokens (force re-login)
|
|
31
|
+
// Or make it an option through user decision.
|
|
32
|
+
await authService.revokeAllTokens(user);
|
|
33
|
+
|
|
34
|
+
return user;
|
|
35
|
+
}
|