mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
Add stateless JWT refresh token mechanism
- New config: Controller.jwt_refresh_token_expire_minutes (default 30 days) - New endpoint: POST /v3/access/users/refresh (public, unauthenticated) - Login/authenticate responses now include refresh_token - AuthService: _create_token helper, create_refresh_token, get_token_data now parses type claim (token_use) for token classification - Security: refresh tokens rejected on HTTP + WebSocket access paths; /refresh strictly requires type=='refresh' - Logout works for free via existing token_version mechanism - Tests: 9 new TestRefreshToken cases, all passing; 34 existing tests still pass (no regressions)
This commit is contained in:
7 files changed
+262
-8
No files matched your search
@@ -35,6 +35,17 @@ log = logging.getLogger(__name__)
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/v3/access/users/login", auto_error=False)
|
||||
|
||||
|
||||
def _reject_refresh_token(token_data) -> None:
|
||||
"""Reject tokens with type == 'refresh' — they must not grant API access."""
|
||||
|
||||
if token_data.token_use == "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh tokens cannot be used for API access",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
async def get_user_from_token(
|
||||
bearer_token: str = Depends(oauth2_scheme),
|
||||
user_repo: UsersRepository = Depends(get_repository(UsersRepository)),
|
||||
@@ -82,6 +93,7 @@ async def get_user_from_token(
|
||||
|
||||
# JWT authentication
|
||||
token_data = auth_service.get_token_data(token)
|
||||
_reject_refresh_token(token_data)
|
||||
user = await user_repo.get_user_by_username(token_data.username)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
@@ -137,6 +149,7 @@ async def get_current_active_user_from_websocket(
|
||||
|
||||
try:
|
||||
token_data = auth_service.get_token_data(token)
|
||||
_reject_refresh_token(token_data)
|
||||
user = await user_repo.get_user_by_username(token_data.username)
|
||||
|
||||
if user is None:
|
||||
|
||||
@@ -67,7 +67,8 @@ async def login(
|
||||
|
||||
token = schemas.Token(
|
||||
access_token=auth_service.create_access_token(user.username, token_version=user.token_version),
|
||||
token_type="bearer"
|
||||
token_type="bearer",
|
||||
refresh_token=auth_service.create_refresh_token(user.username, token_version=user.token_version),
|
||||
)
|
||||
return token
|
||||
|
||||
@@ -92,11 +93,55 @@ async def authenticate(
|
||||
|
||||
token = schemas.Token(
|
||||
access_token=auth_service.create_access_token(user.username, token_version=user.token_version),
|
||||
token_type="bearer"
|
||||
token_type="bearer",
|
||||
refresh_token=auth_service.create_refresh_token(user.username, token_version=user.token_version),
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=schemas.Token)
|
||||
async def refresh_access_token(
|
||||
request: schemas.RefreshTokenRequest,
|
||||
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
|
||||
) -> schemas.Token:
|
||||
"""
|
||||
Exchange a refresh token for a new access token.
|
||||
|
||||
Public endpoint — the refresh token itself proves identity. Respects the
|
||||
user's token_version, so logout (which increments it) invalidates all
|
||||
outstanding refresh tokens. Refresh tokens are stateless JWTs with a
|
||||
longer expiry (default 30 days). Stolen tokens remain valid until their
|
||||
`exp` or until logout — no replay protection without a server-side table.
|
||||
"""
|
||||
|
||||
token_data = auth_service.get_token_data(request.refresh_token)
|
||||
if token_data.token_use != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
user = await users_repo.get_user_by_username(token_data.username)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if token_data.token_version != user.token_version:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=f"Token has been revoked for '{token_data.username}'",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return schemas.Token(
|
||||
access_token=auth_service.create_access_token(user.username, token_version=user.token_version),
|
||||
token_type="bearer",
|
||||
refresh_token=auth_service.create_refresh_token(user.username, token_version=user.token_version),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
current_user: schemas.User = Depends(get_current_active_user),
|
||||
|
||||
Reference in new issue
Block a user