mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
Merge pull request #2792 from yueguobin/feature/refresh-token-mechanism-2786
Add stateless JWT refresh token mechanism
This commit is contained in:
commit
58d3055dd3
154
docs/features/refresh-token-mechanism.md
Normal file
154
docs/features/refresh-token-mechanism.md
Normal file
@ -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": "<refresh_token>"
|
||||
}
|
||||
```
|
||||
|
||||
**Response 200:**
|
||||
```json
|
||||
{
|
||||
"access_token": "<new_access_token>",
|
||||
"token_type": "bearer",
|
||||
"refresh_token": "<new_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.
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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"),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user