[server] complete basic account auth api
This commit is contained in:
parent
8f94811cb0
commit
f3ece825ac
@ -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.' };
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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;
|
@ -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();
|
||||
}
|
||||
}
|
||||
];
|
||||
|
@ -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);
|
||||
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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();
|
||||
};
|
||||
}
|
||||
|
22
server/middlewares/error-response.mjs
Normal file
22
server/middlewares/error-response.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
@ -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
3
server/routes.mjs
Normal file
@ -0,0 +1,3 @@
|
||||
import auth_routes from './apps/auth/routes.mjs';
|
||||
|
||||
export default [auth_routes];
|
@ -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;
|
||||
}
|
||||
|
@ -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.
|
||||
*/
|
||||
|
Loading…
x
Reference in New Issue
Block a user