UNPKG

@rytass/member-base-nestjs-module

Version:

Rytass Member System NestJS Base Module

375 lines (372 loc) 16.1 kB
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 };