Browse Source

feat: Sessions, access control

tags/v0.1.0
Dale 1 year ago
parent
commit
92f763f772
17 changed files with 238 additions and 18 deletions
  1. +4
    -0
      .vscode/settings.json
  2. +5
    -1
      config/local.json
  3. +0
    -2
      knexfile.js
  4. +16
    -0
      migrations/20190630124000_session-table.js
  5. +1
    -1
      package.json
  6. +62
    -0
      src/server/api/v1/auth.ts
  7. +3
    -0
      src/server/api/v1/index.ts
  8. +11
    -0
      src/server/api/v1/interfaces/ISession.ts
  9. +9
    -0
      src/server/api/v1/schemas/auth/loginRequest.json
  10. +4
    -1
      src/server/api/v1/users.ts
  11. +36
    -0
      src/server/db/Sessions.ts
  12. +9
    -0
      src/server/db/Users.ts
  13. +21
    -10
      src/server/lib/http/handler/APIEndpoint.ts
  14. +12
    -0
      src/server/lib/http/interfaces.ts
  15. +44
    -0
      src/server/lib/http/middleware.ts
  16. +0
    -2
      src/server/lib/validation/errors.ts
  17. +1
    -1
      src/server/server.ts

+ 4
- 0
.vscode/settings.json View File

@@ -0,0 +1,4 @@
{
"workbench.tree.indent": 2,
"editor.tabSize": 2
}

+ 5
- 1
config/local.json View File

@@ -1,9 +1,13 @@
{
"auth": {
"sessionTTL": 0
},
"database": {
"client": "pg",
"connection": {
"database": "miracle",
"user": "deiru"
"user": "deiru",
"password": "test"
}
}
}

+ 0
- 2
knexfile.js View File

@@ -5,6 +5,4 @@ var args = minimist(process.argv.slice(2));
var configPath = args._[1] || process.cwd() + "/config/local.json";
var config = JSON.parse(fs.readFileSync(configPath, 'utf8'));

console.log(config.database);

module.exports = config.database;

+ 16
- 0
migrations/20190630124000_session-table.js View File

@@ -0,0 +1,16 @@
exports.up = function (knex) {
return knex.schema.createTableIfNotExists('sessions', function (t) {
t.uuid('id').primary().unique();
t.uuid('user_id');
t.foreign('user_id')
.references("id")
.inTable("users");
t.timestamps();
});
};


exports.down = function (knex) {
return knex.schema.dropTableIfExists('sesions');
};


+ 1
- 1
package.json View File

@@ -11,7 +11,7 @@
"run:server": "./node_modules/.bin/ts-node -r ./node_modules/tsconfig-paths/register ./src/server/server.ts",
"build:server": "rm -rf ./dist/* && ./node_modules/.bin/tsc",
"test:server": "NODE_PATH=./src ./node_modules/.bin/mocha -r ts-node/register -r tsconfig-paths/register test/server/**/*.spec.ts --configPath ./config/test.json",
"migration:add": "./node_modules/.bin/knex migrate:make --migrations-directory ./migrations -x ts",
"migration:add": "./node_modules/.bin/knex migrate:make --migrations-directory .\\migrations",
"migration:local": "./node_modules/.bin/knex migrate:latest",
"migration:test": "yarn migration:local ./config/test.json",
"migration": "./node_modules/.bin/knex migrate:latest --migrations-directory ./migrations"


+ 62
- 0
src/server/api/v1/auth.ts View File

@@ -0,0 +1,62 @@
import Users from 'server/db/Users';
import Sessions from 'server/db/Sessions'
import { acceptsSchema, authenticateUser } from 'server/lib/http/middleware';
import { Router, Request, Response } from 'express';

import * as loginSchema from 'server/api/v1/schemas/auth/loginRequest.json';
import * as createUserSchema from 'server/api/v1/schemas/user/createUser.json';
import ISession, { ISessionRaw } from './interfaces/ISession';
import { Session } from 'inspector';
import { sendData, sendDuplicate, sendUnauthorized, sendNoContent } from 'server/lib/http/response';
import { IUserCreation, IUserSafe } from './interfaces/IUser';
import { create } from 'domain';
import { checkPassword } from 'server/lib/crypto';
import { RequestWithSession } from 'server/lib/http/interfaces';

export function getRoutes(router: Router) {
router.post("/auth/login", acceptsSchema(loginSchema), login);
router.delete("/auth/login", authenticateUser, logout)
router.post("/auth/sign-up", acceptsSchema(createUserSchema), signUp);
}

function login(req: Request, res: Response) {
Users.getV1UserByUsername(req.body.username)
.then(user => {
if (user) {
const passwordsMatch = checkPassword(
req.body.password, user.passwordHash, user.passwordSalt
);
if (passwordsMatch) {
Sessions.createSession({ user_id: user.id })
.then(session => sendData(res)({ token: session.id }))
} else {
sendUnauthorized(res)(req.body)
}
} else {
sendUnauthorized(res)(req.body)
}
})
}

function logout(req: RequestWithSession, res: Response) {
if (req.session) {
Sessions.destroySession(req.session.id).then(() => sendNoContent(res))
}
}

