Skip to main content

authx_plugins/username/
service.rs

1use 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
21/// Username + password authentication service.
22///
23/// Analogous to `EmailPasswordService` but lookups are by username instead of email.
24pub 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    /// Create a new account with a username + password.
47    #[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    /// Sign in with username + password, creating a new session on success.
87    #[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}