Add JRPC API, reworked Login and User pages
This commit is contained in:
52
Backend/src/api/jrpc/services/account.ts
Normal file
52
Backend/src/api/jrpc/services/account.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { Profile, ContactInfo, Gender, Server, UserRegisterInfo } from "@hibas123/openauth-internalapi";
|
||||
import type { SessionContext } from "../index";
|
||||
import Mail from "../../../models/mail";
|
||||
import User from "../../../models/user";
|
||||
import { RequireLogin } from "../../../helper/login";
|
||||
|
||||
export default class AccountService extends Server.AccountService<SessionContext> {
|
||||
Register(regcode: string, info: UserRegisterInfo, ctx: SessionContext): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
@RequireLogin()
|
||||
async GetProfile(ctx: SessionContext): Promise<Profile> {
|
||||
if (!ctx.user) throw new Error("Not logged in");
|
||||
|
||||
|
||||
return {
|
||||
id: ctx.user.uid,
|
||||
username: ctx.user.username,
|
||||
name: ctx.user.name,
|
||||
birthday: ctx.user.birthday.valueOf(),
|
||||
gender: ctx.user.gender as number as Gender,
|
||||
}
|
||||
}
|
||||
|
||||
@RequireLogin()
|
||||
async UpdateProfile(info: Profile, ctx: SessionContext): Promise<void> {
|
||||
if (!ctx.user) throw new Error("Not logged in");
|
||||
|
||||
ctx.user.name = info.name;
|
||||
ctx.user.birthday = new Date(info.birthday);
|
||||
ctx.user.gender = info.gender as number;
|
||||
|
||||
await User.save(ctx.user);
|
||||
}
|
||||
|
||||
@RequireLogin()
|
||||
async GetContactInfos(ctx: SessionContext): Promise<ContactInfo> {
|
||||
if (!ctx.user) throw new Error("Not logged in");
|
||||
|
||||
let mails = await Promise.all(
|
||||
ctx.user.mails.map((mail) => Mail.findById(mail))
|
||||
);
|
||||
|
||||
let contact = {
|
||||
mail: mails.filter((e) => !!e),
|
||||
phone: ctx.user.phones,
|
||||
};
|
||||
|
||||
return contact;
|
||||
}
|
||||
}
|
265
Backend/src/api/jrpc/services/login.ts
Normal file
265
Backend/src/api/jrpc/services/login.ts
Normal file
@ -0,0 +1,265 @@
|
||||
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);
|
||||
}
|
||||
}
|
35
Backend/src/api/jrpc/services/security.ts
Normal file
35
Backend/src/api/jrpc/services/security.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { Server, Session } from "@hibas123/openauth-internalapi";
|
||||
import type { SessionContext } from "../index";
|
||||
import Logging from "@hibas123/nodelogging";
|
||||
import { RequireLogin } from "../../../helper/login";
|
||||
import crypto from "node:crypto";
|
||||
import User from "../../../models/user";
|
||||
|
||||
export default class SecurityService extends Server.SecurityService<SessionContext> {
|
||||
@RequireLogin()
|
||||
async GetSessions(ctx: SessionContext): Promise<Session[]> {
|
||||
return []
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
@RequireLogin()
|
||||
async RevokeSession(id: string, ctx: SessionContext): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
@RequireLogin()
|
||||
async ChangePassword(old_pw: string, new_pw: string, ctx: SessionContext): Promise<void> {
|
||||
let old_pw_hash = crypto.createHash("sha512").update(ctx.user.salt + old_pw).digest("hex");
|
||||
|
||||
if (old_pw_hash != ctx.user.password) {
|
||||
throw new Error("Wrong password");
|
||||
}
|
||||
|
||||
let salt = crypto.randomBytes(32).toString("base64");
|
||||
let password_hash = crypto.createHash("sha512").update(salt + new_pw).digest("hex");
|
||||
|
||||
ctx.user.salt = salt;
|
||||
ctx.user.password = password_hash;
|
||||
|
||||
await User.save(ctx.user);
|
||||
}
|
||||
}
|
194
Backend/src/api/jrpc/services/twofactor.ts
Normal file
194
Backend/src/api/jrpc/services/twofactor.ts
Normal file
@ -0,0 +1,194 @@
|
||||
import { TFANewTOTP, Server, TFAOption, UserRegisterInfo, TFAWebAuthRegister } from "@hibas123/openauth-internalapi";
|
||||
import type { SessionContext } from "../index";
|
||||
import TwoFactorModel, { ITOTP, IWebAuthn, TFATypes } from "../../../models/twofactor";
|
||||
import moment = require("moment");
|
||||
import * as speakeasy from "speakeasy";
|
||||
import * as qrcode from "qrcode";
|
||||
import config from "../../../config";
|
||||
import { generateRegistrationOptions, verifyRegistrationResponse } from '@simplewebauthn/server';
|
||||
import type { RegistrationResponseJSON } from '@simplewebauthn/typescript-types';
|
||||
import Logging from "@hibas123/nodelogging";
|
||||
import { Binary } from "mongodb";
|
||||
import { RequireLogin } from "../../../helper/login";
|
||||
|
||||
|
||||
export default class TFAService extends Server.TFAService<SessionContext> {
|
||||
@RequireLogin()
|
||||
AddBackupCodes(name: string, ctx: SessionContext): Promise<string[]> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
@RequireLogin()
|
||||
RemoveBackupCodes(id: string, ctx: SessionContext): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
@RequireLogin()
|
||||
async GetOptions(ctx: SessionContext): Promise<TFAOption[]> {
|
||||
let twofactor = await TwoFactorModel.find({ user: ctx.user._id, valid: true });
|
||||
let expired = twofactor.filter((e) =>
|
||||
e.expires ? moment().isAfter(moment(e.expires)) : false
|
||||
);
|
||||
await Promise.all(
|
||||
expired.map((e) => {
|
||||
e.valid = false;
|
||||
return TwoFactorModel.save(e);
|
||||
})
|
||||
);
|
||||
|
||||
twofactor = twofactor.filter((e) => e.valid);
|
||||
let tfa = twofactor.map<TFAOption>((e) => {
|
||||
return {
|
||||
id: e._id.toString(),
|
||||
name: e.name,
|
||||
tfatype: e.type as number,
|
||||
expires: e.expires?.valueOf()
|
||||
};
|
||||
});
|
||||
|
||||
return tfa;
|
||||
}
|
||||
|
||||
@RequireLogin()
|
||||
async Delete(id: string, ctx: SessionContext): Promise<void> {
|
||||
let twofactor = await TwoFactorModel.findById(id);
|
||||
if (!twofactor || !twofactor.user.equals(ctx.user._id))
|
||||
throw new Error("Invalid ID");
|
||||
|
||||
twofactor.valid = false;
|
||||
await TwoFactorModel.save(twofactor);
|
||||
}
|
||||
|
||||
@RequireLogin()
|
||||
async AddTOTP(name: string, ctx: SessionContext): Promise<TFANewTOTP> {
|
||||
//Generating new
|
||||
let secret = speakeasy.generateSecret({
|
||||
name: config.core.name,
|
||||
issuer: config.core.name,
|
||||
otpauth_url: true
|
||||
});
|
||||
|
||||
let twofactor = TwoFactorModel.new(<ITOTP>{
|
||||
name: name,
|
||||
user: ctx.user._id,
|
||||
type: TFATypes.TOTP,
|
||||
valid: false,
|
||||
data: secret.base32,
|
||||
});
|
||||
|
||||
let dataurl = await qrcode.toDataURL(secret.otpauth_url);
|
||||
await TwoFactorModel.save(twofactor);
|
||||
|
||||
return {
|
||||
id: twofactor._id.toString(),
|
||||
qr: dataurl,
|
||||
secret: secret.base32
|
||||
}
|
||||
}
|
||||
|
||||
@RequireLogin()
|
||||
async VerifyTOTP(id: string, code: string, ctx: SessionContext): Promise<void> {
|
||||
let twofactor = await TwoFactorModel.findById(id);
|
||||
if (!twofactor || !twofactor.user.equals(ctx.user._id))
|
||||
throw new Error("Invalid ID");
|
||||
|
||||
let verified = speakeasy.totp.verify({
|
||||
secret: twofactor.data,
|
||||
encoding: "base32",
|
||||
token: code,
|
||||
});
|
||||
|
||||
if (!verified) throw new Error("Invalid code");
|
||||
|
||||
twofactor.valid = true;
|
||||
twofactor.expires = undefined;
|
||||
|
||||
await TwoFactorModel.save(twofactor);
|
||||
}
|
||||
|
||||
@RequireLogin()
|
||||
async AddWebauthn(name: string, ctx: SessionContext): Promise<TFAWebAuthRegister> {
|
||||
// TODO: Get already registered options
|
||||
|
||||
const rpID = new URL(config.core.url).hostname;
|
||||
const options = generateRegistrationOptions({
|
||||
rpName: config.core.name,
|
||||
rpID,
|
||||
userID: ctx.user.uid,
|
||||
userName: ctx.user.username,
|
||||
attestationType: 'direct',
|
||||
userDisplayName: ctx.user.name,
|
||||
excludeCredentials: [],
|
||||
authenticatorSelection: {
|
||||
userVerification: "required",
|
||||
requireResidentKey: false,
|
||||
residentKey: "discouraged",
|
||||
authenticatorAttachment: "cross-platform"
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
const twofactor = TwoFactorModel.new({
|
||||
name,
|
||||
type: TFATypes.WEBAUTHN,
|
||||
user: ctx.user._id,
|
||||
valid: false,
|
||||
data: {
|
||||
challenge: options.challenge
|
||||
}
|
||||
});
|
||||
|
||||
await TwoFactorModel.save(twofactor);
|
||||
|
||||
Logging.debug(twofactor);
|
||||
|
||||
return {
|
||||
id: twofactor._id.toString(),
|
||||
challenge: JSON.stringify(options)
|
||||
};
|
||||
}
|
||||
|
||||
@RequireLogin()
|
||||
async VerifyWebAuthn(id: string, registration: string, ctx: SessionContext): Promise<void> {
|
||||
let twofactor = await TwoFactorModel.findById(id) as IWebAuthn;
|
||||
if (!twofactor || !twofactor.user.equals(ctx.user._id))
|
||||
throw new Error("Invalid ID");
|
||||
|
||||
const rpID = new URL(config.core.url).hostname;
|
||||
|
||||
const response = JSON.parse(registration) as RegistrationResponseJSON;
|
||||
|
||||
let verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge: twofactor.data.challenge,
|
||||
expectedOrigin: config.core.url,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: true,
|
||||
});
|
||||
|
||||
if (verification.verified) {
|
||||
const { credentialPublicKey, credentialID, counter } = verification.registrationInfo;
|
||||
|
||||
//TODO: Check if already registered!
|
||||
// TwoFactorModel.find({
|
||||
// data: {
|
||||
// }
|
||||
// })
|
||||
|
||||
twofactor.data = {
|
||||
device: {
|
||||
credentialPublicKey: new Binary(credentialPublicKey),
|
||||
credentialID: new Binary(credentialID),
|
||||
counter: verification.registrationInfo.counter,
|
||||
transports: response.response.transports as any[]
|
||||
}
|
||||
}
|
||||
|
||||
twofactor.valid = true;
|
||||
|
||||
await TwoFactorModel.save(twofactor);
|
||||
} else {
|
||||
throw new Error("Invalid response");
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user