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 834c84774638b994b13fb967b09511fbec6ed986
parent 97aa407fe4eadb00f048f1700154359cecfe5a0b
Author: Daniel GarcĂ­a <dani-garcia@users.noreply.github.com>
Date:   Sat, 19 Jan 2019 21:36:34 +0100

Implement admin JWT cookie, separate JWT issuers for each type of token and migrate admin page to handlebars template

Diffstat:
Msrc/api/admin.rs | 127++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Msrc/api/core/accounts.rs | 4++--
Msrc/api/core/organizations.rs | 4++--
Msrc/api/notifications.rs | 2+-
Msrc/api/web.rs | 49++++---------------------------------------------
Msrc/auth.rs | 73++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Msrc/db/models/device.rs | 6+++---
Msrc/main.rs | 10+++++++---
Dsrc/static/admin.html | 196-------------------------------------------------------------------------------
Asrc/static/templates/admin/admin_login.hbs | 55+++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/static/templates/admin/admin_page.hbs | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/util.rs | 29++++++++++++++++++++++++++++-
12 files changed, 364 insertions(+), 316 deletions(-)

diff --git a/src/api/admin.rs b/src/api/admin.rs @@ -1,32 +1,99 @@ use rocket_contrib::json::Json; use serde_json::Value; +use rocket::http::{Cookie, Cookies}; +use rocket::request::{self, FlashMessage, Form, FromRequest, Request}; +use rocket::response::{content::Html, Flash, Redirect}; +use rocket::{Outcome, Route}; + use crate::api::{JsonResult, JsonUpcase}; +use crate::auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp}; +use crate::db::{models::*, DbConn}; +use crate::error::Error; +use crate::mail; use crate::CONFIG; -use crate::db::models::*; -use crate::db::DbConn; -use crate::mail; +pub fn routes() -> Vec<Route> { + if CONFIG.admin_token.is_none() { + return Vec::new(); + } -use rocket::request::{self, FromRequest, Request}; -use rocket::{Outcome, Route}; + routes![admin_login, post_admin_login, admin_page, invite_user, delete_user] +} -pub fn routes() -> Vec<Route> { - routes![get_users, invite_user, delete_user] +#[derive(FromForm)] +struct LoginForm { + token: String, } -#[derive(Deserialize, Debug)] -#[allow(non_snake_case)] -struct InviteData { - Email: String, +const COOKIE_NAME: &'static str = "BWRS_ADMIN"; +const ADMIN_PATH: &'static str = "/admin"; + +#[get("/", rank = 2)] +fn admin_login(flash: Option<FlashMessage>) -> Result<Html<String>, Error> { + // If there is an error, show it + let msg = flash + .map(|msg| format!("{}: {}", msg.name(), msg.msg())) + .unwrap_or_default(); + let error = json!({ "error": msg }); + + // Return the page + let text = CONFIG.templates.render("admin/admin_login", &error)?; + Ok(Html(text)) } -#[get("/users")] -fn get_users(_token: AdminToken, conn: DbConn) -> JsonResult { +#[post("/", data = "<data>")] +fn post_admin_login(data: Form<LoginForm>, mut cookies: Cookies, ip: ClientIp) -> Result<Redirect, Flash<Redirect>> { + let data = data.into_inner(); + + if !_validate_token(&data.token) { + error!("Invalid admin token. IP: {}", ip.ip); + Err(Flash::error( + Redirect::to(ADMIN_PATH), + "Invalid admin token, please try again.", + )) + } else { + // If the token received is valid, generate JWT and save it as a cookie + let claims = generate_admin_claims(); + let jwt = encode_jwt(&claims); + + let cookie = Cookie::build(COOKIE_NAME, jwt) + .path(ADMIN_PATH) + .http_only(true) + .finish(); + + cookies.add(cookie); + Ok(Redirect::to(ADMIN_PATH)) + } +} + +fn _validate_token(token: &str) -> bool { + match CONFIG.admin_token.as_ref() { + None => false, + Some(t) => t == token, + } +} + +#[derive(Serialize)] +struct AdminTemplateData { + users: Vec<Value>, +} + +#[get("/", rank = 1)] +fn admin_page(_token: AdminToken, conn: DbConn) -> Result<Html<String>, Error> { 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))) + let data = AdminTemplateData { users: users_json }; + + let text = CONFIG.templates.render("admin/admin_page", &data)?; + Ok(Html(text)) +} + +#[derive(Deserialize, Debug)] +#[allow(non_snake_case)] +struct InviteData { + Email: String, } #[post("/invite", data = "<data>")] @@ -71,35 +138,23 @@ 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"), - }; + let mut cookies = request.cookies(); - // Get access_token - let access_token: &str = match request.headers().get_one("Authorization") { - Some(a) => match a.rsplit("Bearer ").next() { - Some(split) => split, - None => err_handler!("No access token provided"), - }, - None => err_handler!("No access token provided"), + let access_token = match cookies.get(COOKIE_NAME) { + Some(cookie) => cookie.value(), + None => return Outcome::Forward(()), // If there is no cookie, redirect to login }; - // TODO: What authentication to use? - // Option 1: Make it a config option - // Option 2: Generate random token, and - // Option 2a: Send it to admin email, like upstream - // Option 2b: Print in console or save to data dir, so admin can check - - use crate::auth::ClientIp; - let ip = match request.guard::<ClientIp>() { - Outcome::Success(ip) => ip, + Outcome::Success(ip) => ip.ip, _ => err_handler!("Error getting Client IP"), }; - if access_token != config_token { - err_handler!("Invalid admin token", format!("IP: {}.", ip.ip)) + if decode_admin(access_token).is_err() { + // Remove admin cookie + cookies.remove(Cookie::named(COOKIE_NAME)); + error!("Invalid or expired admin JWT. IP: {}.", ip); + return Outcome::Forward(()); } Outcome::Success(AdminToken {}) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs @@ -4,7 +4,7 @@ use crate::db::models::*; use crate::db::DbConn; use crate::api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType}; -use crate::auth::{decode_invite_jwt, Headers, InviteJWTClaims}; +use crate::auth::{decode_invite, Headers}; use crate::mail; use crate::CONFIG; @@ -66,7 +66,7 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult { } if let Some(token) = data.Token { - let claims: InviteJWTClaims = decode_invite_jwt(&token)?; + let claims = decode_invite(&token)?; if claims.email == data.Email { user } else { diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs @@ -7,7 +7,7 @@ use crate::db::DbConn; use crate::CONFIG; use crate::api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType}; -use crate::auth::{decode_invite_jwt, AdminHeaders, Headers, InviteJWTClaims, OwnerHeaders}; +use crate::auth::{decode_invite, AdminHeaders, Headers, OwnerHeaders}; use crate::mail; @@ -582,7 +582,7 @@ fn accept_invite(_org_id: String, _org_user_id: String, data: JsonUpcase<AcceptD // The web-vault passes org_id and org_user_id in the URL, but we are just reading them from the JWT instead let data: AcceptData = data.into_inner().data; let token = &data.Token; - let claims: InviteJWTClaims = decode_invite_jwt(&token)?; + let claims = decode_invite(&token)?; match User::find_by_mail(&claims.email, &conn) { Some(_) => { diff --git a/src/api/notifications.rs b/src/api/notifications.rs @@ -135,7 +135,7 @@ impl Handler for WSHandler { // Validate the user use crate::auth; - let claims = match auth::decode_jwt(access_token) { + let claims = match auth::decode_login(access_token) { Ok(claims) => claims, Err(_) => return Err(ws::Error::new(ws::ErrorKind::Internal, "Invalid access token provided")), }; diff --git a/src/api/web.rs b/src/api/web.rs @@ -2,18 +2,18 @@ use std::io; use std::path::{Path, PathBuf}; use rocket::http::ContentType; -use rocket::request::Request; use rocket::response::content::Content; -use rocket::response::{self, NamedFile, Responder}; +use rocket::response::NamedFile; use rocket::Route; use rocket_contrib::json::Json; use serde_json::Value; use crate::CONFIG; +use crate::util::Cached; pub fn routes() -> Vec<Route> { if CONFIG.web_vault_enabled { - routes![web_index, app_id, web_files, admin_page, attachments, alive] + routes![web_index, app_id, web_files, attachments, alive] } else { routes![attachments, alive] } @@ -43,52 +43,11 @@ fn app_id() -> Cached<Content<Json<Value>>> { )) } -const ADMIN_PAGE: &'static str = include_str!("../static/admin.html"); -use rocket::response::content::Html; - -#[get("/admin")] -fn admin_page() -> Cached<Html<&'static str>> { - Cached::short(Html(ADMIN_PAGE)) -} - -/* // Use this during Admin page development -#[get("/admin")] -fn admin_page() -> Cached<io::Result<NamedFile>> { - Cached::short(NamedFile::open("src/static/admin.html")) -} -*/ - -#[get("/<p..>", rank = 1)] // Only match this if the other routes don't match +#[get("/<p..>", rank = 10)] // Only match this if the other routes don't match fn web_files(p: PathBuf) -> Cached<io::Result<NamedFile>> { Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder).join(p))) } -struct Cached<R>(R, &'static str); - -impl<R> Cached<R> { - fn long(r: R) -> Cached<R> { - // 7 days - Cached(r, "public, max-age=604800") - } - - fn short(r: R) -> Cached<R> { - // 10 minutes - Cached(r, "public, max-age=600") - } -} - -impl<'r, R: Responder<'r>> Responder<'r> for Cached<R> { - fn respond_to(self, req: &Request) -> response::Result<'r> { - match self.0.respond_to(req) { - Ok(mut res) => { - res.set_raw_header("Cache-Control", self.1); - Ok(res) - } - e @ Err(_) => e, - } - } -} - #[get("/attachments/<uuid>/<file..>")] fn attachments(uuid: String, file: PathBuf) -> io::Result<NamedFile> { NamedFile::open(Path::new(&CONFIG.attachments_folder).join(uuid).join(file)) diff --git a/src/auth.rs b/src/auth.rs @@ -5,6 +5,7 @@ use crate::util::read_file; use chrono::{Duration, Utc}; use jsonwebtoken::{self, Algorithm, Header}; +use serde::de::DeserializeOwned; use serde::ser::Serialize; use crate::error::{Error, MapResult}; @@ -14,8 +15,10 @@ const JWT_ALGORITHM: Algorithm = Algorithm::RS256; lazy_static! { pub static ref DEFAULT_VALIDITY: Duration = Duration::hours(2); - pub static ref JWT_ISSUER: String = CONFIG.domain.clone(); static ref JWT_HEADER: Header = Header::new(JWT_ALGORITHM); + pub static ref JWT_LOGIN_ISSUER: String = format!("{}|login", CONFIG.domain); + pub static ref JWT_INVITE_ISSUER: String = format!("{}|invite", CONFIG.domain); + pub static ref JWT_ADMIN_ISSUER: String = format!("{}|admin", CONFIG.domain); static ref PRIVATE_RSA_KEY: Vec<u8> = match read_file(&CONFIG.private_rsa_key) { Ok(key) => key, Err(e) => panic!( @@ -39,14 +42,14 @@ pub fn encode_jwt<T: Serialize>(claims: &T) -> String { } } -pub fn decode_jwt(token: &str) -> Result<JWTClaims, Error> { +fn decode_jwt<T: DeserializeOwned>(token: &str, issuer: String) -> Result<T, Error> { let validation = jsonwebtoken::Validation { leeway: 30, // 30 seconds validate_exp: true, validate_iat: false, // IssuedAt is the same as NotBefore validate_nbf: true, aud: None, - iss: Some(JWT_ISSUER.clone()), + iss: Some(issuer), sub: None, algorithms: vec![JWT_ALGORITHM], }; @@ -55,30 +58,23 @@ pub fn decode_jwt(token: &str) -> Result<JWTClaims, Error> { jsonwebtoken::decode(&token, &PUBLIC_RSA_KEY, &validation) .map(|d| d.claims) - .map_res("Error decoding login JWT") + .map_res("Error decoding JWT") } -pub fn decode_invite_jwt(token: &str) -> Result<InviteJWTClaims, Error> { - let validation = jsonwebtoken::Validation { - leeway: 30, // 30 seconds - validate_exp: true, - validate_iat: false, // IssuedAt is the same as NotBefore - validate_nbf: true, - aud: None, - iss: Some(JWT_ISSUER.clone()), - sub: None, - algorithms: vec![JWT_ALGORITHM], - }; +pub fn decode_login(token: &str) -> Result<LoginJWTClaims, Error> { + decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) +} - let token = token.replace(char::is_whitespace, ""); +pub fn decode_invite(token: &str) -> Result<InviteJWTClaims, Error> { + decode_jwt(token, JWT_INVITE_ISSUER.to_string()) +} - jsonwebtoken::decode(&token, &PUBLIC_RSA_KEY, &validation) - .map(|d| d.claims) - .map_res("Error decoding invite JWT") +pub fn decode_admin(token: &str) -> Result<AdminJWTClaims, Error> { + decode_jwt(token, JWT_ADMIN_ISSUER.to_string()) } #[derive(Debug, Serialize, Deserialize)] -pub struct JWTClaims { +pub struct LoginJWTClaims { // Not before pub nbf: i64, // Expiration time @@ -125,17 +121,18 @@ pub struct InviteJWTClaims { pub invited_by_email: Option<String>, } -pub fn generate_invite_claims(uuid: String, - email: String, - org_id: Option<String>, - org_user_id: Option<String>, - invited_by_email: Option<String>, +pub fn generate_invite_claims( + uuid: String, + email: String, + org_id: Option<String>, + org_user_id: Option<String>, + invited_by_email: Option<String>, ) -> InviteJWTClaims { let time_now = Utc::now().naive_utc(); InviteJWTClaims { nbf: time_now.timestamp(), exp: (time_now + Duration::days(5)).timestamp(), - iss: JWT_ISSUER.to_string(), + iss: JWT_INVITE_ISSUER.to_string(), sub: uuid.clone(), email: email.clone(), org_id: org_id.clone(), @@ -144,6 +141,28 @@ pub fn generate_invite_claims(uuid: String, } } +#[derive(Debug, Serialize, Deserialize)] +pub struct AdminJWTClaims { + // Not before + pub nbf: i64, + // Expiration time + pub exp: i64, + // Issuer + pub iss: String, + // Subject + pub sub: String, +} + +pub fn generate_admin_claims() -> AdminJWTClaims { + let time_now = Utc::now().naive_utc(); + AdminJWTClaims { + nbf: time_now.timestamp(), + exp: (time_now + Duration::minutes(20)).timestamp(), + iss: JWT_ADMIN_ISSUER.to_string(), + sub: "admin_panel".to_string(), + } +} + // // Bearer token authentication // @@ -203,7 +222,7 @@ impl<'a, 'r> FromRequest<'a, 'r> for Headers { }; // Check JWT token is valid and get device and user from it - let claims: JWTClaims = match decode_jwt(access_token) { + let claims = match decode_login(access_token) { Ok(claims) => claims, Err(_) => err_handler!("Invalid claim"), }; diff --git a/src/db/models/device.rs b/src/db/models/device.rs @@ -77,11 +77,11 @@ impl Device { // Create the JWT claims struct, to send to the client - use crate::auth::{encode_jwt, JWTClaims, DEFAULT_VALIDITY, JWT_ISSUER}; - let claims = JWTClaims { + use crate::auth::{encode_jwt, LoginJWTClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER}; + let claims = LoginJWTClaims { nbf: time_now.timestamp(), exp: (time_now + *DEFAULT_VALIDITY).timestamp(), - iss: JWT_ISSUER.to_string(), + iss: JWT_LOGIN_ISSUER.to_string(), sub: user.uuid.to_string(), premium: true, diff --git a/src/main.rs b/src/main.rs @@ -95,6 +95,7 @@ fn init_logging() -> Result<(), fern::InitError> { .level(log::LevelFilter::Debug) .level_for("hyper", log::LevelFilter::Warn) .level_for("rustls", log::LevelFilter::Warn) + .level_for("handlebars", log::LevelFilter::Warn) .level_for("ws", log::LevelFilter::Info) .level_for("multipart", log::LevelFilter::Info) .chain(std::io::stdout()); @@ -338,19 +339,22 @@ fn load_templates(path: String) -> Handlebars { hb.set_strict_mode(true); macro_rules! reg { - ($name:expr) => { + ($name:expr) => {{ let template = include_str!(concat!("static/templates/", $name, ".hbs")); hb.register_template_string($name, template).unwrap(); - }; + }}; } - // First register default templates here (use include_str?) + // First register default templates here reg!("email/invite_accepted"); reg!("email/invite_confirmed"); reg!("email/pw_hint_none"); reg!("email/pw_hint_some"); reg!("email/send_org_invite"); + reg!("admin/admin_login"); + reg!("admin/admin_page"); + // And then load user templates to overwrite the defaults // Use .hbs extension for the files // Templates get registered with their relative name diff --git a/src/static/admin.html b/src/static/admin.html @@ -1,195 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> - -<head> - <meta http-equiv="content-type" content="text/html; charset=UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> - <meta name="description" content=""> - <meta name="author" content=""> - <title>Bitwarden_rs Admin Panel</title> - - <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.1.3/css/bootstrap.min.css" - integrity="sha256-eSi1q2PG6J7g7ib17yAaWMcrr5GrtohYChqibrV7PBE=" crossorigin="anonymous" /> - <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" - crossorigin="anonymous"></script> - <script src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-md5/2.10.0/js/md5.js" integrity="sha256-tCQ/BldMlN2vWe5gAiNoNb5svoOgVUhlUgv7UjONKKQ=" - crossorigin="anonymous"></script> - <script src="https://cdnjs.cloudflare.com/ajax/libs/identicon.js/2.3.3/identicon.min.js" integrity="sha256-nYoL3nK/HA1e1pJvLwNPnpKuKG9q89VFX862r5aohmA=" - crossorigin="anonymous"></script> - - <style> - body { padding-top: 70px; } - img { width: 48px; height: 48px; } - </style> - - <script> - let key = null; - - function identicon(email) { - const data = new Identicon(md5(email), { - size: 48, format: 'svg' - }).toString(); - 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-block", 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) { - var name = prompt("To delete user '" + user.Name + "', please type the name below") - if (name) { - if (name == user.Name) { - deleteUser(user.Id); - } else { - alert("Wrong name, please try again") - } - } - 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; - } - - function _post(url, successMsg, errMsg, resetOnErr, data) { - $.post({ url: url, headers: _headers(), data: data }) - .done(function () { - alert(successMsg); - loadUsers(); - }).fail(function (e) { - const r = e.responseJSON; - const msg = r ? r.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() { - inv = $("#email-invite"); - data = JSON.stringify({ "Email": inv.val() }); - inv.val(""); - _post("/admin/invite/", "User invited correctly", - "Error inviting user", false, data); - return false; - } - - $(window).on('load', function () { - setKey(); - - $("#key-form").submit(setKey); - $("#reload-btn").click(loadUsers); - $("#invite-form").submit(inviteUser); - }); - </script> -</head> - -<body class="bg-light"> - <nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top shadow"> - <a class="navbar-brand" href="#">Bitwarden_rs</a> - <div class="navbar-collapse"> - <ul class="navbar-nav"> - <li class="nav-item active"> - <a class="nav-link" href="/admin">Admin Panel</a> - </li> - <li class="nav-item"> - <a class="nav-link" href="/">Vault</a> - </li> - </ul> - </div> - </nav> - <main class="container"> - <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" 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="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 id="reload-btn" href="#">Reload users</a> - </small> - </div> - - <div id="invite-form-block" 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-name">Full Name</strong> - <a class="tmp-del mr-3" href="#">Delete User</a> - </div> - <span class="d-block tmp-mail">Email</span> - </div> - </div> - </main> -</body> - -</html> -\ No newline at end of file diff --git a/src/static/templates/admin/admin_login.hbs b/src/static/templates/admin/admin_login.hbs @@ -0,0 +1,54 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta http-equiv="content-type" content="text/html; charset=UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <title>Bitwarden_rs Admin Panel</title> + + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.2.1/css/bootstrap.min.css" integrity="sha256-azvvU9xKluwHFJ0Cpgtf0CYzK7zgtOznnzxV4924X1w=" crossorigin="anonymous" /> + <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script> + + <style> + body { padding-top: 70px; } + </style> +</head> + +<body class="bg-light"> + <nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top shadow"> + <a class="navbar-brand" href="#">Bitwarden_rs</a> + <div class="navbar-collapse"> + <ul class="navbar-nav"> + <li class="nav-item active"> + <a class="nav-link" href="/admin">Admin Panel</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="/">Vault</a> + </li> + </ul> + </div> + </nav> + <main class="container"> + {{#if error}} + <div class="align-items-center p-3 mb-3 text-white-50 bg-warning rounded shadow"> + <div> + <h6 class="mb-0 text-white">{{error}}</h6> + </div> + </div> + {{/if}} + + <div class="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="post"> + <input type="password" class="form-control w-50 mr-2" name="token" placeholder="Enter admin token"> + <button type="submit" class="btn btn-primary">Save</button> + </form> + </div> + </div> + </main> +</body> + +</html> +\ No newline at end of file diff --git a/src/static/templates/admin/admin_page.hbs b/src/static/templates/admin/admin_page.hbs @@ -0,0 +1,124 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta http-equiv="content-type" content="text/html; charset=UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <title>Bitwarden_rs Admin Panel</title> + + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.2.1/css/bootstrap.min.css" + integrity="sha256-azvvU9xKluwHFJ0Cpgtf0CYzK7zgtOznnzxV4924X1w=" crossorigin="anonymous" /> + <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" + crossorigin="anonymous"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/blueimp-md5/2.10.0/js/md5.js" integrity="sha256-tCQ/BldMlN2vWe5gAiNoNb5svoOgVUhlUgv7UjONKKQ=" + crossorigin="anonymous"></script> + <script src="https://cdnjs.cloudflare.com/ajax/libs/identicon.js/2.3.3/identicon.min.js" integrity="sha256-nYoL3nK/HA1e1pJvLwNPnpKuKG9q89VFX862r5aohmA=" + crossorigin="anonymous"></script> + + <style> + body { padding-top: 70px; } + img { width: 48px; height: 48px; } + </style> + + <script> + function reload() { window.location.reload(); } + function identicon(email) { + const data = new Identicon(md5(email), { size: 48, format: 'svg' }); + return "data:image/svg+xml;base64," + data.toString(); + } + function _post(url, successMsg, errMsg, data) { + $.post({ url: url, data: data }) + .done(function () { + alert(successMsg); + reload(); + }).fail(function (e) { + const r = e.responseJSON; + const msg = r ? r.ErrorModel.Message : "Unknown error"; + alert(errMsg + ": " + msg); + }); + } + function deleteUser(id, mail) { + var input_mail = prompt("To delete user '" + mail + "', please type the name below") + if (input_mail) { + if (input_mail == mail) { + _post("/admin/users/" + id + "/delete", + "User deleted correctly", + "Error deleting user"); + } else { + alert("Wrong email, please try again") + } + } + } + function inviteUser() { + inv = $("#email-invite"); + data = JSON.stringify({ "Email": inv.val() }); + inv.val(""); + _post("/admin/invite/", "User invited correctly", + "Error inviting user", data); + } + + $(window).on('load', function () { + //$("#reload-btn").click(reload); + $("#invite-form").submit(inviteUser); + $("img.identicon").each(function (i, e) { + e.src = identicon(e.dataset.src); + }); + }); + </script> +</head> + +<body class="bg-light"> + <nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top shadow"> + <a class="navbar-brand" href="#">Bitwarden_rs</a> + <div class="navbar-collapse"> + <ul class="navbar-nav"> + <li class="nav-item active"> + <a class="nav-link" href="/admin">Admin Panel</a> + </li> + <li class="nav-item"> + <a class="nav-link" href="/">Vault</a> + </li> + </ul> + </div> + </nav> + <main class="container"> + <div id="users-block" class="my-3 p-3 bg-white rounded shadow"> + <h6 class="border-bottom pb-2 mb-0">Registered Users</h6> + + <div id="users-list"> + {{#each users}} + <div class="media pt-3"> + {{!-- row.find(".tmp-icon").attr("src", identicon(user.Email)) --}} + <img class="mr-2 rounded identicon" data-src="{{Email}}"> + <div class="media-body pb-3 mb-0 small border-bottom"> + <div class="d-flex justify-content-between"> + <strong>{{Name}}</strong> + <a class="tmp-del mr-3" href="" onclick='deleteUser("{{Id}}", "{{Email}}");'>Delete User</a> + </div> + <span class="d-block">{{Email}}</span> + </div> + </div> + {{/each}} + + </div> + + <small class="d-block text-right mt-3"> + <a id="reload-btn" href="">Reload users</a> + </small> + </div> + + <div id="invite-form-block" class="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> + </main> +</body> + +</html> +\ No newline at end of file diff --git a/src/util.rs b/src/util.rs @@ -1,8 +1,9 @@ // -// Web Headers +// Web Headers and caching // use rocket::fairing::{Fairing, Info, Kind}; use rocket::{Request, Response}; +use rocket::response::{self, Responder}; pub struct AppHeaders(); @@ -30,6 +31,32 @@ impl Fairing for AppHeaders { } } +pub struct Cached<R>(R, &'static str); + +impl<R> Cached<R> { + pub fn long(r: R) -> Cached<R> { + // 7 days + Cached(r, "public, max-age=604800") + } + + pub fn short(r: R) -> Cached<R> { + // 10 minutes + Cached(r, "public, max-age=600") + } +} + +impl<'r, R: Responder<'r>> Responder<'r> for Cached<R> { + fn respond_to(self, req: &Request) -> response::Result<'r> { + match self.0.respond_to(req) { + Ok(mut res) => { + res.set_raw_header("Cache-Control", self.1); + Ok(res) + } + e @ Err(_) => e, + } + } +} + // // File handling //