Compare commits

...

22 Commits

Author SHA1 Message Date
D. Berge
80451796e1 Convert expiry time to milliseconds for set-cookie 2020-10-23 14:59:45 +02:00
D. Berge
141d5805ae Reissue user login tokens when close to expiring 2020-10-23 14:50:35 +02:00
D. Berge
250ffe243d Fix JWT token time to live.
Now half an hour.
2020-10-23 14:49:52 +02:00
D. Berge
59aaacbeee Apply access restrictions to writable routes 2020-10-12 19:43:07 +02:00
D. Berge
3c86981dc6 Add authorisation middleware.
Defines three levels of access:
* read: anyone who is logged in
* write: `user` and `admin` roles
* admin: `admin` roles
2020-10-12 19:42:02 +02:00
D. Berge
5594b6863c Do not run authentication if headers already sent 2020-10-12 19:41:00 +02:00
D. Berge
7201c29df5 Inject auth middleware after login routes.
Routes not requiring authentication must,
self-evidently, go before the authentication
middleware.
2020-10-11 22:11:36 +02:00
D. Berge
947736e8c1 Check code rather than errno.
Different versions of that library work
differently.
2020-10-11 22:10:21 +02:00
D. Berge
d782a30e90 Avoid decoding empty cookies 2020-10-11 19:59:28 +02:00
D. Berge
987dbb7700 Handle null/invalid cookies 2020-10-11 19:36:11 +02:00
D. Berge
cdd007ce88 Fix authentification middleware 2020-10-11 19:08:36 +02:00
D. Berge
a38066ec82 Set cookie / user to null if failing to decode JWT 2020-10-11 19:06:57 +02:00
D. Berge
2aca34e488 Read user login info from discrete file.
`$DOUGAL_ROOT/etc/users.yaml` to be exact.
2020-10-11 18:21:19 +02:00
D. Berge
324306a77d Remove logging statement 2020-10-11 18:20:41 +02:00
D. Berge
ab8a66bdcf Set JWT default options 2020-10-11 17:58:41 +02:00
D. Berge
b3f393a6f1 Make navigation bar user control functional.
Shows whether the user is logged in and presents
appropriate options according to whether this is
a manual or automatic login (a manual login is
when the user explicitly logs in with a user name
and password).
2020-10-11 17:57:00 +02:00
D. Berge
1ee886db63 Add login/logout views to frontend 2020-10-11 17:56:32 +02:00
D. Berge
fc9450434c Read credentials from cookie store when loading app 2020-10-11 17:55:17 +02:00
D. Berge
00f4fcf292 Read credentials from API responses 2020-10-11 17:54:34 +02:00
D. Berge
0512ac2c3c Add user module to Vuex store 2020-10-11 17:53:39 +02:00
D. Berge
dd32982cbe Add login/logout middleware 2020-10-11 17:52:13 +02:00
D. Berge
a3bfb73937 Add authentication middleware.
The user is authenticated by one of the following
methods, in order of priority:

* The presence of a valid JWT.
* Its IP.
* Its hostname.

In the case of the latter two methods, if authentication
is successful a JWT valid for 15 minutes will be generated
and passed back to the user in a cookie.
2020-10-11 13:11:43 +02:00
24 changed files with 503 additions and 30 deletions

View File

@@ -1,6 +1,9 @@
{
"jwt": {
"secret": ""
"secret": "",
"options": {
"expiresIn": 1800
}
},
"db": {
"user": "postgres",

View File

@@ -29,6 +29,7 @@
</style>
<script>
import { mapActions } from 'vuex';
import DougalNavigation from './components/navigation';
import DougalFooter from './components/footer';
@@ -61,9 +62,14 @@ export default {
}
},
methods: {
...mapActions(["setCredentials"])
},
mounted () {
// Local Storage values are always strings
this.$vuetify.theme.dark = localStorage.getItem("darkTheme") == "true";
this.setCredentials()
}
};

View File

