import { UUID } from '@og/uuid'; import { DataTypes, Model } from 'sequelize'; 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 representing an user account. * @class * @property {UUID} uid * @property {number} type * @property {string} handle * @property {string} name * @property {Buffer} passwd * @property {Buffer?} totp_key * @property {Date} created_at * @property {number} rating * @property {Map} preference */ export class Account extends Model { /** * 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.findByPk(calcAccountUUID(handle).toString()); } asInfo() { return { uid: this.uid.toString(), type: this.type, handle: this.handle, name: this.name, created_at: Math.floor(this.created_at.getTime() / 1000), rating: this.rating, }; } /** * Set a new handle for the account. * @param {string} handle - new handle. */ setHandle(handle) { if (!isValidHandle(handle)) { throw new 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 new InvalidArgumentError('passwd', 'password is too weak'); } this.passwd = hashPassword(passwd); } /** * 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); } /** * 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; } /** * Creates an new account. * @param {{handle: string, name: string, type: number, plaintext_password: string | null}} * @returns {Account} Created account. */ static createInstance({ handle, name, type, plaintext_password }) { if (handle == null) { throw new InvalidArgumentError('handle', 'a handle is required'); } name = name?.toString() ?? handle; if (name.length < 3 || name.length > 32) { throw new InvalidArgumentError('name', 'name is too long or too short'); } if (!Object.values(ACCOUNT_TYPE).includes(type)) { throw new InvalidArgumentError('type', 'unknown type'); } if (typeof plaintext_password != 'string') { throw new InvalidArgumentError('passwd', 'incorrect type'); } const res = this.build(); res.setHandle(handle); res.name = name; res.type = type ?? ACCOUNT_TYPE.normal; res.setPassword(plaintext_password); return res; } }; Account.init({ uid: { type: DataTypes.UUID, allowNull: false, primaryKey: true, unique: true, get() { return new UUID(this.getDataValue('uid')); }, set(uuid) { this.setDataValue('uid', uuid.toString()); } }, type: { type: DataTypes.INTEGER, allowNull: false, defaultValue: ACCOUNT_TYPE.banned, validate: { isIn: [Object.values(ACCOUNT_TYPE)], }, }, handle: { type: DataTypes.STRING(32), allowNull: false, unique: true, }, name: { type: DataTypes.STRING, allowNull: false, }, passwd: { type: DataTypes.STRING(32, true), allowNull: false, }, totp_key: { type: DataTypes.STRING(20, true), allowNull: true, }, rating: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 1500, } }, { sequelize: config.db_instance, modelName: 'Account', timestamps: true, createdAt: 'created_at', updatedAt: false, });