web-service/apps/auth/src/services/auth.service.ts

332 lines
9.1 KiB
TypeScript

import { Inject, Injectable } from '@nestjs/common';
import { LoginRequest } from '../interfaces/auth.interface';
import { JWTService } from './jwt.service';
import { ILike, Repository } from 'typeorm';
import { UserEntity } from '../database/entities/user.entity';
import { compare } from 'bcrypt';
import { instanceToPlain } from 'class-transformer';
import { InjectRepository } from '@nestjs/typeorm';
import { OTPService } from './otp.service';
import { BanService } from './ban.service';
import {
BadRequestRpcException,
ForbiddenRpcException,
UserInfo,
generateString,
} from '@freeblox/shared';
import { RoleService } from './role.service';
import { ConfigService } from '@nestjs/config';
import { RefreshService } from './refresh.service';
import { AuthChallenge } from '../enums/challenge.enum';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JWTService,
private readonly otpService: OTPService,
private readonly banService: BanService,
private readonly roleService: RoleService,
private readonly refreshService: RefreshService,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
private readonly config: ConfigService,
@Inject(CACHE_MANAGER) private cache: Cache,
) {}
/**
* Login by username/email and password
* @param body Username/email and password
* @returns JWT token
*/
async login(body: LoginRequest, ip: string) {
if (!body.email || !body.password) {
throw new BadRequestRpcException('Invalid username or password');
}
// Prevent wildcards
const userInput = body.email?.replace(/%/g, '');
const userEntity = await this.userRepository.findOne({
where: [
{
username: ILike(userInput),
activated: true,
},
{
email: ILike(userInput),
activated: true,
},
],
});
// User not found
if (!userEntity) {
throw new BadRequestRpcException('Invalid username or password');
}
// Compare passwords
const passwordMatch = await compare(body.password, userEntity.password);
if (!passwordMatch) {
throw new BadRequestRpcException('Invalid username or password');
}
// Check TOTP
const userOTPToken = await this.otpService.userHasTOTP(userEntity);
if (userOTPToken) {
return this.createOTPChallenge(userEntity);
}
// Issue access token
const exp = this.config.get('security.jwtTokenExpiry');
const issuedToken = await this.issueAccessToken(userEntity);
// Issue refresh token
const refreshToken = await this.refreshService.issueRefreshToken(
userEntity,
);
// Set login time to now
await this.userRepository.update(
{ id: userEntity.id },
{ loginAt: new Date() },
);
return {
challenge: null,
challengeSecret: null,
authResult: {
token: issuedToken,
refresh: refreshToken,
expires_in: exp,
},
};
}
/**
* Login by challenge.
* @param challenge Challenge type
* @param token Challenge secret
* @param response Challenge response from user
* @returns Authentication result or another challenge
*/
async loginByChallenge(
challenge: AuthChallenge,
token: string,
response: Record<string, string>,
ip: string,
) {
const decrypted = await this.jwtService.decrypt(token);
const tokenKey = `chlg:${challenge}:${decrypted.token}`;
const challengeSubject = await this.cache.get<string>(tokenKey);
// Invalid token
if (!challengeSubject) {
throw new ForbiddenRpcException('Invalid challenge response');
}
// Wrong subject, somehow..
if (challengeSubject !== decrypted.sub) {
await this.cache.del(tokenKey);
throw new ForbiddenRpcException('Invalid challenge response');
}
const loginUser = await this.userRepository.findOneByOrFail({
id: challengeSubject,
});
// TOTP verification login
if (challenge === AuthChallenge.OTP) {
// If no code is provided, assume request is incorrect
// and delete the challenge from cache.
if (!response.code) {
await this.cache.del(tokenKey);
throw new ForbiddenRpcException('Invalid challenge response');
}
const userOTPToken = await this.otpService.getUserTOTP(loginUser);
// User does not actually have TOTP?
if (!userOTPToken) {
await this.cache.del(tokenKey);
throw new ForbiddenRpcException('Invalid challenge response');
}
const validate = this.otpService.validateTOTP(
userOTPToken.token,
response.code,
);
// Here we do not delete from the cache, let the user retry.
if (!validate) {
throw new ForbiddenRpcException('Invalid challenge response');
}
} else {
await this.cache.del(tokenKey);
throw new ForbiddenRpcException('Invalid challenge');
}
await this.cache.del(tokenKey);
// Issue access and refresh token
const exp = this.config.get('security.jwtTokenExpiry');
const issuedToken = await this.issueAccessToken(loginUser);
const refreshToken = await this.refreshService.issueRefreshToken(loginUser);
// Set login time to now
await this.userRepository.update(
{ id: loginUser.id },
{ loginAt: new Date() },
);
return {
challenge: null,
challengeSecret: null,
authResult: {
token: issuedToken,
refresh: refreshToken,
expires_in: exp,
},
};
}
async loginByRefreshToken(token: string, ip: string) {
const refreshToken = await this.refreshService.useRefreshToken(token);
const userEntity = refreshToken.user;
// Issue new access token
const exp = this.config.get('security.jwtTokenExpiry');
const issuedToken = await this.issueAccessToken(userEntity);
// Set login time to now
await this.userRepository.update(
{ id: userEntity.id },
{ loginAt: new Date() },
);
return {
challenge: null,
challengeSecret: null,
authResult: {
token: issuedToken,
refresh: refreshToken.token,
expires_in: exp,
},
};
}
/**
* Get user from token
* @param token JWT Token
* @returns User entity
*/
async getUserFromToken(token: string) {
const tokenInfo = await this.verifyAccessToken(token);
const user = await this.userRepository.findOneByOrFail({
id: tokenInfo.sub,
activated: true,
});
return instanceToPlain(user);
}
/**
* Verify user token
* @param token JWT token
* @returns User token info
*/
async verifyAccessToken(token: string) {
try {
return await this.jwtService.verify(token);
} catch (e) {
throw new ForbiddenRpcException('Invalid token');
}
}
/**
* Get user entity by ID
* @param id User ID
* @returns User entity
*/
async getUserById(id: string) {
if (!id) throw new BadRequestRpcException('ID is required');
return instanceToPlain(await this.userRepository.findOneByOrFail({ id }));
}
/**
* Get user bans
* @param tokeninfo
*/
async getUserBans(userInfo: UserInfo) {
const user = await this.userRepository.findOneByOrFail({
id: userInfo.sub,
activated: true,
});
const bans = await this.banService.getAllBansForUser(user);
return instanceToPlain(bans);
}
/**
* Issue new JWT for user
* @private
* @param user
* @returns JWT
*/
async issueAccessToken(user: UserEntity) {
// Check for active ban
const bans = await this.banService.getActiveBansForUser(user);
const banned = !!bans.length;
// Get all privileges applicable to user
const bannedPrivileges = bans.reduce(
(privileges, ban) => [...privileges, ...(ban.privileges || [])],
[],
);
const userPrivileges = await this.roleService.getUserPrivileges(
user,
bannedPrivileges,
);
// If we only have privilege bans, only restrict those privileges.
// If we have a system ban, only return the web privilege.
const privileges =
!banned || bans.every((ban) => ban.privilegeBan)
? userPrivileges
: ['web'];
// Issue new token
return this.jwtService.sign({
sub: user.id,
username: user.username,
display_name: user.displayName,
language: user.language,
banned,
privileges,
});
}
/**
* Create OTP challenge for user's login.
* @private
* @param userEntity User
* @returns Challenge response
*/
async createOTPChallenge(userEntity: UserEntity) {
// Create challenge for auth.
const challengeToken = generateString(256);
const tokenKey = `chlg:${AuthChallenge.OTP}:${challengeToken}`;
const encryptedToken = await this.jwtService.encrypt({
token: challengeToken,
sub: userEntity.id,
});
// Challenge is only valid for 5 minutes.
await this.cache.set(tokenKey, userEntity.id, 300000);
return {
challenge: AuthChallenge.OTP,
challengeSecret: encryptedToken,
authResult: null,
};
}
}