Merge branch 'feat/core/identity-type-provider'
This commit is contained in:
196
crates/alknet-core/src/auth/identity.rs
Normal file
196
crates/alknet-core/src/auth/identity.rs
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use arc_swap::ArcSwap;
|
||||||
|
|
||||||
|
use crate::config::DynamicConfig;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Identity {
|
||||||
|
pub id: String,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
pub resources: HashMap<String, Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AuthToken {
|
||||||
|
pub raw: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait IdentityProvider: Send + Sync + 'static {
|
||||||
|
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity>;
|
||||||
|
|
||||||
|
fn resolve_from_token(&self, token: &AuthToken) -> Option<Identity>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ConfigIdentityProvider {
|
||||||
|
dynamic: Arc<ArcSwap<DynamicConfig>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConfigIdentityProvider {
|
||||||
|
pub fn new(dynamic: Arc<ArcSwap<DynamicConfig>>) -> Self {
|
||||||
|
Self { dynamic }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IdentityProvider for ConfigIdentityProvider {
|
||||||
|
fn resolve_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
|
||||||
|
let config = self.dynamic.load();
|
||||||
|
let auth = &config.auth;
|
||||||
|
auth.resolve_identity_from_fingerprint(fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_from_token(&self, _token: &AuthToken) -> Option<Identity> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::auth::keys::KeySource;
|
||||||
|
use crate::auth::ServerAuthConfig;
|
||||||
|
use crate::config::AuthPolicy;
|
||||||
|
use russh::keys::ssh_key::HashAlg;
|
||||||
|
use russh::keys::PrivateKey;
|
||||||
|
use std::io::Write;
|
||||||
|
|
||||||
|
const ED25519_PRIVATE_KEY: &str = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01QAAAJiQ+NvMkPjb\nzAAAAAtzc2gtZWQyNTUxOQAAACBOfInDyRS33JEeDNT8xd10qRdwFN8z/QukCOgEIkv01Q\nAAAECIWwJf7+7MOuZAOOWmoQbE9i/5GxjKsFrtJHjZ34E/fk58icPJFLfckR4M1PzF3XSp\nF3AU3zP9C6QI6AQiS/TVAAAAD3VidW50dUBuczUyODA5NgECAwQFBg==\n-----END OPENSSH PRIVATE KEY-----\n";
|
||||||
|
|
||||||
|
const ED25519_PUBLIC_KEY: &str = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE58icPJFLfckR4M1PzF3XSpF3AU3zP9C6QI6AQiS/TV ubuntu@ns528096";
|
||||||
|
|
||||||
|
fn load_key() -> PrivateKey {
|
||||||
|
russh::keys::decode_secret_key(ED25519_PRIVATE_KEY, None).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_authorized_keys_file(keys_content: &str) -> tempfile::NamedTempFile {
|
||||||
|
let mut f = tempfile::NamedTempFile::new().unwrap();
|
||||||
|
f.write_all(keys_content.as_bytes()).unwrap();
|
||||||
|
f.flush().unwrap();
|
||||||
|
f
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_provider(keys_content: &str) -> (ConfigIdentityProvider, Arc<ArcSwap<DynamicConfig>>) {
|
||||||
|
let f = make_authorized_keys_file(keys_content);
|
||||||
|
let server_auth =
|
||||||
|
ServerAuthConfig::from_keys_and_ca(Some(KeySource::File(f.path().to_path_buf())), None)
|
||||||
|
.unwrap();
|
||||||
|
let auth_policy = AuthPolicy::from_server_auth_config(server_auth);
|
||||||
|
let dynamic = DynamicConfig::new(auth_policy);
|
||||||
|
let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic)));
|
||||||
|
let provider = ConfigIdentityProvider::new(Arc::clone(&arc_swap));
|
||||||
|
(provider, arc_swap)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identity_fields() {
|
||||||
|
let mut resources = HashMap::new();
|
||||||
|
resources.insert(
|
||||||
|
"service".to_string(),
|
||||||
|
vec!["gitea".to_string(), "registry".to_string()],
|
||||||
|
);
|
||||||
|
let identity = Identity {
|
||||||
|
id: "SHA256:abc123".to_string(),
|
||||||
|
scopes: vec![
|
||||||
|
"relay:connect".to_string(),
|
||||||
|
"service:gitea:read".to_string(),
|
||||||
|
],
|
||||||
|
resources,
|
||||||
|
};
|
||||||
|
assert_eq!(identity.id, "SHA256:abc123");
|
||||||
|
assert_eq!(identity.scopes, vec!["relay:connect", "service:gitea:read"]);
|
||||||
|
assert_eq!(
|
||||||
|
identity.resources.get("service").unwrap(),
|
||||||
|
&vec!["gitea".to_string(), "registry".to_string()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identity_equality() {
|
||||||
|
let id1 = Identity {
|
||||||
|
id: "test".to_string(),
|
||||||
|
scopes: vec!["relay:connect".to_string()],
|
||||||
|
resources: HashMap::new(),
|
||||||
|
};
|
||||||
|
let id2 = Identity {
|
||||||
|
id: "test".to_string(),
|
||||||
|
scopes: vec!["relay:connect".to_string()],
|
||||||
|
resources: HashMap::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(id1, id2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identity_inequality_different_id() {
|
||||||
|
let id1 = Identity {
|
||||||
|
id: "a".to_string(),
|
||||||
|
scopes: vec![],
|
||||||
|
resources: HashMap::new(),
|
||||||
|
};
|
||||||
|
let id2 = Identity {
|
||||||
|
id: "b".to_string(),
|
||||||
|
scopes: vec![],
|
||||||
|
resources: HashMap::new(),
|
||||||
|
};
|
||||||
|
assert_ne!(id1, id2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_identity_provider_resolves_valid_fingerprint() {
|
||||||
|
let (provider, _) = make_provider(ED25519_PUBLIC_KEY);
|
||||||
|
let key = load_key().public_key().clone();
|
||||||
|
let fingerprint = format!("{}", key.fingerprint(HashAlg::Sha256));
|
||||||
|
let identity = provider.resolve_from_fingerprint(&fingerprint);
|
||||||
|
assert!(identity.is_some());
|
||||||
|
let identity = identity.unwrap();
|
||||||
|
assert_eq!(identity.id, fingerprint);
|
||||||
|
assert!(!identity.scopes.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_identity_provider_rejects_invalid_fingerprint() {
|
||||||
|
let (provider, _) = make_provider(ED25519_PUBLIC_KEY);
|
||||||
|
let identity = provider.resolve_from_fingerprint("SHA256:invalid");
|
||||||
|
assert!(identity.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_identity_provider_empty_config_rejects_all() {
|
||||||
|
let dynamic = DynamicConfig::default();
|
||||||
|
let arc_swap = Arc::new(ArcSwap::new(Arc::new(dynamic)));
|
||||||
|
let provider = ConfigIdentityProvider::new(arc_swap);
|
||||||
|
let identity = provider.resolve_from_fingerprint("SHA256:anything");
|
||||||
|
assert!(identity.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_identity_provider_resolve_from_token_returns_none() {
|
||||||
|
let (provider, _) = make_provider(ED25519_PUBLIC_KEY);
|
||||||
|
let token = AuthToken {
|
||||||
|
raw: b"test-token".to_vec(),
|
||||||
|
};
|
||||||
|
assert!(provider.resolve_from_token(&token).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auth_token_holds_raw_bytes() {
|
||||||
|
let token = AuthToken { raw: vec![1, 2, 3] };
|
||||||
|
assert_eq!(token.raw, vec![1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_identity_provider_reflects_config_reload() {
|
||||||
|
let (provider, arc_swap) = make_provider(ED25519_PUBLIC_KEY);
|
||||||
|
let key = load_key().public_key().clone();
|
||||||
|
let fingerprint = format!("{}", key.fingerprint(HashAlg::Sha256));
|
||||||
|
|
||||||
|
let identity = provider.resolve_from_fingerprint(&fingerprint);
|
||||||
|
assert!(identity.is_some());
|
||||||
|
|
||||||
|
let new_dynamic = DynamicConfig::default();
|
||||||
|
arc_swap.store(Arc::new(new_dynamic));
|
||||||
|
|
||||||
|
let identity = provider.resolve_from_fingerprint(&fingerprint);
|
||||||
|
assert!(identity.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@
|
|||||||
//! See ADR-012 for the design rationale.
|
//! See ADR-012 for the design rationale.
|
||||||
|
|
||||||
pub mod client_auth;
|
pub mod client_auth;
|
||||||
|
pub mod identity;
|
||||||
pub mod keys;
|
pub mod keys;
|
||||||
pub mod server_auth;
|
pub mod server_auth;
|
||||||
|
|
||||||
pub use client_auth::{ClientAuthConfig, ClientHandler};
|
pub use client_auth::{ClientAuthConfig, ClientHandler};
|
||||||
|
pub use identity::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider};
|
||||||
pub use keys::{load_private_key, load_public_keys, CertAuthorityEntry, KeySource};
|
pub use keys::{load_private_key, load_public_keys, CertAuthorityEntry, KeySource};
|
||||||
pub use server_auth::ServerAuthConfig;
|
pub use server_auth::ServerAuthConfig;
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
|
use russh::keys::ssh_key::HashAlg;
|
||||||
|
|
||||||
|
use crate::auth::identity::Identity;
|
||||||
use crate::auth::ServerAuthConfig;
|
use crate::auth::ServerAuthConfig;
|
||||||
|
|
||||||
pub struct AuthPolicy {
|
pub struct AuthPolicy {
|
||||||
pub authorized_keys: std::collections::HashSet<russh::keys::PublicKey>,
|
pub authorized_keys: std::collections::HashSet<russh::keys::PublicKey>,
|
||||||
pub cert_authorities: Vec<crate::auth::keys::CertAuthorityEntry>,
|
pub cert_authorities: Vec<crate::auth::keys::CertAuthorityEntry>,
|
||||||
encoded_keys: std::collections::HashSet<Vec<u8>>,
|
encoded_keys: std::collections::HashSet<Vec<u8>>,
|
||||||
|
fingerprint_to_key: HashMap<String, russh::keys::PublicKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn encode_key_data(key: &russh::keys::PublicKey) -> Vec<u8> {
|
fn encode_key_data(key: &russh::keys::PublicKey) -> Vec<u8> {
|
||||||
@@ -21,11 +25,16 @@ impl AuthPolicy {
|
|||||||
cert_authorities: Vec<crate::auth::keys::CertAuthorityEntry>,
|
cert_authorities: Vec<crate::auth::keys::CertAuthorityEntry>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let encoded_keys = authorized_keys.iter().map(encode_key_data).collect();
|
let encoded_keys = authorized_keys.iter().map(encode_key_data).collect();
|
||||||
|
let fingerprint_to_key = authorized_keys
|
||||||
|
.iter()
|
||||||
|
.map(|k| (format!("{}", k.fingerprint(HashAlg::Sha256)), k.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
authorized_keys,
|
authorized_keys,
|
||||||
cert_authorities,
|
cert_authorities,
|
||||||
encoded_keys,
|
encoded_keys,
|
||||||
|
fingerprint_to_key,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +46,18 @@ impl AuthPolicy {
|
|||||||
Self::new(std::collections::HashSet::new(), Vec::new())
|
Self::new(std::collections::HashSet::new(), Vec::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_identity_from_fingerprint(&self, fingerprint: &str) -> Option<Identity> {
|
||||||
|
if self.fingerprint_to_key.contains_key(fingerprint) {
|
||||||
|
Some(Identity {
|
||||||
|
id: fingerprint.to_string(),
|
||||||
|
scopes: vec!["relay:connect".to_string()],
|
||||||
|
resources: HashMap::new(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn authenticate_publickey(
|
pub fn authenticate_publickey(
|
||||||
&self,
|
&self,
|
||||||
key: &russh::keys::PublicKey,
|
key: &russh::keys::PublicKey,
|
||||||
@@ -186,6 +207,7 @@ impl Clone for AuthPolicy {
|
|||||||
authorized_keys: self.authorized_keys.clone(),
|
authorized_keys: self.authorized_keys.clone(),
|
||||||
cert_authorities: self.cert_authorities.clone(),
|
cert_authorities: self.cert_authorities.clone(),
|
||||||
encoded_keys: self.encoded_keys.clone(),
|
encoded_keys: self.encoded_keys.clone(),
|
||||||
|
fingerprint_to_key: self.fingerprint_to_key.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ pub mod transport;
|
|||||||
#[cfg(feature = "testutil")]
|
#[cfg(feature = "testutil")]
|
||||||
pub mod testutil;
|
pub mod testutil;
|
||||||
|
|
||||||
|
pub use auth::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider};
|
||||||
pub use client::channel_manager::{ChannelManager, ForwardRequest};
|
pub use client::channel_manager::{ChannelManager, ForwardRequest};
|
||||||
pub use client::connect::{ClientSession, ConnectError, ConnectOptions, TransportMode};
|
pub use client::connect::{ClientSession, ConnectError, ConnectOptions, TransportMode};
|
||||||
pub use config::{
|
pub use config::{
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use russh::server::{Auth, Handler, Msg, Session};
|
|||||||
use russh::Channel;
|
use russh::Channel;
|
||||||
use russh::ChannelId;
|
use russh::ChannelId;
|
||||||
|
|
||||||
|
use crate::auth::identity::{ConfigIdentityProvider, Identity, IdentityProvider};
|
||||||
use crate::config::DynamicConfig;
|
use crate::config::DynamicConfig;
|
||||||
use crate::server::control_channel::{ControlChannelHandler, ControlChannelRouter, ALKNET_PREFIX};
|
use crate::server::control_channel::{ControlChannelHandler, ControlChannelRouter, ALKNET_PREFIX};
|
||||||
use crate::server::rate_limit::{AuthAttemptLimiter, ConnectionRateLimiter};
|
use crate::server::rate_limit::{AuthAttemptLimiter, ConnectionRateLimiter};
|
||||||
@@ -43,7 +44,7 @@ impl std::fmt::Display for TransportKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct ServerHandler {
|
pub struct ServerHandler {
|
||||||
dynamic: Arc<ArcSwap<DynamicConfig>>,
|
identity_provider: Box<dyn IdentityProvider>,
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
outbound_proxy: Option<ProxyConfig>,
|
outbound_proxy: Option<ProxyConfig>,
|
||||||
remote_addr: Option<SocketAddr>,
|
remote_addr: Option<SocketAddr>,
|
||||||
@@ -54,6 +55,7 @@ pub struct ServerHandler {
|
|||||||
connection_allowed: bool,
|
connection_allowed: bool,
|
||||||
auth_limiter: AuthAttemptLimiter,
|
auth_limiter: AuthAttemptLimiter,
|
||||||
connected_at: Instant,
|
connected_at: Instant,
|
||||||
|
authenticated_identity: Option<Identity>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ServerHandler {
|
impl ServerHandler {
|
||||||
@@ -65,6 +67,9 @@ impl ServerHandler {
|
|||||||
connection_limiter: Arc<ConnectionRateLimiter>,
|
connection_limiter: Arc<ConnectionRateLimiter>,
|
||||||
max_auth_attempts: usize,
|
max_auth_attempts: usize,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let identity_provider: Box<dyn IdentityProvider> =
|
||||||
|
Box::new(ConfigIdentityProvider::new(Arc::clone(&dynamic)));
|
||||||
|
|
||||||
let allowed = if let Some(addr) = remote_addr {
|
let allowed = if let Some(addr) = remote_addr {
|
||||||
let ip = addr.ip();
|
let ip = addr.ip();
|
||||||
if connection_limiter.check(ip) {
|
if connection_limiter.check(ip) {
|
||||||
@@ -88,7 +93,7 @@ impl ServerHandler {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
dynamic,
|
identity_provider,
|
||||||
outbound_proxy,
|
outbound_proxy,
|
||||||
remote_addr,
|
remote_addr,
|
||||||
control_channel_router: ControlChannelRouter::without_handler(),
|
control_channel_router: ControlChannelRouter::without_handler(),
|
||||||
@@ -97,9 +102,19 @@ impl ServerHandler {
|
|||||||
connection_allowed: allowed,
|
connection_allowed: allowed,
|
||||||
auth_limiter: AuthAttemptLimiter::new(max_auth_attempts),
|
auth_limiter: AuthAttemptLimiter::new(max_auth_attempts),
|
||||||
connected_at: Instant::now(),
|
connected_at: Instant::now(),
|
||||||
|
authenticated_identity: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_identity_provider(mut self, provider: Box<dyn IdentityProvider>) -> Self {
|
||||||
|
self.identity_provider = provider;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn authenticated_identity(&self) -> Option<&Identity> {
|
||||||
|
self.authenticated_identity.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_connection_allowed(&self) -> bool {
|
pub fn is_connection_allowed(&self) -> bool {
|
||||||
self.connection_allowed
|
self.connection_allowed
|
||||||
}
|
}
|
||||||
@@ -167,12 +182,13 @@ impl Handler for ServerHandler {
|
|||||||
.remote_addr
|
.remote_addr
|
||||||
.map_or("unknown".to_string(), |a| a.to_string());
|
.map_or("unknown".to_string(), |a| a.to_string());
|
||||||
|
|
||||||
let russh_pub = russh::keys::PublicKey::new(public_key.key_data().clone(), user);
|
let identity = self
|
||||||
let auth_config = self.dynamic.load();
|
.identity_provider
|
||||||
let result = auth_config.auth.authenticate_publickey(&russh_pub);
|
.resolve_from_fingerprint(&fingerprint);
|
||||||
|
|
||||||
match result {
|
match identity {
|
||||||
Ok(()) => {
|
Some(id) => {
|
||||||
|
self.authenticated_identity = Some(id);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
remote_addr = %remote_addr_display,
|
remote_addr = %remote_addr_display,
|
||||||
user = user,
|
user = user,
|
||||||
@@ -182,7 +198,7 @@ impl Handler for ServerHandler {
|
|||||||
);
|
);
|
||||||
Ok(Auth::Accept)
|
Ok(Auth::Accept)
|
||||||
}
|
}
|
||||||
Err(_) => {
|
None => {
|
||||||
self.auth_limiter.on_failure();
|
self.auth_limiter.on_failure();
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
remote_addr = %remote_addr_display,
|
remote_addr = %remote_addr_display,
|
||||||
|
|||||||
Reference in New Issue
Block a user