diff --git a/lib/auth.ts b/lib/auth.ts
index 41728bc..2312393 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -17,7 +17,7 @@
* along with GenieACS. If not, see .
*/
-import { createHash } from "crypto";
+import { createHash, randomBytes, pbkdf2 } from "crypto";
export function parseAuthHeader(authHeader): {} {
authHeader = authHeader.trim();
@@ -119,3 +119,21 @@ export function digest(
return authString;
}
+
+export function generateSalt(length): Promise {
+ return new Promise((resolve, reject) => {
+ randomBytes(length, (err, rand) => {
+ if (err) return void reject(err);
+ resolve(rand.toString("hex"));
+ });
+ });
+}
+
+export function hashPassword(pass, salt): Promise {
+ return new Promise((resolve, reject) => {
+ pbkdf2(pass, salt, 10000, 128, "sha512", (err, hash) => {
+ if (err) return void reject(err);
+ resolve(hash.toString("hex"));
+ });
+ });
+}
diff --git a/lib/db.ts b/lib/db.ts
index 327b356..01acf48 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -954,7 +954,7 @@ export function getPermissions(
export function getUsers(
callback: (
err?: Error,
- users?: { _id: string; password: string; roles: string }[]
+ users?: { _id: string; password: string; salt: string; roles: string }[]
) => void
): void {
usersCollection.find().toArray(callback);
diff --git a/lib/local-cache.ts b/lib/local-cache.ts
index 93e7fc9..e60c41a 100644
--- a/lib/local-cache.ts
+++ b/lib/local-cache.ts
@@ -74,6 +74,27 @@ function computeHash(snapshot): string {
h.update(JSON.stringify(keys));
for (const k of keys) h.update(JSON.stringify(snapshot.config[k]));
+ keys = Object.keys(snapshot.files).sort();
+ h.update(JSON.stringify(keys));
+ for (const k of keys) h.update(JSON.stringify(snapshot.files[k]));
+
+ keys = Object.keys(snapshot.users).sort();
+ h.update(JSON.stringify(keys));
+ for (const k of keys) h.update(JSON.stringify(snapshot.users[k]));
+
+ const roles = Object.keys(snapshot.permissions).sort();
+ h.update(JSON.stringify(roles));
+ for (const r of roles) {
+ const levels = Object.keys(snapshot.permissions[r]).sort();
+ h.update(JSON.stringify(levels));
+ for (const l of levels) {
+ keys = Object.keys(snapshot.permissions[r][l]).sort();
+ h.update(JSON.stringify(keys));
+ for (const k of keys)
+ h.update(JSON.stringify(snapshot.permissions[r][l][k]));
+ }
+ }
+
return h.digest("hex");
}
@@ -354,6 +375,7 @@ function refresh(callback): void {
for (const user of _users) {
users[user._id] = {
password: user.password,
+ salt: user.salt,
roles: user.roles.split(",").map(s => s.trim())
};
}
diff --git a/lib/ui.ts b/lib/ui.ts
index 012dda4..aee29af 100644
--- a/lib/ui.ts
+++ b/lib/ui.ts
@@ -12,6 +12,7 @@ import Authorizer from "./common/authorizer";
import * as logger from "./logger";
import * as localCache from "./local-cache";
import { PermissionSet } from "./types";
+import { authSimple } from "./ui/api-functions";
declare module "koa" {
interface Request {
@@ -33,14 +34,6 @@ function getPermissionSets(ctx): PermissionSet[] {
return permissionSets;
}
-function authSimple(ctx, username, password): Promise {
- const users = localCache.getUsers(ctx.state.configSnapshot);
- const user = users[username];
- if (user && user.password === password) return Promise.resolve(user.roles);
-
- return Promise.resolve(null);
-}
-
koa.on("error", async err => {
throw err;
});
@@ -100,7 +93,7 @@ router.post("/login", async ctx => {
logger.accessWarn(log);
}
- const roles = await authSimple(ctx, username, password);
+ const roles = await authSimple(ctx.state.configSnapshot, username, password);
if (roles) return void success(roles, "simple");
diff --git a/lib/ui/api-functions.ts b/lib/ui/api-functions.ts
index b68a5d4..a3d13d9 100644
--- a/lib/ui/api-functions.ts
+++ b/lib/ui/api-functions.ts
@@ -2,6 +2,8 @@ import { exec } from "child_process";
import { ObjectID } from "mongodb";
import * as db from "./db";
import { del } from "../cache";
+import { getUsers } from "../local-cache";
+import { hashPassword } from "../auth";
import {
insertTasks,
watchTask,
@@ -186,13 +188,21 @@ export function putResource(resource, id, data): Promise {
return new Promise((resolve, reject) => {
let promise;
- if (resource === "presets") promise = db.putPreset(id, data);
- else if (resource === "provisions") promise = db.putProvision(id, data);
- else if (resource === "virtualParameters")
+ if (resource === "presets") {
+ promise = db.putPreset(id, data);
+ } else if (resource === "provisions") {
+ promise = db.putProvision(id, data);
+ } else if (resource === "virtualParameters") {
promise = db.putVirtualParameter(id, data);
- else if (resource === "config") promise = db.putConfig(id, data);
- else if (resource === "permissions") promise = db.putPermission(id, data);
- else if (resource === "users") promise = db.putUser(id, data);
+ } else if (resource === "config") {
+ promise = db.putConfig(id, data);
+ } else if (resource === "permissions") {
+ promise = db.putPermission(id, data);
+ } else if (resource === "users") {
+ delete data.password;
+ delete data.salt;
+ promise = db.putUser(id, data);
+ }
promise
.then(() => {
@@ -204,3 +214,17 @@ export function putResource(resource, id, data): Promise {
.catch(reject);
});
}
+
+export function authSimple(snapshot, username, password): Promise {
+ return new Promise((resolve, reject) => {
+ const users = getUsers(snapshot);
+ const user = users[username];
+ if (!user || !user.password) return void resolve(null);
+ hashPassword(password, user.salt)
+ .then(hash => {
+ if (hash === user.password) resolve(user.roles);
+ else resolve(null);
+ })
+ .catch(reject);
+ });
+}
diff --git a/lib/ui/api.ts b/lib/ui/api.ts
index be3126c..75f421b 100644
--- a/lib/ui/api.ts
+++ b/lib/ui/api.ts
@@ -7,6 +7,8 @@ import { parse } from "../common/expression-parser";
import * as logger from "../logger";
import { getConfig } from "../local-cache";
import { QueryOptions, Expression } from "../types";
+import { generateSalt, hashPassword } from "../auth";
+import { del } from "../cache";
const router = new Router();
export default router;
@@ -497,3 +499,64 @@ router.get("/ping/:host", async ctx => {
ctx.body = `${err.name}: ${err.message}`;
}
});
+
+router.put("/users/:id/password", async (ctx, next) => {
+ const username = ctx.params.id;
+ const log = {
+ message: "Change password",
+ context: ctx,
+ username: username
+ };
+
+ const authorizer = ctx.state.authorizer;
+
+ if (!ctx.state.user) {
+ // User not logged in
+ if (
+ !(await apiFunctions.authSimple(
+ ctx.state.configSnapshot,
+ username,
+ ctx.request.body.authPassword
+ ))
+ ) {
+ logUnauthorizedWarning(log);
+ ctx.status = 401;
+ ctx.body = "Authentication failed, check your username and password";
+ return;
+ }
+ } else if (!authorizer.hasAccess("users", 3)) {
+ logUnauthorizedWarning(log);
+ return void next();
+ }
+
+ const filter = ["=", ["PARAM", RESOURCE_IDS.users], username];
+ const res = await db.query("users", filter);
+ if (!res.length) return void next();
+
+ const newPassword = ctx.request.body.newPassword;
+ if (ctx.state.user) {
+ const validate = authorizer.getValidator("users", res[0]);
+ if (!validate("password", { password: newPassword })) {
+ logUnauthorizedWarning(log);
+ return void (ctx.status = 403);
+ }
+ }
+
+ const salt = await generateSalt(64);
+ const password = await hashPassword(newPassword, salt);
+ await db.putUser(username, { password, salt });
+
+ function delCache(): Promise {
+ return new Promise((resolve, reject) => {
+ del("presets_hash", err => {
+ if (err) return void reject(err);
+ resolve();
+ });
+ });
+ }
+
+ await delCache();
+
+ logger.accessInfo(log);
+ ctx.body = "";
+});
diff --git a/lib/ui/db.ts b/lib/ui/db.ts
index b208409..d5c3560 100644
--- a/lib/ui/db.ts
+++ b/lib/ui/db.ts
@@ -82,12 +82,19 @@ export function query(
filter = expression.evaluate(filter, null, Date.now());
if (Array.isArray(filter)) {
- if (resource === "devices")
+ if (resource === "devices") {
filter = mongodbFunctions.processDeviceFilter(filter);
- else if (resource === "tasks")
+ } else if (resource === "tasks") {
filter = mongodbFunctions.processTasksFilter(filter);
- else if (resource === "faults")
+ } else if (resource === "faults") {
filter = mongodbFunctions.processFaultsFilter(filter);
+ } else if (resource === "users") {
+ // Protect against brute force, and dictionary attacks
+ const params = expression.extractParams(filter);
+ if (params.includes("password") || params.includes("salt"))
+ return Promise.reject(new Error("Invalid users filter"));
+ }
+
q = mongodbFunctions.filterToMongoQuery(filter);
} else if (filter != null && !filter) {
return Promise.resolve([]);
@@ -111,7 +118,7 @@ export function query(
cursor.project(projection);
}
- if (resource === "users") cursor.project({ password: 0 });
+ if (resource === "users") cursor.project({ password: 0, salt: 0 });
if (options.skip) cursor.skip(options.skip);
if (options.limit) cursor.limit(options.limit);
diff --git a/tools/configure-ui b/tools/configure-ui
index 8707022..714dc96 100755
--- a/tools/configure-ui
+++ b/tools/configure-ui
@@ -1,6 +1,7 @@
#!/usr/bin/env -S node -r esm -r ts-node/register/transpile-only
import { MongoClient } from "mongodb";
import { get } from "../lib/config";
+import { hashPassword, generateSalt } from "../lib/auth";
const permissionsData = [
{ role: "admin", resource: "devices", access: 3, validate: ["pass"] },
@@ -202,12 +203,21 @@ async function run(): Promise {
.collection("permissions")
.insertMany(perms)
);
- const users = usersData.map(p => {
- p.roles = (p.roles || []).join(",");
- p._id = p.username;
- delete p.username;
- return p;
- });
+ const saltingProms = [];
+ for (const u of usersData) {
+ saltingProms.push(
+ new Promise(async resolve => {
+ u.salt = await generateSalt(64);
+ u.password = await hashPassword(u.password, u.salt);
+ u.roles = (u.roles || []).join(",");
+ u._id = u.username;
+ delete u.username;
+ resolve(u);
+ })
+ );
+ }
+
+ const users = await Promise.all(saltingProms);
inserts.push(
client
.db()
diff --git a/ui/change-password-component.ts b/ui/change-password-component.ts
new file mode 100644
index 0000000..d7bcd4d
--- /dev/null
+++ b/ui/change-password-component.ts
@@ -0,0 +1,116 @@
+import { VnodeDOM, ClosureComponent, Component } from "mithril";
+import * as notifications from "./notifications";
+import { changePassword } from "./store";
+import { m } from "./components";
+
+const component: ClosureComponent = (): Component => {
+ return {
+ view: vnode => {
+ const onPasswordChange = vnode.attrs["onPasswordChange"];
+ const enforceAuth = !vnode.attrs["noAuth"];
+ const username = vnode.attrs["username"];
+
+ if (username) vnode.state["username"] = username;
+
+ const form = [
+ m(
+ "p",
+ m("label", { for: "username" }, "Username"),
+ m("input", {
+ name: "username",
+ type: "text",
+ value: vnode.state["username"],
+ disabled: !!username,
+ oninput: m.withAttr("value", v => (vnode.state["username"] = v)),
+ oncreate: _vnode => {
+ (_vnode.dom as HTMLSelectElement).focus();
+ }
+ })
+ )
+ ];
+
+ let fields = {
+ newPassword: "New password",
+ confirmPassword: "Confirm password"
+ };
+ if (enforceAuth)
+ fields = Object.assign({ authPassword: "Your password" }, fields);
+
+ for (const [f, l] of Object.entries(fields)) {
+ form.push(
+ m(
+ "p",
+ m("label", { for: f }, l),
+ m("input", {
+ name: f,
+ type: "password",
+ value: vnode.state[f],
+ oninput: m.withAttr("value", v => (vnode.state[f] = v))
+ })
+ )
+ );
+ }
+
+ const submit = m(
+ "button.primary",
+ {
+ type: "submit"
+ },
+ "Change password"
+ ) as VnodeDOM;
+
+ form.push(m(".actions-bar", submit));
+
+ const children = [
+ m("h1", "Change password"),
+ m(
+ "form",
+ {
+ onsubmit: e => {
+ e.redraw = false;
+ e.preventDefault();
+ if (
+ !vnode.state["username"] ||
+ !vnode.state["newPassword"] ||
+ (enforceAuth && !vnode.state["authPassword"])
+ ) {
+ notifications.push("error", "Please fill all fields");
+ } else if (
+ vnode.state["newPassword"] !== vnode.state["confirmPassword"]
+ ) {
+ notifications.push(
+ "error",
+ "Password confirm doesn't match new password"
+ );
+ } else {
+ (submit.dom as HTMLFormElement).disabled = true;
+ changePassword(
+ vnode.state["username"],
+ vnode.state["newPassword"],
+ vnode.state["authPassword"]
+ )
+ .then(() => {
+ notifications.push(
+ "success",
+ "Password updated successfully"
+ );
+ if (onPasswordChange) onPasswordChange();
+ (submit.dom as HTMLFormElement).disabled = false;
+ })
+ .catch(err => {
+ notifications.push("error", err.message);
+ (submit.dom as HTMLFormElement).disabled = false;
+ });
+ }
+ }
+ },
+ form
+ )
+ ];
+
+ return m("div.put-form", children);
+ }
+ };
+};
+
+export default component;
diff --git a/ui/login-page.ts b/ui/login-page.ts
index e0c7201..81ac1eb 100644
--- a/ui/login-page.ts
+++ b/ui/login-page.ts
@@ -1,7 +1,9 @@
-import { ClosureComponent, Component } from "mithril";
+import { ClosureComponent, Component, Children } from "mithril";
import { m } from "./components";
import * as store from "./store";
import * as notifications from "./notifications";
+import * as overlay from "./overlay";
+import changePasswordComponent from "./change-password-component";
export function init(args): Promise<{}> {
return Promise.resolve(args);
@@ -63,6 +65,24 @@ export const component: ClosureComponent = (): Component => {
"Login"
)
)
+ ),
+ m(
+ "a",
+ {
+ onclick: () => {
+ const cb = (): Children => {
+ const attrs = {
+ onPasswordChange: () => {
+ overlay.close(cb);
+ m.redraw();
+ }
+ };
+ return m(changePasswordComponent, attrs);
+ };
+ overlay.open(cb);
+ }
+ },
+ "Change password"
)
];
}
diff --git a/ui/put-form-component.ts b/ui/put-form-component.ts
index 09fce37..bc8975e 100644
--- a/ui/put-form-component.ts
+++ b/ui/put-form-component.ts
@@ -6,7 +6,8 @@ const singular = {
presets: "preset",
provisions: "provision",
virtualParameters: "virtual parameter",
- files: "file"
+ files: "file",
+ users: "user"
};
function createField(current, attr, focus): Children {
diff --git a/ui/store.ts b/ui/store.ts
index c65ab78..a3ad1ae 100644
--- a/ui/store.ts
+++ b/ui/store.ts
@@ -549,6 +549,21 @@ export function evaluateExpression(exp, obj): Expression {
return memoizedEvaluate(exp, obj, fulfillTimestamp);
}
+export function changePassword(
+ username,
+ newPassword,
+ authPassword?
+): Promise {
+ const data = { newPassword };
+ if (authPassword) data["authPassword"] = authPassword;
+ return m.request({
+ method: "PUT",
+ url: `/api/users/${username}/password`,
+ background: true,
+ data
+ });
+}
+
export function logIn(username, password): Promise {
return m.request({
method: "POST",
diff --git a/ui/users-page.ts b/ui/users-page.ts
index 229f020..b1a619d 100644
--- a/ui/users-page.ts
+++ b/ui/users-page.ts
@@ -11,6 +11,7 @@ import * as overlay from "./overlay";
import * as smartQuery from "./smart-query";
import { map, parse, stringify } from "../lib/common/expression-parser";
import filterComponent from "./filter-component";
+import changePasswordComponent from "./change-password-component";
import { Children, ClosureComponent, Component } from "mithril";
const PAGE_SIZE = config.ui.pageSize || 10;
@@ -20,7 +21,6 @@ const memoizedJsonParse = memoize(JSON.parse);
const attributes = [
{ id: "_id", label: "Username" },
- { id: "password", label: "Password", type: "password", unsortable: true },
{
id: "roles",
label: "Roles",
@@ -47,19 +47,27 @@ function putActionHandler(action, _object, isNew): Promise {
const object = Object.assign({}, _object);
if (action === "save") {
const id = object["_id"];
+ const password = object["password"];
+ const confirm = object["confirm"];
delete object["_id"];
+ delete object["password"];
+ delete object["confirm"];
if (!id) return void resolve({ _id: "ID can not be empty" });
+ if (isNew) {
+ if (!password) {
+ return void resolve({ password: "Password can not be empty" });
+ } else if (password !== confirm) {
+ return void resolve({
+ confirm: "Confirm password doesn't match password"
+ });
+ }
+ }
+
if (!Array.isArray(object.roles) || !object.roles.length)
return void resolve({ roles: "Role(s) must be selected" });
- if (typeof object.password !== "string" || !object.password.length) {
- if (isNew)
- return void resolve({ password: "Password can not be empty" });
- else delete object.password;
- }
-
object.roles = object.roles.join(",");
store
@@ -78,12 +86,20 @@ function putActionHandler(action, _object, isNew): Promise {
store
.putResource("users", id, object)
.then(() => {
- notifications.push(
- "success",
- `User ${exists ? "updated" : "created"}`
- );
- store.fulfill(0, Date.now());
- resolve();
+ if (isNew) {
+ store
+ .changePassword(id, password)
+ .then(() => {
+ notifications.push("success", "User created");
+ store.fulfill(0, Date.now());
+ resolve();
+ })
+ .catch(reject);
+ } else {
+ notifications.push("success", "User updated");
+ store.fulfill(0, Date.now());
+ resolve();
+ }
})
.catch(reject);
})
@@ -106,15 +122,9 @@ function putActionHandler(action, _object, isNew): Promise {
});
}
-const formData = {
- resource: "users",
- attributes: attributes
-};
-
const getDownloadUrl = memoize(filter => {
const cols = {};
- for (const attr of attributes)
- if (attr.id !== "password") cols[attr.label] = attr.id;
+ for (const attr of attributes) cols[attr.label] = attr.id;
return `/api/users.csv?${m.buildQueryString({
filter: stringify(filter),
@@ -158,8 +168,6 @@ function renderTable(
const labels = [m("th", selectAll)];
for (const attr of attributes) {
- if (attr.id === "password") continue;
-
const label = attr.label;
if (attr.unsortable) {
@@ -187,6 +195,8 @@ function renderTable(
labels.push(m("th", [label, sortable]));
}
+ const canChangePasswords = window.authorizer.hasAccess("users", 3);
+
const rows = [];
for (const user of users) {
const checkbox = m("input", {
@@ -203,8 +213,7 @@ function renderTable(
});
const tds = [m("td", checkbox)];
- for (const attr of attributes)
- if (attr.id !== "password") tds.push(m("td", user[attr.id]));
+ for (const attr of attributes) tds.push(m("td", user[attr.id]));
tds.push(
m(
@@ -214,39 +223,59 @@ function renderTable(
{
onclick: () => {
const cb = (): Children => {
- return m(
- putFormComponent,
- Object.assign(
- {
- base: {
- _id: user._id,
- roles: user.roles.split(",")
+ const children = [
+ m(
+ putFormComponent,
+ Object.assign(
+ {
+ base: {
+ _id: user._id,
+ roles: user.roles.split(",")
+ },
+ actionHandler: (action, object) => {
+ return new Promise(resolve => {
+ putActionHandler(action, object, false)
+ .then(errors => {
+ const errorList = errors
+ ? Object.values(errors)
+ : [];
+ if (errorList.length) {
+ for (const err of errorList)
+ notifications.push("error", err);
+ } else {
+ overlay.close(cb);
+ }
+ resolve();
+ })
+ .catch(err => {
+ notifications.push("error", err.message);
+ resolve();
+ });
+ });
+ }
},
- actionHandler: (action, object) => {
- return new Promise(resolve => {
- putActionHandler(action, object, false)
- .then(errors => {
- const errorList = errors
- ? Object.values(errors)
- : [];
- if (errorList.length) {
- for (const err of errorList)
- notifications.push("error", err);
- } else {
- overlay.close(cb);
- }
- resolve();
- })
- .catch(err => {
- notifications.push("error", err.message);
- resolve();
- });
- });
+ {
+ resource: "users",
+ attributes: attributes
}
- },
- formData
+ )
)
- );
+ ];
+
+ if (canChangePasswords) {
+ children.push(m("hr"));
+ const attrs = {
+ noAuth: true,
+ username: user._id,
+ onPasswordChange: () => {
+ overlay.close(cb);
+ m.redraw();
+ }
+ };
+ children.push(m(changePasswordComponent, attrs));
+ }
+
+ return children;
};
overlay.open(cb);
}
@@ -331,6 +360,15 @@ function renderTable(
];
if (window.authorizer.hasAccess("users", 3)) {
+ const formData = {
+ resource: "users",
+ attributes: [
+ attributes[0],
+ { id: "password", label: "Password", type: "password" },
+ { id: "confirm", label: "Confirm password", type: "password" },
+ attributes[1]
+ ]
+ };
buttons.push(
m(
"button.primary",