mirror of
https://gitlab.com/MoonTestUse1/AdministrationItDepartmens.git
synced 2025-08-14 00:25:46 +02:00
добавление редисаъ
This commit is contained in:
34
backend/alembic/versions/create_tokens_table.py
Normal file
34
backend/alembic/versions/create_tokens_table.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""create tokens table
|
||||||
|
|
||||||
|
Revision ID: create_tokens_table
|
||||||
|
Revises:
|
||||||
|
Create Date: 2024-01-02 22:30:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'create_tokens_table'
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'tokens',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('access_token', sa.String(), nullable=False),
|
||||||
|
sa.Column('employee_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['employee_id'], ['employees.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_tokens_access_token'), 'tokens', ['access_token'], unique=True)
|
||||||
|
op.create_index(op.f('ix_tokens_id'), 'tokens', ['id'], unique=False)
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f('ix_tokens_id'), table_name='tokens')
|
||||||
|
op.drop_index(op.f('ix_tokens_access_token'), table_name='tokens')
|
||||||
|
op.drop_table('tokens')
|
||||||
20
backend/app/core/config.py
Normal file
20
backend/app/core/config.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
DATABASE_URL: str = "postgresql://postgres:postgres@db:5432/support_db"
|
||||||
|
REDIS_URL: str = "redis://redis:6379/0"
|
||||||
|
|
||||||
|
# JWT settings
|
||||||
|
JWT_SECRET_KEY: str = "your-secret-key" # в продакшене использовать сложный ключ
|
||||||
|
JWT_ALGORITHM: str = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
||||||
|
|
||||||
|
# Telegram settings
|
||||||
|
TELEGRAM_BOT_TOKEN: Optional[str] = None
|
||||||
|
TELEGRAM_CHAT_ID: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
env_file = ".env"
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
12
backend/app/models/token.py
Normal file
12
backend/app/models/token.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from ..database import Base
|
||||||
|
|
||||||
|
class Token(Base):
|
||||||
|
__tablename__ = "tokens"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
access_token = Column(String, unique=True, index=True)
|
||||||
|
employee_id = Column(Integer, ForeignKey("employees.id", ondelete="CASCADE"))
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
expires_at = Column(DateTime(timezone=True))
|
||||||
@@ -1,50 +1,58 @@
|
|||||||
"""Authentication router"""
|
"""Authentication router"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models.employee import Employee
|
from ..crud import employees
|
||||||
from ..schemas.auth import AdminLogin, EmployeeLogin
|
from ..schemas.auth import Token
|
||||||
from passlib.context import CryptContext
|
from ..utils.auth import verify_password
|
||||||
|
from ..utils.jwt import create_and_save_token
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
|
||||||
|
|
||||||
@router.post("/admin/login")
|
@router.post("/token", response_model=Token)
|
||||||
def admin_login(login_data: AdminLogin, db: Session = Depends(get_db)):
|
async def login_for_access_token(
|
||||||
"""Admin login endpoint"""
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
if login_data.username == "admin" and login_data.password == "admin123":
|
db: Session = Depends(get_db)
|
||||||
return {
|
):
|
||||||
"access_token": "admin_token",
|
# Проверяем учетные данные сотрудника
|
||||||
"token_type": "bearer"
|
employee = employees.get_employee_by_last_name(db, form_data.username)
|
||||||
}
|
if not employee or not verify_password(form_data.password, employee.hashed_password):
|
||||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
|
||||||
|
|
||||||
@router.post("/login")
|
|
||||||
def employee_login(login_data: EmployeeLogin, db: Session = Depends(get_db)):
|
|
||||||
"""Employee login endpoint"""
|
|
||||||
# Ищем сотрудника по фамилии
|
|
||||||
employee = db.query(Employee).filter(Employee.last_name == login_data.last_name).first()
|
|
||||||
|
|
||||||
if not employee:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=401,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Сотрудник с такой фамилией не найден"
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Проверяем пароль
|
# Создаем и сохраняем токен
|
||||||
if not pwd_context.verify(login_data.password, employee.password):
|
access_token = create_and_save_token(employee.id, db)
|
||||||
raise HTTPException(
|
|
||||||
status_code=401,
|
|
||||||
detail="Неверный пароль"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Возвращаем данные сотрудника
|
|
||||||
return {
|
return {
|
||||||
"id": employee.id,
|
"access_token": access_token,
|
||||||
"first_name": employee.first_name,
|
"token_type": "bearer"
|
||||||
"last_name": employee.last_name,
|
}
|
||||||
"department": employee.department,
|
|
||||||
"office": employee.office,
|
@router.post("/admin/token", response_model=Token)
|
||||||
"access_token": f"employee_token_{employee.id}", # Добавляем токен для авторизации
|
async def admin_login(
|
||||||
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
# Проверяем учетные данные администратора
|
||||||
|
if form_data.username != "admin" or form_data.password != "admin123":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Для админа используем специальный ID
|
||||||
|
admin_id = -1
|
||||||
|
access_token = create_and_save_token(admin_id, db)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
"token_type": "bearer"
|
"token_type": "bearer"
|
||||||
}
|
}
|
||||||
@@ -20,3 +20,13 @@ class EmployeeResponse(BaseModel):
|
|||||||
department: str
|
department: str
|
||||||
office: str
|
office: str
|
||||||
access_token: str
|
access_token: str
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
employee_id: int | None = None
|
||||||
|
is_admin: bool = False
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
86
backend/app/utils/jwt.py
Normal file
86
backend/app/utils/jwt.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from redis import Redis
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..core.config import settings
|
||||||
|
from ..models.token import Token
|
||||||
|
from ..crud.employees import get_employee
|
||||||
|
|
||||||
|
redis = Redis.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
|
|
||||||
|
def create_access_token(data: dict) -> str:
|
||||||
|
to_encode = data.copy()
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
def verify_token(token: str, db: Session) -> dict:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||||
|
employee_id: int = payload.get("sub")
|
||||||
|
if employee_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Проверяем токен в Redis
|
||||||
|
if not redis.get(f"token:{token}"):
|
||||||
|
# Если токена нет в Redis, проверяем в БД
|
||||||
|
db_token = db.query(Token).filter(Token.access_token == token).first()
|
||||||
|
if not db_token or db_token.expires_at < datetime.utcnow():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token has expired or is invalid",
|
||||||
|
)
|
||||||
|
# Если токен валиден, кэшируем его в Redis
|
||||||
|
redis.setex(
|
||||||
|
f"token:{token}",
|
||||||
|
timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
|
"valid"
|
||||||
|
)
|
||||||
|
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_and_save_token(employee_id: int, db: Session) -> str:
|
||||||
|
# Создаем JWT токен
|
||||||
|
access_token = create_access_token({"sub": str(employee_id)})
|
||||||
|
expires_at = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
|
||||||
|
# Сохраняем в БД
|
||||||
|
db_token = Token(
|
||||||
|
access_token=access_token,
|
||||||
|
employee_id=employee_id,
|
||||||
|
expires_at=expires_at
|
||||||
|
)
|
||||||
|
db.add(db_token)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Кэшируем в Redis
|
||||||
|
redis.setex(
|
||||||
|
f"token:{access_token}",
|
||||||
|
timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES),
|
||||||
|
"valid"
|
||||||
|
)
|
||||||
|
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
def get_current_employee(token: str, db: Session):
|
||||||
|
payload = verify_token(token, db)
|
||||||
|
employee_id = int(payload.get("sub"))
|
||||||
|
employee = get_employee(db, employee_id)
|
||||||
|
if employee is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Employee not found",
|
||||||
|
)
|
||||||
|
return employee
|
||||||
@@ -1,17 +1,11 @@
|
|||||||
fastapi==0.110.0
|
fastapi>=0.68.0,<0.69.0
|
||||||
uvicorn==0.27.1
|
uvicorn>=0.15.0,<0.16.0
|
||||||
sqlalchemy==2.0.27
|
sqlalchemy>=1.4.23,<1.5.0
|
||||||
pydantic==2.6.3
|
pydantic>=1.8.0,<2.0.0
|
||||||
pydantic-settings==2.2.1
|
python-jose[cryptography]>=3.3.0,<4.0.0
|
||||||
python-multipart==0.0.9
|
passlib[bcrypt]>=1.7.4,<2.0.0
|
||||||
python-jose[cryptography]==3.3.0
|
python-multipart>=0.0.5,<0.0.6
|
||||||
passlib[bcrypt]>=1.7.4
|
redis>=4.0.0,<5.0.0
|
||||||
bcrypt>=4.0.1
|
python-dotenv>=0.19.0,<0.20.0
|
||||||
aiogram>=3.4.1
|
psycopg2-binary>=2.9.1,<3.0.0
|
||||||
python-dotenv==1.0.1
|
requests>=2.26.0,<3.0.0
|
||||||
psycopg2-binary==2.9.9
|
|
||||||
alembic==1.13.1
|
|
||||||
pytest==8.0.0
|
|
||||||
httpx==0.26.0
|
|
||||||
pytest-asyncio==0.23.5
|
|
||||||
pytest-cov==4.1.0
|
|
||||||
@@ -1,47 +1,52 @@
|
|||||||
version: '3.8'
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
container_name: support-postgres
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: support_db
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: postgres123
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
networks:
|
|
||||||
- support-network
|
|
||||||
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: docker/backend/Dockerfile
|
|
||||||
container_name: support-backend
|
|
||||||
depends_on:
|
|
||||||
- postgres
|
|
||||||
networks:
|
|
||||||
- support-network
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
|
container_name: support-frontend
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: docker/frontend/Dockerfile
|
dockerfile: docker/frontend/Dockerfile
|
||||||
container_name: support-frontend
|
|
||||||
ports:
|
ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
volumes:
|
volumes:
|
||||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
- ./certbot/www:/var/www/certbot/:ro
|
||||||
- /var/www/certbot:/var/www/certbot:ro
|
- ./certbot/conf/:/etc/letsencrypt/:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
|
||||||
- support-network
|
|
||||||
|
|
||||||
networks:
|
backend:
|
||||||
support-network:
|
container_name: support-backend
|
||||||
driver: bridge
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/backend/Dockerfile
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://postgres:postgres@db:5432/support_db
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
|
||||||
|
db:
|
||||||
|
container_name: support-db
|
||||||
|
image: postgres:15
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_PASSWORD=postgres
|
||||||
|
- POSTGRES_DB=support_db
|
||||||
|
|
||||||
|
redis:
|
||||||
|
container_name: support-redis
|
||||||
|
image: redis:alpine
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
Reference in New Issue
Block a user