Authorization

Added authorization system. All app endpoints are now bearer token
verified.

Added endpoints for initial setup, authorization, and updating
passwords.
This commit is contained in:
Joe Bellus 2022-02-05 01:11:24 -05:00
parent 80d14a9688
commit 6c9ea1f774
17 changed files with 590 additions and 71 deletions

173
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

@ -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,7 +145,8 @@ mod tests {
.insert(&state.db)
.await?;
let req = actix_web::test::TestRequest::with_uri(&format!("/applications/{}", model.id))
let mut req =
actix_web::test::TestRequest::with_uri(&format!("/applications/{}", model.id))
.method(Method::GET)
.to_request();
let resp = call_endpoint!(req, state);
@ -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))
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,7 +242,8 @@ mod tests {
.insert(&state.db)
.await?;
let req = actix_web::test::TestRequest::with_uri(&format!("/applications/{}", model.id))
let mut req =
actix_web::test::TestRequest::with_uri(&format!("/applications/{}", model.id))
.method(Method::DELETE)
.to_request();
let resp = call_endpoint!(req, state);

137
src/api/authorization.rs Normal file
View File

@ -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<AppState>,
req: web::Json<AuthRequest>,
) -> Result<HttpResponse> {
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<AppState>,
req: web::Json<UpdatePasswordRequest>,
) -> Result<HttpResponse> {
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<AppState>,
req: web::Json<SetupRequest>,
) -> Result<HttpResponse> {
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::<AuthClaims>(
&data.token,
&jsonwebtoken::DecodingKey::from_secret(&jwt_secret),
&jsonwebtoken::Validation::default(),
);
decoded.expect("Decode failure");
Ok(())
}
}

View File

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

View File

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

View File

@ -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<T: Serialize> ListObjects<T> {
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)
}

146
src/auth.rs Normal file
View File

@ -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<ServiceRequest, actix_web::error::Error> {
// Retrieve shared app state
let state = req
.app_data::<Data<AppState>>()
.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::<AuthClaims>(
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::Model> = 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<Vec<u8>> {
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::<AuthClaims>(
&token,
&DecodingKey::from_secret(&jwt_secret),
&Validation::default(),
);
decoded.expect("Decode failure");
}
}

View File

@ -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(),
}

View File

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

View File

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

View File

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

24
src/entity/setting.rs Normal file
View File

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

View File

@ -6,6 +6,7 @@ pub type Result<T> = std::result::Result<T, Error>;
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 {

View File

@ -4,6 +4,7 @@ use tracing::{info, instrument};
use tracing_subscriber::prelude::*;
mod api;
mod auth;
mod entity;
mod error;
@ -27,14 +28,8 @@ 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())
})
HttpServer::new(move || App::new().app_data(state.clone()).service(api::routes()))
.bind("127.0.0.1:8080")
.unwrap()
.run()