Implement server (#3)
	
		
			
	
		
	
	
		
	
		
			All checks were successful
		
		
	
	
		
			
				
	
				continuous-integration/drone/push Build is passing
				
			
		
		
	
	
				
					
				
			
		
			All checks were successful
		
		
	
	continuous-integration/drone/push Build is passing
				
			Use Actix as HTTP service Reviewed-on: #3
This commit is contained in:
		
							
								
								
									
										1776
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1776
									
								
								Cargo.lock
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										15
									
								
								Cargo.toml
									
									
									
									
									
								
							
							
						
						
									
										15
									
								
								Cargo.toml
									
									
									
									
									
								
							| @@ -8,5 +8,16 @@ edition = "2021" | ||||
| [dependencies] | ||||
| log = "0.4.17" | ||||
| env_logger = "0.10.0" | ||||
| clap = {version="4.2.4", features=["derive", "env"]} | ||||
| lazy_static = "1.4.0" | ||||
| clap = { version = "4.2.4", features = ["derive", "env"] } | ||||
| lazy_static = "1.4.0" | ||||
| actix-web = "4.3.1" | ||||
| askama = "0.12.0" | ||||
| serde = { version = "1.0.160", features = ["derive"] } | ||||
| serde_json = "1.0.96" | ||||
| reqwest = { version = "0.11.16", features = ["json"] } | ||||
| urlencoding = "2.1.2" | ||||
| futures-util = "0.3.28" | ||||
| aes-gcm = "0.10.1" | ||||
| base64 = "0.21.0" | ||||
| rand = "0.8.5" | ||||
| bincode = {version="2.0.0-rc.3",features=["serde"]} | ||||
							
								
								
									
										6
									
								
								assets/bootstrap.min.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								assets/bootstrap.min.css
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										127
									
								
								assets/cover.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								assets/cover.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | ||||
| /* | ||||
|  * Globals | ||||
|  */ | ||||
|  | ||||
|  | ||||
| /* Custom default button */ | ||||
| .btn-light, | ||||
| .btn-light:hover, | ||||
| .btn-light:focus { | ||||
|   color: #333; | ||||
|   text-shadow: none; /* Prevent inheritance from `body` */ | ||||
| } | ||||
|  | ||||
|  | ||||
| /* | ||||
|  * Base structure | ||||
|  */ | ||||
|  | ||||
| body { | ||||
|   text-shadow: 0 .05rem .1rem rgba(0, 0, 0, .5); | ||||
|   box-shadow: inset 0 0 5rem rgba(0, 0, 0, .5); | ||||
| } | ||||
|  | ||||
| .cover-container { | ||||
|   max-width: 42em; | ||||
| } | ||||
|  | ||||
|  | ||||
| main { | ||||
|     overflow-y: scroll; | ||||
| } | ||||
|  | ||||
| /* | ||||
|  * Header | ||||
|  */ | ||||
|  | ||||
| .nav-masthead .nav-link { | ||||
|   color: rgba(255, 255, 255, .5); | ||||
|   border-bottom: .25rem solid transparent; | ||||
| } | ||||
|  | ||||
| .nav-masthead .nav-link:hover, | ||||
| .nav-masthead .nav-link:focus { | ||||
|   border-bottom-color: rgba(255, 255, 255, .25); | ||||
| } | ||||
|  | ||||
| .nav-masthead .nav-link + .nav-link { | ||||
|   margin-left: 1rem; | ||||
| } | ||||
|  | ||||
| .nav-masthead .active { | ||||
|   color: #fff; | ||||
|   border-bottom-color: #fff; | ||||
| } | ||||
|  | ||||
|  | ||||
| .bd-placeholder-img { | ||||
|         font-size: 1.125rem; | ||||
|         text-anchor: middle; | ||||
|         -webkit-user-select: none; | ||||
|         -moz-user-select: none; | ||||
|         user-select: none; | ||||
|   } | ||||
|  | ||||
|   @media (min-width: 768px) { | ||||
|     .bd-placeholder-img-lg { | ||||
|       font-size: 3.5rem; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .b-example-divider { | ||||
|     width: 100%; | ||||
|     height: 3rem; | ||||
|     background-color: rgba(0, 0, 0, .1); | ||||
|     border: solid rgba(0, 0, 0, .15); | ||||
|     border-width: 1px 0; | ||||
|     box-shadow: inset 0 .5em 1.5em rgba(0, 0, 0, .1), inset 0 .125em .5em rgba(0, 0, 0, .15); | ||||
|   } | ||||
|  | ||||
|   .b-example-vr { | ||||
|     flex-shrink: 0; | ||||
|     width: 1.5rem; | ||||
|     height: 100vh; | ||||
|   } | ||||
|  | ||||
|   .bi { | ||||
|     vertical-align: -.125em; | ||||
|     fill: currentColor; | ||||
|   } | ||||
|  | ||||
|   .nav-scroller { | ||||
|     position: relative; | ||||
|     z-index: 2; | ||||
|     height: 2.75rem; | ||||
|     overflow-y: hidden; | ||||
|   } | ||||
|  | ||||
|   .nav-scroller .nav { | ||||
|     display: flex; | ||||
|     flex-wrap: nowrap; | ||||
|     padding-bottom: 1rem; | ||||
|     margin-top: -1px; | ||||
|     overflow-x: auto; | ||||
|     text-align: center; | ||||
|     white-space: nowrap; | ||||
|     -webkit-overflow-scrolling: touch; | ||||
|   } | ||||
|  | ||||
|   .btn-bd-primary { | ||||
|     --bd-violet-bg: #712cf9; | ||||
|     --bd-violet-rgb: 112.520718, 44.062154, 249.437846; | ||||
|  | ||||
|     --bs-btn-font-weight: 600; | ||||
|     --bs-btn-color: var(--bs-white); | ||||
|     --bs-btn-bg: var(--bd-violet-bg); | ||||
|     --bs-btn-border-color: var(--bd-violet-bg); | ||||
|     --bs-btn-hover-color: var(--bs-white); | ||||
|     --bs-btn-hover-bg: #6528e0; | ||||
|     --bs-btn-hover-border-color: #6528e0; | ||||
|     --bs-btn-focus-shadow-rgb: var(--bd-violet-rgb); | ||||
|     --bs-btn-active-color: var(--bs-btn-hover-color); | ||||
|     --bs-btn-active-bg: #5a23c8; | ||||
|     --bs-btn-active-border-color: #5a23c8; | ||||
|   } | ||||
|   .bd-mode-toggle { | ||||
|     z-index: 1500; | ||||
|   } | ||||
							
								
								
									
										58
									
								
								src/app_config.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								src/app_config.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,58 @@ | ||||
| use clap::Parser; | ||||
|  | ||||
| const REDIRECT_URI: &str = "/redirect"; | ||||
|  | ||||
| /// Basic OpenID test client | ||||
| #[derive(Parser, Debug)] | ||||
| pub struct AppConfig { | ||||
|     /// Listen URL | ||||
|     #[arg(short, long, env, default_value = "0.0.0.0:7510")] | ||||
|     pub listen_addr: String, | ||||
|  | ||||
|     /// Public URL, the URL where this service is accessible, without the trailing slash | ||||
|     #[arg(short, long, env, default_value = "http://localhost:7510")] | ||||
|     pub public_url: String, | ||||
|  | ||||
|     /// URL where the OpenID configuration can be found | ||||
|     #[arg(short, long, env)] | ||||
|     pub configuration_url: String, | ||||
|  | ||||
|     /// OpenID client ID | ||||
|     #[arg(long, env)] | ||||
|     pub client_id: String, | ||||
|  | ||||
|     /// OpenID client secret | ||||
|     #[arg(long, env)] | ||||
|     pub client_secret: String, | ||||
|  | ||||
|     /// Proxy IP, might end with a "*" | ||||
|     #[clap(long, env)] | ||||
|     pub proxy_ip: Option<String>, | ||||
| } | ||||
|  | ||||
| impl AppConfig { | ||||
|     pub fn get() -> &'static Self { | ||||
|         &CONF | ||||
|     } | ||||
|  | ||||
|     pub fn redirect_url(&self) -> String { | ||||
|         format!("{}{}", self.public_url, REDIRECT_URI) | ||||
|     } | ||||
| } | ||||
|  | ||||
| lazy_static::lazy_static! { | ||||
|     static ref CONF: AppConfig = { | ||||
|         AppConfig::parse() | ||||
|     }; | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod test { | ||||
|     use crate::app_config::AppConfig; | ||||
|  | ||||
|     #[test] | ||||
|     fn verify_cli() { | ||||
|         use clap::CommandFactory; | ||||
|         AppConfig::command().debug_assert(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										97
									
								
								src/crypto_wrapper.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								src/crypto_wrapper.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| use std::io::ErrorKind; | ||||
|  | ||||
| use crate::Res; | ||||
| use aes_gcm::aead::{Aead, OsRng}; | ||||
| use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce}; | ||||
| use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; | ||||
| use base64::Engine as _; | ||||
| use bincode::{Decode, Encode}; | ||||
| use rand::Rng; | ||||
|  | ||||
| const NONCE_LEN: usize = 12; | ||||
|  | ||||
| pub struct CryptoWrapper { | ||||
|     key: Key<Aes256Gcm>, | ||||
| } | ||||
|  | ||||
| impl CryptoWrapper { | ||||
|     /// Generate a new memory wrapper | ||||
|     pub fn new_random() -> Self { | ||||
|         Self { | ||||
|             key: Aes256Gcm::generate_key(&mut OsRng), | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Encrypt some data | ||||
|     pub fn encrypt<T: Encode + Decode>(&self, data: &T) -> Res<String> { | ||||
|         let aes_key = Aes256Gcm::new(&self.key); | ||||
|         let nonce_bytes = rand::thread_rng().gen::<[u8; NONCE_LEN]>(); | ||||
|  | ||||
|         let serialized_data = bincode::encode_to_vec(data, bincode::config::standard())?; | ||||
|  | ||||
|         let mut enc = aes_key | ||||
|             .encrypt(Nonce::from_slice(&nonce_bytes), serialized_data.as_slice()) | ||||
|             .unwrap(); | ||||
|         enc.extend_from_slice(&nonce_bytes); | ||||
|  | ||||
|         Ok(BASE64_STANDARD.encode(enc)) | ||||
|     } | ||||
|  | ||||
|     /// Decrypt some data previously encrypted using the [`CryptoWrapper::encrypt`] method | ||||
|     pub fn decrypt<T: Decode>(&self, input: &str) -> Res<T> { | ||||
|         let bytes = BASE64_STANDARD.decode(input)?; | ||||
|  | ||||
|         if bytes.len() < NONCE_LEN { | ||||
|             return Err(Box::new(std::io::Error::new( | ||||
|                 ErrorKind::Other, | ||||
|                 "Input string is smaller than nonce!", | ||||
|             ))); | ||||
|         } | ||||
|  | ||||
|         let (enc, nonce) = bytes.split_at(bytes.len() - NONCE_LEN); | ||||
|         assert_eq!(nonce.len(), NONCE_LEN); | ||||
|  | ||||
|         let aes_key = Aes256Gcm::new(&self.key); | ||||
|  | ||||
|         let dec = match aes_key.decrypt(Nonce::from_slice(nonce), enc) { | ||||
|             Ok(d) => d, | ||||
|             Err(e) => { | ||||
|                 log::error!("Failed to decrypt wrapped data! {:#?}", e); | ||||
|                 return Err(Box::new(std::io::Error::new( | ||||
|                     ErrorKind::Other, | ||||
|                     "Failed to decrypt wrapped data!", | ||||
|                 ))); | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         Ok(bincode::decode_from_slice(&dec, bincode::config::standard())?.0) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod test { | ||||
|     use crate::crypto_wrapper::CryptoWrapper; | ||||
|     use bincode::{Decode, Encode}; | ||||
|  | ||||
|     #[derive(Encode, Decode, Eq, PartialEq, Debug)] | ||||
|     struct Message(String); | ||||
|  | ||||
|     #[test] | ||||
|     fn encrypt_and_decrypt() { | ||||
|         let wrapper = CryptoWrapper::new_random(); | ||||
|         let msg = Message("Pierre was here".to_string()); | ||||
|         let enc = wrapper.encrypt(&msg).unwrap(); | ||||
|         let dec: Message = wrapper.decrypt(&enc).unwrap(); | ||||
|  | ||||
|         assert_eq!(dec, msg) | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn encrypt_and_decrypt_invalid() { | ||||
|         let wrapper_1 = CryptoWrapper::new_random(); | ||||
|         let wrapper_2 = CryptoWrapper::new_random(); | ||||
|         let msg = Message("Pierre was here".to_string()); | ||||
|         let enc = wrapper_1.encrypt(&msg).unwrap(); | ||||
|         wrapper_2.decrypt::<Message>(&enc).unwrap_err(); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										10
									
								
								src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/lib.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| use std::error::Error; | ||||
|  | ||||
| pub type Res<A = ()> = Result<A, Box<dyn Error>>; | ||||
|  | ||||
| pub mod app_config; | ||||
| pub mod crypto_wrapper; | ||||
| pub mod openid_primitives; | ||||
| pub mod remote_ip; | ||||
| pub mod state_manager; | ||||
| pub mod time_utils; | ||||
							
								
								
									
										206
									
								
								src/main.rs
									
									
									
									
									
								
							
							
						
						
									
										206
									
								
								src/main.rs
									
									
									
									
									
								
							| @@ -1,50 +1,178 @@ | ||||
| use clap::Parser; | ||||
| use actix_web::middleware::Logger; | ||||
| use actix_web::{get, web, App, HttpResponse, HttpServer}; | ||||
| use askama::Template; | ||||
|  | ||||
| /// Basic OpenID test client | ||||
| #[derive(Parser, Debug)] | ||||
| struct AppConfig { | ||||
|     /// Listen URL | ||||
|     #[arg(short, long, env, default_value = "0.0.0.0:7510")] | ||||
|     listen_addr: String, | ||||
| use oidc_test_client::app_config::AppConfig; | ||||
| use oidc_test_client::openid_primitives::OpenIDConfig; | ||||
| use oidc_test_client::remote_ip::RemoteIP; | ||||
| use oidc_test_client::state_manager::StateManager; | ||||
|  | ||||
|     /// Public URL, the URL where this service is accessible, without the trailing slash | ||||
|     #[arg(short, long, env, default_value = "http://localhost:7510")] | ||||
|     public_url: String, | ||||
|  | ||||
|     /// URL where the OpenID configuration can be found | ||||
|     #[arg(short, long, env)] | ||||
|     configuration_url: String, | ||||
|  | ||||
|     /// OpenID client ID | ||||
|     #[arg(long, env)] | ||||
|     client_id: String, | ||||
|  | ||||
|     /// OpenID client secret | ||||
|     #[arg(long, env)] | ||||
|     client_secret: String, | ||||
| #[get("/assets/bootstrap.min.css")] | ||||
| async fn bootstrap() -> HttpResponse { | ||||
|     HttpResponse::Ok() | ||||
|         .content_type("text/css") | ||||
|         .body(include_str!("../assets/bootstrap.min.css")) | ||||
| } | ||||
|  | ||||
| lazy_static::lazy_static! { | ||||
|     static ref CONF: AppConfig = { | ||||
|         AppConfig::parse() | ||||
|     }; | ||||
| #[get("/assets/cover.css")] | ||||
| async fn cover() -> HttpResponse { | ||||
|     HttpResponse::Ok() | ||||
|         .content_type("text/css") | ||||
|         .body(include_str!("../assets/cover.css")) | ||||
| } | ||||
|  | ||||
| fn main() { | ||||
|     env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); | ||||
|  | ||||
|     log::info!("Will listen on {}", CONF.listen_addr); | ||||
|  | ||||
|     println!("Hello, world!"); | ||||
| #[derive(Template)] | ||||
| #[template(path = "home.html")] | ||||
| struct HomeTemplate { | ||||
|     redirect_url: String, | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod test { | ||||
|     use crate::AppConfig; | ||||
| #[derive(Template)] | ||||
| #[template(path = "result.html")] | ||||
| struct ResultTemplate { | ||||
|     token: String, | ||||
|     user_info: String, | ||||
| } | ||||
|  | ||||
|     #[test] | ||||
|     fn verify_cli() { | ||||
|         use clap::CommandFactory; | ||||
|         AppConfig::command().debug_assert(); | ||||
| #[derive(Template)] | ||||
| #[template(path = "error.html")] | ||||
| struct ErrorTemplate<'a> { | ||||
|     message: &'a str, | ||||
| } | ||||
|  | ||||
| impl<'a> ErrorTemplate<'a> { | ||||
|     pub fn build(message: &'a str) -> HttpResponse { | ||||
|         HttpResponse::Unauthorized() | ||||
|             .content_type("text/html") | ||||
|             .body(Self { message }.render().unwrap()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[get("/")] | ||||
| async fn home() -> HttpResponse { | ||||
|     HttpResponse::Ok().content_type("text/html").body( | ||||
|         HomeTemplate { | ||||
|             redirect_url: AppConfig::get().redirect_url(), | ||||
|         } | ||||
|         .render() | ||||
|         .unwrap(), | ||||
|     ) | ||||
| } | ||||
|  | ||||
| #[get("/start")] | ||||
| async fn start(remote_ip: RemoteIP) -> HttpResponse { | ||||
|     let config = match OpenIDConfig::load_from(&AppConfig::get().configuration_url).await { | ||||
|         Ok(c) => c, | ||||
|         Err(e) => { | ||||
|             log::error!("Failed to load OpenID configuration! {e}"); | ||||
|             return ErrorTemplate::build("Failed to load OpenID configuration!"); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     let state = match StateManager::gen_state(&remote_ip) { | ||||
|         Ok(s) => s, | ||||
|         Err(e) => { | ||||
|             log::error!("Failed to generate state! {:?}", e); | ||||
|             return ErrorTemplate::build("Failed to generate state!"); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     let authorization_url = config.authorization_url( | ||||
|         &AppConfig::get().client_id, | ||||
|         &state, | ||||
|         &AppConfig::get().redirect_url(), | ||||
|     ); | ||||
|  | ||||
|     HttpResponse::Found() | ||||
|         .append_header(("Location", authorization_url)) | ||||
|         .finish() | ||||
| } | ||||
|  | ||||
| #[derive(serde::Deserialize)] | ||||
| struct RedirectQuery { | ||||
|     state: String, | ||||
|     code: String, | ||||
| } | ||||
|  | ||||
| #[get("/redirect")] | ||||
| async fn redirect(remote_ip: RemoteIP, query: web::Query<RedirectQuery>) -> HttpResponse { | ||||
|     // First, validate state | ||||
|     if let Err(e) = StateManager::validate_state(&remote_ip, &query.state) { | ||||
|         log::error!("Failed to validate state {}: {:?}", query.state, e); | ||||
|         return ErrorTemplate::build("State could not be validated!"); | ||||
|     } | ||||
|  | ||||
|     // Then, load OpenID configuration | ||||
|     let config = match OpenIDConfig::load_from(&AppConfig::get().configuration_url).await { | ||||
|         Ok(c) => c, | ||||
|         Err(e) => { | ||||
|             log::error!("Failed to load OpenID configuration! {e}"); | ||||
|             return ErrorTemplate::build("Failed to load OpenID configuration!"); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     // Query token endpoint | ||||
|     let (token, token_str) = match config | ||||
|         .request_token( | ||||
|             &AppConfig::get().client_id, | ||||
|             &AppConfig::get().client_secret, | ||||
|             &query.code, | ||||
|             &AppConfig::get().redirect_url(), | ||||
|         ) | ||||
|         .await | ||||
|     { | ||||
|         Ok(t) => t, | ||||
|         Err(e) => { | ||||
|             log::error!("Failed to retrieve token! {}", e); | ||||
|             return ErrorTemplate::build("Failed to retrieve access token!"); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     // Query userinfo endpoint | ||||
|     let (_user_info, user_info_str) = match config.request_user_info(&token).await { | ||||
|         Ok(t) => t, | ||||
|         Err(e) => { | ||||
|             log::error!("Failed to retrieve user info! {}", e); | ||||
|             return ErrorTemplate::build("Failed to retrieve user info!"); | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     HttpResponse::Ok().content_type("text/html").body( | ||||
|         ResultTemplate { | ||||
|             token: serde_json::to_string_pretty( | ||||
|                 &serde_json::from_str::<serde_json::Value>(&token_str).unwrap(), | ||||
|             ) | ||||
|             .unwrap(), | ||||
|             user_info: serde_json::to_string_pretty( | ||||
|                 &serde_json::from_str::<serde_json::Value>(&user_info_str).unwrap(), | ||||
|             ) | ||||
|             .unwrap(), | ||||
|         } | ||||
|         .render() | ||||
|         .unwrap(), | ||||
|     ) | ||||
| } | ||||
|  | ||||
| #[actix_web::main] | ||||
| async fn main() -> std::io::Result<()> { | ||||
|     env_logger::init_from_env(env_logger::Env::new().default_filter_or("info")); | ||||
|  | ||||
|     log::info!("Init state manager"); | ||||
|     StateManager::init(); | ||||
|  | ||||
|     log::info!("Will listen on {}", AppConfig::get().listen_addr); | ||||
|  | ||||
|     HttpServer::new(|| { | ||||
|         App::new() | ||||
|             .wrap(Logger::default()) | ||||
|             .service(bootstrap) | ||||
|             .service(cover) | ||||
|             .service(home) | ||||
|             .service(start) | ||||
|             .service(redirect) | ||||
|     }) | ||||
|     .bind(&AppConfig::get().listen_addr) | ||||
|     .expect("Failed to bind server!") | ||||
|     .run() | ||||
|     .await | ||||
| } | ||||
|   | ||||
							
								
								
									
										97
									
								
								src/openid_primitives.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										97
									
								
								src/openid_primitives.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,97 @@ | ||||
| use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; | ||||
| use base64::Engine; | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| use crate::Res; | ||||
|  | ||||
| #[derive(Debug, Clone, serde::Deserialize)] | ||||
| pub struct OpenIDConfig { | ||||
|     pub authorization_endpoint: String, | ||||
|     pub token_endpoint: String, | ||||
|     pub userinfo_endpoint: String, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | ||||
| pub struct TokenResponse { | ||||
|     pub access_token: String, | ||||
|     pub token_type: String, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub refresh_token: Option<String>, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub expires_in: Option<u64>, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub id_token: Option<String>, | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] | ||||
| pub struct UserInfo { | ||||
|     pub sub: String, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub name: Option<String>, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub given_name: Option<String>, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub family_name: Option<String>, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub preferred_username: Option<String>, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub email: Option<String>, | ||||
|     #[serde(skip_serializing_if = "Option::is_none")] | ||||
|     pub email_verified: Option<bool>, | ||||
| } | ||||
|  | ||||
| impl OpenIDConfig { | ||||
|     /// Load OpenID configuration from a given URL | ||||
|     pub async fn load_from(url: &str) -> Res<Self> { | ||||
|         Ok(reqwest::get(url).await?.json().await?) | ||||
|     } | ||||
|  | ||||
|     /// Get the authorization URL where a user should be redirect | ||||
|     pub fn authorization_url(&self, client_id: &str, state: &str, redirect_uri: &str) -> String { | ||||
|         let client_id = urlencoding::encode(client_id); | ||||
|         let state = urlencoding::encode(state); | ||||
|         let redirect_uri = urlencoding::encode(redirect_uri); | ||||
|  | ||||
|         format!("{}?response_type=code&scope=openid%20profile%20email&client_id={client_id}&state={state}&redirect_uri={redirect_uri}", self.authorization_endpoint) | ||||
|     } | ||||
|  | ||||
|     /// Query the token endpoint | ||||
|     pub async fn request_token( | ||||
|         &self, | ||||
|         client_id: &str, | ||||
|         client_secret: &str, | ||||
|         code: &str, | ||||
|         redirect_uri: &str, | ||||
|     ) -> Res<(TokenResponse, String)> { | ||||
|         let authorization = BASE64_STANDARD.encode(format!("{}:{}", client_id, client_secret)); | ||||
|  | ||||
|         let mut params = HashMap::new(); | ||||
|         params.insert("grant_type", "authorization_code"); | ||||
|         params.insert("code", code); | ||||
|         params.insert("redirect_uri", redirect_uri); | ||||
|  | ||||
|         let response = reqwest::Client::new() | ||||
|             .post(&self.token_endpoint) | ||||
|             .header("Authorization", format!("Basic {authorization}")) | ||||
|             .form(¶ms) | ||||
|             .send() | ||||
|             .await? | ||||
|             .text() | ||||
|             .await?; | ||||
|  | ||||
|         Ok((serde_json::from_str(&response)?, response)) | ||||
|     } | ||||
|  | ||||
|     /// Query the UserInfo endpoint | ||||
|     pub async fn request_user_info(&self, token: &TokenResponse) -> Res<(UserInfo, String)> { | ||||
|         let response = reqwest::Client::new() | ||||
|             .get(&self.userinfo_endpoint) | ||||
|             .header("Authorization", format!("Bearer {}", token.access_token)) | ||||
|             .send() | ||||
|             .await? | ||||
|             .text() | ||||
|             .await?; | ||||
|  | ||||
|         Ok((serde_json::from_str(&response)?, response)) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										202
									
								
								src/remote_ip.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										202
									
								
								src/remote_ip.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,202 @@ | ||||
| use std::net::{IpAddr, Ipv6Addr}; | ||||
|  | ||||
| use crate::app_config::AppConfig; | ||||
| use actix_web::dev::Payload; | ||||
| use actix_web::{Error, FromRequest, HttpRequest}; | ||||
| use futures_util::future::{ready, Ready}; | ||||
| use std::str::FromStr; | ||||
|  | ||||
| /// Parse an IP address | ||||
| pub fn parse_ip(ip: &str) -> Option<IpAddr> { | ||||
|     let mut ip = match IpAddr::from_str(ip) { | ||||
|         Ok(ip) => ip, | ||||
|         Err(e) => { | ||||
|             log::warn!("Failed to parse an IP address: {}", e); | ||||
|             return None; | ||||
|         } | ||||
|     }; | ||||
|  | ||||
|     if let IpAddr::V6(ipv6) = &mut ip { | ||||
|         let mut octets = ipv6.octets(); | ||||
|         for o in octets.iter_mut().skip(8) { | ||||
|             *o = 0; | ||||
|         } | ||||
|         ip = IpAddr::V6(Ipv6Addr::from(octets)); | ||||
|     } | ||||
|  | ||||
|     Some(ip) | ||||
| } | ||||
|  | ||||
| /// Check if two ips matches | ||||
| pub fn match_ip(pattern: &str, ip: &str) -> bool { | ||||
|     if pattern.eq(ip) { | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     if pattern.ends_with('*') && ip.starts_with(&pattern.replace('*', "")) { | ||||
|         return true; | ||||
|     } | ||||
|  | ||||
|     false | ||||
| } | ||||
|  | ||||
| /// Get the remote IP address | ||||
| pub fn get_remote_ip(req: &HttpRequest, proxy_ip: Option<&str>) -> IpAddr { | ||||
|     let mut ip = req.peer_addr().unwrap().ip(); | ||||
|  | ||||
|     // We check if the request comes from a trusted reverse proxy | ||||
|     if let Some(proxy) = proxy_ip.as_ref() { | ||||
|         if match_ip(proxy, &ip.to_string()) { | ||||
|             if let Some(header) = req.headers().get("X-Forwarded-For") { | ||||
|                 let header = header.to_str().unwrap(); | ||||
|  | ||||
|                 let remote_ip = if let Some((upstream_ip, _)) = header.split_once(',') { | ||||
|                     upstream_ip | ||||
|                 } else { | ||||
|                     header | ||||
|                 }; | ||||
|  | ||||
|                 if let Some(upstream_ip) = parse_ip(remote_ip) { | ||||
|                     ip = upstream_ip; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     ip | ||||
| } | ||||
|  | ||||
| #[derive(Copy, Clone, Debug, Eq, PartialEq)] | ||||
| pub struct RemoteIP(pub IpAddr); | ||||
|  | ||||
| impl From<RemoteIP> for IpAddr { | ||||
|     fn from(i: RemoteIP) -> Self { | ||||
|         i.0 | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl FromRequest for RemoteIP { | ||||
|     type Error = Error; | ||||
|     type Future = Ready<Result<Self, Error>>; | ||||
|  | ||||
|     #[inline] | ||||
|     fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future { | ||||
|         ready(Ok(RemoteIP(get_remote_ip( | ||||
|             req, | ||||
|             AppConfig::get().proxy_ip.as_deref(), | ||||
|         )))) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod test { | ||||
|     use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; | ||||
|     use std::str::FromStr; | ||||
|  | ||||
|     use crate::remote_ip::{get_remote_ip, parse_ip}; | ||||
|     use actix_web::test::TestRequest; | ||||
|  | ||||
|     #[test] | ||||
|     fn test_get_remote_ip() { | ||||
|         let req = TestRequest::default() | ||||
|             .peer_addr(SocketAddr::from_str("192.168.1.1:1000").unwrap()) | ||||
|             .to_http_request(); | ||||
|         assert_eq!( | ||||
|             get_remote_ip(&req, None), | ||||
|             "192.168.1.1".parse::<IpAddr>().unwrap() | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_get_remote_ip_from_proxy() { | ||||
|         let req = TestRequest::default() | ||||
|             .peer_addr(SocketAddr::from_str("192.168.1.1:1000").unwrap()) | ||||
|             .insert_header(("X-Forwarded-For", "1.1.1.1")) | ||||
|             .to_http_request(); | ||||
|         assert_eq!( | ||||
|             get_remote_ip(&req, Some("192.168.1.1")), | ||||
|             "1.1.1.1".parse::<IpAddr>().unwrap() | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_get_remote_ip_from_proxy_2() { | ||||
|         let req = TestRequest::default() | ||||
|             .peer_addr(SocketAddr::from_str("192.168.1.1:1000").unwrap()) | ||||
|             .insert_header(("X-Forwarded-For", "1.1.1.1, 1.2.2.2")) | ||||
|             .to_http_request(); | ||||
|         assert_eq!( | ||||
|             get_remote_ip(&req, Some("192.168.1.1")), | ||||
|             "1.1.1.1".parse::<IpAddr>().unwrap() | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_get_remote_ip_from_proxy_ipv6() { | ||||
|         let req = TestRequest::default() | ||||
|             .peer_addr(SocketAddr::from_str("192.168.1.1:1000").unwrap()) | ||||
|             .insert_header(("X-Forwarded-For", "10::1, 1.2.2.2")) | ||||
|             .to_http_request(); | ||||
|         assert_eq!( | ||||
|             get_remote_ip(&req, Some("192.168.1.1")), | ||||
|             "10::".parse::<IpAddr>().unwrap() | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_get_remote_ip_from_no_proxy() { | ||||
|         let req = TestRequest::default() | ||||
|             .peer_addr(SocketAddr::from_str("192.168.1.1:1000").unwrap()) | ||||
|             .insert_header(("X-Forwarded-For", "1.1.1.1, 1.2.2.2")) | ||||
|             .to_http_request(); | ||||
|         assert_eq!( | ||||
|             get_remote_ip(&req, None), | ||||
|             "192.168.1.1".parse::<IpAddr>().unwrap() | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_get_remote_ip_from_other_proxy() { | ||||
|         let req = TestRequest::default() | ||||
|             .peer_addr(SocketAddr::from_str("192.168.1.1:1000").unwrap()) | ||||
|             .insert_header(("X-Forwarded-For", "1.1.1.1, 1.2.2.2")) | ||||
|             .to_http_request(); | ||||
|         assert_eq!( | ||||
|             get_remote_ip(&req, Some("192.168.1.2")), | ||||
|             "192.168.1.1".parse::<IpAddr>().unwrap() | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn parse_bad_ip() { | ||||
|         let ip = parse_ip("badbad"); | ||||
|         assert_eq!(None, ip); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn parse_ip_v4_address() { | ||||
|         let ip = parse_ip("192.168.1.1").unwrap(); | ||||
|         assert_eq!(ip, IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1))); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn parse_ip_v6_address() { | ||||
|         let ip = parse_ip("2a00:1450:4007:813::200e").unwrap(); | ||||
|         assert_eq!( | ||||
|             ip, | ||||
|             IpAddr::V6(Ipv6Addr::new(0x2a00, 0x1450, 0x4007, 0x813, 0, 0, 0, 0)) | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn parse_ip_v6_address_2() { | ||||
|         let ip = parse_ip("::1").unwrap(); | ||||
|         assert_eq!(ip, IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0))); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn parse_ip_v6_address_3() { | ||||
|         let ip = parse_ip("a::1").unwrap(); | ||||
|         assert_eq!(ip, IpAddr::V6(Ipv6Addr::new(0xa, 0, 0, 0, 0, 0, 0, 0))); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										72
									
								
								src/state_manager.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								src/state_manager.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| use std::error::Error; | ||||
| use std::fmt; | ||||
|  | ||||
| use crate::crypto_wrapper::CryptoWrapper; | ||||
| use crate::remote_ip::RemoteIP; | ||||
| use crate::time_utils::time; | ||||
| use crate::Res; | ||||
| use bincode::{Decode, Encode}; | ||||
| use std::net::IpAddr; | ||||
|  | ||||
| pub struct StateManager; | ||||
|  | ||||
| static mut WRAPPER: Option<CryptoWrapper> = None; | ||||
|  | ||||
| #[derive(Encode, Decode, Debug)] | ||||
| struct State { | ||||
|     ip: IpAddr, | ||||
|     expire: u64, | ||||
| } | ||||
|  | ||||
| impl State { | ||||
|     pub fn new(ip: IpAddr) -> Self { | ||||
|         Self { | ||||
|             ip, | ||||
|             expire: time() + 15 * 60, | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[derive(Debug, Copy, Clone)] | ||||
| enum StateError { | ||||
|     InvalidIp, | ||||
|     Expired, | ||||
| } | ||||
|  | ||||
| impl Error for StateError {} | ||||
|  | ||||
| impl fmt::Display for StateError { | ||||
|     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { | ||||
|         write!(f, "StateManager error {:?}", self) | ||||
|     } | ||||
| } | ||||
|  | ||||
| impl StateManager { | ||||
|     pub fn init() { | ||||
|         unsafe { | ||||
|             WRAPPER = Some(CryptoWrapper::new_random()); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Generate a new state | ||||
|     pub fn gen_state(ip: &RemoteIP) -> Res<String> { | ||||
|         let state = State::new(ip.0); | ||||
|  | ||||
|         unsafe { WRAPPER.as_ref().unwrap() }.encrypt(&state) | ||||
|     } | ||||
|  | ||||
|     /// Validate generated state | ||||
|     pub fn validate_state(ip: &RemoteIP, state: &str) -> Res { | ||||
|         let state: State = unsafe { WRAPPER.as_ref().unwrap() }.decrypt(state)?; | ||||
|  | ||||
|         if state.ip != ip.0 { | ||||
|             return Err(Box::new(StateError::InvalidIp)); | ||||
|         } | ||||
|  | ||||
|         if state.expire < time() { | ||||
|             return Err(Box::new(StateError::Expired)); | ||||
|         } | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										9
									
								
								src/time_utils.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/time_utils.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| use std::time::{SystemTime, UNIX_EPOCH}; | ||||
|  | ||||
| /// Get current time since epoch | ||||
| pub fn time() -> u64 { | ||||
|     SystemTime::now() | ||||
|         .duration_since(UNIX_EPOCH) | ||||
|         .unwrap() | ||||
|         .as_secs() | ||||
| } | ||||
							
								
								
									
										41
									
								
								templates/base_page.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								templates/base_page.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| <!doctype html> | ||||
| <html lang="en" class="h-100" data-bs-theme="auto"> | ||||
| <head> | ||||
|  | ||||
|   <meta charset="utf-8"> | ||||
|   <meta name="viewport" content="width=device-width, initial-scale=1"> | ||||
|   <meta name="description" content="OIDC basic test client"> | ||||
|   <meta name="author" content="Pierre HUBERT"> | ||||
|   <title>OIDC Test client</title> | ||||
|  | ||||
|   <link href="/assets/bootstrap.min.css" rel="stylesheet" integrity="sha384-KK94CHFLLe+nY2dmCWGMq91rCGa5gtU4mk92HdvYe+M/SXH301p5ILy+dN9+nJOZ" crossorigin="anonymous"> | ||||
|   <link href="/assets/cover.css" rel="stylesheet" /> | ||||
|  | ||||
|   <meta name="theme-color" content="#712cf9"> | ||||
|  | ||||
| </head> | ||||
| <body class="d-flex h-100 text-center text-bg-dark"> | ||||
|  | ||||
|  | ||||
| <div class="cover-container d-flex w-100 h-100 p-3 mx-auto flex-column"> | ||||
|   <header class="mb-auto"> | ||||
|     <div> | ||||
|       <h3 class="float-md-start mb-0">OIDC test client</h3> | ||||
|  | ||||
|     </div> | ||||
|   </header> | ||||
|  | ||||
|   <main class="px-3"> | ||||
|     {% block content %} | ||||
|     TO_REPLACE | ||||
|     {% endblock content %} | ||||
|   </main> | ||||
|  | ||||
|   <footer class="mt-auto text-white-50"> | ||||
|     <p>© Pierre HUBERT</p> | ||||
|   </footer> | ||||
| </div> | ||||
|  | ||||
|  | ||||
| </body> | ||||
| </html> | ||||
							
								
								
									
										9
									
								
								templates/error.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								templates/error.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| {% extends "base_page.html" %} | ||||
| {% block content %} | ||||
|  | ||||
| <div class="alert alert-danger" role="alert"> | ||||
|   {{ message }} | ||||
| </div> | ||||
|  | ||||
| <a class="btn btn-primary" href="/start" role="button">Start again</a> | ||||
| {% endblock content %} | ||||
							
								
								
									
										11
									
								
								templates/home.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								templates/home.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| {% extends "base_page.html" %} | ||||
| {% block content %} | ||||
|  | ||||
| <h1>Test OIDC Authentication flow.</h1> | ||||
| <p class="lead">Get started testing OIDC authentication flow</p> | ||||
| <p>Redirect URI: {{ redirect_url }}</p> | ||||
| <p class="lead"> | ||||
|     <a href="/start" class="btn btn-lg btn-light fw-bold border-white bg-white">Start</a> | ||||
| </p> | ||||
|  | ||||
| {% endblock content %} | ||||
							
								
								
									
										30
									
								
								templates/result.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								templates/result.html
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,30 @@ | ||||
| {% extends "base_page.html" %} | ||||
| {% block content %} | ||||
|  | ||||
| <style> | ||||
|     .card { | ||||
|         text-align: left; | ||||
|         margin-bottom: 20px; | ||||
|     } | ||||
| </style> | ||||
|  | ||||
| <div class="alert alert-success" role="alert"> | ||||
|     Login successful | ||||
| </div> | ||||
|  | ||||
| <div class="card"> | ||||
|     <div class="card-body"> | ||||
|         <h5 class="card-title">Token response</h5> | ||||
|         <pre class="card-text">{{ token }}</pre> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <div class="card"> | ||||
|     <div class="card-body"> | ||||
|         <h5 class="card-title">User info</h5> | ||||
|         <pre class="card-text">{{ user_info }}</pre> | ||||
|     </div> | ||||
| </div> | ||||
|  | ||||
| <a class="btn btn-primary" href="/start" role="button">Start again</a> | ||||
| {% endblock content %} | ||||
		Reference in New Issue
	
	Block a user