liuq 3 месяцев назад
Родитель
Сommit
3a7bcab18b

+ 35 - 5
backend/app/api/v1/deps.py

@@ -3,7 +3,7 @@ from fastapi import Depends, HTTPException, status, Response
 from fastapi.security import OAuth2PasswordBearer, APIKeyHeader
 from jose import jwt, JWTError
 from sqlalchemy.orm import Session
-from datetime import datetime
+from datetime import datetime, timedelta
 from app.core import security
 from app.core.config import settings
 from app.core.database import SessionLocal
@@ -44,13 +44,28 @@ def get_current_user(
         # Sliding Expiration Check
         # If token is valid but expires soon (e.g. less than half of total lifetime), renew it
         exp = payload.get("exp")
+        is_long_term = payload.get("long_term", False)
+        
         if exp:
             now = datetime.now().timestamp()
             remaining_seconds = exp - now
+            
+            threshold = settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 / 2
+            if is_long_term:
+                threshold = settings.ACCESS_TOKEN_EXPIRE_MINUTES_LONG * 60 / 2
+                
             # If remaining time is less than half of the configured expiration time
-            if remaining_seconds < (settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 / 2):
+            if remaining_seconds < threshold:
+                expires_delta = None
+                if is_long_term:
+                     expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES_LONG)
+                
                 # Issue new token
-                new_token = security.create_access_token(subject=token_data.sub)
+                new_token = security.create_access_token(
+                    subject=token_data.sub,
+                    expires_delta=expires_delta,
+                    is_long_term=is_long_term
+                )
                 # Set in response header
                 response.headers["X-New-Token"] = new_token
                 
@@ -98,11 +113,26 @@ def get_current_user_optional(
 
         # Sliding Expiration Check for Optional Auth
         exp = payload.get("exp")
+        is_long_term = payload.get("long_term", False)
+        
         if exp:
             now = datetime.now().timestamp()
             remaining_seconds = exp - now
-            if remaining_seconds < (settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 / 2):
-                new_token = security.create_access_token(subject=token_data.sub)
+            
+            threshold = settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60 / 2
+            if is_long_term:
+                threshold = settings.ACCESS_TOKEN_EXPIRE_MINUTES_LONG * 60 / 2
+
+            if remaining_seconds < threshold:
+                expires_delta = None
+                if is_long_term:
+                     expires_delta = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES_LONG)
+                     
+                new_token = security.create_access_token(
+                    subject=token_data.sub, 
+                    expires_delta=expires_delta,
+                    is_long_term=is_long_term
+                )
                 response.headers["X-New-Token"] = new_token
 
     except (JWTError, Exception):

+ 11 - 1
backend/app/api/v1/endpoints/auth.py

@@ -1,10 +1,12 @@
 from typing import Any
+from datetime import timedelta
 from fastapi import APIRouter, Depends, HTTPException
 from fastapi.security import OAuth2PasswordRequestForm
 from sqlalchemy.orm import Session
 
 from app.api.v1 import deps
 from app.core import security
+from app.core.config import settings
 from app.models.user import User
 from app.models.application import Application
 from app.schemas.token import Token, LoginRequest, AppLoginRequest
