feat(auth): 服务端托管 Google OAuth;修复未解锁 vault 时 bootstrap

- API:桌面登录 session、Google 托管回调与轮询
- Desktop:轮询登录;bootstrap 在 vault 未解锁时不返回 shell,避免跳过主密码
- 文档与 deploy/.env.example 对齐 GOOGLE_OAUTH_* 与 SECRETS_PUBLIC_BASE_URL
This commit is contained in:
agent
2026-04-14 20:28:52 +08:00
committed by voson
parent e6bd2225cd
commit 57c3efb70e
10 changed files with 738 additions and 266 deletions

View File

@@ -20,6 +20,9 @@ tracing-subscriber.workspace = true
uuid.workspace = true
chrono.workspace = true
reqwest.workspace = true
sha2.workspace = true
url.workspace = true
base64 = "0.22.1"
secrets-application = { path = "../../crates/application" }
secrets-device-auth = { path = "../../crates/device-auth" }

View File

@@ -1,23 +1,25 @@
use anyhow::{Context, Result as AnyResult};
use axum::{
Json, Router,
extract::{Path, State},
extract::{Path, Query, State},
http::{HeaderMap, StatusCode, header},
response::{Html, IntoResponse, Redirect},
routing::{get, post},
};
use chrono::{DateTime, Utc};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Duration, Utc};
use reqwest::Client;
use secrets_application::sync::{fetch_object, sync_pull, sync_push};
use secrets_device_auth::{
hash_device_login_token, new_device_fingerprint, new_device_login_token,
};
use secrets_device_auth::{hash_device_login_token, new_device_fingerprint, new_device_login_token};
use secrets_domain::{
SyncPullRequest, SyncPullResponse, SyncPushRequest, SyncPushResponse, VaultObjectEnvelope,
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sqlx::PgPool;
use sha2::{Digest, Sha256};
use sqlx::{PgPool, Postgres, Transaction};
use tracing_subscriber::EnvFilter;
use url::Url;
use uuid::Uuid;
#[derive(Clone)]
@@ -32,20 +34,79 @@ struct DemoLoginResponse {
}
#[derive(Debug, Deserialize)]
struct DesktopGoogleLoginRequest {
access_token: String,
struct DesktopLoginStartRequest {
device_name: String,
platform: String,
client_version: String,
device_fingerprint: String,
}
#[derive(Debug, Deserialize)]
struct DesktopLoginPollQuery {
session_id: String,
}
#[derive(Debug, Deserialize)]
struct GoogleStartQuery {
session_id: String,
}
#[derive(Debug, Deserialize)]
struct GoogleCallbackQuery {
state: Option<String>,
code: Option<String>,
error: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GoogleUserInfo {
sub: String,
email: String,
name: Option<String>,
}
#[derive(Clone)]
struct GoogleOAuthConfig {
client_id: String,
client_secret: String,
auth_uri: String,
token_uri: String,
redirect_uri: String,
}
#[derive(Serialize)]
struct DesktopLoginStartResponse {
session_id: String,
auth_url: String,
expires_at: String,
}
#[derive(Serialize)]
struct DesktopLoginPollResponse {
status: String,
device_token: Option<String>,
error: Option<String>,
}
#[derive(Debug, Deserialize)]
struct GoogleTokenResponse {
access_token: String,
}
#[derive(Debug, sqlx::FromRow)]
struct DesktopLoginSessionRow {
session_id: String,
oauth_state: String,
pkce_verifier: String,
device_name: String,
platform: String,
client_version: String,
device_fingerprint: String,
status: String,
error_message: Option<String>,
expires_at: DateTime<Utc>,
}
#[derive(Serialize)]
struct DeviceView {
name: String,
@@ -84,6 +145,13 @@ struct ObjectResponse {
object: VaultObjectEnvelope,
}
const LOGIN_STATUS_PENDING: &str = "pending";
const LOGIN_STATUS_SUCCEEDED: &str = "succeeded";
const LOGIN_STATUS_FAILED: &str = "failed";
const LOGIN_STATUS_EXPIRED: &str = "expired";
const LOGIN_STATUS_CONSUMED: &str = "consumed";
const DESKTOP_LOGIN_SESSION_TTL_MINUTES: i64 = 10;
#[tokio::main]
async fn main() -> AnyResult<()> {
let _ = dotenvy::dotenv();
@@ -104,7 +172,10 @@ async fn main() -> AnyResult<()> {
let app = Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/auth/demo-login", post(api_demo_login))
.route("/auth/google/desktop-login", post(api_google_desktop_login))
.route("/auth/desktop/start", post(api_desktop_login_start))
.route("/auth/desktop/poll", get(api_desktop_login_poll))
.route("/auth/google/start", get(api_google_login_start))
.route("/auth/google/callback", get(api_google_login_callback))
.route("/me", get(api_me))
.route("/sync/pull", post(api_sync_pull))
.route("/sync/push", post(api_sync_push))
@@ -174,33 +245,130 @@ async fn api_demo_login(
Ok(Json(DemoLoginResponse { device_token }))
}
async fn api_google_desktop_login(
async fn api_desktop_login_start(
State(state): State<AppState>,
Json(payload): Json<DesktopGoogleLoginRequest>,
) -> std::result::Result<Json<DemoLoginResponse>, (StatusCode, Json<serde_json::Value>)> {
let google_user = state
.http
.get("https://openidconnect.googleapis.com/v1/userinfo")
.bearer_auth(&payload.access_token)
.send()
Json(payload): Json<DesktopLoginStartRequest>,
) -> std::result::Result<Json<DesktopLoginStartResponse>, (StatusCode, Json<serde_json::Value>)> {
let session_id = new_session_secret();
let oauth_state = new_session_secret();
let pkce_verifier = new_session_secret();
let expires_at = Utc::now() + Duration::minutes(DESKTOP_LOGIN_SESSION_TTL_MINUTES);
let auth_url = format!(
"{}/auth/google/start?session_id={}",
public_base_url().map_err(internal_error)?,
session_id
);
sqlx::query(
r#"
INSERT INTO desktop_login_sessions (
session_id, oauth_state, pkce_verifier, device_name, platform, client_version,
device_fingerprint, status, expires_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
"#,
)
.bind(&session_id)
.bind(&oauth_state)
.bind(&pkce_verifier)
.bind(&payload.device_name)
.bind(&payload.platform)
.bind(&payload.client_version)
.bind(&payload.device_fingerprint)
.bind(LOGIN_STATUS_PENDING)
.bind(expires_at)
.execute(&state.pool)
.await
.map_err(internal_error)?;
Ok(Json(DesktopLoginStartResponse {
session_id,
auth_url,
expires_at: expires_at.to_rfc3339(),
}))
}
async fn api_google_login_start(
State(state): State<AppState>,
Query(query): Query<GoogleStartQuery>,
) -> std::result::Result<Redirect, (StatusCode, Json<serde_json::Value>)> {
let session = fetch_desktop_login_session(&state.pool, &query.session_id)
.await
.map_err(internal_error)?
.error_for_status()
.ok_or_else(|| unauthorized("desktop login session not found"))?;
ensure_login_session_pending(&session).map_err(unauthorized)?;
let google = google_oauth_config().map_err(internal_error)?;
let challenge = pkce_challenge(&session.pkce_verifier);
let mut auth_url = Url::parse(&google.auth_uri).map_err(internal_error)?;
auth_url
.query_pairs_mut()
.append_pair("client_id", &google.client_id)
.append_pair("redirect_uri", &google.redirect_uri)
.append_pair("response_type", "code")
.append_pair("scope", "openid email profile")
.append_pair("state", &session.oauth_state)
.append_pair("code_challenge", &challenge)
.append_pair("code_challenge_method", "S256")
.append_pair("access_type", "offline")
.append_pair("prompt", "consent");
Ok(Redirect::temporary(auth_url.as_str()))
}
async fn api_google_login_callback(
State(state): State<AppState>,
Query(query): Query<GoogleCallbackQuery>,
) -> std::result::Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let oauth_state = query
.state
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| unauthorized("missing oauth state"))?;
let mut tx = state.pool.begin().await.map_err(internal_error)?;
let session = fetch_desktop_login_session_by_state(&mut tx, oauth_state)
.await
.map_err(internal_error)?
.json::<GoogleUserInfo>()
.ok_or_else(|| unauthorized("desktop login session not found"))?;
if let Some(error) = query.error.as_deref().filter(|value| !value.is_empty()) {
mark_login_session_failed(&mut tx, &session.session_id, &format!("google oauth error: {error}"))
.await
.map_err(internal_error)?;
tx.commit().await.map_err(internal_error)?;
return Ok(Html(login_result_html(
"登录未完成",
"你已取消 Google 授权或授权未成功,可以返回 Secrets 重试。",
)));
}
ensure_login_session_pending(&session).map_err(unauthorized)?;
let code = query
.code
.as_deref()
.filter(|value| !value.is_empty())
.ok_or_else(|| unauthorized("missing google auth code"))?;
let google = google_oauth_config().map_err(internal_error)?;
let google_token = exchange_google_auth_code(&state.http, &google, code, &session.pkce_verifier)
.await
.map_err(internal_error)?;
let google_user = fetch_google_userinfo(&state.http, &google_token.access_token)
.await
.map_err(internal_error)?;
let user_id = upsert_user_from_google(&state.pool, &google_user)
.await
.map_err(internal_error)?;
upsert_google_oauth_account(&state.pool, user_id, &google_user)
.await
.map_err(internal_error)?;
let device_id = upsert_device_for_login(
&state.pool,
user_id,
&payload.device_name,
&payload.platform,
&payload.client_version,
&payload.device_fingerprint,
&session.device_name,
&session.platform,
&session.client_version,
&session.device_fingerprint,
)
.await
.map_err(internal_error)?;
@@ -209,14 +377,115 @@ async fn api_google_desktop_login(
&state.pool,
user_id,
device_id,
&payload.device_name,
&payload.platform,
&payload.client_version,
&session.device_name,
&session.platform,
&session.client_version,
)
.await
.map_err(internal_error)?;
Ok(Json(DemoLoginResponse { device_token }))
mark_login_session_succeeded(
&mut tx,
&session.session_id,
user_id,
device_id,
device_token.clone(),
hash_device_login_token(&device_token),
)
.await
.map_err(internal_error)?;
tx.commit().await.map_err(internal_error)?;
Ok(Html(login_result_html(
"登录成功",
"Google 授权已完成,可以返回 Secrets 桌面端继续。",
)))
}
async fn api_desktop_login_poll(
State(state): State<AppState>,
Query(query): Query<DesktopLoginPollQuery>,
) -> std::result::Result<Json<DesktopLoginPollResponse>, (StatusCode, Json<serde_json::Value>)> {
let mut tx = state.pool.begin().await.map_err(internal_error)?;
let session = fetch_desktop_login_session_for_update(&mut tx, &query.session_id)
.await
.map_err(internal_error)?
.ok_or_else(|| unauthorized("desktop login session not found"))?;
let now = Utc::now();
if session.expires_at < now && session.status == LOGIN_STATUS_PENDING {
mark_login_session_expired(&mut tx, &session.session_id)
.await
.map_err(internal_error)?;
tx.commit().await.map_err(internal_error)?;
return Ok(Json(DesktopLoginPollResponse {
status: LOGIN_STATUS_EXPIRED.to_string(),
device_token: None,
error: Some("login session expired".to_string()),
}));
}
match session.status.as_str() {
LOGIN_STATUS_PENDING => {
tx.commit().await.map_err(internal_error)?;
Ok(Json(DesktopLoginPollResponse {
status: LOGIN_STATUS_PENDING.to_string(),
device_token: None,
error: None,
}))
}
LOGIN_STATUS_FAILED => {
tx.commit().await.map_err(internal_error)?;
Ok(Json(DesktopLoginPollResponse {
status: LOGIN_STATUS_FAILED.to_string(),
device_token: None,
error: session.error_message,
}))
}
LOGIN_STATUS_EXPIRED => {
tx.commit().await.map_err(internal_error)?;
Ok(Json(DesktopLoginPollResponse {
status: LOGIN_STATUS_EXPIRED.to_string(),
device_token: None,
error: session.error_message.or(Some("login session expired".to_string())),
}))
}
LOGIN_STATUS_CONSUMED => {
tx.commit().await.map_err(internal_error)?;
Ok(Json(DesktopLoginPollResponse {
status: LOGIN_STATUS_CONSUMED.to_string(),
device_token: None,
error: Some("login session already consumed".to_string()),
}))
}
LOGIN_STATUS_SUCCEEDED => {
let device_token = consume_device_token_for_poll(&mut tx, &session.session_id)
.await
.map_err(internal_error)?;
sqlx::query(
"UPDATE desktop_login_sessions SET status = $2, consumed_at = NOW(), updated_at = NOW() WHERE session_id = $1",
)
.bind(&session.session_id)
.bind(LOGIN_STATUS_CONSUMED)
.execute(&mut *tx)
.await
.map_err(internal_error)?;
tx.commit().await.map_err(internal_error)?;
Ok(Json(DesktopLoginPollResponse {
status: LOGIN_STATUS_SUCCEEDED.to_string(),
device_token: Some(device_token),
error: None,
}))
}
_ => {
tx.commit().await.map_err(internal_error)?;
Ok(Json(DesktopLoginPollResponse {
status: LOGIN_STATUS_FAILED.to_string(),
device_token: None,
error: Some("invalid login session status".to_string()),
}))
}
}
}
async fn api_sync_pull(
@@ -313,6 +582,215 @@ async fn api_me(
}))
}
fn public_base_url() -> AnyResult<String> {
std::env::var("SECRETS_PUBLIC_BASE_URL")
.or_else(|_| std::env::var("SECRETS_API_BASE"))
.context("SECRETS_PUBLIC_BASE_URL or SECRETS_API_BASE must be set")
}
fn google_oauth_config() -> AnyResult<GoogleOAuthConfig> {
Ok(GoogleOAuthConfig {
client_id: std::env::var("GOOGLE_OAUTH_CLIENT_ID")
.context("GOOGLE_OAUTH_CLIENT_ID is not set")?,
client_secret: std::env::var("GOOGLE_OAUTH_CLIENT_SECRET")
.context("GOOGLE_OAUTH_CLIENT_SECRET is not set")?,
auth_uri: std::env::var("GOOGLE_OAUTH_AUTH_URI")
.unwrap_or_else(|_| "https://accounts.google.com/o/oauth2/v2/auth".to_string()),
token_uri: std::env::var("GOOGLE_OAUTH_TOKEN_URI")
.unwrap_or_else(|_| "https://oauth2.googleapis.com/token".to_string()),
redirect_uri: std::env::var("GOOGLE_OAUTH_REDIRECT_URI")
.context("GOOGLE_OAUTH_REDIRECT_URI is not set")?,
})
}
fn new_session_secret() -> String {
new_device_login_token()
}
fn pkce_challenge(verifier: &str) -> String {
let digest = Sha256::digest(verifier.as_bytes());
URL_SAFE_NO_PAD.encode(digest)
}
fn login_result_html(title: &str, message: &str) -> String {
format!(
"<html><body><h3>{}</h3><p>{}</p><p>现在可以返回 Secrets 桌面端。</p></body></html>",
title, message
)
}
fn ensure_login_session_pending(session: &DesktopLoginSessionRow) -> Result<(), &'static str> {
if session.expires_at < Utc::now() {
return Err("desktop login session expired");
}
if session.status != LOGIN_STATUS_PENDING {
return Err("desktop login session is no longer pending");
}
Ok(())
}
async fn fetch_desktop_login_session(
pool: &PgPool,
session_id: &str,
) -> AnyResult<Option<DesktopLoginSessionRow>> {
sqlx::query_as::<_, DesktopLoginSessionRow>(
r#"
SELECT
session_id, oauth_state, pkce_verifier, device_name, platform, client_version,
device_fingerprint, status, error_message, user_id, device_id, device_token,
device_token_hash, expires_at
FROM desktop_login_sessions
WHERE session_id = $1
"#,
)
.bind(session_id)
.fetch_optional(pool)
.await
.context("failed to load desktop login session")
}
async fn fetch_desktop_login_session_for_update(
tx: &mut Transaction<'_, Postgres>,
session_id: &str,
) -> AnyResult<Option<DesktopLoginSessionRow>> {
sqlx::query_as::<_, DesktopLoginSessionRow>(
r#"
SELECT
session_id, oauth_state, pkce_verifier, device_name, platform, client_version,
device_fingerprint, status, error_message, user_id, device_id, device_token,
device_token_hash, expires_at
FROM desktop_login_sessions
WHERE session_id = $1
FOR UPDATE
"#,
)
.bind(session_id)
.fetch_optional(&mut **tx)
.await
.context("failed to lock desktop login session")
}
async fn fetch_desktop_login_session_by_state(
tx: &mut Transaction<'_, Postgres>,
oauth_state: &str,
) -> AnyResult<Option<DesktopLoginSessionRow>> {
sqlx::query_as::<_, DesktopLoginSessionRow>(
r#"
SELECT
session_id, oauth_state, pkce_verifier, device_name, platform, client_version,
device_fingerprint, status, error_message, user_id, device_id, device_token,
device_token_hash, expires_at
FROM desktop_login_sessions
WHERE oauth_state = $1
FOR UPDATE
"#,
)
.bind(oauth_state)
.fetch_optional(&mut **tx)
.await
.context("failed to load desktop login session by oauth state")
}
async fn mark_login_session_failed(
tx: &mut Transaction<'_, Postgres>,
session_id: &str,
message: &str,
) -> AnyResult<()> {
sqlx::query(
"UPDATE desktop_login_sessions SET status = $2, error_message = $3, updated_at = NOW() WHERE session_id = $1",
)
.bind(session_id)
.bind(LOGIN_STATUS_FAILED)
.bind(message)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn mark_login_session_expired(
tx: &mut Transaction<'_, Postgres>,
session_id: &str,
) -> AnyResult<()> {
sqlx::query(
"UPDATE desktop_login_sessions SET status = $2, error_message = $3, updated_at = NOW() WHERE session_id = $1",
)
.bind(session_id)
.bind(LOGIN_STATUS_EXPIRED)
.bind("login session expired")
.execute(&mut **tx)
.await?;
Ok(())
}
async fn mark_login_session_succeeded(
tx: &mut Transaction<'_, Postgres>,
session_id: &str,
user_id: Uuid,
device_id: Uuid,
device_token: String,
device_token_hash: String,
) -> AnyResult<()> {
sqlx::query(
r#"
UPDATE desktop_login_sessions
SET status = $2,
user_id = $3,
device_id = $4,
device_token = $5,
device_token_hash = $6,
updated_at = NOW()
WHERE session_id = $1
"#,
)
.bind(session_id)
.bind(LOGIN_STATUS_SUCCEEDED)
.bind(user_id)
.bind(device_id)
.bind(device_token)
.bind(device_token_hash)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn exchange_google_auth_code(
http: &Client,
google: &GoogleOAuthConfig,
code: &str,
code_verifier: &str,
) -> AnyResult<GoogleTokenResponse> {
http.post(&google.token_uri)
.form(&[
("client_id", google.client_id.clone()),
("client_secret", google.client_secret.clone()),
("code", code.to_string()),
("code_verifier", code_verifier.to_string()),
("grant_type", "authorization_code".to_string()),
("redirect_uri", google.redirect_uri.clone()),
])
.send()
.await
.context("failed to exchange google auth code")?
.error_for_status()
.context("google token exchange failed")?
.json::<GoogleTokenResponse>()
.await
.context("failed to decode google token response")
}
async fn fetch_google_userinfo(http: &Client, access_token: &str) -> AnyResult<GoogleUserInfo> {
http.get("https://openidconnect.googleapis.com/v1/userinfo")
.bearer_auth(access_token)
.send()
.await
.context("failed to request google userinfo")?
.error_for_status()
.context("google userinfo request failed")?
.json::<GoogleUserInfo>()
.await
.context("failed to decode google userinfo")
}
async fn require_auth(
pool: &PgPool,
headers: &HeaderMap,
@@ -465,6 +943,37 @@ async fn upsert_user_from_google(pool: &PgPool, google_user: &GoogleUserInfo) ->
.context("failed to create user from google login")
}
async fn upsert_google_oauth_account(
pool: &PgPool,
user_id: Uuid,
google_user: &GoogleUserInfo,
) -> AnyResult<()> {
sqlx::query(
r#"
INSERT INTO oauth_accounts (user_id, provider, provider_id, email, name)
VALUES ($1, 'google', $2, $3, $4)
ON CONFLICT (provider, provider_id)
DO UPDATE SET
user_id = EXCLUDED.user_id,
email = EXCLUDED.email,
name = EXCLUDED.name
"#,
)
.bind(user_id)
.bind(&google_user.sub)
.bind(&google_user.email)
.bind(
google_user
.name
.clone()
.unwrap_or_else(|| google_user.email.clone()),
)
.execute(pool)
.await
.context("failed to upsert google oauth account")?;
Ok(())
}
async fn upsert_device_for_login(
pool: &PgPool,
user_id: Uuid,
@@ -556,6 +1065,28 @@ async fn issue_device_login_token(
Ok(device_token)
}
async fn consume_device_token_for_poll(
tx: &mut Transaction<'_, Postgres>,
session_id: &str,
) -> AnyResult<String> {
let token = sqlx::query_scalar::<_, Option<String>>(
"SELECT device_token FROM desktop_login_sessions WHERE session_id = $1 FOR UPDATE",
)
.bind(session_id)
.fetch_one(&mut **tx)
.await?
.context("device token already consumed")?;
sqlx::query(
"UPDATE desktop_login_sessions SET device_token = NULL, updated_at = NOW() WHERE session_id = $1",
)
.bind(session_id)
.execute(&mut **tx)
.await?;
Ok(token)
}
fn internal_error<E: std::fmt::Display>(error: E) -> (StatusCode, Json<serde_json::Value>) {
(
StatusCode::INTERNAL_SERVER_ERROR,