From 71d9490d0988c97b8b023a70e0b61c65f32ad3b9 Mon Sep 17 00:00:00 2001 From: Jan Tuomi Date: Wed, 5 Feb 2020 19:25:36 +0200 Subject: Implement permissions --- backend/migrations/20200205122810_permissions.js | 10 ++ .../migrations/20200205123553_users_permissions.js | 11 ++ backend/seeds/permissions.js | 10 ++ backend/src/authn.js | 139 ++++++++++++++++++++ backend/src/authz.js | 42 ++++++ backend/src/permissions.js | 5 + backend/src/routes.js | 141 +++------------------ docker-compose.yml | 1 + frontend/public/index.html | 5 +- frontend/src/Home.js | 11 +- frontend/src/Login.js | 17 ++- frontend/src/Register.js | 18 +-- frontend/src/UserList.js | 56 ++++++++ 13 files changed, 320 insertions(+), 146 deletions(-) create mode 100644 backend/migrations/20200205122810_permissions.js create mode 100644 backend/migrations/20200205123553_users_permissions.js create mode 100644 backend/seeds/permissions.js create mode 100644 backend/src/authn.js create mode 100644 backend/src/authz.js create mode 100644 backend/src/permissions.js create mode 100644 frontend/src/UserList.js diff --git a/backend/migrations/20200205122810_permissions.js b/backend/migrations/20200205122810_permissions.js new file mode 100644 index 0000000..b6c22eb --- /dev/null +++ b/backend/migrations/20200205122810_permissions.js @@ -0,0 +1,10 @@ +exports.up = function(knex) { + return knex.schema.createTable('permissions', function(table) { + table.increments(); + table.string('name').notNullable(); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTable('permissions'); +}; diff --git a/backend/migrations/20200205123553_users_permissions.js b/backend/migrations/20200205123553_users_permissions.js new file mode 100644 index 0000000..96656cb --- /dev/null +++ b/backend/migrations/20200205123553_users_permissions.js @@ -0,0 +1,11 @@ +exports.up = function(knex) { + return knex.schema.createTable('users_permissions', function(table) { + table.integer('user_id').unsigned().references('users.id').notNullable(); + table.integer('permission_id').unsigned().references('permissions.id').notNullable(); + table.primary([ 'user_id', 'permission_id' ]); + }); +}; + +exports.down = function(knex) { + return knex.schema.dropTable('users_permissions'); +}; diff --git a/backend/seeds/permissions.js b/backend/seeds/permissions.js new file mode 100644 index 0000000..485e700 --- /dev/null +++ b/backend/seeds/permissions.js @@ -0,0 +1,10 @@ +const PERMISSIONS = require('../src/permissions'); + +exports.seed = function(knex) { + // Deletes ALL existing entries + return knex('permissions').del().then(function() { + // Inserts seed entries + const rows = PERMISSIONS.map((p, idx) => ({ id: idx + 1, name: p })); + return knex('permissions').insert(rows); + }); +}; diff --git a/backend/src/authn.js b/backend/src/authn.js new file mode 100644 index 0000000..c6c4a2e --- /dev/null +++ b/backend/src/authn.js @@ -0,0 +1,139 @@ +const base64 = require('base-64'); +const sha512 = require('js-sha512'); +const shortid = require('shortid'); +const db = require('./db'); + +const buildSessionCookie = ({ username, sessionToken, isLogout }) => { + if (!isLogout) { + const tokenData = { + username, + sessionToken, + }; + const tokenDataStr = JSON.stringify(tokenData); + const encodedToken = base64.encode(tokenDataStr); + return `sessionCookie=${encodedToken}; Domain=localhost; HttpOnly; SameSite=strict;`; + } else { + return `sessionCookie=deleted; Domain=localhost; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=strict;`; + } +}; + +const authMiddleware = async (req, res, next) => { + const cookies = req.cookies; + const encodedCookie = cookies.sessionCookie; + + if (!encodedCookie) { + res.status(403); + return res.json({ + message: 'Log in first!', + }); + } + const sessionCookieStr = base64.decode(encodedCookie); + const sessionCookie = JSON.parse(sessionCookieStr); + + const { username, sessionToken } = sessionCookie; + + const user = await db('users').where({ username }).first(); + if (!user) { + res.status(400); + res.set('Set-Cookie', buildSessionCookie({ isLogout: true })); + return res.json({ + message: 'Invalid session token.', + sessionCookie, + }); + } + + const salt = user.salt; + + const sessionTokenHash = sha512(salt + sessionToken); + const token = await db('tokens').where({ type: 'session', value: sessionTokenHash }); + if (!token) { + res.status(400); + res.set('Set-Cookie', buildSessionCookie({ isLogout: true })); + return res.json({ + message: 'Invalid session token.', + sessionCookie, + }); + } + + req.sessionTokenHash = sessionTokenHash; + req.user = user; + + next(); +}; + +const registerRoute = async (req, res) => { + const { username, password } = req.body; + if (!username || !password) { + res.status(400); + return res.json({ + error: 'Username or password missing.', + }); + } + + const existingUser = await db('users').where({ username }).first(); + if (existingUser) { + res.status(400); + return res.json({ + error: 'Username already taken.', + }); + } + + const salt = shortid.generate(); + const pwHash = sha512(salt + password); + + const sessionToken = shortid.generate(); + const sessionTokenHash = sha512(salt + sessionToken); + + const user = (await db('users').insert({ username, password: pwHash, salt }).returning('*'))[0]; + await db('tokens').insert({ user_id: user.id, type: 'session', value: sessionTokenHash }); + + res.set('Set-Cookie', buildSessionCookie({ username, sessionToken, remember: false, isLogout: false })); + return res.json({ + username, + }); +}; + +const loginRoute = async (req, res) => { + const { username, password, remember } = req.body; + if (!username || !password) { + res.status(400); + return res.json({ + error: 'Username or password missing.', + }); + } + + const user = await db('users').where({ username }).first(); + + if (user) { + const sessionToken = shortid.generate(); + const sessionTokenHash = sha512(user.salt + sessionToken); + + await db('tokens').insert({ user_id: user.id, type: 'session', value: sessionTokenHash }); + + res.set('Set-Cookie', buildSessionCookie({ username, sessionToken, remember, isLogout: false })); + + return res.json({ + username, + }); + } + + res.status(403); + res.json({ + error: 'Wrong username or password', + }); +}; + +const logoutRoute = async (req, res) => { + await db('tokens').where({ type: 'session', user_id: req.user.id, value: req.sessionTokenHash }).delete(); + res.status(204); + res.set('Set-Cookie', buildSessionCookie({ isLogout: true })); + res.send(); +}; + +module.exports = { + buildSessionCookie, + authMiddleware, + registerRoute, + loginRoute, + logoutRoute, +}; diff --git a/backend/src/authz.js b/backend/src/authz.js new file mode 100644 index 0000000..94dfa85 --- /dev/null +++ b/backend/src/authz.js @@ -0,0 +1,42 @@ +const db = require('./db'); + +const userHasPermission = async (user, permission_name) => { + const permission = await db('permissions').where({ name: permission_name }).first(); + const userPermission = await db('users_permissions') + .where({ user_id: user.id, permission_id: permission.id }) + .first(); + + return !!userPermission; +}; + +const userHasPermissions = async (user, permission_names) => { + for (permission_name of permission_names) { + const hasPermission = await userHasPermission(user, permission_name); + if (!hasPermission) { + return false; + } + } + return true; +}; + +const permissionMiddleware = permission_name => async (req, res, next) => { + const user = req.user; + const hasPermission = Array.isArray(permission_name) + ? await userHasPermissions(user, permission_name) + : await userHasPermission(user, permission_name); + + if (hasPermission) { + await next(); + } else { + res.status(401); + res.json({ + error: 'Required permission missing.', + permission_name, + }); + } +}; + +module.exports = { + userHasPermission, + permissionMiddleware, +}; diff --git a/backend/src/permissions.js b/backend/src/permissions.js new file mode 100644 index 0000000..63cbb13 --- /dev/null +++ b/backend/src/permissions.js @@ -0,0 +1,5 @@ +const PERMISSIONS = { + READ_USERS: 'READ_USERS', +}; + +module.exports = PERMISSIONS; diff --git a/backend/src/routes.js b/backend/src/routes.js index b0f3660..9d6775e 100644 --- a/backend/src/routes.js +++ b/backend/src/routes.js @@ -1,69 +1,15 @@ const express = require('express'); -const sha512 = require('js-sha512'); -const shortid = require('shortid'); -const base64 = require('base-64'); const db = require('./db'); +const { permissionMiddleware } = require('./authz'); +const PERMISSIONS = require('./permissions'); -const buildSessionCookie = ({ username, sessionToken, isLogout, remember }) => { - if (!isLogout) { - const joinedToken = `${username}&${sessionToken}`; - const encodedToken = base64.encode(joinedToken); - if (remember) { - return `sessionCookie=${encodedToken}; Domain=localhost; HttpOnly; SameSite=strict;`; - } else { - return `sessionCookie=${encodedToken}; Domain=localhost; HttpOnly; SameSite=strict; Max-Age=86400;`; - } - } else { - return `sessionCookie=deleted; Domain=localhost; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=strict;`; - } -}; +const { authMiddleware, registerRoute, loginRoute, logoutRoute } = require('./authn'); const normalRouter = express.Router(); const authedRouter = express.Router(); -authedRouter.use(async (req, res, next) => { - const cookies = req.cookies; - const encodedCookie = cookies.sessionCookie; - - if (!encodedCookie) { - res.status(403); - return res.json({ - message: 'Log in first!', - }); - } - const sessionCookie = base64.decode(encodedCookie); - - const [ username, sessionToken ] = sessionCookie.split('&'); - - const user = await db('users').where({ username }).first(); - if (!user) { - res.status(400); - res.set('Set-Cookie', buildSessionCookie({ isLogout: true })); - return res.json({ - message: 'Invalid session token.', - sessionCookie, - }); - } - - const salt = user.salt; - - const sessionTokenHash = sha512(salt + sessionToken); - const token = await db('tokens').where({ type: 'session', value: sessionTokenHash }); - if (!token) { - res.status(400); - res.set('Set-Cookie', buildSessionCookie({ isLogout: true })); - return res.json({ - message: 'Invalid session token.', - sessionCookie, - }); - } - - req.sessionTokenHash = sessionTokenHash; - req.user = user; - - next(); -}); +authedRouter.use(authMiddleware); normalRouter.get('/', (req, res) => { res.json({ @@ -71,74 +17,10 @@ normalRouter.get('/', (req, res) => { }); }); -normalRouter.post('/register', async (req, res) => { - const { username, password } = req.body; - if (!username || !password) { - res.status(400); - return res.json({ - error: 'Username or password missing.', - }); - } - - const existingUser = await db('users').where({ username }).first(); - if (existingUser) { - res.status(400); - return res.json({ - error: 'Username already taken.', - }); - } - - const salt = shortid.generate(); - const pwHash = sha512(salt + password); - - const sessionToken = shortid.generate(); - const sessionTokenHash = sha512(salt + sessionToken); - - const user = (await db('users').insert({ username, password: pwHash, salt }).returning('*'))[0]; - await db('tokens').insert({ user_id: user.id, type: 'session', value: sessionTokenHash }); - - res.set('Set-Cookie', buildSessionCookie({ username, sessionToken, remember: false, isLogout: false })); - return res.json({ - username, - }); -}); - -normalRouter.post('/login', async (req, res) => { - const { username, password, remember } = req.body; - if (!username || !password) { - res.status(400); - return res.json({ - error: 'Username or password missing.', - }); - } - - const user = await db('users').where({ username }).first(); - - if (user) { - const sessionToken = shortid.generate(); - const sessionTokenHash = sha512(user.salt + sessionToken); - - await db('tokens').insert({ user_id: user.id, type: 'session', value: sessionTokenHash }); - - res.set('Set-Cookie', buildSessionCookie({ username, sessionToken, remember, isLogout: false })); - - return res.json({ - username, - }); - } - - res.status(403); - res.json({ - error: 'Wrong username or password', - }); -}); - -authedRouter.post('/logout', async (req, res) => { - await db('tokens').where({ type: 'session', user_id: req.user.id, value: req.sessionTokenHash }).delete(); - res.status(204); - res.set('Set-Cookie', buildSessionCookie({ isLogout: true })); - res.send(); -}); +/* Authentication flow */ +normalRouter.post('/register', registerRoute); +normalRouter.post('/login', loginRoute); +authedRouter.post('/logout', logoutRoute); authedRouter.get('/user', (req, res) => { const user = req.user; @@ -148,4 +30,11 @@ authedRouter.get('/user', (req, res) => { }); }); +authedRouter.get('/users', permissionMiddleware(PERMISSIONS.READ_USERS), async (req, res) => { + const users = await db('users').select('id', 'username', 'created_at'); + return res.json({ + users, + }); +}); + module.exports = { authedRouter, normalRouter }; diff --git a/docker-compose.yml b/docker-compose.yml index 06a0b88..12ddeb3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: volumes: - ./backend/src:/app/src - ./backend/migrations:/app/migrations + - ./backend/seeds:/app/seeds depends_on: - db diff --git a/frontend/public/index.html b/frontend/public/index.html index b41fd33..4f82110 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -6,13 +6,16 @@ + +