anderson-ufrj
commited on
Commit
·
8b80916
1
Parent(s):
0c5f570
feat(auth): migrate authentication from in-memory to PostgreSQL
Browse files- Add auth_service with database-backed user management
- Implement JWT blacklist for token revocation
- Add account lockout after failed login attempts
- Create database schema for users and sessions
- Add connection pooling for PostgreSQL
- Maintain API compatibility with existing auth routes
- scripts/setup_database.sql +197 -0
- src/api/auth_db.py +226 -0
- src/api/routes/auth_db.py +269 -0
- src/infrastructure/database.py +22 -0
- src/services/auth_service.py +297 -0
scripts/setup_database.sql
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- Cidadão.AI Database Schema Setup
|
| 2 |
+
-- Author: Anderson Henrique da Silva
|
| 3 |
+
-- Date: 2025-09-24
|
| 4 |
+
|
| 5 |
+
-- Enable UUID extension
|
| 6 |
+
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
| 7 |
+
|
| 8 |
+
-- Users table for authentication
|
| 9 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 10 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 11 |
+
username VARCHAR(255) UNIQUE NOT NULL,
|
| 12 |
+
email VARCHAR(255) UNIQUE NOT NULL,
|
| 13 |
+
password_hash VARCHAR(255) NOT NULL,
|
| 14 |
+
full_name VARCHAR(255),
|
| 15 |
+
is_active BOOLEAN DEFAULT true,
|
| 16 |
+
is_admin BOOLEAN DEFAULT false,
|
| 17 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 18 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 19 |
+
last_login TIMESTAMP WITH TIME ZONE,
|
| 20 |
+
failed_login_attempts INTEGER DEFAULT 0,
|
| 21 |
+
locked_until TIMESTAMP WITH TIME ZONE
|
| 22 |
+
);
|
| 23 |
+
|
| 24 |
+
-- JWT Blacklist table
|
| 25 |
+
CREATE TABLE IF NOT EXISTS jwt_blacklist (
|
| 26 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 27 |
+
token_jti VARCHAR(255) UNIQUE NOT NULL,
|
| 28 |
+
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
| 29 |
+
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
| 30 |
+
blacklisted_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 31 |
+
reason VARCHAR(255)
|
| 32 |
+
);
|
| 33 |
+
|
| 34 |
+
-- Sessions table
|
| 35 |
+
CREATE TABLE IF NOT EXISTS sessions (
|
| 36 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 37 |
+
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
| 38 |
+
token VARCHAR(255) UNIQUE NOT NULL,
|
| 39 |
+
ip_address INET,
|
| 40 |
+
user_agent TEXT,
|
| 41 |
+
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
| 42 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 43 |
+
last_activity TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 44 |
+
);
|
| 45 |
+
|
| 46 |
+
-- Rate limiting table
|
| 47 |
+
CREATE TABLE IF NOT EXISTS rate_limits (
|
| 48 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 49 |
+
identifier VARCHAR(255) NOT NULL, -- IP or user_id
|
| 50 |
+
endpoint VARCHAR(255) NOT NULL,
|
| 51 |
+
window_start TIMESTAMP WITH TIME ZONE NOT NULL,
|
| 52 |
+
request_count INTEGER DEFAULT 1,
|
| 53 |
+
UNIQUE(identifier, endpoint, window_start)
|
| 54 |
+
);
|
| 55 |
+
|
| 56 |
+
-- Audit logs table
|
| 57 |
+
CREATE TABLE IF NOT EXISTS audit_logs (
|
| 58 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 59 |
+
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
| 60 |
+
action VARCHAR(100) NOT NULL,
|
| 61 |
+
resource_type VARCHAR(100),
|
| 62 |
+
resource_id VARCHAR(255),
|
| 63 |
+
ip_address INET,
|
| 64 |
+
user_agent TEXT,
|
| 65 |
+
request_data JSONB,
|
| 66 |
+
response_status INTEGER,
|
| 67 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 68 |
+
);
|
| 69 |
+
|
| 70 |
+
-- Investigations table (existing)
|
| 71 |
+
CREATE TABLE IF NOT EXISTS investigations (
|
| 72 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 73 |
+
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
| 74 |
+
title VARCHAR(500) NOT NULL,
|
| 75 |
+
description TEXT,
|
| 76 |
+
status VARCHAR(50) DEFAULT 'pending',
|
| 77 |
+
type VARCHAR(100),
|
| 78 |
+
data JSONB,
|
| 79 |
+
results JSONB,
|
| 80 |
+
metadata JSONB,
|
| 81 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 82 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 83 |
+
completed_at TIMESTAMP WITH TIME ZONE
|
| 84 |
+
);
|
| 85 |
+
|
| 86 |
+
-- Chat sessions table
|
| 87 |
+
CREATE TABLE IF NOT EXISTS chat_sessions (
|
| 88 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 89 |
+
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
| 90 |
+
title VARCHAR(500),
|
| 91 |
+
context JSONB,
|
| 92 |
+
is_active BOOLEAN DEFAULT true,
|
| 93 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 94 |
+
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
| 95 |
+
last_message_at TIMESTAMP WITH TIME ZONE
|
| 96 |
+
);
|
| 97 |
+
|
| 98 |
+
-- Chat messages table
|
| 99 |
+
CREATE TABLE IF NOT EXISTS chat_messages (
|
| 100 |
+
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
| 101 |
+
session_id UUID REFERENCES chat_sessions(id) ON DELETE CASCADE,
|
| 102 |
+
role VARCHAR(50) NOT NULL, -- 'user' or 'assistant'
|
| 103 |
+
content TEXT NOT NULL,
|
| 104 |
+
metadata JSONB,
|
| 105 |
+
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
| 106 |
+
);
|
| 107 |
+
|
| 108 |
+
-- Create indexes for performance
|
| 109 |
+
CREATE INDEX idx_users_email ON users(email);
|
| 110 |
+
CREATE INDEX idx_users_username ON users(username);
|
| 111 |
+
CREATE INDEX idx_jwt_blacklist_jti ON jwt_blacklist(token_jti);
|
| 112 |
+
CREATE INDEX idx_jwt_blacklist_expires ON jwt_blacklist(expires_at);
|
| 113 |
+
CREATE INDEX idx_sessions_token ON sessions(token);
|
| 114 |
+
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
| 115 |
+
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
|
| 116 |
+
CREATE INDEX idx_rate_limits_identifier ON rate_limits(identifier, endpoint, window_start);
|
| 117 |
+
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id);
|
| 118 |
+
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at);
|
| 119 |
+
CREATE INDEX idx_investigations_user_id ON investigations(user_id);
|
| 120 |
+
CREATE INDEX idx_investigations_status ON investigations(status);
|
| 121 |
+
CREATE INDEX idx_chat_sessions_user_id ON chat_sessions(user_id);
|
| 122 |
+
CREATE INDEX idx_chat_messages_session_id ON chat_messages(session_id);
|
| 123 |
+
|
| 124 |
+
-- Create updated_at trigger function
|
| 125 |
+
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
| 126 |
+
RETURNS TRIGGER AS $$
|
| 127 |
+
BEGIN
|
| 128 |
+
NEW.updated_at = CURRENT_TIMESTAMP;
|
| 129 |
+
RETURN NEW;
|
| 130 |
+
END;
|
| 131 |
+
$$ language 'plpgsql';
|
| 132 |
+
|
| 133 |
+
-- Apply updated_at trigger to tables
|
| 134 |
+
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
|
| 135 |
+
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
| 136 |
+
|
| 137 |
+
CREATE TRIGGER update_investigations_updated_at BEFORE UPDATE ON investigations
|
| 138 |
+
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
| 139 |
+
|
| 140 |
+
CREATE TRIGGER update_chat_sessions_updated_at BEFORE UPDATE ON chat_sessions
|
| 141 |
+
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
| 142 |
+
|
| 143 |
+
-- Insert default admin user (change password after setup!)
|
| 144 |
+
INSERT INTO users (username, email, password_hash, full_name, is_admin, is_active)
|
| 145 |
+
VALUES (
|
| 146 |
+
'admin',
|
| 147 |
+
'[email protected]',
|
| 148 |
+
'$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiGH9jG6FnJi', -- default: Admin123!
|
| 149 |
+
'Administrator',
|
| 150 |
+
true,
|
| 151 |
+
true
|
| 152 |
+
) ON CONFLICT (username) DO NOTHING;
|
| 153 |
+
|
| 154 |
+
-- Create cleanup function for expired data
|
| 155 |
+
CREATE OR REPLACE FUNCTION cleanup_expired_data()
|
| 156 |
+
RETURNS void AS $$
|
| 157 |
+
BEGIN
|
| 158 |
+
-- Remove expired blacklisted tokens
|
| 159 |
+
DELETE FROM jwt_blacklist WHERE expires_at < CURRENT_TIMESTAMP;
|
| 160 |
+
|
| 161 |
+
-- Remove expired sessions
|
| 162 |
+
DELETE FROM sessions WHERE expires_at < CURRENT_TIMESTAMP;
|
| 163 |
+
|
| 164 |
+
-- Remove old rate limit records (older than 1 day)
|
| 165 |
+
DELETE FROM rate_limits WHERE window_start < CURRENT_TIMESTAMP - INTERVAL '1 day';
|
| 166 |
+
END;
|
| 167 |
+
$$ LANGUAGE plpgsql;
|
| 168 |
+
|
| 169 |
+
-- Optional: Create a view for active sessions with user info
|
| 170 |
+
CREATE OR REPLACE VIEW active_sessions AS
|
| 171 |
+
SELECT
|
| 172 |
+
s.id,
|
| 173 |
+
s.user_id,
|
| 174 |
+
u.username,
|
| 175 |
+
u.email,
|
| 176 |
+
s.ip_address,
|
| 177 |
+
s.user_agent,
|
| 178 |
+
s.created_at,
|
| 179 |
+
s.last_activity,
|
| 180 |
+
s.expires_at
|
| 181 |
+
FROM sessions s
|
| 182 |
+
JOIN users u ON s.user_id = u.id
|
| 183 |
+
WHERE s.expires_at > CURRENT_TIMESTAMP;
|
| 184 |
+
|
| 185 |
+
COMMENT ON TABLE users IS 'Application users with authentication data';
|
| 186 |
+
COMMENT ON TABLE jwt_blacklist IS 'Revoked JWT tokens';
|
| 187 |
+
COMMENT ON TABLE sessions IS 'Active user sessions';
|
| 188 |
+
COMMENT ON TABLE rate_limits IS 'API rate limiting tracking';
|
| 189 |
+
COMMENT ON TABLE audit_logs IS 'Security audit trail';
|
| 190 |
+
COMMENT ON TABLE investigations IS 'Government transparency investigations';
|
| 191 |
+
COMMENT ON TABLE chat_sessions IS 'Chat conversation sessions';
|
| 192 |
+
COMMENT ON TABLE chat_messages IS 'Chat message history';
|
| 193 |
+
|
| 194 |
+
-- Grant permissions (adjust based on your Supabase setup)
|
| 195 |
+
GRANT ALL ON ALL TABLES IN SCHEMA public TO postgres;
|
| 196 |
+
GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO postgres;
|
| 197 |
+
GRANT ALL ON ALL FUNCTIONS IN SCHEMA public TO postgres;
|
src/api/auth_db.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database-backed authentication module for Cidadão.AI
|
| 3 |
+
Replaces in-memory storage with PostgreSQL
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from typing import Optional, List
|
| 9 |
+
from uuid import UUID
|
| 10 |
+
from fastapi import HTTPException, status, Depends
|
| 11 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 12 |
+
|
| 13 |
+
from src.services.auth_service import auth_service
|
| 14 |
+
from src.core.exceptions import AuthenticationError, ValidationError
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class User:
|
| 18 |
+
"""User model compatible with existing code"""
|
| 19 |
+
def __init__(self, **kwargs):
|
| 20 |
+
self.id = str(kwargs.get('id'))
|
| 21 |
+
self.email = kwargs.get('email')
|
| 22 |
+
self.name = kwargs.get('full_name', kwargs.get('username'))
|
| 23 |
+
self.username = kwargs.get('username')
|
| 24 |
+
self.role = 'admin' if kwargs.get('is_admin') else 'analyst'
|
| 25 |
+
self.is_active = kwargs.get('is_active', True)
|
| 26 |
+
self.created_at = kwargs.get('created_at')
|
| 27 |
+
self.last_login = kwargs.get('last_login')
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class AuthManager:
|
| 31 |
+
"""Database-backed authentication manager"""
|
| 32 |
+
|
| 33 |
+
def __init__(self):
|
| 34 |
+
self.secret_key = os.getenv('JWT_SECRET_KEY')
|
| 35 |
+
if not self.secret_key:
|
| 36 |
+
raise ValueError("JWT_SECRET_KEY environment variable is required")
|
| 37 |
+
|
| 38 |
+
self.algorithm = 'HS256'
|
| 39 |
+
self.access_token_expire_minutes = int(os.getenv('ACCESS_TOKEN_EXPIRE_MINUTES', '30'))
|
| 40 |
+
self.refresh_token_expire_days = int(os.getenv('REFRESH_TOKEN_EXPIRE_DAYS', '7'))
|
| 41 |
+
self._auth_service = auth_service
|
| 42 |
+
|
| 43 |
+
async def authenticate_user(self, username: str, password: str) -> Optional[User]:
|
| 44 |
+
"""Authenticate user with username/email and password"""
|
| 45 |
+
try:
|
| 46 |
+
user_data = await self._auth_service.authenticate_user(username, password)
|
| 47 |
+
if user_data:
|
| 48 |
+
return User(**user_data)
|
| 49 |
+
return None
|
| 50 |
+
except AuthenticationError as e:
|
| 51 |
+
raise HTTPException(
|
| 52 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 53 |
+
detail=str(e)
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
def create_access_token(self, user: User) -> str:
|
| 57 |
+
"""Create JWT access token"""
|
| 58 |
+
return self._auth_service.create_access_token({"sub": user.id})
|
| 59 |
+
|
| 60 |
+
def create_refresh_token(self, user: User) -> str:
|
| 61 |
+
"""Create JWT refresh token"""
|
| 62 |
+
return self._auth_service.create_refresh_token({"sub": user.id})
|
| 63 |
+
|
| 64 |
+
async def verify_token(self, token: str) -> dict:
|
| 65 |
+
"""Verify and decode JWT token"""
|
| 66 |
+
try:
|
| 67 |
+
return await self._auth_service.verify_token(token)
|
| 68 |
+
except AuthenticationError as e:
|
| 69 |
+
raise HTTPException(
|
| 70 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 71 |
+
detail=str(e)
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
async def get_current_user(self, token: str) -> User:
|
| 75 |
+
"""Get current user from token"""
|
| 76 |
+
try:
|
| 77 |
+
user_data = await self._auth_service.get_current_user(token)
|
| 78 |
+
if not user_data:
|
| 79 |
+
raise HTTPException(
|
| 80 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 81 |
+
detail="User not found or inactive"
|
| 82 |
+
)
|
| 83 |
+
return User(**user_data)
|
| 84 |
+
except AuthenticationError as e:
|
| 85 |
+
raise HTTPException(
|
| 86 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 87 |
+
detail=str(e)
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
async def refresh_access_token(self, refresh_token: str) -> str:
|
| 91 |
+
"""Create new access token from refresh token"""
|
| 92 |
+
try:
|
| 93 |
+
tokens = await self._auth_service.refresh_access_token(refresh_token)
|
| 94 |
+
return tokens['access_token']
|
| 95 |
+
except AuthenticationError as e:
|
| 96 |
+
raise HTTPException(
|
| 97 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 98 |
+
detail=str(e)
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
async def register_user(
|
| 102 |
+
self,
|
| 103 |
+
email: str,
|
| 104 |
+
password: str,
|
| 105 |
+
name: str,
|
| 106 |
+
role: str = 'analyst'
|
| 107 |
+
) -> User:
|
| 108 |
+
"""Register new user"""
|
| 109 |
+
try:
|
| 110 |
+
# Use email as username if not provided
|
| 111 |
+
username = email.split('@')[0]
|
| 112 |
+
is_admin = role == 'admin'
|
| 113 |
+
|
| 114 |
+
user_data = await self._auth_service.create_user(
|
| 115 |
+
username=username,
|
| 116 |
+
email=email,
|
| 117 |
+
password=password,
|
| 118 |
+
full_name=name
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# Update admin status if needed
|
| 122 |
+
if is_admin and user_data:
|
| 123 |
+
# This would need a separate method in auth_service
|
| 124 |
+
# For now, admin users must be set directly in database
|
| 125 |
+
pass
|
| 126 |
+
|
| 127 |
+
return User(**user_data)
|
| 128 |
+
|
| 129 |
+
except ValidationError as e:
|
| 130 |
+
raise HTTPException(
|
| 131 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 132 |
+
detail=str(e)
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
async def change_password(
|
| 136 |
+
self,
|
| 137 |
+
user_id: str,
|
| 138 |
+
old_password: str,
|
| 139 |
+
new_password: str
|
| 140 |
+
) -> bool:
|
| 141 |
+
"""Change user password"""
|
| 142 |
+
try:
|
| 143 |
+
return await self._auth_service.change_password(
|
| 144 |
+
UUID(user_id),
|
| 145 |
+
old_password,
|
| 146 |
+
new_password
|
| 147 |
+
)
|
| 148 |
+
except (ValidationError, AuthenticationError) as e:
|
| 149 |
+
raise HTTPException(
|
| 150 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 151 |
+
detail=str(e)
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
async def deactivate_user(self, user_id: str) -> bool:
|
| 155 |
+
"""Deactivate user account"""
|
| 156 |
+
# This would need implementation in auth_service
|
| 157 |
+
# For now, return not implemented
|
| 158 |
+
raise HTTPException(
|
| 159 |
+
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
| 160 |
+
detail="User deactivation not implemented yet"
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
async def get_all_users(self) -> List[User]:
|
| 164 |
+
"""Get all users (admin only)"""
|
| 165 |
+
# This would need implementation in auth_service
|
| 166 |
+
# For now, return empty list
|
| 167 |
+
return []
|
| 168 |
+
|
| 169 |
+
async def revoke_token(self, token: str, reason: Optional[str] = None):
|
| 170 |
+
"""Add token to blacklist"""
|
| 171 |
+
await self._auth_service.revoke_token(token, reason)
|
| 172 |
+
|
| 173 |
+
@classmethod
|
| 174 |
+
async def from_vault(cls, vault_enabled: bool = True):
|
| 175 |
+
"""Create AuthManager instance - for compatibility"""
|
| 176 |
+
return cls()
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# Create async-safe auth manager getter
|
| 180 |
+
_auth_manager_instance = None
|
| 181 |
+
|
| 182 |
+
async def get_auth_manager() -> AuthManager:
|
| 183 |
+
"""Get or create auth manager instance"""
|
| 184 |
+
global _auth_manager_instance
|
| 185 |
+
if _auth_manager_instance is None:
|
| 186 |
+
_auth_manager_instance = AuthManager()
|
| 187 |
+
return _auth_manager_instance
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# FastAPI dependencies
|
| 191 |
+
security = HTTPBearer()
|
| 192 |
+
|
| 193 |
+
async def get_current_user(
|
| 194 |
+
credentials: HTTPAuthorizationCredentials = Depends(security)
|
| 195 |
+
) -> User:
|
| 196 |
+
"""FastAPI dependency to get current authenticated user"""
|
| 197 |
+
if not credentials:
|
| 198 |
+
raise HTTPException(
|
| 199 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 200 |
+
detail="Authentication required"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
auth = await get_auth_manager()
|
| 204 |
+
return await auth.get_current_user(credentials.credentials)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def require_role(required_role: str):
|
| 208 |
+
"""Decorator to require specific role"""
|
| 209 |
+
async def role_checker(user: User = Depends(get_current_user)) -> User:
|
| 210 |
+
if user.role != required_role and user.role != 'admin':
|
| 211 |
+
raise HTTPException(
|
| 212 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 213 |
+
detail=f"Role '{required_role}' required"
|
| 214 |
+
)
|
| 215 |
+
return user
|
| 216 |
+
return role_checker
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
| 220 |
+
"""Require admin role"""
|
| 221 |
+
if user.role != 'admin':
|
| 222 |
+
raise HTTPException(
|
| 223 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 224 |
+
detail="Admin role required"
|
| 225 |
+
)
|
| 226 |
+
return user
|
src/api/routes/auth_db.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Database-backed authentication routes for Cidadão.AI API
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from typing import Optional
|
| 7 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 8 |
+
from fastapi.security import HTTPAuthorizationCredentials
|
| 9 |
+
from pydantic import BaseModel, EmailStr
|
| 10 |
+
|
| 11 |
+
from ..auth_db import get_auth_manager, get_current_user, require_admin, security, User
|
| 12 |
+
|
| 13 |
+
router = APIRouter(prefix="/auth", tags=["authentication"])
|
| 14 |
+
|
| 15 |
+
# Request/Response Models
|
| 16 |
+
class LoginRequest(BaseModel):
|
| 17 |
+
email: EmailStr
|
| 18 |
+
password: str
|
| 19 |
+
|
| 20 |
+
class LoginResponse(BaseModel):
|
| 21 |
+
access_token: str
|
| 22 |
+
refresh_token: str
|
| 23 |
+
token_type: str = "bearer"
|
| 24 |
+
expires_in: int
|
| 25 |
+
user: dict
|
| 26 |
+
|
| 27 |
+
class RefreshRequest(BaseModel):
|
| 28 |
+
refresh_token: str
|
| 29 |
+
|
| 30 |
+
class RefreshResponse(BaseModel):
|
| 31 |
+
access_token: str
|
| 32 |
+
token_type: str = "bearer"
|
| 33 |
+
expires_in: int
|
| 34 |
+
|
| 35 |
+
class RegisterRequest(BaseModel):
|
| 36 |
+
email: EmailStr
|
| 37 |
+
password: str
|
| 38 |
+
name: str
|
| 39 |
+
role: Optional[str] = "analyst"
|
| 40 |
+
|
| 41 |
+
class ChangePasswordRequest(BaseModel):
|
| 42 |
+
old_password: str
|
| 43 |
+
new_password: str
|
| 44 |
+
|
| 45 |
+
class UserResponse(BaseModel):
|
| 46 |
+
id: str
|
| 47 |
+
email: str
|
| 48 |
+
name: str
|
| 49 |
+
role: str
|
| 50 |
+
is_active: bool
|
| 51 |
+
created_at: datetime
|
| 52 |
+
last_login: Optional[datetime] = None
|
| 53 |
+
|
| 54 |
+
@router.post("/login", response_model=LoginResponse)
|
| 55 |
+
async def login(request: LoginRequest):
|
| 56 |
+
"""
|
| 57 |
+
Authenticate user and return JWT tokens
|
| 58 |
+
"""
|
| 59 |
+
auth_manager = await get_auth_manager()
|
| 60 |
+
user = await auth_manager.authenticate_user(request.email, request.password)
|
| 61 |
+
|
| 62 |
+
if not user:
|
| 63 |
+
raise HTTPException(
|
| 64 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 65 |
+
detail="Invalid credentials",
|
| 66 |
+
headers={"WWW-Authenticate": "Bearer"}
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
access_token = auth_manager.create_access_token(user)
|
| 70 |
+
refresh_token = auth_manager.create_refresh_token(user)
|
| 71 |
+
|
| 72 |
+
return LoginResponse(
|
| 73 |
+
access_token=access_token,
|
| 74 |
+
refresh_token=refresh_token,
|
| 75 |
+
expires_in=auth_manager.access_token_expire_minutes * 60,
|
| 76 |
+
user={
|
| 77 |
+
"id": user.id,
|
| 78 |
+
"email": user.email,
|
| 79 |
+
"name": user.name,
|
| 80 |
+
"role": user.role,
|
| 81 |
+
"is_active": user.is_active
|
| 82 |
+
}
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
@router.post("/refresh", response_model=RefreshResponse)
|
| 86 |
+
async def refresh_token(request: RefreshRequest):
|
| 87 |
+
"""
|
| 88 |
+
Refresh access token using refresh token
|
| 89 |
+
"""
|
| 90 |
+
auth_manager = await get_auth_manager()
|
| 91 |
+
try:
|
| 92 |
+
new_access_token = await auth_manager.refresh_access_token(request.refresh_token)
|
| 93 |
+
|
| 94 |
+
return RefreshResponse(
|
| 95 |
+
access_token=new_access_token,
|
| 96 |
+
expires_in=auth_manager.access_token_expire_minutes * 60
|
| 97 |
+
)
|
| 98 |
+
except Exception as e:
|
| 99 |
+
raise HTTPException(
|
| 100 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 101 |
+
detail="Invalid refresh token"
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
@router.post("/register", response_model=UserResponse)
|
| 105 |
+
async def register(
|
| 106 |
+
request: RegisterRequest,
|
| 107 |
+
current_user: User = Depends(get_current_user)
|
| 108 |
+
):
|
| 109 |
+
"""
|
| 110 |
+
Register new user (admin only)
|
| 111 |
+
"""
|
| 112 |
+
# Only admin can register new users
|
| 113 |
+
await require_admin(current_user)
|
| 114 |
+
|
| 115 |
+
auth_manager = await get_auth_manager()
|
| 116 |
+
try:
|
| 117 |
+
user = await auth_manager.register_user(
|
| 118 |
+
email=request.email,
|
| 119 |
+
password=request.password,
|
| 120 |
+
name=request.name,
|
| 121 |
+
role=request.role
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
return UserResponse(
|
| 125 |
+
id=user.id,
|
| 126 |
+
email=user.email,
|
| 127 |
+
name=user.name,
|
| 128 |
+
role=user.role,
|
| 129 |
+
is_active=user.is_active,
|
| 130 |
+
created_at=user.created_at,
|
| 131 |
+
last_login=user.last_login
|
| 132 |
+
)
|
| 133 |
+
except HTTPException:
|
| 134 |
+
raise
|
| 135 |
+
except Exception as e:
|
| 136 |
+
raise HTTPException(
|
| 137 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 138 |
+
detail=f"Failed to register user: {str(e)}"
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
@router.get("/me", response_model=UserResponse)
|
| 142 |
+
async def get_current_user_info(current_user: User = Depends(get_current_user)):
|
| 143 |
+
"""
|
| 144 |
+
Get current user information
|
| 145 |
+
"""
|
| 146 |
+
return UserResponse(
|
| 147 |
+
id=current_user.id,
|
| 148 |
+
email=current_user.email,
|
| 149 |
+
name=current_user.name,
|
| 150 |
+
role=current_user.role,
|
| 151 |
+
is_active=current_user.is_active,
|
| 152 |
+
created_at=current_user.created_at,
|
| 153 |
+
last_login=current_user.last_login
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
@router.post("/change-password")
|
| 157 |
+
async def change_password(
|
| 158 |
+
request: ChangePasswordRequest,
|
| 159 |
+
current_user: User = Depends(get_current_user)
|
| 160 |
+
):
|
| 161 |
+
"""
|
| 162 |
+
Change current user password
|
| 163 |
+
"""
|
| 164 |
+
auth_manager = await get_auth_manager()
|
| 165 |
+
try:
|
| 166 |
+
success = await auth_manager.change_password(
|
| 167 |
+
user_id=current_user.id,
|
| 168 |
+
old_password=request.old_password,
|
| 169 |
+
new_password=request.new_password
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
if success:
|
| 173 |
+
return {"message": "Password changed successfully"}
|
| 174 |
+
else:
|
| 175 |
+
raise HTTPException(
|
| 176 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 177 |
+
detail="Failed to change password"
|
| 178 |
+
)
|
| 179 |
+
except HTTPException:
|
| 180 |
+
raise
|
| 181 |
+
except Exception as e:
|
| 182 |
+
raise HTTPException(
|
| 183 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 184 |
+
detail=f"Failed to change password: {str(e)}"
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
@router.post("/logout")
|
| 188 |
+
async def logout(
|
| 189 |
+
current_user: User = Depends(get_current_user),
|
| 190 |
+
credentials: HTTPAuthorizationCredentials = Depends(security)
|
| 191 |
+
):
|
| 192 |
+
"""
|
| 193 |
+
Logout user - revoke current token
|
| 194 |
+
"""
|
| 195 |
+
auth_manager = await get_auth_manager()
|
| 196 |
+
await auth_manager.revoke_token(credentials.credentials, "User logout")
|
| 197 |
+
return {"message": "Logged out successfully"}
|
| 198 |
+
|
| 199 |
+
@router.get("/users", response_model=list[UserResponse])
|
| 200 |
+
async def list_users(current_user: User = Depends(get_current_user)):
|
| 201 |
+
"""
|
| 202 |
+
List all users (admin only)
|
| 203 |
+
"""
|
| 204 |
+
await require_admin(current_user)
|
| 205 |
+
|
| 206 |
+
auth_manager = await get_auth_manager()
|
| 207 |
+
users = await auth_manager.get_all_users()
|
| 208 |
+
|
| 209 |
+
return [
|
| 210 |
+
UserResponse(
|
| 211 |
+
id=user.id,
|
| 212 |
+
email=user.email,
|
| 213 |
+
name=user.name,
|
| 214 |
+
role=user.role,
|
| 215 |
+
is_active=user.is_active,
|
| 216 |
+
created_at=user.created_at,
|
| 217 |
+
last_login=user.last_login
|
| 218 |
+
) for user in users
|
| 219 |
+
]
|
| 220 |
+
|
| 221 |
+
@router.post("/users/{user_id}/deactivate")
|
| 222 |
+
async def deactivate_user(
|
| 223 |
+
user_id: str,
|
| 224 |
+
current_user: User = Depends(get_current_user)
|
| 225 |
+
):
|
| 226 |
+
"""
|
| 227 |
+
Deactivate user account (admin only)
|
| 228 |
+
"""
|
| 229 |
+
await require_admin(current_user)
|
| 230 |
+
|
| 231 |
+
# Prevent admin from deactivating themselves
|
| 232 |
+
if user_id == current_user.id:
|
| 233 |
+
raise HTTPException(
|
| 234 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 235 |
+
detail="Cannot deactivate your own account"
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
auth_manager = await get_auth_manager()
|
| 239 |
+
try:
|
| 240 |
+
success = await auth_manager.deactivate_user(user_id)
|
| 241 |
+
if success:
|
| 242 |
+
return {"message": "User deactivated successfully"}
|
| 243 |
+
except HTTPException:
|
| 244 |
+
raise
|
| 245 |
+
except Exception as e:
|
| 246 |
+
raise HTTPException(
|
| 247 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 248 |
+
detail=f"Failed to deactivate user: {str(e)}"
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
@router.post("/verify")
|
| 252 |
+
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
|
| 253 |
+
"""
|
| 254 |
+
Verify if token is valid
|
| 255 |
+
"""
|
| 256 |
+
auth_manager = await get_auth_manager()
|
| 257 |
+
try:
|
| 258 |
+
user = await auth_manager.get_current_user(credentials.credentials)
|
| 259 |
+
return {
|
| 260 |
+
"valid": True,
|
| 261 |
+
"user": {
|
| 262 |
+
"id": user.id,
|
| 263 |
+
"email": user.email,
|
| 264 |
+
"name": user.name,
|
| 265 |
+
"role": user.role
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
except HTTPException:
|
| 269 |
+
return {"valid": False}
|
src/infrastructure/database.py
CHANGED
|
@@ -5,6 +5,7 @@ Suporte para PostgreSQL, Redis Cluster, e cache inteligente
|
|
| 5 |
|
| 6 |
import asyncio
|
| 7 |
import logging
|
|
|
|
| 8 |
from typing import Dict, List, Optional, Any, Union
|
| 9 |
from datetime import datetime, timedelta
|
| 10 |
import json
|
|
@@ -487,6 +488,7 @@ class DatabaseManager:
|
|
| 487 |
|
| 488 |
# Singleton instance
|
| 489 |
_db_manager: Optional[DatabaseManager] = None
|
|
|
|
| 490 |
|
| 491 |
async def get_database_manager() -> DatabaseManager:
|
| 492 |
"""Obter instância singleton do database manager"""
|
|
@@ -500,6 +502,26 @@ async def get_database_manager() -> DatabaseManager:
|
|
| 500 |
|
| 501 |
return _db_manager
|
| 502 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
|
| 504 |
async def cleanup_database():
|
| 505 |
"""Cleanup global do sistema de banco"""
|
|
|
|
| 5 |
|
| 6 |
import asyncio
|
| 7 |
import logging
|
| 8 |
+
import os
|
| 9 |
from typing import Dict, List, Optional, Any, Union
|
| 10 |
from datetime import datetime, timedelta
|
| 11 |
import json
|
|
|
|
| 488 |
|
| 489 |
# Singleton instance
|
| 490 |
_db_manager: Optional[DatabaseManager] = None
|
| 491 |
+
_db_pool: Optional[asyncpg.Pool] = None
|
| 492 |
|
| 493 |
async def get_database_manager() -> DatabaseManager:
|
| 494 |
"""Obter instância singleton do database manager"""
|
|
|
|
| 502 |
|
| 503 |
return _db_manager
|
| 504 |
|
| 505 |
+
async def get_db_pool() -> asyncpg.Pool:
|
| 506 |
+
"""Get PostgreSQL connection pool for direct queries"""
|
| 507 |
+
global _db_pool
|
| 508 |
+
|
| 509 |
+
if _db_pool is None:
|
| 510 |
+
database_url = os.getenv("DATABASE_URL")
|
| 511 |
+
if not database_url:
|
| 512 |
+
raise ValueError("DATABASE_URL environment variable is required")
|
| 513 |
+
|
| 514 |
+
_db_pool = await asyncpg.create_pool(
|
| 515 |
+
database_url,
|
| 516 |
+
min_size=10,
|
| 517 |
+
max_size=20,
|
| 518 |
+
command_timeout=60,
|
| 519 |
+
max_queries=50000,
|
| 520 |
+
max_inactive_connection_lifetime=300
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
return _db_pool
|
| 524 |
+
|
| 525 |
|
| 526 |
async def cleanup_database():
|
| 527 |
"""Cleanup global do sistema de banco"""
|
src/services/auth_service.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication service using PostgreSQL database"""
|
| 2 |
+
|
| 3 |
+
from datetime import datetime, timedelta, timezone
|
| 4 |
+
from typing import Optional, Dict, Any
|
| 5 |
+
from uuid import UUID, uuid4
|
| 6 |
+
import bcrypt
|
| 7 |
+
from jose import JWTError, jwt
|
| 8 |
+
from pydantic import EmailStr
|
| 9 |
+
import asyncpg
|
| 10 |
+
from asyncpg.pool import Pool
|
| 11 |
+
|
| 12 |
+
from src.core.config import settings
|
| 13 |
+
from src.core.exceptions import AuthenticationError, ValidationError
|
| 14 |
+
from src.infrastructure.database import get_db_pool
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class AuthService:
|
| 18 |
+
"""Service for handling authentication with PostgreSQL backend"""
|
| 19 |
+
|
| 20 |
+
def __init__(self):
|
| 21 |
+
self.algorithm = "HS256"
|
| 22 |
+
self.access_token_expire = timedelta(minutes=30)
|
| 23 |
+
self.refresh_token_expire = timedelta(days=7)
|
| 24 |
+
self._pool: Optional[Pool] = None
|
| 25 |
+
|
| 26 |
+
async def get_pool(self) -> Pool:
|
| 27 |
+
"""Get database connection pool"""
|
| 28 |
+
if self._pool is None:
|
| 29 |
+
self._pool = await get_db_pool()
|
| 30 |
+
return self._pool
|
| 31 |
+
|
| 32 |
+
async def create_user(
|
| 33 |
+
self,
|
| 34 |
+
username: str,
|
| 35 |
+
email: EmailStr,
|
| 36 |
+
password: str,
|
| 37 |
+
full_name: Optional[str] = None
|
| 38 |
+
) -> Dict[str, Any]:
|
| 39 |
+
"""Create a new user in the database"""
|
| 40 |
+
# Validate password strength
|
| 41 |
+
if len(password) < 8:
|
| 42 |
+
raise ValidationError("Password must be at least 8 characters long")
|
| 43 |
+
|
| 44 |
+
# Hash password
|
| 45 |
+
password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
|
| 46 |
+
|
| 47 |
+
pool = await self.get_pool()
|
| 48 |
+
|
| 49 |
+
try:
|
| 50 |
+
async with pool.acquire() as conn:
|
| 51 |
+
# Check if user already exists
|
| 52 |
+
existing = await conn.fetchrow(
|
| 53 |
+
"SELECT id FROM users WHERE username = $1 OR email = $2",
|
| 54 |
+
username, email
|
| 55 |
+
)
|
| 56 |
+
if existing:
|
| 57 |
+
raise ValidationError("Username or email already exists")
|
| 58 |
+
|
| 59 |
+
# Create user
|
| 60 |
+
user = await conn.fetchrow("""
|
| 61 |
+
INSERT INTO users (username, email, password_hash, full_name)
|
| 62 |
+
VALUES ($1, $2, $3, $4)
|
| 63 |
+
RETURNING id, username, email, full_name, is_active, is_admin, created_at
|
| 64 |
+
""", username, email, password_hash.decode('utf-8'), full_name)
|
| 65 |
+
|
| 66 |
+
return dict(user)
|
| 67 |
+
|
| 68 |
+
except asyncpg.UniqueViolationError:
|
| 69 |
+
raise ValidationError("Username or email already exists")
|
| 70 |
+
|
| 71 |
+
async def authenticate_user(self, username: str, password: str) -> Optional[Dict[str, Any]]:
|
| 72 |
+
"""Authenticate user with username and password"""
|
| 73 |
+
pool = await self.get_pool()
|
| 74 |
+
|
| 75 |
+
async with pool.acquire() as conn:
|
| 76 |
+
# Get user by username or email
|
| 77 |
+
user = await conn.fetchrow("""
|
| 78 |
+
SELECT id, username, email, password_hash, full_name,
|
| 79 |
+
is_active, is_admin, failed_login_attempts, locked_until
|
| 80 |
+
FROM users
|
| 81 |
+
WHERE username = $1 OR email = $1
|
| 82 |
+
""", username)
|
| 83 |
+
|
| 84 |
+
if not user:
|
| 85 |
+
return None
|
| 86 |
+
|
| 87 |
+
user_dict = dict(user)
|
| 88 |
+
|
| 89 |
+
# Check if account is locked
|
| 90 |
+
if user_dict['locked_until'] and user_dict['locked_until'] > datetime.now(timezone.utc):
|
| 91 |
+
raise AuthenticationError("Account is locked. Please try again later.")
|
| 92 |
+
|
| 93 |
+
# Check if account is active
|
| 94 |
+
if not user_dict['is_active']:
|
| 95 |
+
raise AuthenticationError("Account is deactivated")
|
| 96 |
+
|
| 97 |
+
# Verify password
|
| 98 |
+
if not bcrypt.checkpw(password.encode('utf-8'), user_dict['password_hash'].encode('utf-8')):
|
| 99 |
+
# Increment failed login attempts
|
| 100 |
+
await self._increment_failed_attempts(conn, user_dict['id'])
|
| 101 |
+
return None
|
| 102 |
+
|
| 103 |
+
# Reset failed attempts on successful login
|
| 104 |
+
await conn.execute("""
|
| 105 |
+
UPDATE users
|
| 106 |
+
SET failed_login_attempts = 0,
|
| 107 |
+
locked_until = NULL,
|
| 108 |
+
last_login = $1
|
| 109 |
+
WHERE id = $2
|
| 110 |
+
""", datetime.now(timezone.utc), user_dict['id'])
|
| 111 |
+
|
| 112 |
+
# Remove password hash from return
|
| 113 |
+
user_dict.pop('password_hash')
|
| 114 |
+
return user_dict
|
| 115 |
+
|
| 116 |
+
async def _increment_failed_attempts(self, conn: asyncpg.Connection, user_id: UUID):
|
| 117 |
+
"""Increment failed login attempts and lock account if necessary"""
|
| 118 |
+
result = await conn.fetchrow("""
|
| 119 |
+
UPDATE users
|
| 120 |
+
SET failed_login_attempts = failed_login_attempts + 1
|
| 121 |
+
WHERE id = $1
|
| 122 |
+
RETURNING failed_login_attempts
|
| 123 |
+
""", user_id)
|
| 124 |
+
|
| 125 |
+
# Lock account after 5 failed attempts
|
| 126 |
+
if result['failed_login_attempts'] >= 5:
|
| 127 |
+
locked_until = datetime.now(timezone.utc) + timedelta(minutes=30)
|
| 128 |
+
await conn.execute("""
|
| 129 |
+
UPDATE users
|
| 130 |
+
SET locked_until = $1
|
| 131 |
+
WHERE id = $2
|
| 132 |
+
""", locked_until, user_id)
|
| 133 |
+
|
| 134 |
+
def create_access_token(self, data: Dict[str, Any]) -> str:
|
| 135 |
+
"""Create JWT access token"""
|
| 136 |
+
to_encode = data.copy()
|
| 137 |
+
expire = datetime.now(timezone.utc) + self.access_token_expire
|
| 138 |
+
to_encode.update({
|
| 139 |
+
"exp": expire,
|
| 140 |
+
"type": "access",
|
| 141 |
+
"jti": str(uuid4()) # JWT ID for blacklisting
|
| 142 |
+
})
|
| 143 |
+
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=self.algorithm)
|
| 144 |
+
|
| 145 |
+
def create_refresh_token(self, data: Dict[str, Any]) -> str:
|
| 146 |
+
"""Create JWT refresh token"""
|
| 147 |
+
to_encode = data.copy()
|
| 148 |
+
expire = datetime.now(timezone.utc) + self.refresh_token_expire
|
| 149 |
+
to_encode.update({
|
| 150 |
+
"exp": expire,
|
| 151 |
+
"type": "refresh",
|
| 152 |
+
"jti": str(uuid4()) # JWT ID for blacklisting
|
| 153 |
+
})
|
| 154 |
+
return jwt.encode(to_encode, settings.JWT_SECRET_KEY, algorithm=self.algorithm)
|
| 155 |
+
|
| 156 |
+
async def verify_token(self, token: str, token_type: str = "access") -> Dict[str, Any]:
|
| 157 |
+
"""Verify JWT token and check blacklist"""
|
| 158 |
+
try:
|
| 159 |
+
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[self.algorithm])
|
| 160 |
+
|
| 161 |
+
# Check token type
|
| 162 |
+
if payload.get("type") != token_type:
|
| 163 |
+
raise AuthenticationError("Invalid token type")
|
| 164 |
+
|
| 165 |
+
# Check if token is blacklisted
|
| 166 |
+
if await self._is_token_blacklisted(payload.get("jti")):
|
| 167 |
+
raise AuthenticationError("Token has been revoked")
|
| 168 |
+
|
| 169 |
+
return payload
|
| 170 |
+
|
| 171 |
+
except JWTError:
|
| 172 |
+
raise AuthenticationError("Invalid token")
|
| 173 |
+
|
| 174 |
+
async def _is_token_blacklisted(self, jti: Optional[str]) -> bool:
|
| 175 |
+
"""Check if token JTI is in blacklist"""
|
| 176 |
+
if not jti:
|
| 177 |
+
return False
|
| 178 |
+
|
| 179 |
+
pool = await self.get_pool()
|
| 180 |
+
async with pool.acquire() as conn:
|
| 181 |
+
result = await conn.fetchrow(
|
| 182 |
+
"SELECT id FROM jwt_blacklist WHERE token_jti = $1",
|
| 183 |
+
jti
|
| 184 |
+
)
|
| 185 |
+
return result is not None
|
| 186 |
+
|
| 187 |
+
async def revoke_token(self, token: str, reason: Optional[str] = None):
|
| 188 |
+
"""Add token to blacklist"""
|
| 189 |
+
try:
|
| 190 |
+
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[self.algorithm])
|
| 191 |
+
jti = payload.get("jti")
|
| 192 |
+
if not jti:
|
| 193 |
+
return
|
| 194 |
+
|
| 195 |
+
pool = await self.get_pool()
|
| 196 |
+
async with pool.acquire() as conn:
|
| 197 |
+
await conn.execute("""
|
| 198 |
+
INSERT INTO jwt_blacklist (token_jti, user_id, expires_at, reason)
|
| 199 |
+
VALUES ($1, $2, $3, $4)
|
| 200 |
+
ON CONFLICT (token_jti) DO NOTHING
|
| 201 |
+
""", jti, payload.get("sub"),
|
| 202 |
+
datetime.fromtimestamp(payload.get("exp"), tz=timezone.utc),
|
| 203 |
+
reason)
|
| 204 |
+
|
| 205 |
+
except JWTError:
|
| 206 |
+
pass # Invalid token, ignore
|
| 207 |
+
|
| 208 |
+
async def get_current_user(self, token: str) -> Optional[Dict[str, Any]]:
|
| 209 |
+
"""Get current user from token"""
|
| 210 |
+
payload = await self.verify_token(token)
|
| 211 |
+
user_id = payload.get("sub")
|
| 212 |
+
|
| 213 |
+
if not user_id:
|
| 214 |
+
return None
|
| 215 |
+
|
| 216 |
+
pool = await self.get_pool()
|
| 217 |
+
async with pool.acquire() as conn:
|
| 218 |
+
user = await conn.fetchrow("""
|
| 219 |
+
SELECT id, username, email, full_name, is_active, is_admin, created_at
|
| 220 |
+
FROM users
|
| 221 |
+
WHERE id = $1 AND is_active = true
|
| 222 |
+
""", UUID(user_id))
|
| 223 |
+
|
| 224 |
+
return dict(user) if user else None
|
| 225 |
+
|
| 226 |
+
async def refresh_access_token(self, refresh_token: str) -> Dict[str, str]:
|
| 227 |
+
"""Create new access token from refresh token"""
|
| 228 |
+
payload = await self.verify_token(refresh_token, token_type="refresh")
|
| 229 |
+
|
| 230 |
+
# Get user to ensure they still exist and are active
|
| 231 |
+
user = await self.get_current_user(refresh_token)
|
| 232 |
+
if not user:
|
| 233 |
+
raise AuthenticationError("User not found or inactive")
|
| 234 |
+
|
| 235 |
+
# Create new tokens
|
| 236 |
+
access_token = self.create_access_token({"sub": str(user['id'])})
|
| 237 |
+
new_refresh_token = self.create_refresh_token({"sub": str(user['id'])})
|
| 238 |
+
|
| 239 |
+
# Revoke old refresh token
|
| 240 |
+
await self.revoke_token(refresh_token, "Token refreshed")
|
| 241 |
+
|
| 242 |
+
return {
|
| 243 |
+
"access_token": access_token,
|
| 244 |
+
"refresh_token": new_refresh_token,
|
| 245 |
+
"token_type": "bearer"
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
async def cleanup_expired_tokens(self):
|
| 249 |
+
"""Remove expired tokens from blacklist"""
|
| 250 |
+
pool = await self.get_pool()
|
| 251 |
+
async with pool.acquire() as conn:
|
| 252 |
+
await conn.execute("""
|
| 253 |
+
DELETE FROM jwt_blacklist
|
| 254 |
+
WHERE expires_at < $1
|
| 255 |
+
""", datetime.now(timezone.utc))
|
| 256 |
+
|
| 257 |
+
async def change_password(
|
| 258 |
+
self,
|
| 259 |
+
user_id: UUID,
|
| 260 |
+
current_password: str,
|
| 261 |
+
new_password: str
|
| 262 |
+
) -> bool:
|
| 263 |
+
"""Change user password"""
|
| 264 |
+
if len(new_password) < 8:
|
| 265 |
+
raise ValidationError("Password must be at least 8 characters long")
|
| 266 |
+
|
| 267 |
+
pool = await self.get_pool()
|
| 268 |
+
async with pool.acquire() as conn:
|
| 269 |
+
# Get current password hash
|
| 270 |
+
user = await conn.fetchrow(
|
| 271 |
+
"SELECT password_hash FROM users WHERE id = $1",
|
| 272 |
+
user_id
|
| 273 |
+
)
|
| 274 |
+
|
| 275 |
+
if not user:
|
| 276 |
+
return False
|
| 277 |
+
|
| 278 |
+
# Verify current password
|
| 279 |
+
if not bcrypt.checkpw(current_password.encode('utf-8'),
|
| 280 |
+
user['password_hash'].encode('utf-8')):
|
| 281 |
+
raise AuthenticationError("Current password is incorrect")
|
| 282 |
+
|
| 283 |
+
# Hash new password
|
| 284 |
+
new_hash = bcrypt.hashpw(new_password.encode('utf-8'), bcrypt.gensalt())
|
| 285 |
+
|
| 286 |
+
# Update password
|
| 287 |
+
await conn.execute("""
|
| 288 |
+
UPDATE users
|
| 289 |
+
SET password_hash = $1, updated_at = $2
|
| 290 |
+
WHERE id = $3
|
| 291 |
+
""", new_hash.decode('utf-8'), datetime.now(timezone.utc), user_id)
|
| 292 |
+
|
| 293 |
+
return True
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
# Singleton instance
|
| 297 |
+
auth_service = AuthService()
|