Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow passwordless auth by client certificate (RFC-4217) #358

Merged
merged 2 commits into from
Jun 2, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/auth/anonymous.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,8 @@ impl Authenticator<DefaultUser> for AnonymousAuthenticator {
async fn authenticate(&self, _username: &str, _password: &Credentials) -> Result<DefaultUser, AuthenticationError> {
Ok(DefaultUser {})
}

async fn cert_auth_sufficient(&self, _username: &str) -> bool {
hannesdejager marked this conversation as resolved.
Show resolved Hide resolved
true
}
}
2 changes: 1 addition & 1 deletion src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ pub use anonymous::AnonymousAuthenticator;

pub(crate) mod authenticator;
#[allow(unused_imports)]
pub use authenticator::{AuthenticationError, Authenticator, Credentials};
pub use authenticator::{AuthenticationError, Authenticator, ClientCert, Credentials};

mod user;
pub use user::{DefaultUser, UserDetail};
2 changes: 1 addition & 1 deletion src/server/controlchan/commands/pass.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ where
let creds = crate::auth::Credentials {
password: Some(pass),
source_ip: session.source.ip(),
certificate_chain: None,
certificate_chain: session.cert_chain.clone(),
};
tokio::spawn(async move {
let msg = match auther.authenticate(&user, &creds).await {
Expand Down
278 changes: 276 additions & 2 deletions src/server/controlchan/commands/user.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::auth::{AuthenticationError, Credentials};
use crate::{
auth::UserDetail,
server::{
Expand All @@ -12,6 +13,7 @@ use crate::{
};
use async_trait::async_trait;
use bytes::Bytes;
use std::sync::Arc;

#[derive(Debug)]
pub struct User {
Expand All @@ -34,8 +36,33 @@ where
#[tracing_attributes::instrument]
async fn handle(&self, args: CommandContext<Storage, Usr>) -> Result<Reply, ControlChanError> {
let mut session = args.session.lock().await;
match session.state {
SessionState::New | SessionState::WaitPass => {
let username_str = std::str::from_utf8(&self.username)?;
let cert_auth_sufficient = args.authenticator.cert_auth_sufficient(username_str).await;
match (session.state, &session.cert_chain, cert_auth_sufficient) {
(SessionState::New, Some(_), true) => {
let auth_result: Result<Usr, AuthenticationError> = args
.authenticator
.authenticate(
username_str,
&Credentials {
certificate_chain: session.cert_chain.clone(),
password: None,
source_ip: session.source.ip(),
},
)
.await;
match auth_result {
Ok(user_detail) => {
let user = username_str;
session.username = Some(user.to_string());
session.state = SessionState::WaitCmd;
session.user = Arc::new(Some(user_detail));
Ok(Reply::new(ReplyCode::UserLoggedInViaCert, "User logged in"))
}
Err(_e) => Ok(Reply::new(ReplyCode::NotLoggedIn, "Invalid credentials")),
}
}
(SessionState::New, None, _) | (SessionState::WaitPass, None, _) | (SessionState::New, Some(_), false) => {
let user = std::str::from_utf8(&self.username)?;
session.username = Some(user.to_string());
session.state = SessionState::WaitPass;
Expand All @@ -45,3 +72,250 @@ where
}
}
}

#[cfg(test)]
mod tests {

use crate::auth::{AuthenticationError, Authenticator, ClientCert, Credentials, DefaultUser, UserDetail};
use crate::server::controlchan::handler::CommandHandler;
use crate::server::session::SharedSession;
use crate::server::{Command, ControlChanMsg, Reply, ReplyCode, Session, SessionState};
use crate::storage::{Fileinfo, Result};
use crate::storage::{Metadata, StorageBackend};
use async_trait::async_trait;
use bytes::Bytes;
use futures::channel::mpsc;
use pretty_assertions::assert_eq;
use slog::o;
use std::fmt::Debug;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
use tokio::io::AsyncRead;
use tokio::sync::Mutex;

#[derive(Debug)]
struct Auth {
pub short_auth: bool,
pub auth_ok: bool,
}

#[async_trait]
#[allow(unused)]
impl Authenticator<DefaultUser> for Auth {
async fn authenticate(&self, username: &str, creds: &Credentials) -> std::result::Result<DefaultUser, AuthenticationError> {
if self.auth_ok {
Ok(DefaultUser {})
} else {
Err(AuthenticationError::new("bad credentials"))
}
}

async fn cert_auth_sufficient(&self, username: &str) -> bool {
self.short_auth
}
}

struct Meta {}

#[allow(unused)]
impl Metadata for Meta {
fn len(&self) -> u64 {
todo!()
}

fn is_dir(&self) -> bool {
todo!()
}

fn is_file(&self) -> bool {
todo!()
}

fn is_symlink(&self) -> bool {
todo!()
}

fn modified(&self) -> Result<SystemTime> {
todo!()
}

fn gid(&self) -> u32 {
todo!()
}

fn uid(&self) -> u32 {
todo!()
}
}

#[derive(Debug)]
struct Vfs {}

#[async_trait]
#[allow(unused)]
impl StorageBackend<DefaultUser> for Vfs {
type Metadata = Meta;

async fn metadata<P: AsRef<Path> + Send + Debug>(&self, user: &Option<DefaultUser>, path: P) -> Result<Self::Metadata> {
todo!()
}

async fn list<P: AsRef<Path> + Send + Debug>(&self, user: &Option<DefaultUser>, path: P) -> Result<Vec<Fileinfo<PathBuf, Self::Metadata>>>
where
<Self as StorageBackend<DefaultUser>>::Metadata: Metadata,
{
todo!()
}

async fn get<P: AsRef<Path> + Send + Debug>(
&self,
user: &Option<DefaultUser>,
path: P,
start_pos: u64,
) -> Result<Box<dyn AsyncRead + Send + Sync + Unpin>> {
todo!()
}

async fn put<P: AsRef<Path> + Send + Debug, R: AsyncRead + Send + Sync + Unpin + 'static>(
&self,
user: &Option<DefaultUser>,
input: R,
path: P,
start_pos: u64,
) -> Result<u64> {
todo!()
}

async fn del<P: AsRef<Path> + Send + Debug>(&self, user: &Option<DefaultUser>, path: P) -> Result<()> {
todo!()
}

async fn mkd<P: AsRef<Path> + Send + Debug>(&self, user: &Option<DefaultUser>, path: P) -> Result<()> {
todo!()
}

async fn rename<P: AsRef<Path> + Send + Debug>(&self, user: &Option<DefaultUser>, from: P, to: P) -> Result<()> {
todo!()
}

async fn rmd<P: AsRef<Path> + Send + Debug>(&self, user: &Option<DefaultUser>, path: P) -> Result<()> {
todo!()
}

async fn cwd<P: AsRef<Path> + Send + Debug>(&self, user: &Option<DefaultUser>, path: P) -> Result<()> {
todo!()
}
}

impl Reply {
fn matches_code(&self, code: ReplyCode) -> bool {
match self {
Reply::None => false,
Reply::CodeAndMsg { code: c, .. } | Reply::MultiLine { code: c, .. } => c == &code,
}
}
}

impl<Storage, User> super::CommandContext<Storage, User>
where
Storage: StorageBackend<User> + 'static,
Storage::Metadata: Metadata + Sync,
User: UserDetail + 'static,
{
fn test(session_arc: SharedSession<Storage, User>, auther: Arc<dyn Authenticator<User>>) -> super::CommandContext<Storage, User> {
let (tx, _) = mpsc::channel::<ControlChanMsg>(1);
super::CommandContext {
parsed_command: Command::User {
username: Bytes::from("test-user"),
},
session: session_arc,
authenticator: auther,
tls_configured: true,
passive_ports: Default::default(),
passive_host: Default::default(),
tx_control_chan: tx,
local_addr: "127.0.0.1:8080".parse().unwrap(),
storage_features: 0,
tx_proxyloop: None,
logger: slog::Logger::root(slog::Discard {}, o!()),
sitemd5: Default::default(),
}
}
}

struct Test {
short_auth: bool,
auth_ok: bool,
cert: Option<Vec<crate::auth::ClientCert>>,
expected_reply: ReplyCode,
expected_state: SessionState,
}

async fn test(test: Test) {
let user_cmd = super::User {
username: Bytes::from("test-user"),
};
let mut session = Session::new(Arc::new(Vfs {}), "127.0.0.1:8080".parse().unwrap());
session.cert_chain = test.cert;
let session_arc = Arc::new(Mutex::new(session));
let ctx = super::CommandContext::test(
session_arc.clone(),
Arc::new(Auth {
short_auth: test.short_auth,
auth_ok: test.auth_ok,
}),
);
let reply = user_cmd.handle(ctx).await.unwrap();
assert_eq!(reply.matches_code(test.expected_reply), true, "Reply code must match");
assert_eq!(session_arc.lock().await.state, test.expected_state, "Next state must match");
}

#[tokio::test]
async fn login_user_pass_no_cert() {
test(Test {
short_auth: false,
auth_ok: false,
cert: None,
expected_reply: ReplyCode::NeedPassword,
expected_state: SessionState::WaitPass,
})
.await
}

#[tokio::test]
async fn login_user_pass_with_cert() {
test(Test {
short_auth: false,
auth_ok: true,
cert: Some(vec![ClientCert(vec![0])]),
expected_reply: ReplyCode::NeedPassword,
expected_state: SessionState::WaitPass,
})
.await
}

#[tokio::test]
async fn login_by_cert_bad_creds() {
test(Test {
short_auth: true,
auth_ok: false,
cert: Some(vec![ClientCert(vec![0])]),
expected_reply: ReplyCode::NotLoggedIn,
expected_state: SessionState::New,
})
.await
}

#[tokio::test]
async fn login_by_cert_ok() {
test(Test {
short_auth: true,
auth_ok: true,
cert: Some(vec![ClientCert(vec![0])]),
expected_reply: ReplyCode::UserLoggedInViaCert,
expected_state: SessionState::WaitCmd,
})
.await
}
}
12 changes: 10 additions & 2 deletions src/server/controlchan/control_loop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ use futures::{
channel::mpsc::{channel, Receiver, Sender},
SinkExt, StreamExt,
};
use rustls::{ServerSession, Session as RustlsSession};
use std::{net::SocketAddr, ops::Range, sync::Arc, time::Duration};
use tokio::{
io::{AsyncRead, AsyncWrite},
Expand Down Expand Up @@ -105,7 +106,7 @@ where
let event_chain = PrimaryEventHandler {
logger: logger.clone(),
session: shared_session.clone(),
authenticator,
authenticator: authenticator.clone(),
tls_configured,
passive_ports,
passive_host,
Expand Down Expand Up @@ -200,7 +201,14 @@ where
};
let accepted = acceptor.accept(io).await;
let io: Box<dyn AsyncReadAsyncWriteSendUnpin> = match accepted {
Ok(stream) => Box::new(stream),
Ok(stream) => {
let s: &ServerSession = stream.get_ref().1;
if let Some(certs) = s.get_peer_certificates() {
let mut session = shared_session.lock().await;
session.cert_chain = Some(certs.iter().map(|c| crate::auth::ClientCert(c.0.clone())).collect());
}
Box::new(stream)
}
Err(err) => {
slog::warn!(logger, "Closing control channel. Could not upgrade to TLS: {}", err);
return;
Expand Down
3 changes: 2 additions & 1 deletion src/server/controlchan/reply.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// A reply to the FTP client
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub enum Reply {
None,
CodeAndMsg { code: ReplyCode, msg: String },
Expand Down Expand Up @@ -65,6 +65,7 @@ pub enum ReplyCode {
EnteringPassiveMode = 227,
EnteringExtendedPassiveMode = 229,
UserLoggedIn = 230,
UserLoggedInViaCert = 232,
AuthOkayNoDataNeeded = 234,
FileActionOkay = 250,
DirCreated = 257,
Expand Down
6 changes: 6 additions & 0 deletions src/server/ftpserver/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ pub enum PassiveHost {

impl Eq for PassiveHost {}

impl Default for PassiveHost {
fn default() -> Self {
PassiveHost::FromConnection
}
}

impl From<Ipv4Addr> for PassiveHost {
fn from(ip: Ipv4Addr) -> Self {
PassiveHost::Ip(ip)
Expand Down
Loading