diff --git a/src/services/auth.ts b/src/services/auth.ts deleted file mode 100644 index 224edcb..0000000 --- a/src/services/auth.ts +++ /dev/null @@ -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, - @InjectRepository(JWTToken) - private tokenRepo: Repository, - 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 { - return this.userRepo.findOneBy({ - id: userId - }); - } - - async findUser(username: string, gitlab: boolean): Promise { - return this.userRepo.findOneBy({ - name: username, - isGitlabUser: gitlab - }); - } - - async getToken(tokenId: number): Promise { - return this.tokenRepo.findOneBy({ - id: tokenId - }); - } - - async validateUser(username: string, pass: string): Promise { - 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 { - 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 { - 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 { - 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 { - const root = await this.fsService.generateRoot(user); - user.rootId = root.id; - return this.userRepo.save(user); - } - - async singupGitlab( - info: GitlabUserResponse, - data: GitlabTokenResponse - ): Promise { - 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.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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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; - } -} diff --git a/src/services/auth/base.ts b/src/services/auth/base.ts new file mode 100644 index 0000000..17e9055 --- /dev/null +++ b/src/services/auth/base.ts @@ -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, + @InjectRepository(JWTToken) + protected tokenRepo: Repository, + protected fsService: FileSystemService + ) {} + + async getUser(userId: number): Promise { + return this.userRepo.findOneBy({ + id: userId + }); + } + + async findUser(username: string, gitlab: boolean): Promise { + return this.userRepo.findOneBy({ + name: username, + isGitlabUser: gitlab + }); + } + + async getToken(tokenId: number): Promise { + return this.tokenRepo.findOneBy({ + id: tokenId + }); + } + + async validateUser(username: string, pass: string): Promise { + 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 { + 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 { + 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 + }); + } +} diff --git a/src/services/auth/gitlab.ts b/src/services/auth/gitlab.ts new file mode 100644 index 0000000..e7d8ee6 --- /dev/null +++ b/src/services/auth/gitlab.ts @@ -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 { + 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.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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)); + } +} diff --git a/src/services/auth/index.ts b/src/services/auth/index.ts new file mode 100644 index 0000000..96e7221 --- /dev/null +++ b/src/services/auth/index.ts @@ -0,0 +1,2 @@ +export { default as AuthService } from './gitlab'; +export * from './strategies'; diff --git a/src/services/auth/strategies.ts b/src/services/auth/strategies.ts new file mode 100644 index 0000000..8bad69e --- /dev/null +++ b/src/services/auth/strategies.ts @@ -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; + } +} diff --git a/src/services/auth/tfa.ts b/src/services/auth/tfa.ts new file mode 100644 index 0000000..93b3462 --- /dev/null +++ b/src/services/auth/tfa.ts @@ -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 { + 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 { + 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); + } +}