api: add openid redirect/login API
This commit is contained in:
parent
d8a47ec649
commit
3b7b1dfb8e
|
@ -26,6 +26,7 @@ pub mod domain;
|
||||||
pub mod role;
|
pub mod role;
|
||||||
pub mod tfa;
|
pub mod tfa;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
pub mod openid;
|
||||||
|
|
||||||
#[allow(clippy::large_enum_variant)]
|
#[allow(clippy::large_enum_variant)]
|
||||||
enum AuthResult {
|
enum AuthResult {
|
||||||
|
@ -335,7 +336,7 @@ pub fn list_permissions(
|
||||||
let auth_id = match auth_id {
|
let auth_id = match auth_id {
|
||||||
Some(auth_id) if auth_id == current_auth_id => current_auth_id,
|
Some(auth_id) if auth_id == current_auth_id => current_auth_id,
|
||||||
Some(auth_id) => {
|
Some(auth_id) => {
|
||||||
if user_privs & PRIV_SYS_AUDIT != 0
|
if user_privs & PRIV_SYS_AUDIT != 0
|
||||||
|| (auth_id.is_token()
|
|| (auth_id.is_token()
|
||||||
&& !current_auth_id.is_token()
|
&& !current_auth_id.is_token()
|
||||||
&& auth_id.user() == current_auth_id.user())
|
&& auth_id.user() == current_auth_id.user())
|
||||||
|
@ -423,6 +424,7 @@ const SUBDIRS: SubdirMap = &sorted!([
|
||||||
&Router::new().get(&API_METHOD_LIST_PERMISSIONS)
|
&Router::new().get(&API_METHOD_LIST_PERMISSIONS)
|
||||||
),
|
),
|
||||||
("ticket", &Router::new().post(&API_METHOD_CREATE_TICKET)),
|
("ticket", &Router::new().post(&API_METHOD_CREATE_TICKET)),
|
||||||
|
("openid", &openid::ROUTER),
|
||||||
("domains", &domain::ROUTER),
|
("domains", &domain::ROUTER),
|
||||||
("roles", &role::ROUTER),
|
("roles", &role::ROUTER),
|
||||||
("users", &user::ROUTER),
|
("users", &user::ROUTER),
|
||||||
|
|
|
@ -60,9 +60,6 @@ fn list_domains() -> Result<Value, Error> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(list.into())
|
Ok(list.into())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const ROUTER: Router = Router::new()
|
pub const ROUTER: Router = Router::new()
|
||||||
|
|
|
@ -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<Value, Error> {
|
||||||
|
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<String, Error> {
|
||||||
|
|
||||||
|
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);
|
|
@ -153,6 +153,8 @@ pub enum DeletableProperty {
|
||||||
client_key,
|
client_key,
|
||||||
/// Delete the comment property.
|
/// Delete the comment property.
|
||||||
comment,
|
comment,
|
||||||
|
/// Delete the autocreate property
|
||||||
|
autocreate,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[api(
|
#[api(
|
||||||
|
@ -177,6 +179,11 @@ pub enum DeletableProperty {
|
||||||
type: String,
|
type: String,
|
||||||
optional: true,
|
optional: true,
|
||||||
},
|
},
|
||||||
|
autocreate: {
|
||||||
|
description: "Automatically create users if they do not exist.",
|
||||||
|
optional: true,
|
||||||
|
type: bool,
|
||||||
|
},
|
||||||
comment: {
|
comment: {
|
||||||
schema: SINGLE_LINE_COMMENT_SCHEMA,
|
schema: SINGLE_LINE_COMMENT_SCHEMA,
|
||||||
optional: true,
|
optional: true,
|
||||||
|
@ -206,6 +213,7 @@ pub fn update_openid_realm(
|
||||||
issuer_url: Option<String>,
|
issuer_url: Option<String>,
|
||||||
client_id: Option<String>,
|
client_id: Option<String>,
|
||||||
client_key: Option<String>,
|
client_key: Option<String>,
|
||||||
|
autocreate: Option<bool>,
|
||||||
comment: Option<String>,
|
comment: Option<String>,
|
||||||
delete: Option<Vec<DeletableProperty>>,
|
delete: Option<Vec<DeletableProperty>>,
|
||||||
digest: Option<String>,
|
digest: Option<String>,
|
||||||
|
@ -228,6 +236,7 @@ pub fn update_openid_realm(
|
||||||
match delete_prop {
|
match delete_prop {
|
||||||
DeletableProperty::client_key => { config.client_key = None; },
|
DeletableProperty::client_key => { config.client_key = None; },
|
||||||
DeletableProperty::comment => { config.comment = 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 let Some(client_id) = client_id { config.client_id = client_id; }
|
||||||
|
|
||||||
if client_key.is_some() { config.client_key = client_key; }
|
if client_key.is_some() { config.client_key = client_key; }
|
||||||
|
if autocreate.is_some() { config.autocreate = autocreate; }
|
||||||
|
|
||||||
domains.set_data(&realm, "openid", &config)?;
|
domains.set_data(&realm, "openid", &config)?;
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,8 @@ use lazy_static::lazy_static;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use serde::{Serialize, Deserialize};
|
use serde::{Serialize, Deserialize};
|
||||||
|
|
||||||
|
use proxmox_openid::{OpenIdAuthenticator, OpenIdConfig};
|
||||||
|
|
||||||
use proxmox::api::{
|
use proxmox::api::{
|
||||||
api,
|
api,
|
||||||
schema::*,
|
schema::*,
|
||||||
|
@ -25,6 +27,22 @@ lazy_static! {
|
||||||
pub static ref CONFIG: SectionConfig = init();
|
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(
|
#[api(
|
||||||
properties: {
|
properties: {
|
||||||
|
@ -48,6 +66,16 @@ lazy_static! {
|
||||||
optional: true,
|
optional: true,
|
||||||
schema: SINGLE_LINE_COMMENT_SCHEMA,
|
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)]
|
#[derive(Serialize,Deserialize)]
|
||||||
|
@ -61,6 +89,22 @@ pub struct OpenIdRealmConfig {
|
||||||
pub client_key: Option<String>,
|
pub client_key: Option<String>,
|
||||||
#[serde(skip_serializing_if="Option::is_none")]
|
#[serde(skip_serializing_if="Option::is_none")]
|
||||||
pub comment: Option<String>,
|
pub comment: Option<String>,
|
||||||
|
#[serde(skip_serializing_if="Option::is_none")]
|
||||||
|
pub autocreate: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if="Option::is_none")]
|
||||||
|
pub username_claim: Option<OpenIdUserAttribute>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpenIdRealmConfig {
|
||||||
|
|
||||||
|
pub fn authenticator(&self, redirect_url: &str) -> Result<OpenIdAuthenticator, Error> {
|
||||||
|
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 {
|
fn init() -> SectionConfig {
|
||||||
|
|
Loading…
Reference in New Issue