Sign in with GitHub or Google 83940d53664f
An administrator sets each provider up under Administration (client id, a sealed secret, on or off; the callback URL shown); the login page offers the usable ones. OAuth 2.0 authorization code with a MACed state cookie; a known subject signs in, else the account with that verified email is linked, else an account is made when registration is open. Connect or disconnect under Settings › Security. closes #106
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 17:30 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
83940d53664f90b19c6f5baa2c14c3c53ab01c1728d8936684dee85928050c6a- Depends on
- 19bb31ff1f5c 2a83f5303458 34beb1a7e376 3556d08e17f9 4ac01a732552 66fd9d6b1452 749d9a5fcd9f 89e89836cae7 93be9ce4653e a160b98fb922 b5668108487f c40c2d3edb51 d23464c0ac60 df71c7892d27 e71846aa07b9
- Depended on by
- a22e4aec2e28 737de0fac722 8597fb5ca16c c92a99490cba c8684d44d549 3b1bc27fe805 d1bf2d698777 5d90e0e045e2 3ff52534f69b 0024ccb1b050 3dd6582de060 810896d41be7 bff9106edcba 155cab142b63 e8e7158bbc73 efd29a0033e8
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 41 atoms, 3 tree operations, 15 files
Browse the tree at this change
added crates/hub/migrations/0041_account_logins.sql +11 −0
| 1 | + | -- Sign-in with an external account (hub spec §5.1.2, #106): which |
|
| 2 | + | -- provider subject is which account, and the login name there. |
|
| 3 | + | CREATE TABLE account_logins ( |
|
| 4 | + | provider TEXT NOT NULL, |
|
| 5 | + | subject TEXT NOT NULL, |
|
| 6 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 7 | + | login TEXT NOT NULL DEFAULT '', |
|
| 8 | + | linked_ms INTEGER NOT NULL, |
|
| 9 | + | PRIMARY KEY (provider, subject) |
|
| 10 | + | ); |
|
| 11 | + | CREATE INDEX account_logins_by_account ON account_logins (account_id); |
added crates/hub/src/signin.rs +316 −0
| 1 | + | //! Sign in with an external account (hub spec §5.1.2, oxygenhub#106): |
|
| 2 | + | //! GitHub and Google through OAuth 2.0's authorization code flow, set up |
|
| 3 | + | //! by an administrator per provider (client id, a sealed secret, on or |
|
| 4 | + | //! off). A first sign-in links the provider's subject to the account with |
|
| 5 | + | //! the same verified email, or makes an account when registration is |
|
| 6 | + | //! open; later sign-ins find the link. |
|
| 7 | + | ||
| 8 | + | use crate::db::{accounts, now_ms, Pool}; |
|
| 9 | + | use crate::Shared; |
|
| 10 | + | use sqlx::FromRow; |
|
| 11 | + | ||
| 12 | + | /// A provider the hub knows how to talk to. |
|
| 13 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 14 | + | pub struct Provider { |
|
| 15 | + | pub id: &'static str, |
|
| 16 | + | pub name: &'static str, |
|
| 17 | + | authorize: &'static str, |
|
| 18 | + | token: &'static str, |
|
| 19 | + | userinfo: &'static str, |
|
| 20 | + | pub scope: &'static str, |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | pub const PROVIDERS: [Provider; 2] = [ |
|
| 24 | + | Provider { |
|
| 25 | + | id: "github", |
|
| 26 | + | name: "GitHub", |
|
| 27 | + | authorize: "https://github.com/login/oauth/authorize", |
|
| 28 | + | token: "https://github.com/login/oauth/access_token", |
|
| 29 | + | userinfo: "https://api.github.com/user", |
|
| 30 | + | scope: "read:user user:email", |
|
| 31 | + | }, |
|
| 32 | + | Provider { |
|
| 33 | + | id: "google", |
|
| 34 | + | name: "Google", |
|
| 35 | + | authorize: "https://accounts.google.com/o/oauth2/v2/auth", |
|
| 36 | + | token: "https://oauth2.googleapis.com/token", |
|
| 37 | + | userinfo: "https://openidconnect.googleapis.com/v1/userinfo", |
|
| 38 | + | scope: "openid email profile", |
|
| 39 | + | }, |
|
| 40 | + | ]; |
|
| 41 | + | ||
| 42 | + | pub fn provider(id: &str) -> Option<&'static Provider> { |
|
| 43 | + | PROVIDERS.iter().find(|p| p.id == id) |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | impl Provider { |
|
| 47 | + | /// The provider's endpoints, or a test double's when `[auth] |
|
| 48 | + | /// signin_mock` names one: `{mock}/{id}/authorize|token|userinfo`. |
|
| 49 | + | fn url(&self, app: &Shared, which: &str) -> String { |
|
| 50 | + | match &app.config.auth.signin_mock { |
|
| 51 | + | Some(base) => format!("{}/{}/{which}", base.trim_end_matches('/'), self.id), |
|
| 52 | + | None => match which { |
|
| 53 | + | "authorize" => self.authorize.to_string(), |
|
| 54 | + | "token" => self.token.to_string(), |
|
| 55 | + | _ => self.userinfo.to_string(), |
|
| 56 | + | }, |
|
| 57 | + | } |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | /// Where the provider sends the person back. |
|
| 61 | + | pub fn callback_url(&self, app: &Shared) -> String { |
|
| 62 | + | format!("{}/login/{}/callback", app.config.base_url(), self.id) |
|
| 63 | + | } |
|
| 64 | + | } |
|
| 65 | + | ||
| 66 | + | /// What an administrator set for a provider. |
|
| 67 | + | #[derive(Clone, Debug, Default)] |
|
| 68 | + | pub struct Settings { |
|
| 69 | + | pub client_id: String, |
|
| 70 | + | /// The client secret, opened; empty when none is set. |
|
| 71 | + | pub secret: String, |
|
| 72 | + | pub enabled: bool, |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | impl Settings { |
|
| 76 | + | pub fn usable(&self) -> bool { |
|
| 77 | + | self.enabled && !self.client_id.is_empty() && !self.secret.is_empty() |
|
| 78 | + | } |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | fn key(id: &str, what: &str) -> String { |
|
| 82 | + | format!("signin.{id}.{what}") |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | pub async fn settings(app: &Shared, id: &str) -> sqlx::Result<Settings> { |
|
| 86 | + | let client_id = accounts::setting(&app.db, &key(id, "client_id")).await?.unwrap_or_default(); |
|
| 87 | + | let sealed = accounts::setting(&app.db, &key(id, "secret")).await?.unwrap_or_default(); |
|
| 88 | + | let secret = hex::decode(&sealed).ok().and_then(|b| app.secret_key.open(&b)).unwrap_or_default(); |
|
| 89 | + | let enabled = accounts::setting(&app.db, &key(id, "enabled")).await?.as_deref() == Some("1"); |
|
| 90 | + | Ok(Settings { client_id, secret, enabled }) |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | /// Save a provider's settings; `secret` is `None` to keep the stored one. |
|
| 94 | + | pub async fn save(app: &Shared, id: &str, client_id: &str, secret: Option<&str>, enabled: bool) -> sqlx::Result<()> { |
|
| 95 | + | accounts::set_setting(&app.db, &key(id, "client_id"), client_id.trim()).await?; |
|
| 96 | + | if let Some(s) = secret { |
|
| 97 | + | accounts::set_setting(&app.db, &key(id, "secret"), &hex::encode(app.secret_key.seal(s.trim()))).await?; |
|
| 98 | + | } |
|
| 99 | + | accounts::set_setting(&app.db, &key(id, "enabled"), if enabled { "1" } else { "0" }).await?; |
|
| 100 | + | Ok(()) |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | /// The providers people may sign in with: usable ones, in order. |
|
| 104 | + | pub async fn enabled(app: &Shared) -> Vec<&'static Provider> { |
|
| 105 | + | let mut out = vec![]; |
|
| 106 | + | for p in &PROVIDERS { |
|
| 107 | + | if settings(app, p.id).await.map(|s| s.usable()).unwrap_or(false) { |
|
| 108 | + | out.push(p); |
|
| 109 | + | } |
|
| 110 | + | } |
|
| 111 | + | out |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | // ------------------------------------------------------------- the flow |
|
| 115 | + | ||
| 116 | + | /// The state cookie's name: a nonce, what the sign-in is for, and a MAC. |
|
| 117 | + | pub const STATE_COOKIE: &str = "oxyhub_signin"; |
|
| 118 | + | ||
| 119 | + | /// A state value: `NONCE.MODE.MAC`, where MODE is `in` (sign in) or |
|
| 120 | + | /// `link:ACCOUNT_ID` (connect to a signed-in account). |
|
| 121 | + | pub fn state_value(app: &Shared, mode: &str) -> String { |
|
| 122 | + | let nonce = crate::auth::random_hex(16); |
|
| 123 | + | let mac = app.cookie_key.mac(format!("signin:{nonce}:{mode}").as_bytes()); |
|
| 124 | + | format!("{nonce}.{mode}.{mac}") |
|
| 125 | + | } |
|
| 126 | + | ||
| 127 | + | /// The mode a state value carries, when its MAC holds. |
|
| 128 | + | pub fn state_mode(app: &Shared, value: &str) -> Option<String> { |
|
| 129 | + | let (nonce, rest) = value.split_once('.')?; |
|
| 130 | + | let (mode, mac) = rest.rsplit_once('.')?; |
|
| 131 | + | let want = app.cookie_key.mac(format!("signin:{nonce}:{mode}").as_bytes()); |
|
| 132 | + | crate::auth::mac_equal(&want, mac).then(|| mode.to_string()) |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | /// Where to send the person to sign in with `p`. |
|
| 136 | + | pub fn authorize_url(app: &Shared, p: &Provider, client_id: &str, state: &str) -> String { |
|
| 137 | + | let q = |s: &str| crate::web::settings::urlencode(s); |
|
| 138 | + | format!( |
|
| 139 | + | "{}?client_id={}&redirect_uri={}&scope={}&state={}&response_type=code{}", |
|
| 140 | + | p.url(app, "authorize"), |
|
| 141 | + | q(client_id), |
|
| 142 | + | q(&p.callback_url(app)), |
|
| 143 | + | q(p.scope), |
|
| 144 | + | q(state), |
|
| 145 | + | if p.id == "google" { "&access_type=online&prompt=select_account" } else { "" } |
|
| 146 | + | ) |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | /// Who the provider says the person is. |
|
| 150 | + | #[derive(Clone, Debug)] |
|
| 151 | + | pub struct External { |
|
| 152 | + | pub provider: &'static str, |
|
| 153 | + | pub subject: String, |
|
| 154 | + | pub login: String, |
|
| 155 | + | pub name: String, |
|
| 156 | + | /// A verified email, when the provider vouches for one. |
|
| 157 | + | pub email: Option<String>, |
|
| 158 | + | } |
|
| 159 | + | ||
| 160 | + | /// Swap the code for a token and ask who it belongs to. |
|
| 161 | + | pub async fn identify(app: &Shared, p: &'static Provider, s: &Settings, code: &str) -> Result<External, String> { |
|
| 162 | + | let client = reqwest::Client::builder() |
|
| 163 | + | .timeout(std::time::Duration::from_secs(20)) |
|
| 164 | + | .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION"))) |
|
| 165 | + | .build() |
|
| 166 | + | .map_err(|e| e.to_string())?; |
|
| 167 | + | let token: serde_json::Value = client |
|
| 168 | + | .post(p.url(app, "token")) |
|
| 169 | + | .header("accept", "application/json") |
|
| 170 | + | .form(&[ |
|
| 171 | + | ("client_id", s.client_id.as_str()), |
|
| 172 | + | ("client_secret", s.secret.as_str()), |
|
| 173 | + | ("code", code), |
|
| 174 | + | ("redirect_uri", &p.callback_url(app)), |
|
| 175 | + | ("grant_type", "authorization_code"), |
|
| 176 | + | ]) |
|
| 177 | + | .send() |
|
| 178 | + | .await |
|
| 179 | + | .map_err(|e| format!("{} did not answer: {e}", p.name))? |
|
| 180 | + | .json() |
|
| 181 | + | .await |
|
| 182 | + | .map_err(|_| format!("{} answered with something other than a token", p.name))?; |
|
| 183 | + | let Some(access) = token["access_token"].as_str() else { |
|
| 184 | + | let why = token["error_description"].as_str().or(token["error"].as_str()).unwrap_or("no token"); |
|
| 185 | + | return Err(format!("{} refused the sign-in: {why}", p.name)); |
|
| 186 | + | }; |
|
| 187 | + | let get = |url: String| { |
|
| 188 | + | let client = client.clone(); |
|
| 189 | + | let access = access.to_string(); |
|
| 190 | + | async move { |
|
| 191 | + | client |
|
| 192 | + | .get(url) |
|
| 193 | + | .bearer_auth(access) |
|
| 194 | + | .header("accept", "application/json") |
|
| 195 | + | .send() |
|
| 196 | + | .await |
|
| 197 | + | .map_err(|e| format!("{} did not answer: {e}", p.name))? |
|
| 198 | + | .json::<serde_json::Value>() |
|
| 199 | + | .await |
|
| 200 | + | .map_err(|_| format!("{} answered with something unexpected", p.name)) |
|
| 201 | + | } |
|
| 202 | + | }; |
|
| 203 | + | let user = get(p.url(app, "userinfo")).await?; |
|
| 204 | + | Ok(match p.id { |
|
| 205 | + | "github" => { |
|
| 206 | + | let subject = |
|
| 207 | + | user["id"].as_i64().map(|i| i.to_string()).or_else(|| user["id"].as_str().map(String::from)).ok_or("GitHub gave no id")?; |
|
| 208 | + | // The primary verified address is on its own endpoint. |
|
| 209 | + | let emails = get(format!("{}/emails", p.url(app, "userinfo"))).await.unwrap_or_default(); |
|
| 210 | + | let email = emails |
|
| 211 | + | .as_array() |
|
| 212 | + | .and_then(|list| { |
|
| 213 | + | list.iter() |
|
| 214 | + | .filter(|e| e["verified"] == true) |
|
| 215 | + | .max_by_key(|e| e["primary"] == true) |
|
| 216 | + | .and_then(|e| e["email"].as_str().map(String::from)) |
|
| 217 | + | }) |
|
| 218 | + | .or_else(|| user["email"].as_str().map(String::from).filter(|_| false)); |
|
| 219 | + | External { |
|
| 220 | + | provider: p.id, |
|
| 221 | + | subject, |
|
| 222 | + | login: user["login"].as_str().unwrap_or("").to_string(), |
|
| 223 | + | name: user["name"].as_str().unwrap_or("").to_string(), |
|
| 224 | + | email, |
|
| 225 | + | } |
|
| 226 | + | } |
|
| 227 | + | _ => { |
|
| 228 | + | let subject = user["sub"].as_str().ok_or("Google gave no subject")?.to_string(); |
|
| 229 | + | let email = user["email"].as_str().filter(|_| user["email_verified"] == true).map(String::from); |
|
| 230 | + | let login = email.as_deref().and_then(|e| e.split('@').next()).unwrap_or("").to_string(); |
|
| 231 | + | External { provider: p.id, subject, login, name: user["name"].as_str().unwrap_or("").to_string(), email } |
|
| 232 | + | } |
|
| 233 | + | }) |
|
| 234 | + | } |
|
| 235 | + | ||
| 236 | + | // --------------------------------------------------------------- links |
|
| 237 | + | ||
| 238 | + | #[derive(Clone, Debug, FromRow)] |
|
| 239 | + | pub struct Login { |
|
| 240 | + | pub provider: String, |
|
| 241 | + | pub subject: String, |
|
| 242 | + | pub account_id: i64, |
|
| 243 | + | pub login: String, |
|
| 244 | + | pub linked_ms: i64, |
|
| 245 | + | } |
|
| 246 | + | ||
| 247 | + | pub async fn linked_account(pool: &Pool, provider: &str, subject: &str) -> sqlx::Result<Option<accounts::Account>> { |
|
| 248 | + | let id: Option<i64> = sqlx::query_scalar("SELECT account_id FROM account_logins WHERE provider = ? AND subject = ?") |
|
| 249 | + | .bind(provider) |
|
| 250 | + | .bind(subject) |
|
| 251 | + | .fetch_optional(pool) |
|
| 252 | + | .await?; |
|
| 253 | + | match id { |
|
| 254 | + | Some(id) => accounts::account_by_id(pool, id).await, |
|
| 255 | + | None => Ok(None), |
|
| 256 | + | } |
|
| 257 | + | } |
|
| 258 | + | ||
| 259 | + | pub async fn link(pool: &Pool, provider: &str, subject: &str, account_id: i64, login: &str) -> sqlx::Result<()> { |
|
| 260 | + | sqlx::query("INSERT OR REPLACE INTO account_logins (provider, subject, account_id, login, linked_ms) VALUES (?, ?, ?, ?, ?)") |
|
| 261 | + | .bind(provider) |
|
| 262 | + | .bind(subject) |
|
| 263 | + | .bind(account_id) |
|
| 264 | + | .bind(login) |
|
| 265 | + | .bind(now_ms()) |
|
| 266 | + | .execute(pool) |
|
| 267 | + | .await?; |
|
| 268 | + | Ok(()) |
|
| 269 | + | } |
|
| 270 | + | ||
| 271 | + | pub async fn unlink(pool: &Pool, provider: &str, account_id: i64) -> sqlx::Result<bool> { |
|
| 272 | + | let r = sqlx::query("DELETE FROM account_logins WHERE provider = ? AND account_id = ?") |
|
| 273 | + | .bind(provider) |
|
| 274 | + | .bind(account_id) |
|
| 275 | + | .execute(pool) |
|
| 276 | + | .await?; |
|
| 277 | + | Ok(r.rows_affected() > 0) |
|
| 278 | + | } |
|
| 279 | + | ||
| 280 | + | pub async fn links_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<Login>> { |
|
| 281 | + | sqlx::query_as("SELECT * FROM account_logins WHERE account_id = ? ORDER BY provider").bind(account_id).fetch_all(pool).await |
|
| 282 | + | } |
|
| 283 | + | ||
| 284 | + | /// A username for a new account from what the provider calls them: |
|
| 285 | + | /// valid, and free. |
|
| 286 | + | pub async fn free_username(pool: &Pool, wanted: &str, provider: &str) -> sqlx::Result<String> { |
|
| 287 | + | let base: String = wanted.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')).take(40).collect(); |
|
| 288 | + | let base = base.trim_matches(|c: char| !c.is_ascii_alphanumeric()).to_string(); |
|
| 289 | + | let base = if base.is_empty() { provider.to_string() } else { base }; |
|
| 290 | + | let mut candidates = vec![base.clone(), format!("{base}-{provider}")]; |
|
| 291 | + | candidates.extend((2..100).map(|n| format!("{base}-{n}"))); |
|
| 292 | + | for c in candidates { |
|
| 293 | + | if !crate::repos::names::valid_owner(&c) { |
|
| 294 | + | continue; |
|
| 295 | + | } |
|
| 296 | + | let taken = accounts::account_by_name(pool, &c).await?.is_some() |
|
| 297 | + | || accounts::org_by_name(pool, &c).await?.is_some() |
|
| 298 | + | || accounts::username_retired(pool, &c).await?; |
|
| 299 | + | if !taken { |
|
| 300 | + | return Ok(c); |
|
| 301 | + | } |
|
| 302 | + | } |
|
| 303 | + | Ok(format!("{provider}-{}", crate::auth::random_hex(4))) |
|
| 304 | + | } |
|
| 305 | + | ||
| 306 | + | #[cfg(test)] |
|
| 307 | + | mod tests { |
|
| 308 | + | use super::*; |
|
| 309 | + | ||
| 310 | + | #[test] |
|
| 311 | + | fn providers_are_known() { |
|
| 312 | + | assert_eq!(provider("github").map(|p| p.name), Some("GitHub")); |
|
| 313 | + | assert_eq!(provider("google").map(|p| p.name), Some("Google")); |
|
| 314 | + | assert!(provider("gitlab").is_none()); |
|
| 315 | + | } |
|
| 316 | + | } |
added crates/hub/src/web/signin.rs +283 −0
| 1 | + | //! Sign in with GitHub or Google on the web (hub spec §5.1.2, #106): |
|
| 2 | + | //! the start of the flow, the callback, the administrator's provider |
|
| 3 | + | //! settings, and connecting or disconnecting a provider from Settings › |
|
| 4 | + | //! Security. |
|
| 5 | + | ||
| 6 | + | use super::session::signed_in; |
|
| 7 | + | use super::settings::{csrf, require}; |
|
| 8 | + | use super::{Error, Shell}; |
|
| 9 | + | use crate::auth::{self, Viewer}; |
|
| 10 | + | use crate::db::accounts; |
|
| 11 | + | use crate::signin::{self, External, Provider, STATE_COOKIE}; |
|
| 12 | + | use crate::Shared; |
|
| 13 | + | use axum::extract::{Path, Query, State}; |
|
| 14 | + | use axum::http::{header, HeaderMap}; |
|
| 15 | + | use axum::response::{IntoResponse, Redirect, Response}; |
|
| 16 | + | use axum::Form; |
|
| 17 | + | use serde::Deserialize; |
|
| 18 | + | ||
| 19 | + | fn state_cookie(app: &Shared, value: &str, max_age: u32) -> String { |
|
| 20 | + | let secure = if app.config.base_url().starts_with("https://") { "; Secure" } else { "" }; |
|
| 21 | + | format!("{STATE_COOKIE}={value}; Path=/login; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}") |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | fn cookie_value(headers: &HeaderMap) -> Option<String> { |
|
| 25 | + | headers |
|
| 26 | + | .get_all(header::COOKIE) |
|
| 27 | + | .iter() |
|
| 28 | + | .filter_map(|v| v.to_str().ok()) |
|
| 29 | + | .flat_map(|v| v.split(';')) |
|
| 30 | + | .filter_map(|c| c.trim().strip_prefix(&format!("{STATE_COOKIE}="))) |
|
| 31 | + | .map(String::from) |
|
| 32 | + | .next() |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | /// Begin: a state cookie, then off to the provider. |
|
| 36 | + | async fn begin(app: &Shared, p: &'static Provider, mode: &str) -> Result<Response, String> { |
|
| 37 | + | let s = signin::settings(app, p.id).await.map_err(|e| e.to_string())?; |
|
| 38 | + | if !s.usable() { |
|
| 39 | + | return Err(format!("Signing in with {} is not set up on this hub.", p.name)); |
|
| 40 | + | } |
|
| 41 | + | let state = signin::state_value(app, mode); |
|
| 42 | + | let to = signin::authorize_url(app, p, &s.client_id, &state); |
|
| 43 | + | Ok(([(header::SET_COOKIE, state_cookie(app, &state, 600))], Redirect::to(&to)).into_response()) |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | /// `GET /login/{provider}`: sign in with the provider. |
|
| 47 | + | pub async fn start(State(app): State<Shared>, viewer: Viewer, Path(id): Path<String>) -> Result<Response, Error> { |
|
| 48 | + | let shell = Shell::new(&app, "/login", &viewer); |
|
| 49 | + | let p = signin::provider(&id).ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 50 | + | if viewer.signed_in() { |
|
| 51 | + | return Ok(Redirect::to("/").into_response()); |
|
| 52 | + | } |
|
| 53 | + | begin(&app, p, "in").await.map_err(|m| Error::bad(shell.clone(), m)) |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | /// `GET /settings/security/connect/{provider}`: link the provider to the |
|
| 57 | + | /// signed-in account. |
|
| 58 | + | pub async fn connect(State(app): State<Shared>, viewer: Viewer, Path(id): Path<String>) -> Result<Response, Error> { |
|
| 59 | + | let shell = Shell::new(&app, "/settings/security", &viewer); |
|
| 60 | + | let me = require(&viewer, &shell)?; |
|
| 61 | + | let p = signin::provider(&id).ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 62 | + | begin(&app, p, &format!("link:{}", me.account.id)).await.map_err(|m| Error::bad(shell.clone(), m)) |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | #[derive(Deserialize)] |
|
| 66 | + | pub struct Callback { |
|
| 67 | + | #[serde(default)] |
|
| 68 | + | code: String, |
|
| 69 | + | #[serde(default)] |
|
| 70 | + | state: String, |
|
| 71 | + | #[serde(default)] |
|
| 72 | + | error: String, |
|
| 73 | + | #[serde(default)] |
|
| 74 | + | error_description: String, |
|
| 75 | + | } |
|
| 76 | + | ||
| 77 | + | fn to_login(msg: &str) -> Response { |
|
| 78 | + | Redirect::to(&format!("/login?error={}", super::settings::urlencode(msg))).into_response() |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | fn to_security(msg: &str, ok: bool) -> Response { |
|
| 82 | + | let k = if ok { "notice" } else { "error" }; |
|
| 83 | + | Redirect::to(&format!("/settings/security?{k}={}", super::settings::urlencode(msg))).into_response() |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | /// `GET /login/{provider}/callback`: the provider sent the person back. |
|
| 87 | + | pub async fn callback( |
|
| 88 | + | State(app): State<Shared>, |
|
| 89 | + | viewer: Viewer, |
|
| 90 | + | Path(id): Path<String>, |
|
| 91 | + | headers: HeaderMap, |
|
| 92 | + | Query(q): Query<Callback>, |
|
| 93 | + | ) -> Result<Response, Error> { |
|
| 94 | + | let shell = Shell::new(&app, "/login", &viewer); |
|
| 95 | + | let p = signin::provider(&id).ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 96 | + | // The state cookie goes, whatever else the answer sets (appended, so a |
|
| 97 | + | // session cookie beside it is kept). |
|
| 98 | + | let clear = state_cookie(&app, "", 0); |
|
| 99 | + | let with_clear = |mut r: Response| { |
|
| 100 | + | if let Ok(v) = header::HeaderValue::from_str(&clear) { |
|
| 101 | + | r.headers_mut().append(header::SET_COOKIE, v); |
|
| 102 | + | } |
|
| 103 | + | r |
|
| 104 | + | }; |
|
| 105 | + | if !q.error.is_empty() { |
|
| 106 | + | let why = if q.error_description.is_empty() { q.error.clone() } else { q.error_description.clone() }; |
|
| 107 | + | return Ok(with_clear(to_login(&format!("{} did not sign you in: {why}", p.name)))); |
|
| 108 | + | } |
|
| 109 | + | let Some(cookie) = cookie_value(&headers) else { |
|
| 110 | + | return Ok(with_clear(to_login("The sign-in took too long or the browser kept no cookie; try again."))); |
|
| 111 | + | }; |
|
| 112 | + | if cookie != q.state || q.code.is_empty() { |
|
| 113 | + | return Ok(with_clear(to_login("The sign-in did not match the one started here; try again."))); |
|
| 114 | + | } |
|
| 115 | + | let Some(mode) = signin::state_mode(&app, &cookie) else { |
|
| 116 | + | return Ok(with_clear(to_login("The sign-in did not match the one started here; try again."))); |
|
| 117 | + | }; |
|
| 118 | + | let s = signin::settings(&app, p.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 119 | + | if !s.usable() { |
|
| 120 | + | return Ok(with_clear(to_login(&format!("Signing in with {} is not set up on this hub.", p.name)))); |
|
| 121 | + | } |
|
| 122 | + | let who = match signin::identify(&app, p, &s, &q.code).await { |
|
| 123 | + | Ok(w) => w, |
|
| 124 | + | Err(msg) => return Ok(with_clear(to_login(&msg))), |
|
| 125 | + | }; |
|
| 126 | + | let e = |e| Error::from_db(shell.clone(), e); |
|
| 127 | + | // Connecting to a signed-in account. |
|
| 128 | + | if let Some(account_id) = mode.strip_prefix("link:").and_then(|i| i.parse::<i64>().ok()) { |
|
| 129 | + | let Some(me) = viewer.account().filter(|a| a.id == account_id) else { |
|
| 130 | + | return Ok(with_clear(to_security("Sign in first, then connect the account.", false))); |
|
| 131 | + | }; |
|
| 132 | + | if let Some(other) = signin::linked_account(&app.db, p.id, &who.subject).await.map_err(e)? { |
|
| 133 | + | if other.id != me.id { |
|
| 134 | + | return Ok(with_clear(to_security(&format!("That {} account is connected to {} already.", p.name, other.username), false))); |
|
| 135 | + | } |
|
| 136 | + | } |
|
| 137 | + | signin::link(&app.db, p.id, &who.subject, me.id, &who.login).await.map_err(e)?; |
|
| 138 | + | accounts::audit(&app.db, Some(me), "signin.connected", &me.username, p.id).await.ok(); |
|
| 139 | + | return Ok(with_clear(to_security(&format!("{} connected as {}.", p.name, who.login), true))); |
|
| 140 | + | } |
|
| 141 | + | // Signing in: a known link, else the account with that verified email, |
|
| 142 | + | // else a new account when registration is open. |
|
| 143 | + | if let Some(account) = signin::linked_account(&app.db, p.id, &who.subject).await.map_err(e)? { |
|
| 144 | + | if account.blocked() { |
|
| 145 | + | return Ok(with_clear(to_login("This account cannot sign in."))); |
|
| 146 | + | } |
|
| 147 | + | accounts::audit(&app.db, Some(&account), "login", &account.username, p.id).await.ok(); |
|
| 148 | + | return Ok(with_clear(signed_in(&app, &account, "/"))); |
|
| 149 | + | } |
|
| 150 | + | if let Some(email) = &who.email { |
|
| 151 | + | if let Some(account) = accounts::account_by_verified_email(&app.db, email).await.map_err(e)? { |
|
| 152 | + | if account.blocked() { |
|
| 153 | + | return Ok(with_clear(to_login("This account cannot sign in."))); |
|
| 154 | + | } |
|
| 155 | + | signin::link(&app.db, p.id, &who.subject, account.id, &who.login).await.map_err(e)?; |
|
| 156 | + | accounts::audit(&app.db, Some(&account), "signin.connected", &account.username, &format!("{} by email", p.id)).await.ok(); |
|
| 157 | + | accounts::audit(&app.db, Some(&account), "login", &account.username, p.id).await.ok(); |
|
| 158 | + | return Ok(with_clear(signed_in(&app, &account, "/"))); |
|
| 159 | + | } |
|
| 160 | + | } |
|
| 161 | + | let mode = super::session::registration_mode(&app).await; |
|
| 162 | + | if mode != "open" { |
|
| 163 | + | return Ok(with_clear(to_login(&format!( |
|
| 164 | + | "No account here is connected to that {} account, and registration is {}. Register or sign in first, then connect it under Settings › Security.", |
|
| 165 | + | p.name, |
|
| 166 | + | if mode == "invite" { "by invitation" } else { "closed" } |
|
| 167 | + | )))); |
|
| 168 | + | } |
|
| 169 | + | let account = register(&app, p, &who).await.map_err(e)?; |
|
| 170 | + | Ok(with_clear(signed_in(&app, &account, "/"))) |
|
| 171 | + | } |
|
| 172 | + | ||
| 173 | + | /// A new account for someone the provider vouches for: a username from |
|
| 174 | + | /// their login there, an unusable password, the email verified when the |
|
| 175 | + | /// provider says so. |
|
| 176 | + | async fn register(app: &Shared, p: &Provider, who: &External) -> sqlx::Result<accounts::Account> { |
|
| 177 | + | let username = signin::free_username(&app.db, &who.login, p.id).await?; |
|
| 178 | + | let hash = auth::hash_password(&auth::random_hex(24)).unwrap_or_default(); |
|
| 179 | + | let first = accounts::account_count(&app.db).await? == 0; |
|
| 180 | + | let account = |
|
| 181 | + | accounts::create_account(&app.db, &username, who.email.as_deref().unwrap_or(""), &hash, &auth::random_hex(16), first).await?; |
|
| 182 | + | if who.email.is_some() { |
|
| 183 | + | accounts::set_email_verified(&app.db, account.id, true).await?; |
|
| 184 | + | } |
|
| 185 | + | if !who.name.is_empty() { |
|
| 186 | + | accounts::set_profile(&app.db, account.id, &who.name, who.email.as_deref().unwrap_or("")).await?; |
|
| 187 | + | if who.email.is_some() { |
|
| 188 | + | accounts::set_email_verified(&app.db, account.id, true).await?; |
|
| 189 | + | } |
|
| 190 | + | } |
|
| 191 | + | signin::link(&app.db, p.id, &who.subject, account.id, &who.login).await?; |
|
| 192 | + | accounts::audit(&app.db, Some(&account), "register", &username, &format!("with {}", p.id)).await.ok(); |
|
| 193 | + | accounts::account_by_id(&app.db, account.id).await.map(|a| a.unwrap_or(account)) |
|
| 194 | + | } |
|
| 195 | + | ||
| 196 | + | #[derive(Deserialize)] |
|
| 197 | + | pub struct DisconnectForm { |
|
| 198 | + | #[serde(rename = "_csrf")] |
|
| 199 | + | csrf_token: String, |
|
| 200 | + | } |
|
| 201 | + | ||
| 202 | + | /// `POST /settings/security/disconnect/{provider}`. |
|
| 203 | + | pub async fn disconnect( |
|
| 204 | + | State(app): State<Shared>, |
|
| 205 | + | viewer: Viewer, |
|
| 206 | + | Path(id): Path<String>, |
|
| 207 | + | Form(f): Form<DisconnectForm>, |
|
| 208 | + | ) -> Result<Response, Error> { |
|
| 209 | + | let shell = Shell::new(&app, "/settings/security", &viewer); |
|
| 210 | + | let me = require(&viewer, &shell)?; |
|
| 211 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 212 | + | let p = signin::provider(&id).ok_or_else(|| Error::not_found(shell.clone()))?; |
|
| 213 | + | if signin::unlink(&app.db, p.id, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 214 | + | accounts::audit(&app.db, Some(&me.account), "signin.disconnected", &me.account.username, p.id).await.ok(); |
|
| 215 | + | } |
|
| 216 | + | Ok(to_security(&format!("{} disconnected.", p.name), true)) |
|
| 217 | + | } |
|
| 218 | + | ||
| 219 | + | /// A provider as the admin page shows it. |
|
| 220 | + | pub struct ProviderView { |
|
| 221 | + | pub id: &'static str, |
|
| 222 | + | pub name: &'static str, |
|
| 223 | + | pub client_id: String, |
|
| 224 | + | pub has_secret: bool, |
|
| 225 | + | pub enabled: bool, |
|
| 226 | + | pub callback: String, |
|
| 227 | + | } |
|
| 228 | + | ||
| 229 | + | pub async fn provider_views(app: &Shared) -> sqlx::Result<Vec<ProviderView>> { |
|
| 230 | + | let mut out = vec![]; |
|
| 231 | + | for p in &signin::PROVIDERS { |
|
| 232 | + | let s = signin::settings(app, p.id).await?; |
|
| 233 | + | out.push(ProviderView { |
|
| 234 | + | id: p.id, |
|
| 235 | + | name: p.name, |
|
| 236 | + | client_id: s.client_id, |
|
| 237 | + | has_secret: !s.secret.is_empty(), |
|
| 238 | + | enabled: s.enabled, |
|
| 239 | + | callback: p.callback_url(app), |
|
| 240 | + | }); |
|
| 241 | + | } |
|
| 242 | + | Ok(out) |
|
| 243 | + | } |
|
| 244 | + | ||
| 245 | + | #[derive(Deserialize)] |
|
| 246 | + | pub struct ProviderForm { |
|
| 247 | + | #[serde(rename = "_csrf")] |
|
| 248 | + | pub csrf_token: String, |
|
| 249 | + | pub provider: String, |
|
| 250 | + | #[serde(default)] |
|
| 251 | + | pub client_id: String, |
|
| 252 | + | #[serde(default)] |
|
| 253 | + | pub secret: String, |
|
| 254 | + | #[serde(default)] |
|
| 255 | + | pub enabled: Option<String>, |
|
| 256 | + | } |
|
| 257 | + | ||
| 258 | + | /// Apply the admin form: `Ok(notice)` or `Err(message)`. |
|
| 259 | + | pub async fn apply(app: &Shared, viewer: &Viewer, f: ProviderForm) -> sqlx::Result<Result<String, String>> { |
|
| 260 | + | let Some(p) = signin::provider(&f.provider) else { return Ok(Err("Unknown provider.".into())) }; |
|
| 261 | + | let client_id = f.client_id.trim(); |
|
| 262 | + | if client_id.len() > 200 || client_id.contains(char::is_whitespace) && !client_id.is_empty() { |
|
| 263 | + | return Ok(Err("A client id is one token.".into())); |
|
| 264 | + | } |
|
| 265 | + | let secret = f.secret.trim(); |
|
| 266 | + | let secret = if secret.is_empty() { None } else { Some(secret) }; |
|
| 267 | + | let enabled = f.enabled.is_some(); |
|
| 268 | + | signin::save(app, p.id, client_id, secret, enabled).await?; |
|
| 269 | + | let s = signin::settings(app, p.id).await?; |
|
| 270 | + | accounts::audit(&app.db, viewer.account(), "admin.signin", p.id, if s.usable() { "on" } else { "off" }).await.ok(); |
|
| 271 | + | Ok(Ok(if s.usable() { |
|
| 272 | + | format!("Signing in with {} is on.", p.name) |
|
| 273 | + | } else if enabled { |
|
| 274 | + | format!("{} is saved but off until it has a client id and secret.", p.name) |
|
| 275 | + | } else { |
|
| 276 | + | format!("{} is off.", p.name) |
|
| 277 | + | })) |
|
| 278 | + | } |
|
| 279 | + | ||
| 280 | + | /// A page's view of what people may sign in with. |
|
| 281 | + | pub async fn buttons(app: &Shared) -> Vec<(&'static str, &'static str)> { |
|
| 282 | + | signin::enabled(app).await.into_iter().map(|p| (p.id, p.name)).collect() |
|
| 283 | + | } |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Sign in with GitHub or Google (#106).** Set up per provider under Administration (client id, sealed secret, on or off); the login page offers them. A first sign-in links the account with the same verified email, or makes an account when registration is open; connect or disconnect under Settings › Security. |
|
| 8 | + | ||
| 7 | 9 | - **Wikis (#104).** Each repository can have a wiki: Markdown pages kept as an Oxygen repository of their own, read and edited on the web (with history, a sidebar, a page list and search) or cloned and pushed as `OWNER/NAME.wiki`. Web edits are signed changes, as code edits are. Settings › Advanced deletes the wiki data. |
|
| 8 | 10 | ||
| 9 | 11 | - **Advanced settings: transfer, archive, delete (#108).** A repository's Delete section becomes Advanced: transfer the repository to a user or organisation (the old address redirects and `oxy` remotes keep working), archive it read-only with a reason and bring it back, delete it, and a placeholder for deleting wiki data ahead of wikis. |
|
| … | 107 unchanged lines |
modified crates/hub/src/config.rs +3 −0
| … | 137 unchanged lines |
||
| 138 | 138 | /// Bearer tokens allowed to push until accounts exist (hub 0.2). Empty |
|
| 139 | 139 | /// means every push is accepted, as `oxy serve` does. |
|
| 140 | 140 | pub push_tokens: Vec<String>, |
|
| 141 | + | /// For tests only: a base URL that stands in for every sign-in |
|
| 142 | + | /// provider (`{base}/{provider}/authorize|token|userinfo`). |
|
| 143 | + | pub signin_mock: Option<String>, |
|
| 141 | 144 | } |
|
| 142 | 145 | ||
| 143 | 146 | #[derive(Clone, Debug, Deserialize)] |
|
| … | 227 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 23 unchanged lines |
||
| 24 | 24 | pub mod render; |
|
| 25 | 25 | pub mod repos; |
|
| 26 | 26 | pub mod secrets; |
|
| 27 | + | pub mod signin; |
|
| 27 | 28 | pub mod social; |
|
| 28 | 29 | pub mod variables; |
|
| 29 | 30 | pub mod web; |
|
| … | 131 unchanged lines |
modified crates/hub/src/web/admin.rs +17 −0
| … | 33 unchanged lines |
||
| 34 | 34 | variables: super::variables::Panel, |
|
| 35 | 35 | /// The site's Flow secrets (#107). |
|
| 36 | 36 | secrets: super::secrets::Panel, |
|
| 37 | + | /// Sign-in providers (#106). |
|
| 38 | + | providers: Vec<super::signin::ProviderView>, |
|
| 37 | 39 | } |
|
| 38 | 40 | ||
| 39 | 41 | fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> { |
|
| … | 19 unchanged lines |
||
| 59 | 61 | let assistants = super::assistants::panel(app, &site_assistants()).await.map_err(e)?; |
|
| 60 | 62 | let variables = super::variables::panel(app, &site_assistants()).await.map_err(e)?; |
|
| 61 | 63 | let secrets = super::secrets::panel(app, &site_assistants()).await.map_err(e)?; |
|
| 64 | + | let providers = super::signin::provider_views(app).await.map_err(e)?; |
|
| 62 | 65 | Ok(Page(AdminPage { |
|
| 66 | + | providers, |
|
| 63 | 67 | assistants, |
|
| 64 | 68 | variables, |
|
| 65 | 69 | secrets, |
|
| … | 350 unchanged lines |
||
| 416 | 420 | let outcome = super::secrets::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 417 | 421 | admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 418 | 422 | } |
|
| 423 | + | ||
| 424 | + | /// `POST /admin/signin`: a sign-in provider's client id, secret and switch (#106). |
|
| 425 | + | pub async fn signin( |
|
| 426 | + | State(app): State<Shared>, |
|
| 427 | + | viewer: Viewer, |
|
| 428 | + | Form(f): Form<super::signin::ProviderForm>, |
|
| 429 | + | ) -> Result<Page<AdminPage>, Error> { |
|
| 430 | + | let shell = Shell::new(&app, "/admin", &viewer); |
|
| 431 | + | require_admin(&viewer, &shell)?; |
|
| 432 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 433 | + | let outcome = super::signin::apply(&app, &viewer, f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 434 | + | admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 435 | + | } |
modified crates/hub/src/web/mod.rs +6 −0
| … | 30 unchanged lines |
||
| 31 | 31 | mod secrets; |
|
| 32 | 32 | mod session; |
|
| 33 | 33 | pub(crate) mod settings; |
|
| 34 | + | mod signin; |
|
| 34 | 35 | mod statics; |
|
| 35 | 36 | mod variables; |
|
| 36 | 37 | mod webhooks; |
|
| … | 79 unchanged lines |
||
| 116 | 117 | .route("/admin/mail", post(admin::set_mail)) |
|
| 117 | 118 | .route("/admin/mail/test", post(admin::test_mail)) |
|
| 118 | 119 | .route("/admin/assistants", post(admin::assistants)) |
|
| 120 | + | .route("/admin/signin", post(admin::signin)) |
|
| 121 | + | .route("/login/{provider}", get(signin::start)) |
|
| 122 | + | .route("/login/{provider}/callback", get(signin::callback)) |
|
| 123 | + | .route("/settings/security/connect/{provider}", get(signin::connect)) |
|
| 124 | + | .route("/settings/security/disconnect/{provider}", post(signin::disconnect)) |
|
| 119 | 125 | .route("/admin/variables", post(admin::variables)) |
|
| 120 | 126 | .route("/admin/secrets", post(admin::secrets)) |
|
| 121 | 127 | .route("/admin/runners/{id}/remove", post(runners::remove)) |
|
| … | 571 unchanged lines |
modified crates/hub/src/web/session.rs +16 −3
| … | 19 unchanged lines |
||
| 20 | 20 | totp_for: Option<String>, |
|
| 21 | 21 | /// Arrived from a password reset. |
|
| 22 | 22 | reset: bool, |
|
| 23 | + | /// Providers to sign in with (#106): (id, name). |
|
| 24 | + | providers: Vec<(&'static str, &'static str)>, |
|
| 23 | 25 | } |
|
| 24 | 26 | ||
| 25 | 27 | #[derive(Deserialize)] |
|
| 26 | 28 | pub struct NextQuery { |
|
| 27 | 29 | next: Option<String>, |
|
| 28 | 30 | reset: Option<String>, |
|
| 31 | + | #[serde(default)] |
|
| 32 | + | error: Option<String>, |
|
| 29 | 33 | } |
|
| 30 | 34 | ||
| 31 | 35 | fn safe_next(next: Option<String>) -> String { |
|
| … | 8 unchanged lines |
||
| 40 | 44 | return Redirect::to(&safe_next(q.next)).into_response(); |
|
| 41 | 45 | } |
|
| 42 | 46 | let reset = q.reset.is_some(); |
|
| 43 | - | Page(LoginPage { shell: Shell::new(&app, "/login", &viewer), error: None, next: safe_next(q.next), totp_for: None, reset }) |
|
| 44 | - | .into_response() |
|
| 47 | + | let providers = super::signin::buttons(&app).await; |
|
| 48 | + | Page(LoginPage { |
|
| 49 | + | shell: Shell::new(&app, "/login", &viewer), |
|
| 50 | + | error: q.error, |
|
| 51 | + | next: safe_next(q.next), |
|
| 52 | + | totp_for: None, |
|
| 53 | + | reset, |
|
| 54 | + | providers, |
|
| 55 | + | }) |
|
| 56 | + | .into_response() |
|
| 45 | 57 | } |
|
| 46 | 58 | ||
| 47 | 59 | #[derive(Deserialize)] |
|
| … | 10 unchanged lines |
||
| 58 | 70 | let shell = Shell::new(&app, "/login", &viewer); |
|
| 59 | 71 | let next = safe_next(Some(f.next.clone())); |
|
| 60 | 72 | let fail = |msg: &str, totp_for: Option<String>| { |
|
| 61 | - | Page(LoginPage { shell: shell.clone(), error: Some(msg.into()), next: next.clone(), totp_for, reset: false }).into_response() |
|
| 73 | + | Page(LoginPage { shell: shell.clone(), error: Some(msg.into()), next: next.clone(), totp_for, reset: false, providers: vec![] }) |
|
| 74 | + | .into_response() |
|
| 62 | 75 | }; |
|
| 63 | 76 | let username = f.username.trim().to_string(); |
|
| 64 | 77 | let account = accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| … | 143 unchanged lines |
modified crates/hub/src/web/settings.rs +35 −4
| … | 5 unchanged lines |
||
| 6 | 6 | use crate::db::{self, accounts}; |
|
| 7 | 7 | use crate::Shared; |
|
| 8 | 8 | use askama::Template; |
|
| 9 | - | use axum::extract::{Form, Multipart, Path, State}; |
|
| 9 | + | use axum::extract::{Form, Multipart, Path, Query, State}; |
|
| 10 | 10 | use axum::http::{header, HeaderMap, StatusCode}; |
|
| 11 | 11 | use axum::response::{IntoResponse, Response}; |
|
| 12 | 12 | use oxy_core::identity::{Identity, Signature}; |
|
| … | 257 unchanged lines |
||
| 270 | 270 | /// Enrolment in progress: secret, URI and QR. |
|
| 271 | 271 | enrol: Option<(String, String, String)>, |
|
| 272 | 272 | recovery: Vec<String>, |
|
| 273 | + | /// Providers on this hub and the login connected, if any (#106). |
|
| 274 | + | connected: Vec<(&'static str, &'static str, Option<String>)>, |
|
| 273 | 275 | } |
|
| 274 | 276 | ||
| 275 | - | pub async fn security(State(app): State<Shared>, viewer: Viewer) -> Result<Page<SecurityPage>, Error> { |
|
| 277 | + | #[derive(Deserialize)] |
|
| 278 | + | pub struct SecurityQuery { |
|
| 279 | + | notice: Option<String>, |
|
| 280 | + | error: Option<String>, |
|
| 281 | + | } |
|
| 282 | + | ||
| 283 | + | pub async fn security(State(app): State<Shared>, viewer: Viewer, Query(q): Query<SecurityQuery>) -> Result<Page<SecurityPage>, Error> { |
|
| 276 | 284 | let shell = Shell::new(&app, "/settings/security", &viewer); |
|
| 277 | 285 | let me = require(&viewer, &shell)?; |
|
| 278 | - | Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: None, recovery: vec![] })) |
|
| 286 | + | let connected = connected_of(&app, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 287 | + | Ok(Page(SecurityPage { shell, me: me.account, notice: q.notice, error: q.error, enrol: None, recovery: vec![], connected })) |
|
| 279 | 288 | } |
|
| 280 | 289 | ||
| 290 | + | /// Each sign-in provider this hub offers, with the login connected to the account. |
|
| 291 | + | pub async fn connected_of(app: &Shared, account_id: i64) -> sqlx::Result<Vec<(&'static str, &'static str, Option<String>)>> { |
|
| 292 | + | let links = crate::signin::links_of(&app.db, account_id).await?; |
|
| 293 | + | let mut out = vec![]; |
|
| 294 | + | for p in crate::signin::enabled(app).await { |
|
| 295 | + | out.push((p.id, p.name, links.iter().find(|l| l.provider == p.id).map(|l| l.login.clone()))); |
|
| 296 | + | } |
|
| 297 | + | Ok(out) |
|
| 298 | + | } |
|
| 299 | + | ||
| 281 | 300 | #[derive(Deserialize)] |
|
| 282 | 301 | pub struct TotpForm { |
|
| 283 | 302 | #[serde(rename = "_csrf")] |
|
| … | 14 unchanged lines |
||
| 298 | 317 | let secret = totp::new_secret(); |
|
| 299 | 318 | let uri = totp::uri(&app.config.site.name, &me.account.username, &secret); |
|
| 300 | 319 | let qr = totp::qr_svg(&uri).unwrap_or_default(); |
|
| 301 | - | Ok(Page(SecurityPage { shell, me: me.account, notice: None, error: None, enrol: Some((secret, uri, qr)), recovery: vec![] })) |
|
| 320 | + | Ok(Page(SecurityPage { |
|
| 321 | + | shell, |
|
| 322 | + | me: me.account, |
|
| 323 | + | notice: None, |
|
| 324 | + | error: None, |
|
| 325 | + | enrol: Some((secret, uri, qr)), |
|
| 326 | + | recovery: vec![], |
|
| 327 | + | connected: vec![], |
|
| 328 | + | })) |
|
| 302 | 329 | } |
|
| 303 | 330 | ||
| 304 | 331 | /// Step two: the first code proves the app holds the secret. |
|
| … | 12 unchanged lines |
||
| 317 | 344 | error: Some("That code did not match; scan again and enter the current code.".into()), |
|
| 318 | 345 | enrol: Some((f.secret, uri, qr)), |
|
| 319 | 346 | recovery: vec![], |
|
| 347 | + | connected: vec![], |
|
| 320 | 348 | })); |
|
| 321 | 349 | } |
|
| 322 | 350 | let (codes, stored) = totp::new_recovery_codes(); |
|
| … | 7 unchanged lines |
||
| 330 | 358 | error: None, |
|
| 331 | 359 | enrol: None, |
|
| 332 | 360 | recovery: codes, |
|
| 361 | + | connected: vec![], |
|
| 333 | 362 | })) |
|
| 334 | 363 | } |
|
| 335 | 364 | ||
| … | 9 unchanged lines |
||
| 345 | 374 | error: Some("Your password is wrong.".into()), |
|
| 346 | 375 | enrol: None, |
|
| 347 | 376 | recovery: vec![], |
|
| 377 | + | connected: vec![], |
|
| 348 | 378 | })); |
|
| 349 | 379 | } |
|
| 350 | 380 | accounts::set_totp(&app.db, me.account.id, None, "").await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| … | 6 unchanged lines |
||
| 357 | 387 | error: None, |
|
| 358 | 388 | enrol: None, |
|
| 359 | 389 | recovery: vec![], |
|
| 390 | + | connected: vec![], |
|
| 360 | 391 | })) |
|
| 361 | 392 | } |
|
| 362 | 393 | ||
| … | 245 unchanged lines |
modified crates/hub/tests/accounts.rs +174 −11
| … | 49 unchanged lines |
||
| 50 | 50 | struct Res { |
|
| 51 | 51 | status: StatusCode, |
|
| 52 | 52 | body: String, |
|
| 53 | + | /// The session cookie's value, when the response set one. |
|
| 53 | 54 | cookie: Option<String>, |
|
| 54 | 55 | location: Option<String>, |
|
| 56 | + | /// Every Set-Cookie header, whole. |
|
| 57 | + | raw_cookies: Vec<String>, |
|
| 55 | 58 | } |
|
| 56 | 59 | ||
| 57 | 60 | async fn send(app: &Shared, method: &str, path: &str, cookie: Option<&str>, form: Option<&str>) -> Res { |
|
| 61 | + | let header_value = cookie.map(|c| format!("oxyhub={c}")); |
|
| 62 | + | send_with(app, method, path, header_value.as_deref(), form).await |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | /// As `send`, with the whole Cookie header given. |
|
| 66 | + | async fn send_with(app: &Shared, method: &str, path: &str, cookie_header: Option<&str>, form: Option<&str>) -> Res { |
|
| 58 | 67 | let mut req = Request::builder().method(method).uri(path); |
|
| 59 | - | if let Some(c) = cookie { |
|
| 60 | - | req = req.header(header::COOKIE, format!("oxyhub={c}")); |
|
| 68 | + | if let Some(c) = cookie_header { |
|
| 69 | + | req = req.header(header::COOKIE, c.to_string()); |
|
| 61 | 70 | } |
|
| 62 | 71 | let body = match form { |
|
| 63 | 72 | Some(f) => { |
|
| … | 4 unchanged lines |
||
| 68 | 77 | }; |
|
| 69 | 78 | let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
|
| 70 | 79 | let status = res.status(); |
|
| 71 | - | let cookie = res |
|
| 72 | - | .headers() |
|
| 73 | - | .get(header::SET_COOKIE) |
|
| 74 | - | .and_then(|v| v.to_str().ok()) |
|
| 75 | - | .and_then(|v| v.split(';').next()) |
|
| 76 | - | .and_then(|kv| kv.split_once('=')) |
|
| 77 | - | .map(|(_, v)| v.to_string()); |
|
| 80 | + | let raw_cookies: Vec<String> = |
|
| 81 | + | res.headers().get_all(header::SET_COOKIE).iter().filter_map(|v| v.to_str().ok()).map(String::from).collect(); |
|
| 82 | + | let cookie = |
|
| 83 | + | raw_cookies.iter().filter_map(|c| c.split(';').next()).filter_map(|kv| kv.strip_prefix("oxyhub=")).map(String::from).next(); |
|
| 78 | 84 | let location = res.headers().get(header::LOCATION).and_then(|v| v.to_str().ok()).map(String::from); |
|
| 79 | 85 | let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 80 | - | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location } |
|
| 86 | + | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie, location, raw_cookies } |
|
| 81 | 87 | } |
|
| 82 | 88 | ||
| 83 | 89 | async fn get(app: &Shared, path: &str, cookie: Option<&str>) -> Res { |
|
| … | 235 unchanged lines |
||
| 319 | 325 | let res = oxygenhub::router(app.clone()).oneshot(req).await.unwrap(); |
|
| 320 | 326 | let status = res.status(); |
|
| 321 | 327 | let bytes = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 322 | - | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie: None, location: None } |
|
| 328 | + | Res { status, body: String::from_utf8_lossy(&bytes).into_owned(), cookie: None, location: None, raw_cookies: vec![] } |
|
| 323 | 329 | } |
|
| 324 | 330 | ||
| 325 | 331 | /// Status, content type and bytes of a picture. |
|
| … | 492 unchanged lines |
||
| 818 | 824 | // Cleared. |
|
| 819 | 825 | post(app, "/settings", Some(&andy), &format!("{base}&bluesky=&mastodon=&github=")).await; |
|
| 820 | 826 | assert!(!get(app, "/andy", None).await.body.contains("class=\"social\"")); |
|
| 827 | + | } |
|
| 828 | + | ||
| 829 | + | /// A stand-in GitHub and Google: token exchanges and who the token is. |
|
| 830 | + | async fn mock_provider() -> (String, std::sync::Arc<std::sync::Mutex<Vec<String>>>) { |
|
| 831 | + | use std::sync::{Arc, Mutex}; |
|
| 832 | + | let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(vec![])); |
|
| 833 | + | let s1 = seen.clone(); |
|
| 834 | + | let s2 = seen.clone(); |
|
| 835 | + | let router = axum::Router::new() |
|
| 836 | + | .route( |
|
| 837 | + | "/github/token", |
|
| 838 | + | axum::routing::post(move |body: String| { |
|
| 839 | + | let s1 = s1.clone(); |
|
| 840 | + | async move { |
|
| 841 | + | s1.lock().unwrap().push(format!("github token {body}")); |
|
| 842 | + | axum::Json(serde_json::json!({ "access_token": "gh-token", "token_type": "bearer" })) |
|
| 843 | + | } |
|
| 844 | + | }), |
|
| 845 | + | ) |
|
| 846 | + | .route( |
|
| 847 | + | "/github/userinfo", |
|
| 848 | + | axum::routing::get(|headers: axum::http::HeaderMap| async move { |
|
| 849 | + | assert_eq!(headers.get("authorization").and_then(|v| v.to_str().ok()), Some("Bearer gh-token")); |
|
| 850 | + | axum::Json(serde_json::json!({ "id": 583231, "login": "octocat", "name": "The Octocat", "email": null })) |
|
| 851 | + | }), |
|
| 852 | + | ) |
|
| 853 | + | .route( |
|
| 854 | + | "/github/userinfo/emails", |
|
| 855 | + | axum::routing::get(|| async { |
|
| 856 | + | axum::Json(serde_json::json!([ |
|
| 857 | + | { "email": "[email protected]", "primary": false, "verified": false }, |
|
| 858 | + | { "email": "[email protected]", "primary": true, "verified": true } |
|
| 859 | + | ])) |
|
| 860 | + | }), |
|
| 861 | + | ) |
|
| 862 | + | .route( |
|
| 863 | + | "/google/token", |
|
| 864 | + | axum::routing::post(move |body: String| { |
|
| 865 | + | let s2 = s2.clone(); |
|
| 866 | + | async move { |
|
| 867 | + | s2.lock().unwrap().push(format!("google token {body}")); |
|
| 868 | + | axum::Json(serde_json::json!({ "access_token": "g-token", "id_token": "x" })) |
|
| 869 | + | } |
|
| 870 | + | }), |
|
| 871 | + | ) |
|
| 872 | + | .route( |
|
| 873 | + | "/google/userinfo", |
|
| 874 | + | axum::routing::get(|| async { |
|
| 875 | + | axum::Json(serde_json::json!({ "sub": "1076915035000615071", "email": "[email protected]", "email_verified": true, "name": "Andy McCall" })) |
|
| 876 | + | }), |
|
| 877 | + | ); |
|
| 878 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 879 | + | let port = listener.local_addr().unwrap().port(); |
|
| 880 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 881 | + | (format!("http://127.0.0.1:{port}"), seen) |
|
| 882 | + | } |
|
| 883 | + | ||
| 884 | + | fn state_of(res: &Res) -> (String, String) { |
|
| 885 | + | let cookie = res.raw_cookies.iter().find(|c| c.starts_with("oxyhub_signin=")).expect("state cookie").clone(); |
|
| 886 | + | let value = cookie.trim_start_matches("oxyhub_signin=").split(';').next().unwrap().to_string(); |
|
| 887 | + | (cookie, value) |
|
| 888 | + | } |
|
| 889 | + | ||
| 890 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 891 | + | async fn people_sign_in_with_github_or_google_once_an_admin_sets_it_up() { |
|
| 892 | + | hermetic(); |
|
| 893 | + | let (mock, seen) = mock_provider().await; |
|
| 894 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 895 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 896 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 897 | + | let port = listener.local_addr().unwrap().port(); |
|
| 898 | + | config.server.base_url = format!("http://127.0.0.1:{port}"); |
|
| 899 | + | config.registration.mode = "open".into(); |
|
| 900 | + | config.auth.signin_mock = Some(mock.clone()); |
|
| 901 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 902 | + | let router = oxygenhub::router(app.clone()); |
|
| 903 | + | tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); |
|
| 904 | + | let app = &app; |
|
| 905 | + | let andy = register(app, "andy", "").await; |
|
| 906 | + | let andy_row = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap(); |
|
| 907 | + | db::accounts::set_email_verified(&app.db, andy_row.id, true).await.unwrap(); |
|
| 908 | + | ||
| 909 | + | // Nothing to sign in with until an administrator sets a provider up. |
|
| 910 | + | assert!(!get(app, "/login", None).await.body.contains("/login/github")); |
|
| 911 | + | let admin = get(app, "/admin", Some(&andy)).await.body; |
|
| 912 | + | assert!(admin.contains("Sign in with") && admin.contains(&format!("http://127.0.0.1:{port}/login/github/callback")), "{admin}"); |
|
| 913 | + | let csrf = csrf_of(&admin); |
|
| 914 | + | let r = |
|
| 915 | + | post(app, "/admin/signin", Some(&andy), &format!("_csrf={csrf}&provider=github&client_id=gh-cid&secret=gh-sek&enabled=1")).await; |
|
| 916 | + | assert!(r.body.contains("Signing in with GitHub is on.") && !r.body.contains("gh-sek"), "{}", r.body); |
|
| 917 | + | let r = post(app, "/admin/signin", Some(&andy), &format!("_csrf={csrf}&provider=google&client_id=g-cid&secret=g-sek&enabled=1")).await; |
|
| 918 | + | assert!(r.body.contains("Signing in with Google is on."), "{}", r.body); |
|
| 919 | + | let login = get(app, "/login", None).await.body; |
|
| 920 | + | assert!(login.contains("href=\"/login/github\"") && login.contains("href=\"/login/google\""), "{login}"); |
|
| 921 | + | ||
| 922 | + | // GitHub: off to the provider with a state, back with a code: a new account. |
|
| 923 | + | let r = get(app, "/login/github", None).await; |
|
| 924 | + | assert_eq!(r.status, StatusCode::SEE_OTHER); |
|
| 925 | + | let to = r.location.clone().unwrap(); |
|
| 926 | + | assert!(to.starts_with(&format!("{mock}/github/authorize?client_id=gh-cid&redirect_uri=")) && to.contains("&state="), "{to}"); |
|
| 927 | + | let (cookie, state) = state_of(&r); |
|
| 928 | + | let bad = send_with(app, "GET", "/login/github/callback?code=c1&state=wrong", Some(&cookie), None).await; |
|
| 929 | + | assert!(bad.location.as_deref().unwrap_or("").starts_with("/login?error="), "{:?}", bad.location); |
|
| 930 | + | let ok = send_with(app, "GET", &format!("/login/github/callback?code=c1&state={state}"), Some(&cookie), None).await; |
|
| 931 | + | assert_eq!(ok.status, StatusCode::SEE_OTHER, "{}", ok.body); |
|
| 932 | + | assert_eq!(ok.location.as_deref(), Some("/")); |
|
| 933 | + | let octo_cookie = ok.cookie.clone().expect("signed in"); |
|
| 934 | + | let octo = db::accounts::account_by_name(&app.db, "octocat").await.unwrap().expect("account made from the GitHub login"); |
|
| 935 | + | assert!( |
|
| 936 | + | (octo.email.as_str(), octo.email_verified, octo.display_name.as_str()) == ("[email protected]", true, "The Octocat"), |
|
| 937 | + | "{octo:?}" |
|
| 938 | + | ); |
|
| 939 | + | assert!(seen.lock().unwrap().iter().any(|s| s.contains("github token") && s.contains("code=c1") && s.contains("client_secret=gh-sek"))); |
|
| 940 | + | assert!(get(app, "/settings", Some(&octo_cookie)).await.body.contains("octocat")); |
|
| 941 | + | // Again: the same account, no new one. |
|
| 942 | + | let r = get(app, "/login/github", None).await; |
|
| 943 | + | let (cookie, state) = state_of(&r); |
|
| 944 | + | let ok = send_with(app, "GET", &format!("/login/github/callback?code=c2&state={state}"), Some(&cookie), None).await; |
|
| 945 | + | assert_eq!(ok.location.as_deref(), Some("/")); |
|
| 946 | + | assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 2); |
|
| 947 | + | ||
| 948 | + | // Google, for an address Andy has verified: linked to Andy, signed in as Andy. |
|
| 949 | + | let r = get(app, "/login/google", None).await; |
|
| 950 | + | assert!(r.location.clone().unwrap().contains("prompt=select_account")); |
|
| 951 | + | let (cookie, state) = state_of(&r); |
|
| 952 | + | let ok = send_with(app, "GET", &format!("/login/google/callback?code=c3&state={state}"), Some(&cookie), None).await; |
|
| 953 | + | assert_eq!(ok.location.as_deref(), Some("/"), "{}", ok.body); |
|
| 954 | + | let as_andy = ok.cookie.clone().unwrap(); |
|
| 955 | + | let sec = get(app, "/settings/security", Some(&as_andy)).await.body; |
|
| 956 | + | assert!( |
|
| 957 | + | sec.contains("Connected accounts") |
|
| 958 | + | && sec.contains("Google: <strong>andy</strong>") |
|
| 959 | + | && sec.contains("GitHub: <a href=\"/settings/security/connect/github\">connect</a>"), |
|
| 960 | + | "{sec}" |
|
| 961 | + | ); |
|
| 962 | + | assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 2, "no third account"); |
|
| 963 | + | // Disconnect, then connect again from Settings › Security. |
|
| 964 | + | let csrf_s = csrf_of(&sec); |
|
| 965 | + | let r = post(app, "/settings/security/disconnect/google", Some(&as_andy), &format!("_csrf={csrf_s}")).await; |
|
| 966 | + | assert!(r.location.as_deref().unwrap_or("").contains("notice=Google%20disconnected"), "{:?}", r.location); |
|
| 967 | + | assert!(!get(app, "/settings/security", Some(&as_andy)).await.body.contains("Google: <strong>andy</strong>")); |
|
| 968 | + | let r = get(app, "/settings/security/connect/google", Some(&as_andy)).await; |
|
| 969 | + | assert_eq!(r.status, StatusCode::SEE_OTHER); |
|
| 970 | + | let (cookie, state) = state_of(&r); |
|
| 971 | + | let both = format!("{cookie}; oxyhub={as_andy}"); |
|
| 972 | + | let ok = send_with(app, "GET", &format!("/login/google/callback?code=c4&state={state}"), Some(&both), None).await; |
|
| 973 | + | assert!(ok.location.as_deref().unwrap_or("").contains("notice=Google%20connected%20as%20andy"), "{:?} {}", ok.location, ok.body); |
|
| 974 | + | ||
| 975 | + | // With registration closed, an unknown GitHub account is turned away. |
|
| 976 | + | db::accounts::set_setting(&app.db, "registration_mode", "closed").await.unwrap(); |
|
| 977 | + | sqlx::query("DELETE FROM account_logins WHERE provider = 'github'").execute(&app.db).await.unwrap(); |
|
| 978 | + | sqlx::query("UPDATE accounts SET email_verified = 0 WHERE username = 'octocat'").execute(&app.db).await.unwrap(); |
|
| 979 | + | let r = get(app, "/login/github", None).await; |
|
| 980 | + | let (cookie, state) = state_of(&r); |
|
| 981 | + | let no = send_with(app, "GET", &format!("/login/github/callback?code=c5&state={state}"), Some(&cookie), None).await; |
|
| 982 | + | assert!(no.location.as_deref().unwrap_or("").contains("registration%20is%20closed"), "{:?}", no.location); |
|
| 983 | + | assert!(no.cookie.is_none()); |
|
| 821 | 984 | } |
modified docs/spec.md +4 −0
| … | 161 unchanged lines |
||
| 162 | 162 | ||
| 163 | 163 | Sessions are a signed cookie `oxyhub=<username>.<mac>` where `mac = HMAC-SHA256(cookie_key, "session:" || username || ":" || session_salt)`, `HttpOnly`, `Secure` when served over HTTPS, `SameSite=Lax`. Password changes and "sign out everywhere" rotate the salt. Passkeys (WebAuthn) are planned for 1.0 as a second factor or password replacement. |
|
| 164 | 164 | ||
| 165 | + | #### 5.1.2 Sign in with GitHub or Google (done 2026-10-04) |
|
| 166 | + | ||
| 167 | + | An administrator sets up each provider under Administration › Sign in with: the OAuth client id, the client secret (sealed under `keys/secrets.key`, never shown again) and a switch; the page shows the callback URL to register with the provider (`/login/{provider}/callback`). The login page then offers the usable providers (oxygenhub#106). The flow is OAuth 2.0's authorization code: `/login/{provider}` sets a state cookie (a nonce and the purpose, MACed with the cookie key, ten minutes, path `/login`) and sends the person to the provider; the callback checks the state against the cookie, swaps the code for a token server to server, and asks who it is (GitHub: `/user` and the primary verified address from `/user/emails`; Google: the OpenID `userinfo`, the address only when `email_verified`). Then: a subject already linked signs in; else an account with that verified email is linked and signed in; else, when registration is open, an account is made from the provider's login name (made valid and free), with an unusable password and the email verified when the provider vouched for it; otherwise the person is told to register or sign in and connect. A signed-in person connects or disconnects a provider under Settings › Security; a subject can be linked to one account. Links live in `account_logins`. Audited as `admin.signin`, `signin.connected`, `signin.disconnected`, `login` and `register` with the provider. For tests, `[auth] signin_mock` names a base URL that stands in for every provider. |
|
| 168 | + | ||
| 165 | 169 | #### 5.1.1 Email verification and password reset |
|
| 166 | 170 | ||
| 167 | 171 | Both use **account links**: 32 random bytes as hex in a URL, stored only as their BLAKE3 hash, bound to one account and one purpose, expiring, and consumed on first use. |
|
| … | 545 unchanged lines |
modified templates/admin.html +13 −0
| … | 44 unchanged lines |
||
| 45 | 45 | <ul class="plain small">{% for (code, used, at) in invitations %}<li><code>{{ code }}</code> {% if used.is_some() %}<span class="mute">used</span>{% else %}<a href="/register?invite={{ code }}">link</a>{% endif %} · {{ at|ago_i }}</li>{% endfor %}</ul> |
|
| 46 | 46 | <h3>Organisations</h3> |
|
| 47 | 47 | <ul class="plain">{% for o in orgs %}<li><a href="/orgs/{{ o.name }}/settings">{{ o.name }}</a></li>{% endfor %}</ul> |
|
| 48 | + | <h3>Sign in with</h3> |
|
| 49 | + | {% for p in providers %} |
|
| 50 | + | <form class="card" method="post" action="/admin/signin"> |
|
| 51 | + | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="provider" value="{{ p.id }}"> |
|
| 52 | + | <h4>{{ p.name }} {% if p.enabled && p.has_secret && !p.client_id.is_empty() %}<span class="tag head">on</span>{% else %}<span class="tag dim">off</span>{% endif %}</h4> |
|
| 53 | + | <label for="cid-{{ p.id }}">Client id</label><input type="text" id="cid-{{ p.id }}" name="client_id" value="{{ p.client_id }}" autocomplete="off"> |
|
| 54 | + | <label for="sec-{{ p.id }}">Client secret</label><input type="password" id="sec-{{ p.id }}" name="secret" placeholder="{% if p.has_secret %}unchanged{% else %}none{% endif %}" autocomplete="new-password"> |
|
| 55 | + | <div class="choice"><label><input type="checkbox" name="enabled" value="1"{% if p.enabled %} checked{% endif %}> on</label></div> |
|
| 56 | + | <p class="small mute">Callback URL to register with {{ p.name }}: <code>{{ p.callback }}</code></p> |
|
| 57 | + | <button type="submit">SAVE</button> |
|
| 58 | + | </form> |
|
| 59 | + | {% endfor %} |
|
| 60 | + | <p class="small mute">A first sign-in links the account with the same verified email, or makes an account when registration is open. Secrets are kept sealed.</p> |
|
| 48 | 61 | <h3>Mail</h3> |
|
| 49 | 62 | <form class="card" method="post" action="/admin/mail"> |
|
| 50 | 63 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| … | 14 unchanged lines |
modified templates/login.html +1 −0
| … | 17 unchanged lines |
||
| 18 | 18 | {% endif %} |
|
| 19 | 19 | <button type="submit">SIGN IN</button> |
|
| 20 | 20 | </form> |
|
| 21 | + | {% if !providers.is_empty() %}<div class="card signin-with"><p class="small mute">Or sign in with</p>{% for (id, name) in providers %}<a class="go" href="/login/{{ id }}">{{ name }}</a> {% endfor %}</div>{% endif %} |
|
| 21 | 22 | <p class="small mute"><a href="/login/reset">Forgot your password?</a> · No account? <a href="/register">Register</a>.</p> |
|
| 22 | 23 | {% endblock %} |
modified templates/settings_security.html +5 −0
| … | 33 unchanged lines |
||
| 34 | 34 | <button type="submit">SET UP</button> |
|
| 35 | 35 | </form> |
|
| 36 | 36 | {% endif %} |
|
| 37 | + | {% if !connected.is_empty() %}<section class="panel" id="connected"> |
|
| 38 | + | <h2>Connected accounts</h2> |
|
| 39 | + | <p class="small">Sign in with these instead of a password.</p> |
|
| 40 | + | <ul class="plain">{% for (id, name, login) in connected %}<li>{{ name }}: {% if let Some(l) = login %}<strong>{{ l }}</strong> <form class="tiny inline" method="post" action="/settings/security/disconnect/{{ id }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="linkish">disconnect</button></form>{% else %}<a href="/settings/security/connect/{{ id }}">connect</a>{% endif %}</li>{% endfor %}</ul> |
|
| 41 | + | </section>{% endif %} |
|
| 37 | 42 | <form class="card" method="post" action="/settings/security/sessions"> |
|
| 38 | 43 | <input type="hidden" name="_csrf" value="{{ shell.csrf }}"> |
|
| 39 | 44 | <h2>Sessions</h2> |
|
| … | 4 unchanged lines |