| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | pub mod statuses; |
| 8 | |
| 9 | use std::net::IpAddr; |
| 10 | use std::path::PathBuf; |
| 11 | use std::time::Instant; |
| 12 | |
| 13 | use forge_core::{Action, Denied, Repo, User, authorize, token::hash_token}; |
| 14 | use forge_git::browse; |
| 15 | use http::HeaderMap; |
| 16 | use http::header::{AUTHORIZATION, CONTENT_TYPE, HeaderValue, RETRY_AFTER, WWW_AUTHENTICATE}; |
| 17 | use serde_json::{Value, json}; |
| 18 | use topcoat::router::StatusCode; |
| 19 | use topcoat::router::response::Response; |
| 20 | |
| 21 | use crate::state::AppState; |
| 22 | use crate::throttle::{self, GIT_IP_LIMIT}; |
| 23 | |
| 24 | |
| 25 | pub const REPO_SCOPE: &str = "repo"; |
| 26 | |
| 27 | |
| 28 | pub type Refusal = Box<Response>; |
| 29 | |
| 30 | |
| 31 | pub fn json(status: StatusCode, value: &Value) -> Response { |
| 32 | let mut res = Response::new(topcoat::router::Body::from(value.to_string())); |
| 33 | *res.status_mut() = status; |
| 34 | res.headers_mut().insert( |
| 35 | CONTENT_TYPE, |
| 36 | HeaderValue::from_static("application/json; charset=utf-8"), |
| 37 | ); |
| 38 | res |
| 39 | } |
| 40 | |
| 41 | |
| 42 | pub fn error(status: StatusCode, message: &str) -> Response { |
| 43 | json(status, &json!({ "error": message })) |
| 44 | } |
| 45 | |
| 46 | |
| 47 | pub fn unauthorized() -> Response { |
| 48 | let mut res = error(StatusCode::UNAUTHORIZED, "authentication required"); |
| 49 | res.headers_mut().insert( |
| 50 | WWW_AUTHENTICATE, |
| 51 | HeaderValue::from_static("Bearer realm=\"forge\""), |
| 52 | ); |
| 53 | res |
| 54 | } |
| 55 | |
| 56 | |
| 57 | pub fn internal(message: impl std::fmt::Display) -> Refusal { |
| 58 | tracing::error!(error = %message, "api request failed"); |
| 59 | Box::new(error( |
| 60 | StatusCode::INTERNAL_SERVER_ERROR, |
| 61 | "internal server error", |
| 62 | )) |
| 63 | } |
| 64 | |
| 65 | |
| 66 | |
| 67 | fn presented_token(headers: &HeaderMap) -> Option<Option<String>> { |
| 68 | let value = headers.get(AUTHORIZATION)?.to_str().ok()?; |
| 69 | let Some((scheme, rest)) = value.split_once(' ') else { |
| 70 | return Some(None); |
| 71 | }; |
| 72 | if !(scheme.eq_ignore_ascii_case("bearer") || scheme.eq_ignore_ascii_case("token")) { |
| 73 | return Some(None); |
| 74 | } |
| 75 | let token = rest.trim(); |
| 76 | if token.is_empty() || !token.starts_with("forge_") { |
| 77 | return Some(None); |
| 78 | } |
| 79 | Some(Some(token.to_owned())) |
| 80 | } |
| 81 | |
| 82 | |
| 83 | |
| 84 | |
| 85 | |
| 86 | |
| 87 | |
| 88 | |
| 89 | |
| 90 | pub async fn authenticate( |
| 91 | state: &AppState, |
| 92 | ip: IpAddr, |
| 93 | headers: &HeaderMap, |
| 94 | ) -> Result<Option<User>, Refusal> { |
| 95 | if !headers.contains_key(AUTHORIZATION) { |
| 96 | return Ok(None); |
| 97 | } |
| 98 | let key = throttle::ip_key("git-ip", ip); |
| 99 | let now = Instant::now(); |
| 100 | if let Some(retry) = state.throttle.check(&key, GIT_IP_LIMIT, now) { |
| 101 | tracing::warn!(ip = %ip, retry_after_secs = retry.as_secs(), "api authentication throttled"); |
| 102 | let mut res = error( |
| 103 | StatusCode::TOO_MANY_REQUESTS, |
| 104 | "too many failed attempts, try again later", |
| 105 | ); |
| 106 | res.headers_mut() |
| 107 | .insert(RETRY_AFTER, HeaderValue::from(retry.as_secs().max(1))); |
| 108 | return Err(Box::new(res)); |
| 109 | } |
| 110 | |
| 111 | let Some(Some(token)) = presented_token(headers) else { |
| 112 | state.throttle.record_failure(&key, now); |
| 113 | return Err(Box::new(unauthorized())); |
| 114 | }; |
| 115 | let access = match state.store.token_by_hash(&hash_token(&token)).await { |
| 116 | Ok(Some(access)) => access, |
| 117 | Ok(None) => { |
| 118 | state.throttle.record_failure(&key, now); |
| 119 | return Err(Box::new(unauthorized())); |
| 120 | } |
| 121 | Err(e) => return Err(internal(e)), |
| 122 | }; |
| 123 | let user = match state.store.user_by_id(&access.user_id).await { |
| 124 | Ok(Some(user)) if !user.is_org() => user, |
| 125 | Ok(_) => { |
| 126 | state.throttle.record_failure(&key, now); |
| 127 | return Err(Box::new(unauthorized())); |
| 128 | } |
| 129 | Err(e) => return Err(internal(e)), |
| 130 | }; |
| 131 | if !access.scopes.iter().any(|scope| scope == REPO_SCOPE) { |
| 132 | return Err(Box::new(error( |
| 133 | StatusCode::FORBIDDEN, |
| 134 | "the token lacks the repo scope", |
| 135 | ))); |
| 136 | } |
| 137 | if let Err(e) = state.store.touch_token(&access.id).await { |
| 138 | return Err(internal(e)); |
| 139 | } |
| 140 | Ok(Some(user)) |
| 141 | } |
| 142 | |
| 143 | |
| 144 | |
| 145 | fn refused(actor: Option<&User>, denied: Denied) -> Refusal { |
| 146 | Box::new(match (actor, denied) { |
| 147 | (None, _) => unauthorized(), |
| 148 | (Some(_), Denied::NotFound) => error(StatusCode::NOT_FOUND, "repository not found"), |
| 149 | (Some(_), Denied::Forbidden) => error(StatusCode::FORBIDDEN, "forbidden"), |
| 150 | }) |
| 151 | } |
| 152 | |
| 153 | |
| 154 | pub async fn load_repo( |
| 155 | state: &AppState, |
| 156 | actor: Option<&User>, |
| 157 | owner: &str, |
| 158 | name: &str, |
| 159 | action: Action, |
| 160 | ) -> Result<Repo, Refusal> { |
| 161 | let repo = match state.store.repo_by_owner_and_name(owner, name).await { |
| 162 | Ok(Some(repo)) => repo, |
| 163 | Ok(None) => return Err(refused(actor, Denied::NotFound)), |
| 164 | Err(e) => return Err(internal(e)), |
| 165 | }; |
| 166 | let (role, org_role) = match actor { |
| 167 | Some(user) => ( |
| 168 | state |
| 169 | .store |
| 170 | .collaborator_role(&repo.id, &user.id) |
| 171 | .await |
| 172 | .map_err(internal)?, |
| 173 | state |
| 174 | .store |
| 175 | .org_repo_role(&repo.owner_id, &user.id, &repo.id) |
| 176 | .await |
| 177 | .map_err(internal)?, |
| 178 | ), |
| 179 | None => (None, None), |
| 180 | }; |
| 181 | authorize(actor, &repo, role, org_role, action).map_err(|denied| refused(actor, denied))?; |
| 182 | Ok(repo) |
| 183 | } |
| 184 | |
| 185 | |
| 186 | pub fn repo_dir(state: &AppState, repo: &Repo) -> Result<PathBuf, Refusal> { |
| 187 | forge_git::repo::repo_path(&state.config.data_dir, &repo.id).map_err(internal) |
| 188 | } |
| 189 | |
| 190 | |
| 191 | pub async fn commit_exists(path: PathBuf, sha: String) -> Result<bool, Refusal> { |
| 192 | let found = tokio::task::spawn_blocking(move || browse::read_commit(&path, &sha)) |
| 193 | .await |
| 194 | .map_err(internal)? |
| 195 | .map_err(internal)?; |
| 196 | Ok(found.is_some()) |
| 197 | } |
| 198 | |
| 199 | |
| 200 | pub fn normalize_sha(raw: &str) -> Option<String> { |
| 201 | let lower = raw.to_ascii_lowercase(); |
| 202 | forge_core::store::commit_status::is_full_sha(&lower).then_some(lower) |
| 203 | } |
| 204 | |
| 205 | |
| 206 | pub async fn read_body(body: topcoat::router::Body, limit: usize) -> Result<Vec<u8>, Refusal> { |
| 207 | topcoat::router::to_bytes(body, limit) |
| 208 | .await |
| 209 | .map(|bytes| bytes.to_vec()) |
| 210 | .map_err(|_| { |
| 211 | Box::new(error( |
| 212 | StatusCode::PAYLOAD_TOO_LARGE, |
| 213 | "request body is too large", |
| 214 | )) |
| 215 | }) |
| 216 | } |
| 217 | |
| 218 | #[cfg(test)] |
| 219 | mod tests { |
| 220 | use super::*; |
| 221 | use http::HeaderValue; |
| 222 | |
| 223 | fn headers_with(value: &str) -> HeaderMap { |
| 224 | let mut headers = HeaderMap::new(); |
| 225 | headers.insert( |
| 226 | AUTHORIZATION, |
| 227 | HeaderValue::from_str(value).expect("valid header"), |
| 228 | ); |
| 229 | headers |
| 230 | } |
| 231 | |
| 232 | #[test] |
| 233 | fn accepts_bearer_and_token_schemes() { |
| 234 | assert_eq!( |
| 235 | presented_token(&headers_with("Bearer forge_abc")), |
| 236 | Some(Some("forge_abc".into())) |
| 237 | ); |
| 238 | assert_eq!( |
| 239 | presented_token(&headers_with("token forge_abc")), |
| 240 | Some(Some("forge_abc".into())) |
| 241 | ); |
| 242 | assert_eq!( |
| 243 | presented_token(&headers_with("BEARER forge_abc")), |
| 244 | Some(Some("forge_abc".into())) |
| 245 | ); |
| 246 | } |
| 247 | |
| 248 | #[test] |
| 249 | fn rejects_other_shapes() { |
| 250 | assert_eq!( |
| 251 | presented_token(&headers_with("Basic Zm9vOmJhcg==")), |
| 252 | Some(None) |
| 253 | ); |
| 254 | assert_eq!( |
| 255 | presented_token(&headers_with("Bearer notforge")), |
| 256 | Some(None) |
| 257 | ); |
| 258 | assert_eq!(presented_token(&headers_with("Bearer ")), Some(None)); |
| 259 | assert_eq!(presented_token(&HeaderMap::new()), None); |
| 260 | } |
| 261 | |
| 262 | #[test] |
| 263 | fn sha_is_lowercased_and_must_be_full() { |
| 264 | assert_eq!(normalize_sha(&"AB".repeat(20)), Some("ab".repeat(20))); |
| 265 | assert_eq!(normalize_sha("abc123"), None); |
| 266 | assert_eq!(normalize_sha(&"z".repeat(40)), None); |
| 267 | } |
| 268 | } |