authx_plugins/username/
service.rs1use argon2::password_hash::{SaltString, rand_core::OsRng};
2use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
3use chrono::Utc;
4use tracing::instrument;
5
6use authx_core::{
7 crypto::sha256_hex,
8 error::{AuthError, Result},
9 events::{AuthEvent, EventBus},
10 models::{CreateCredential, CreateSession, CreateUser, CredentialKind, Session, User},
11};
12use authx_storage::ports::{CredentialRepository, SessionRepository, UserRepository};
13
14#[derive(Debug)]
15pub struct UsernameAuthResponse {
16 pub user: User,
17 pub session: Session,
18 pub token: String,
19}
20
21pub struct UsernameService<S> {
25 storage: S,
26 events: EventBus,
27 session_ttl_secs: i64,
28 argon2: Argon2<'static>,
29}
30
31impl<S> UsernameService<S>
32where
33 S: UserRepository + CredentialRepository + SessionRepository + Clone + Send + Sync + 'static,
34{
35 pub fn new(storage: S, events: EventBus, session_ttl_secs: i64) -> Self {
36 use argon2::{Algorithm, Params, Version};
37 let params = Params::new(65536, 3, 4, None).expect("valid argon2 params");
38 Self {
39 storage,
40 events,
41 session_ttl_secs,
42 argon2: Argon2::new(Algorithm::Argon2id, Version::V0x13, params),
43 }
44 }
45
46 #[instrument(skip(self, password), fields(username = %username))]
48 pub async fn sign_up(&self, username: &str, email: &str, password: &str) -> Result<User> {
49 if password.len() < 8 {
50 return Err(AuthError::WeakPassword);
51 }
52 let salt = SaltString::generate(&mut OsRng);
53 let hash = self
54 .argon2
55 .hash_password(password.as_bytes(), &salt)
56 .map_err(|e| AuthError::Internal(format!("argon2 hash: {e}")))?
57 .to_string();
58
59 let user = UserRepository::create(
60 &self.storage,
61 CreateUser {
62 email: email.to_owned(),
63 username: Some(username.to_owned()),
64 metadata: None,
65 },
66 )
67 .await?;
68
69 CredentialRepository::create(
70 &self.storage,
71 CreateCredential {
72 user_id: user.id,
73 kind: CredentialKind::Password,
74 credential_hash: hash,
75 metadata: None,
76 },
77 )
78 .await?;
79
80 self.events
81 .emit(AuthEvent::UserCreated { user: user.clone() });
82 tracing::info!(user_id = %user.id, username = %username, "username sign-up complete");
83 Ok(user)
84 }
85
86 #[instrument(skip(self, password), fields(username = %username, ip = %ip))]
88 pub async fn sign_in(
89 &self,
90 username: &str,
91 password: &str,
92 ip: &str,
93 ) -> Result<UsernameAuthResponse> {
94 let user = UserRepository::find_by_username(&self.storage, username)
95 .await?
96 .ok_or(AuthError::InvalidCredentials)?;
97
98 let hash_str = CredentialRepository::find_password_hash(&self.storage, user.id)
99 .await?
100 .ok_or(AuthError::InvalidCredentials)?;
101
102 let parsed = PasswordHash::new(&hash_str)
103 .map_err(|e| AuthError::Internal(format!("argon2 parse: {e}")))?;
104 if self
105 .argon2
106 .verify_password(password.as_bytes(), &parsed)
107 .is_err()
108 {
109 tracing::warn!(username = %username, "username sign-in: wrong password");
110 return Err(AuthError::InvalidCredentials);
111 }
112
113 let raw: [u8; 32] = rand::Rng::r#gen(&mut rand::thread_rng());
114 let raw_str = hex::encode(raw);
115 let token_hash = sha256_hex(raw_str.as_bytes());
116
117 let session = SessionRepository::create(
118 &self.storage,
119 CreateSession {
120 user_id: user.id,
121 token_hash,
122 device_info: serde_json::Value::Null,
123 ip_address: ip.to_owned(),
124 org_id: None,
125 expires_at: Utc::now() + chrono::Duration::seconds(self.session_ttl_secs),
126 },
127 )
128 .await?;
129
130 self.events.emit(AuthEvent::SignIn {
131 user: user.clone(),
132 session: session.clone(),
133 });
134 tracing::info!(user_id = %user.id, "username sign-in complete");
135 Ok(UsernameAuthResponse {
136 user,
137 session,
138 token: raw_str,
139 })
140 }
141}