From d8946eb4629fedddc2016c2bac6555e83d0df0f2 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Thu, 25 Apr 2024 19:25:08 +0200 Subject: [PATCH] Start to implement OpenID authentication --- remote_backend/Cargo.lock | 47 ++++++++ remote_backend/Cargo.toml | 8 +- remote_backend/src/app_config.rs | 4 +- remote_backend/src/constants.rs | 5 + .../src/controllers/auth_controller.rs | 97 +++++++++++++++ remote_backend/src/controllers/mod.rs | 89 ++++++++++++++ .../src/extractors/auth_extractor.rs | 47 ++++++++ remote_backend/src/extractors/mod.rs | 1 + remote_backend/src/lib.rs | 4 + remote_backend/src/main.rs | 19 ++- .../src/middlewares/auth_middleware.rs | 111 ++++++++++++++++++ remote_backend/src/middlewares/mod.rs | 1 + 12 files changed, 427 insertions(+), 6 deletions(-) create mode 100644 remote_backend/src/constants.rs create mode 100644 remote_backend/src/controllers/auth_controller.rs create mode 100644 remote_backend/src/controllers/mod.rs create mode 100644 remote_backend/src/extractors/auth_extractor.rs create mode 100644 remote_backend/src/extractors/mod.rs create mode 100644 remote_backend/src/middlewares/auth_middleware.rs create mode 100644 remote_backend/src/middlewares/mod.rs diff --git a/remote_backend/Cargo.lock b/remote_backend/Cargo.lock index e41ffd8..30a79ef 100644 --- a/remote_backend/Cargo.lock +++ b/remote_backend/Cargo.lock @@ -58,6 +58,22 @@ dependencies = [ "zstd", ] +[[package]] +name = "actix-identity" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c99b7a5614b72a78f04aa2021e5370fc1aef2475fffeffc0c1266b99007062" +dependencies = [ + "actix-service", + "actix-session", + "actix-utils", + "actix-web", + "derive_more", + "futures-core", + "serde", + "tracing", +] + [[package]] name = "actix-macros" version = "0.2.4" @@ -130,6 +146,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "actix-session" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b671404ec72194d8af58c2bdaf51e3c477a0595056bd5010148405870dda8df2" +dependencies = [ + "actix-service", + "actix-utils", + "actix-web", + "anyhow", + "derive_more", + "serde", + "serde_json", + "tracing", +] + [[package]] name = "actix-utils" version = "3.0.1" @@ -360,6 +392,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea22880d78093b0cbe17c89f64a7d457941e65759157ec6cb31a31d652b05e5" + [[package]] name = "base64" version = "0.21.7" @@ -565,7 +603,14 @@ version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" dependencies = [ + "aes-gcm", + "base64 0.20.0", + "hkdf", + "hmac", "percent-encoding", + "rand", + "sha2", + "subtle", "time", "version_check", ] @@ -1646,12 +1691,14 @@ checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" name = "remote_backend" version = "0.1.0" dependencies = [ + "actix-identity", "actix-remote-ip", "actix-web", "anyhow", "basic-jwt", "clap", "env_logger", + "futures-util", "lazy_static", "light-openid", "log", diff --git a/remote_backend/Cargo.toml b/remote_backend/Cargo.toml index 95f5f3f..7903df4 100644 --- a/remote_backend/Cargo.toml +++ b/remote_backend/Cargo.toml @@ -12,10 +12,12 @@ clap = { version = "4.5.4", features = ["derive", "env"] } serde = { version = "1.0.198", features = ["derive"] } light-openid = { version = "1.0.2", features = ["crypto-wrapper"] } basic-jwt = "0.2.0" -actix-remote-ip = "0.1.0" -lazy_static = "1.4.0" actix-web = "4.5.1" +actix-remote-ip = "0.1.0" +actix-identity = "0.7.1" +lazy_static = "1.4.0" anyhow = "1.0.82" reqwest = { version = "0.12.4", features = ["json"] } thiserror = "1.0.59" -uuid = { version = "1.8.0", features = ["v4"] } \ No newline at end of file +uuid = { version = "1.8.0", features = ["v4"] } +futures-util = "0.3.30" \ No newline at end of file diff --git a/remote_backend/src/app_config.rs b/remote_backend/src/app_config.rs index ad37e95..c3d565e 100644 --- a/remote_backend/src/app_config.rs +++ b/remote_backend/src/app_config.rs @@ -33,9 +33,9 @@ pub struct AppConfig { )] pub oidc_configuration_url: String, - /// Disable OpenID authentication + /// Disable authentication (for development purposes ONLY) #[arg(long, env)] - pub disable_oidc: bool, + pub unsecure_disable_login: bool, /// OpenID client ID #[arg(long, env, default_value = "foo")] diff --git a/remote_backend/src/constants.rs b/remote_backend/src/constants.rs new file mode 100644 index 0000000..2ca4b8c --- /dev/null +++ b/remote_backend/src/constants.rs @@ -0,0 +1,5 @@ +pub const ROUTES_WITHOUT_AUTH: [&str; 3] = [ + "/api/server/config", + "/api/auth/start_oidc", + "/api/auth/finish_oidc", +]; diff --git a/remote_backend/src/controllers/auth_controller.rs b/remote_backend/src/controllers/auth_controller.rs new file mode 100644 index 0000000..274d608 --- /dev/null +++ b/remote_backend/src/controllers/auth_controller.rs @@ -0,0 +1,97 @@ +use actix_remote_ip::RemoteIP; +use actix_web::web::Data; +use actix_web::{web, HttpResponse, Responder}; +use light_openid::basic_state_manager::BasicStateManager; + +use crate::app_config::AppConfig; +use crate::controllers::HttpResult; +use crate::extractors::auth_extractor::AuthExtractor; + +#[derive(serde::Serialize)] +struct StartOIDCResponse { + url: String, +} + +/// Start OIDC authentication +pub async fn start_oidc(sm: Data, ip: RemoteIP) -> HttpResult { + let prov = AppConfig::get().openid_provider(); + + let conf = + light_openid::primitives::OpenIDConfig::load_from_url(prov.configuration_url).await?; + + Ok(HttpResponse::Ok().json(StartOIDCResponse { + url: conf.gen_authorization_url( + prov.client_id, + &sm.gen_state(ip.0)?, + &AppConfig::get().oidc_redirect_url(), + ), + })) +} + +#[derive(serde::Deserialize)] +pub struct FinishOpenIDLoginQuery { + code: String, + state: String, +} + +/// Finish OIDC authentication +pub async fn finish_oidc( + sm: Data, + remote_ip: RemoteIP, + req: web::Json, + auth: AuthExtractor, +) -> HttpResult { + if let Err(e) = sm.validate_state(remote_ip.0, &req.state) { + log::error!("Failed to validate OIDC CB state! {e}"); + return Ok(HttpResponse::BadRequest().json("Invalid state!")); + } + + let prov = AppConfig::get().openid_provider(); + + let conf = + light_openid::primitives::OpenIDConfig::load_from_url(prov.configuration_url).await?; + + let (token, _) = conf + .request_token( + prov.client_id, + prov.client_secret, + &req.code, + &AppConfig::get().oidc_redirect_url(), + ) + .await?; + let (user_info, _) = conf.request_user_info(&token).await?; + + if user_info.email_verified != Some(true) { + log::error!("Email is not verified!"); + return Ok(HttpResponse::Unauthorized().json("Email unverified by IDP!")); + } + + let mail = match user_info.email { + Some(m) => m, + None => { + return Ok(HttpResponse::Unauthorized().json("Email not provided by the IDP!")); + } + }; + + auth.authenticate(mail); + + Ok(HttpResponse::Ok().finish()) +} + +#[derive(serde::Serialize)] +struct CurrentUser { + id: String, +} + +/// Get current authenticated user +pub async fn current_user(auth: AuthExtractor) -> impl Responder { + HttpResponse::Ok().json(CurrentUser { + id: auth.id().unwrap_or_else(|| "Anonymous".to_string()), + }) +} + +/// Sign out +pub async fn sign_out(auth: AuthExtractor) -> impl Responder { + auth.sign_out(); + HttpResponse::Ok().finish() +} diff --git a/remote_backend/src/controllers/mod.rs b/remote_backend/src/controllers/mod.rs new file mode 100644 index 0000000..7a1fb5e --- /dev/null +++ b/remote_backend/src/controllers/mod.rs @@ -0,0 +1,89 @@ +use actix_web::body::BoxBody; +use actix_web::http::StatusCode; +use actix_web::HttpResponse; +use std::error::Error; +use std::fmt::{Display, Formatter}; +use std::io::ErrorKind; + +pub mod auth_controller; + +/// Custom error to ease controller writing +#[derive(Debug)] +pub enum HttpErr { + Err(anyhow::Error), + HTTPResponse(HttpResponse), +} + +impl Display for HttpErr { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + HttpErr::Err(err) => Display::fmt(err, f), + HttpErr::HTTPResponse(res) => { + Display::fmt(&format!("HTTP RESPONSE {}", res.status().as_str()), f) + } + } + } +} + +impl actix_web::error::ResponseError for HttpErr { + fn status_code(&self) -> StatusCode { + match self { + HttpErr::Err(_) => StatusCode::INTERNAL_SERVER_ERROR, + HttpErr::HTTPResponse(r) => r.status(), + } + } + fn error_response(&self) -> HttpResponse { + log::error!("Error while processing request! {}", self); + + HttpResponse::InternalServerError().body("Failed to execute request!") + } +} + +impl From for HttpErr { + fn from(err: anyhow::Error) -> HttpErr { + HttpErr::Err(err) + } +} + +impl From> for HttpErr { + fn from(value: Box) -> Self { + HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into()) + } +} + +impl From for HttpErr { + fn from(value: std::io::Error) -> Self { + HttpErr::Err(value.into()) + } +} + +impl From for HttpErr { + fn from(value: std::num::ParseIntError) -> Self { + HttpErr::Err(value.into()) + } +} + +impl From for HttpErr { + fn from(value: reqwest::Error) -> Self { + HttpErr::Err(value.into()) + } +} + +impl From for HttpErr { + fn from(value: reqwest::header::ToStrError) -> Self { + HttpErr::Err(value.into()) + } +} + +impl From for HttpErr { + fn from(value: actix_web::Error) -> Self { + HttpErr::Err(std::io::Error::new(ErrorKind::Other, value.to_string()).into()) + } +} + +impl From for HttpErr { + fn from(value: HttpResponse) -> Self { + HttpErr::HTTPResponse(value) + } +} +pub type HttpResult = Result; diff --git a/remote_backend/src/extractors/auth_extractor.rs b/remote_backend/src/extractors/auth_extractor.rs new file mode 100644 index 0000000..26c2570 --- /dev/null +++ b/remote_backend/src/extractors/auth_extractor.rs @@ -0,0 +1,47 @@ +use actix_identity::Identity; +use actix_web::dev::Payload; +use actix_web::{Error, FromRequest, HttpMessage, HttpRequest}; +use futures_util::future::{ready, Ready}; +use std::fmt::Display; + +pub struct AuthExtractor { + identity: Option, + request: HttpRequest, +} + +impl AuthExtractor { + /// Check whether the user is authenticated or not + pub fn is_authenticated(&self) -> bool { + self.identity.is_some() + } + + /// Authenticate the user + pub fn authenticate(&self, id: impl Display) { + Identity::login(&self.request.extensions(), id.to_string()) + .expect("Unable to set authentication!"); + } + + pub fn id(&self) -> Option { + self.identity.as_ref().map(|i| i.id().unwrap()) + } + + pub fn sign_out(self) { + if let Some(i) = self.identity { + i.logout() + } + } +} + +impl FromRequest for AuthExtractor { + type Error = Error; + type Future = Ready>; + + fn from_request(req: &HttpRequest, payload: &mut Payload) -> Self::Future { + let identity: Option = Identity::from_request(req, payload).into_inner().ok(); + + ready(Ok(Self { + identity, + request: req.clone(), + })) + } +} diff --git a/remote_backend/src/extractors/mod.rs b/remote_backend/src/extractors/mod.rs new file mode 100644 index 0000000..89bd714 --- /dev/null +++ b/remote_backend/src/extractors/mod.rs @@ -0,0 +1 @@ +pub mod auth_extractor; diff --git a/remote_backend/src/lib.rs b/remote_backend/src/lib.rs index b8a566c..d6bffdd 100644 --- a/remote_backend/src/lib.rs +++ b/remote_backend/src/lib.rs @@ -1,3 +1,7 @@ pub mod app_config; +pub mod constants; +pub mod controllers; +pub mod extractors; +pub mod middlewares; pub mod utils; pub mod virtweb_client; diff --git a/remote_backend/src/main.rs b/remote_backend/src/main.rs index 084904d..7c45a6d 100644 --- a/remote_backend/src/main.rs +++ b/remote_backend/src/main.rs @@ -1,9 +1,10 @@ use actix_remote_ip::RemoteIPConfig; use actix_web::middleware::Logger; use actix_web::web::Data; -use actix_web::{App, HttpServer}; +use actix_web::{web, App, HttpServer}; use light_openid::basic_state_manager::BasicStateManager; use remote_backend::app_config::AppConfig; +use remote_backend::controllers::auth_controller; use remote_backend::virtweb_client; #[actix_web::main] @@ -21,6 +22,22 @@ async fn main() -> std::io::Result<()> { .app_data(Data::new(RemoteIPConfig { proxy: AppConfig::get().proxy_ip.clone(), })) + .route( + "/api/auth/start_oidc", + web::get().to(auth_controller::start_oidc), + ) + .route( + "/api/auth/finish_oidc", + web::post().to(auth_controller::finish_oidc), + ) + .route( + "/api/auth/user", + web::get().to(auth_controller::current_user), + ) + .route( + "/api/auth/sign_out", + web::get().to(auth_controller::sign_out), + ) }) .bind(&AppConfig::get().listen_address)? .run() diff --git a/remote_backend/src/middlewares/auth_middleware.rs b/remote_backend/src/middlewares/auth_middleware.rs new file mode 100644 index 0000000..eab5a01 --- /dev/null +++ b/remote_backend/src/middlewares/auth_middleware.rs @@ -0,0 +1,111 @@ +use std::future::{ready, Ready}; +use std::rc::Rc; + +use crate::app_config::AppConfig; +use crate::constants; +use crate::extractors::auth_extractor::AuthExtractor; +use actix_web::body::EitherBody; +use actix_web::dev::Payload; +use actix_web::{ + dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, + Error, FromRequest, HttpResponse, +}; +use futures_util::future::LocalBoxFuture; + +// There are two steps in middleware processing. +// 1. Middleware initialization, middleware factory gets called with +// next service in chain as parameter. +// 2. Middleware's call method gets called with normal request. +#[derive(Default)] +pub struct AuthChecker; + +// Middleware factory is `Transform` trait +// `S` - type of the next service +// `B` - type of response's body +impl Transform for AuthChecker +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Transform = AuthMiddleware; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(AuthMiddleware { + service: Rc::new(service), + })) + } +} + +pub struct AuthMiddleware { + service: Rc, +} + +impl Service for AuthMiddleware +where + S: Service, Error = Error> + 'static, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + let service = Rc::clone(&self.service); + + Box::pin(async move { + let remote_ip = + actix_remote_ip::RemoteIP::from_request(req.request(), &mut Payload::None) + .await + .unwrap(); + + // Check if no authentication is required + if constants::ROUTES_WITHOUT_AUTH.contains(&req.path()) + || !req.path().starts_with("/api/") + { + log::trace!("No authentication is required") + } + // Check cookie authentication + else if !&AppConfig::get().unsecure_disable_login { + let auth = match AuthExtractor::from_request(req.request(), &mut Payload::None) + .into_inner() + { + Ok(auth) => auth, + Err(e) => { + log::error!( + "Failed to extract authentication information from request! {e}" + ); + return Ok(req + .into_response(HttpResponse::PreconditionFailed().finish()) + .map_into_right_body()); + } + }; + + if !auth.is_authenticated() { + log::error!( + "User attempted to access privileged route without authentication!" + ); + return Ok(req + .into_response( + HttpResponse::PreconditionFailed().json("Please authenticate!"), + ) + .map_into_right_body()); + } + } + + log::info!("{} - {}", remote_ip.0, req.path()); + + service + .call(req) + .await + .map(ServiceResponse::map_into_left_body) + }) + } +} diff --git a/remote_backend/src/middlewares/mod.rs b/remote_backend/src/middlewares/mod.rs new file mode 100644 index 0000000..2a709c0 --- /dev/null +++ b/remote_backend/src/middlewares/mod.rs @@ -0,0 +1 @@ +pub mod auth_middleware;