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.
|
||||
|
||||
pub mod client_auth;
|
||||
pub mod identity;
|
||||
pub mod keys;
|
||||
pub mod server_auth;
|
||||
|
||||
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 server_auth::ServerAuthConfig;
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use russh::keys::ssh_key::HashAlg;
|
||||
|
||||
use crate::auth::identity::Identity;
|
||||
use crate::auth::ServerAuthConfig;
|
||||
|
||||
pub struct AuthPolicy {
|
||||
pub authorized_keys: std::collections::HashSet<russh::keys::PublicKey>,
|
||||
pub cert_authorities: Vec<crate::auth::keys::CertAuthorityEntry>,
|
||||
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> {
|
||||
@@ -21,11 +25,16 @@ impl AuthPolicy {
|
||||
cert_authorities: Vec<crate::auth::keys::CertAuthorityEntry>,
|
||||
) -> Self {
|
||||
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 {
|
||||
authorized_keys,
|
||||
cert_authorities,
|
||||
encoded_keys,
|
||||
fingerprint_to_key,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +46,18 @@ impl AuthPolicy {
|
||||
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(
|
||||
&self,
|
||||
key: &russh::keys::PublicKey,
|
||||
@@ -186,6 +207,7 @@ impl Clone for AuthPolicy {
|
||||
authorized_keys: self.authorized_keys.clone(),
|
||||
cert_authorities: self.cert_authorities.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")]
|
||||
pub mod testutil;
|
||||
|
||||
pub use auth::{AuthToken, ConfigIdentityProvider, Identity, IdentityProvider};
|
||||
pub use client::channel_manager::{ChannelManager, ForwardRequest};
|
||||
pub use client::connect::{ClientSession, ConnectError, ConnectOptions, TransportMode};
|
||||
pub use config::{
|
||||
|
||||
@@ -9,6 +9,7 @@ use russh::server::{Auth, Handler, Msg, Session};
|
||||
use russh::Channel;
|
||||
use russh::ChannelId;
|
||||
|
||||
use crate::auth::identity::{ConfigIdentityProvider, Identity, IdentityProvider};
|
||||
use crate::config::DynamicConfig;
|
||||
use crate::server::control_channel::{ControlChannelHandler, ControlChannelRouter, ALKNET_PREFIX};
|
||||
use crate::server::rate_limit::{AuthAttemptLimiter, ConnectionRateLimiter};
|
||||
@@ -43,7 +44,7 @@ impl std::fmt::Display for TransportKind {
|
||||
}
|
||||
|
||||
pub struct ServerHandler {
|
||||
dynamic: Arc<ArcSwap<DynamicConfig>>,
|
||||
identity_provider: Box<dyn IdentityProvider>,
|
||||
#[allow(dead_code)]
|
||||
outbound_proxy: Option<ProxyConfig>,
|
||||
remote_addr: Option<SocketAddr>,
|
||||
@@ -54,6 +55,7 @@ pub struct ServerHandler {
|
||||
connection_allowed: bool,
|
||||
auth_limiter: AuthAttemptLimiter,
|
||||
connected_at: Instant,
|
||||
authenticated_identity: Option<Identity>,
|
||||
}
|
||||
|
||||
impl ServerHandler {
|
||||
@@ -65,6 +67,9 @@ impl ServerHandler {
|
||||
connection_limiter: Arc<ConnectionRateLimiter>,
|
||||
max_auth_attempts: usize,
|
||||
) -> Self {
|
||||
let identity_provider: Box<dyn IdentityProvider> =
|
||||
Box::new(ConfigIdentityProvider::new(Arc::clone(&dynamic)));
|
||||
|
||||
let allowed = if let Some(addr) = remote_addr {
|
||||
let ip = addr.ip();
|
||||
if connection_limiter.check(ip) {
|
||||
@@ -88,7 +93,7 @@ impl ServerHandler {
|
||||
};
|
||||
|
||||
Self {
|
||||
dynamic,
|
||||
identity_provider,
|
||||
outbound_proxy,
|
||||
remote_addr,
|
||||
control_channel_router: ControlChannelRouter::without_handler(),
|
||||
@@ -97,9 +102,19 @@ impl ServerHandler {
|
||||
connection_allowed: allowed,
|
||||
auth_limiter: AuthAttemptLimiter::new(max_auth_attempts),
|
||||
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 {
|
||||
self.connection_allowed
|
||||
}
|
||||
@@ -167,12 +182,13 @@ impl Handler for ServerHandler {
|
||||
.remote_addr
|
||||
.map_or("unknown".to_string(), |a| a.to_string());
|
||||
|
||||
let russh_pub = russh::keys::PublicKey::new(public_key.key_data().clone(), user);
|
||||
let auth_config = self.dynamic.load();
|
||||
let result = auth_config.auth.authenticate_publickey(&russh_pub);
|
||||
let identity = self
|
||||
.identity_provider
|
||||
.resolve_from_fingerprint(&fingerprint);
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
match identity {
|
||||
Some(id) => {
|
||||
self.authenticated_identity = Some(id);
|
||||
tracing::info!(
|
||||
remote_addr = %remote_addr_display,
|
||||
user = user,
|
||||
@@ -182,7 +198,7 @@ impl Handler for ServerHandler {
|
||||
);
|
||||
Ok(Auth::Accept)
|
||||
}
|
||||
Err(_) => {
|
||||
None => {
|
||||
self.auth_limiter.on_failure();
|
||||
tracing::info!(
|
||||
remote_addr = %remote_addr_display,
|
||||
|
||||
Reference in New Issue
Block a user