diff --git a/Cargo.toml b/Cargo.toml index f60d02b..cf14f4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ version = "0.1.0" actix-files = { git = "https://github.com/actix/actix-web", rev = "a9dc1586a0935c48c3f841761bf81c43ca9e2651" } actix-http = { git = "https://github.com/actix/actix-web", rev = "a9dc1586a0935c48c3f841761bf81c43ca9e2651" } actix-web = { git = "https://github.com/actix/actix-web", rev = "a9dc1586a0935c48c3f841761bf81c43ca9e2651" } +actix-web-httpauth = { git = "https://github.com/nhruo123/actix-extras", rev = "b4e8db446843a99b06c7ec40f18ef7b59ee7e955" } [dependencies] actix = "0.11.1" @@ -17,6 +18,7 @@ actix-rt = "2.2" actix-server = "2.0.0-beta.5" actix-service = "2.0.0" actix-web = "4.0.0-beta.6" +actix-web-httpauth = "0.6.0-beta.1" anyhow = "*" clap = "3.0.0-beta.2" chrono = { version = "*", features = [ "serde" ]} @@ -39,6 +41,10 @@ sea-query = { version = "0.9.4", features = [ "with-chrono" ] } lldap_model = { path = "model" } lldap_app = { path = "app" } serde_json = "1.0.64" +jwt = "0.13.0" +hmac = "0.10" +sha2 = "0.9.5" +time = "0.2.26" [dependencies.figment] features = ["toml", "env"] diff --git a/src/infra/configuration.rs b/src/infra/configuration.rs index 1491b3b..1ca0f9f 100644 --- a/src/infra/configuration.rs +++ b/src/infra/configuration.rs @@ -13,6 +13,7 @@ pub struct Configuration { pub ldaps_port: u16, pub http_port: u16, pub secret_pepper: String, + pub jwt_secret: String, pub ldap_base_dn: String, pub ldap_user_dn: String, pub ldap_user_pass: String, @@ -27,6 +28,7 @@ impl Default for Configuration { ldaps_port: 6360, http_port: 17170, secret_pepper: String::from("secretsecretpepper"), + jwt_secret: String::from("secretjwtsecret"), ldap_base_dn: String::from("dc=example,dc=com"), ldap_user_dn: String::from("cn=admin,dc=example,dc=com"), ldap_user_pass: String::from("password"), diff --git a/src/infra/tcp_server.rs b/src/infra/tcp_server.rs index 9aeb53a..67e6f33 100644 --- a/src/infra/tcp_server.rs +++ b/src/infra/tcp_server.rs @@ -3,10 +3,36 @@ use crate::infra::configuration::Configuration; use actix_files::{Files, NamedFile}; use actix_http::HttpServiceBuilder; use actix_server::ServerBuilder; -use actix_service::map_config; -use actix_web::{dev::AppConfig, web, App, HttpRequest, HttpResponse}; +use actix_service::{map_config, Service}; +use actix_web::{ + cookie::Cookie, + dev::{AppConfig, ServiceRequest}, + error::{ErrorBadRequest, ErrorUnauthorized}, + web, App, HttpRequest, HttpResponse, +}; +use actix_web_httpauth::{extractors::bearer::BearerAuth, middleware::HttpAuthentication}; use anyhow::{Context, Result}; +use chrono::prelude::*; +use futures_util::FutureExt; +use futures_util::TryFutureExt; +use hmac::{Hmac, NewMac}; +use jwt::{SignWithKey, VerifyWithKey}; +use log::*; +use serde::{Deserialize, Serialize}; +use sha2::Sha512; +use std::collections::HashSet; use std::path::PathBuf; +use time::ext::NumericalDuration; + +type Token = jwt::Token; +type SignedToken = Token; + +#[derive(Serialize, Deserialize)] +struct JWTClaims { + exp: DateTime, + user: String, + groups: HashSet, +} async fn index(req: HttpRequest) -> actix_web::Result { let mut path = PathBuf::new(); @@ -43,6 +69,55 @@ where .unwrap_or_else(error_to_http_response) } +fn create_jwt(key: &Hmac, user: String, groups: HashSet) -> SignedToken { + let claims = JWTClaims { + exp: Utc::now() + chrono::Duration::days(1), + user, + groups, + }; + let header = jwt::Header { + algorithm: jwt::AlgorithmType::Hs512, + ..Default::default() + }; + jwt::Token::new(header, claims).sign_with_key(key).unwrap() +} + +async fn post_authorize( + data: web::Data>, + request: web::Json, +) -> ApiResult +where + Backend: BackendHandler + 'static, +{ + let req: BindRequest = request.clone(); + data.backend_handler + .bind(req) + // If the authentication was successful, we need to fetch the groups to create the JWT + // token. + .and_then(|_| data.backend_handler.get_user_groups(request.name.clone())) + .await + .map(|groups| { + let token = create_jwt(&data.jwt_key, request.name.clone(), groups); + ApiResult::Right( + HttpResponse::Ok() + .cookie( + Cookie::build("token", token.as_str()) + .max_age(1.days()) + .path("/api") + .http_only(true) + .finish(), + ) + .cookie( + Cookie::build("user_id", &request.name) + .max_age(1.days()) + .finish(), + ) + .body(token.as_str().to_owned()), + ) + }) + .unwrap_or_else(error_to_http_response) +} + fn api_config(cfg: &mut web::ServiceConfig) where Backend: BackendHandler + 'static, @@ -66,11 +141,79 @@ where ); } +async fn token_validator( + req: ServiceRequest, + credentials: BearerAuth, +) -> Result +where + Backend: BackendHandler + 'static, +{ + let state = req + .app_data::>>() + .expect("Invalid app config"); + let token: Token<_> = VerifyWithKey::verify_with_key(credentials.token(), &state.jwt_key) + .map_err(|_| ErrorUnauthorized("Invalid JWT"))?; + if token.claims().exp.lt(&Utc::now()) { + return Err(ErrorUnauthorized("Expired JWT")); + } + let groups = &token.claims().groups; + if groups.contains("lldap_admin") { + debug!("Got authorized token for user {}", &token.claims().user); + Ok(req) + } else { + Err(ErrorUnauthorized( + "JWT error: User is not in group lldap_admin", + )) + } +} + +fn http_config(cfg: &mut web::ServiceConfig, backend_handler: Backend, jwt_secret: String) +where + Backend: BackendHandler + 'static, +{ + cfg.data(AppState:: { + backend_handler, + jwt_key: Hmac::new_varkey(&jwt_secret.as_bytes()).unwrap(), + }) + // Serve index.html and main.js, and default to index.html. + .route( + "/{filename:(index\\.html|main\\.js)?}", + web::get().to(index), + ) + .service(web::resource("/authorize").route(web::post().to(post_authorize::))) + // API endpoint. + .service( + web::scope("/api") + .wrap(HttpAuthentication::bearer(token_validator::)) + .wrap_fn(|mut req, srv| { + if let Some(token_cookie) = req.cookie("token") { + if let Ok(header_value) = actix_http::header::HeaderValue::from_str(&format!( + "Bearer {}", + token_cookie.value() + )) { + req.headers_mut() + .insert(actix_http::header::AUTHORIZATION, header_value); + } else { + return async move { + Ok(req.error_response(ErrorBadRequest("Invalid token cookie"))) + } + .boxed_local(); + } + }; + Box::pin(srv.call(req)) + }) + .configure(api_config::), + ) + // Serve the /pkg path with the compiled WASM app. + .service(Files::new("/pkg", "./app/pkg")); +} + struct AppState where Backend: BackendHandler + 'static, { pub backend_handler: Backend, + pub jwt_key: Hmac, } pub fn build_tcp_server( @@ -81,31 +224,18 @@ pub fn build_tcp_server( where Backend: BackendHandler + 'static, { + let http_port = config.http_port.clone(); + let jwt_secret = config.jwt_secret.clone(); server_builder .bind("http", ("0.0.0.0", config.http_port), move || { + let backend_handler = backend_handler.clone(); + let jwt_secret = jwt_secret.clone(); HttpServiceBuilder::new() .finish(map_config( - App::new() - .data(AppState:: { - backend_handler: backend_handler.clone(), - }) - // Serve index.html and main.js, and default to index.html. - .route( - "/{filename:(index\\.html|main\\.js)?}", - web::get().to(index), - ) - // API endpoint. - .service(web::scope("/api").configure(api_config::)) - // Serve the /pkg path with the compiled WASM app. - .service(Files::new("/pkg", "./app/pkg")), + App::new().configure(move |cfg| http_config(cfg, backend_handler, jwt_secret)), |_| AppConfig::default(), )) .tcp() }) - .with_context(|| { - format!( - "While bringing up the TCP server with port {}", - config.http_port - ) - }) + .with_context(|| format!("While bringing up the TCP server with port {}", http_port)) }