From cf8427bfff001d6941c076e6afe0c2372814900a Mon Sep 17 00:00:00 2001 From: Ivan Golubykh Date: Fri, 1 Dec 2023 12:46:21 +0400 Subject: [PATCH] M2-4180 Track user login operations added. --- src/apps/authentication/api/auth.py | 26 +++---- src/apps/authentication/api/auth_utils.py | 25 +++++++ src/apps/logs/crud/user_activity_log.py | 12 +++ src/apps/logs/db/schemas.py | 23 +++++- src/apps/logs/domain/constants.py | 9 +++ src/apps/logs/services/user_activity_log.py | 12 +++ src/apps/logs/user_activity_log.py | 35 +++++++++ ...2023_11_30_10_41-add_user_activity_logs.py | 73 +++++++++++++++++++ src/infrastructure/http/deps.py | 2 +- src/infrastructure/http/domain.py | 2 + 10 files changed, 200 insertions(+), 19 deletions(-) create mode 100644 src/apps/authentication/api/auth_utils.py create mode 100644 src/apps/logs/crud/user_activity_log.py create mode 100644 src/apps/logs/domain/constants.py create mode 100644 src/apps/logs/services/user_activity_log.py create mode 100644 src/apps/logs/user_activity_log.py create mode 100644 src/infrastructure/database/migrations/versions/2023_11_30_10_41-add_user_activity_logs.py diff --git a/src/apps/authentication/api/auth.py b/src/apps/authentication/api/auth.py index 4dfba6e35ee..a51c09b69fa 100644 --- a/src/apps/authentication/api/auth.py +++ b/src/apps/authentication/api/auth.py @@ -5,6 +5,7 @@ from jose import JWTError, jwt from pydantic import ValidationError +from apps.authentication.api.auth_utils import auth_user from apps.authentication.deps import get_current_token, get_current_user from apps.authentication.domain.login import UserLogin, UserLoginRequest from apps.authentication.domain.logout import UserLogoutRequest @@ -16,17 +17,13 @@ TokenPayload, TokenPurpose, ) -from apps.authentication.errors import ( - AuthenticationError, - InvalidCredentials, - InvalidRefreshToken, -) +from apps.authentication.errors import AuthenticationError, InvalidRefreshToken from apps.authentication.services.security import AuthenticationService +from apps.logs.user_activity_log import user_activity_login_log from apps.shared.domain.response import Response from apps.shared.response import EmptyResponse from apps.users import UsersCRUD from apps.users.domain import PublicUser, User -from apps.users.errors import UserNotFound from apps.users.services.user_device import UserDeviceService from config import settings from infrastructure.database import atomic @@ -36,20 +33,15 @@ async def get_token( user_login_schema: UserLoginRequest = Body(...), session=Depends(get_session), + user=Depends(auth_user), + user_activity_log=Depends(user_activity_login_log), ) -> Response[UserLogin]: """Generate the JWT access token.""" async with atomic(session): - try: - user: User = await AuthenticationService( - session - ).authenticate_user(user_login_schema) - if user_login_schema.device_id: - await UserDeviceService(session, user.id).add_device( - user_login_schema.device_id - ) - except UserNotFound: - raise InvalidCredentials(email=user_login_schema.email) - + if user_login_schema.device_id: + await UserDeviceService(session, user.id).add_device( + user_login_schema.device_id + ) if user.email_encrypted != user_login_schema.email: user = await UsersCRUD(session).update_encrypted_email( user, user_login_schema.email diff --git a/src/apps/authentication/api/auth_utils.py b/src/apps/authentication/api/auth_utils.py new file mode 100644 index 00000000000..df45ac26714 --- /dev/null +++ b/src/apps/authentication/api/auth_utils.py @@ -0,0 +1,25 @@ +from fastapi import Body, Depends + +from apps.authentication.domain.login import UserLoginRequest +from apps.authentication.errors import InvalidCredentials +from apps.authentication.services.security import AuthenticationService +from apps.users.domain import User +from apps.users.errors import UserNotFound +from infrastructure.database import atomic +from infrastructure.database.deps import get_session + + +async def auth_user( + user_login_schema: UserLoginRequest = Body(...), + session=Depends(get_session), +) -> User: + async with atomic(session): + try: + user: User = await AuthenticationService( + session + ).authenticate_user(user_login_schema) + + except UserNotFound: + raise InvalidCredentials(email=user_login_schema.email) + + return user diff --git a/src/apps/logs/crud/user_activity_log.py b/src/apps/logs/crud/user_activity_log.py new file mode 100644 index 00000000000..fd68b97cd66 --- /dev/null +++ b/src/apps/logs/crud/user_activity_log.py @@ -0,0 +1,12 @@ +from apps.logs.db.schemas import UserActivityLogSchema +from infrastructure.database.crud import BaseCRUD + + +class UserActivityLogCRUD(BaseCRUD[UserActivityLogSchema]): + schema_class = UserActivityLogSchema + + async def save( + self, schema: UserActivityLogSchema + ) -> UserActivityLogSchema: + """Return UserActivityLogSchema instance.""" + return await self._create(schema) diff --git a/src/apps/logs/db/schemas.py b/src/apps/logs/db/schemas.py index 3a1cf32986d..edd1eba09f1 100644 --- a/src/apps/logs/db/schemas.py +++ b/src/apps/logs/db/schemas.py @@ -1,4 +1,4 @@ -from sqlalchemy import Boolean, Column, String +from sqlalchemy import Boolean, Column, ForeignKey, String from sqlalchemy.dialects.postgresql import JSONB from infrastructure.database.base import Base @@ -16,3 +16,24 @@ class NotificationLogSchema(Base): notification_descriptions_updated = Column(Boolean(), nullable=False) notifications_in_queue_updated = Column(Boolean(), nullable=False) scheduled_notifications_updated = Column(Boolean(), nullable=False) + + +class UserActivityLogSchema(Base): + __tablename__ = "user_activity_logs" + + user_id = Column( + ForeignKey("users.id", ondelete="RESTRICT"), + nullable=False, + index=True, + ) + device_id = Column(String(), nullable=True) + event_type = Column(String(), nullable=False) + event = Column(String(), nullable=False) + user_agent = Column(String(), nullable=True) + mindlogger_content = Column(String(), nullable=False) + + def __repr__(self): + return ( + f"UserActivityLogSchema(id='{self.id}', user_id='{self.user_id}'," + f" target='{self.target}', action='{self.action}')" + ) diff --git a/src/apps/logs/domain/constants.py b/src/apps/logs/domain/constants.py new file mode 100644 index 00000000000..5c524a5c7fd --- /dev/null +++ b/src/apps/logs/domain/constants.py @@ -0,0 +1,9 @@ +from enum import Enum + + +class UserActivityEventType(str, Enum): + LOGIN = "login" + + +class UserActivityEvent(str, Enum): + LOGIN = "login" diff --git a/src/apps/logs/services/user_activity_log.py b/src/apps/logs/services/user_activity_log.py new file mode 100644 index 00000000000..8d3e80c2fad --- /dev/null +++ b/src/apps/logs/services/user_activity_log.py @@ -0,0 +1,12 @@ +from apps.logs.crud.user_activity_log import UserActivityLogCRUD +from apps.logs.db.schemas import UserActivityLogSchema + + +class UserActivityLogService: + def __init__(self, session): + self.session = session + + async def create_log( + self, schema: UserActivityLogSchema + ) -> UserActivityLogSchema: + return await UserActivityLogCRUD(self.session).save(schema) diff --git a/src/apps/logs/user_activity_log.py b/src/apps/logs/user_activity_log.py new file mode 100644 index 00000000000..55d2a8cbf90 --- /dev/null +++ b/src/apps/logs/user_activity_log.py @@ -0,0 +1,35 @@ +from fastapi import Body, Depends +from starlette.requests import Request + +from apps.authentication.api.auth_utils import auth_user +from apps.authentication.domain.login import UserLoginRequest +from apps.logs.db.schemas import UserActivityLogSchema +from apps.logs.domain.constants import UserActivityEvent, UserActivityEventType +from apps.logs.services.user_activity_log import UserActivityLogService +from infrastructure.database.deps import get_session +from infrastructure.http.deps import get_mindlogger_content_source +from infrastructure.http.domain import MindloggerContentSource + + +async def user_activity_login_log( + request: Request, + user_login_schema: UserLoginRequest = Body(...), + session=Depends(get_session), + user=Depends(auth_user), + mindlogger_content=Depends(get_mindlogger_content_source), +) -> None: + if ( + mindlogger_content == MindloggerContentSource.undefined.name + and user_login_schema.device_id + ): + mindlogger_content = MindloggerContentSource.mobile.name + + schema = UserActivityLogSchema( + user_id=user.id, + device_id=user_login_schema.device_id, + event_type=UserActivityEventType.LOGIN, + event=UserActivityEvent.LOGIN, + user_agent=request.headers.get("user-agent"), + mindlogger_content=mindlogger_content, + ) + await UserActivityLogService(session).create_log(schema) diff --git a/src/infrastructure/database/migrations/versions/2023_11_30_10_41-add_user_activity_logs.py b/src/infrastructure/database/migrations/versions/2023_11_30_10_41-add_user_activity_logs.py new file mode 100644 index 00000000000..012302ae487 --- /dev/null +++ b/src/infrastructure/database/migrations/versions/2023_11_30_10_41-add_user_activity_logs.py @@ -0,0 +1,73 @@ +"""add-user-activity-logs + +Revision ID: bc2fcc0b0cd1 +Revises: 75c9ca1f506b +Create Date: 2023-11-30 10:41:15.308713 + +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "bc2fcc0b0cd1" +down_revision = "75c9ca1f506b" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "user_activity_logs", + sa.Column( + "id", + postgresql.UUID(as_uuid=True), + server_default=sa.text("gen_random_uuid()"), + nullable=False, + ), + sa.Column( + "created_at", + sa.DateTime(), + server_default=sa.text("timezone('utc', now())"), + nullable=True, + ), + sa.Column( + "updated_at", + sa.DateTime(), + server_default=sa.text("timezone('utc', now())"), + nullable=True, + ), + sa.Column("migrated_date", sa.DateTime(), nullable=True), + sa.Column("migrated_updated", sa.DateTime(), nullable=True), + sa.Column("is_deleted", sa.Boolean(), nullable=True), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("device_id", sa.String(), nullable=True), + sa.Column("event_type", sa.String(), nullable=False), + sa.Column("event", sa.String(), nullable=False), + sa.Column("user_agent", sa.String(), nullable=True), + sa.Column("mindlogger_content", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name=op.f("fk_user_activity_logs_user_id_users"), + ondelete="RESTRICT", + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user_activity_logs")), + ) + op.create_index( + op.f("ix_user_activity_logs_user_id"), + "user_activity_logs", + ["user_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index( + op.f("ix_user_activity_logs_user_id"), table_name="user_activity_logs" + ) + op.drop_table("user_activity_logs") + # ### end Alembic commands ### diff --git a/src/infrastructure/http/deps.py b/src/infrastructure/http/deps.py index 0312e324990..47b589163bb 100644 --- a/src/infrastructure/http/deps.py +++ b/src/infrastructure/http/deps.py @@ -11,7 +11,7 @@ async def get_mindlogger_content_source( return getattr( MindloggerContentSource, request.headers.get( - "mindlogger-content-source", MindloggerContentSource.web.name + "mindlogger-content-source", MindloggerContentSource.undefined.name ), ) diff --git a/src/infrastructure/http/domain.py b/src/infrastructure/http/domain.py index b8a70c69c1e..6981ab83347 100644 --- a/src/infrastructure/http/domain.py +++ b/src/infrastructure/http/domain.py @@ -6,3 +6,5 @@ class MindloggerContentSource(str, Enum): web = "web" admin = "admin" + mobile = "mobile" + undefined = "undefined"