[server] add Account model
This commit is contained in:
parent
55b7945dc1
commit
10cf8b2630
5
.gitignore
vendored
5
.gitignore
vendored
@ -19,4 +19,9 @@ node_modules
|
||||
# Xmake
|
||||
.xmake
|
||||
|
||||
# Test
|
||||
*.tmp
|
||||
|
||||
# Config
|
||||
config.mjs
|
||||
kv-data
|
||||
|
@ -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[]` | 用户参与的比赛回放的全局唯一标识符列表 |
|
||||
|
16
server/apps/auth/handle.mjs
Normal file
16
server/apps/auth/handle.mjs
Normal file
@ -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);
|
||||
}
|
153
server/apps/auth/model.mjs
Normal file
153
server/apps/auth/model.mjs
Normal file
@ -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) },
|
||||
];
|
||||
};
|
35
server/apps/auth/password.mjs
Normal file
35
server/apps/auth/password.mjs
Normal file
@ -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;
|
||||
}
|
17
server/config.template.mjs
Normal file
17
server/config.template.mjs
Normal file
@ -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');
|
278
server/kv/index.mjs
Normal file
278
server/kv/index.mjs
Normal file
@ -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<ArrayBuffer|null>} - 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<boolean>} - 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<ArrayBuffer|null>} - 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<boolean>} - 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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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<boolean>} - 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');
|
||||
}
|
||||
};
|
@ -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 <szdytom@qq.com>",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user