Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions deploy/miniapp-market/market.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ MARKET_SESSION_SECRET=replace-with-at-least-32-random-bytes
# Comma-separated numeric GitHub IDs allowed to administer the market.
# Empty means no administrator until this deployment sets its own IDs.
MARKET_ADMIN_GITHUB_IDS=
# Comma-separated email addresses allowed to administer the market. Matched
# case-insensitively against code-verified email sign-in identities, so an
# operator can administer without a GitHub account.
MARKET_ADMIN_EMAILS=
RUST_LOG=openbitfun_miniapp_market=info,tower_http=info

# Optional email-code sign-in. Production stores these in a separate root-only smtp.env.
Expand Down
1 change: 1 addition & 0 deletions src/apps/miniapp-market-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ cargo run -p openbitfun-miniapp-market-server
| `MARKET_GITHUB_CLIENT_SECRET` | GitHub OAuth App secret |
| `MARKET_SESSION_SECRET` | 会话签名 secret,生产至少 24 字符并应使用强随机值 |
| `MARKET_ADMIN_GITHUB_IDS` | 逗号分隔的 GitHub 数字 ID;留空表示没有管理员 |
| `MARKET_ADMIN_EMAILS` | 逗号分隔的邮箱地址(大小写不敏感);命中验证过的邮箱验证码身份时同样授予管理员,留空表示只认 GitHub ID |
| `MARKET_PUBLIC_BROWSE` | 是否向匿名用户开放目录 |
| `MARKET_WEB_SUBMISSIONS_ENABLED` | 是否允许 Web Cookie 会话投稿;默认及生产为 `false`,Desktop Bearer 投稿不受影响 |
| `RUST_LOG` | 英文 JSON 日志过滤器 |
Expand Down
35 changes: 30 additions & 5 deletions src/crates/services/miniapp-market-service/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,7 @@ impl AuthService {
}

pub(crate) fn is_admin(&self, user: &AuthenticatedUser) -> bool {
user.profile.github_id > 0
&& self
.config
.admin_github_ids
.contains(&user.profile.github_id)
is_admin_identity(&self.config, user.profile.github_id, user.email.as_deref())
}

pub(crate) async fn start_web_oauth(&self, return_to: &str) -> MarketResult<String> {
Expand Down Expand Up @@ -857,6 +853,16 @@ pub(super) fn safe_return_to(value: &str) -> String {
}
}

fn is_admin_identity(config: &MarketConfig, github_id: i64, email: Option<&str>) -> bool {
if github_id > 0 && config.admin_github_ids.contains(&github_id) {
return true;
}
// `email` only comes from `email_identities`, which records code-verified addresses.
email
.map(|value| value.trim().to_ascii_lowercase())
.is_some_and(|value| config.admin_emails.contains(&value))
}

fn bearer_token(headers: &HeaderMap) -> Option<&str> {
headers
.get(header::AUTHORIZATION)
Expand Down Expand Up @@ -903,6 +909,7 @@ mod tests {
github_client_secret: Some("client-secret".to_string()),
session_secret: "test-session-secret-at-least-24".to_string(),
admin_github_ids: HashSet::from([24753352]),
admin_emails: HashSet::new(),
public_browse: false,
web_submissions_enabled: false,
}
Expand Down Expand Up @@ -1394,4 +1401,22 @@ mod tests {
.1;
assert_eq!(second_family, first_family);
}

#[test]
fn administrators_are_matched_by_github_id_or_verified_email() {
let temporary = tempfile::tempdir().unwrap();
let mut config = test_config(temporary.path());
config.admin_emails = HashSet::from(["bob@example.com".to_string()]);

assert!(is_admin_identity(&config, 24753352, None));
assert!(is_admin_identity(&config, 0, Some("bob@example.com")));
assert!(is_admin_identity(&config, 0, Some(" Bob@Example.com ")));
assert!(!is_admin_identity(&config, 0, Some("someone@example.com")));
assert!(!is_admin_identity(&config, 0, None));
assert!(!is_admin_identity(
&config,
1234,
Some("someone@example.com")
));
}
}
33 changes: 33 additions & 0 deletions src/crates/services/miniapp-market-service/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ pub struct MarketConfig {
pub github_client_secret: Option<String>,
pub session_secret: String,
pub admin_github_ids: HashSet<i64>,
pub admin_emails: HashSet<String>,
pub public_browse: bool,
pub web_submissions_enabled: bool,
}
Expand Down Expand Up @@ -53,6 +54,7 @@ impl MarketConfig {
}
let admin_github_ids =
parse_admin_github_ids(&env::var("MARKET_ADMIN_GITHUB_IDS").unwrap_or_default());
let admin_emails = parse_admin_emails(&env::var("MARKET_ADMIN_EMAILS").unwrap_or_default());
let public_browse = env::var("MARKET_PUBLIC_BROWSE")
.map(|value| matches!(value.as_str(), "1" | "true" | "TRUE" | "yes"))
.unwrap_or(true);
Expand All @@ -71,6 +73,7 @@ impl MarketConfig {
github_client_secret: non_empty_env("MARKET_GITHUB_CLIENT_SECRET"),
session_secret,
admin_github_ids,
admin_emails,
public_browse,
web_submissions_enabled,
})
Expand Down Expand Up @@ -113,6 +116,18 @@ fn parse_admin_github_ids(raw: &str) -> HashSet<i64> {
.collect()
}

