From a89c9d59cf09e14e043ed63dafac5d1a0da20367 Mon Sep 17 00:00:00 2001 From: KernelDeimos Date: Fri, 10 May 2024 18:28:57 -0400 Subject: [PATCH] Add UserProtectedEndpointsService --- packages/backend/src/CoreModule.js | 3 + packages/backend/src/api/APIError.js | 22 +++++ .../routers/user-protected/change-password.js | 7 ++ .../web/UserProtectedEndpointsService.js | 92 +++++++++++++++++++ packages/backend/src/util/expressutil.js | 21 +++++ 5 files changed, 145 insertions(+) create mode 100644 packages/backend/src/routers/user-protected/change-password.js create mode 100644 packages/backend/src/services/web/UserProtectedEndpointsService.js create mode 100644 packages/backend/src/util/expressutil.js diff --git a/packages/backend/src/CoreModule.js b/packages/backend/src/CoreModule.js index 4d1f84e51..b8f231126 100644 --- a/packages/backend/src/CoreModule.js +++ b/packages/backend/src/CoreModule.js @@ -204,6 +204,9 @@ const install = async ({ services, app }) => { const { OTPService } = require('./services/auth/OTPService'); services.registerService('otp', OTPService); + + const { UserProtectedEndpointsService } = require("./services/web/UserProtectedEndpointsService"); + services.registerService('__user-protected-endpoints', UserProtectedEndpointsService); } const install_legacy = async ({ services }) => { diff --git a/packages/backend/src/api/APIError.js b/packages/backend/src/api/APIError.js index b7591d979..0ec6ba969 100644 --- a/packages/backend/src/api/APIError.js +++ b/packages/backend/src/api/APIError.js @@ -340,6 +340,28 @@ module.exports = class APIError { message: '2FA is already enabled.', }, + // protected endpoints + 'too_many_requests': { + status: 429, + message: 'Too many requests.', + }, + 'user_tokens_only': { + status: 403, + message: 'This endpoint must be requested with a user session', + }, + 'temporary_accounts_not_allowed': { + status: 403, + message: 'Temporary accounts cannot perform this action', + }, + 'password_required': { + status: 400, + message: 'Password is required.', + }, + 'password_mismatch': { + status: 403, + message: 'Password does not match.', + }, + // Object Mapping 'field_not_allowed_for_create': { status: 400, diff --git a/packages/backend/src/routers/user-protected/change-password.js b/packages/backend/src/routers/user-protected/change-password.js new file mode 100644 index 000000000..9f6980d09 --- /dev/null +++ b/packages/backend/src/routers/user-protected/change-password.js @@ -0,0 +1,7 @@ +module.exports = { + route: '/change-password', + methods: ['POST'], + handler: async (req, res, next) => { + res.send('this is a test response'); + } +}; diff --git a/packages/backend/src/services/web/UserProtectedEndpointsService.js b/packages/backend/src/services/web/UserProtectedEndpointsService.js new file mode 100644 index 000000000..ae9acd059 --- /dev/null +++ b/packages/backend/src/services/web/UserProtectedEndpointsService.js @@ -0,0 +1,92 @@ +const { get_user } = require("../../helpers"); +const auth2 = require("../../middleware/auth2"); +const { Context } = require("../../util/context"); +const BaseService = require("../BaseService"); +const { UserActorType } = require("../auth/Actor"); +const { Endpoint } = require("../../util/expressutil"); +const APIError = require("../../api/APIError.js"); + +/** + * This service registers endpoints that are protected by password authentication, + * excluding login. These endpoints are typically for actions that affect + * security settings on the user's account. + */ +class UserProtectedEndpointsService extends BaseService { + static MODULES = { + express: require('express'), + }; + + ['__on_install.routes'] () { + const router = (() => { + const require = this.require; + const express = require('express'); + return express.Router(); + })() + + const { app } = this.services.get('web-server'); + app.use('/user-protected', router); + + // Apply edge (unauthenticated) rate-limiting + router.use((req, res, next) => { + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check(req.baseUrl + req.path) ) { + return APIError.create('too_many_requests').write(res); + } + next(); + }) + + // Require authenticated session + router.use(auth2); + + // Only allow user sessions, not API tokens for apps + router.use((req, res, next) => { + const actor = Context.get('actor'); + if ( ! (actor.type instanceof UserActorType) ) { + return APIError.create('user_tokens_only').write(res); + } + next(); + }); + + // Prioritize consistency for user object + router.use(async (req, res, next) => { + const user = await get_user({ id: req.user.id, force: true }); + req.user = user; + next(); + }); + + // Do not allow temporary users + router.use(async (req, res, next) => { + if ( req.user.password === null ) { + return APIError.create('temporary_account').write(res); + } + next(); + }); + + // Require password in request + router.use(async (req, res, next) => { + if ( ! req.body.password ) { + return (APIError.create('password_required')).write(res); + } + + const bcrypt = (() => { + const require = this.require; + return require('bcrypt'); + })(); + + const user = await get_user({ id: req.user.id, force: true }); + const isMatch = await bcrypt.compare(req.body.password, user.password); + if ( ! isMatch ) { + return APIError.create('password_mismatch').write(res); + } + next(); + }); + + Endpoint( + require('../../routers/user-protected/change-password.js') + ).attach(router); + } +} + +module.exports = { + UserProtectedEndpointsService +}; diff --git a/packages/backend/src/util/expressutil.js b/packages/backend/src/util/expressutil.js new file mode 100644 index 000000000..a78637023 --- /dev/null +++ b/packages/backend/src/util/expressutil.js @@ -0,0 +1,21 @@ +const eggspress = require("../api/eggspress"); + +const Endpoint = function Endpoint (spec) { + return { + attach (route) { + const eggspress_options = { + allowedMethods: spec.methods ?? ['GET'], + }; + const eggspress_router = eggspress( + spec.route, + eggspress_options, + spec.handler, + ); + route.use(eggspress_router); + } + }; +} + +module.exports = { + Endpoint, +};