From f2434a595385c13be3056e8fc8e564a9a8fdd0eb Mon Sep 17 00:00:00 2001 From: szdytom Date: Mon, 4 Mar 2024 18:52:31 +0800 Subject: [PATCH] ... Signed-off-by: szdytom --- server/apps/README.md | 0 server/apps/auth/middlewares.mjs | 32 +++++++++++++ server/apps/auth/model.mjs | 21 +++++++- server/apps/auth/routes.mjs | 0 server/apps/auth/views.mjs | 56 ++++++++++++++++++++++ server/config.template.mjs | 3 ++ server/index.mjs | 16 +++++++ server/kv/index.mjs | 72 +++++++++++++++++++++------- server/middlewares/auth-required.mjs | 15 ++++++ server/middlewares/data-required.mjs | 30 ++++++++++++ server/middlewares/index.mjs | 2 + server/package.json | 4 ++ shared/utility/index.mjs | 1 + 13 files changed, 235 insertions(+), 17 deletions(-) create mode 100644 server/apps/README.md create mode 100644 server/apps/auth/middlewares.mjs create mode 100644 server/apps/auth/routes.mjs create mode 100644 server/apps/auth/views.mjs create mode 100644 server/index.mjs create mode 100644 server/middlewares/auth-required.mjs create mode 100644 server/middlewares/data-required.mjs create mode 100644 server/middlewares/index.mjs diff --git a/server/apps/README.md b/server/apps/README.md new file mode 100644 index 0000000..e69de29 diff --git a/server/apps/auth/middlewares.mjs b/server/apps/auth/middlewares.mjs new file mode 100644 index 0000000..5309493 --- /dev/null +++ b/server/apps/auth/middlewares.mjs @@ -0,0 +1,32 @@ +import { ACCOUNT_TYPE, Account } from './model.mjs'; + +/** @typedef {import('koa').Context} Context */ +/** @typedef {import('koa').Next} Next */ + +/** + * Middleware function to load user account information if user is logged in and account data is not yet loaded. + * @param {Context} ctx - Koa context object. + * @param {Next} next - Next middleware function. + */ +export async function loadAccount(ctx, next) { + if (ctx.state.user != null && ctx.state.account == null) { + ctx.state.account = await Account.load(ctx.user.uid); + } + await next(); +} + +/** + * Middleware function to check if the user has admin privileges. + * @param {Context} ctx - Koa context object. + * @param {Next} next - Next middleware function. + */ +export async function adminRequired(ctx, next) { + /** @type {?Account} */ + const account = ctx.state.account; + + if (account != null && (account.type == ACCOUNT_TYPE.admin || account.type == ACCOUNT_TYPE.superuser)) { + await next(); + } else { + ctx.status = 403; // 403 (Forbidden) + } +} diff --git a/server/apps/auth/model.mjs b/server/apps/auth/model.mjs index df793ee..460ca37 100644 --- a/server/apps/auth/model.mjs +++ b/server/apps/auth/model.mjs @@ -1,6 +1,6 @@ import { BASIC_TYPES } from '@og/binary-struct'; import { UUID } from '@og/uuid'; -import { Model } from '../../kv/index.mjs'; +import { Model, BaseModel } from '../../kv/index.mjs'; import * as config from '../../config.mjs'; import { hashPassword, isWeakPassword } from './password.mjs'; import { timingSafeEqual } from 'node:crypto'; @@ -25,6 +25,7 @@ function calcAccountUUID(handle) { /** * A Model repersenting an user account. * @class + * @extends {BaseModel} */ export class Account extends Model('Account', config.kv_instance) { /** @type {UUID} */ @@ -106,6 +107,24 @@ export class Account extends Model('Account', config.kv_instance) { return timingSafeEqual(ihash, this.passwd); } + /** + * Checks if the account can be logged in. + * @returns {boolean} - true if the account can be logged in. + */ + canLogin() { + return this.type != ACCOUNT_TYPE.banned; + } + + /** + * Asynchronously loads a Account instance based on its handle. + * @param {string} handle - the handle of the account to load. + * @returns {Promise} - The loaded account instance or null if not found. + * @async + */ + static loadByHandle(handle) { + return this.load(calcAccountUUID(handle).toString()); + } + /** * Creates an new account. * @param {{handle: string, name: string, type: number, plaintext_password: string | null}} diff --git a/server/apps/auth/routes.mjs b/server/apps/auth/routes.mjs new file mode 100644 index 0000000..e69de29 diff --git a/server/apps/auth/views.mjs b/server/apps/auth/views.mjs new file mode 100644 index 0000000..83804b6 --- /dev/null +++ b/server/apps/auth/views.mjs @@ -0,0 +1,56 @@ +import { sign } from 'jsonwebtoken'; +import { syllableRequired } from '../../middlewares/index.mjs.mjs'; +import { ACCOUNT_TYPE, Account } from './model.mjs'; +import * as config from '../../config.mjs'; + +/** @typedef {import('koa').Context} Context */ +/** @typedef {import('koa').Next} Next */ + +export const login_view = [ + syllableRequired('handle', 'string'), + syllableRequired('passwd', 'string'), + + /** + * @param {Context} ctx + * @param {Next} next + */ + async function(ctx, next) { + /** @type {{handle: string, passwd: string}} */ + const {handle, passwd} = ctx.request.body; + const account = await Account.loadByHandle(handle); + if (account == null || !account.checkPassword(passwd)) { + ctx.status = 400; // Bad Request. + ctx.body = { error: 'Authentication failed: handle or password is incorrect.' }; + } else if (!account.canLogin()) { + ctx.status = 400; // Bad Request. + ctx.body = { error: 'Your account is banned or restricted.' }; + } else { + const token = sign({ + uid: account.uid.toString(), + handle: account.handle, + auth_step: 'done', + }, config, { expiresIn: config.jwt_expire }); + ctx.status = 200; + ctx.body = { uid: account.uid.toString(), token: token, auth_step: 'done' }; + } + } +]; + +export const register_view = [ + syllableRequired('handle', 'string'), + syllableRequired('name', 'string'), + syllableRequired('passwd', 'string'), + /** + * @param {Context} ctx + * @param {Next} next + */ + async function(ctx, next) { + /** @type {{handle: string, name: string, passwd: string}} */ + const {handle, name, passwd} = ctx.request.body; + Account.create({ + handle, name, + type: ACCOUNT_TYPE.normal, + plaintext_password: passwd, + }); + } +]; diff --git a/server/config.template.mjs b/server/config.template.mjs index 431a8f1..7943260 100644 --- a/server/config.template.mjs +++ b/server/config.template.mjs @@ -15,3 +15,6 @@ export const secret = Buffer.from('m5GDNW92K/c+YdDTlai3lG0wwL8h63LcLD9XZOSz8Lsqo // PRODUCTION: change the folder to a suitable place // key-value storage instance to use export const kv_instance = new FileSystemKeyValueStorage('./kv-data'); + +// Time for a JWT to expire. +export const jwt_expire = '48h'; diff --git a/server/index.mjs b/server/index.mjs new file mode 100644 index 0000000..e0c8464 --- /dev/null +++ b/server/index.mjs @@ -0,0 +1,16 @@ +import Koa from 'koa'; +import * as config from './config.mjs'; +import Router from 'koa-router'; +import cors from '@koa/cors'; +import jwt from 'koa-jwt'; +import bodyParser from 'koa-bodyparser'; + +const app = new Koa(); +const router = new Router(); + +app.use(cors()); +app.use(bodyParser()); +app.use(jwt({ secret: config.secret, passthrough: true })); +app.use(router.routes()); +app.use(router.allowedMethods()); +app.listen(config.port); diff --git a/server/kv/index.mjs b/server/kv/index.mjs index 8f4b315..f4c6893 100644 --- a/server/kv/index.mjs +++ b/server/kv/index.mjs @@ -4,22 +4,6 @@ import { pluralize } from 'inflection'; import fs from 'node:fs/promises'; import path from 'node:path'; -/** - * Abstract base class for all models. - * @abstract - * @class - */ -class BaseModel { - /** - * Gets the primary key of the model. - * @abstract - * @returns {string|null} The primary key value or null if not applicable. - */ - get pk() { - throw VirtualMethodNotImplementedError(); - } -} - /** * @abstract * @class @@ -182,6 +166,62 @@ export class FileSystemKeyValueStorage extends BaseKeyValueStorage { } } + +/** + * Abstract base class for all models. + * @abstract + * @class + */ +export class BaseModel { + /** + * Gets the primary key of the model. + * @abstract + * @returns {string|null} The primary key value or null if not applicable. + */ + get pk() { + throw VirtualMethodNotImplementedError(); + } + + /** + * Asynchronously saves the current model instance to the key-value storage. + * @throws {Error} - Throws an error if the primary key is not set. + * @returns {Promise} + * @abstract + */ + async save() { + throw new VirtualMethodNotImplementedError(); + } + + /** + * Asynchronously destroys the current model instance in the key-value storage. + * @throws {Error} - Throws an error if the primary key is not set. + * @returns {Promise} + * @abstract + */ + async destroy() { + throw new VirtualMethodNotImplementedError(); + } + + /** + * Asynchronously checks if the current model instance can be created in the key-value storage. + * @returns {Promise} - True if the model can be created, false otherwise. + * @abstract + */ + async canCreate() { + throw new VirtualMethodNotImplementedError(); + } + + /** + * Asynchronously loads a model instance from the key-value storage based on its primary key. + * @param {string} pk - The primary key of the model to load. + * @returns {Promise<*>} - The loaded model instance or null if not found. + * @abstract + */ + static async load(pk) { + throw new VirtualMethodNotImplementedError(); + } +} + /** * A mixin function that creates a model class with basic CRUD operations and storage methods. * diff --git a/server/middlewares/auth-required.mjs b/server/middlewares/auth-required.mjs new file mode 100644 index 0000000..b636871 --- /dev/null +++ b/server/middlewares/auth-required.mjs @@ -0,0 +1,15 @@ +/** @typedef {import('koa').Context} Context */ +/** @typedef {import('koa').Next} Next */ + +/** + * Middleware function to check if user is logged in. + * @param {Context} ctx - Koa context object. + * @param {Next} next - Next middleware function. + */ +export function loginRequired(ctx, next) { + if (ctx.state.user == null) { + ctx.status = 401; // 401 (Unauthorized) + } else { + return next(); + } +} diff --git a/server/middlewares/data-required.mjs b/server/middlewares/data-required.mjs new file mode 100644 index 0000000..b9c4446 --- /dev/null +++ b/server/middlewares/data-required.mjs @@ -0,0 +1,30 @@ +/** @typedef {import('koa').Context} Context */ +/** @typedef {import('koa').Next} Next */ +/** @typedef {import('koa').Middleware} Middleware */ + +/** + * Koa middleware generator for checking the presence and type of a field in the request body. + * + * @param {string} field_name - The name of the field to be checked. + * @param {string} field_type - The expected type of the field. + * @returns {Middleware} Koa middleware function. + */ +export function syllableRequired(field_name, field_type) { + /** + * @param {Context} ctx - Koa context object. + * @param {Next} next - Next middleware function. + */ + return async (ctx, next) => { + const fieldValue = ctx.request.body[field_name]; + + if (fieldValue === undefined || typeof fieldValue !== field_type) { + ctx.status = 400; // 400 Bad Request + ctx.body = { + error: `Field '${field_name}' is required and must be of type '${field_type}'.`, + }; + return; + } + + await next(); + }; +} diff --git a/server/middlewares/index.mjs b/server/middlewares/index.mjs new file mode 100644 index 0000000..51ea0c7 --- /dev/null +++ b/server/middlewares/index.mjs @@ -0,0 +1,2 @@ +export { loginRequired } from './auth-required.mjs'; +export { syllableRequired } from './data-required.mjs'; diff --git a/server/package.json b/server/package.json index 142fd5f..1b82293 100644 --- a/server/package.json +++ b/server/package.json @@ -16,11 +16,15 @@ "author": "szdytom ", "license": "GPL-2.0-only", "dependencies": { + "@koa/cors": "^5.0.0", "@og/binary-struct": "file:../shared/bstruct", "@og/error-utils": "file:../shared/error-utils", "@og/uuid": "file:../shared/uuid", "inflection": "^3.0.0", + "jsonwebtoken": "^9.0.2", "koa": "^2.15.0", + "koa-bodyparser": "^4.4.1", + "koa-jwt": "^4.0.4", "koa-router": "^12.0.1" }, "devDependencies": { diff --git a/shared/utility/index.mjs b/shared/utility/index.mjs index 95a1e27..89e1a47 100644 --- a/shared/utility/index.mjs +++ b/shared/utility/index.mjs @@ -2,3 +2,4 @@ export { areArrayBuffersEqual } from './buffer.mjs'; export { Queue, QueueEmptyError } from './queue.mjs'; export { AsyncLock } from './async-lock.mjs'; export { AsyncByteQueue } from './async-queue.mjs'; +export { applyDecorators } from './decorator.mjs';