identity proofs

This commit is contained in:
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 {
return intent in Intents
}
export let codes = Object.fromEntries(
Intents.map((e) => [
Object.keys(Intents).map((e) => [
e,
{
byId: new Map<string, Code>(),
@ -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)
}

View file

@ -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
})
})
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(requiresAccount)
.use(requiresAdmin)
.use(requiresPermissions("admin"))
.use(requiresPermissions("manage_server"))
export default function (files: Files) {
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 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<Accounts.Account, "password"> & {
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<Accounts.Account>
> = {
validator: Validator<T>,
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<typeof UserUpdateScheme>
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, <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()
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)!

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",
"to": "/account/:user/access"
},
{
"file": "account/prove",
"to": "/account/:user/proveIdentity"
},
"session",
{
"file": "index",

View file

@ -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
}>()