diff --git a/src/server/lib/auth.ts b/src/server/lib/auth.ts index 169a6ef..75a4fd8 100644 --- a/src/server/lib/auth.ts +++ b/src/server/lib/auth.ts @@ -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 diff --git a/src/server/lib/middleware.ts b/src/server/lib/middleware.ts index 2f0ed0c..9a1a542 100644 --- a/src/server/lib/middleware.ts +++ b/src/server/lib/middleware.ts @@ -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() } \ No newline at end of file diff --git a/src/server/routes/adminRoutes.ts b/src/server/routes/adminRoutes.ts index cf554a4..510eec2 100644 --- a/src/server/routes/adminRoutes.ts +++ b/src/server/routes/adminRoutes.ts @@ -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) { diff --git a/src/server/routes/authRoutes.ts b/src/server/routes/authRoutes.ts index 72b5863..a8de6ff 100644 --- a/src/server/routes/authRoutes.ts +++ b/src/server/routes/authRoutes.ts @@ -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() -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 }) }) diff --git a/src/server/routes/fileApiRoutes.ts b/src/server/routes/fileApiRoutes.ts index 8953a84..f64d141 100644 --- a/src/server/routes/fileApiRoutes.ts +++ b/src/server/routes/fileApiRoutes.ts @@ -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 diff --git a/src/server/routes/primaryApi.ts b/src/server/routes/primaryApi.ts index 0ff3ed7..33558d8 100644 --- a/src/server/routes/primaryApi.ts +++ b/src/server/routes/primaryApi.ts @@ -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