summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--backend/migrations/20200205122810_permissions.js10
-rw-r--r--backend/migrations/20200205123553_users_permissions.js11
-rw-r--r--backend/seeds/permissions.js10
-rw-r--r--backend/src/authn.js139
-rw-r--r--backend/src/authz.js42
-rw-r--r--backend/src/permissions.js5
-rw-r--r--backend/src/routes.js141
-rw-r--r--docker-compose.yml1
-rw-r--r--frontend/public/index.html5
-rw-r--r--frontend/src/Home.js11
-rw-r--r--frontend/src/Login.js17
-rw-r--r--frontend/src/Register.js18
-rw-r--r--frontend/src/UserList.js56
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;