From 1832997ebe0f6085cd4043779201c8c685638dd0 Mon Sep 17 00:00:00 2001 From: arrelin Date: Tue, 10 Mar 2026 14:28:24 +0300 Subject: [PATCH] mobile update --- backend/Cargo.toml | 4 +- backend/src/lib.rs | 13 -- backend/src/routes/oauth.rs | 169 ++++++++++++++++---------- backend/src/services/oauth_service.rs | 20 +++ frontend/src/pages/Login.tsx | 12 +- 5 files changed, 134 insertions(+), 84 deletions(-) diff --git a/backend/Cargo.toml b/backend/Cargo.toml index ff910c8..88d90c2 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -25,4 +25,6 @@ time = "0.3" oauth2 = { version = "5.0.0", features = ["reqwest"] } reqwest = { version = "0.13.1", features = ["json"] } rand = "0.9.2" -uuid = { version = "1", features = ["v4"] } \ No newline at end of file +uuid = { version = "1", features = ["v4"] } +sha2 = "0.10" +hex = "0.4" \ No newline at end of file diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 9b06193..265f36c 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,14 +1,10 @@ use axum::{ routing::{get, post, put, delete}, Router, middleware as axum_middleware, - Extension, }; use sea_orm::{sqlx, Database, DatabaseConnection, DbErr}; use sea_orm_migration::prelude::*; use std::net::SocketAddr; -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; -use std::time::Instant; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; use tower_sessions::{Expiry, SessionManagerLayer, cookie::SameSite}; @@ -18,12 +14,6 @@ use time::Duration; use tower_http::cors::CorsLayer; use axum::http::{Method, HeaderValue}; -pub enum MobileStoreEntry { - Csrf { created_at: Instant }, - AuthToken { user_id: i32, created_at: Instant }, -} - -pub type MobileTokenStore = Arc>>; pub mod models; pub mod services; @@ -179,13 +169,10 @@ pub async fn create_app(db: DatabaseConnection) -> Result { .layer(auth_layer.clone()) .with_state(db.clone()); - let mobile_token_store: MobileTokenStore = Arc::new(Mutex::new(HashMap::new())); - let oauth_routes = Router::new() .route("/auth/google", get(routes::oauth::google_auth)) .route("/auth/google/callback", get(routes::oauth::google_callback)) .route("/auth/mobile-callback", get(routes::oauth::mobile_callback)) - .layer(Extension(mobile_token_store)) .layer(auth_layer.clone()) .with_state(db.clone()); diff --git a/backend/src/routes/oauth.rs b/backend/src/routes/oauth.rs index ad5b156..b52ab4f 100644 --- a/backend/src/routes/oauth.rs +++ b/backend/src/routes/oauth.rs @@ -2,23 +2,81 @@ use axum::{ extract::{Query, State}, http::StatusCode, response::{Html, IntoResponse, Redirect, Response}, - Extension, Json, }; use axum_login::AuthSession; use sea_orm::{DatabaseConnection, EntityTrait}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use tower_sessions::Session; use utoipa::ToSchema; use crate::auth::AuthBackend; use crate::models::User; use crate::services::OAuthService; -use crate::{MobileStoreEntry, MobileTokenStore}; const CSRF_TOKEN_KEY: &str = "oauth_csrf_token"; const FRONTEND_URL_KEY: &str = "oauth_frontend_url"; +fn mobile_secret() -> String { + std::env::var("MOBILE_SECRET").unwrap_or_else(|_| "family-budget-mobile-secret".to_string()) +} + +fn sign(data: &str) -> String { + let secret = mobile_secret(); + let mut hasher = Sha256::new(); + hasher.update(format!("{}:{}", secret, data).as_bytes()); + hex::encode(hasher.finalize()) +} + +fn make_mobile_csrf_state(nonce: &str) -> String { + let sig = sign(&format!("csrf.mobile.{}", nonce)); + format!("mobile.{}.{}", nonce, sig) +} + +fn verify_mobile_csrf_state(state: &str) -> bool { + let mut parts = state.splitn(3, '.'); + match (parts.next(), parts.next(), parts.next()) { + (Some("mobile"), Some(nonce), Some(sig)) => { + sign(&format!("csrf.mobile.{}", nonce)) == sig + } + _ => false, + } +} + +fn make_auth_token(user_id: i32) -> String { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let payload = format!("{}.{}", user_id, timestamp); + let sig = sign(&format!("auth.{}", payload)); + format!("{}.{}", payload, sig) +} + +fn verify_auth_token(token: &str) -> Option { + let mut parts = token.splitn(3, '.'); + let user_id_str = parts.next()?; + let timestamp_str = parts.next()?; + let sig = parts.next()?; + + let payload = format!("{}.{}", user_id_str, timestamp_str); + if sign(&format!("auth.{}", payload)) != sig { + return None; + } + + let timestamp: u64 = timestamp_str.parse().ok()?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + if now.saturating_sub(timestamp) > 300 { + return None; + } + + user_id_str.parse().ok() +} + #[derive(Debug, Deserialize, ToSchema)] pub struct GoogleAuthQuery { pub redirect_url: Option, @@ -41,7 +99,8 @@ pub struct OAuthUrlResponse { path = "/auth/google", tag = "auth", params( - ("redirect_url" = Option, Query, description = "Frontend URL to redirect after auth") + ("redirect_url" = Option, Query, description = "Frontend URL to redirect after auth"), + ("mobile" = Option, Query, description = "Mobile OAuth flow") ), responses( (status = 200, description = "Returns Google OAuth URL", body = OAuthUrlResponse) @@ -49,30 +108,29 @@ pub struct OAuthUrlResponse { )] pub async fn google_auth( session: Session, - Extension(token_store): Extension, Query(query): Query, ) -> Result, StatusCode> { let oauth_service = OAuthService::new(); - let (auth_url, csrf_token) = oauth_service.get_auth_url(); if query.mobile.unwrap_or(false) { - let mut store = token_store.lock().unwrap(); - store.insert( - format!("csrf:{}", csrf_token.secret()), - MobileStoreEntry::Csrf { created_at: std::time::Instant::now() }, - ); - } else { + let nonce = uuid::Uuid::new_v4().to_string(); + let mobile_state = make_mobile_csrf_state(&nonce); + let auth_url = oauth_service.get_auth_url_with_state(mobile_state); + return Ok(Json(OAuthUrlResponse { url: auth_url })); + } + + let (auth_url, csrf_token) = oauth_service.get_auth_url(); + + session + .insert(CSRF_TOKEN_KEY, csrf_token.secret().clone()) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if let Some(redirect_url) = query.redirect_url { session - .insert(CSRF_TOKEN_KEY, csrf_token.secret().clone()) + .insert(FRONTEND_URL_KEY, redirect_url) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - - if let Some(redirect_url) = query.redirect_url { - session - .insert(FRONTEND_URL_KEY, redirect_url) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; - } } Ok(Json(OAuthUrlResponse { url: auth_url })) @@ -91,34 +149,21 @@ pub async fn google_callback( mut auth_session: AuthSession, session: Session, State(db): State, - Extension(token_store): Extension, Query(query): Query, ) -> Result { - let session_csrf: Option = session - .get(CSRF_TOKEN_KEY) - .await - .unwrap_or(None); + let is_mobile = verify_mobile_csrf_state(&query.state); - let is_mobile; - let csrf_valid; - - if let Some(csrf) = session_csrf { - is_mobile = false; - csrf_valid = csrf == query.state; + if !is_mobile { + let session_csrf: Option = session + .get(CSRF_TOKEN_KEY) + .await + .unwrap_or(None); session.remove::(CSRF_TOKEN_KEY).await.ok(); - } else { - let key = format!("csrf:{}", &query.state); - let mut store = token_store.lock().unwrap(); - csrf_valid = matches!( - store.get(&key), - Some(MobileStoreEntry::Csrf { created_at }) if created_at.elapsed().as_secs() < 300 - ); - store.remove(&key); - is_mobile = csrf_valid; - } - if !csrf_valid { - return Err(StatusCode::UNAUTHORIZED); + match session_csrf { + Some(csrf) if csrf == query.state => {} + _ => return Err(StatusCode::UNAUTHORIZED), + } } let frontend_url: Option = session @@ -145,14 +190,7 @@ pub async fn google_callback( .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; if is_mobile { - let token = uuid::Uuid::new_v4().to_string(); - { - let mut store = token_store.lock().unwrap(); - store.insert( - token.clone(), - MobileStoreEntry::AuthToken { user_id: user.id, created_at: std::time::Instant::now() }, - ); - } + let token = make_auth_token(user.id); let deep_link = format!("com.arrelin.family-budget-android://auth?token={}", token); let html = format!( r#""#, @@ -189,26 +227,11 @@ pub struct MobileCallbackQuery { pub async fn mobile_callback( mut auth_session: AuthSession, + session: Session, State(db): State, - Extension(token_store): Extension, Query(query): Query, ) -> Result, StatusCode> { - let user_id = { - let mut store = token_store.lock().unwrap(); - match store.get(&query.token) { - Some(MobileStoreEntry::AuthToken { user_id, created_at }) - if created_at.elapsed().as_secs() < 300 => - { - let uid = *user_id; - store.remove(&query.token); - uid - } - _ => { - store.remove(&query.token); - return Err(StatusCode::UNAUTHORIZED); - } - } - }; + let user_id = verify_auth_token(&query.token).ok_or(StatusCode::UNAUTHORIZED)?; let user = User::find_by_id(user_id) .one(&db) @@ -221,5 +244,17 @@ pub async fn mobile_callback( .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if let Some(family_id) = user.family_id { + let mut authorized_families: Vec = session + .get("authorized_families") + .await + .unwrap_or(None) + .unwrap_or_default(); + if !authorized_families.contains(&family_id) { + authorized_families.push(family_id); + session.insert("authorized_families", authorized_families).await.ok(); + } + } + Ok(Json(serde_json::json!({"success": true}))) } diff --git a/backend/src/services/oauth_service.rs b/backend/src/services/oauth_service.rs index 2d807e5..cd49fbf 100644 --- a/backend/src/services/oauth_service.rs +++ b/backend/src/services/oauth_service.rs @@ -44,6 +44,26 @@ impl OAuthService { (auth_url.to_string(), csrf_token) } + pub fn get_auth_url_with_state(&self, state: String) -> String { + let client_id = std::env::var("GOOGLE_CLIENT_ID") + .expect("GOOGLE_CLIENT_ID must be set"); + let client_secret = std::env::var("GOOGLE_CLIENT_SECRET") + .expect("GOOGLE_CLIENT_SECRET must be set"); + let redirect_url = std::env::var("GOOGLE_REDIRECT_URL") + .unwrap_or_else(|_| "http://localhost:8080/api/auth/google/callback".to_string()); + + let client = Self::get_client(client_id, client_secret, redirect_url); + + let (auth_url, _) = client + .authorize_url(move || CsrfToken::new(state)) + .add_scope(Scope::new("openid".to_string())) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .url(); + + auth_url.to_string() + } + pub async fn exchange_code(&self, code: String) -> Result { let client_id = std::env::var("GOOGLE_CLIENT_ID") .expect("GOOGLE_CLIENT_ID must be set"); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 777b248..9ebd797 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -26,8 +26,14 @@ export default function Login() { const url = Array.isArray(urls) ? urls[0] : urls; if (!url.startsWith(DEEP_LINK_SCHEME)) return; - const token = new URL(url).searchParams.get('token'); - if (!token) return; + let token: string | null; + try { + token = new URL(url).searchParams.get('token'); + } catch { + setError(t('login.error')); + return; + } + if (!token) { setError(t('login.error')); return; } try { setLoading(true); @@ -35,7 +41,7 @@ export default function Login() { const me = await authApi.me(); setUser(me.data); } catch { - setError(t('login.authError')); + setError(t('login.error')); setLoading(false); } });