diff --git a/fixbackend/app.py b/fixbackend/app.py
index bd0f373c..4540bffd 100644
--- a/fixbackend/app.py
+++ b/fixbackend/app.py
@@ -25,24 +25,26 @@
from fastapi.openapi.docs import get_swagger_ui_html
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
+from fastapi_users.exceptions import FastAPIUsersException
from fixcloudutils.logging import setup_logger
+from prometheus_client import Counter
from prometheus_fastapi_instrumentator import Instrumentator
from sqlalchemy import select
from starlette.exceptions import HTTPException
from fixbackend import config, dependencies
-from fixbackend.auth.api_token_router import api_token_router
-from fixbackend.customer_support.router import admin_console_router
from fixbackend.analytics.router import analytics_router
from fixbackend.app_dependencies import create_dependencies
+from fixbackend.auth.api_token_router import api_token_router
from fixbackend.auth.auth_backend import cookie_transport
from fixbackend.auth.depedencies import refreshed_session_scope
from fixbackend.auth.oauth_router import github_client, google_client
-from fixbackend.auth.router import auth_router
+from fixbackend.auth.auth_router import auth_router
from fixbackend.auth.users_router import users_router
from fixbackend.billing.router import billing_info_router
from fixbackend.cloud_accounts.router import cloud_accounts_callback_router, cloud_accounts_router
from fixbackend.config import Config
+from fixbackend.customer_support.router import admin_console_router
from fixbackend.dependencies import ServiceNames as SN, FixDependency, FixDependencies # noqa
from fixbackend.errors import ClientError, NotAllowed, ResourceNotFound, WrongState
from fixbackend.events.router import websocket_router
@@ -53,11 +55,7 @@
from fixbackend.notification.notification_router import notification_router, unsubscribe_router
from fixbackend.permissions.router import roles_router
from fixbackend.subscription.router import subscription_router
-from fixbackend.types import Redis
from fixbackend.workspaces.router import workspaces_router
-from prometheus_client import Counter
-from fastapi_users.exceptions import FastAPIUsersException
-
log = logging.getLogger(__name__)
API_PREFIX = "/api"
@@ -301,9 +299,7 @@ async def hello() -> Response:
if cfg.args.mode == "app":
api_router = APIRouter(prefix=API_PREFIX)
- api_router.include_router(
- auth_router(cfg, google, github, deps.service(SN.temp_store_redis, Redis)), prefix="/auth", tags=["auth"]
- )
+ api_router.include_router(auth_router(cfg, google, github, deps), prefix="/auth", tags=["auth"])
api_router.include_router(workspaces_router(), prefix="/workspaces", tags=["workspaces"])
api_router.include_router(cloud_accounts_router(deps), prefix="/workspaces", tags=["cloud_accounts"])
api_router.include_router(inventory_router(deps), prefix="/workspaces")
diff --git a/fixbackend/auth/auth_backend.py b/fixbackend/auth/auth_backend.py
index d29bc4e7..854fbe87 100644
--- a/fixbackend/auth/auth_backend.py
+++ b/fixbackend/auth/auth_backend.py
@@ -11,7 +11,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-
+import time
from datetime import timedelta
from typing import Any, Dict, List, Optional
@@ -63,6 +63,10 @@ async def read_token(self, token: Optional[str], user_manager: BaseUserManager[U
try:
parsed_id = user_manager.parse_id(user_id)
user = await user_manager.get(parsed_id)
+ if amt := user.auth_min_time:
+ data_at = data.get("at")
+ if data_at is None or (data_at / 1000) < amt.timestamp():
+ return None
return user
except (exceptions.UserNotExists, exceptions.InvalidID):
return None
@@ -76,6 +80,7 @@ async def create_token(self, sub: str, token_origin: str, permissions: Dict[Work
"sub": sub,
"token_origin": token_origin,
"permissions": {str(ws): perms for ws, perms in permissions.items()},
+ "at": int(time.time() * 1000), # precision: milliseconds
}
if self.lifetime_seconds:
expire = utc() + timedelta(seconds=self.lifetime_seconds)
diff --git a/fixbackend/auth/router.py b/fixbackend/auth/auth_router.py
similarity index 93%
rename from fixbackend/auth/router.py
rename to fixbackend/auth/auth_router.py
index 98af6862..3430c052 100644
--- a/fixbackend/auth/router.py
+++ b/fixbackend/auth/auth_router.py
@@ -11,16 +11,18 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-from datetime import timedelta
+from datetime import timedelta, datetime, timezone
from logging import getLogger
from typing import Any, Dict, List, Optional, Tuple
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status, Form
+from fastapi import Query
from fastapi_users.authentication import AuthenticationBackend, Strategy
from fastapi_users.exceptions import UserAlreadyExists, InvalidPasswordException, UserNotExists
from fastapi_users.router import ErrorCode
from fastapi_users.router.oauth import generate_state_token
+from fixcloudutils.util import utc
from httpx_oauth.clients.google import GoogleOAuth2
from httpx_oauth.oauth2 import BaseOAuth2
@@ -39,7 +41,9 @@
OAuth2PasswordMFARequestForm,
)
from fixbackend.auth.user_manager import UserManagerDependency, UserManager, get_user_manager
+from fixbackend.auth.user_repository import UserRepository
from fixbackend.config import Config
+from fixbackend.dependencies import FixDependency, ServiceNames as SN
from fixbackend.ids import UserId
from fastapi_users import schemas
from disposable_email_domains import blocklist
@@ -77,12 +81,14 @@ async def get_associate_url(
def auth_router(
- config: Config, google_client: GoogleOAuth2, github_client: GithubOauthClient, redis: Redis
+ config: Config,
+ google_client: GoogleOAuth2,
+ github_client: GithubOauthClient,
+ dependencies: FixDependency,
) -> APIRouter:
router = APIRouter()
-
+ redis = dependencies.service(SN.temp_store_redis, Redis)
login_rate_limiter = LoginRateLimiter(redis, limit=config.auth_rate_limit_per_minute, window=timedelta(minutes=1))
-
auth_backend = get_auth_backend(config)
router.include_router(
@@ -243,6 +249,19 @@ async def logout(
return await auth_backend.logout(strategy, user, token)
+ @router.put("/jwt/expire")
+ async def jwt_expire(
+ user: AuthenticatedUser,
+ expire_older_than: Optional[datetime] = Query(
+ default=None,
+ description="All tokens older than this timestamp get invalidated. "
+ "If no value is provided, the current time is assumed.",
+ ),
+ ) -> Response:
+ ts_utc = expire_older_than.astimezone(timezone.utc) if expire_older_than else utc()
+ await dependencies.service(SN.user_repo, UserRepository).update_partial(user.id, auth_min_time=ts_utc)
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
+
@router.post("/register", status_code=status.HTTP_201_CREATED, name="register:register")
async def register(request: Request, user_create: UserCreate, user_manager: UserManagerDependency) -> UserRead:
try:
diff --git a/fixbackend/auth/models/__init__.py b/fixbackend/auth/models/__init__.py
index 4235d466..71d16a0c 100644
--- a/fixbackend/auth/models/__init__.py
+++ b/fixbackend/auth/models/__init__.py
@@ -47,6 +47,7 @@ class User(UserOAuthProtocol[UserId, OAuthAccount]):
oauth_accounts: List[OAuthAccount]
roles: List[UserRole]
created_at: datetime
+ auth_min_time: Optional[datetime] = None
@frozen
diff --git a/fixbackend/auth/models/orm.py b/fixbackend/auth/models/orm.py
index 3351b1fa..90bc5d24 100644
--- a/fixbackend/auth/models/orm.py
+++ b/fixbackend/auth/models/orm.py
@@ -71,6 +71,7 @@ class User(SQLAlchemyBaseUserTableUUID, CreatedUpdatedMixin, Base):
)
last_login: Mapped[Optional[datetime]] = mapped_column(UTCDateTime, nullable=True)
last_active: Mapped[Optional[datetime]] = mapped_column(UTCDateTime, nullable=True)
+ auth_min_time: Mapped[Optional[datetime]] = mapped_column(UTCDateTime, nullable=True)
def to_model(self) -> models.User:
return models.User(
@@ -85,20 +86,7 @@ def to_model(self) -> models.User:
is_mfa_active=self.is_mfa_active,
roles=[role.to_model() for role in self.roles],
created_at=self.created_at,
- )
-
- @staticmethod
- def from_model(user: models.User) -> "User":
- return User(
- id=user.id,
- email=user.email,
- hashed_password=user.hashed_password,
- is_active=user.is_active,
- is_superuser=user.is_superuser,
- is_verified=user.is_verified,
- otp_secret=user.otp_secret,
- is_mfa_active=user.is_mfa_active,
- oauth_accounts=[OAuthAccount.from_model(acc) for acc in user.oauth_accounts],
+ auth_min_time=self.auth_min_time,
)
diff --git a/fixbackend/auth/user_repository.py b/fixbackend/auth/user_repository.py
index 25018d7b..30dc2d76 100644
--- a/fixbackend/auth/user_repository.py
+++ b/fixbackend/auth/user_repository.py
@@ -112,7 +112,11 @@ async def create(self, create_dict: Dict[str, Any]) -> User:
return user.to_model()
async def update_partial(
- self, uid: UserId, last_login: Optional[datetime] = None, last_active: Optional[datetime] = None
+ self,
+ uid: UserId,
+ last_login: Optional[datetime] = None,
+ last_active: Optional[datetime] = None,
+ auth_min_time: Optional[datetime] = None,
) -> None:
async with self.user_db() as db:
orm_user = await db.session.get(orm.User, uid)
@@ -122,6 +126,8 @@ async def update_partial(
orm_user.last_login = last_login
if last_active:
orm_user.last_active = last_active
+ if auth_min_time:
+ orm_user.auth_min_time = auth_min_time
await db.session.commit()
async def update(self, user: User, update_dict: Dict[str, Any]) -> User:
@@ -145,7 +151,8 @@ async def delete(self, user: User) -> None:
"""Delete a user."""
async with self.user_db() as db:
- await db.delete(orm.User.from_model(user))
+ await db.session.execute(delete(orm.User).where(orm.User.id == user.id)) # type: ignore
+ await db.session.commit()
async def add_oauth_account(self, user: User, create_dict: Dict[str, Any]) -> User:
"""Create an OAuth account and add it to the user."""
diff --git a/migrations/versions/2024-10-08T07:09:26Z_user_auth_min_time.py b/migrations/versions/2024-10-08T07:09:26Z_user_auth_min_time.py
new file mode 100644
index 00000000..e3a3cc85
--- /dev/null
+++ b/migrations/versions/2024-10-08T07:09:26Z_user_auth_min_time.py
@@ -0,0 +1,18 @@
+"""user:
+Introduce auth_min_time column to user table.
+Create Date: 2024-10-08 07:09:26.627447+00:00
+"""
+
+from typing import Union
+
+import sqlalchemy as sa
+from alembic import op
+
+from fixbackend.sqlalechemy_extensions import UTCDateTime
+
+revision: str = "f5eaa189e1f2"
+down_revision: Union[str, None] = "000dd4f966a4"
+
+
+def upgrade() -> None:
+ op.add_column("user", sa.Column("auth_min_time", UTCDateTime(timezone=True), nullable=True))
diff --git a/tests/fixbackend/auth/router_test.py b/tests/fixbackend/auth/auth_router_test.py
similarity index 87%
rename from tests/fixbackend/auth/router_test.py
rename to tests/fixbackend/auth/auth_router_test.py
index a81bdf04..3786f52c 100644
--- a/tests/fixbackend/auth/router_test.py
+++ b/tests/fixbackend/auth/auth_router_test.py
@@ -11,12 +11,15 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
-
+import time
from typing import Callable, List, Optional, Sequence, Tuple, override
import jwt
import pytest
from fastapi import Request, FastAPI
+from fastapi_users.password import PasswordHelper
+from fixcloudutils.types import Json
+from fixcloudutils.util import utc
from httpx import AsyncClient
from pyotp import TOTP
from sqlalchemy import select
@@ -30,7 +33,7 @@
from fixbackend.auth.user_repository import UserRepository
from fixbackend.auth.user_verifier import AuthEmailSender
from fixbackend.certificates.cert_store import CertificateStore
-from fixbackend.dependencies import FixDependencies, ServiceNames
+from fixbackend.dependencies import FixDependencies, ServiceNames as SN
from fixbackend.domain_events.events import Event, UserRegistered, WorkspaceCreated
from fixbackend.domain_events.publisher import DomainEventPublisher
from fixbackend.ids import InvitationId, UserId, WorkspaceId
@@ -40,7 +43,6 @@
from fixbackend.workspaces.models import WorkspaceInvitation
from fixbackend.workspaces.repository import WorkspaceRepository
from tests.fixbackend.conftest import InMemoryDomainEventPublisher, InsecureFastPasswordHelper
-from fastapi_users.password import PasswordHelper
class InMemoryVerifier(AuthEmailSender):
@@ -133,10 +135,10 @@ async def user_manager(
password_helper: InsecureFastPasswordHelper,
fix_deps: FixDependencies,
) -> UserManager:
- verifier = fix_deps.add(ServiceNames.auth_email_sender, InMemoryVerifier())
- invitation_repo = fix_deps.add(ServiceNames.invitation_repository, InMemoryInvitationRepo())
+ verifier = fix_deps.add(SN.auth_email_sender, InMemoryVerifier())
+ invitation_repo = fix_deps.add(SN.invitation_repository, InMemoryInvitationRepo())
return fix_deps.add(
- ServiceNames.user_manager,
+ SN.user_manager,
UserManager(
fix_deps.config,
user_repository,
@@ -149,22 +151,8 @@ async def user_manager(
)
-@pytest.mark.asyncio
-async def test_registration_flow(
- api_client: AsyncClient,
- fast_api: FastAPI,
- domain_event_sender: InMemoryDomainEventPublisher,
- workspace_repository: WorkspaceRepository,
- user_repository: UserRepository,
- cert_store: CertificateStore,
- user_manager: UserManager,
- jwt_strategy: FixJWTStrategy,
- fix_deps: FixDependencies,
-) -> None:
-
- user_manager.password_helper = PasswordHelper()
- verifier = fix_deps.service(ServiceNames.auth_email_sender, InMemoryVerifier)
- role_repo = fix_deps.add(ServiceNames.role_repository, InMemoryRoleRepository())
+async def register_user(fix_deps: FixDependencies, api_client: AsyncClient) -> Tuple[User, Json, str]:
+ verifier = fix_deps.service(SN.auth_email_sender, InMemoryVerifier)
registration_json = {
"email": "user@example.com",
"password": "changeMe123456789",
@@ -197,18 +185,37 @@ async def test_registration_flow(
assert response_json["is_active"] is True
assert response_json["id"] == str(user.id)
- # workspace is created
- workspaces = await workspace_repository.list_workspaces(user)
- assert len(workspaces) == 1
- workspace = workspaces[0]
- await role_repo.add_roles(user.id, workspace.id, Roles.workspace_owner)
-
# verified can login
response = await api_client.post("/api/auth/jwt/login", data=login_json)
assert response.status_code == 204
auth_cookie = response.cookies.get(SessionCookie)
assert auth_cookie is not None
+ return user, login_json, auth_cookie
+
+
+@pytest.mark.asyncio
+async def test_registration_flow(
+ api_client: AsyncClient,
+ fast_api: FastAPI,
+ domain_event_sender: InMemoryDomainEventPublisher,
+ workspace_repository: WorkspaceRepository,
+ user_repository: UserRepository,
+ cert_store: CertificateStore,
+ user_manager: UserManager,
+ jwt_strategy: FixJWTStrategy,
+ fix_deps: FixDependencies,
+) -> None:
+ user_manager.password_helper = PasswordHelper()
+ role_repo = fix_deps.add(SN.role_repository, InMemoryRoleRepository())
+ user, login_json, auth_cookie = await register_user(fix_deps, api_client)
+
+ # workspace is created
+ workspaces = await workspace_repository.list_workspaces(user)
+ assert len(workspaces) == 1
+ workspace = workspaces[0]
+ await role_repo.add_roles(user.id, workspace.id, Roles.workspace_owner)
+
# role is set on login
auth_token = jwt.api_jwt.decode_complete(auth_cookie, options={"verify_signature": False})
assert auth_token["payload"]["permissions"] == {str(workspace.id): workspace_owner_permissions.value}
@@ -245,7 +252,7 @@ async def test_registration_flow(
# password can be reset with providing a current one
response = await api_client.patch(
"/api/users/me",
- json={"password": "FooBar123456789123456789", "current_password": registration_json["password"]},
+ json={"password": "FooBar123456789123456789", "current_password": login_json["password"]},
cookies={SessionCookie: auth_cookie},
)
assert response.status_code == 200
@@ -262,7 +269,7 @@ async def test_mfa_flow(
jwt_strategy: FixJWTStrategy,
fix_deps: FixDependencies,
) -> None:
- verifier = fix_deps.service(ServiceNames.auth_email_sender, InMemoryVerifier)
+ verifier = fix_deps.service(SN.auth_email_sender, InMemoryVerifier)
# register user
registration_json = {"email": "user2@example.com", "password": "changeMe123456789"}
@@ -359,3 +366,33 @@ async def test_mfa_flow(
cookies={SessionCookie: auth_cookie},
)
assert response.status_code == 204
+
+
+@pytest.mark.asyncio
+async def test_auth_min_time(api_client: AsyncClient, fix_deps: FixDependencies, user_manager: UserManager) -> None:
+ _, login_json, auth_cookie = await register_user(fix_deps, api_client)
+
+ # API can be accessed
+ resp = await api_client.get("/api/users/me", cookies={SessionCookie: auth_cookie})
+ assert resp.status_code == 200
+
+ # Update user's auth_min_time
+ time.sleep(0.01)
+ resp = await api_client.put(
+ "/api/auth/jwt/expire", params=dict(expire_older_than=str(utc())), cookies={SessionCookie: auth_cookie}
+ )
+ assert resp.status_code == 204
+
+ # API cannot be accessed, since JWT is invalid
+ resp = await api_client.get("/api/users/me", cookies={SessionCookie: auth_cookie})
+ assert resp.status_code == 401
+
+ # Login again
+ time.sleep(0.01)
+ response = await api_client.post("/api/auth/jwt/login", data=login_json)
+ assert response.status_code == 204
+ auth_cookie = response.cookies.get(SessionCookie) or ""
+
+ # API can be accessed
+ resp = await api_client.get("/api/users/me", cookies={SessionCookie: auth_cookie})
+ assert resp.status_code == 200
diff --git a/tests/fixbackend/conftest.py b/tests/fixbackend/conftest.py
index 4fd5a979..ed32fe98 100644
--- a/tests/fixbackend/conftest.py
+++ b/tests/fixbackend/conftest.py
@@ -971,7 +971,7 @@ def gcp_service_account_key_repo(async_session_maker: AsyncSessionMaker) -> GcpS
class GcpServiceAccountServiceMock(GcpServiceAccountService):
- def __init__(self) -> None:
+ def __init__(self) -> None: # noqa
pass
async def start(self) -> Any:
@@ -1003,7 +1003,7 @@ def azure_subscription_credentials_repo(
class AzureSubscriptionServiceMock(AzureSubscriptionService):
- def __init__(self) -> None:
+ def __init__(self) -> None: # noqa
pass
async def start(self) -> Any:
@@ -1127,6 +1127,7 @@ async def fix_deps(
azure_subscription_service: AzureSubscriptionService,
jwt_service: JwtService,
redis: Redis,
+ user_repository: UserRepository,
) -> FixDependencies:
# noinspection PyTestUnpassedFixture
return FixDependencies(
@@ -1153,6 +1154,7 @@ async def fix_deps(
ServiceNames.azure_subscription_service: azure_subscription_service,
ServiceNames.jwt_service: jwt_service,
ServiceNames.temp_store_redis: redis,
+ ServiceNames.user_repo: user_repository,
}
)
@@ -1224,11 +1226,11 @@ async def get_prices(self) -> List[stripe.Price]:
self.requests.append(dict(call="get_prices"))
return []
- async def checkout_session(self, customer: str, **params: Any) -> str: # type: ignore
+ async def checkout_session(self, customer: str, **params: Any) -> str: # type: ignore # noqa
self.requests.append(dict(call="checkout_session", customer=customer))
return f"https://localhost/{customer}/checkout"
- async def billing_portal_session(self, customer: str, **params: Any) -> str: # type: ignore
+ async def billing_portal_session(self, customer: str, **params: Any) -> str: # type: ignore # noqa
self.requests.append(dict(call="billing_portal_session", customer=customer))
return f"https://localhost/{customer}/billing"
@@ -1311,7 +1313,7 @@ def email_sender() -> InMemoryEmailSender:
class JwtServiceMock(JwtService):
- def __init__(self) -> None:
+ def __init__(self) -> None: # noqa
self.secret = "secret"
async def encode(self, payload: Json, audience: List[str]) -> str: