identity proofs

This commit is contained in:
split / May 2024-05-22 23:53:04 -07:00
parent 8f13bf2fea
commit 9b68d7a705
7 changed files with 253 additions and 89 deletions

View file

@ -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<Intent, {codeGenerator?: () => string}>
export function isIntent(intent: string): intent is Intent { export function isIntent(intent: string): intent is Intent {
return intent in Intents return intent in Intents
} }
export let codes = Object.fromEntries( export let codes = Object.fromEntries(
Intents.map((e) => [ Object.keys(Intents).map((e) => [
e, e,
{ {
byId: new Map<string, Code>(), byId: new Map<string, Code>(),
@ -24,13 +31,10 @@ export let codes = Object.fromEntries(
// this is stupid whyd i write this // this is stupid whyd i write this
export class Code { export class Code {
readonly id: string = generateFileId(12) readonly id: string
readonly for: string readonly for: string
readonly intent: Intent readonly intent: Intent
readonly expiryClear: NodeJS.Timeout readonly expiryClear: NodeJS.Timeout
readonly data: any readonly data: any
constructor( constructor(
@ -39,25 +43,28 @@ export class Code {
data?: any, data?: any,
time: number = 15 * 60 * 1000 time: number = 15 * 60 * 1000
) { ) {
const { codeGenerator = () => generateFileId(12) } = Intents[intent]
this.for = forUser this.for = forUser
this.intent = intent this.intent = intent
this.expiryClear = setTimeout(this.terminate.bind(this), time) this.expiryClear = setTimeout(this.terminate.bind(this), time)
this.data = data this.data = data
this.id = codeGenerator()
codes[intent].byId.set(this.id, this) let byUser = codes[intent].byUser.get(forUser)
let byUser = codes[intent].byUser.get(this.for)
if (!byUser) { if (!byUser) {
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) byUser.push(this)
} }
terminate() { terminate() {
codes[this.intent].byId.delete(this.id) 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) bu.splice(bu.indexOf(this), 1)
clearTimeout(this.expiryClear) clearTimeout(this.expiryClear)
} }

View file

@ -4,6 +4,7 @@ import ServeError from "../lib/errors.js"
import * as auth from "./auth.js" import * as auth from "./auth.js"
import { setCookie } from "hono/cookie" import { setCookie } from "hono/cookie"
import { z } from "zod" import { z } from "zod"
import { codes } from "./codes.js"
/** /**
* @description Middleware which adds an account, if any, to ctx.get("account") * @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) => { 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.req.param("user") == "me"
? ctx.get("account") ? actor
: ctx.req.param("user").startsWith("@") : ctx.req.param("user").startsWith("@")
? Accounts.getFromUsername(ctx.req.param("user").slice(1)) ? Accounts.getFromUsername(ctx.req.param("user").slice(1))
: Accounts.getFromId(ctx.req.param("user")) : 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") 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() return next()
} }
@ -46,6 +85,16 @@ export const requiresAccount: RequestHandler = function (ctx, next) {
return 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 * @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 // Not really middleware but a utility
export const login = (ctx: Context, account: string) => export const login = (ctx: Context, account: string) => {
setCookie(ctx, "auth", auth.create(account, 3 * 24 * 60 * 60 * 1000), { let token = auth.create(account, 3 * 24 * 60 * 60 * 1000)
setCookie(ctx, "auth", token, {
path: "/", path: "/",
sameSite: "Strict", sameSite: "Strict",
secure: true, secure: true,
httpOnly: true httpOnly: true
}) })
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
}

View file

@ -20,7 +20,7 @@ adminRoutes
.use(getAccount) .use(getAccount)
.use(requiresAccount) .use(requiresAccount)
.use(requiresAdmin) .use(requiresAdmin)
.use(requiresPermissions("admin")) .use(requiresPermissions("manage_server"))
export default function (files: Files) { export default function (files: Files) {
adminRoutes.post("/reset", async (ctx) => { adminRoutes.post("/reset", async (ctx) => {

View file

@ -9,6 +9,7 @@ import Files from "../../../../lib/files.js"
import * as Accounts from "../../../../lib/accounts.js" import * as Accounts from "../../../../lib/accounts.js"
import * as auth from "../../../../lib/auth.js" import * as auth from "../../../../lib/auth.js"
import { import {
accountMgmtRoute,
assertAPI, assertAPI,
getAccount, getAccount,
getTarget, getTarget,
@ -18,6 +19,7 @@ import {
requiresAccount, requiresAccount,
requiresPermissions, requiresPermissions,
scheme, scheme,
verifyPoi,
} from "../../../../lib/middleware.js" } from "../../../../lib/middleware.js"
import ServeError from "../../../../lib/errors.js" import ServeError from "../../../../lib/errors.js"
import { CodeMgr, sendMail } from "../../../../lib/mail.js" import { CodeMgr, sendMail } from "../../../../lib/mail.js"
@ -36,7 +38,7 @@ const router = new Hono<{
type UserUpdateParameters = Partial< type UserUpdateParameters = Partial<
Omit<Accounts.Account, "password"> & { Omit<Accounts.Account, "password"> & {
password: string password: string
currentPassword?: string poi?: string
} }
> >
type Message = [200 | 400 | 401 | 403 | 429 | 501, string] type Message = [200 | 400 | 401 | 403 | 429 | 501, string]
@ -69,7 +71,9 @@ type SchemedValidator<
T extends keyof Partial<Accounts.Account> T extends keyof Partial<Accounts.Account>
> = { > = {
validator: Validator<T>, validator: Validator<T>,
schema: z.ZodTypeAny schema: z.ZodTypeAny,
noAPIAccess?: boolean,
requireProofOfIdentity?: boolean
} }
const validators: { const validators: {
@ -83,13 +87,9 @@ const validators: {
}, },
email: { email: {
schema: AccountSchemas.Account.shape.email.nullable(), schema: AccountSchemas.Account.shape.email.nullable(),
noAPIAccess: true,
requireProofOfIdentity: true,
validator: (actor, target, params, ctx) => { 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 (!params.email) {
if (target.email) { if (target.email) {
@ -107,8 +107,7 @@ const validators: {
// send verification email // send verification email
if ( if (
(CodeMgr.codes.verifyEmail.byUser.get(target.id)?.length || (CodeMgr.codes.verifyEmail.byUser.get(target.id)?.length || 0) >= 2
0) >= 2
) )
return [429, "you have too many active codes"] return [429, "you have too many active codes"]
@ -135,14 +134,9 @@ const validators: {
}, },
password: { password: {
schema: AccountSchemas.StringPassword, schema: AccountSchemas.StringPassword,
noAPIAccess: true,
requireProofOfIdentity: true,
validator: (actor, target, params) => { 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) { if (target.email) {
sendMail( sendMail(
target.email, target.email,
@ -158,14 +152,9 @@ const validators: {
}, },
username: { username: {
schema: AccountSchemas.Username, schema: AccountSchemas.Username,
noAPIAccess: true,
requireProofOfIdentity: true,
validator: (actor, target, params) => { 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)) if (Accounts.getFromUsername(params.username))
return [400, "account with this username already exists"] return [400, "account with this username already exists"]
@ -217,7 +206,11 @@ const validators: {
} }
router.use(getAccount) router.use(getAccount)
router.all("/:user", getTarget) router.on(
["GET","PATCH","DELETE"],
"/:user",
requiresAccount, getTarget, accountMgmtRoute
)
function isMessage(object: any): object is Message { function isMessage(object: any): object is Message {
return ( 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) { export default function (files: Files) {
router.post("/", scheme(z.object({ router.post("/", scheme(z.object({
username: AccountSchemas.Username, username: AccountSchemas.Username,
password: AccountSchemas.StringPassword password: AccountSchemas.StringPassword
})), async (ctx) => { })), async (ctx) => {
const body = await ctx.req.json() const body = await ctx.req.json()
if (!Configuration.accounts.registrationEnabled) { if (!ctx.get("account")?.admin) {
return ServeError(ctx, 403, "account registration disabled") if (!Configuration.accounts.registrationEnabled)
} return ServeError(ctx, 403, "account registration disabled")
if (auth.validate(getCookie(ctx, "auth")!)) { if (ctx.get("account"))
return ServeError(ctx, 400, "you are already logged in") return ServeError(ctx, 400, "you are already logged in")
} }
if (Accounts.getFromUsername(body.username)) { if (Accounts.getFromUsername(body.username)) {
@ -252,8 +268,9 @@ export default function (files: Files) {
return Accounts.create(body.username, body.password) return Accounts.create(body.username, body.password)
.then((account) => { .then((account) => {
login(ctx, account) if (!ctx.get("account"))
return ctx.text("logged in") login(ctx, account)
return ctx.text(account)
}) })
.catch((e) => { .catch((e) => {
console.error(e) console.error(e)
@ -263,39 +280,27 @@ export default function (files: Files) {
router.patch( router.patch(
"/:user", "/:user",
requiresAccount, scheme(UserUpdateScheme),
requiresPermissions("manage_account"),
async (ctx) => { async (ctx) => {
const body = (await ctx.req.json()) as UserUpdateParameters const body = (await ctx.req.json()) as z.infer<typeof UserUpdateScheme>
const actor = ctx.get("account")! const actor = ctx.get("account")!
const target = ctx.get("target")! const target = ctx.get("target")!
if (Array.isArray(body)) return ServeError(ctx, 400, "invalid body") const tokenType = auth.getType(auth.tokenFor(ctx)!)
let results: ( if (body.poi && !verifyPoi(target.id, body.poi))
| [ return ServeError(ctx, 403, "invalid proof of identity provided")
keyof Accounts.Account,
Accounts.Account[keyof Accounts.Account], let results: Result[] = (
]
| Message
)[] = (
Object.entries(body).filter( Object.entries(body).filter(
(e) => e[0] !== "currentPassword" (e) => e[0] !== "poi"
) as [ )
keyof Accounts.Account,
UserUpdateParameters[keyof Accounts.Account],
][]
).map(([x, v]) => { ).map(([x, v]) => {
if (!validators[x]) let validator = validators[x as keyof typeof validators]!
return [
400,
`the ${x} parameter cannot be set or is not a valid parameter`,
] as Message
let validator = validators[x]! if (target == actor && tokenType !== "User") {
if (validator.noAPIAccess)
let check = validator.schema.safeParse(v) return [400, "no API access to this route"]
if (!check.success) }
return [400, issuesToMessage(check.error.issues)]
return [ return [
x, x,
@ -322,20 +327,24 @@ export default function (files: Files) {
} }
) )
router.delete("/:user", requiresAccount, noAPIAccess, async (ctx) => { router.delete("/:user", noAPIAccess, async (ctx) => {
let acc = ctx.get("target") 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) auth.invalidate(token.token)
}) })
await Accounts.deleteAccount(acc.id) await Accounts.deleteAccount(target.id)
if (acc.email) { if (target.email) {
await sendMail( await sendMail(
acc.email, target.email,
"Notice of account deletion", "Notice of account deletion",
`Your account, <span username>${acc.username}</span>, has been removed. Thank you for using monofile.` `Your account, <span username>${target.username}</span>, has been removed. Thank you for using monofile.`
).catch() ).catch()
return ctx.text("OK") return ctx.text("OK")
} }
@ -343,7 +352,7 @@ export default function (files: Files) {
return ctx.text("account deleted") return ctx.text("account deleted")
}) })
router.get("/:user", requiresAccount, async (ctx) => { router.get("/:user", async (ctx) => {
let acc = ctx.get("target") let acc = ctx.get("target")
let sessionToken = auth.tokenFor(ctx)! let sessionToken = auth.tokenFor(ctx)!

View file

@ -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<typeof ProofCreationSchema>
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
}

View file

@ -12,6 +12,10 @@ export default {
"file": "account/access", "file": "account/access",
"to": "/account/:user/access" "to": "/account/:user/access"
}, },
{
"file": "account/prove",
"to": "/account/:user/proveIdentity"
},
"session", "session",
{ {
"file": "index", "file": "index",

View file

@ -17,7 +17,7 @@ import { FileSchemas } from "../../../../lib/schemas/index.js"
const router = new Hono<{ const router = new Hono<{
Variables: { Variables: {
account: Accounts.Account, account: Accounts.Account,
parsedSchema: any parsedScheme: any
}, },
Bindings: HttpBindings Bindings: HttpBindings
}>() }>()