Split up Auth Service into multiple files
This commit is contained in:
parent
762c1c84c9
commit
6113478acd
@ -1,387 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
|
||||||
UnauthorizedException
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { JWTToken, tfaTypes, User, UserRole } from '../entities';
|
|
||||||
import { LessThanOrEqual, Repository } from 'typeorm';
|
|
||||||
import * as argon2 from 'argon2';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
|
||||||
import { Strategy as LocalStrategy } from 'passport-local';
|
|
||||||
import { ExtractJwt, Strategy as JWTStrategy } from 'passport-jwt';
|
|
||||||
import FileSystemService from './filesystem';
|
|
||||||
import * as jwt from 'jsonwebtoken';
|
|
||||||
import { createTransport } from 'nodemailer';
|
|
||||||
import * as notp from 'notp';
|
|
||||||
import { randomBytes } from 'crypto';
|
|
||||||
import { FastifyRequest } from 'fastify';
|
|
||||||
import axios from 'axios';
|
|
||||||
|
|
||||||
const GITLAB_ID =
|
|
||||||
'98bcbad78cb1f880d1d1de62291d70a791251a7bea077bfe7df111ef3c115760';
|
|
||||||
const GITLAB_SECRET =
|
|
||||||
'7ee01d2b204aff3a05f9d028f004d169b6d381ec873e195f314b3935fa150959';
|
|
||||||
const GITLAB_URL = 'https://gitlab.mattv.de';
|
|
||||||
const GITLAB_API_URL = 'https://ssh.gitlab.mattv.de';
|
|
||||||
|
|
||||||
const jwtSecret = 'CUM';
|
|
||||||
|
|
||||||
const mailAccount = createTransport({
|
|
||||||
host: 'mail.mattv.de',
|
|
||||||
port: 587,
|
|
||||||
secure: false,
|
|
||||||
auth: {
|
|
||||||
user: 'no-reply@mattv.de',
|
|
||||||
pass: 'noreplyLONGPASS123'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
interface jwtPayload {
|
|
||||||
sub: number;
|
|
||||||
jti: number;
|
|
||||||
exp?: number;
|
|
||||||
iat?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GitlabTokenResponse {
|
|
||||||
access_token: string;
|
|
||||||
token_type: string;
|
|
||||||
expires_in: number;
|
|
||||||
refresh_token: string;
|
|
||||||
created_at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GitlabUserResponse {
|
|
||||||
username: string;
|
|
||||||
is_admin?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AuthService {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(User)
|
|
||||||
private userRepo: Repository<User>,
|
|
||||||
@InjectRepository(JWTToken)
|
|
||||||
private tokenRepo: Repository<JWTToken>,
|
|
||||||
private fsService: FileSystemService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
generateTfaSecret(): string {
|
|
||||||
const set =
|
|
||||||
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz!@#$%^&*()<>?/[]{},.:;';
|
|
||||||
return randomBytes(32)
|
|
||||||
.map((b) =>
|
|
||||||
set.charCodeAt(Math.floor((b / 255.0) * (set.length - 1)))
|
|
||||||
)
|
|
||||||
.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUser(userId: number): Promise<User | null> {
|
|
||||||
return this.userRepo.findOneBy({
|
|
||||||
id: userId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async findUser(username: string, gitlab: boolean): Promise<User | null> {
|
|
||||||
return this.userRepo.findOneBy({
|
|
||||||
name: username,
|
|
||||||
isGitlabUser: gitlab
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getToken(tokenId: number): Promise<JWTToken | null> {
|
|
||||||
return this.tokenRepo.findOneBy({
|
|
||||||
id: tokenId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async validateUser(username: string, pass: string): Promise<User | null> {
|
|
||||||
const user = await this.findUser(username, false);
|
|
||||||
if (!user)
|
|
||||||
throw new UnauthorizedException('Invalid username or password');
|
|
||||||
if (!(await argon2.verify(user.password, pass)))
|
|
||||||
throw new UnauthorizedException('Invalid username or password');
|
|
||||||
if (user.role == UserRole.DISABLED)
|
|
||||||
throw new UnauthorizedException('Account is disabled');
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
async cleanupTokens(): Promise<void> {
|
|
||||||
await this.tokenRepo.delete({
|
|
||||||
exp: LessThanOrEqual(Math.floor(Date.now() / 1000))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
requiresTfa(user: User): boolean {
|
|
||||||
return user.tfaType != tfaTypes.NONE;
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyTfa(
|
|
||||||
user: User,
|
|
||||||
token: string,
|
|
||||||
type?: tfaTypes
|
|
||||||
): Promise<boolean> {
|
|
||||||
if (!type) type = user.tfaType;
|
|
||||||
const delta = notp.totp.verify(token, user.tfaSecret, {
|
|
||||||
window: 10
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
delta &&
|
|
||||||
(type == tfaTypes.EMAIL ? delta.delta <= 0 : delta.delta == 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendTfaMail(user: User) {
|
|
||||||
await mailAccount.sendMail({
|
|
||||||
from: 'fileserver@mattv.de',
|
|
||||||
to: user.name,
|
|
||||||
subject: 'Fileserver - EMail 2fa code',
|
|
||||||
text: `Your code is: ${notp.totp.gen(
|
|
||||||
user.tfaSecret
|
|
||||||
)}\nIt is valid for 5 Minutes`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async setupTfa(user: User): Promise<string> {
|
|
||||||
if (user.tfaType != tfaTypes.NONE)
|
|
||||||
throw new BadRequestException(
|
|
||||||
'2 Factor authentication is already setup'
|
|
||||||
);
|
|
||||||
const secret = this.generateTfaSecret();
|
|
||||||
user.tfaSecret = secret;
|
|
||||||
await this.userRepo.save(user);
|
|
||||||
return secret;
|
|
||||||
}
|
|
||||||
|
|
||||||
async setTfaType(user: User, type: tfaTypes) {
|
|
||||||
user.tfaType = type;
|
|
||||||
await this.userRepo.save(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
async login(req: Request, user: User) {
|
|
||||||
if (user.isGitlabUser && !(await this.verifyGitlabUser(req, user))) {
|
|
||||||
await this.revokeAll(user);
|
|
||||||
throw new UnauthorizedException('Invalid gitlab token');
|
|
||||||
}
|
|
||||||
const token = new JWTToken();
|
|
||||||
token.ownerId = user.id;
|
|
||||||
const db_token = await this.tokenRepo.save(token);
|
|
||||||
const payload: jwtPayload = {
|
|
||||||
sub: user.id,
|
|
||||||
jti: db_token.id
|
|
||||||
};
|
|
||||||
const jwtToken = jwt.sign(payload, jwtSecret, {
|
|
||||||
mutatePayload: true,
|
|
||||||
expiresIn: '1d'
|
|
||||||
});
|
|
||||||
db_token.exp = payload.exp;
|
|
||||||
await this.tokenRepo.save(db_token);
|
|
||||||
return jwtToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
async singupInternal(user: User): Promise<User> {
|
|
||||||
const root = await this.fsService.generateRoot(user);
|
|
||||||
user.rootId = root.id;
|
|
||||||
return this.userRepo.save(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
async singupGitlab(
|
|
||||||
info: GitlabUserResponse,
|
|
||||||
data: GitlabTokenResponse
|
|
||||||
): Promise<User> {
|
|
||||||
const user = new User();
|
|
||||||
user.name = info.username;
|
|
||||||
user.password = '';
|
|
||||||
user.isGitlabUser = true;
|
|
||||||
user.role = info.is_admin ? UserRole.ADMIN : UserRole.DISABLED;
|
|
||||||
return this.singupInternal(await this.setGitlabTokens(user, data));
|
|
||||||
}
|
|
||||||
|
|
||||||
async signup(username: string, password: string) {
|
|
||||||
if (await this.findUser(username, false))
|
|
||||||
throw new BadRequestException('User already exists');
|
|
||||||
const user = new User();
|
|
||||||
user.name = username;
|
|
||||||
user.password = await argon2.hash(password);
|
|
||||||
await this.singupInternal(await this.userRepo.save(user));
|
|
||||||
}
|
|
||||||
|
|
||||||
async revoke(token: JWTToken) {
|
|
||||||
await this.tokenRepo.delete({
|
|
||||||
id: token.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async revokeAll(user: User) {
|
|
||||||
await this.tokenRepo.delete({
|
|
||||||
ownerId: user.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async setGitlabTokens(
|
|
||||||
user: User,
|
|
||||||
data: GitlabTokenResponse
|
|
||||||
): Promise<User> {
|
|
||||||
user.gitlabAT = data.access_token;
|
|
||||||
user.gitlabRT = data.refresh_token;
|
|
||||||
return this.userRepo.save(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
getGitlabRedirectUrl(req: Request): string {
|
|
||||||
const _req = req as unknown as FastifyRequest;
|
|
||||||
return `${_req.protocol}://${_req.hostname}/api/auth/gitlab_callback`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getGitlabAuthUrl(req: Request): string {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append('redirect_uri', this.getGitlabRedirectUrl(req));
|
|
||||||
params.append('response_type', 'code');
|
|
||||||
params.append('scope', 'read_user');
|
|
||||||
params.append('client_id', GITLAB_ID);
|
|
||||||
return `${GITLAB_URL}/oauth/authorize?${params.toString()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getGitlabUserFromCode(req: Request, code: string): Promise<User> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append('redirect_uri', this.getGitlabRedirectUrl(req));
|
|
||||||
params.append('client_id', GITLAB_ID);
|
|
||||||
params.append('client_secret', GITLAB_SECRET);
|
|
||||||
params.append('code', code);
|
|
||||||
params.append('grant_type', 'authorization_code');
|
|
||||||
const resp = await axios.post(
|
|
||||||
`${GITLAB_API_URL}/oauth/token?${params.toString()}`,
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
const data: GitlabTokenResponse = resp.data;
|
|
||||||
const userInfoResp = await axios.get(`${GITLAB_API_URL}/api/v4/user`, {
|
|
||||||
headers: { Authorization: `Bearer ${data.access_token}` }
|
|
||||||
});
|
|
||||||
const userInfo: GitlabUserResponse = userInfoResp.data;
|
|
||||||
let user = await this.findUser(userInfo.username, true);
|
|
||||||
if (!user) user = await this.singupGitlab(userInfo, data);
|
|
||||||
else user = await this.setGitlabTokens(user, data);
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getGitlabUserInfo(
|
|
||||||
req: Request,
|
|
||||||
user: User
|
|
||||||
): Promise<GitlabUserResponse | null> {
|
|
||||||
try {
|
|
||||||
const userInfoResp = await axios.get(
|
|
||||||
`${GITLAB_API_URL}/api/v4/user`,
|
|
||||||
{
|
|
||||||
headers: { Authorization: `Bearer ${user.gitlabAT}` }
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return userInfoResp.data;
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async tryRefreshGitlabTokens(
|
|
||||||
req: Request,
|
|
||||||
user: User
|
|
||||||
): Promise<User | null> {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
params.append('redirect_uri', this.getGitlabRedirectUrl(req));
|
|
||||||
params.append('client_id', GITLAB_ID);
|
|
||||||
params.append('client_secret', GITLAB_SECRET);
|
|
||||||
params.append('refresh_token', user.gitlabRT);
|
|
||||||
params.append('grant_type', 'refresh_token');
|
|
||||||
try {
|
|
||||||
const resp = await axios.post(
|
|
||||||
`${GITLAB_API_URL}/oauth/token?${params.toString()}`,
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
const data: GitlabTokenResponse = resp.data;
|
|
||||||
return this.setGitlabTokens(user, data);
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyGitlabUser(req: Request, user: User): Promise<boolean> {
|
|
||||||
let info = await this.getGitlabUserInfo(req, user);
|
|
||||||
if (!info) {
|
|
||||||
user = await this.tryRefreshGitlabTokens(req, user);
|
|
||||||
if (!user) return false;
|
|
||||||
info = await this.getGitlabUserInfo(req, user);
|
|
||||||
}
|
|
||||||
return info && info.username == user.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteUser(user: User): Promise<void> {
|
|
||||||
await this.revokeAll(user);
|
|
||||||
await this.fsService.delete(await user.root, true);
|
|
||||||
await this.userRepo.remove(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
async changePassword(
|
|
||||||
user: User,
|
|
||||||
oldPW: string,
|
|
||||||
newPw: string
|
|
||||||
): Promise<void> {
|
|
||||||
if (!(await argon2.verify(user.password, oldPW)))
|
|
||||||
throw new ForbiddenException('Old password is wrong');
|
|
||||||
user.password = await argon2.hash(newPw);
|
|
||||||
await this.revokeAll(await this.userRepo.save(user));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AuthLocalService extends PassportStrategy(LocalStrategy) {
|
|
||||||
constructor(private authService: AuthService) {
|
|
||||||
super();
|
|
||||||
}
|
|
||||||
|
|
||||||
async validate(username: string, pass: string) {
|
|
||||||
const user = await this.authService.validateUser(username, pass);
|
|
||||||
if (!user)
|
|
||||||
throw new UnauthorizedException('Invalid username or password');
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AuthJwtService extends PassportStrategy(JWTStrategy) {
|
|
||||||
constructor(private authService: AuthService) {
|
|
||||||
super({
|
|
||||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
|
||||||
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
||||||
ExtractJwt.fromBodyField('jwtToken')
|
|
||||||
]),
|
|
||||||
ignoreExpiration: false,
|
|
||||||
passReqToCallback: true,
|
|
||||||
secretOrKey: jwtSecret
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async validate(req: Request, payload: jwtPayload) {
|
|
||||||
await this.authService.cleanupTokens();
|
|
||||||
const token = await this.authService.getToken(payload.jti);
|
|
||||||
if (!token)
|
|
||||||
throw new UnauthorizedException(
|
|
||||||
'Invalid token, please log in again'
|
|
||||||
);
|
|
||||||
const user = await this.authService.getUser(token.ownerId);
|
|
||||||
if (!user || user.id != payload.sub)
|
|
||||||
throw new UnauthorizedException(
|
|
||||||
'Invalid token, please log in again'
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
user.isGitlabUser &&
|
|
||||||
!(await this.authService.verifyGitlabUser(req, user))
|
|
||||||
) {
|
|
||||||
await this.authService.revokeAll(user);
|
|
||||||
throw new UnauthorizedException('Invalid gitlab token');
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
req.token = token;
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
}
|
|
111
src/services/auth/base.ts
Normal file
111
src/services/auth/base.ts
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { JWTToken, User, UserRole } from '../../entities';
|
||||||
|
import { LessThanOrEqual, Repository } from 'typeorm';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
import FileSystemService from '../filesystem';
|
||||||
|
import * as jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
|
export const jwtSecret = 'CUM';
|
||||||
|
|
||||||
|
export interface jwtPayload {
|
||||||
|
sub: number;
|
||||||
|
jti: number;
|
||||||
|
exp?: number;
|
||||||
|
iat?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class BaseAuthService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(User)
|
||||||
|
protected userRepo: Repository<User>,
|
||||||
|
@InjectRepository(JWTToken)
|
||||||
|
protected tokenRepo: Repository<JWTToken>,
|
||||||
|
protected fsService: FileSystemService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getUser(userId: number): Promise<User | null> {
|
||||||
|
return this.userRepo.findOneBy({
|
||||||
|
id: userId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findUser(username: string, gitlab: boolean): Promise<User | null> {
|
||||||
|
return this.userRepo.findOneBy({
|
||||||
|
name: username,
|
||||||
|
isGitlabUser: gitlab
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getToken(tokenId: number): Promise<JWTToken | null> {
|
||||||
|
return this.tokenRepo.findOneBy({
|
||||||
|
id: tokenId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateUser(username: string, pass: string): Promise<User | null> {
|
||||||
|
const user = await this.findUser(username, false);
|
||||||
|
if (!user)
|
||||||
|
throw new UnauthorizedException('Invalid username or password');
|
||||||
|
if (!(await argon2.verify(user.password, pass)))
|
||||||
|
throw new UnauthorizedException('Invalid username or password');
|
||||||
|
if (user.role == UserRole.DISABLED)
|
||||||
|
throw new UnauthorizedException('Account is disabled');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupTokens(): Promise<void> {
|
||||||
|
await this.tokenRepo.delete({
|
||||||
|
exp: LessThanOrEqual(Math.floor(Date.now() / 1000))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(req: Request, user: User) {
|
||||||
|
const token = new JWTToken();
|
||||||
|
token.ownerId = user.id;
|
||||||
|
const db_token = await this.tokenRepo.save(token);
|
||||||
|
const payload: jwtPayload = {
|
||||||
|
sub: user.id,
|
||||||
|
jti: db_token.id
|
||||||
|
};
|
||||||
|
const jwtToken = jwt.sign(payload, jwtSecret, {
|
||||||
|
mutatePayload: true,
|
||||||
|
expiresIn: '1d'
|
||||||
|
});
|
||||||
|
db_token.exp = payload.exp;
|
||||||
|
await this.tokenRepo.save(db_token);
|
||||||
|
return jwtToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async singupInternal(user: User): Promise<User> {
|
||||||
|
const root = await this.fsService.generateRoot(user);
|
||||||
|
user.rootId = root.id;
|
||||||
|
return this.userRepo.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async signup(username: string, password: string) {
|
||||||
|
if (await this.findUser(username, false))
|
||||||
|
throw new BadRequestException('User already exists');
|
||||||
|
const user = new User();
|
||||||
|
user.name = username;
|
||||||
|
user.password = await argon2.hash(password);
|
||||||
|
await this.singupInternal(await this.userRepo.save(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
async revoke(token: JWTToken) {
|
||||||
|
await this.tokenRepo.delete({
|
||||||
|
id: token.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async revokeAll(user: User) {
|
||||||
|
await this.tokenRepo.delete({
|
||||||
|
ownerId: user.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
159
src/services/auth/gitlab.ts
Normal file
159
src/services/auth/gitlab.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { User, UserRole } from '../../entities';
|
||||||
|
import { FastifyRequest } from 'fastify';
|
||||||
|
import axios from 'axios';
|
||||||
|
import * as argon2 from 'argon2';
|
||||||
|
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import TfaAuthService from './tfa';
|
||||||
|
|
||||||
|
const GITLAB_ID =
|
||||||
|
'98bcbad78cb1f880d1d1de62291d70a791251a7bea077bfe7df111ef3c115760';
|
||||||
|
const GITLAB_SECRET =
|
||||||
|
'7ee01d2b204aff3a05f9d028f004d169b6d381ec873e195f314b3935fa150959';
|
||||||
|
const GITLAB_URL = 'https://gitlab.mattv.de';
|
||||||
|
const GITLAB_API_URL = 'https://ssh.gitlab.mattv.de';
|
||||||
|
|
||||||
|
interface GitlabTokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token: string;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitlabUserResponse {
|
||||||
|
username: string;
|
||||||
|
is_admin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class GitlabAuthService extends TfaAuthService {
|
||||||
|
async login(req: Request, user: User) {
|
||||||
|
if (user.isGitlabUser && !(await this.verifyGitlabUser(req, user))) {
|
||||||
|
await this.revokeAll(user);
|
||||||
|
throw new UnauthorizedException('Invalid gitlab token');
|
||||||
|
}
|
||||||
|
return super.login(req, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async singupGitlab(
|
||||||
|
info: GitlabUserResponse,
|
||||||
|
data: GitlabTokenResponse
|
||||||
|
): Promise<User> {
|
||||||
|
const user = new User();
|
||||||
|
user.name = info.username;
|
||||||
|
user.password = '';
|
||||||
|
user.isGitlabUser = true;
|
||||||
|
user.role = info.is_admin ? UserRole.ADMIN : UserRole.DISABLED;
|
||||||
|
return this.singupInternal(await this.setGitlabTokens(user, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
async setGitlabTokens(
|
||||||
|
user: User,
|
||||||
|
data: GitlabTokenResponse
|
||||||
|
): Promise<User> {
|
||||||
|
user.gitlabAT = data.access_token;
|
||||||
|
user.gitlabRT = data.refresh_token;
|
||||||
|
return this.userRepo.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
getGitlabRedirectUrl(req: Request): string {
|
||||||
|
const _req = req as unknown as FastifyRequest;
|
||||||
|
return `${_req.protocol}://${_req.hostname}/api/auth/gitlab_callback`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGitlabAuthUrl(req: Request): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('redirect_uri', this.getGitlabRedirectUrl(req));
|
||||||
|
params.append('response_type', 'code');
|
||||||
|
params.append('scope', 'read_user');
|
||||||
|
params.append('client_id', GITLAB_ID);
|
||||||
|
return `${GITLAB_URL}/oauth/authorize?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGitlabUserFromCode(req: Request, code: string): Promise<User> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('redirect_uri', this.getGitlabRedirectUrl(req));
|
||||||
|
params.append('client_id', GITLAB_ID);
|
||||||
|
params.append('client_secret', GITLAB_SECRET);
|
||||||
|
params.append('code', code);
|
||||||
|
params.append('grant_type', 'authorization_code');
|
||||||
|
const resp = await axios.post(
|
||||||
|
`${GITLAB_API_URL}/oauth/token?${params.toString()}`,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const data: GitlabTokenResponse = resp.data;
|
||||||
|
const userInfoResp = await axios.get(`${GITLAB_API_URL}/api/v4/user`, {
|
||||||
|
headers: { Authorization: `Bearer ${data.access_token}` }
|
||||||
|
});
|
||||||
|
const userInfo: GitlabUserResponse = userInfoResp.data;
|
||||||
|
let user = await this.findUser(userInfo.username, true);
|
||||||
|
if (!user) user = await this.singupGitlab(userInfo, data);
|
||||||
|
else user = await this.setGitlabTokens(user, data);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getGitlabUserInfo(
|
||||||
|
req: Request,
|
||||||
|
user: User
|
||||||
|
): Promise<GitlabUserResponse | null> {
|
||||||
|
try {
|
||||||
|
const userInfoResp = await axios.get(
|
||||||
|
`${GITLAB_API_URL}/api/v4/user`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${user.gitlabAT}` }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return userInfoResp.data;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async tryRefreshGitlabTokens(
|
||||||
|
req: Request,
|
||||||
|
user: User
|
||||||
|
): Promise<User | null> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('redirect_uri', this.getGitlabRedirectUrl(req));
|
||||||
|
params.append('client_id', GITLAB_ID);
|
||||||
|
params.append('client_secret', GITLAB_SECRET);
|
||||||
|
params.append('refresh_token', user.gitlabRT);
|
||||||
|
params.append('grant_type', 'refresh_token');
|
||||||
|
try {
|
||||||
|
const resp = await axios.post(
|
||||||
|
`${GITLAB_API_URL}/oauth/token?${params.toString()}`,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
const data: GitlabTokenResponse = resp.data;
|
||||||
|
return this.setGitlabTokens(user, data);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyGitlabUser(req: Request, user: User): Promise<boolean> {
|
||||||
|
let info = await this.getGitlabUserInfo(req, user);
|
||||||
|
if (!info) {
|
||||||
|
user = await this.tryRefreshGitlabTokens(req, user);
|
||||||
|
if (!user) return false;
|
||||||
|
info = await this.getGitlabUserInfo(req, user);
|
||||||
|
}
|
||||||
|
return info && info.username == user.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteUser(user: User): Promise<void> {
|
||||||
|
await this.revokeAll(user);
|
||||||
|
await this.fsService.delete(await user.root, true);
|
||||||
|
await this.userRepo.remove(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(
|
||||||
|
user: User,
|
||||||
|
oldPW: string,
|
||||||
|
newPw: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (!(await argon2.verify(user.password, oldPW)))
|
||||||
|
throw new ForbiddenException('Old password is wrong');
|
||||||
|
user.password = await argon2.hash(newPw);
|
||||||
|
await this.revokeAll(await this.userRepo.save(user));
|
||||||
|
}
|
||||||
|
}
|
2
src/services/auth/index.ts
Normal file
2
src/services/auth/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default as AuthService } from './gitlab';
|
||||||
|
export * from './strategies';
|
60
src/services/auth/strategies.ts
Normal file
60
src/services/auth/strategies.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
|
import { Strategy as LocalStrategy } from 'passport-local';
|
||||||
|
import { ExtractJwt, Strategy as JWTStrategy } from 'passport-jwt';
|
||||||
|
import AuthService from './gitlab';
|
||||||
|
import { jwtPayload, jwtSecret } from './base';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthLocalService extends PassportStrategy(LocalStrategy) {
|
||||||
|
constructor(private authService: AuthService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(username: string, pass: string) {
|
||||||
|
const user = await this.authService.validateUser(username, pass);
|
||||||
|
if (!user)
|
||||||
|
throw new UnauthorizedException('Invalid username or password');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthJwtService extends PassportStrategy(JWTStrategy) {
|
||||||
|
constructor(private authService: AuthService) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||||
|
ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
ExtractJwt.fromBodyField('jwtToken')
|
||||||
|
]),
|
||||||
|
ignoreExpiration: false,
|
||||||
|
passReqToCallback: true,
|
||||||
|
secretOrKey: jwtSecret
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(req: Request, payload: jwtPayload) {
|
||||||
|
await this.authService.cleanupTokens();
|
||||||
|
const token = await this.authService.getToken(payload.jti);
|
||||||
|
if (!token)
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Invalid token, please log in again'
|
||||||
|
);
|
||||||
|
const user = await this.authService.getUser(token.ownerId);
|
||||||
|
if (!user || user.id != payload.sub)
|
||||||
|
throw new UnauthorizedException(
|
||||||
|
'Invalid token, please log in again'
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
user.isGitlabUser &&
|
||||||
|
!(await this.authService.verifyGitlabUser(req, user))
|
||||||
|
) {
|
||||||
|
await this.authService.revokeAll(user);
|
||||||
|
throw new UnauthorizedException('Invalid gitlab token');
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
req.token = token;
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
74
src/services/auth/tfa.ts
Normal file
74
src/services/auth/tfa.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { tfaTypes, User } from '../../entities';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import BaseAuthService from './base';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import * as notp from 'notp';
|
||||||
|
import { createTransport } from 'nodemailer';
|
||||||
|
|
||||||
|
const mailAccount = createTransport({
|
||||||
|
host: 'mail.mattv.de',
|
||||||
|
port: 587,
|
||||||
|
secure: false,
|
||||||
|
auth: {
|
||||||
|
user: 'no-reply@mattv.de',
|
||||||
|
pass: 'noreplyLONGPASS123'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default class TfaAuthService extends BaseAuthService {
|
||||||
|
generateTfaSecret(): string {
|
||||||
|
const set =
|
||||||
|
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXTZabcdefghiklmnopqrstuvwxyz!@#$%^&*()<>?/[]{},.:;';
|
||||||
|
return randomBytes(32)
|
||||||
|
.map((b) =>
|
||||||
|
set.charCodeAt(Math.floor((b / 255.0) * (set.length - 1)))
|
||||||
|
)
|
||||||
|
.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
requiresTfa(user: User): boolean {
|
||||||
|
return user.tfaType != tfaTypes.NONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyTfa(
|
||||||
|
user: User,
|
||||||
|
token: string,
|
||||||
|
type?: tfaTypes
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!type) type = user.tfaType;
|
||||||
|
const delta = notp.totp.verify(token, user.tfaSecret, {
|
||||||
|
window: 10
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
delta &&
|
||||||
|
(type == tfaTypes.EMAIL ? delta.delta <= 0 : delta.delta == 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendTfaMail(user: User) {
|
||||||
|
await mailAccount.sendMail({
|
||||||
|
from: 'fileserver@mattv.de',
|
||||||
|
to: user.name,
|
||||||
|
subject: 'Fileserver - EMail 2fa code',
|
||||||
|
text: `Your code is: ${notp.totp.gen(
|
||||||
|
user.tfaSecret
|
||||||
|
)}\nIt is valid for 5 Minutes`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async setupTfa(user: User): Promise<string> {
|
||||||
|
if (user.tfaType != tfaTypes.NONE)
|
||||||
|
throw new BadRequestException(
|
||||||
|
'2 Factor authentication is already setup'
|
||||||
|
);
|
||||||
|
const secret = this.generateTfaSecret();
|
||||||
|
user.tfaSecret = secret;
|
||||||
|
await this.userRepo.save(user);
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setTfaType(user: User, type: tfaTypes) {
|
||||||
|
user.tfaType = type;
|
||||||
|
await this.userRepo.save(user);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user