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)
return ServeError(ctx, 403, "you cannot manage this user")
if (!acc) return ServeError(ctx, 404, "account does not exist")
ctx.set("target", acc) 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")
ctx.set("target", target)
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
}>() }>()