mobile update

This commit is contained in:
arrelin
2026-03-10 14:28:24 +03:00
parent 45eefeb1f5
commit 1832997ebe
5 changed files with 134 additions and 84 deletions

View File

@@ -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<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)]
pub struct GoogleAuthQuery {
pub redirect_url: Option<String>,
@@ -41,7 +99,8 @@ pub struct OAuthUrlResponse {
path = "/auth/google",
tag = "auth",
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(
(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<MobileTokenStore>,
Query(query): Query<GoogleAuthQuery>,
) -> Result<Json<OAuthUrlResponse>, 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<AuthBackend>,
session: Session,
State(db): State<DatabaseConnection>,
Extension(token_store): Extension<MobileTokenStore>,
Query(query): Query<GoogleCallbackQuery>,
) -> Result<Response, StatusCode> {
let session_csrf: Option<String> = 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<String> = session
.get(CSRF_TOKEN_KEY)
.await
.unwrap_or(None);
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 {
return Err(StatusCode::UNAUTHORIZED);
match session_csrf {
Some(csrf) if csrf == query.state => {}
_ => return Err(StatusCode::UNAUTHORIZED),
}
}
let frontend_url: Option<String> = 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#"<!DOCTYPE html><html><head><meta http-equiv="refresh" content="0;url={0}"></head><body><script>window.location="{0}"</script></body></html>"#,
@@ -189,26 +227,11 @@ pub struct MobileCallbackQuery {
pub async fn mobile_callback(
mut auth_session: AuthSession<AuthBackend>,
session: Session,
State(db): State<DatabaseConnection>,
Extension(token_store): Extension<MobileTokenStore>,
Query(query): Query<MobileCallbackQuery>,
) -> Result<Json<serde_json::Value>, 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<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})))
}