| 1 | //! HTTP Basic authentication for Git clients (spec §6). |
| 2 | //! |
| 3 | //! "Git over HTTPS uses Basic auth: username is the user handle, password is a |
| 4 | //! personal access token." |
| 5 | //! |
| 6 | //! The handle is accepted but not trusted for identity — the token alone |
| 7 | //! determines who the caller is. A token that belongs to a different user than |
| 8 | //! the supplied handle still authenticates as its real owner rather than |
| 9 | //! being rejected, because the handle is decoration in the credential helper |
| 10 | //! and users routinely have it stale. |
| 11 | |
| 12 | use axum::http::HeaderMap; |
| 13 | use base64::Engine; |
| 14 | use df_db::models::User; |
| 15 | use uuid::Uuid; |
| 16 | |
| 17 | use crate::error::AppResult; |
| 18 | use crate::state::AppState; |
| 19 | |
| 20 | /// Decode `Authorization: Basic …` into (username, password). |
| 21 | fn parse_basic(headers: &HeaderMap) -> Option<(String, String)> { |
| 22 | let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?; |
| 23 | let encoded = raw.strip_prefix("Basic ").or_else(|| raw.strip_prefix("basic "))?; |
| 24 | |
| 25 | let decoded = base64::engine::general_purpose::STANDARD |
| 26 | .decode(encoded.trim()) |
| 27 | .ok()?; |
| 28 | let text = String::from_utf8(decoded).ok()?; |
| 29 | |
| 30 | // The password may itself contain a colon, so split only on the first. |
| 31 | let (user, pass) = text.split_once(':')?; |
| 32 | Some((user.to_string(), pass.to_string())) |
| 33 | } |
| 34 | |
| 35 | /// Authenticate a Git request, if credentials were supplied. |
| 36 | /// |
| 37 | /// Returns `Ok(None)` for an anonymous request — which is legitimate for |
| 38 | /// reading a public repository — and for credentials that do not verify. The |
| 39 | /// caller turns that into a challenge or a 404 as appropriate; it never |
| 40 | /// distinguishes "no credentials" from "bad credentials" to the client. |
| 41 | pub async fn authenticate(state: &AppState, headers: &HeaderMap) -> AppResult<Option<User>> { |
| 42 | let Some((handle, token)) = parse_basic(headers) else { |
| 43 | return Ok(None); |
| 44 | }; |
| 45 | |
| 46 | let Some(user_id) = df_auth::tokens::verify(&state.db, &token).await? else { |
| 47 | tracing::info!(%handle, "git authentication failed: token did not verify"); |
| 48 | return Ok(None); |
| 49 | }; |
| 50 | |
| 51 | let user = sqlx::query_as::<_, User>( |
| 52 | "SELECT id, subject, handle, display_name, email, avatar_url, is_admin, created_at |
| 53 | FROM users WHERE id = $1", |
| 54 | ) |
| 55 | .bind(user_id) |
| 56 | .fetch_optional(&state.db) |
| 57 | .await?; |
| 58 | |
| 59 | if let Some(u) = &user { |
| 60 | if !u.handle.eq_ignore_ascii_case(&handle) { |
| 61 | // Not an error: credential helpers cache stale usernames. The token |
| 62 | // is the authority. |
| 63 | tracing::debug!( |
| 64 | supplied = %handle, |
| 65 | actual = %u.handle, |
| 66 | "git basic-auth handle does not match the token owner; using the token owner" |
| 67 | ); |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | Ok(user) |
| 72 | } |
| 73 | |
| 74 | /// The authenticated user's id, for attributing a push. |
| 75 | pub async fn authenticated_user_id(state: &AppState, headers: &HeaderMap) -> Option<Uuid> { |
| 76 | authenticate(state, headers).await.ok().flatten().map(|u| u.id) |
| 77 | } |
| 78 | |
| 79 | #[cfg(test)] |
| 80 | mod tests { |
| 81 | use super::*; |
| 82 | use axum::http::HeaderValue; |
| 83 | |
| 84 | fn headers_with(value: &str) -> HeaderMap { |
| 85 | let mut h = HeaderMap::new(); |
| 86 | h.insert( |
| 87 | axum::http::header::AUTHORIZATION, |
| 88 | HeaderValue::from_str(value).unwrap(), |
| 89 | ); |
| 90 | h |
| 91 | } |
| 92 | |
| 93 | fn basic(user: &str, pass: &str) -> String { |
| 94 | format!( |
| 95 | "Basic {}", |
| 96 | base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}")) |
| 97 | ) |
| 98 | } |
| 99 | |
| 100 | #[test] |
| 101 | fn parses_ordinary_credentials() { |
| 102 | let h = headers_with(&basic("snow", "dgf_abc123")); |
| 103 | assert_eq!( |
| 104 | parse_basic(&h), |
| 105 | Some(("snow".into(), "dgf_abc123".into())) |
| 106 | ); |
| 107 | } |
| 108 | |
| 109 | #[test] |
| 110 | fn a_password_may_contain_colons() { |
| 111 | // Splitting on the last colon, or on all of them, would corrupt tokens. |
| 112 | let h = headers_with(&basic("snow", "a:b:c")); |
| 113 | assert_eq!(parse_basic(&h), Some(("snow".into(), "a:b:c".into()))); |
| 114 | } |
| 115 | |
| 116 | #[test] |
| 117 | fn an_empty_username_is_still_parsed() { |
| 118 | // Some credential helpers send only a token, with an empty username. |
| 119 | let h = headers_with(&basic("", "dgf_token")); |
| 120 | assert_eq!(parse_basic(&h), Some(("".into(), "dgf_token".into()))); |
| 121 | } |
| 122 | |
| 123 | #[test] |
| 124 | fn missing_or_malformed_headers_yield_none() { |
| 125 | assert_eq!(parse_basic(&HeaderMap::new()), None); |
| 126 | assert_eq!(parse_basic(&headers_with("Bearer sometoken")), None); |
| 127 | assert_eq!(parse_basic(&headers_with("Basic !!!not-base64!!!")), None); |
| 128 | // Valid base64, but no colon separator. |
| 129 | let no_colon = base64::engine::general_purpose::STANDARD.encode("nocolon"); |
| 130 | assert_eq!(parse_basic(&headers_with(&format!("Basic {no_colon}"))), None); |
| 131 | } |
| 132 | |
| 133 | #[test] |
| 134 | fn the_scheme_match_is_case_insensitive() { |
| 135 | let encoded = base64::engine::general_purpose::STANDARD.encode("a:b"); |
| 136 | assert!(parse_basic(&headers_with(&format!("basic {encoded}"))).is_some()); |
| 137 | } |
| 138 | |
| 139 | #[test] |
| 140 | fn non_utf8_credentials_are_rejected_rather_than_panicking() { |
| 141 | let encoded = base64::engine::general_purpose::STANDARD.encode([0xff, 0xfe, 0x3a, 0x61]); |
| 142 | assert_eq!(parse_basic(&headers_with(&format!("Basic {encoded}"))), None); |
| 143 | } |
| 144 | } |
144 lines · Rust