diff --git a/.gitignore b/.gitignore index 3edf0fb..af002e3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,10 +11,15 @@ logs/ env .env ./env + # IDEs .idea/ .vscode/ # OS .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db + +# Chaves Criptográficas e Segredos +*.pem +*.json \ No newline at end of file diff --git a/Authlib b/Authlib new file mode 100644 index 0000000..2ac08ee --- /dev/null +++ b/Authlib @@ -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 diff --git a/alembic/env.py b/alembic/env.py index 9bc738b..710ff62 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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 --- diff --git a/alembic/versions/9ad96a26ae81_adicionar_tabelas_oidc_client_e_code.py b/alembic/versions/9ad96a26ae81_adicionar_tabelas_oidc_client_e_code.py new file mode 100644 index 0000000..724b27e --- /dev/null +++ b/alembic/versions/9ad96a26ae81_adicionar_tabelas_oidc_client_e_code.py @@ -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 ### diff --git a/app/core/config.py b/app/core/config.py index 98d8563..b07d7b3 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/app/models/oauth2_authorization_code.py b/app/models/oauth2_authorization_code.py new file mode 100644 index 0000000..4f577c7 --- /dev/null +++ b/app/models/oauth2_authorization_code.py @@ -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 \ No newline at end of file diff --git a/app/models/oauth2_client.py b/app/models/oauth2_client.py new file mode 100644 index 0000000..60b3b15 --- /dev/null +++ b/app/models/oauth2_client.py @@ -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) \ No newline at end of file diff --git a/app/oidc_server.py b/app/oidc_server.py new file mode 100644 index 0000000..a26c200 --- /dev/null +++ b/app/oidc_server.py @@ -0,0 +1,112 @@ +# auth_api/app/oidc_server.py +import time +from typing import Optional, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from authlib.oauth2.rfc6749 import ClientMixin, TokenMixin, AuthorizationCodeMixin +from authlib.oidc.core import UserInfo + +# Importar modelos e CRUDs necessários +from app.models.oauth2_client import OAuth2Client +from app.models.oauth2_authorization_code import OAuth2AuthorizationCode +from app.models.refresh_token import RefreshToken # Reutilizaremos esta +from app.models.user import User +from app.crud import crud_refresh_token # Para interagir com refresh tokens +from app.core.security import pwd_context # Para verificar client_secret +from app.db.session import get_db # Para obter a sessão async + +# --- Implementações para Authlib --- + +async def query_client(client_id: str) -> Optional[OAuth2Client]: + """Função que o Authlib usa para encontrar um cliente OIDC pelo seu client_id.""" + async for db in get_db(): # Obtém a sessão async + stmt = select(OAuth2Client).where(OAuth2Client.client_id == client_id) + result = await db.execute(stmt) + client = result.scalars().first() + # NOTA: Authlib espera um objeto que implemente ClientMixin. + # Nosso modelo OAuth2Client já tem os métodos necessários. + return client # Retorna o objeto SQLAlchemy diretamente + +async def save_token(token: Dict[str, Any], request: Any) -> None: + """ + Função que o Authlib usa para *salvar* os tokens gerados (access e refresh). + Nós só salvaremos o refresh token na nossa tabela `refresh_tokens`. + """ + # O objeto 'request' aqui é do Authlib e contém informações úteis + client: OAuth2Client = request.client + user: User = request.user + + if token.get("token_type") == "Bearer" and token.get("refresh_token"): + # Estamos interessados apenas no refresh_token + refresh_token_str = token["refresh_token"] + expires_in = token.get("expires_in", 0) + expires_at = datetime.fromtimestamp(int(time.time()) + expires_in, tz=timezone.utc).replace(tzinfo=None) + + # Reutiliza o CRUD existente para refresh tokens + async for db in get_db(): + # Aqui poderíamos passar IP/User-Agent se quiséssemos rastrear sessões OIDC também + await crud_refresh_token.create_refresh_token( + db, + user=user, + token=refresh_token_str, + expires_at=expires_at + # Opcional: Adicionar ip_address=..., user_agent=... se capturado + ) + # Nota: Não salvamos o access_token porque ele é um JWT auto-contido. + +async def query_authorization_code(code: str, client: OAuth2Client) -> Optional[OAuth2AuthorizationCode]: + """Função que o Authlib usa para encontrar um código de autorização.""" + async for db in get_db(): + stmt = select(OAuth2AuthorizationCode).where( + OAuth2AuthorizationCode.code == code, + OAuth2AuthorizationCode.client_id == client.client_id # Garante que o código pertence ao cliente + ) + result = await db.execute(stmt) + auth_code = result.scalars().first() + if auth_code and not auth_code.is_expired(): + return auth_code + return None + +async def delete_authorization_code(authorization_code: OAuth2AuthorizationCode) -> None: + """Função que o Authlib usa para deletar um código após ele ser usado.""" + async for db in get_db(): + await db.delete(authorization_code) + await db.commit() + +async def authenticate_user_for_oidc(authorization_code: OAuth2AuthorizationCode) -> Optional[User]: + """ + Função que o Authlib usa para obter o objeto User associado a um código + de autorização (usado principalmente durante a troca de código por token). + """ + async for db in get_db(): + stmt = select(User).where(User.id == authorization_code.user_id) + result = await db.execute(stmt) + return result.scalars().first() + +# --- Helpers Adicionais (Exemplo) --- + +def generate_user_info(user: User, scope: str) -> UserInfo: + """ + Gera o objeto UserInfo para o ID Token e o endpoint UserInfo, + baseado nos scopes solicitados. + """ + # O scope 'openid' é obrigatório e sempre retorna 'sub' (subject ID) + user_info = UserInfo(sub=str(user.id)) + + # Adiciona claims baseados nos scopes + scopes = set(scope.split()) + if "profile" in scopes: + user_info["name"] = user.full_name + # Adicione outros claims de perfil que você tenha (picture, website, etc.) + if "email" in scopes: + user_info["email"] = user.email + user_info["email_verified"] = user.is_verified + + # Adicione seus custom claims aqui se um scope específico for solicitado + # Ex: if "roles" in scopes and user.custom_claims and "roles" in user.custom_claims: + # user_info["roles"] = user.custom_claims["roles"] + + return user_info + +# Importações adicionadas no topo +from datetime import datetime, timezone \ No newline at end of file diff --git a/convert_keys.py b/convert_keys.py new file mode 100644 index 0000000..5901ee1 --- /dev/null +++ b/convert_keys.py @@ -0,0 +1,88 @@ +import json +from authlib.jose import JsonWebKey +from cryptography.hazmat.primitives import serialization + +# --- Configuração --- +PRIVATE_KEY_FILE = 'private_key.pem' +PUBLIC_KEY_FILE = 'public_key.pem' +OUTPUT_PRIVATE_JWK_FILE = 'private_jwk.json' +OUTPUT_PUBLIC_JWK_SET_FILE = 'public_jwk_set.json' +KEY_ID = 'verax-auth-oidc-key-1' # Um identificador único para esta chave + +# --- Carregar Chave Privada PEM --- +try: + with open(PRIVATE_KEY_FILE, 'rb') as f: + private_pem = f.read() + # Apenas verifica se pode ser carregada, não guarda o objeto cryptography + serialization.load_pem_private_key(private_pem, password=None) + print(f"Chave privada '{PRIVATE_KEY_FILE}' carregada com sucesso.") +except FileNotFoundError: + print(f"ERRO: Ficheiro da chave privada '{PRIVATE_KEY_FILE}' não encontrado.") + exit(1) +except Exception as e: + print(f"ERRO ao carregar a chave privada: {e}") + exit(1) + +# --- Carregar Chave Pública PEM --- +try: + with open(PUBLIC_KEY_FILE, 'rb') as f: + public_pem = f.read() + # Apenas verifica se pode ser carregada, não guarda o objeto cryptography + serialization.load_pem_public_key(public_pem) + print(f"Chave pública '{PUBLIC_KEY_FILE}' carregada com sucesso.") +except FileNotFoundError: + print(f"ERRO: Ficheiro da chave pública '{PUBLIC_KEY_FILE}' não encontrado.") + exit(1) +except Exception as e: + print(f"ERRO ao carregar a chave pública: {e}") + exit(1) + +# --- Converter para JWK (com tratamento de erro) --- +# Inicializar variáveis para evitar NameError +private_jwk_dict = None +public_jwk_set = None +try: + # Chave privada + # Passar o conteúdo PEM bruto (bytes) diretamente + private_jwk_obj = JsonWebKey.import_key(private_pem, {'use': 'sig'}) + private_jwk_dict = private_jwk_obj.as_dict(private=True) # Pedir o formato completo + private_jwk_dict['kid'] = KEY_ID # Adicionar o Key ID + + # Chave pública (para o JWKSet) + # Passar o conteúdo PEM bruto (bytes) diretamente + public_jwk_obj = JsonWebKey.import_key(public_pem, {'use': 'sig'}) + public_jwk_dict = public_jwk_obj.as_dict(private=False) # Pedir apenas o formato público + public_jwk_dict['kid'] = KEY_ID # Adicionar o Key ID + public_jwk_set = {'keys': [public_jwk_dict]} + +except Exception as e: + print(f"ERRO durante a conversão das chaves PEM para JWK: {e}") + exit(1) # Sair se a conversão falhar + +# --- Salvar Ficheiros JSON --- +try: + # Verificar se as variáveis foram realmente criadas antes de salvar + if private_jwk_dict: + with open(OUTPUT_PRIVATE_JWK_FILE, 'w') as f: + json.dump(private_jwk_dict, f, indent=4) + print(f"Chave privada JWK salva em '{OUTPUT_PRIVATE_JWK_FILE}'.") + else: + # Este else não deve ser atingido por causa do exit(1) acima, mas é uma segurança extra + print("ERRO: Dicionário da chave privada JWK não foi definido.") + exit(1) + + if public_jwk_set: + with open(OUTPUT_PUBLIC_JWK_SET_FILE, 'w') as f: + json.dump(public_jwk_set, f, indent=4) + print(f"Conjunto de chaves públicas JWKSet salvo em '{OUTPUT_PUBLIC_JWK_SET_FILE}'.") + else: + # Este else não deve ser atingido + print("ERRO: Conjunto de chaves públicas JWKSet não foi definido.") + exit(1) + +except Exception as e: + print(f"ERRO ao salvar os ficheiros JWK: {e}") + exit(1) + +print("\nConversão concluída!") +print(f"IMPORTANTE: Proteja o ficheiro '{OUTPUT_PRIVATE_JWK_FILE}'!") \ No newline at end of file diff --git a/main.py b/main.py index a02c526..db89980 100644 --- a/main.py +++ b/main.py @@ -1,54 +1,85 @@ # auth_api/main.py +import os +import json # Para carregar as chaves JWK from fastapi import FastAPI, Request, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -# --- Imports de Segurança --- -# IMPORTAR HTTPBearer +# --- Imports de Segurança (FastAPI) --- from fastapi.security import OAuth2PasswordBearer, APIKeyHeader, HTTPBearer -# --- Fim Imports --- -# --- Adicionar imports do slowapi --- +# --- Imports slowapi (Rate Limiting) --- from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded from slowapi.middleware import SlowAPIMiddleware -# --- Fim imports slowapi --- + +# --- Imports da sua Aplicação --- from app.db.session import dispose_engine -# Importar routers -from app.api.endpoints import auth, users, mgmt -# Importar dependência de chave de API E OS NOVOS ESQUEMAS -from app.api.dependencies import get_api_key, oauth2_scheme, bearer_scheme, api_key_scheme +from app.api.endpoints import auth, users, mgmt # Seus routers existentes +from app.api.dependencies import get_api_key, oauth2_scheme, bearer_scheme, api_key_scheme # Suas dependências existentes +from app.core.config import settings # Importar settings para carregar as chaves +from app.db.base import Base # noqa - Importar Base para Alembic +# Importar todos os modelos para Alembic/Base.metadata +from app.models import ( # noqa + user, refresh_token, mfa_recovery_code, trusted_device, + oauth2_client, oauth2_authorization_code +) + +# --- Authlib Imports --- +from authlib.integrations.starlette_oauth2 import AuthorizationServer +from authlib.oauth2.rfc6749 import grants # OAuth2 Grants +from authlib.oidc.core import grants as oidc_grants # OpenID Connect Grants +from authlib.jose import JsonWebKey # Para carregar JWK + +# --- Funções Ponte OIDC (do ficheiro oidc_server.py) --- +from app.oidc_server import ( + query_client, + save_token, + query_authorization_code, + delete_authorization_code, + authenticate_user_for_oidc, + # generate_user_info # Importaremos quando usarmos +) +from app.models.user import User as UserModel # Para type hints -# Importar modelos para Alembic/Base.metadata -from app.db.base import Base # noqa -from app.models import user, refresh_token # noqa +# --- Carregar Chaves JWK das Configurações --- +try: + private_jwk_json_str = settings.OIDC_PRIVATE_JWK_JSON + public_jwk_set_json_str = settings.OIDC_PUBLIC_JWK_SET_JSON -# --- REMOVER DEFINIÇÕES DE ESQUEMAS DAQUI --- -# Elas agora são importadas de 'dependencies.py' -# oauth2_scheme = ... (REMOVER) -# api_key_scheme = ... (REMOVER) -# --- FIM REMOÇÃO --- + if not private_jwk_json_str or not public_jwk_set_json_str: + raise ValueError("Variáveis OIDC_PRIVATE_JWK_JSON ou OIDC_PUBLIC_JWK_SET_JSON não definidas.") + PRIVATE_JWK = json.loads(private_jwk_json_str) + JWK_SET = json.loads(public_jwk_set_json_str) + # Validar minimamente se as chaves parecem corretas + if 'keys' not in JWK_SET or not isinstance(JWK_SET['keys'], list) or not JWK_SET['keys']: + raise ValueError("OIDC_PUBLIC_JWK_SET_JSON inválido: deve ser um JWKSet com uma lista 'keys'.") + if 'kty' not in PRIVATE_JWK: + raise ValueError("OIDC_PRIVATE_JWK_JSON inválido: não parece ser uma chave JWK.") + + print(f"INFO: Chaves JWK OIDC carregadas com sucesso a partir da configuração (kty: {PRIVATE_JWK.get('kty')}).") + +except (AttributeError, ValueError, json.JSONDecodeError) as e: + print(f"ERRO FATAL: Falha ao carregar ou parsear as chaves JWK OIDC da configuração: {e}") + print("Verifique as variáveis OIDC_PRIVATE_JWK_JSON e OIDC_PUBLIC_JWK_SET_JSON no seu .env ou ambiente.") + exit(1) # Sair se as chaves não puderem ser carregadas + +# --- Configuração do FastAPI --- limiter = Limiter(key_func=get_remote_address, default_limits=["10/minute"]) app = FastAPI( title="Auth API", - description="API Centralizada de Autenticação", - version="1.0.0", - # --- Adicionar/Atualizar OpenAPI security schemes --- - # Isso informa explicitamente ao Swagger UI sobre os métodos de autenticação + description="API Centralizada de Autenticação com OIDC", + version="1.1.0", # Versão incrementada openapi_components={ "securitySchemes": { - # 1. Para o /token (fluxo de senha) - "OAuth2PasswordBearer": oauth2_scheme, - # 2. NOVO: Para os endpoints com cadeado (colar o token) + "OAuth2PasswordBearer": oauth2_scheme, "BearerAuth": bearer_scheme, - # 3. Para o /mgmt - "APIKeyHeader": api_key_scheme + "APIKeyHeader": api_key_scheme } } - # --- Fim OpenAPI --- ) app.state.limiter = limiter @@ -59,6 +90,7 @@ "http://localhost:5173", "http://localhost:3000", "http://localhost:8000", + # Adicione aqui as origens do seu frontend ] app.add_middleware( CORSMiddleware, @@ -68,46 +100,89 @@ allow_headers=["*"], ) -# Incluir routers da API +# --- Configuração do Servidor Authlib OIDC --- +server = AuthorizationServer( + query_client=query_client, + save_token=save_token, +) + +def configure_oidc_grants(server: AuthorizationServer): + """Configura e regista os fluxos (grants) OIDC no servidor Authlib.""" + # 1. Fluxo Principal: Authorization Code Flow (com OpenID Connect) + server.register_grant( + oidc_grants.OpenIDAuthorizationCodeGrant, + [ + authenticate_user_for_oidc, # Hook para obter o utilizador a partir do código + ] + ) + # 2. Código de Autorização (necessário pelo grant acima) + server.register_grant( + grants.AuthorizationCodeGrant, + [ + query_authorization_code, # Como encontrar um código + delete_authorization_code, # Como deletar um código após uso + authenticate_user_for_oidc, # Como obter o utilizador do código + ] + ) + # 3. Fluxo de Refresh Token (para clientes OIDC) + server.register_grant(grants.RefreshTokenGrant) + +# Chamar a configuração dos grants +configure_oidc_grants(server) + +# --- Registrar Roteadores da API Existente --- api_prefix = "/api/v1" -# --- Router de Autenticação --- -# Alguns endpoints são públicos (/token, /verify-email, etc.) -# Outros requerem Bearer token (/me, /mfa/...) -# Adicionamos a dependência global do oauth2_scheme aqui, mas endpoints específicos -# como /token não o usarão diretamente. A proteção real vem das dependências -# como get_current_active_user dentro dos endpoints. app.include_router( auth.router, prefix=f"{api_prefix}/auth", tags=["Authentication"], - # REMOVA a dependência do router - # dependencies=[Depends(oauth2_scheme)] # <-- REMOVA ESTA LINHA ) - -# --- Router de Usuários --- -# POST / é público, mas GET /, GET /{id}, PUT /me requerem autenticação. -# GET / e GET /{id} também requerem admin (verificado dentro do endpoint). app.include_router( users.router, prefix=f"{api_prefix}/users", tags=["Users"], - # REMOVA a dependência do router - # dependencies=[Depends(oauth2_scheme)] # <-- REMOVA ESTA LINHA ) - -# --- Router de Gerenciamento --- -# Protegido APENAS pela chave de API app.include_router( mgmt.router, prefix=f"{api_prefix}/mgmt", tags=["Management"], - # A dependência get_api_key já usa o api_key_scheme internamente dependencies=[Depends(get_api_key)], - # NÃO associar ao oauth2_scheme aqui ) +# --- NOVOS Endpoints OIDC --- + +# 1. Endpoint de Descoberta OIDC +@app.get('/.well-known/openid-configuration') +async def openid_configuration(): + """Retorna os metadados do servidor OIDC.""" + # Usa o JWT_ISSUER definido nas settings (que deve ser a URL base) + issuer_url = settings.JWT_ISSUER + if not issuer_url or not issuer_url.startswith("http"): + # Fallback, mas idealmente JWT_ISSUER deve ser a URL base correta no .env + issuer_url = "http://localhost:8001" + print(f"AVISO: JWT_ISSUER não é uma URL válida nas settings. Usando fallback: {issuer_url}") + return server.generate_metadata( + issuer=issuer_url, + authorization_endpoint=f'{issuer_url}/oauth/authorize', + token_endpoint=f'{issuer_url}/oauth/token', + jwks_uri=f'{issuer_url}/oauth/jwks', + userinfo_endpoint=f'{issuer_url}/oauth/userinfo', # Implementaremos depois + # revocation_endpoint=f'{issuer_url}/oauth/revoke', # Implementaremos depois + # end_session_endpoint=f'{issuer_url}/oauth/logout', # Implementaremos depois + ) + +# 2. Endpoint JWKS (Chaves Públicas) +@app.get('/oauth/jwks') +async def jwks_uri(): + """Retorna as chaves públicas (JWKSet) usadas para assinar os ID Tokens.""" + return JWK_SET + +# (Os endpoints /oauth/authorize e /oauth/token serão adicionados no próximo passo) + + +# --- Evento de Shutdown e Rota Raiz --- @app.on_event("shutdown") async def shutdown_event(): print("Shutting down: Disposing database engine...") @@ -116,4 +191,12 @@ async def shutdown_event(): @app.get("/") def read_root(): - return {"message": "Auth API is running!"} \ No newline at end of file + return {"message": "Auth API with OIDC support is running!"} + +# --- Middleware para injetar estado Authlib --- +@app.middleware("http") +async def add_authlib_server_state(request: Request, call_next): + request.state.oauth2_server = server + request.state.oidc_private_jwk = PRIVATE_JWK + response = await call_next(request) + return response \ No newline at end of file