import { Server, LoginState, TFAOption, TFAType } from "@hibas123/openauth-internalapi"; import type { SessionContext } from "../index"; import Logging from "@hibas123/nodelogging"; import User, { IUser } from "../../../models/user"; import moment from "moment"; import crypto from "node:crypto"; import TwoFactor, { ITwoFactor, IWebAuthn } from "../../../models/twofactor"; import speakeasy from "speakeasy"; import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server"; import config from "../../../config"; //FIXME: There are a lot of uneccessary database requests happening here. Since this is not a "hot" path, it should not matter to much, but it should be fixed nontheless. export default class LoginService extends Server.LoginService { private async getUser(username: string): Promise { let user = await User.findOne({ username: username.toLowerCase() }); if (!user) { throw new Error("User not found"); } return user; } private async getLoginState(ctx: SessionContext): Promise { if (ctx.user && ctx.session.validated) { return { success: true } } else if (ctx.session.login_state) { //TODO: Check login_state expiration or so if (ctx.session.login_state.username) { let user = await this.getUser(ctx.session.login_state.username); if (!ctx.session.login_state.password_correct) { let passwordSalt = user.salt; return { success: false, username: ctx.session.login_state.username, password: false, passwordSalt: passwordSalt, } } else { let tfa = await this.getTwoFactors(await this.getUser(ctx.session.login_state.username)) if (tfa.length <= 0) { ctx.session.user_id = user._id.toString(); ctx.session.login_state = undefined; Logging.warn("This should have been set somewhere else!"); return { success: true, } } else { return { success: false, username: ctx.session.login_state.username, password: true, requireTwoFactor: tfa, } } } } } else { return { success: false, username: undefined, password: false, } } } private async getTwoFactors(user: IUser): Promise { let twofactors = await TwoFactor.find({ user: user._id, valid: true }) return twofactors.map(tf => { return { id: tf._id.toString(), name: tf.name, tfatype: tf.type as number, } }) } private async enableSession(ctx: SessionContext) { let user = await this.getUser(ctx.session.login_state.username); ctx.user = user; ctx.session.user_id = user._id.toString(); ctx.session.login_state = undefined; ctx.session.validated = true; } GetState(ctx: SessionContext): Promise { return this.getLoginState(ctx); } async Start(username: string, ctx: SessionContext): Promise { let user = await this.getUser(username); ctx.session.login_state = { username: username, password_correct: false, } return this.getLoginState(ctx); } async UsePassword(password_hash: string, date: number, ctx: SessionContext): Promise { if (!ctx.session.login_state) { throw new Error("No login state. Call Start() first."); } let user = await this.getUser(ctx.session.login_state.username); if (date <= 0) { if (user.password !== password_hash) { throw new Error("Password incorrect"); } } else { if ( !moment(date).isBetween( moment().subtract(1, "minute"), moment().add(1, "minute") ) ) { throw new Error("Date incorrect. Please check your devices time!"); } else { let upw = crypto .createHash("sha512") .update(user.password + date.toString()) .digest("hex"); if (upw !== password_hash) { throw new Error("Password incorrect"); } } } ctx.session.login_state.password_correct = true; let tfas = await this.getTwoFactors(user); if (tfas.length <= 0) { await this.enableSession(ctx); } return this.getLoginState(ctx); } private async getAndCheckTFA(id: string, shouldType: TFAType, ctx: SessionContext): Promise { if (!ctx.session.login_state) { throw new Error("No login state. Call Start() first."); } let user = await this.getUser(ctx.session.login_state.username); let tfa = await TwoFactor.findById(id); if (!tfa || tfa.user.toString() != user._id.toString()) { throw new Error("Two factor not found"); } if (tfa.type != shouldType as number) { throw new Error("Two factor is not the correct type!"); } if (!tfa.valid) { throw new Error("Two factor is not valid"); } if (tfa.expires && moment().isAfter(moment(tfa.expires))) { throw new Error("Two factor is expired"); } return tfa as T; } async UseTOTP(id: string, code: string, ctx: SessionContext): Promise { let tfa = await this.getAndCheckTFA(id, TFAType.TOTP, ctx); let valid = speakeasy.totp.verify({ secret: tfa.data, encoding: "base32", token: code, }); if (!valid) { throw new Error("Code incorrect"); } await this.enableSession(ctx); return this.getLoginState(ctx); } async UseBackupCode(id: string, code: string, ctx: SessionContext): Promise { let tfa = await this.getAndCheckTFA(id, TFAType.BACKUP_CODE, ctx); if (tfa.data.indexOf(code) < 0) { throw new Error("Code incorrect"); } tfa.data = tfa.data.filter(c => c != code); await TwoFactor.save(tfa); //TODO: handle the case where the last backup code is used await this.enableSession(ctx); return this.getLoginState(ctx); } async GetWebAuthnChallenge(id: string, ctx: SessionContext): Promise { let tfa = await this.getAndCheckTFA(id, TFAType.WEBAUTHN, ctx); const rpID = new URL(config.core.url).hostname; let options = generateAuthenticationOptions({ timeout: 60000, userVerification: "discouraged", rpID, allowCredentials: [{ id: tfa.data.device.credentialID.buffer, type: "public-key", transports: tfa.data.device.transports }] }) ctx.session.login_state.webauthn_challenge = options.challenge; Logging.debug("Challenge", options, tfa, tfa.data.device.credentialID); return JSON.stringify(options); } async UseWebAuthn(id: string, response: string, ctx: SessionContext): Promise { let tfa = await this.getAndCheckTFA(id, TFAType.WEBAUTHN, ctx); if (!ctx.session.login_state.webauthn_challenge) { throw new Error("No webauthn challenge"); } let rpID = new URL(config.core.url).hostname; let verification = await verifyAuthenticationResponse({ response: JSON.parse(response), authenticator: { counter: tfa.data.device.counter, credentialID: tfa.data.device.credentialID.buffer, credentialPublicKey: tfa.data.device.credentialPublicKey.buffer, transports: tfa.data.device.transports }, expectedChallenge: ctx.session.login_state.webauthn_challenge, expectedOrigin: config.core.url, expectedRPID: rpID, requireUserVerification: false }) if (verification.verified) { tfa.data.device.counter = verification.authenticationInfo.newCounter; await TwoFactor.save(tfa); } await this.enableSession(ctx); return this.getLoginState(ctx); } }