feat: auth Добавлена поддержка сессий пользователей

Добавлена функциональность управления сессиями пользователей, включая создание сессий при входе, получение списка активных сессий, деактивацию отдельных сессий и деактивацию всех сессий пользователя.
This commit is contained in:
Андрей Дувакин 2025-07-03 09:16:19 +05:00
parent 89bfb07a23
commit dc47e4b003
14 changed files with 335 additions and 30 deletions

View File

@ -0,0 +1,55 @@
import datetime
from typing import Optional, Sequence
from sqlalchemy import update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.domain.models.sessions import Session
class SessionsRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def create(self, session: Session) -> Session:
self.db.add(session)
await self.db.commit()
await self.db.refresh(session)
return session
async def get_by_id(self, session_id: int) -> Optional[Session]:
result = await self.db.execute(
select(Session).filter_by(id=session_id)
)
return result.scalars().all()
async def get_by_token(self, token: str) -> Optional[Session]:
result = await self.db.execute(
select(Session).filter_by(token=token, is_active=True)
)
return result.scalars().first()
async def get_by_user_id(self, user_id: int) -> Sequence[Session]:
result = await self.db.execute(
select(Session).filter_by(user_id=user_id, is_active=True)
)
return result.scalars().all()
async def deactivate_session(self, session_id: int) -> None:
await self.db.execute(
update(Session).filter_by(id=session_id).values(is_active=False)
)
await self.db.commit()
async def deactivate_all_sessions(self, user_id: int) -> None:
await self.db.execute(
update(Session).filter_by(user_id=user_id).values(is_active=False)
)
await self.db.commit()
async def cleanup_expired_sessions(self) -> None:
await self.db.execute(
delete(Session).filter(Session.expires_at < datetime.datetime.now())
)
await self.db.commit()

View File

