forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
9.3 KiB
1//! Our own small JSON API under `/api/v1`. Authentication is by personal access token only:
2//! `Authorization: Bearer forge_...` or `Authorization: token forge_...`. Session cookies are never
3//! read here, so a browser cannot drive these routes and CSRF protection does not apply. Errors are
4//! `{"error": "..."}` with a matching status code. Anonymous callers get 401 whenever they cannot
5//! use a repo, the same as git, so missing and hidden repos look identical to them.
6
7pub mod statuses;
8
9use std::net::IpAddr;
10use std::path::PathBuf;
11use std::time::Instant;
12
13use forge_core::{Action, Denied, Repo, User, authorize, token::hash_token};
14use forge_git::browse;
15use http::HeaderMap;
16use http::header::{AUTHORIZATION, CONTENT_TYPE, HeaderValue, RETRY_AFTER, WWW_AUTHENTICATE};
17use serde_json::{Value, json};
18use topcoat::router::StatusCode;
19use topcoat::router::response::Response;
20
21use crate::state::AppState;
22use crate::throttle::{self, GIT_IP_LIMIT};
23
24/// The scope a token needs to use this API. It is the only scope the token form offers.
25pub const REPO_SCOPE: &str = "repo";
26
27/// A failed step. The response is boxed, as in git_http, to keep `Result` small.
28pub type Refusal = Box<Response>;
29
30/// Builds a JSON response with the given status.
31pub 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/// A JSON error: `{"error": message}`.
42pub fn error(status: StatusCode, message: &str) -> Response {
43 json(status, &json!({ "error": message }))
44}
45
46/// 401 with a Bearer challenge.
47pub 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/// A 500 that does not reveal the cause; the cause is logged.
57pub 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/// Pulls the token out of `Bearer <token>` or `token <token>`. The scheme is matched
66/// case-insensitively. Returns `None` for any other shape, including a missing header.
67fn 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/// Resolves the caller from the `Authorization` header.
83///
84/// - No header: `Ok(None)`, an anonymous caller.
85/// - A header that is not a forge token, an unknown or expired token, or a token whose user is
86/// gone: 401, and the failure counts against the client IP. The budget is the git one, so
87/// guesses cannot be split between git and the API.
88/// - A token without the `repo` scope: 403, which does not count as a failed login.
89/// - Otherwise the token's `last_used_at` is touched and the user is returned.
90pub 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/// Maps a refused action. Anonymous callers always get 401, whether the repo is hidden, missing,
144/// or merely read-only to them. Signed-in callers get 404 for hidden repos and 403 otherwise.
145fn 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/// Loads `owner/name` and checks that `actor` may perform `action` on it.
154pub 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/// On-disk path of a repo's bare directory.
186pub 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/// True when `sha` names a commit in the repo. Runs the git read off the async runtime.
191pub 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/// Lowercases a full commit id. `None` unless it is 40 or 64 hex characters.
200pub 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/// Reads the request body, refusing anything over `limit` bytes.
206pub 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)]
219mod 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}