Compare commits

...

80 Commits
1.6.1 ... 1.7.0

Author SHA1 Message Date
Daniel García
8fac72db53 Mention relation between DOMAIN and mail settings in .env template 2019-02-08 19:21:48 +01:00
Daniel García
820c8b0dce Change use of deserialize_with for Option iterator 2019-02-08 19:12:08 +01:00
Daniel García
8b4a6f2a64 Fixed some clippy lints and changed update_uuid_revision to only use one db query 2019-02-08 18:45:07 +01:00
Daniel García
ef63342e20 Add reset user config button 2019-02-06 17:34:32 +01:00
Daniel García
89840790e7 Fix .env path traversal issue 2019-02-06 17:34:31 +01:00
Daniel García
a72809b225 Yubico and SMTP enable/disable master switches 2019-02-06 17:34:31 +01:00
Daniel García
9976e4736e Add groups 2019-02-06 17:34:31 +01:00
Daniel García
dc92f07232 Added env variable to select config file. Initial work towards groups and added tooltips with descriptions and nicer names 2019-02-06 17:34:30 +01:00
Daniel García
3db815b969 Implemented config form and fixed config priority 2019-02-06 17:34:30 +01:00
Daniel García
ade293cf52 Save config 2019-02-06 17:34:29 +01:00
Daniel García
877408b808 Implement basic config loading and updating. No save to file yet. 2019-02-06 17:34:29 +01:00
Daniel García
86ed75bf7c Config can now be serialized / deserialized 2019-02-06 17:34:29 +01:00
Daniel García
20d8d800f3 Updated dependencies 2019-02-06 17:34:29 +01:00
Daniel García
7ce06b3808 Merge pull request #387 from mprasil/collections_edit_revision
Update revision when adding or removing cipher from collection
2019-02-06 17:33:03 +01:00
Miroslav Prasil
08ca47cadb Update revision when adding or removing cipher from collection 2019-02-06 14:47:47 +00:00
Daniel García
0bd3a26051 Merge pull request #386 from mprasil/revision_collection_delete
Update revision of affected users when deleting Collection
2019-02-06 14:53:16 +01:00
Miroslav Prasil
5272b465cc Update revision of affected users when deleting Collection 2019-02-06 13:39:32 +00:00
Daniel García
b75f38033b Merge pull request #385 from mprasil/update_revision_retry
Retry updating revision - fixes #383
2019-02-05 15:31:07 +01:00
Miroslav Prasil
637f655b6f Do not allocate uneccessary Vec 2019-02-05 14:16:07 +00:00
Miroslav Prasil
b3f7394c06 Do not update revision at the end, as we already did that 2019-02-05 14:09:59 +00:00
Miroslav Prasil
1a5ecd4d4a cipher does not need to be mutable 2019-02-05 13:52:30 +00:00
Miroslav Prasil
bd65c4e312 Remove superfluous cipher.save() call 2019-02-05 13:49:30 +00:00
Miroslav Prasil
bce656c787 Retry updating revision - fixes #383 2019-02-05 11:52:11 +00:00
Daniel García
06522c9ac0 Merge pull request #382 from BlackDex/iter-iconlist
Loop through the iconlist until an icon is found
2019-02-04 18:54:15 +01:00
BlackDex
9026cc8d42 Fixed issue when the iconlist is smaller then 5
When the iconlist was smaller then 5 items, it would cause a panic.
Solved by using .truncate() on the iconlist.
2019-02-04 17:27:40 +01:00
BlackDex
574b040142 Loop through the iconlist until an icon is found
Loop for a maximum of 5 times through the iconlist or until a
successful download of an icon.
2019-02-04 16:59:52 +01:00
Daniel García
48113b7bd9 Merge pull request #381 from BlackDex/issue-380
Fixed issue #380
2019-02-04 13:56:11 +01:00
BlackDex
c13f115473 Fixed issue #380
- Created a separate function for parsing the sizes attribute
 - Parsing sizes now with regex
 - Should work with any non-digit separator
2019-02-04 12:55:39 +01:00
Daniel García
1e20f9f1d8 Merge pull request #377 from BlackDex/icon-cookies
Added cookies to the icon download request.
2019-01-31 18:16:30 +01:00
BlackDex
bc461d9baa Some small changes on the iter of the cookies 2019-01-31 17:58:03 +01:00
BlackDex
5016e30cf2 Added cookies to the icon download request.
Some sites use XSRF Tokens, or other Tokens to verify a subseqense
response. The cookies which are sent during the page request are now
used when downloading the favicon.

A site which uses this is mijn.ing.nl.
2019-01-31 15:49:58 +01:00
Daniel García
f42ac5f2c0 Update web vault error message 2019-01-29 21:45:25 +01:00
Daniel García
2a60414031 Reuse the client between requests, and use the client when downloading the icons themselves 2019-01-29 21:21:26 +01:00
Daniel García
9a2a304860 Merge pull request #372 from BlackDex/better-href-fix
Changed the way to fix the href
2019-01-29 19:30:53 +01:00
BlackDex
feb74a5e86 Changed the way to fix the href
- Using url from reqwest to fix href, this fixes:
   + "//domain.com/icon.png"
   + "relative/path/to/icon.png"
   + "/absolute/path/to/icon.png"
 - Removed fix_href function
 - Some variable changes
