Skip to main content

max / makenotwork

3.4 KB · 103 lines History Blame Raw
1 //! Personal access tokens for git over HTTPS (Basic auth).
2 //!
3 //! The plaintext token is shown to the user once at creation and never stored;
4 //! only its SHA-256 hex hash lives in the DB, so a database read can't recover
5 //! a usable credential. Lookups hash the presented token and match the column.
6
7 use chrono::{DateTime, Utc};
8 use sqlx::PgPool;
9
10 use crate::db::{GitAccessTokenId, UserId};
11 use crate::error::Result;
12
13 /// A git access token row, minus the hash (never returned to handlers/UI).
14 #[derive(Debug, sqlx::FromRow)]
15 pub struct DbGitAccessToken {
16 pub id: GitAccessTokenId,
17 pub user_id: UserId,
18 pub name: String,
19 pub can_push: bool,
20 pub created_at: DateTime<Utc>,
21 pub last_used_at: Option<DateTime<Utc>>,
22 pub expires_at: Option<DateTime<Utc>>,
23 }
24
25 /// The user + push scope resolved from a presented token (active tokens only).
26 pub struct ResolvedToken {
27 pub user_id: UserId,
28 pub can_push: bool,
29 }
30
31 /// Create a token. `token_hash` is the SHA-256 hex of the plaintext (the caller
32 /// generates the plaintext via `crypto::generate_git_token` and shows it once).
33 #[tracing::instrument(skip_all)]
34 pub async fn create(
35 pool: &PgPool,
36 user_id: UserId,
37 name: &str,
38 token_hash: &str,
39 can_push: bool,
40 expires_at: Option<DateTime<Utc>>,
41 ) -> Result<GitAccessTokenId> {
42 let id = sqlx::query_scalar::<_, GitAccessTokenId>(
43 r#"INSERT INTO git_access_tokens (user_id, name, token_hash, can_push, expires_at)
44 VALUES ($1, $2, $3, $4, $5)
45 RETURNING id"#,
46 )
47 .bind(user_id)
48 .bind(name)
49 .bind(token_hash)
50 .bind(can_push)
51 .bind(expires_at)
52 .fetch_one(pool)
53 .await?;
54
55 Ok(id)
56 }
57
58 /// List a user's tokens (no hashes), newest first.
59 #[tracing::instrument(skip_all)]
60 pub async fn list_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbGitAccessToken>> {
61 let rows = sqlx::query_as::<_, DbGitAccessToken>(
62 r#"SELECT id, user_id, name, can_push, created_at, last_used_at, expires_at
63 FROM git_access_tokens WHERE user_id = $1 ORDER BY created_at DESC"#,
64 )
65 .bind(user_id)
66 .fetch_all(pool)
67 .await?;
68
69 Ok(rows)
70 }
71
72 /// Revoke (delete) a token the user owns. Returns true if a row was removed.
73 #[tracing::instrument(skip_all)]
74 pub async fn revoke(pool: &PgPool, id: GitAccessTokenId, user_id: UserId) -> Result<bool> {
75 let result = sqlx::query("DELETE FROM git_access_tokens WHERE id = $1 AND user_id = $2")
76 .bind(id)
77 .bind(user_id)
78 .execute(pool)
79 .await?;
80
81 Ok(result.rows_affected() > 0)
82 }
83
84 /// Resolve a presented token's hash to its (user, push-scope), if the token
85 /// exists and has not expired. Stamps `last_used_at` on a hit. Returns `None`
86 /// for unknown or expired tokens — callers must treat that as unauthenticated.
87 #[tracing::instrument(skip_all)]
88 pub async fn resolve_active(pool: &PgPool, token_hash: &str) -> Result<Option<ResolvedToken>> {
89 // UPDATE...RETURNING does the expiry filter and the last_used_at stamp in
90 // one round-trip; an expired token matches no row, so it resolves to None.
91 let row: Option<(UserId, bool)> = sqlx::query_as(
92 r#"UPDATE git_access_tokens
93 SET last_used_at = NOW()
94 WHERE token_hash = $1 AND (expires_at IS NULL OR expires_at > NOW())
95 RETURNING user_id, can_push"#,
96 )
97 .bind(token_hash)
98 .fetch_optional(pool)
99 .await?;
100
101 Ok(row.map(|(user_id, can_push)| ResolvedToken { user_id, can_push }))
102 }
103