From 0e8d0cb87bdb9244974e3932d44eae5506647eca Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 23 Jun 2026 22:39:11 +0800 Subject: [PATCH 1/3] 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) --- .../controller/dependencies/authentication.py | 13 ++ gns3server/api/routes/controller/users.py | 49 ++++- gns3server/schemas/__init__.py | 2 +- gns3server/schemas/config.py | 1 + gns3server/schemas/controller/tokens.py | 8 + gns3server/services/authentication.py | 22 ++- tests/api/routes/controller/test_users.py | 175 ++++++++++++++++++ 7 files changed, 262 insertions(+), 8 deletions(-) diff --git a/gns3server/api/routes/controller/dependencies/authentication.py b/gns3server/api/routes/controller/dependencies/authentication.py index b762969d2..60c880894 100644 --- a/gns3server/api/routes/controller/dependencies/authentication.py +++ b/gns3server/api/routes/controller/dependencies/authentication.py @@ -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: diff --git a/gns3server/api/routes/controller/users.py b/gns3server/api/routes/controller/users.py index 1a19da98f..97fedd47f 100644 --- a/gns3server/api/routes/controller/users.py +++ b/gns3server/api/routes/controller/users.py @@ -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), diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index 59f9b6cef..d228f4b84 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -57,7 +57,7 @@ except ImportError: from .controller.rbac import RoleCreate, RoleUpdate, Role, Privilege, ACECreate, ACEUpdate, ACE from .controller.pools import Resource, ResourceCreate, ResourcePoolCreate, ResourcePoolUpdate, ResourcePool -from .controller.tokens import Token, ApiKeyCreate +from .controller.tokens import Token, ApiKeyCreate, RefreshTokenRequest from .controller.snapshots import SnapshotCreate, Snapshot from .controller.iou_license import IOULicense from .controller.capabilities import Capabilities diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index 9e328b2a9..294ea4cf6 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -35,6 +35,7 @@ class ControllerSettings(BaseModel): jwt_secret_key: str = None jwt_algorithm: str = "HS256" jwt_access_token_expire_minutes: int = 1440 # 24 hours + jwt_refresh_token_expire_minutes: int = 43200 # 30 days default_admin_username: str = "admin" default_admin_password: SecretStr = SecretStr("admin") model_config = ConfigDict(validate_assignment=True, str_strip_whitespace=True) diff --git a/gns3server/schemas/controller/tokens.py b/gns3server/schemas/controller/tokens.py index e36a1d35c..61945e257 100644 --- a/gns3server/schemas/controller/tokens.py +++ b/gns3server/schemas/controller/tokens.py @@ -22,12 +22,20 @@ class Token(BaseModel): access_token: str token_type: str + refresh_token: Optional[str] = None class TokenData(BaseModel): username: Optional[str] = None token_version: int = 0 + token_use: str = "access" + + +class RefreshTokenRequest(BaseModel): + """Schema for requesting a token refresh.""" + + refresh_token: str class ApiKeyCreate(BaseModel): diff --git a/gns3server/services/authentication.py b/gns3server/services/authentication.py index 42c980415..574c96c69 100644 --- a/gns3server/services/authentication.py +++ b/gns3server/services/authentication.py @@ -46,12 +46,11 @@ class AuthService: return bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hashed_password.encode('utf-8')) - def create_access_token(self, username, token_version: int = 0, secret_key: str = None, expires_in: int = 0) -> str: + def _create_token(self, username, token_version, token_type, expires_in, secret_key=None) -> str: + """Shared helper to create any kind of signed JWT token.""" - if not expires_in: - expires_in = Config.instance().settings.Controller.jwt_access_token_expire_minutes expire = datetime.now(timezone.utc) + timedelta(minutes=expires_in) - to_encode = {"sub": username, "exp": expire, "ver": token_version} + to_encode = {"sub": username, "exp": expire, "ver": token_version, "type": token_type} if secret_key is None: secret_key = Config.instance().settings.Controller.jwt_secret_key if secret_key is None: @@ -62,6 +61,18 @@ class AuthService: encoded_jwt = jwt.encode({"alg": algorithm}, to_encode, key) return encoded_jwt + def create_access_token(self, username, token_version: int = 0, secret_key: str = None, expires_in: int = 0) -> str: + + if not expires_in: + expires_in = Config.instance().settings.Controller.jwt_access_token_expire_minutes + return self._create_token(username, token_version, "access", expires_in, secret_key) + + def create_refresh_token(self, username, token_version: int = 0, secret_key: str = None, expires_in: int = 0) -> str: + + if not expires_in: + expires_in = Config.instance().settings.Controller.jwt_refresh_token_expire_minutes + return self._create_token(username, token_version, "refresh", expires_in, secret_key) + def get_token_data(self, token: str, secret_key: str = None) -> TokenData: credentials_exception = HTTPException( @@ -86,7 +97,8 @@ class AuthService: if token_exp and time.time() > token_exp: raise credentials_exception token_version: int = payload.claims.get("ver", 0) - token_data = TokenData(username=username, token_version=token_version) + token_use: str = payload.claims.get("type", "access") + token_data = TokenData(username=username, token_version=token_version, token_use=token_use) except (JoseError, ValidationError, ValueError): raise credentials_exception return token_data diff --git a/tests/api/routes/controller/test_users.py b/tests/api/routes/controller/test_users.py index 5db4a038c..e30719d02 100644 --- a/tests/api/routes/controller/test_users.py +++ b/tests/api/routes/controller/test_users.py @@ -255,6 +255,10 @@ class TestUserLogin: assert "token_type" in response.json() assert response.json().get("token_type") == "bearer" + # check that refresh token is returned + assert "refresh_token" in response.json() + assert response.json().get("refresh_token") is not None + @pytest.mark.parametrize( "username, password, status_code", ( @@ -311,6 +315,7 @@ class TestUnauthorizedUser: response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials) assert response.status_code == status.HTTP_200_OK assert response.json().get("access_token") + assert response.json().get("refresh_token") is not None token = response.json().get("access_token") response = await unauthorized_client.get(app.url_path_for("statistics"), params={"token": token}) @@ -485,6 +490,176 @@ class TestLogout: assert response.status_code == status.HTTP_401_UNAUTHORIZED +class TestRefreshToken: + + async def test_login_returns_refresh_token( + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, + ) -> None: + + credentials = {"username": test_user.username, "password": "user1_password"} + response = await unauthorized_client.post(app.url_path_for("login"), + data=credentials, + headers={"content-type": "application/x-www-form-urlencoded"}) + assert response.status_code == status.HTTP_200_OK + assert "refresh_token" in response.json() + assert response.json().get("refresh_token") is not None + + async def test_authenticate_returns_refresh_token( + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, + ) -> None: + + credentials = {"username": test_user.username, "password": "user1_password"} + response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials) + assert response.status_code == status.HTTP_200_OK + assert "refresh_token" in response.json() + assert response.json().get("refresh_token") is not None + + async def test_refresh_endpoint_returns_new_tokens( + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, + ) -> None: + + # authenticate to get a refresh token + credentials = {"username": test_user.username, "password": "user1_password"} + auth_response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials) + refresh_token = auth_response.json()["refresh_token"] + + # use the refresh token at /refresh + response = await unauthorized_client.post( + app.url_path_for("refresh_access_token"), + json={"refresh_token": refresh_token}, + ) + assert response.status_code == status.HTTP_200_OK + assert "access_token" in response.json() + assert response.json().get("token_type") == "bearer" + assert "refresh_token" in response.json() + assert response.json().get("refresh_token") is not None + + async def test_new_access_token_from_refresh_works_on_protected_route( + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, + ) -> None: + + credentials = {"username": test_user.username, "password": "user1_password"} + auth_response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials) + refresh_token = auth_response.json()["refresh_token"] + + # refresh to get a new access token + refresh_response = await unauthorized_client.post( + app.url_path_for("refresh_access_token"), + json={"refresh_token": refresh_token}, + ) + new_access_token = refresh_response.json()["access_token"] + + # the new access token must work on a protected route + response = await unauthorized_client.get( + app.url_path_for("get_logged_in_user"), + headers={"Authorization": f"Bearer {new_access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["username"] == test_user.username + + async def test_refresh_with_stale_token_after_logout( + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, + ) -> None: + + # authenticate and get a refresh token + credentials = {"username": test_user.username, "password": "user1_password"} + auth_response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials) + refresh_token = auth_response.json()["refresh_token"] + + # logout — bumps token_version, invalidating the refresh token + access_token = auth_response.json()["access_token"] + await unauthorized_client.post( + app.url_path_for("logout"), + headers={"Authorization": f"Bearer {access_token}"} + ) + + # /refresh must now reject the stale refresh token + response = await unauthorized_client.post( + app.url_path_for("refresh_access_token"), + json={"refresh_token": refresh_token}, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_refresh_rejects_access_token( + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, + ) -> None: + + # an access token presented at /refresh must be rejected + credentials = {"username": test_user.username, "password": "user1_password"} + auth_response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials) + access_token = auth_response.json()["access_token"] + + response = await unauthorized_client.post( + app.url_path_for("refresh_access_token"), + json={"refresh_token": access_token}, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_refresh_rejects_expired_token( + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, + ) -> None: + + # a refresh token with an already-expired timestamp + expired_refresh = auth_service.create_refresh_token(test_user.username, expires_in=-1) + + response = await unauthorized_client.post( + app.url_path_for("refresh_access_token"), + json={"refresh_token": expired_refresh}, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_refresh_rejects_invalid_token( + self, + app: FastAPI, + unauthorized_client: AsyncClient, + ) -> None: + + response = await unauthorized_client.post( + app.url_path_for("refresh_access_token"), + json={"refresh_token": "not-a-valid-token"}, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + async def test_refresh_token_rejected_as_bearer( + self, + app: FastAPI, + unauthorized_client: AsyncClient, + test_user: User, + ) -> None: + + # a refresh token used as a bearer access token must be rejected + credentials = {"username": test_user.username, "password": "user1_password"} + auth_response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials) + refresh_token = auth_response.json()["refresh_token"] + + response = await unauthorized_client.get( + app.url_path_for("get_logged_in_user"), + headers={"Authorization": f"Bearer {refresh_token}"}, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + class TestSuperAdmin: async def test_super_admin_exists( From c8c14af9f476cc3277e485feca4e320391ad8777 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 23 Jun 2026 23:19:07 +0800 Subject: [PATCH 2/3] Add /refresh to allowed public endpoints list in route auth test --- tests/api/routes/test_routes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/api/routes/test_routes.py b/tests/api/routes/test_routes.py index 98c4dcdd6..8d671b979 100644 --- a/tests/api/routes/test_routes.py +++ b/tests/api/routes/test_routes.py @@ -38,6 +38,7 @@ ALLOWED_CONTROLLER_ENDPOINTS = [ ("/v3/version", "POST"), ("/v3/access/users/login", "POST"), ("/v3/access/users/authenticate", "POST"), + ("/v3/access/users/refresh", "POST"), ("/v3/symbols", "GET"), ("/v3/symbols/{symbol_id:path}/raw", "GET"), ("/v3/symbols/{symbol_id:path}/dimensions", "GET"), From 0dd2fb4173e39190b60d9fd981607264cf33cb3b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 23 Jun 2026 23:36:37 +0800 Subject: [PATCH 3/3] Add refresh token mechanism documentation under docs/features/ --- docs/features/refresh-token-mechanism.md | 154 +++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 docs/features/refresh-token-mechanism.md diff --git a/docs/features/refresh-token-mechanism.md b/docs/features/refresh-token-mechanism.md new file mode 100644 index 000000000..e82e393d6 --- /dev/null +++ b/docs/features/refresh-token-mechanism.md @@ -0,0 +1,154 @@ +# Stateless JWT Refresh Token Mechanism + +## Overview + +GNS3 server now supports a stateless JWT refresh token mechanism for interactive sessions (e.g., Web UI). This allows clients to stay authenticated across page reloads without repeated username/password prompts, while keeping access tokens short-lived. + +No new database table or migration is required — refresh tokens are signed JWTs using the same secret and algorithm as access tokens. + +## Architecture + +```mermaid +graph TD + Client -->|login / authenticate| API[Controller API] + API -->|access_token + refresh_token| Client + Client -->|POST /refresh| Refresh[Refresh Endpoint] + Refresh -->|new access_token + new refresh_token| Client + Client -->|Bearer access_token| Protected[Protected Endpoints] + Protected -->|401| Client + Client -->|refresh_token in body| Refresh + Refresh -->|401 if invalid/expired/revoked| Client + Refresh -->|verify type, exp, ver| AuthService[AuthService] + AuthService -->|check token_version| DB[(users table)] +``` + +## Business Process + +### Login / Authenticate Flow + +```mermaid +sequenceDiagram + participant C as Client + participant API as Controller API + participant AS as AuthService + participant DB as Database + + C->>API: POST /login or /authenticate (username + password) + API->>DB: authenticate_user() + DB-->>API: user (with token_version) + API->>AS: create_access_token(user, ver) + API->>AS: create_refresh_token(user, ver) + AS-->>API: access_token (type: access, exp: 15min) + AS-->>API: refresh_token (type: refresh, exp: 30d) + API-->>C: { access_token, token_type, refresh_token } +``` + +### Refresh Flow (Silent Renewal) + +```mermaid +sequenceDiagram + participant C as Client + participant API as Controller API + participant AS as AuthService + participant DB as Database + + Note over C: access_token expired + C->>API: POST /refresh { refresh_token } + API->>AS: get_token_data(refresh_token) + AS-->>API: { username, ver, token_use: "refresh" } + API->>DB: get_user_by_username() + DB-->>API: user (with current token_version) + Note over API,DB: rejects if user not found, inactive, or token_version mismatch + API->>AS: create_access_token(user, ver) + API->>AS: create_refresh_token(user, ver) + AS-->>API: new access_token (sliding window) + AS-->>API: new refresh_token (sliding window) + API-->>C: { access_token, token_type, refresh_token } + C->>API: Retry original request with new access_token +``` + +### Logout — Token Revocation + +```mermaid +sequenceDiagram + participant C as Client + participant API as Controller API + participant DB as Database + + C->>API: POST /logout (Bearer access_token) + API->>DB: logout_user(user_id) → token_version += 1 + DB-->>API: done + API-->>C: 204 No Content + Note over C, DB: All existing access and refresh tokens with old ver are now invalid +``` + +## API Endpoints + +| Method | Path | Description | Authentication | +|--------|------|-------------|---------------| +| POST | `/v3/access/users/login` | Login with form data, returns access + refresh tokens | Public | +| POST | `/v3/access/users/authenticate` | Login with JSON, returns access + refresh tokens | Public | +| POST | `/v3/access/users/refresh` | Exchange a refresh token for a new access token + refresh token | Public (token itself proves identity) | +| POST | `/v3/access/users/logout` | Revoke all tokens for the current user | Bearer token required | + +### POST /v3/access/users/refresh + +**Request:** +```json +{ + "refresh_token": "" +} +``` + +**Response 200:** +```json +{ + "access_token": "", + "token_type": "bearer", + "refresh_token": "" +} +``` + +**Error Responses:** +- `401` — Invalid, expired, or revoked refresh token +- `422` — Missing `refresh_token` field in request body + +## Security Design + +### Token Claims + +| Claim | Access Token | Refresh Token | +|-------|-------------|---------------| +| `sub` | username | username | +| `exp` | 24h (configurable) | 30d (configurable) | +| `ver` | user's `token_version` | user's `token_version` | +| `type` | `"access"` | `"refresh"` | + +### Key Security Properties + +- **Type-based isolation**: Access tokens (`type: access`) are rejected by `/refresh`. Refresh tokens (`type: refresh`) are rejected by HTTP and WebSocket authentication paths. This prevents a stolen long-lived refresh token from being used directly for API access. +- **Token version integration**: Both token types carry the user's `token_version`. `logout` increments `token_version` in the database, immediately invalidating all outstanding access and refresh tokens. +- **Stateless (no replay detection)**: Since there is no `refresh_tokens` database table, a stolen refresh token remains valid until its `exp` or until the user logs out. This is an accepted trade-off for avoiding a new table and migration. +- **Sliding window**: Each `/refresh` call issues a new refresh token with a fresh expiry, keeping active sessions alive indefinitely until logout or inactivity. + +### Implementation Files + +- `gns3server/services/authentication.py` — `_create_token`, `create_access_token`, `create_refresh_token`, `get_token_data` +- `gns3server/api/routes/controller/users.py` — `refresh_access_token` endpoint handler +- `gns3server/api/routes/controller/dependencies/authentication.py` — `_reject_refresh_token` guard in HTTP and WebSocket paths +- `gns3server/schemas/controller/tokens.py` — `Token`, `TokenData`, `RefreshTokenRequest` models +- `gns3server/schemas/config.py` — `jwt_refresh_token_expire_minutes` configuration + +## Configuration + +| Setting | Default | Description | +|---------|---------|-------------| +| `Controller.jwt_access_token_expire_minutes` | 1440 (24h) | Access token TTL. Web UI recommends 15 min. | +| `Controller.jwt_refresh_token_expire_minutes` | 43200 (30d) | Refresh token TTL. | +| `Controller.jwt_secret_key` | (random) | HMAC signing key for all JWT tokens. | + +## Notes + +- **Web UI integration**: The client should implement a response interceptor that catches 401, silently calls `/refresh`, and retries the original request. Multiple concurrent 401s should be queued with a single refresh request. +- **No per-session revocation**: All tokens for a user share the same `token_version`. Logout revokes everything. Per-session granularity would require adding a `refresh_tokens` table. +- **Rate limiting**: `/refresh` is a public endpoint with a valid credential (the refresh token). Rate limiting is recommended if brute-force attacks are a concern.