2019-01-29 18:08:23 +01:00
Daniel García
c0e350b734 Disable icon downloads, accept optional query after icon href, format and clippy fixes 2019-01-28 23:58:32 +01:00
Daniel García
bef1183c49 Only send one notification per vault import and purge, improve move ciphers functions 2019-01-28 00:39:14 +01:00
Daniel García
f935f5cf46 Remove local icon extractor 2019-01-27 16:42:30 +01:00
Daniel García
07388d327f Merge pull request #370 from BlackDex/favicons
Added better favicon downloader.
2019-01-27 16:37:47 +01:00
BlackDex
4de16b2d17 Removed unwrap and added ? 2019-01-27 16:25:02 +01:00
BlackDex
da068a43c1 Moved function call to get_icon_url to prevent error bubbeling 2019-01-27 16:03:18 +01:00
BlackDex
9657463717 Added better favicon downloader. 2019-01-27 15:39:19 +01:00
Daniel García
69036cc6a4 Add disabled user badge (no password) and deauthorize button to admin page. 2019-01-26 19:28:54 +01:00
Daniel García
700e084101 Add 2FA icon to admin panel 2019-01-25 18:50:57 +01:00
Daniel García
a1dc47b826 Change config to thread-safe system, needed for a future config panel.
Improved some two factor methods.
2019-01-25 18:24:57 +01:00
Daniel García
86de0ca17b Fix editing users from collections menu 2019-01-25 17:43:51 +01:00
Daniel García
80414f8452 Merge pull request #365 from CoreFiling/master
Fix the list of users with access to a collection to display correctly.
2019-01-25 16:52:27 +01:00
Stephen White
fc0e239bdf No point calling find_by_uuid now we don't use the result. 2019-01-25 14:25:15 +00:00
Stephen White
928ad6c1d8 Fix the list of users with access to a collection to display correctly.
https://github.com/dani-garcia/bitwarden_rs/issues/364
2019-01-25 14:18:06 +00:00
Daniel García
9d027b96d8 Update web-vault to fix U2F NotTrustedAnchor error 2019-01-24 18:43:22 +01:00
Daniel García
ddd49596ba Fix invite empty email 2019-01-22 17:26:17 +01:00
Daniel García
b8cabadd43 Fix admin page links 2019-01-21 23:41:27 +01:00
Daniel García
ce42b07a80 Update Diesel to 1.4 and other dependencies 2019-01-21 15:29:52 +01:00
Daniel García
bfd93e5b13 Show organizations in admin panel, implement reload templates option 2019-01-20 17:43:56 +01:00
Daniel García
a797459560 Implement HIBP check [WIP].
Add extra security attributes to admin cookie.
Error handling.
2019-01-20 15:36:33 +01:00
Daniel García
6cbb683f99 Rename admin templates to match email 2019-01-19 22:59:32 +01:00
Daniel García
92bbb98d48 Created base template 2019-01-19 22:12:52 +01:00
Daniel García
834c847746 Implement admin JWT cookie, separate JWT issuers for each type of token and migrate admin page to handlebars template 2019-01-19 21:41:49 +01:00
Daniel García
97aa407fe4 Move email templates to subfolder 2019-01-19 17:40:18 +01:00
Daniel García
86a254ad9e Ignore build.rs git errors 2019-01-19 17:35:47 +01:00
Daniel García
64c38856cc Merge pull request #348 from mprasil/c_version
Bump the vault version used to the latest one
2019-01-18 13:20:15 +01:00
Miroslav Prasil
b4f6206eda Bump the vault version used to the latest one 2019-01-18 11:52:36 +00:00
Daniel García
82f828a327 Merge pull request #347 from TBK/patch-1
Add Feature-Policy header
2019-01-17 21:33:45 +01:00
TBK
d8116a80df Add Feature-Policy header 2019-01-17 21:08:31 +01:00
Daniel García
e0aec8d373 Use new i64::to_be_bytes and remove byteorder dep
(https://doc.rust-lang.org/stable/std/primitive.i64.html#method.to_be_bytes)
2019-01-16 22:14:17 +01:00
Daniel García
1ce2587330 Correct update cipher order: first save cipher, then cipher-folder, then notify 2019-01-16 19:57:49 +01:00
Daniel García
20964ac2d8 Merge pull request #343 from mprasil/share_fix
Fix sharing the item to organization.
2019-01-16 12:58:58 +01:00
Miroslav Prasil
71a10e0378 Fix sharing the item to organization. 2019-01-16 11:33:43 +00:00
Daniel García
9bf13b7872 Can't return inside multipart closure 2019-01-15 22:00:41 +01:00
Daniel García
d420992f8c Update some function calls to use ? 2019-01-15 21:47:16 +01:00
Daniel García
c259a0e3e2 Save recovery code when using yubikey and stop repeating headers.user everywhere 2019-01-15 21:38:21 +01:00
Daniel García
432be274ba Improve org mismatch check, consider different orgs 2019-01-15 17:31:03 +01:00
Daniel García
484bf5b703 Check that the client is not updating an outdated cipher, that should be part of an org now 2019-01-15 16:35:08 +01:00
Daniel García
979b6305af Update dependencies 2019-01-15 15:30:12 +01:00
Daniel García
4bf32af60e Fix folder notifications, enable template strict mode and add missing option to env template 2019-01-15 15:28:47 +01:00
Daniel García
0e4a746eeb Added SMTP_FROM_NAME 2019-01-15 15:28:47 +01:00
Daniel García
2fe919cc5e Embed the default templates 2019-01-15 15:28:46 +01:00
Daniel García
bcd750695f Default to $data_folder/templates and remove dev option (use TEMPLATES_FOLDER=src/static/templates instead) 2019-01-15 15:28:46 +01:00
Daniel García
19b6bb0fd6 Initial stab at templates 2019-01-15 15:28:46 +01:00
Daniel García
60f6a350be Update yubico to fix OpenSSL error 2019-01-13 14:37:17 +01:00
43 changed files with 2401 additions and 1636 deletions

View File

@@ -10,6 +10,12 @@
# ICON_CACHE_FOLDER=data/icon_cache # ICON_CACHE_FOLDER=data/icon_cache
# ATTACHMENTS_FOLDER=data/attachments # ATTACHMENTS_FOLDER=data/attachments
## Templates data folder, by default uses embedded templates
## Check source code to see the format
# TEMPLATES_FOLDER=/path/to/templates
## Automatically reload the templates for every request, slow, use only for development
# RELOAD_TEMPLATES=false
## Cache time-to-live for successfully obtained icons, in seconds (0 is "forever") ## Cache time-to-live for successfully obtained icons, in seconds (0 is "forever")
# ICON_CACHE_TTL=2592000 # ICON_CACHE_TTL=2592000
## Cache time-to-live for icons which weren't available, in seconds (0 is "forever") ## Cache time-to-live for icons which weren't available, in seconds (0 is "forever")
@@ -19,6 +25,9 @@
# WEB_VAULT_FOLDER=web-vault/ # WEB_VAULT_FOLDER=web-vault/
# WEB_VAULT_ENABLED=true # WEB_VAULT_ENABLED=true
## Enables websocket notifications
# WEBSOCKET_ENABLED=false
## Controls the WebSocket server address and port ## Controls the WebSocket server address and port
# WEBSOCKET_ADDRESS=0.0.0.0 # WEBSOCKET_ADDRESS=0.0.0.0
# WEBSOCKET_PORT=3012 # WEBSOCKET_PORT=3012
@@ -34,11 +43,11 @@
## It's recommended to also set 'ROCKET_CLI_COLORS=off' ## It's recommended to also set 'ROCKET_CLI_COLORS=off'
# LOG_FILE=/path/to/log # LOG_FILE=/path/to/log
## Use a local favicon extractor ## Disable icon downloading
## Set to false to use bitwarden's official icon servers ## Set to true to disable icon downloading, this would still serve icons from $ICON_CACHE_FOLDER,
## Set to true to use the local version, which is not as smart, ## but it won't produce any external network request. Needs to set $ICON_CACHE_TTL to 0,
## but it doesn't send the cipher domains to bitwarden's servers ## otherwise it will delete them and they won't be downloaded again.
# LOCAL_ICON_EXTRACTOR=false # DISABLE_ICON_DOWNLOAD=false
## Controls if new users can register ## Controls if new users can register
# SIGNUPS_ALLOWED=true # SIGNUPS_ALLOWED=true
@@ -60,7 +69,8 @@
## Domain settings ## Domain settings
## The domain must match the address from where you access the server ## The domain must match the address from where you access the server
## Unless you are using U2F, or having problems with attachments not downloading, there is no need to change this ## It's recommended to configure this value, otherwise certain functionality might not work,
## like attachment downloads, email links and U2F.
## For U2F to work, the server must use HTTPS, you can use Let's Encrypt for free certs ## For U2F to work, the server must use HTTPS, you can use Let's Encrypt for free certs
# DOMAIN=https://bw.domain.tld:8443 # DOMAIN=https://bw.domain.tld:8443
@@ -79,9 +89,11 @@
# ROCKET_TLS={certs="/path/to/certs.pem",key="/path/to/key.pem"} # ROCKET_TLS={certs="/path/to/certs.pem",key="/path/to/key.pem"}
## Mail specific settings, set SMTP_HOST and SMTP_FROM to enable the mail service. ## Mail specific settings, set SMTP_HOST and SMTP_FROM to enable the mail service.
## To make sure the email links are pointing to the correct host, set the DOMAIN variable.
## Note: if SMTP_USERNAME is specified, SMTP_PASSWORD is mandatory ## Note: if SMTP_USERNAME is specified, SMTP_PASSWORD is mandatory
# SMTP_HOST=smtp.domain.tld # SMTP_HOST=smtp.domain.tld
# SMTP_FROM=bitwarden-rs@domain.tld # SMTP_FROM=bitwarden-rs@domain.tld
# SMTP_FROM_NAME=Bitwarden_RS
# SMTP_PORT=587 # SMTP_PORT=587
# SMTP_SSL=true # SMTP_SSL=true
# SMTP_USERNAME=username # SMTP_USERNAME=username

1262
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,10 +19,10 @@ rocket = { version = "0.4.0", features = ["tls"], default-features = false }
rocket_contrib = "0.4.0" rocket_contrib = "0.4.0"
# HTTP client # HTTP client
reqwest = "0.9.8" reqwest = "0.9.9"
# multipart/form-data support # multipart/form-data support
multipart = "0.15.4" multipart = "0.16.1"
# WebSockets library # WebSockets library
ws = "0.7.9" ws = "0.7.9"
@@ -34,9 +34,9 @@ rmpv = "0.4.0"
chashmap = "2.2.0" chashmap = "2.2.0"
# A generic serialization/deserialization framework # A generic serialization/deserialization framework
serde = "1.0.84" serde = "1.0.85"
serde_derive = "1.0.84" serde_derive = "1.0.85"
serde_json = "1.0.34" serde_json = "1.0.37"
# Logging # Logging
log = "0.4.6" log = "0.4.6"
@@ -44,17 +44,17 @@ fern = "0.5.7"
syslog = { version = "4.0.1", optional = true } syslog = { version = "4.0.1", optional = true }
# A safe, extensible ORM and Query builder # A safe, extensible ORM and Query builder
diesel = { version = "1.3.3", features = ["sqlite", "chrono", "r2d2"] } diesel = { version = "1.4.1", features = ["sqlite", "chrono", "r2d2"] }
diesel_migrations = { version = "1.3.0", features = ["sqlite"] } diesel_migrations = { version = "1.4.0", features = ["sqlite"] }
# Bundled SQLite # Bundled SQLite
libsqlite3-sys = { version = "0.9.3", features = ["bundled"] } libsqlite3-sys = { version = "0.12.0", features = ["bundled"] }
# Crypto library # Crypto library
ring = { version = "0.13.5", features = ["rsa_signing"] } ring = { version = "0.13.5", features = ["rsa_signing"] }
# UUID generation # UUID generation
uuid = { version = "0.7.1", features = ["v4"] } uuid = { version = "0.7.2", features = ["v4"] }
# Date and time library for Rust # Date and time library for Rust
chrono = "0.4.6" chrono = "0.4.6"
@@ -72,7 +72,7 @@ jsonwebtoken = "5.0.1"
u2f = "0.1.4" u2f = "0.1.4"
# Yubico Library # Yubico Library
yubico = { version = "0.5.0", features = ["online"], default-features = false } yubico = { version = "0.5.1", features = ["online"], default-features = false }
# A `dotenv` implementation for Rust # A `dotenv` implementation for Rust
dotenv = { version = "0.13.0", default-features = false } dotenv = { version = "0.13.0", default-features = false }
@@ -85,15 +85,19 @@ derive_more = "0.13.0"
# Numerical libraries # Numerical libraries
num-traits = "0.2.6" num-traits = "0.2.6"
num-derive = "0.2.3" num-derive = "0.2.4"
# Email libraries # Email libraries
lettre = "0.9.0" lettre = "0.9.0"
lettre_email = "0.9.0" lettre_email = "0.9.0"
native-tls = "0.2.2" native-tls = "0.2.2"
# Number encoding library # Template library
byteorder = "1.2.7" handlebars = "1.1.0"
# For favicon extraction from main website
soup = "0.3.0"
regex = "1.1.0"
[patch.crates-io] [patch.crates-io]
# Add support for Timestamp type # Add support for Timestamp type
@@ -102,3 +106,4 @@ rmp = { git = 'https://github.com/dani-garcia/msgpack-rust' }
# Use new native_tls version 0.2 # Use new native_tls version 0.2
lettre = { git = 'https://github.com/lettre/lettre', rev = 'c988b1760ad81' } lettre = { git = 'https://github.com/lettre/lettre', rev = 'c988b1760ad81' }
lettre_email = { git = 'https://github.com/lettre/lettre', rev = 'c988b1760ad81' } lettre_email = { git = 'https://github.com/lettre/lettre', rev = 'c988b1760ad81' }

View File

@@ -4,7 +4,7 @@
####################### VAULT BUILD IMAGE ####################### ####################### VAULT BUILD IMAGE #######################
FROM alpine as vault FROM alpine as vault
ENV VAULT_VERSION "v2.8.0b" ENV VAULT_VERSION "v2.8.0d"
ENV URL "https://github.com/dani-garcia/bw_web_builds/releases/download/$VAULT_VERSION/bw_web_$VAULT_VERSION.tar.gz" ENV URL "https://github.com/dani-garcia/bw_web_builds/releases/download/$VAULT_VERSION/bw_web_$VAULT_VERSION.tar.gz"

View File

@@ -4,7 +4,7 @@
####################### VAULT BUILD IMAGE ####################### ####################### VAULT BUILD IMAGE #######################
FROM alpine as vault FROM alpine as vault
ENV VAULT_VERSION "v2.8.0b" ENV VAULT_VERSION "v2.8.0d"
ENV URL "https://github.com/dani-garcia/bw_web_builds/releases/download/$VAULT_VERSION/bw_web_$VAULT_VERSION.tar.gz" ENV URL "https://github.com/dani-garcia/bw_web_builds/releases/download/$VAULT_VERSION/bw_web_$VAULT_VERSION.tar.gz"

View File

@@ -4,7 +4,7 @@
####################### VAULT BUILD IMAGE ####################### ####################### VAULT BUILD IMAGE #######################
FROM alpine as vault FROM alpine as vault
ENV VAULT_VERSION "v2.8.0b" ENV VAULT_VERSION "v2.8.0d"
ENV URL "https://github.com/dani-garcia/bw_web_builds/releases/download/$VAULT_VERSION/bw_web_$VAULT_VERSION.tar.gz" ENV URL "https://github.com/dani-garcia/bw_web_builds/releases/download/$VAULT_VERSION/bw_web_$VAULT_VERSION.tar.gz"

View File

@@ -4,7 +4,7 @@
####################### VAULT BUILD IMAGE ####################### ####################### VAULT BUILD IMAGE #######################
FROM alpine as vault FROM alpine as vault
ENV VAULT_VERSION "v2.8.0b" ENV VAULT_VERSION "v2.8.0d"
ENV URL "https://github.com/dani-garcia/bw_web_builds/releases/download/$VAULT_VERSION/bw_web_$VAULT_VERSION.tar.gz" ENV URL "https://github.com/dani-garcia/bw_web_builds/releases/download/$VAULT_VERSION/bw_web_$VAULT_VERSION.tar.gz"

View File

@@ -1,7 +1,7 @@
use std::process::Command; use std::process::Command;
fn main() { fn main() {
read_git_info().expect("Unable to read Git info"); read_git_info().ok();
} }
fn run(args: &[&str]) -> Result<String, std::io::Error> { fn run(args: &[&str]) -> Result<String, std::io::Error> {

View File

@@ -1 +1 @@
nightly-2019-01-08 nightly-2019-01-26

View File

@@ -1,68 +1,182 @@
use rocket_contrib::json::Json;
use serde_json::Value; use serde_json::Value;
use crate::api::{JsonResult, JsonUpcase}; use rocket::http::{Cookie, Cookies, SameSite};
use rocket::request::{self, FlashMessage, Form, FromRequest, Request};
use rocket::response::{content::Html, Flash, Redirect};
use rocket::{Outcome, Route};
use rocket_contrib::json::Json;
use crate::api::{ApiResult, EmptyResult};
use crate::auth::{decode_admin, encode_jwt, generate_admin_claims, ClientIp};
use crate::config::ConfigBuilder;
use crate::db::{models::*, DbConn};
use crate::error::Error;
use crate::mail;
use crate::CONFIG; use crate::CONFIG;
use crate::db::models::*;
use crate::db::DbConn;
use crate::mail;
use rocket::request::{self, FromRequest, Request};
use rocket::{Outcome, Route};
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
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,
deauth_user,
post_config,
delete_config,
]
}
const COOKIE_NAME: &str = "BWRS_ADMIN";
const ADMIN_PATH: &str = "/admin";
const BASE_TEMPLATE: &str = "admin/base";
#[get("/", rank = 2)]
fn admin_login(flash: Option<FlashMessage>) -> ApiResult<Html<String>> {
// If there is an error, show it
let msg = flash.map(|msg| format!("{}: {}", msg.name(), msg.msg()));
let json = json!({"page_content": "admin/login", "error": msg});
// Return the page
let text = CONFIG.render_template(BASE_TEMPLATE, &json)?;
Ok(Html(text))
}
#[derive(FromForm)]
struct LoginForm {
token: String,
}
#[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 the token is invalid, redirect to login page
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)
.max_age(chrono::Duration::minutes(20))
.same_site(SameSite::Strict)
.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>,
page_content: String,
config: Value,
}
impl AdminTemplateData {
fn new(users: Vec<Value>) -> Self {
Self {
users,
page_content: String::from("admin/page"),
config: CONFIG.prepare_json(),
}
}
fn render(self) -> Result<String, Error> {
CONFIG.render_template(BASE_TEMPLATE, &self)
}
}
#[get("/", rank = 1)]
fn admin_page(_token: AdminToken, conn: DbConn) -> ApiResult<Html<String>> {
let users = User::get_all(&conn);
let users_json: Vec<Value> = users.iter().map(|u| u.to_json(&conn)).collect();
let text = AdminTemplateData::new(users_json).render()?;
Ok(Html(text))
} }
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[allow(non_snake_case)] #[allow(non_snake_case)]
struct InviteData { struct InviteData {
Email: String, email: String,
}
#[get("/users")]
fn get_users(_token: AdminToken, conn: DbConn) -> JsonResult {
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("/invite", data = "<data>")] #[post("/invite", data = "<data>")]
fn invite_user(data: JsonUpcase<InviteData>, _token: AdminToken, conn: DbConn) -> JsonResult { fn invite_user(data: Json<InviteData>, _token: AdminToken, conn: DbConn) -> EmptyResult {
let data: InviteData = data.into_inner().data; let data: InviteData = data.into_inner();
let email = data.Email.clone(); let email = data.email.clone();
if User::find_by_mail(&data.Email, &conn).is_some() { if User::find_by_mail(&data.email, &conn).is_some() {
err!("User already exists") err!("User already exists")
} }
if !CONFIG.invitations_allowed { if !CONFIG.invitations_allowed() {
err!("Invitations are not allowed") err!("Invitations are not allowed")
} }
if let Some(ref mail_config) = CONFIG.mail { if CONFIG.mail_enabled() {
let mut user = User::new(email); let mut user = User::new(email);
user.save(&conn)?; user.save(&conn)?;
let org_name = "bitwarden_rs"; let org_name = "bitwarden_rs";
mail::send_invite(&user.email, &user.uuid, None, None, &org_name, None, mail_config)?; mail::send_invite(&user.email, &user.uuid, None, None, &org_name, None)
} else { } else {
let mut invitation = Invitation::new(data.Email); let mut invitation = Invitation::new(data.email);
invitation.save(&conn)?; invitation.save(&conn)
} }
Ok(Json(json!({})))
} }
#[post("/users/<uuid>/delete")] #[post("/users/<uuid>/delete")]
fn delete_user(uuid: String, _token: AdminToken, conn: DbConn) -> JsonResult { fn delete_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
let user = match User::find_by_uuid(&uuid, &conn) { let user = match User::find_by_uuid(&uuid, &conn) {
Some(user) => user, Some(user) => user,
None => err!("User doesn't exist"), None => err!("User doesn't exist"),
}; };
user.delete(&conn)?; user.delete(&conn)
Ok(Json(json!({}))) }
#[post("/users/<uuid>/deauth")]
fn deauth_user(uuid: String, _token: AdminToken, conn: DbConn) -> EmptyResult {
let mut user = match User::find_by_uuid(&uuid, &conn) {
Some(user) => user,
None => err!("User doesn't exist"),
};
user.reset_security_stamp();
user.save(&conn)
}
#[post("/config", data = "<data>")]
fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult {
let data: ConfigBuilder = data.into_inner();
CONFIG.update_config(data)
}
#[post("/config/delete")]
fn delete_config(_token: AdminToken) -> EmptyResult {
CONFIG.delete_user_config()
} }
pub struct AdminToken {} pub struct AdminToken {}
@@ -71,35 +185,23 @@ impl<'a, 'r> FromRequest<'a, 'r> for AdminToken {
type Error = &'static str; type Error = &'static str;
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> { fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
let config_token = match CONFIG.admin_token.as_ref() { let mut cookies = request.cookies();
Some(token) => token,
None => err_handler!("Admin panel is disabled"), 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::<ClientIp>() { let ip = match request.guard::<ClientIp>() {
Outcome::Success(ip) => ip, Outcome::Success(ip) => ip.ip,
_ => err_handler!("Error getting Client IP"), _ => err_handler!("Error getting Client IP"),
}; };
if access_token != config_token { if decode_admin(access_token).is_err() {
err_handler!("Invalid admin token", format!("IP: {}.", ip.ip)) // Remove admin cookie
cookies.remove(Cookie::named(COOKIE_NAME));
error!("Invalid or expired admin JWT. IP: {}.", ip);
return Outcome::Forward(());
} }
Outcome::Success(AdminToken {}) Outcome::Success(AdminToken {})

View File

@@ -4,7 +4,7 @@ use crate::db::models::*;
use crate::db::DbConn; use crate::db::DbConn;
use crate::api::{EmptyResult, JsonResult, JsonUpcase, Notify, NumberOrString, PasswordData, UpdateType}; 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::mail;
use crate::CONFIG; use crate::CONFIG;
@@ -66,7 +66,7 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
} }
if let Some(token) = data.Token { if let Some(token) = data.Token {
let claims: InviteJWTClaims = decode_invite_jwt(&token)?; let claims = decode_invite(&token)?;
if claims.email == data.Email { if claims.email == data.Email {
user user
} else { } else {
@@ -79,14 +79,14 @@ fn register(data: JsonUpcase<RegisterData>, conn: DbConn) -> EmptyResult {
} }
user user
} else if CONFIG.signups_allowed { } else if CONFIG.signups_allowed() {
err!("Account with this email already exists") err!("Account with this email already exists")
} else { } else {
err!("Registration not allowed") err!("Registration not allowed")
} }
} }
None => { None => {
if CONFIG.signups_allowed || Invitation::take(&data.Email, &conn) { if CONFIG.signups_allowed() || Invitation::take(&data.Email, &conn) {
User::new(data.Email.clone()) User::new(data.Email.clone())
} else { } else {
err!("Registration not allowed") err!("Registration not allowed")
@@ -419,9 +419,9 @@ fn password_hint(data: JsonUpcase<PasswordHintData>, conn: DbConn) -> EmptyResul
None => return Ok(()), None => return Ok(()),
}; };
if let Some(ref mail_config) = CONFIG.mail { if CONFIG.mail_enabled() {
mail::send_password_hint(&data.Email, hint, mail_config)?; mail::send_password_hint(&data.Email, hint)?;
} else if CONFIG.show_password_hint { } else if CONFIG.show_password_hint() {
if let Some(hint) = hint { if let Some(hint) = hint {
err!(format!("Your password hint is: {}", &hint)); err!(format!("Your password hint is: {}", &hint));
} else { } else {

View File

@@ -221,6 +221,10 @@ pub fn update_cipher_from_data(
nt: &Notify, nt: &Notify,
ut: UpdateType, ut: UpdateType,
) -> EmptyResult { ) -> EmptyResult {
if cipher.organization_uuid.is_some() && cipher.organization_uuid != data.OrganizationId {
err!("Organization mismatch. Please resync the client before updating the cipher")
}
if let Some(org_id) = data.OrganizationId { if let Some(org_id) = data.OrganizationId {
match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &conn) { match UserOrganization::find_by_user_and_org(&headers.user.uuid, &org_id, &conn) {
None => err!("You don't have permission to add item to organization"), None => err!("You don't have permission to add item to organization"),
@@ -300,10 +304,13 @@ pub fn update_cipher_from_data(
cipher.password_history = data.PasswordHistory.map(|f| f.to_string()); cipher.password_history = data.PasswordHistory.map(|f| f.to_string());
cipher.save(&conn)?; cipher.save(&conn)?;
cipher.move_to_folder(data.FolderId, &headers.user.uuid, &conn)?;
if ut != UpdateType::None {
nt.send_cipher_update(ut, &cipher, &cipher.update_users_revision(&conn)); nt.send_cipher_update(ut, &cipher, &cipher.update_users_revision(&conn));
}
cipher.move_to_folder(data.FolderId, &headers.user.uuid, &conn) Ok(())
} }
use super::folders::FolderData; use super::folders::FolderData;
@@ -346,25 +353,18 @@ fn post_ciphers_import(data: JsonUpcase<ImportData>, headers: Headers, conn: DbC
} }
// Read and create the ciphers // Read and create the ciphers
for (index, cipher_data) in data.Ciphers.into_iter().enumerate() { for (index, mut cipher_data) in data.Ciphers.into_iter().enumerate() {
let folder_uuid = relations_map.get(&index).map(|i| folders[*i].uuid.clone()); let folder_uuid = relations_map.get(&index).map(|i| folders[*i].uuid.clone());
cipher_data.FolderId = folder_uuid;
let mut cipher = Cipher::new(cipher_data.Type, cipher_data.Name.clone()); let mut cipher = Cipher::new(cipher_data.Type, cipher_data.Name.clone());
update_cipher_from_data( update_cipher_from_data(&mut cipher, cipher_data, &headers, false, &conn, &nt, UpdateType::None)?;
&mut cipher,
cipher_data,
&headers,
false,
&conn,
&nt,
UpdateType::CipherCreate,
)?;
cipher.move_to_folder(folder_uuid, &headers.user.uuid.clone(), &conn)?;
} }
let mut user = headers.user; let mut user = headers.user;
user.update_revision(&conn) user.update_revision(&conn)?;
nt.send_user_update(UpdateType::Vault, &user);
Ok(())
} }
#[put("/ciphers/<uuid>/admin", data = "<data>")] #[put("/ciphers/<uuid>/admin", data = "<data>")]
@@ -632,7 +632,14 @@ fn share_cipher_by_uuid(
} }
#[post("/ciphers/<uuid>/attachment", format = "multipart/form-data", data = "<data>")] #[post("/ciphers/<uuid>/attachment", format = "multipart/form-data", data = "<data>")]
fn post_attachment(uuid: String, data: Data, content_type: &ContentType, headers: Headers, conn: DbConn, nt: Notify) -> JsonResult { fn post_attachment(
uuid: String,
data: Data,
content_type: &ContentType,
headers: Headers,
conn: DbConn,
nt: Notify,
) -> JsonResult {
let cipher = match Cipher::find_by_uuid(&uuid, &conn) { let cipher = match Cipher::find_by_uuid(&uuid, &conn) {
Some(cipher) => cipher, Some(cipher) => cipher,
None => err!("Cipher doesn't exist"), None => err!("Cipher doesn't exist"),
@@ -646,13 +653,13 @@ fn post_attachment(uuid: String, data: Data, content_type: &ContentType, headers
let boundary_pair = params.next().expect("No boundary provided"); let boundary_pair = params.next().expect("No boundary provided");
let boundary = boundary_pair.1; let boundary = boundary_pair.1;
let base_path = Path::new(&CONFIG.attachments_folder).join(&cipher.uuid); let base_path = Path::new(&CONFIG.attachments_folder()).join(&cipher.uuid);
let mut attachment_key = None; let mut attachment_key = None;
Multipart::with_body(data.open(), boundary) Multipart::with_body(data.open(), boundary)
.foreach_entry(|mut field| { .foreach_entry(|mut field| {
match field.headers.name.as_str() { match &*field.headers.name {
"key" => { "key" => {
use std::io::Read; use std::io::Read;
let mut key_buffer = String::new(); let mut key_buffer = String::new();
@@ -811,56 +818,59 @@ fn delete_cipher_selected_post(data: JsonUpcase<Value>, headers: Headers, conn:
delete_cipher_selected(data, headers, conn, nt) delete_cipher_selected(data, headers, conn, nt)
} }
#[post("/ciphers/move", data = "<data>")] #[derive(Deserialize)]
fn move_cipher_selected(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult { #[allow(non_snake_case)]
let data = data.into_inner().data; struct MoveCipherData {
FolderId: Option<String>,
Ids: Vec<String>,
}
let folder_id = match data.get("FolderId") { #[post("/ciphers/move", data = "<data>")]
Some(folder_id) => match folder_id.as_str() { fn move_cipher_selected(data: JsonUpcase<MoveCipherData>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult {
Some(folder_id) => match Folder::find_by_uuid(folder_id, &conn) { let data = data.into_inner().data;
let user_uuid = headers.user.uuid;
if let Some(ref folder_id) = data.FolderId {
match Folder::find_by_uuid(folder_id, &conn) {
Some(folder) => { Some(folder) => {
if folder.user_uuid != headers.user.uuid { if folder.user_uuid != user_uuid {
err!("Folder is not owned by user") err!("Folder is not owned by user")
} }
Some(folder.uuid)
} }
None => err!("Folder doesn't exist"), None => err!("Folder doesn't exist"),
}, }
None => err!("Folder id provided in wrong format"), }
},
None => None,
};
let uuids = match data.get("Ids") { for uuid in data.Ids {
Some(ids) => match ids.as_array() { let cipher = match Cipher::find_by_uuid(&uuid, &conn) {
Some(ids) => ids.iter().filter_map(Value::as_str),
None => err!("Posted ids field is not an array"),
},
None => err!("Request missing ids field"),
};
for uuid in uuids {
let mut cipher = match Cipher::find_by_uuid(uuid, &conn) {
Some(cipher) => cipher, Some(cipher) => cipher,
None => err!("Cipher doesn't exist"), None => err!("Cipher doesn't exist"),
}; };
if !cipher.is_accessible_to_user(&headers.user.uuid, &conn) { if !cipher.is_accessible_to_user(&user_uuid, &conn) {
err!("Cipher is not accessible by user") err!("Cipher is not accessible by user")
} }
// Move cipher // Move cipher
cipher.move_to_folder(folder_id.clone(), &headers.user.uuid, &conn)?; cipher.move_to_folder(data.FolderId.clone(), &user_uuid, &conn)?;
cipher.save(&conn)?;
nt.send_cipher_update(UpdateType::CipherUpdate, &cipher, &cipher.update_users_revision(&conn)); nt.send_cipher_update(
UpdateType::CipherUpdate,
&cipher,
&[user_uuid.clone()]
);
} }
Ok(()) Ok(())
} }
#[put("/ciphers/move", data = "<data>")] #[put("/ciphers/move", data = "<data>")]
fn move_cipher_selected_put(data: JsonUpcase<Value>, headers: Headers, conn: DbConn, nt: Notify) -> EmptyResult { fn move_cipher_selected_put(
data: JsonUpcase<MoveCipherData>,
headers: Headers,
conn: DbConn,
nt: Notify,
) -> EmptyResult {
move_cipher_selected(data, headers, conn, nt) move_cipher_selected(data, headers, conn, nt)
} }
@@ -869,7 +879,7 @@ fn delete_all(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn, nt
let data: PasswordData = data.into_inner().data; let data: PasswordData = data.into_inner().data;
let password_hash = data.MasterPasswordHash; let password_hash = data.MasterPasswordHash;
let user = headers.user; let mut user = headers.user;
if !user.check_valid_password(&password_hash) { if !user.check_valid_password(&password_hash) {
err!("Invalid password") err!("Invalid password")
@@ -878,15 +888,15 @@ fn delete_all(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn, nt
// Delete ciphers and their attachments // Delete ciphers and their attachments
for cipher in Cipher::find_owned_by_user(&user.uuid, &conn) { for cipher in Cipher::find_owned_by_user(&user.uuid, &conn) {
cipher.delete(&conn)?; cipher.delete(&conn)?;
nt.send_cipher_update(UpdateType::CipherDelete, &cipher, &cipher.update_users_revision(&conn));
} }
// Delete folders // Delete folders
for f in Folder::find_by_user(&user.uuid, &conn) { for f in Folder::find_by_user(&user.uuid, &conn) {
f.delete(&conn)?; f.delete(&conn)?;
nt.send_folder_update(UpdateType::FolderCreate, &f);
} }
user.update_revision(&conn)?;
nt.send_user_update(UpdateType::Vault, &user);
Ok(()) Ok(())
} }

View File

@@ -11,6 +11,7 @@ pub fn routes() -> Vec<Route> {
get_eq_domains, get_eq_domains,
post_eq_domains, post_eq_domains,
put_eq_domains, put_eq_domains,
hibp_breach,
]; ];
let mut routes = Vec::new(); let mut routes = Vec::new();
@@ -116,8 +117,8 @@ fn post_eq_domains(data: JsonUpcase<EquivDomainData>, headers: Headers, conn: Db
let mut user = headers.user; let mut user = headers.user;
use serde_json::to_string; use serde_json::to_string;
user.excluded_globals = to_string(&excluded_globals).unwrap_or("[]".to_string()); user.excluded_globals = to_string(&excluded_globals).unwrap_or_else(|_| "[]".to_string());
user.equivalent_domains = to_string(&equivalent_domains).unwrap_or("[]".to_string()); user.equivalent_domains = to_string(&equivalent_domains).unwrap_or_else(|_| "[]".to_string());
user.save(&conn)?; user.save(&conn)?;
@@ -128,3 +129,20 @@ fn post_eq_domains(data: JsonUpcase<EquivDomainData>, headers: Headers, conn: Db
fn put_eq_domains(data: JsonUpcase<EquivDomainData>, headers: Headers, conn: DbConn) -> JsonResult { fn put_eq_domains(data: JsonUpcase<EquivDomainData>, headers: Headers, conn: DbConn) -> JsonResult {
post_eq_domains(data, headers, conn) post_eq_domains(data, headers, conn)
} }
#[get("/hibp/breach?<username>")]
fn hibp_breach(username: String) -> JsonResult {
let url = format!("https://haveibeenpwned.com/api/v2/breachedaccount/{}", username);
let user_agent = "Bitwarden_RS";
use reqwest::{header::USER_AGENT, Client};
let value: Value = Client::new()
.get(&url)
.header(USER_AGENT, user_agent)
.send()?
.error_for_status()?
.json()?;
Ok(Json(value))
}

View File

@@ -1,19 +1,16 @@
use rocket::request::Form; use rocket::request::Form;
use rocket::Route;
use rocket_contrib::json::Json; use rocket_contrib::json::Json;
use serde_json::Value; use serde_json::Value;
use crate::api::{
EmptyResult, JsonResult, JsonUpcase, JsonUpcaseVec, Notify, NumberOrString, PasswordData, UpdateType,
};
use crate::auth::{decode_invite, AdminHeaders, Headers, OwnerHeaders};
use crate::db::models::*; use crate::db::models::*;
use crate::db::DbConn; 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::mail; use crate::mail;
use crate::CONFIG;
use serde::{Deserialize, Deserializer};
use rocket::Route;
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
routes![ routes![
@@ -26,6 +23,7 @@ pub fn routes() -> Vec<Route> {
get_org_collections, get_org_collections,
get_org_collection_detail, get_org_collection_detail,
get_collection_users, get_collection_users,
put_collection_users,
put_organization, put_organization,
post_organization, post_organization,
post_organization_collections, post_organization_collections,
@@ -371,15 +369,44 @@ fn get_collection_users(org_id: String, coll_id: String, _headers: AdminHeaders,
.map(|col_user| { .map(|col_user| {
UserOrganization::find_by_user_and_org(&col_user.user_uuid, &org_id, &conn) UserOrganization::find_by_user_and_org(&col_user.user_uuid, &org_id, &conn)
.unwrap() .unwrap()
.to_json_collection_user_details(col_user.read_only, &conn) .to_json_collection_user_details(col_user.read_only)
}) })
.collect(); .collect();
Ok(Json(json!({ Ok(Json(json!(user_list)))
"Data": user_list, }
"Object": "list",
"ContinuationToken": null, #[put("/organizations/<org_id>/collections/<coll_id>/users", data = "<data>")]
}))) fn put_collection_users(
org_id: String,
coll_id: String,
data: JsonUpcaseVec<CollectionData>,
_headers: AdminHeaders,
conn: DbConn,
) -> EmptyResult {
// Get org and collection, check that collection is from org
if Collection::find_by_uuid_and_org(&coll_id, &org_id, &conn).is_none() {
err!("Collection not found in Organization")
}
// Delete all the user-collections
CollectionUser::delete_all_by_collection(&coll_id, &conn)?;
// And then add all the received ones (except if the user has access_all)
for d in data.iter().map(|d| &d.data) {
let user = match UserOrganization::find_by_uuid(&d.Id, &conn) {
Some(u) => u,
None => err!("User is not part of organization"),
};
if user.access_all {
continue;
}
CollectionUser::save(&user.user_uuid, &coll_id, d.ReadOnly, &conn)?;
}
Ok(())
} }
#[derive(FromForm)] #[derive(FromForm)]
@@ -415,14 +442,6 @@ fn get_org_users(org_id: String, _headers: AdminHeaders, conn: DbConn) -> JsonRe
}))) })))
} }
fn deserialize_collections<'de, D>(deserializer: D) -> Result<Vec<CollectionData>, D::Error>
where
D: Deserializer<'de>,
{
// Deserialize null to empty Vec
Deserialize::deserialize(deserializer).or(Ok(vec![]))
}
#[derive(Deserialize)] #[derive(Deserialize)]
#[allow(non_snake_case)] #[allow(non_snake_case)]
struct CollectionData { struct CollectionData {
@@ -435,8 +454,7 @@ struct CollectionData {
struct InviteData { struct InviteData {
Emails: Vec<String>, Emails: Vec<String>,
Type: NumberOrString, Type: NumberOrString,
#[serde(deserialize_with = "deserialize_collections")] Collections: Option<Vec<CollectionData>>,
Collections: Vec<CollectionData>,
AccessAll: Option<bool>, AccessAll: Option<bool>,
} }
@@ -454,17 +472,18 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
} }
for email in data.Emails.iter() { for email in data.Emails.iter() {
let mut user_org_status = match CONFIG.mail { let mut user_org_status = if CONFIG.mail_enabled() {
Some(_) => UserOrgStatus::Invited as i32, UserOrgStatus::Invited as i32
None => UserOrgStatus::Accepted as i32, // Automatically mark user as accepted if no email invites } else {
UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites
}; };
let user = match User::find_by_mail(&email, &conn) { let user = match User::find_by_mail(&email, &conn) {
None => { None => {
if !CONFIG.invitations_allowed { if !CONFIG.invitations_allowed() {
err!(format!("User email does not exist: {}", email)) err!(format!("User email does not exist: {}", email))
} }
if CONFIG.mail.is_none() { if !CONFIG.mail_enabled() {
let mut invitation = Invitation::new(email.clone()); let mut invitation = Invitation::new(email.clone());
invitation.save(&conn)?; invitation.save(&conn)?;
} }
@@ -491,7 +510,7 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
// If no accessAll, add the collections received // If no accessAll, add the collections received
if !access_all { if !access_all {
for col in &data.Collections { for col in data.Collections.iter().flatten() {
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) { match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
None => err!("Collection not found in Organization"), None => err!("Collection not found in Organization"),
Some(collection) => { Some(collection) => {
@@ -503,7 +522,7 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
new_user.save(&conn)?; new_user.save(&conn)?;
if let Some(ref mail_config) = CONFIG.mail { if CONFIG.mail_enabled() {
let org_name = match Organization::find_by_uuid(&org_id, &conn) { let org_name = match Organization::find_by_uuid(&org_id, &conn) {
Some(org) => org.name, Some(org) => org.name,
None => err!("Error looking up organization"), None => err!("Error looking up organization"),
@@ -516,7 +535,6 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
Some(new_user.uuid), Some(new_user.uuid),
&org_name, &org_name,
Some(headers.user.email.clone()), Some(headers.user.email.clone()),
mail_config,
)?; )?;
} }
} }
@@ -526,11 +544,11 @@ fn send_invite(org_id: String, data: JsonUpcase<InviteData>, headers: AdminHeade
#[post("/organizations/<org_id>/users/<user_org>/reinvite")] #[post("/organizations/<org_id>/users/<user_org>/reinvite")]
fn reinvite_user(org_id: String, user_org: String, headers: AdminHeaders, conn: DbConn) -> EmptyResult { fn reinvite_user(org_id: String, user_org: String, headers: AdminHeaders, conn: DbConn) -> EmptyResult {
if !CONFIG.invitations_allowed { if !CONFIG.invitations_allowed() {
err!("Invitations are not allowed.") err!("Invitations are not allowed.")
} }
if CONFIG.mail.is_none() { if !CONFIG.mail_enabled() {
err!("SMTP is not configured.") err!("SMTP is not configured.")
} }
@@ -553,7 +571,7 @@ fn reinvite_user(org_id: String, user_org: String, headers: AdminHeaders, conn:
None => err!("Error looking up organization."), None => err!("Error looking up organization."),
}; };
if let Some(ref mail_config) = CONFIG.mail { if CONFIG.mail_enabled() {
mail::send_invite( mail::send_invite(
&user.email, &user.email,
&user.uuid, &user.uuid,
@@ -561,7 +579,6 @@ fn reinvite_user(org_id: String, user_org: String, headers: AdminHeaders, conn:
Some(user_org.uuid), Some(user_org.uuid),
&org_name, &org_name,
Some(headers.user.email), Some(headers.user.email),
mail_config,
)?; )?;
} else { } else {
let mut invitation = Invitation::new(user.email.clone()); let mut invitation = Invitation::new(user.email.clone());
@@ -582,7 +599,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 // 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 data: AcceptData = data.into_inner().data;
let token = &data.Token; let token = &data.Token;
let claims: InviteJWTClaims = decode_invite_jwt(&token)?; let claims = decode_invite(&token)?;
match User::find_by_mail(&claims.email, &conn) { match User::find_by_mail(&claims.email, &conn) {
Some(_) => { Some(_) => {
@@ -605,7 +622,7 @@ fn accept_invite(_org_id: String, _org_user_id: String, data: JsonUpcase<AcceptD
None => err!("Invited user not found"), None => err!("Invited user not found"),
} }
if let Some(ref mail_config) = CONFIG.mail { if CONFIG.mail_enabled() {
let mut org_name = String::from("bitwarden_rs"); let mut org_name = String::from("bitwarden_rs");
if let Some(org_id) = &claims.org_id { if let Some(org_id) = &claims.org_id {
org_name = match Organization::find_by_uuid(&org_id, &conn) { org_name = match Organization::find_by_uuid(&org_id, &conn) {
@@ -615,10 +632,10 @@ fn accept_invite(_org_id: String, _org_user_id: String, data: JsonUpcase<AcceptD
}; };
if let Some(invited_by_email) = &claims.invited_by_email { if let Some(invited_by_email) = &claims.invited_by_email {
// User was invited to an organization, so they must be confirmed manually after acceptance // User was invited to an organization, so they must be confirmed manually after acceptance
mail::send_invite_accepted(&claims.email, invited_by_email, &org_name, mail_config)?; mail::send_invite_accepted(&claims.email, invited_by_email, &org_name)?;
} else { } else {
// User was invited from /admin, so they are automatically confirmed // User was invited from /admin, so they are automatically confirmed
mail::send_invite_confirmed(&claims.email, &org_name, mail_config)?; mail::send_invite_confirmed(&claims.email, &org_name)?;
} }
} }
@@ -654,7 +671,7 @@ fn confirm_invite(
None => err!("Invalid key provided"), None => err!("Invalid key provided"),
}; };
if let Some(ref mail_config) = CONFIG.mail { if CONFIG.mail_enabled() {
let org_name = match Organization::find_by_uuid(&org_id, &conn) { let org_name = match Organization::find_by_uuid(&org_id, &conn) {
Some(org) => org.name, Some(org) => org.name,
None => err!("Error looking up organization."), None => err!("Error looking up organization."),
@@ -663,7 +680,7 @@ fn confirm_invite(
Some(user) => user.email, Some(user) => user.email,
None => err!("Error looking up user."), None => err!("Error looking up user."),
}; };
mail::send_invite_confirmed(&address, &org_name, mail_config)?; mail::send_invite_confirmed(&address, &org_name)?;
} }
user_to_confirm.save(&conn) user_to_confirm.save(&conn)
@@ -683,8 +700,7 @@ fn get_user(org_id: String, org_user_id: String, _headers: AdminHeaders, conn: D
#[allow(non_snake_case)] #[allow(non_snake_case)]
struct EditUserData { struct EditUserData {
Type: NumberOrString, Type: NumberOrString,
#[serde(deserialize_with = "deserialize_collections")] Collections: Option<Vec<CollectionData>>,
Collections: Vec<CollectionData>,
AccessAll: bool, AccessAll: bool,
} }
@@ -749,7 +765,7 @@ fn edit_user(
// If no accessAll, add the collections received // If no accessAll, add the collections received
if !data.AccessAll { if !data.AccessAll {
for col in &data.Collections { for col in data.Collections.iter().flatten() {
match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) { match Collection::find_by_uuid_and_org(&col.Id, &org_id, &conn) {
None => err!("Collection not found in Organization"), None => err!("Collection not found in Organization"),
Some(collection) => { Some(collection) => {

View File

@@ -3,15 +3,14 @@ use rocket_contrib::json::Json;
use serde_json; use serde_json;
use serde_json::Value; use serde_json::Value;
use crate::api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData};
use crate::auth::Headers;
use crate::crypto;
use crate::db::{ use crate::db::{
models::{TwoFactor, TwoFactorType, User}, models::{TwoFactor, TwoFactorType, User},
DbConn, DbConn,
}; };
use crate::error::{Error, MapResult};
use crate::crypto;
use crate::api::{ApiResult, EmptyResult, JsonResult, JsonUpcase, NumberOrString, PasswordData};
use crate::auth::Headers;
use rocket::Route; use rocket::Route;
@@ -50,13 +49,14 @@ fn get_twofactor(headers: Headers, conn: DbConn) -> JsonResult {
#[post("/two-factor/get-recover", data = "<data>")] #[post("/two-factor/get-recover", data = "<data>")]
fn get_recover(data: JsonUpcase<PasswordData>, headers: Headers) -> JsonResult { fn get_recover(data: JsonUpcase<PasswordData>, headers: Headers) -> JsonResult {
let data: PasswordData = data.into_inner().data; let data: PasswordData = data.into_inner().data;
let user = headers.user;
if !headers.user.check_valid_password(&data.MasterPasswordHash) { if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password"); err!("Invalid password");
} }
Ok(Json(json!({ Ok(Json(json!({
"Code": headers.user.totp_recover, "Code": user.totp_recover,
"Object": "twoFactorRecover" "Object": "twoFactorRecover"
}))) })))
} }
@@ -93,7 +93,7 @@ fn recover(data: JsonUpcase<RecoverTwoFactor>, conn: DbConn) -> JsonResult {
// Remove all twofactors from the user // Remove all twofactors from the user
for twofactor in TwoFactor::find_by_user(&user.uuid, &conn) { for twofactor in TwoFactor::find_by_user(&user.uuid, &conn) {
twofactor.delete(&conn).expect("Error deleting twofactor"); twofactor.delete(&conn)?;
} }
// Remove the recovery code, not needed without twofactors // Remove the recovery code, not needed without twofactors
@@ -113,15 +113,16 @@ struct DisableTwoFactorData {
fn disable_twofactor(data: JsonUpcase<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult { fn disable_twofactor(data: JsonUpcase<DisableTwoFactorData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: DisableTwoFactorData = data.into_inner().data; let data: DisableTwoFactorData = data.into_inner().data;
let password_hash = data.MasterPasswordHash; let password_hash = data.MasterPasswordHash;
let user = headers.user;
if !headers.user.check_valid_password(&password_hash) { if !user.check_valid_password(&password_hash) {
err!("Invalid password"); err!("Invalid password");
} }
let type_ = data.Type.into_i32().expect("Invalid type"); let type_ = data.Type.into_i32().expect("Invalid type");
if let Some(twofactor) = TwoFactor::find_by_user_and_type(&headers.user.uuid, type_, &conn) { if let Some(twofactor) = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn) {
twofactor.delete(&conn).expect("Error deleting twofactor"); twofactor.delete(&conn)?;
} }
Ok(Json(json!({ Ok(Json(json!({
@@ -139,13 +140,14 @@ fn disable_twofactor_put(data: JsonUpcase<DisableTwoFactorData>, headers: Header
#[post("/two-factor/get-authenticator", data = "<data>")] #[post("/two-factor/get-authenticator", data = "<data>")]
fn generate_authenticator(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult { fn generate_authenticator(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: PasswordData = data.into_inner().data; let data: PasswordData = data.into_inner().data;
let user = headers.user;
if !headers.user.check_valid_password(&data.MasterPasswordHash) { if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password"); err!("Invalid password");
} }
let type_ = TwoFactorType::Authenticator as i32; let type_ = TwoFactorType::Authenticator as i32;
let twofactor = TwoFactor::find_by_user_and_type(&headers.user.uuid, type_, &conn); let twofactor = TwoFactor::find_by_user_and_type(&user.uuid, type_, &conn);
let (enabled, key) = match twofactor { let (enabled, key) = match twofactor {
Some(tf) => (true, tf.data), Some(tf) => (true, tf.data),
@@ -177,7 +179,9 @@ fn activate_authenticator(data: JsonUpcase<EnableAuthenticatorData>, headers: He
None => err!("Malformed token"), None => err!("Malformed token"),
}; };
if !headers.user.check_valid_password(&password_hash) { let mut user = headers.user;
if !user.check_valid_password(&password_hash) {
err!("Invalid password"); err!("Invalid password");
} }
@@ -192,16 +196,15 @@ fn activate_authenticator(data: JsonUpcase<EnableAuthenticatorData>, headers: He
} }
let type_ = TwoFactorType::Authenticator; let type_ = TwoFactorType::Authenticator;
let twofactor = TwoFactor::new(headers.user.uuid.clone(), type_, key.to_uppercase()); let twofactor = TwoFactor::new(user.uuid.clone(), type_, key.to_uppercase());
// Validate the token provided with the key // Validate the token provided with the key
if !twofactor.check_totp_code(token) { if !twofactor.check_totp_code(token) {
err!("Invalid totp code") err!("Invalid totp code")
} }
let mut user = headers.user;
_generate_recover_code(&mut user, &conn); _generate_recover_code(&mut user, &conn);
twofactor.save(&conn).expect("Error saving twofactor"); twofactor.save(&conn)?;
Ok(Json(json!({ Ok(Json(json!({
"Enabled": true, "Enabled": true,
@@ -232,26 +235,25 @@ use crate::CONFIG;
const U2F_VERSION: &str = "U2F_V2"; const U2F_VERSION: &str = "U2F_V2";
lazy_static! { lazy_static! {
static ref APP_ID: String = format!("{}/app-id.json", &CONFIG.domain); static ref APP_ID: String = format!("{}/app-id.json", &CONFIG.domain());
static ref U2F: U2f = U2f::new(APP_ID.clone()); static ref U2F: U2f = U2f::new(APP_ID.clone());
} }
#[post("/two-factor/get-u2f", data = "<data>")] #[post("/two-factor/get-u2f", data = "<data>")]
fn generate_u2f(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult { fn generate_u2f(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
if !CONFIG.domain_set { if !CONFIG.domain_set() {
err!("`DOMAIN` environment variable is not set. U2F disabled") err!("`DOMAIN` environment variable is not set. U2F disabled")
} }
let data: PasswordData = data.into_inner().data; let data: PasswordData = data.into_inner().data;
let user = headers.user;
if !headers.user.check_valid_password(&data.MasterPasswordHash) { if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password"); err!("Invalid password");
} }
let user_uuid = &headers.user.uuid;
let u2f_type = TwoFactorType::U2f as i32; let u2f_type = TwoFactorType::U2f as i32;
let enabled = TwoFactor::find_by_user_and_type(user_uuid, u2f_type, &conn).is_some(); let enabled = TwoFactor::find_by_user_and_type(&user.uuid, u2f_type, &conn).is_some();
Ok(Json(json!({ Ok(Json(json!({
"Enabled": enabled, "Enabled": enabled,
@@ -262,17 +264,18 @@ fn generate_u2f(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn)
#[post("/two-factor/get-u2f-challenge", data = "<data>")] #[post("/two-factor/get-u2f-challenge", data = "<data>")]
fn generate_u2f_challenge(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult { fn generate_u2f_challenge(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: PasswordData = data.into_inner().data; let data: PasswordData = data.into_inner().data;
let user = headers.user;
if !headers.user.check_valid_password(&data.MasterPasswordHash) { if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password"); err!("Invalid password");
} }
let user_uuid = &headers.user.uuid; let user_uuid = &user.uuid;
let challenge = _create_u2f_challenge(user_uuid, TwoFactorType::U2fRegisterChallenge, &conn).challenge; let challenge = _create_u2f_challenge(user_uuid, TwoFactorType::U2fRegisterChallenge, &conn).challenge;
Ok(Json(json!({ Ok(Json(json!({
"UserId": headers.user.uuid, "UserId": user.uuid,
"AppId": APP_ID.to_string(), "AppId": APP_ID.to_string(),
"Challenge": challenge, "Challenge": challenge,
"Version": U2F_VERSION, "Version": U2F_VERSION,
@@ -282,6 +285,8 @@ fn generate_u2f_challenge(data: JsonUpcase<PasswordData>, headers: Headers, conn
#[derive(Deserialize, Debug)] #[derive(Deserialize, Debug)]
#[allow(non_snake_case)] #[allow(non_snake_case)]
struct EnableU2FData { struct EnableU2FData {
Id: NumberOrString, // 1..5
Name: String,
MasterPasswordHash: String, MasterPasswordHash: String,
DeviceResponse: String, DeviceResponse: String,
} }
@@ -311,17 +316,19 @@ impl RegisterResponseCopy {
#[post("/two-factor/u2f", data = "<data>")] #[post("/two-factor/u2f", data = "<data>")]
fn activate_u2f(data: JsonUpcase<EnableU2FData>, headers: Headers, conn: DbConn) -> JsonResult { fn activate_u2f(data: JsonUpcase<EnableU2FData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: EnableU2FData = data.into_inner().data; let data: EnableU2FData = data.into_inner().data;
let mut user = headers.user;
if !headers.user.check_valid_password(&data.MasterPasswordHash) { if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password"); err!("Invalid password");
} }
let tf_challenge = let tf_type = TwoFactorType::U2fRegisterChallenge as i32;
TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::U2fRegisterChallenge as i32, &conn); let tf_challenge = match TwoFactor::find_by_user_and_type(&user.uuid, tf_type, &conn) {
Some(c) => c,
None => err!("Can't recover challenge"),
};
if let Some(tf_challenge) = tf_challenge {
let challenge: Challenge = serde_json::from_str(&tf_challenge.data)?; let challenge: Challenge = serde_json::from_str(&tf_challenge.data)?;
tf_challenge.delete(&conn)?; tf_challenge.delete(&conn)?;
let response_copy: RegisterResponseCopy = serde_json::from_str(&data.DeviceResponse)?; let response_copy: RegisterResponseCopy = serde_json::from_str(&data.DeviceResponse)?;
@@ -343,13 +350,12 @@ fn activate_u2f(data: JsonUpcase<EnableU2FData>, headers: Headers, conn: DbConn)
registrations.push(registration); registrations.push(registration);
let tf_registration = TwoFactor::new( let tf_registration = TwoFactor::new(
headers.user.uuid.clone(), user.uuid.clone(),
TwoFactorType::U2f, TwoFactorType::U2f,
serde_json::to_string(&registrations).unwrap(), serde_json::to_string(&registrations).unwrap(),
); );
tf_registration.save(&conn)?; tf_registration.save(&conn)?;
let mut user = headers.user;
_generate_recover_code(&mut user, &conn); _generate_recover_code(&mut user, &conn);
Ok(Json(json!({ Ok(Json(json!({
@@ -362,9 +368,6 @@ fn activate_u2f(data: JsonUpcase<EnableU2FData>, headers: Headers, conn: DbConn)
}, },
"Object": "twoFactorU2f" "Object": "twoFactorU2f"
}))) })))
} else {
err!("Can't recover challenge")
}
} }
#[put("/two-factor/u2f", data = "<data>")] #[put("/two-factor/u2f", data = "<data>")]
@@ -459,7 +462,7 @@ pub fn validate_u2f_login(user_uuid: &str, response: &str, conn: &DbConn) -> Emp
} }
Err(e) => { Err(e) => {
info!("E {:#}", e); info!("E {:#}", e);
break; // break;
} }
} }
} }
@@ -489,29 +492,9 @@ use yubico::config::Config;
use yubico::Yubico; use yubico::Yubico;
fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> { fn parse_yubikeys(data: &EnableYubikeyData) -> Vec<String> {
let mut yubikeys: Vec<String> = Vec::new(); let data_keys = [&data.Key1, &data.Key2, &data.Key3, &data.Key4, &data.Key5];
if data.Key1.is_some() { data_keys.iter().filter_map(|e| e.as_ref().cloned()).collect()
yubikeys.push(data.Key1.as_ref().unwrap().to_owned());
}
if data.Key2.is_some() {
yubikeys.push(data.Key2.as_ref().unwrap().to_owned());
}
if data.Key3.is_some() {
yubikeys.push(data.Key3.as_ref().unwrap().to_owned());
}
if data.Key4.is_some() {
yubikeys.push(data.Key4.as_ref().unwrap().to_owned());
}
if data.Key5.is_some() {
yubikeys.push(data.Key5.as_ref().unwrap().to_owned());
}
yubikeys
} }
fn jsonify_yubikeys(yubikeys: Vec<String>) -> serde_json::Value { fn jsonify_yubikeys(yubikeys: Vec<String>) -> serde_json::Value {
@@ -524,40 +507,40 @@ fn jsonify_yubikeys(yubikeys: Vec<String>) -> serde_json::Value {
result result
} }
fn verify_yubikey_otp(otp: String) -> JsonResult { fn get_yubico_credentials() -> Result<(String, String), Error> {
if !CONFIG.yubico_cred_set { match (CONFIG.yubico_client_id(), CONFIG.yubico_secret_key()) {
err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled") (Some(id), Some(secret)) => Ok((id, secret)),
_ => err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled"),
} }
}
fn verify_yubikey_otp(otp: String) -> EmptyResult {
let (yubico_id, yubico_secret) = get_yubico_credentials()?;
let yubico = Yubico::new(); let yubico = Yubico::new();
let config = Config::default() let config = Config::default().set_client_id(yubico_id).set_key(yubico_secret);
.set_client_id(CONFIG.yubico_client_id.to_owned())
.set_key(CONFIG.yubico_secret_key.to_owned());
let result = match CONFIG.yubico_server { match CONFIG.yubico_server() {
Some(ref server) => yubico.verify(otp, config.set_api_hosts(vec![server.to_owned()])), Some(server) => yubico.verify(otp, config.set_api_hosts(vec![server])),
None => yubico.verify(otp, config), None => yubico.verify(otp, config),
};
match result {
Ok(_answer) => Ok(Json(json!({}))),
Err(_e) => err!("Failed to verify OTP"),
} }
.map_res("Failed to verify OTP")
.and(Ok(()))
} }
#[post("/two-factor/get-yubikey", data = "<data>")] #[post("/two-factor/get-yubikey", data = "<data>")]
fn generate_yubikey(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult { fn generate_yubikey(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbConn) -> JsonResult {
if !CONFIG.yubico_cred_set { // Make sure the credentials are set
err!("`YUBICO_CLIENT_ID` or `YUBICO_SECRET_KEY` environment variable is not set. Yubikey OTP Disabled") get_yubico_credentials()?;
}
let data: PasswordData = data.into_inner().data; let data: PasswordData = data.into_inner().data;
let user = headers.user;
if !headers.user.check_valid_password(&data.MasterPasswordHash) { if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password"); err!("Invalid password");
} }
let user_uuid = &headers.user.uuid; let user_uuid = &user.uuid;
let yubikey_type = TwoFactorType::YubiKey as i32; let yubikey_type = TwoFactorType::YubiKey as i32;
let r = TwoFactor::find_by_user_and_type(user_uuid, yubikey_type, &conn); let r = TwoFactor::find_by_user_and_type(user_uuid, yubikey_type, &conn);
@@ -583,13 +566,14 @@ fn generate_yubikey(data: JsonUpcase<PasswordData>, headers: Headers, conn: DbCo
#[post("/two-factor/yubikey", data = "<data>")] #[post("/two-factor/yubikey", data = "<data>")]
fn activate_yubikey(data: JsonUpcase<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult { fn activate_yubikey(data: JsonUpcase<EnableYubikeyData>, headers: Headers, conn: DbConn) -> JsonResult {
let data: EnableYubikeyData = data.into_inner().data; let data: EnableYubikeyData = data.into_inner().data;
let mut user = headers.user;
if !headers.user.check_valid_password(&data.MasterPasswordHash) { if !user.check_valid_password(&data.MasterPasswordHash) {
err!("Invalid password"); err!("Invalid password");
} }
// Check if we already have some data // Check if we already have some data
let yubikey_data = TwoFactor::find_by_user_and_type(&headers.user.uuid, TwoFactorType::YubiKey as i32, &conn); let yubikey_data = TwoFactor::find_by_user_and_type(&user.uuid, TwoFactorType::YubiKey as i32, &conn);
if let Some(yubikey_data) = yubikey_data { if let Some(yubikey_data) = yubikey_data {
yubikey_data.delete(&conn)?; yubikey_data.delete(&conn)?;
@@ -611,11 +595,7 @@ fn activate_yubikey(data: JsonUpcase<EnableYubikeyData>, headers: Headers, conn:
continue; continue;
} }
let result = verify_yubikey_otp(yubikey.to_owned()); verify_yubikey_otp(yubikey.to_owned()).map_res("Invalid Yubikey OTP provided")?;
if let Err(_e) = result {
err!("Invalid Yubikey OTP provided");
}
} }
let yubikey_ids: Vec<String> = yubikeys.into_iter().map(|x| (&x[..12]).to_owned()).collect(); let yubikey_ids: Vec<String> = yubikeys.into_iter().map(|x| (&x[..12]).to_owned()).collect();
@@ -626,12 +606,14 @@ fn activate_yubikey(data: JsonUpcase<EnableYubikeyData>, headers: Headers, conn:
}; };
let yubikey_registration = TwoFactor::new( let yubikey_registration = TwoFactor::new(
headers.user.uuid.clone(), user.uuid.clone(),
TwoFactorType::YubiKey, TwoFactorType::YubiKey,
serde_json::to_string(&yubikey_metadata).unwrap(), serde_json::to_string(&yubikey_metadata).unwrap(),
); );
yubikey_registration.save(&conn)?; yubikey_registration.save(&conn)?;
_generate_recover_code(&mut user, &conn);
let mut result = jsonify_yubikeys(yubikey_metadata.Keys); let mut result = jsonify_yubikeys(yubikey_metadata.Keys);
result["Enabled"] = Value::Bool(true); result["Enabled"] = Value::Bool(true);

View File

@@ -1,14 +1,19 @@
use std::error::Error;
use std::fs::{create_dir_all, remove_file, symlink_metadata, File}; use std::fs::{create_dir_all, remove_file, symlink_metadata, File};
use std::io::prelude::*; use std::io::prelude::*;
use std::time::SystemTime; use std::time::{Duration, SystemTime};
use rocket::http::ContentType; use rocket::http::ContentType;
use rocket::response::Content; use rocket::response::Content;
use rocket::Route; use rocket::Route;
use reqwest; use reqwest::{header::HeaderMap, Client, Response};
use rocket::http::{Cookie};
use regex::Regex;
use soup::prelude::*;
use crate::error::Error;
use crate::CONFIG; use crate::CONFIG;
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
@@ -17,6 +22,16 @@ pub fn routes() -> Vec<Route> {
const FALLBACK_ICON: &[u8; 344] = include_bytes!("../static/fallback-icon.png"); const FALLBACK_ICON: &[u8; 344] = include_bytes!("../static/fallback-icon.png");
lazy_static! {
// Reuse the client between requests
static ref CLIENT: Client = Client::builder()
.gzip(true)
.timeout(Duration::from_secs(5))
.default_headers(_header_map())
.build()
.unwrap();
}
#[get("/<domain>/icon.png")] #[get("/<domain>/icon.png")]
fn icon(domain: String) -> Content<Vec<u8>> { fn icon(domain: String) -> Content<Vec<u8>> {
let icon_type = ContentType::new("image", "x-icon"); let icon_type = ContentType::new("image", "x-icon");
@@ -32,16 +47,18 @@ fn icon(domain: String) -> Content<Vec<u8>> {
} }
fn get_icon(domain: &str) -> Vec<u8> { fn get_icon(domain: &str) -> Vec<u8> {
let path = format!("{}/{}.png", CONFIG.icon_cache_folder, domain); let path = format!("{}/{}.png", CONFIG.icon_cache_folder(), domain);
if let Some(icon) = get_cached_icon(&path) { if let Some(icon) = get_cached_icon(&path) {
return icon; return icon;
} }
let url = get_icon_url(&domain); if CONFIG.disable_icon_download() {
return FALLBACK_ICON.to_vec();
}
// Get the icon, or fallback in case of error // Get the icon, or fallback in case of error
match download_icon(&url) { match download_icon(&domain) {
Ok(icon) => { Ok(icon) => {
save_icon(&path, &icon); save_icon(&path, &icon);
icon icon
@@ -77,7 +94,7 @@ fn get_cached_icon(path: &str) -> Option<Vec<u8>> {
None None
} }
fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Box<Error>> { fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Error> {
let meta = symlink_metadata(path)?; let meta = symlink_metadata(path)?;
let modified = meta.modified()?; let modified = meta.modified()?;
let age = SystemTime::now().duration_since(modified)?; let age = SystemTime::now().duration_since(modified)?;
@@ -87,7 +104,7 @@ fn file_is_expired(path: &str, ttl: u64) -> Result<bool, Box<Error>> {
fn icon_is_negcached(path: &str) -> bool { fn icon_is_negcached(path: &str) -> bool {
let miss_indicator = path.to_owned() + ".miss"; let miss_indicator = path.to_owned() + ".miss";
let expired = file_is_expired(&miss_indicator, CONFIG.icon_cache_negttl); let expired = file_is_expired(&miss_indicator, CONFIG.icon_cache_negttl());
match expired { match expired {
// No longer negatively cached, drop the marker // No longer negatively cached, drop the marker
@@ -110,34 +127,222 @@ fn mark_negcache(path: &str) {
} }
fn icon_is_expired(path: &str) -> bool { fn icon_is_expired(path: &str) -> bool {
let expired = file_is_expired(path, CONFIG.icon_cache_ttl); let expired = file_is_expired(path, CONFIG.icon_cache_ttl());
expired.unwrap_or(true) expired.unwrap_or(true)
} }
fn get_icon_url(domain: &str) -> String { #[derive(Debug)]
if CONFIG.local_icon_extractor { struct IconList {
format!("http://{}/favicon.ico", domain) priority: u8,
href: String,
}
/// Returns a Result/Tuple which holds a Vector IconList and a string which holds the cookies from the last response.
/// There will always be a result with a string which will contain https://example.com/favicon.ico and an empty string for the cookies.
/// This does not mean that that location does exists, but it is the default location browser use.
///
/// # Argument
/// * `domain` - A string which holds the domain with extension.
///
/// # Example
/// ```
/// let (mut iconlist, cookie_str) = get_icon_url("github.com")?;
/// let (mut iconlist, cookie_str) = get_icon_url("gitlab.com")?;
/// ```
fn get_icon_url(domain: &str) -> Result<(Vec<IconList>, String), Error> {
// Default URL with secure and insecure schemes
let ssldomain = format!("https://{}", domain);
let httpdomain = format!("http://{}", domain);
// Create the iconlist
let mut iconlist: Vec<IconList> = Vec::new();
// Create the cookie_str to fill it all the cookies from the response
// These cookies can be used to request/download the favicon image.
// Some sites have extra security in place with for example XSRF Tokens.
let mut cookie_str = String::new();
let resp = get_page(&ssldomain).or_else(|_| get_page(&httpdomain));
if let Ok(content) = resp {
// Extract the URL from the respose in case redirects occured (like @ gitlab.com)
let url = content.url().clone();
let raw_cookies = content.headers().get_all("set-cookie");
cookie_str = raw_cookies.iter().map(|raw_cookie| {
let cookie = Cookie::parse(raw_cookie.to_str().unwrap_or_default()).unwrap();
format!("{}={}; ", cookie.name(), cookie.value())
}).collect::<String>();
// Add the default favicon.ico to the list with the domain the content responded from.
iconlist.push(IconList { priority: 35, href: url.join("/favicon.ico").unwrap().into_string() });
let soup = Soup::from_reader(content)?;
// Search for and filter
let favicons = soup
.tag("link")
.attr("rel", Regex::new(r"icon$|apple.*icon")?) // Only use icon rels
.attr("href", Regex::new(r"(?i)\w+\.(jpg|jpeg|png|ico)(\?.*)?$")?) // Only allow specific extensions
.find_all();
// Loop through all the found icons and determine it's priority
for favicon in favicons {
let sizes = favicon.get("sizes").unwrap_or_default();
let href = url.join(&favicon.get("href").unwrap_or_default()).unwrap().into_string();
let priority = get_icon_priority(&href, &sizes);
iconlist.push(IconList { priority, href })
}
} else { } else {
format!("https://icons.bitwarden.com/{}/icon.png", domain) // Add the default favicon.ico to the list with just the given domain
iconlist.push(IconList { priority: 35, href: format!("{}/favicon.ico", ssldomain) });
}
// Sort the iconlist by priority
iconlist.sort_by_key(|x| x.priority);
// There always is an icon in the list, so no need to check if it exists, and just return the first one
Ok((iconlist, cookie_str))
}
fn get_page(url: &str) -> Result<Response, Error> {
//CLIENT.get(url).send()?.error_for_status().map_err(Into::into)
get_page_with_cookies(url, "")
}
fn get_page_with_cookies(url: &str, cookie_str: &str) -> Result<Response, Error> {
CLIENT.get(url).header("cookie", cookie_str).send()?.error_for_status().map_err(Into::into)
}
/// Returns a Integer with the priority of the type of the icon which to prefer.
/// The lower the number the better.
///
/// # Arguments
/// * `href` - A string which holds the href value or relative path.
/// * `sizes` - The size of the icon if available as a <width>x<height> value like 32x32.
///
/// # Example
/// ```
/// priority1 = get_icon_priority("http://example.com/path/to/a/favicon.png", "32x32");
/// priority2 = get_icon_priority("https://example.com/path/to/a/favicon.ico", "");
/// ```
fn get_icon_priority(href: &str, sizes: &str) -> u8 {
// Check if there is a dimension set
let (width, height) = parse_sizes(sizes);
// Check if there is a size given
if width != 0 && height != 0 {
// Only allow square dimensions
if width == height {
// Change priority by given size
if width == 32 {
1
} else if width == 64 {
2
} else if width >= 24 && width <= 128 {
3
} else if width == 16 {
4
} else {
5
}
// There are dimensions available, but the image is not a square
} else {
200
}
} else {
// Change priority by file extension
if href.ends_with(".png") {
10
} else if href.ends_with(".jpg") || href.ends_with(".jpeg") {
20
} else {
30
}
} }
} }
fn download_icon(url: &str) -> Result<Vec<u8>, reqwest::Error> { /// Returns a Tuple with the width and hight as a seperate value extracted from the sizes attribute
info!("Downloading icon for {}...", url); /// It will return 0 for both values if no match has been found.
let mut res = reqwest::get(url)?; ///
/// # Arguments
/// * `sizes` - The size of the icon if available as a <width>x<height> value like 32x32.
///
/// # Example
/// ```
/// let (width, height) = parse_sizes("64x64"); // (64, 64)
/// let (width, height) = parse_sizes("x128x128"); // (128, 128)
/// let (width, height) = parse_sizes("32"); // (0, 0)
/// ```
fn parse_sizes(sizes: &str) -> (u16, u16) {
let mut width: u16 = 0;
let mut height: u16 = 0;
res = res.error_for_status()?; if !sizes.is_empty() {
match Regex::new(r"(?x)(\d+)\D*(\d+)").unwrap().captures(sizes.trim()) {
None => {},
Some(dimensions) => {
if dimensions.len() >= 3 {
width = dimensions[1].parse::<u16>().unwrap_or_default();
height = dimensions[2].parse::<u16>().unwrap_or_default();
}
},
}
}
let mut buffer: Vec<u8> = vec![]; (width, height)
}
fn download_icon(domain: &str) -> Result<Vec<u8>, Error> {
let (mut iconlist, cookie_str) = get_icon_url(&domain)?;
let mut buffer = Vec::new();
iconlist.truncate(5);
for icon in iconlist {
let url = icon.href;
info!("Downloading icon for {} via {}...", domain, url);
match get_page_with_cookies(&url, &cookie_str) {
Ok(mut res) => {
info!("Download finished for {}", url);
res.copy_to(&mut buffer)?; res.copy_to(&mut buffer)?;
break;
},
Err(_) => info!("Download failed for {}", url),
};
}
if buffer.is_empty() {
err!("Empty response")
}
Ok(buffer) Ok(buffer)
} }
fn save_icon(path: &str, icon: &[u8]) { fn save_icon(path: &str, icon: &[u8]) {
create_dir_all(&CONFIG.icon_cache_folder).expect("Error creating icon cache"); create_dir_all(&CONFIG.icon_cache_folder()).expect("Error creating icon cache");
if let Ok(mut f) = File::create(path) { if let Ok(mut f) = File::create(path) {
f.write_all(icon).expect("Error writing icon file"); f.write_all(icon).expect("Error writing icon file");
}; };
} }
fn _header_map() -> HeaderMap {
// Set some default headers for the request.
// Use a browser like user-agent to make sure most websites will return there correct website.
use reqwest::header::*;
macro_rules! headers {
($( $name:ident : $value:literal),+ $(,)? ) => {
let mut headers = HeaderMap::new();
$( headers.insert($name, HeaderValue::from_static($value)); )+
headers
};
}
headers! {
USER_AGENT: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299",
ACCEPT_LANGUAGE: "en-US,en;q=0.8",
CACHE_CONTROL: "no-cache",
PRAGMA: "no-cache",
ACCEPT: "text/html,application/xhtml+xml,application/xml; q=0.9,image/webp,image/apng,*/*;q=0.8",
}
}

View File

@@ -151,10 +151,7 @@ fn twofactor_auth(
device: &mut Device, device: &mut Device,
conn: &DbConn, conn: &DbConn,
) -> ApiResult<Option<String>> { ) -> ApiResult<Option<String>> {
let twofactors_raw = TwoFactor::find_by_user(user_uuid, conn); let twofactors = TwoFactor::find_by_user(user_uuid, conn);
// Remove u2f challenge twofactors (impl detail)
let twofactors: Vec<_> = twofactors_raw.iter().filter(|tf| tf.type_ < 1000).collect();
let providers: Vec<_> = twofactors.iter().map(|tf| tf.type_).collect(); let providers: Vec<_> = twofactors.iter().map(|tf| tf.type_).collect();
// No twofactor token if twofactor is disabled // No twofactor token if twofactor is disabled
@@ -234,7 +231,7 @@ fn _json_err_twofactor(providers: &[i32], user_uuid: &str, conn: &DbConn) -> Api
match TwoFactorType::from_i32(*provider) { match TwoFactorType::from_i32(*provider) {
Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ } Some(TwoFactorType::Authenticator) => { /* Nothing to do for TOTP */ }
Some(TwoFactorType::U2f) if CONFIG.domain_set => { Some(TwoFactorType::U2f) if CONFIG.domain_set() => {
let request = two_factor::generate_u2f_login(user_uuid, conn)?; let request = two_factor::generate_u2f_login(user_uuid, conn)?;
let mut challenge_list = Vec::new(); let mut challenge_list = Vec::new();

View File

@@ -23,6 +23,7 @@ pub type EmptyResult = ApiResult<()>;
use crate::util; use crate::util;
type JsonUpcase<T> = Json<util::UpCase<T>>; type JsonUpcase<T> = Json<util::UpCase<T>>;
type JsonUpcaseVec<T> = Json<Vec<util::UpCase<T>>>;
// Common structs representing JSON data received // Common structs representing JSON data received
#[derive(Deserialize)] #[derive(Deserialize)]

View File

@@ -25,7 +25,7 @@ fn negotiate(_headers: Headers, _conn: DbConn) -> JsonResult {
let conn_id = BASE64URL.encode(&crypto::get_random(vec![0u8; 16])); let conn_id = BASE64URL.encode(&crypto::get_random(vec![0u8; 16]));
let mut available_transports: Vec<JsonValue> = Vec::new(); let mut available_transports: Vec<JsonValue> = Vec::new();
if CONFIG.websocket_enabled { if CONFIG.websocket_enabled() {
available_transports.push(json!({"transport":"WebSockets", "transferFormats":["Text","Binary"]})); available_transports.push(json!({"transport":"WebSockets", "transferFormats":["Text","Binary"]}));
} }
@@ -91,10 +91,7 @@ fn serialize_date(date: NaiveDateTime) -> Value {
let nanos: i64 = date.timestamp_subsec_nanos() as i64; let nanos: i64 = date.timestamp_subsec_nanos() as i64;
let timestamp = nanos << 34 | seconds; let timestamp = nanos << 34 | seconds;
use byteorder::{BigEndian, WriteBytesExt}; let bs = timestamp.to_be_bytes();
let mut bs = [0u8; 8];
bs.as_mut().write_i64::<BigEndian>(timestamp).expect("Unable to write");
// -1 is Timestamp // -1 is Timestamp
// https://github.com/msgpack/msgpack/blob/master/spec.md#timestamp-extension-type // https://github.com/msgpack/msgpack/blob/master/spec.md#timestamp-extension-type
@@ -138,7 +135,7 @@ impl Handler for WSHandler {
// Validate the user // Validate the user
use crate::auth; use crate::auth;
let claims = match auth::decode_jwt(access_token) { let claims = match auth::decode_login(access_token) {
Ok(claims) => claims, Ok(claims) => claims,
Err(_) => return Err(ws::Error::new(ws::ErrorKind::Internal, "Invalid access token provided")), Err(_) => return Err(ws::Error::new(ws::ErrorKind::Internal, "Invalid access token provided")),
}; };
@@ -243,7 +240,6 @@ impl WebSocketUsers {
} }
// NOTE: The last modified date needs to be updated before calling these methods // NOTE: The last modified date needs to be updated before calling these methods
#[allow(dead_code)]
pub fn send_user_update(&self, ut: UpdateType, user: &User) { pub fn send_user_update(&self, ut: UpdateType, user: &User) {
let data = create_update( let data = create_update(
vec![ vec![
@@ -328,6 +324,7 @@ fn create_ping() -> Vec<u8> {
} }
#[allow(dead_code)] #[allow(dead_code)]
#[derive(PartialEq)]
pub enum UpdateType { pub enum UpdateType {
CipherUpdate = 0, CipherUpdate = 0,
CipherCreate = 1, CipherCreate = 1,
@@ -343,6 +340,8 @@ pub enum UpdateType {
SyncSettings = 10, SyncSettings = 10,
LogOut = 11, LogOut = 11,
None = 100,
} }
use rocket::State; use rocket::State;
@@ -352,9 +351,12 @@ pub fn start_notification_server() -> WebSocketUsers {
let factory = WSFactory::init(); let factory = WSFactory::init();
let users = factory.users.clone(); let users = factory.users.clone();
if CONFIG.websocket_enabled { if CONFIG.websocket_enabled() {
thread::spawn(move || { thread::spawn(move || {
WebSocket::new(factory).unwrap().listen(&CONFIG.websocket_url).unwrap(); WebSocket::new(factory)
.unwrap()
.listen((CONFIG.websocket_address().as_str(), CONFIG.websocket_port()))
.unwrap();
}); });
} }

View File

@@ -2,18 +2,18 @@ use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use rocket::http::ContentType; use rocket::http::ContentType;
use rocket::request::Request;
use rocket::response::content::Content; use rocket::response::content::Content;
use rocket::response::{self, NamedFile, Responder}; use rocket::response::NamedFile;
use rocket::Route; use rocket::Route;
use rocket_contrib::json::Json; use rocket_contrib::json::Json;
use serde_json::Value; use serde_json::Value;
use crate::util::Cached;
use crate::CONFIG; use crate::CONFIG;
pub fn routes() -> Vec<Route> { pub fn routes() -> Vec<Route> {
if CONFIG.web_vault_enabled { 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 { } else {
routes![attachments, alive] routes![attachments, alive]
} }
@@ -21,7 +21,9 @@ pub fn routes() -> Vec<Route> {
#[get("/")] #[get("/")]
fn web_index() -> Cached<io::Result<NamedFile>> { fn web_index() -> Cached<io::Result<NamedFile>> {
Cached::short(NamedFile::open(Path::new(&CONFIG.web_vault_folder).join("index.html"))) Cached::short(NamedFile::open(
Path::new(&CONFIG.web_vault_folder()).join("index.html"),
))
} }
#[get("/app-id.json")] #[get("/app-id.json")]
@@ -35,7 +37,7 @@ fn app_id() -> Cached<Content<Json<Value>>> {
{ {
"version": { "major": 1, "minor": 0 }, "version": { "major": 1, "minor": 0 },
"ids": [ "ids": [
&CONFIG.domain, &CONFIG.domain(),
"ios:bundle-id:com.8bit.bitwarden", "ios:bundle-id:com.8bit.bitwarden",
"android:apk-key-hash:dUGFzUzf3lmHSLBDBIv+WaFyZMI" ] "android:apk-key-hash:dUGFzUzf3lmHSLBDBIv+WaFyZMI" ]
}] }]
@@ -43,55 +45,14 @@ fn app_id() -> Cached<Content<Json<Value>>> {
)) ))
} }
const ADMIN_PAGE: &'static str = include_str!("../static/admin.html"); #[get("/<p..>", rank = 10)] // Only match this if the other routes don't match
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
fn web_files(p: PathBuf) -> Cached<io::Result<NamedFile>> { fn web_files(p: PathBuf) -> Cached<io::Result<NamedFile>> {
Cached::long(NamedFile::open(Path::new(&CONFIG.web_vault_folder).join(p))) 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..>")] #[get("/attachments/<uuid>/<file..>")]
fn attachments(uuid: String, file: PathBuf) -> io::Result<NamedFile> { fn attachments(uuid: String, file: PathBuf) -> io::Result<NamedFile> {
NamedFile::open(Path::new(&CONFIG.attachments_folder).join(uuid).join(file)) NamedFile::open(Path::new(&CONFIG.attachments_folder()).join(uuid).join(file))
} }
#[get("/alive")] #[get("/alive")]

View File

@@ -5,6 +5,7 @@ use crate::util::read_file;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use jsonwebtoken::{self, Algorithm, Header}; use jsonwebtoken::{self, Algorithm, Header};
use serde::de::DeserializeOwned;
use serde::ser::Serialize; use serde::ser::Serialize;
use crate::error::{Error, MapResult}; use crate::error::{Error, MapResult};
@@ -14,20 +15,22 @@ const JWT_ALGORITHM: Algorithm = Algorithm::RS256;
lazy_static! { lazy_static! {
pub static ref DEFAULT_VALIDITY: Duration = Duration::hours(2); 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); static ref JWT_HEADER: Header = Header::new(JWT_ALGORITHM);
static ref PRIVATE_RSA_KEY: Vec<u8> = match read_file(&CONFIG.private_rsa_key) { 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, Ok(key) => key,
Err(e) => panic!( Err(e) => panic!(
"Error loading private RSA Key from {}\n Error: {}", "Error loading private RSA Key from {}\n Error: {}",
CONFIG.private_rsa_key, e CONFIG.private_rsa_key(), e
), ),
}; };
static ref PUBLIC_RSA_KEY: Vec<u8> = match read_file(&CONFIG.public_rsa_key) { static ref PUBLIC_RSA_KEY: Vec<u8> = match read_file(&CONFIG.public_rsa_key()) {
Ok(key) => key, Ok(key) => key,
Err(e) => panic!( Err(e) => panic!(
"Error loading public RSA Key from {}\n Error: {}", "Error loading public RSA Key from {}\n Error: {}",
CONFIG.public_rsa_key, e CONFIG.public_rsa_key(), e
), ),
}; };
} }
@@ -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 { let validation = jsonwebtoken::Validation {
leeway: 30, // 30 seconds leeway: 30, // 30 seconds
validate_exp: true, validate_exp: true,
validate_iat: false, // IssuedAt is the same as NotBefore validate_iat: false, // IssuedAt is the same as NotBefore
validate_nbf: true, validate_nbf: true,
aud: None, aud: None,
iss: Some(JWT_ISSUER.clone()), iss: Some(issuer),
sub: None, sub: None,
algorithms: vec![JWT_ALGORITHM], algorithms: vec![JWT_ALGORITHM],
}; };
@@ -55,30 +58,23 @@ pub fn decode_jwt(token: &str) -> Result<JWTClaims, Error> {
jsonwebtoken::decode(&token, &PUBLIC_RSA_KEY, &validation) jsonwebtoken::decode(&token, &PUBLIC_RSA_KEY, &validation)
.map(|d| d.claims) .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> { pub fn decode_login(token: &str) -> Result<LoginJWTClaims, Error> {
let validation = jsonwebtoken::Validation { decode_jwt(token, JWT_LOGIN_ISSUER.to_string())
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],
};
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) pub fn decode_admin(token: &str) -> Result<AdminJWTClaims, Error> {
.map(|d| d.claims) decode_jwt(token, JWT_ADMIN_ISSUER.to_string())
.map_res("Error decoding invite JWT")
} }
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
pub struct JWTClaims { pub struct LoginJWTClaims {
// Not before // Not before
pub nbf: i64, pub nbf: i64,
// Expiration time // Expiration time
@@ -125,7 +121,8 @@ pub struct InviteJWTClaims {
pub invited_by_email: Option<String>, pub invited_by_email: Option<String>,
} }
pub fn generate_invite_claims(uuid: String, pub fn generate_invite_claims(
uuid: String,
email: String, email: String,
org_id: Option<String>, org_id: Option<String>,
org_user_id: Option<String>, org_user_id: Option<String>,
@@ -135,7 +132,7 @@ pub fn generate_invite_claims(uuid: String,
InviteJWTClaims { InviteJWTClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + Duration::days(5)).timestamp(), exp: (time_now + Duration::days(5)).timestamp(),
iss: JWT_ISSUER.to_string(), iss: JWT_INVITE_ISSUER.to_string(),
sub: uuid.clone(), sub: uuid.clone(),
email: email.clone(), email: email.clone(),
org_id: org_id.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 // Bearer token authentication
// //
@@ -166,8 +185,8 @@ impl<'a, 'r> FromRequest<'a, 'r> for Headers {
let headers = request.headers(); let headers = request.headers();
// Get host // Get host
let host = if CONFIG.domain_set { let host = if CONFIG.domain_set() {
CONFIG.domain.clone() CONFIG.domain()
} else if let Some(referer) = headers.get_one("Referer") { } else if let Some(referer) = headers.get_one("Referer") {
referer.to_string() referer.to_string()
} else { } else {
@@ -203,7 +222,7 @@ impl<'a, 'r> FromRequest<'a, 'r> for Headers {
}; };
// Check JWT token is valid and get device and user from it // 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, Ok(claims) => claims,
Err(_) => err_handler!("Invalid claim"), Err(_) => err_handler!("Invalid claim"),
}; };

470
src/config.rs Normal file
View File

@@ -0,0 +1,470 @@
use std::process::exit;
use std::sync::RwLock;
use crate::error::Error;
use crate::util::get_env;
lazy_static! {
pub static ref CONFIG: Config = Config::load().unwrap_or_else(|e| {
println!("Error loading config:\n\t{:?}\n", e);
exit(12)
});
pub static ref CONFIG_FILE: String = get_env("CONFIG_FILE").unwrap_or_else(|| "data/config.json".into());
}
macro_rules! make_config {
($(
$(#[doc = $groupdoc:literal])?
$group:ident $(: $group_enabled:ident)? {
$(
$(#[doc = $doc:literal])+
$name:ident : $ty:ty, $editable:literal, $none_action:ident $(, $default:expr)?;
)+},
)+) => {
pub struct Config { inner: RwLock<Inner> }
struct Inner {
templates: Handlebars,
config: ConfigItems,
_env: ConfigBuilder,
_usr: ConfigBuilder,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ConfigBuilder {
$($(
#[serde(skip_serializing_if = "Option::is_none")]
$name: Option<$ty>,
)+)+
}
impl ConfigBuilder {
fn from_env() -> Self {
dotenv::from_path(".env").ok();
let mut builder = ConfigBuilder::default();
$($(
builder.$name = get_env(&stringify!($name).to_uppercase());
)+)+
builder
}
fn from_file(path: &str) -> Result<Self, Error> {
use crate::util::read_file_string;
let config_str = read_file_string(path)?;
serde_json::from_str(&config_str).map_err(Into::into)
}
/// Merges the values of both builders into a new builder.
/// If both have the same element, `other` wins.
fn merge(&self, other: &Self) -> Self {
let mut builder = self.clone();
$($(
if let v @Some(_) = &other.$name {
builder.$name = v.clone();
}
)+)+
builder
}
/// Returns a new builder with all the elements from self,
/// except those that are equal in both sides
fn _remove(&self, other: &Self) -> Self {
let mut builder = ConfigBuilder::default();
$($(
if &self.$name != &other.$name {
builder.$name = self.$name.clone();
}
)+)+
builder
}
fn build(&self) -> ConfigItems {
let mut config = ConfigItems::default();
let _domain_set = self.domain.is_some();
$($(
config.$name = make_config!{ @build self.$name.clone(), &config, $none_action, $($default)? };
)+)+
config.domain_set = _domain_set;
config
}
}
#[derive(Debug, Clone, Default)]
pub struct ConfigItems { $($(pub $name: make_config!{@type $ty, $none_action}, )+)+ }
#[allow(unused)]
impl Config {
$($(
pub fn $name(&self) -> make_config!{@type $ty, $none_action} {
self.inner.read().unwrap().config.$name.clone()
}
)+)+
pub fn prepare_json(&self) -> serde_json::Value {
let (def, cfg) = {
let inner = &self.inner.read().unwrap();
(inner._env.build(), inner.config.clone())
};
fn _get_form_type(rust_type: &str) -> &'static str {
match rust_type {
"String" => "text",
"bool" => "checkbox",
_ => "number"
}
}
fn _get_doc(doc: &str) -> serde_json::Value {
let mut split = doc.split("|>").map(str::trim);
json!({
"name": split.next(),
"description": split.next()
})
}
json!([ $({
"group": stringify!($group),
"grouptoggle": stringify!($($group_enabled)?),
"groupdoc": make_config!{ @show $($groupdoc)? },
"elements": [
$( {
"editable": $editable,
"name": stringify!($name),
"value": cfg.$name,
"default": def.$name,
"type": _get_form_type(stringify!($ty)),
"doc": _get_doc(concat!($($doc),+)),
}, )+
]}, )+ ])
}
}
};
// Group or empty string
( @show ) => { "" };
( @show $lit:literal ) => { $lit };
// Wrap the optionals in an Option type
( @type $ty:ty, option) => { Option<$ty> };
( @type $ty:ty, $id:ident) => { $ty };
// Generate the values depending on none_action
( @build $value:expr, $config:expr, option, ) => { $value };
( @build $value:expr, $config:expr, def, $default:expr ) => { $value.unwrap_or($default) };
( @build $value:expr, $config:expr, auto, $default_fn:expr ) => {{
match $value {
Some(v) => v,
None => {
let f: &Fn(&ConfigItems) -> _ = &$default_fn;
f($config)
}
}
}};
}
//STRUCTURE:
// /// Short description (without this they won't appear on the list)
// group {
// /// Friendly Name |> Description (Optional)
// name: type, is_editable, none_action, <default_value (Optional)>
// }
//
// Where none_action applied when the value wasn't provided and can be:
// def: Use a default value
// auto: Value is auto generated based on other values
// option: Value is optional
make_config! {
folders {
/// Data folder |> Main data folder
data_folder: String, false, def, "data".to_string();
/// Database URL
database_url: String, false, auto, |c| format!("{}/{}", c.data_folder, "db.sqlite3");
/// Icon chache folder
icon_cache_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "icon_cache");
/// Attachments folder
attachments_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "attachments");
/// Templates folder
templates_folder: String, false, auto, |c| format!("{}/{}", c.data_folder, "templates");
/// Session JWT key
rsa_key_filename: String, false, auto, |c| format!("{}/{}", c.data_folder, "rsa_key");
/// Web vault folder
web_vault_folder: String, false, def, "web-vault/".to_string();
},
ws {
/// Enable websocket notifications
websocket_enabled: bool, false, def, false;
/// Websocket address
websocket_address: String, false, def, "0.0.0.0".to_string();
/// Websocket port
websocket_port: u16, false, def, 3012;
},
/// General settings
settings {
/// Domain URL |> This needs to be set to the URL used to access the server, including 'http[s]://' and port, if it's different than the default. Some server functions don't work correctly without this value
domain: String, true, def, "http://localhost".to_string();
/// PRIVATE |> Domain set
domain_set: bool, false, def, false;
/// Enable web vault
web_vault_enabled: bool, false, def, true;
/// Disable icon downloads |> Set to true to disable icon downloading, this would still serve icons from $ICON_CACHE_FOLDER,
/// but it won't produce any external network request. Needs to set $ICON_CACHE_TTL to 0,
/// otherwise it will delete them and they won't be downloaded again.
disable_icon_download: bool, true, def, false;
/// Allow new signups |> Controls if new users can register. Note that while this is disabled, users could still be invited
signups_allowed: bool, true, def, true;
/// Allow invitations |> Controls whether users can be invited by organization admins, even when signups are disabled
invitations_allowed: bool, true, def, true;
/// Password iterations |> Number of server-side passwords hashing iterations. The changes only apply when a user changes their password. Not recommended to lower the value
password_iterations: i32, true, def, 100_000;
/// Show password hints |> Controls if the password hint should be shown directly in the web page. Otherwise, if email is disabled, there is no way to see the password hint
show_password_hint: bool, true, def, true;
/// Admin page token |> The token used to authenticate in this very same page. Changing it here won't deauthorize the current session
admin_token: String, true, option;
},
/// Advanced settings
advanced {
/// Positive icon cache expiry |> Number of seconds to consider that an already cached icon is fresh. After this period, the icon will be redownloaded
icon_cache_ttl: u64, true, def, 2_592_000;
/// Negative icon cache expiry |> Number of seconds before trying to download an icon that failed again.
icon_cache_negttl: u64, true, def, 259_200;
/// Reload templates (Dev) |> When this is set to true, the templates get reloaded with every request. ONLY use this during development, as it can slow down the server
reload_templates: bool, true, def, false;
/// Enable extended logging
extended_logging: bool, false, def, true;
/// Log file path
log_file: String, false, option;
},
/// Yubikey settings
yubico: _enable_yubico {
/// Enabled
_enable_yubico: bool, true, def, true;
/// Client ID
yubico_client_id: String, true, option;
/// Secret Key
yubico_secret_key: String, true, option;
/// Server
yubico_server: String, true, option;
},
/// SMTP Email Settings
smtp: _enable_smtp {
/// Enabled
_enable_smtp: bool, true, def, true;
/// Host
smtp_host: String, true, option;
/// Enable SSL
smtp_ssl: bool, true, def, true;
/// Port
smtp_port: u16, true, auto, |c| if c.smtp_ssl {587} else {25};
/// From Address
smtp_from: String, true, def, String::new();
/// From Name
smtp_from_name: String, true, def, "Bitwarden_RS".to_string();
/// Username
smtp_username: String, true, option;
/// Password
smtp_password: String, true, option;
},
}
fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() {
err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` need to be set for Yubikey OTP support")
}
if cfg.smtp_host.is_some() == cfg.smtp_from.is_empty() {
err!("Both `SMTP_HOST` and `SMTP_FROM` need to be set for email support")
}
if cfg.smtp_username.is_some() != cfg.smtp_password.is_some() {
err!("Both `SMTP_USERNAME` and `SMTP_PASSWORD` need to be set to enable email authentication")
}
Ok(())
}
impl Config {
pub fn load() -> Result<Self, Error> {
// Loading from env and file
let _env = ConfigBuilder::from_env();
let _usr = ConfigBuilder::from_file(&CONFIG_FILE).unwrap_or_default();
// Create merged config, config file overwrites env
let builder = _env.merge(&_usr);
// Fill any missing with defaults
let config = builder.build();
validate_config(&config)?;
Ok(Config {
inner: RwLock::new(Inner {
templates: load_templates(&config.templates_folder),
config,
_env,
_usr,
}),
})
}
pub fn update_config(&self, other: ConfigBuilder) -> Result<(), Error> {
// Remove default values
//let builder = other.remove(&self.inner.read().unwrap()._env);
// TODO: Remove values that are defaults, above only checks those set by env and not the defaults
let builder = other;
// Serialize now before we consume the builder
let config_str = serde_json::to_string_pretty(&builder)?;
// Prepare the combined config
let config = {
let env = &self.inner.read().unwrap()._env;
env.merge(&builder).build()
};
validate_config(&config)?;
// Save both the user and the combined config
{
let mut writer = self.inner.write().unwrap();
writer.config = config;
writer._usr = builder;
}
//Save to file
use std::{fs::File, io::Write};
let mut file = File::create(&*CONFIG_FILE)?;
file.write_all(config_str.as_bytes())?;
Ok(())
}
pub fn delete_user_config(&self) -> Result<(), Error> {
crate::util::delete_file(&CONFIG_FILE)?;
// Empty user config
let usr = ConfigBuilder::default();
// Config now is env + defaults
let config = {
let env = &self.inner.read().unwrap()._env;
env.build()
};
// Save configs
{
let mut writer = self.inner.write().unwrap();
writer.config = config;
writer._usr = usr;
}
Ok(())
}
pub fn private_rsa_key(&self) -> String {
format!("{}.der", CONFIG.rsa_key_filename())
}
pub fn private_rsa_key_pem(&self) -> String {
format!("{}.pem", CONFIG.rsa_key_filename())
}
pub fn public_rsa_key(&self) -> String {
format!("{}.pub.der", CONFIG.rsa_key_filename())
}
pub fn mail_enabled(&self) -> bool {
let inner = &self.inner.read().unwrap().config;
inner._enable_smtp && inner.smtp_host.is_some()
}
pub fn yubico_enabled(&self) -> bool {
let inner = &self.inner.read().unwrap().config;
inner._enable_yubico && inner.yubico_client_id.is_some() && inner.yubico_secret_key.is_some()
}
pub fn render_template<T: serde::ser::Serialize>(
&self,
name: &str,
data: &T,
) -> Result<String, crate::error::Error> {
if CONFIG.reload_templates() {
warn!("RELOADING TEMPLATES");
let hb = load_templates(CONFIG.templates_folder().as_ref());
hb.render(name, data).map_err(Into::into)
} else {
let hb = &CONFIG.inner.read().unwrap().templates;
hb.render(name, data).map_err(Into::into)
}
}
}
use handlebars::{
Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext, RenderError, Renderable,
};
fn load_templates(path: &str) -> Handlebars {
let mut hb = Handlebars::new();
// Error on missing params
hb.set_strict_mode(true);
hb.register_helper("case", Box::new(CaseHelper));
macro_rules! reg {
($name:expr) => {{
let template = include_str!(concat!("static/templates/", $name, ".hbs"));
hb.register_template_string($name, template).unwrap();
}};
}
// 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/base");
reg!("admin/login");
reg!("admin/page");
// And then load user templates to overwrite the defaults
// Use .hbs extension for the files
// Templates get registered with their relative name
hb.register_templates_directory(".hbs", path).unwrap();
hb
}
#[derive(Clone, Copy)]
pub struct CaseHelper;
impl HelperDef for CaseHelper {
fn call<'reg: 'rc, 'rc>(
&self,
h: &Helper<'reg, 'rc>,
r: &'reg Handlebars,
ctx: &Context,
rc: &mut RenderContext<'reg>,
out: &mut Output,
) -> HelperResult {
let param = h
.param(0)
.ok_or_else(|| RenderError::new("Param not found for helper \"case\""))?;
let value = param.value().clone();
if h.params().iter().skip(1).any(|x| x.value() == &value) {
h.template().map(|t| t.render(r, ctx, rc, out)).unwrap_or(Ok(()))
} else {
Ok(())
}
}
}

View File

@@ -25,13 +25,13 @@ pub mod schema;
/// Initializes a database pool. /// Initializes a database pool.
pub fn init_pool() -> Pool { pub fn init_pool() -> Pool {
let manager = ConnectionManager::new(&*CONFIG.database_url); let manager = ConnectionManager::new(CONFIG.database_url());
r2d2::Pool::builder().build(manager).expect("Failed to create pool") r2d2::Pool::builder().build(manager).expect("Failed to create pool")
} }
pub fn get_connection() -> Result<Connection, ConnectionError> { pub fn get_connection() -> Result<Connection, ConnectionError> {
Connection::establish(&CONFIG.database_url) Connection::establish(&CONFIG.database_url())
} }
/// Attempts to retrieve a single connection from the managed database pool. If /// Attempts to retrieve a single connection from the managed database pool. If

View File

@@ -28,7 +28,7 @@ impl Attachment {
} }
pub fn get_file_path(&self) -> String { pub fn get_file_path(&self) -> String {
format!("{}/{}/{}", CONFIG.attachments_folder, self.cipher_uuid, self.id) format!("{}/{}/{}", CONFIG.attachments_folder(), self.cipher_uuid, self.id)
} }
pub fn to_json(&self, host: &str) -> Value { pub fn to_json(&self, host: &str) -> Value {

View File

@@ -196,40 +196,28 @@ impl Cipher {
} }
pub fn move_to_folder(&self, folder_uuid: Option<String>, user_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn move_to_folder(&self, folder_uuid: Option<String>, user_uuid: &str, conn: &DbConn) -> EmptyResult {
match self.get_folder_uuid(&user_uuid, &conn) { User::update_uuid_revision(user_uuid, &conn);
None => {
match folder_uuid { match (self.get_folder_uuid(&user_uuid, &conn), folder_uuid) {
Some(new_folder) => { // No changes
self.update_users_revision(conn); (None, None) => Ok(()),
let folder_cipher = FolderCipher::new(&new_folder, &self.uuid); (Some(ref old), Some(ref new)) if old == new => Ok(()),
folder_cipher.save(&conn)
} // Add to folder
None => Ok(()), //nothing to do (None, Some(new)) => FolderCipher::new(&new, &self.uuid).save(&conn),
}
} // Remove from folder
Some(current_folder) => { (Some(old), None) => match FolderCipher::find_by_folder_and_cipher(&old, &self.uuid, &conn) {
match folder_uuid { Some(old) => old.delete(&conn),
Some(new_folder) => {
if current_folder == new_folder {
Ok(()) //nothing to do
} else {
self.update_users_revision(conn);
if let Some(current_folder) =
FolderCipher::find_by_folder_and_cipher(&current_folder, &self.uuid, &conn)
{
current_folder.delete(&conn)?;
}
FolderCipher::new(&new_folder, &self.uuid).save(&conn)
}
}
None => {
self.update_users_revision(conn);
match FolderCipher::find_by_folder_and_cipher(&current_folder, &self.uuid, &conn) {
Some(current_folder) => current_folder.delete(&conn),
None => err!("Couldn't move from previous folder"), None => err!("Couldn't move from previous folder"),
},
// Move to another folder
(Some(old), Some(new)) => {
if let Some(old) = FolderCipher::find_by_folder_and_cipher(&old, &self.uuid, &conn) {
old.delete(&conn)?;
} }
} FolderCipher::new(&new, &self.uuid).save(&conn)
}
} }
} }
} }

View File

@@ -44,12 +44,7 @@ use crate::error::MapResult;
/// Database methods /// Database methods
impl Collection { impl Collection {
pub fn save(&mut self, conn: &DbConn) -> EmptyResult { pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
// Update affected users revision self.update_users_revision(conn);
UserOrganization::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn)
.iter()
.for_each(|user_org| {
User::update_uuid_revision(&user_org.user_uuid, conn);
});
diesel::replace_into(collections::table) diesel::replace_into(collections::table)
.values(&*self) .values(&*self)
@@ -58,6 +53,7 @@ impl Collection {
} }
pub fn delete(self, conn: &DbConn) -> EmptyResult { pub fn delete(self, conn: &DbConn) -> EmptyResult {
self.update_users_revision(conn);
CollectionCipher::delete_all_by_collection(&self.uuid, &conn)?; CollectionCipher::delete_all_by_collection(&self.uuid, &conn)?;
CollectionUser::delete_all_by_collection(&self.uuid, &conn)?; CollectionUser::delete_all_by_collection(&self.uuid, &conn)?;
@@ -73,6 +69,14 @@ impl Collection {
Ok(()) Ok(())
} }
pub fn update_users_revision(&self, conn: &DbConn) {
UserOrganization::find_by_collection_and_org(&self.uuid, &self.org_uuid, conn)
.iter()
.for_each(|user_org| {
User::update_uuid_revision(&user_org.user_uuid, conn);
});
}
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> { pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Self> {
collections::table collections::table
.filter(collections::uuid.eq(uuid)) .filter(collections::uuid.eq(uuid))
@@ -241,7 +245,9 @@ impl CollectionUser {
pub fn delete_all_by_collection(collection_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn delete_all_by_collection(collection_uuid: &str, conn: &DbConn) -> EmptyResult {
CollectionUser::find_by_collection(&collection_uuid, conn) CollectionUser::find_by_collection(&collection_uuid, conn)
.iter() .iter()
.for_each(|collection| User::update_uuid_revision(&collection.user_uuid, conn)); .for_each(|collection| {
User::update_uuid_revision(&collection.user_uuid, conn);
});
diesel::delete(users_collections::table.filter(users_collections::collection_uuid.eq(collection_uuid))) diesel::delete(users_collections::table.filter(users_collections::collection_uuid.eq(collection_uuid)))
.execute(&**conn) .execute(&**conn)
@@ -272,6 +278,7 @@ pub struct CollectionCipher {
/// Database methods /// Database methods
impl CollectionCipher { impl CollectionCipher {
pub fn save(cipher_uuid: &str, collection_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn save(cipher_uuid: &str, collection_uuid: &str, conn: &DbConn) -> EmptyResult {
Self::update_users_revision(&collection_uuid, conn);
diesel::replace_into(ciphers_collections::table) diesel::replace_into(ciphers_collections::table)
.values(( .values((
ciphers_collections::cipher_uuid.eq(cipher_uuid), ciphers_collections::cipher_uuid.eq(cipher_uuid),
@@ -282,6 +289,7 @@ impl CollectionCipher {
} }
pub fn delete(cipher_uuid: &str, collection_uuid: &str, conn: &DbConn) -> EmptyResult { pub fn delete(cipher_uuid: &str, collection_uuid: &str, conn: &DbConn) -> EmptyResult {
Self::update_users_revision(&collection_uuid, conn);
diesel::delete( diesel::delete(
ciphers_collections::table ciphers_collections::table
.filter(ciphers_collections::cipher_uuid.eq(cipher_uuid)) .filter(ciphers_collections::cipher_uuid.eq(cipher_uuid))
@@ -302,4 +310,10 @@ impl CollectionCipher {
.execute(&**conn) .execute(&**conn)
.map_res("Error removing ciphers from collection") .map_res("Error removing ciphers from collection")
} }
pub fn update_users_revision(collection_uuid: &str, conn: &DbConn) {
if let Some(collection) = Collection::find_by_uuid(collection_uuid, conn) {
collection.update_users_revision(conn);
}
}
} }

View File

@@ -77,11 +77,11 @@ impl Device {
// Create the JWT claims struct, to send to the client // Create the JWT claims struct, to send to the client
use crate::auth::{encode_jwt, JWTClaims, DEFAULT_VALIDITY, JWT_ISSUER}; use crate::auth::{encode_jwt, LoginJWTClaims, DEFAULT_VALIDITY, JWT_LOGIN_ISSUER};
let claims = JWTClaims { let claims = LoginJWTClaims {
nbf: time_now.timestamp(), nbf: time_now.timestamp(),
exp: (time_now + *DEFAULT_VALIDITY).timestamp(), exp: (time_now + *DEFAULT_VALIDITY).timestamp(),
iss: JWT_ISSUER.to_string(), iss: JWT_LOGIN_ISSUER.to_string(),
sub: user.uuid.to_string(), sub: user.uuid.to_string(),
premium: true, premium: true,

View File

@@ -292,18 +292,10 @@ impl UserOrganization {
}) })
} }
pub fn to_json_collection_user_details(&self, read_only: bool, conn: &DbConn) -> Value { pub fn to_json_collection_user_details(&self, read_only: bool) -> Value {
let user = User::find_by_uuid(&self.user_uuid, conn).unwrap();
json!({ json!({
"OrganizationUserId": self.uuid, "Id": self.uuid,
"AccessAll": self.access_all, "ReadOnly": read_only
"Name": user.name,
"Email": user.email,
"Type": self.type_,
"Status": self.status,
"ReadOnly": read_only,
"Object": "collectionUser",
}) })
} }

View File

@@ -15,7 +15,7 @@ pub struct TwoFactor {
} }
#[allow(dead_code)] #[allow(dead_code)]
#[derive(FromPrimitive, ToPrimitive)] #[derive(FromPrimitive)]
pub enum TwoFactorType { pub enum TwoFactorType {
Authenticator = 0, Authenticator = 0,
Email = 1, Email = 1,
@@ -100,6 +100,7 @@ impl TwoFactor {
pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> { pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Self> {
twofactor::table twofactor::table
.filter(twofactor::user_uuid.eq(user_uuid)) .filter(twofactor::user_uuid.eq(user_uuid))
.filter(twofactor::type_.lt(1000)) // Filter implementation types
.load::<Self>(&**conn) .load::<Self>(&**conn)
.expect("Error loading twofactor") .expect("Error loading twofactor")
} }

View File

@@ -56,7 +56,7 @@ impl User {
password_hash: Vec::new(), password_hash: Vec::new(),
salt: crypto::get_random_64(), salt: crypto::get_random_64(),
password_iterations: CONFIG.password_iterations, password_iterations: CONFIG.password_iterations(),
security_stamp: crate::util::get_uuid(), security_stamp: crate::util::get_uuid(),
@@ -120,6 +120,7 @@ impl User {
let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).is_empty(); let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).is_empty();
json!({ json!({
"_Enabled": !self.password_hash.is_empty(),
"Id": self.uuid, "Id": self.uuid,
"Name": self.name, "Name": self.name,
"Email": self.email, "Email": self.email,
@@ -137,6 +138,10 @@ impl User {
} }
pub fn save(&mut self, conn: &DbConn) -> EmptyResult { pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
if self.email.trim().is_empty() {
err!("User email can't be empty")
}
self.updated_at = Utc::now().naive_utc(); self.updated_at = Utc::now().naive_utc();
diesel::replace_into(users::table) // Insert or update diesel::replace_into(users::table) // Insert or update
@@ -168,18 +173,26 @@ impl User {
} }
pub fn update_uuid_revision(uuid: &str, conn: &DbConn) { pub fn update_uuid_revision(uuid: &str, conn: &DbConn) {
if let Some(mut user) = User::find_by_uuid(&uuid, conn) { if let Err(e) = Self::_update_revision(uuid, &Utc::now().naive_utc(), conn) {
if user.update_revision(conn).is_err() { warn!("Failed to update revision for {}: {:#?}", uuid, e);
warn!("Failed to update revision for {}", user.email); }
};
};
} }
pub fn update_revision(&mut self, conn: &DbConn) -> EmptyResult { pub fn update_revision(&mut self, conn: &DbConn) -> EmptyResult {
self.updated_at = Utc::now().naive_utc(); self.updated_at = Utc::now().naive_utc();
diesel::update(users::table.filter(users::uuid.eq(&self.uuid)))
.set(users::updated_at.eq(&self.updated_at)) Self::_update_revision(&self.uuid, &self.updated_at, conn)
}
fn _update_revision(uuid: &str, date: &NaiveDateTime, conn: &DbConn) -> EmptyResult {
crate::util::retry(
|| {
diesel::update(users::table.filter(users::uuid.eq(uuid)))
.set(users::updated_at.eq(date))
.execute(&**conn) .execute(&**conn)
},
10,
)
.map_res("Error updating user revision") .map_res("Error updating user revision")
} }
@@ -213,6 +226,10 @@ impl Invitation {
} }
pub fn save(&mut self, conn: &DbConn) -> EmptyResult { pub fn save(&mut self, conn: &DbConn) -> EmptyResult {
if self.email.trim().is_empty() {
err!("Invitation email can't be empty")
}
diesel::replace_into(invitations::table) diesel::replace_into(invitations::table)
.values(&*self) .values(&*self)
.execute(&**conn) .execute(&**conn)
@@ -234,7 +251,7 @@ impl Invitation {
} }
pub fn take(mail: &str, conn: &DbConn) -> bool { pub fn take(mail: &str, conn: &DbConn) -> bool {
CONFIG.invitations_allowed CONFIG.invitations_allowed()
&& match Self::find_by_mail(mail, &conn) { && match Self::find_by_mail(mail, &conn) {
Some(invitation) => invitation.delete(&conn).is_ok(), Some(invitation) => invitation.delete(&conn).is_ok(),
None => false, None => false,

View File

@@ -4,9 +4,9 @@
use std::error::Error as StdError; use std::error::Error as StdError;
macro_rules! make_error { macro_rules! make_error {
( $( $name:ident ( $ty:ty ): $src_fn:expr, $usr_msg_fun:expr ),+ $(,)* ) => { ( $( $name:ident ( $ty:ty ): $src_fn:expr, $usr_msg_fun:expr ),+ $(,)? ) => {
#[derive(Display)] #[derive(Display)]
enum ErrorKind { $($name( $ty )),+ } pub enum ErrorKind { $($name( $ty )),+ }
pub struct Error { message: String, error: ErrorKind } pub struct Error { message: String, error: ErrorKind }
$(impl From<$ty> for Error { $(impl From<$ty> for Error {
@@ -32,11 +32,16 @@ macro_rules! make_error {
}; };
} }
use diesel::result::Error as DieselError; use diesel::result::Error as DieselErr;
use jsonwebtoken::errors::Error as JwtError; use handlebars::RenderError as HbErr;
use serde_json::{Error as SerError, Value}; use jsonwebtoken::errors::Error as JWTErr;
use std::io::Error as IOError; use regex::Error as RegexErr;
use reqwest::Error as ReqErr;
use serde_json::{Error as SerdeErr, Value};
use std::io::Error as IOErr;
use std::time::SystemTimeError as TimeErr;
use u2f::u2ferror::U2fError as U2fErr; use u2f::u2ferror::U2fError as U2fErr;
use yubico::yubicoerror::YubicoError as YubiErr;
// Error struct // Error struct
// Contains a String error message, meant for the user and an enum variant, with an error of different types. // Contains a String error message, meant for the user and an enum variant, with an error of different types.
@@ -48,12 +53,17 @@ make_error! {
SimpleError(String): _no_source, _api_error, SimpleError(String): _no_source, _api_error,
// Used for special return values, like 2FA errors // Used for special return values, like 2FA errors
JsonError(Value): _no_source, _serialize, JsonError(Value): _no_source, _serialize,
DbError(DieselError): _has_source, _api_error, DbError(DieselErr): _has_source, _api_error,
U2fError(U2fErr): _has_source, _api_error, U2fError(U2fErr): _has_source, _api_error,
SerdeError(SerError): _has_source, _api_error, SerdeError(SerdeErr): _has_source, _api_error,
JWTError(JwtError): _has_source, _api_error, JWTError(JWTErr): _has_source, _api_error,
IoErrror(IOError): _has_source, _api_error, TemplError(HbErr): _has_source, _api_error,
//WsError(ws::Error): _has_source, _api_error, //WsError(ws::Error): _has_source, _api_error,
IOError(IOErr): _has_source, _api_error,
TimeError(TimeErr): _has_source, _api_error,
ReqError(ReqErr): _has_source, _api_error,
RegexError(RegexErr): _has_source, _api_error,
YubiError(YubiErr): _has_source, _api_error,
} }
impl std::fmt::Debug for Error { impl std::fmt::Debug for Error {

View File

@@ -4,27 +4,28 @@ use lettre::{ClientSecurity, ClientTlsParameters, SmtpClient, SmtpTransport, Tra
use lettre_email::EmailBuilder; use lettre_email::EmailBuilder;
use native_tls::{Protocol, TlsConnector}; use native_tls::{Protocol, TlsConnector};
use crate::MailConfig;
use crate::CONFIG;
use crate::auth::{generate_invite_claims, encode_jwt};
use crate::api::EmptyResult; use crate::api::EmptyResult;
use crate::auth::{encode_jwt, generate_invite_claims};
use crate::error::Error; use crate::error::Error;
use crate::CONFIG;
fn mailer(config: &MailConfig) -> SmtpTransport { fn mailer() -> SmtpTransport {
let client_security = if config.smtp_ssl { let host = CONFIG.smtp_host().unwrap();
let client_security = if CONFIG.smtp_ssl() {
let tls = TlsConnector::builder() let tls = TlsConnector::builder()
.min_protocol_version(Some(Protocol::Tlsv11)) .min_protocol_version(Some(Protocol::Tlsv11))
.build() .build()
.unwrap(); .unwrap();
ClientSecurity::Required(ClientTlsParameters::new(config.smtp_host.clone(), tls)) ClientSecurity::Required(ClientTlsParameters::new(host.clone(), tls))
} else { } else {
ClientSecurity::None ClientSecurity::None
}; };
let smtp_client = SmtpClient::new((config.smtp_host.as_str(), config.smtp_port), client_security).unwrap(); let smtp_client = SmtpClient::new((host.as_str(), CONFIG.smtp_port()), client_security).unwrap();
let smtp_client = match (&config.smtp_username, &config.smtp_password) { let smtp_client = match (&CONFIG.smtp_username(), &CONFIG.smtp_password()) {
(Some(user), Some(pass)) => smtp_client.credentials(Credentials::new(user.clone(), pass.clone())), (Some(user), Some(pass)) => smtp_client.credentials(Credentials::new(user.clone(), pass.clone())),
_ => smtp_client, _ => smtp_client,
}; };
@@ -35,25 +36,33 @@ fn mailer(config: &MailConfig) -> SmtpTransport {
.transport() .transport()
} }
pub fn send_password_hint(address: &str, hint: Option<String>, config: &MailConfig) -> EmptyResult { fn get_text(template_name: &'static str, data: serde_json::Value) -> Result<(String, String), Error> {
let (subject, body) = if let Some(hint) = hint { let text = CONFIG.render_template(template_name, &data)?;
( let mut text_split = text.split("<!---------------->");
"Your master password hint",
format!( let subject = match text_split.next() {
"You (or someone) recently requested your master password hint.\n\n\ Some(s) => s.trim().to_string(),
Your hint is: \"{}\"\n\n\ None => err!("Template doesn't contain subject"),
If you did not request your master password hint you can safely ignore this email.\n",
hint
),
)
} else {
(
"Sorry, you have no password hint...",
"Sorry, you have not specified any password hint...\n".into(),
)
}; };
send_email(&address, &subject, &body, &config) let body = match text_split.next() {
Some(s) => s.trim().to_string(),
None => err!("Template doesn't contain body"),
};
Ok((subject, body))
}
pub fn send_password_hint(address: &str, hint: Option<String>) -> EmptyResult {
let template_name = if hint.is_some() {
"email/pw_hint_some"
} else {
"email/pw_hint_none"
};
let (subject, body) = get_text(template_name, json!({ "hint": hint }))?;
send_email(&address, &subject, &body)
} }
pub fn send_invite( pub fn send_invite(
@@ -63,7 +72,6 @@ pub fn send_invite(
org_user_id: Option<String>, org_user_id: Option<String>,
org_name: &str, org_name: &str,
invited_by_email: Option<String>, invited_by_email: Option<String>,
config: &MailConfig,
) -> EmptyResult { ) -> EmptyResult {
let claims = generate_invite_claims( let claims = generate_invite_claims(
uuid.to_string(), uuid.to_string(),
@@ -73,66 +81,58 @@ pub fn send_invite(
invited_by_email.clone(), invited_by_email.clone(),
); );
let invite_token = encode_jwt(&claims); let invite_token = encode_jwt(&claims);
let (subject, body) = {
(format!("Join {}", &org_name),
format!(
"<html>
<p>You have been invited to join the <b>{}</b> organization.<br><br>
<a href=\"{}/#/accept-organization/?organizationId={}&organizationUserId={}&email={}&organizationName={}&token={}\">
Click here to join</a></p>
<p>If you do not wish to join this organization, you can safely ignore this email.</p>
</html>",
org_name, CONFIG.domain, org_id.unwrap_or("_".to_string()), org_user_id.unwrap_or("_".to_string()), address, org_name, invite_token
))
};
send_email(&address, &subject, &body, &config) let (subject, body) = get_text(
"email/send_org_invite",
json!({
"url": CONFIG.domain(),
"org_id": org_id.unwrap_or_else(|| "_".to_string()),
"org_user_id": org_user_id.unwrap_or_else(|| "_".to_string()),
"email": address,
"org_name": org_name,
"token": invite_token,
}),
)?;
send_email(&address, &subject, &body)
} }
pub fn send_invite_accepted( pub fn send_invite_accepted(new_user_email: &str, address: &str, org_name: &str) -> EmptyResult {
new_user_email: &str, let (subject, body) = get_text(
address: &str, "email/invite_accepted",
org_name: &str, json!({
config: &MailConfig, "url": CONFIG.domain(),
) -> EmptyResult { "email": new_user_email,
let (subject, body) = { "org_name": org_name,
("Invitation accepted", }),
format!( )?;
"<html>
<p>Your invitation for <b>{}</b> to join <b>{}</b> was accepted. Please <a href=\"{}\">log in</a> to the bitwarden_rs server and confirm them from the organization management page.</p>
</html>", new_user_email, org_name, CONFIG.domain))
};
send_email(&address, &subject, &body, &config) send_email(&address, &subject, &body)
} }
pub fn send_invite_confirmed( pub fn send_invite_confirmed(address: &str, org_name: &str) -> EmptyResult {
address: &str, let (subject, body) = get_text(
org_name: &str, "email/invite_confirmed",
config: &MailConfig, json!({
) -> EmptyResult { "url": CONFIG.domain(),
let (subject, body) = { "org_name": org_name,
(format!("Invitation to {} confirmed", org_name), }),
format!( )?;
"<html>
<p>Your invitation to join <b>{}</b> was confirmed. It will now appear under the Organizations the next time you <a href=\"{}\">log in</a> to the web vault.</p>
</html>", org_name, CONFIG.domain))
};
send_email(&address, &subject, &body, &config) send_email(&address, &subject, &body)
} }
fn send_email(address: &str, subject: &str, body: &str, config: &MailConfig) -> EmptyResult { fn send_email(address: &str, subject: &str, body: &str) -> EmptyResult {
let email = EmailBuilder::new() let email = EmailBuilder::new()
.to(address) .to(address)
.from((config.smtp_from.clone(), "Bitwarden-rs")) .from((CONFIG.smtp_from().as_str(), CONFIG.smtp_from_name().as_str()))
.subject(subject) .subject(subject)
.header(("Content-Type", "text/html")) .header(("Content-Type", "text/html"))
.body(body) .body(body)
.build() .build()
.map_err(|e| Error::new("Error building email", e.to_string()))?; .map_err(|e| Error::new("Error building email", e.to_string()))?;
mailer(config) mailer()
.send(email.into()) .send(email.into())
.map_err(|e| Error::new("Error sending email", e.to_string())) .map_err(|e| Error::new("Error sending email", e.to_string()))
.and(Ok(())) .and(Ok(()))

View File

@@ -1,6 +1,5 @@
#![feature(proc_macro_hygiene, decl_macro, vec_remove_item, try_trait)] #![feature(proc_macro_hygiene, decl_macro, vec_remove_item, try_trait)]
#![recursion_limit = "128"] #![recursion_limit = "256"]
#![allow(proc_macro_derive_resolution_fallback)] // TODO: Remove this when diesel update fixes warnings
#[macro_use] #[macro_use]
extern crate rocket; extern crate rocket;
@@ -22,6 +21,7 @@ extern crate derive_more;
extern crate num_derive; extern crate num_derive;
use rocket::{fairing::AdHoc, Rocket}; use rocket::{fairing::AdHoc, Rocket};
use std::{ use std::{
path::Path, path::Path,
process::{exit, Command}, process::{exit, Command},
@@ -31,11 +31,14 @@ use std::{
mod error; mod error;
mod api; mod api;
mod auth; mod auth;
mod config;
mod crypto; mod crypto;
mod db; mod db;
mod mail; mod mail;
mod util; mod util;
pub use config::CONFIG;
fn init_rocket() -> Rocket { fn init_rocket() -> Rocket {
rocket::ignite() rocket::ignite()
.mount("/", api::web_routes()) .mount("/", api::web_routes())
@@ -67,7 +70,7 @@ mod migrations {
} }
fn main() { fn main() {
if CONFIG.extended_logging { if CONFIG.extended_logging() {
init_logging().ok(); init_logging().ok();
} }
@@ -93,11 +96,13 @@ fn init_logging() -> Result<(), fern::InitError> {
.level(log::LevelFilter::Debug) .level(log::LevelFilter::Debug)
.level_for("hyper", log::LevelFilter::Warn) .level_for("hyper", log::LevelFilter::Warn)
.level_for("rustls", log::LevelFilter::Warn) .level_for("rustls", log::LevelFilter::Warn)
.level_for("handlebars", log::LevelFilter::Warn)
.level_for("ws", log::LevelFilter::Info) .level_for("ws", log::LevelFilter::Info)
.level_for("multipart", log::LevelFilter::Info) .level_for("multipart", log::LevelFilter::Info)
.level_for("html5ever", log::LevelFilter::Info)
.chain(std::io::stdout()); .chain(std::io::stdout());
if let Some(log_file) = CONFIG.log_file.as_ref() { if let Some(log_file) = CONFIG.log_file() {
logger = logger.chain(fern::log_file(log_file)?); logger = logger.chain(fern::log_file(log_file)?);
} }
@@ -131,7 +136,8 @@ fn chain_syslog(logger: fern::Dispatch) -> fern::Dispatch {
} }
fn check_db() { fn check_db() {
let path = Path::new(&CONFIG.database_url); let url = CONFIG.database_url();
let path = Path::new(&url);
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
use std::fs; use std::fs;
@@ -151,7 +157,7 @@ fn check_db() {
fn check_rsa_keys() { fn check_rsa_keys() {
// If the RSA keys don't exist, try to create them // If the RSA keys don't exist, try to create them
if !util::file_exists(&CONFIG.private_rsa_key) || !util::file_exists(&CONFIG.public_rsa_key) { if !util::file_exists(&CONFIG.private_rsa_key()) || !util::file_exists(&CONFIG.public_rsa_key()) {
info!("JWT keys don't exist, checking if OpenSSL is available..."); info!("JWT keys don't exist, checking if OpenSSL is available...");
Command::new("openssl").arg("version").output().unwrap_or_else(|_| { Command::new("openssl").arg("version").output().unwrap_or_else(|_| {
@@ -164,7 +170,7 @@ fn check_rsa_keys() {
let mut success = Command::new("openssl") let mut success = Command::new("openssl")
.arg("genrsa") .arg("genrsa")
.arg("-out") .arg("-out")
.arg(&CONFIG.private_rsa_key_pem) .arg(&CONFIG.private_rsa_key_pem())
.output() .output()
.expect("Failed to create private pem file") .expect("Failed to create private pem file")
.status .status
@@ -173,11 +179,11 @@ fn check_rsa_keys() {
success &= Command::new("openssl") success &= Command::new("openssl")
.arg("rsa") .arg("rsa")
.arg("-in") .arg("-in")
.arg(&CONFIG.private_rsa_key_pem) .arg(&CONFIG.private_rsa_key_pem())
.arg("-outform") .arg("-outform")
.arg("DER") .arg("DER")
.arg("-out") .arg("-out")
.arg(&CONFIG.private_rsa_key) .arg(&CONFIG.private_rsa_key())
.output() .output()
.expect("Failed to create private der file") .expect("Failed to create private der file")
.status .status
@@ -186,14 +192,14 @@ fn check_rsa_keys() {
success &= Command::new("openssl") success &= Command::new("openssl")
.arg("rsa") .arg("rsa")
.arg("-in") .arg("-in")
.arg(&CONFIG.private_rsa_key) .arg(&CONFIG.private_rsa_key())
.arg("-inform") .arg("-inform")
.arg("DER") .arg("DER")
.arg("-RSAPublicKey_out") .arg("-RSAPublicKey_out")
.arg("-outform") .arg("-outform")
.arg("DER") .arg("DER")
.arg("-out") .arg("-out")
.arg(&CONFIG.public_rsa_key) .arg(&CONFIG.public_rsa_key())
.output() .output()
.expect("Failed to create public der file") .expect("Failed to create public der file")
.status .status
@@ -209,14 +215,14 @@ fn check_rsa_keys() {
} }
fn check_web_vault() { fn check_web_vault() {
if !CONFIG.web_vault_enabled { if !CONFIG.web_vault_enabled() {
return; return;
} }
let index_path = Path::new(&CONFIG.web_vault_folder).join("index.html"); let index_path = Path::new(&CONFIG.web_vault_folder()).join("index.html");
if !index_path.exists() { if !index_path.exists() {
error!("Web vault is not found. Please follow the steps in the README to install it"); error!("Web vault is not found. To install it, please follow the steps in https://github.com/dani-garcia/bitwarden_rs/wiki/Building-binary#install-the-web-vault");
exit(1); exit(1);
} }
} }
@@ -230,157 +236,3 @@ fn unofficial_warning() -> AdHoc {
warn!("\\--------------------------------------------------------------------/"); warn!("\\--------------------------------------------------------------------/");
}) })
} }
lazy_static! {
// Load the config from .env or from environment variables
static ref CONFIG: Config = Config::load();
}
#[derive(Debug)]
pub struct MailConfig {
smtp_host: String,
smtp_port: u16,
smtp_ssl: bool,
smtp_from: String,
smtp_username: Option<String>,
smtp_password: Option<String>,
}
impl MailConfig {
fn load() -> Option<Self> {
use crate::util::{get_env, get_env_or};
// When SMTP_HOST is absent, we assume the user does not want to enable it.
let smtp_host = match get_env("SMTP_HOST") {
Some(host) => host,
None => return None,
};
let smtp_from = get_env("SMTP_FROM").unwrap_or_else(|| {
error!("Please specify SMTP_FROM to enable SMTP support.");
exit(1);
});
let smtp_ssl = get_env_or("SMTP_SSL", true);
let smtp_port = get_env("SMTP_PORT").unwrap_or_else(|| if smtp_ssl { 587u16 } else { 25u16 });
let smtp_username = get_env("SMTP_USERNAME");
let smtp_password = get_env("SMTP_PASSWORD").or_else(|| {
if smtp_username.as_ref().is_some() {
error!("SMTP_PASSWORD is mandatory when specifying SMTP_USERNAME.");
exit(1);
} else {
None
}
});
Some(MailConfig {
smtp_host,
smtp_port,
smtp_ssl,
smtp_from,
smtp_username,
smtp_password,
})
}
}
#[derive(Debug)]
pub struct Config {
database_url: String,
icon_cache_folder: String,
attachments_folder: String,
icon_cache_ttl: u64,
icon_cache_negttl: u64,
private_rsa_key: String,
private_rsa_key_pem: String,
public_rsa_key: String,
web_vault_folder: String,
web_vault_enabled: bool,
websocket_enabled: bool,
websocket_url: String,
extended_logging: bool,
log_file: Option<String>,
local_icon_extractor: bool,
signups_allowed: bool,
invitations_allowed: bool,
admin_token: Option<String>,
password_iterations: i32,
show_password_hint: bool,
domain: String,
domain_set: bool,
yubico_cred_set: bool,
yubico_client_id: String,
yubico_secret_key: String,
yubico_server: Option<String>,
mail: Option<MailConfig>,
}
impl Config {
fn load() -> Self {
use crate::util::{get_env, get_env_or};
dotenv::dotenv().ok();
let df = get_env_or("DATA_FOLDER", "data".to_string());
let key = get_env_or("RSA_KEY_FILENAME", format!("{}/{}", &df, "rsa_key"));
let domain = get_env("DOMAIN");
let yubico_client_id = get_env("YUBICO_CLIENT_ID");
let yubico_secret_key = get_env("YUBICO_SECRET_KEY");
Config {
database_url: get_env_or("DATABASE_URL", format!("{}/{}", &df, "db.sqlite3")),
icon_cache_folder: get_env_or("ICON_CACHE_FOLDER", format!("{}/{}", &df, "icon_cache")),
attachments_folder: get_env_or("ATTACHMENTS_FOLDER", format!("{}/{}", &df, "attachments")),
// icon_cache_ttl defaults to 30 days (30 * 24 * 60 * 60 seconds)
icon_cache_ttl: get_env_or("ICON_CACHE_TTL", 2_592_000),
// icon_cache_negttl defaults to 3 days (3 * 24 * 60 * 60 seconds)
icon_cache_negttl: get_env_or("ICON_CACHE_NEGTTL", 259_200),
private_rsa_key: format!("{}.der", &key),
private_rsa_key_pem: format!("{}.pem", &key),
public_rsa_key: format!("{}.pub.der", &key),
web_vault_folder: get_env_or("WEB_VAULT_FOLDER", "web-vault/".into()),
web_vault_enabled: get_env_or("WEB_VAULT_ENABLED", true),
websocket_enabled: get_env_or("WEBSOCKET_ENABLED", false),
websocket_url: format!(
"{}:{}",
get_env_or("WEBSOCKET_ADDRESS", "0.0.0.0".to_string()),
get_env_or("WEBSOCKET_PORT", 3012)
),
extended_logging: get_env_or("EXTENDED_LOGGING", true),
log_file: get_env("LOG_FILE"),
local_icon_extractor: get_env_or("LOCAL_ICON_EXTRACTOR", false),
signups_allowed: get_env_or("SIGNUPS_ALLOWED", true),
admin_token: get_env("ADMIN_TOKEN"),
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),
domain_set: domain.is_some(),
domain: domain.unwrap_or("http://localhost".into()),
yubico_cred_set: yubico_client_id.is_some() && yubico_secret_key.is_some(),
yubico_client_id: yubico_client_id.unwrap_or("00000".into()),
yubico_secret_key: yubico_secret_key.unwrap_or("AAAAAAA".into()),
yubico_server: get_env("YUBICO_SERVER"),
mail: MailConfig::load(),
}
}
}

View File

@@ -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>

View File

@@ -0,0 +1,49 @@
<!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>
<script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.2.1/js/bootstrap.bundle.min.js" integrity="sha256-MSYVjWgrr6UL/9eQfQvOyt6/gsxb6dpwI1zqM5DbLCs="
crossorigin="anonymous"></script>
<style>
body {
padding-top: 70px;
}
img {
width: 48px;
height: 48px;
}
</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>
{{> (page_content) }}
</body>
</html>

View File

@@ -0,0 +1,21 @@
<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>

View File

@@ -0,0 +1,232 @@
<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">
<img class="mr-2 rounded identicon" data-src="{{Email}}">
<div class="media-body pb-3 mb-0 small border-bottom">
<div class="row justify-content-between">
<div class="col">
<strong>{{Name}}</strong>
{{#if TwoFactorEnabled}}
<span class="badge badge-success ml-2">2FA</span>
{{/if}}
{{#unless _Enabled}}
<span class="badge badge-warning ml-2">Disabled</span>
{{/unless}}
<span class="d-block">{{Email}}</span>
</div>
<div class="col">
<strong> Organizations:</strong>
<span class="d-block">
{{#each Organizations}}
<span class="badge badge-primary" data-orgtype="{{Type}}">{{Name}}</span>
{{/each}}
</span>
</div>
<div style="flex: 0 0 240px;">
<a class="mr-3" href="#" onclick='deauthUser("{{Id}}")'>Deauthorize sessions</a>
<a class="mr-3" href="#" onclick='deleteUser("{{Id}}", "{{Email}}")'>Delete User</a>
</div>
</div>
</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>
<div id="config-block" class="align-items-center p-3 mb-3 bg-secondary rounded shadow">
<div>
<h6 class="text-white mb-3">Configuration</h6>
<form class="form accordion" id="config-form">
{{#each config}}
{{#if groupdoc}}
<div class="card bg-light mb-3">
<div class="card-header"><button type="button" class="btn btn-link collapsed" data-toggle="collapse"
data-target="#g_{{group}}">{{groupdoc}}</button></div>
<div id="g_{{group}}" class="card-body collapse" data-parent="#config-form">
{{#each elements}}
{{#if editable}}
<div class="form-group row" title="{{doc.description}}">
{{#case type "text" "number"}}
<label for="input_{{name}}" class="col-sm-3 col-form-label">{{doc.name}}</label>
<div class="col-sm-8">
<input class="form-control conf-{{type}}" id="input_{{name}}" type="{{type}}" name="{{name}}"
value="{{value}}" {{#if default}} placeholder="Default: {{default}}" {{/if}}>
</div>
{{/case}}
{{#case type "checkbox"}}
<div class="col-sm-3">{{doc.name}}</div>
<div class="col-sm-8">
<div class="form-check">
<input class="form-check-input conf-{{type}}" type="checkbox" id="input_{{name}}"
name="{{name}}" {{#if value}} checked {{/if}}>
{{#if default}}
<label class="form-check-label" for="input_{{name}}"> Default: {{default}} </label>
{{/if}}
</div>
</div>
{{/case}}
</div>
{{/if}}
{{/each}}
</div>
</div>
{{/if}}
{{/each}}
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-danger float-right" onclick="deleteConfig();">Reset defaults</button>
</form>
</div>
</div>
</main>
<style>
#config-block ::placeholder {
/* Most modern browsers support this now. */
color: orangered;
}
</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,
//async: false,
contentType: "application/json",
}).done(function () {
alert(successMsg);
}).fail(function (e) {
const r = e.responseJSON;
const msg = r ? r.ErrorModel.Message : "Unknown error";
alert(errMsg + ": " + msg);
}).always(reload);
}
function deleteUser(id, mail) {
var input_mail = prompt("To delete user '" + mail + "', please type the name below")
if (input_mail != null) {
if (input_mail == mail) {
_post("/admin/users/" + id + "/delete",
"User deleted correctly",
"Error deleting user");
} else {
alert("Wrong email, please try again")
}
}
return false;
}
function deauthUser(id) {
_post("/admin/users/" + id + "/deauth",
"Sessions deauthorized correctly",
"Error deauthorizing sessions");
return false;
}
function inviteUser() {
inv = $("#email-invite");
data = JSON.stringify({ "email": inv.val() });
inv.val("");
_post("/admin/invite/", "User invited correctly",
"Error inviting user", data);
return false;
}
function getFormData() {
let data = {};
$(".conf-checkbox").each(function (i, e) {
data[e.name] = $(e).is(":checked");
});
$(".conf-number").each(function (i, e) {
data[e.name] = +e.value;
});
$(".conf-text").each(function (i, e) {
data[e.name] = e.value || null;
});
return data;
}
function saveConfig() {
data = JSON.stringify(getFormData());
_post("/admin/config/", "Config saved correctly",
"Error saving config", data);
return false;
}
function deleteConfig() {
var input = prompt("This will remove all user configurations, and restore the defaults and the " +
"values set by the environment. This operation could be dangerous. Type 'DELETE' to proceed:");
if (input === "DELETE") {
_post("/admin/config/delete",
"Config deleted correctly",
"Error deleting config");
} else {
alert("Wrong input, please try again")
}
return false;
}
function masterCheck(check_id, inputs_query) {
function toggleEnabled(check_id, inputs_query, enabled) {
$(inputs_query).prop("disabled", !enabled)
if (!enabled)
$(inputs_query).val("");
$(check_id).prop("disabled", false);
};
function onChanged(check_id, inputs_query) {
return function _fn() { toggleEnabled(check_id, inputs_query, this.checked); };
};
toggleEnabled(check_id, inputs_query, $(check_id).is(":checked"));
$(check_id).change(onChanged(check_id, inputs_query));
}
let OrgTypes = {
"0": { "name": "Owner", "color": "orange" },
"1": { "name": "Admin", "color": "blueviolet" },
"2": { "name": "User", "color": "blue" },
"3": { "name": "Manager", "color": "green" },
};
$(window).on('load', function () {
$("#invite-form").submit(inviteUser);
$("#config-form").submit(saveConfig);
$("img.identicon").each(function (i, e) {
e.src = identicon(e.dataset.src);
});
$('[data-orgtype]').each(function (i, e) {
let orgtype = OrgTypes[e.dataset.orgtype];
e.style.backgroundColor = orgtype.color;
e.title = orgtype.name;
});
// These are formatted because otherwise the
// VSCode formatter breaks But they still work
// {{#each config}} {{#if grouptoggle}}
masterCheck("#input_{{grouptoggle}}", "#g_{{group}} input");
// {{/if}} {{/each}}
});
</script>

View File

@@ -0,0 +1,8 @@
Invitation accepted
<!---------------->
<html>
<p>
Your invitation for <b>{{email}}</b> to join <b>{{org_name}}</b> was accepted.
Please <a href="{{url}}">log in</a> to the bitwarden_rs server and confirm them from the organization management page.
</p>
</html>

View File

@@ -0,0 +1,8 @@
Invitation to {{org_name}} confirmed
<!---------------->
<html>
<p>
Your invitation to join <b>{{org_name}}</b> was confirmed.
It will now appear under the Organizations the next time you <a href="{{url}}">log in</a> to the web vault.
</p>
</html>

View File

@@ -0,0 +1,3 @@
Sorry, you have no password hint...
<!---------------->
Sorry, you have not specified any password hint...

View File

@@ -0,0 +1,7 @@
Your master password hint
<!---------------->
You (or someone) recently requested your master password hint.
Your hint is: "{{hint}}"
If you did not request your master password hint you can safely ignore this email.

View File

@@ -0,0 +1,12 @@
Join {{org_name}}
<!---------------->
<html>
<p>
You have been invited to join the <b>{{org_name}}</b> organization.
<br>
<br>
<a href="{{url}}/#/accept-organization/?organizationId={{org_id}}&organizationUserId={{org_user_id}}&email={{email}}&organizationName={{org_name}}&token={{token}}">
Click here to join</a>
</p>
<p>If you do not wish to join this organization, you can safely ignore this email.</p>
</html>

View File

@@ -1,7 +1,8 @@
// //
// Web Headers // Web Headers and caching
// //
use rocket::fairing::{Fairing, Info, Kind}; use rocket::fairing::{Fairing, Info, Kind};
use rocket::response::{self, Responder};
use rocket::{Request, Response}; use rocket::{Request, Response};
pub struct AppHeaders(); pub struct AppHeaders();
@@ -15,6 +16,7 @@ impl Fairing for AppHeaders {
} }
fn on_response(&self, _req: &Request, res: &mut Response) { fn on_response(&self, _req: &Request, res: &mut Response) {
res.set_raw_header("Feature-Policy", "accelerometer 'none'; ambient-light-sensor 'none'; autoplay 'none'; camera 'none'; encrypted-media 'none'; fullscreen 'none'; geolocation 'none'; gyroscope 'none'; magnetometer 'none'; microphone 'none'; midi 'none'; payment 'none'; picture-in-picture 'none'; sync-xhr 'self' https://haveibeenpwned.com https://twofactorauth.org; usb 'none'; vr 'none'");
res.set_raw_header("Referrer-Policy", "same-origin"); res.set_raw_header("Referrer-Policy", "same-origin");
res.set_raw_header("X-Frame-Options", "SAMEORIGIN"); res.set_raw_header("X-Frame-Options", "SAMEORIGIN");
res.set_raw_header("X-Content-Type-Options", "nosniff"); res.set_raw_header("X-Content-Type-Options", "nosniff");
@@ -29,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 // File handling
// //
@@ -49,6 +77,15 @@ pub fn read_file(path: &str) -> IOResult<Vec<u8>> {
Ok(contents) Ok(contents)
} }
pub fn read_file_string(path: &str) -> IOResult<String> {
let mut contents = String::new();
let mut file = File::open(Path::new(path))?;
file.read_to_string(&mut contents)?;
Ok(contents)
}
pub fn delete_file(path: &str) -> IOResult<()> { pub fn delete_file(path: &str) -> IOResult<()> {
let res = fs::remove_file(path); let res = fs::remove_file(path);
@@ -112,18 +149,6 @@ where
} }
} }
pub fn try_parse_string_or<S, T, U>(string: impl Try<Ok = S, Error = U>, default: T) -> T
where
S: AsRef<str>,
T: FromStr,
{
if let Ok(Ok(value)) = string.into_result().map(|s| s.as_ref().parse::<T>()) {
value
} else {
default
}
}
// //
// Env methods // Env methods
// //
@@ -137,13 +162,6 @@ where
try_parse_string(env::var(key)) try_parse_string(env::var(key))
} }
pub fn get_env_or<V>(key: &str, default: V) -> V
where
V: FromStr,
{
try_parse_string_or(env::var(key), default)
}
// //
// Date util methods // Date util methods
// //