@@ -57,7 +59,15 @@ def login_json(
     if user.status == "DISABLED":
         raise HTTPException(status_code=400, detail="账户已禁用")
         
-    access_token = security.create_access_token(subject=user.id)
+    access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
+    if login_data.remember_me:
+        access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES_LONG)
+        
+    access_token = security.create_access_token(
+        subject=user.id, 
+        expires_delta=access_token_expires,
+        is_long_term=login_data.remember_me
+    )
     return {
         "access_token": access_token,
         "token_type": "bearer",

+ 10 - 1
backend/app/api/v1/endpoints/simple_auth.py

@@ -1,11 +1,13 @@
 from typing import Optional, List
 import json
+from datetime import timedelta
 from fastapi import APIRouter, Depends, HTTPException, Body
 from sqlalchemy.orm import Session
 from pydantic import BaseModel
 
 from app.api.v1 import deps
 from app.core import security
+from app.core.config import settings
 from app.core.utils import generate_english_name
 from app.models.user import User, UserRole, UserStatus
 from app.models.application import Application
@@ -79,7 +81,14 @@ def login_with_password(
             raise HTTPException(status_code=400, detail="用户已禁用")
 
         # Generate JWT Access Token
-        access_token = security.create_access_token(user.id)
+        access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
+        if req.remember_me:
+             access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES_LONG)
+        access_token = security.create_access_token(
+            user.id, 
+            expires_delta=access_token_expires,
+            is_long_term=req.remember_me
+        )
         
         # Log Success
         LoginLogService.create_log(db, log_create)

+ 1 - 0
backend/app/core/config.py

@@ -30,6 +30,7 @@ class Settings(BaseSettings):
     SECRET_KEY: str = "change_me_in_production"
     ALGORITHM: str = "HS256"
     ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
+    ACCESS_TOKEN_EXPIRE_MINUTES_LONG: int = 43200 # 30 days
     
     # CORS
     BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []

+ 4 - 1
backend/app/core/security.py

@@ -14,13 +14,16 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
 def get_password_hash(password: str) -> str:
     return pwd_context.hash(password)
 
-def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None) -> str:
+def create_access_token(subject: Union[str, Any], expires_delta: timedelta = None, is_long_term: bool = False) -> str:
     if expires_delta:
         expire = datetime.now() + expires_delta
     else:
         expire = datetime.now() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
     
     to_encode = {"exp": expire, "sub": str(subject)}
+    if is_long_term:
+        to_encode["long_term"] = True
+        
     encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
     return encoded_jwt
 

+ 4 - 0
backend/app/schemas/simple_auth.py

@@ -38,6 +38,10 @@ class PasswordLoginRequest(BaseModel):
         ..., 
         description="用户密码"
     )
+    remember_me: bool = Field(
+        False,
+        description="是否记住登录(长期有效)"
+    )
     sign: Optional[str] = Field(
         None, 
         description="签名(可选)。如果提供,必须同时提供timestamp。用于服务端安全调用"

+ 2 - 0
backend/app/schemas/token.py

@@ -12,6 +12,8 @@ class LoginRequest(BaseModel):
     mobile: str
     password: str
     app_id: Optional[str] = None  # Optional for direct platform login
+    remember_me: bool = False
+
 
 class AppLoginRequest(BaseModel):
     app_id: str

+ 5 - 0
frontend/nginx.conf

@@ -15,6 +15,11 @@ server {
         proxy_set_header Host $host;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        
+        # WebSocket Support
+        proxy_http_version 1.1;
+        proxy_set_header Upgrade $http_upgrade;
+        proxy_set_header Connection "upgrade";
     }
 }
 

+ 7 - 2
frontend/src/views/Login.vue

@@ -22,6 +22,9 @@
             show-password
           />
         </el-form-item>
+        <el-form-item>
+          <el-checkbox v-model="loginForm.remember_me">记住我 (30天免登录)</el-checkbox>
+        </el-form-item>
         <el-form-item>
           <el-button type="primary" :loading="loading" @click="handleLogin" style="width: 100%">
             登录
@@ -49,7 +52,8 @@ const authStore = useAuthStore()
 
 const loginForm = reactive({
   mobile: '',
-  password: ''
+  password: '',
+  remember_me: true
 })
 
 const loading = ref(false)
@@ -89,7 +93,8 @@ const handleLogin = async () => {
       // Platform Login
       await authStore.login({
         mobile: loginForm.mobile,
-        password: loginForm.password
+        password: loginForm.password,
+        remember_me: loginForm.remember_me
       })
       ElMessage.success('登录成功')
       router.push('/dashboard')

+ 8 - 2
frontend/src/views/admin/maintenance/SystemLogs.vue

@@ -129,9 +129,15 @@ const getWsUrl = () => {
   // Construct WS URL from VITE_API_BASE_URL
   const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1'
   const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-  // Remove http: or https:
-  const urlBody = baseUrl.replace(/^https?:/, '')
   const token = localStorage.getItem('token')
+  
+  // If baseUrl is relative (starts with /), prepend current host
+  if (baseUrl.startsWith('/')) {
+    return `${wsProtocol}//${window.location.host}${baseUrl}/system-logs/stream?token=${token}`
+  }
+  
+  // If baseUrl is absolute (starts with http), replace protocol
+  const urlBody = baseUrl.replace(/^https?:/, '')
   return `${wsProtocol}${urlBody}/system-logs/stream?token=${token}`
 }