Skip to content
Open
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
7 changes: 6 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,15 @@ logs/
env
.env
./env

# IDEs
.idea/
.vscode/

# OS
.DS_Store
Thumbs.db
Thumbs.db

# Chaves Criptográficas e Segredos
*.pem
*.json
75 changes: 75 additions & 0 deletions Authlib
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
alembic==1.17.0
annotated-types==0.7.0
anyio==4.11.0
asyncpg==0.30.0
Authlib==1.6.5
bcrypt==3.2.0
cachetools==6.2.1
certifi==2025.10.5
cffi==2.0.0
chardet==5.2.0
charset-normalizer==3.4.4
click==8.3.0
colorama==0.4.6
cryptography==46.0.3
cssselect==1.3.0
cssutils==2.11.1
Deprecated==1.2.18
dnspython==2.8.0
ecdsa==0.19.1
email-validator==2.3.0
emails==0.6
fastapi==0.119.1
greenlet==3.2.4
h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.28.1
idna==3.11
iniconfig==2.3.0
Jinja2==3.1.6
limits==5.6.0
loguru==0.7.3
lxml==6.0.2
Mako==1.3.10
MarkupSafe==3.0.3
more-itertools==10.8.0
packaging==25.0
passlib==1.7.4
pillow==12.0.0
pluggy==1.6.0
premailer==3.10.0
psycopg2-binary==2.9.11
pyasn1==0.6.1
pycparser==2.23
pydantic==2.12.3
pydantic-settings==2.11.0
pydantic_core==2.41.4
Pygments==2.19.2
pyotp==2.9.0
pypng==0.20220715.0
pytest==8.4.2
python-dateutil==2.9.0.post0
python-dotenv==1.1.1
python-http-client==3.3.7
python-jose==3.5.0
python-multipart==0.0.20
PyYAML==6.0.3
qrcode==7.4.2
requests==2.32.5
rsa==4.9.1
sendgrid==6.11.0
six==1.17.0
slowapi==0.1.9
sniffio==1.3.1
SQLAlchemy==2.0.44
starkbank-ecdsa==2.2.0
starlette==0.48.0
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.5.0
uvicorn==0.38.0
watchfiles==1.1.1
websockets==15.0.1
win32_setctime==1.2.0
wrapt==1.17.3
3 changes: 3 additions & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
from app.models import mfa_recovery_code # noqa F401
from app.models import trusted_device # noqa F401 <-- ADICIONE ESTA LINHA
# --- FIM ADIÇÃO ---
from app.models import oauth2_client # noqa F401 <-- ADICIONE ESTA LINHA
from app.models import oauth2_authorization_code # noqa F401 <-- ADICIONE ESTA LINHA
# ...
# --- Fim Importar Modelos ---


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""adicionar_tabelas_oidc_client_e_code

