| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
use crate::db::{accounts, now_ms, Pool}; |
| 9 |
use crate::Shared; |
| 10 |
use sqlx::FromRow; |
| 11 |
|
| 12 |
|
| 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 |
|
| 48 |
|
| 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 |
|
| 61 |
pub fn callback_url(&self, app: &Shared) -> String { |
| 62 |
format!("{}/login/{}/callback", app.config.base_url(), self.id) |
| 63 |
} |
| 64 |
} |
| 65 |
|
| 66 |
|
| 67 |
#[derive(Clone, Debug, Default)] |
| 68 |
pub struct Settings { |
| 69 |
pub client_id: String, |
| 70 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 115 |
|
| 116 |
|
| 117 |
pub const STATE_COOKIE: &str = "oxyhub_signin"; |
| 118 |
|
| 119 |
|
| 120 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 157 |
pub email: Option<String>, |
| 158 |
} |
| 159 |
|
| 160 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 285 |
|
| 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 |
} |