From 7e1f8d71444370478de52925219b4badf3776764 Mon Sep 17 00:00:00 2001 From: Jordan Doyle Date: Thu, 12 Jan 2023 00:20:35 +0000 Subject: [PATCH] Allow SASL authentication before the registration process has finished --- 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(-) create mode 100644 src/connection/authenticate.rs create mode 100644 src/connection/sasl.rs 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, mode: Option, real_name: Option, + user_id: Option, } #[derive(Clone)] @@ -72,6 +74,7 @@ impl TryFrom 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 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, IrcCodec>, - connection: &InitiatedConnection, - strategy: String, - database: &sqlx::Pool, -) -> Result, 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, -) -> Result, 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 { - 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, + pub database: sqlx::Pool, +} + +impl Actor for Authenticate { + type Context = Context; +} + +impl Handler for Authenticate { + type Result = ResponseFuture>; + + 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, +) -> Result, 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), + Done(String, UserId), +} + +#[derive(Message)] +#[rtype(result = "Result")] +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 { + 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()], + ), + } + } +} -- libgit2 1.7.2