新增空闲锁屏、修复401等问题,新增文件版本管理、共享库等占位符

This commit is contained in:
Cheng Zhou
2026-01-13 13:59:07 +08:00
parent b3cd8e03f2
commit 0c7c03069a
21 changed files with 829 additions and 40 deletions
+81 -1
View File
@@ -1,3 +1,4 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import File, UploadFile
from pydantic import BaseModel, EmailStr, Field
@@ -5,7 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from pathlib import Path
import uuid
from app.core.security import create_access_token, verify_password
from app.core.config import settings
from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password
from app.core.deps import get_current_user, get_db_session
from app.crud import user as user_crud
from app.models.user import UserRole, UserStatus
@@ -18,6 +20,21 @@ class LoginRequest(BaseModel):
password: str = Field(min_length=1)
class ExtendResponse(BaseModel):
accessToken: str
expiresAt: datetime
class UnlockRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1)
class UnlockResponse(BaseModel):
accessToken: str
expiresAt: datetime
router = APIRouter()
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
@@ -58,10 +75,12 @@ async def login_for_access_token(
detail="账号未审核或不可用",
)
session_start = datetime.now(timezone.utc)
access_token = create_access_token(
user_id=str(db_user.id),
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None,
session_start=session_start,
)
return Token(access_token=access_token, token_type="bearer")
@@ -71,6 +90,67 @@ async def read_me(current_user=Depends(get_current_user)) -> UserRead:
return current_user
@router.post("/extend", response_model=ExtendResponse)
async def extend_access_token(
token: str = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db_session),
) -> ExtendResponse:
payload = decode_token_allow_expired(token)
exp = payload.get("exp")
if not exp:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期")
now_ts = int(datetime.now(timezone.utc).timestamp())
if now_ts > int(exp) + settings.JWT_EXTEND_GRACE_SECONDS:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已过期")
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无法验证登录凭据")
db_user = await user_crud.get_by_id(db, uuid.UUID(str(user_id)))
if not db_user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在或已停用")
if db_user.status != UserStatus.ACTIVE:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
session_start_ts = payload.get("orig_iat") or payload.get("iat")
if session_start_ts:
max_seconds = settings.ABSOLUTE_SESSION_MAX_HOURS * 3600
if now_ts - int(session_start_ts) > max_seconds:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="会话已到期,请重新登录")
session_start = datetime.fromtimestamp(int(session_start_ts), tz=timezone.utc)
else:
session_start = datetime.now(timezone.utc)
new_token = create_access_token(
user_id=str(db_user.id),
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None,
session_start=session_start,
)
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
return ExtendResponse(accessToken=new_token, expiresAt=expires_at)
@router.post("/unlock", response_model=UnlockResponse)
async def unlock_session(
payload: UnlockRequest,
db: AsyncSession = Depends(get_db_session),
) -> UnlockResponse:
db_user = await user_crud.get_by_email(db, payload.email)
if not db_user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在")
if not verify_password(payload.password, db_user.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="密码错误")
if db_user.status != UserStatus.ACTIVE:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用")
session_start = datetime.now(timezone.utc)
access_token = create_access_token(
user_id=str(db_user.id),
role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role,
expires_minutes=None,
session_start=session_start,
)
expires_at = session_start + timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
return UnlockResponse(accessToken=access_token, expiresAt=expires_at)
@router.patch("/me", response_model=UserRead)
async def update_me(
payload: UserSelfUpdate,
+2
View File
@@ -12,6 +12,8 @@ class Settings(BaseSettings):
ENV: Literal["development", "production", "test"] = "development"
JWT_SECRET_KEY: str = "dev-secret"
JWT_EXPIRE_MINUTES: int = 60
JWT_EXTEND_GRACE_SECONDS: int = 120
ABSOLUTE_SESSION_MAX_HOURS: int = 8
@lru_cache
+7 -2
View File
@@ -33,12 +33,17 @@ async def get_current_user(
) from exc
user = await user_crud.get_by_id(db, uuid.UUID(str(token_data.sub)))
if not user or not user.is_active:
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="账号不存在或已停用",
detail="账号不存在",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="账号已停用",
)
return user
+34 -5
View File
@@ -14,11 +14,23 @@ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login", scheme_name="Bearer")
def create_access_token(*, user_id: str, role: str, expires_minutes: Optional[int] = None) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES
)
to_encode: Dict[str, Any] = {"sub": user_id, "role": role, "exp": expire}
def create_access_token(
*,
user_id: str,
role: str,
expires_minutes: Optional[int] = None,
session_start: Optional[datetime] = None,
) -> str:
now = datetime.now(timezone.utc)
expire = now + timedelta(minutes=expires_minutes or settings.JWT_EXPIRE_MINUTES)
session_start_time = session_start or now
to_encode: Dict[str, Any] = {
"sub": user_id,
"role": role,
"exp": expire,
"iat": int(now.timestamp()),
"orig_iat": int(session_start_time.timestamp()),
}
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=ALGORITHM)
@@ -34,6 +46,23 @@ def decode_token(token: str) -> Dict[str, Any]:
return payload
def decode_token_allow_expired(token: str) -> Dict[str, Any]:
try:
payload = jwt.decode(
token,
settings.JWT_SECRET_KEY,
algorithms=[ALGORITHM],
options={"verify_exp": False},
)
except JWTError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无法验证登录凭据",
headers={"WWW-Authenticate": "Bearer"},
) from exc
return payload
def hash_password(plain_password: str) -> str:
return pwd_context.hash(plain_password)