[server] complete basic account auth api

This commit is contained in:
方而静 2024-03-05 22:34:14 +08:00
parent 8f94811cb0
commit f3ece825ac
Signed by: szTom
GPG Key ID: 072D999D60C6473C
13 changed files with 189 additions and 36 deletions

View File

@ -9,8 +9,8 @@ import { ACCOUNT_TYPE, Account } from './model.mjs';
* @param {Next} next - Next middleware function. * @param {Next} next - Next middleware function.
*/ */
export async function loadAccount(ctx, next) { export async function loadAccount(ctx, next) {
if (ctx.state.user != null && ctx.state.account == null) { if (ctx.state.user != null && ctx.state.user.auth_step == 'done' && ctx.state.account == null) {
ctx.state.account = await Account.load(ctx.user.uid); ctx.state.account = await Account.load(ctx.state.user.uid);
} }
await next(); await next();
} }
@ -28,5 +28,6 @@ export async function adminRequired(ctx, next) {
await next(); await next();
} else { } else {
ctx.status = 403; // 403 (Forbidden) ctx.status = 403; // 403 (Forbidden)
ctx.body = { error: 'You must be an administrator to access this resource.' };
} }
} }

View File

@ -66,13 +66,25 @@ export class Account extends Model('Account', config.kv_instance) {
return this.uid?.toString(); 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. * Set a new handle for the account.
* @param {string} handle - new handle. * @param {string} handle - new handle.
*/ */
setHandle(handle) { setHandle(handle) {
if (!isValidHandle(handle)) { if (!isValidHandle(handle)) {
throw InvalidArgumentError('handle', 'handle is unacceptable'); throw new InvalidArgumentError('handle', 'handle is unacceptable');
} }
this.handle = handle; this.handle = handle;
this.uid = calcAccountUUID(handle); this.uid = calcAccountUUID(handle);
@ -84,7 +96,7 @@ export class Account extends Model('Account', config.kv_instance) {
*/ */
setPassword(passwd) { setPassword(passwd) {
if (isWeakPassword(passwd)) { if (isWeakPassword(passwd)) {
throw InvalidArgumentError('passwd', 'password is too weak.'); throw new InvalidArgumentError('passwd', 'password is too weak');
} }
this.passwd = hashPassword(passwd); this.passwd = hashPassword(passwd);
} }
@ -132,17 +144,17 @@ export class Account extends Model('Account', config.kv_instance) {
*/ */
static create({ handle, name, type, plaintext_password }) { static create({ handle, name, type, plaintext_password }) {
if (handle == null) { if (handle == null) {
throw InvalidArgumentError('handle', 'a handle is required'); throw new InvalidArgumentError('handle', 'a handle is required');
} }
name = name?.toString() ?? handle; name = name?.toString() ?? handle;
if (name.length < 3 || name.length > 32) { 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)) { if (type != null && !Object.values(ACCOUNT_TYPE).includes(type)) {
throw InvalidArgumentError('type', 'unknown type'); throw new InvalidArgumentError('type', 'unknown type');
} }
const res = new this(); const res = new this();

View File

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

View File

@ -1,6 +1,8 @@
import { sign } from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { syllableRequired } from '../../middlewares/index.mjs.mjs'; import { syllableRequired, parameterRequired, loginRequired } from '../../middlewares/index.mjs';
import { loadAccount } from './middlewares.mjs';
import { ACCOUNT_TYPE, Account } from './model.mjs'; import { ACCOUNT_TYPE, Account } from './model.mjs';
import { isValidUUID } from '@og/uuid';
import * as config from '../../config.mjs'; import * as config from '../../config.mjs';
/** @typedef {import('koa').Context} Context */ /** @typedef {import('koa').Context} Context */
@ -25,18 +27,53 @@ export const login_view = [
ctx.status = 400; // Bad Request. ctx.status = 400; // Bad Request.
ctx.body = { error: 'Your account is banned or restricted.' }; ctx.body = { error: 'Your account is banned or restricted.' };
} else { } else {
const token = sign({ const token = jwt.sign({
uid: account.uid.toString(), uid: account.uid.toString(),
handle: account.handle, handle: account.handle,
auth_step: 'done', auth_step: 'done',
}, config, { expiresIn: config.jwt_expire }); }, config.secret, { expiresIn: config.jwt_expire });
ctx.status = 200; 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('handle', 'string'),
syllableRequired('name', 'string'), syllableRequired('name', 'string'),
syllableRequired('passwd', 'string'), syllableRequired('passwd', 'string'),
@ -47,10 +84,38 @@ export const register_view = [
async function(ctx, next) { async function(ctx, next) {
/** @type {{handle: string, name: string, passwd: string}} */ /** @type {{handle: string, name: string, passwd: string}} */
const {handle, name, passwd} = ctx.request.body; const {handle, name, passwd} = ctx.request.body;
Account.create({ let account = Account.create({
handle, name, handle, name,
type: ACCOUNT_TYPE.normal, type: ACCOUNT_TYPE.normal,
plaintext_password: passwd, 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();
}
} }
]; ];

View File

@ -1,16 +1,19 @@
import Koa from 'koa'; import Koa from 'koa';
import * as config from './config.mjs'; import * as config from './config.mjs';
import Router from 'koa-router';
import cors from '@koa/cors'; import cors from '@koa/cors';
import jwt from 'koa-jwt'; import jwt from 'koa-jwt';
import bodyParser from 'koa-bodyparser'; import bodyParser from 'koa-bodyparser';
import routes from './routes.mjs';
import { errorAsResponse } from './middlewares/index.mjs';
const app = new Koa(); const app = new Koa();
const router = new Router();
app.use(errorAsResponse);
app.use(cors()); app.use(cors());
app.use(bodyParser()); app.use(bodyParser());
app.use(jwt({ secret: config.secret, passthrough: true })); app.use(jwt({ secret: config.secret, passthrough: true }));
app.use(router.routes()); routes.forEach(router => {
app.use(router.allowedMethods()); app.use(router.routes());
app.use(router.allowedMethods());
});
app.listen(config.port); app.listen(config.port);

View File

@ -19,7 +19,7 @@ export class BaseKeyValueStorage {
* @async * @async
*/ */
async set(cls, pk, val) { async set(cls, pk, val) {
throw VirtualMethodNotImplementedError(); throw new VirtualMethodNotImplementedError();
} }
/** /**
@ -31,7 +31,7 @@ export class BaseKeyValueStorage {
* @returns {Promise<ArrayBuffer|null>} - The value associated with the class and primary key, or null if not found. * @returns {Promise<ArrayBuffer|null>} - The value associated with the class and primary key, or null if not found.
*/ */
async get(cls, pk) { async get(cls, pk) {
throw VirtualMethodNotImplementedError(); throw new VirtualMethodNotImplementedError();
} }
/** /**
@ -43,7 +43,7 @@ export class BaseKeyValueStorage {
* @returns {Promise<boolean>} - True if the value exists, otherwise false. * @returns {Promise<boolean>} - True if the value exists, otherwise false.
*/ */
async has(cls, pk) { async has(cls, pk) {
throw VirtualMethodNotImplementedError(); throw new VirtualMethodNotImplementedError();
} }
/** /**
@ -54,7 +54,7 @@ export class BaseKeyValueStorage {
* @async * @async
*/ */
async destroy(cls, pk) { async destroy(cls, pk) {
throw VirtualMethodNotImplementedError(); throw new VirtualMethodNotImplementedError();
} }
/** /**
@ -161,8 +161,8 @@ export class FileSystemKeyValueStorage extends BaseKeyValueStorage {
* @returns {string} - The file path. * @returns {string} - The file path.
*/ */
_getFilePath(cls, pk) { _getFilePath(cls, pk) {
const classDir = path.join(this.root, cls); const cls_dir = path.join(this.root, cls);
return path.join(classDir, pk); 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. * @returns {string|null} The primary key value or null if not applicable.
*/ */
get pk() { 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. * 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. * @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 * @abstract
*/ */
static async load(pk) { static async load(pk) {
@ -245,7 +245,7 @@ export function Model(name, kv) {
async save() { async save() {
const pk = this.pk; const pk = this.pk;
if (pk == null) { if (pk == null) {
throw PrimaryKeyNotSetError(); throw new PrimaryKeyNotSetError();
} }
const buf = serializeToBinary(this, this.constructor); const buf = serializeToBinary(this, this.constructor);
@ -260,7 +260,7 @@ export function Model(name, kv) {
async destroy() { async destroy() {
const pk = this.pk; const pk = this.pk;
if (pk == null) { if (pk == null) {
throw PrimaryKeyNotSetError(); throw new PrimaryKeyNotSetError();
} }
await kv.destroy(cls, pk); await kv.destroy(cls, pk);
@ -276,7 +276,7 @@ export function Model(name, kv) {
return false; return false;
} }
return !(await kv.has(pk)); return !(await kv.has(cls, pk));
} }
/** /**

View File

@ -7,8 +7,9 @@
* @param {Next} next - Next middleware function. * @param {Next} next - Next middleware function.
*/ */
export function loginRequired(ctx, next) { 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.status = 401; // 401 (Unauthorized)
ctx.body = { error: 'You are not identified or your authentication process is incomplete.' };
} else { } else {
return next(); return next();
} }

View File

@ -15,9 +15,9 @@ export function syllableRequired(field_name, field_type) {
* @param {Next} next - Next middleware function. * @param {Next} next - Next middleware function.
*/ */
return async (ctx, next) => { 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.status = 400; // 400 Bad Request
ctx.body = { ctx.body = {
error: `Field '${field_name}' is required and must be of type '${field_type}'.`, 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(); 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();
};
}

View File

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

View File

@ -1,2 +1,3 @@
export { loginRequired } from './auth-required.mjs'; 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';

3
server/routes.mjs Normal file
View File

@ -0,0 +1,3 @@
import auth_routes from './apps/auth/routes.mjs';
export default [auth_routes];

View File

@ -653,7 +653,7 @@ export class RawBufferHandler extends BaseTypeHandler {
*/ */
serialize(view, offset, value) { serialize(view, offset, value) {
for (let i = 0; i < this.n; i += 1) { 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; return offset + this.n;
} }

View File

@ -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'; import { Buffer } from 'buffer';
export function isValidUUID(value) {
return validate(value);
}
/** /**
* Represents a UUID (Universally Unique Identifier) object. * Represents a UUID (Universally Unique Identifier) object.
*/ */