🏡 index : ~doyle/titanirc.git

author Jordan Doyle <jordan@doyle.la> 2023-01-12 0:20:35.0 +00:00:00
committer Jordan Doyle <jordan@doyle.la> 2023-01-12 0:20:35.0 +00:00:00
commit
7e1f8d71444370478de52925219b4badf3776764 [patch]
tree
581d2f93f4a2bcde6e68741b225dc66737a3b1e3
parent
b7a472e34e89ec2bf0a128e30fba86c2fff16ca4
download
7e1f8d71444370478de52925219b4badf3776764.tar.gz

Allow SASL authentication before the registration process has finished



Diff

 src/client.rs                  |   2 +-
 src/connection.rs              | 365 ++++++------------------------------------
 src/connection/authenticate.rs | 136 ++++++++++++++++-
 src/connection/sasl.rs         | 144 +++++++++++++++++-
 4 files changed, 336 insertions(+), 311 deletions(-)

diff --git a/src/client.rs b/src/client.rs
index 5dcb23b..59e5dbe 100644
--- a/src/client.rs
+++ b/src/client.rs
@@ -12,7 +12,7 @@ use tracing::{debug, error, info_span, instrument, warn, Instrument, Span};

use crate::{
    channel::Channel,
    connection::{InitiatedConnection, MessageSink, SaslAlreadyAuthenticated},
    connection::{sasl::SaslAlreadyAuthenticated, InitiatedConnection, MessageSink},
    messages::{
        Broadcast, ChannelFetchTopic, ChannelInvite, ChannelJoin, ChannelKickUser, ChannelList,
        ChannelMemberList, ChannelMessage, ChannelPart, ChannelSetMode, ChannelUpdateTopic,
diff --git a/src/connection.rs b/src/connection.rs
index 556aedf..d930e3d 100644
--- a/src/connection.rs
+++ b/src/connection.rs
@@ -1,17 +1,15 @@
mod authenticate;
pub mod sasl;

use std::{
    io::{Error, ErrorKind},
    net::SocketAddr,
    str::FromStr,
};

use actix::{io::FramedWrite, Addr};
use argon2::PasswordHash;
use base64::{prelude::BASE64_STANDARD, Engine};
use actix::{io::FramedWrite, Actor, Addr};
use const_format::concatcp;
use futures::{SinkExt, TryStreamExt};
use irc_proto::{
    error::ProtocolError, CapSubCommand, Command, IrcCodec, Message, Prefix, Response,
};
use irc_proto::{error::ProtocolError, CapSubCommand, Command, IrcCodec, Message, Prefix};
use tokio::{
    io::{ReadHalf, WriteHalf},
    net::TcpStream,
@@ -20,7 +18,10 @@ use tokio_util::codec::FramedRead;
use tracing::{instrument, warn};

use crate::{
    database::verify_password,
    connection::{
        authenticate::{Authenticate, AuthenticateMessage, AuthenticateResult},
        sasl::{AuthStrategy, ConnectionSuccess, SaslSuccess},
    },
    persistence::{events::ReserveNick, Persistence},
};

@@ -39,6 +40,7 @@ pub struct ConnectionRequest {
    user: Option<String>,
    mode: Option<String>,
    real_name: Option<String>,
    user_id: Option<UserId>,
}

#[derive(Clone)]
@@ -72,6 +74,7 @@ impl TryFrom<ConnectionRequest> for InitiatedConnection {
            user: Some(user),
            mode: Some(mode),
            real_name: Some(real_name),
            user_id: Some(user_id),
        } = value else {
            return Err(value);
        };
@@ -82,7 +85,7 @@ impl TryFrom<ConnectionRequest> for InitiatedConnection {
            user,
            mode,
            real_name,
            user_id: UserId(0),
            user_id,
        })
    }
}
@@ -102,7 +105,11 @@ pub async fn negotiate_client_connection(
        ..ConnectionRequest::default()
    };

    let mut capabilities_requested = false;
    let authenticate_handle = Authenticate {
        selected_strategy: None,
        database: database.clone(),
    }
    .start();

    // wait for the initiating commands from the user, giving us their NICK & USER and the user
    // requesting the server's capabilities - any clients not requesting capabilities are not
