Skip to main content

pray_core/
auth_store.rs

1#[path = "auth_store_support.rs"]
2mod support;
3#[path = "auth_store_keys.rs"]
4mod keys;
5
6use support::*;
7
8use crate::auth::{
9    AuthRegistrationResponse, AuthSessionKind, AuthSessionResponse, AuthVerificationResponse,
10};
11use crate::trust::EmailConfirmationPolicy;
12use crate::{PrayError, PrayResult};
13use rusqlite::{Connection, OptionalExtension};
14use std::fs;
15use std::path::{Path, PathBuf};
16
17#[derive(Debug, Clone)]
18pub struct RegistryAuthStore {
19    database_path: PathBuf,
20}
21
22impl RegistryAuthStore {
23    pub fn open(root: &Path) -> PrayResult<Self> {
24        let database_path = root.join(".pray/auth.db");
25        if let Some(parent) = database_path.parent() {
26            fs::create_dir_all(parent)?;
27        }
28        let store = Self { database_path };
29        store.initialize()?;
30        Ok(store)
31    }
32    pub fn register_email(
33        &self,
34        email: &str,
35        policy: EmailConfirmationPolicy,
36    ) -> PrayResult<AuthRegistrationResponse> {
37        validate_email(email)?;
38        let connection = self.connection()?;
39        let timestamp = current_unix_timestamp()?;
40        let verified = matches!(policy, EmailConfirmationPolicy::Disabled);
41        let verification_code = if verified {
42            None
43        } else {
44            Some(generate_verification_code(email, timestamp))
45        };
46        let policy_text = email_confirmation_policy_text(policy);
47
48        connection.execute(
49        "INSERT INTO users (email, email_verified, email_confirmation_policy, created_at) VALUES (?1, ?2, ?3, ?4)
50         ON CONFLICT(email) DO UPDATE SET email_verified = excluded.email_verified, email_confirmation_policy = excluded.email_confirmation_policy",
51        rusqlite::params![email, verified, policy_text, timestamp],
52    )?;
53        if let Some(code) = verification_code.as_ref() {
54            connection.execute(
55            "INSERT INTO email_verification_codes (email, code, created_at, verified_at)
56             VALUES (?1, ?2, ?3, NULL)
57             ON CONFLICT(email) DO UPDATE SET code = excluded.code, created_at = excluded.created_at, verified_at = NULL",
58            rusqlite::params![email, code, timestamp],
59        )?;
60        }
61
62        Ok(AuthRegistrationResponse {
63            email: email.to_string(),
64            verified,
65            verification_code,
66        })
67    }
68    pub fn verify_email(
69        &self,
70        email: &str,
71        code: &str,
72    ) -> PrayResult<AuthVerificationResponse> {
73        validate_email(email)?;
74        if code.trim().is_empty() {
75            return Err(PrayError::Unsupported(
76                "verification code cannot be empty".to_string(),
77            ));
78        }
79        let connection = self.connection()?;
80        let stored_code: Option<String> = connection
81            .query_row(
82                "SELECT code FROM email_verification_codes WHERE email = ?1",
83                rusqlite::params![email],
84                |row| row.get(0),
85            )
86            .optional()?;
87        let Some(stored_code) = stored_code else {
88            return Err(PrayError::Resolution(format!(
89                "no verification code found for {email}"
90            )));
91        };
92        if stored_code != code {
93            return Err(PrayError::Resolution(format!(
94                "verification code mismatch for {email}"
95            )));
96        }
97        let timestamp = current_unix_timestamp()?;
98        connection.execute(
99            "UPDATE users SET email_verified = 1 WHERE email = ?1",
100            rusqlite::params![email],
101        )?;
102        connection.execute(
103            "UPDATE email_verification_codes SET verified_at = ?2 WHERE email = ?1",
104            rusqlite::params![email, timestamp],
105        )?;
106        Ok(AuthVerificationResponse {
107            email: email.to_string(),
108            verified: true,
109        })
110    }
111    pub fn user_verified(&self, email: &str) -> PrayResult<bool> {
112        validate_email(email)?;
113        let connection = self.connection()?;
114        let verified: Option<bool> = connection
115            .query_row(
116                "SELECT email_verified FROM users WHERE email = ?1",
117                rusqlite::params![email],
118                |row| row.get(0),
119            )
120            .optional()?;
121        Ok(verified.unwrap_or(false))
122    }
123    pub fn issue_session(
124        &self,
125        email: &str,
126        kind: AuthSessionKind,
127    ) -> PrayResult<AuthSessionResponse> {
128        validate_email(email)?;
129        let connection = self.connection()?;
130        let user: Option<(bool, String)> = connection
131            .query_row(
132                "SELECT email_verified, email_confirmation_policy FROM users WHERE email = ?1",
133                rusqlite::params![email],
134                |row| Ok((row.get(0)?, row.get(1)?)),
135            )
136            .optional()?;
137        let Some((verified, policy)) = user else {
138            return Err(PrayError::Resolution(format!("unknown user: {email}")));
139        };
140        if !verified
141            && policy != email_confirmation_policy_text(EmailConfirmationPolicy::Optional)
142        {
143            return Err(PrayError::Resolution(format!(
144                "email confirmation required for {email}"
145            )));
146        }
147        let timestamp = current_unix_timestamp()?;
148        let token = generate_session_token(email, &kind, timestamp);
149        connection.execute(
150            "INSERT INTO sessions (token, email, kind, created_at, last_used_at)
151         VALUES (?1, ?2, ?3, ?4, ?4)
152         ON CONFLICT(token) DO UPDATE SET last_used_at = excluded.last_used_at",
153            rusqlite::params![token, email, auth_session_kind_text(&kind), timestamp],
154        )?;
155        Ok(AuthSessionResponse {
156            email: email.to_string(),
157            token,
158            kind,
159        })
160    }
161    pub fn resolve_session(&self, token: &str) -> PrayResult<Option<AuthSessionResponse>> {
162        if token.trim().is_empty() {
163            return Ok(None);
164        }
165        let connection = self.connection()?;
166        let session: Option<(String, String)> = connection
167            .query_row(
168                "SELECT email, kind FROM sessions WHERE token = ?1",
169                rusqlite::params![token],
170                |row| Ok((row.get(0)?, row.get(1)?)),
171            )
172            .optional()?;
173        let Some((email, kind_text)) = session else {
174            return Ok(None);
175        };
176        let kind = parse_auth_session_kind(&kind_text)?;
177        let timestamp = current_unix_timestamp()?;
178        connection.execute(
179            "UPDATE sessions SET last_used_at = ?2 WHERE token = ?1",
180            rusqlite::params![token, timestamp],
181        )?;
182        Ok(Some(AuthSessionResponse {
183            email,
184            token: token.to_string(),
185            kind,
186        }))
187    }
188    fn initialize(&self) -> PrayResult<()> {
189        let connection = self.connection()?;
190        connection.execute_batch(
191            "CREATE TABLE IF NOT EXISTS users (
192            email TEXT PRIMARY KEY,
193            email_verified INTEGER NOT NULL,
194            email_confirmation_policy TEXT NOT NULL,
195            created_at INTEGER NOT NULL
196        );
197        CREATE TABLE IF NOT EXISTS email_verification_codes (
198            email TEXT PRIMARY KEY,
199            code TEXT NOT NULL,
200            created_at INTEGER NOT NULL,
201            verified_at INTEGER
202        );
203        CREATE TABLE IF NOT EXISTS passkeys (
204            credential_id TEXT PRIMARY KEY,
205            email TEXT NOT NULL,
206            public_key TEXT NOT NULL,
207            label TEXT,
208            created_at INTEGER NOT NULL,
209            last_used_at INTEGER,
210            FOREIGN KEY(email) REFERENCES users(email) ON DELETE CASCADE
211        );
212        CREATE TABLE IF NOT EXISTS ssh_keys (
213            fingerprint TEXT PRIMARY KEY,
214            email TEXT NOT NULL,
215            public_key TEXT NOT NULL,
216            label TEXT,
217            created_at INTEGER NOT NULL,
218            last_used_at INTEGER,
219            FOREIGN KEY(email) REFERENCES users(email) ON DELETE CASCADE
220        );
221        CREATE TABLE IF NOT EXISTS sessions (
222            token TEXT PRIMARY KEY,
223            email TEXT NOT NULL,
224            kind TEXT NOT NULL,
225            created_at INTEGER NOT NULL,
226            last_used_at INTEGER,
227            FOREIGN KEY(email) REFERENCES users(email) ON DELETE CASCADE
228        );
229        CREATE TABLE IF NOT EXISTS auth_challenges (
230            challenge_id TEXT PRIMARY KEY,
231            email TEXT NOT NULL,
232            kind TEXT NOT NULL,
233            challenge TEXT NOT NULL,
234            created_at INTEGER NOT NULL,
235            used_at INTEGER,
236            FOREIGN KEY(email) REFERENCES users(email) ON DELETE CASCADE
237        );",
238        )?;
239        Ok(())
240    }
241    fn connection(&self) -> PrayResult<Connection> {
242        let connection = Connection::open(&self.database_path)?;
243        connection.pragma_update(None, "foreign_keys", "ON")?;
244        Ok(connection)
245    }
246}
247
248pub use support::ssh_public_key_fingerprint_text;