From d5b194b9e89ba6ee8317decd503f4e31d9620b68 Mon Sep 17 00:00:00 2001 From: Shweelan Samson Date: Tue, 19 Mar 2019 12:24:31 +0300 Subject: [PATCH] Salt and hash passwords --- lib/auth.ts | 20 ++++- lib/db.ts | 2 +- lib/local-cache.ts | 22 +++++ lib/ui.ts | 11 +-- lib/ui/api-functions.ts | 36 ++++++-- lib/ui/api.ts | 63 ++++++++++++++ lib/ui/db.ts | 15 +++- tools/configure-ui | 22 +++-- ui/change-password-component.ts | 116 +++++++++++++++++++++++++ ui/login-page.ts | 22 ++++- ui/put-form-component.ts | 3 +- ui/store.ts | 15 ++++ ui/users-page.ts | 146 ++++++++++++++++++++------------ 13 files changed, 410 insertions(+), 83 deletions(-) create mode 100644 ui/change-password-component.ts 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",