Merge pull request #2662 from UmmmAGoodName/3.0

Fix addressing issue #2005 including tests
This commit is contained in:
Jeremy Grossmann 2026-04-06 20:10:47 +08:00 committed by GitHub
commit 2e1db7de6f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 199 additions and 14 deletions

View File

@ -171,11 +171,13 @@ router.include_router(
router.include_router(
_llm_router,
prefix="/access",
dependencies=[Depends(get_current_active_user)],
tags=["LLM Model Configurations"]
)
router.include_router(
_chat_router,
prefix="/projects/{project_id}/chat",
dependencies=[Depends(get_current_active_user)],
tags=["Chat"]
)

View File

@ -47,14 +47,20 @@ async def get_user_from_token(
headers={"WWW-Authenticate": "Bearer"},
)
username = auth_service.get_username_from_token(token)
user = await user_repo.get_user_by_username(username)
token_data = auth_service.get_token_data(token)
user = await user_repo.get_user_by_username(token_data.username)
if user is None:
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 user
@ -87,13 +93,18 @@ async def get_current_active_user_from_websocket(
await websocket.accept()
try:
username = auth_service.get_username_from_token(token)
user = await user_repo.get_user_by_username(username)
token_data = auth_service.get_token_data(token)
user = await user_repo.get_user_by_username(token_data.username)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Could not validate credentials for '{username}'"
detail=f"Could not validate credentials for '{token_data.username}'"
)
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}'"
)
# Super admin is always authorized
@ -103,7 +114,7 @@ async def get_current_active_user_from_websocket(
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"'{username}' is not an active user"
detail=f"'{token_data.username}' is not an active user"
)
return user

View File

@ -65,7 +65,10 @@ async def login(
headers={"WWW-Authenticate": "Bearer"},
)
token = schemas.Token(access_token=auth_service.create_access_token(user.username), token_type="bearer")
token = schemas.Token(
access_token=auth_service.create_access_token(user.username, token_version=user.token_version),
token_type="bearer"
)
return token
@ -87,10 +90,25 @@ async def authenticate(
headers={"WWW-Authenticate": "Bearer"},
)
token = schemas.Token(access_token=auth_service.create_access_token(user.username), token_type="bearer")
token = schemas.Token(
access_token=auth_service.create_access_token(user.username, token_version=user.token_version),
token_type="bearer"
)
return token
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
current_user: schemas.User = Depends(get_current_active_user),
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
) -> None:
"""
Logout the current user by revoking all existing tokens.
"""
await users_repo.logout_user(current_user.user_id)
@router.get("/me", response_model=schemas.User)
async def get_logged_in_user(current_user: schemas.User = Depends(get_current_active_user)) -> schemas.User:
"""

View File

@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from sqlalchemy import Table, Boolean, Column, String, DateTime, ForeignKey, event
from sqlalchemy import Table, Boolean, Column, Integer, String, DateTime, ForeignKey, event
from sqlalchemy.orm import relationship
from .base import Base, BaseTable, generate_uuid, GUID
@ -45,6 +45,7 @@ class User(BaseTable):
full_name = Column(String)
hashed_password = Column(String)
last_login = Column(DateTime)
token_version = Column(Integer, default=0, nullable=False, server_default="0")
is_active = Column(Boolean, default=True)
is_superadmin = Column(Boolean, default=False)
groups = relationship("UserGroup", secondary=user_group_map, back_populates="users")

View File

@ -113,6 +113,18 @@ class UsersRepository(BaseRepository):
await self._db_session.refresh(user_db) # force refresh of updated_at value
return user_db
async def logout_user(self, user_id: UUID) -> None:
"""
Increment token_version to invalidate all existing tokens for the user.
"""
query = update(models.User).\
where(models.User.user_id == user_id).\
values(token_version=models.User.token_version + 1)
await self._db_session.execute(query)
await self._db_session.commit()
async def delete_user(self, user_id: UUID) -> bool:
"""
Delete a user.

View File