@@ -116,14 +123,12 @@ pub async fn negotiate_client_connection(
        match msg.command {
            Command::PASS(_) => {}
            Command::NICK(nick) => request.nick = Some(nick),
            Command::USER(user, mode, real_name) => {
                request.user = Some(user);
            Command::USER(_user, mode, real_name) => {
                // we ignore the user here, as it will be set by the AUTHENTICATE command
                request.mode = Some(mode);
                request.real_name = Some(real_name);
            }
            Command::CAP(_, CapSubCommand::LIST | CapSubCommand::LS, _, _) => {
                capabilities_requested = true;

                write
                    .send(Message {
                        tags: None,
@@ -138,6 +143,27 @@ pub async fn negotiate_client_connection(
                    .await
                    .unwrap();
            }
            Command::CAP(_, CapSubCommand::REQ, Some(arguments), None) => {
                write
                    .send(AcknowledgedCapabilities(arguments).into_message())
                    .await?;
            }
            Command::AUTHENTICATE(msg) => {
                match authenticate_handle
                    .send(AuthenticateMessage(msg))
                    .await
                    .unwrap()?
                {
                    AuthenticateResult::Reply(v) => {
                        write.send(*v).await?;
                    }
                    AuthenticateResult::Done(username, user_id) => {
                        request.user = Some(username);
                        request.user_id = Some(user_id);
                        write.send(SaslSuccess::into_message()).await?;
                    }
                }
            }
            _ => {
                warn!(?msg, "Client sent unknown command during negotiation");
            }
@@ -154,178 +180,30 @@ pub async fn negotiate_client_connection(

    // if the user closed the connection before the connection was fully established,
    // return back early
    let Some(mut initiated) = initiated else {
        return Ok(None);
    };

    if !capabilities_requested {
        return Err(ProtocolError::Io(Error::new(
            ErrorKind::InvalidData,
            "capabilities not requested by client, so SASL authentication can not be performed",
        )));
    }

    let mut user_id = None;

    // start negotiating capabilities with the client
    while let Some(msg) = s.try_next().await? {
        match msg.command {
            Command::CAP(_, CapSubCommand::REQ, Some(arguments), None) => {
                write
                    .send(AcknowledgedCapabilities(arguments).into_message())
                    .await?;
            }
            Command::CAP(_, CapSubCommand::END, _, _) => {
                break;
            }
            Command::AUTHENTICATE(strategy) => {
                user_id =
                    start_authenticate_flow(s, write, &initiated, strategy, &database).await?;
            }
            _ => {
                return Err(ProtocolError::Io(Error::new(
                    ErrorKind::InvalidData,
                    format!("client sent non-cap message during negotiation {msg:?}"),
                )))
            }
        }
    }

    if let Some(user_id) = user_id {
        initiated.user_id.0 = user_id;

        let reserved_nick = persistence
            .send(ReserveNick {
                user_id: initiated.user_id,
                nick: initiated.nick.clone(),
            })
            .await
            .map_err(|e| ProtocolError::Io(Error::new(ErrorKind::InvalidData, e)))?;

        if !reserved_nick {
            return Err(ProtocolError::Io(Error::new(
                ErrorKind::InvalidData,
                "nick is already in use by another user",
            )));
        }

        Ok(Some(initiated))
    } else {
        Err(ProtocolError::Io(Error::new(
            ErrorKind::InvalidData,
            "user has not authenticated",
        )))
    }
}

/// When the client has given us a strategy to use, we can start the authentication flow.
///
/// This function will return true or false, depending on whether authentication was successful,
/// or an `Err` if an internal error occurs.
async fn start_authenticate_flow(
    s: &mut MessageStream,
    write: &mut tokio_util::codec::FramedWrite<WriteHalf<TcpStream>, IrcCodec>,
    connection: &InitiatedConnection,
    strategy: String,
    database: &sqlx::Pool<sqlx::Any>,
) -> Result<Option<i64>, ProtocolError> {
    let Ok(auth_strategy) = AuthStrategy::from_str(&strategy) else {
        write.send(SaslStrategyUnsupported(connection.nick.to_string()).into_message())
            .await?;
    let Some(initiated) = initiated else {
        return Ok(None);
    };

    // tell the client to go ahead with their authentication
    write
        .send(Message {
            tags: None,
            prefix: None,
            command: Command::AUTHENTICATE("+".to_string()),
        })
        .send(ConnectionSuccess(initiated.clone()).into_message())
        .await?;

    // consume all AUTHENTICATE messages from the client
    while let Some(msg) = s.try_next().await? {
        let Command::AUTHENTICATE(arguments) = msg.command else {
            return Err(ProtocolError::Io(Error::new(
                ErrorKind::InvalidData,
                format!("client sent invalid message during authentication {msg:?}"),
            )));
        };

        // user has cancelled authentication
        if arguments == "*" {
            write
                .send(SaslAborted(connection.nick.to_string()).into_message())
                .await?;
            break;
        }

        let user_id = match auth_strategy {
            AuthStrategy::Plain => {
                // TODO: this needs to deal with the case where the full arguments can be split over
                //  multiple messages
                handle_plain_authentication(&arguments, connection, database).await?
            }
        };

        if user_id.is_some() {
            for message in SaslSuccess(connection.clone()).into_messages() {
                write.send(message).await?;
            }

            return Ok(user_id);
        }

        write
            .send(SaslFail(connection.nick.to_string()).into_message())
            .await?;
    }

    Ok(None)
}

/// Attempts to handle an `AUTHENTICATE` command for the `PLAIN` authentication method.
///
/// This will parse the full message, ensure that the identity is correct and compare the hashes
/// to what we have stored in the database.
///
/// This function will return the authenticated user id, or none if the password was incorrect.
pub async fn handle_plain_authentication(
    arguments: &str,
    connection: &InitiatedConnection,
    database: &sqlx::Pool<sqlx::Any>,
) -> Result<Option<i64>, Error> {
    let arguments = BASE64_STANDARD
        .decode(arguments)
        .map_err(|e| Error::new(ErrorKind::InvalidData, e))?;

    // split the PLAIN message into its respective parts
    let mut message = arguments.splitn(3, |f| *f == b'\0');
    let (Some(authorization_identity), Some(authentication_identity), Some(password)) = (message.next(), message.next(), message.next()) else {
        return Err(Error::new(ErrorKind::InvalidData, "bad plain message"));
    };
    let reserved_nick = persistence
        .send(ReserveNick {
            user_id: initiated.user_id,
            nick: initiated.nick.clone(),
        })
        .await
        .map_err(|e| ProtocolError::Io(Error::new(ErrorKind::InvalidData, e)))?;

    // we don't want any ambiguity here, so we only identities matching the `USER` command
    if authorization_identity != connection.user.as_bytes()
        || authentication_identity != connection.user.as_bytes()
    {
        return Err(Error::new(ErrorKind::InvalidData, "username mismatch"));
    if !reserved_nick {
        return Err(ProtocolError::Io(Error::new(
            ErrorKind::InvalidData,
            "nick is already in use by another user",
        )));
    }

    // lookup the user's password based on the USER command they sent earlier
    let (user_id, password_hash) =
        crate::database::create_user_or_fetch_password_hash(database, &connection.user, password)
            .await
            .unwrap();
    let password_hash = PasswordHash::new(&password_hash).unwrap();

    // check the user's password
    match verify_password(password, &password_hash) {
        Ok(()) => Ok(Some(user_id)),
        Err(argon2::password_hash::Error::Password) => Ok(None),
        Err(e) => Err(Error::new(ErrorKind::InvalidData, e.to_string())),
    }
    Ok(Some(initiated))
}

/// Return an acknowledgement to the client for their requested capabilities.
@@ -346,136 +224,3 @@ impl AcknowledgedCapabilities {
        }
    }
}

/// A requested SASL strategy.
#[derive(Copy, Clone, Debug)]
pub enum AuthStrategy {
    Plain,
}

impl AuthStrategy {
    /// A list of all supported SASL strategies.
    pub const SUPPORTED: &'static str = "PLAIN";
}

/// Parse a SASL strategy from the wire.
impl FromStr for AuthStrategy {
    type Err = std::io::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "PLAIN" => Ok(Self::Plain),
            _ => Err(Error::new(ErrorKind::InvalidData, "unknown auth strategy")),
        }
    }
}

/// Returned to the client if they try to call AUTHENTICATE again after negotiation.
pub struct SaslAlreadyAuthenticated(pub String);

impl SaslAlreadyAuthenticated {
    #[must_use]
    pub fn into_message(self) -> Message {
        Message {
            tags: None,
            prefix: None,
            command: Command::Response(
                Response::ERR_SASLALREADY,
                vec![
                    self.0,
                    "You have already authenticated using SASL".to_string(),
                ],
            ),
        }
    }
}

/// Returned to the client when an invalid SASL strategy is attempted.
pub struct SaslStrategyUnsupported(String);

impl SaslStrategyUnsupported {
    #[must_use]
    pub fn into_message(self) -> Message {
        Message {
            tags: None,
            prefix: None,
            command: Command::Response(
                Response::RPL_SASLMECHS,
                vec![
                    self.0,
                    AuthStrategy::SUPPORTED.to_string(),
                    "are available SASL mechanisms".to_string(),
                ],
            ),
        }
    }
}

/// Returned to the client when authentication is successful.
pub struct SaslSuccess(InitiatedConnection);

impl SaslSuccess {
    #[must_use]
    pub fn into_messages(self) -> [Message; 2] {
        [
            Message {
                tags: None,
                prefix: None,
                command: Command::Response(
                    Response::RPL_SASLSUCCESS,
                    vec![
                        self.0.nick.to_string(),
                        "SASL authentication successful".to_string(),
                    ],
                ),
            },
            Message {
                tags: None,
                prefix: None,
                command: Command::Response(
                    Response::RPL_LOGGEDIN,
                    vec![
                        self.0.nick.to_string(),
                        self.0.to_nick().to_string(),
                        self.0.user.to_string(),
                        format!("You are now logged in as {}", self.0.user),
                    ],
                ),
            },
        ]
    }
}

/// Returned to the client when SASL authentication fails.
pub struct SaslFail(String);

impl SaslFail {
    #[must_use]
    pub fn into_message(self) -> Message {
        Message {
            tags: None,
            prefix: None,
            command: Command::Response(
                Response::ERR_SASLFAIL,
                vec![self.0, "SASL authentication failed".to_string()],
            ),
        }
    }
}

/// Returned to the client when they abort SASL.
pub struct SaslAborted(String);

impl SaslAborted {
    #[must_use]
    pub fn into_message(self) -> Message {
        Message {
            tags: None,
            prefix: None,
            command: Command::Response(
                Response::ERR_SASLABORT,
                vec![self.0, "SASL authentication aborted".to_string()],
            ),
        }
    }
}
diff --git a/src/connection/authenticate.rs b/src/connection/authenticate.rs
new file mode 100644
index 0000000..2ad106d
--- /dev/null
+++ b/src/connection/authenticate.rs
@@ -0,0 +1,136 @@
//! An actor to handle the user's initial authentication.
//!
//! The connection initiation process is expected to call the actor with
//! every `AUTHENTICATE` command it receives from the client, and this
//! actor will return back with either a response for the user, or the user's
//! ID, once logged in.
//!
//! The user will be created if the username does not exist.

use std::{
    io::{Error, ErrorKind},
    str::FromStr,
};

use actix::{Actor, ActorContext, Context, Handler, Message, ResponseFuture};
use argon2::PasswordHash;
use base64::{prelude::BASE64_STANDARD, Engine};
use futures::TryFutureExt;
use irc_proto::Command;

use crate::{
    connection::{
        sasl::{AuthStrategy, SaslAborted, SaslFail, SaslStrategyUnsupported},
        UserId,
    },
    database::verify_password,
};

pub struct Authenticate {
    pub selected_strategy: Option<AuthStrategy>,
    pub database: sqlx::Pool<sqlx::Any>,
}

impl Actor for Authenticate {
    type Context = Context<Self>;
}

impl Handler<AuthenticateMessage> for Authenticate {
    type Result = ResponseFuture<Result<AuthenticateResult, std::io::Error>>;

    fn handle(&mut self, msg: AuthenticateMessage, ctx: &mut Self::Context) -> Self::Result {
        let Some(selected_strategy) = self.selected_strategy else {
            let message = match AuthStrategy::from_str(&msg.0) {
                Ok(strategy) => {
                    self.selected_strategy = Some(strategy);

                    // tell the client to go ahead with their authentication
                    irc_proto::Message {
                        tags: None,
                        prefix: None,
                        command: Command::AUTHENTICATE("+".to_string()),
                    }
                }
                Err(_) => SaslStrategyUnsupported::into_message(),
            };

            return Box::pin(futures::future::ok(AuthenticateResult::Reply(Box::new(message))));
        };

        // user has cancelled authentication
        if msg.0 == "*" {
            ctx.stop();
            return Box::pin(futures::future::ok(AuthenticateResult::Reply(Box::new(
                SaslAborted::into_message(),
            ))));
        }

        match selected_strategy {
            AuthStrategy::Plain => Box::pin(
                handle_plain_authentication(msg.0, self.database.clone()).map_ok(|v| {
                    v.map_or_else(
                        || AuthenticateResult::Reply(Box::new(SaslFail::into_message())),
                        |(username, user_id)| AuthenticateResult::Done(username, user_id),
                    )
                }),
            ),
        }
    }
}

/// Attempts to handle an `AUTHENTICATE` command for the `PLAIN` authentication method.
///
/// This will parse the full message, ensure that the identity is correct and compare the hashes
/// to what we have stored in the database.
///
/// This function will return the authenticated user id and username, or None if the password was
/// incorrect.
pub async fn handle_plain_authentication(
    arguments: String,
    database: sqlx::Pool<sqlx::Any>,
) -> Result<Option<(String, UserId)>, Error> {
    // TODO: this needs to deal with AUTHENTICATE spanning more than one message
    let arguments = BASE64_STANDARD
        .decode(&arguments)
        .map_err(|e| Error::new(ErrorKind::InvalidData, e))?;

    // split the PLAIN message into its respective parts
    let mut message = arguments.splitn(3, |f| *f == b'\0');
    let (Some(authorization_identity), Some(authentication_identity), Some(password)) = (message.next(), message.next(), message.next()) else {
        return Err(Error::new(ErrorKind::InvalidData, "bad plain message"));
    };

    // we don't want any ambiguity here, so the two identities need to match
    if authorization_identity != authentication_identity {
        return Err(Error::new(ErrorKind::InvalidData, "identity mismatch"));
    }

    let authorization_identity = std::str::from_utf8(authentication_identity)
        .map_err(|e| Error::new(ErrorKind::InvalidData, e))?;

    // lookup the user's password based on the USER command they sent earlier
    let (user_id, password_hash) = crate::database::create_user_or_fetch_password_hash(
        &database,
        authorization_identity,
        password,
    )
    .await
    .unwrap();
    let password_hash = PasswordHash::new(&password_hash).unwrap();

    // check the user's password
    match verify_password(password, &password_hash) {
        Ok(()) => Ok(Some((authorization_identity.to_string(), UserId(user_id)))),
        Err(argon2::password_hash::Error::Password) => Ok(None),
        Err(e) => Err(Error::new(ErrorKind::InvalidData, e.to_string())),
    }
}

pub enum AuthenticateResult {
    Reply(Box<irc_proto::Message>),
    Done(String, UserId),
}

#[derive(Message)]
#[rtype(result = "Result<AuthenticateResult, std::io::Error>")]
pub struct AuthenticateMessage(pub String);
diff --git a/src/connection/sasl.rs b/src/connection/sasl.rs
new file mode 100644
index 0000000..9331996
--- /dev/null
+++ b/src/connection/sasl.rs
@@ -0,0 +1,144 @@
use std::{
    io::{Error, ErrorKind},
    str::FromStr,
};

use irc_proto::{Command, Message, Response};

use crate::connection::InitiatedConnection;

/// A requested SASL strategy.
#[derive(Copy, Clone, Debug)]
pub enum AuthStrategy {
    Plain,
}

impl AuthStrategy {
    /// A list of all supported SASL strategies.
    pub const SUPPORTED: &'static str = "PLAIN";
}

/// Parse a SASL strategy from the wire.
impl FromStr for AuthStrategy {
    type Err = std::io::Error;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s {
            "PLAIN" => Ok(Self::Plain),
            _ => Err(Error::new(ErrorKind::InvalidData, "unknown auth strategy")),
        }
    }
}

/// Returned to the client if they try to call AUTHENTICATE again after negotiation.
pub struct SaslAlreadyAuthenticated(pub String);

impl SaslAlreadyAuthenticated {
    #[must_use]
    pub fn into_message(self) -> Message {
        Message {
            tags: None,
            prefix: None,
            command: Command::Response(
                Response::ERR_SASLALREADY,
                vec![
                    self.0,
                    "You have already authenticated using SASL".to_string(),
                ],
            ),
        }
    }
}

/// Returned to the client when an invalid SASL strategy is attempted.
pub struct SaslStrategyUnsupported;

impl SaslStrategyUnsupported {
    #[must_use]
    pub fn into_message() -> Message {
        Message {
            tags: None,
            prefix: None,
            command: Command::Response(
                Response::RPL_SASLMECHS,
                vec![
                    AuthStrategy::SUPPORTED.to_string(),
                    "are available SASL mechanisms".to_string(),
                ],
            ),
        }
    }
}

/// Returned to the client when SASL authentication is successful.
pub struct SaslSuccess;

impl SaslSuccess {
    #[must_use]
    pub fn into_message() -> Message {
        Message {
            tags: None,
            prefix: None,
            command: Command::Response(
                Response::RPL_SASLSUCCESS,
                vec!["SASL authentication successful".to_string()],
            ),
        }
    }
}

/// Returned to the client when the whole connection flow is successful.
pub struct ConnectionSuccess(pub InitiatedConnection);

impl ConnectionSuccess {
    #[must_use]
    pub fn into_message(self) -> Message {
        Message {
            tags: None,
            prefix: None,
            command: Command::Response(
                Response::RPL_LOGGEDIN,
                vec![
                    self.0.nick.to_string(),
                    self.0.to_nick().to_string(),
                    self.0.user.to_string(),
                    format!("You are now logged in as {}", self.0.user),
                ],
            ),
        }
    }
}

/// Returned to the client when SASL authentication fails.
pub struct SaslFail;

impl SaslFail {
    #[must_use]
    pub fn into_message() -> Message {
        Message {
            tags: None,
            prefix: None,
            command: Command::Response(
                Response::ERR_SASLFAIL,
                vec!["SASL authentication failed".to_string()],
            ),
        }
    }
}

/// Returned to the client when they abort SASL.
pub struct SaslAborted;

impl SaslAborted {
    #[must_use]
    pub fn into_message() -> Message {
        Message {
            tags: None,
            prefix: None,
            command: Command::Response(
                Response::ERR_SASLABORT,
                vec!["SASL authentication aborted".to_string()],
            ),
        }
    }
}