diff --git a/src/api2/access.rs b/src/api2/access.rs index 46725c97..e5430f62 100644 --- a/src/api2/access.rs +++ b/src/api2/access.rs @@ -26,6 +26,7 @@ pub mod domain; pub mod role; pub mod tfa; pub mod user; +pub mod openid; #[allow(clippy::large_enum_variant)] enum AuthResult { @@ -335,7 +336,7 @@ pub fn list_permissions( let auth_id = match auth_id { Some(auth_id) if auth_id == current_auth_id => current_auth_id, Some(auth_id) => { - if user_privs & PRIV_SYS_AUDIT != 0 + if user_privs & PRIV_SYS_AUDIT != 0 || (auth_id.is_token() && !current_auth_id.is_token() && auth_id.user() == current_auth_id.user()) @@ -423,6 +424,7 @@ const SUBDIRS: SubdirMap = &sorted!([ &Router::new().get(&API_METHOD_LIST_PERMISSIONS) ), ("ticket", &Router::new().post(&API_METHOD_CREATE_TICKET)), + ("openid", &openid::ROUTER), ("domains", &domain::ROUTER), ("roles", &role::ROUTER), ("users", &user::ROUTER), diff --git a/src/api2/access/domain.rs b/src/api2/access/domain.rs index 2dff9d32..69809acc 100644 --- a/src/api2/access/domain.rs +++ b/src/api2/access/domain.rs @@ -60,9 +60,6 @@ fn list_domains() -> Result { } Ok(list.into()) - - - } pub const ROUTER: Router = Router::new() diff --git a/src/api2/access/openid.rs b/src/api2/access/openid.rs new file mode 100644 index 00000000..3a3afefb --- /dev/null +++ b/src/api2/access/openid.rs @@ -0,0 +1,192 @@ +//! OpenID redirect/login API +use std::convert::TryFrom; + +use anyhow::{bail, Error}; + +use serde_json::{json, Value}; + +use proxmox::api::router::{Router, SubdirMap}; +use proxmox::api::{api, Permission, RpcEnvironment}; +use proxmox::{list_subdirs_api_method}; +use proxmox::{identity, sortable}; +use proxmox::tools::fs::open_file_locked; + +use proxmox_openid::OpenIdAuthenticator; + +use crate::server::ticket::ApiTicket; +use crate::tools::ticket::Ticket; + +use crate::config::domains::{OpenIdUserAttribute, OpenIdRealmConfig}; +use crate::config::cached_user_info::CachedUserInfo; + +use crate::api2::types::*; +use crate::auth_helpers::*; + +#[api( + input: { + properties: { + state: { + description: "OpenId state.", + type: String, + }, + code: { + description: "OpenId authorization code.", + type: String, + }, + "redirect-url": { + description: "Redirection Url. The client should set this to used server url.", + type: String, + }, + }, + }, + returns: { + properties: { + username: { + type: String, + description: "User name.", + }, + ticket: { + type: String, + description: "Auth ticket.", + }, + CSRFPreventionToken: { + type: String, + description: "Cross Site Request Forgery Prevention Token.", + }, + }, + }, + protected: true, + access: { + permission: &Permission::World, + }, +)] +/// Verify OpenID authorization code and create a ticket +/// +/// Returns: An authentication ticket with additional infos. +pub fn openid_login( + state: String, + code: String, + redirect_url: String, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result { + let user_info = CachedUserInfo::new()?; + + let (realm, private_auth_state) = + OpenIdAuthenticator::verify_public_auth_state(PROXMOX_BACKUP_RUN_DIR_M!(), &state)?; + + let (domains, _digest) = crate::config::domains::config()?; + let config: OpenIdRealmConfig = domains.lookup("openid", &realm)?; + + let open_id = config.authenticator(&redirect_url)?; + + let info = open_id.verify_authorization_code(&code, &private_auth_state)?; + + // eprintln!("VERIFIED {} {:?} {:?}", info.subject().as_str(), info.name(), info.email()); + + let unique_name = match config.username_claim { + None | Some(OpenIdUserAttribute::Subject) => info.subject().as_str(), + Some(OpenIdUserAttribute::Username) => { + match info.preferred_username() { + Some(name) => name.as_str(), + None => bail!("missing claim 'preferred_name'"), + } + } + Some(OpenIdUserAttribute::Email) => { + match info.email() { + Some(name) => name.as_str(), + None => bail!("missing claim 'email'"), + } + } + }; + + let user_id = Userid::try_from(format!("{}@{}", unique_name, realm))?; + + if !user_info.is_active_user_id(&user_id) { + if config.autocreate.unwrap_or(false) { + use crate::config::user; + let _lock = open_file_locked(user::USER_CFG_LOCKFILE, std::time::Duration::new(10, 0), true)?; + let user = user::User { + userid: user_id.clone(), + comment: None, + enable: None, + expire: None, + firstname: info.given_name().and_then(|n| n.get(None)).map(|n| n.to_string()), + lastname: info.family_name().and_then(|n| n.get(None)).map(|n| n.to_string()), + email: info.email().map(|e| e.to_string()), + }; + let (mut config, _digest) = user::config()?; + if config.sections.get(user.userid.as_str()).is_some() { + bail!("autocreate user failed - '{}' already exists.", user.userid); + } + config.set_data(user.userid.as_str(), "user", &user)?; + user::save_config(&config)?; + // fixme: replace sleep with shared memory change notification + std::thread::sleep(std::time::Duration::new(6, 0)); + } else { + bail!("user account '{}' missing, disabled or expired.", user_id); + } + } + + let api_ticket = ApiTicket::full(user_id.clone()); + let ticket = Ticket::new("PBS", &api_ticket)?.sign(private_auth_key(), None)?; + let token = assemble_csrf_prevention_token(csrf_secret(), &user_id); + + crate::server::rest::auth_logger()? + .log(format!("successful auth for user '{}'", user_id)); + + Ok(json!({ + "username": user_id, + "ticket": ticket, + "CSRFPreventionToken": token, + })) +} + +#[api( + protected: true, + input: { + properties: { + realm: { + schema: REALM_ID_SCHEMA, + }, + "redirect-url": { + description: "Redirection Url. The client should set this to used server url.", + type: String, + }, + }, + }, + returns: { + description: "Redirection URL.", + type: String, + }, + access: { + description: "Anyone can access this (before the user is authenticated).", + permission: &Permission::World, + }, +)] +/// Create OpenID Redirect Session +fn openid_auth_url( + realm: String, + redirect_url: String, + _rpcenv: &mut dyn RpcEnvironment, +) -> Result { + + let (domains, _digest) = crate::config::domains::config()?; + let config: OpenIdRealmConfig = domains.lookup("openid", &realm)?; + + let open_id = config.authenticator(&redirect_url)?; + + let url = open_id.authorize_url(PROXMOX_BACKUP_RUN_DIR_M!(), &realm)? + .to_string(); + + Ok(url.into()) +} + +#[sortable] +const SUBDIRS: SubdirMap = &sorted!([ + ("login", &Router::new().post(&API_METHOD_OPENID_LOGIN)), + ("auth-url", &Router::new().post(&API_METHOD_OPENID_AUTH_URL)), +]); + +pub const ROUTER: Router = Router::new() + .get(&list_subdirs_api_method!(SUBDIRS)) + .subdirs(SUBDIRS); diff --git a/src/api2/config/access/openid.rs b/src/api2/config/access/openid.rs index 15fddaf0..9325de94 100644 --- a/src/api2/config/access/openid.rs +++ b/src/api2/config/access/openid.rs @@ -153,6 +153,8 @@ pub enum DeletableProperty { client_key, /// Delete the comment property. comment, + /// Delete the autocreate property + autocreate, } #[api( @@ -177,6 +179,11 @@ pub enum DeletableProperty { type: String, optional: true, }, + autocreate: { + description: "Automatically create users if they do not exist.", + optional: true, + type: bool, + }, comment: { schema: SINGLE_LINE_COMMENT_SCHEMA, optional: true, @@ -206,6 +213,7 @@ pub fn update_openid_realm( issuer_url: Option, client_id: Option, client_key: Option, + autocreate: Option, comment: Option, delete: Option>, digest: Option, @@ -228,6 +236,7 @@ pub fn update_openid_realm( match delete_prop { DeletableProperty::client_key => { config.client_key = None; }, DeletableProperty::comment => { config.comment = None; }, + DeletableProperty::autocreate => { config.autocreate = None; }, } } } @@ -245,6 +254,7 @@ pub fn update_openid_realm( if let Some(client_id) = client_id { config.client_id = client_id; } if client_key.is_some() { config.client_key = client_key; } + if autocreate.is_some() { config.autocreate = autocreate; } domains.set_data(&realm, "openid", &config)?; diff --git a/src/config/domains.rs b/src/config/domains.rs index 3cdd4174..d08efc24 100644 --- a/src/config/domains.rs +++ b/src/config/domains.rs @@ -3,6 +3,8 @@ use lazy_static::lazy_static; use std::collections::HashMap; use serde::{Serialize, Deserialize}; +use proxmox_openid::{OpenIdAuthenticator, OpenIdConfig}; + use proxmox::api::{ api, schema::*, @@ -25,6 +27,22 @@ lazy_static! { pub static ref CONFIG: SectionConfig = init(); } +#[api()] +#[derive(Eq, PartialEq, Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +/// Use the value of this attribute/claim as unique user name. It is +/// up to the identity provider to guarantee the uniqueness. The +/// OpenID specification only guarantees that Subject ('sub') is unique. Also +/// make sure that the user is not allowed to change that attribute by +/// himself! +pub enum OpenIdUserAttribute { + /// Subject (OpenId 'sub' claim) + Subject, + /// Username (OpenId 'preferred_username' claim) + Username, + /// Email (OpenId 'email' claim) + Email, +} #[api( properties: { @@ -48,6 +66,16 @@ lazy_static! { optional: true, schema: SINGLE_LINE_COMMENT_SCHEMA, }, + autocreate: { + description: "Automatically create users if they do not exist.", + optional: true, + type: bool, + default: false, + }, + "username-claim": { + type: OpenIdUserAttribute, + optional: true, + }, }, )] #[derive(Serialize,Deserialize)] @@ -61,6 +89,22 @@ pub struct OpenIdRealmConfig { pub client_key: Option, #[serde(skip_serializing_if="Option::is_none")] pub comment: Option, + #[serde(skip_serializing_if="Option::is_none")] + pub autocreate: Option, + #[serde(skip_serializing_if="Option::is_none")] + pub username_claim: Option, +} + +impl OpenIdRealmConfig { + + pub fn authenticator(&self, redirect_url: &str) -> Result { + let config = OpenIdConfig { + issuer_url: self.issuer_url.clone(), + client_id: self.client_id.clone(), + client_key: self.client_key.clone(), + }; + OpenIdAuthenticator::discover(&config, redirect_url) + } } fn init() -> SectionConfig {