vw_small

Hardened fork of Vaultwarden (https://github.com/dani-garcia/vaultwarden) with fewer features.
git clone https://git.philomathiclife.com/repos/vw_small
Log | Files | Refs | README

commit 1b5134dfe29d96befff035d76b8996973abc7c90
parent 5fecf09631bdba7674d04dca1bd8a2a3644d58c9
Author: Daniel GarcĂ­a <dani-garcia@users.noreply.github.com>
Date:   Tue, 18 Dec 2018 18:52:58 +0100

Fixed delete user when 2FA is enabled, implemented delete user for admin panel, and the front-end part for invite user. Secured admin panel behind a configurable token.

Diffstat:
M.env | 14+++++++++++---
Msrc/api/admin.rs | 50+++++++++++++++++++++++---------------------------
Msrc/db/models/two_factor.rs | 11+++++++++--
Msrc/db/models/user.rs | 3++-
Msrc/main.rs | 4+++-
Msrc/static/admin.html | 151+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
6 files changed, 157 insertions(+), 76 deletions(-)

diff --git a/.env b/.env @@ -34,15 +34,23 @@ ## It's recommended to also set 'ROCKET_CLI_COLORS=off' # LOG_FILE=/path/to/log -## Controls if new users can register -# SIGNUPS_ALLOWED=true - ## Use a local favicon extractor ## Set to false to use bitwarden's official icon servers ## Set to true to use the local version, which is not as smart, ## but it doesn't send the cipher domains to bitwarden's servers # LOCAL_ICON_EXTRACTOR=false +## Controls if new users can register +# SIGNUPS_ALLOWED=true + +## Token for the admin interface, preferably use a long random string +## One option is to use 'openssl rand -base64 48' +## If not set, the admin panel is disabled +# ADMIN_TOKEN=Vy2VyYTTsKPv8W5aEOWUbB/Bt3DEKePbHmI4m9VcemUMS2rEviDowNAFqYi1xjmp + +## Invitations org admins to invite users, even when signups are disabled +# INVITATIONS_ALLOWED=true + ## Controls the PBBKDF password iterations to apply on the server ## The change only applies when the password is changed # PASSWORD_ITERATIONS=100000 diff --git a/src/api/admin.rs b/src/api/admin.rs @@ -1,20 +1,17 @@ use rocket_contrib::json::Json; use serde_json::Value; +use crate::api::{JsonResult, JsonUpcase}; +use crate::CONFIG; + use crate::db::models::*; use crate::db::DbConn; -use crate::api::{EmptyResult, JsonResult, JsonUpcase}; - -use rocket::{Route, Outcome}; -use rocket::request::{self, Request, FromRequest}; +use rocket::request::{self, FromRequest, Request}; +use rocket::{Outcome, Route}; pub fn routes() -> Vec<Route> { - routes![ - get_users, - invite_user, - delete_user, - ] + routes![get_users, invite_user, delete_user] } #[derive(Deserialize, Debug)] @@ -25,14 +22,14 @@ struct InviteData { #[get("/users")] fn get_users(_token: AdminToken, conn: DbConn) -> JsonResult { - let users = User::get_all(&conn); + let users = User::get_all(&conn); let users_json: Vec<Value> = users.iter().map(|u| u.to_json(&conn)).collect(); - + Ok(Json(Value::Array(users_json))) } -#[post("/users", data="<data>")] -fn invite_user(data: JsonUpcase<InviteData>, _token: AdminToken, conn: DbConn) -> EmptyResult { +#[post("/invite", data = "<data>")] +fn invite_user(data: JsonUpcase<InviteData>, _token: AdminToken, conn: DbConn) -> JsonResult { let data: InviteData = data.into_inner().data; if User::find_by_mail(&data.Email, &conn).is_some() { @@ -42,30 +39,30 @@ fn invite_user(data: JsonUpcase<InviteData>, _token: AdminToken, conn: DbConn) - err!("Unimplemented") } -#[delete("/users/<uuid>")] -fn delete_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult { - let _user = match User::find_by_uuid(&uuid, &conn) { +#[post("/users/<uuid>/delete")] +fn delete_user(uuid: String, _token: AdminToken, conn: DbConn) -> JsonResult { + let user = match User::find_by_uuid(&uuid, &conn) { Some(user) => user, - None => err!("User doesn't exist") + None => err!("User doesn't exist"), }; - // TODO: Enable this once we have a more secure auth method - err!("Unimplemented") - /* match user.delete(&conn) { - Ok(_) => Ok(()), - Err(e) => err!("Error deleting user", e) + Ok(_) => Ok(Json(json!({}))), + Err(e) => err!("Error deleting user", e), } - */ } - pub struct AdminToken {} impl<'a, 'r> FromRequest<'a, 'r> for AdminToken { type Error = &'static str; fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> { + let config_token = match CONFIG.admin_token.as_ref() { + Some(token) => token, + None => err_handler!("Admin panel is disabled"), + }; + // Get access_token let access_token: &str = match request.headers().get_one("Authorization") { Some(a) => match a.rsplit("Bearer ").next() { @@ -81,10 +78,10 @@ impl<'a, 'r> FromRequest<'a, 'r> for AdminToken { // Option 2a: Send it to admin email, like upstream // Option 2b: Print in console or save to data dir, so admin can check - if access_token != "token123" { + if access_token != config_token { err_handler!("Invalid admin token") } Outcome::Success(AdminToken {}) } -} -\ No newline at end of file +} diff --git a/src/db/models/two_factor.rs b/src/db/models/two_factor.rs @@ -107,4 +107,12 @@ impl TwoFactor { .filter(twofactor::type_.eq(type_)) .first::<Self>(&**conn).ok() } -} -\ No newline at end of file + + pub fn delete_all_by_user(user_uuid: &str, conn: &DbConn) -> QueryResult<usize> { + diesel::delete( + twofactor::table.filter( + twofactor::user_uuid.eq(user_uuid) + ) + ).execute(&**conn) + } +} diff --git a/src/db/models/user.rs b/src/db/models/user.rs @@ -113,7 +113,7 @@ use diesel; use diesel::prelude::*; use crate::db::DbConn; use crate::db::schema::{users, invitations}; -use super::{Cipher, Folder, Device, UserOrganization, UserOrgType}; +use super::{Cipher, Folder, Device, UserOrganization, UserOrgType, TwoFactor}; /// Database methods impl User { @@ -168,6 +168,7 @@ impl User { Cipher::delete_all_by_user(&self.uuid, &*conn)?; Folder::delete_all_by_user(&self.uuid, &*conn)?; Device::delete_all_by_user(&self.uuid, &*conn)?; + TwoFactor::delete_all_by_user(&self.uuid, &*conn)?; Invitation::take(&self.email, &*conn); // Delete invitation if any diesel::delete(users::table.filter( diff --git a/src/main.rs b/src/main.rs @@ -272,6 +272,7 @@ pub struct Config { local_icon_extractor: bool, signups_allowed: bool, invitations_allowed: bool, + admin_token: Option<String>, server_admin_email: Option<String>, password_iterations: i32, show_password_hint: bool, @@ -325,7 +326,8 @@ impl Config { local_icon_extractor: get_env_or("LOCAL_ICON_EXTRACTOR", false), signups_allowed: get_env_or("SIGNUPS_ALLOWED", true), - server_admin_email: get_env("SERVER_ADMIN_EMAIL"), + admin_token: get_env("ADMIN_TOKEN"), + server_admin_email:None, // TODO: Delete this invitations_allowed: get_env_or("INVITATIONS_ALLOWED", true), password_iterations: get_env_or("PASSWORD_ITERATIONS", 100_000), show_password_hint: get_env_or("SHOW_PASSWORD_HINT", true), diff --git a/src/static/admin.html b/src/static/admin.html @@ -20,13 +20,12 @@ <style> body { padding-top: 70px; } img { width: 48px; height: 48px; } - #logo { width: 48px; height: 48px; } </style> <script> let key = null; - function getIdenticon(email) { + function identicon(email) { const data = new Identicon(md5(email), { size: 48, format: 'svg' @@ -35,41 +34,97 @@ return "data:image/svg+xml;base64," + data; } + function setVis(elem, vis) { + if (vis) { $(elem).removeClass('d-none'); } + else { $(elem).addClass('d-none'); } + } + + function updateVis() { + setVis("#no-key-form", !key); + setVis("#users-block", key); + setVis("#invite-form", key); + } + + function setKey() { + key = $('#key').val() || window.location.hash.slice(1); + updateVis(); + if (key) { loadUsers(); } + return false; + } + + function resetKey() { + key = null; + updateVis(); + } + + function fillRow(data) { + for (i in data) { + const user = data[i]; + const row = $("#tmp-row").clone(); + + row.attr("id", "user-row:" + user.Id); + row.find(".tmp-name").text(user.Name); + row.find(".tmp-mail").text(user.Email); + row.find(".tmp-icon").attr("src", identicon(user.Email)) + + row.find(".tmp-del").on("click", function (e) { + if (confirm("Delete User '" + user.Name + "'?")) { + deleteUser(user.Id); + } + return false; + }); + + row.appendTo("#users-list"); + setVis(row, true); + } + } + + function _headers() { return { "Authorization": "Bearer " + key }; } + function loadUsers() { $("#users-list").empty(); + $.get({ url: "/admin/users", headers: _headers() }) + .done(fillRow) + .fail(resetKey); + + return false; + } - $.ajax({ - type: "GET", - url: "/admin/users", - headers: { "Authorization": "Bearer " + key } - }).done(function (data) { - for (i in data) { - let user = data[i]; - let row = $("#tmp-user-row").clone(); - - row.attr("id", "user-row:" + user.Id); - row.find(".tmp-user-name").text(user.Name); - row.find(".tmp-user-mail").text(user.Email); - row.find(".tmp-user-icon").attr("src", getIdenticon(user.Email)) - - row.find(".tmp-user-del").on("click", function (e) { - alert("Not Implemented: Deleting UUID " + user.Id); - }); - - row.appendTo("#users-list"); - row.removeClass('d-none'); - } - }) + function _post(url, successMsg, errMsg, resetOnErr, data) { + $.post({ url: url, headers: _headers(), data: data }) + .done(() => { + alert(successMsg); + loadUsers(); + }) + .fail((e) => { + const msg = e.responseJSON ? + e.responseJSON.ErrorModel.Message + : "Unknown error"; + alert(errMsg + ": " + msg); + if (resetOnErr) { resetKey(); } + }); + } + + function deleteUser(id) { + _post("/admin/users/" + id + "/delete", + "User deleted correctly", + "Error deleting user", true); + } + + function inviteUser() { + data = JSON.stringify({ "Email": $("#email-invite").val() }); + + _post("/admin/invite/", + "User invited correctly", + "Error inviting user", false, data); } $(window).on('load', function () { - key = new URLSearchParams(window.location.search).get('key'); - if (key) { - $("#no-key-form").addClass('d-none'); - loadUsers(); - } else { - $("#users-block").addClass('d-none'); - } + setKey(); + + $("#key-form").submit(setKey); + $("#reload-btn").on("click", loadUsers); + $("#invite-form").submit(inviteUser); }); </script> </head> @@ -89,36 +144,48 @@ </div> </nav> <main class="container"> - <div id="no-key-form" class="align-items-center p-3 mb-3 text-white-50 bg-danger rounded shadow"> + <div id="no-key-form" class="d-none align-items-center p-3 mb-3 text-white-50 bg-danger rounded shadow"> <div> <h6 class="mb-0 text-white">Authentication key needed to continue</h6> <small>Please provide it below:</small> - <form class="form-inline" method="get"> - <input type="text" class="form-control mr-2" id="key" name="key" placeholder="Enter admin key"> - <button type="submit" class="btn btn-primary">Submit</button> + <form class="form-inline" id="key-form"> + <input type="password" class="form-control w-50 mr-2" id="key" placeholder="Enter admin key"> + <button type="submit" class="btn btn-primary">Save</button> </form> </div> </div> - <div id="users-block" class="my-3 p-3 bg-white rounded shadow"> + <div id="users-block" class="d-none my-3 p-3 bg-white rounded shadow"> <h6 class="border-bottom pb-2 mb-0">Registered Users</h6> <div id="users-list"></div> <small class="d-block text-right mt-3"> - <a href="#" onclick="loadUsers();">Reload users</a> + <a id="reload-btn" href="#">Reload users</a> </small> </div> - <div id="tmp-user-row" class="d-none media pt-3"> - <img src="#" alt="identicon" class="mr-2 rounded tmp-user-icon"> + <div id="invite-form" class="d-none align-items-center p-3 mb-3 text-white-50 bg-secondary rounded shadow"> + <div> + <h6 class="mb-0 text-white">Invite User</h6> + <small>Email:</small> + + <form class="form-inline" id="invite-form"> + <input type="email" class="form-control w-50 mr-2" id="email-invite" placeholder="Enter email"> + <button type="submit" class="btn btn-primary">Invite</button> + </form> + </div> + </div> + + <div id="tmp-row" class="d-none media pt-3"> + <img class="mr-2 rounded tmp-icon"> <div class="media-body pb-3 mb-0 small border-bottom"> <div class="d-flex justify-content-between"> - <strong class="tmp-user-name">Full Name</strong> - <a class="tmp-user-del mr-3" href="#">Delete User</a> + <strong class="tmp-name">Full Name</strong> + <a class="tmp-del mr-3" href="#">Delete User</a> </div> - <span class="d-block tmp-user-mail">Email</span> + <span class="d-block tmp-mail">Email</span> </div> </div> </main>