Revision ID: 9ad96a26ae81
Revises: 5be083e42f0d
Create Date: 2025-10-24 09:16:33.195843

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '9ad96a26ae81'
down_revision: Union[str, Sequence[str], None] = '5be083e42f0d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('oauth2_clients',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('client_id', sa.String(length=48), nullable=False),
sa.Column('client_secret_hash', sa.String(length=255), nullable=True),
sa.Column('client_name', sa.String(length=120), nullable=True),
sa.Column('redirect_uris_str', sa.Text(), nullable=True),
sa.Column('scope_str', sa.Text(), nullable=False),
sa.Column('response_types_str', sa.Text(), nullable=False),
sa.Column('grant_types_str', sa.Text(), nullable=False),
sa.Column('token_endpoint_auth_method', sa.String(length=120), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_oauth2_clients_client_id'), 'oauth2_clients', ['client_id'], unique=True)
op.create_table('oauth2_authorization_codes',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('code', sa.String(length=120), nullable=False),
sa.Column('client_id', sa.String(length=48), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('redirect_uri', sa.Text(), nullable=True),
sa.Column('response_type', sa.Text(), nullable=True),
sa.Column('scope', sa.Text(), nullable=True),
sa.Column('auth_time', sa.Integer(), nullable=False),
sa.Column('nonce', sa.String(length=120), nullable=True),
sa.Column('code_challenge', sa.String(length=128), nullable=True),
sa.Column('code_challenge_method', sa.String(length=48), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('code')
)
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('oauth2_authorization_codes')
op.drop_index(op.f('ix_oauth2_clients_client_id'), table_name='oauth2_clients')
op.drop_table('oauth2_clients')
# ### end Alembic commands ###
47 changes: 31 additions & 16 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@

class Settings(BaseSettings):

TRUSTED_DEVICE_COOKIE_NAME: str = "auth_device_id"
TRUSTED_DEVICE_COOKIE_MAX_AGE_DAYS: int = 30 # Tempo que o dispositivo será lembrado

# Core
DATABASE_URL: str
SECRET_KEY: str
Expand All @@ -23,19 +20,19 @@ class Settings(BaseSettings):
REFRESH_SECRET_KEY: str
REFRESH_TOKEN_EXPIRE_DAYS: int = 7

# --- Configurações de Email (SendGrid) ---
BREVO_API_KEY: str # Substitua SENDGRID_API_KEY por esta linha
# Email (Brevo)
BREVO_API_KEY: str
EMAIL_FROM: EmailStr
EMAIL_FROM_NAME: str | None = "Verax"
EMAIL_FROM_NAME: str | None = "Verax" # Manteve 'Verax' do seu código original

# Email Links
VERIFICATION_URL_BASE: str = "http://localhost:8000/verify"
VERIFICATION_URL_BASE: str = "http://localhost:8000/verify" # Manteve do seu código original
EMAIL_VERIFICATION_TOKEN_EXPIRE_MINUTES: int = 60

# Password Reset
RESET_PASSWORD_SECRET_KEY: str | None = None
RESET_PASSWORD_TOKEN_EXPIRE_MINUTES: int = 30
RESET_PASSWORD_URL_BASE: str = "http://localhost:8000/reset-password"
RESET_PASSWORD_URL_BASE: str = "http://localhost:8000/reset-password" # Manteve do seu código original

# Account Lockout
LOGIN_MAX_FAILED_ATTEMPTS: int = 5
Expand All @@ -44,17 +41,27 @@ class Settings(BaseSettings):
# Chave de API Interna
INTERNAL_API_KEY: str

# --- OIDC JWT Claims ---
JWT_ISSUER: str = "urn:verax:authapi"
JWT_AUDIENCE: str = "urn:verax:client"
# OIDC JWT Claims (API como Recurso / IdP)
# Issuer deve ser a URL base da SUA API
JWT_ISSUER: str = "http://localhost:8001" # Ajustado para o valor padrão recomendado
# Audience padrão para tokens emitidos para clientes (pode ser sobrescrito)
JWT_AUDIENCE: str = "your-client-app-id" # Ajustado para um placeholder mais claro

# --- Google OAuth2 ---
# Google OAuth2
GOOGLE_CLIENT_ID: str | None = None
GOOGLE_CLIENT_SECRET: str | None = None
# URL do SEU frontend (para produção)
GOOGLE_REDIRECT_URI_FRONTEND: str = "http://localhost:3000/google-callback"
# URL do backend (usado apenas para testes locais da API)
GOOGLE_REDIRECT_URI_BACKEND: str = "http://localhost:8001/api/v1/auth/google/callback"
GOOGLE_REDIRECT_URI_FRONTEND: str = "http://localhost:3000/google-callback" # Manteve do seu código original
GOOGLE_REDIRECT_URI_BACKEND: str = "http://localhost:8001/api/v1/auth/google/callback" # Manteve do seu código original

# Device Trust
TRUSTED_DEVICE_COOKIE_NAME: str = "auth_device_id"
TRUSTED_DEVICE_COOKIE_MAX_AGE_DAYS: int = 30

# --- OIDC JWK Keys ---
# Estas linhas leem as variáveis do .env
OIDC_PRIVATE_JWK_JSON: str
OIDC_PUBLIC_JWK_SET_JSON: str
# --- Fim OIDC JWK Keys ---

class Config:
case_sensitive = True
Expand All @@ -64,6 +71,14 @@ class Config:
try:
# AQUI é onde o settings é criado e exportado
settings = Settings()

# Verificação adicional para o JWT_ISSUER (importante para OIDC)
if not settings.JWT_ISSUER or not settings.JWT_ISSUER.startswith("http"):
logging.warning(
f"JWT_ISSUER ('{settings.JWT_ISSUER}') não parece ser uma URL válida. "
f"Para OIDC funcionar corretamente, defina JWT_ISSUER no .env com a URL base da sua API (ex: http://localhost:8001)"
)

except Exception as e:
logging.error(f"FATAL: Erro ao carregar 'settings' a partir do .env em {ENV_FILE_PATH}: {e}")
# Se der erro aqui, o 'settings' não será criado, causando o ImportError
Expand Down
42 changes: 42 additions & 0 deletions app/models/oauth2_authorization_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# auth_api/app/models/oauth2_authorization_code.py
from sqlalchemy import String, Text, Integer, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from typing import Optional
from app.db.base import Base

class OAuth2AuthorizationCode(Base):
"""
Armazena temporariamente os códigos de autorização gerados
durante o fluxo OIDC Authorization Code.
"""
__tablename__ = "oauth2_authorization_codes"

id: Mapped[int] = mapped_column(primary_key=True)
code: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
client_id: Mapped[str] = mapped_column(String(48), nullable=False) # FK para oauth2_clients.client_id seria melhor
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
redirect_uri: Mapped[Optional[Text]] = mapped_column(Text)
response_type: Mapped[Optional[Text]] = mapped_column(Text)
scope: Mapped[Optional[Text]] = mapped_column(Text)
auth_time: Mapped[int] = mapped_column(Integer, nullable=False) # Timestamp UNIX

# Campos OIDC adicionais
nonce: Mapped[Optional[str]] = mapped_column(String(120))
code_challenge: Mapped[Optional[str]] = mapped_column(String(128)) # Para PKCE
code_challenge_method: Mapped[Optional[str]] = mapped_column(String(48)) # Para PKCE (ex: "S256")

user: Mapped["User"] = relationship() # type: ignore

def is_expired(self) -> bool:
# Lógica para verificar se o código expirou (ex: auth_time + 60 segundos)
import time
return time.time() > self.auth_time + 60 # Exemplo: expira em 60 segundos

def get_redirect_uri(self) -> Optional[str]:
return self.redirect_uri

def get_scope(self) -> Optional[str]:
return self.scope

def get_auth_time(self) -> int:
return self.auth_time
86 changes: 86 additions & 0 deletions app/models/oauth2_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# auth_api/app/models/oauth2_client.py
from sqlalchemy import String, Text, Boolean, Integer, Index, JSON
from sqlalchemy.orm import Mapped, mapped_column
from typing import List, Optional

from app.db.base import Base

class OAuth2Client(Base):
"""
Representa uma aplicação cliente registrada que pode usar
esta API como um Provedor de Identidade OIDC/OAuth2.
"""
__tablename__ = "oauth2_clients"

id: Mapped[int] = mapped_column(primary_key=True)
# client_id e client_secret são gerados por nós e fornecidos à aplicação cliente
client_id: Mapped[str] = mapped_column(String(48), unique=True, index=True, nullable=False)
client_secret_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # Hash do secret, se confidencial

# Metadados do cliente (seguindo RFC7591)
client_name: Mapped[Optional[str]] = mapped_column(String(120))
# Lista de URIs para onde podemos redirecionar o utilizador após o login
redirect_uris_str: Mapped[Optional[Text]] = mapped_column(Text) # Armazenado como texto separado por espaço
# Lista de scopes que este cliente pode solicitar (ex: "openid profile email")
scope_str: Mapped[Optional[Text]] = mapped_column(Text, default="openid profile email", nullable=False) # Armazenado como texto separado por espaço
# Tipos de resposta permitidos (ex: "code", "token id_token")
response_types_str: Mapped[Optional[Text]] = mapped_column(Text, default="code", nullable=False) # Armazenado como texto separado por espaço
# Tipos de grant permitidos (ex: "authorization_code", "refresh_token")
grant_types_str: Mapped[Optional[Text]] = mapped_column(Text, default="authorization_code refresh_token", nullable=False) # Armazenado como texto separado por espaço
# Método de autenticação do endpoint de token (ex: "client_secret_basic", "client_secret_post", "none")
token_endpoint_auth_method: Mapped[Optional[str]] = mapped_column(String(120), default="client_secret_basic")

# Campos específicos do Authlib para conveniência (podem ser derivados dos _str)
@property
def redirect_uris(self) -> List[str]:
return self.redirect_uris_str.split() if self.redirect_uris_str else []

@property
def scope(self) -> str:
return self.scope_str or "" # Authlib espera uma string

@property
def response_types(self) -> List[str]:
return self.response_types_str.split() if self.response_types_str else []

@property
def grant_types(self) -> List[str]:
return self.grant_types_str.split() if self.grant_types_str else []

# -- Métodos Helper exigidos/úteis para Authlib --
def get_client_id(self) -> str:
return self.client_id

def get_default_redirect_uri(self) -> Optional[str]:
if self.redirect_uris:
return self.redirect_uris[0]
return None

def check_redirect_uri(self, redirect_uri: str) -> bool:
return redirect_uri in self.redirect_uris

def has_client_secret(self) -> bool:
return bool(self.client_secret_hash)

def check_client_secret(self, client_secret: str) -> bool:
# Implementar comparação de hash segura aqui!
# Ex: return pwd_context.verify(client_secret, self.client_secret_hash)
# Por agora, faremos uma comparação simples (NÃO SEGURO PARA PRODUÇÃO)
# Você precisará importar e usar o seu 'pwd_context' de security.py
from app.core.security import pwd_context # Import local temporário
if not self.client_secret_hash:
return False
return pwd_context.verify(client_secret, self.client_secret_hash)


def check_response_type(self, response_type: str) -> bool:
return response_type in self.response_types

def check_grant_type(self, grant_type: str) -> bool:
return grant_type in self.grant_types

def check_scope(self, scope: str) -> bool:
# Verifica se todos os scopes pedidos estão nos scopes permitidos
requested_scopes = set(scope.split())
allowed_scopes = set(self.scope.split())
return requested_scopes.issubset(allowed_scopes)
Loading