| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | |
|
| 9 | | use crate::db::{accounts, now_ms, Pool}; |
| 10 | | use crate::Shared; |
| 11 | | use sqlx::FromRow; |
| 12 | |
|
| 13 | |
|
| 14 | | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 15 | | pub struct Provider { |
| 16 | | pub id: &'static str, |
| 17 | | pub name: &'static str, |
| 18 | | authorize: &'static str, |
| 19 | | token: &'static str, |
| 20 | | userinfo: &'static str, |
| 21 | | pub scope: &'static str, |
| 22 | | |
| 23 | | |
| 24 | | |
| 25 | | pub default_base: Option<&'static str>, |
| 26 | | } |
| 27 | |
|
| 28 | | pub const PROVIDERS: [Provider; 3] = [ |
| 29 | | |
| 30 | | |
| 31 | | Provider { |
| 32 | | id: "gitlab", |
| 33 | | name: "GitLab", |
| 34 | | authorize: "/oauth/authorize", |
| 35 | | token: "/oauth/token", |
| 36 | | userinfo: "/oauth/userinfo", |
| 37 | | scope: "openid profile email", |
| 38 | | default_base: Some("https://gitlab.com"), |
| 39 | | }, |
| 40 | | Provider { |
| 41 | | id: "github", |
| 42 | | name: "GitHub", |
| 43 | | authorize: "https://github.com/login/oauth/authorize", |
| 44 | | token: "https://github.com/login/oauth/access_token", |
| 45 | | userinfo: "https://api.github.com/user", |
| 46 | | scope: "read:user user:email", |
| 47 | | default_base: None, |
| 48 | | }, |
| 49 | | Provider { |
| 50 | | id: "google", |
| 51 | | name: "Google", |
| 52 | | authorize: "https://accounts.google.com/o/oauth2/v2/auth", |
| 53 | | token: "https://oauth2.googleapis.com/token", |
| 54 | | userinfo: "https://openidconnect.googleapis.com/v1/userinfo", |
| 55 | | scope: "openid email profile", |
| 56 | | default_base: None, |
| 57 | | }, |
| 58 | | ]; |
| 59 | |
|
| 60 | 30× | pub fn provider(id: &str) -> Option<&'static Provider> { |
| 61 | 63× | PROVIDERS.iter().find(|p| p.id == id) |
| 62 | 30× | } |
| 63 | |
|
| 64 | | impl Provider { |
| 65 | | |
| 66 | | |
| 67 | 29× | fn url(&self, app: &Shared, s: &Settings, which: &str) -> String { |
| 68 | 29× | let path = match which { |
| 69 | 29× | "authorize" => self.authorize, |
| 70 | 21× | "token" => self.token, |
| 71 | 13× | _ => self.userinfo, |
| 72 | | }; |
| 73 | 29× | match &app.config.auth.signin_mock { |
| 74 | 29× | Some(base) => format!("{}/{}/{which}", base.trim_end_matches('/'), self.id), |
| 75 | 0× | None => match self.default_base { |
| 76 | 0× | Some(default) => format!("{}{path}", s.base_or(default)), |
| 77 | 0× | None => path.to_string(), |
| 78 | | }, |
| 79 | | } |
| 80 | 29× | } |
| 81 | |
|
| 82 | | |
| 83 | 304× | pub fn callback_url(&self, app: &Shared) -> String { |
| 84 | 304× | format!("{}/login/{}/callback", app.config.base_url(), self.id) |
| 85 | 304× | } |
| 86 | | } |
| 87 | |
|
| 88 | |
|
| 89 | | #[derive(Clone, Debug, Default)] |
| 90 | | pub struct Settings { |
| 91 | | pub client_id: String, |
| 92 | | |
| 93 | | pub secret: String, |
| 94 | | pub enabled: bool, |
| 95 | | |
| 96 | | pub base: String, |
| 97 | | } |
| 98 | |
|
| 99 | | impl Settings { |
| 100 | 87× | pub fn usable(&self) -> bool { |
| 101 | 87× | self.enabled && !self.client_id.is_empty() && !self.secret.is_empty() |
| 102 | 87× | } |
| 103 | |
|
| 104 | | |
| 105 | 2× | pub fn base_or<'a>(&'a self, default: &'a str) -> &'a str { |
| 106 | 2× | let b = self.base.trim().trim_end_matches('/'); |
| 107 | 2× | if b.is_empty() { |
| 108 | 1× | default |
| 109 | | } else { |
| 110 | 1× | b |
| 111 | | } |
| 112 | 2× | } |
| 113 | | } |
| 114 | |
|
| 115 | |
|
| 116 | |
|
| 117 | 8× | pub fn valid_base(base: &str) -> bool { |
| 118 | 8× | let Some(rest) = base.strip_prefix("https://") else { return false }; |
| 119 | 6× | let host = rest.split('/').next().unwrap_or(""); |
| 120 | 6× | !host.is_empty() && !rest.contains(['?', '#', ' ']) && host.contains('.') |
| 121 | 8× | } |
| 122 | |
|
| 123 | 1500× | fn key(id: &str, what: &str) -> String { |
| 124 | 1500× | format!("signin.{id}.{what}") |
| 125 | 1500× | } |
| 126 | |
|
| 127 | 371× | pub async fn settings(app: &Shared, id: &str) -> sqlx::Result<Settings> { |
| 128 | 371× | let client_id = accounts::setting(&app.db, &key(id, "client_id")).await?.unwrap_or_default(); |
| 129 | 371× | let sealed = accounts::setting(&app.db, &key(id, "secret")).await?.unwrap_or_default(); |
| 130 | 371× | let secret = hex::decode(&sealed).ok().and_then(|b| app.secret_key.open(&b)).unwrap_or_default(); |
| 131 | 371× | let enabled = accounts::setting(&app.db, &key(id, "enabled")).await?.as_deref() == Some("1"); |
| 132 | 371× | let base = accounts::setting(&app.db, &key(id, "base")).await?.unwrap_or_default(); |
| 133 | 371× | Ok(Settings { client_id, secret, enabled, base }) |
| 134 | 371× | } |
| 135 | |
|
| 136 | |
|
| 137 | 4× | pub async fn save(app: &Shared, id: &str, client_id: &str, secret: Option<&str>, enabled: bool, base: &str) -> sqlx::Result<()> { |
| 138 | 4× | accounts::set_setting(&app.db, &key(id, "base"), base.trim().trim_end_matches('/')).await?; |
| 139 | 4× | accounts::set_setting(&app.db, &key(id, "client_id"), client_id.trim()).await?; |
| 140 | 4× | if let Some(s) = secret { |
| 141 | 4× | accounts::set_setting(&app.db, &key(id, "secret"), &hex::encode(app.secret_key.seal(s.trim()))).await?; |
| 142 | 0× | } |
| 143 | 4× | accounts::set_setting(&app.db, &key(id, "enabled"), if enabled { "1" } else { "0" }).await?; |
| 144 | 4× | Ok(()) |
| 145 | 4× | } |
| 146 | |
|
| 147 | |
|
| 148 | 21× | pub async fn enabled(app: &Shared) -> Vec<&'static Provider> { |
| 149 | 21× | let mut out = vec![]; |
| 150 | 63× | for p in &PROVIDERS { |
| 151 | 63× | if settings(app, p.id).await.map(|s| s.usable()).unwrap_or(false) { |
| 152 | 12× | out.push(p); |
| 153 | 51× | } |
| 154 | | } |
| 155 | 21× | out |
| 156 | 21× | } |
| 157 | |
|
| 158 | |
|
| 159 | |
|
| 160 | |
|
| 161 | | pub const STATE_COOKIE: &str = "oxyhub_signin"; |
| 162 | |
|
| 163 | |
|
| 164 | |
|
| 165 | 8× | pub fn state_value(app: &Shared, mode: &str) -> String { |
| 166 | 8× | let nonce = crate::auth::random_hex(16); |
| 167 | 8× | let mac = app.cookie_key.mac(format!("signin:{nonce}:{mode}").as_bytes()); |
| 168 | 8× | format!("{nonce}.{mode}.{mac}") |
| 169 | 8× | } |
| 170 | |
|
| 171 | |
|
| 172 | 8× | pub fn state_mode(app: &Shared, value: &str) -> Option<String> { |
| 173 | 8× | let (nonce, rest) = value.split_once('.')?; |
| 174 | 8× | let (mode, mac) = rest.rsplit_once('.')?; |
| 175 | 8× | let want = app.cookie_key.mac(format!("signin:{nonce}:{mode}").as_bytes()); |
| 176 | 8× | crate::auth::mac_equal(&want, mac).then(|| mode.to_string()) |
| 177 | 8× | } |
| 178 | |
|
| 179 | |
|
| 180 | 8× | pub fn authorize_url(app: &Shared, p: &Provider, s: &Settings, state: &str) -> String { |
| 181 | 32× | let q = |s: &str| crate::web::settings::urlencode(s); |
| 182 | 8× | format!( |
| 183 | | "{}?client_id={}&redirect_uri={}&scope={}&state={}&response_type=code{}", |
| 184 | 8× | p.url(app, s, "authorize"), |
| 185 | 8× | q(&s.client_id), |
| 186 | 8× | q(&p.callback_url(app)), |
| 187 | 8× | q(p.scope), |
| 188 | 8× | q(state), |
| 189 | 8× | if p.id == "google" { "&access_type=online&prompt=select_account" } else { "" } |
| 190 | | ) |
| 191 | 8× | } |
| 192 | |
|
| 193 | |
|
| 194 | | #[derive(Clone, Debug)] |
| 195 | | pub struct External { |
| 196 | | pub provider: &'static str, |
| 197 | | pub subject: String, |
| 198 | | pub login: String, |
| 199 | | pub name: String, |
| 200 | | |
| 201 | | pub email: Option<String>, |
| 202 | | } |
| 203 | |
|
| 204 | |
|
| 205 | 8× | pub async fn identify(app: &Shared, p: &'static Provider, s: &Settings, code: &str) -> Result<External, String> { |
| 206 | 8× | let client = reqwest::Client::builder() |
| 207 | 8× | .timeout(std::time::Duration::from_secs(20)) |
| 208 | 8× | .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION"))) |
| 209 | 8× | .build() |
| 210 | 8× | .map_err(|e| e.to_string())?; |
| 211 | 8× | let token: serde_json::Value = client |
| 212 | 8× | .post(p.url(app, s, "token")) |
| 213 | 8× | .header("accept", "application/json") |
| 214 | 8× | .form(&[ |
| 215 | 8× | ("client_id", s.client_id.as_str()), |
| 216 | 8× | ("client_secret", s.secret.as_str()), |
| 217 | 8× | ("code", code), |
| 218 | 8× | ("redirect_uri", &p.callback_url(app)), |
| 219 | 8× | ("grant_type", "authorization_code"), |
| 220 | 8× | ]) |
| 221 | 8× | .send() |
| 222 | 8× | .await |
| 223 | 8× | .map_err(|e| format!("{} did not answer: {e}", p.name))? |
| 224 | 8× | .json() |
| 225 | 8× | .await |
| 226 | 8× | .map_err(|_| format!("{} answered with something other than a token", p.name))?; |
| 227 | 8× | let Some(access) = token["access_token"].as_str() else { |
| 228 | 0× | let why = token["error_description"].as_str().or(token["error"].as_str()).unwrap_or("no token"); |
| 229 | 0× | return Err(format!("{} refused the sign-in: {why}", p.name)); |
| 230 | | }; |
| 231 | 13× | let get = |url: String| { |
| 232 | 13× | let client = client.clone(); |
| 233 | 13× | let access = access.to_string(); |
| 234 | 13× | async move { |
| 235 | 13× | client |
| 236 | 13× | .get(url) |
| 237 | 13× | .bearer_auth(access) |
| 238 | 13× | .header("accept", "application/json") |
| 239 | 13× | .send() |
| 240 | 13× | .await |
| 241 | 13× | .map_err(|e| format!("{} did not answer: {e}", p.name))? |
| 242 | 13× | .json::<serde_json::Value>() |
| 243 | 13× | .await |
| 244 | 13× | .map_err(|_| format!("{} answered with something unexpected", p.name)) |
| 245 | 13× | } |
| 246 | 13× | }; |
| 247 | 8× | let user = get(p.url(app, s, "userinfo")).await?; |
| 248 | 8× | Ok(match p.id { |
| 249 | 8× | "github" => { |
| 250 | 5× | let subject = |
| 251 | 5× | user["id"].as_i64().map(|i| i.to_string()).or_else(|| user["id"].as_str().map(String::from)).ok_or("GitHub gave no id")?; |
| 252 | | |
| 253 | 5× | let emails = get(format!("{}/emails", p.url(app, s, "userinfo"))).await.unwrap_or_default(); |
| 254 | 5× | let email = emails |
| 255 | 5× | .as_array() |
| 256 | 5× | .and_then(|list| { |
| 257 | 5× | list.iter() |
| 258 | 10× | .filter(|e| e["verified"] == true) |
| 259 | 5× | .max_by_key(|e| e["primary"] == true) |
| 260 | 5× | .and_then(|e| e["email"].as_str().map(String::from)) |
| 261 | 5× | }) |
| 262 | 5× | .or_else(|| user["email"].as_str().map(String::from).filter(|_| false)); |
| 263 | 5× | External { |
| 264 | 5× | provider: p.id, |
| 265 | 5× | subject, |
| 266 | 5× | login: user["login"].as_str().unwrap_or("").to_string(), |
| 267 | 5× | name: user["name"].as_str().unwrap_or("").to_string(), |
| 268 | 5× | email, |
| 269 | 5× | } |
| 270 | | } |
| 271 | | |
| 272 | | |
| 273 | 3× | "gitlab" => { |
| 274 | 1× | let subject = user["sub"] |
| 275 | 1× | .as_str() |
| 276 | 1× | .map(String::from) |
| 277 | 1× | .or_else(|| user["sub"].as_i64().map(|i| i.to_string())) |
| 278 | 1× | .ok_or("GitLab gave no subject")?; |
| 279 | 1× | let email = user["email"].as_str().filter(|_| user["email_verified"] == true).map(String::from); |
| 280 | 1× | let login = user["nickname"].as_str().or(user["preferred_username"].as_str()).unwrap_or("").to_string(); |
| 281 | 1× | External { provider: p.id, subject, login, name: user["name"].as_str().unwrap_or("").to_string(), email } |
| 282 | | } |
| 283 | | _ => { |
| 284 | 2× | let subject = user["sub"].as_str().ok_or("Google gave no subject")?.to_string(); |
| 285 | 2× | let email = user["email"].as_str().filter(|_| user["email_verified"] == true).map(String::from); |
| 286 | 2× | let login = email.as_deref().and_then(|e| e.split('@').next()).unwrap_or("").to_string(); |
| 287 | 2× | External { provider: p.id, subject, login, name: user["name"].as_str().unwrap_or("").to_string(), email } |
| 288 | | } |
| 289 | | }) |
| 290 | 8× | } |
| 291 | |
|
| 292 | |
|
| 293 | |
|
| 294 | | #[derive(Clone, Debug, FromRow)] |
| 295 | | pub struct Login { |
| 296 | | pub provider: String, |
| 297 | | pub subject: String, |
| 298 | | pub account_id: i64, |
| 299 | | pub login: String, |
| 300 | | pub linked_ms: i64, |
| 301 | | } |
| 302 | |
|
| 303 | 8× | pub async fn linked_account(pool: &Pool, provider: &str, subject: &str) -> sqlx::Result<Option<accounts::Account>> { |
| 304 | 8× | let id: Option<i64> = sqlx::query_scalar("SELECT account_id FROM account_logins WHERE provider = $1 AND subject = $2") |
| 305 | 8× | .bind(provider) |
| 306 | 8× | .bind(subject) |
| 307 | 8× | .fetch_optional(pool) |
| 308 | 8× | .await?; |
| 309 | 8× | match id { |
| 310 | 1× | Some(id) => accounts::account_by_id(pool, id).await, |
| 311 | 7× | None => Ok(None), |
| 312 | | } |
| 313 | 8× | } |
| 314 | |
|
| 315 | 5× | pub async fn link(pool: &Pool, provider: &str, subject: &str, account_id: i64, login: &str) -> sqlx::Result<()> { |
| 316 | 5× | sqlx::query("INSERT INTO account_logins (provider, subject, account_id, login, linked_ms) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (provider, subject) DO UPDATE SET account_id = excluded.account_id, login = excluded.login, linked_ms = excluded.linked_ms") |
| 317 | 5× | .bind(provider) |
| 318 | 5× | .bind(subject) |
| 319 | 5× | .bind(account_id) |
| 320 | 5× | .bind(login) |
| 321 | 5× | .bind(now_ms()) |
| 322 | 5× | .execute(pool) |
| 323 | 5× | .await?; |
| 324 | 5× | Ok(()) |
| 325 | 5× | } |
| 326 | |
|
| 327 | 1× | pub async fn unlink(pool: &Pool, provider: &str, account_id: i64) -> sqlx::Result<bool> { |
| 328 | 1× | let r = sqlx::query("DELETE FROM account_logins WHERE provider = $1 AND account_id = $2") |
| 329 | 1× | .bind(provider) |
| 330 | 1× | .bind(account_id) |
| 331 | 1× | .execute(pool) |
| 332 | 1× | .await?; |
| 333 | 1× | Ok(r.rows_affected() > 0) |
| 334 | 1× | } |
| 335 | |
|
| 336 | 10× | pub async fn links_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<Login>> { |
| 337 | 10× | sqlx::query_as("SELECT * FROM account_logins WHERE account_id = $1 ORDER BY provider").bind(account_id).fetch_all(pool).await |
| 338 | 10× | } |
| 339 | |
|
| 340 | |
|
| 341 | |
|
| 342 | 3× | pub async fn free_username(pool: &Pool, wanted: &str, provider: &str) -> sqlx::Result<String> { |
| 343 | 20× | let base: String = wanted.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')).take(40).collect(); |
| 344 | 6× | let base = base.trim_matches(|c: char| !c.is_ascii_alphanumeric()).to_string(); |
| 345 | 3× | let base = if base.is_empty() { provider.to_string() } else { base }; |
| 346 | 3× | let mut candidates = vec![base.clone(), format!("{base}-{provider}")]; |
| 347 | 294× | candidates.extend((2..100).map(|n| format!("{base}-{n}"))); |
| 348 | 3× | for c in candidates { |
| 349 | 3× | if !crate::repos::names::valid_owner(&c) { |
| 350 | 0× | continue; |
| 351 | 3× | } |
| 352 | 3× | let taken = accounts::account_by_name(pool, &c).await?.is_some() |
| 353 | 3× | || accounts::org_by_name(pool, &c).await?.is_some() |
| 354 | 3× | || accounts::username_retired(pool, &c).await?; |
| 355 | 3× | if !taken { |
| 356 | 3× | return Ok(c); |
| 357 | 0× | } |
| 358 | | } |
| 359 | 0× | Ok(format!("{provider}-{}", crate::auth::random_hex(4))) |
| 360 | 3× | } |
| 361 | |
|
| 362 | | #[cfg(test)] |
| 363 | | mod tests { |
| 364 | | use super::*; |
| 365 | |
|
| 366 | | #[test] |
| 367 | 1× | fn providers_are_known() { |
| 368 | 1× | assert_eq!(provider("github").map(|p| p.name), Some("GitHub")); |
| 369 | 1× | assert_eq!(provider("google").map(|p| p.name), Some("Google")); |
| 370 | 1× | assert_eq!(provider("gitlab").map(|p| (p.name, p.default_base)), Some(("GitLab", Some("https://gitlab.com")))); |
| 371 | 1× | assert!(provider("bitbucket").is_none()); |
| 372 | | |
| 373 | 1× | assert!(valid_base("https://gitlab.example.org") && valid_base("https://code.example.org/gitlab")); |
| 374 | 1× | assert!( |
| 375 | 1× | !valid_base("http://gitlab.example.org") |
| 376 | 1× | && !valid_base("https://") |
| 377 | 1× | && !valid_base("https://gitlab.example.org/?x=1") |
| 378 | 1× | && !valid_base("https://localhost") |
| 379 | | ); |
| 380 | 1× | let s = Settings { base: "https://gitlab.example.org/".into(), ..Settings::default() }; |
| 381 | 1× | assert_eq!(s.base_or("https://gitlab.com"), "https://gitlab.example.org"); |
| 382 | 1× | assert_eq!(Settings::default().base_or("https://gitlab.com"), "https://gitlab.com"); |
| 383 | 1× | } |
| 384 | | } |