exp-node-server 1.1.7 → 2.0.0
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/README.md +82 -20
- package/dist/cli.js +525 -0
- package/package.json +29 -9
- package/templates/mongoose/.dockerignore +10 -0
- package/templates/mongoose/.env.example +56 -7
- package/templates/mongoose/.prettierignore +3 -0
- package/templates/mongoose/.prettierrc +7 -0
- package/templates/mongoose/Dockerfile +33 -0
- package/templates/mongoose/_gitignore +32 -0
- package/templates/mongoose/docker-compose.yml +21 -0
- package/templates/mongoose/eslint.config.js +35 -0
- package/templates/mongoose/package-lock.json +6377 -0
- package/templates/mongoose/package.json +49 -10
- package/templates/mongoose/readme.md +85 -0
- package/templates/mongoose/src/app/configs/index.ts +178 -26
- package/templates/mongoose/src/app/errors/zodError.ts +16 -16
- package/templates/mongoose/src/app/lib/db.ts +18 -0
- package/templates/mongoose/src/app/lib/logger.ts +12 -0
- package/templates/mongoose/src/app/middlewares/auth.ts +48 -44
- package/templates/mongoose/src/app/middlewares/global_error_handler.ts +59 -65
- package/templates/mongoose/src/app/middlewares/not_found_api.ts +7 -7
- package/templates/mongoose/src/app/middlewares/rate_limiter.ts +153 -0
- package/templates/mongoose/src/app/middlewares/request_validator.ts +11 -10
- package/templates/mongoose/src/app/middlewares/uploader.ts +17 -13
- package/templates/mongoose/src/app/modules/account/account.controller.ts +156 -0
- package/templates/mongoose/src/app/modules/account/account.interface.ts +65 -0
- package/templates/mongoose/src/app/modules/account/account.route.ts +101 -0
- package/templates/mongoose/src/app/modules/account/account.schema.ts +97 -0
- package/templates/mongoose/src/app/modules/account/account.service.ts +422 -0
- package/templates/mongoose/src/app/modules/account/account.swagger.ts +278 -0
- package/templates/mongoose/src/app/modules/account/account.validation.ts +63 -0
- package/templates/mongoose/src/app/modules/profile/profile.controller.ts +17 -0
- package/templates/mongoose/src/app/modules/profile/profile.interface.ts +7 -0
- package/templates/mongoose/src/app/modules/profile/profile.route.ts +28 -0
- package/templates/mongoose/src/app/modules/profile/profile.schema.ts +13 -0
- package/templates/mongoose/src/app/modules/profile/profile.service.ts +28 -0
- package/templates/mongoose/src/app/modules/profile/profile.swagger.ts +34 -0
- package/templates/mongoose/src/app/modules/profile/profile.validation.ts +8 -0
- package/templates/mongoose/src/app/passport-js/index.ts +87 -0
- package/templates/mongoose/src/app/passport-js/oauth-state.ts +150 -0
- package/templates/mongoose/src/app/passport-js/providers.ts +258 -0
- package/templates/mongoose/src/app/passport-js/social-account.ts +141 -0
- package/templates/mongoose/src/app/queues/agenda.ts +24 -0
- package/templates/mongoose/src/app/queues/email.queue.ts +53 -0
- package/templates/mongoose/src/app/queues/index.ts +13 -0
- package/templates/mongoose/src/app/templates/otpEmailTemplate.ts +185 -0
- package/templates/mongoose/src/app/templates/resetPasswordEmailTemplate.ts +157 -0
- package/templates/mongoose/src/app/types/error.d.ts +7 -7
- package/templates/mongoose/src/app/types/express.d.ts +11 -0
- package/templates/mongoose/src/app/types/passport-oauth1.d.ts +51 -0
- package/templates/mongoose/src/app/utils/JWT.ts +38 -18
- package/templates/mongoose/src/app/utils/app_error.ts +10 -10
- package/templates/mongoose/src/app/utils/auth_cookie.ts +27 -0
- package/templates/mongoose/src/app/utils/catch_async.ts +8 -8
- package/templates/mongoose/src/app/utils/cloudinary.ts +26 -59
- package/templates/mongoose/src/app/utils/mail_sender.ts +26 -92
- package/templates/mongoose/src/app/utils/manage_response.ts +19 -19
- package/templates/mongoose/src/app/utils/otp.ts +20 -0
- package/templates/mongoose/src/app/utils/session.ts +55 -0
- package/templates/mongoose/src/app.ts +73 -42
- package/templates/mongoose/src/routes.ts +13 -13
- package/templates/mongoose/src/server.ts +38 -18
- package/templates/mongoose/src/swaggerOptions.ts +23 -23
- package/templates/mongoose/tests/app.test.ts +97 -0
- package/templates/mongoose/tests/global-setup.ts +15 -0
- package/templates/mongoose/tests/helpers/fixtures.ts +42 -0
- package/templates/mongoose/tests/routes/account.test.ts +614 -0
- package/templates/mongoose/tests/routes/profile.test.ts +95 -0
- package/templates/mongoose/tests/routes/social.test.ts +310 -0
- package/templates/mongoose/tests/setup.ts +25 -0
- package/templates/mongoose/tsconfig.json +16 -20
- package/templates/mongoose/vitest.config.ts +43 -0
- package/templates/prisma/.dockerignore +2 -0
- package/templates/prisma/.env.example +62 -6
- package/templates/prisma/.prettierignore +3 -0
- package/templates/prisma/.prettierrc +7 -0
- package/templates/prisma/.projectrc.json +1 -1
- package/templates/prisma/Dockerfile +21 -7
- package/templates/prisma/_gitignore +35 -0
- package/templates/prisma/docker-compose.yml +8 -7
- package/templates/prisma/eslint.config.js +35 -0
- package/templates/prisma/package-lock.json +7327 -0
- package/templates/prisma/package.json +47 -9
- package/templates/prisma/prisma/schema/account.schema.prisma +92 -10
- package/templates/prisma/prisma/schema/profile.schema.prisma +8 -3
- package/templates/prisma/prisma/schema/schema.prisma +1 -2
- package/templates/prisma/prisma.config.ts +5 -5
- package/templates/prisma/readme.md +81 -0
- package/templates/prisma/src/app/configs/index.ts +175 -26
- package/templates/prisma/src/app/errors/zodError.ts +16 -16
- package/templates/prisma/src/app/lib/logger.ts +12 -0
- package/templates/prisma/src/app/lib/prisma.ts +4 -6
- package/templates/prisma/src/app/middlewares/auth.ts +37 -16
- package/templates/prisma/src/app/middlewares/global_error_handler.ts +42 -35
- package/templates/prisma/src/app/middlewares/not_found_api.ts +7 -7
- package/templates/prisma/src/app/middlewares/rate_limiter.ts +124 -0
- package/templates/prisma/src/app/middlewares/request_validator.ts +4 -3
- package/templates/prisma/src/app/middlewares/uploader.ts +17 -13
- package/templates/prisma/src/app/modules/account/account.controller.ts +74 -34
- package/templates/prisma/src/app/modules/account/account.route.ts +68 -22
- package/templates/prisma/src/app/modules/account/account.service.ts +329 -309
- package/templates/prisma/src/app/modules/account/account.swagger.ts +167 -76
- package/templates/prisma/src/app/modules/account/account.validation.ts +41 -19
- package/templates/prisma/src/app/modules/profile/profile.controller.ts +4 -4
- package/templates/prisma/src/app/modules/profile/profile.route.ts +18 -12
- package/templates/prisma/src/app/modules/profile/profile.service.ts +11 -18
- package/templates/prisma/src/app/modules/profile/profile.swagger.ts +11 -11
- package/templates/prisma/src/app/modules/profile/profile.validation.ts +2 -2
- package/templates/prisma/src/app/passport-js/index.ts +87 -0
- package/templates/prisma/src/app/passport-js/oauth-state.ts +150 -0
- package/templates/prisma/src/app/passport-js/providers.ts +263 -0
- package/templates/prisma/src/app/passport-js/social-account.ts +147 -0
- package/templates/prisma/src/app/queues/boss.ts +8 -0
- package/templates/prisma/src/app/queues/email.queue.ts +56 -0
- package/templates/prisma/src/app/queues/index.ts +14 -0
- package/templates/prisma/src/app/queues/maintenance.queue.ts +30 -0
- package/templates/prisma/src/app/templates/otpEmailTemplate.ts +185 -0
- package/templates/prisma/src/app/templates/resetPasswordEmailTemplate.ts +157 -0
- package/templates/prisma/src/app/types/error.d.ts +7 -7
- package/templates/prisma/src/app/types/express.d.ts +11 -0
- package/templates/prisma/src/app/types/passport-oauth1.d.ts +51 -0
- package/templates/prisma/src/app/utils/JWT.ts +36 -17
- package/templates/prisma/src/app/utils/app_error.ts +10 -10
- package/templates/prisma/src/app/utils/auth_cookie.ts +27 -0
- package/templates/prisma/src/app/utils/catch_async.ts +8 -8
- package/templates/prisma/src/app/utils/cloudinary.ts +26 -59
- package/templates/prisma/src/app/utils/mail_sender.ts +26 -92
- package/templates/prisma/src/app/utils/manage_response.ts +19 -19
- package/templates/prisma/src/app/utils/otp.ts +20 -0
- package/templates/prisma/src/app/utils/session.ts +57 -0
- package/templates/prisma/src/app.ts +73 -42
- package/templates/prisma/src/routes.ts +12 -10
- package/templates/prisma/src/server.ts +37 -19
- package/templates/prisma/src/swaggerOptions.ts +22 -26
- package/templates/prisma/tests/app.test.ts +86 -0
- package/templates/prisma/tests/helpers/fixtures.ts +35 -0
- package/templates/prisma/tests/helpers/prisma.ts +32 -0
- package/templates/prisma/tests/routes/account.test.ts +627 -0
- package/templates/prisma/tests/routes/profile.test.ts +91 -0
- package/templates/prisma/tests/routes/social.test.ts +317 -0
- package/templates/prisma/tests/setup.ts +10 -0
- package/templates/prisma/tsconfig.json +16 -23
- package/templates/prisma/vitest.config.ts +38 -0
- package/bin/cli.js +0 -41
- package/src/createProject.js +0 -61
- package/src/generateModule.js +0 -93
- package/src/utils/getProjectType.js +0 -13
- package/src/utils/mongoose/controllerGenerator.js +0 -70
- package/src/utils/mongoose/routeGenerator.js +0 -27
- package/src/utils/mongoose/schemaGenerator.js +0 -11
- package/src/utils/mongoose/serviceGenerator.js +0 -49
- package/src/utils/mongoose/swaggerGenerator.js +0 -112
- package/src/utils/mongoose/validationGenerator.js +0 -13
- package/src/utils/prisma/prismaSchemaGenerator.js +0 -10
- package/src/utils/prisma/prismaServiceGenerator.js +0 -47
- package/src/utils/routeInjector.js +0 -40
- package/src/utils/swaggerInjector.js +0 -52
- package/src/utils/validateModuleName.js +0 -17
- package/src/utils/validateProjectName.js +0 -15
- package/src/utils.js +0 -26
- package/templates/mongoose/src/app/errors/castError.ts +0 -23
- package/templates/mongoose/src/app/errors/duplicateError.ts +0 -26
- package/templates/mongoose/src/app/errors/validationError.ts +0 -25
- package/templates/mongoose/src/app/modules/auth/auth.controller.ts +0 -127
- package/templates/mongoose/src/app/modules/auth/auth.interface.ts +0 -24
- package/templates/mongoose/src/app/modules/auth/auth.route.ts +0 -46
- package/templates/mongoose/src/app/modules/auth/auth.schema.ts +0 -19
- package/templates/mongoose/src/app/modules/auth/auth.service.ts +0 -324
- package/templates/mongoose/src/app/modules/auth/auth.swagger.ts +0 -214
- package/templates/mongoose/src/app/modules/auth/auth.validation.ts +0 -37
- package/templates/mongoose/src/app/modules/user/user.controller.ts +0 -17
- package/templates/mongoose/src/app/modules/user/user.interface.ts +0 -15
- package/templates/mongoose/src/app/modules/user/user.route.ts +0 -20
- package/templates/mongoose/src/app/modules/user/user.schema.ts +0 -22
- package/templates/mongoose/src/app/modules/user/user.service.ts +0 -22
- package/templates/mongoose/src/app/modules/user/user.swagger.ts +0 -75
- package/templates/mongoose/src/app/modules/user/user.validation.ts +0 -18
- package/templates/mongoose/src/app/types/index.d.ts +0 -12
- package/templates/mongoose/src/app/utils/isAccountExist.ts +0 -31
- package/templates/prisma/src/app/types/index.d.ts +0 -12
- package/templates/prisma/src/app/utils/otpGenerator.ts +0 -29
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { model, Schema, type Types } from 'mongoose';
|
|
2
|
+
import { profile_model } from '../profile/profile.schema.js';
|
|
3
|
+
import {
|
|
4
|
+
PROVIDERS,
|
|
5
|
+
ROLES,
|
|
6
|
+
type T_account,
|
|
7
|
+
type T_refresh_token,
|
|
8
|
+
type T_social_account,
|
|
9
|
+
type T_social_login_code,
|
|
10
|
+
} from './account.interface.js';
|
|
11
|
+
|
|
12
|
+
const accountSchema = new Schema<T_account>(
|
|
13
|
+
{
|
|
14
|
+
email: { type: String, required: true, unique: true, lowercase: true, trim: true },
|
|
15
|
+
// never loaded unless asked for with `.select('+password')`
|
|
16
|
+
password: { type: String, default: null, select: false },
|
|
17
|
+
provider: { type: String, enum: PROVIDERS, default: 'LOCAL' },
|
|
18
|
+
role: { type: String, enum: ROLES, default: 'USER' },
|
|
19
|
+
isDeleted: { type: Boolean, default: false },
|
|
20
|
+
isAccountVerified: { type: Boolean, default: false },
|
|
21
|
+
tokenVersion: { type: Number, default: 0 },
|
|
22
|
+
lastOtp: { type: String, default: null, select: false },
|
|
23
|
+
lastOtpSendingTime: { type: Date, default: null },
|
|
24
|
+
otpAttempts: { type: Number, default: 0 },
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
timestamps: true,
|
|
28
|
+
versionKey: false,
|
|
29
|
+
toJSON: {
|
|
30
|
+
// belt and braces: secrets never leave the server, even if selected
|
|
31
|
+
transform: (_doc, ret: Record<string, unknown>) => {
|
|
32
|
+
delete ret.password;
|
|
33
|
+
delete ret.lastOtp;
|
|
34
|
+
return ret;
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export const account_model = model('account', accountSchema);
|
|
41
|
+
|
|
42
|
+
const socialAccountSchema = new Schema<T_social_account>(
|
|
43
|
+
{
|
|
44
|
+
accountId: { type: Schema.Types.ObjectId, ref: 'account', required: true, index: true },
|
|
45
|
+
provider: { type: String, enum: PROVIDERS, required: true },
|
|
46
|
+
providerUserId: { type: String, required: true },
|
|
47
|
+
},
|
|
48
|
+
{ timestamps: { createdAt: true, updatedAt: false }, versionKey: false },
|
|
49
|
+
);
|
|
50
|
+
socialAccountSchema.index({ provider: 1, providerUserId: 1 }, { unique: true });
|
|
51
|
+
|
|
52
|
+
export const social_account_model = model('social_account', socialAccountSchema);
|
|
53
|
+
|
|
54
|
+
const socialLoginCodeSchema = new Schema<T_social_login_code>(
|
|
55
|
+
{
|
|
56
|
+
_id: { type: String, required: true },
|
|
57
|
+
accountId: { type: Schema.Types.ObjectId, ref: 'account', required: true },
|
|
58
|
+
// TTL index: MongoDB deletes expired codes by itself
|
|
59
|
+
expiresAt: { type: Date, required: true, expires: 0 },
|
|
60
|
+
},
|
|
61
|
+
{ versionKey: false },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
export const social_login_code_model = model('social_login_code', socialLoginCodeSchema);
|
|
65
|
+
|
|
66
|
+
const refreshTokenSchema = new Schema<T_refresh_token>(
|
|
67
|
+
{
|
|
68
|
+
_id: { type: String, required: true },
|
|
69
|
+
accountId: { type: Schema.Types.ObjectId, ref: 'account', required: true, index: true },
|
|
70
|
+
familyId: { type: String, required: true, index: true },
|
|
71
|
+
tokenVersion: { type: Number, required: true },
|
|
72
|
+
// TTL index: MongoDB deletes expired tokens by itself
|
|
73
|
+
expiresAt: { type: Date, required: true, expires: 0 },
|
|
74
|
+
usedAt: { type: Date, default: null },
|
|
75
|
+
},
|
|
76
|
+
{ timestamps: { createdAt: true, updatedAt: false }, versionKey: false },
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
export const refresh_token_model = model('refresh_token', refreshTokenSchema);
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Creates an account and its profile. Without a transaction (they need a replica
|
|
83
|
+
* set), the account is removed again if the profile can't be created.
|
|
84
|
+
*/
|
|
85
|
+
export const createAccountWithProfile = async (
|
|
86
|
+
account: Partial<T_account> & { _id?: Types.ObjectId },
|
|
87
|
+
profile: { fullName: string; profilePhoto?: string | null },
|
|
88
|
+
) => {
|
|
89
|
+
const created = await account_model.create(account);
|
|
90
|
+
try {
|
|
91
|
+
await profile_model.create({ accountId: created._id, ...profile });
|
|
92
|
+
} catch (err) {
|
|
93
|
+
await account_model.deleteOne({ _id: created._id });
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
return created;
|
|
97
|
+
};
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import bcrypt from 'bcrypt';
|
|
2
|
+
import type { Request } from 'express';
|
|
3
|
+
import { isValidObjectId, Types } from 'mongoose';
|
|
4
|
+
import { configs } from '../../configs/index.js';
|
|
5
|
+
import { hashLoginCode } from '../../passport-js/social-account.js';
|
|
6
|
+
import { enqueueEmail } from '../../queues/email.queue.js';
|
|
7
|
+
import { AppError } from '../../utils/app_error.js';
|
|
8
|
+
import { jwtHelpers, type TokenUser } from '../../utils/JWT.js';
|
|
9
|
+
import { hashRefreshToken, readRefreshToken } from '../../utils/session.js';
|
|
10
|
+
import {
|
|
11
|
+
generateOtp,
|
|
12
|
+
hashOtp,
|
|
13
|
+
isOtpMatch,
|
|
14
|
+
OTP_EXPIRY_MS,
|
|
15
|
+
OTP_MAX_ATTEMPTS,
|
|
16
|
+
OTP_RESEND_COOLDOWN_MS,
|
|
17
|
+
} from '../../utils/otp.js';
|
|
18
|
+
import { profile_model } from '../profile/profile.schema.js';
|
|
19
|
+
import {
|
|
20
|
+
account_model,
|
|
21
|
+
createAccountWithProfile,
|
|
22
|
+
refresh_token_model,
|
|
23
|
+
social_account_model,
|
|
24
|
+
social_login_code_model,
|
|
25
|
+
} from './account.schema.js';
|
|
26
|
+
|
|
27
|
+
// cheap hashes under tests, the real cost everywhere else
|
|
28
|
+
const BCRYPT_ROUNDS = configs.is_test ? 4 : 12;
|
|
29
|
+
|
|
30
|
+
// a rotated refresh token that comes back within this window is most likely a
|
|
31
|
+
// parallel refresh (two tabs at once), not theft, so it doesn't end the session
|
|
32
|
+
const REFRESH_REUSE_GRACE_MS = 30 * 1000;
|
|
33
|
+
|
|
34
|
+
// a valid hash to compare against when the account doesn't exist, so login takes
|
|
35
|
+
// the same time either way (prevents finding registered emails by timing)
|
|
36
|
+
const DUMMY_HASH = bcrypt.hashSync('dummy-password', BCRYPT_ROUNDS);
|
|
37
|
+
|
|
38
|
+
type VerifyTokenPayload = { purpose: 'verify-account'; accountId: string };
|
|
39
|
+
// `tv` is the account's token version; any password change makes the link stale
|
|
40
|
+
type ResetTokenPayload = { purpose: 'reset-password'; accountId: string; tv: number };
|
|
41
|
+
|
|
42
|
+
const toTokenUser = (account: {
|
|
43
|
+
_id: unknown;
|
|
44
|
+
email: string;
|
|
45
|
+
role: TokenUser['role'];
|
|
46
|
+
tokenVersion: number;
|
|
47
|
+
}): TokenUser => ({
|
|
48
|
+
accountId: String(account._id),
|
|
49
|
+
email: account.email,
|
|
50
|
+
role: account.role,
|
|
51
|
+
tokenVersion: account.tokenVersion,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/** A fresh OTP + verification link, and the fields to store for it. */
|
|
55
|
+
const newVerification = (accountId: string) => {
|
|
56
|
+
const otp = generateOtp();
|
|
57
|
+
const token = jwtHelpers.generateToken(
|
|
58
|
+
{ purpose: 'verify-account', accountId } satisfies VerifyTokenPayload,
|
|
59
|
+
configs.jwt.verified_token,
|
|
60
|
+
'5m',
|
|
61
|
+
);
|
|
62
|
+
return {
|
|
63
|
+
otp,
|
|
64
|
+
verificationLink: `${configs.jwt.front_end_url}/verify?token=${encodeURIComponent(token)}`,
|
|
65
|
+
data: { lastOtp: hashOtp(otp), lastOtpSendingTime: new Date(), otpAttempts: 0 },
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const create_account_into_db = async (req: Request) => {
|
|
70
|
+
const payload: { email: string; password: string; fullName: string } = req.body;
|
|
71
|
+
|
|
72
|
+
if (await account_model.exists({ email: payload.email })) {
|
|
73
|
+
throw new AppError('An account with this email already exists', 409);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// id generated up front so the account and its OTP are written in one insert
|
|
77
|
+
const _id = new Types.ObjectId();
|
|
78
|
+
const { otp, verificationLink, data } = newVerification(String(_id));
|
|
79
|
+
|
|
80
|
+
const account = await createAccountWithProfile(
|
|
81
|
+
{
|
|
82
|
+
_id,
|
|
83
|
+
email: payload.email,
|
|
84
|
+
password: await bcrypt.hash(payload.password, BCRYPT_ROUNDS),
|
|
85
|
+
...data,
|
|
86
|
+
},
|
|
87
|
+
{ fullName: payload.fullName },
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
await enqueueEmail({ type: 'verify-account', email: account.email, otp, verificationLink });
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
id: String(account._id),
|
|
94
|
+
email: account.email,
|
|
95
|
+
role: account.role,
|
|
96
|
+
isAccountVerified: account.isAccountVerified,
|
|
97
|
+
profile: { fullName: payload.fullName },
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const verify_account_using_otp_into_db = async (req: Request) => {
|
|
102
|
+
const payload: { email: string; otp: string } = req.body;
|
|
103
|
+
const invalid = new AppError('Invalid or expired OTP', 401);
|
|
104
|
+
|
|
105
|
+
const account = await account_model.findOne({ email: payload.email }).select('+lastOtp').lean();
|
|
106
|
+
if (!account || account.isAccountVerified || !account.lastOtp) {
|
|
107
|
+
throw invalid;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const isExpired =
|
|
111
|
+
!account.lastOtpSendingTime ||
|
|
112
|
+
Date.now() - account.lastOtpSendingTime.getTime() > OTP_EXPIRY_MS;
|
|
113
|
+
if (isExpired) {
|
|
114
|
+
throw invalid;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// use up an attempt atomically, so parallel guesses can't go past the limit
|
|
118
|
+
const attempt = await account_model.updateOne(
|
|
119
|
+
{ _id: account._id, otpAttempts: { $lt: OTP_MAX_ATTEMPTS } },
|
|
120
|
+
{ $inc: { otpAttempts: 1 } },
|
|
121
|
+
);
|
|
122
|
+
if (!attempt.modifiedCount) {
|
|
123
|
+
throw new AppError('Too many wrong attempts, please request a new OTP', 429);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!isOtpMatch(payload.otp, account.lastOtp)) {
|
|
127
|
+
throw invalid;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// only succeeds if this OTP wasn't replaced or used in the meantime
|
|
131
|
+
const verified = await account_model.updateOne(
|
|
132
|
+
{ _id: account._id, lastOtp: account.lastOtp },
|
|
133
|
+
{ $set: { isAccountVerified: true, lastOtp: null, otpAttempts: 0 } },
|
|
134
|
+
);
|
|
135
|
+
if (!verified.modifiedCount) {
|
|
136
|
+
throw invalid;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const verify_account_using_link_into_db = async (req: Request) => {
|
|
142
|
+
const decoded = jwtHelpers.verifyToken<VerifyTokenPayload>(
|
|
143
|
+
req.body.token,
|
|
144
|
+
configs.jwt.verified_token,
|
|
145
|
+
);
|
|
146
|
+
if (decoded.purpose !== 'verify-account' || !isValidObjectId(decoded.accountId)) {
|
|
147
|
+
throw new AppError('Invalid token', 401);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const result = await account_model.updateOne(
|
|
151
|
+
{ _id: decoded.accountId, isDeleted: false },
|
|
152
|
+
{ $set: { isAccountVerified: true, lastOtp: null, otpAttempts: 0 } },
|
|
153
|
+
);
|
|
154
|
+
if (!result.matchedCount) {
|
|
155
|
+
throw new AppError('Invalid token', 401);
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const login_user_into_db = async (req: Request): Promise<TokenUser> => {
|
|
161
|
+
const payload: { email: string; password: string } = req.body;
|
|
162
|
+
const invalid = new AppError('Invalid email or password', 401);
|
|
163
|
+
|
|
164
|
+
const account = await account_model.findOne({ email: payload.email }).select('+password').lean();
|
|
165
|
+
|
|
166
|
+
// always run bcrypt, and never skip the password check: social-login accounts
|
|
167
|
+
// have no password and must sign in through their provider
|
|
168
|
+
const isPasswordMatch = await bcrypt.compare(payload.password, account?.password ?? DUMMY_HASH);
|
|
169
|
+
if (!account || !account.password || !isPasswordMatch) {
|
|
170
|
+
throw invalid;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (account.isDeleted) {
|
|
174
|
+
throw new AppError('Account is deleted', 401);
|
|
175
|
+
}
|
|
176
|
+
if (!account.isAccountVerified) {
|
|
177
|
+
throw new AppError('Account is not verified', 401);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return toTokenUser(account);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const get_user_account_from_db = async (req: Request) => {
|
|
184
|
+
const accountId = req.user!.accountId;
|
|
185
|
+
const [account, profile, socialAccounts] = await Promise.all([
|
|
186
|
+
account_model
|
|
187
|
+
.findById(accountId)
|
|
188
|
+
.select('email role provider isAccountVerified isDeleted')
|
|
189
|
+
.lean(),
|
|
190
|
+
profile_model.findOne({ accountId }).select('fullName profilePhoto -_id').lean(),
|
|
191
|
+
social_account_model.find({ accountId }).select('provider createdAt -_id').lean(),
|
|
192
|
+
]);
|
|
193
|
+
if (!account) return null;
|
|
194
|
+
const { _id, ...rest } = account;
|
|
195
|
+
return { id: String(_id), ...rest, profile, socialAccounts };
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
/** Changes the password and revokes every other session. Returns the user for a new token. */
|
|
199
|
+
const change_password_into_db = async (req: Request): Promise<TokenUser> => {
|
|
200
|
+
const payload: { oldPassword: string; newPassword: string } = req.body;
|
|
201
|
+
|
|
202
|
+
if (payload.oldPassword === payload.newPassword) {
|
|
203
|
+
throw new AppError('New password must be different from the old password', 400);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const account = await account_model.findById(req.user!.accountId).select('+password').lean();
|
|
207
|
+
if (!account) {
|
|
208
|
+
throw new AppError('Account not found', 404);
|
|
209
|
+
}
|
|
210
|
+
if (!account.password) {
|
|
211
|
+
throw new AppError('This account uses social login and has no password', 400);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const isPasswordMatch = await bcrypt.compare(payload.oldPassword, account.password);
|
|
215
|
+
if (!isPasswordMatch) {
|
|
216
|
+
throw new AppError('Incorrect password', 401);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const updated = await account_model
|
|
220
|
+
.findByIdAndUpdate(
|
|
221
|
+
account._id,
|
|
222
|
+
{
|
|
223
|
+
$set: { password: await bcrypt.hash(payload.newPassword, BCRYPT_ROUNDS) },
|
|
224
|
+
$inc: { tokenVersion: 1 },
|
|
225
|
+
},
|
|
226
|
+
{ returnDocument: 'after' },
|
|
227
|
+
)
|
|
228
|
+
.lean();
|
|
229
|
+
await refresh_token_model.deleteMany({ accountId: account._id });
|
|
230
|
+
return toTokenUser(updated!);
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const resend_otp_and_verification_link_from_db = async (req: Request) => {
|
|
234
|
+
const email: string = req.body.email;
|
|
235
|
+
const account = await account_model.findOne({ email }).select('isAccountVerified').lean();
|
|
236
|
+
|
|
237
|
+
// same response whether or not the account exists (no email enumeration)
|
|
238
|
+
if (!account || account.isAccountVerified) return null;
|
|
239
|
+
|
|
240
|
+
const { otp, verificationLink, data } = newVerification(String(account._id));
|
|
241
|
+
|
|
242
|
+
// the cooldown check is part of the update, so parallel requests send one email
|
|
243
|
+
const result = await account_model.updateOne(
|
|
244
|
+
{
|
|
245
|
+
_id: account._id,
|
|
246
|
+
isAccountVerified: false,
|
|
247
|
+
$or: [
|
|
248
|
+
{ lastOtpSendingTime: null },
|
|
249
|
+
{ lastOtpSendingTime: { $lt: new Date(Date.now() - OTP_RESEND_COOLDOWN_MS) } },
|
|
250
|
+
],
|
|
251
|
+
},
|
|
252
|
+
{ $set: data },
|
|
253
|
+
);
|
|
254
|
+
if (!result.modifiedCount) {
|
|
255
|
+
throw new AppError('Please wait a minute before requesting a new OTP', 429);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
await enqueueEmail({ type: 'verify-account', email, otp, verificationLink });
|
|
259
|
+
return null;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const forget_password_generate_reset_token_from_db = async (req: Request) => {
|
|
263
|
+
const email: string = req.body.email;
|
|
264
|
+
const account = await account_model.findOne({ email }).select('isDeleted tokenVersion').lean();
|
|
265
|
+
|
|
266
|
+
// same response whether or not the account exists (no email enumeration)
|
|
267
|
+
if (!account || account.isDeleted) return null;
|
|
268
|
+
|
|
269
|
+
const resetToken = jwtHelpers.generateToken(
|
|
270
|
+
{
|
|
271
|
+
purpose: 'reset-password',
|
|
272
|
+
accountId: String(account._id),
|
|
273
|
+
tv: account.tokenVersion,
|
|
274
|
+
} satisfies ResetTokenPayload,
|
|
275
|
+
configs.jwt.reset_secret,
|
|
276
|
+
configs.jwt.reset_expires,
|
|
277
|
+
);
|
|
278
|
+
const resetLink = `${configs.jwt.front_end_url}/reset-password?token=${encodeURIComponent(resetToken)}`;
|
|
279
|
+
|
|
280
|
+
await enqueueEmail({ type: 'reset-password', email, resetLink });
|
|
281
|
+
return null;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const reset_password_using_token_into_db = async (req: Request) => {
|
|
285
|
+
const { token, newPass }: { token: string; newPass: string } = req.body;
|
|
286
|
+
const invalid = new AppError('Invalid or expired reset link', 401);
|
|
287
|
+
|
|
288
|
+
// the account comes from the signed token, never from the request body
|
|
289
|
+
const decoded = jwtHelpers.verifyToken<ResetTokenPayload>(token, configs.jwt.reset_secret);
|
|
290
|
+
if (
|
|
291
|
+
decoded.purpose !== 'reset-password' ||
|
|
292
|
+
typeof decoded.tv !== 'number' ||
|
|
293
|
+
!isValidObjectId(decoded.accountId)
|
|
294
|
+
) {
|
|
295
|
+
throw invalid;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Matching on the token version makes the link single-use (the update bumps it)
|
|
299
|
+
// and also signs out every existing session. Following the emailed link proves
|
|
300
|
+
// ownership of the address, so the account counts as verified from now on.
|
|
301
|
+
const result = await account_model.updateOne(
|
|
302
|
+
{ _id: decoded.accountId, isDeleted: false, tokenVersion: decoded.tv },
|
|
303
|
+
{
|
|
304
|
+
$set: {
|
|
305
|
+
password: await bcrypt.hash(newPass, BCRYPT_ROUNDS),
|
|
306
|
+
isAccountVerified: true,
|
|
307
|
+
lastOtp: null,
|
|
308
|
+
otpAttempts: 0,
|
|
309
|
+
},
|
|
310
|
+
$inc: { tokenVersion: 1 },
|
|
311
|
+
},
|
|
312
|
+
);
|
|
313
|
+
if (!result.modifiedCount) {
|
|
314
|
+
throw invalid;
|
|
315
|
+
}
|
|
316
|
+
await refresh_token_model.deleteMany({ accountId: decoded.accountId });
|
|
317
|
+
return null;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/** Revokes every access and refresh token issued for the account. */
|
|
321
|
+
const sign_out_everywhere_from_db = async (req: Request) => {
|
|
322
|
+
const accountId = req.user!.accountId;
|
|
323
|
+
await account_model.updateOne({ _id: accountId }, { $inc: { tokenVersion: 1 } });
|
|
324
|
+
await refresh_token_model.deleteMany({ accountId });
|
|
325
|
+
return null;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
/** Ends the session the refresh token belongs to (this device only). */
|
|
329
|
+
const sign_out_from_db = async (req: Request) => {
|
|
330
|
+
const token = readRefreshToken(req);
|
|
331
|
+
if (!token) return null;
|
|
332
|
+
const row = await refresh_token_model.findById(hashRefreshToken(token)).select('familyId').lean();
|
|
333
|
+
if (row) await refresh_token_model.deleteMany({ familyId: row.familyId });
|
|
334
|
+
return null;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Validates and uses up a refresh token. Returns the user and the session it
|
|
339
|
+
* belongs to, so the controller can issue the next token of that session.
|
|
340
|
+
*/
|
|
341
|
+
const refresh_session_from_db = async (
|
|
342
|
+
req: Request,
|
|
343
|
+
): Promise<{ user: TokenUser; familyId: string }> => {
|
|
344
|
+
const invalid = new AppError('Invalid or expired refresh token', 401);
|
|
345
|
+
const token = readRefreshToken(req);
|
|
346
|
+
if (!token) throw invalid;
|
|
347
|
+
|
|
348
|
+
const tokenHash = hashRefreshToken(token);
|
|
349
|
+
const row = await refresh_token_model.findById(tokenHash).lean();
|
|
350
|
+
if (!row) throw invalid;
|
|
351
|
+
|
|
352
|
+
const endSession = () => refresh_token_model.deleteMany({ familyId: row.familyId });
|
|
353
|
+
|
|
354
|
+
if (row.usedAt) {
|
|
355
|
+
// An already rotated token came back. Outside the grace window that means two
|
|
356
|
+
// parties hold this session (the token was stolen), so end the whole session.
|
|
357
|
+
if (Date.now() - row.usedAt.getTime() > REFRESH_REUSE_GRACE_MS) await endSession();
|
|
358
|
+
throw invalid;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const account = await account_model
|
|
362
|
+
.findById(row.accountId)
|
|
363
|
+
.select('email role tokenVersion isDeleted')
|
|
364
|
+
.lean();
|
|
365
|
+
const isStale =
|
|
366
|
+
row.expiresAt.getTime() < Date.now() ||
|
|
367
|
+
!account ||
|
|
368
|
+
account.isDeleted ||
|
|
369
|
+
account.tokenVersion !== row.tokenVersion; // password changed / signed out everywhere
|
|
370
|
+
if (isStale) {
|
|
371
|
+
await endSession();
|
|
372
|
+
throw invalid;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// mark it used atomically, so of two parallel refreshes only one gets a new token
|
|
376
|
+
const used = await refresh_token_model.updateOne(
|
|
377
|
+
{ _id: tokenHash, usedAt: null },
|
|
378
|
+
{ $set: { usedAt: new Date() } },
|
|
379
|
+
);
|
|
380
|
+
if (!used.modifiedCount) throw invalid;
|
|
381
|
+
|
|
382
|
+
return { user: toTokenUser(account), familyId: row.familyId };
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/** Trades the single-use code from a social login redirect for the user to sign in. */
|
|
386
|
+
const exchange_social_login_code_from_db = async (req: Request): Promise<TokenUser> => {
|
|
387
|
+
const invalid = new AppError('Invalid or expired login code', 401);
|
|
388
|
+
|
|
389
|
+
// deleting is what makes the code single-use, even under concurrent requests
|
|
390
|
+
const row = await social_login_code_model
|
|
391
|
+
.findOneAndDelete({ _id: hashLoginCode(req.body.code) })
|
|
392
|
+
.lean();
|
|
393
|
+
// the TTL index removes expired codes within a minute; check expiry anyway
|
|
394
|
+
if (!row || row.expiresAt.getTime() < Date.now()) {
|
|
395
|
+
throw invalid;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const account = await account_model
|
|
399
|
+
.findById(row.accountId)
|
|
400
|
+
.select('email role tokenVersion isDeleted')
|
|
401
|
+
.lean();
|
|
402
|
+
if (!account || account.isDeleted) {
|
|
403
|
+
throw invalid;
|
|
404
|
+
}
|
|
405
|
+
return toTokenUser(account);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
export const account_services = {
|
|
409
|
+
create_account_into_db,
|
|
410
|
+
login_user_into_db,
|
|
411
|
+
get_user_account_from_db,
|
|
412
|
+
change_password_into_db,
|
|
413
|
+
verify_account_using_otp_into_db,
|
|
414
|
+
resend_otp_and_verification_link_from_db,
|
|
415
|
+
verify_account_using_link_into_db,
|
|
416
|
+
forget_password_generate_reset_token_from_db,
|
|
417
|
+
reset_password_using_token_into_db,
|
|
418
|
+
sign_out_everywhere_from_db,
|
|
419
|
+
sign_out_from_db,
|
|
420
|
+
refresh_session_from_db,
|
|
421
|
+
exchange_social_login_code_from_db,
|
|
422
|
+
};
|