function createSession(user_id: string): PromiseLike<ISession> {
return Sessions.createSession({ user_id });
}

function signUp(req: Request, res: Response) {
Users.checkIfUserExists(req.body.username, req.body.email)
.then((exists) => {
if (!exists) {
Users.createV1User(req.body)
.then(user => createSession(user.id))
.then(session => sendData(res)({ token: session.id }))
} else {
sendDuplicate(res)(req.body);
}
});
}

+ 3
- 0
src/server/api/v1/index.ts View File

@@ -2,6 +2,7 @@ import { Router, json } from 'express';

import { ApiV1CommonHeaders } from 'server/api/v1/middleware';
import Users from 'server/api/v1/users';
import { getRoutes } from './auth';

const v1Router = Router();

@@ -9,5 +10,7 @@ const v1Router = Router();
v1Router.use(ApiV1CommonHeaders);
v1Router.use(json());
v1Router.use('/users', Users.getRouter());
getRoutes(v1Router);


export default v1Router;

+ 11
- 0
src/server/api/v1/interfaces/ISession.ts View File

@@ -0,0 +1,11 @@
import IUser from 'server/api/v1/interfaces/IUser';

export default interface ISession {
id?: string;
user: IUser;
}

export interface ISessionRaw {
id?: string;
user_id: string;
}

+ 9
- 0
src/server/api/v1/schemas/auth/loginRequest.json View File

@@ -0,0 +1,9 @@
{
"type": "object",
"properties": {
"username": { "type": "string" },
"password": { "type": "string", "minLength": 8 }
},
"additionalProperties": false,
"required": [ "username", "password" ]
}

+ 4
- 1
src/server/api/v1/users.ts View File

@@ -1,4 +1,3 @@
import * as uuidv4 from 'uuid/v4';
import * as R from 'ramda';
import { Request, Response } from 'express';

@@ -13,6 +12,10 @@ import * as updateUserSchema from 'server/api/v1/schemas/user/updateUser.json'
import Users from 'server/db/Users';

