import {
HttpStatus,
Inject,
Injectable,
UnprocessableEntityException
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import * as config from 'config';
import { existsSync, unlinkSync } from 'fs';
import { SignOptions } from 'jsonwebtoken';
import { DeepPartial, Not, ObjectLiteral } from 'typeorm';
import {
RateLimiterRes,
RateLimiterStoreAbstract
} from 'rate-limiter-flexible';
import { ExceptionTitleList } from 'src/common/constants/exception-title-list.constants';
import { StatusCodesList } from 'src/common/constants/status-codes-list.constants';
import { ForbiddenException } from 'src/exception/forbidden.exception';
import { NotFoundException } from 'src/exception/not-found.exception';
import { UnauthorizedException } from 'src/exception/unauthorized.exception';
import { CustomHttpException } from 'src/exception/custom-http.exception';
import { MailJobInterface } from 'src/mail/interface/mail-job.interface';
import { MailService } from 'src/mail/mail.service';
import { Pagination } from 'src/paginate';
import { RefreshToken } from 'src/refresh-token/entities/refresh-token.entity';
import { RefreshTokenService } from 'src/refresh-token/refresh-token.service';
import { ChangePasswordDto } from 'src/auth/dto/change-password.dto';
import { ForgetPasswordDto } from 'src/auth/dto/forget-password.dto';
import { ResetPasswordDto } from 'src/auth/dto/reset-password.dto';
import { UserLoginDto } from 'src/auth/dto/user-login.dto';
import { UserSearchFilterDto } from 'src/auth/dto/user-search-filter.dto';
import { UserEntity } from 'src/auth/entity/user.entity';
import {
adminUserGroupsForSerializing,
defaultUserGroupsForSerializing,
ownerUserGroupsForSerializing,
UserSerializer
} from 'src/auth/serializer/user.serializer';
import { UserStatusEnum } from 'src/auth/user-status.enum';
import { UserRepository } from 'src/auth/user.repository';
import { ValidationPayloadInterface } from 'src/common/interfaces/validation-error.interface';
import { RefreshPaginateFilterDto } from 'src/refresh-token/dto/refresh-paginate-filter.dto';
import { RefreshTokenSerializer } from 'src/refresh-token/serializer/refresh-token.serializer';
const throttleConfig = config.get('throttle.login');
const jwtConfig = config.get('jwt');
const appConfig = config.get('app');
// const isSameSite = process.env.IS_SAME_SITE || appConfig.sameSite;
// for heroku
const isSameSite =
appConfig.sameSite !== null
? appConfig.sameSite
: process.env.IS_SAME_SITE === 'true';
const BASE_OPTIONS: SignOptions = {
issuer: appConfig.appUrl,
audience: appConfig.frontendUrl
};
@Injectable()
export class AuthService {
constructor(
@InjectRepository(UserRepository)
private readonly userRepository: UserRepository,
private readonly jwt: JwtService,
private readonly mailService: MailService,
private readonly refreshTokenService: RefreshTokenService,
@Inject('LOGIN_THROTTLE')
private readonly rateLimiter: RateLimiterStoreAbstract
) {}
/**
* send mail
* @param user
* @param subject
* @param url
* @param slug
* @param linkLabel
*/
async sendMailToUser(
user: UserSerializer,
subject: string,
url: string,
slug: string,
linkLabel: string
) {
const appConfig = config.get('app');
const mailData: MailJobInterface = {
to: user.email,
subject,
slug,
context: {
email: user.email,
link: `${linkLabel} →`,
username: user.username,
subject
}
};
await this.mailService.sendMail(mailData, 'system-mail');
}
/**
* add new user
* @param createUserDto
*/
async create(
createUserDto: DeepPartial
): Promise {
const token = await this.generateUniqueToken(12);
if (!createUserDto.status) {
createUserDto.roleId = 2;
const currentDateTime = new Date();
currentDateTime.setHours(currentDateTime.getHours() + 1);
createUserDto.tokenValidityDate = currentDateTime;
}
const registerProcess = !createUserDto.status;
const user = await this.userRepository.store(createUserDto, token);
const subject = registerProcess ? 'Account created' : 'Set Password';
const link = registerProcess ? `verify/${token}` : `reset/${token}`;
const slug = registerProcess ? 'activate-account' : 'new-user-set-password';
const linkLabel = registerProcess ? 'Activate Account' : 'Set Password';
await this.sendMailToUser(user, subject, link, slug, linkLabel);
return user;
}
/**
* find user entity by condition
* @param field
* @param value
*/
async findBy(field: string, value: string): Promise {
return this.userRepository.findBy(field, value);
}
/**
* Login user by username and password
* @param userLoginDto
* @param refreshTokenPayload
*/
async login(
userLoginDto: UserLoginDto,
refreshTokenPayload: Partial
): Promise {
const usernameIPkey = `${userLoginDto.username}_${refreshTokenPayload.ip}`;
const resUsernameAndIP = await this.rateLimiter.get(usernameIPkey);
let retrySecs = 0;
// Check if user is already blocked
if (
resUsernameAndIP !== null &&
resUsernameAndIP.consumedPoints > throttleConfig.limit
) {
retrySecs = Math.round(resUsernameAndIP.msBeforeNext / 1000) || 1;
}
if (retrySecs > 0) {
throw new CustomHttpException(
`tooManyRequest-{"second":"${String(retrySecs)}"}`,
HttpStatus.TOO_MANY_REQUESTS,
StatusCodesList.TooManyTries
);
}
const [user, error, code] = await this.userRepository.login(userLoginDto);
if (!user) {
const [result, throttleError] = await this.limitConsumerPromiseHandler(
usernameIPkey
);
if (!result) {
throw new CustomHttpException(
`tooManyRequest-{"second":${String(
Math.round(throttleError.msBeforeNext / 1000) || 1
)}}`,
HttpStatus.TOO_MANY_REQUESTS,
StatusCodesList.TooManyTries
);
}
throw new UnauthorizedException(error, code);
}
const accessToken = await this.generateAccessToken(user);
let refreshToken = null;
if (userLoginDto.remember) {
refreshToken = await this.refreshTokenService.generateRefreshToken(
user,
refreshTokenPayload
);
}
await this.rateLimiter.delete(usernameIPkey);
return this.buildResponsePayload(accessToken, refreshToken);
}
/**
* Generate access token
* @param user
* @param isTwoFAAuthenticated
*/
public async generateAccessToken(
user: UserSerializer,
isTwoFAAuthenticated = false
): Promise {
const opts: SignOptions = {
...BASE_OPTIONS,
subject: String(user.id)
};
return this.jwt.signAsync({
...opts,
isTwoFAAuthenticated
});
}
/**
* promise handler to handle result and error for login
* throttle by user
* @param usernameIPkey
*/
async limitConsumerPromiseHandler(
usernameIPkey: string
): Promise<[RateLimiterRes, RateLimiterRes]> {
return new Promise((resolve) => {
this.rateLimiter
.consume(usernameIPkey)
.then((rateLimiterRes) => {
resolve([rateLimiterRes, null]);
})
.catch((rateLimiterError) => {
resolve([null, rateLimiterError]);
});
});
}
/**
* get user profile
* @param user
*/
async get(user: UserEntity): Promise {
return this.userRepository.transform(user, {
groups: ownerUserGroupsForSerializing
});
}
/**
* Get user By Id
* @param id
*/
async findById(id: number): Promise {
return this.userRepository.get(id, ['role'], {
groups: [
...adminUserGroupsForSerializing,
...ownerUserGroupsForSerializing
]
});
}
/**
* Get all user paginated
* @param userSearchFilterDto
*/
async findAll(
userSearchFilterDto: UserSearchFilterDto
): Promise> {
return this.userRepository.paginate(
userSearchFilterDto,
['role'],
['username', 'email', 'name', 'contact', 'address'],
{
groups: [
...adminUserGroupsForSerializing,
...ownerUserGroupsForSerializing,
...defaultUserGroupsForSerializing
]
}
);
}
/**
* update user
* @param id
* @param updateUserDto
*/
async update(
id: number,
updateUserDto: DeepPartial
): Promise {
const user = await this.userRepository.get(id, [], {
groups: [
...ownerUserGroupsForSerializing,
...adminUserGroupsForSerializing
]
});
const checkUniqueFieldArray = ['username', 'email'];
const errorPayload: ValidationPayloadInterface[] = [];
for (const field of checkUniqueFieldArray) {
const condition: ObjectLiteral = {
[field]: updateUserDto[field]
};
condition.id = Not(id);
const checkUnique = await this.userRepository.countEntityByCondition(
condition
);
if (checkUnique > 0) {
errorPayload.push({
property: field,
constraints: {
unique: 'already taken'
}
});
}
}
if (Object.keys(errorPayload).length > 0) {
throw new UnprocessableEntityException(errorPayload);
}
if (updateUserDto.avatar && user.avatar) {
const path = `public/images/profile/${user.avatar}`;
if (existsSync(path)) {
unlinkSync(`public/images/profile/${user.avatar}`);
}
}
return this.userRepository.updateEntity(user, updateUserDto);
}
/**
* activate newly register account
* @param token
*/
async activateAccount(token: string): Promise {
const user = await this.userRepository.findOne({ token });
if (!user) {
throw new NotFoundException();
}
if (user.status !== UserStatusEnum.INACTIVE) {
throw new ForbiddenException(
ExceptionTitleList.UserInactive,
StatusCodesList.UserInactive
);
}
user.status = UserStatusEnum.ACTIVE;
user.token = await this.generateUniqueToken(6);
user.skipHashPassword = true;
await user.save();
}
/**
* forget password and send reset code by email
* @param forgetPasswordDto
*/
async forgotPassword(forgetPasswordDto: ForgetPasswordDto): Promise {
const { email } = forgetPasswordDto;
const user = await this.userRepository.findOne({
where: {
email
}
});
if (!user) {
return;
}
const token = await this.generateUniqueToken(6);
user.token = token;
const currentDateTime = new Date();
currentDateTime.setHours(currentDateTime.getHours() + 1);
user.tokenValidityDate = currentDateTime;
user.skipHashPassword = true;
await user.save();
const subject = 'Reset Password';
await this.sendMailToUser(
user,
subject,
`reset/${token}`,
'reset-password',
subject
);
}
/**
* reset password using token
* @param resetPasswordDto
*/
async resetPassword(resetPasswordDto: ResetPasswordDto): Promise {
const { password } = resetPasswordDto;
const user = await this.userRepository.getUserForResetPassword(
resetPasswordDto
);
if (!user) {
throw new NotFoundException();
}
user.token = await this.generateUniqueToken(6);
user.password = password;
await user.save();
}
/**
* change password of logged in user
* @param user
* @param changePasswordDto
*/
async changePassword(
user: UserEntity,
changePasswordDto: ChangePasswordDto
): Promise {
const { oldPassword, password } = changePasswordDto;
const checkOldPwdMatches = await user.validatePassword(oldPassword);
if (!checkOldPwdMatches) {
throw new CustomHttpException(
ExceptionTitleList.IncorrectOldPassword,
HttpStatus.PRECONDITION_FAILED,
StatusCodesList.IncorrectOldPassword
);
}
user.password = password;
await user.save();
}
/**
* generate random string code providing length
* @param length
* @param uppercase
* @param lowercase
* @param numerical
*/
generateRandomCode(
length: number,
uppercase = true,
lowercase = true,
numerical = true
): string {
let result = '';
const lowerCaseAlphabets = 'abcdefghijklmnopqrstuvwxyz';
const upperCaseAlphabets = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const numericalLetters = '0123456789';
let characters = '';
if (uppercase) {
characters += upperCaseAlphabets;
}
if (lowercase) {
characters += lowerCaseAlphabets;
}
if (numerical) {
characters += numericalLetters;
}
const charactersLength = characters.length;
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * charactersLength));
}
return result;
}
/**
* generate unique token
* @param length
*/
async generateUniqueToken(length: number): Promise {
const token = this.generateRandomCode(length);
const condition: ObjectLiteral = {
token
};
const tokenCount = await this.userRepository.countEntityByCondition(
condition
);
if (tokenCount > 0) {
await this.generateUniqueToken(length);
}
return token;
}
/**
* Get cookie for logout action
*/
getCookieForLogOut(): string[] {
return [
`Authentication=; HttpOnly; Path=/; Max-Age=0; ${
!isSameSite ? 'SameSite=None; Secure;' : ''
}`,
`Refresh=; HttpOnly; Path=/; Max-Age=0; ${
!isSameSite ? 'SameSite=None; Secure;' : ''
}`,
`ExpiresIn=; Path=/; Max-Age=0; ${
!isSameSite ? 'SameSite=None; Secure;' : ''
}`
];
}
/**
* build response payload
* @param accessToken
* @param refreshToken
*/
buildResponsePayload(accessToken: string, refreshToken?: string): string[] {
let tokenCookies = [
`Authentication=${accessToken}; HttpOnly; Path=/; ${
!isSameSite ? 'SameSite=None; Secure;' : ''
} Max-Age=${jwtConfig.cookieExpiresIn}`
];
if (refreshToken) {
const expiration = new Date();
expiration.setSeconds(expiration.getSeconds() + jwtConfig.expiresIn);
tokenCookies = tokenCookies.concat([
`Refresh=${refreshToken}; HttpOnly; Path=/; ${
!isSameSite ? 'SameSite=None; Secure;' : ''
} Max-Age=${jwtConfig.cookieExpiresIn}`,
`ExpiresIn=${expiration}; Path=/; ${
!isSameSite ? 'SameSite=None; Secure;' : ''
} Max-Age=${jwtConfig.cookieExpiresIn}`
]);
}
return tokenCookies;
}
/**
* Create access token from refresh token
* @param refreshToken
*/
async createAccessTokenFromRefreshToken(refreshToken: string) {
const { token } =
await this.refreshTokenService.createAccessTokenFromRefreshToken(
refreshToken
);
return this.buildResponsePayload(token);
}
/**
* revoke refresh token for logout action
* @param encoded
*/
async revokeRefreshToken(encoded: string): Promise {
// ignore exception because anyway we are going invalidate cookies
try {
const { token } = await this.refreshTokenService.resolveRefreshToken(
encoded
);
if (token) {
token.isRevoked = true;
await token.save();
}
} catch (e) {
throw new CustomHttpException(
ExceptionTitleList.InvalidRefreshToken,
HttpStatus.PRECONDITION_FAILED,
StatusCodesList.InvalidRefreshToken
);
}
}
/**
* get active refresh token list for user
* @param userId
* @param filter
**/
activeRefreshTokenList(
userId: number,
filter: RefreshPaginateFilterDto
): Promise> {
return this.refreshTokenService.getRefreshTokenByUserId(userId, filter);
}
/**
* revoke token by id
* @param id
* @param userId
**/
revokeTokenById(id: number, userId: number): Promise {
return this.refreshTokenService.revokeRefreshTokenById(id, userId);
}
/**
* set two factor auth secret for user
* @param secret
* @param userId
**/
async setTwoFactorAuthenticationSecret(secret: string, userId: number) {
// add one minute throttle to generate next two factor token
const twoFAThrottleTime = new Date();
twoFAThrottleTime.setSeconds(twoFAThrottleTime.getSeconds() + 60);
return this.userRepository.update(userId, {
twoFASecret: secret,
twoFAThrottleTime
});
}
/**
* Turn two factor authentication for user
* @param user
* @param isTwoFAEnabled
* @param qrDataUri
**/
async turnOnTwoFactorAuthentication(
user: UserEntity,
isTwoFAEnabled = true,
qrDataUri: string
) {
if (isTwoFAEnabled) {
const subject = 'Activate Two Factor Authentication';
const mailData: MailJobInterface = {
to: user.email,
subject,
slug: 'two-factor-authentication',
context: {
email: user.email,
qrcode: 'cid:2fa-qrcode',
username: user.username,
subject
},
attachments: [
{
filename: '2fa-qrcode.png',
path: qrDataUri,
cid: '2fa-qrcode'
}
]
};
await this.mailService.sendMail(mailData, 'system-mail');
}
return this.userRepository.update(user.id, {
isTwoFAEnabled
});
}
/**
* Count data by condition
* @param condition
**/
async countByCondition(condition: ObjectLiteral) {
return this.userRepository.countEntityByCondition(condition);
}
async getRefreshTokenGroupedData(field: string) {
return this.refreshTokenService.getRefreshTokenGroupedData(field);
}
}