Skip to content

Commit

Permalink
[feat] Maintain last_login and last_active timestamps per user
Browse files Browse the repository at this point in the history
  • Loading branch information
aquamatthias committed Oct 7, 2024
1 parent 45ecb12 commit 474d5a4
Show file tree
Hide file tree
Showing 10 changed files with 89 additions and 30 deletions.
2 changes: 1 addition & 1 deletion fixbackend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
4 changes: 2 additions & 2 deletions fixbackend/auth/auth_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 5 additions & 4 deletions fixbackend/auth/depedencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

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
Expand All @@ -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):
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions fixbackend/auth/models/orm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
6 changes: 4 additions & 2 deletions fixbackend/auth/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

from fixbackend.auth.cookies import APIKeyCookie

FixAuthenticatedCookie = "fix.authenticated"


class CookieTransport(Transport):
scheme: APIKeyCookie
Expand Down Expand Up @@ -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)

Check warning

Code scanning / CodeQL

Failure to use secure cookies Medium

Cookie is added without the Secure and HttpOnly attributes properly set.
response.set_cookie(
self.cookie_name,
token,
Expand All @@ -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)

Check warning

Code scanning / CodeQL

Failure to use secure cookies Medium

Cookie is added without the Secure and HttpOnly attributes properly set.
response.set_cookie(
self.cookie_name,
"",
Expand Down
3 changes: 3 additions & 0 deletions fixbackend/auth/user_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 (
Expand Down
15 changes: 14 additions & 1 deletion fixbackend/auth/user_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

from datetime import datetime
from typing import Annotated, Any, AsyncIterator, Dict, List, Optional, Sequence
from uuid import UUID

Expand Down Expand Up @@ -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."""

Expand Down
21 changes: 18 additions & 3 deletions fixbackend/auth/users_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,23 @@
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

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))
Expand All @@ -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
Original file line number Diff line number Diff line change
@@ -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))
34 changes: 17 additions & 17 deletions tests/fixbackend/auth/router_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -206,20 +206,20 @@ 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
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}

# 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
Expand All @@ -238,15 +238,15 @@ 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": "[email protected]"}, cookies={session_cookie_name: auth_cookie}
"/api/users/me", json={"password": "[email protected]"}, cookies={SessionCookie: auth_cookie}
)
assert response.status_code == 400

# 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"]},
cookies={session_cookie_name: auth_cookie},
cookies={SessionCookie: auth_cookie},
)
assert response.status_code == 200

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand All @@ -348,14 +348,14 @@ 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

# mfa can be disabled with recovery code
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

0 comments on commit 474d5a4

Please sign in to comment.