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: