diff options
| -rw-r--r-- | backend/migrations/20200205122810_permissions.js | 10 | ||||
| -rw-r--r-- | backend/migrations/20200205123553_users_permissions.js | 11 | ||||
| -rw-r--r-- | backend/seeds/permissions.js | 10 | ||||
| -rw-r--r-- | backend/src/authn.js | 139 | ||||
| -rw-r--r-- | backend/src/authz.js | 42 | ||||
| -rw-r--r-- | backend/src/permissions.js | 5 | ||||
| -rw-r--r-- | backend/src/routes.js | 141 | ||||
| -rw-r--r-- | docker-compose.yml | 1 | ||||
| -rw-r--r-- | frontend/public/index.html | 5 | ||||
| -rw-r--r-- | frontend/src/Home.js | 11 | ||||
| -rw-r--r-- | frontend/src/Login.js | 17 | ||||
| -rw-r--r-- | frontend/src/Register.js | 18 | ||||
| -rw-r--r-- | frontend/src/UserList.js | 56 |
13 files changed, 320 insertions, 146 deletions
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 @@ <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="theme-color" content="#000000" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self' localhost:4000; - style-src 'self' 'unsafe-inline'; + style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src * data:; script-src 'self' 'unsafe-inline';" /> <meta name="description" content="Web site created using create-react-app" /> + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css" + integrity="sha256-D9M5yrVDqFlla7nlELDaYZIpXfFWDytQtiV+TaH6F1I=" crossorigin="anonymous"> + <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <!-- manifest.json provides metadata used when your web app is installed on a diff --git a/frontend/src/Home.js b/frontend/src/Home.js index c67c69a..50524bd 100644 --- a/frontend/src/Home.js +++ b/frontend/src/Home.js @@ -3,6 +3,7 @@ import { observer } from 'mobx-react'; import LogoutBtn from './LogoutBtn'; import userState from './UserState'; +import UserList from './UserList'; const Home = observer(({ user }) => { if (user.authError) { @@ -17,11 +18,17 @@ const Home = observer(({ user }) => { return ( <div> - <LogoutBtn /> - Hello {user.username}! + <div className="navbar"> + <LogoutBtn /> + </div> + + <h1 className="title">Hello {user.username}!</h1> <div> <img src="https://placehold.it/200x200" alt="Placeholder" /> </div> + <div> + <UserList /> + </div> </div> ); }); diff --git a/frontend/src/Login.js b/frontend/src/Login.js index d17a5ab..70fff91 100644 --- a/frontend/src/Login.js +++ b/frontend/src/Login.js @@ -28,17 +28,16 @@ const Login = () => { return ( <form onSubmit={handleSubmit(onSubmit)}> - <h1>Log in</h1> - <label> - Username <input name="username" type="text" ref={register} required /> + <h1 class="title">Log in</h1> + <label className="label"> + Username <input className="input" name="username" type="text" ref={register} required /> </label> - <label> - Password <input name="password" type="password" ref={register} required /> + <label className="label"> + Password <input className="input" name="password" type="password" ref={register} required /> </label> - <label> - Remember me <input name="remember" type="checkbox" ref={register} /> - </label> - <button type="submit">Log in</button> + <button className="button" type="submit"> + Log in + </button> <div className="error">{loginError}</div> <div> No account? Register <Link to="/register">here</Link>. diff --git a/frontend/src/Register.js b/frontend/src/Register.js index f4a6ec3..6ba1537 100644 --- a/frontend/src/Register.js +++ b/frontend/src/Register.js @@ -32,17 +32,19 @@ const Register = () => { return ( <form onSubmit={handleSubmit(onSubmit)}> - <h1>Register new account</h1> - <label> - Username <input name="username" type="text" ref={register} required /> + <h1 className="title">Register new account</h1> + <label className="label"> + Username <input className="input" name="username" type="text" ref={register} required /> </label> - <label> - Password <input name="password" type="password" ref={register} required /> + <label className="label"> + Password <input className="input" name="password" type="password" ref={register} required /> </label> - <label> - Confirm password <input name="confirm_password" type="password" ref={register} required /> + <label className="label"> + Confirm password <input className="input" name="confirm_password" type="password" ref={register} required /> </label> - <button type="submit">Register</button> + <button className="button" type="submit"> + Register + </button> <div className="error">{loginError}</div> {/* <div className="error">{errors}</div> */} </form> diff --git a/frontend/src/UserList.js b/frontend/src/UserList.js new file mode 100644 index 0000000..dd17ace --- /dev/null +++ b/frontend/src/UserList.js @@ -0,0 +1,56 @@ +import React, { useEffect, useState } from 'react'; +import axios from './axios'; + +const UserList = () => { + const [ data, setData ] = useState({}); + useEffect(() => { + (async () => { + try { + const resp = await axios.get('/users'); + setData({ + users: resp.data.users, + }); + } catch (err) { + if (err.response.status === 401) { + setData({ + error: err.response.data.error, + }); + } else { + throw err; + } + } + })(); + }, []); + + const users = data.users; + const userRows = !!users + ? users.map(user => ( + <tr key={user.id}> + <td>{user.id}</td> + <td>{user.username}</td> + </tr> + )) + : null; + + return ( + <div> + <h2 className="subtitle">User list</h2> + <div> + <div className="error" key="error"> + {data.error} + </div> + <table className="table"> + <thead> + <tr> + <th>ID</th> + <th>Username</th> + </tr> + </thead> + <tbody>{userRows}</tbody> + </table> + </div> + </div> + ); +}; + +export default UserList; |
