Skip to content
Clone
d3d11f4
425 lines 14.1 KB Rust
use models::users::user;
use models::users::user_recovery_codes;
use models::users::{user_devices, user_emails};
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set};
use serde::{Deserialize, Serialize};
use session_layer::{AuthUser, Session};
use time::OffsetDateTime;
use uuid::Uuid;
use super::AuthService;
use crate::errors::AuthError;
use crate::share::crypto::{aes_decrypt, now_secs, random_token, sha256_b64url};
use crate::share::rate::{attempts_reset, attempts_track};
use crate::share::totp::verify_totp_code;
use crate::share::types::UserPublic;
const TWO_FA_KEY: &str = "auth:2fa";
const TWO_FA_TTL_SECS: i64 = 300;
const LOGIN_ATTEMPTS_MAX: u64 = 5;
const LOGIN_ATTEMPTS_WINDOW: u64 = 300;
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LoginCmd {
pub identifier: String,
pub password: String,
pub pow_answer: String,
/// Opt-in long-lived session ("keep me signed in"). Defaults to false so
/// older clients keep the standard lifespan.
#[serde(default)]
pub remember: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct LoginResp {
pub user: UserPublic,
pub needs_2fa: bool,
pub challenge: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
pub struct TwoFactorLoginCmd {
pub code: String,
pub challenge: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TwoFactorChallenge {
pub uid: Uuid,
pub challenge: String,
pub expires_at: i64,
pub ua_hash: Option<String>,
pub ip_hash: Option<String>,
/// Sessions written before this field existed must still decode.
#[serde(default)]
pub remember: bool,
}
impl AuthService {
pub async fn login(
&self,
session: &Session,
cmd: LoginCmd,
user_agent: Option<&str>,
client_ip: Option<&str>,
) -> Result<LoginResp, AuthError> {
let result = self.login_inner(session, cmd, user_agent, client_ip).await;
export::metrics::auth_login(match &result {
Ok(resp) if resp.needs_2fa => "challenge",
Ok(_) => "ok",
Err(_) => "rejected",
});
result
}
async fn login_inner(
&self,
session: &Session,
cmd: LoginCmd,
user_agent: Option<&str>,
client_ip: Option<&str>,
) -> Result<LoginResp, AuthError> {
self.verify_pow(session, &cmd.pow_answer)?;
let ua_hash = user_agent.map(|ua| sha256_b64url(ua.as_bytes()));
let ip_hash = client_ip.map(|ip| sha256_b64url(ip.as_bytes()));
let ua_hash = Some(ua_hash.unwrap_or_default()).filter(|s| !s.is_empty());
let ip_hash = Some(ip_hash.unwrap_or_default()).filter(|s| !s.is_empty());
let db = self.db();
let identifier = cmd.identifier.trim().to_ascii_lowercase();
let attempts_key = format!("auth:login_attempts:{identifier}");
if !attempts_track(
self.redis(),
&attempts_key,
LOGIN_ATTEMPTS_MAX,
LOGIN_ATTEMPTS_WINDOW,
)
.await?
{
return Err(AuthError::RateLimited);
}
let Some(user) = Self::find_user_by_identifier(db.read(), &identifier).await? else {
return Err(AuthError::InvalidCredentials);
};
if user.prohibit_login {
return Err(AuthError::Forbidden);
}
if user.is_restricted {
return Err(AuthError::Forbidden);
}
if !user.is_active {
return Err(AuthError::Forbidden);
}
let stored = user
.passwd_hash
.as_deref()
.ok_or_else(|| AuthError::BadRequest("account has no password".to_string()))?;
if !crate::share::crypto::verify_password(&cmd.password, stored) {
return Err(AuthError::InvalidCredentials);
}
attempts_reset(self.redis(), &attempts_key).await?;
if user.totp_enabled {
session.set_store(true);
let challenge = TwoFactorChallenge {
uid: user.uid,
challenge: Uuid::now_v7().to_string(),
expires_at: now_secs() + TWO_FA_TTL_SECS,
ua_hash,
ip_hash,
remember: cmd.remember,
};
session.set(TWO_FA_KEY, &challenge);
return Ok(LoginResp {
user: user_dto(&user),
needs_2fa: true,
challenge: Some(challenge.challenge.clone()),
});
}
self.finish_login(session, &user, ua_hash, ip_hash, cmd.remember)
.await
}
pub async fn login_2fa(
&self,
session: &Session,
cmd: TwoFactorLoginCmd,
) -> Result<LoginResp, AuthError> {
let result = self.login_2fa_inner(session, cmd).await;
export::metrics::auth_login(match &result {
Ok(_) => "ok",
Err(_) => "rejected",
});
result
}
async fn login_2fa_inner(
&self,
session: &Session,
cmd: TwoFactorLoginCmd,
) -> Result<LoginResp, AuthError> {
let Some(challenge) = session.get::<TwoFactorChallenge>(TWO_FA_KEY) else {
return Err(AuthError::BadRequest(
"no pending two-factor challenge".to_string(),
));
};
if challenge.challenge != cmd.challenge {
session.remove(TWO_FA_KEY);
return Err(AuthError::BadRequest(
"invalid two-factor challenge".to_string(),
));
}
let attempts_key = format!("auth:2fa_attempts:{}", challenge.uid);
if !attempts_track(self.redis(), &attempts_key, 5, 300).await? {
session.remove(TWO_FA_KEY);
return Err(AuthError::RateLimited);
}
if challenge.expires_at <= now_secs() {
session.remove(TWO_FA_KEY);
return Err(AuthError::TwoFactorExpired);
}
let db = self.db();
let Some(user) = user::Entity::find_by_id(challenge.uid)
.one(db.read())
.await?
else {
session.remove(TWO_FA_KEY);
return Err(AuthError::BadRequest(
"invalid two-factor challenge".to_string(),
));
};
if !user.totp_enabled {
session.remove(TWO_FA_KEY);
return Err(AuthError::BadRequest(
"two-factor authentication is not enabled".to_string(),
));
}
self.consume_two_factor_code(&user, &cmd.code).await?;
session.remove(TWO_FA_KEY);
attempts_reset(self.redis(), &attempts_key).await?;
self.finish_login(
session,
&user,
challenge.ua_hash,
challenge.ip_hash,
challenge.remember,
)
.await
}
async fn consume_two_factor_code(
&self,
user: &user::Model,
code: &str,
) -> Result<(), AuthError> {
let secret_key = self
.config()
.auth
.totp_encryption_key
.as_deref()
.ok_or_else(|| AuthError::Internal("totp encryption key not configured".to_string()))?;
let stored = user
.totp_encrypted_secret
.as_deref()
.ok_or_else(|| AuthError::Internal("totp secret missing".to_string()))?;
let secret = aes_decrypt(secret_key, stored)?;
match verify_totp_code(&secret, code, user.totp_last_counter) {
Ok((_, counter)) => {
let now = OffsetDateTime::now_utc();
let mut active = user.clone().into_active_model();
active.totp_last_used_at = Set(Some(now));
active.totp_last_counter = Set(Some(counter));
active.updated_at = Set(now);
active.update(self.db().write()).await?;
Ok(())
}
Err(totp_error) => {
if let TotpErrorCategory::Invalid = TotpErrorCategory::from(&totp_error) {
self.consume_recovery_code(user.uid, code).await
} else {
Err(AuthError::from(totp_error))
}
}
}
}
async fn consume_recovery_code(&self, uid: Uuid, code: &str) -> Result<(), AuthError> {
let hash = sha256_b64url(code.trim().as_bytes());
let db = self.db();
let Some(row) = user_recovery_codes::Entity::find()
.filter(user_recovery_codes::Column::UserId.eq(uid))
.filter(user_recovery_codes::Column::CodeHash.eq(&hash))
.filter(user_recovery_codes::Column::UsedAt.is_null())
.one(db.write())
.await?
else {
return Err(AuthError::RecoveryCodeInvalid);
};
let mut active = row.into_active_model();
active.used_at = Set(Some(OffsetDateTime::now_utc()));
active.update(db.write()).await?;
Ok(())
}
async fn finish_login(
&self,
session: &Session,
user: &user::Model,
ua_hash: Option<String>,
ip_hash: Option<String>,
remember: bool,
) -> Result<LoginResp, AuthError> {
self.assert_not_suspended(user.uid).await?;
let db = self.db();
let primary = user_emails::Entity::find()
.filter(user_emails::Column::UserId.eq(user.uid))
.filter(user_emails::Column::IsPrimary.eq(true))
.one(db.read())
.await?;
let email = primary.map(|row| row.email).unwrap_or_default();
let device_id = match ua_hash.as_deref() {
Some(hash) => match self.record_device(user.uid, hash, ip_hash.clone()).await {
Ok(id) => id,
Err(error) => {
tracing::warn!(%error, "failed to record device, continuing without it");
None
}
},
None => None,
};
let token = random_token(32);
let token_hash = sha256_b64url(token.as_bytes());
self.create_session(
&token_hash,
user.uid,
ua_hash.as_deref(),
ip_hash.as_deref(),
device_id,
remember,
)
.await?;
// Marks the session row and the browser cookie for the long window;
// without it both expire on the standard lifespan.
session.set_longterm(remember);
let auth_user = AuthUser {
uid: user.uid,
username: user.username.clone(),
display_name: Some(user.display_name.clone()),
avatar_url: Some(export::resolve_user_avatar_url(user.uid, &user.avatar_url)),
email,
};
session_layer::share::session::apply_login(session, auth_user, user.passwd_version);
session.set(
&session_layer::share::session::session_token_key(user.uid),
token_hash,
);
let now = OffsetDateTime::now_utc();
let mut active = user.clone().into_active_model();
active.last_login_at = Set(Some(now));
active.updated_at = Set(now);
let _ = active.update(db.write()).await;
Ok(LoginResp {
user: user_dto(user),
needs_2fa: false,
challenge: None,
})
}
async fn record_device(
&self,
uid: Uuid,
ua_hash: &str,
ip_hash: Option<String>,
) -> Result<Option<Uuid>, AuthError> {
let db = self.db();
let now = OffsetDateTime::now_utc();
if let Some(device) = user_devices::Entity::find()
.filter(user_devices::Column::UserId.eq(uid))
.filter(user_devices::Column::DeviceHash.eq(ua_hash))
.one(db.write())
.await?
{
let id = device.id;
let mut active = device.into_active_model();
active.last_seen_at = Set(now);
active.last_ip_hash = Set(ip_hash);
active.updated_at = Set(now);
active.update(db.write()).await?;
Ok(Some(id))
} else {
let id = Uuid::now_v7();
let row = user_devices::ActiveModel {
id: Set(id),
user_id: Set(uid),
device_name: Set(None),
device_type: Set(None),
device_hash: Set(ua_hash.to_string()),
user_agent_hash: Set(Some(ua_hash.to_string())),
os: Set(None),
last_ip_hash: Set(ip_hash),
last_seen_at: Set(now),
first_seen_at: Set(now),
is_trusted: Set(false),
created_at: Set(now),
updated_at: Set(now),
};
row.insert(db.write()).await?;
Ok(Some(id))
}
}
async fn find_user_by_identifier(
db: &sea_orm::DatabaseConnection,
identifier: &str,
) -> Result<Option<user::Model>, AuthError> {
if identifier.contains('@')
&& let Some((_email, Some(user))) = user_emails::Entity::find()
.filter(user_emails::Column::LowerEmail.eq(identifier))
.filter(user_emails::Column::DeletedAt.is_null())
.find_also_related(user::Entity)
.one(db)
.await?
{
return Ok(Some(user));
}
Ok(user::Entity::find()
.filter(user::Column::LowerName.eq(identifier))
.one(db)
.await?)
}
}
enum TotpErrorCategory {
Invalid,
MalformedSecret,
}
impl From<&crate::share::totp::TotpError> for TotpErrorCategory {
fn from(error: &crate::share::totp::TotpError) -> Self {
match error {
crate::share::totp::TotpError::InvalidCode
| crate::share::totp::TotpError::InvalidOrExpiredCode => Self::Invalid,
crate::share::totp::TotpError::MalformedSecret => Self::MalformedSecret,
}
}
}
fn user_dto(u: &user::Model) -> UserPublic {
UserPublic {
user_id: u.uid,
username: u.username.clone(),
display_name: u.display_name.clone(),
avatar_url: space::services::spaces::resolve_user_avatar_url(u.uid, &u.avatar_url),
}
}