diff --git a/.gitignore b/.gitignore index 7768f3c..3c34374 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,9 @@ node_modules # Xmake .xmake -*.tmp \ No newline at end of file +# Test +*.tmp + +# Config +config.mjs +kv-data diff --git a/docs/server.md b/docs/server.md index f6e7a3c..1f6339e 100644 --- a/docs/server.md +++ b/docs/server.md @@ -379,7 +379,7 @@ | `name` | `str` | 用户显示名 | | `passwd` | `u8[32]` | 用户密码的加盐 SHA256 摘要值 | | `totp_key` | `u8[20]` | 用户的时间性一次性口令的秘钥 | -| `created_at` | `DateTime` | 账户创建时间 | +| `created_at` | `DateTime` | 账户创建时间 | | `rating` | `i32` | 用户的等级分 | | `preference` | `AccountPreference` | 用户的偏好设置 | | `replays` | `uuid[]` | 用户参与的比赛回放的全局唯一标识符列表 | diff --git a/server/apps/auth/handle.mjs b/server/apps/auth/handle.mjs new file mode 100644 index 0000000..3421f9b --- /dev/null +++ b/server/apps/auth/handle.mjs @@ -0,0 +1,16 @@ + +/** + * Checks if the handle is valid. + * @param {string} handle - The handle to be validated. + * @returns {boolean} - Returns true if the handle is valid, otherwise returns false. + */ +export function isValidHandle(handle) { + // Length must be between 3 and 32 characters + if (handle.length < 3 || handle.length > 32) { + return false; + } + + // Uses regular expression to validate if the handle consists of 26 letters (case-insensitive), underscores, and digits. + const regex = /^[a-zA-Z0-9_]+$/; + return regex.test(handle); +} diff --git a/server/apps/auth/model.mjs b/server/apps/auth/model.mjs new file mode 100644 index 0000000..df793ee --- /dev/null +++ b/server/apps/auth/model.mjs @@ -0,0 +1,153 @@ +import { BASIC_TYPES } from '@og/binary-struct'; +import { UUID } from '@og/uuid'; +import { Model } from '../../kv/index.mjs'; +import * as config from '../../config.mjs'; +import { hashPassword, isWeakPassword } from './password.mjs'; +import { timingSafeEqual } from 'node:crypto'; +import { InvalidArgumentError } from '@og/error-utils'; +import { isValidHandle } from './handle.mjs'; + +export const ACCOUNT_TYPE = { + banned: 0x00, + normal: 0x01, + admin: 0x02, + robot: 0x03, + temporary: 0x04, + superuser: 0xff, +}; + +export const ACCOUNT_UUID_NS = 'aff7791d-4b71-4187-9788-13fa0c7fb51e'; + +function calcAccountUUID(handle) { + return UUID.v5('account:' + handle, ACCOUNT_UUID_NS); +} + +/** + * A Model repersenting an user account. + * @class + */ +export class Account extends Model('Account', config.kv_instance) { + /** @type {UUID} */ + uid; + + /** @type {number} */ + type; + + /** @type {string} */ + handle; + + /** @type {string} */ + name; + + /** @type {Buffer} */ + passwd; + + /** @type {Buffer} */ + totp_key = Buffer.alloc(20, 0); + + /** @type {Date} */ + created_at = new Date(); + + /** @type {number} */ + rating = 0; + + /** @type {Map} */ + preference = new Map(); + + /** @type {UUID[]} */ + replays = []; + + /** + * Getter of the primary key of Account. + * @returns {string|null} The primary key value or null if not applicable. + */ + get pk() { + return this.uid?.toString(); + } + + /** + * Set a new handle for the account. + * @param {string} handle - new handle. + */ + setHandle(handle) { + if (!isValidHandle(handle)) { + throw InvalidArgumentError('handle', 'handle is unacceptable'); + } + this.handle = handle; + this.uid = calcAccountUUID(handle); + } + + /** + * Set a new password for the account. + * @param {string} passwd - new password. + */ + setPassword(passwd) { + if (isWeakPassword(passwd)) { + throw InvalidArgumentError('passwd', 'password is too weak.'); + } + this.passwd = hashPassword(passwd); + } + + /** + * Clears the password of the account. + */ + clearPassword() { + this.type = ACCOUNT_TYPE.temporary; + this.passwd = hashPassword(''); + } + + /** + * Checks if the given password is correct. + * @param {string} passwd - password. + * @returns {boolean} - true if the given value is the password of the account. + */ + checkPassword(input) { + const ihash = hashPassword(input); + return timingSafeEqual(ihash, this.passwd); + } + + /** + * Creates an new account. + * @param {{handle: string, name: string, type: number, plaintext_password: string | null}} + * @returns {Account} Created account. + */ + static create({ handle, name, type, plaintext_password }) { + if (handle == null) { + throw InvalidArgumentError('handle', 'a handle is required'); + } + + name = name?.toString() ?? handle; + + if (name.length < 3 || name.length > 32) { + throw InvalidArgumentError('name', 'name is too long or too short'); + } + + if (type != null && Object.values(ACCOUNT_TYPE).includes(type)) { + throw InvalidArgumentError('type', 'unknown type'); + } + + const res = new this(); + res.setHandle(handle); + res.name = name; + res.type = type ?? ACCOUNT_TYPE.normal; + if (plaintext_password != null) { + res.setPassword(plaintext_password); + } else { + res.clearPassword(); + } + return res; + } + + static typedef = [ + { field: 'uid', type: BASIC_TYPES.uuid }, + { field: 'type', type: BASIC_TYPES.u8 }, + { field: 'handle', type: BASIC_TYPES.str }, + { field: 'name', type: BASIC_TYPES.str }, + { field: 'passwd', type: BASIC_TYPES.raw(32) }, + { field: 'totp_key', type: BASIC_TYPES.raw(20) }, + { field: 'created_at', type: BASIC_TYPES.DateTime }, + { field: 'rating', type: BASIC_TYPES.i32 }, + { field: 'preference', type: BASIC_TYPES.StringMap }, + { field: 'replays', type: BASIC_TYPES.array(BASIC_TYPES.uuid) }, + ]; +}; diff --git a/server/apps/auth/password.mjs b/server/apps/auth/password.mjs new file mode 100644 index 0000000..a2dd31f --- /dev/null +++ b/server/apps/auth/password.mjs @@ -0,0 +1,35 @@ +import { createHash, timingSafeEqual } from 'node:crypto'; +import * as config from '../../config.mjs'; + +/** + * Calculate the SHA256 hash of a password string with a salt. + * @param {string} password - The password to hash. + * @returns {string} The SHA256 hash of the password with the salt. + */ +export function hashPassword(password) { + const hasher = createHash('sha256'); + hasher.update(config.secret); + hasher.update(password); + return hasher.digest(); +} + +/** + * Check if a password is too weak. + * @param {string} password - The password to check. + * @returns {boolean} true if the password is too weak. + */ +export function isWeakPassword(password) { + if (password.length > 8) { + return false; + } + + if (password.length < 6) { + return true; + } + + const weak_passwords = ['123456', '111111', '666666', '12345678', '88888888']; + if (weak_passwords.includes(password)) { + return true; + } + return false; +} diff --git a/server/config.template.mjs b/server/config.template.mjs new file mode 100644 index 0000000..431a8f1 --- /dev/null +++ b/server/config.template.mjs @@ -0,0 +1,17 @@ +import { FileSystemKeyValueStorage } from './kv/index.mjs'; + +// PRODUCTION: set this to false in production! +export const debug = true; + +// HTTP port to listen to +export const port = 3000; + +// PRODUCTION: change this to a random value. +// secret for jwt and hash salt. +// you can generate a new one with +// require('node:crypto').randomBytes(48).toString('base64') +export const secret = Buffer.from('m5GDNW92K/c+YdDTlai3lG0wwL8h63LcLD9XZOSz8LsqoyBFm7y0h0V/xNSECLwA', 'base64'); + +// PRODUCTION: change the folder to a suitable place +// key-value storage instance to use +export const kv_instance = new FileSystemKeyValueStorage('./kv-data'); diff --git a/server/kv/index.mjs b/server/kv/index.mjs new file mode 100644 index 0000000..8f4b315 --- /dev/null +++ b/server/kv/index.mjs @@ -0,0 +1,278 @@ +import { deserializeFromBinary, serializeToBinary } from '@og/binary-struct'; +import { VirtualMethodNotImplementedError } from '@og/error-utils'; +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 + * @classdesc Represents a key-value storage interface. + */ +export class BaseKeyValueStorage { + /** + * Sets a value for the given class and primary key. + * @param {string} cls - The class identifier. + * @param {string} pk - The primary key. + * @param {ArrayBuffer} val - The value to set. + * @abstruct + * @async + */ + async set(cls, pk, val) { + throw VirtualMethodNotImplementedError(); + } + + /** + * Retrieves the value associated with the given class and primary key. + * @param {string} cls - The class identifier. + * @param {string} pk - The primary key. + * @abstruct + * @async + * @returns {Promise} - The value associated with the class and primary key, or null if not found. + */ + async get(cls, pk) { + throw VirtualMethodNotImplementedError(); + } + + /** + * Checks whether a value exists for the given class and primary key. + * @param {string} cls - The class identifier. + * @param {string} pk - The primary key. + * @abstruct + * @async + * @returns {Promise} - True if the value exists, otherwise false. + */ + async has(cls, pk) { + throw VirtualMethodNotImplementedError(); + } + + /** + * Deletes the value associated with the given class and primary key. + * @param {string} cls - The class identifier. + * @param {string} pk - The primary key. + * @abstruct + * @async + */ + async destroy(cls, pk) { + throw VirtualMethodNotImplementedError(); + } + + /** + * Initializes or updates the class. + * @param {string} cls - The class identifier. + * @abstruct + * @async + */ + async touchClass(cls) { } +} + +/** + * Represents a key-value storage implementation using the file system. + * @class + * @extends {BaseKeyValueStorage} + */ +export class FileSystemKeyValueStorage extends BaseKeyValueStorage { + /** + * Creates an instance of FileSystemKeyValueStorage. + * @param {string} root - The root directory where key-value data will be stored. + */ + constructor(root) { + super(); + /** @private @type {string} */ + this.root = root; + } + + /** + * Sets a value for the given class and primary key. + * @param {string} cls - The class identifier. + * @param {string} pk - The primary key. + * @param {ArrayBuffer} val - The value to set. + * @abstruct + */ + async set(cls, pk, val) { + const filePath = this._getFilePath(cls, pk); + await fs.writeFile(filePath, Buffer.from(val)); + } + + /** + * Retrieves the value associated with the given class and primary key. + * @param {string} cls - The class identifier. + * @param {string} pk - The primary key. + * @abstruct + * @returns {Promise} - The value associated with the class and primary key, or null if not found. + */ + async get(cls, pk) { + const filepath = this._getFilePath(cls, pk); + try { + const data = await fs.readFile(filepath); + return data.buffer; + } catch (error) { + if (error.code === 'ENOENT') { // File not found + return null; + } else { + throw error; + } + } + } + + /** + * Checks whether a value exists for the given class and primary key. + * @param {string} cls - The class identifier. + * @param {string} pk - The primary key. + * @abstruct + * @returns {Promise} - True if the value exists, otherwise false. + */ + async has(cls, pk) { + const filepath = this._getFilePath(cls, pk); + try { + await fs.access(filepath); + return true; + } catch { + return false; + } + } + + /** + * Deletes the value associated with the given class and primary key. + * @param {string} cls - The class identifier. + * @param {string} pk - The primary key. + * @abstruct + */ + async destroy(cls, pk) { + const filepath = this._getFilePath(cls, pk); + await fs.unlink(filepath); + } + + /** + * Initializes or updates the class by creating a folder for it if it doesn't exist. + * @param {string} cls - The class identifier. + * @abstruct + */ + async touchClass(cls) { + const cls_dir = path.join(this.root, cls); + await fs.mkdir(cls_dir, { recursive: true }); + } + + /** + * Constructs the file path for a given class and primary key. + * @private + * @param {string} cls - The class identifier. + * @param {string} pk - The primary key. + * @returns {string} - The file path. + */ + _getFilePath(cls, pk) { + const classDir = path.join(this.root, cls); + return path.join(classDir, pk); + } +} + +/** + * A mixin function that creates a model class with basic CRUD operations and storage methods. + * + * @param {string} name - The name of the model. + * @param {BaseKeyValueStorage} kv - The key-value storage for data persistence. + * @returns {Function} - The generated model class. + */ +export function Model(name, kv) { + const cls = pluralize(name); + + /** + * @class + * @extends BaseModel + */ + return class extends BaseModel { + /** + * 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} + */ + async save() { + const pk = this.pk; + if (pk == null) { + throw PrimaryKeyNotSetError(); + } + + const buf = serializeToBinary(this, this.constructor); + await kv.set(cls, pk, buf); + } + + /** + * 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} + */ + async destroy() { + const pk = this.pk; + if (pk == null) { + throw PrimaryKeyNotSetError(); + } + + await kv.destroy(cls, pk); + } + + /** + * 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. + */ + async canCreate() { + const pk = this.pk; + if (pk == null) { + return false; + } + + return !(await kv.has(pk)); + } + + /** + * 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. + */ + static async load(pk) { + const buf = await kv.get(cls, pk); + if (buf == null) { + return null; + } + + return deserializeFromBinary(new DataView(buf), this); + } + + /** + * Initializes the key-value storage for the current class. + */ + static { + kv.touchClass(cls); + } + }; +} + +/** + * Represents an error that is thrown when the primary key of an object is not set. + * @class + * @extends {Error} + */ +class PrimaryKeyNotSetError extends Error { + /** + * Constructs a new PrimaryKeyNotSetError instance with a default error message. + * @constructor + */ + constructor() { + super('pk() returns null'); + } +}; diff --git a/server/package.json b/server/package.json index 1a6944a..142fd5f 100644 --- a/server/package.json +++ b/server/package.json @@ -2,10 +2,9 @@ "name": "opengenerals-server", "version": "0.1.0", "description": "Server of Open Generals.IO", - "main": "src/index.js", + "main": "index.mjs", "type": "module", - "scripts": { - }, + "scripts": {}, "repository": { "type": "git", "url": "https://git.gzezfisher.top/szTom/opengenerals" @@ -17,5 +16,14 @@ "author": "szdytom ", "license": "GPL-2.0-only", "dependencies": { + "@og/binary-struct": "file:../shared/bstruct", + "@og/error-utils": "file:../shared/error-utils", + "@og/uuid": "file:../shared/uuid", + "inflection": "^3.0.0", + "koa": "^2.15.0", + "koa-router": "^12.0.1" + }, + "devDependencies": { + "@types/koa__router": "^12.0.4" } } diff --git a/server/src/index.mjs b/server/src/index.mjs deleted file mode 100644 index e69de29..0000000