diff --git a/app/routers/authentication.py b/app/routers/authentication.py index e69de29..93d5a34 100755 --- a/app/routers/authentication.py +++ b/app/routers/authentication.py @@ -0,0 +1,59 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from app.services import auth +from app.services.database.community import get_community_by_username # Atualizar após banco de dados +from app.schemas import Token, TokenPayload, Community +import jwt +from jwt.exceptions import InvalidTokenError + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/authentication/token") + +def setup(): + router = APIRouter(prefix='/authentication', tags=['authentication']) + + async def authenticate_community(username: str, password: str): + # Valida se o usuário existe e se a senha está correta + db_user = await get_community_by_username(username) + if not db_user or not auth.verify_password(password, db_user.password): + return None + return db_user + + @router.post("/token", response_model=Token) + async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()): + # Rota de login: valida credenciais e retorna token JWT + community = await authenticate_community(form_data.username, form_data.password) + if not community: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Credenciais inválidas" + ) + # Community ex: email='alice@example.com' id=1 username='alice' full_name="Alice in the Maravilha's world" password='$2b$12$cA3fzLrRCmLp1aKn6ULhF.sQfaPQ70EoJU3Q0Szf6e4/YaVsKAAHS' + payload = TokenPayload(username=community.username) + token, expires_in = auth.create_access_token(data=payload) + return { + "access_token": token, + "token_type": "Bearer", + "expires_in": expires_in + } + + + @router.get("/me", response_model=Community) + async def get_current_community(token: str = Depends(oauth2_scheme)): + # Rota protegida: retorna dados do usuário atual com base no token + creds_exc = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token inválido", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload_dict = jwt.decode(token, auth.SECRET_KEY, algorithms=[auth.ALGORITHM]) + payload = TokenPayload(**payload_dict) + except (InvalidTokenError, ValueError): + raise creds_exc + + community = get_community_by_username(payload.sub) + if not community: + raise creds_exc + return community + + return router # Retorna o router configurado com as rotas de autenticação diff --git a/app/routers/router.py b/app/routers/router.py old mode 100755 new mode 100644 index f0df6a5..1169fa2 --- a/app/routers/router.py +++ b/app/routers/router.py @@ -2,10 +2,11 @@ from app.routers.healthcheck.routes import setup as healthcheck_router_setup from app.routers.news.routes import setup as news_router_setup - +from app.routers.authentication import setup as authentication_router_setup def setup_router() -> APIRouter: router = APIRouter() router.include_router(healthcheck_router_setup(), prefix="") router.include_router(news_router_setup(), prefix="") + router.include_router(authentication_router_setup(), prefix='') return router diff --git a/app/schemas.py b/app/schemas.py old mode 100755 new mode 100644 index b80ab0f..935f89d --- a/app/schemas.py +++ b/app/schemas.py @@ -1,8 +1,39 @@ +from pydantic import BaseModel, HttpUrl from datetime import datetime +from typing import List +from enum import Enum -from pydantic import BaseModel, HttpUrl +## User Class +class Community(BaseModel): + username: str + full_name: str + email: str + +class CommunityInDB(Community): + password: str + +class Token(BaseModel): + access_token: str + token_type: str + expires_in: int + +class TokenPayload(BaseModel): + username: str + +## Subscription Class +class TagEnum(str, Enum): + bug_fix = "bug_fix" + update = "update" + deprecate = "deprecate" + new_feature = "new_feature" + security_fix = "security_fix" + +class Subscription(BaseModel): + tags: List[TagEnum] + libraries_list: List[str] +## News class News(BaseModel): description: str tag: str diff --git a/app/services/auth.py b/app/services/auth.py index e69de29..5559a44 100755 --- a/app/services/auth.py +++ b/app/services/auth.py @@ -0,0 +1,40 @@ +from passlib.context import CryptContext +from datetime import datetime, timedelta, timezone +from app.schemas import TokenPayload +import jwt +import os + +SECRET_KEY = os.getenv("SECRET_KEY", "default_fallback_key") +ALGORITHM = os.getenv("ALGORITHM", "HS256") +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", 20)) + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +def verify_password(plain, hashed): + # Verifica se a senha passada bate com a hash da comunidade + print(plain, hashed) + return pwd_context.verify(plain, hashed) + +def get_password_hash(password): + # Retorna a senha em hash para salvar no banco de dados + return pwd_context.hash(password) + +def create_access_token(data: TokenPayload, expires_delta: timedelta | None = None): + """ + Gera um token JWT contendo os dados do usuário (payload) e uma data de expiração. + + Parâmetros: + - data (TokenPayload): Dicionário com os dados que serão codificados no token. Deve conter a chave 'sub' com o identificador do usuário. + - expires_delta (timedelta | None): Tempo até o token expirar. Se não fornecido, usará o padrão de 20 minutos. + + Retorna: + - str: Token JWT assinado. + - int: tempo de expiração em segundos + """ + if not expires_delta: + expires_delta = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + expire = datetime.now(timezone.utc) + expires_delta + to_encode = {"sub": data.username, "exp": expire} + token = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + + return token, int(expires_delta.total_seconds()) diff --git a/app/services/database/community.py b/app/services/database/community.py new file mode 100644 index 0000000..c8f60eb --- /dev/null +++ b/app/services/database/community.py @@ -0,0 +1,12 @@ +# app/services/database/community.py +from sqlmodel import select +from sqlalchemy.exc import NoResultFound +from app.services.database.model.community_model import Community +from app.services.database.database import get_session + +async def get_community_by_username(username: str): + async for session in get_session(): + stmt = select(Community).where(Community.username == username) + result = await session.exec(stmt) + user = result.one_or_none() + return user diff --git a/app/services/database/database.py b/app/services/database/database.py old mode 100755 new mode 100644 diff --git a/app/services/database/model/community_model.py b/app/services/database/model/community_model.py new file mode 100644 index 0000000..147865a --- /dev/null +++ b/app/services/database/model/community_model.py @@ -0,0 +1,9 @@ +from sqlmodel import SQLModel, Field +from typing import Optional + +class Community(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + username: str = Field(index=True, nullable=False, unique=True) + full_name: Optional[str] = None + email: Optional[str] = None + password: str # senha hashed \ No newline at end of file diff --git a/app/services/database/run_seeder.py b/app/services/database/run_seeder.py new file mode 100644 index 0000000..135ea40 --- /dev/null +++ b/app/services/database/run_seeder.py @@ -0,0 +1,11 @@ +# run_seeder.py -- para rodar manualmente +import asyncio +from app.services.database.seeder import insert_test_community +from app.services.database.database import init_db + +async def main(): + await init_db() + await insert_test_community() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/services/database/seeder.py b/app/services/database/seeder.py new file mode 100644 index 0000000..375520c --- /dev/null +++ b/app/services/database/seeder.py @@ -0,0 +1,25 @@ +# app/services/database/seeder.py +# PARA INSERIR INFORMAÇÕES DE TESTES NO BANCO + +async def insert_test_community(): + from app.services.database.database import AsyncSessionLocal # import local para evitar circular import + from app.services.database.model.community_model import Community + from app.services.auth import get_password_hash + from sqlmodel import select + + + async with AsyncSessionLocal() as session: + result = await session.exec(select(Community).where(Community.username == "alice")) + if result.first(): + return + + user = Community( + username="alice", + full_name="Alice in the Maravilha's world", + email="alice@example.com", + password=get_password_hash("secret123") + ) + session.add(user) + await session.commit() + print("Usuário de teste 'alice' criado com sucesso.") + diff --git a/docker-compose.yaml b/docker-compose.yaml old mode 100755 new mode 100644 index fd58fc3..d77fff9 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,6 +13,9 @@ services: - PYTHONPATH=/server - SQLITE_PATH=app/services/database/pynewsdb.db - SQLITE_URL=sqlite+aiosqlite:// + - SECRET_KEY=1a6c5f3b7d2e4a7fb68d0casd3f9a7b2d8c4e5f6a3b0d4e9c7a8f1b6d3c0a7f5e + - ALGORITHM=HS256 + - ACCESS_TOKEN_EXPIRE_MINUTES=20 restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/api/healthcheck"] diff --git a/pynewsdb.db b/pynewsdb.db new file mode 100644 index 0000000..5b9143d Binary files /dev/null and b/pynewsdb.db differ