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:
@@ -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" }
|
||||
|
||||
@@ -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,
|
||||
|
||||
2
apps/desktop/dist/index.html
vendored
2
apps/desktop/dist/index.html
vendored
@@ -31,7 +31,7 @@
|
||||
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
|
||||
</svg>
|
||||
<span>使用 Google 登录</span>
|
||||
<span>前往浏览器登录</span>
|
||||
</button>
|
||||
</div>
|
||||
<p id="login-error" class="error-text hidden"></p>
|
||||
|
||||
2
apps/desktop/dist/main.js
vendored
2
apps/desktop/dist/main.js
vendored
@@ -510,6 +510,7 @@ async function doDemoLogin() {
|
||||
if (!invoke) return;
|
||||
setLoginError("");
|
||||
loginButton.disabled = true;
|
||||
loginButton.textContent = "正在打开浏览器...";
|
||||
try {
|
||||
let data = await invoke("continue_demo_login");
|
||||
data = await ensureUnlockedShell(data);
|
||||
@@ -518,6 +519,7 @@ async function doDemoLogin() {
|
||||
} catch (error) {
|
||||
setLoginError(String(error));
|
||||
} finally {
|
||||
loginButton.textContent = "前往浏览器登录";
|
||||
loginButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ mod local_vault;
|
||||
mod session_api;
|
||||
|
||||
use anyhow::{Context, Result as AnyResult, anyhow};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use local_vault::{
|
||||
LocalDetailField, LocalEntryDetail, LocalEntryDraft, LocalEntryQuery, LocalHistoryItem,
|
||||
LocalSecretDraft, LocalSecretUpdateDraft, LocalSecretValue, LocalVault,
|
||||
@@ -24,7 +23,6 @@ use secrets_device_auth::new_device_fingerprint;
|
||||
use secrets_domain::KdfConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use session_api::start_desktop_session_server;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
@@ -32,11 +30,7 @@ use std::{
|
||||
sync::{Arc, RwLock},
|
||||
};
|
||||
use tauri::Manager;
|
||||
use tokio::{
|
||||
io::{AsyncReadExt, AsyncWriteExt},
|
||||
net::TcpListener,
|
||||
};
|
||||
use url::Url;
|
||||
use tokio::time::{Duration, sleep};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct DesktopState {
|
||||
@@ -200,33 +194,28 @@ struct DemoLoginResponse {
|
||||
device_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GoogleDesktopClientFile {
|
||||
installed: GoogleDesktopClient,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GoogleDesktopClient {
|
||||
client_id: String,
|
||||
client_secret: Option<String>,
|
||||
auth_uri: String,
|
||||
token_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GoogleTokenResponse {
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct GoogleDesktopLoginRequest {
|
||||
access_token: String,
|
||||
struct DesktopLoginStartRequest {
|
||||
device_name: String,
|
||||
platform: String,
|
||||
client_version: String,
|
||||
device_fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DesktopLoginStartResponse {
|
||||
session_id: String,
|
||||
auth_url: String,
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DesktopLoginPollResponse {
|
||||
status: String,
|
||||
device_token: Option<String>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct EntryListQuery {
|
||||
folder: Option<String>,
|
||||
@@ -320,8 +309,7 @@ async fn continue_demo_login(
|
||||
window: tauri::Window,
|
||||
state: tauri::State<'_, DesktopState>,
|
||||
) -> Result<AppBootstrap, String> {
|
||||
let google_client = load_google_desktop_client().map_err(|err| err.to_string())?;
|
||||
let payload = complete_google_desktop_login(&state, &google_client)
|
||||
let payload = complete_hosted_google_desktop_login(&state)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
@@ -599,21 +587,9 @@ async fn bootstrap_from_disk(state: &DesktopState) -> AnyResult<AppBootstrap> {
|
||||
}
|
||||
|
||||
if !vault_state.unlocked {
|
||||
let user = authorized_get(state, "/me")
|
||||
.await?
|
||||
.json::<UserProfile>()
|
||||
.await
|
||||
.context("failed to decode current user")?;
|
||||
return Ok(AppBootstrap {
|
||||
logged_in: true,
|
||||
shell: Some(ShellData {
|
||||
user,
|
||||
folders: Vec::new(),
|
||||
entry_types: Vec::new(),
|
||||
entries: Vec::new(),
|
||||
selected_entry_id: None,
|
||||
selected_entry: None,
|
||||
}),
|
||||
shell: None,
|
||||
vault: Some(VaultStatus {
|
||||
unlocked: false,
|
||||
has_master_password: vault_state.has_master_password,
|
||||
@@ -929,118 +905,11 @@ fn apply_mcp_config_to_all() -> AnyResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..")
|
||||
}
|
||||
|
||||
fn default_google_oauth_client_filename() -> &'static str {
|
||||
"client_secret_738964258008-0svfo4g7ta347iedrf6r9see87a8u3hn.apps.googleusercontent.com.json"
|
||||
}
|
||||
|
||||
fn resolve_google_oauth_client_path() -> AnyResult<PathBuf> {
|
||||
let configured = std::env::var("GOOGLE_OAUTH_CLIENT_FILE").ok();
|
||||
let mut candidates = Vec::new();
|
||||
let repo_root = workspace_root();
|
||||
|
||||
if let Some(raw_path) = configured.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
let path = PathBuf::from(raw_path);
|
||||
if path.is_absolute() {
|
||||
candidates.push(path);
|
||||
} else {
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
candidates.push(cwd.join(&path));
|
||||
}
|
||||
candidates.push(repo_root.join(&path));
|
||||
}
|
||||
} else {
|
||||
candidates.push(repo_root.join(default_google_oauth_client_filename()));
|
||||
}
|
||||
|
||||
candidates.dedup();
|
||||
|
||||
candidates.into_iter().find(|path| path.exists()).with_context(|| {
|
||||
let configured_hint = configured
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!("当前 GOOGLE_OAUTH_CLIENT_FILE={value}。"))
|
||||
.unwrap_or_else(|| "当前未设置 GOOGLE_OAUTH_CLIENT_FILE。".to_string());
|
||||
format!(
|
||||
"google oauth client file not found. {} 请把 {} 放到仓库根目录,或把 GOOGLE_OAUTH_CLIENT_FILE 设置为该文件的绝对路径",
|
||||
configured_hint,
|
||||
default_google_oauth_client_filename(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn load_google_desktop_client() -> AnyResult<GoogleDesktopClient> {
|
||||
let configured = resolve_google_oauth_client_path()?;
|
||||
let raw = fs::read_to_string(&configured)
|
||||
.with_context(|| format!("failed to read {}", configured.display()))?;
|
||||
let parsed: GoogleDesktopClientFile =
|
||||
serde_json::from_str(&raw).context("failed to parse google desktop client file")?;
|
||||
Ok(parsed.installed)
|
||||
}
|
||||
|
||||
async fn complete_google_desktop_login(
|
||||
state: &DesktopState,
|
||||
google_client: &GoogleDesktopClient,
|
||||
) -> AnyResult<DemoLoginResponse> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.context("failed to bind local oauth callback listener")?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.context("failed to read callback listener address")?
|
||||
.port();
|
||||
let redirect_uri = format!("http://localhost:{port}/oauth/callback");
|
||||
let verifier = new_device_fingerprint();
|
||||
let challenge = pkce_challenge(&verifier);
|
||||
|
||||
let mut auth_url = Url::parse(&google_client.auth_uri).context("invalid google auth uri")?;
|
||||
auth_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("client_id", &google_client.client_id)
|
||||
.append_pair("redirect_uri", &redirect_uri)
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("scope", "openid email profile")
|
||||
.append_pair("code_challenge", &challenge)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("access_type", "offline")
|
||||
.append_pair("prompt", "consent");
|
||||
|
||||
open_system_browser(auth_url.as_str())?;
|
||||
let auth_code = wait_for_google_callback(listener).await?;
|
||||
|
||||
let mut form = vec![
|
||||
("client_id", google_client.client_id.clone()),
|
||||
("code", auth_code),
|
||||
("code_verifier", verifier),
|
||||
("grant_type", "authorization_code".to_string()),
|
||||
("redirect_uri", redirect_uri),
|
||||
];
|
||||
if let Some(secret) = google_client.client_secret.clone() {
|
||||
form.push(("client_secret", secret));
|
||||
}
|
||||
|
||||
let google_token = state
|
||||
async fn complete_hosted_google_desktop_login(state: &DesktopState) -> AnyResult<DemoLoginResponse> {
|
||||
let start = state
|
||||
.client
|
||||
.post(&google_client.token_uri)
|
||||
.form(&form)
|
||||
.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")?;
|
||||
|
||||
let response = state
|
||||
.client
|
||||
.post(format!("{}/auth/google/desktop-login", state.api_base))
|
||||
.json(&GoogleDesktopLoginRequest {
|
||||
access_token: google_token.access_token,
|
||||
.post(format!("{}/auth/desktop/start", state.api_base))
|
||||
.json(&DesktopLoginStartRequest {
|
||||
device_name: current_device_name(),
|
||||
platform: std::env::consts::OS.to_string(),
|
||||
client_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
@@ -1048,56 +917,50 @@ async fn complete_google_desktop_login(
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.context("failed to call google desktop login API")?;
|
||||
|
||||
response
|
||||
.context("failed to create desktop login session")?
|
||||
.error_for_status()
|
||||
.context("google desktop login failed")?
|
||||
.json::<DemoLoginResponse>()
|
||||
.context("desktop login session creation failed")?
|
||||
.json::<DesktopLoginStartResponse>()
|
||||
.await
|
||||
.context("failed to decode desktop login response")
|
||||
}
|
||||
.context("failed to decode desktop login session")?;
|
||||
|
||||
async fn wait_for_google_callback(listener: TcpListener) -> AnyResult<String> {
|
||||
let (mut socket, _) = listener
|
||||
.accept()
|
||||
.await
|
||||
.context("failed to accept oauth callback connection")?;
|
||||
let mut buffer = [0_u8; 4096];
|
||||
let read = socket
|
||||
.read(&mut buffer)
|
||||
.await
|
||||
.context("failed to read oauth callback request")?;
|
||||
let request = String::from_utf8_lossy(&buffer[..read]);
|
||||
let path = request
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_whitespace().nth(1))
|
||||
.context("invalid oauth callback request line")?;
|
||||
let callback_url = Url::parse(&format!("http://localhost{path}"))
|
||||
.context("failed to parse oauth callback url")?;
|
||||
let code = callback_url
|
||||
.query_pairs()
|
||||
.find(|(key, _)| key == "code")
|
||||
.map(|(_, value)| value.into_owned())
|
||||
.context("oauth callback missing code")?;
|
||||
let _ = &start.expires_at;
|
||||
open_system_browser(&start.auth_url)?;
|
||||
|
||||
let body = "<html><body><h3>登录成功,可以返回 Secrets。</h3></body></html>";
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
socket
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.context("failed to respond to oauth callback")?;
|
||||
Ok(code)
|
||||
}
|
||||
for _ in 0..120 {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
let poll = state
|
||||
.client
|
||||
.get(format!("{}/auth/desktop/poll", state.api_base))
|
||||
.query(&[("session_id", start.session_id.as_str())])
|
||||
.send()
|
||||
.await
|
||||
.context("failed to poll desktop login session")?
|
||||
.error_for_status()
|
||||
.context("desktop login poll failed")?
|
||||
.json::<DesktopLoginPollResponse>()
|
||||
.await
|
||||
.context("failed to decode desktop login poll response")?;
|
||||
|
||||
fn pkce_challenge(verifier: &str) -> String {
|
||||
let digest = Sha256::digest(verifier.as_bytes());
|
||||
URL_SAFE_NO_PAD.encode(digest)
|
||||
match poll.status.as_str() {
|
||||
"pending" => continue,
|
||||
"succeeded" => {
|
||||
let device_token = poll
|
||||
.device_token
|
||||
.context("desktop login succeeded without device token")?;
|
||||
return Ok(DemoLoginResponse { device_token });
|
||||
}
|
||||
"failed" | "expired" | "consumed" => {
|
||||
return Err(anyhow!(
|
||||
"{}",
|
||||
poll.error.unwrap_or_else(|| format!("desktop login {}", poll.status))
|
||||
));
|
||||
}
|
||||
other => return Err(anyhow!("unexpected desktop login status: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!("desktop login timed out"))
|
||||
}
|
||||
|
||||
fn open_system_browser(url: &str) -> AnyResult<()> {
|
||||
|
||||
Reference in New Issue
Block a user