/// Administrator identities that sign in with a verified email code instead of GitHub.
///
/// Emails are matched case-insensitively; only addresses proven by `email_identities`
/// (verified one-time code) are ever attached to a user, so this cannot be spoofed by
/// a public GitHub profile address.
fn parse_admin_emails(raw: &str) -> HashSet<String> {
raw.split(',')
.map(|value| value.trim().to_ascii_lowercase())
.filter(|value| value.contains('@'))
.collect()
}

fn non_empty_env(key: &str) -> Option<String> {
env::var(key)
.ok()
Expand All @@ -138,6 +153,24 @@ mod tests {
);
}

#[test]
fn admin_emails_are_empty_without_an_explicit_deployment_setting() {
assert!(parse_admin_emails("").is_empty());
assert!(parse_admin_emails(" , ").is_empty());
assert!(parse_admin_emails("not-an-email, 42").is_empty());
}

#[test]
fn admin_emails_parse_and_normalize_case() {
assert_eq!(
parse_admin_emails(" Bob@Example.com , second@example.com,"),
HashSet::from([
"bob@example.com".to_string(),
"second@example.com".to_string()
])
);
}

#[test]
fn origin_of_strips_path_query_and_fragment() {
assert_eq!(
Expand Down
2 changes: 2 additions & 0 deletions src/crates/services/miniapp-market-service/src/email_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ impl AuthService {
mod tests {
use super::*;
use crate::{auth::DesktopAuthPollRequest, config::MarketConfig, db::Database};
use std::collections::HashSet;
async fn setup() -> (tempfile::TempDir, AuthService) {
let dir = tempfile::tempdir().unwrap();
let config = MarketConfig {
Expand All @@ -497,6 +498,7 @@ mod tests {
github_client_secret: Some("secret".into()),
session_secret: "test-only-session-secret-at-least-24".into(),
admin_github_ids: [42, 0].into_iter().collect(),
admin_emails: HashSet::new(),
public_browse: true,
web_submissions_enabled: false,
};
Expand Down
2 changes: 2 additions & 0 deletions src/crates/services/miniapp-market-service/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ mod tests {
use axum::body::{Body, Bytes};
use axum::http::{header, Request, StatusCode};
use axum::routing::post;
use std::collections::HashSet;
use std::io::Cursor;
use tower::ServiceExt;

Expand All @@ -161,6 +162,7 @@ mod tests {
github_client_secret: None,
session_secret: "test-session-secret-at-least-24".to_string(),
admin_github_ids: [24753352].into_iter().collect(),
admin_emails: HashSet::new(),
public_browse: true,
web_submissions_enabled: false,
};
Expand Down
4 changes: 4 additions & 0 deletions src/crates/services/miniapp-market-service/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2356,6 +2356,7 @@ mod tests {
github_client_secret: Some("client-secret".to_string()),
session_secret: "test-session-secret-at-least-24".to_string(),
admin_github_ids: HashSet::from([24753352]),
admin_emails: HashSet::new(),
public_browse: true,
web_submissions_enabled: false,
};
Expand Down Expand Up @@ -2548,6 +2549,7 @@ mod tests {
github_client_secret: Some("client-secret".to_string()),
session_secret: "test-session-secret-at-least-24".to_string(),
admin_github_ids: HashSet::from([24753352]),
admin_emails: HashSet::new(),
public_browse: true,
web_submissions_enabled: false,
};
Expand Down Expand Up @@ -2725,6 +2727,7 @@ mod tests {
github_client_secret: Some("client-secret".to_string()),
session_secret: "test-session-secret-at-least-24".to_string(),
admin_github_ids: HashSet::from([24753352]),
admin_emails: HashSet::new(),
public_browse: true,
web_submissions_enabled: false,
};
Expand Down Expand Up @@ -2890,6 +2893,7 @@ mod tests {
github_client_secret: Some("client-secret".to_string()),
session_secret: "test-session-secret-at-least-24".to_string(),
admin_github_ids: HashSet::from([24753352]),
admin_emails: HashSet::new(),
public_browse: true,
web_submissions_enabled: false,
};
Expand Down
Loading