Merge pull request #13 from nbitzz/token-permissions

Add permissions to tokens
This commit is contained in:
unlinkability 2023-10-03 17:01:49 +01:00 committed by GitHub
commit aa840e176f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 126 additions and 36 deletions

View file

@ -1,19 +1,46 @@
import crypto from "crypto"
import express from "express"
import { readFile, writeFile } from "fs/promises"
export let AuthTokens: AuthToken[] = []
export let AuthTokenTO:{[key:string]:NodeJS.Timeout} = {}
export const ValidTokenPermissions = [
"user", // permissions to /auth/me, with email docked
"email", // adds email back to /auth/me
"private", // allows app to read private files
"upload", // allows an app to upload under an account
"manage", // allows an app to manage an account's files
"customize", // allows an app to change customization settings
"admin" // only available for accounts with admin
// gives an app access to all admin tools
] as const
export type TokenType = "User" | "App"
export type TokenPermission = typeof ValidTokenPermissions[number]
export interface AuthToken {
account: string,
token: string,
expire: number
expire: number,
type?: TokenType, // if !type, assume User
tokenPermissions?: TokenPermission[] // default to user if type is App,
// give full permissions if type is User
}
export function create(id:string,expire:number=(24*60*60*1000)) {
export function create(
id:string,
expire:number=(24*60*60*1000),
type:TokenType="User",
tokenPermissions?:TokenPermission[]
) {
let token = {
account:id,
token:crypto.randomBytes(36).toString('hex'),
expire:Date.now()+expire
expire: expire ? Date.now()+expire : 0,
type,
tokenPermissions: type == "App" ? tokenPermissions || ["user"] : undefined
}
AuthTokens.push(token)
@ -24,11 +51,32 @@ export function create(id:string,expire:number=(24*60*60*1000)) {
return token.token
}
export function tokenFor(req: express.Request) {
return req.cookies.auth || (
req.header("authorization")?.startsWith("Bearer ")
? req.header("authorization")?.split(" ")[1]
: undefined
)
}
function getToken(token:string) {
return AuthTokens.find(e => e.token == token && (e.expire == 0 || Date.now() < e.expire))
}
export function validate(token:string) {
return AuthTokens.find(e => e.token == token && Date.now() < e.expire)?.account
return getToken(token)?.account
}
export function getType(token:string): TokenType | undefined {
return getToken(token)?.type
}
export function getPermissions(token:string): TokenPermission[] | undefined {
return getToken(token)?.tokenPermissions
}
export function tokenTimer(token:AuthToken) {
if (!token.expire) return // justincase
if (Date.now() >= token.expire) {
invalidate(token.token)
return

View file

@ -1,17 +1,14 @@
import * as Accounts from "./accounts";
import express, { type RequestHandler } from "express"
import ServeError from "../lib/errors";
import * as auth from "./auth";
export let getAccount: RequestHandler = function(req, res, next) {
res.locals.acc = Accounts.getFromToken(req.cookies.auth || (
req.header("authorization")?.startsWith("Bearer ")
? req.header("authorization")?.split(" ")[1]
: undefined
))
export const getAccount: RequestHandler = function(req, res, next) {
res.locals.acc = Accounts.getFromToken(auth.tokenFor(req))
next()
}
export let requiresAccount: RequestHandler = function(_req, res, next) {
export const requiresAccount: RequestHandler = function(_req, res, next) {
if (!res.locals.acc) {
ServeError(res, 401, "not logged in")
return
@ -19,10 +16,49 @@ export let requiresAccount: RequestHandler = function(_req, res, next) {
next()
}
export let requiresAdmin: RequestHandler = function(_req, res, next) {
export const requiresAdmin: RequestHandler = function(_req, res, next) {
if (!res.locals.acc.admin) {
ServeError(res, 403, "you are not an administrator")
return
}
next()
}
/**
* @description Blocks requests based on the permissions which a token has. Does not apply to routes being accessed with a token of type `User`
* @param tokenPermissions Permissions which your route requires.
* @returns Express middleware
*/
export const requiresPermissions = function(...tokenPermissions: auth.TokenPermission[]): RequestHandler {
return function(req, res, next) {
let token = auth.tokenFor(req)
let type = auth.getType(token)
if (type == "App") {
let permissions = auth.getPermissions(token)
if (!permissions) ServeError(res, 403, "insufficient permissions")
else {
for (let v of tokenPermissions) {
if (!permissions.includes(v as auth.TokenPermission)) {
ServeError(res,403,"insufficient permissions")
return
}
}
next()
}
} else next()
}
}
/**
* @description Blocks requests based on whether or not the token being used to access the route is of type `User`.
*/
export const noAPIAccess: RequestHandler = function(req, res, next) {
if (auth.getType(auth.tokenFor(req)) == "App") ServeError(res, 403, "apps are not allowed to access this endpoint")
else next()
}

View file

@ -5,7 +5,7 @@ import * as auth from "../lib/auth";
import bytes from "bytes"
import {writeFile} from "fs";
import { sendMail } from "../lib/mail";
import { getAccount, requiresAccount, requiresAdmin } from "../lib/middleware"
import { getAccount, requiresAccount, requiresAdmin, requiresPermissions } from "../lib/middleware"
import ServeError from "../lib/errors";
import Files from "../lib/files";
@ -19,6 +19,7 @@ adminRoutes
.use(getAccount)
.use(requiresAccount)
.use(requiresAdmin)
.use(requiresPermissions("admin"))
let files:Files
export function setFilesObj(newFiles:Files) {

View file

@ -3,7 +3,7 @@ import { Router } from "express";
import * as Accounts from "../lib/accounts";
import * as auth from "../lib/auth";
import { sendMail } from "../lib/mail";
import { getAccount, requiresAccount } from "../lib/middleware"
import { getAccount, noAPIAccess, requiresAccount, requiresPermissions } from "../lib/middleware"
import { accountRatelimit } from "../lib/ratelimit"
import ServeError from "../lib/errors";
@ -123,7 +123,7 @@ authRoutes.post("/logout", (req,res) => {
res.send("logged out")
})
authRoutes.post("/dfv", requiresAccount, parser, (req,res) => {
authRoutes.post("/dfv", requiresAccount, requiresPermissions("manage"), parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (['public','private','anonymous'].includes(req.body.defaultFileVisibility)) {
@ -136,7 +136,7 @@ authRoutes.post("/dfv", requiresAccount, parser, (req,res) => {
}
})
authRoutes.post("/customcss", requiresAccount, parser, (req,res) => {
authRoutes.post("/customcss", requiresAccount, requiresPermissions("customize"), parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.fileId != "string") req.body.fileId = undefined;
@ -158,7 +158,7 @@ authRoutes.post("/customcss", requiresAccount, parser, (req,res) => {
}
})
authRoutes.post("/embedcolor", requiresAccount, parser, (req,res) => {
authRoutes.post("/embedcolor", requiresAccount, requiresPermissions("customize"), parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.color != "string") req.body.color = undefined;
@ -181,7 +181,7 @@ authRoutes.post("/embedcolor", requiresAccount, parser, (req,res) => {
}
})
authRoutes.post("/embedsize", requiresAccount, parser, (req,res) => {
authRoutes.post("/embedsize", requiresAccount, requiresPermissions("customize"), parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.largeImage != "boolean") req.body.color = false;
@ -193,7 +193,7 @@ authRoutes.post("/embedsize", requiresAccount, parser, (req,res) => {
res.send(`custom embed image size saved`)
})
authRoutes.post("/delete_account", requiresAccount, parser, async (req,res) => {
authRoutes.post("/delete_account", requiresAccount, noAPIAccess, parser, async (req,res) => {
let acc = res.locals.acc as Accounts.Account
let accId = acc.id
@ -217,7 +217,7 @@ authRoutes.post("/delete_account", requiresAccount, parser, async (req,res) => {
} else cpl()
})
authRoutes.post("/change_username", requiresAccount, parser, (req,res) => {
authRoutes.post("/change_username", requiresAccount, noAPIAccess, parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.username != "string" || req.body.username.length < 3 || req.body.username.length > 20) {
@ -253,7 +253,7 @@ authRoutes.post("/change_username", requiresAccount, parser, (req,res) => {
let verificationCodes = new Map<string, {code: string, email: string, expiry: NodeJS.Timeout}>()
authRoutes.post("/request_email_change", requiresAccount, accountRatelimit({ requests: 4, per: 60*60*1000 }), parser, (req,res) => {
authRoutes.post("/request_email_change", requiresAccount, noAPIAccess, accountRatelimit({ requests: 4, per: 60*60*1000 }), parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
@ -292,7 +292,7 @@ authRoutes.post("/request_email_change", requiresAccount, accountRatelimit({ req
})
})
authRoutes.get("/confirm_email/:code", requiresAccount, (req,res) => {
authRoutes.get("/confirm_email/:code", requiresAccount, noAPIAccess, (req,res) => {
let acc = res.locals.acc as Accounts.Account
@ -314,7 +314,7 @@ authRoutes.get("/confirm_email/:code", requiresAccount, (req,res) => {
}
})
authRoutes.post("/remove_email", requiresAccount, (req,res) => {
authRoutes.post("/remove_email", requiresAccount, noAPIAccess, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (acc.email) {
@ -404,7 +404,7 @@ authRoutes.get("/emergency_login/:code", (req,res) => {
}
})
authRoutes.post("/change_password", requiresAccount, parser, (req,res) => {
authRoutes.post("/change_password", requiresAccount, noAPIAccess, parser, (req,res) => {
let acc = res.locals.acc as Accounts.Account
if (typeof req.body.password != "string" || req.body.password.length < 8) {
@ -429,7 +429,7 @@ authRoutes.post("/change_password", requiresAccount, parser, (req,res) => {
res.send("password changed - logged out all sessions")
})
authRoutes.post("/logout_sessions", requiresAccount, (req,res) => {
authRoutes.post("/logout_sessions", requiresAccount, noAPIAccess, (req,res) => {
let acc = res.locals.acc as Accounts.Account
let accId = acc.id
@ -441,15 +441,20 @@ authRoutes.post("/logout_sessions", requiresAccount, (req,res) => {
res.send("logged out all sessions")
})
authRoutes.get("/me", requiresAccount, (req,res) => {
authRoutes.get("/me", requiresAccount, requiresPermissions("user"), (req,res) => {
let acc = res.locals.acc as Accounts.Account
let sessionToken = auth.tokenFor(req)
let accId = acc.id
res.send({
...acc,
sessionCount: auth.AuthTokens.filter(e => e.account == accId && e.expire > Date.now()).length,
sessionExpires: auth.AuthTokens.find(e => e.token == req.cookies.auth)?.expire,
password: undefined
sessionCount: auth.AuthTokens.filter(e => e.type != "App" && e.account == accId && (e.expire > Date.now() || !e.expire)).length,
sessionExpires: auth.AuthTokens.find(e => e.token == sessionToken)?.expire,
password: undefined,
email:
auth.getType(sessionToken) == "User" || auth.getPermissions(sessionToken)?.includes("email")
? acc.email
: undefined
})
})

View file

@ -7,7 +7,7 @@ import {writeFile} from "fs";
import ServeError from "../lib/errors";
import Files from "../lib/files";
import { getAccount, requiresAccount } from "../lib/middleware";
import { getAccount, requiresAccount, requiresPermissions } from "../lib/middleware";
let parser = bodyParser.json({
type: ["text/plain","application/json"]
@ -24,7 +24,7 @@ let config = require(`${process.cwd()}/config.json`)
fileApiRoutes.use(getAccount);
fileApiRoutes.get("/list", requiresAccount, (req,res) => {
fileApiRoutes.get("/list", requiresAccount, requiresPermissions("user"), (req,res) => {
let acc = res.locals.acc as Accounts.Account
@ -44,7 +44,7 @@ fileApiRoutes.get("/list", requiresAccount, (req,res) => {
})
fileApiRoutes.post("/manage", parser, (req,res) => {
fileApiRoutes.post("/manage", parser, requiresPermissions("manage"), (req,res) => {
let acc = res.locals.acc as Accounts.Account

View file

@ -8,7 +8,7 @@ import multer, {memoryStorage} from "multer"
import ServeError from "../lib/errors";
import Files from "../lib/files";
import { getAccount } from "../lib/middleware";
import { getAccount, requiresPermissions } from "../lib/middleware";
let parser = bodyParser.json({
type: ["text/plain","application/json"]
@ -115,7 +115,7 @@ primaryApi.head(["/file/:fileId", "/cpt/:fileId/*", "/:fileId"], (req: express.R
// upload handlers
primaryApi.post("/upload",multerSetup.single('file'),async (req,res) => {
primaryApi.post("/upload", requiresPermissions("upload"), multerSetup.single('file'), async (req,res) => {
let acc = res.locals.acc as Accounts.Account
@ -149,7 +149,7 @@ primaryApi.post("/upload",multerSetup.single('file'),async (req,res) => {
}
})
primaryApi.post("/clone", bodyParser.json({type: ["text/plain","application/json"]}) ,(req,res) => {
primaryApi.post("/clone", requiresPermissions("upload"), bodyParser.json({type: ["text/plain","application/json"]}) ,(req,res) => {
let acc = res.locals.acc as Accounts.Account