[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.
*/
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.' };
}
}

View File

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

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 { 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();
}
}
];

View File

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

View File

@ -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<ArrayBuffer|null>} - 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<boolean>} - 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));
}
/**

View File

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

View File

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

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 { 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) {
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;
}

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