diff --git a/fixbackend/app.py b/fixbackend/app.py index 66304c92..bd0f373c 100644 --- a/fixbackend/app.py +++ b/fixbackend/app.py @@ -309,7 +309,7 @@ async def hello() -> Response: api_router.include_router(inventory_router(deps), prefix="/workspaces") api_router.include_router(websocket_router(cfg), prefix="/workspaces", tags=["events"]) api_router.include_router(cloud_accounts_callback_router(deps), prefix="/cloud", tags=["cloud_accounts"]) - api_router.include_router(users_router(), prefix="/users", tags=["users"]) + api_router.include_router(users_router(deps), prefix="/users", tags=["users"]) api_router.include_router(subscription_router(deps), tags=["billing"]) api_router.include_router(billing_info_router(cfg), prefix="/workspaces", tags=["billing"]) api_router.include_router(notification_router(deps), prefix="/workspaces", tags=["notification"]) diff --git a/fixbackend/auth/auth_backend.py b/fixbackend/auth/auth_backend.py index 3dca30d2..d29bc4e7 100644 --- a/fixbackend/auth/auth_backend.py +++ b/fixbackend/auth/auth_backend.py @@ -92,12 +92,12 @@ async def get_session_strategy(fix: FixDependency) -> Strategy[User, UserId]: return fix.service(ServiceNames.jwt_strategy, FixJWTStrategy) -session_cookie_name = "session_token" +SessionCookie = "session_token" def cookie_transport(session_ttl: int) -> CookieTransport: return CookieTransport( - cookie_name=session_cookie_name, + cookie_name=SessionCookie, cookie_secure=True, cookie_httponly=True, cookie_samesite="lax", diff --git a/fixbackend/auth/depedencies.py b/fixbackend/auth/depedencies.py index f08a32e3..07b17cdd 100644 --- a/fixbackend/auth/depedencies.py +++ b/fixbackend/auth/depedencies.py @@ -13,14 +13,15 @@ # 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 typing import Annotated, Optional -from datetime import datetime, timedelta from fastapi import Depends, Cookie from fastapi_users import FastAPIUsers +from fixcloudutils.util import utc from starlette.requests import HTTPConnection, Request -from fixbackend.auth.auth_backend import get_auth_backend, get_session_strategy, session_cookie_name, FixJWTStrategy +from fixbackend.auth.auth_backend import get_auth_backend, get_session_strategy, SessionCookie, FixJWTStrategy from fixbackend.auth.models import User from fixbackend.auth.user_manager import get_user_manager from fixbackend.config import get_config @@ -41,7 +42,7 @@ async def get_current_active_verified_user( connection: HTTPConnection, # could be either a websocket or an http request user: Annotated[User, Depends(get_current_active_user)], strategy: Annotated[FixJWTStrategy, Depends(get_session_strategy)], - session_token: Annotated[Optional[str], Cookie(alias=session_cookie_name, include_in_schema=False)] = None, + session_token: Annotated[Optional[str], Cookie(alias=SessionCookie, include_in_schema=False)] = None, ) -> User: # if this is called for websocket - skip the rest if not isinstance(connection, Request): @@ -52,7 +53,7 @@ async def get_current_active_verified_user( # if we get the authenticated user, the jwt cookie should be there. if session_token and (token := await strategy.decode_token(session_token)): # if the token is to be expired in 1 hour, we need to refresh it - if token.get("exp", 0) < (datetime.utcnow() + timedelta(hours=1)).timestamp(): + if token.get("exp", 0) < (utc() + timedelta(hours=1)).timestamp(): connection.scope[refreshed_session_scope] = await strategy.write_token(user) return user diff --git a/fixbackend/auth/models/orm.py b/fixbackend/auth/models/orm.py index 3860fce2..3351b1fa 100644 --- a/fixbackend/auth/models/orm.py +++ b/fixbackend/auth/models/orm.py @@ -69,6 +69,8 @@ class User(SQLAlchemyBaseUserTableUUID, CreatedUpdatedMixin, Base): roles: Mapped[List[UserRoleAssignmentEntity]] = relationship( "UserRoleAssignmentEntity", backref="user", lazy="joined" ) + last_login: Mapped[Optional[datetime]] = mapped_column(UTCDateTime, nullable=True) + last_active: Mapped[Optional[datetime]] = mapped_column(UTCDateTime, nullable=True) def to_model(self) -> models.User: return models.User( diff --git a/fixbackend/auth/transport.py b/fixbackend/auth/transport.py index 16a6c8b6..6211132c 100644 --- a/fixbackend/auth/transport.py +++ b/fixbackend/auth/transport.py @@ -21,6 +21,8 @@ from fixbackend.auth.cookies import APIKeyCookie +FixAuthenticatedCookie = "fix.authenticated" + class CookieTransport(Transport): scheme: APIKeyCookie @@ -53,7 +55,7 @@ async def get_logout_response(self) -> Response: return self._set_logout_cookie(response) def _set_login_cookie(self, response: Response, token: str) -> Response: - response.set_cookie("fix.authenticated", value="1", samesite="lax", max_age=self.cookie_max_age) + response.set_cookie(FixAuthenticatedCookie, value="1", samesite="lax", max_age=self.cookie_max_age) response.set_cookie( self.cookie_name, token, @@ -67,7 +69,7 @@ def _set_login_cookie(self, response: Response, token: str) -> Response: return response def _set_logout_cookie(self, response: Response) -> Response: - response.set_cookie("fix.authenticated", value="0", samesite="lax", max_age=self.cookie_max_age) + response.set_cookie(FixAuthenticatedCookie, value="0", samesite="lax", max_age=self.cookie_max_age) response.set_cookie( self.cookie_name, "", diff --git a/fixbackend/auth/user_manager.py b/fixbackend/auth/user_manager.py index f3f209dc..45976ce3 100644 --- a/fixbackend/auth/user_manager.py +++ b/fixbackend/auth/user_manager.py @@ -25,6 +25,7 @@ from fastapi import Depends, Request from fastapi_users import BaseUserManager, exceptions from fastapi_users.password import PasswordHelperProtocol, PasswordHelper +from fixcloudutils.util import utc from passlib.context import CryptContext from starlette.responses import Response @@ -104,6 +105,8 @@ async def on_after_login( await super().on_after_login(user, request, response) log.info(f"User logged in: {user.email} ({user.id})") await self.domain_events_publisher.publish(UserLoggedIn(user.id, user.email)) + now = utc() + await self.user_repository.update_partial(user.id, last_active=now, last_login=now) async def add_to_workspace(self, user: User) -> None: if ( diff --git a/fixbackend/auth/user_repository.py b/fixbackend/auth/user_repository.py index b5ea1da0..25018d7b 100644 --- a/fixbackend/auth/user_repository.py +++ b/fixbackend/auth/user_repository.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 . - +from datetime import datetime from typing import Annotated, Any, AsyncIterator, Dict, List, Optional, Sequence from uuid import UUID @@ -111,6 +111,19 @@ async def create(self, create_dict: Dict[str, Any]) -> User: user = await db.create(create_dict) return user.to_model() + async def update_partial( + self, uid: UserId, last_login: Optional[datetime] = None, last_active: Optional[datetime] = None + ) -> None: + async with self.user_db() as db: + orm_user = await db.session.get(orm.User, uid) + if orm_user is None: + raise ValueError(f"User {uid} not found") + if last_login: + orm_user.last_login = last_login + if last_active: + orm_user.last_active = last_active + await db.session.commit() + async def update(self, user: User, update_dict: Dict[str, Any]) -> User: """Update a user.""" diff --git a/fixbackend/auth/users_router.py b/fixbackend/auth/users_router.py index e775b46b..d5cf9572 100644 --- a/fixbackend/auth/users_router.py +++ b/fixbackend/auth/users_router.py @@ -11,16 +11,23 @@ # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . - +import logging from fastapi.routing import APIRouter +from fixcloudutils.util import utc +from starlette.responses import Response + from fixbackend.auth.depedencies import AuthenticatedUser, fastapi_users from fixbackend.auth.schemas import UserNotificationSettingsRead, UserRead, UserUpdate, UserNotificationSettingsWrite +from fixbackend.auth.user_repository import UserRepository +from fixbackend.dependencies import FixDependencies, ServiceNames from fixbackend.notification.user_notification_repo import UserNotificationSettingsRepositoryDependency +log = logging.getLogger(__name__) -def users_router() -> APIRouter: + +def users_router(dependencies: FixDependencies) -> APIRouter: router = APIRouter() router.include_router(fastapi_users.get_users_router(UserRead, UserUpdate)) @@ -39,7 +46,15 @@ async def update_user_notification_settings( notification_settings: UserNotificationSettingsWrite, user_notification_repo: UserNotificationSettingsRepositoryDependency, ) -> UserNotificationSettingsRead: - updated = await user_notification_repo.update_notification_settings(user.id, **notification_settings.dict()) + updated = await user_notification_repo.update_notification_settings( + user.id, **notification_settings.model_dump() + ) return UserNotificationSettingsRead.from_model(updated) + @router.post("/me/active") + async def signal_active(user: AuthenticatedUser) -> Response: + log.info(f"User {user.id} send an active signal") + await dependencies.service(ServiceNames.user_repo, UserRepository).update_partial(user.id, last_active=utc()) + return Response(status_code=204) + return router diff --git a/migrations/versions/2024-10-07T09:24:49Z_user_last_login_and_activity.py b/migrations/versions/2024-10-07T09:24:49Z_user_last_login_and_activity.py new file mode 100644 index 00000000..a656c310 --- /dev/null +++ b/migrations/versions/2024-10-07T09:24:49Z_user_last_login_and_activity.py @@ -0,0 +1,23 @@ +"""user: last_login and activity + +Revision ID: 000dd4f966a4 +Revises: 2c3086217445 +Create Date: 2024-10-07 09:24:49.803805+00:00 + +""" + +from typing import Union + +from alembic import op +import sqlalchemy as sa + +from fixbackend.sqlalechemy_extensions import UTCDateTime + +# revision identifiers, used by Alembic. +revision: str = "000dd4f966a4" +down_revision: Union[str, None] = "2c3086217445" + + +def upgrade() -> None: + op.add_column("user", sa.Column("last_login", UTCDateTime(timezone=True), nullable=True)) + op.add_column("user", sa.Column("last_active", UTCDateTime(timezone=True), nullable=True)) diff --git a/tests/fixbackend/auth/router_test.py b/tests/fixbackend/auth/router_test.py index 20306643..a81bdf04 100644 --- a/tests/fixbackend/auth/router_test.py +++ b/tests/fixbackend/auth/router_test.py @@ -22,7 +22,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from fixbackend.auth.auth_backend import session_cookie_name, FixJWTStrategy +from fixbackend.auth.auth_backend import SessionCookie, FixJWTStrategy from fixbackend.auth.models import User from fixbackend.auth.models.orm import UserMFARecoveryCode from fixbackend.auth.schemas import OTPConfig @@ -62,7 +62,7 @@ async def publish(self, event: Event) -> None: class InMemoryInvitationRepo(InvitationRepository): - def __init__(self) -> None: + def __init__(self) -> None: # noqa pass async def get_invitation_by_email(self, email: str) -> Optional[WorkspaceInvitation]: @@ -90,7 +90,7 @@ async def update_invitation( class InMemoryRoleRepository(RoleRepository): - def __init__(self) -> None: + def __init__(self) -> None: # noqa self.roles: List[UserRole] = [] @override @@ -206,7 +206,7 @@ async def test_registration_flow( # 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(session_cookie_name) + auth_cookie = response.cookies.get(SessionCookie) assert auth_cookie is not None # role is set on login @@ -214,12 +214,12 @@ async def test_registration_flow( assert auth_token["payload"]["permissions"] == {str(workspace.id): workspace_owner_permissions.value} # workspace can be listed - response = await api_client.get("/api/workspaces/", cookies={session_cookie_name: auth_cookie}) + response = await api_client.get("/api/workspaces/", cookies={SessionCookie: auth_cookie}) workspace_json = response.json()[0] assert workspace_json.get("name") == user.email # workspace can be viewed by an owner - response = await api_client.get(f"/api/workspaces/{workspace.id}", cookies={session_cookie_name: auth_cookie}) + response = await api_client.get(f"/api/workspaces/{workspace.id}", cookies={SessionCookie: auth_cookie}) assert response.status_code == 200 workspace_json = response.json() assert workspace_json.get("name") == user.email @@ -238,7 +238,7 @@ async def test_registration_flow( # password can be reset only with providing a current one response = await api_client.patch( - "/api/users/me", json={"password": "foobar@foo.com"}, cookies={session_cookie_name: auth_cookie} + "/api/users/me", json={"password": "foobar@foo.com"}, cookies={SessionCookie: auth_cookie} ) assert response.status_code == 400 @@ -246,7 +246,7 @@ async def test_registration_flow( response = await api_client.patch( "/api/users/me", json={"password": "FooBar123456789123456789", "current_password": registration_json["password"]}, - cookies={session_cookie_name: auth_cookie}, + cookies={SessionCookie: auth_cookie}, ) assert response.status_code == 200 @@ -278,16 +278,16 @@ async def test_mfa_flow( login_json = {"username": registration_json["email"], "password": registration_json["password"]} response = await api_client.post("/api/auth/jwt/login", data=login_json) assert response.status_code == 204 - auth_cookie = response.cookies.get(session_cookie_name) + auth_cookie = response.cookies.get(SessionCookie) assert auth_cookie is not None # mfa can be added and enabled - response = await api_client.post("/api/auth/mfa/add", cookies={session_cookie_name: auth_cookie}) + response = await api_client.post("/api/auth/mfa/add", cookies={SessionCookie: auth_cookie}) assert response.status_code == 200 otp_config = OTPConfig.model_validate(response.json()) totp = TOTP(otp_config.secret) response = await api_client.post( - "/api/auth/mfa/enable", data={"otp": totp.now()}, cookies={session_cookie_name: auth_cookie} + "/api/auth/mfa/enable", data={"otp": totp.now()}, cookies={SessionCookie: auth_cookie} ) assert response.status_code == 204 @@ -301,13 +301,13 @@ async def test_mfa_flow( # mfa can-not be disabled without valid otp response = await api_client.post( - "/api/auth/mfa/disable", data={"otp": "wrong"}, cookies={session_cookie_name: auth_cookie} + "/api/auth/mfa/disable", data={"otp": "wrong"}, cookies={SessionCookie: auth_cookie} ) assert response.status_code == 428 # mfa can be disabled with otp response = await api_client.post( - "/api/auth/mfa/disable", data={"otp": totp.now()}, cookies={session_cookie_name: auth_cookie} + "/api/auth/mfa/disable", data={"otp": totp.now()}, cookies={SessionCookie: auth_cookie} ) assert response.status_code == 204 @@ -316,12 +316,12 @@ async def test_mfa_flow( assert response.status_code == 204 # enable mfa again - response = await api_client.post("/api/auth/mfa/add", cookies={session_cookie_name: auth_cookie}) + response = await api_client.post("/api/auth/mfa/add", cookies={SessionCookie: auth_cookie}) assert response.status_code == 200 otp_config = OTPConfig.model_validate(response.json()) totp = TOTP(otp_config.secret) response = await api_client.post( - "/api/auth/mfa/enable", data={"otp": totp.now()}, cookies={session_cookie_name: auth_cookie} + "/api/auth/mfa/enable", data={"otp": totp.now()}, cookies={SessionCookie: auth_cookie} ) assert response.status_code == 204 @@ -348,7 +348,7 @@ async def test_mfa_flow( # mfa can-not be disabled without valid recovery code response = await api_client.post( - "/api/auth/mfa/disable", data={"recovery_code": "wrong"}, cookies={session_cookie_name: auth_cookie} + "/api/auth/mfa/disable", data={"recovery_code": "wrong"}, cookies={SessionCookie: auth_cookie} ) assert response.status_code == 428 @@ -356,6 +356,6 @@ async def test_mfa_flow( response = await api_client.post( "/api/auth/mfa/disable", data={"recovery_code": otp_config.recovery_codes[1]}, - cookies={session_cookie_name: auth_cookie}, + cookies={SessionCookie: auth_cookie}, ) assert response.status_code == 204