3 Commits

Author SHA1 Message Date
arrelin
7c352d9e82 mobile update 2026-03-10 14:59:04 +03:00
arrelin
035e6b20c7 mobile update 2026-03-10 14:45:08 +03:00
arrelin
1832997ebe mobile update 2026-03-10 14:28:24 +03:00
8 changed files with 175 additions and 85 deletions

View File

@@ -26,3 +26,7 @@ oauth2 = { version = "5.0.0", features = ["reqwest"] }
reqwest = { version = "0.13.1", features = ["json"] } reqwest = { version = "0.13.1", features = ["json"] }
rand = "0.9.2" rand = "0.9.2"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
sha2 = "0.10"
hex = "0.4"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@@ -1,14 +1,10 @@
use axum::{ use axum::{
routing::{get, post, put, delete}, routing::{get, post, put, delete},
Router, middleware as axum_middleware, Router, middleware as axum_middleware,
Extension,
}; };
use sea_orm::{sqlx, Database, DatabaseConnection, DbErr}; use sea_orm::{sqlx, Database, DatabaseConnection, DbErr};
use sea_orm_migration::prelude::*; use sea_orm_migration::prelude::*;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use utoipa::OpenApi; use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi; use utoipa_swagger_ui::SwaggerUi;
use tower_sessions::{Expiry, SessionManagerLayer, cookie::SameSite}; use tower_sessions::{Expiry, SessionManagerLayer, cookie::SameSite};
@@ -18,12 +14,6 @@ use time::Duration;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use axum::http::{Method, HeaderValue}; use axum::http::{Method, HeaderValue};
pub enum MobileStoreEntry {
Csrf { created_at: Instant },
AuthToken { user_id: i32, created_at: Instant },
}
pub type MobileTokenStore = Arc<Mutex<HashMap<String, MobileStoreEntry>>>;
pub mod models; pub mod models;
pub mod services; pub mod services;
@@ -179,13 +169,10 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
.layer(auth_layer.clone()) .layer(auth_layer.clone())
.with_state(db.clone()); .with_state(db.clone());
let mobile_token_store: MobileTokenStore = Arc::new(Mutex::new(HashMap::new()));
let oauth_routes = Router::new() let oauth_routes = Router::new()
.route("/auth/google", get(routes::oauth::google_auth)) .route("/auth/google", get(routes::oauth::google_auth))
.route("/auth/google/callback", get(routes::oauth::google_callback)) .route("/auth/google/callback", get(routes::oauth::google_callback))
.route("/auth/mobile-callback", get(routes::oauth::mobile_callback)) .route("/auth/mobile-callback", get(routes::oauth::mobile_callback))
.layer(Extension(mobile_token_store))
.layer(auth_layer.clone()) .layer(auth_layer.clone())
.with_state(db.clone()); .with_state(db.clone());
@@ -244,7 +231,7 @@ pub async fn create_app(db: DatabaseConnection) -> Result<Router, DbErr> {
.url("/api-docs/openapi.json", ApiDoc::openapi()); .url("/api-docs/openapi.json", ApiDoc::openapi());
let allowed_origins = std::env::var("ALLOWED_ORIGINS") let allowed_origins = std::env::var("ALLOWED_ORIGINS")
.unwrap_or_else(|_| "http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:8080".to_string()); .unwrap_or_else(|_| "http://localhost:3000,http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:8080,http://localhost:1420,http://tauri.localhost,https://tauri.localhost".to_string());
let origins: Vec<HeaderValue> = allowed_origins let origins: Vec<HeaderValue> = allowed_origins
.split(',') .split(',')

View File

@@ -3,6 +3,13 @@ use sea_orm::DbErr;
use sea_orm_migration::prelude::*; use sea_orm_migration::prelude::*;
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), DbErr> { async fn main() -> Result<(), DbErr> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "family_budget=debug,info".parse().unwrap()),
)
.init();
let db = establish_connection().await?; let db = establish_connection().await?;
println!("Successfully connected to database!"); println!("Successfully connected to database!");

View File