class UsersEndpoint extends APIEndpoint {

authenticateUsers = true;
authorizeRole = [ 'user' ];

users: { [key: string]: any } = {};
getIdFromParams: (req: Request) => string = (
R.pathOr<string>(null, ['req', 'params', 'id'])


+ 36
- 0
src/server/db/Sessions.ts View File

@@ -0,0 +1,36 @@
import uuidv4 from 'uuid/v4';

import db from 'server/db';

// import IUser, { IUserCreation, IUserSafe } from 'server/api/v1/interfaces/IUser';
import ISession, { ISessionRaw } from 'server/api/v1/interfaces/ISession';
import { hashPassword } from 'server/lib/crypto';

class Sessions {
augmentSession(rawSession?: ISessionRaw): PromiseLike<ISession|null> {
if (rawSession) {
return db('users').where({ id: rawSession.user_id }).get(0)
.then((user) => ({ id: rawSession.id, user }))
} else {
return Promise.resolve(null)
}
}

getSession(id: string): PromiseLike<ISession|null> {
return db('sessions').where({ id }).get(0).then(this.augmentSession)
}

createSession(rawSession: ISessionRaw): PromiseLike<ISession> {
return db('sessions').insert({
...rawSession, id: uuidv4(),
}).returning("*").get(0).then(
(session: ISessionRaw) => this.augmentSession(session)
);
}

destroySession(id: string): PromiseLike<boolean> {
return db('sessions').where({ id }).delete();
}
}

export default new Sessions;

+ 9
- 0
src/server/db/Users.ts View File

@@ -1,3 +1,4 @@
import * as R from 'ramda';
import uuidv4 from 'uuid/v4';

import db from 'server/db';
@@ -21,6 +22,11 @@ class Users {
}
}

checkIfUserExists = (username: string, email: string): PromiseLike<boolean> => {
return db("users").where(function() {
return this.where({ username }).orWhere({ email });
}).count().then(count => count > 0)
}

getV1Users = (): PromiseLike<IUser[]> =>
db('users').select('*')
@@ -35,6 +41,9 @@ class Users {
getV1User = (id: string): PromiseLike<IUser> =>
db('users').select('*').where({ id }).get(0);

getV1UserByUsername = (username: string): PromiseLike<IUser> =>
db('users').select('*').where({ username }).get(0);

getV1UserByEmail = (email: string): PromiseLike<IUser> =>
db('users').select('*').where({ email }).get(0);



+ 21
- 10
src/server/lib/http/handler/APIEndpoint.ts View File

@@ -1,7 +1,7 @@
import { Response, Router, Request } from 'express';

import { sendParams, sendNotImplemented, sendNoContent } from 'server/lib/http/response';
import { acceptsSchema } from '../middleware';
import { acceptsSchema, authenticateUser, authorizeRoles } from '../middleware';

interface IDocumentationObject {
title?: string;
@@ -24,6 +24,9 @@ export default class APIEndpoint {
router: Router;
documentation?: IDocumentationObject;

authenticateUsers?: boolean;
authorizeRole?: string | string[];

getList?: Handler
getId?: Handler
post?: Handler
@@ -71,15 +74,23 @@ export default class APIEndpoint {

getRouter = (): Router => {
const handlers: Handlers = this.getHandlers();
this.router.get(`/`, handlers.getList);
if ((this.postSchema || this.schema) && this.post) this.router.post(`/`, acceptsSchema(this.postSchema || this.schema), handlers.post);
else this.router.post(`/`, handlers.post);
this.router.get(`/:id`, handlers.getId);
if ((this.putIdSchema || this.schema) && this.putId) this.router.put(`/:id`, acceptsSchema(this.putIdSchema || this.schema), handlers.putId);
else this.router.put(`/`, handlers.putId);
this.router.delete(`/:id`, handlers.deleteId);
this.router.options(`/`, handlers.options);
return this.router;
const endpointRouter = Router();
if (this.authenticateUsers) {
endpointRouter.use(authenticateUser);
}
if (this.authenticateUsers && this.authorizeRole) {
endpointRouter.use(authorizeRoles(this.authorizeRole));
}
endpointRouter.get(`/`, handlers.getList);
if ((this.postSchema || this.schema) && this.post) {
endpointRouter.post(`/`, acceptsSchema(this.postSchema || this.schema), handlers.post)
} else endpointRouter.post(`/`, handlers.post);
endpointRouter.get(`/:id`, handlers.getId);
if ((this.putIdSchema || this.schema) && this.putId) endpointRouter.put(`/:id`, acceptsSchema(this.putIdSchema || this.schema), handlers.putId);
else endpointRouter.put(`/`, handlers.putId);
endpointRouter.delete(`/:id`, handlers.deleteId);
endpointRouter.options(`/`, handlers.options);
return endpointRouter;
}

getRoutes = (): any => {


+ 12
- 0
src/server/lib/http/interfaces.ts View File

@@ -1,3 +1,7 @@
import { Request } from 'express';
import IUser from 'server/api/v1/interfaces/IUser';
import ISession from 'server/api/v1/interfaces/ISession';

export interface ErrorsObject {
[key: string]: string,
}
@@ -10,3 +14,11 @@ export interface ResponseObject<TData = any> {
schema: any;
message?: string;
}

export interface RequestWithUser extends Request {
user?: IUser;
}

export interface RequestWithSession extends Request {
session?: ISession;
}

+ 44
- 0
src/server/lib/http/middleware.ts View File

@@ -1,7 +1,13 @@
import * as R from 'ramda';
import uuidv4 from 'uuid/v4';
import { Request, Response } from 'express';
import AJV from 'ajv';

import { convertErrors } from 'server/lib/validation/errors';
import { request } from 'http';
import Sessions from 'server/db/Sessions';
import { RequestWithUser, RequestWithSession } from './interfaces';
import { sendUnauthenticated, sendUnauthorized } from './response';

export function JsonApiHeaders (req: Request, res: Response, next: () => void) {
res.setHeader('Content-Type', 'application/json');
@@ -32,3 +38,41 @@ export const acceptsSchema = (schema: object) => (req: Request, res: Response, n
});
}
};

export function authenticateUser(req: RequestWithSession, res: Response, next: any) {
if (!req.headers.authorization) {
sendUnauthenticated(res)({ token: '' });
return;
}
const token = R.last(req.headers.authorization.split(' '));
Sessions.getSession(token).then(
(session) => {
if (session) {
req.session = session;
next();
} else {
sendUnauthenticated(res)({ token });
}
},
() => sendUnauthenticated(res)({ token }),
)
}

export const authorizeRoles =
(roles: string | string[]) => (req: RequestWithSession, res: Response, next: any) => {
if (R.is(String, roles)) {
const hasAccess = req.session.user.role === roles;
if (hasAccess) {
next();
} else {
sendUnauthorized(res)({ token: req.session.id });
}
} else {
const hasAccess = R.includes(req.session.user.role, roles);
if (hasAccess) {
next();
} else {
sendUnauthorized(res)({ token: req.session.id });
}
}
}

+ 0
- 2
src/server/lib/validation/errors.ts View File

@@ -33,7 +33,6 @@ function commonErrorHandler (error: any) {
const fullPath = propertyName
? uniq([...parentPath, propertyName]).filter(identity).join('.')
: error.dataPath.split('.').filter(identity).join('.');
console.log(`Parent path: ${parentPath}\nPropertyName: ${propertyName}\nDataPath: ${error.dataPath}\n\n`)
return {
path: fullPath,
errorCode: keywordErrorMap[error.keyword] || error.keyword,
@@ -46,7 +45,6 @@ function defaultHandler(error: any) {
}

export function convertErrors(validationErrors: any): any {
console.log(validationErrors);
const errorsByPath = validationErrors.reduce((acc: any, error: any) => {
const errorHandler = commonErrorHandler;
const { path, ...convertedError } = errorHandler(error);


+ 1
- 1
src/server/server.ts View File

@@ -7,4 +7,4 @@ const app = Express();

app.use('/api', apiRouter);

app.listen(8080, () => console.log('Listening on 8080!'));
app.listen(8080, () => console.info('Listening on 8080!'));

Loading…
Cancel
Save