266 lines
8.6 KiB
TypeScript
266 lines
8.6 KiB
TypeScript
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<SessionContext> {
|
|
private async getUser(username: string): Promise<IUser> {
|
|
let user = await User.findOne({ username: username.toLowerCase() });
|
|
if (!user) {
|
|
throw new Error("User not found");
|
|
}
|
|
return user;
|
|
}
|
|
|
|
private async getLoginState(ctx: SessionContext): Promise<LoginState> {
|
|
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<TFAOption[]> {
|
|
let twofactors = await TwoFactor.find({
|
|
user: user._id,
|
|
valid: true
|
|
})
|
|
|
|
return twofactors.map<TFAOption>(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<LoginState> {
|
|
return this.getLoginState(ctx);
|
|
}
|
|
|
|
async Start(username: string, ctx: SessionContext): Promise<LoginState> {
|
|
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<LoginState> {
|
|
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<T extends ITwoFactor>(id: string, shouldType: TFAType, ctx: SessionContext): Promise<T> {
|
|
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<LoginState> {
|
|
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<LoginState> {
|
|
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<string> {
|
|
let tfa = await this.getAndCheckTFA<IWebAuthn>(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<LoginState> {
|
|
let tfa = await this.getAndCheckTFA<IWebAuthn>(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);
|
|
}
|
|
}
|