From d69b44528ed3ca34ddf9b6779e1e3f1780de8c84 Mon Sep 17 00:00:00 2001 From: Pierre Hubert Date: Tue, 12 Apr 2022 20:40:44 +0200 Subject: [PATCH] Add `/openid/token` route --- Cargo.lock | 1 + Cargo.toml | 3 +- src/actors/openid_sessions_actor.rs | 43 +++++++- src/constants.rs | 7 +- src/controllers/openid_controller.rs | 140 +++++++++++++++++++++++++-- src/data/openid_config.rs | 3 + src/main.rs | 3 +- src/middlewares/auth_middleware.rs | 4 +- 8 files changed, 188 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0199a67..e4e1de3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -386,6 +386,7 @@ dependencies = [ "actix-identity", "actix-web", "askama", + "base64", "bcrypt", "clap", "env_logger", diff --git a/Cargo.toml b/Cargo.toml index 9de3ab6..9b565c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,4 +22,5 @@ mime_guess = "2.0.4" askama = "0.11.1" futures-util = "0.3.21" urlencoding = "2.1.0" -rand = "0.8.5" \ No newline at end of file +rand = "0.8.5" +base64 = "0.13.0" \ No newline at end of file diff --git a/src/actors/openid_sessions_actor.rs b/src/actors/openid_sessions_actor.rs index ae27867..9856343 100644 --- a/src/actors/openid_sessions_actor.rs +++ b/src/actors/openid_sessions_actor.rs @@ -17,10 +17,13 @@ pub struct Session { pub redirect_uri: String, pub authorization_code: String, - pub code_expire_on: u64, + pub authorization_code_expire_at: u64, + pub authorization_code_used: bool, - pub token: String, - pub token_expire_at: u64, + pub access_token: String, + pub access_token_expire_at: u64, + pub refresh_token: String, + pub refresh_token_expire_at: u64, pub nonce: Option, pub code_challenge: Option<(String, String)>, @@ -28,7 +31,8 @@ pub struct Session { impl Session { pub fn is_expired(&self) -> bool { - self.code_expire_on < time() && self.token_expire_at < time() + self.authorization_code_expire_at < time() && self.access_token_expire_at < time() + && self.refresh_token_expire_at < time() } } @@ -36,6 +40,14 @@ impl Session { #[rtype(result = "()")] pub struct PushNewSession(pub Session); +#[derive(Message)] +#[rtype(result = "Option")] +pub struct FindSessionByAuthorizationCode(pub String); + +#[derive(Message)] +#[rtype(result = "()")] +pub struct MarkAuthorizationCodeUsed(pub String); + #[derive(Default)] pub struct OpenIDSessionsActor { session: Vec, @@ -65,4 +77,27 @@ impl Handler for OpenIDSessionsActor { fn handle(&mut self, msg: PushNewSession, _ctx: &mut Self::Context) -> Self::Result { self.session.push(msg.0) } +} + +impl Handler for OpenIDSessionsActor { + type Result = Option; + + fn handle(&mut self, msg: FindSessionByAuthorizationCode, _ctx: &mut Self::Context) -> Self::Result { + self.session + .iter() + .find(|f| f.authorization_code.eq(&msg.0)) + .cloned() + } +} + +impl Handler for OpenIDSessionsActor { + type Result = (); + + fn handle(&mut self, msg: MarkAuthorizationCodeUsed, _ctx: &mut Self::Context) -> Self::Result { + if let Some(r) = self.session + .iter_mut() + .find(|f| f.authorization_code.eq(&msg.0)) { + r.authorization_code_used = true; + } + } } \ No newline at end of file diff --git a/src/constants.rs b/src/constants.rs index bb165a4..a96ce1e 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -44,11 +44,14 @@ pub const TEMPORARY_PASSWORDS_LEN: usize = 20; /// Open ID routes pub const AUTHORIZE_URI: &str = "/openid/authorize"; +pub const TOKEN_URI: &str = "/openid/token"; /// Open ID constants pub const OPEN_ID_SESSION_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); pub const OPEN_ID_SESSION_LEN: usize = 40; pub const OPEN_ID_AUTHORIZATION_CODE_LEN: usize = 120; pub const OPEN_ID_AUTHORIZATION_CODE_TIMEOUT: u64 = 300; -pub const OPEN_ID_TOKEN_LEN: usize = 120; -pub const OPEN_ID_TOKEN_TIMEOUT: u64 = 3600; \ No newline at end of file +pub const OPEN_ID_ACCESS_TOKEN_LEN: usize = 120; +pub const OPEN_ID_ACCESS_TOKEN_TIMEOUT: u64 = 3600; +pub const OPEN_ID_REFRESH_TOKEN_LEN: usize = 120; +pub const OPEN_ID_REFRESH_TOKEN_TIMEOUT: u64 = 360000; \ No newline at end of file diff --git a/src/controllers/openid_controller.rs b/src/controllers/openid_controller.rs index 48a8724..8171eff 100644 --- a/src/controllers/openid_controller.rs +++ b/src/controllers/openid_controller.rs @@ -1,10 +1,11 @@ use actix::Addr; -use actix_web::{HttpResponse, Responder, web}; +use actix_web::{HttpRequest, HttpResponse, Responder, web}; +use actix_web::error::ErrorUnauthorized; use askama::Template; use crate::actors::openid_sessions_actor; use crate::actors::openid_sessions_actor::{OpenIDSessionsActor, Session, SessionID}; -use crate::constants::{AUTHORIZE_URI, OPEN_ID_AUTHORIZATION_CODE_LEN, OPEN_ID_AUTHORIZATION_CODE_TIMEOUT, OPEN_ID_SESSION_LEN, OPEN_ID_TOKEN_LEN, OPEN_ID_TOKEN_TIMEOUT}; +use crate::constants::{AUTHORIZE_URI, OPEN_ID_ACCESS_TOKEN_LEN, OPEN_ID_ACCESS_TOKEN_TIMEOUT, OPEN_ID_AUTHORIZATION_CODE_LEN, OPEN_ID_AUTHORIZATION_CODE_TIMEOUT, OPEN_ID_REFRESH_TOKEN_LEN, OPEN_ID_REFRESH_TOKEN_TIMEOUT, OPEN_ID_SESSION_LEN, TOKEN_URI}; use crate::controllers::base_controller::FatalErrorPage; use crate::data::app_config::AppConfig; use crate::data::client::{ClientID, ClientManager}; @@ -17,13 +18,14 @@ pub async fn get_configuration(app_conf: web::Data) -> impl Responder HttpResponse::Ok().json(OpenIDConfig { issuer: app_conf.full_url("/"), authorization_endpoint: app_conf.full_url(AUTHORIZE_URI), - token_endpoint: app_conf.full_url("openid/token"), + token_endpoint: app_conf.full_url(TOKEN_URI), userinfo_endpoint: app_conf.full_url("openid/userinfo"), jwks_uri: app_conf.full_url("openid/jwks_uri"), scopes_supported: vec!["openid", "profile", "email"], response_types_supported: vec!["code", "id_token", "token id_token"], subject_types_supported: vec!["public"], id_token_signing_alg_values_supported: vec!["RS256"], + token_endpoint_auth_methods_supported: vec!["client_secret_post", "client_secret_basic"], claims_supported: vec!["sub", "exp", "name", "given_name", "family_name", "email"], }) } @@ -123,9 +125,12 @@ pub async fn authorize(user: CurrentUser, query: web::Query, user: user.uid.clone(), redirect_uri, authorization_code: rand_str(OPEN_ID_AUTHORIZATION_CODE_LEN), - code_expire_on: time() + OPEN_ID_AUTHORIZATION_CODE_TIMEOUT, - token: rand_str(OPEN_ID_TOKEN_LEN), - token_expire_at: time() + OPEN_ID_TOKEN_TIMEOUT, + authorization_code_expire_at: time() + OPEN_ID_AUTHORIZATION_CODE_TIMEOUT, + authorization_code_used: false, + access_token: rand_str(OPEN_ID_ACCESS_TOKEN_LEN), + access_token_expire_at: time() + OPEN_ID_ACCESS_TOKEN_TIMEOUT, + refresh_token: rand_str(OPEN_ID_REFRESH_TOKEN_LEN), + refresh_token_expire_at: time() + OPEN_ID_REFRESH_TOKEN_TIMEOUT, nonce: query.0.nonce, code_challenge, }; @@ -140,4 +145,127 @@ pub async fn authorize(user: CurrentUser, query: web::Query, urlencoding::encode(&query.0.state), urlencoding::encode(&session.authorization_code) ))).finish() +} + +#[derive(Debug, serde::Deserialize)] +pub struct TokenQuery { + grant_type: String, + client_id: Option, + client_secret: Option, + redirect_uri: String, + code: String, +} + +#[derive(Debug, serde::Serialize)] +pub struct TokenResponse { + access_token: String, + token_type: &'static str, + refresh_token: String, + expires_in: u64, + id_token: String, +} + +pub async fn token(req: HttpRequest, + query: web::Form, + clients: web::Data, + sessions: web::Data>) -> actix_web::Result { + + // Extraction authentication information + let authorization_header = req.headers().get("authorization"); + let (client_id, client_secret) = match (&query.client_id, &query.client_secret, authorization_header) { + // post authentication + (Some(client_id), Some(client_secret), None) => { + (client_id.clone(), client_secret.to_string()) + } + + // Basic authentication + (None, None, Some(v)) => { + let token = match v.to_str().unwrap_or_default().strip_prefix("Basic ") { + None => { + log::warn!("Token request failed: Authorization header does not start with 'Basic '! => got '{:#?}'", v); + return Ok(HttpResponse::Unauthorized().body("Authorization header does not start with 'Basic '")); + } + Some(v) => v + }; + + let decode = String::from_utf8_lossy(&match base64::decode(token) { + Ok(d) => d, + Err(e) => { + log::warn!("Failed to decode authorization header! {:?}", e); + return Ok(HttpResponse::InternalServerError().body("Failed to decode authorization header!")); + } + }).to_string(); + + match decode.split_once(':') { + None => (ClientID(decode), "".to_string()), + Some((id, secret)) => (ClientID(id.to_string()), secret.to_string()) + } + } + + _ => { + log::warn!("Token request failed: Unknown client authentication method! {:#?}", query.0); + return Ok(HttpResponse::BadRequest().body("Authentication method unknown!")); + } + }; + + let client = clients + .find_by_id(&client_id) + .ok_or_else(|| ErrorUnauthorized("Client not found"))?; + + if !client.secret.eq(&client_secret) { + log::warn!("Token request failed: client secret is invalid! {:#?}", query.0); + return Ok(HttpResponse::Unauthorized().body("Client secret is invalid!")); + } + + if query.grant_type != "authorization_code" { + log::warn!("Token request failed: Grant type unsupported! {:#?}", query.0); + return Ok(HttpResponse::BadRequest().body("Grant type unsupported!")); + } + + let session: Session = match sessions + .send(openid_sessions_actor::FindSessionByAuthorizationCode(query.code.clone())) + .await.unwrap() + { + None => { + log::warn!("Token request failed: Session not found! {:#?}", query.0); + return Ok(HttpResponse::NotFound().body("Session not found!")); + } + Some(s) => s, + }; + + if session.client != client.id { + log::warn!("Token request failed: Client mismatch! {:#?}", query.0); + return Ok(HttpResponse::Unauthorized().body("Client mismatch!")); + } + + if session.redirect_uri != query.redirect_uri { + log::warn!("Token request failed: Invalid redirect URI! {:#?}", query.0); + return Ok(HttpResponse::Unauthorized().body("Invalid redirect URI!")); + } + + if session.authorization_code_expire_at < time() { + log::warn!("Token request failed: Authorization code expired! {:#?}", query.0); + return Ok(HttpResponse::Unauthorized().body("Authorization code expired!")); + } + + if session.authorization_code_used { + log::warn!("Token request failed: Authorization already used! {:#?}", query.0); + return Ok(HttpResponse::Unauthorized().body("Authorization already used!")); + } + + // Mark session as used + sessions.send(openid_sessions_actor::MarkAuthorizationCodeUsed(session.authorization_code)) + .await.unwrap(); + + + Ok(HttpResponse::Ok() + .append_header(("Cache-Control", "no-store")) + .append_header(("Pragam", "no-cache")) + .json(TokenResponse { + access_token: session.access_token, + token_type: "Bearer", + refresh_token: session.refresh_token, + expires_in: session.access_token_expire_at - time(), + id_token: session.session_id.0, + })) } \ No newline at end of file diff --git a/src/data/openid_config.rs b/src/data/openid_config.rs index 70486e9..9575dad 100644 --- a/src/data/openid_config.rs +++ b/src/data/openid_config.rs @@ -27,6 +27,9 @@ pub struct OpenIDConfig { /// REQUIRED. JSON array containing a list of the JWS signing algorithms (alg values) supported by the OP for the ID Token to encode the Claims in a JWT `[`JWT`. The algorithm RS256 MUST be included. The value none MAY be supported, but MUST NOT be used unless the Response Type used returns no ID Token from the Authorization Endpoint (such as when using the Authorization Code Flow). pub id_token_signing_alg_values_supported: Vec<&'static str>, + /// OPTIONAL. JSON array containing a list of Client Authentication methods supported by this Token Endpoint. The options are client_secret_post, client_secret_basic, client_secret_jwt, and private_key_jwt + pub token_endpoint_auth_methods_supported: Vec<&'static str>, + /// RECOMMENDED. JSON array containing a list of the Claim Names of the Claims that the OpenID Provider MAY be able to supply values for. Note that for privacy or other reasons, this might not be an exhaustive list. pub claims_supported: Vec<&'static str>, } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 49ca2ff..4c3b4a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -129,7 +129,8 @@ async fn main() -> std::io::Result<()> { // OpenID routes .route("/.well-known/openid-configuration", web::get().to(openid_controller::get_configuration)) - .route("/openid/authorize", web::get().to(openid_controller::authorize)) + .route(AUTHORIZE_URI, web::get().to(openid_controller::authorize)) + .route(TOKEN_URI, web::post().to(openid_controller::token)) }) .bind(listen_address)? .run() diff --git a/src/middlewares/auth_middleware.rs b/src/middlewares/auth_middleware.rs index 2085980..cbe85c3 100644 --- a/src/middlewares/auth_middleware.rs +++ b/src/middlewares/auth_middleware.rs @@ -13,7 +13,7 @@ use actix_web::body::EitherBody; use actix_web::http::{header, Method}; use askama::Template; -use crate::constants::{ADMIN_ROUTES, AUTHENTICATED_ROUTES, AUTHORIZE_URI}; +use crate::constants::{ADMIN_ROUTES, AUTHENTICATED_ROUTES, AUTHORIZE_URI, TOKEN_URI}; use crate::controllers::base_controller::{FatalErrorPage, redirect_user_for_login}; use crate::data::app_config::AppConfig; use crate::data::session_identity::{SessionIdentity, SessionIdentityData, SessionStatus}; @@ -91,7 +91,7 @@ impl Service for AuthInnerMiddleware // Check if POST request comes from another website (block invalid origins) let origin = req.headers().get(header::ORIGIN); - if req.method() == Method::POST { + if req.method() == Method::POST && req.path() != TOKEN_URI { if let Some(o) = origin { if !o.to_str().unwrap_or("bad").eq(&config.website_origin) { log::warn!(