From 834c84774638b994b13fb967b09511fbec6ed986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Garc=C3=ADa?= Date: Sat, 19 Jan 2019 21:36:34 +0100 Subject: [PATCH] Implement admin JWT cookie, separate JWT issuers for each type of token and migrate admin page to handlebars template --- src/api/admin.rs | 135 +++++++++----- src/api/core/accounts.rs | 4 +- src/api/core/organizations.rs | 4 +- src/api/notifications.rs | 2 +- src/api/web.rs | 49 +----- src/auth.rs | 73 +++++--- src/db/models/device.rs | 6 +- src/main.rs | 10 +- src/static/admin.html | 195 --------------------- src/static/templates/admin/admin_login.hbs | 54 ++++++ src/static/templates/admin/admin_page.hbs | 124 +++++++++++++ src/util.rs | 29 ++- 12 files changed, 366 insertions(+), 319 deletions(-) delete mode 100644 src/static/admin.html create mode 100644 src/static/templates/admin/admin_login.hbs create mode 100644 src/static/templates/admin/admin_page.hbs diff --git a/src/api/admin.rs b/src/api/admin.rs index 98448d3..af3fa52 100644 --- a/src/api/admin.rs +++ b/src/api/admin.rs @@ -1,18 +1,93 @@ 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::mail; - -use rocket::request::{self, FromRequest, Request}; +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; + pub fn routes() -> Vec { - routes![get_users, invite_user, delete_user] + if CONFIG.admin_token.is_none() { + return Vec::new(); + } + + routes![admin_login, post_admin_login, admin_page, invite_user, delete_user] +} + +#[derive(FromForm)] +struct LoginForm { + token: String, +} + +const COOKIE_NAME: &'static str = "BWRS_ADMIN"; +const ADMIN_PATH: &'static str = "/admin"; + +#[get("/", rank = 2)] +fn admin_login(flash: Option) -> Result, 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)) +} + +#[post("/", data = "")] +fn post_admin_login(data: Form, mut cookies: Cookies, ip: ClientIp) -> Result> { + 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, +} + +#[get("/", rank = 1)] +fn admin_page(_token: AdminToken, conn: DbConn) -> Result, Error> { + let users = User::get_all(&conn); + let users_json: Vec = users.iter().map(|u| u.to_json(&conn)).collect(); + + let data = AdminTemplateData { users: users_json }; + + let text = CONFIG.templates.render("admin/admin_page", &data)?; + Ok(Html(text)) } #[derive(Deserialize, Debug)] @@ -21,14 +96,6 @@ struct InviteData { Email: String, } -#[get("/users")] -fn get_users(_token: AdminToken, conn: DbConn) -> JsonResult { - let users = User::get_all(&conn); - let users_json: Vec = users.iter().map(|u| u.to_json(&conn)).collect(); - - Ok(Json(Value::Array(users_json))) -} - #[post("/invite", data = "")] fn invite_user(data: JsonUpcase, _token: AdminToken, conn: DbConn) -> JsonResult { let data: InviteData = data.into_inner().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 { - let config_token = match CONFIG.admin_token.as_ref() { - Some(token) => token, - None => err_handler!("Admin panel is disabled"), + let mut cookies = request.cookies(); + + let access_token = match cookies.get(COOKIE_NAME) { + Some(cookie) => cookie.value(), + None => return Outcome::Forward(()), // If there is no cookie, redirect to login }; - // 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"), - }; - - // 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::() { - 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 index ae76734..e60e568 100644 --- 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, 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 index 6e27890..318c641 100644 --- 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 { diff --git a/src/api/notifications.rs b/src/api/notifications.rs index 0c78b5a..177eb20 100644 --- 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 index 4ab5ec3..6625dc4 100644 --- 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 { 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>> { )) } -const ADMIN_PAGE: &'static str = include_str!("../static/admin.html"); -use rocket::response::content::Html; - -#[get("/admin")] -fn admin_page() -> Cached> { - Cached::short(Html(ADMIN_PAGE)) -} - -/* // Use this during Admin page development -#[get("/admin")] -fn admin_page() -> Cached> { - Cached::short(NamedFile::open("src/static/admin.html")) -} -*/ - -#[get("/", rank = 1)] // Only match this if the other routes don't match +#[get("/", rank = 10)] // Only match this if the other routes don't match fn web_files(p: PathBuf) -> Cached> { Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder).join(p))) } -struct Cached(R, &'static str); - -impl Cached { - fn long(r: R) -> Cached { - // 7 days - Cached(r, "public, max-age=604800") - } - - fn short(r: R) -> Cached { - // 10 minutes - Cached(r, "public, max-age=600") - } -} - -impl<'r, R: Responder<'r>> Responder<'r> for Cached { - 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//")] fn attachments(uuid: String, file: PathBuf) -> io::Result { NamedFile::open(Path::new(&CONFIG.attachments_folder).join(uuid).join(file)) diff --git a/src/auth.rs b/src/auth.rs index 4450135..3b888fa 100644 --- 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 = match read_file(&CONFIG.private_rsa_key) { Ok(key) => key, Err(e) => panic!( @@ -39,14 +42,14 @@ pub fn encode_jwt(claims: &T) -> String { } } -pub fn decode_jwt(token: &str) -> Result { +fn decode_jwt(token: &str, issuer: String) -> Result { 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 { 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 { - 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 { + decode_jwt(token, JWT_LOGIN_ISSUER.to_string()) +} - let token = token.replace(char::is_whitespace, ""); +pub fn decode_invite(token: &str) -> Result { + 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 { + 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, } -pub fn generate_invite_claims(uuid: String, - email: String, - org_id: Option, - org_user_id: Option, - invited_by_email: Option, +pub fn generate_invite_claims( + uuid: String, + email: String, + org_id: Option, + org_user_id: Option, + invited_by_email: Option, ) -> 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 index 01e1277..157bd9a 100644 --- 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 index 88b0d40..286824f 100644 --- 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 deleted file mode 100644 index 35b0de5..0000000 --- a/src/static/admin.html +++ /dev/null @@ -1,195 +0,0 @@ - - - - - - - - - Bitwarden_rs Admin Panel - - - - - - - - - - - - - -
-
-
-
Authentication key needed to continue
- Please provide it below: - -
- - -
-
-
- -
-
Registered Users
- -
- - - Reload users - -
- -
-
-
Invite User
- Email: - -
- - -
-
-
- -
- -
-
- Full Name - Delete User -
- Email -
-
-
- - - \ No newline at end of file diff --git a/src/static/templates/admin/admin_login.hbs b/src/static/templates/admin/admin_login.hbs new file mode 100644 index 0000000..82b883b --- /dev/null +++ b/src/static/templates/admin/admin_login.hbs @@ -0,0 +1,54 @@ + + + + + + + Bitwarden_rs Admin Panel + + + + + + + + + +
+ {{#if error}} +
+
+
{{error}}
+
+
+ {{/if}} + +
+
+
Authentication key needed to continue
+ Please provide it below: + +
+ + +
+
+
+
+ + + \ No newline at end of file diff --git a/src/static/templates/admin/admin_page.hbs b/src/static/templates/admin/admin_page.hbs new file mode 100644 index 0000000..c8e02e4 --- /dev/null +++ b/src/static/templates/admin/admin_page.hbs @@ -0,0 +1,124 @@ + + + + + + + Bitwarden_rs Admin Panel + + + + + + + + + + + + + +
+
+
Registered Users
+ +
+ {{#each users}} +
+ {{!-- row.find(".tmp-icon").attr("src", identicon(user.Email)) --}} + +
+
+ {{Name}} + Delete User +
+ {{Email}} +
+
+ {{/each}} + +
+ + + Reload users + +
+ +
+
+
Invite User
+ Email: + +
+ + +
+
+
+
+ + + \ No newline at end of file diff --git a/src/util.rs b/src/util.rs index f24746b..5bd3710 100644 --- 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, &'static str); + +impl Cached { + pub fn long(r: R) -> Cached { + // 7 days + Cached(r, "public, max-age=604800") + } + + pub fn short(r: R) -> Cached { + // 10 minutes + Cached(r, "public, max-age=600") + } +} + +impl<'r, R: Responder<'r>> Responder<'r> for Cached { + 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 //