Skip to main content

pebble_cms/services/
auth.rs

1use crate::models::{User, UserRole};
2use crate::Database;
3use anyhow::Result;
4use argon2::{
5    password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
6    Argon2,
7};
8use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
9use rand::{rngs::OsRng, RngCore};
10
11pub const MIN_PASSWORD_LENGTH: usize = 8;
12const MAX_USERNAME_LENGTH: usize = 100;
13const MAX_EMAIL_LENGTH: usize = 254;
14
15pub fn validate_username(username: &str) -> Result<()> {
16    if username.is_empty() {
17        anyhow::bail!("Username cannot be empty");
18    }
19    if username.len() > MAX_USERNAME_LENGTH {
20        anyhow::bail!(
21            "Username must be {} characters or less",
22            MAX_USERNAME_LENGTH
23        );
24    }
25    if !username
26        .chars()
27        .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
28    {
29        anyhow::bail!("Username can only contain letters, numbers, underscores, and hyphens");
30    }
31    Ok(())
32}
33
34pub fn validate_email(email: &str) -> Result<()> {
35    if email.is_empty() {
36        anyhow::bail!("Email cannot be empty");
37    }
38    if email.len() > MAX_EMAIL_LENGTH {
39        anyhow::bail!("Email must be {} characters or less", MAX_EMAIL_LENGTH);
40    }
41    if !email.contains('@') || !email.contains('.') {
42        anyhow::bail!("Invalid email format");
43    }
44    Ok(())
45}
46
47pub fn validate_password(password: &str) -> Result<()> {
48    if password.len() < MIN_PASSWORD_LENGTH {
49        anyhow::bail!(
50            "Password must be at least {} characters",
51            MIN_PASSWORD_LENGTH
52        );
53    }
54    if !password.chars().any(|c| c.is_ascii_lowercase()) {
55        anyhow::bail!("Password must contain at least one lowercase letter");
56    }
57    if !password.chars().any(|c| c.is_ascii_uppercase()) {
58        anyhow::bail!("Password must contain at least one uppercase letter");
59    }
60    if !password.chars().any(|c| c.is_ascii_digit()) {
61        anyhow::bail!("Password must contain at least one number");
62    }
63    Ok(())
64}
65
66pub fn hash_password(password: &str) -> Result<String> {
67    validate_password(password)?;
68    let salt = SaltString::generate(&mut OsRng);
69    let argon2 = Argon2::default();
70    let hash = argon2
71        .hash_password(password.as_bytes(), &salt)
72        .map_err(|e| anyhow::anyhow!("Password hashing failed: {}", e))?;
73    Ok(hash.to_string())
74}
75
76const DUMMY_HASH: &str =
77    "$argon2id$v=19$m=19456,t=2,p=1$dW5rbm93bg$0000000000000000000000000000000000000000000";
78
79pub fn verify_password(password: &str, hash: &str) -> bool {
80    let parsed_hash = match PasswordHash::new(hash) {
81        Ok(h) => h,
82        Err(_) => {
83            if let Ok(dummy) = PasswordHash::new(DUMMY_HASH) {
84                let _ = Argon2::default().verify_password(password.as_bytes(), &dummy);
85            }
86            return false;
87        }
88    };
89    Argon2::default()
90        .verify_password(password.as_bytes(), &parsed_hash)
91        .is_ok()
92}
93
94pub fn generate_session_token() -> String {
95    let mut bytes = [0u8; 32];
96    OsRng.fill_bytes(&mut bytes);
97    URL_SAFE_NO_PAD.encode(bytes)
98}
99
100pub fn create_user(
101    db: &Database,
102    username: &str,
103    email: &str,
104    password: &str,
105    role: UserRole,
106) -> Result<i64> {
107    validate_username(username)?;
108    validate_email(email)?;
109    let password_hash = hash_password(password)?;
110    let conn = db.get()?;
111    conn.execute(
112        "INSERT INTO users (username, email, password_hash, role) VALUES (?, ?, ?, ?)",
113        (username, email, &password_hash, role.to_string()),
114    )?;
115    Ok(conn.last_insert_rowid())
116}
117
118pub fn update_password(db: &Database, username: &str, password: &str) -> Result<()> {
119    let password_hash = hash_password(password)?;
120    let conn = db.get()?;
121    conn.execute(
122        "UPDATE users SET password_hash = ? WHERE username = ?",
123        (&password_hash, username),
124    )?;
125    Ok(())
126}
127
128pub fn authenticate(db: &Database, username: &str, password: &str) -> Result<Option<User>> {
129    let conn = db.get()?;
130    let user: Option<User> = conn
131        .query_row(
132            "SELECT id, username, email, password_hash, role, created_at, updated_at FROM users WHERE username = ?",
133            [username],
134            |row| {
135                Ok(User {
136                    id: row.get(0)?,
137                    username: row.get(1)?,
138                    email: row.get(2)?,
139                    password_hash: row.get(3)?,
140                    role: row.get::<_, String>(4)?.parse().unwrap_or(UserRole::Viewer),
141                    created_at: row.get(5)?,
142                    updated_at: row.get(6)?,
143                })
144            },
145        )
146        .ok();
147
148    match user {
149        Some(u) if verify_password(password, &u.password_hash) => Ok(Some(u)),
150        _ => Ok(None),
151    }
152}
153
154pub fn create_session(db: &Database, user_id: i64, duration_days: i64) -> Result<String> {
155    let token = generate_session_token();
156    let conn = db.get()?;
157    conn.execute(
158        "INSERT INTO sessions (user_id, token, expires_at) VALUES (?, ?, datetime('now', ?||' days'))",
159        (user_id, &token, duration_days),
160    )?;
161    Ok(token)
162}
163
164pub fn validate_session(db: &Database, token: &str) -> Result<Option<User>> {
165    let conn = db.get()?;
166    let user = conn
167        .query_row(
168            r#"
169            SELECT u.id, u.username, u.email, u.password_hash, u.role, u.created_at, u.updated_at
170            FROM users u
171            JOIN sessions s ON s.user_id = u.id
172            WHERE s.token = ? AND s.expires_at > datetime('now')
173            "#,
174            [token],
175            |row| {
176                Ok(User {
177                    id: row.get(0)?,
178                    username: row.get(1)?,
179                    email: row.get(2)?,
180                    password_hash: row.get(3)?,
181                    role: row.get::<_, String>(4)?.parse().unwrap_or(UserRole::Viewer),
182                    created_at: row.get(5)?,
183                    updated_at: row.get(6)?,
184                })
185            },
186        )
187        .ok();
188    Ok(user)
189}
190
191pub fn delete_session(db: &Database, token: &str) -> Result<()> {
192    let conn = db.get()?;
193    conn.execute("DELETE FROM sessions WHERE token = ?", [token])?;
194    Ok(())
195}
196
197pub fn cleanup_expired_sessions(db: &Database) -> Result<()> {
198    let conn = db.get()?;
199    conn.execute(
200        "DELETE FROM sessions WHERE expires_at <= datetime('now')",
201        [],
202    )?;
203    Ok(())
204}
205
206pub fn has_users(db: &Database) -> Result<bool> {
207    let conn = db.get()?;
208    let count: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?;
209    Ok(count > 0)
210}
211
212pub fn list_users(db: &Database) -> Result<Vec<User>> {
213    let conn = db.get()?;
214    let mut stmt = conn.prepare(
215        "SELECT id, username, email, password_hash, role, created_at, updated_at FROM users ORDER BY created_at DESC",
216    )?;
217    let users = stmt
218        .query_map([], |row| {
219            Ok(User {
220                id: row.get(0)?,
221                username: row.get(1)?,
222                email: row.get(2)?,
223                password_hash: row.get(3)?,
224                role: row.get::<_, String>(4)?.parse().unwrap_or(UserRole::Viewer),
225                created_at: row.get(5)?,
226                updated_at: row.get(6)?,
227            })
228        })?
229        .collect::<Result<Vec<_>, _>>()?;
230    Ok(users)
231}
232
233pub fn get_user(db: &Database, id: i64) -> Result<Option<User>> {
234    let conn = db.get()?;
235    let user = conn
236        .query_row(
237            "SELECT id, username, email, password_hash, role, created_at, updated_at FROM users WHERE id = ?",
238            [id],
239            |row| {
240                Ok(User {
241                    id: row.get(0)?,
242                    username: row.get(1)?,
243                    email: row.get(2)?,
244                    password_hash: row.get(3)?,
245                    role: row.get::<_, String>(4)?.parse().unwrap_or(UserRole::Viewer),
246                    created_at: row.get(5)?,
247                    updated_at: row.get(6)?,
248                })
249            },
250        )
251        .ok();
252    Ok(user)
253}
254
255pub fn update_user(
256    db: &Database,
257    id: i64,
258    email: Option<&str>,
259    role: Option<UserRole>,
260) -> Result<()> {
261    let conn = db.get()?;
262    if let Some(email) = email {
263        validate_email(email)?;
264        conn.execute("UPDATE users SET email = ? WHERE id = ?", (email, id))?;
265    }
266    if let Some(role) = role {
267        conn.execute(
268            "UPDATE users SET role = ? WHERE id = ?",
269            (role.to_string(), id),
270        )?;
271    }
272    Ok(())
273}
274
275pub fn delete_user(db: &Database, id: i64) -> Result<()> {
276    let conn = db.get()?;
277    conn.execute("DELETE FROM users WHERE id = ?", [id])?;
278    Ok(())
279}