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;