@rytass/member-base-nestjs-module
Version:
Rytass Member System NestJS Base Module
375 lines (372 loc) • 16.1 kB
JavaScript
import { Injectable, Inject, Logger, BadRequestException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { verify, hash } from 'argon2';
import { MEMBER_BASE_MODULE_OPTIONS, RESOLVED_MEMBER_REPO, LOGIN_FAILED_BAN_THRESHOLD, RESET_PASSWORD_TOKEN_EXPIRATION, RESET_PASSWORD_TOKEN_SECRET, ACCESS_TOKEN_SECRET, ACCESS_TOKEN_EXPIRATION, REFRESH_TOKEN_SECRET, REFRESH_TOKEN_EXPIRATION, ONLY_RESET_REFRESH_TOKEN_EXPIRATION_BY_PASSWORD, PASSWORD_AGE_LIMIT_IN_DAYS, FORCE_REJECT_LOGIN_ON_PASSWORD_EXPIRED, CUSTOMIZED_JWT_PAYLOAD, LOGIN_FAILED_AUTO_UNLOCK_SECONDS } from '../typings/member-base.tokens.js';
import { sign, verify as verify$1 } from 'jsonwebtoken';
import { MemberLoginLogRepo } from '../models/member-login-log.entity.js';
import { PasswordValidatorService } from './password-validator.service.js';
import { MemberPasswordHistoryRepo } from '../models/member-password-history.entity.js';
import { MemberNotFoundError, PasswordDoesNotMeetPolicyError, InvalidPasswordError, PasswordValidationError, InvalidToken, MemberAlreadyExistedError, PasswordChangedError, MemberBannedError, PasswordExpiredError, PasswordShouldUpdatePasswordError } from '../constants/errors/base.error.js';
function _ts_decorate(decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
}
function _ts_metadata(k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
}
function _ts_param(paramIndex, decorator) {
return function(target, key) {
decorator(target, key, paramIndex);
};
}
class MemberBaseService {
originalProvidedOptions;
baseMemberRepo;
memberLoginLogRepo;
loginFailedBanThreshold;
resetPasswordTokenExpiration;
resetPasswordTokenSecret;
accessTokenSecret;
accessTokenExpiration;
refreshTokenSecret;
refreshTokenExpiration;
onlyResetRefreshTokenExpirationByPassword;
memberPasswordHistoryRepo;
passwordAgeLimitInDays;
forceRejectLoginOnPasswordExpired;
passwordValidatorService;
customizedJwtPayload;
loginFailedAutoUnlockSeconds;
constructor(originalProvidedOptions, baseMemberRepo, memberLoginLogRepo, loginFailedBanThreshold, resetPasswordTokenExpiration, resetPasswordTokenSecret, accessTokenSecret, accessTokenExpiration, refreshTokenSecret, refreshTokenExpiration, onlyResetRefreshTokenExpirationByPassword, memberPasswordHistoryRepo, passwordAgeLimitInDays, forceRejectLoginOnPasswordExpired, passwordValidatorService, customizedJwtPayload, loginFailedAutoUnlockSeconds){
this.originalProvidedOptions = originalProvidedOptions;
this.baseMemberRepo = baseMemberRepo;
this.memberLoginLogRepo = memberLoginLogRepo;
this.loginFailedBanThreshold = loginFailedBanThreshold;
this.resetPasswordTokenExpiration = resetPasswordTokenExpiration;
this.resetPasswordTokenSecret = resetPasswordTokenSecret;
this.accessTokenSecret = accessTokenSecret;
this.accessTokenExpiration = accessTokenExpiration;
this.refreshTokenSecret = refreshTokenSecret;
this.refreshTokenExpiration = refreshTokenExpiration;
this.onlyResetRefreshTokenExpirationByPassword = onlyResetRefreshTokenExpirationByPassword;
this.memberPasswordHistoryRepo = memberPasswordHistoryRepo;
this.passwordAgeLimitInDays = passwordAgeLimitInDays;
this.forceRejectLoginOnPasswordExpired = forceRejectLoginOnPasswordExpired;
this.passwordValidatorService = passwordValidatorService;
this.customizedJwtPayload = customizedJwtPayload;
this.loginFailedAutoUnlockSeconds = loginFailedAutoUnlockSeconds;
}
logger = new Logger(MemberBaseService.name);
onApplicationBootstrap() {
if (!this.originalProvidedOptions?.accessTokenSecret || !this.originalProvidedOptions?.refreshTokenSecret) {
this.logger.warn('No access token secret or refresh token secret provided, using random secret');
}
}
signRefreshToken(member, domain) {
return sign({
...this.customizedJwtPayload(member),
passwordChangedAt: member.passwordChangedAt?.getTime() ?? null,
...domain ? {
domain
} : {}
}, this.refreshTokenSecret, {
expiresIn: this.validateExpiration(this.refreshTokenExpiration, 'REFRESH_TOKEN_EXPIRATION')
});
}
signAccessToken(member, domain) {
return sign({
...this.customizedJwtPayload(member),
...domain ? {
domain
} : {}
}, this.accessTokenSecret, {
expiresIn: this.validateExpiration(this.accessTokenExpiration, 'ACCESS_TOKEN_EXPIRATION')
});
}
async getResetPasswordToken(account) {
const member = await this.baseMemberRepo.findOne({
where: {
account
}
});
if (!member) {
throw new MemberNotFoundError();
}
const requestedOn = new Date();
member.resetPasswordRequestedAt = requestedOn;
await this.baseMemberRepo.save(member);
const token = sign({
id: member.id,
requestedOn: requestedOn.getTime()
}, this.resetPasswordTokenSecret, {
expiresIn: this.validateExpiration(this.resetPasswordTokenExpiration, 'RESET_PASSWORD_TOKEN_EXPIRATION')
});
return token;
}
async changePassword(id, originPassword, newPassword) {
if (!await this.passwordValidatorService.validatePassword(newPassword, id)) {
throw new PasswordDoesNotMeetPolicyError();
}
const member = await this.baseMemberRepo.findOne({
where: {
id
}
});
if (!member) {
throw new MemberNotFoundError();
}
try {
if (await verify(member.password, originPassword)) {
member.password = await hash(newPassword);
member.passwordChangedAt = new Date();
member.shouldUpdatePassword = false;
await this.baseMemberRepo.save(member);
await this.memberPasswordHistoryRepo.save(this.memberPasswordHistoryRepo.create({
memberId: member.id,
password: member.password
}));
return member;
} else {
throw new InvalidPasswordError();
}
} catch (_err) {
throw new PasswordValidationError();
}
}
async changePasswordWithToken(token, newPassword) {
try {
const { id, requestedOn } = verify$1(token, this.resetPasswordTokenSecret);
if (!await this.passwordValidatorService.validatePassword(newPassword, id)) {
throw new PasswordDoesNotMeetPolicyError();
}
const member = await this.baseMemberRepo.findOne({
where: {
id,
resetPasswordRequestedAt: new Date(requestedOn)
}
});
if (!member) {
throw new InvalidToken();
}
member.password = await hash(newPassword);
member.passwordChangedAt = new Date();
member.shouldUpdatePassword = false;
member.resetPasswordRequestedAt = null;
await this.baseMemberRepo.save(member);
await this.memberPasswordHistoryRepo.save(this.memberPasswordHistoryRepo.create({
memberId: member.id,
password: member.password
}));
return member;
} catch (_ex) {
throw new InvalidToken();
}
}
async register(account, password, memberOptions) {
if (!await this.passwordValidatorService.validatePassword(password)) {
throw new PasswordDoesNotMeetPolicyError();
}
let member = this.baseMemberRepo.create({
account
});
member.password = await hash(password);
try {
member = await this.baseMemberRepo.save({
...memberOptions,
account: member.account,
password: member.password
});
await this.memberPasswordHistoryRepo.save(this.memberPasswordHistoryRepo.create({
memberId: member.id,
password: member.password
}));
} catch (ex) {
if (/unique/.test(ex.message)) {
throw new MemberAlreadyExistedError();
}
}
return member;
}
async registerWithoutPassword(account, memberOptions) {
const password = this.passwordValidatorService.generateValidPassword();
let member = this.baseMemberRepo.create({
account
});
member.password = await hash(password);
try {
member = await this.baseMemberRepo.save({
shouldUpdatePassword: true,
...memberOptions,
account: member.account,
password: member.password
});
await this.memberPasswordHistoryRepo.save(this.memberPasswordHistoryRepo.create({
memberId: member.id,
password: member.password
}));
} catch (ex) {
if (/unique/.test(ex.message)) {
throw new MemberAlreadyExistedError();
}
}
return [
member,
password
];
}
async refreshToken(refreshToken, options) {
try {
const { id, account, passwordChangedAt, exp: _exp, domain } = verify$1(refreshToken, this.refreshTokenSecret);
const member = await this.baseMemberRepo.findOne({
where: {
id,
account
}
});
if (!member) {
throw new MemberNotFoundError();
}
if (member.passwordChangedAt?.getTime() !== passwordChangedAt) {
throw new PasswordChangedError();
}
return {
accessToken: this.signAccessToken(member, options?.domain ?? domain ?? undefined),
refreshToken: this.signRefreshToken(member, options?.domain ?? domain ?? undefined)
};
} catch (ex) {
if (ex instanceof BadRequestException) throw ex;
throw new InvalidToken();
}
}
async login(account, password, options) {
const member = await this.baseMemberRepo.findOne({
where: {
account
}
});
if (!member) {
throw new MemberNotFoundError();
}
if (member.loginFailedCounter >= this.loginFailedBanThreshold) {
if (this.loginFailedAutoUnlockSeconds) {
const latestFailedRecord = await this.memberLoginLogRepo.findOne({
order: {
createdAt: 'DESC'
},
where: {
memberId: member.id,
success: false
}
});
if (!latestFailedRecord || latestFailedRecord.createdAt.getTime() + this.loginFailedAutoUnlockSeconds * 1000 > Date.now()) {
throw new MemberBannedError();
}
} else {
throw new MemberBannedError();
}
}
const isPasswordExpired = this.passwordAgeLimitInDays ? this.passwordValidatorService.shouldUpdatePassword(member) : false;
if (isPasswordExpired && this.forceRejectLoginOnPasswordExpired) {
throw new PasswordExpiredError();
}
const ip = typeof options === 'string' ? options : options?.ip ?? undefined;
try {
if (await verify(member.password, password)) {
if (member.shouldUpdatePassword) {
throw new PasswordShouldUpdatePasswordError();
}
member.loginFailedCounter = 0;
await this.baseMemberRepo.save(member);
// async log
this.memberLoginLogRepo.save({
memberId: member.id,
success: true,
ip: ip ? `${ip}/32` : null
});
const domain = typeof options === 'string' ? undefined : options?.domain ?? undefined;
return {
accessToken: this.signAccessToken(member, domain),
refreshToken: this.signRefreshToken(member, domain),
...this.passwordAgeLimitInDays ? {
shouldUpdatePassword: isPasswordExpired,
passwordChangedAt: member.passwordChangedAt?.toISOString()
} : {}
};
}
member.loginFailedCounter += 1;
await this.baseMemberRepo.save(member);
// async log
this.memberLoginLogRepo.save({
memberId: member.id,
success: false,
ip: ip ? `${ip}/32` : null
});
throw new InvalidPasswordError();
} catch (err) {
if (err instanceof BadRequestException) throw err;
throw new PasswordValidationError();
}
}
async resetLoginFailedCounter(id) {
const member = await this.baseMemberRepo.findOne({
where: {
id
}
});
if (!member) {
throw new MemberNotFoundError();
}
member.loginFailedCounter = 0;
await this.baseMemberRepo.save(member);
return member;
}
validateExpiration(source, tokenName) {
if (typeof source !== 'number' || Number.isNaN(source)) {
throw new BadRequestException(`[${tokenName}] must be a number (in seconds), but got: ${source}`);
}
return source;
}
}
MemberBaseService = _ts_decorate([
Injectable(),
_ts_param(0, Inject(MEMBER_BASE_MODULE_OPTIONS)),
_ts_param(1, Inject(RESOLVED_MEMBER_REPO)),
_ts_param(2, Inject(MemberLoginLogRepo)),
_ts_param(3, Inject(LOGIN_FAILED_BAN_THRESHOLD)),
_ts_param(4, Inject(RESET_PASSWORD_TOKEN_EXPIRATION)),
_ts_param(5, Inject(RESET_PASSWORD_TOKEN_SECRET)),
_ts_param(6, Inject(ACCESS_TOKEN_SECRET)),
_ts_param(7, Inject(ACCESS_TOKEN_EXPIRATION)),
_ts_param(8, Inject(REFRESH_TOKEN_SECRET)),
_ts_param(9, Inject(REFRESH_TOKEN_EXPIRATION)),
_ts_param(10, Inject(ONLY_RESET_REFRESH_TOKEN_EXPIRATION_BY_PASSWORD)),
_ts_param(11, Inject(MemberPasswordHistoryRepo)),
_ts_param(12, Inject(PASSWORD_AGE_LIMIT_IN_DAYS)),
_ts_param(13, Inject(FORCE_REJECT_LOGIN_ON_PASSWORD_EXPIRED)),
_ts_param(14, Inject(PasswordValidatorService)),
_ts_param(15, Inject(CUSTOMIZED_JWT_PAYLOAD)),
_ts_param(16, Inject(LOGIN_FAILED_AUTO_UNLOCK_SECONDS)),
_ts_metadata("design:type", Function),
_ts_metadata("design:paramtypes", [
Object,
typeof Repository === "undefined" ? Object : Repository,
typeof Repository === "undefined" ? Object : Repository,
Number,
Number,
String,
String,
Number,
String,
Number,
Boolean,
typeof Repository === "undefined" ? Object : Repository,
Object,
Boolean,
typeof PasswordValidatorService === "undefined" ? Object : PasswordValidatorService,
Function,
Object
])
], MemberBaseService);
export { MemberBaseService };