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