g1t/services/identity/src/lib.rs

369 lines12,620 bytesCodeBlame
1//! The identity service: accounts, sessions, SSH keys and access tokens.
2//!
3//! Reached only through service bindings; see `g1t_contracts::identity` for
4//! the methods and their arguments.
5
6mod crypto;
7
8use g1t_contracts::identity::*;
9use g1t_contracts::{FailureCode, Outcome, User, Viewer, is_valid_namespace, new_id};
10use g1t_kit::{args, now_ms, reply, rpc_method};
11use serde::Deserialize;
12use worker::wasm_bindgen::JsValue;
13use worker::{Context, D1Database, Env, Request, Response, Result, event};
14
15const SESSION_TTL_SECONDS: u32 = 30 * 24 * 60 * 60;
16const TOKEN_PREFIX: &str = "g1t_";
17const MIN_PASSWORD_LENGTH: usize = 10;
18
19#[derive(Deserialize)]
20struct UserRow {
21 id: String,
22 username: String,
23 password_hash: String,
24}
25
26#[derive(Deserialize)]
27struct KeyRow {
28 id: String,
29 title: String,
30 fingerprint: String,
31 created_at: u64,
32}
33
34impl From<KeyRow> for SshKey {
35 fn from(row: KeyRow) -> Self {
36 SshKey {
37 id: row.id,
38 title: row.title,
39 fingerprint: row.fingerprint,
40 created_at: row.created_at * 1000,
41 }
42 }
43}
44
45#[derive(Deserialize)]
46struct TokenRow {
47 id: String,
48 name: String,
49 created_at: u64,
50}
51
52impl From<TokenRow> for AccessToken {
53 fn from(row: TokenRow) -> Self {
54 AccessToken {
55 id: row.id,
56 name: row.name,
57 created_at: row.created_at * 1000,
58 }
59 }
60}
61
62struct Identity {
63 db: D1Database,
64}
65
66impl Identity {
67 /// Runs a query that returns at most one user.
68 async fn find_user(&self, sql: &str, param: &str) -> Result<Viewer> {
69 self.db
70 .prepare(sql)
71 .bind(&[JsValue::from(param)])?
72 .first::<User>(None)
73 .await
74 }
75
76 async fn user_for_password(&self, username: &str, password: &str) -> Result<Viewer> {
77 let row = self
78 .db
79 .prepare("SELECT id, username, password_hash FROM users WHERE username = ?")
80 .bind(&[JsValue::from(username.to_lowercase())])?
81 .first::<UserRow>(None)
82 .await?;
83 Ok(row
84 .filter(|row| crypto::verify_password(password, &row.password_hash))
85 .map(|row| User {
86 id: row.id,
87 username: row.username,
88 }))
89 }
90
91 async fn register(&self, a: RegisterArgs) -> Result<Outcome<SignedIn>> {
92 let username = a.username.trim().to_lowercase();
93 let email = a.email.trim().to_lowercase();
94 let invalid = |message: &str| Ok(Outcome::fail(FailureCode::Invalid, message));
95 if !is_valid_namespace(&username) {
96 return invalid(
97 "Usernames use lowercase letters, digits and single hyphens, up to 39 characters.",
98 );
99 }
100 let well_formed_email = email
101 .split_once('@')
102 .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.'))
103 && !email.contains(char::is_whitespace);
104 if !well_formed_email {
105 return invalid("Enter a valid email address.");
106 }
107 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
108 return invalid("Use a password of at least 10 characters.");
109 }
110 let taken = self
111 .db
112 .prepare("SELECT username FROM users WHERE username = ? OR email = ?")
113 .bind(&[username.as_str().into(), email.as_str().into()])?
114 .first::<serde_json::Value>(None)
115 .await?;
116 if taken.is_some() {
117 return Ok(Outcome::fail(
118 FailureCode::Conflict,
119 "That username or email is already registered.",
120 ));
121 }
122 let user = User {
123 id: new_id("usr", now_ms()),
124 username,
125 };
126 self.db
127 .prepare("INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?)")
128 .bind(&[
129 user.id.as_str().into(),
130 user.username.as_str().into(),
131 email.into(),
132 crypto::hash_password(&a.password).into(),
133 ])?
134 .run()
135 .await?;
136 self.start_session(user).await
137 }
138
139 async fn sign_in(&self, a: SignInArgs) -> Result<Outcome<SignedIn>> {
140 let Some(user) = self.user_for_password(&a.username, &a.password).await? else {
141 return Ok(Outcome::fail(
142 FailureCode::Unauthenticated,
143 "Incorrect username or password.",
144 ));
145 };
146 self.start_session(user).await
147 }
148
149 async fn start_session(&self, user: User) -> Result<Outcome<SignedIn>> {
150 let session_token = crypto::random_hex(32);
151 self.db
152 .prepare(
153 "INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, unixepoch() + ?)",
154 )
155 .bind(&[
156 crypto::sha256_hex(&session_token).into(),
157 user.id.as_str().into(),
158 SESSION_TTL_SECONDS.into(),
159 ])?
160 .run()
161 .await?;
162 Ok(Outcome::Ok(SignedIn {
163 user,
164 session_token,
165 }))
166 }
167
168 async fn sign_out(&self, a: SessionArgs) -> Result<()> {
169 self.db
170 .prepare("DELETE FROM sessions WHERE id = ?")
171 .bind(&[crypto::sha256_hex(&a.session_token).into()])?
172 .run()
173 .await?;
174 Ok(())
175 }
176
177 async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> {
178 self.find_user(
179 "SELECT users.id, users.username FROM sessions
180 JOIN users ON users.id = sessions.user_id
181 WHERE sessions.id = ? AND sessions.expires_at > unixepoch()",
182 &crypto::sha256_hex(&a.session_token),
183 )
184 .await
185 }
186
187 async fn user_for_access_token(&self, token: &str) -> Result<Viewer> {
188 if !token.starts_with(TOKEN_PREFIX) {
189 return Ok(None);
190 }
191 self.find_user(
192 "SELECT users.id, users.username FROM access_tokens
193 JOIN users ON users.id = access_tokens.user_id
194 WHERE token_hash = ?",
195 &crypto::sha256_hex(token),
196 )
197 .await
198 }
199
200 async fn user_for_git_credentials(&self, a: GitCredentialsArgs) -> Result<Viewer> {
201 // Like GitHub, a token alone identifies its user.
202 if a.secret.starts_with(TOKEN_PREFIX) {
203 self.user_for_access_token(&a.secret).await
204 } else {
205 self.user_for_password(&a.username, &a.secret).await
206 }
207 }
208
209 async fn user_for_ssh_key(&self, a: FingerprintArgs) -> Result<Viewer> {
210 self.find_user(
211 "SELECT users.id, users.username FROM ssh_keys
212 JOIN users ON users.id = ssh_keys.user_id
213 WHERE fingerprint = ?",
214 &a.fingerprint,
215 )
216 .await
217 }
218
219 async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> {
220 self.find_user(
221 "SELECT id, username FROM users WHERE username = ?",
222 &a.username.to_lowercase(),
223 )
224 .await
225 }
226
227 async fn list_ssh_keys(&self, a: UserArgs) -> Result<Vec<SshKey>> {
228 let rows = self
229 .db
230 .prepare("SELECT id, title, fingerprint, created_at FROM ssh_keys WHERE user_id = ? ORDER BY id")
231 .bind(&[a.user.id.into()])?
232 .all()
233 .await?
234 .results::<KeyRow>()?;
235 Ok(rows.into_iter().map(SshKey::from).collect())
236 }
237
238 async fn add_ssh_key(&self, a: AddSshKeyArgs) -> Result<Outcome<SshKey>> {
239 let Some(key) = crypto::parse_ssh_key(&a.public_key) else {
240 return Ok(Outcome::fail(
241 FailureCode::Invalid,
242 "That is not a valid OpenSSH public key.",
243 ));
244 };
245 let taken = self
246 .db
247 .prepare("SELECT id FROM ssh_keys WHERE fingerprint = ?")
248 .bind(&[key.fingerprint.as_str().into()])?
249 .first::<serde_json::Value>(None)
250 .await?;
251 if taken.is_some() {
252 return Ok(Outcome::fail(
253 FailureCode::Conflict,
254 "That key is already registered.",
255 ));
256 }
257 let now = now_ms();
258 let title = [a.title.trim(), key.comment.as_str(), "SSH key"]
259 .into_iter()
260 .find(|candidate| !candidate.is_empty())
261 .unwrap_or_default()
262 .to_owned();
263 let row = KeyRow {
264 id: new_id("key", now),
265 title,
266 fingerprint: key.fingerprint,
267 created_at: now / 1000,
268 };
269 self.db
270 .prepare(
271 "INSERT INTO ssh_keys (id, user_id, title, public_key, fingerprint, created_at)
272 VALUES (?, ?, ?, ?, ?, ?)",
273 )
274 .bind(&[
275 row.id.as_str().into(),
276 a.user.id.into(),
277 row.title.as_str().into(),
278 key.public_key.into(),
279 row.fingerprint.as_str().into(),
280 (row.created_at as f64).into(),
281 ])?
282 .run()
283 .await?;
284 Ok(Outcome::Ok(row.into()))
285 }
286
287 async fn list_access_tokens(&self, a: UserArgs) -> Result<Vec<AccessToken>> {
288 let rows = self
289 .db
290 .prepare("SELECT id, name, created_at FROM access_tokens WHERE user_id = ? ORDER BY id")
291 .bind(&[a.user.id.into()])?
292 .all()
293 .await?
294 .results::<TokenRow>()?;
295 Ok(rows.into_iter().map(AccessToken::from).collect())
296 }
297
298 async fn create_access_token(&self, a: CreateAccessTokenArgs) -> Result<CreatedAccessToken> {
299 let token = format!("{TOKEN_PREFIX}{}", crypto::random_hex(20));
300 let now = now_ms();
301 let name = match a.name.trim() {
302 "" => "Access token",
303 name => name,
304 };
305 let row = TokenRow {
306 id: new_id("tok", now),
307 name: name.to_owned(),
308 created_at: now / 1000,
309 };
310 self.db
311 .prepare(
312 "INSERT INTO access_tokens (id, user_id, name, token_hash, created_at)
313 VALUES (?, ?, ?, ?, ?)",
314 )
315 .bind(&[
316 row.id.as_str().into(),
317 a.user.id.into(),
318 row.name.as_str().into(),
319 crypto::sha256_hex(&token).into(),
320 (row.created_at as f64).into(),
321 ])?
322 .run()
323 .await?;
324 Ok(CreatedAccessToken {
325 token,
326 info: row.into(),
327 })
328 }
329
330 /// Deletes a row the user owns from `table`.
331 async fn remove(&self, table: &str, a: RemoveArgs) -> Result<()> {
332 self.db
333 .prepare(format!("DELETE FROM {table} WHERE id = ? AND user_id = ?"))
334 .bind(&[a.id.into(), a.user.id.into()])?
335 .run()
336 .await?;
337 Ok(())
338 }
339}
340
341#[event(fetch)]
342async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
343 let Some(method) = rpc_method(&request) else {
344 return Response::error("Not found", 404);
345 };
346 let body: serde_json::Value = request.json().await?;
347 let identity = Identity { db: env.d1("DB")? };
348
349 match method.as_str() {
350 "register" => reply(&identity.register(args(body)?).await?),
351 "sign_in" => reply(&identity.sign_in(args(body)?).await?),
352 "sign_out" => reply(&identity.sign_out(args(body)?).await?),
353 "user_for_session" => reply(&identity.user_for_session(args(body)?).await?),
354 "user_for_git_credentials" => reply(&identity.user_for_git_credentials(args(body)?).await?),
355 "user_for_access_token" => {
356 let a: TokenArgs = args(body)?;
357 reply(&identity.user_for_access_token(&a.token).await?)
358 }
359 "user_for_ssh_key" => reply(&identity.user_for_ssh_key(args(body)?).await?),
360 "user_by_username" => reply(&identity.user_by_username(args(body)?).await?),
361 "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?),
362 "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?),
363 "remove_ssh_key" => reply(&identity.remove("ssh_keys", args(body)?).await?),
364 "list_access_tokens" => reply(&identity.list_access_tokens(args(body)?).await?),
365 "create_access_token" => reply(&identity.create_access_token(args(body)?).await?),
366 "remove_access_token" => reply(&identity.remove("access_tokens", args(body)?).await?),
367 _ => Response::error("Unknown method", 404),
368 }
369}