|
1 | 1 | use std::collections::HashSet; |
2 | 2 |
|
3 | 3 | use anyhow::{Result, bail}; |
4 | | -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; |
5 | 4 | use chrono::Utc; |
6 | | -use rand::RngExt; |
7 | 5 | use rand::distr::{Alphanumeric, SampleString}; |
8 | | -use rusqlite::{OptionalExtension, Transaction}; |
| 6 | +use rusqlite::{Connection, OptionalExtension, Transaction}; |
9 | 7 |
|
10 | 8 | use crate::app::{ |
11 | 9 | SqlitePool, |
@@ -42,7 +40,7 @@ impl LiwanApiKeys { |
42 | 40 | ) -> Result<(ApiKey, String)> { |
43 | 41 | let display_name = display_name.trim(); |
44 | 42 | validate_display_name(display_name)?; |
45 | | - let secret = URL_SAFE_NO_PAD.encode(rand::rng().random::<[u8; 24]>()); |
| 43 | + let secret = Alphanumeric.sample_string(&mut rand::rng(), 32); |
46 | 44 | let plaintext = format!("{KEY_PREFIX}{secret}"); |
47 | 45 | let id = Alphanumeric.sample_string(&mut rand::rng(), 16); |
48 | 46 | let secret_hash = blake3::hash(secret.as_bytes()).to_hex().to_string(); |
@@ -76,61 +74,7 @@ impl LiwanApiKeys { |
76 | 74 | /// Lists all key metadata without exposing hashes. |
77 | 75 | pub fn all(&self) -> Result<Vec<ApiKey>> { |
78 | 76 | let conn = self.pool.get()?; |
79 | | - let mut stmt = conn.prepare_cached( |
80 | | - "select id, display_name, created_at, expires_at, last_used_at, |
81 | | - (select json_group_array(entity_id) from api_key_entities where key_id = api_keys.id), |
82 | | - permissions_json, all_entities, |
83 | | - (select json_group_array(project_id) from api_key_projects where key_id = api_keys.id), all_projects |
84 | | - from api_keys order by created_at desc", |
85 | | - )?; |
86 | | - let rows = stmt.query_map([], |row| { |
87 | | - Ok(( |
88 | | - row.get::<_, String>(0)?, |
89 | | - row.get::<_, String>(1)?, |
90 | | - row.get(2)?, |
91 | | - row.get(3)?, |
92 | | - row.get(4)?, |
93 | | - row.get::<_, String>(5)?, |
94 | | - row.get::<_, String>(6)?, |
95 | | - row.get::<_, bool>(7)?, |
96 | | - row.get::<_, String>(8)?, |
97 | | - row.get::<_, bool>(9)?, |
98 | | - )) |
99 | | - })?; |
100 | | - let mut keys = Vec::new(); |
101 | | - for row in rows { |
102 | | - let ( |
103 | | - id, |
104 | | - display_name, |
105 | | - created_at, |
106 | | - expires_at, |
107 | | - last_used_at, |
108 | | - entities, |
109 | | - permissions, |
110 | | - all_entities, |
111 | | - projects, |
112 | | - all_projects, |
113 | | - ) = row?; |
114 | | - keys.push(ApiKey { |
115 | | - id, |
116 | | - display_name, |
117 | | - entities: if all_entities { |
118 | | - AccessScope::All |
119 | | - } else { |
120 | | - AccessScope::Selected(serde_json::from_str(&entities)?) |
121 | | - }, |
122 | | - projects: if all_projects { |
123 | | - AccessScope::All |
124 | | - } else { |
125 | | - AccessScope::Selected(serde_json::from_str(&projects)?) |
126 | | - }, |
127 | | - permissions: serde_json::from_str(&permissions)?, |
128 | | - created_at, |
129 | | - last_used_at, |
130 | | - expires_at, |
131 | | - }); |
132 | | - } |
133 | | - Ok(keys) |
| 77 | + query_keys(&conn, "", []) |
134 | 78 | } |
135 | 79 |
|
136 | 80 | /// Updates a key's display name, access, and permissions. |
@@ -168,54 +112,16 @@ impl LiwanApiKeys { |
168 | 112 |
|
169 | 113 | /// Replaces a key's secret and sets a new expiration, without changing its access. |
170 | 114 | pub fn regenerate(&self, key_id: &str, expiration: ApiKeyExpiration) -> Result<Option<(ApiKey, String)>> { |
171 | | - let secret = URL_SAFE_NO_PAD.encode(rand::rng().random::<[u8; 24]>()); |
| 115 | + let secret = Alphanumeric.sample_string(&mut rand::rng(), 32); |
172 | 116 | let hash = blake3::hash(secret.as_bytes()).to_hex().to_string(); |
173 | 117 | let expires_at = expiration.expires_at(); |
174 | 118 | let mut conn = self.pool.get()?; |
175 | 119 | let tx = conn.transaction()?; |
176 | | - let changed = tx.execute( |
| 120 | + tx.execute( |
177 | 121 | "update api_keys set secret_hash = ?, expires_at = ?, last_used_at = null where id = ?", |
178 | 122 | rusqlite::params![hash, expires_at, key_id], |
179 | 123 | )?; |
180 | | - if changed == 0 { |
181 | | - return Ok(None); |
182 | | - } |
183 | | - let (display_name, created_at, entities, all_entities, projects, all_projects, permissions) = tx.query_row( |
184 | | - "select display_name, created_at, |
185 | | - (select json_group_array(entity_id) from api_key_entities where key_id = api_keys.id), all_entities, |
186 | | - (select json_group_array(project_id) from api_key_projects where key_id = api_keys.id), all_projects, |
187 | | - permissions_json from api_keys where id = ?", |
188 | | - [key_id], |
189 | | - |row| { |
190 | | - Ok(( |
191 | | - row.get::<_, String>(0)?, |
192 | | - row.get(1)?, |
193 | | - row.get::<_, String>(2)?, |
194 | | - row.get::<_, bool>(3)?, |
195 | | - row.get::<_, String>(4)?, |
196 | | - row.get::<_, bool>(5)?, |
197 | | - row.get::<_, String>(6)?, |
198 | | - )) |
199 | | - }, |
200 | | - )?; |
201 | | - let key = ApiKey { |
202 | | - id: key_id.to_string(), |
203 | | - display_name, |
204 | | - entities: if all_entities { |
205 | | - AccessScope::All |
206 | | - } else { |
207 | | - AccessScope::Selected(serde_json::from_str(&entities)?) |
208 | | - }, |
209 | | - projects: if all_projects { |
210 | | - AccessScope::All |
211 | | - } else { |
212 | | - AccessScope::Selected(serde_json::from_str(&projects)?) |
213 | | - }, |
214 | | - permissions: serde_json::from_str(&permissions)?, |
215 | | - created_at, |
216 | | - last_used_at: None, |
217 | | - expires_at, |
218 | | - }; |
| 124 | + let Some(key) = query_keys(&tx, "where id = ?", [key_id])?.pop() else { return Ok(None) }; |
219 | 125 | tx.commit()?; |
220 | 126 | Ok(Some((key, format!("{KEY_PREFIX}{secret}")))) |
221 | 127 | } |
@@ -288,17 +194,37 @@ impl LiwanApiKeys { |
288 | 194 | } else { |
289 | 195 | AccessScope::Selected(permitted_entities.into_iter().collect()) |
290 | 196 | }, |
291 | | - projects: if all_projects { |
292 | | - AccessScope::All |
293 | | - } else { |
294 | | - AccessScope::Selected(serde_json::from_str(&projects)?) |
295 | | - }, |
| 197 | + projects: AccessScope::from_db(all_projects, &projects)?, |
296 | 198 | permissions: serde_json::from_str(&permissions)?, |
297 | 199 | }, |
298 | 200 | })) |
299 | 201 | } |
300 | 202 | } |
301 | 203 |
|
| 204 | +fn query_keys(conn: &Connection, filter: &str, params: impl rusqlite::Params) -> Result<Vec<ApiKey>> { |
| 205 | + let mut stmt = conn.prepare_cached(&format!( |
| 206 | + "select id, display_name, created_at, expires_at, last_used_at, permissions_json, all_entities, |
| 207 | + (select json_group_array(entity_id) from api_key_entities where key_id = api_keys.id), all_projects, |
| 208 | + (select json_group_array(project_id) from api_key_projects where key_id = api_keys.id) |
| 209 | + from api_keys {filter} order by created_at desc" |
| 210 | + ))?; |
| 211 | + let mut rows = stmt.query(params)?; |
| 212 | + let mut keys = Vec::new(); |
| 213 | + while let Some(row) = rows.next()? { |
| 214 | + keys.push(ApiKey { |
| 215 | + id: row.get(0)?, |
| 216 | + display_name: row.get(1)?, |
| 217 | + created_at: row.get(2)?, |
| 218 | + expires_at: row.get(3)?, |
| 219 | + last_used_at: row.get(4)?, |
| 220 | + permissions: serde_json::from_str(&row.get::<_, String>(5)?)?, |
| 221 | + entities: AccessScope::from_db(row.get(6)?, &row.get::<_, String>(7)?)?, |
| 222 | + projects: AccessScope::from_db(row.get(8)?, &row.get::<_, String>(9)?)?, |
| 223 | + }); |
| 224 | + } |
| 225 | + Ok(keys) |
| 226 | +} |
| 227 | + |
302 | 228 | fn validate_display_name(display_name: &str) -> Result<()> { |
303 | 229 | if display_name.is_empty() || display_name.len() > 100 { |
304 | 230 | bail!("API key name must be between 1 and 100 characters"); |
|
0 commit comments