From 6c9ea1f774ab760b469e4756246275da93d5009c Mon Sep 17 00:00:00 2001 From: Joe Bellus Date: Sat, 5 Feb 2022 01:11:24 -0500 Subject: [PATCH] Authorization Added authorization system. All app endpoints are now bearer token verified. Added endpoints for initial setup, authorization, and updating passwords. --- Cargo.lock | 173 ++++++++++++++++-- Cargo.toml | 9 +- .../20220203034730_create-apps-table.sql | 4 + src/api/application_categories.rs | 17 +- src/api/applications.rs | 28 +-- src/api/authorization.rs | 137 ++++++++++++++ src/api/bookmark_categories.rs | 12 +- src/api/bookmarks.rs | 10 +- src/api/mod.rs | 57 +++++- src/auth.rs | 146 +++++++++++++++ src/entity/application.rs | 4 +- src/entity/bookmark.rs | 1 + src/entity/bookmark_category.rs | 1 + src/entity/mod.rs | 4 +- src/entity/setting.rs | 24 +++ src/error.rs | 13 ++ src/main.rs | 21 +-- 17 files changed, 590 insertions(+), 71 deletions(-) create mode 100644 src/api/authorization.rs create mode 100644 src/auth.rs create mode 100644 src/entity/setting.rs diff --git a/Cargo.lock b/Cargo.lock index 8ee65dd..48e07a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -424,9 +424,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.0.0-rc.1" +version = "4.0.0-rc.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7bfa913583b6cfe5f0d1588e752bcf6b107556422624fefdaf99c40ba2b7f16" +checksum = "73170d019de2d82c0d826c1f315c3106134bd764e9247505ba6f0d78d22dfe9e" dependencies = [ "actix-codec 0.4.2", "actix-http 3.0.0-rc.1", @@ -484,6 +484,21 @@ dependencies = [ "syn", ] +[[package]] +name = "actix-web-httpauth" +version = "0.6.0-beta.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95ff332baee0f5ce55d94c690acb33a390d04a458d4bbd40a593cf847ac74ba4" +dependencies = [ + "actix-service 2.0.2", + "actix-utils 3.0.0", + "actix-web 4.0.0-rc.2", + "base64", + "futures-core", + "futures-util", + "pin-project-lite 0.2.8", +] + [[package]] name = "actix_derive" version = "0.6.0" @@ -796,6 +811,17 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" +[[package]] +name = "bcrypt" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f691e63585950d8c1c43644d11bab9073e40f5060dd2822734ae7c3dc69a3a80" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.2.4", +] + [[package]] name = "bincode" version = "1.3.3" @@ -861,6 +887,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "blowfish" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3ff3fc1de48c1ac2e3341c4df38b0d1bfb8fdf04632a187c8b75aaa319a7ab" +dependencies = [ + "byteorder", + "cipher", + "opaque-debug", +] + [[package]] name = "brotli" version = "3.3.3" @@ -1032,6 +1069,15 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "cipher" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee52072ec15386f770805afd189a01c8841be8696bed250fa2f13c4c0d6dfb7" +dependencies = [ + "generic-array", +] + [[package]] name = "clap" version = "2.34.0" @@ -1472,7 +1518,7 @@ dependencies = [ "futures-core", "futures-sink", "pin-project 1.0.10", - "spin", + "spin 0.9.2", ] [[package]] @@ -2032,6 +2078,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "8.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "012bb02250fdd38faa5feee63235f7a459974440b9b57593822414c31f92839e" +dependencies = [ + "base64", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "kernel32-sys" version = "0.2.2" @@ -2337,6 +2397,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93ab6289c7b344a8a9f60f88d80aa20032336fe78da341afc91c8a2341fc75f" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-integer" version = "0.1.44" @@ -2519,6 +2590,15 @@ dependencies = [ "syn", ] +[[package]] +name = "pem" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9a3b09a20e374558580a4914d3b7d89bd61b954a5a5e1dcbea98753addb1947" +dependencies = [ + "base64", +] + [[package]] name = "percent-encoding" version = "2.1.0" @@ -2750,6 +2830,15 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quickcheck" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" +dependencies = [ + "rand 0.8.4", +] + [[package]] name = "quote" version = "1.0.15" @@ -2900,6 +2989,21 @@ dependencies = [ "quick-error", ] +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted", + "web-sys", + "winapi 0.3.9", +] + [[package]] name = "rusb" version = "0.8.1" @@ -3267,6 +3371,18 @@ dependencies = [ "libc", ] +[[package]] +name = "simple_asn1" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a762b1c38b9b990c694b9c2f8abe3372ce6a9ceaae6bca39cfc46e054f45745" +dependencies = [ + "num-bigint 0.4.3", + "num-traits", + "thiserror", + "time 0.3.7", +] + [[package]] name = "simplelog" version = "0.11.2" @@ -3317,6 +3433,12 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "spin" version = "0.9.2" @@ -3378,7 +3500,7 @@ dependencies = [ "libsqlite3-sys", "log", "memchr", - "num-bigint", + "num-bigint 0.3.3", "once_cell", "parking_lot", "percent-encoding", @@ -3693,6 +3815,7 @@ dependencies = [ "itoa 1.0.1", "libc", "num_threads", + "quickcheck", "time-macros 0.2.3", ] @@ -3838,9 +3961,9 @@ dependencies = [ [[package]] name = "tracing" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375a639232caf30edfc78e8d89b2d4c375515393e7af7e16f01cd96917fb2105" +checksum = "2d8d93354fe2a8e50d5953f5ae2e47a3fc2ef03292e7ea46e3cc38f549525fb9" dependencies = [ "cfg-if 1.0.0", "log", @@ -3851,11 +3974,11 @@ dependencies = [ [[package]] name = "tracing-actix-web" -version = "0.5.0-rc.1" +version = "0.5.0-rc.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0066b8102bdd05325d02966fac8b5e360eafd8dcdf81006c4d30f3543f488714" +checksum = "eba9ba7b978b6fd2838d47cd459cd4e23451bdd571448509e71476531b9306f2" dependencies = [ - "actix-web 4.0.0-rc.1", + "actix-web 4.0.0-rc.2", "pin-project 1.0.10", "tracing", "tracing-futures", @@ -3864,9 +3987,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4f480b8f81512e825f337ad51e94c1eb5d3bbdf2b363dcd01e2b19a9ffe3f8e" +checksum = "8276d9a4a3a558d7b7ad5303ad50b53d58264641b82914b7ada36bd762e7a716" dependencies = [ "proc-macro2", "quote", @@ -3875,11 +3998,12 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.21" +version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f4ed65637b8390770814083d20756f87bfa2c21bf2f110babdc5438351746e4" +checksum = "03cfcb51380632a72d3111cb8d3447a8d908e577d31beeac006f836383d29a23" dependencies = [ "lazy_static", + "valuable", ] [[package]] @@ -3905,9 +4029,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5312f325fe3588e277415f5a6cca1f4ccad0f248c4cd5a4bd33032d7286abc22" +checksum = "74786ce43333fcf51efe947aed9718fbe46d5c7328ec3f1029e818083966d9aa" dependencies = [ "ansi_term", "lazy_static", @@ -4068,6 +4192,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "url" version = "2.2.2" @@ -4097,9 +4227,14 @@ dependencies = [ "actix", "actix-cors", "actix-rt 2.6.0", - "actix-web 4.0.0-rc.1", + "actix-web 4.0.0-rc.2", + "actix-web-httpauth", + "base64", + "bcrypt", "cargo-embed", "chrono", + "jsonwebtoken", + "rand 0.8.4", "sea-orm", "serde", "serde_json", @@ -4110,6 +4245,12 @@ dependencies = [ "tracing-unwrap", ] +[[package]] +name = "valuable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b7e5d4d90034032940e4ace0d9a9a057e7a45cd94e6c007832e39edb82f6d" + [[package]] name = "value-bag" version = "1.0.0-alpha.8" diff --git a/Cargo.toml b/Cargo.toml index eadc801..e11a978 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,9 +7,9 @@ edition = "2021" [dependencies] sea-orm = { version = "0.5.0", features = [ "sqlx-sqlite", "runtime-actix-native-tls", "macros", "mock"], default-features = false } -tracing = "0.1.29" +tracing = "0.1.30" tracing-unwrap = "0.9.2" -tracing-subscriber = { version = "0.3.7", features=["fmt"] } +tracing-subscriber = { version = "0.3.8", features = ["fmt"] } actix = "0.12.0" actix-cors = "0.5.4" chrono = { version = "0.4.19", features = ["serde"] } @@ -20,3 +20,8 @@ actix-rt = "2.6.0" tracing-test = "0.2.1" tracing-actix-web = "0.5.0-rc.1" cargo-embed = "0.12.0" +bcrypt = "0.10.1" +actix-web-httpauth = "0.6.0-beta.7" +jsonwebtoken = "8.0.1" +rand = "0.8.4" +base64 = "0.13.0" diff --git a/migrations/20220203034730_create-apps-table.sql b/migrations/20220203034730_create-apps-table.sql index f8b3ae7..dbd9094 100644 --- a/migrations/20220203034730_create-apps-table.sql +++ b/migrations/20220203034730_create-apps-table.sql @@ -26,3 +26,7 @@ CREATE TABLE bookmark_category ( active BOOLEAN NOT NULL DEFAULT 1, glyph TEXT ); +CREATE TABLE settings ( + setting_name TEXT NOT NULL, + setting_value TEXT NOT NULL +); diff --git a/src/api/application_categories.rs b/src/api/application_categories.rs index 37830f3..4d3487d 100644 --- a/src/api/application_categories.rs +++ b/src/api/application_categories.rs @@ -135,7 +135,7 @@ mod tests { .insert(&state.db) .await?; - let req = actix_web::test::TestRequest::with_uri("/application_categories") + let mut req = actix_web::test::TestRequest::with_uri("/application_categories") .method(Method::GET) .to_request(); let resp = call_endpoint!(req, state); @@ -159,7 +159,7 @@ mod tests { .insert(&state.db) .await?; - let req = actix_web::test::TestRequest::with_uri(&format!( + let mut req = actix_web::test::TestRequest::with_uri(&format!( "/application_categories/{}", model.id )) @@ -185,7 +185,7 @@ mod tests { let state = setup_state().await?; - let req = actix_web::test::TestRequest::with_uri("/application_categories") + let mut req = actix_web::test::TestRequest::with_uri("/application_categories") .method(Method::POST) .set_json(model.clone()) .to_request(); @@ -224,7 +224,7 @@ mod tests { model.category_name = "Another name".into(); - let req = actix_web::test::TestRequest::with_uri(&format!( + let mut req = actix_web::test::TestRequest::with_uri(&format!( "/application_categories/{}", model.id )) @@ -258,14 +258,17 @@ mod tests { .insert(&state.db) .await?; - let req = actix_web::test::TestRequest::with_uri(&format!( + let mut req = actix_web::test::TestRequest::with_uri(&format!( "/application_categories/{}", model.id )) .method(Method::DELETE) .to_request(); let resp = call_endpoint!(req, state); - assert_eq!(resp.status(), 200); + let status = resp.status(); + let body = test::read_body(resp).await; + assert_eq!(body, ""); + assert_eq!(status, 200); assert_eq!( application_category::Entity::find() .count(&state.db) @@ -312,7 +315,7 @@ mod tests { .insert(&state.db) .await?; - let req = actix_web::test::TestRequest::with_uri(&format!( + let mut req = actix_web::test::TestRequest::with_uri(&format!( "/application_categories/{}/applications", category.id )) diff --git a/src/api/applications.rs b/src/api/applications.rs index d43904e..98d44d0 100644 --- a/src/api/applications.rs +++ b/src/api/applications.rs @@ -123,7 +123,7 @@ mod tests { .insert(&state.db) .await?; - let req = actix_web::test::TestRequest::with_uri("/applications") + let mut req = actix_web::test::TestRequest::with_uri("/applications") .method(Method::GET) .to_request(); let resp = call_endpoint!(req, state); @@ -145,9 +145,10 @@ mod tests { .insert(&state.db) .await?; - let req = actix_web::test::TestRequest::with_uri(&format!("/applications/{}", model.id)) - .method(Method::GET) - .to_request(); + let mut req = + actix_web::test::TestRequest::with_uri(&format!("/applications/{}", model.id)) + .method(Method::GET) + .to_request(); let resp = call_endpoint!(req, state); let status = resp.status(); let mut data = get_response!(resp, crate::entity::application::Model); @@ -171,7 +172,7 @@ mod tests { let state = setup_state().await?; - let req = actix_web::test::TestRequest::with_uri("/applications") + let mut req = actix_web::test::TestRequest::with_uri("/applications") .method(Method::POST) .set_json(model.clone()) .to_request(); @@ -207,10 +208,12 @@ mod tests { model.url = "http://updated.com".into(); - let req = actix_web::test::TestRequest::with_uri(&format!("/applications/{}", model.id)) - .method(Method::PUT) - .set_json(model.clone()) - .to_request(); + let mut req = + actix_web::test::TestRequest::with_uri(&format!("/applications/{}", model.id)) + .method(Method::PUT) + .set_json(model.clone()) + .to_request(); + let resp = call_endpoint!(req, state); assert_eq!(resp.status(), 200); let mut data = get_response!(resp, crate::entity::application::Model); @@ -239,9 +242,10 @@ mod tests { .insert(&state.db) .await?; - let req = actix_web::test::TestRequest::with_uri(&format!("/applications/{}", model.id)) - .method(Method::DELETE) - .to_request(); + let mut req = + actix_web::test::TestRequest::with_uri(&format!("/applications/{}", model.id)) + .method(Method::DELETE) + .to_request(); let resp = call_endpoint!(req, state); assert_eq!(resp.status(), 200); assert_eq!(application::Entity::find().count(&state.db).await?, 0); diff --git a/src/api/authorization.rs b/src/api/authorization.rs new file mode 100644 index 0000000..0af31f5 --- /dev/null +++ b/src/api/authorization.rs @@ -0,0 +1,137 @@ +use std::ops::Add; + +use jsonwebtoken::{encode, EncodingKey, Header}; +use tracing::instrument; + +use crate::api::api_prelude::*; +use crate::auth::{get_secret, set_password, verify_password}; +use crate::error::{Error, Result}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthRequest { + password: String, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthResponse { + token: String, +} + +#[instrument] +#[post("authorize")] +pub async fn authorize( + state: web::Data, + req: web::Json, +) -> Result { + if verify_password(&state.db, &req.password).await { + let secret = get_secret(&state.db).await?; + let exp = chrono::Utc::now() + .add(chrono::Duration::days(30)) + .timestamp() as usize; + let token = encode( + &Header::default(), + &crate::auth::AuthClaims { exp }, + &EncodingKey::from_secret(&secret), + ) + .map_err(|_| Error::unauthorized())?; + Ok(HttpResponse::Ok().json(AuthResponse { token })) + } else { + Err(Error::unauthorized()) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdatePasswordRequest { + password: String, +} + +#[instrument] +#[post("password")] +pub async fn update_password( + state: web::Data, + req: web::Json, +) -> Result { + set_password(&state.db, &req.password).await?; + Ok(HttpResponse::Ok().body("")) +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetupRequest { + password: String, +} + +#[instrument] +#[post("setup")] +pub async fn initial_setup( + state: web::Data, + req: web::Json, +) -> Result { + let count = setting::Entity::find() + .filter(setting::Column::SettingName.eq("password_hash".to_string())) + .count(&state.db) + .await?; + if count == 0 { + set_password(&state.db, &req.password).await?; + Ok(HttpResponse::Ok().body("")) + } else { + Err(Error::new( + crate::error::ErrorCode::UnAuthorized, + "Setup has already been run", + )) + } +} + +#[cfg(test)] +mod tests { + + use crate::{ + api::test_prelude::*, + auth::{get_secret, AuthClaims}, + }; + use actix_web::http::Method; + + #[actix_rt::test] + async fn test_authorize() -> Result<()> { + let state = setup_state().await?; + + let test_password = "sshh a secret".to_string(); + let test_hash = bcrypt::hash(test_password.clone(), bcrypt::DEFAULT_COST).unwrap(); + + setting::ActiveModel { + id: NotSet, + setting_name: Set("password_hash".into()), + setting_value: Set(test_hash), + } + .insert(&state.db) + .await?; + + let mut req = actix_web::test::TestRequest::with_uri("/authorize") + .method(Method::POST) + .set_json(super::AuthRequest { + password: test_password, + }) + .to_request(); + let resp = call_endpoint!(req, state); + + let jwt_secret = get_secret(&state.db).await?; + + let status = resp.status(); + assert_eq!(status, 200); + let data = get_response!(resp, super::AuthResponse); + assert_eq!(status, 200); + + let decoded = jsonwebtoken::decode::( + &data.token, + &jsonwebtoken::DecodingKey::from_secret(&jwt_secret), + &jsonwebtoken::Validation::default(), + ); + + decoded.expect("Decode failure"); + + Ok(()) + } +} diff --git a/src/api/bookmark_categories.rs b/src/api/bookmark_categories.rs index fd5dbc1..e566b70 100644 --- a/src/api/bookmark_categories.rs +++ b/src/api/bookmark_categories.rs @@ -135,7 +135,7 @@ mod tests { .insert(&state.db) .await?; - let req = actix_web::test::TestRequest::with_uri("/bookmark_categories") + let mut req = actix_web::test::TestRequest::with_uri("/bookmark_categories") .method(Method::GET) .to_request(); let resp = call_endpoint!(req, state); @@ -156,7 +156,7 @@ mod tests { .insert(&state.db) .await?; - let req = + let mut req = actix_web::test::TestRequest::with_uri(&format!("/bookmark_categories/{}", model.id)) .method(Method::GET) .to_request(); @@ -180,7 +180,7 @@ mod tests { let state = setup_state().await?; - let req = actix_web::test::TestRequest::with_uri("/bookmark_categories") + let mut req = actix_web::test::TestRequest::with_uri("/bookmark_categories") .method(Method::POST) .set_json(model.clone()) .to_request(); @@ -214,7 +214,7 @@ mod tests { model.category_name = "Another name".into(); - let req = + let mut req = actix_web::test::TestRequest::with_uri(&format!("/bookmark_categories/{}", model.id)) .method(Method::PUT) .set_json(model.clone()) @@ -246,7 +246,7 @@ mod tests { .insert(&state.db) .await?; - let req = + let mut req = actix_web::test::TestRequest::with_uri(&format!("/bookmark_categories/{}", model.id)) .method(Method::DELETE) .to_request(); @@ -293,7 +293,7 @@ mod tests { .insert(&state.db) .await?; - let req = actix_web::test::TestRequest::with_uri(&format!( + let mut req = actix_web::test::TestRequest::with_uri(&format!( "/bookmark_categories/{}/bookmarks", category.id )) diff --git a/src/api/bookmarks.rs b/src/api/bookmarks.rs index 801b485..64f0800 100644 --- a/src/api/bookmarks.rs +++ b/src/api/bookmarks.rs @@ -116,7 +116,7 @@ mod tests { .insert(&state.db) .await?; - let req = actix_web::test::TestRequest::with_uri("/bookmarks") + let mut req = actix_web::test::TestRequest::with_uri("/bookmarks") .method(Method::GET) .to_request(); let resp = call_endpoint!(req, state); @@ -138,7 +138,7 @@ mod tests { .insert(&state.db) .await?; - let req = actix_web::test::TestRequest::with_uri(&format!("/bookmarks/{}", model.id)) + let mut req = actix_web::test::TestRequest::with_uri(&format!("/bookmarks/{}", model.id)) .method(Method::GET) .to_request(); let resp = call_endpoint!(req, state); @@ -162,7 +162,7 @@ mod tests { let state = setup_state().await?; - let req = actix_web::test::TestRequest::with_uri("/bookmarks") + let mut req = actix_web::test::TestRequest::with_uri("/bookmarks") .method(Method::POST) .set_json(model.clone()) .to_request(); @@ -198,7 +198,7 @@ mod tests { model.url = "http://updated.com".into(); - let req = actix_web::test::TestRequest::with_uri(&format!("/bookmarks/{}", model.id)) + let mut req = actix_web::test::TestRequest::with_uri(&format!("/bookmarks/{}", model.id)) .method(Method::PUT) .set_json(model.clone()) .to_request(); @@ -230,7 +230,7 @@ mod tests { .insert(&state.db) .await?; - let req = actix_web::test::TestRequest::with_uri(&format!("/bookmarks/{}", model.id)) + let mut req = actix_web::test::TestRequest::with_uri(&format!("/bookmarks/{}", model.id)) .method(Method::DELETE) .to_request(); let resp = call_endpoint!(req, state); diff --git a/src/api/mod.rs b/src/api/mod.rs index 9d23104..68d8f3f 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,5 +1,9 @@ +use actix_web::Scope; +use actix_web_httpauth::middleware::HttpAuthentication; use serde::{Deserialize, Serialize}; +use crate::{api, auth}; + #[macro_export] #[cfg(test)] macro_rules! call_endpoint { @@ -17,13 +21,30 @@ macro_rules! call_endpoint { // tracing::subscriber::set_global_default(subscriber) // .expect("Unable to set a global collector"); + let jwt_secret = crate::auth::get_secret(&$state.db).await?; + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &crate::auth::AuthClaims { + exp: 10_000_000_000, + }, + &jsonwebtoken::EncodingKey::from_secret(&jwt_secret), + ) + .map_err(|e| { + crate::error::Error::new( + crate::error::ErrorCode::Internal, + &format!("Token error: {} ", e), + ) + })?; + + $req.headers_mut().append( + actix_web::http::header::AUTHORIZATION, + actix_web::http::header::HeaderValue::from_str(&format!("Bearer {}", token)).unwrap(), + ); + let a = App::new() .wrap(tracing_actix_web::TracingLogger::default()) .app_data($state.clone()) - .service(crate::api::applications::routes()) - .service(crate::api::application_categories::routes()) - .service(crate::api::bookmarks::routes()) - .service(crate::api::bookmark_categories::routes()); + .service(routes()); let app = actix_web::test::init_service(a).await; let resp = actix_web::test::call_service(&app, $req).await; resp @@ -40,6 +61,7 @@ macro_rules! get_response { pub mod application_categories; pub mod applications; +pub mod authorization; pub mod bookmark_categories; pub mod bookmarks; @@ -51,14 +73,17 @@ mod api_prelude { pub use actix_web::{delete, get, post, put, web, Error, HttpResponse, Scope}; pub use sea_orm::prelude::*; pub use sea_orm::{NotSet, Set}; + pub use serde::{Deserialize, Serialize}; } #[cfg(test)] -mod test_prelude { +pub mod test_prelude { pub use super::ListObjects; + use crate::auth; pub use crate::entity::*; pub use crate::AppState; + pub use super::routes; pub use crate::error::Result; pub use actix_web::dev::ServiceResponse; pub use actix_web::{test, web, App}; @@ -87,6 +112,12 @@ mod test_prelude { let stmt: TableCreateStatement = schema.create_table_from_entity(bookmark_category::Entity); db.execute(db.get_database_backend().build(&stmt)).await?; + + let stmt: TableCreateStatement = schema.create_table_from_entity(setting::Entity); + db.execute(db.get_database_backend().build(&stmt)).await?; + + auth::generate_secret(&db).await?; + Ok(actix_web::web::Data::new(AppState { db })) } } @@ -106,3 +137,19 @@ impl ListObjects { Self { items, total } } } + +pub fn routes() -> Scope { + let auth_handler = HttpAuthentication::bearer(auth::validator); + let protected_routes = Scope::new("") + .wrap(auth_handler) + .service(api::applications::routes()) + .service(api::application_categories::routes()) + .service(api::bookmarks::routes()) + .service(api::bookmark_categories::routes()) + .service(api::authorization::update_password); + + Scope::new("") + .service(api::authorization::authorize) + .service(api::authorization::initial_setup) + .service(protected_routes) +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..d22e197 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,146 @@ +use crate::{ + entity::setting, + error::{Error, ErrorCode}, + AppState, +}; +use actix_web::{dev::ServiceRequest, web::Data}; +use actix_web_httpauth::extractors::bearer::BearerAuth; +use bcrypt::verify; +use jsonwebtoken::{decode, DecodingKey, Validation}; +use sea_orm::{prelude::*, ActiveValue::NotSet, Set}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct AuthClaims { + pub exp: usize, +} + +/// JWT validation middleware. Used to wrap protected routes in Actix . +pub async fn validator( + req: ServiceRequest, + credentials: BearerAuth, +) -> Result { + // Retrieve shared app state + let state = req + .app_data::>() + .ok_or_else(|| Error::new(ErrorCode::Internal, "Could not retrieve state"))?; + // Get the secret from the database + let secret = get_secret(&state.db).await?; + // decode the JWT token from the header + let decoded = decode::( + credentials.token(), + &DecodingKey::from_secret(&secret), + &Validation::default(), + ); + // We have no users or anything else to do, if the token is properly formed we are good to go. + match decoded { + Ok(_) => Ok(req), + Err(e) => Err(Error::new(ErrorCode::UnAuthorized, &e.to_string()).into()), + } +} + +/// Verifies the passed password against the password hash stored in the settings table in the database. +pub async fn verify_password(db: &DatabaseConnection, password: &str) -> bool { + let rec: Option = crate::entity::setting::Entity::find() + .filter(crate::entity::setting::Column::SettingName.eq("password_hash".to_string())) + .one(db) + .await + .unwrap_or_default(); + if let Some(rec) = rec { + verify(password, &rec.setting_value).unwrap_or(false) + } else { + false + } +} + +pub async fn set_password(db: &DatabaseConnection, password: &str) -> crate::error::Result<()> { + let password_hash = bcrypt::hash(password, bcrypt::DEFAULT_COST) + .map_err(|e| Error::new(ErrorCode::Internal, &format!("Hash error: {}", e)))?; + let rec = crate::entity::setting::Entity::find() + .filter(crate::entity::setting::Column::SettingName.eq("password_hash".to_string())) + .one(db) + .await?; + + if let Some(rec) = rec { + setting::ActiveModel { + id: Set(rec.id), + setting_value: Set(password_hash), + setting_name: NotSet, + } + .update(db) + .await?; + } else { + setting::ActiveModel { + id: NotSet, + setting_name: Set("password_hash".into()), + setting_value: Set(password_hash), + } + .insert(db) + .await?; + } + Ok(()) +} + +use rand::Rng; +use tracing::instrument; +#[instrument(skip_all)] +/// Generate a new secret and store it to the settings table. This is used during intiial configuration. +pub async fn generate_secret(db: &DatabaseConnection) -> crate::error::Result<()> { + let b = rand::thread_rng().gen::<[u8; 32]>(); + setting::ActiveModel { + id: NotSet, + setting_name: Set("secret".to_string()), + setting_value: Set(base64::encode(b)), + } + .insert(db) + .await?; + Ok(()) +} + +#[instrument(skip_all)] +/// Retrieves the JWT secret from the database. +pub async fn get_secret(db: &DatabaseConnection) -> crate::error::Result> { + let rec = setting::Entity::find() + .filter(setting::Column::SettingName.eq("secret".to_string())) + .one(db) + .await?; + + if let Some(rec) = rec { + if let Ok(b64) = base64::decode(rec.setting_value) { + Ok(b64) + } else { + Err(Error::new(ErrorCode::Internal, "Could not decode secret")) + } + } else { + Err(Error::new(ErrorCode::Internal, "No secret found")) + } +} + +#[cfg(test)] +mod tests { + use jsonwebtoken::{decode, DecodingKey, Validation}; + + use super::{get_secret, AuthClaims}; + + #[actix_rt::test] + async fn test_tokens() { + let state = crate::api::test_prelude::setup_state().await.unwrap(); + let jwt_secret = get_secret(&state.db).await.unwrap(); + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &crate::auth::AuthClaims { + exp: 10_000_000_000, + }, + &jsonwebtoken::EncodingKey::from_secret(&jwt_secret), + ) + .unwrap(); + + let decoded = decode::( + &token, + &DecodingKey::from_secret(&jwt_secret), + &Validation::default(), + ); + + decoded.expect("Decode failure"); + } +} diff --git a/src/entity/application.rs b/src/entity/application.rs index 38a4e07..eecf7ab 100644 --- a/src/entity/application.rs +++ b/src/entity/application.rs @@ -1,5 +1,3 @@ -//! SeaORM Entity. Generated by sea-orm-codegen 0.5.0 - use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; @@ -49,7 +47,7 @@ impl Default for Model { app_name: Default::default(), url: Default::default(), description: Default::default(), - active: Default::default(), + active: true, glyph: Default::default(), application_category_id: Default::default(), } diff --git a/src/entity/bookmark.rs b/src/entity/bookmark.rs index bd5676e..a208656 100644 --- a/src/entity/bookmark.rs +++ b/src/entity/bookmark.rs @@ -4,6 +4,7 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] #[sea_orm(table_name = "bookmark")] pub struct Model { #[sea_orm(primary_key)] diff --git a/src/entity/bookmark_category.rs b/src/entity/bookmark_category.rs index 12c2f0c..c5f4ae0 100644 --- a/src/entity/bookmark_category.rs +++ b/src/entity/bookmark_category.rs @@ -4,6 +4,7 @@ use sea_orm::entity::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] #[sea_orm(table_name = "bookmark_category")] pub struct Model { #[sea_orm(primary_key)] diff --git a/src/entity/mod.rs b/src/entity/mod.rs index a2426b2..bd07095 100644 --- a/src/entity/mod.rs +++ b/src/entity/mod.rs @@ -1,8 +1,8 @@ //! SeaORM Entity. Generated by sea-orm-codegen 0.5.0 -pub mod prelude; - pub mod application; pub mod application_category; pub mod bookmark; pub mod bookmark_category; +pub mod prelude; +pub mod setting; diff --git a/src/entity/setting.rs b/src/entity/setting.rs new file mode 100644 index 0000000..411cad2 --- /dev/null +++ b/src/entity/setting.rs @@ -0,0 +1,24 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "setting")] +#[serde(rename_all = "camelCase")] +pub struct Model { + #[sea_orm(primary_key)] + #[serde(skip_deserializing)] + pub id: i32, + pub setting_name: String, + pub setting_value: String, +} + +#[derive(Copy, Clone, Debug, EnumIter)] +pub enum Relation {} + +impl RelationTrait for Relation { + fn def(&self) -> RelationDef { + panic!("No RelationDef") + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/error.rs b/src/error.rs index de78916..788d316 100644 --- a/src/error.rs +++ b/src/error.rs @@ -6,6 +6,7 @@ pub type Result = std::result::Result; pub enum ErrorCode { NotFound, DatabaseError, + UnAuthorized, Internal, } @@ -17,12 +18,24 @@ pub struct Error { } impl Error { + pub fn new(code: ErrorCode, message: &str) -> Self { + Self { + code, + message: message.into(), + } + } pub fn not_found() -> Self { Self { code: ErrorCode::NotFound, message: "Resource not found".to_string(), } } + pub fn unauthorized() -> Self { + Self { + code: ErrorCode::NotFound, + message: "Unauthorzed".to_string(), + } + } } impl From<&str> for Error { diff --git a/src/main.rs b/src/main.rs index 3eab936..f35b9bd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use tracing::{info, instrument}; use tracing_subscriber::prelude::*; mod api; +mod auth; mod entity; mod error; @@ -27,19 +28,13 @@ async fn main() { let state = web::Data::new(AppState { db }); info!("Starting http server on 8080"); - HttpServer::new(move || { - App::new() - .app_data(state.clone()) - .service(api::applications::routes()) - .service(api::application_categories::routes()) - .service(api::bookmarks::routes()) - .service(api::bookmark_categories::routes()) - }) - .bind("127.0.0.1:8080") - .unwrap() - .run() - .await - .expect("Couldnt launch server"); + + HttpServer::new(move || App::new().app_data(state.clone()).service(api::routes())) + .bind("127.0.0.1:8080") + .unwrap() + .run() + .await + .expect("Couldnt launch server"); } #[instrument]