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;