use argon2::password_hash::{SaltString, rand_core::OsRng};
use argon2::{Argon2, PasswordHasher};
use chrono::Utc;
use tracing::instrument;
use uuid::Uuid;
use authx_core::{
crypto::sha256_hex,
error::{AuthError, Result},
events::{AuthEvent, EventBus},
models::{
CreateCredential, CreateSession, CreateUser, CredentialKind, Session, UpdateUser, User,
},
};
use authx_storage::ports::{CredentialRepository, SessionRepository, UserRepository};
#[derive(Debug)]
pub struct GuestSession {
pub user: User,
pub session: Session,
pub token: String,
}
pub struct AnonymousService<S> {
storage: S,
events: EventBus,
session_ttl_secs: i64,
argon2: Argon2<'static>,
}
impl<S> AnonymousService<S>
where
S: UserRepository + CredentialRepository + SessionRepository + Clone + Send + Sync + 'static,
{
pub fn new(storage: S, events: EventBus, session_ttl_secs: i64) -> Self {
use argon2::{Algorithm, Params, Version};
let params = Params::new(65536, 3, 4, None).expect("valid argon2 params");
Self {
storage,
events,
session_ttl_secs,
argon2: Argon2::new(Algorithm::Argon2id, Version::V0x13, params),
}
}
#[instrument(skip(self), fields(ip = %ip))]
pub async fn create_guest(&self, ip: &str) -> Result<GuestSession> {
let guest_id = Uuid::new_v4();
let email = format!("guest_{}@authx.guest", guest_id);
let user = UserRepository::create(
&self.storage,
CreateUser {
email: email.clone(),
username: None,
metadata: Some(serde_json::json!({ "guest": true })),
},
)
.await?;
let raw: [u8; 32] = rand::Rng::r#gen(&mut rand::thread_rng());
let raw_str = hex::encode(raw);
let token_hash = sha256_hex(raw_str.as_bytes());
let session = SessionRepository::create(
&self.storage,
CreateSession {
user_id: user.id,
token_hash,
device_info: serde_json::Value::Null,
ip_address: ip.to_owned(),
org_id: None,
expires_at: Utc::now() + chrono::Duration::seconds(self.session_ttl_secs),
},
)
.await?;
self.events
.emit(AuthEvent::UserCreated { user: user.clone() });
self.events.emit(AuthEvent::SignIn {
user: user.clone(),
session: session.clone(),
});
tracing::info!(user_id = %user.id, "guest session created");
Ok(GuestSession {
user,
session,
token: raw_str,
})
}
#[instrument(skip(self, password), fields(guest_user_id = %guest_user_id))]
pub async fn upgrade(&self, guest_user_id: Uuid, email: &str, password: &str) -> Result<User> {
let user = UserRepository::find_by_id(&self.storage, guest_user_id)
.await?
.ok_or(AuthError::UserNotFound)?;
let is_guest = user
.metadata
.get("guest")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !is_guest {
return Err(AuthError::Forbidden("not a guest account".into()));
}
if password.len() < 8 {
return Err(AuthError::WeakPassword);
}
let salt = SaltString::generate(&mut OsRng);
let hash = self
.argon2
.hash_password(password.as_bytes(), &salt)
.map_err(|e| AuthError::Internal(format!("argon2 hash: {e}")))?
.to_string();
let updated = UserRepository::update(
&self.storage,
guest_user_id,
UpdateUser {
email: Some(email.to_owned()),
metadata: Some(serde_json::json!({ "guest": false })),
..Default::default()
},
)
.await?;
CredentialRepository::create(
&self.storage,
CreateCredential {
user_id: guest_user_id,
kind: CredentialKind::Password,
credential_hash: hash,
metadata: None,
},
)
.await?;
self.events.emit(AuthEvent::UserUpdated {
user: updated.clone(),
});
tracing::info!(user_id = %guest_user_id, email = %email, "guest account upgraded");
Ok(updated)
}
}