@ -0,0 +1,24 @@
"""add token version to users table
Revision ID: aff810fc119a
Revises: ec4b7b198555
Create Date: 2026-04-06 19:49:12.155446
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'aff810fc119a'
down_revision = 'ec4b7b198555'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('users', sa.Column('token_version', sa.Integer(), nullable=False, server_default='0'))
def downgrade() -> None:
op.drop_column('users', 'token_version')

View File

@ -27,3 +27,4 @@ class Token(BaseModel):
class TokenData(BaseModel):
username: Optional[str] = None
token_version: int = 0

View File

@ -45,12 +45,12 @@ class AuthService:
return bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hashed_password.encode('utf-8'))
def create_access_token(self, username, secret_key: str = None, expires_in: int = 0) -> str:
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
expire = datetime.now(timezone.utc) + timedelta(minutes=expires_in)
to_encode = {"sub": username, "exp": expire}
to_encode = {"sub": username, "exp": expire, "ver": token_version}
if secret_key is None:
secret_key = Config.instance().settings.Controller.jwt_secret_key
if secret_key is None:
@ -61,7 +61,7 @@ class AuthService:
encoded_jwt = jwt.encode({"alg": algorithm}, to_encode, key)
return encoded_jwt
def get_username_from_token(self, token: str, secret_key: str = None) -> Optional[str]:
def get_token_data(self, token: str, secret_key: str = None) -> TokenData:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
@ -80,7 +80,11 @@ class AuthService:
username: str = payload.claims.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
token_version: int = payload.claims.get("ver", 0)
token_data = TokenData(username=username, token_version=token_version)
except (JoseError, ValidationError, ValueError):
raise credentials_exception
return token_data.username
return token_data
def get_username_from_token(self, token: str, secret_key: str = None) -> Optional[str]:
return self.get_token_data(token, secret_key).username

View File

@ -247,6 +247,7 @@ class TestUserLogin:
key = OctKey.import_key(jwt_secret)
payload = jwt.decode(token, key, algorithms=["HS256"])
assert "sub" in payload.claims
assert "ver" in payload.claims
username = payload.claims.get("sub")
assert username == test_user.username
@ -373,6 +374,117 @@ class TestUserMe:
assert response.status_code == status_code
class TestLogout:
async def test_logout_returns_no_content(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
) -> None:
# login to get a fresh token that includes token_version
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
token = response.json()["access_token"]
response = await unauthorized_client.post(
app.url_path_for("logout"),
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_token_is_rejected_after_logout(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
db_session: AsyncSession,
) -> None:
# login and get a token
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
token = response.json()["access_token"]
# logout — increments token_version
await unauthorized_client.post(
app.url_path_for("logout"),
headers={"Authorization": f"Bearer {token}"}
)
# old token must now be rejected
response = await unauthorized_client.get(
app.url_path_for("get_logged_in_user"),
headers={"Authorization": f"Bearer {token}"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json()["message"] == f"Token has been revoked for '{test_user.username}'"
async def test_new_token_works_after_logout_and_relogin(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
) -> None:
credentials = {"username": test_user.username, "password": "user1_password"}
# login, then logout
response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials)
old_token = response.json()["access_token"]
await unauthorized_client.post(
app.url_path_for("logout"),
headers={"Authorization": f"Bearer {old_token}"}
)
# login again to get a fresh token
response = await unauthorized_client.post(app.url_path_for("authenticate"), json=credentials)
assert response.status_code == status.HTTP_200_OK
new_token = response.json()["access_token"]
# new token must work
response = await unauthorized_client.get(
app.url_path_for("get_logged_in_user"),
headers={"Authorization": f"Bearer {new_token}"}
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["username"] == test_user.username
async def test_stale_version_token_is_rejected(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
test_user: User,
db_session: AsyncSession,
) -> None:
# craft a token with ver=0 while the user's token_version is already higher
user_repo = UsersRepository(db_session)
user_in_db = await user_repo.get_user_by_username(test_user.username)
# force token_version ahead so any ver=0 token is stale
await user_repo.logout_user(user_in_db.user_id)
stale_token = auth_service.create_access_token(test_user.username, token_version=0)
response = await unauthorized_client.get(
app.url_path_for("get_logged_in_user"),
headers={"Authorization": f"Bearer {stale_token}"}
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
async def test_logout_without_token_returns_unauthorized(
self,
app: FastAPI,
unauthorized_client: AsyncClient,
) -> None:
response = await unauthorized_client.post(app.url_path_for("logout"))
assert response.status_code == status.HTTP_401_UNAUTHORIZED
class TestSuperAdmin:
async def test_super_admin_exists(