feat(auth): support trusted device (#52)
New API to maintain sessions and devices:
- GET /api/private/admin/sessions
- DELETE /api/private/admin/sessions/{session_id}
- GET /api/private/admin/trusted-devices
- DELETE /api/private/admin/trusted-devices/{device_id}
Auth:
web clients request `/oauth/token` and `/api/v2/session/verify` with `X-UUID` header to save the client as trusted device.
---------
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
157
app/router/private/admin.py
Normal file
157
app/router/private/admin.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.database.auth import OAuthToken
|
||||
from app.database.verification import LoginSession, LoginSessionResp, TrustedDevice, TrustedDeviceResp
|
||||
from app.dependencies.database import Database
|
||||
from app.dependencies.geoip import get_geoip_helper
|
||||
from app.dependencies.user import UserAndToken, get_client_user_and_token
|
||||
from app.helpers.geoip_helper import GeoIPHelper
|
||||
|
||||
from .router import router
|
||||
|
||||
from fastapi import Depends, HTTPException, Security
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import col, select
|
||||
|
||||
|
||||
class SessionsResp(BaseModel):
|
||||
total: int
|
||||
current: int = 0
|
||||
sessions: list[LoginSessionResp]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin/sessions",
|
||||
name="获取当前用户的登录会话列表",
|
||||
tags=["用户会话", "g0v0 API", "管理"],
|
||||
response_model=SessionsResp,
|
||||
)
|
||||
async def get_sessions(
|
||||
session: Database,
|
||||
user_and_token: UserAndToken = Security(get_client_user_and_token),
|
||||
geoip: GeoIPHelper = Depends(get_geoip_helper),
|
||||
):
|
||||
current_user, token = user_and_token
|
||||
sessions = (
|
||||
await session.exec(
|
||||
select(
|
||||
LoginSession,
|
||||
)
|
||||
.where(LoginSession.user_id == current_user.id, col(LoginSession.is_verified).is_(True))
|
||||
.order_by(col(LoginSession.created_at).desc())
|
||||
)
|
||||
).all()
|
||||
return SessionsResp(
|
||||
total=len(sessions),
|
||||
current=token.id,
|
||||
sessions=[LoginSessionResp.from_db(s, geoip) for s in sessions],
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/admin/sessions/{session_id}",
|
||||
name="注销指定的登录会话",
|
||||
tags=["用户会话", "g0v0 API", "管理"],
|
||||
status_code=204,
|
||||
)
|
||||
async def delete_session(
|
||||
session: Database,
|
||||
session_id: int,
|
||||
user_and_token: UserAndToken = Security(get_client_user_and_token),
|
||||
):
|
||||
current_user, token = user_and_token
|
||||
if session_id == token.id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the current session")
|
||||
|
||||
db_session = await session.get(LoginSession, session_id)
|
||||
if not db_session or db_session.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
|
||||
await session.delete(db_session)
|
||||
|
||||
token = await session.get(OAuthToken, db_session.token_id or 0)
|
||||
if token:
|
||||
await session.delete(token)
|
||||
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
|
||||
class TrustedDevicesResp(BaseModel):
|
||||
total: int
|
||||
current: int = 0
|
||||
devices: list[TrustedDeviceResp]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/admin/trusted-devices",
|
||||
name="获取当前用户的受信任设备列表",
|
||||
tags=["用户会话", "g0v0 API", "管理"],
|
||||
response_model=TrustedDevicesResp,
|
||||
)
|
||||
async def get_trusted_devices(
|
||||
session: Database,
|
||||
user_and_token: UserAndToken = Security(get_client_user_and_token),
|
||||
geoip: GeoIPHelper = Depends(get_geoip_helper),
|
||||
):
|
||||
current_user, token = user_and_token
|
||||
devices = (
|
||||
await session.exec(
|
||||
select(TrustedDevice)
|
||||
.where(TrustedDevice.user_id == current_user.id)
|
||||
.order_by(col(TrustedDevice.last_used_at).desc())
|
||||
)
|
||||
).all()
|
||||
|
||||
current_device_id = (
|
||||
await session.exec(
|
||||
select(TrustedDevice.id)
|
||||
.join(LoginSession, col(LoginSession.device_id) == TrustedDevice.id)
|
||||
.where(
|
||||
LoginSession.token_id == token.id,
|
||||
TrustedDevice.user_id == current_user.id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).first()
|
||||
|
||||
return TrustedDevicesResp(
|
||||
total=len(devices),
|
||||
current=current_device_id or 0,
|
||||
devices=[TrustedDeviceResp.from_db(device, geoip) for device in devices],
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/admin/trusted-devices/{device_id}",
|
||||
name="移除受信任设备",
|
||||
tags=["用户会话", "g0v0 API", "管理"],
|
||||
status_code=204,
|
||||
)
|
||||
async def delete_trusted_device(
|
||||
session: Database,
|
||||
device_id: int,
|
||||
user_and_token: UserAndToken = Security(get_client_user_and_token),
|
||||
):
|
||||
current_user, token = user_and_token
|
||||
device = await session.get(TrustedDevice, device_id)
|
||||
current_device_id = (
|
||||
await session.exec(
|
||||
select(TrustedDevice.id)
|
||||
.join(LoginSession, col(LoginSession.device_id) == TrustedDevice.id)
|
||||
.where(
|
||||
LoginSession.token_id == token.id,
|
||||
TrustedDevice.user_id == current_user.id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).first()
|
||||
if device_id == current_device_id:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the current trusted device")
|
||||
|
||||
if not device or device.user_id != current_user.id:
|
||||
raise HTTPException(status_code=404, detail="Trusted device not found")
|
||||
|
||||
await session.delete(device)
|
||||
await session.commit()
|
||||
return
|
||||
Reference in New Issue
Block a user