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 fastapi.security import OAuth2PasswordBearer, APIKeyHeader
 from jose import jwt, JWTError
 from jose import jwt, JWTError
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
-from datetime import datetime
+from datetime import datetime, timedelta
 from app.core import security
 from app.core import security
 from app.core.config import settings
 from app.core.config import settings
 from app.core.database import SessionLocal
 from app.core.database import SessionLocal
@@ -44,13 +44,28 @@ def get_current_user(
         # Sliding Expiration Check
         # Sliding Expiration Check
         # If token is valid but expires soon (e.g. less than half of total lifetime), renew it
         # If token is valid but expires soon (e.g. less than half of total lifetime), renew it
         exp = payload.get("exp")
         exp = payload.get("exp")
+        is_long_term = payload.get("long_term", False)
+        
         if exp:
         if exp:
             now = datetime.now().timestamp()
             now = datetime.now().timestamp()
             remaining_seconds = exp - now
             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 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
                 # 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
                 # Set in response header
                 response.headers["X-New-Token"] = new_token
                 response.headers["X-New-Token"] = new_token
                 
                 
@@ -98,11 +113,26 @@ def get_current_user_optional(
 
 
         # Sliding Expiration Check for Optional Auth
         # Sliding Expiration Check for Optional Auth
         exp = payload.get("exp")
         exp = payload.get("exp")
+        is_long_term = payload.get("long_term", False)
+        
         if exp:
         if exp:
             now = datetime.now().timestamp()
             now = datetime.now().timestamp()
             remaining_seconds = exp - now
             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
                 response.headers["X-New-Token"] = new_token
 
 
     except (JWTError, Exception):
     except (JWTError, Exception):

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

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

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

@@ -1,11 +1,13 @@
 from typing import Optional, List
 from typing import Optional, List
 import json
 import json
+from datetime import timedelta
 from fastapi import APIRouter, Depends, HTTPException, Body
 from fastapi import APIRouter, Depends, HTTPException, Body
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 from pydantic import BaseModel
 from pydantic import BaseModel
 
 
 from app.api.v1 import deps
 from app.api.v1 import deps
 from app.core import security
 from app.core import security
+from app.core.config import settings
 from app.core.utils import generate_english_name
 from app.core.utils import generate_english_name
 from app.models.user import User, UserRole, UserStatus
 from app.models.user import User, UserRole, UserStatus
 from app.models.application import Application
 from app.models.application import Application
@@ -79,7 +81,14 @@ def login_with_password(
             raise HTTPException(status_code=400, detail="用户已禁用")
             raise HTTPException(status_code=400, detail="用户已禁用")
 
 
         # Generate JWT Access Token
         # 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
         # Log Success
         LoginLogService.create_log(db, log_create)
         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"
     SECRET_KEY: str = "change_me_in_production"
     ALGORITHM: str = "HS256"
     ALGORITHM: str = "HS256"
     ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
     ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
+    ACCESS_TOKEN_EXPIRE_MINUTES_LONG: int = 43200 # 30 days
     
     
     # CORS
     # CORS
     BACKEND_CORS_ORIGINS: List[AnyHttpUrl] = []
     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:
 def get_password_hash(password: str) -> str:
     return pwd_context.hash(password)
     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:
     if expires_delta:
         expire = datetime.now() + expires_delta
         expire = datetime.now() + expires_delta
     else:
     else:
         expire = datetime.now() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
         expire = datetime.now() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
     
     
     to_encode = {"exp": expire, "sub": str(subject)}
     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)
     encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
     return encoded_jwt
     return encoded_jwt
 
 

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

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

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

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

+ 5 - 0
frontend/nginx.conf

@@ -15,6 +15,11 @@ server {
         proxy_set_header Host $host;
         proxy_set_header Host $host;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
         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
             show-password
           />
           />
         </el-form-item>
         </el-form-item>
+        <el-form-item>
+          <el-checkbox v-model="loginForm.remember_me">记住我 (30天免登录)</el-checkbox>
+        </el-form-item>
         <el-form-item>
         <el-form-item>
           <el-button type="primary" :loading="loading" @click="handleLogin" style="width: 100%">
           <el-button type="primary" :loading="loading" @click="handleLogin" style="width: 100%">
             登录
             登录
@@ -49,7 +52,8 @@ const authStore = useAuthStore()
 
 
 const loginForm = reactive({
 const loginForm = reactive({
   mobile: '',
   mobile: '',
-  password: ''
+  password: '',
+  remember_me: true
 })
 })
 
 
 const loading = ref(false)
 const loading = ref(false)
@@ -89,7 +93,8 @@ const handleLogin = async () => {
       // Platform Login
       // Platform Login
       await authStore.login({
       await authStore.login({
         mobile: loginForm.mobile,
         mobile: loginForm.mobile,
-        password: loginForm.password
+        password: loginForm.password,
+        remember_me: loginForm.remember_me
       })
       })
       ElMessage.success('登录成功')
       ElMessage.success('登录成功')
       router.push('/dashboard')
       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
   // Construct WS URL from VITE_API_BASE_URL
   const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1'
   const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api/v1'
   const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
   const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
-  // Remove http: or https:
-  const urlBody = baseUrl.replace(/^https?:/, '')
   const token = localStorage.getItem('token')
   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}`
   return `${wsProtocol}${urlBody}/system-logs/stream?token=${token}`
 }
 }