Skip to main content

rainier_auth/
user.rs

1//! Who can log in — [`Authenticatable`], [`Credentials`] and [`UserProvider`].
2
3use std::collections::HashMap;
4use std::sync::Arc;
5
6use rainier_database::{Model, Repository};
7use rainier_orm::sea_query::Value;
8use rainier_support::Result;
9
10use crate::abilities::Abilities;
11use crate::hashing::Hasher;
12
13/// A model that can be authenticated.
14///
15/// Implemented on the application's user model. The framework only ever needs
16/// these three facts about it, which is what keeps the guards independent of
17/// whatever else a user row holds.
18pub trait Authenticatable: Send + Sync + 'static {
19    /// The value that identifies this user in a session or a token subject.
20    fn auth_identifier(&self) -> String;
21
22    /// The stored password hash, or `None` for a user who cannot log in with a
23    /// password (an SSO-only or machine account).
24    fn auth_password_hash(&self) -> Option<&str>;
25
26    /// The column credentials are looked up by — usually `email`.
27    fn auth_username_column() -> &'static str
28    where
29        Self: Sized,
30    {
31        "email"
32    }
33
34    /// The column holding an API token, when the application issues them.
35    fn auth_token_column() -> &'static str
36    where
37        Self: Sized,
38    {
39        "api_token"
40    }
41}
42
43/// The values a user submitted to log in.
44///
45/// A map rather than a struct because the identifying field varies (`email`,
46/// `username`, `phone`) and an application may authenticate on more than one
47/// (`email` plus `tenant_id`).
48#[derive(Debug, Clone, Default, PartialEq, Eq)]
49pub struct Credentials {
50    values: HashMap<String, String>,
51}
52
53impl Credentials {
54    /// Empty credentials.
55    pub fn new() -> Self {
56        Self::default()
57    }
58
59    /// The usual pair.
60    pub fn password(username: impl Into<String>, password: impl Into<String>) -> Self {
61        Self::new().with("email", username).with("password", password)
62    }
63
64    /// Add a field.
65    pub fn with(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
66        self.values.insert(key.into(), value.into());
67        self
68    }
69
70    /// Read a field.
71    pub fn get(&self, key: &str) -> Option<&str> {
72        self.values.get(key).map(String::as_str)
73    }
74
75    /// The submitted password.
76    pub fn password_value(&self) -> Option<&str> {
77        self.get("password")
78    }
79
80    /// Every field except `password` — what a provider looks a user up by.
81    ///
82    /// The password is excluded because it is *verified*, never queried:
83    /// a `WHERE password = ?` against a hash column could never match, and
84    /// against a plaintext column would be a much worse bug.
85    pub fn lookup_fields(&self) -> Vec<(&str, &str)> {
86        self.values
87            .iter()
88            .filter(|(key, _)| key.as_str() != "password")
89            .map(|(key, value)| (key.as_str(), value.as_str()))
90            .collect()
91    }
92
93    /// Whether there is anything to look a user up by.
94    pub fn is_empty(&self) -> bool {
95        self.lookup_fields().is_empty()
96    }
97}
98
99/// Finds and verifies users.
100///
101/// Generic over the user type rather than yielding `dyn Authenticatable`: an
102/// application has one user model and wants it back with its own type, so
103/// erasing it here would only force a downcast at every call site.
104#[async_trait::async_trait]
105pub trait UserProvider<U: Authenticatable>: Send + Sync + 'static {
106    /// The user with this identifier.
107    async fn retrieve_by_id(&self, id: &str) -> Result<Option<U>>;
108
109    /// The user matching these credentials, **without** checking the password.
110    async fn retrieve_by_credentials(&self, credentials: &Credentials) -> Result<Option<U>>;
111
112    /// Whether the credentials are valid for `user`.
113    async fn validate_credentials(&self, user: &U, credentials: &Credentials) -> Result<bool>;
114
115    /// The user holding this API token.
116    async fn retrieve_by_token(&self, token: &str) -> Result<Option<U>> {
117        let _ = token;
118        Ok(None)
119    }
120
121    /// What the token itself is allowed to do.
122    ///
123    /// Defaults to [`Abilities::everything`], which is what a provider with no
124    /// `abilities` column means: the token may do whatever its owner may. So
125    /// nothing changes for an application until it starts issuing narrower
126    /// tokens.
127    ///
128    /// Only called for a token that already resolved to a user, so this does
129    /// not need to re-authenticate — it is answering "what was this token
130    /// issued for", not "is it valid".
131    async fn retrieve_abilities_by_token(&self, token: &str) -> Result<Abilities> {
132        let _ = token;
133        Ok(Abilities::everything())
134    }
135}
136
137/// A [`UserProvider`] backed by a [`Repository`] — the usual one.
138pub struct RepositoryUserProvider<U: Model + Authenticatable> {
139    users: Arc<dyn Repository<U>>,
140    hasher: Arc<dyn Hasher>,
141}
142
143impl<U: Model + Authenticatable> RepositoryUserProvider<U> {
144    /// Look users up through `users`, verifying passwords with `hasher`.
145    pub fn new(users: Arc<dyn Repository<U>>, hasher: Arc<dyn Hasher>) -> Self {
146        Self { users, hasher }
147    }
148
149    /// The repository users are read from.
150    pub fn repository(&self) -> &Arc<dyn Repository<U>> {
151        &self.users
152    }
153}
154
155#[async_trait::async_trait]
156impl<U> UserProvider<U> for RepositoryUserProvider<U>
157where
158    U: Model + Authenticatable,
159{
160    async fn retrieve_by_id(&self, id: &str) -> Result<Option<U>> {
161        // The identifier is a string because that is what a session cookie or
162        // a token subject carries. Numeric keys are parsed back so the query
163        // binds an integer rather than a string, which would never match on a
164        // strictly-typed backend.
165        let key: Value = match id.parse::<i64>() {
166            Ok(numeric) => numeric.into(),
167            Err(_) => id.into(),
168        };
169        self.users.find(key).await
170    }
171
172    async fn retrieve_by_credentials(&self, credentials: &Credentials) -> Result<Option<U>> {
173        let fields = credentials.lookup_fields();
174        if fields.is_empty() {
175            // Without this, an empty credential set would select the first
176            // user in the table and let anyone in as them.
177            return Ok(None);
178        }
179
180        let mut criteria = rainier_database::Criteria::new();
181        for (column, value) in fields {
182            criteria = criteria.where_eq(column, value);
183        }
184        self.users.first_matching(criteria).await
185    }
186
187    async fn validate_credentials(&self, user: &U, credentials: &Credentials) -> Result<bool> {
188        let (Some(submitted), Some(stored)) =
189            (credentials.password_value(), user.auth_password_hash())
190        else {
191            // No password submitted, or an account that has none: refuse
192            // rather than treat "nothing to compare" as a match.
193            return Ok(false);
194        };
195        Ok(self.hasher.verify(submitted, stored))
196    }
197
198    async fn retrieve_by_token(&self, token: &str) -> Result<Option<U>> {
199        if token.is_empty() {
200            return Ok(None);
201        }
202        self.users.first_by(U::auth_token_column(), token.into()).await
203    }
204}
205
206impl<U: Model + Authenticatable> std::fmt::Debug for RepositoryUserProvider<U> {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        f.debug_struct("RepositoryUserProvider").field("model", &U::model_name()).finish()
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::hashing::Argon2Hasher;
216    use rainier_database::testing::{fake_database, MemoryConnection};
217    use rainier_database::{EntityRepository, OwnedRow};
218    use rainier_orm::Dialect;
219
220    #[derive(rainier_orm::Entity, Clone, Debug, PartialEq)]
221    #[orm(table = "users")]
222    struct User {
223        #[orm(pk, auto_increment)]
224        id: u64,
225        #[orm(unique)]
226        email: String,
227        password: String,
228        api_token: Option<String>,
229    }
230
231    impl Model for User {}
232
233    impl Authenticatable for User {
234        fn auth_identifier(&self) -> String {
235            self.id.to_string()
236        }
237        fn auth_password_hash(&self) -> Option<&str> {
238            Some(&self.password)
239        }
240    }
241
242    fn hasher() -> Arc<dyn Hasher> {
243        Arc::new(Argon2Hasher::insecure_for_tests())
244    }
245
246    fn user_row(hash: &str) -> OwnedRow {
247        OwnedRow::new()
248            .with("id", 1_u64)
249            .with("email", "ada@example.com")
250            .with("password", hash)
251            .with("api_token", "tok_123")
252    }
253
254    fn provider(
255        connection: MemoryConnection,
256    ) -> (RepositoryUserProvider<User>, Arc<MemoryConnection>) {
257        let (db, handle) = fake_database(connection);
258        let users: Arc<dyn Repository<User>> = Arc::new(EntityRepository::<User>::new(db));
259        (RepositoryUserProvider::new(users, hasher()), handle)
260    }
261
262    #[test]
263    fn credentials_exclude_the_password_from_lookups() {
264        let credentials = Credentials::password("ada@example.com", "secret");
265
266        assert_eq!(credentials.password_value(), Some("secret"));
267        assert_eq!(credentials.lookup_fields(), vec![("email", "ada@example.com")]);
268        assert!(!credentials.is_empty());
269    }
270
271    #[test]
272    fn credentials_can_carry_extra_lookup_fields() {
273        let credentials = Credentials::password("ada@example.com", "secret").with("tenant_id", "7");
274
275        let mut fields = credentials.lookup_fields();
276        fields.sort();
277        assert_eq!(fields, vec![("email", "ada@example.com"), ("tenant_id", "7")]);
278    }
279
280    #[test]
281    fn credentials_with_only_a_password_have_nothing_to_look_up_by() {
282        let credentials = Credentials::new().with("password", "secret");
283        assert!(credentials.is_empty());
284    }
285
286    #[tokio::test]
287    async fn retrieves_a_user_by_credentials() {
288        let (provider, connection) =
289            provider(MemoryConnection::new(Dialect::Sqlite).returning([user_row("$argon2id$x")]));
290
291        let found = provider
292            .retrieve_by_credentials(&Credentials::password("ada@example.com", "secret"))
293            .await
294            .unwrap();
295
296        assert_eq!(found.unwrap().email, "ada@example.com");
297
298        // The password column is *selected* (it has to be, to verify against)
299        // but must never appear as a predicate: a `WHERE password = ?` against
300        // a hash could never match, and against plaintext would be far worse.
301        let sql = connection.last_statement().unwrap();
302        let (_, filters) = sql.split_once("WHERE").expect("the lookup should be filtered");
303        assert!(filters.contains("email"), "{sql}");
304        assert!(!filters.contains("password"), "the password must never be queried: {sql}");
305        assert_eq!(connection.bindings()[0][0], "ada@example.com".into());
306    }
307
308    #[tokio::test]
309    async fn empty_credentials_never_match_a_user() {
310        // Regression guard: with no lookup fields, an unfiltered query would
311        // return the first user in the table and let anyone in as them.
312        let (provider, connection) =
313            provider(MemoryConnection::new(Dialect::Sqlite).returning([user_row("$argon2id$x")]));
314
315        assert!(provider.retrieve_by_credentials(&Credentials::new()).await.unwrap().is_none());
316        assert_eq!(connection.statement_count(), 0, "and it should not even query");
317    }
318
319    #[tokio::test]
320    async fn validates_a_password_against_the_stored_hash() {
321        let hasher = Argon2Hasher::insecure_for_tests();
322        let stored = hasher.hash("secret").unwrap();
323        let (provider, _) = provider(MemoryConnection::new(Dialect::Sqlite));
324
325        let user =
326            User { id: 1, email: "ada@example.com".into(), password: stored, api_token: None };
327
328        assert!(provider
329            .validate_credentials(&user, &Credentials::password("ada@example.com", "secret"))
330            .await
331            .unwrap());
332
333        assert!(!provider
334            .validate_credentials(&user, &Credentials::password("ada@example.com", "wrong"))
335            .await
336            .unwrap());
337    }
338
339    #[tokio::test]
340    async fn a_missing_password_never_validates() {
341        let (provider, _) = provider(MemoryConnection::new(Dialect::Sqlite));
342        let user = User {
343            id: 1,
344            email: "ada@example.com".into(),
345            password: String::new(),
346            api_token: None,
347        };
348
349        // No password submitted at all.
350        let no_password = Credentials::new().with("email", "ada@example.com");
351        assert!(!provider.validate_credentials(&user, &no_password).await.unwrap());
352    }
353
354    #[tokio::test]
355    async fn retrieves_by_id_binding_a_numeric_key_as_a_number() {
356        let (provider, connection) =
357            provider(MemoryConnection::new(Dialect::Sqlite).returning([user_row("h")]));
358
359        assert!(provider.retrieve_by_id("1").await.unwrap().is_some());
360        assert_eq!(connection.bindings()[0][0], 1_i64.into(), "not the string \"1\"");
361    }
362
363    #[tokio::test]
364    async fn retrieves_by_token() {
365        let (provider, connection) =
366            provider(MemoryConnection::new(Dialect::Sqlite).returning([user_row("h")]));
367
368        assert!(provider.retrieve_by_token("tok_123").await.unwrap().is_some());
369        assert!(connection.last_statement().unwrap().contains("api_token"));
370    }
371
372    #[tokio::test]
373    async fn an_empty_token_never_matches() {
374        let (provider, connection) =
375            provider(MemoryConnection::new(Dialect::Sqlite).returning([user_row("h")]));
376
377        assert!(provider.retrieve_by_token("").await.unwrap().is_none());
378        assert_eq!(connection.statement_count(), 0);
379    }
380}