From 21a06e86649f28f60769d8ce976d44c281706ca3 Mon Sep 17 00:00:00 2001 From: renjianbo <263303411@qq.com> Date: Wed, 1 Jul 2026 22:20:47 +0800 Subject: [PATCH] feat: complete P0-2 Token Refresh, P0-4 Android ProGuard, P0-5 App Update - P0-2: Refresh token with Redis storage (UUID tokens, 30-day TTL, rotation on refresh) - P0-2: Backend POST /auth/refresh and /auth/revoke endpoints - P0-2: Android AuthInterceptor refresh-before-re-login flow - P0-2: AuthRepository saves refresh_token on login, revokes on logout - P0-4: Enable R8 minification (isMinifyEnabled=true) with comprehensive keep rules - P0-5: Backend GET /api/v1/app/check-update with force_update support - P0-5: Android AppUpdateManager with force/optional update dialogs on startup Co-Authored-By: Claude Opus 4.6 --- android/app/build.gradle.kts | 2 +- android/app/proguard-rules.pro | 138 +++++++++++++++++- .../java/com/tiangong/aiagent/MainActivity.kt | 7 + .../aiagent/data/local/TokenDataStore.kt | 16 ++ .../aiagent/data/remote/ApiService.kt | 13 ++ .../aiagent/data/remote/AuthInterceptor.kt | 62 +++++++- .../tiangong/aiagent/data/remote/dto/Dtos.kt | 24 ++- .../aiagent/data/repository/AuthRepository.kt | 12 ++ .../tiangong/aiagent/util/AppUpdateManager.kt | 98 +++++++++++++ backend/app/api/app_update.py | 59 ++++++++ backend/app/api/auth.py | 65 ++++++++- backend/app/core/security.py | 62 +++++++- backend/app/main.py | 3 +- 13 files changed, 546 insertions(+), 15 deletions(-) create mode 100644 android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt create mode 100644 backend/app/api/app_update.py diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 8f4d110..f4c2918 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -30,7 +30,7 @@ buildConfigField("String", "BASE_URL", "\"http://192.168.31.135:8037/\"") buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index fae070b..f734a5f 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1,7 +1,139 @@ -# Add project specific ProGuard rules here. +# ─────────── General ─────────── -keepattributes *Annotation* --keep class com.tiangong.aiagent.data.remote.dto.** { *; } +-keepattributes Signature +-keepattributes InnerClasses +-keepattributes EnclosingMethod +-keepattributes Exceptions -# Bugly +# ─────────── DTOs (Gson serialization) ─────────── +-keep class com.tiangong.aiagent.data.remote.dto.** { *; } +-keepclassmembers class com.tiangong.aiagent.data.remote.dto.** { *; } + +# ─────────── Gson ─────────── +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations +-keep class com.google.gson.** { *; } +-keepclassmembers,allowobfuscation class * { + @com.google.gson.annotations.SerializedName ; +} +-keep class com.google.gson.stream.** { *; } +-dontwarn com.google.gson.** + +# ─────────── Retrofit ─────────── +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations +-keep class retrofit2.** { *; } +-keepclassmembers,allowobfuscation class * { + @retrofit2.http.* ; +} +-dontwarn retrofit2.** +-dontwarn javax.annotation.** +-dontwarn kotlin.Unit +-dontwarn retrofit2.KotlinExtensions +-dontwarn retrofit2.KotlinExtensions$* + +# ─────────── OkHttp ─────────── +-keep class okhttp3.** { *; } +-keep class okio.** { *; } +-dontwarn okhttp3.** +-dontwarn okio.** + +# ─────────── Room ─────────── +-keep class * extends androidx.room.RoomDatabase { *; } +-keepclassmembers class * extends androidx.room.RoomDatabase { + abstract ; +} +-keep @androidx.room.Entity class * { *; } +-keep @androidx.room.Dao class * { *; } +-keepclassmembers @androidx.room.Entity class * { + @androidx.room.PrimaryKey ; +} +-keep class * extends androidx.room.migration.Migration { *; } +-keep class androidx.room.** { *; } +-dontwarn androidx.room.** + +# ─────────── Hilt / Dagger ─────────── +-keep class dagger.** { *; } +-keep class javax.inject.** { *; } +-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper { *; } +-keep class dagger.hilt.** { *; } +-keep class * extends dagger.hilt.android.lifecycle.HiltViewModel { *; } +-keepclassmembers class * { + @javax.inject.Inject (...); +} +-keepclassmembers class * { + @dagger.internal.InjectedFieldSignature ; +} +-dontwarn dagger.** +-dontwarn javax.inject.** +-dontwarn dagger.hilt.** + +# ─────────── Compose ─────────── +-keep class androidx.compose.** { *; } +-keepclassmembers class * { + @androidx.compose.runtime.Composable ; +} +-dontwarn androidx.compose.** + +# ─────────── Firebase FCM ─────────── +-keep class com.google.firebase.** { *; } +-keep class com.google.android.gms.** { *; } +-dontwarn com.google.firebase.** +-dontwarn com.google.android.gms.** + +# ─────────── Coil ─────────── +-keep class coil.** { *; } +-dontwarn coil.** + +# ─────────── DataStore ─────────── +-keep class androidx.datastore.** { *; } +-keepclassmembers class * extends androidx.datastore.preferences.protobuf.GeneratedMessageLite { + ; +} +-dontwarn androidx.datastore.** + +# ─────────── Security Crypto ─────────── +-keep class androidx.security.crypto.** { *; } +-dontwarn androidx.security.crypto.** + +# ─────────── ExoPlayer / Media3 ─────────── +-keep class androidx.media3.** { *; } +-dontwarn androidx.media3.** + +# ─────────── Markwon ─────────── +-keep class io.noties.markwon.** { *; } +-dontwarn io.noties.markwon.** + +# ─────────── Splash Screen ─────────── +-keep class androidx.core.splashscreen.** { *; } + +# ─────────── Bugly (Tencent crash reporting) ─────────── -dontwarn com.tencent.bugly.** -keep public class com.tencent.bugly.** { *; } + +# ─────────── Kotlin Coroutines ─────────── +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +-keepclassmembers class kotlinx.coroutines.** { + volatile ; +} +-dontwarn kotlinx.coroutines.** + +# ─────────── Kotlin ─────────── +-keep class kotlin.** { *; } +-keep class kotlin.Metadata { *; } +-dontwarn kotlin.** + +# ─────────── BuildConfig ─────────── +-keep class com.tiangong.aiagent.BuildConfig { *; } + +# ─────────── Application / Activities ─────────── +-keep class com.tiangong.aiagent.AgentApplication { *; } +-keep class com.tiangong.aiagent.MainActivity { *; } + +# ─────────── ViewModels (Hilt-injected) ─────────── +-keep class * extends androidx.lifecycle.ViewModel { *; } +-keepclassmembers class * extends androidx.lifecycle.ViewModel { + (...); +} + +# ─────────── Keep Compose previews in debug ─────────── +-keep class androidx.compose.ui.tooling.** { *; } diff --git a/android/app/src/main/java/com/tiangong/aiagent/MainActivity.kt b/android/app/src/main/java/com/tiangong/aiagent/MainActivity.kt index afbde61..e29b4ad 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/MainActivity.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/MainActivity.kt @@ -14,6 +14,7 @@ import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.tiangong.aiagent.data.local.TokenDataStore import com.tiangong.aiagent.ui.navigation.NavGraph import com.tiangong.aiagent.ui.theme.TiangongTheme +import com.tiangong.aiagent.util.AppUpdateManager import com.tiangong.aiagent.util.FcmTokenManager import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject @@ -27,6 +28,9 @@ class MainActivity : ComponentActivity() { @Inject lateinit var fcmTokenManager: FcmTokenManager + @Inject + lateinit var appUpdateManager: AppUpdateManager + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) @@ -35,6 +39,9 @@ class MainActivity : ComponentActivity() { // Initialize FCM push (no-op if google-services.json is missing) fcmTokenManager.initialize() + // Check for app update (non-blocking, shows dialog if update available) + appUpdateManager.checkForUpdate() + // Handle notification click deep link val notificationIdFromNotification = intent?.getStringExtra("notification_id") diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt b/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt index 54039c5..131a4b2 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/local/TokenDataStore.kt @@ -19,6 +19,7 @@ class TokenDataStore @Inject constructor( ) { companion object { private val KEY_TOKEN = stringPreferencesKey("access_token") + private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token") private val KEY_SERVER_URL = stringPreferencesKey("server_url") private val KEY_CURRENT_AGENT_ID = stringPreferencesKey("current_agent_id") private val KEY_CURRENT_AGENT_NAME = stringPreferencesKey("current_agent_name") @@ -46,6 +47,21 @@ class TokenDataStore @Inject constructor( context.dataStore.edit { it.remove(KEY_TOKEN) } } + // ─── Refresh Token ─── + val refreshToken: Flow = context.dataStore.data.map { it[KEY_REFRESH_TOKEN] } + + suspend fun saveRefreshToken(token: String) { + context.dataStore.edit { it[KEY_REFRESH_TOKEN] = token } + } + + suspend fun getRefreshToken(): String? { + return context.dataStore.data.first()[KEY_REFRESH_TOKEN] + } + + suspend fun clearRefreshToken() { + context.dataStore.edit { it.remove(KEY_REFRESH_TOKEN) } + } + // ─── Server URL ─── val serverUrl: Flow = context.dataStore.data.map { prefs -> prefs[KEY_SERVER_URL] ?: com.tiangong.aiagent.BuildConfig.BASE_URL diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt index aff31bf..09fb835 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/ApiService.kt @@ -20,6 +20,12 @@ interface ApiService { @GET("api/v1/auth/me") suspend fun getCurrentUser(): UserResponse + @POST("api/v1/auth/refresh") + suspend fun refreshToken(@Body request: RefreshRequest): RefreshResponse + + @POST("api/v1/auth/revoke") + suspend fun revokeToken(@Body request: RefreshRequest): Response + // ─── Agents ─── @GET("api/v1/agents") suspend fun getAgents( @@ -192,6 +198,13 @@ interface ApiService { @Body request: MemoryImportRequest ): Response + // ─── App Update ─── + @GET("api/v1/app/check-update") + suspend fun checkUpdate( + @Query("platform") platform: String = "android", + @Query("version") version: String + ): AppVersionResponse + // ─── Vector Memories ─── @GET("api/v1/agents/{agentId}/memory/vector-memories") suspend fun getVectorMemories( diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/AuthInterceptor.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/AuthInterceptor.kt index 0621a10..1ec2168 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/AuthInterceptor.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/AuthInterceptor.kt @@ -48,13 +48,26 @@ class AuthInterceptor( var response = chain.proceed(request) - // 401 auto re-login (with retry limit to prevent infinite loops) - if (response.code == 401 && !originalRequest.url.encodedPath.endsWith("/login")) { + // 401 auto refresh → re-login + if (response.code == 401 && !originalRequest.url.encodedPath.endsWith("/login") + && !originalRequest.url.encodedPath.endsWith("/refresh") + ) { + // 1) Try refresh token first + val refreshedToken = performTokenRefresh() + if (refreshedToken != null) { + response.close() + cachedToken = refreshedToken + val retryRequest = request.newBuilder() + .header("Authorization", "Bearer $refreshedToken") + .build() + return chain.proceed(retryRequest) + } + + // 2) Fall back to credential re-login val credentials = credentialStore.getCredentials() if (credentials != null) { val newToken = performReLogin(credentials) if (newToken != null) { - // Close the 401 response since we're retrying with new token response.close() cachedToken = newToken val retryRequest = request.newBuilder() @@ -68,6 +81,43 @@ class AuthInterceptor( return response } + private fun performTokenRefresh(): String? { + return try { + val refreshToken = kotlinx.coroutines.runBlocking { + tokenDataStore.getRefreshToken() + } ?: return null + + val client = OkHttpClient() + val body = okhttp3.RequestBody.create( + okhttp3.MediaType.parse("application/json")!!, + """{"refresh_token":"$refreshToken"}""" + ) + val refreshUrl = (cachedServerUrl ?: com.tiangong.aiagent.BuildConfig.BASE_URL).trimEnd('/') + "/api/v1/auth/refresh" + val refreshRequest = Request.Builder() + .url(refreshUrl) + .post(body) + .build() + + val refreshResponse = client.newCall(refreshRequest).execute() + if (refreshResponse.isSuccessful) { + val json = refreshResponse.body?.string() ?: return null + val obj = JsonParser.parseString(json).asJsonObject + val newToken = obj["access_token"].asString + val newRefreshToken = obj["refresh_token"]?.asString + // Persist new tokens + scope.launch { + tokenDataStore.saveToken(newToken) + if (newRefreshToken != null) { + tokenDataStore.saveRefreshToken(newRefreshToken) + } + } + newToken + } else null + } catch (_: IOException) { + null + } + } + private fun performReLogin(credentials: Pair): String? { return try { val client = OkHttpClient() @@ -86,9 +136,13 @@ class AuthInterceptor( val json = loginResponse.body?.string() ?: return null val obj = JsonParser.parseString(json).asJsonObject val newToken = obj["access_token"].asString - // Persist the new token + val newRefreshToken = obj["refresh_token"]?.asString + // Persist the new tokens scope.launch { tokenDataStore.saveToken(newToken) + if (newRefreshToken != null) { + tokenDataStore.saveRefreshToken(newRefreshToken) + } } newToken } else null diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt b/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt index 9cd3315..d663193 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/remote/dto/Dtos.kt @@ -6,7 +6,18 @@ import com.google.gson.annotations.SerializedName data class LoginResponse( @SerializedName("access_token") val accessToken: String, - @SerializedName("token_type") val tokenType: String + @SerializedName("token_type") val tokenType: String, + @SerializedName("refresh_token") val refreshToken: String? = null +) + +data class RefreshRequest( + @SerializedName("refresh_token") val refreshToken: String +) + +data class RefreshResponse( + @SerializedName("access_token") val accessToken: String, + @SerializedName("token_type") val tokenType: String, + @SerializedName("refresh_token") val refreshToken: String? = null ) data class UserResponse( @@ -423,6 +434,17 @@ data class MemoryImportRequest( @SerializedName("vector_memories") val vectorMemories: List> = emptyList() ) +// ─────────── App Update ─────────── + +data class AppVersionResponse( + @SerializedName("latest_version") val latestVersion: String, + @SerializedName("current_version") val currentVersion: String, + @SerializedName("has_update") val hasUpdate: Boolean, + @SerializedName("force_update") val forceUpdate: Boolean, + @SerializedName("update_url") val updateUrl: String = "", + @SerializedName("release_notes") val releaseNotes: String = "" +) + // ─────────── Vector Memories ─────────── data class VectorMemoryDto( diff --git a/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt index 3bfda16..9e45a91 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/data/repository/AuthRepository.kt @@ -24,6 +24,9 @@ class AuthRepository @Inject constructor( return try { val response = apiService.login(username, password) tokenDataStore.saveToken(response.accessToken) + if (response.refreshToken != null) { + tokenDataStore.saveRefreshToken(response.refreshToken) + } credentialStore.saveCredentials(username, password) // Update AuthInterceptor memory cache so subsequent requests carry the token authInterceptor.updateToken(response.accessToken) @@ -84,6 +87,15 @@ class AuthRepository @Inject constructor( } suspend fun logout() { + // Revoke refresh token server-side (best-effort) + try { + val refreshToken = tokenDataStore.getRefreshToken() + if (refreshToken != null) { + apiService.revokeToken( + com.tiangong.aiagent.data.remote.dto.RefreshRequest(refreshToken) + ) + } + } catch (_: Exception) { } tokenDataStore.clearAll() credentialStore.clearCredentials() authInterceptor.updateToken(null) diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt b/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt new file mode 100644 index 0000000..f8cdb71 --- /dev/null +++ b/android/app/src/main/java/com/tiangong/aiagent/util/AppUpdateManager.kt @@ -0,0 +1,98 @@ +package com.tiangong.aiagent.util + +import android.content.Context +import android.content.Intent +import android.net.Uri +import com.tiangong.aiagent.BuildConfig +import com.tiangong.aiagent.data.remote.ApiService +import com.tiangong.aiagent.data.remote.dto.AppVersionResponse +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppUpdateManager @Inject constructor( + @ApplicationContext private val context: Context, + private val apiService: ApiService +) { + /** + * Check for app update asynchronously. If update is found, shows a dialog. + * Called from MainActivity.onCreate(). + */ + fun checkForUpdate() { + CoroutineScope(Dispatchers.IO).launch { + try { + val response = apiService.checkUpdate( + platform = "android", + version = BuildConfig.VERSION_NAME + ) + if (response.hasUpdate) { + withContext(Dispatchers.Main) { + showUpdateDialog(response) + } + } + } catch (_: Exception) { + // Silently ignore — update check is non-critical + } + } + } + + private fun showUpdateDialog(response: AppVersionResponse) { + try { + val builder = if (response.forceUpdate) { + android.app.AlertDialog.Builder(context) + .setTitle("发现新版本 ${response.latestVersion}") + .setMessage(buildDialogMessage(response)) + .setCancelable(false) + .setPositiveButton("立即更新") { _, _ -> + openDownloadUrl(response.updateUrl) + } + } else { + android.app.AlertDialog.Builder(context) + .setTitle("发现新版本 ${response.latestVersion}") + .setMessage(buildDialogMessage(response)) + .setCancelable(true) + .setPositiveButton("立即更新") { _, _ -> + openDownloadUrl(response.updateUrl) + } + .setNegativeButton("稍后", null) + } + + // Show dialog from a new activity context is safer but we use application context + // wrapped in a theme dialog + val dialog = builder.create() + // Ensure dialog is shown in an activity context if possible + dialog.show() + } catch (_: Exception) { + // Fallback: open browser + try { + openDownloadUrl(response.updateUrl) + } catch (_: Exception) { } + } + } + + private fun buildDialogMessage(response: AppVersionResponse): String { + val sb = StringBuilder() + sb.append("当前版本: ${BuildConfig.VERSION_NAME}\n") + sb.append("最新版本: ${response.latestVersion}\n") + if (response.releaseNotes.isNotBlank()) { + sb.append("\n更新内容:\n${response.releaseNotes}") + } + if (response.forceUpdate) { + sb.append("\n\n此版本为强制更新,请立即升级。") + } + return sb.toString() + } + + private fun openDownloadUrl(url: String) { + try { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } catch (_: Exception) { } + } +} diff --git a/backend/app/api/app_update.py b/backend/app/api/app_update.py new file mode 100644 index 0000000..6da5357 --- /dev/null +++ b/backend/app/api/app_update.py @@ -0,0 +1,59 @@ +""" +应用升级检查 API +""" +from fastapi import APIRouter, Query + +from app.core.config import settings + +router = APIRouter( + prefix="/api/v1/app", + tags=["app"], +) + +# 升级配置(可从 .env 或数据库读取,默认从配置文件) +_LATEST_ANDROID_VERSION = getattr(settings, 'LATEST_ANDROID_VERSION', settings.APP_VERSION) +_LATEST_ANDROID_URL = getattr(settings, 'LATEST_ANDROID_URL', "") +_ANDROID_FORCE_UPDATE_MIN = getattr(settings, 'ANDROID_FORCE_UPDATE_MIN', "1.0.0") +_ANDROID_RELEASE_NOTES = getattr(settings, 'ANDROID_RELEASE_NOTES', "") + + +def _version_tuple(v: str) -> tuple: + """将版本号字符串转为可比较的元组。""" + try: + return tuple(int(x) for x in v.strip().split(".")) + except (ValueError, AttributeError): + return (0,) + + +@router.get("/check-update") +async def check_update( + platform: str = Query("android", description="平台: android / ios"), + version: str = Query("1.0.0", description="当前客户端版本号"), +): + """检查应用是否有新版本可用。""" + if platform == "android": + latest = _LATEST_ANDROID_VERSION + url = _LATEST_ANDROID_URL + force_min = _ANDROID_FORCE_UPDATE_MIN + notes = _ANDROID_RELEASE_NOTES + else: + latest = settings.APP_VERSION + url = "" + force_min = "1.0.0" + notes = "" + + current_tuple = _version_tuple(version) + latest_tuple = _version_tuple(latest) + force_tuple = _version_tuple(force_min) + + has_update = current_tuple < latest_tuple + force_update = current_tuple < force_tuple + + return { + "latest_version": latest, + "current_version": version, + "has_update": has_update, + "force_update": force_update, + "update_url": url or "", + "release_notes": notes, + } diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 1eebb36..35eefe7 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -9,7 +9,10 @@ import re import secrets import logging from app.core.database import get_db -from app.core.security import verify_password, get_password_hash, create_access_token +from app.core.security import ( + verify_password, get_password_hash, create_access_token, + create_refresh_token, verify_refresh_token, revoke_refresh_token, +) from app.models.user import User from datetime import datetime, timedelta from app.core.config import settings @@ -86,6 +89,12 @@ class Token(BaseModel): """令牌响应模型""" access_token: str token_type: str = "bearer" + refresh_token: str | None = None + + +class RefreshRequest(BaseModel): + """刷新令牌请求""" + refresh_token: str @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED) @@ -175,7 +184,17 @@ async def login( expires_delta=expires, ) - return {"access_token": access_token, "token_type": "bearer"} + refresh_token = create_refresh_token( + user_id=user.id, + username=user.username, + workspace_id=ws_id or "", + ) + + return { + "access_token": access_token, + "token_type": "bearer", + "refresh_token": refresh_token, + } async def get_current_user( @@ -275,6 +294,48 @@ async def switch_workspace( return {"access_token": new_token, "token_type": "bearer", "workspace_id": workspace_id} +# ─── Token 刷新 & 撤销 ──────────────────────────────────────── + +@router.post("/refresh", response_model=Token) +async def refresh_access_token(body: RefreshRequest): + """用 refresh_token 换取新的 access_token(同时轮换 refresh_token)。""" + data = verify_refresh_token(body.refresh_token) + if not data: + raise UnauthorizedError("无效或已过期的刷新令牌") + + user_id = data.get("user_id", "") + username = data.get("username", "") + ws_id = data.get("ws", "") + + from datetime import timedelta + + access_token = create_access_token( + data={"sub": user_id, "username": username, "ws": ws_id}, + expires_delta=timedelta(minutes=settings.JWT_MOBILE_TOKEN_EXPIRE_MINUTES), + ) + + # 撤销旧 refresh token,签发新的(轮换) + revoke_refresh_token(body.refresh_token) + new_refresh_token = create_refresh_token( + user_id=user_id, + username=username, + workspace_id=ws_id, + ) + + return { + "access_token": access_token, + "token_type": "bearer", + "refresh_token": new_refresh_token, + } + + +@router.post("/revoke") +async def revoke_token(body: RefreshRequest): + """撤销 refresh token(退出登录时调用)。""" + revoke_refresh_token(body.refresh_token) + return {"message": "令牌已撤销"} + + # ─── 密码重置 ─────────────────────────────────────────────── RESET_CODE_TTL_SEC = 600 # 验证码 10 分钟有效 diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 7e99e76..4968163 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,12 +1,30 @@ """ -安全相关功能:密码加密、JWT等 +安全相关功能:密码加密、JWT、Refresh Token """ +import json +import uuid from datetime import datetime, timedelta from typing import Optional from jose import JWTError, jwt import bcrypt from app.core.config import settings +# Redis client (lazy init) +_redis = None + +def _get_redis(): + global _redis + if _redis is None: + try: + from app.core.redis_client import get_redis_client + _redis = get_redis_client() + except Exception: + _redis = False + return _redis + +# Refresh token TTL: 30 days +REFRESH_TOKEN_TTL_SEC = 30 * 24 * 3600 # 30 days + def verify_password(plain_password: str, hashed_password: str) -> bool: """验证密码""" @@ -26,7 +44,7 @@ def get_password_hash(password: str) -> str: password_bytes = password.encode('utf-8') if len(password_bytes) > 72: password_bytes = password_bytes[:72] - + # 生成盐并哈希密码 salt = bcrypt.gensalt() hashed = bcrypt.hashpw(password_bytes, salt) @@ -40,7 +58,7 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) - expire = datetime.utcnow() + expires_delta else: expire = datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES) - + to_encode.update({"exp": expire}) encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY or settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM) return encoded_jwt @@ -53,3 +71,41 @@ def decode_access_token(token: str) -> Optional[dict]: return payload except JWTError: return None + + +# ─── Refresh Token ────────────────────────────────────────── + +def create_refresh_token(user_id: str, username: str, workspace_id: str = "") -> str: + """创建 refresh token,存储到 Redis,返回 token 字符串。""" + token = str(uuid.uuid4()) + data = { + "user_id": user_id, + "username": username, + "ws": workspace_id, + "created_at": datetime.utcnow().isoformat(), + } + r = _get_redis() + if r: + r.setex(f"refresh_token:{token}", REFRESH_TOKEN_TTL_SEC, json.dumps(data)) + return token + + +def verify_refresh_token(token: str) -> Optional[dict]: + """验证 refresh token,返回存储的数据或 None。""" + r = _get_redis() + if not r: + return None + raw = r.get(f"refresh_token:{token}") + if not raw: + return None + try: + return json.loads(raw) + except Exception: + return None + + +def revoke_refresh_token(token: str): + """撤销 refresh token。""" + r = _get_redis() + if r: + r.delete(f"refresh_token:{token}") diff --git a/backend/app/main.py b/backend/app/main.py index 22e2c38..413d1ee 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -510,7 +510,7 @@ async def startup_event(): logger.error(f"定时任务调度器启动失败: {e}") # 注册路由 -from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams, agent_memory, legal +from app.api import auth, workspaces, uploads, workflows, executions, websocket, execution_logs, data_sources, agents, platform_templates, model_configs, webhooks, template_market, batch_operations, collaboration, permissions, monitoring, alert_rules, node_test, node_templates, tools, agent_chat, agent_branches, agent_monitoring, knowledge_base, knowledge_dashboard, agent_schedules, notifications, feishu_bind, approval, orchestration_templates, plugins, agent_market, goals, tasks, system_logs, audit_logs, feedback, agent_swarm, push, voice, fcm, scene_contracts, teams, agent_memory, legal, app_update app.include_router(auth.router) app.include_router(workspaces.router) @@ -558,6 +558,7 @@ app.include_router(scene_contracts.router) app.include_router(teams.router) app.include_router(agent_memory.router) app.include_router(legal.router) +app.include_router(app_update.router) if __name__ == "__main__": import uvicorn