authx_plugins/anonymous/
service.rs1use argon2::password_hash::{SaltString, rand_core::OsRng};
2use argon2::{Argon2, PasswordHasher};
3use chrono::Utc;
4use tracing::instrument;
5use uuid::Uuid;
6
7use authx_core::{
8 crypto::sha256_hex,
9 error::{AuthError, Result},
10 events::{AuthEvent, EventBus},
11 models::{
12 CreateCredential, CreateSession, CreateUser, CredentialKind, Session, UpdateUser, User,
13 },
14};
15use authx_storage::ports::{CredentialRepository, SessionRepository, UserRepository};
16
17#[derive(Debug)]
19pub struct GuestSession {
20 pub user: User,
21 pub session: Session,
22 pub token: String,
24}
25
26pub struct AnonymousService<S> {
31 storage: S,
32 events: EventBus,
33 session_ttl_secs: i64,
34 argon2: Argon2<'static>,
35}
36
37impl<S> AnonymousService<S>
38where
39 S: UserRepository + CredentialRepository + SessionRepository + Clone + Send + Sync + 'static,
40{
41 pub fn new(storage: S, events: EventBus, session_ttl_secs: i64) -> Self {
42 use argon2::{Algorithm, Params, Version};
43 let params = Params::new(65536, 3, 4, None).expect("valid argon2 params");
44 Self {
45 storage,
46 events,
47 session_ttl_secs,
48 argon2: Argon2::new(Algorithm::Argon2id, Version::V0x13, params),
49 }
50 }
51
52 #[instrument(skip(self), fields(ip = %ip))]
55 pub async fn create_guest(&self, ip: &str) -> Result<GuestSession> {
56 let guest_id = Uuid::new_v4();
57 let email = format!("guest_{}@authx.guest", guest_id);
58
59 let user = UserRepository::create(
60 &self.storage,
61 CreateUser {
62 email: email.clone(),
63 username: None,
64 metadata: Some(serde_json::json!({ "guest": true })),
65 },
66 )
67 .await?;
68
69 let raw: [u8; 32] = rand::Rng::r#gen(&mut rand::thread_rng());
70 let raw_str = hex::encode(raw);
71 let token_hash = sha256_hex(raw_str.as_bytes());
72
73 let session = SessionRepository::create(
74 &self.storage,
75 CreateSession {
76 user_id: user.id,
77 token_hash,
78 device_info: serde_json::Value::Null,
79 ip_address: ip.to_owned(),
80 org_id: None,
81 expires_at: Utc::now() + chrono::Duration::seconds(self.session_ttl_secs),
82 },
83 )
84 .await?;
85
86 self.events
87 .emit(AuthEvent::UserCreated { user: user.clone() });
88 self.events.emit(AuthEvent::SignIn {
89 user: user.clone(),
90 session: session.clone(),
91 });
92 tracing::info!(user_id = %user.id, "guest session created");
93 Ok(GuestSession {
94 user,
95 session,
96 token: raw_str,
97 })
98 }
99
100 #[instrument(skip(self, password), fields(guest_user_id = %guest_user_id))]
104 pub async fn upgrade(&self, guest_user_id: Uuid, email: &str, password: &str) -> Result<User> {
105 let user = UserRepository::find_by_id(&self.storage, guest_user_id)
106 .await?
107 .ok_or(AuthError::UserNotFound)?;
108
109 let is_guest = user
111 .metadata
112 .get("guest")
113 .and_then(|v| v.as_bool())
114 .unwrap_or(false);
115 if !is_guest {
116 return Err(AuthError::Forbidden("not a guest account".into()));
117 }
118
119 if password.len() < 8 {
120 return Err(AuthError::WeakPassword);
121 }
122 let salt = SaltString::generate(&mut OsRng);
123 let hash = self
124 .argon2
125 .hash_password(password.as_bytes(), &salt)
126 .map_err(|e| AuthError::Internal(format!("argon2 hash: {e}")))?
127 .to_string();
128
129 let updated = UserRepository::update(
130 &self.storage,
131 guest_user_id,
132 UpdateUser {
133 email: Some(email.to_owned()),
134 metadata: Some(serde_json::json!({ "guest": false })),
135 ..Default::default()
136 },
137 )
138 .await?;
139
140 CredentialRepository::create(
141 &self.storage,
142 CreateCredential {
143 user_id: guest_user_id,
144 kind: CredentialKind::Password,
145 credential_hash: hash,
146 metadata: None,
147 },
148 )
149 .await?;
150
151 self.events.emit(AuthEvent::UserUpdated {
152 user: updated.clone(),
153 });
154 tracing::info!(user_id = %guest_user_id, email = %email, "guest account upgraded");
155 Ok(updated)
156 }
157}