mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2025-07-30 15:49:08 +00:00
First working version
This commit is contained in:
commit
5cd40c63ed
172 changed files with 17903 additions and 0 deletions
149
src/api/core/accounts.rs
Normal file
149
src/api/core/accounts.rs
Normal file
|
@ -0,0 +1,149 @@
|
|||
use rocket::Route;
|
||||
use rocket::response::status::BadRequest;
|
||||
|
||||
use rocket_contrib::{Json, Value};
|
||||
|
||||
use db::DbConn;
|
||||
use db::models::*;
|
||||
use util;
|
||||
|
||||
use auth::Headers;
|
||||
|
||||
use CONFIG;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct RegisterData {
|
||||
email: String,
|
||||
key: String,
|
||||
keys: Option<KeysData>,
|
||||
masterPasswordHash: String,
|
||||
masterPasswordHint: Option<String>,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct KeysData {
|
||||
encryptedPrivateKey: String,
|
||||
publicKey: String,
|
||||
}
|
||||
|
||||
#[post("/accounts/register", data = "<data>")]
|
||||
fn register(data: Json<RegisterData>, conn: DbConn) -> Result<(), BadRequest<Json>> {
|
||||
if CONFIG.signups_allowed {
|
||||
err!(format!("Signups not allowed"))
|
||||
}
|
||||
println!("DEBUG - {:#?}", data);
|
||||
|
||||
if let Some(_) = User::find_by_mail(&data.email, &conn) {
|
||||
err!("Email already exists")
|
||||
}
|
||||
|
||||
let mut user = User::new(data.email.clone(),
|
||||
data.key.clone(),
|
||||
data.masterPasswordHash.clone());
|
||||
|
||||
// Add extra fields if present
|
||||
if let Some(name) = data.name.clone() {
|
||||
user.name = name;
|
||||
}
|
||||
|
||||
if let Some(hint) = data.masterPasswordHint.clone() {
|
||||
user.password_hint = Some(hint);
|
||||
}
|
||||
|
||||
if let Some(ref keys) = data.keys {
|
||||
user.private_key = Some(keys.encryptedPrivateKey.clone());
|
||||
user.public_key = Some(keys.publicKey.clone());
|
||||
}
|
||||
|
||||
user.save(&conn);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[get("/accounts/profile")]
|
||||
fn profile(headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
Ok(Json(headers.user.to_json()))
|
||||
}
|
||||
|
||||
#[post("/accounts/keys", data = "<data>")]
|
||||
fn post_keys(data: Json<KeysData>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let mut user = headers.user;
|
||||
|
||||
user.private_key = Some(data.encryptedPrivateKey.clone());
|
||||
user.public_key = Some(data.publicKey.clone());
|
||||
|
||||
user.save(&conn);
|
||||
|
||||
Ok(Json(user.to_json()))
|
||||
}
|
||||
|
||||
#[post("/accounts/password", data = "<data>")]
|
||||
fn post_password(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let key = data["key"].as_str().unwrap();
|
||||
let password_hash = data["masterPasswordHash"].as_str().unwrap();
|
||||
let new_password_hash = data["newMasterPasswordHash"].as_str().unwrap();
|
||||
|
||||
let mut user = headers.user;
|
||||
|
||||
if !user.check_valid_password(password_hash) {
|
||||
err!("Invalid password")
|
||||
}
|
||||
|
||||
user.set_password(new_password_hash);
|
||||
user.key = key.to_string();
|
||||
|
||||
user.save(&conn);
|
||||
|
||||
Ok(Json(json!({})))
|
||||
}
|
||||
|
||||
#[post("/accounts/security-stamp", data = "<data>")]
|
||||
fn post_sstamp(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let password_hash = data["masterPasswordHash"].as_str().unwrap();
|
||||
|
||||
let mut user = headers.user;
|
||||
|
||||
if !user.check_valid_password(password_hash) {
|
||||
err!("Invalid password")
|
||||
}
|
||||
|
||||
user.reset_security_stamp();
|
||||
|
||||
Ok(Json(json!({})))
|
||||
}
|
||||
|
||||
#[post("/accounts/email-token", data = "<data>")]
|
||||
fn post_email(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
println!("{:#?}", data);
|
||||
let password_hash = data["masterPasswordHash"].as_str().unwrap();
|
||||
|
||||
let mut user = headers.user;
|
||||
|
||||
if !user.check_valid_password(password_hash) {
|
||||
err!("Invalid password")
|
||||
}
|
||||
|
||||
err!("Not implemented")
|
||||
}
|
||||
|
||||
#[post("/accounts/delete", data = "<data>")]
|
||||
fn delete_account(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let password_hash = data["masterPasswordHash"].as_str().unwrap();
|
||||
|
||||
let mut user = headers.user;
|
||||
|
||||
if !user.check_valid_password(password_hash) {
|
||||
err!("Invalid password")
|
||||
}
|
||||
|
||||
err!("Not implemented")
|
||||
}
|
||||
|
||||
#[get("/accounts/revision-date")]
|
||||
fn revision_date(headers: Headers, conn: DbConn) -> Result<String, BadRequest<Json>> {
|
||||
let revision_date = headers.user.updated_at.timestamp();
|
||||
Ok(revision_date.to_string())
|
||||
}
|
251
src/api/core/ciphers.rs
Normal file
251
src/api/core/ciphers.rs
Normal file
|
@ -0,0 +1,251 @@
|
|||
use std::io::{Cursor, Read};
|
||||
|
||||
use rocket::{Route, Data};
|
||||
use rocket::http::ContentType;
|
||||
use rocket::response::status::BadRequest;
|
||||
|
||||
use rocket_contrib::{Json, Value};
|
||||
|
||||
use multipart::server::Multipart;
|
||||
|
||||
use db::DbConn;
|
||||
use db::models::*;
|
||||
use util;
|
||||
|
||||
use auth::Headers;
|
||||
|
||||
#[get("/sync")]
|
||||
fn sync(headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let user = headers.user;
|
||||
|
||||
let folders = Folder::find_by_user(&user.uuid, &conn);
|
||||
let folders_json: Vec<Value> = folders.iter().map(|c| c.to_json()).collect();
|
||||
|
||||
let ciphers = Cipher::find_by_user(&user.uuid, &conn);
|
||||
let ciphers_json: Vec<Value> = ciphers.iter().map(|c| c.to_json()).collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"Profile": user.to_json(),
|
||||
"Folders": folders_json,
|
||||
"Ciphers": ciphers_json,
|
||||
"Domains": {
|
||||
"EquivalentDomains": [],
|
||||
"GlobalEquivalentDomains": [],
|
||||
"Object": "domains",
|
||||
},
|
||||
"Object": "sync"
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
#[get("/ciphers")]
|
||||
fn get_ciphers(headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let ciphers = Cipher::find_by_user(&headers.user.uuid, &conn);
|
||||
|
||||
let ciphers_json: Vec<Value> = ciphers.iter().map(|c| c.to_json()).collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"Data": ciphers_json,
|
||||
"Object": "list",
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/ciphers/<uuid>")]
|
||||
fn get_cipher(uuid: String, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let cipher = match Cipher::find_by_uuid(&uuid, &conn) {
|
||||
Some(cipher) => cipher,
|
||||
None => err!("Cipher doesn't exist")
|
||||
};
|
||||
|
||||
if cipher.user_uuid != headers.user.uuid {
|
||||
err!("Cipher is now owned by user")
|
||||
}
|
||||
|
||||
Ok(Json(cipher.to_json()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
#[allow(non_snake_case)]
|
||||
struct CipherData {
|
||||
#[serde(rename = "type")]
|
||||
type_: i32,
|
||||
folderId: Option<String>,
|
||||
organizationId: Option<String>,
|
||||
name: Option<String>,
|
||||
notes: Option<String>,
|
||||
favorite: Option<bool>,
|
||||
login: Option<Value>,
|
||||
card: Option<Value>,
|
||||
fields: Option<Vec<Value>>,
|
||||
}
|
||||
|
||||
#[post("/ciphers", data = "<data>")]
|
||||
fn post_ciphers(data: Json<CipherData>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let mut cipher = Cipher::new(headers.user.uuid.clone(),
|
||||
data.type_,
|
||||
data.favorite.unwrap_or(false));
|
||||
|
||||
if let Some(ref folder_id) = data.folderId {
|
||||
// TODO: Validate folder is owned by user
|
||||
cipher.folder_uuid = Some(folder_id.clone());
|
||||
}
|
||||
|
||||
if let Some(ref org_id) = data.organizationId {
|
||||
cipher.organization_uuid = Some(org_id.clone());
|
||||
}
|
||||
|
||||
cipher.data = match value_from_data(&data) {
|
||||
Ok(value) => {
|
||||
use serde_json;
|
||||
println!("--- {:?}", serde_json::to_string(&value));
|
||||
println!("--- {:?}", value.to_string());
|
||||
|
||||
value.to_string()
|
||||
}
|
||||
Err(msg) => err!(msg)
|
||||
};
|
||||
|
||||
cipher.save(&conn);
|
||||
|
||||
Ok(Json(cipher.to_json()))
|
||||
}
|
||||
|
||||
fn value_from_data(data: &CipherData) -> Result<Value, &'static str> {
|
||||
let mut values = json!({
|
||||
"Name": data.name,
|
||||
"Notes": data.notes
|
||||
});
|
||||
|
||||
match data.type_ {
|
||||
1 /*Login*/ => {
|
||||
let login_data = match data.login {
|
||||
Some(ref login) => login.clone(),
|
||||
None => return Err("Login data missing")
|
||||
};
|
||||
|
||||
if !copy_values(&login_data, &mut values) {
|
||||
return Err("Login data invalid");
|
||||
}
|
||||
}
|
||||
3 /*Card*/ => {
|
||||
let card_data = match data.card {
|
||||
Some(ref card) => card.clone(),
|
||||
None => return Err("Card data missing")
|
||||
};
|
||||
|
||||
if !copy_values(&card_data, &mut values) {
|
||||
return Err("Card data invalid");
|
||||
}
|
||||
}
|
||||
_ => return Err("Unknown type")
|
||||
}
|
||||
|
||||
if let Some(ref fields) = data.fields {
|
||||
values["Fields"] = Value::Array(fields.iter().map(|f| {
|
||||
use std::collections::BTreeMap;
|
||||
use serde_json;
|
||||
|
||||
let empty_map: BTreeMap<String, Value> = BTreeMap::new();
|
||||
let mut value = serde_json::to_value(empty_map).unwrap();
|
||||
|
||||
copy_values(&f, &mut value);
|
||||
|
||||
value
|
||||
}).collect());
|
||||
} else {
|
||||
values["Fields"] = Value::Null;
|
||||
}
|
||||
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn copy_values(from: &Value, to: &mut Value) -> bool {
|
||||
let map = match from.as_object() {
|
||||
Some(map) => map,
|
||||
None => return false
|
||||
};
|
||||
|
||||
for (key, val) in map {
|
||||
to[util::upcase_first(key)] = val.clone();
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
#[post("/ciphers/import", data = "<data>")]
|
||||
fn post_ciphers_import(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
println!("{:#?}", data);
|
||||
err!("Not implemented")
|
||||
}
|
||||
|
||||
#[post("/ciphers/<uuid>/attachment", format = "multipart/form-data", data = "<data>")]
|
||||
fn post_attachment(uuid: String, data: Data, content_type: &ContentType, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
// TODO: Check if cipher exists
|
||||
|
||||
let mut params = content_type.params();
|
||||
let boundary_pair = params.next().expect("No boundary provided"); // ("boundary", "----WebKitFormBoundary...")
|
||||
let boundary = boundary_pair.1;
|
||||
|
||||
use data_encoding::BASE64URL;
|
||||
use crypto;
|
||||
use CONFIG;
|
||||
|
||||
// TODO: Maybe use the same format as the official server?
|
||||
let attachment_id = BASE64URL.encode(&crypto::get_random_64());
|
||||
let path = format!("{}/{}/{}", CONFIG.attachments_folder,
|
||||
headers.user.uuid, attachment_id);
|
||||
println!("Path {:#?}", path);
|
||||
|
||||
let mut mp = Multipart::with_body(data.open(), boundary);
|
||||
match mp.save().with_dir(path).into_entries() {
|
||||
Some(entries) => {
|
||||
println!("Entries {:#?}", entries);
|
||||
|
||||
let saved_file = &entries.files["data"][0]; // Only one file at a time
|
||||
let file_name = &saved_file.filename; // This is provided by the client, don't trust it
|
||||
let file_size = &saved_file.size;
|
||||
}
|
||||
None => err!("No data entries")
|
||||
}
|
||||
|
||||
err!("Not implemented")
|
||||
}
|
||||
|
||||
#[delete("/ciphers/<uuid>/attachment/<attachment_id>")]
|
||||
fn delete_attachment(uuid: String, attachment_id: String, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
if uuid != headers.user.uuid {
|
||||
err!("Permission denied")
|
||||
}
|
||||
|
||||
// Delete file
|
||||
|
||||
// Delete entry in cipher
|
||||
|
||||
err!("Not implemented")
|
||||
}
|
||||
|
||||
#[post("/ciphers/<uuid>")]
|
||||
fn post_cipher(uuid: String, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
put_cipher(uuid, headers, conn)
|
||||
}
|
||||
|
||||
#[put("/ciphers/<uuid>")]
|
||||
fn put_cipher(uuid: String, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> { err!("Not implemented") }
|
||||
|
||||
#[delete("/ciphers/<uuid>")]
|
||||
fn delete_cipher(uuid: String, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> { err!("Not implemented") }
|
||||
|
||||
#[post("/ciphers/delete", data = "<data>")]
|
||||
fn delete_all(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let password_hash = data["masterPasswordHash"].as_str().unwrap();
|
||||
|
||||
let user = headers.user;
|
||||
|
||||
if !user.check_valid_password(password_hash) {
|
||||
err!("Invalid password")
|
||||
}
|
||||
|
||||
// Cipher::delete_from_user(&conn);
|
||||
|
||||
err!("Not implemented")
|
||||
}
|
102
src/api/core/folders.rs
Normal file
102
src/api/core/folders.rs
Normal file
|
@ -0,0 +1,102 @@
|
|||
use rocket::Route;
|
||||
use rocket::response::status::BadRequest;
|
||||
|
||||
use rocket_contrib::{Json, Value};
|
||||
|
||||
use db::DbConn;
|
||||
use db::models::*;
|
||||
use util;
|
||||
|
||||
use auth::Headers;
|
||||
|
||||
#[get("/folders")]
|
||||
fn get_folders(headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let folders = Folder::find_by_user(&headers.user.uuid, &conn);
|
||||
|
||||
let folders_json: Vec<Value> = folders.iter().map(|c| c.to_json()).collect();
|
||||
|
||||
Ok(Json(json!({
|
||||
"Data": folders_json,
|
||||
"Object": "list",
|
||||
})))
|
||||
}
|
||||
|
||||
#[get("/folders/<uuid>")]
|
||||
fn get_folder(uuid: String, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let mut folder = match Folder::find_by_uuid(&uuid, &conn) {
|
||||
Some(folder) => folder,
|
||||
_ => err!("Invalid folder")
|
||||
};
|
||||
|
||||
if folder.user_uuid != headers.user.uuid {
|
||||
err!("Folder belongs to another user")
|
||||
}
|
||||
|
||||
Ok(Json(folder.to_json()))
|
||||
}
|
||||
|
||||
#[post("/folders", data = "<data>")]
|
||||
fn post_folders(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let name = &data["name"].as_str();
|
||||
|
||||
if name.is_none() {
|
||||
err!("Invalid name")
|
||||
}
|
||||
|
||||
let folder = Folder::new(headers.user.uuid.clone(), name.unwrap().into());
|
||||
|
||||
folder.save(&conn);
|
||||
|
||||
Ok(Json(folder.to_json()))
|
||||
}
|
||||
|
||||
#[post("/folders/<uuid>", data = "<data>")]
|
||||
fn post_folder(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
put_folder(uuid, data, headers, conn)
|
||||
}
|
||||
|
||||
#[put("/folders/<uuid>", data = "<data>")]
|
||||
fn put_folder(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let mut folder = match Folder::find_by_uuid(&uuid, &conn) {
|
||||
Some(folder) => folder,
|
||||
_ => err!("Invalid folder")
|
||||
};
|
||||
|
||||
if folder.user_uuid != headers.user.uuid {
|
||||
err!("Folder belongs to another user")
|
||||
}
|
||||
|
||||
let name = &data["name"].as_str();
|
||||
|
||||
if name.is_none() {
|
||||
err!("Invalid name")
|
||||
}
|
||||
|
||||
folder.name = name.unwrap().into();
|
||||
|
||||
folder.save(&conn);
|
||||
|
||||
Ok(Json(folder.to_json()))
|
||||
}
|
||||
|
||||
#[post("/folders/<uuid>/delete", data = "<data>")]
|
||||
fn delete_folder_post(uuid: String, data: Json<Value>, headers: Headers, conn: DbConn) -> Result<(), BadRequest<Json>> {
|
||||
// Data contains a json object with the id, but we don't need it
|
||||
delete_folder(uuid, headers, conn)
|
||||
}
|
||||
|
||||
#[delete("/folders/<uuid>")]
|
||||
fn delete_folder(uuid: String, headers: Headers, conn: DbConn) -> Result<(), BadRequest<Json>> {
|
||||
let folder = match Folder::find_by_uuid(&uuid, &conn) {
|
||||
Some(folder) => folder,
|
||||
_ => err!("Invalid folder")
|
||||
};
|
||||
|
||||
if folder.user_uuid != headers.user.uuid {
|
||||
err!("Folder belongs to another user")
|
||||
}
|
||||
|
||||
folder.delete(&conn);
|
||||
|
||||
Ok(())
|
||||
}
|
100
src/api/core/mod.rs
Normal file
100
src/api/core/mod.rs
Normal file
|
@ -0,0 +1,100 @@
|
|||
mod accounts;
|
||||
mod ciphers;
|
||||
mod folders;
|
||||
mod two_factor;
|
||||
|
||||
use self::accounts::*;
|
||||
use self::ciphers::*;
|
||||
use self::folders::*;
|
||||
use self::two_factor::*;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![
|
||||
register,
|
||||
profile,
|
||||
post_keys,
|
||||
post_password,
|
||||
post_sstamp,
|
||||
post_email,
|
||||
delete_account,
|
||||
revision_date,
|
||||
|
||||
sync,
|
||||
|
||||
get_ciphers,
|
||||
get_cipher,
|
||||
post_ciphers,
|
||||
post_ciphers_import,
|
||||
post_attachment,
|
||||
delete_attachment,
|
||||
post_cipher,
|
||||
put_cipher,
|
||||
delete_cipher,
|
||||
delete_all,
|
||||
|
||||
get_folders,
|
||||
get_folder,
|
||||
post_folders,
|
||||
post_folder,
|
||||
put_folder,
|
||||
delete_folder_post,
|
||||
delete_folder,
|
||||
|
||||
get_twofactor,
|
||||
get_recover,
|
||||
generate_authenticator,
|
||||
activate_authenticator,
|
||||
disable_authenticator,
|
||||
|
||||
get_collections,
|
||||
|
||||
clear_device_token,
|
||||
put_device_token,
|
||||
|
||||
get_eq_domains,
|
||||
post_eq_domains
|
||||
]
|
||||
}
|
||||
|
||||
///
|
||||
/// Move this somewhere else
|
||||
///
|
||||
|
||||
use rocket::Route;
|
||||
use rocket::response::status::BadRequest;
|
||||
|
||||
use rocket_contrib::{Json, Value};
|
||||
|
||||
use db::DbConn;
|
||||
use db::models::*;
|
||||
use util;
|
||||
|
||||
use auth::Headers;
|
||||
|
||||
|
||||
// GET /api/collections?writeOnly=false
|
||||
#[get("/collections")]
|
||||
fn get_collections() -> Result<Json, BadRequest<Json>> {
|
||||
Ok(Json(json!({
|
||||
"Data": [],
|
||||
"Object": "list"
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
#[put("/devices/identifier/<uuid>/clear-token")]
|
||||
fn clear_device_token(uuid: String) -> Result<Json, BadRequest<Json>> { err!("Not implemented") }
|
||||
|
||||
#[put("/devices/identifier/<uuid>/token")]
|
||||
fn put_device_token(uuid: String) -> Result<Json, BadRequest<Json>> { err!("Not implemented") }
|
||||
|
||||
|
||||
#[get("/settings/domains")]
|
||||
fn get_eq_domains() -> Result<Json, BadRequest<Json>> {
|
||||
err!("Not implemented")
|
||||
}
|
||||
|
||||
#[post("/settings/domains")]
|
||||
fn post_eq_domains() -> Result<Json, BadRequest<Json>> {
|
||||
err!("Not implemented")
|
||||
}
|
131
src/api/core/two_factor.rs
Normal file
131
src/api/core/two_factor.rs
Normal file
|
@ -0,0 +1,131 @@
|
|||
use rocket::Route;
|
||||
use rocket::response::status::BadRequest;
|
||||
|
||||
use rocket_contrib::{Json, Value};
|
||||
|
||||
use data_encoding::BASE32;
|
||||
|
||||
use db::DbConn;
|
||||
use db::models::*;
|
||||
|
||||
use util;
|
||||
use crypto;
|
||||
|
||||
use auth::Headers;
|
||||
|
||||
|
||||
#[get("/two-factor")]
|
||||
fn get_twofactor(headers: Headers) -> Result<Json, BadRequest<Json>> {
|
||||
let data = if headers.user.totp_secret.is_none() {
|
||||
Value::Null
|
||||
} else {
|
||||
json!([{
|
||||
"Enabled": true,
|
||||
"Type": 0,
|
||||
"Object": "twoFactorProvider"
|
||||
}])
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"Data": data,
|
||||
"Object": "list"
|
||||
})))
|
||||
}
|
||||
|
||||
#[post("/two-factor/get-recover", data = "<data>")]
|
||||
fn get_recover(data: Json<Value>, headers: Headers) -> Result<Json, BadRequest<Json>> {
|
||||
let password_hash = data["masterPasswordHash"].as_str().unwrap();
|
||||
|
||||
if !headers.user.check_valid_password(password_hash) {
|
||||
err!("Invalid password");
|
||||
}
|
||||
|
||||
Ok(Json(json!({
|
||||
"Code": headers.user.totp_recover,
|
||||
"Object": "twoFactorRecover"
|
||||
})))
|
||||
}
|
||||
|
||||
#[post("/two-factor/get-authenticator", data = "<data>")]
|
||||
fn generate_authenticator(data: Json<Value>, headers: Headers) -> Result<Json, BadRequest<Json>> {
|
||||
let password_hash = data["masterPasswordHash"].as_str().unwrap();
|
||||
|
||||
if !headers.user.check_valid_password(password_hash) {
|
||||
err!("Invalid password");
|
||||
}
|
||||
|
||||
let (enabled, key) = match headers.user.totp_secret {
|
||||
Some(secret) => (true, secret),
|
||||
_ => (false, BASE32.encode(&crypto::get_random(vec![0u8; 20])))
|
||||
};
|
||||
|
||||
Ok(Json(json!({
|
||||
"Enabled": enabled,
|
||||
"Key": key,
|
||||
"Object": "twoFactorAuthenticator"
|
||||
})))
|
||||
}
|
||||
|
||||
#[post("/two-factor/authenticator", data = "<data>")]
|
||||
fn activate_authenticator(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let password_hash = data["masterPasswordHash"].as_str().unwrap();
|
||||
|
||||
if !headers.user.check_valid_password(password_hash) {
|
||||
err!("Invalid password");
|
||||
}
|
||||
let token = data["token"].as_str(); // 123456
|
||||
let key = data["key"].as_str().unwrap(); // YI4SKBIXG32LOA6VFKH2NI25VU3E4QML
|
||||
|
||||
// Validate key as base32 and 20 bytes length
|
||||
let decoded_key: Vec<u8> = match BASE32.decode(key.as_bytes()) {
|
||||
Ok(decoded) => decoded,
|
||||
_ => err!("Invalid totp secret")
|
||||
};
|
||||
|
||||
if decoded_key.len() != 20 {
|
||||
err!("Invalid key length")
|
||||
}
|
||||
|
||||
// Set key in user.totp_secret
|
||||
let mut user = headers.user;
|
||||
user.totp_secret = Some(key.to_uppercase());
|
||||
|
||||
// Validate the token provided with the key
|
||||
if !user.check_totp_code(util::parse_option_string(token)) {
|
||||
err!("Invalid totp code")
|
||||
}
|
||||
|
||||
// Generate totp_recover
|
||||
let totp_recover = BASE32.encode(&crypto::get_random(vec![0u8; 20]));
|
||||
user.totp_recover = Some(totp_recover);
|
||||
|
||||
user.save(&conn);
|
||||
|
||||
Ok(Json(json!({
|
||||
"Enabled": true,
|
||||
"Key": key,
|
||||
"Object": "twoFactorAuthenticator"
|
||||
})))
|
||||
}
|
||||
|
||||
#[post("/two-factor/disable", data = "<data>")]
|
||||
fn disable_authenticator(data: Json<Value>, headers: Headers, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let _type = &data["type"];
|
||||
let password_hash = data["masterPasswordHash"].as_str().unwrap();
|
||||
|
||||
if !headers.user.check_valid_password(password_hash) {
|
||||
err!("Invalid password");
|
||||
}
|
||||
|
||||
let mut user = headers.user;
|
||||
user.totp_secret = None;
|
||||
user.totp_recover = None;
|
||||
|
||||
user.save(&conn);
|
||||
|
||||
Ok(Json(json!({
|
||||
"Enabled": false,
|
||||
"Type": 0,
|
||||
"Object": "twoFactorProvider"
|
||||
})))
|
||||
}
|
85
src/api/icons.rs
Normal file
85
src/api/icons.rs
Normal file
|
@ -0,0 +1,85 @@
|
|||
use std::io;
|
||||
use std::io::prelude::*;
|
||||
use std::fs::{create_dir_all, File};
|
||||
use std::path::Path;
|
||||
|
||||
use rocket::Route;
|
||||
use rocket::response::Content;
|
||||
use rocket::http::ContentType;
|
||||
|
||||
use reqwest;
|
||||
|
||||
use CONFIG;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![icon]
|
||||
}
|
||||
|
||||
#[get("/<domain>/icon.png")]
|
||||
fn icon(domain: String) -> Content<Vec<u8>> {
|
||||
// Validate the domain to avoid directory traversal attacks
|
||||
if domain.contains("/") || domain.contains("..") {
|
||||
return Content(ContentType::PNG, get_fallback_icon());
|
||||
}
|
||||
|
||||
let url = format!("https://icons.bitwarden.com/{}/icon.png", domain);
|
||||
|
||||
// Get the icon, or fallback in case of error
|
||||
let icon = match get_icon_cached(&domain, &url) {
|
||||
Ok(icon) => icon,
|
||||
Err(e) => return Content(ContentType::PNG, get_fallback_icon())
|
||||
};
|
||||
|
||||
Content(ContentType::PNG, icon)
|
||||
}
|
||||
|
||||
fn get_icon(url: &str) -> Result<Vec<u8>, reqwest::Error> {
|
||||
let mut res = reqwest::get(url)?;
|
||||
|
||||
res = match res.error_for_status() {
|
||||
Err(e) => return Err(e),
|
||||
Ok(res) => res
|
||||
};
|
||||
|
||||
let mut buffer: Vec<u8> = vec![];
|
||||
res.copy_to(&mut buffer)?;
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
fn get_icon_cached(key: &str, url: &str) -> io::Result<Vec<u8>> {
|
||||
create_dir_all(&CONFIG.icon_cache_folder)?;
|
||||
let path = &format!("{}/{}.png", CONFIG.icon_cache_folder, key);
|
||||
|
||||
/// Try to read the cached icon, and return it if it exists
|
||||
match File::open(path) {
|
||||
Ok(mut f) => {
|
||||
let mut buffer = Vec::new();
|
||||
|
||||
if f.read_to_end(&mut buffer).is_ok() {
|
||||
return Ok(buffer);
|
||||
}
|
||||
/* If error reading file continue */
|
||||
}
|
||||
Err(_) => { /* Continue */ }
|
||||
}
|
||||
|
||||
println!("Downloading icon for {}...", key);
|
||||
let icon = match get_icon(url) {
|
||||
Ok(icon) => icon,
|
||||
Err(_) => return Err(io::Error::new(io::ErrorKind::NotFound, ""))
|
||||
};
|
||||
|
||||
/// Save the currently downloaded icon
|
||||
match File::create(path) {
|
||||
Ok(mut f) => { f.write_all(&icon); }
|
||||
Err(_) => { /* Continue */ }
|
||||
};
|
||||
|
||||
Ok(icon)
|
||||
}
|
||||
|
||||
fn get_fallback_icon() -> Vec<u8> {
|
||||
let fallback_icon = "https://raw.githubusercontent.com/bitwarden/web/master/src/images/fa-globe.png";
|
||||
get_icon_cached("default", fallback_icon).unwrap()
|
||||
}
|
225
src/api/identity.rs
Normal file
225
src/api/identity.rs
Normal file
|
@ -0,0 +1,225 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use rocket::Route;
|
||||
use rocket::request::{Form, FormItems, FromForm};
|
||||
use rocket::response::status::BadRequest;
|
||||
|
||||
use rocket_contrib::Json;
|
||||
|
||||
use db::DbConn;
|
||||
use db::models::*;
|
||||
use util;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![ login]
|
||||
}
|
||||
|
||||
#[post("/connect/token", data = "<connect_data>")]
|
||||
fn login(connect_data: Form<ConnectData>, conn: DbConn) -> Result<Json, BadRequest<Json>> {
|
||||
let data = connect_data.get();
|
||||
println!("{:#?}", data);
|
||||
|
||||
let mut device = match data.grant_type {
|
||||
GrantType::RefreshToken => {
|
||||
// Extract token
|
||||
let token = data.get("refresh_token").unwrap();
|
||||
|
||||
// Get device by refresh token
|
||||
match Device::find_by_refresh_token(token, &conn) {
|
||||
Some(device) => device,
|
||||
None => err!("Invalid refresh token")
|
||||
}
|
||||
}
|
||||
GrantType::Password => {
|
||||
// Validate scope
|
||||
let scope = data.get("scope").unwrap();
|
||||
if scope != "api offline_access" {
|
||||
err!("Scope not supported")
|
||||
}
|
||||
|
||||
// Get the user
|
||||
let username = data.get("username").unwrap();
|
||||
let user = match User::find_by_mail(username, &conn) {
|
||||
Some(user) => user,
|
||||
None => err!("Invalid username or password")
|
||||
};
|
||||
|
||||
// Check password
|
||||
let password = data.get("password").unwrap();
|
||||
if !user.check_valid_password(password) {
|
||||
err!("Invalid username or password")
|
||||
}
|
||||
|
||||
/*
|
||||
//TODO: When invalid username or password, return this with a 400 BadRequest:
|
||||
{
|
||||
"error": "invalid_grant",
|
||||
"error_description": "invalid_username_or_password",
|
||||
"ErrorModel": {
|
||||
"Message": "Username or password is incorrect. Try again.",
|
||||
"ValidationErrors": null,
|
||||
"ExceptionMessage": null,
|
||||
"ExceptionStackTrace": null,
|
||||
"InnerExceptionMessage": null,
|
||||
"Object": "error"
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// Check if totp code is required and the value is correct
|
||||
let totp_code = util::parse_option_string(data.get("twoFactorToken").map(String::as_ref));
|
||||
|
||||
if !user.check_totp_code(totp_code) {
|
||||
// Return error 400
|
||||
return err_json!(json!({
|
||||
"error" : "invalid_grant",
|
||||
"error_description" : "Two factor required.",
|
||||
"TwoFactorProviders" : [ 0 ],
|
||||
"TwoFactorProviders2" : { "0" : null }
|
||||
}));
|
||||
}
|
||||
|
||||
// Let's only use the header and ignore the 'devicetype' parameter
|
||||
// TODO Get header Device-Type
|
||||
let device_type_num = 0;// headers.device_type;
|
||||
|
||||
let (device_id, device_name) = match data.get("client_id").unwrap().as_ref() {
|
||||
"web" => { (format!("web-{}", user.uuid), String::from("web")) }
|
||||
"browser" | "mobile" => {
|
||||
(
|
||||
data.get("deviceidentifier").unwrap().clone(),
|
||||
data.get("devicename").unwrap().clone(),
|
||||
)
|
||||
}
|
||||
_ => err!("Invalid client id")
|
||||
};
|
||||
|
||||
// Find device or create new
|
||||
let device = match Device::find_by_uuid(&device_id, &conn) {
|
||||
Some(device) => {
|
||||
// Check if valid device
|
||||
if device.user_uuid != user.uuid {
|
||||
device.delete(&conn);
|
||||
err!("Device is not owned by user")
|
||||
}
|
||||
|
||||
device
|
||||
}
|
||||
None => {
|
||||
// Create new device
|
||||
Device::new(device_id, user.uuid, device_name, device_type_num)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
device
|
||||
}
|
||||
};
|
||||
|
||||
let user = User::find_by_uuid(&device.user_uuid, &conn).unwrap();
|
||||
let (access_token, expires_in) = device.refresh_tokens(&user);
|
||||
device.save(&conn);
|
||||
|
||||
// TODO: when to include :privateKey and :TwoFactorToken?
|
||||
Ok(Json(json!({
|
||||
"access_token": access_token,
|
||||
"expires_in": expires_in,
|
||||
"token_type": "Bearer",
|
||||
"refresh_token": device.refresh_token,
|
||||
"Key": user.key,
|
||||
"PrivateKey": user.private_key
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ConnectData {
|
||||
grant_type: GrantType,
|
||||
data: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ConnectData {
|
||||
fn get(&self, key: &str) -> Option<&String> {
|
||||
self.data.get(&key.to_lowercase())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
enum GrantType { RefreshToken, Password }
|
||||
|
||||
|
||||
const VALUES_REFRESH: [&str; 1] = ["refresh_token"];
|
||||
|
||||
const VALUES_PASSWORD: [&str; 5] = ["client_id",
|
||||
"grant_type", "password", "scope", "username"];
|
||||
|
||||
const VALUES_DEVICE: [&str; 3] = ["deviceidentifier",
|
||||
"devicename", "devicetype"];
|
||||
|
||||
|
||||
impl<'f> FromForm<'f> for ConnectData {
|
||||
type Error = String;
|
||||
|
||||
fn from_form(items: &mut FormItems<'f>, strict: bool) -> Result<Self, Self::Error> {
|
||||
let mut data = HashMap::new();
|
||||
|
||||
// Insert data into map
|
||||
for (key, value) in items {
|
||||
let decoded_key: String = match key.url_decode() {
|
||||
Ok(decoded) => decoded,
|
||||
Err(e) => return Err(format!("Error decoding key: {}", value)),
|
||||
};
|
||||
|
||||
let decoded_value: String = match value.url_decode() {
|
||||
Ok(decoded) => decoded,
|
||||
Err(e) => return Err(format!("Error decoding value: {}", value)),
|
||||
};
|
||||
|
||||
data.insert(decoded_key.to_lowercase(), decoded_value);
|
||||
}
|
||||
|
||||
// Validate needed values
|
||||
let grant_type =
|
||||
match data.get("grant_type").map(|s| &s[..]) {
|
||||
Some("refresh_token") => {
|
||||
// Check if refresh token is proviced
|
||||
if let Err(msg) = check_values(&data, &VALUES_REFRESH) {
|
||||
return Err(msg);
|
||||
}
|
||||
|
||||
GrantType::RefreshToken
|
||||
}
|
||||
Some("password") => {
|
||||
// Check if basic values are provided
|
||||
if let Err(msg) = check_values(&data, &VALUES_PASSWORD) {
|
||||
return Err(msg);
|
||||
}
|
||||
|
||||
// Check that device values are present on device
|
||||
match data.get("client_id").unwrap().as_ref() {
|
||||
"browser" | "mobile" => {
|
||||
if let Err(msg) = check_values(&data, &VALUES_DEVICE) {
|
||||
return Err(msg);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
GrantType::Password
|
||||
}
|
||||
|
||||
_ => return Err(format!("Grant type not supported"))
|
||||
};
|
||||
|
||||
Ok(ConnectData { grant_type, data })
|
||||
}
|
||||
}
|
||||
|
||||
fn check_values(map: &HashMap<String, String>, values: &[&str]) -> Result<(), String> {
|
||||
for value in values {
|
||||
if !map.contains_key(*value) {
|
||||
return Err(format!("{} cannot be blank", value));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
9
src/api/mod.rs
Normal file
9
src/api/mod.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
mod core;
|
||||
mod icons;
|
||||
mod identity;
|
||||
mod web;
|
||||
|
||||
pub use self::core::routes as core_routes;
|
||||
pub use self::icons::routes as icons_routes;
|
||||
pub use self::identity::routes as identity_routes;
|
||||
pub use self::web::routes as web_routes;
|
43
src/api/web.rs
Normal file
43
src/api/web.rs
Normal file
|
@ -0,0 +1,43 @@
|
|||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rocket::Route;
|
||||
use rocket::response::NamedFile;
|
||||
use rocket_contrib::{Json, Value};
|
||||
|
||||
use auth::Headers;
|
||||
|
||||
use CONFIG;
|
||||
|
||||
pub fn routes() -> Vec<Route> {
|
||||
routes![index, files, attachments, alive]
|
||||
}
|
||||
|
||||
// TODO: Might want to use in memory cache: https://github.com/hgzimmerman/rocket-file-cache
|
||||
#[get("/")]
|
||||
fn index() -> io::Result<NamedFile> {
|
||||
NamedFile::open(Path::new(&CONFIG.web_vault_folder).join("index.html"))
|
||||
}
|
||||
|
||||
#[get("/<p..>")] // Only match this if the other routes don't match
|
||||
fn files(p: PathBuf) -> io::Result<NamedFile> {
|
||||
NamedFile::open(Path::new(&CONFIG.web_vault_folder).join(p))
|
||||
}
|
||||
|
||||
#[get("/attachments/<uuid>/<file..>")]
|
||||
fn attachments(uuid: String, file: PathBuf, headers: Headers) -> io::Result<NamedFile> {
|
||||
if uuid != headers.user.uuid {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "Permission denied"));
|
||||
}
|
||||
|
||||
NamedFile::open(Path::new(&CONFIG.attachments_folder).join(file))
|
||||
}
|
||||
|
||||
|
||||
#[get("/alive")]
|
||||
fn alive() -> Json<String> {
|
||||
use util::format_date;
|
||||
use chrono::{NaiveDateTime, Utc};
|
||||
|
||||
Json(format_date(&Utc::now().naive_utc()))
|
||||
}
|
164
src/auth.rs
Normal file
164
src/auth.rs
Normal file
|
@ -0,0 +1,164 @@
|
|||
///
|
||||
/// JWT Handling
|
||||
///
|
||||
|
||||
use util::read_file;
|
||||
use std::path::Path;
|
||||
use time::Duration;
|
||||
|
||||
use jwt;
|
||||
use serde::ser::Serialize;
|
||||
use serde::de::Deserialize;
|
||||
|
||||
use CONFIG;
|
||||
|
||||
const JWT_ALGORITHM: jwt::Algorithm = jwt::Algorithm::RS256;
|
||||
pub const JWT_ISSUER: &'static str = "localhost:8000/identity";
|
||||
|
||||
lazy_static! {
|
||||
pub static ref DEFAULT_VALIDITY: Duration = Duration::hours(2);
|
||||
static ref JWT_HEADER: jwt::Header = jwt::Header::new(JWT_ALGORITHM);
|
||||
|
||||
static ref PRIVATE_RSA_KEY: Vec<u8> = match read_file(&CONFIG.private_rsa_key) {
|
||||
Ok(key) => key,
|
||||
Err(e) => panic!("Error loading private RSA Key from {}\n Error: {}", CONFIG.private_rsa_key, e)
|
||||
};
|
||||
|
||||
static ref PUBLIC_RSA_KEY: Vec<u8> = match read_file(&CONFIG.public_rsa_key) {
|
||||
Ok(key) => key,
|
||||
Err(e) => panic!("Error loading public RSA Key from {}\n Error: {}", CONFIG.public_rsa_key, e)
|
||||
};
|
||||
}
|
||||
|
||||
pub fn encode_jwt<T: Serialize>(claims: &T) -> String {
|
||||
match jwt::encode(&JWT_HEADER, claims, &PRIVATE_RSA_KEY) {
|
||||
Ok(token) => return token,
|
||||
Err(e) => panic!("Error encoding jwt {}", e)
|
||||
};
|
||||
}
|
||||
|
||||
pub fn decode_jwt(token: &str) -> Result<JWTClaims, String> {
|
||||
let validation = jwt::Validation {
|
||||
leeway: 30, // 30 seconds
|
||||
validate_exp: true,
|
||||
validate_iat: true,
|
||||
validate_nbf: true,
|
||||
aud: None,
|
||||
iss: Some(JWT_ISSUER.into()),
|
||||
sub: None,
|
||||
algorithms: vec![JWT_ALGORITHM],
|
||||
};
|
||||
|
||||
match jwt::decode(token, &PUBLIC_RSA_KEY, &validation) {
|
||||
Ok(decoded) => Ok(decoded.claims),
|
||||
Err(msg) => {
|
||||
println!("Error validating jwt - {:#?}", msg);
|
||||
Err(msg.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct JWTClaims {
|
||||
// Not before
|
||||
pub nbf: i64,
|
||||
// Expiration time
|
||||
pub exp: i64,
|
||||
// Issuer
|
||||
pub iss: String,
|
||||
// Subject
|
||||
pub sub: String,
|
||||
|
||||
pub premium: bool,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub email_verified: bool,
|
||||
|
||||
// user security_stamp
|
||||
pub sstamp: String,
|
||||
// device uuid
|
||||
pub device: String,
|
||||
// [ "api", "offline_access" ]
|
||||
pub scope: Vec<String>,
|
||||
// [ "Application" ]
|
||||
pub amr: Vec<String>,
|
||||
}
|
||||
|
||||
///
|
||||
/// Bearer token authentication
|
||||
///
|
||||
|
||||
use rocket::Outcome;
|
||||
use rocket::http::Status;
|
||||
use rocket::request::{self, Request, FromRequest};
|
||||
|
||||
use db::DbConn;
|
||||
use db::models::{User, Device};
|
||||
|
||||
pub struct Headers {
|
||||
pub device_type: i32,
|
||||
pub device: Device,
|
||||
pub user: User,
|
||||
}
|
||||
|
||||
impl<'a, 'r> FromRequest<'a, 'r> for Headers {
|
||||
type Error = &'static str;
|
||||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<Self, Self::Error> {
|
||||
let headers = request.headers();
|
||||
|
||||
/// Get device type
|
||||
let device_type = match headers.get_one("Device-Type")
|
||||
.map(|s| s.parse::<i32>()) {
|
||||
Some(Ok(dt)) => dt,
|
||||
_ => return err_handler!("Device-Type is invalid or missing")
|
||||
};
|
||||
|
||||
/// Get access_token
|
||||
let access_token: &str = match request.headers().get_one("Authorization") {
|
||||
Some(a) => {
|
||||
let split: Option<&str> = a.rsplit("Bearer ").next();
|
||||
|
||||
if split.is_none() {
|
||||
err_handler!("No access token provided")
|
||||
}
|
||||
|
||||
split.unwrap()
|
||||
}
|
||||
None => err_handler!("No access token provided")
|
||||
};
|
||||
|
||||
/// Check JWT token is valid and get device and user from it
|
||||
let claims: JWTClaims = match decode_jwt(access_token) {
|
||||
Ok(claims) => claims,
|
||||
Err(msg) => {
|
||||
println!("Invalid claim: {}", msg);
|
||||
err_handler!("Invalid claim")
|
||||
}
|
||||
};
|
||||
|
||||
let device_uuid = claims.device;
|
||||
let user_uuid = claims.sub;
|
||||
|
||||
let conn = match request.guard::<DbConn>() {
|
||||
Outcome::Success(conn) => conn,
|
||||
_ => err_handler!("Error getting DB")
|
||||
};
|
||||
|
||||
let device = match Device::find_by_uuid(&device_uuid, &conn) {
|
||||
Some(device) => device,
|
||||
None => err_handler!("Invalid device id")
|
||||
};
|
||||
|
||||
let user = match User::find_by_uuid(&user_uuid, &conn) {
|
||||
Some(user) => user,
|
||||
None => err_handler!("Device has no user associated")
|
||||
};
|
||||
|
||||
if user.security_stamp != claims.sstamp {
|
||||
err_handler!("Invalid security stamp")
|
||||
}
|
||||
|
||||
Outcome::Success(Headers { device_type, device, user })
|
||||
}
|
||||
}
|
168
src/bin/proxy.rs
Normal file
168
src/bin/proxy.rs
Normal file
|
@ -0,0 +1,168 @@
|
|||
#![feature(plugin)]
|
||||
|
||||
#![plugin(rocket_codegen)]
|
||||
extern crate rocket;
|
||||
extern crate rocket_contrib;
|
||||
extern crate reqwest;
|
||||
|
||||
use std::io::{self, Cursor};
|
||||
use std::str::FromStr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rocket::{Request, Response};
|
||||
use rocket::config::Config;
|
||||
use rocket::fairing::{Fairing, Info, Kind};
|
||||
use rocket::http;
|
||||
use rocket::response::NamedFile;
|
||||
|
||||
use reqwest::header::{self, Headers};
|
||||
|
||||
/**
|
||||
** These routes are here to avoid showing errors in the console,
|
||||
** redirect the body data to the fairing and show the web vault.
|
||||
**/
|
||||
|
||||
#[get("/")]
|
||||
fn index() -> io::Result<NamedFile> {
|
||||
NamedFile::open(Path::new("web-vault").join("index.html"))
|
||||
}
|
||||
|
||||
#[get("/<p..>")] // Only match this if the other routes don't match
|
||||
fn get(p: PathBuf) -> io::Result<NamedFile> {
|
||||
NamedFile::open(Path::new("web-vault").join(p))
|
||||
}
|
||||
|
||||
#[delete("/<_p..>")]
|
||||
fn delete(_p: PathBuf) {}
|
||||
|
||||
#[put("/<_p..>", data = "<d>")]
|
||||
fn put(_p: PathBuf, d: Vec<u8>) -> Vec<u8> { d }
|
||||
|
||||
#[post("/<_p..>", data = "<d>")]
|
||||
fn post(_p: PathBuf, d: Vec<u8>) -> Vec<u8> { d }
|
||||
|
||||
|
||||
fn main() {
|
||||
let config = Config::development().unwrap();
|
||||
|
||||
rocket::custom(config, false)
|
||||
.mount("/", routes![get, put, post, delete, index])
|
||||
.attach(ProxyFairing { client: reqwest::Client::new() })
|
||||
.launch();
|
||||
}
|
||||
|
||||
struct ProxyFairing {
|
||||
client: reqwest::Client
|
||||
}
|
||||
|
||||
impl Fairing for ProxyFairing {
|
||||
fn info(&self) -> Info {
|
||||
Info {
|
||||
name: "Proxy Fairing",
|
||||
kind: Kind::Launch | Kind::Response,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_launch(&self, _rocket: &rocket::Rocket) {
|
||||
println!("Started proxy on locahost:8000");
|
||||
}
|
||||
|
||||
fn on_response(&self, req: &Request, res: &mut Response) {
|
||||
// Prepare the data to make the request
|
||||
// -------------------------------------
|
||||
|
||||
let url = {
|
||||
let url = req.uri().as_str();
|
||||
|
||||
// Check if we are outside the API paths
|
||||
if !url.starts_with("/api/")
|
||||
&& !url.starts_with("/identity/") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace the path with the real server URL
|
||||
url.replacen("/api/", "https://api.bitwarden.com/", 1)
|
||||
.replacen("/identity/", "https://identity.bitwarden.com/", 1)
|
||||
};
|
||||
|
||||
let host = url.split("/").collect::<Vec<_>>()[2];
|
||||
let headers = headers_rocket_to_reqwest(req.headers(), host);
|
||||
let method = reqwest::Method::from_str(req.method().as_str()).unwrap();
|
||||
let body = res.body_bytes();
|
||||
|
||||
println!("\n\nREQ. {} {}", req.method().as_str(), url);
|
||||
println!("HEADERS. {:#?}", headers);
|
||||
if let Some(ref body) = body {
|
||||
let body_string = String::from_utf8_lossy(body);
|
||||
if !body_string.contains("<!DOCTYPE html>") {
|
||||
println!("BODY. {:?}", body_string);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Execute the request
|
||||
// -------------------------------------
|
||||
let mut client = self.client.request(method, &url);
|
||||
let request_builder = client.headers(headers);
|
||||
|
||||
if let Some(body_vec) = body {
|
||||
request_builder.body(body_vec);
|
||||
}
|
||||
|
||||
let mut server_res = match request_builder.send() {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
res.set_status(http::Status::BadRequest);
|
||||
res.set_sized_body(Cursor::new(e.to_string()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Get the response values
|
||||
// -------------------------------------
|
||||
let mut res_body: Vec<u8> = vec![];
|
||||
server_res.copy_to(&mut res_body).unwrap();
|
||||
|
||||
let res_status = server_res.status().as_u16();
|
||||
let mut res_headers = server_res.headers().clone();
|
||||
|
||||
// These headers break stuff
|
||||
res_headers.remove::<header::TransferEncoding>();
|
||||
res_headers.remove::<header::ContentLength>();
|
||||
|
||||
println!("\n\nRES. {} {}", res_status, url);
|
||||
// Nothing interesting here
|
||||
// println!("HEADERS. {:#?}", res_headers);
|
||||
println!("BODY. {:?}", String::from_utf8_lossy(&res_body));
|
||||
|
||||
// Prepare the response
|
||||
// -------------------------------------
|
||||
res.set_status(http::Status::from_code(res_status).unwrap_or(http::Status::BadRequest));
|
||||
|
||||
headers_reqwest_to_rocket(&res_headers, res);
|
||||
res.set_sized_body(Cursor::new(res_body));
|
||||
}
|
||||
}
|
||||
|
||||
fn headers_rocket_to_reqwest(headers: &http::HeaderMap, host: &str) -> Headers {
|
||||
let mut new_headers = Headers::new();
|
||||
|
||||
for header in headers.iter() {
|
||||
let name = header.name().to_string();
|
||||
|
||||
let value = if name.to_lowercase() != "host" {
|
||||
header.value().to_string()
|
||||
} else {
|
||||
host.to_string()
|
||||
};
|
||||
|
||||
new_headers.set_raw(name, value);
|
||||
}
|
||||
new_headers
|
||||
}
|
||||
|
||||
fn headers_reqwest_to_rocket(headers: &Headers, res: &mut Response) {
|
||||
for header in headers.iter() {
|
||||
res.set_raw_header(header.name().to_string(), header.value_string());
|
||||
}
|
||||
}
|
36
src/crypto.rs
Normal file
36
src/crypto.rs
Normal file
|
@ -0,0 +1,36 @@
|
|||
///
|
||||
/// PBKDF2 derivation
|
||||
///
|
||||
|
||||
use ring::{digest, pbkdf2};
|
||||
|
||||
static DIGEST_ALG: &digest::Algorithm = &digest::SHA256;
|
||||
const OUTPUT_LEN: usize = digest::SHA256_OUTPUT_LEN;
|
||||
|
||||
pub fn hash_password(secret: &[u8], salt: &[u8], iterations: u32) -> Vec<u8> {
|
||||
let mut out = vec![0u8; OUTPUT_LEN]; // Initialize array with zeros
|
||||
|
||||
pbkdf2::derive(DIGEST_ALG, iterations, salt, secret, &mut out);
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
pub fn verify_password_hash(secret: &[u8], salt: &[u8], previous: &[u8], iterations: u32) -> bool {
|
||||
pbkdf2::verify(DIGEST_ALG, iterations, salt, secret, previous).is_ok()
|
||||
}
|
||||
|
||||
///
|
||||
/// Random values
|
||||
///
|
||||
|
||||
pub fn get_random_64() -> Vec<u8> {
|
||||
get_random(vec![0u8; 64])
|
||||
}
|
||||
|
||||
pub fn get_random(mut array: Vec<u8>) -> Vec<u8> {
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
|
||||
SystemRandom::new().fill(&mut array);
|
||||
|
||||
array
|
||||
}
|
60
src/db/mod.rs
Normal file
60
src/db/mod.rs
Normal file
|
@ -0,0 +1,60 @@
|
|||
use std::ops::Deref;
|
||||
|
||||
use diesel::{Connection as DieselConnection, ConnectionError};
|
||||
use diesel::sqlite::SqliteConnection;
|
||||
use r2d2;
|
||||
use r2d2_diesel::ConnectionManager;
|
||||
|
||||
use rocket::http::Status;
|
||||
use rocket::request::{self, FromRequest};
|
||||
use rocket::{Outcome, Request, State};
|
||||
|
||||
use CONFIG;
|
||||
|
||||
/// An alias to the database connection used
|
||||
type Connection = SqliteConnection;
|
||||
|
||||
/// An alias to the type for a pool of Diesel SQLite connections.
|
||||
type Pool = r2d2::Pool<ConnectionManager<Connection>>;
|
||||
|
||||
/// Connection request guard type: a wrapper around an r2d2 pooled connection.
|
||||
pub struct DbConn(pub r2d2::PooledConnection<ConnectionManager<Connection>>);
|
||||
|
||||
pub mod schema;
|
||||
pub mod models;
|
||||
|
||||
/// Initializes a database pool.
|
||||
pub fn init_pool() -> Pool {
|
||||
let manager = ConnectionManager::new(&*CONFIG.database_url);
|
||||
|
||||
r2d2::Pool::builder()
|
||||
.build(manager)
|
||||
.expect("Failed to create pool")
|
||||
}
|
||||
|
||||
pub fn get_connection() -> Result<Connection, ConnectionError> {
|
||||
Connection::establish(&CONFIG.database_url)
|
||||
}
|
||||
|
||||
/// Attempts to retrieve a single connection from the managed database pool. If
|
||||
/// no pool is currently managed, fails with an `InternalServerError` status. If
|
||||
/// no connections are available, fails with a `ServiceUnavailable` status.
|
||||
impl<'a, 'r> FromRequest<'a, 'r> for DbConn {
|
||||
type Error = ();
|
||||
|
||||
fn from_request(request: &'a Request<'r>) -> request::Outcome<DbConn, ()> {
|
||||
let pool = request.guard::<State<Pool>>()?;
|
||||
match pool.get() {
|
||||
Ok(conn) => Outcome::Success(DbConn(conn)),
|
||||
Err(_) => Outcome::Failure((Status::ServiceUnavailable, ()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For the convenience of using an &DbConn as a &Database.
|
||||
impl Deref for DbConn {
|
||||
type Target = Connection;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
112
src/db/models/cipher.rs
Normal file
112
src/db/models/cipher.rs
Normal file
|
@ -0,0 +1,112 @@
|
|||
use chrono::{NaiveDate, NaiveDateTime, Utc};
|
||||
use time::Duration;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Queryable, Insertable, Identifiable)]
|
||||
#[table_name = "ciphers"]
|
||||
#[primary_key(uuid)]
|
||||
pub struct Cipher {
|
||||
pub uuid: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
|
||||
pub user_uuid: String,
|
||||
pub folder_uuid: Option<String>,
|
||||
pub organization_uuid: Option<String>,
|
||||
|
||||
// Login = 1,
|
||||
// SecureNote = 2,
|
||||
// Card = 3,
|
||||
// Identity = 4
|
||||
pub type_: i32,
|
||||
|
||||
pub data: String,
|
||||
pub favorite: bool,
|
||||
pub attachments: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
impl Cipher {
|
||||
pub fn new(user_uuid: String, type_: i32, favorite: bool) -> Cipher {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
Cipher {
|
||||
uuid: Uuid::new_v4().to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
|
||||
user_uuid,
|
||||
folder_uuid: None,
|
||||
organization_uuid: None,
|
||||
|
||||
type_,
|
||||
favorite,
|
||||
|
||||
data: String::new(),
|
||||
attachments: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> JsonValue {
|
||||
use serde_json;
|
||||
use util::format_date;
|
||||
|
||||
let data: JsonValue = serde_json::from_str(&self.data).unwrap();
|
||||
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"Type": self.type_,
|
||||
"RevisionDate": format_date(&self.updated_at),
|
||||
"FolderId": self.folder_uuid,
|
||||
"Favorite": self.favorite,
|
||||
"OrganizationId": "",
|
||||
"Attachments": self.attachments,
|
||||
"OrganizationUseTotp": false,
|
||||
"Data": data,
|
||||
"Object": "cipher",
|
||||
"Edit": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use db::DbConn;
|
||||
use db::schema::ciphers;
|
||||
|
||||
/// Database methods
|
||||
impl Cipher {
|
||||
pub fn save(&self, conn: &DbConn) -> bool {
|
||||
// TODO: Update modified date
|
||||
|
||||
match diesel::replace_into(ciphers::table)
|
||||
.values(self)
|
||||
.execute(&**conn) {
|
||||
Ok(1) => true, // One row inserted
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(self, conn: &DbConn) -> bool {
|
||||
match diesel::delete(ciphers::table.filter(
|
||||
ciphers::uuid.eq(self.uuid)))
|
||||
.execute(&**conn) {
|
||||
Ok(1) => true, // One row deleted
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Cipher> {
|
||||
ciphers::table
|
||||
.filter(ciphers::uuid.eq(uuid))
|
||||
.first::<Cipher>(&**conn).ok()
|
||||
}
|
||||
|
||||
pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Cipher> {
|
||||
ciphers::table
|
||||
.filter(ciphers::user_uuid.eq(user_uuid))
|
||||
.load::<Cipher>(&**conn).expect("Error loading ciphers")
|
||||
}
|
||||
}
|
117
src/db/models/device.rs
Normal file
117
src/db/models/device.rs
Normal file
|
@ -0,0 +1,117 @@
|
|||
use chrono::{NaiveDate, NaiveDateTime, Utc};
|
||||
use time::Duration;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Queryable, Insertable, Identifiable)]
|
||||
#[table_name = "devices"]
|
||||
#[primary_key(uuid)]
|
||||
pub struct Device {
|
||||
pub uuid: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
|
||||
pub user_uuid: String,
|
||||
|
||||
pub name: String,
|
||||
/// https://github.com/bitwarden/core/tree/master/src/Core/Enums
|
||||
pub type_: i32,
|
||||
pub push_token: Option<String>,
|
||||
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
impl Device {
|
||||
pub fn new(uuid: String, user_uuid: String, name: String, type_: i32) -> Device {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
Device {
|
||||
uuid,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
|
||||
user_uuid,
|
||||
name,
|
||||
type_,
|
||||
|
||||
push_token: None,
|
||||
refresh_token: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh_tokens(&mut self, user: &super::User) -> (String, i64) {
|
||||
// If there is no refresh token, we create one
|
||||
if self.refresh_token.is_empty() {
|
||||
use data_encoding::BASE64URL;
|
||||
use crypto;
|
||||
|
||||
self.refresh_token = BASE64URL.encode(&crypto::get_random_64());
|
||||
}
|
||||
|
||||
// Update the expiration of the device and the last update date
|
||||
let time_now = Utc::now().naive_utc();
|
||||
|
||||
self.updated_at = time_now;
|
||||
|
||||
// Create the JWT claims struct, to send to the client
|
||||
use auth::{encode_jwt, JWTClaims, DEFAULT_VALIDITY, JWT_ISSUER};
|
||||
let claims = JWTClaims {
|
||||
nbf: time_now.timestamp(),
|
||||
exp: (time_now + *DEFAULT_VALIDITY).timestamp(),
|
||||
iss: JWT_ISSUER.to_string(),
|
||||
sub: user.uuid.to_string(),
|
||||
premium: true,
|
||||
name: user.name.to_string(),
|
||||
email: user.email.to_string(),
|
||||
email_verified: true,
|
||||
sstamp: user.security_stamp.to_string(),
|
||||
device: self.uuid.to_string(),
|
||||
scope: vec!["api".into(), "offline_access".into()],
|
||||
amr: vec!["Application".into()],
|
||||
};
|
||||
|
||||
(encode_jwt(&claims), DEFAULT_VALIDITY.num_seconds())
|
||||
}
|
||||
}
|
||||
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use db::DbConn;
|
||||
use db::schema::devices;
|
||||
|
||||
/// Database methods
|
||||
impl Device {
|
||||
pub fn save(&self, conn: &DbConn) -> bool {
|
||||
// TODO: Update modified date
|
||||
|
||||
match diesel::replace_into(devices::table)
|
||||
.values(self)
|
||||
.execute(&**conn) {
|
||||
Ok(1) => true, // One row inserted
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(self, conn: &DbConn) -> bool {
|
||||
match diesel::delete(devices::table.filter(
|
||||
devices::uuid.eq(self.uuid)))
|
||||
.execute(&**conn) {
|
||||
Ok(1) => true, // One row deleted
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Device> {
|
||||
devices::table
|
||||
.filter(devices::uuid.eq(uuid))
|
||||
.first::<Device>(&**conn).ok()
|
||||
}
|
||||
|
||||
pub fn find_by_refresh_token(refresh_token: &str, conn: &DbConn) -> Option<Device> {
|
||||
devices::table
|
||||
.filter(devices::refresh_token.eq(refresh_token))
|
||||
.first::<Device>(&**conn).ok()
|
||||
}
|
||||
}
|
83
src/db/models/folder.rs
Normal file
83
src/db/models/folder.rs
Normal file
|
@ -0,0 +1,83 @@
|
|||
use chrono::{NaiveDate, NaiveDateTime, Utc};
|
||||
use time::Duration;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Queryable, Insertable, Identifiable)]
|
||||
#[table_name = "folders"]
|
||||
#[primary_key(uuid)]
|
||||
pub struct Folder {
|
||||
pub uuid: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
pub user_uuid: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
impl Folder {
|
||||
pub fn new(user_uuid: String, name: String) -> Folder {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
Folder {
|
||||
uuid: Uuid::new_v4().to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
|
||||
user_uuid,
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> JsonValue {
|
||||
use util::format_date;
|
||||
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"RevisionDate": format_date(&self.updated_at),
|
||||
"Name": self.name,
|
||||
"Object": "folder",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use db::DbConn;
|
||||
use db::schema::folders;
|
||||
|
||||
/// Database methods
|
||||
impl Folder {
|
||||
pub fn save(&self, conn: &DbConn) -> bool {
|
||||
// TODO: Update modified date
|
||||
|
||||
match diesel::replace_into(folders::table)
|
||||
.values(self)
|
||||
.execute(&**conn) {
|
||||
Ok(1) => true, // One row inserted
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(self, conn: &DbConn) -> bool {
|
||||
match diesel::delete(folders::table.filter(
|
||||
folders::uuid.eq(self.uuid)))
|
||||
.execute(&**conn) {
|
||||
Ok(1) => true, // One row deleted
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<Folder> {
|
||||
folders::table
|
||||
.filter(folders::uuid.eq(uuid))
|
||||
.first::<Folder>(&**conn).ok()
|
||||
}
|
||||
|
||||
pub fn find_by_user(user_uuid: &str, conn: &DbConn) -> Vec<Folder> {
|
||||
folders::table
|
||||
.filter(folders::user_uuid.eq(user_uuid))
|
||||
.load::<Folder>(&**conn).expect("Error loading folders")
|
||||
}
|
||||
}
|
9
src/db/models/mod.rs
Normal file
9
src/db/models/mod.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
mod cipher;
|
||||
mod device;
|
||||
mod folder;
|
||||
mod user;
|
||||
|
||||
pub use self::cipher::Cipher;
|
||||
pub use self::device::Device;
|
||||
pub use self::folder::Folder;
|
||||
pub use self::user::User;
|
159
src/db/models/user.rs
Normal file
159
src/db/models/user.rs
Normal file
|
@ -0,0 +1,159 @@
|
|||
use chrono::{NaiveDate, NaiveDateTime, Utc};
|
||||
use time::Duration;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use CONFIG;
|
||||
|
||||
#[derive(Queryable, Insertable, Identifiable)]
|
||||
#[table_name = "users"]
|
||||
#[primary_key(uuid)]
|
||||
pub struct User {
|
||||
pub uuid: String,
|
||||
pub created_at: NaiveDateTime,
|
||||
pub updated_at: NaiveDateTime,
|
||||
|
||||
pub email: String,
|
||||
pub name: String,
|
||||
|
||||
pub password_hash: Vec<u8>,
|
||||
pub salt: Vec<u8>,
|
||||
pub password_iterations: i32,
|
||||
pub password_hint: Option<String>,
|
||||
|
||||
pub key: String,
|
||||
pub private_key: Option<String>,
|
||||
pub public_key: Option<String>,
|
||||
pub totp_secret: Option<String>,
|
||||
pub totp_recover: Option<String>,
|
||||
pub security_stamp: String,
|
||||
}
|
||||
|
||||
/// Local methods
|
||||
impl User {
|
||||
pub fn new(mail: String, key: String, password: String) -> User {
|
||||
let now = Utc::now().naive_utc();
|
||||
let email = mail.to_lowercase();
|
||||
|
||||
use crypto;
|
||||
|
||||
let iterations = CONFIG.password_iterations;
|
||||
let salt = crypto::get_random_64();
|
||||
let password_hash = crypto::hash_password(password.as_bytes(), &salt, iterations as u32);
|
||||
|
||||
User {
|
||||
uuid: Uuid::new_v4().to_string(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
name: email.clone(),
|
||||
email,
|
||||
key,
|
||||
|
||||
password_hash,
|
||||
salt,
|
||||
password_iterations: iterations,
|
||||
|
||||
security_stamp: Uuid::new_v4().to_string(),
|
||||
|
||||
password_hint: None,
|
||||
private_key: None,
|
||||
public_key: None,
|
||||
totp_secret: None,
|
||||
totp_recover: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn check_valid_password(&self, password: &str) -> bool {
|
||||
use crypto;
|
||||
|
||||
crypto::verify_password_hash(password.as_bytes(),
|
||||
&self.salt,
|
||||
&self.password_hash,
|
||||
self.password_iterations as u32)
|
||||
}
|
||||
|
||||
pub fn set_password(&mut self, password: &str) {
|
||||
use crypto;
|
||||
self.password_hash = crypto::hash_password(password.as_bytes(),
|
||||
&self.salt,
|
||||
self.password_iterations as u32);
|
||||
self.reset_security_stamp();
|
||||
}
|
||||
|
||||
pub fn reset_security_stamp(&mut self) {
|
||||
self.security_stamp = Uuid::new_v4().to_string();
|
||||
}
|
||||
|
||||
pub fn check_totp_code(&self, totp_code: Option<u64>) -> bool {
|
||||
if let Some(ref totp_secret) = self.totp_secret {
|
||||
if let Some(code) = totp_code {
|
||||
// Validate totp
|
||||
use data_encoding::BASE32;
|
||||
use oath::{totp_raw_now, HashType};
|
||||
|
||||
let decoded_secret = match BASE32.decode(totp_secret.as_bytes()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return false
|
||||
};
|
||||
|
||||
let generated = totp_raw_now(&decoded_secret, 6, 0, 30, &HashType::SHA1);
|
||||
generated == code
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> JsonValue {
|
||||
json!({
|
||||
"Id": self.uuid,
|
||||
"Name": self.name,
|
||||
"Email": self.email,
|
||||
"EmailVerified": true,
|
||||
"Premium": true,
|
||||
"MasterPasswordHint": self.password_hint,
|
||||
"Culture": "en-US",
|
||||
"TwoFactorEnabled": self.totp_secret.is_some(),
|
||||
"Key": self.key,
|
||||
"PrivateKey": self.private_key,
|
||||
"SecurityStamp": self.security_stamp,
|
||||
"Organizations": [],
|
||||
"Object": "profile"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
use diesel;
|
||||
use diesel::prelude::*;
|
||||
use db::DbConn;
|
||||
use db::schema::users;
|
||||
|
||||
/// Database methods
|
||||
impl User {
|
||||
pub fn save(&self, conn: &DbConn) -> bool {
|
||||
// TODO: Update modified date
|
||||
|
||||
match diesel::replace_into(users::table) // Insert or update
|
||||
.values(self)
|
||||
.execute(&**conn) {
|
||||
Ok(1) => true, // One row inserted
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_by_mail(mail: &str, conn: &DbConn) -> Option<User> {
|
||||
let lower_mail = mail.to_lowercase();
|
||||
users::table
|
||||
.filter(users::email.eq(lower_mail))
|
||||
.first::<User>(&**conn).ok()
|
||||
}
|
||||
|
||||
pub fn find_by_uuid(uuid: &str, conn: &DbConn) -> Option<User> {
|
||||
users::table
|
||||
.filter(users::uuid.eq(uuid))
|
||||
.first::<User>(&**conn).ok()
|
||||
}
|
||||
}
|
71
src/db/schema.rs
Normal file
71
src/db/schema.rs
Normal file
|
@ -0,0 +1,71 @@
|
|||
table! {
|
||||
ciphers (uuid) {
|
||||
uuid -> Text,
|
||||
created_at -> Timestamp,
|
||||
updated_at -> Timestamp,
|
||||
user_uuid -> Text,
|
||||
folder_uuid -> Nullable<Text>,
|
||||
organization_uuid -> Nullable<Text>,
|
||||
#[sql_name = "type"]
|
||||
type_ -> Integer,
|
||||
data -> Text,
|
||||
favorite -> Bool,
|
||||
attachments -> Nullable<Binary>,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
devices (uuid) {
|
||||
uuid -> Text,
|
||||
created_at -> Timestamp,
|
||||
updated_at -> Timestamp,
|
||||
user_uuid -> Text,
|
||||
name -> Text,
|
||||
#[sql_name = "type"]
|
||||
type_ -> Integer,
|
||||
push_token -> Nullable<Text>,
|
||||
refresh_token -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
folders (uuid) {
|
||||
uuid -> Text,
|
||||
created_at -> Timestamp,
|
||||
updated_at -> Timestamp,
|
||||
user_uuid -> Text,
|
||||
name -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
table! {
|
||||
users (uuid) {
|
||||
uuid -> Text,
|
||||
created_at -> Timestamp,
|
||||
updated_at -> Timestamp,
|
||||
email -> Text,
|
||||
name -> Text,
|
||||
password_hash -> Binary,
|
||||
salt -> Binary,
|
||||
password_iterations -> Integer,
|
||||
password_hint -> Nullable<Text>,
|
||||
key -> Text,
|
||||
private_key -> Nullable<Text>,
|
||||
public_key -> Nullable<Text>,
|
||||
totp_secret -> Nullable<Text>,
|
||||
totp_recover -> Nullable<Text>,
|
||||
security_stamp -> Text,
|
||||
}
|
||||
}
|
||||
|
||||
joinable!(ciphers -> folders (folder_uuid));
|
||||
joinable!(ciphers -> users (user_uuid));
|
||||
joinable!(devices -> users (user_uuid));
|
||||
joinable!(folders -> users (user_uuid));
|
||||
|
||||
allow_tables_to_appear_in_same_query!(
|
||||
ciphers,
|
||||
devices,
|
||||
folders,
|
||||
users,
|
||||
);
|
151
src/main.rs
Normal file
151
src/main.rs
Normal file
|
@ -0,0 +1,151 @@
|
|||
#![allow(dead_code, unused_variables, unused, unused_mut)]
|
||||
|
||||
#![feature(plugin, custom_derive)]
|
||||
#![cfg_attr(test, plugin(stainless))]
|
||||
#![plugin(rocket_codegen)]
|
||||
extern crate rocket;
|
||||
#[macro_use]
|
||||
extern crate rocket_contrib;
|
||||
extern crate reqwest;
|
||||
extern crate multipart;
|
||||
extern crate serde;
|
||||
#[macro_use]
|
||||
extern crate serde_derive;
|
||||
#[macro_use]
|
||||
extern crate serde_json;
|
||||
#[macro_use]
|
||||
extern crate diesel;
|
||||
#[macro_use]
|
||||
extern crate diesel_migrations;
|
||||
extern crate r2d2_diesel;
|
||||
extern crate r2d2;
|
||||
extern crate ring;
|
||||
extern crate uuid;
|
||||
extern crate chrono;
|
||||
extern crate time;
|
||||
extern crate oath;
|
||||
extern crate data_encoding;
|
||||
extern crate jsonwebtoken as jwt;
|
||||
extern crate dotenv;
|
||||
#[macro_use]
|
||||
extern crate lazy_static;
|
||||
|
||||
|
||||
use std::{io, env};
|
||||
|
||||
use rocket::{Data, Request, Rocket};
|
||||
use rocket::fairing::{Fairing, Info, Kind};
|
||||
|
||||
#[macro_use]
|
||||
mod util;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
mod api;
|
||||
mod db;
|
||||
mod crypto;
|
||||
mod auth;
|
||||
|
||||
fn init_rocket() -> Rocket {
|
||||
rocket::ignite()
|
||||
.mount("/", api::web_routes())
|
||||
.mount("/api", api::core_routes())
|
||||
.mount("/identity", api::identity_routes())
|
||||
.mount("/icons", api::icons_routes())
|
||||
.manage(db::init_pool())
|
||||
.attach(DebugFairing)
|
||||
}
|
||||
|
||||
// Embed the migrations from the migrations folder into the application
|
||||
// This way, the program automatically migrates the database to the latest version
|
||||
// https://docs.rs/diesel_migrations/*/diesel_migrations/macro.embed_migrations.html
|
||||
embed_migrations!();
|
||||
|
||||
fn main() {
|
||||
println!("{:#?}", *CONFIG);
|
||||
|
||||
// Make sure the database is up to date (create if it doesn't exist, or run the migrations)
|
||||
let connection = db::get_connection().expect("Can't conect to DB");
|
||||
embedded_migrations::run_with_output(&connection, &mut io::stdout());
|
||||
|
||||
// Validate location of rsa keys
|
||||
if !util::file_exists(&CONFIG.private_rsa_key) {
|
||||
panic!("private_rsa_key doesn't exist");
|
||||
}
|
||||
if !util::file_exists(&CONFIG.public_rsa_key) {
|
||||
panic!("public_rsa_key doesn't exist");
|
||||
}
|
||||
|
||||
init_rocket().launch();
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
// Load the config from .env or from environment variables
|
||||
static ref CONFIG: Config = Config::load();
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
database_url: String,
|
||||
private_rsa_key: String,
|
||||
public_rsa_key: String,
|
||||
icon_cache_folder: String,
|
||||
attachments_folder: String,
|
||||
web_vault_folder: String,
|
||||
|
||||
signups_allowed: bool,
|
||||
password_iterations: i32,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
fn load() -> Self {
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
Config {
|
||||
database_url: env::var("DATABASE_URL").unwrap_or("data/db.sqlite3".into()),
|
||||
private_rsa_key: env::var("PRIVATE_RSA_KEY").unwrap_or("data/private_rsa_key.der".into()),
|
||||
public_rsa_key: env::var("PUBLIC_RSA_KEY").unwrap_or("data/public_rsa_key.der".into()),
|
||||
icon_cache_folder: env::var("ICON_CACHE_FOLDER").unwrap_or("data/icon_cache".into()),
|
||||
attachments_folder: env::var("ATTACHMENTS_FOLDER").unwrap_or("data/attachments".into()),
|
||||
web_vault_folder: env::var("WEB_VAULT_FOLDER").unwrap_or("web-vault/".into()),
|
||||
|
||||
signups_allowed: util::parse_option_string(env::var("SIGNUPS_ALLOWED").ok()).unwrap_or(false),
|
||||
password_iterations: util::parse_option_string(env::var("PASSWORD_ITERATIONS").ok()).unwrap_or(100_000),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DebugFairing;
|
||||
|
||||
impl Fairing for DebugFairing {
|
||||
fn info(&self) -> Info {
|
||||
Info {
|
||||
name: "Request Debugger",
|
||||
kind: Kind::Request,
|
||||
}
|
||||
}
|
||||
|
||||
fn on_request(&self, req: &mut Request, data: &Data) {
|
||||
let uri_string = req.uri().to_string();
|
||||
|
||||
// Ignore web requests
|
||||
if !uri_string.starts_with("/api") &&
|
||||
!uri_string.starts_with("/identity") {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
for header in req.headers().iter() {
|
||||
println!("DEBUG- {:#?} {:#?}", header.name(), header.value());
|
||||
}
|
||||
*/
|
||||
|
||||
/*let body_data = data.peek();
|
||||
|
||||
if body_data.len() > 0 {
|
||||
println!("DEBUG- Body Complete: {}", data.peek_complete());
|
||||
println!("DEBUG- {}", String::from_utf8_lossy(body_data));
|
||||
}*/
|
||||
}
|
||||
}
|
49
src/tests.rs
Normal file
49
src/tests.rs
Normal file
|
@ -0,0 +1,49 @@
|
|||
use super::init_rocket;
|
||||
use rocket::local::Client;
|
||||
use rocket::http::Status;
|
||||
|
||||
#[test]
|
||||
fn hello_world() {
|
||||
let client = Client::new(init_rocket()).expect("valid rocket instance");
|
||||
let mut response = client.get("/alive").dispatch();
|
||||
assert_eq!(response.status(), Status::Ok);
|
||||
// assert_eq!(response.body_string(), Some("Hello, world!".into()));
|
||||
}
|
||||
|
||||
// TODO: For testing, we can use either a test_transaction, or an in-memory database
|
||||
|
||||
// TODO: test_transaction http://docs.diesel.rs/diesel/connection/trait.Connection.html#method.begin_test_transaction
|
||||
|
||||
// TODO: in-memory database https://github.com/diesel-rs/diesel/issues/419 (basically use ":memory:" as the connection string
|
||||
|
||||
describe! route_tests {
|
||||
before_each {
|
||||
let rocket = init_rocket();
|
||||
let client = Client::new(rocket).expect("valid rocket instance");
|
||||
}
|
||||
|
||||
describe! alive {
|
||||
before_each {
|
||||
let mut res = client.get("/alive").dispatch();
|
||||
let body_str = res.body().and_then(|b| b.into_string()).unwrap();
|
||||
}
|
||||
|
||||
it "responds with status OK 200" {
|
||||
assert_eq!(res.status(), Status::Ok);
|
||||
}
|
||||
|
||||
it "responds with current year" {
|
||||
assert!(body_str.contains("2018"));
|
||||
}
|
||||
}
|
||||
|
||||
describe! nested_example {
|
||||
ignore "this is ignored" {
|
||||
assert_eq!(1, 2);
|
||||
}
|
||||
|
||||
failing "this fails" {
|
||||
assert_eq!(1, 2);
|
||||
}
|
||||
}
|
||||
}
|
84
src/util.rs
Normal file
84
src/util.rs
Normal file
|
@ -0,0 +1,84 @@
|
|||
///
|
||||
/// Macros
|
||||
///
|
||||
#[macro_export]
|
||||
macro_rules! err {
|
||||
($expr:expr) => {{
|
||||
err_json!(json!($expr));
|
||||
}}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! err_json {
|
||||
($expr:expr) => {{
|
||||
println!("ERROR: {}", $expr);
|
||||
return Err($crate::rocket::response::status::BadRequest(Some($crate::rocket_contrib::Json($expr))));
|
||||
}}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! err_handler {
|
||||
($expr:expr) => {{
|
||||
println!("ERROR: {}", $expr);
|
||||
return $crate::rocket::Outcome::Failure(($crate::rocket::http::Status::Unauthorized, $expr));
|
||||
}}
|
||||
}
|
||||
|
||||
///
|
||||
/// File handling
|
||||
///
|
||||
|
||||
use std::path::Path;
|
||||
use std::io::Read;
|
||||
use std::fs::File;
|
||||
|
||||
pub fn file_exists(path: &str) -> bool {
|
||||
Path::new(path).exists()
|
||||
}
|
||||
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
let mut file = File::open(Path::new(path))
|
||||
.map_err(|e| format!("Error opening file: {}", e))?;
|
||||
|
||||
let mut contents: Vec<u8> = Vec::new();
|
||||
|
||||
file.read_to_end(&mut contents)
|
||||
.map_err(|e| format!("Error reading file: {}", e))?;
|
||||
|
||||
Ok(contents)
|
||||
}
|
||||
|
||||
|
||||
///
|
||||
/// String util methods
|
||||
///
|
||||
|
||||
use std::str::FromStr;
|
||||
|
||||
pub fn upcase_first(s: &str) -> String {
|
||||
let mut c = s.chars();
|
||||
match c.next() {
|
||||
None => String::new(),
|
||||
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_option_string<S, T>(string: Option<S>) -> Option<T> where S: Into<String>, T: FromStr {
|
||||
if let Some(Ok(value)) = string.map(|s| s.into().parse::<T>()) {
|
||||
Some(value)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
///
|
||||
/// Date util methods
|
||||
///
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
const DATETIME_FORMAT: &'static str = "%Y-%m-%dT%H:%M:%S%.6fZ";
|
||||
|
||||
pub fn format_date(date: &NaiveDateTime) -> String {
|
||||
date.format(DATETIME_FORMAT).to_string()
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue