crates/cli/ents-web/src/pages/login.rs
login.rshistorycomment on this file
| 1 | //! `GET /login`, `GET`/`POST /login/challenge/{code}`, `POST /logout`: |
| 2 | //! the hosted sign-in surface (`roots.web-signin`), mounted only when the |
| 3 | //! composition root injected [`AccessPolicy::SignInRequired`] — a local |
| 4 | //! root has no sign-in surface at all, so under |
| 5 | //! [`AccessPolicy::Trusted`] these routes do not exist and `/login` is a |
| 6 | //! plain 404. |
| 7 | //! |
| 8 | //! The flow inverts a device-code login (`gh auth login`'s shape) because |
| 9 | //! here the *CLI* holds the credential: the browser's `/login` page mints |
| 10 | //! a short one-time code bound to its own session and displays the |
| 11 | //! `git ents login` command to run; the CLI fetches the full challenge |
| 12 | //! (`GET`, non-consuming), rebuilds the payload locally from the host the |
| 13 | //! member addressed, signs it under [`crate::auth::LOGIN_NAMESPACE`], and |
| 14 | //! posts the signature back (`POST`, consuming). The page refreshes |
| 15 | //! itself until the session reads as signed in. |
| 16 | //! |
| 17 | //! The CLI endpoints speak `key=value` text lines, not HTML forms' |
| 18 | //! escaping rules and not JSON — the same trivially-parseable shape the |
| 19 | //! challenge payload itself uses, so neither side grows a parser. |
| 20 | |
| 21 | use std::sync::Arc; |
| 22 | |
| 23 | use axum::Form; |
| 24 | use axum::extract::{Path, State}; |
| 25 | use axum::http::{StatusCode, header}; |
| 26 | use axum::response::{IntoResponse, Redirect, Response}; |
| 27 | use gix_object::{Find, Write}; |
| 28 | use maud::html; |
| 29 | use serde::Deserialize; |
| 30 | |
| 31 | use crate::auth; |
| 32 | use crate::session::{Session, SessionId, SessionMember}; |
| 33 | use crate::state::{AccessPolicy, AppState, Realm}; |
| 34 | |
| 35 | /// The realm, or a 404 — these handlers are only ever routed under |
| 36 | /// [`AccessPolicy::SignInRequired`], so a miss here is a wiring error, |
| 37 | /// answered exactly as an unmounted route would be. |
| 38 | fn realm<O>(state: &AppState<O>) -> Result<&Realm, Box<Response>> { |
| 39 | match &state.access { |
| 40 | AccessPolicy::SignInRequired(realm) => Ok(realm), |
| 41 | AccessPolicy::Trusted => Err(Box::new(StatusCode::NOT_FOUND.into_response())), |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | /// `GET /login`: the sign-in page. Signed out, it mints a challenge |
| 46 | /// bound to this browser's session and shows the one command to run; |
| 47 | /// the page refreshes itself every few seconds until the session reads |
| 48 | /// as signed in — no script needed, and a challenge consumed by the CLI |
| 49 | /// re-issues on the next refresh only if sign-in did not complete. |
| 50 | // @relation(roots.web-signin, scope=function) |
| 51 | pub async fn show<O>( |
| 52 | State(state): State<Arc<AppState<O>>>, |
| 53 | axum::Extension(session): axum::Extension<Session>, |
| 54 | axum::Extension(SessionId(session_id)): axum::Extension<SessionId>, |
| 55 | ) -> Response |
| 56 | where |
| 57 | O: Find + Write + Send + 'static, |
| 58 | { |
| 59 | let realm = match realm(&state) { |
| 60 | Ok(realm) => realm, |
| 61 | Err(response) => return *response, |
| 62 | }; |
| 63 | |
| 64 | let body = match &session.member { |
| 65 | Some(member) => html! { |
| 66 | div.readable { |
| 67 | p { "Signed in as " strong { (member.username) } "." } |
| 68 | p.muted { |
| 69 | "Edits you make here are authored as this member and " |
| 70 | "signed by the server's own key -- history reads " |
| 71 | em { (member.username) " via the web" } "." |
| 72 | } |
| 73 | form method="post" action="/logout" { |
| 74 | input type="hidden" name="csrf" value=(session.csrf); |
| 75 | button.btn type="submit" { "Sign out" } |
| 76 | } |
| 77 | } |
| 78 | }, |
| 79 | None => { |
| 80 | let (code, _nonce) = realm.challenges.issue(&session_id); |
| 81 | // The code is eight ASCII base32 characters by construction; |
| 82 | // split_at is byte-indexed and cannot land inside a char. |
| 83 | let (head, tail) = code.split_at(4); |
| 84 | let display = format!("{head}-{tail}"); |
| 85 | html! { |
| 86 | div.readable { |
| 87 | p { |
| 88 | "Prove control of an enrolled member key. Run this " |
| 89 | "on your own machine -- the key never leaves it:" |
| 90 | } |
| 91 | pre.login-code { |
| 92 | "git ents login https://" span.code { (realm.host) " " (display) } |
| 93 | } |
| 94 | p.muted { |
| 95 | "This page refreshes on its own; the code is " |
| 96 | "single-use and expires in ten minutes." |
| 97 | } |
| 98 | } |
| 99 | } |
| 100 | } |
| 101 | }; |
| 102 | |
| 103 | let markup = super::layout( |
| 104 | &super::RepoHeader::from_state(&state), |
| 105 | &super::identity_label(&state), |
| 106 | super::Tab::Account, |
| 107 | "Sign in", |
| 108 | body, |
| 109 | ); |
| 110 | if session.member.is_some() { |
| 111 | markup.into_response() |
| 112 | } else { |
| 113 | // A refresh header, not a script: the page re-renders as signed |
| 114 | // in on the first refresh after the CLI completes the challenge. |
| 115 | ([(header::HeaderName::from_static("refresh"), "3")], markup).into_response() |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | /// `GET /login/challenge/{code}`: the CLI's fetch — the challenge's |
| 120 | /// bound facts as `key=value` lines, without consuming it. The CLI MUST |
| 121 | /// rebuild the payload from the host it addressed rather than trusting |
| 122 | /// these lines (`roots.web-signin`); they exist so it can carry the |
| 123 | /// nonce and confirm the host matches. |
| 124 | // @relation(roots.web-signin, scope=function) |
| 125 | pub async fn challenge<O>( |
| 126 | State(state): State<Arc<AppState<O>>>, |
| 127 | Path(code): Path<String>, |
| 128 | ) -> Response |
| 129 | where |
| 130 | O: Find + Write + Send + 'static, |
| 131 | { |
| 132 | let realm = match realm(&state) { |
| 133 | Ok(realm) => realm, |
| 134 | Err(response) => return *response, |
| 135 | }; |
| 136 | match realm.challenges.peek(&code) { |
| 137 | Some(challenge) => ( |
| 138 | [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], |
| 139 | format!( |
| 140 | "host={}\ncode={}\nnonce={}\n", |
| 141 | realm.host, |
| 142 | auth::normalize_code(&code), |
| 143 | challenge.nonce |
| 144 | ), |
| 145 | ) |
| 146 | .into_response(), |
| 147 | None => ( |
| 148 | StatusCode::NOT_FOUND, |
| 149 | "unknown or expired code; reload the sign-in page for a fresh one\n", |
| 150 | ) |
| 151 | .into_response(), |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | /// What the CLI posts back to complete a sign-in: the member's public |
| 156 | /// key line and the armored SSHSIG over the locally-rebuilt payload. |
| 157 | #[derive(Deserialize)] |
| 158 | pub struct Completion { |
| 159 | /// The member's OpenSSH public key line. |
| 160 | pub public_key: String, |
| 161 | /// The armored SSHSIG PEM over [`crate::auth::challenge_payload`]. |
| 162 | pub signature: String, |
| 163 | } |
| 164 | |
| 165 | /// `POST /login/challenge/{code}`: consume the challenge, verify the |
| 166 | /// signature over the server's own reconstruction of the payload, check |
| 167 | /// the key names an enrolled *active* member, and mark the bound browser |
| 168 | /// session signed in. Deliberately outside the session/CSRF discipline: |
| 169 | /// this request carries no cookie and authenticates by signature alone |
| 170 | /// (`roots.web-signin`). |
| 171 | // @relation(roots.web-signin, scope=function) |
| 172 | pub async fn complete<O>( |
| 173 | State(state): State<Arc<AppState<O>>>, |
| 174 | Path(code): Path<String>, |
| 175 | Form(completion): Form<Completion>, |
| 176 | ) -> Response |
| 177 | where |
| 178 | O: Find + Write + Send + 'static, |
| 179 | { |
| 180 | let realm = match realm(&state) { |
| 181 | Ok(realm) => realm, |
| 182 | Err(response) => return *response, |
| 183 | }; |
| 184 | let Some(challenge) = realm.challenges.take(&code) else { |
| 185 | return ( |
| 186 | StatusCode::NOT_FOUND, |
| 187 | "unknown or expired code; reload the sign-in page for a fresh one\n", |
| 188 | ) |
| 189 | .into_response(); |
| 190 | }; |
| 191 | |
| 192 | let code = auth::normalize_code(&code); |
| 193 | let payload = auth::challenge_payload(&realm.host, &code, &challenge.nonce); |
| 194 | let public_key = completion.public_key.trim(); |
| 195 | if !auth::verify_login(public_key, payload.as_bytes(), &completion.signature) { |
| 196 | return ( |
| 197 | StatusCode::UNAUTHORIZED, |
| 198 | "the signature did not verify against that key for this host and code\n", |
| 199 | ) |
| 200 | .into_response(); |
| 201 | } |
| 202 | let username = match auth::active_member_by_key(&state, public_key) { |
| 203 | Ok(Some(username)) => username, |
| 204 | Ok(None) => { |
| 205 | return ( |
| 206 | StatusCode::UNAUTHORIZED, |
| 207 | "that key is not an enrolled, active member of this repository\n", |
| 208 | ) |
| 209 | .into_response(); |
| 210 | } |
| 211 | Err(error) => return error.into_response(), |
| 212 | }; |
| 213 | |
| 214 | let member = SessionMember { |
| 215 | username: username.clone(), |
| 216 | key: public_key.to_owned(), |
| 217 | }; |
| 218 | if !state.sessions.authenticate(&challenge.session_id, member) { |
| 219 | return ( |
| 220 | StatusCode::GONE, |
| 221 | "the browser session that requested this code no longer exists; reload the sign-in \ |
| 222 | page\n", |
| 223 | ) |
| 224 | .into_response(); |
| 225 | } |
| 226 | ( |
| 227 | [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], |
| 228 | format!("member={username}\n"), |
| 229 | ) |
| 230 | .into_response() |
| 231 | } |
| 232 | |
| 233 | /// What the logout form posts: the session's CSRF token. |
| 234 | #[derive(Deserialize)] |
| 235 | pub struct LogoutForm { |
| 236 | /// The per-session CSRF token (`roots.web-session`). |
| 237 | pub csrf: String, |
| 238 | } |
| 239 | |
| 240 | /// `POST /logout`: drop the session's signed-in member, keeping the |
| 241 | /// session (and its CSRF token) itself. CSRF-checked like every other |
| 242 | /// browser mutation — a cross-site form must not be able to sign the |
| 243 | /// user out. |
| 244 | // @relation(roots.web-signin, roots.web-session, scope=function) |
| 245 | pub async fn logout<O>( |
| 246 | State(state): State<Arc<AppState<O>>>, |
| 247 | axum::Extension(session): axum::Extension<Session>, |
| 248 | axum::Extension(SessionId(session_id)): axum::Extension<SessionId>, |
| 249 | Form(form): Form<LogoutForm>, |
| 250 | ) -> crate::Result<impl IntoResponse> |
| 251 | where |
| 252 | O: Find + Write + Send + 'static, |
| 253 | { |
| 254 | super::require_csrf(&session, &form.csrf)?; |
| 255 | state.sessions.clear_member(&session_id); |
| 256 | Ok(Redirect::to("/")) |
| 257 | } |