diff --git a/src/server/lib/codes.ts b/src/server/lib/codes.ts index 715b124..10f675f 100644 --- a/src/server/lib/codes.ts +++ b/src/server/lib/codes.ts @@ -1,15 +1,22 @@ -import { generateFileId } from "./files.js"; +import { generateFileId } from "./files.js"; +import crypto from "node:crypto" -export const Intents = ["verifyEmail", "recoverAccount", "deletionOtp"] as const +export type Intent = "verifyEmail" | "recoverAccount" | "identityProof" -export type Intent = (typeof Intents)[number] +export const Intents = { + verifyEmail: {}, + recoverAccount: {}, + identityProof: { + codeGenerator: crypto.randomUUID + } +} as Record string}> export function isIntent(intent: string): intent is Intent { return intent in Intents } export let codes = Object.fromEntries( - Intents.map((e) => [ + Object.keys(Intents).map((e) => [ e, { byId: new Map(), @@ -24,13 +31,10 @@ export let codes = Object.fromEntries( // this is stupid whyd i write this export class Code { - readonly id: string = generateFileId(12) + readonly id: string readonly for: string - readonly intent: Intent - readonly expiryClear: NodeJS.Timeout - readonly data: any constructor( @@ -39,25 +43,28 @@ export class Code { data?: any, time: number = 15 * 60 * 1000 ) { + const { codeGenerator = () => generateFileId(12) } = Intents[intent] + this.for = forUser this.intent = intent this.expiryClear = setTimeout(this.terminate.bind(this), time) this.data = data + this.id = codeGenerator() - codes[intent].byId.set(this.id, this) - - let byUser = codes[intent].byUser.get(this.for) + let byUser = codes[intent].byUser.get(forUser) if (!byUser) { byUser = [] - codes[intent].byUser.set(this.for, byUser) + codes[intent].byUser.set(forUser, byUser) } + codes[intent].byId.set(this.id, this) + byUser.push(this) } terminate() { codes[this.intent].byId.delete(this.id) - let bu = codes[this.intent].byUser.get(this.id)! + let bu = codes[this.intent].byUser.get(this.for)! bu.splice(bu.indexOf(this), 1) clearTimeout(this.expiryClear) } diff --git a/src/server/lib/middleware.ts b/src/server/lib/middleware.ts index 51b817e..1180dc1 100644 --- a/src/server/lib/middleware.ts +++ b/src/server/lib/middleware.ts @@ -4,6 +4,7 @@ import ServeError from "../lib/errors.js" import * as auth from "./auth.js" import { setCookie } from "hono/cookie" import { z } from "zod" +import { codes } from "./codes.js" /** * @description Middleware which adds an account, if any, to ctx.get("account") @@ -21,17 +22,55 @@ export const getAccount: RequestHandler = function (ctx, next) { */ export const getTarget: RequestHandler = async (ctx, next) => { - let acc = + let tok = auth.tokenFor(ctx) + let permissions + if (tok && auth.getType(tok) != "User") + permissions = auth.getPermissions(tok) + + let actor = ctx.get("account") + + let target = ctx.req.param("user") == "me" - ? ctx.get("account") + ? actor : ctx.req.param("user").startsWith("@") ? Accounts.getFromUsername(ctx.req.param("user").slice(1)) : Accounts.getFromId(ctx.req.param("user")) - if (acc != ctx.get("account") && !ctx.get("account")?.admin) + + if (!target) return ServeError(ctx, 404, "account does not exist") + + if (actor && ( + ( + target != actor // target is not the current account + && !actor?.admin // account is not admin + ) + || ( + actor?.admin // account is admin + && permissions && !permissions.includes("manage_server") // permissions does not include manage_server + ) + )) return ServeError(ctx, 403, "you cannot manage this user") - if (!acc) return ServeError(ctx, 404, "account does not exist") + + ctx.set("target", target) - ctx.set("target", acc) + return next() +} + +/** + * @description Blocks routes with a target user set to the account performing the action from bot tokens which do not have the manage_account permission + */ +export const accountMgmtRoute: RequestHandler = async (ctx,next) => { + let tok = auth.tokenFor(ctx) + let permissions + if (tok && auth.getType(tok) != "User") + permissions = auth.getPermissions(tok) + + if ( + ( + ctx.get("account") == ctx.get("target") // if the current target is the user account + && (permissions && !permissions.includes("manage_account")) // if permissions does not include manage_account + ) + ) + return ServeError(ctx, 403, "you cannot manage this user") return next() } @@ -46,6 +85,16 @@ export const requiresAccount: RequestHandler = function (ctx, next) { return next() } +/** + * @description Middleware which blocks requests which do not have ctx.get("target") set + */ +export const requiresTarget: RequestHandler = function (ctx, next) { + if (!ctx.get("target")) { + return ServeError(ctx, 404, "no target account") + } + return next() +} + /** * @description Middleware which blocks requests that have ctx.get("account").admin set to a falsy value */ @@ -135,10 +184,25 @@ export function scheme(scheme: z.ZodTypeAny, transformer: (ctx: Context) => Prom // Not really middleware but a utility -export const login = (ctx: Context, account: string) => - setCookie(ctx, "auth", auth.create(account, 3 * 24 * 60 * 60 * 1000), { +export const login = (ctx: Context, account: string) => { + let token = auth.create(account, 3 * 24 * 60 * 60 * 1000) + setCookie(ctx, "auth", token, { path: "/", sameSite: "Strict", secure: true, httpOnly: true - }) \ No newline at end of file + }) + return token +} + +export const verifyPoi = (user: string, poi?: string, wantsMfaPoi: boolean = false) => { + if (!poi) return false + + let poiCode = codes.identityProof.byId.get(poi) + + if (!poiCode || poiCode.for !== user || poiCode.data == wantsMfaPoi) + return false + + poiCode.terminate() + return true +} \ No newline at end of file diff --git a/src/server/routes/api/v0/adminRoutes.ts b/src/server/routes/api/v0/adminRoutes.ts index d251b1f..1ffeaff 100644 --- a/src/server/routes/api/v0/adminRoutes.ts +++ b/src/server/routes/api/v0/adminRoutes.ts @@ -20,7 +20,7 @@ adminRoutes .use(getAccount) .use(requiresAccount) .use(requiresAdmin) - .use(requiresPermissions("admin")) + .use(requiresPermissions("manage_server")) export default function (files: Files) { adminRoutes.post("/reset", async (ctx) => { diff --git a/src/server/routes/api/v1/account/index.ts b/src/server/routes/api/v1/account/index.ts index bc16ce1..0ee25d4 100644 --- a/src/server/routes/api/v1/account/index.ts +++ b/src/server/routes/api/v1/account/index.ts @@ -9,6 +9,7 @@ import Files from "../../../../lib/files.js" import * as Accounts from "../../../../lib/accounts.js" import * as auth from "../../../../lib/auth.js" import { + accountMgmtRoute, assertAPI, getAccount, getTarget, @@ -18,6 +19,7 @@ import { requiresAccount, requiresPermissions, scheme, + verifyPoi, } from "../../../../lib/middleware.js" import ServeError from "../../../../lib/errors.js" import { CodeMgr, sendMail } from "../../../../lib/mail.js" @@ -36,7 +38,7 @@ const router = new Hono<{ type UserUpdateParameters = Partial< Omit & { password: string - currentPassword?: string + poi?: string } > type Message = [200 | 400 | 401 | 403 | 429 | 501, string] @@ -69,7 +71,9 @@ type SchemedValidator< T extends keyof Partial > = { validator: Validator, - schema: z.ZodTypeAny + schema: z.ZodTypeAny, + noAPIAccess?: boolean, + requireProofOfIdentity?: boolean } const validators: { @@ -83,13 +87,9 @@ const validators: { }, email: { schema: AccountSchemas.Account.shape.email.nullable(), + noAPIAccess: true, + requireProofOfIdentity: true, validator: (actor, target, params, ctx) => { - if ( - !params.currentPassword || // actor on purpose here to allow admins - (params.currentPassword && - Accounts.password.check(actor.id, params.currentPassword)) - ) - return [401, "current password incorrect"] if (!params.email) { if (target.email) { @@ -107,8 +107,7 @@ const validators: { // send verification email if ( - (CodeMgr.codes.verifyEmail.byUser.get(target.id)?.length || - 0) >= 2 + (CodeMgr.codes.verifyEmail.byUser.get(target.id)?.length || 0) >= 2 ) return [429, "you have too many active codes"] @@ -135,14 +134,9 @@ const validators: { }, password: { schema: AccountSchemas.StringPassword, + noAPIAccess: true, + requireProofOfIdentity: true, validator: (actor, target, params) => { - if ( - !params.currentPassword || // actor on purpose here to allow admins - (params.currentPassword && - Accounts.password.check(actor.id, params.currentPassword)) - ) - return [401, "current password incorrect"] - if (target.email) { sendMail( target.email, @@ -158,14 +152,9 @@ const validators: { }, username: { schema: AccountSchemas.Username, + noAPIAccess: true, + requireProofOfIdentity: true, validator: (actor, target, params) => { - if ( - !params.currentPassword || // actor on purpose here to allow admins - (params.currentPassword && - Accounts.password.check(actor.id, params.currentPassword)) - ) - return [401, "current password incorrect"] - if (Accounts.getFromUsername(params.username)) return [400, "account with this username already exists"] @@ -217,7 +206,11 @@ const validators: { } router.use(getAccount) -router.all("/:user", getTarget) +router.on( + ["GET","PATCH","DELETE"], + "/:user", + requiresAccount, getTarget, accountMgmtRoute +) function isMessage(object: any): object is Message { return ( @@ -228,18 +221,41 @@ function isMessage(object: any): object is Message { ) } +type Result = [ + keyof Accounts.Account, + Accounts.Account[keyof Accounts.Account], +] | Message + +const BaseUserUpdateScheme = z.object( + Object.fromEntries(Object.entries(validators).filter(e => !e[1].requireProofOfIdentity).map( + ([name, validator]) => [name, validator.schema.optional()] + )) +) + +const UserUpdateScheme = z.union([ + BaseUserUpdateScheme.extend({ + poi: z.undefined() + }).strict(), + BaseUserUpdateScheme.extend({ + poi: z.string().uuid(), + ...Object.fromEntries(Object.entries(validators).filter(e => e[1].requireProofOfIdentity).map( + ([name, validator]) => [name, validator.schema.optional()] + )) + }).strict() +]) + export default function (files: Files) { router.post("/", scheme(z.object({ username: AccountSchemas.Username, password: AccountSchemas.StringPassword })), async (ctx) => { const body = await ctx.req.json() - if (!Configuration.accounts.registrationEnabled) { - return ServeError(ctx, 403, "account registration disabled") - } + if (!ctx.get("account")?.admin) { + if (!Configuration.accounts.registrationEnabled) + return ServeError(ctx, 403, "account registration disabled") - if (auth.validate(getCookie(ctx, "auth")!)) { - return ServeError(ctx, 400, "you are already logged in") + if (ctx.get("account")) + return ServeError(ctx, 400, "you are already logged in") } if (Accounts.getFromUsername(body.username)) { @@ -252,8 +268,9 @@ export default function (files: Files) { return Accounts.create(body.username, body.password) .then((account) => { - login(ctx, account) - return ctx.text("logged in") + if (!ctx.get("account")) + login(ctx, account) + return ctx.text(account) }) .catch((e) => { console.error(e) @@ -263,39 +280,27 @@ export default function (files: Files) { router.patch( "/:user", - requiresAccount, - requiresPermissions("manage_account"), + scheme(UserUpdateScheme), async (ctx) => { - const body = (await ctx.req.json()) as UserUpdateParameters + const body = (await ctx.req.json()) as z.infer const actor = ctx.get("account")! const target = ctx.get("target")! - if (Array.isArray(body)) return ServeError(ctx, 400, "invalid body") + const tokenType = auth.getType(auth.tokenFor(ctx)!) - let results: ( - | [ - keyof Accounts.Account, - Accounts.Account[keyof Accounts.Account], - ] - | Message - )[] = ( + if (body.poi && !verifyPoi(target.id, body.poi)) + return ServeError(ctx, 403, "invalid proof of identity provided") + + let results: Result[] = ( Object.entries(body).filter( - (e) => e[0] !== "currentPassword" - ) as [ - keyof Accounts.Account, - UserUpdateParameters[keyof Accounts.Account], - ][] + (e) => e[0] !== "poi" + ) ).map(([x, v]) => { - if (!validators[x]) - return [ - 400, - `the ${x} parameter cannot be set or is not a valid parameter`, - ] as Message + let validator = validators[x as keyof typeof validators]! - let validator = validators[x]! - - let check = validator.schema.safeParse(v) - if (!check.success) - return [400, issuesToMessage(check.error.issues)] + if (target == actor && tokenType !== "User") { + if (validator.noAPIAccess) + return [400, "no API access to this route"] + } return [ x, @@ -322,20 +327,24 @@ export default function (files: Files) { } ) - router.delete("/:user", requiresAccount, noAPIAccess, async (ctx) => { - let acc = ctx.get("target") + router.delete("/:user", noAPIAccess, async (ctx) => { + let actor = ctx.get("account") + let target = ctx.get("target") - auth.AuthTokens.filter((e) => e.account == acc?.id).forEach((token) => { + if (actor == target && !verifyPoi(actor.id, ctx.req.query("poi"))) + return ServeError(ctx, 403, "no proof of identity provided") + + auth.AuthTokens.filter((e) => e.account == target?.id).forEach((token) => { auth.invalidate(token.token) }) - await Accounts.deleteAccount(acc.id) + await Accounts.deleteAccount(target.id) - if (acc.email) { + if (target.email) { await sendMail( - acc.email, + target.email, "Notice of account deletion", - `Your account, ${acc.username}, has been removed. Thank you for using monofile.` + `Your account, ${target.username}, has been removed. Thank you for using monofile.` ).catch() return ctx.text("OK") } @@ -343,7 +352,7 @@ export default function (files: Files) { return ctx.text("account deleted") }) - router.get("/:user", requiresAccount, async (ctx) => { + router.get("/:user", async (ctx) => { let acc = ctx.get("target") let sessionToken = auth.tokenFor(ctx)! diff --git a/src/server/routes/api/v1/account/prove.ts b/src/server/routes/api/v1/account/prove.ts new file mode 100644 index 0000000..05a1894 --- /dev/null +++ b/src/server/routes/api/v1/account/prove.ts @@ -0,0 +1,80 @@ +// Modules + +import { type Context, Hono } from "hono" +import { getCookie, setCookie } from "hono/cookie" + +// Libs + +import Files from "../../../../lib/files.js" +import * as Accounts from "../../../../lib/accounts.js" +import * as auth from "../../../../lib/auth.js" +import { + assertAPI, + getAccount, + getTarget, + issuesToMessage, + login, + noAPIAccess, + requiresAccount, + requiresPermissions, + requiresTarget, + scheme, +} from "../../../../lib/middleware.js" +import ServeError from "../../../../lib/errors.js" + +import Configuration from "../../../../lib/config.js" +import { AccountSchemas, AuthSchemas, FileSchemas } from "../../../../lib/schemas/index.js" +import { z } from "zod" +import { BlankInput } from "hono/types" +import * as CodeMgr from "../../../../lib/codes.js" + +const router = new Hono<{ + Variables: { + account?: Accounts.Account + target: Accounts.Account + parsedScheme: any + } +}>() + +router.use(getAccount, getTarget, requiresTarget, noAPIAccess) + +const ProofCreationSchema = z.object({ + password: z.string().optional(), + /*auth: AuthSchemas.2fa.any*/ // if we add 2fa... +}) + +export default function () { + + router.get("/", async (ctx) => { + return ctx.json(["none"]) // if we add 2fa in the future, return available 2fa methods + }) + + router.post("/", scheme( + ProofCreationSchema + ), async (ctx) => { + + let actor = ctx.get("account") + let target = ctx.get("target") + let body = ctx.get("parsedScheme") as z.infer + + if (true /*(!actor || !actor.2fa)*/) { + // if there is no actor, + // or if the actor doesn't have 2fa + // check their password first + + if (!Accounts.password.check(target.id, body.password||"")) + return ServeError(ctx, 401, `bad password`) + } + + // if actor does have 2fa in an else block here + + return ctx.text(new CodeMgr.Code( + "identityProof", + target.id, + Boolean(actor), // so that you can only log in with proofs created when logged out + 5 * 60 * 1000 + ).id) + }) + + return router +} diff --git a/src/server/routes/api/v1/definition.ts b/src/server/routes/api/v1/definition.ts index 8c6c220..cde2eb8 100644 --- a/src/server/routes/api/v1/definition.ts +++ b/src/server/routes/api/v1/definition.ts @@ -12,6 +12,10 @@ export default { "file": "account/access", "to": "/account/:user/access" }, + { + "file": "account/prove", + "to": "/account/:user/proveIdentity" + }, "session", { "file": "index", diff --git a/src/server/routes/api/v1/file/index.ts b/src/server/routes/api/v1/file/index.ts index 15a52f0..c5b7e7d 100644 --- a/src/server/routes/api/v1/file/index.ts +++ b/src/server/routes/api/v1/file/index.ts @@ -17,7 +17,7 @@ import { FileSchemas } from "../../../../lib/schemas/index.js" const router = new Hono<{ Variables: { account: Accounts.Account, - parsedSchema: any + parsedScheme: any }, Bindings: HttpBindings }>()