Skip to main content

pray_core/
auth_store.rs

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