@@ -15,14 +15,39 @@
<v-breadcrumbs :items="path"></v-breadcrumbs>
<template v-if="$route.name != 'Login'">
<v-btn text link to="/login" v-if="!$root.user">Log in</v-btn>
<template v-else>
<v-btn title="Edit profile" disabled>
{{$root.user.name}}
</v-btn>
<v-btn class="ml-2" title="Log out" link to="/?logout=1">
<v-icon>mdi-logout</v-icon>
<v-btn text link to="/login" v-if="!user && !loading">Log in</v-btn>
<template v-else-if="user">
<v-menu
offset-y
>
<template v-slot:activator="{on, attrs}">
<v-avatar :color="user.colour || 'primary'" :title="`${user.name} (${user.role})`" v-bind="attrs" v-on="on">
<span class="white--text">{{user.name.slice(0, 5)}}</span>
</v-avatar>
</template>
<v-list dense>
<v-list-item link to="/login" v-if="user.autologin">
<v-list-item-icon><v-icon small>mdi-login</v-icon></v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Log in as a different user</v-list-item-title>
<v-list-item-subtitle>Autologin from {{user.ip}}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item link to="/logout" v-else>
<v-list-item-icon><v-icon small>mdi-logout</v-icon></v-list-item-icon>
<v-list-item-title>Log out</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<!--
<v-btn small text class="ml-2" title="Log out" link to="/?logout=1">
<v-icon small>mdi-logout</v-icon>
</v-btn>
-->
</template>
</template>
<template v-slot:extension v-if="$route.matched.find(i => i.name == 'Project')">
@@ -35,6 +60,7 @@
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'DougalNavigation',
@@ -58,7 +84,9 @@ export default {
computed: {
tab () {
return this.tabs.findIndex(t => t.href == this.$route.path.split(/\/+/)[3]);
}
},
...mapGetters(['user', 'loading'])
},
watch: {

View File

@@ -1,6 +1,8 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import Home from '../views/Home.vue'
import Login from '../views/Login.vue'
import Logout from '../views/Logout.vue'
import Project from '../views/Project.vue'
import ProjectList from '../views/ProjectList.vue'
import ProjectSummary from '../views/ProjectSummary.vue'
@@ -31,6 +33,27 @@ Vue.use(VueRouter)
// which is lazy-loaded when the route is visited.
component: () => import(/* webpackChunkName: "about" */ '../views/About.vue')
},
{
pathToRegexpOptions: { strict: true },
path: "/login",
redirect: "/login/"
},
{
pathToRegexpOptions: { strict: true },
name: "Login",
path: "/login/",
component: Login,
meta: {
// breadcrumbs: [
// { text: "Projects", href: "/projects", disabled: true }
// ]
}
},
{
// pathToRegexpOptions: { strict: true },
path: "/logout",
component: Logout,
},
{
pathToRegexpOptions: { strict: true },
path: "/projects",

View File

@@ -2,6 +2,7 @@ import Vue from 'vue'
import Vuex from 'vuex'
import api from './modules/api'
import user from './modules/user'
import snack from './modules/snack'
import project from './modules/project'
import notify from './modules/notify'
@@ -11,6 +12,7 @@ Vue.use(Vuex)
export default new Vuex.Store({
modules: {
api,
user,
snack,
project,
notify

View File

@@ -18,6 +18,9 @@ async function api ({state, commit, dispatch}, [resource, init = {}, cb]) {
cb(null, res);
}
if (res.ok) {
await dispatch('setCredentials');
try {
return await res.json();
} catch (err) {

View File

@@ -0,0 +1,50 @@
import jwt_decode from 'jwt-decode';
async function login ({commit, dispatch}, loginRequest) {
const url = "/login";
const init = {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: loginRequest
}
const res = await dispatch('api', [url, init]);
if (res && res.ok) {
dispatch('setCredentials', true);
}
}
async function logout ({commit, dispatch}) {
commit('setCookie', null);
commit('setUser', null);
await dispatch('api', ["/logout"]);
// Should delete JWT cookie
}
function browserCookie (state) {
return document.cookie.split(/; */).find(i => /^JWT=.+/.test(i));
}
function cookieChanged (cookie) {
return browserCookie != cookie;
}
function setCredentials ({state, commit, getters, dispatch}, force = false) {
if (cookieChanged(state.cookie) || force) {
try {
const cookie = browserCookie();
const decoded = cookie ? jwt_decode(cookie.split("=")[1]) : null;
commit('setCookie', cookie);
commit('setUser', decoded);
} catch (err) {
if (err.name == "InvalidTokenError") {
console.warn("Failed to decode", browserCookie());
} else {
console.error("setCredentials", err);
}
}
}
}
export default { login, logout, setCredentials };

View File

@@ -0,0 +1,6 @@
function user (state) {
return state.user;
}
export default { user };

View File

@@ -0,0 +1,6 @@
import state from './state'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
export default { state, getters, actions, mutations };

View File

@@ -0,0 +1,10 @@
function setCookie (state, cookie) {
state.cookie = cookie;
}
function setUser (state, user) {
state.user = user;
}
export default { setCookie, setUser };

View File

@@ -0,0 +1,6 @@
const state = () => ({
cookie: null,
user: null,
});
export default state;

View File

@@ -0,0 +1,98 @@
<template>
<v-container fluid>
<v-row>
<v-col>
<v-form :disabled="loading">
<v-card class="mx-auto" max-width="600" tile>
<v-card-title style="word-break: normal;">Login</v-card-title>
<v-card-text>
<v-text-field
v-model="credentials.user"
label="User"
required
>
</v-text-field>
<v-text-field
v-model="credentials.password"
type="password"
label="Password"
required
>
</v-text-field>
</v-card-text>
<v-card-actions>
<v-btn
:disabled="!valid"
color="success"
class="mr-4"
@click="submit"
>Login
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
class="mr-4"
@click="reset"
>Reset
</v-btn>
</v-card-actions>
</v-card>
</v-form>
</v-col>
</v-row>
</v-container>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
export default {
name: 'Login',
components: {
},
data () {
return {
credentials: {
user: null,
password: null
}
};
},
computed: {
valid () {
return this.credentials.user !== null && this.credentials.password !== null;
},
...mapGetters(['loading', 'user'])
},
methods: {
async submit () {
await this.logout();
await this.login(this.credentials);
if (this.user && !this.user.autologin) {
this.$router.replace("/");
} else {
this.showSnack(["Bad login", "warning"]);
}
},
reset () {
this.credentials = {user: null, password: null};
},
...mapActions(["login", "logout", "showSnack"])
}
}
</script>

View File

@@ -0,0 +1,19 @@
<template>
</template>
<script>
import { mapActions } from 'vuex';
export default {
name: 'Logout',
methods: {
...mapActions(["logout"])
},
async created () {
await this.logout();
this.$router.replace("/");
}
}
</script>

View File

@@ -62,8 +62,22 @@ const allMeta = (key, value) => {
return { all: [ meta(key, value) ] };
};
// These routes do not require authentication
app.map({
'*': { all: [ meta() ] }, // Create the req.meta object
'/login': {
post: [ mw.user.login ]
},
'/logout': {
get: [ mw.user.logout ],
post: [ mw.user.logout ]
}
});
app.use(mw.auth.authentify);
// We must be authenticated before we can access these
app.map({
'/project': {
get: [ mw.project.list ], // Get list of projects
},
@@ -93,7 +107,7 @@ app.map({
},
'/project/:project/line/:line': {
// get: [ mw.line.get ],
patch: [ mw.line.patch ],
patch: [ mw.auth.access.write, mw.line.patch ],
},
'/project/:project/sequence/': {
@@ -101,30 +115,30 @@ app.map({
},
'/project/:project/sequence/:sequence': {
// get: [ mw.sequence.get ],
patch: [ mw.sequence.patch ],
patch: [ mw.auth.access.write, mw.sequence.patch ],
},
'/project/:project/plan/': {
get: [ mw.plan.list ],
put: [ mw.plan.put ],
post: [ mw.plan.post ]
put: [ mw.auth.access.write, mw.plan.put ],
post: [ mw.auth.access.write, mw.plan.post ]
},
'/project/:project/plan/:sequence': {
// get: [ mw.plan.get ],
patch: [ mw.plan.patch ],
delete: [ mw.plan.delete ]
patch: [ mw.auth.access.write, mw.plan.patch ],
delete: [ mw.auth.access.write, mw.plan.delete ]
},
//
'/project/:project/event/': {
get: [ mw.event.cache.get, mw.event.list, mw.event.cache.save ],
post: [ mw.event.post ],
put: [ mw.event.put ],
delete: [ mw.event.delete ],
post: [ mw.auth.access.write, mw.event.post ],
put: [ mw.auth.access.write, mw.event.put ],
delete: [ mw.auth.access.write, mw.event.delete ],
':type/': {
':id/': {
// get: [ mw.event.get ],
put: [ mw.event.put ],
delete: [ mw.event.delete ]
put: [ mw.auth.access.write, mw.event.put ],
delete: [mw.auth.access.write, mw.event.delete ]
}
},
},
@@ -134,7 +148,7 @@ app.map({
},
'/project/:project/configuration/:path(*)?': {
get: [ mw.configuration.get ],
// post: [ mw.label.post ],
// post: [ mw.auth.access.admin, mw.label.post ],
},
'/project/:project/info/:path(*)': {
get: [ mw.info.get ],
@@ -165,7 +179,7 @@ app.map({
'gis/:featuretype(line|point)': {
get: [ mw.gis.navdata.get ]
}
}
},
//
// '/user': {
// get: [ mw.user.get ],
@@ -177,12 +191,6 @@ app.map({
// // delete: [ mw.user.delete ]
// },
//
// '/login': {
// post: [ mw.user.login ]
// },
// '/logout': {
// post: [ mw.user.logout ]
// }
});
// Generic error handler. Stops stack dumps

View File

@@ -0,0 +1,31 @@
async function read (req, res, next) {
if (req.user) {
next();
} else {
next({status: 403, message: "Access denied"});
}
}
async function write (req, res, next) {
if (req.user && (req.user.role == "user" || req.user.role == "admin")) {
next();
} else {
next({status: 403, message: "Access denied"});
}
}
async function admin (req, res, next) {
if (req.user && req.user.role == "admin") {
next();
} else {
next({status: 403, message: "Access denied"});
}
}
module.exports = {
read,
write,
admin
};

View File

@@ -0,0 +1,92 @@
const dns = require('dns');
const { Netmask } = require('netmask');
const cfg = require('../../../lib/config');
const jwt = require('../../../lib/jwt');
async function authorisedIP (req, res) {
const validIPs = cfg._("global.users.login.ip") || {};
for (const key in validIPs) {
const block = new Netmask(key);
if (block.contains(req.ip)) {
const payload = Object.assign({
ip: req.ip,
autologin: true
}, validIPs[key]);
jwt.issue(payload, req, res);
return true;
}
}
return false;
}
async function authorisedHost (req, res) {
const validHosts = cfg._("global.users.login.host") || {};
for (const key in validHosts) {
try {
const ip = await dns.promises.resolve(key);
if (ip == req.ip) {
const payload = Object.assign({
ip: req.ip,
host: key,
autologin: true
}, validHosts[key]);
jwt.issue(payload, req, res);
return true;
}
} catch (err) {
if (err.code != "ENODATA") {
console.error(err);
}
}
}
return false;
}
async function auth (req, res, next) {
if (res.headersSent) {
// Nothing to do, this request must have been
// handled already by another middleware.
return;
}
// Check for a valid JWT (already decoded by a previous
// middleware).
if (req.user) {
if (!req.user.autologin) {
// If this is not an automatic login, check if the token is in the
// second half of its lifetime. If so, reissue a new one, valid for
// another cfg.jwt.options.expiresIn seconds.
if (req.user.exp) {
const ttl = req.user.exp - Date.now()/1000;
if (ttl < cfg.jwt.options.expiresIn/2) {
const credentials = cfg._("global.users.login.user").find(i => i.name == req.user.name && i.role == req.user.role);
if (credentials) {
// Refresh token
payload = Object.assign({}, credentials);
delete payload.hash;
jwt.issue(Object.assign({}, credentials), req, res);
}
}
}
}
next();
return;
}
// Check if the IP is known to us
if (await authorisedIP(req, res)) {
next();
return;
}
// Check if the hostname is known to us
if (await authorisedHost(req, res)) {
next();
return;
}
next({status: 401, message: "Not authorised"});
}
module.exports = auth;

View File

@@ -1,3 +1,4 @@
exports.jwt = require('./jwt');
// exports.access = require('./access');
exports.authentify = require('./authentify');
exports.access = require('./access');

View File

@@ -0,0 +1,3 @@
exports.login = require('./login');
exports.logout = require('./logout');

View File

@@ -0,0 +1,28 @@
const crypto = require('crypto');
const cfg = require('../../../lib/config');
const jwt = require('../../../lib/jwt');
async function login (req, res, next) {
if (req.body) {
const {user, password} = req.body;
if (user && password) {
const hash = crypto
.pbkdf2Sync(password, 'Dougal'+user, 10712, 48, 'sha512')
.toString('base64');
for (const credentials of cfg._("global.users.login.user") || []) {
if (credentials.name == user && credentials.hash == hash) {
const payload = Object.assign({}, credentials);
delete payload.hash;
jwt.issue(payload, req, res);
res.status(204).send();
next();
return;
}
}
next({status: 401, message: "Unauthorised"});
}
}
next({status: 400, message: "Bad request"});
}
module.exports = login;

View File

@@ -0,0 +1,8 @@
async function logout (req, res, next) {
res.clearCookie("JWT");
res.status(204).send();
next();
}
module.exports = logout;

View File

@@ -6,6 +6,7 @@ const YAML = require('yaml');
const cfgPrefix = process.env.DOUGAL_ROOT || ((process.env.HOME || ".") + "/software");
const cfgPath = process.env.DOUGAL_API_CONFIG || (cfgPrefix+"/etc/www/config.json");
const globalCfgPath = cfgPrefix+"/etc/config.yaml";
const usersCfgPath = cfgPrefix+"/etc/users.yaml";
let config = {}
@@ -24,7 +25,10 @@ try {
config = {
"jwt": {
secret
secret,
options: {
expiresIn: 15*60*1000
}
},
"db": {
"user": "postgres",
@@ -68,6 +72,17 @@ try {
config.global = YAML.parse(text);
}
}
if (fs.existsSync(usersCfgPath)) {
const text = fs.readFileSync(usersCfgPath, 'utf8');
if (text) {
if (!config.global) {
config.global = {};
}
config.global.users = YAML.parse(text);
}
}
} catch (err) {
console.error(err);
}

21
lib/www/server/lib/jwt.js Normal file
View File

@@ -0,0 +1,21 @@
const jwt = require('jsonwebtoken');
const cfg = require('./config');
function issue (payload, req, res) {
const token = jwt.sign(payload, cfg.jwt.secret, cfg.jwt.options);
if (req) {
req.user = payload;
}
if (res) {
res.cookie("JWT", token, {maxAge: cfg.jwt.options.expiresIn*1000 || 0});
}
return token;
}
module.exports = {
issue
};

View File

@@ -366,6 +366,11 @@
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
"integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
},
"netmask": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/netmask/-/netmask-1.0.6.tgz",
"integrity": "sha1-ICl+idhvb2QA8lDZ9Pa0wZRfzTU="
},
"node-fetch": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",

View File

@@ -13,6 +13,7 @@
"express": "^4.17.1",
"express-jwt": "^6.0.0",
"jsonwebtoken": "^8.5.1",
"netmask": "^1.0.6",
"node-fetch": "^2.6.1",
"pg": "^8.3.3",
"ws": "^7.3.1",