332 lines
9.1 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|