@ -1,10 +1,14 @@
from fastapi import APIRouter, Depends, Response
from fastapi import APIRouter, Depends, Response, Request
from sqlalchemy.ext.asyncio import AsyncSession
from typing import List
from app.database.session import get_db
from app.domain.entities.auth import AuthEntity
from app.domain.entities.responses.session import SessionEntity
from app.domain.entities.token_entity import TokenEntity
from app.infrastructure.auth_service import AuthService
from app.infrastructure.dependencies import get_current_user
from app.domain.models.users import User
router = APIRouter()
@ -19,10 +23,11 @@ router = APIRouter()
async def auth_user(
response: Response,
user_data: AuthEntity,
request: Request,
db: AsyncSession = Depends(get_db)
):
auth_service = AuthService(db)
token = await auth_service.authenticate_user(user_data.login, user_data.password)
token = await auth_service.authenticate_user(user_data.login, user_data.password, request)
response.set_cookie(
key="users_access_token",
@ -32,3 +37,46 @@ async def auth_user(
)
return token
@router.get(
"/sessions/",
response_model=List[SessionEntity],
summary="Get user sessions",
description="Returns a list of active sessions for the current user",
)
async def get_sessions(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
auth_service = AuthService(db)
return await auth_service.get_user_sessions(user.id)
@router.post(
"/sessions/{session_id}/logout/",
summary="Log out from a specific session",
description="Deactivates a specific session by ID",
)
async def logout_session(
session_id: int,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
auth_service = AuthService(db)
await auth_service.deactivate_session(session_id, user.id)
return {"message": "Session deactivated"}
@router.post(
"/sessions/logout_all/",
summary="Log out from all sessions",
description="Deactivates all sessions for the current user",
)
async def logout_all_sessions(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db)
):
auth_service = AuthService(db)
await auth_service.deactivate_all_sessions(user.id)
return {"message": "All sessions deactivated"}

View File

@ -0,0 +1,123 @@
"""0008 добавил тпблицу сессий
Revision ID: b013393cef10
Revises: 4f3877d7a2b1
Create Date: 2025-07-03 09:05:56.233095
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b013393cef10'
down_revision: Union[str, None] = '4f3877d7a2b1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('sessions',
sa.Column('token', sa.String(), nullable=False),
sa.Column('device_info', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['public.users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token'),
schema='public'
)
op.drop_constraint('appointment_files_appointment_id_fkey', 'appointment_files', type_='foreignkey')
op.create_foreign_key(None, 'appointment_files', 'appointments', ['appointment_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('appointments_patient_id_fkey', 'appointments', type_='foreignkey')
op.drop_constraint('appointments_doctor_id_fkey', 'appointments', type_='foreignkey')
op.drop_constraint('appointments_type_id_fkey', 'appointments', type_='foreignkey')
op.create_foreign_key(None, 'appointments', 'patients', ['patient_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'appointments', 'users', ['doctor_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'appointments', 'appointment_types', ['type_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('backups_user_id_fkey', 'backups', type_='foreignkey')
op.create_foreign_key(None, 'backups', 'users', ['user_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('lens_type_id_fkey', 'lens', type_='foreignkey')
op.create_foreign_key(None, 'lens', 'lens_types', ['type_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('lens_issues_doctor_id_fkey', 'lens_issues', type_='foreignkey')
op.drop_constraint('lens_issues_patient_id_fkey', 'lens_issues', type_='foreignkey')
op.drop_constraint('lens_issues_lens_id_fkey', 'lens_issues', type_='foreignkey')
op.create_foreign_key(None, 'lens_issues', 'patients', ['patient_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'lens_issues', 'lens', ['lens_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'lens_issues', 'users', ['doctor_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('mailing_user_id_fkey', 'mailing', type_='foreignkey')
op.create_foreign_key(None, 'mailing', 'users', ['user_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('mailing_options_option_id_fkey', 'mailing_options', type_='foreignkey')
op.drop_constraint('mailing_options_mailing_id_fkey', 'mailing_options', type_='foreignkey')
op.create_foreign_key(None, 'mailing_options', 'mailing', ['mailing_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'mailing_options', 'mailing_delivery_methods', ['option_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('recipients_mailing_id_fkey', 'recipients', type_='foreignkey')
op.drop_constraint('recipients_patient_id_fkey', 'recipients', type_='foreignkey')
op.create_foreign_key(None, 'recipients', 'patients', ['patient_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'recipients', 'mailing', ['mailing_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('scheduled_appointments_type_id_fkey', 'scheduled_appointments', type_='foreignkey')
op.drop_constraint('scheduled_appointments_doctor_id_fkey', 'scheduled_appointments', type_='foreignkey')
op.drop_constraint('scheduled_appointments_patient_id_fkey', 'scheduled_appointments', type_='foreignkey')
op.create_foreign_key(None, 'scheduled_appointments', 'appointment_types', ['type_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'scheduled_appointments', 'patients', ['patient_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'scheduled_appointments', 'users', ['doctor_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('set_contents_type_id_fkey', 'set_contents', type_='foreignkey')
op.drop_constraint('set_contents_set_id_fkey', 'set_contents', type_='foreignkey')
op.create_foreign_key(None, 'set_contents', 'sets', ['set_id'], ['id'], source_schema='public', referent_schema='public')
op.create_foreign_key(None, 'set_contents', 'lens_types', ['type_id'], ['id'], source_schema='public', referent_schema='public')
op.drop_constraint('users_role_id_fkey', 'users', type_='foreignkey')
op.create_foreign_key(None, 'users', 'roles', ['role_id'], ['id'], source_schema='public', referent_schema='public')
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'users', schema='public', type_='foreignkey')
op.create_foreign_key('users_role_id_fkey', 'users', 'roles', ['role_id'], ['id'])
op.drop_constraint(None, 'set_contents', schema='public', type_='foreignkey')
op.drop_constraint(None, 'set_contents', schema='public', type_='foreignkey')
op.create_foreign_key('set_contents_set_id_fkey', 'set_contents', 'sets', ['set_id'], ['id'])
op.create_foreign_key('set_contents_type_id_fkey', 'set_contents', 'lens_types', ['type_id'], ['id'])
op.drop_constraint(None, 'scheduled_appointments', schema='public', type_='foreignkey')
op.drop_constraint(None, 'scheduled_appointments', schema='public', type_='foreignkey')
op.drop_constraint(None, 'scheduled_appointments', schema='public', type_='foreignkey')
op.create_foreign_key('scheduled_appointments_patient_id_fkey', 'scheduled_appointments', 'patients', ['patient_id'], ['id'])
op.create_foreign_key('scheduled_appointments_doctor_id_fkey', 'scheduled_appointments', 'users', ['doctor_id'], ['id'])
op.create_foreign_key('scheduled_appointments_type_id_fkey', 'scheduled_appointments', 'appointment_types', ['type_id'], ['id'])
op.drop_constraint(None, 'recipients', schema='public', type_='foreignkey')
op.drop_constraint(None, 'recipients', schema='public', type_='foreignkey')
op.create_foreign_key('recipients_patient_id_fkey', 'recipients', 'patients', ['patient_id'], ['id'])
op.create_foreign_key('recipients_mailing_id_fkey', 'recipients', 'mailing', ['mailing_id'], ['id'])
op.drop_constraint(None, 'mailing_options', schema='public', type_='foreignkey')
op.drop_constraint(None, 'mailing_options', schema='public', type_='foreignkey')
op.create_foreign_key('mailing_options_mailing_id_fkey', 'mailing_options', 'mailing', ['mailing_id'], ['id'])
op.create_foreign_key('mailing_options_option_id_fkey', 'mailing_options', 'mailing_delivery_methods', ['option_id'], ['id'])
op.drop_constraint(None, 'mailing', schema='public', type_='foreignkey')
op.create_foreign_key('mailing_user_id_fkey', 'mailing', 'users', ['user_id'], ['id'])
op.drop_constraint(None, 'lens_issues', schema='public', type_='foreignkey')
op.drop_constraint(None, 'lens_issues', schema='public', type_='foreignkey')
op.drop_constraint(None, 'lens_issues', schema='public', type_='foreignkey')
op.create_foreign_key('lens_issues_lens_id_fkey', 'lens_issues', 'lens', ['lens_id'], ['id'])
op.create_foreign_key('lens_issues_patient_id_fkey', 'lens_issues', 'patients', ['patient_id'], ['id'])
op.create_foreign_key('lens_issues_doctor_id_fkey', 'lens_issues', 'users', ['doctor_id'], ['id'])
op.drop_constraint(None, 'lens', schema='public', type_='foreignkey')
op.create_foreign_key('lens_type_id_fkey', 'lens', 'lens_types', ['type_id'], ['id'])
op.drop_constraint(None, 'backups', schema='public', type_='foreignkey')
op.create_foreign_key('backups_user_id_fkey', 'backups', 'users', ['user_id'], ['id'])
op.drop_constraint(None, 'appointments', schema='public', type_='foreignkey')
op.drop_constraint(None, 'appointments', schema='public', type_='foreignkey')
op.drop_constraint(None, 'appointments', schema='public', type_='foreignkey')
op.create_foreign_key('appointments_type_id_fkey', 'appointments', 'appointment_types', ['type_id'], ['id'])
op.create_foreign_key('appointments_doctor_id_fkey', 'appointments', 'users', ['doctor_id'], ['id'])
op.create_foreign_key('appointments_patient_id_fkey', 'appointments', 'patients', ['patient_id'], ['id'])
op.drop_constraint(None, 'appointment_files', schema='public', type_='foreignkey')
op.create_foreign_key('appointment_files_appointment_id_fkey', 'appointment_files', 'appointments', ['appointment_id'], ['id'])
op.drop_table('sessions', schema='public')
# ### end Alembic commands ###

View File

@ -0,0 +1,16 @@
from pydantic import BaseModel
from datetime import datetime
from typing import Optional
class SessionEntity(BaseModel):
id: int
user_id: int
token: str
device_info: Optional[str]
created_at: datetime
expires_at: datetime
is_active: bool
class Config:
from_attributes = True

View File

@ -16,6 +16,7 @@ from app.domain.models.patients import Patient
from app.domain.models.recipients import Recipient
from app.domain.models.roles import Role
from app.domain.models.scheduled_appointments import ScheduledAppointment
from app.domain.models.sessions import Session
from app.domain.models.set_contents import SetContent
from app.domain.models.sets import Set
from app.domain.models.users import User

View File

@ -0,0 +1,20 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime
from app.domain.models.base import BaseModel
from app.settings import settings
class Session(BaseModel):
__tablename__ = 'sessions'
__table_args__ = {"schema": settings.SCHEMA}
token = Column(String, nullable=False, unique=True)
device_info = Column(String, nullable=True)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
expires_at = Column(DateTime, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
user_id = Column(Integer, ForeignKey(f'{settings.SCHEMA}.users.id'), nullable=False)
user = relationship("User", back_populates="sessions")

View File

@ -25,6 +25,7 @@ class User(BaseModel):
appointments = relationship('Appointment', back_populates='doctor')
mailing = relationship('Mailing', back_populates='user')
scheduled_appointments = relationship('ScheduledAppointment', back_populates='doctor')
sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
def check_password(self, password):
return check_password_hash(self.password, password)

View File

@ -1,23 +1,37 @@
import datetime
from typing import Optional
from typing import Optional, List
import jwt
from fastapi import HTTPException
from fastapi import HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from starlette import status
from app.application.users_repository import UsersRepository
from app.application.sessions_repository import SessionsRepository
from app.domain.entities.responses.session import SessionEntity
from app.domain.models.sessions import Session
from app.settings import get_auth_data
class AuthService:
def __init__(self, db: AsyncSession):
self.users_repository = UsersRepository(db)
self.sessions_repository = SessionsRepository(db)
async def authenticate_user(self, login: str, password: str) -> Optional[dict]:
async def authenticate_user(self, login: str, password: str, request: Request) -> Optional[dict]:
user = await self.users_repository.get_by_login(login)
if user and user.check_password(password):
access_token = self.create_access_token({"user_id": user.id})
# Создаем сессию
session = Session(
user_id=user.id,
token=access_token,
device_info=request.headers.get("User-Agent", "Unknown"),
created_at=datetime.datetime.now(), # Naive datetime
expires_at=datetime.datetime.now() + datetime.timedelta(days=30),
is_active=True
)
await self.sessions_repository.create(session)
return {
"access_token": access_token,
"user_id": user.id
@ -25,12 +39,27 @@ class AuthService:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Неправильный логин или пароль")
async def get_user_sessions(self, user_id: int) -> List[SessionEntity]:
sessions = await self.sessions_repository.get_by_user_id(user_id)
return [SessionEntity.from_orm(session) for session in sessions]
async def deactivate_session(self, session_id: int, user_id: int) -> None:
session = await self.sessions_repository.get_by_id(session_id)
if not session or session.user_id != user_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Сессия не найдена или доступ запрещен")
await self.sessions_repository.deactivate_session(session_id)
async def deactivate_all_sessions(self, user_id: int) -> None:
await self.sessions_repository.deactivate_all_sessions(user_id)
async def cleanup_expired_sessions(self) -> None:
await self.sessions_repository.cleanup_expired_sessions()
@staticmethod
def create_access_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=30)
expire = datetime.datetime.now() + datetime.timedelta(days=30) # Naive datetime
to_encode.update({"exp": expire})
auth_data = get_auth_data()
encode_jwt = jwt.encode(to_encode, auth_data['secret_key'], algorithm=auth_data['algorithm'])
return encode_jwt

View File

@ -8,6 +8,7 @@ from app.database.session import get_db
from app.domain.models.users import User
from app.settings import get_auth_data
from app.application.users_repository import UsersRepository
from app.application.sessions_repository import SessionsRepository
security = HTTPBearer()
@ -29,6 +30,10 @@ async def get_current_user(
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неправильный токен")
session = await SessionsRepository(db).get_by_token(credentials.credentials)
if not session or not session.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Сессия неактивна или не найдена")
user = await UsersRepository(db).get_by_id_with_role(user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Пользователь не найден")

View File

@ -17,7 +17,7 @@ const AdminRoute = () => {
}
if (isUserError) {
return <Result status="500" title="500" subTitle="Произошла ошибка при загрузке данных пользователя"/>;
return <Navigate to="/login"/>;
}
if (!user) {

View File

@ -3,13 +3,13 @@ import {useSelector} from "react-redux";
import LoadingIndicator from "../Components/Widgets/LoadingIndicator/LoadingIndicator.jsx";
const PrivateRoute = () => {
const {user, userData, isLoading} = useSelector((state) => state.auth);
const {user, userData, isLoading, error} = useSelector((state) => state.auth);
if (isLoading) {
return <LoadingIndicator/>;
}
if (!user || !userData || userData.is_blocked) {
if (error || !user || !userData || userData.is_blocked) {
return <Navigate to="/login"/>;
}

View File

@ -3,7 +3,6 @@ import {
useDeleteBackupMutation,
useGetBackupsQuery,
useUploadBackupMutation,
useRestoreBackupMutation,
} from "../../../../../Api/backupsApi.js";
import {notification} from "antd";
import {useState} from "react";

View File

@ -1,18 +1,18 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useDispatch, useSelector } from "react-redux";
import { Grid, notification } from "antd";
import { setError, setUser } from "../../../Redux/Slices/authSlice.js";
import { useLoginMutation } from "../../../Api/authApi.js";
import { checkAuth } from "../../../Redux/Slices/authSlice.js";
import {useEffect, useRef} from "react";
import {useNavigate} from "react-router-dom";
import {useDispatch, useSelector} from "react-redux";
import {Grid, notification} from "antd";
import {setError, setUser} from "../../../Redux/Slices/authSlice.js";
import {useLoginMutation} from "../../../Api/authApi.js";
import {checkAuth} from "../../../Redux/Slices/authSlice.js";
const { useBreakpoint } = Grid;
const {useBreakpoint} = Grid;
const useLoginPage = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const [loginUser, { isLoading }] = useLoginMutation();
const { user, userData } = useSelector((state) => state.auth);
const [loginUser, {isLoading}] = useLoginMutation();
const {user, userData} = useSelector((state) => state.auth);
const screens = useBreakpoint();
const hasRedirected = useRef(false);
@ -74,10 +74,15 @@ const useLoginPage = () => {
const response = await loginUser(loginData).unwrap();
const token = response.access_token || response.token;
if (!token) {
throw new Error("Сервер не вернул токен авторизации");
notification.error({
message: "Ошибка при входе",
description: "Не удалось войти. Проверьте логин и пароль.",
placement: "topRight",
});
return;
}
localStorage.setItem("access_token", token);
dispatch(setUser({ token }));
dispatch(setUser({token}));
await dispatch(checkAuth()).unwrap();
} catch (error) {

View File

@ -1,7 +1,7 @@
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { usersApi } from "../../Api/usersApi.js";
import {createSlice, createAsyncThunk} from "@reduxjs/toolkit";
import {usersApi} from "../../Api/usersApi.js";
export const checkAuth = createAsyncThunk("auth/checkAuth", async (_, { dispatch, rejectWithValue }) => {
export const checkAuth = createAsyncThunk("auth/checkAuth", async (_, {dispatch, rejectWithValue}) => {
try {
const token = localStorage.getItem("access_token");
if (!token) {
@ -9,10 +9,10 @@ export const checkAuth = createAsyncThunk("auth/checkAuth", async (_, { dispatch
}
const userData = await dispatch(
usersApi.endpoints.getAuthenticatedUserData.initiate(undefined, { forceRefetch: true })
usersApi.endpoints.getAuthenticatedUserData.initiate(undefined, {forceRefetch: true})
).unwrap();
return { token, userData };
return {token, userData};
} catch (error) {
localStorage.removeItem("access_token");
return rejectWithValue(error?.data?.detail || "Failed to authenticate");
@ -38,6 +38,9 @@ const authSlice = createSlice({
setError(state, action) {
state.error = action.payload;
state.isLoading = false;
state.user = null;
state.userData = null;
localStorage.removeItem("access_token");
},
logout(state) {
state.user = null;
@ -57,7 +60,7 @@ const authSlice = createSlice({
state.error = null;
})
.addCase(checkAuth.fulfilled, (state, action) => {
state.user = { token: action.payload.token };
state.user = {token: action.payload.token};
state.userData = action.payload.userData;
state.isLoading = false;
})
@ -70,5 +73,5 @@ const authSlice = createSlice({
},
});
export const { setUser, setError, logout, setUserData } = authSlice.actions;
export const {setUser, setError, logout, setUserData} = authSlice.actions;
export default authSlice.reducer;