@@ -2,23 +2,82 @@ use axum::{
extract::{Query, State}, extract::{Query, State},
http::StatusCode, http::StatusCode,
response::{Html, IntoResponse, Redirect, Response}, response::{Html, IntoResponse, Redirect, Response},
Extension,
Json, Json,
}; };
use axum_login::AuthSession; use axum_login::AuthSession;
use sea_orm::{DatabaseConnection, EntityTrait}; use sea_orm::{DatabaseConnection, EntityTrait};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tower_sessions::Session; use tower_sessions::Session;
use tracing::{info, warn};
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::auth::AuthBackend; use crate::auth::AuthBackend;
use crate::models::User; use crate::models::User;
use crate::services::OAuthService; use crate::services::OAuthService;
use crate::{MobileStoreEntry, MobileTokenStore};
const CSRF_TOKEN_KEY: &str = "oauth_csrf_token"; const CSRF_TOKEN_KEY: &str = "oauth_csrf_token";
const FRONTEND_URL_KEY: &str = "oauth_frontend_url"; 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<i32> {
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)] #[derive(Debug, Deserialize, ToSchema)]
pub struct GoogleAuthQuery { pub struct GoogleAuthQuery {
pub redirect_url: Option<String>, pub redirect_url: Option<String>,
@@ -41,7 +100,8 @@ pub struct OAuthUrlResponse {
path = "/auth/google", path = "/auth/google",
tag = "auth", tag = "auth",
params( params(
("redirect_url" = Option<String>, Query, description = "Frontend URL to redirect after auth") ("redirect_url" = Option<String>, Query, description = "Frontend URL to redirect after auth"),
("mobile" = Option<bool>, Query, description = "Mobile OAuth flow")
), ),
responses( responses(
(status = 200, description = "Returns Google OAuth URL", body = OAuthUrlResponse) (status = 200, description = "Returns Google OAuth URL", body = OAuthUrlResponse)
@@ -49,30 +109,30 @@ pub struct OAuthUrlResponse {
)] )]
pub async fn google_auth( pub async fn google_auth(
session: Session, session: Session,
Extension(token_store): Extension<MobileTokenStore>,
Query(query): Query<GoogleAuthQuery>, Query(query): Query<GoogleAuthQuery>,
) -> Result<Json<OAuthUrlResponse>, StatusCode> { ) -> Result<Json<OAuthUrlResponse>, StatusCode> {
let oauth_service = OAuthService::new(); let oauth_service = OAuthService::new();
let (auth_url, csrf_token) = oauth_service.get_auth_url();
if query.mobile.unwrap_or(false) { if query.mobile.unwrap_or(false) {
let mut store = token_store.lock().unwrap(); let nonce = uuid::Uuid::new_v4().to_string();
store.insert( let mobile_state = make_mobile_csrf_state(&nonce);
format!("csrf:{}", csrf_token.secret()), let auth_url = oauth_service.get_auth_url_with_state(mobile_state);
MobileStoreEntry::Csrf { created_at: std::time::Instant::now() }, info!("mobile google_auth: generated signed state for nonce={}", nonce);
); return Ok(Json(OAuthUrlResponse { url: auth_url }));
} else { }
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 session
.insert(CSRF_TOKEN_KEY, csrf_token.secret().clone()) .insert(FRONTEND_URL_KEY, redirect_url)
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .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 })) Ok(Json(OAuthUrlResponse { url: auth_url }))
@@ -91,34 +151,25 @@ pub async fn google_callback(
mut auth_session: AuthSession<AuthBackend>, mut auth_session: AuthSession<AuthBackend>,
session: Session, session: Session,
State(db): State<DatabaseConnection>, State(db): State<DatabaseConnection>,
Extension(token_store): Extension<MobileTokenStore>,
Query(query): Query<GoogleCallbackQuery>, Query(query): Query<GoogleCallbackQuery>,
) -> Result<Response, StatusCode> { ) -> Result<Response, StatusCode> {
let session_csrf: Option<String> = session let is_mobile = verify_mobile_csrf_state(&query.state);
.get(CSRF_TOKEN_KEY) info!("google_callback: state={} is_mobile={}", &query.state[..query.state.len().min(20)], is_mobile);
.await
.unwrap_or(None);
let is_mobile; if !is_mobile {
let csrf_valid; let session_csrf: Option<String> = session
.get(CSRF_TOKEN_KEY)
if let Some(csrf) = session_csrf { .await
is_mobile = false; .unwrap_or(None);
csrf_valid = csrf == query.state;
session.remove::<String>(CSRF_TOKEN_KEY).await.ok(); session.remove::<String>(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 { match session_csrf {
return Err(StatusCode::UNAUTHORIZED); Some(csrf) if csrf == query.state => {}
_ => {
warn!("google_callback: CSRF mismatch, session_csrf={:?}", session_csrf.as_deref().map(|s| &s[..s.len().min(10)]));
return Err(StatusCode::UNAUTHORIZED);
}
}
} }
let frontend_url: Option<String> = session let frontend_url: Option<String> = session
@@ -145,14 +196,8 @@ pub async fn google_callback(
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if is_mobile { if is_mobile {
let token = uuid::Uuid::new_v4().to_string(); let token = make_auth_token(user.id);
{ info!("google_callback: mobile auth for user_id={}, token_prefix={}", user.id, &token[..token.len().min(20)]);
let mut store = token_store.lock().unwrap();
store.insert(
token.clone(),
MobileStoreEntry::AuthToken { user_id: user.id, created_at: std::time::Instant::now() },
);
}
let deep_link = format!("com.arrelin.family-budget-android://auth?token={}", token); let deep_link = format!("com.arrelin.family-budget-android://auth?token={}", token);
let html = format!( let html = format!(
r#"<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url={0}"></head><body><script>window.location="{0}"</script></body></html>"#, r#"<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url={0}"></head><body><script>window.location="{0}"</script></body></html>"#,
@@ -189,26 +234,19 @@ pub struct MobileCallbackQuery {
pub async fn mobile_callback( pub async fn mobile_callback(
mut auth_session: AuthSession<AuthBackend>, mut auth_session: AuthSession<AuthBackend>,
session: Session,
State(db): State<DatabaseConnection>, State(db): State<DatabaseConnection>,
Extension(token_store): Extension<MobileTokenStore>,
Query(query): Query<MobileCallbackQuery>, Query(query): Query<MobileCallbackQuery>,
) -> Result<Json<serde_json::Value>, StatusCode> { ) -> Result<Json<serde_json::Value>, StatusCode> {
let user_id = { info!("mobile_callback: received token_prefix={}", &query.token[..query.token.len().min(20)]);
let mut store = token_store.lock().unwrap(); let user_id = match verify_auth_token(&query.token) {
match store.get(&query.token) { Some(id) => id,
Some(MobileStoreEntry::AuthToken { user_id, created_at }) None => {
if created_at.elapsed().as_secs() < 300 => warn!("mobile_callback: token verification failed for token={}", &query.token[..query.token.len().min(40)]);
{ return Err(StatusCode::UNAUTHORIZED);
let uid = *user_id;
store.remove(&query.token);
uid
}
_ => {
store.remove(&query.token);
return Err(StatusCode::UNAUTHORIZED);
}
} }
}; };
info!("mobile_callback: token valid for user_id={}", user_id);
let user = User::find_by_id(user_id) let user = User::find_by_id(user_id)
.one(&db) .one(&db)
@@ -221,5 +259,17 @@ pub async fn mobile_callback(
.await .await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
if let Some(family_id) = user.family_id {
let mut authorized_families: Vec<i32> = 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}))) Ok(Json(serde_json::json!({"success": true})))
} }

View File

@@ -44,6 +44,26 @@ impl OAuthService {
(auth_url.to_string(), csrf_token) (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<String, OAuthError> { pub async fn exchange_code(&self, code: String) -> Result<String, OAuthError> {
let client_id = std::env::var("GOOGLE_CLIENT_ID") let client_id = std::env::var("GOOGLE_CLIENT_ID")
.expect("GOOGLE_CLIENT_ID must be set"); .expect("GOOGLE_CLIENT_ID must be set");

View File

@@ -26,16 +26,24 @@ export default function Login() {
const url = Array.isArray(urls) ? urls[0] : urls; const url = Array.isArray(urls) ? urls[0] : urls;
if (!url.startsWith(DEEP_LINK_SCHEME)) return; if (!url.startsWith(DEEP_LINK_SCHEME)) return;
const token = new URL(url).searchParams.get('token'); let token: string | null;
if (!token) return; try {
token = new URL(url).searchParams.get('token');
} catch {
setError(t('login.authError'));
return;
}
if (!token) { setError(t('login.authError')); return; }
try { try {
setLoading(true); setLoading(true);
await authApi.mobileCallback(token); await authApi.mobileCallback(token);
const me = await authApi.me(); const me = await authApi.me();
setUser(me.data); setUser(me.data);
} catch { } catch (err: any) {
setError(t('login.authError')); const status = err?.response?.status;
const msg = err?.response?.data ? JSON.stringify(err.response.data) : err?.message;
setError(`${status ?? 'network'}: ${msg}`);
setLoading(false); setLoading(false);
} }
}); });

View File

@@ -12,6 +12,20 @@ export default defineConfig({
? 'https://family-budget.duckdns.org' ? 'https://family-budget.duckdns.org'
: 'http://localhost:8080', : 'http://localhost:8080',
changeOrigin: true, changeOrigin: true,
configure: (proxy) => {
if (process.env.TAURI_DEV_HOST) {
proxy.on('proxyRes', (proxyRes) => {
const cookies = proxyRes.headers['set-cookie'];
if (cookies) {
proxyRes.headers['set-cookie'] = cookies.map(cookie =>
cookie
.replace(/;\s*Secure/gi, '')
.replace(/;\s*Domain=[^;]*/gi, '')
);
}
});
}
},
} }
} }
} }

View File

@@ -6,7 +6,7 @@
"build": { "build": {
"beforeDevCommand": "npm run dev --prefix ../frontend", "beforeDevCommand": "npm run dev --prefix ../frontend",
"devUrl": "http://localhost:5173", "devUrl": "http://localhost:5173",
"beforeBuildCommand": "npm run build --prefix ../frontend", "beforeBuildCommand": "VITE_API_BASE_URL=https://family-budget.duckdns.org/api npm run build --prefix ../frontend",
"frontendDist": "../frontend/dist" "frontendDist": "../frontend/dist"
}, },
"app": { "app": {