Skip to main content

anvil_core/auth/
mod.rs

1//! Auth: sessions, guards, policies. Argon2-backed.
2//!
3//! ## Submodules
4//!
5//! - [`totp`] — RFC 6238 time-based one-time passwords (second-factor auth).
6//! - [`recovery`] — single-use recovery codes for TOTP fallback.
7
8pub mod recovery;
9pub mod totp;
10
11use std::marker::PhantomData;
12use std::sync::Arc;
13
14use argon2::{
15    password_hash::{rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString},
16    Argon2, PasswordHash,
17};
18use async_trait::async_trait;
19use axum::extract::{FromRef, FromRequestParts};
20use axum::http::request::Parts;
21use parking_lot::RwLock;
22use serde::{de::DeserializeOwned, Serialize};
23use tower_sessions::Session;
24
25use crate::container::Container;
26use crate::Error;
27
28pub const SESSION_USER_ID_KEY: &str = "_auth.user_id";
29
30/// Marker trait for app-defined user models that participate in auth.
31///
32/// Implement (or derive via `#[derive(Authenticatable)]`) on the model that
33/// represents your logged-in user. The methods drive both the `Auth<U>`
34/// extractor (loads the current user) and `attempt()` (login by credentials).
35#[async_trait]
36pub trait Authenticatable: Send + Sync + Sized + Clone + 'static {
37    type Id: Serialize + DeserializeOwned + Send + Sync + Clone + 'static;
38
39    /// Return this user's ID — what gets stored in the session.
40    fn id(&self) -> Self::Id;
41
42    /// Look up by ID, used by the `Auth<U>` extractor on every request.
43    async fn find_by_id(container: &Container, id: &Self::Id) -> Result<Option<Self>, Error>;
44
45    /// Look up by login identifier (email, username, etc.) and return the user
46    /// along with the stored password hash. Used by `attempt()`.
47    async fn find_by_credentials(
48        container: &Container,
49        identifier: &str,
50    ) -> Result<Option<(Self, String)>, Error>;
51}
52
53/// Manager-level auth state. Currently holds a hashing pepper toggle; future
54/// expansion: multiple guards, OAuth providers, etc.
55#[derive(Default, Clone)]
56pub struct AuthManager {
57    #[allow(dead_code)]
58    inner: Arc<RwLock<AuthInner>>,
59}
60
61#[derive(Default)]
62struct AuthInner {
63    #[allow(dead_code)]
64    hasher_pepper: Option<String>,
65}
66
67impl AuthManager {
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    pub fn with_pepper(self, pepper: impl Into<String>) -> Self {
73        self.inner.write().hasher_pepper = Some(pepper.into());
74        self
75    }
76}
77
78/// Hash a password using argon2id. Returns the encoded PHC string.
79pub fn hash_password(plain: &str) -> Result<String, Error> {
80    let salt = SaltString::generate(&mut OsRng);
81    let argon2 = Argon2::default();
82    argon2
83        .hash_password(plain.as_bytes(), &salt)
84        .map(|h| h.to_string())
85        .map_err(|e| Error::Internal(format!("password hash failed: {e}")))
86}
87
88/// Verify a plaintext password against an encoded PHC string.
89pub fn verify_password(plain: &str, hash: &str) -> bool {
90    let Ok(parsed) = PasswordHash::new(hash) else {
91        return false;
92    };
93    Argon2::default()
94        .verify_password(plain.as_bytes(), &parsed)
95        .is_ok()
96}
97
98/// Run a credentials-based login attempt. Returns the authenticated user
99/// or `None` if credentials are invalid. Does NOT persist the login — call
100/// [`login`] to write the user ID into the session.
101pub async fn attempt<U: Authenticatable>(
102    container: &Container,
103    identifier: &str,
104    password: &str,
105) -> Result<Option<U>, Error> {
106    let Some((user, hash)) = U::find_by_credentials(container, identifier).await? else {
107        return Ok(None);
108    };
109    if verify_password(password, &hash) {
110        Ok(Some(user))
111    } else {
112        Ok(None)
113    }
114}
115
116/// Persist a user as authenticated for the current session.
117pub async fn login<U: Authenticatable>(session: &Session, user: &U) -> Result<(), Error> {
118    let id = user.id();
119    session
120        .insert(SESSION_USER_ID_KEY, id)
121        .await
122        .map_err(|e| Error::Internal(format!("session write failed: {e}")))?;
123    Ok(())
124}
125
126/// Clear the authenticated user from the session.
127pub async fn logout(session: &Session) -> Result<(), Error> {
128    session
129        .remove::<serde_json::Value>(SESSION_USER_ID_KEY)
130        .await
131        .map_err(|e| Error::Internal(format!("session clear failed: {e}")))?;
132    Ok(())
133}
134
135/// The `Auth<U>` extractor. On every request, looks up the session, reads the
136/// stored user ID, and loads the user via `U::find_by_id`. Returns 401 if
137/// there's no session, no user_id, or no matching user.
138///
139/// ```ignore
140/// async fn dashboard(Auth(user): Auth<User>) -> Result<ViewResponse> { ... }
141/// ```
142pub struct Auth<U: Authenticatable>(pub U);
143
144#[async_trait]
145impl<U, S> FromRequestParts<S> for Auth<U>
146where
147    U: Authenticatable,
148    Container: FromRef<S>,
149    S: Send + Sync,
150{
151    type Rejection = Error;
152
153    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
154        let session = Session::from_request_parts(parts, state)
155            .await
156            .map_err(|_| Error::Unauthenticated)?;
157        let id: Option<U::Id> = session
158            .get(SESSION_USER_ID_KEY)
159            .await
160            .map_err(|e| Error::Internal(e.to_string()))?;
161        let id = id.ok_or(Error::Unauthenticated)?;
162        let container = Container::from_ref(state);
163        let user = U::find_by_id(&container, &id)
164            .await?
165            .ok_or(Error::Unauthenticated)?;
166        Ok(Auth(user))
167    }
168}
169
170/// Optional version of `Auth<U>` — returns `None` instead of 401 when the
171/// user isn't authenticated. Useful for routes that customize their response
172/// based on auth state (e.g., a home page that shows "Login" vs the user's name).
173pub struct OptionalAuth<U: Authenticatable>(pub Option<U>);
174
175#[async_trait]
176impl<U, S> FromRequestParts<S> for OptionalAuth<U>
177where
178    U: Authenticatable,
179    Container: FromRef<S>,
180    S: Send + Sync,
181{
182    type Rejection = Error;
183
184    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
185        let Ok(session) = Session::from_request_parts(parts, state).await else {
186            return Ok(OptionalAuth(None));
187        };
188        let Some(id): Option<U::Id> = session
189            .get(SESSION_USER_ID_KEY)
190            .await
191            .map_err(|e| Error::Internal(e.to_string()))?
192        else {
193            return Ok(OptionalAuth(None));
194        };
195        let container = Container::from_ref(state);
196        let user = U::find_by_id(&container, &id).await?;
197        Ok(OptionalAuth(user))
198    }
199}
200
201/// Policy trait: implementations decide whether `user` can perform `ability` on `subject`.
202pub trait Policy<U, S> {
203    fn check(user: &U, ability: &str, subject: &S) -> bool;
204}
205
206/// Convenience: ability-check shorthand. Returns `Error::Forbidden` on failure.
207pub fn authorize<P, U, S>(user: &U, ability: &str, subject: &S) -> Result<(), Error>
208where
209    P: Policy<U, S>,
210{
211    if P::check(user, ability, subject) {
212        Ok(())
213    } else {
214        Err(Error::forbidden(ability))
215    }
216}
217
218/// Phantom guard markers. The current `Auth<U>` extractor is session-only;
219/// these are reserved so v0.2 can add bearer-token guards via type parameter.
220pub struct WebGuard;
221pub struct ApiGuard;
222
223pub trait Guard: Send + Sync + 'static {}
224impl Guard for WebGuard {}
225impl Guard for ApiGuard {}
226
227pub struct Guarded<U, G>(PhantomData<(U, G)>);