Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat] Maintain last_login and last_active timestamps per user #631

Merged
merged 2 commits into from
Oct 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Dismissed Show dismissed Hide dismissed
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)
Dismissed Show dismissed Hide dismissed
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": "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

# 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
Loading