Signed-off-by: szdytom <szdytom@qq.com>
This commit is contained in:
方而静 2024-03-04 18:52:31 +08:00
parent 936aa71b85
commit f2434a5953
Signed by: szTom
GPG Key ID: 072D999D60C6473C
13 changed files with 235 additions and 17 deletions

0
server/apps/README.md Normal file
View File

View File

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

View File

@ -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<?Account>} - 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}}

View File

View File

@ -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,
});
}
];

View File

@ -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';

16
server/index.mjs Normal file
View File

@ -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);

View File

@ -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<void>}
* @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<void>}
* @abstract
*/
async destroy() {
throw new VirtualMethodNotImplementedError();
}
/**
* Asynchronously checks if the current model instance can be created in the key-value storage.
* @returns {Promise<boolean>} - 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.
*

View File

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

View File

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

View File

@ -0,0 +1,2 @@
export { loginRequired } from './auth-required.mjs';
export { syllableRequired } from './data-required.mjs';

View File

@ -16,11 +16,15 @@
"author": "szdytom <szdytom@qq.com>",
"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": {

View File

@ -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';