新增空闲锁屏、修复401等问题,新增文件版本管理、共享库等占位符
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user