diff --git a/server/apps/auth/middlewares.mjs b/server/apps/auth/middlewares.mjs index 5309493..0b2df6c 100644 --- a/server/apps/auth/middlewares.mjs +++ b/server/apps/auth/middlewares.mjs @@ -9,8 +9,8 @@ import { ACCOUNT_TYPE, Account } from './model.mjs'; * @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); + if (ctx.state.user != null && ctx.state.user.auth_step == 'done' && ctx.state.account == null) { + ctx.state.account = await Account.load(ctx.state.user.uid); } await next(); } @@ -28,5 +28,6 @@ export async function adminRequired(ctx, next) { await next(); } else { ctx.status = 403; // 403 (Forbidden) + ctx.body = { error: 'You must be an administrator to access this resource.' }; } } diff --git a/server/apps/auth/model.mjs b/server/apps/auth/model.mjs index 460ca37..b5ecd00 100644 --- a/server/apps/auth/model.mjs +++ b/server/apps/auth/model.mjs @@ -66,13 +66,25 @@ export class Account extends Model('Account', config.kv_instance) { return this.uid?.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, + replays: this.replays.map(r => r.toString()), + }; + } + /** * Set a new handle for the account. * @param {string} handle - new handle. */ setHandle(handle) { if (!isValidHandle(handle)) { - throw InvalidArgumentError('handle', 'handle is unacceptable'); + throw new InvalidArgumentError('handle', 'handle is unacceptable'); } this.handle = handle; this.uid = calcAccountUUID(handle); @@ -84,7 +96,7 @@ export class Account extends Model('Account', config.kv_instance) { */ setPassword(passwd) { if (isWeakPassword(passwd)) { - throw InvalidArgumentError('passwd', 'password is too weak.'); + throw new InvalidArgumentError('passwd', 'password is too weak'); } this.passwd = hashPassword(passwd); } @@ -132,17 +144,17 @@ export class Account extends Model('Account', config.kv_instance) { */ static create({ handle, name, type, plaintext_password }) { if (handle == null) { - throw InvalidArgumentError('handle', 'a handle is required'); + throw new 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'); + throw new InvalidArgumentError('name', 'name is too long or too short'); } - if (type != null && Object.values(ACCOUNT_TYPE).includes(type)) { - throw InvalidArgumentError('type', 'unknown type'); + if (type != null && !Object.values(ACCOUNT_TYPE).includes(type)) { + throw new InvalidArgumentError('type', 'unknown type'); } const res = new this(); diff --git a/server/apps/auth/routes.mjs b/server/apps/auth/routes.mjs index e69de29..7378cd3 100644 --- a/server/apps/auth/routes.mjs +++ b/server/apps/auth/routes.mjs @@ -0,0 +1,12 @@ +import { login_view, whoami_view, refresh_token_view, signup_view, account_info_view } from './views.mjs'; +import Router from 'koa-router'; + +const routes = new Router(); + +routes.post('/auth/login', ...login_view); +routes.post('/auth/signup', ...signup_view); +routes.post('/auth/whoami', ...whoami_view); +routes.post('/auth/refresh', ...refresh_token_view); +routes.get('/account/:uid', ...account_info_view); + +export default routes; diff --git a/server/apps/auth/views.mjs b/server/apps/auth/views.mjs index 83804b6..6884c89 100644 --- a/server/apps/auth/views.mjs +++ b/server/apps/auth/views.mjs @@ -1,6 +1,8 @@ -import { sign } from 'jsonwebtoken'; -import { syllableRequired } from '../../middlewares/index.mjs.mjs'; +import jwt from 'jsonwebtoken'; +import { syllableRequired, parameterRequired, loginRequired } from '../../middlewares/index.mjs'; +import { loadAccount } from './middlewares.mjs'; import { ACCOUNT_TYPE, Account } from './model.mjs'; +import { isValidUUID } from '@og/uuid'; import * as config from '../../config.mjs'; /** @typedef {import('koa').Context} Context */ @@ -25,18 +27,53 @@ export const login_view = [ ctx.status = 400; // Bad Request. ctx.body = { error: 'Your account is banned or restricted.' }; } else { - const token = sign({ + const token = jwt.sign({ uid: account.uid.toString(), handle: account.handle, auth_step: 'done', - }, config, { expiresIn: config.jwt_expire }); + }, config.secret, { expiresIn: config.jwt_expire }); ctx.status = 200; - ctx.body = { uid: account.uid.toString(), token: token, auth_step: 'done' }; + ctx.body = { uid: account.uid.toString(), token, auth_step: 'done' }; } } ]; -export const register_view = [ +export const whoami_view = [ + loginRequired, + loadAccount, + + /** + * @param {Context} ctx + * @param {Next} next + */ + async function(ctx, next) { + const account = ctx.state.account; + ctx.status = 200; + ctx.body = { handle: account.handle, name: account.name }; + } +]; + +export const refresh_token_view = [ + loginRequired, + + /** + * @param {Context} ctx + * @param {Next} next + */ + async function(ctx, next) { + /** @type {Account?} */ + const old_token = ctx.state.user; + const new_token = jwt.sign({ + uid: old_token.uid, + handle: old_token.handle, + auth_step: 'done', + }, config.secret, { expiresIn: config.jwt_expire }); + ctx.status = 200; + ctx.body = { uid: old_token.uid, token: new_token, auth_step: 'done'}; + } +]; + +export const signup_view = [ syllableRequired('handle', 'string'), syllableRequired('name', 'string'), syllableRequired('passwd', 'string'), @@ -47,10 +84,38 @@ export const register_view = [ async function(ctx, next) { /** @type {{handle: string, name: string, passwd: string}} */ const {handle, name, passwd} = ctx.request.body; - Account.create({ + let account = Account.create({ handle, name, type: ACCOUNT_TYPE.normal, plaintext_password: passwd, }); + + if (!await account.canCreate()) { + ctx.status = 400; + ctx.body = { error: 'Handle is taken or cannot be used.' }; + return; + } + + await account.save(); + ctx.status = 204; + } +]; + +export const account_info_view = [ + parameterRequired('uid', isValidUUID), + /** + * @param {Context} ctx + * @param {Next} next + */ + async function(ctx, next) { + /** @type {Account?} */ + let account = await Account.load(ctx.params.uid); + if (account == null) { + ctx.status = 404; // 404 Not Found + ctx.body = { error: 'requested user is not found' }; + } else { + ctx.status = 200; + ctx.body = account.asInfo(); + } } ]; diff --git a/server/index.mjs b/server/index.mjs index e0c8464..1051d17 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -1,16 +1,19 @@ 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'; +import routes from './routes.mjs'; +import { errorAsResponse } from './middlewares/index.mjs'; const app = new Koa(); -const router = new Router(); +app.use(errorAsResponse); app.use(cors()); app.use(bodyParser()); app.use(jwt({ secret: config.secret, passthrough: true })); -app.use(router.routes()); -app.use(router.allowedMethods()); +routes.forEach(router => { + 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 f4c6893..908aab7 100644 --- a/server/kv/index.mjs +++ b/server/kv/index.mjs @@ -19,7 +19,7 @@ export class BaseKeyValueStorage { * @async */ async set(cls, pk, val) { - throw VirtualMethodNotImplementedError(); + throw new VirtualMethodNotImplementedError(); } /** @@ -31,7 +31,7 @@ export class BaseKeyValueStorage { * @returns {Promise} - The value associated with the class and primary key, or null if not found. */ async get(cls, pk) { - throw VirtualMethodNotImplementedError(); + throw new VirtualMethodNotImplementedError(); } /** @@ -43,7 +43,7 @@ export class BaseKeyValueStorage { * @returns {Promise} - True if the value exists, otherwise false. */ async has(cls, pk) { - throw VirtualMethodNotImplementedError(); + throw new VirtualMethodNotImplementedError(); } /** @@ -54,7 +54,7 @@ export class BaseKeyValueStorage { * @async */ async destroy(cls, pk) { - throw VirtualMethodNotImplementedError(); + throw new VirtualMethodNotImplementedError(); } /** @@ -161,8 +161,8 @@ export class FileSystemKeyValueStorage extends BaseKeyValueStorage { * @returns {string} - The file path. */ _getFilePath(cls, pk) { - const classDir = path.join(this.root, cls); - return path.join(classDir, pk); + const cls_dir = path.join(this.root, cls); + return path.join(cls_dir, pk); } } @@ -179,7 +179,7 @@ export class BaseModel { * @returns {string|null} The primary key value or null if not applicable. */ get pk() { - throw VirtualMethodNotImplementedError(); + throw new VirtualMethodNotImplementedError(); } /** @@ -214,7 +214,7 @@ export class BaseModel { /** * 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. + * @returns {Promise<*>} The loaded model instance or null if not found. * @abstract */ static async load(pk) { @@ -245,7 +245,7 @@ export function Model(name, kv) { async save() { const pk = this.pk; if (pk == null) { - throw PrimaryKeyNotSetError(); + throw new PrimaryKeyNotSetError(); } const buf = serializeToBinary(this, this.constructor); @@ -260,7 +260,7 @@ export function Model(name, kv) { async destroy() { const pk = this.pk; if (pk == null) { - throw PrimaryKeyNotSetError(); + throw new PrimaryKeyNotSetError(); } await kv.destroy(cls, pk); @@ -276,7 +276,7 @@ export function Model(name, kv) { return false; } - return !(await kv.has(pk)); + return !(await kv.has(cls, pk)); } /** diff --git a/server/middlewares/auth-required.mjs b/server/middlewares/auth-required.mjs index b636871..3647c7a 100644 --- a/server/middlewares/auth-required.mjs +++ b/server/middlewares/auth-required.mjs @@ -7,8 +7,9 @@ * @param {Next} next - Next middleware function. */ export function loginRequired(ctx, next) { - if (ctx.state.user == null) { + if (ctx.state.user == null || ctx.state.user.auth_step != 'done') { ctx.status = 401; // 401 (Unauthorized) + ctx.body = { error: 'You are not identified or your authentication process is incomplete.' }; } else { return next(); } diff --git a/server/middlewares/data-required.mjs b/server/middlewares/data-required.mjs index b9c4446..bc28c8a 100644 --- a/server/middlewares/data-required.mjs +++ b/server/middlewares/data-required.mjs @@ -15,9 +15,9 @@ export function syllableRequired(field_name, field_type) { * @param {Next} next - Next middleware function. */ return async (ctx, next) => { - const fieldValue = ctx.request.body[field_name]; + const field_value = ctx.request.body[field_name]; - if (fieldValue === undefined || typeof fieldValue !== field_type) { + if (field_value === undefined || typeof field_value !== field_type) { ctx.status = 400; // 400 Bad Request ctx.body = { error: `Field '${field_name}' is required and must be of type '${field_type}'.`, @@ -28,3 +28,32 @@ export function syllableRequired(field_name, field_type) { await next(); }; } + +/** @typedef {function(string): boolean} ParameterPredicate */ + +/** + * Koa middleware generator for checking the presence of a parameter in the request URL. + * + * @param {string} param_name - The name of the parameter to be checked. + * @param {ParameterPredicate?} predicate - The expected type of the field. + * @returns {Middleware} Koa middleware function. + */ +export function parameterRequired(param_name, predicate) { + /** + * @param {Context} ctx - Koa context object. + * @param {Next} next - Next middleware function. + */ + return async (ctx, next) => { + const param_value = ctx.params[param_name]; + + if (param_value === undefined || (predicate != null && !predicate(param_value))) { + ctx.status = 400; // 400 Bad Request + ctx.body = { + error: `URL Parameter '${param_name}' is missing or invalid.`, + }; + return; + } + + await next(); + }; +} diff --git a/server/middlewares/error-response.mjs b/server/middlewares/error-response.mjs new file mode 100644 index 0000000..62e3b92 --- /dev/null +++ b/server/middlewares/error-response.mjs @@ -0,0 +1,22 @@ +import { InvalidArgumentError } from '@og/error-utils'; + +/** @typedef {import('koa').Context} Context */ +/** @typedef {import('koa').Next} Next */ + +/** + * @param {Context} ctx + * @param {Next} next + */ +export async function errorAsResponse(ctx, next) { + try { + await next(); + } catch (err) { + if (err instanceof InvalidArgumentError) { + ctx.status = 400; + ctx.body = { error: err.toString() }; + } else { + ctx.status = 500; + throw err; + } + } +}; diff --git a/server/middlewares/index.mjs b/server/middlewares/index.mjs index 51ea0c7..110ecb0 100644 --- a/server/middlewares/index.mjs +++ b/server/middlewares/index.mjs @@ -1,2 +1,3 @@ export { loginRequired } from './auth-required.mjs'; -export { syllableRequired } from './data-required.mjs'; +export { syllableRequired, parameterRequired } from './data-required.mjs'; +export { errorAsResponse } from './error-response.mjs'; diff --git a/server/routes.mjs b/server/routes.mjs new file mode 100644 index 0000000..3b66c1c --- /dev/null +++ b/server/routes.mjs @@ -0,0 +1,3 @@ +import auth_routes from './apps/auth/routes.mjs'; + +export default [auth_routes]; diff --git a/shared/bstruct/type-handler.mjs b/shared/bstruct/type-handler.mjs index cf0c2e4..544f679 100644 --- a/shared/bstruct/type-handler.mjs +++ b/shared/bstruct/type-handler.mjs @@ -653,7 +653,7 @@ export class RawBufferHandler extends BaseTypeHandler { */ serialize(view, offset, value) { for (let i = 0; i < this.n; i += 1) { - view.setUint8(offset + i, value.readUInt8(offset + i)); + view.setUint8(offset + i, value.readUInt8(i)); } return offset + this.n; } diff --git a/shared/uuid/index.mjs b/shared/uuid/index.mjs index 4294317..0d9d522 100644 --- a/shared/uuid/index.mjs +++ b/shared/uuid/index.mjs @@ -1,6 +1,10 @@ -import { v4 as uuidv4, v5 as uuidv5, stringify, parse } from 'uuid'; +import { v4 as uuidv4, v5 as uuidv5, stringify, parse, validate } from 'uuid'; import { Buffer } from 'buffer'; +export function isValidUUID(value) { + return validate(value); +} + /** * Represents a UUID (Universally Unique Identifier) object. */