user: Add sshca user login command
The `sshca user login` command will eventually provide the command-line interface for obtaining user SSH certificates. It initiates the OAuth2 login process, retreiving an OpenID Connect Identity Token from the OpenID Server. This token will be submitted to the SSHCA server to authorize a request to sign a certificate. For now, though, the token is printed to standard output, e.g. to be used in a `curl` request.dev/auto-reload
parent
c26d67a25b
commit
3b55f7418e
|
@ -13,3 +13,8 @@ max_line_length = 79
|
|||
max_line_length = 79
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.html]
|
||||
max_line_length = 79
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -10,11 +10,18 @@ edition = "2021"
|
|||
[dependencies]
|
||||
argh = "0.1.12"
|
||||
argon2 = { version = "0.5.2", default-features = false, features = ["alloc"] }
|
||||
form_urlencoded = "1.2.0"
|
||||
gethostname = "0.4.3"
|
||||
hyper = { version = "0.14", features = ["server"] }
|
||||
jsonwebtoken = { version = "9.1.0", default-features = false }
|
||||
openidconnect = { version = "3.4.0", default-features = false, features = ["reqwest", "native-tls"] }
|
||||
reqwest = { version = "0.11.22", features = ["multipart"] }
|
||||
serde = { version = "1.0.190", features = ["derive"] }
|
||||
serde_json = "1.0.108"
|
||||
tera = { version = "1.19.1", default-features = false }
|
||||
thiserror = "1.0.50"
|
||||
tokio = { version = "1.33.0", features = ["rt", "macros"] }
|
||||
tracing = "0.1.40"
|
||||
tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
|
||||
uuid = "1.5.0"
|
||||
webbrowser = "0.8.12"
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
mod user;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time;
|
||||
|
||||
|
@ -25,6 +27,7 @@ struct Args {
|
|||
#[argh(subcommand)]
|
||||
enum Subcommand {
|
||||
Host(HostArgs),
|
||||
User(user::Args),
|
||||
}
|
||||
|
||||
/// Manage host keys and certificates
|
||||
|
@ -86,6 +89,7 @@ async fn inner_main() -> MainResult {
|
|||
let args: Args = argh::from_env();
|
||||
match args.command {
|
||||
Subcommand::Host(args) => host_cmd(args).await,
|
||||
Subcommand::User(args) => user::main(args).await,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<title>SSHCA Login</title>
|
||||
<style>
|
||||
html, body {
|
||||
background-color: #fbfaf5;
|
||||
color: #000000;
|
||||
font-family: sans-serif;
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ff0000;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body {
|
||||
background-color: #1c1b22;
|
||||
color: #dadade
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% if error %}
|
||||
<h1>Login Error</h1>
|
||||
<p class="error">
|
||||
{{ error }}
|
||||
</p>
|
||||
{% else %}
|
||||
<h1>Login Successful</h1>
|
||||
<p>You may now close this window.</p>
|
||||
<script>
|
||||
setTimeout(window.close, 2000);
|
||||
</script>
|
||||
{% endif %}
|
||||
</body>
|
|
@ -0,0 +1,325 @@
|
|||
//! OpenID Connect User Authentication
|
||||
//!
|
||||
//! The SSHCA server uses OIDC Identity Tokens to authorize users in
|
||||
//! order to issue SSH user certificates.
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use hyper::service;
|
||||
use hyper::{Body, Request, Response, Server};
|
||||
use openidconnect::core::{
|
||||
CoreAuthenticationFlow, CoreClient, CoreErrorResponseType,
|
||||
CoreProviderMetadata,
|
||||
};
|
||||
use openidconnect::reqwest::async_http_client;
|
||||
use openidconnect::reqwest::Error as OidcReqwestError;
|
||||
use openidconnect::url::ParseError;
|
||||
use openidconnect::{
|
||||
AccessTokenHash, AuthorizationCode, ClaimsVerificationError, ClientId,
|
||||
ClientSecret, CsrfToken, DiscoveryError, IssuerUrl, Nonce,
|
||||
PkceCodeChallenge, RedirectUrl, RequestTokenError, Scope, SigningError,
|
||||
StandardErrorResponse,
|
||||
};
|
||||
use openidconnect::{OAuth2TokenResponse, TokenResponse};
|
||||
use reqwest::Url;
|
||||
use serde::Deserialize;
|
||||
use tera::{Context, Tera};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::time::error::Elapsed;
|
||||
use tracing::{debug, error, info, trace};
|
||||
|
||||
/// Error type for issues during login/authentication
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LoginError {
|
||||
#[error("SSHCA Server not configured for OpenID Connect authorization")]
|
||||
OidcNotConfigured,
|
||||
#[error("Invalid OIDC IdP URL: cannot be a base")]
|
||||
UnsupportedUrl,
|
||||
#[error("Invalid OIDC IdP URL: {0}")]
|
||||
UrlParse(#[from] ParseError),
|
||||
#[error("HTTP request error: {0}")]
|
||||
Request(#[from] reqwest::Error),
|
||||
#[error("Failed to parse JSON document: {0}")]
|
||||
JsonParse(#[from] serde_json::Error),
|
||||
#[error("OIDC discovery failed: {0}")]
|
||||
OidcDiscovery(#[from] DiscoveryError<OidcReqwestError<reqwest::Error>>),
|
||||
#[error("Token request error: {0}")]
|
||||
TokenRequestError(
|
||||
#[from]
|
||||
RequestTokenError<
|
||||
OidcReqwestError<reqwest::Error>,
|
||||
StandardErrorResponse<CoreErrorResponseType>,
|
||||
>,
|
||||
),
|
||||
#[error("Server did not return an ID token")]
|
||||
MissingIdToken,
|
||||
#[error("Server returned an ID token")]
|
||||
InvalidIdToken,
|
||||
#[error("Invalid token claims: {0}")]
|
||||
ClaimsVerificationError(#[from] ClaimsVerificationError),
|
||||
#[error("Token signature error: {0}")]
|
||||
SigningError(#[from] SigningError),
|
||||
#[error("Invalid request: {0}")]
|
||||
InvalidRequest(String),
|
||||
#[error("Missing state parameter")]
|
||||
MissingStateParam,
|
||||
#[error("Invalid state parameter")]
|
||||
InvalidCsrfState,
|
||||
#[error("Missing OAuth2 authorization code")]
|
||||
MissingAuthCode,
|
||||
#[error("{0}")]
|
||||
IdpError(String),
|
||||
#[error("Timed out waiting for OAuth2 authorization callback")]
|
||||
Timeout(#[from] Elapsed),
|
||||
}
|
||||
|
||||
/// OpenID Connect Client Configuration
|
||||
///
|
||||
/// All fields are optional, as the server may not be configured for
|
||||
/// OIDC authorization.
|
||||
#[derive(Deserialize)]
|
||||
pub struct OidcConfig {
|
||||
/// OIDC IdP base URL
|
||||
url: Option<String>,
|
||||
/// OAuth2 client ID
|
||||
client_id: Option<String>,
|
||||
/// OAuth2 client secret
|
||||
client_secret: Option<String>,
|
||||
}
|
||||
|
||||
/// Retrieve OpenID Connect client configuration from SSHCA server
|
||||
///
|
||||
/// The SSHCA server provides the necessary configuration values for
|
||||
/// contacting the OpenID Provider. This function retrieves those
|
||||
/// values from the server, returning an [`OidcConfig`] structure that
|
||||
/// can be passed to [`login`].
|
||||
///
|
||||
/// If an error occurs communicating with the server, [`LoginError`]
|
||||
/// is returned.
|
||||
pub async fn get_oidc_config(url: &str) -> Result<OidcConfig, LoginError> {
|
||||
let mut url = Url::parse(url)?;
|
||||
url.path_segments_mut()
|
||||
.map_err(|_| LoginError::UnsupportedUrl)?
|
||||
.pop_if_empty()
|
||||
.push("user")
|
||||
.push("oidc-config");
|
||||
let client = reqwest::Client::new();
|
||||
info!("Fetching SSHCA OIDC configuration");
|
||||
debug!("Request: GET {}", url);
|
||||
let res = client.get(url).send().await?;
|
||||
debug!("Response: {:?} {}", &res.version(), &res.status());
|
||||
res.error_for_status_ref()?;
|
||||
Ok(serde_json::from_str(&res.text().await?)?)
|
||||
}
|
||||
|
||||
/// Log in with the OIDC IdP and return an identity token
|
||||
///
|
||||
/// This function performs the OAuth2 login process, requesting an
|
||||
/// identity token from the OpenID Provider.
|
||||
///
|
||||
/// The OAuth2 login process requires user interaction via a web
|
||||
/// browser. If possible, this function will launch a browser and
|
||||
/// navigate to the OIDC authorization URL. If the browser could not
|
||||
/// be launched automatically, the authorization URL is printed to
|
||||
/// standard error, where the user must click it or copy & paste it
|
||||
/// into a browser manually.
|
||||
///
|
||||
/// After initiating the login process, this function starts an HTTP
|
||||
/// server (usually listening on the loopback interface), in order to
|
||||
/// handle the request from the IdP after the user has successfully
|
||||
/// logged in. The request will contain an OAuth2 Authorization Code,
|
||||
/// which will be converted into an OIDC Identity Token by making an
|
||||
/// HTTP request to the IdP directly.
|
||||
pub async fn login(
|
||||
config: OidcConfig,
|
||||
listen: Option<SocketAddr>,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<String, LoginError> {
|
||||
if config.url.is_none() || config.client_id.is_none() {
|
||||
return Err(LoginError::OidcNotConfigured);
|
||||
}
|
||||
let oidc_url = config.url.unwrap();
|
||||
let client_id = config.client_id.unwrap();
|
||||
|
||||
let listen = listen.unwrap_or(([127, 0, 0, 1], 8976).into());
|
||||
let timeout = timeout.unwrap_or_else(|| Duration::from_secs(300));
|
||||
let provider_metadata = CoreProviderMetadata::discover_async(
|
||||
IssuerUrl::new(oidc_url)?,
|
||||
async_http_client,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let client = CoreClient::from_provider_metadata(
|
||||
provider_metadata,
|
||||
ClientId::new(client_id),
|
||||
config.client_secret.map(ClientSecret::new),
|
||||
)
|
||||
.set_redirect_uri(RedirectUrl::new(format!("http://{}", listen))?);
|
||||
|
||||
let (pkce_challenge, pkce_verifier) =
|
||||
PkceCodeChallenge::new_random_sha256();
|
||||
trace!("PKCE: {:?} {:?}", pkce_challenge, pkce_verifier);
|
||||
|
||||
let (auth_url, csrf_token, nonce) = client
|
||||
.authorize_url(
|
||||
CoreAuthenticationFlow::AuthorizationCode,
|
||||
CsrfToken::new_random,
|
||||
Nonce::new_random,
|
||||
)
|
||||
.set_pkce_challenge(pkce_challenge)
|
||||
.add_scope(Scope::new("openid".into()))
|
||||
.add_scope(Scope::new("profile".into()))
|
||||
.add_scope(Scope::new("email".into()))
|
||||
.add_scope(Scope::new("groups".into()))
|
||||
.url();
|
||||
trace!(
|
||||
"CSRF token: {}, nonce: {}",
|
||||
csrf_token.secret(),
|
||||
nonce.secret()
|
||||
);
|
||||
|
||||
let srv = tokio::spawn(run_server(listen, csrf_token, timeout));
|
||||
|
||||
if let Err(e) = webbrowser::open(auth_url.as_str()) {
|
||||
eprintln!("Could not open web browser: {}", e);
|
||||
eprintln!("Browse to: {}", auth_url);
|
||||
}
|
||||
|
||||
let code = srv.await.unwrap()?;
|
||||
trace!("Got authorization code: {}", code);
|
||||
|
||||
info!("Exchanging authorization code for access token");
|
||||
let token_response = client
|
||||
.exchange_code(AuthorizationCode::new(code))
|
||||
.set_pkce_verifier(pkce_verifier)
|
||||
.request_async(async_http_client)
|
||||
.await?;
|
||||
debug!("Received response token type {:?}", token_response.token_type());
|
||||
debug!("Access token: {}", token_response.access_token().secret());
|
||||
trace!("Token response: {:?}", token_response);
|
||||
|
||||
let id_token = token_response
|
||||
.id_token()
|
||||
.ok_or(LoginError::MissingIdToken)?;
|
||||
let claims = id_token.claims(&client.id_token_verifier(), &nonce)?;
|
||||
|
||||
if let Some(expected_access_token_hash) = claims.access_token_hash() {
|
||||
let actual_access_token_hash = AccessTokenHash::from_token(
|
||||
token_response.access_token(),
|
||||
&id_token.signing_alg()?,
|
||||
)?;
|
||||
if actual_access_token_hash != *expected_access_token_hash {
|
||||
return Err(LoginError::InvalidIdToken);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(id_token.to_string())
|
||||
}
|
||||
|
||||
/// Start HTTP server for OAuth2 callback
|
||||
///
|
||||
/// After the user logs in, the OAuth2 IdP redirects the browser to the
|
||||
/// URL provided in the authorization request. This function starts
|
||||
/// an HTTP server, listening on the specified socket address (usually
|
||||
/// some port on the loopback interface) to receive the callback
|
||||
/// request. Only a single request is handled, after which the server
|
||||
/// is stopped and the OAuth2 Authorization Code included in the
|
||||
/// query string of the request is parsed and returned.
|
||||
///
|
||||
/// If an error occurs while running the server or handling the request,
|
||||
/// [`LoginError`] is returned.
|
||||
async fn run_server(
|
||||
listen: SocketAddr,
|
||||
csrf_token: CsrfToken,
|
||||
timeout: Duration,
|
||||
) -> Result<String, LoginError> {
|
||||
let csrf_token = Arc::new(csrf_token);
|
||||
let (tx, mut rx) = mpsc::channel(1);
|
||||
let notify = Arc::new(Notify::new());
|
||||
let notifier = notify.clone();
|
||||
|
||||
let svc = service::make_service_fn(move |_| {
|
||||
let csrf_token = csrf_token.clone();
|
||||
let result = tx.clone();
|
||||
let notifier = notifier.clone();
|
||||
|
||||
async move {
|
||||
Ok::<_, hyper::Error>(service::service_fn(move |req| {
|
||||
debug!("Handling HTTP request");
|
||||
let csrf_token = csrf_token.clone();
|
||||
let result = result.clone();
|
||||
let notifier = notifier.clone();
|
||||
async move {
|
||||
let mut ctx = Context::new();
|
||||
match handle_callback(req, &csrf_token).await {
|
||||
Ok(s) => {
|
||||
result.send(Ok(s)).await.unwrap();
|
||||
}
|
||||
Err(e) => {
|
||||
ctx.insert("error", &e.to_string());
|
||||
result.send(Err(e)).await.unwrap();
|
||||
}
|
||||
};
|
||||
let mut tera = Tera::default();
|
||||
tera.add_raw_template(
|
||||
"callback",
|
||||
include_str!("callback.html"),
|
||||
)
|
||||
.unwrap();
|
||||
let res = tera.render("callback", &ctx).unwrap();
|
||||
notifier.notify_one();
|
||||
Ok::<_, hyper::Error>(Response::new(Body::from(res)))
|
||||
}
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
debug!("Starting HTTP server on {}", listen);
|
||||
let server = tokio::spawn(
|
||||
Server::bind(&listen)
|
||||
.serve(svc)
|
||||
.with_graceful_shutdown(async move { notify.notified().await }),
|
||||
);
|
||||
info!("Waiting for callback request");
|
||||
let code = tokio::time::timeout(timeout, rx.recv()).await?.unwrap();
|
||||
let _ = server.await.unwrap();
|
||||
code
|
||||
}
|
||||
|
||||
/// HTTP request handler for OAuth2 authorization callbacks
|
||||
///
|
||||
/// This function validates the CSRF token and parses the OAuth2
|
||||
/// Authorization Code from the callback request.
|
||||
async fn handle_callback(
|
||||
req: Request<Body>,
|
||||
csrf_token: &CsrfToken,
|
||||
) -> Result<String, LoginError> {
|
||||
let query = req
|
||||
.uri()
|
||||
.query()
|
||||
.ok_or(LoginError::InvalidRequest("Missing query string".into()))?;
|
||||
let params: HashMap<_, _> =
|
||||
form_urlencoded::parse(query.as_bytes()).collect();
|
||||
|
||||
let state = params.get("state").ok_or(LoginError::MissingStateParam)?;
|
||||
if state != csrf_token.secret() {
|
||||
return Err(LoginError::InvalidCsrfState);
|
||||
}
|
||||
|
||||
if let Some(error) = params.get("error") {
|
||||
let msg = if let Some(err_desc) = params.get("error_description") {
|
||||
format!("Error handling ODIC callback ({}): {}", error, err_desc,)
|
||||
} else {
|
||||
format!("Error handling OIDC callback: {}", error)
|
||||
};
|
||||
error!("{}", msg);
|
||||
return Err(LoginError::IdpError(msg));
|
||||
}
|
||||
|
||||
let code = params.get("code").ok_or(LoginError::MissingAuthCode)?;
|
||||
info!("Received OAuth2 authorization code");
|
||||
Ok(code.to_string())
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
//! CLI module for user features
|
||||
//!
|
||||
//! The `sshca user` sub-command handles user-based operations, such
|
||||
//! as signing an SSH user certificate.
|
||||
mod login;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use argh::FromArgs;
|
||||
|
||||
use crate::MainResult;
|
||||
|
||||
/// Manage host keys and certificates
|
||||
#[derive(FromArgs)]
|
||||
#[argh(subcommand, name = "user")]
|
||||
pub(crate) struct Args {
|
||||
#[argh(subcommand)]
|
||||
command: UserSubcommand,
|
||||
}
|
||||
|
||||
#[derive(FromArgs)]
|
||||
#[argh(subcommand)]
|
||||
enum UserSubcommand {
|
||||
Login(LoginArgs),
|
||||
}
|
||||
|
||||
/// Log in and obtain an SSH user certificate
|
||||
#[derive(FromArgs)]
|
||||
#[argh(subcommand, name = "login")]
|
||||
struct LoginArgs {
|
||||
/// listen socket address for OIDC callback (default: 127.0.0.1:8976)
|
||||
#[argh(option, short = 'l')]
|
||||
callback_listen_address: Option<String>,
|
||||
|
||||
/// oidc callback timeout, in seconds (default: 300)
|
||||
#[argh(option, short = 't')]
|
||||
callback_timeout: Option<u64>,
|
||||
}
|
||||
|
||||
/// Main entry point for `sshca user`
|
||||
pub(crate) async fn main(args: Args) -> MainResult {
|
||||
match args.command {
|
||||
UserSubcommand::Login(args) => login(args).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Entry point for `sshca user login`
|
||||
async fn login(args: LoginArgs) -> MainResult {
|
||||
let listen = match args.callback_listen_address {
|
||||
Some(s) => Some(s.parse()?),
|
||||
None => None,
|
||||
};
|
||||
let timeout = args.callback_timeout.map(Duration::from_secs);
|
||||
let url = super::get_sshca_server_url()?;
|
||||
let config = login::get_oidc_config(&url).await?;
|
||||
let token = login::login(config, listen, timeout).await?;
|
||||
println!("{}", token);
|
||||
Ok(())
|
||||
}
|
Loading…
Reference in New Issue