Fix .gitignore and add missing authentication source files
This commit fixes overly broad .gitignore patterns that were excluding important source code files from version control. Previously, wildcard patterns like *auth*, *token*, *secret*, *connection*, and *credential* were excluding ALL files containing these words, including critical application code. Changes: - Updated .gitignore with specific patterns for sensitive config files (*.json, *.txt, *.yml, *.yaml extensions only) - Removed broad wildcards that excluded source code files Added missing source files: - shared/auth/ (9 files): Complete authentication system - JWT handler, middleware, auth service, models, routes - reports-app/backend/app/routers/auth.py: Authentication API router - reports-app/backend/app/auth_middleware_wrapper.py: Middleware wrapper - reports-app/frontend/src/stores/auth.js: Vue.js auth store - reports-app/frontend/tests/: E2E tests and fixtures for auth - reports-app/telegram-bot/app/auth/: Telegram auth linking module - deployment/windows/scripts/Setup-ClaudeAuth.ps1: Windows deployment script - security/secrets_scanner.py: Security scanning utility These files are essential for the application to function and should have been included in the initial commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
78
reports-app/backend/app/auth_middleware_wrapper.py
Normal file
78
reports-app/backend/app/auth_middleware_wrapper.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Wrapper pentru AuthenticationMiddleware cu fix pentru endpoint-urile protejate
|
||||
"""
|
||||
from fastapi import Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
import sys
|
||||
import os
|
||||
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../shared'))
|
||||
|
||||
from auth.middleware import AuthenticationMiddleware
|
||||
from auth.models import AuthError
|
||||
|
||||
|
||||
class FixedAuthenticationMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
Wrapper pentru AuthenticationMiddleware care aplică fix-ul pentru endpoint-urile protejate
|
||||
"""
|
||||
|
||||
def __init__(self, app, **kwargs):
|
||||
super().__init__(app)
|
||||
# Create the original middleware instance without wrapping in BaseHTTPMiddleware
|
||||
self.auth_middleware = AuthenticationMiddleware(app, **kwargs)
|
||||
print("[FIXED MIDDLEWARE] FixedAuthenticationMiddleware initialized")
|
||||
print(f"[FIXED MIDDLEWARE] Original middleware type: {type(self.auth_middleware)}")
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
"""
|
||||
Aplică fix-ul pentru endpoint-urile protejate:
|
||||
- Returnează 401 pentru căile protejate fără token în loc să seteze request.state
|
||||
"""
|
||||
path = request.url.path
|
||||
print(f"[FIXED MIDDLEWARE] Processing path: {path}")
|
||||
|
||||
# Verifică dacă path-ul trebuie exclus
|
||||
excluded_paths = ["/docs", "/health", "/api/auth/login", "/redoc", "/openapi.json"]
|
||||
is_excluded = (path == "/" or any(path.startswith(excluded) for excluded in excluded_paths))
|
||||
print(f"[FIXED MIDDLEWARE] Checking exclusions for {path}")
|
||||
print(f"[FIXED MIDDLEWARE] Excluded paths: {excluded_paths}")
|
||||
print(f"[FIXED MIDDLEWARE] Is excluded: {is_excluded}")
|
||||
|
||||
if is_excluded:
|
||||
print(f"[FIXED MIDDLEWARE] Path {path} is excluded, skipping auth")
|
||||
request.state.user = None
|
||||
request.state.is_authenticated = False
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
# Extrage token-ul
|
||||
authorization = request.headers.get("Authorization")
|
||||
print(f"[FIXED MIDDLEWARE] Authorization header: {authorization}")
|
||||
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
print(f"[FIXED MIDDLEWARE] No valid token for protected path {path}, returning 401")
|
||||
|
||||
error = AuthError(
|
||||
error="authentication_required",
|
||||
error_description="Authentication required",
|
||||
error_code="AUTH_003"
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content=error.dict(),
|
||||
headers={"WWW-Authenticate": "Bearer"}
|
||||
)
|
||||
|
||||
# Token există, să îl validez prin middleware-ul original
|
||||
print(f"[FIXED MIDDLEWARE] Token found, delegating to original middleware")
|
||||
try:
|
||||
result = await self.auth_middleware.dispatch(request, call_next)
|
||||
print(f"[FIXED MIDDLEWARE] Original middleware returned: {type(result)}")
|
||||
print(f"[FIXED MIDDLEWARE] Request state after middleware: user={getattr(request.state, 'user', 'MISSING')}, is_authenticated={getattr(request.state, 'is_authenticated', 'MISSING')}")
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"[FIXED MIDDLEWARE] Exception in original middleware: {str(e)}")
|
||||
raise
|
||||
109
reports-app/backend/app/routers/auth.py
Normal file
109
reports-app/backend/app/routers/auth.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
API Router pentru autentificare - Wrapper peste shared auth
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.join(os.path.dirname(__file__), '../../../../shared'))
|
||||
|
||||
from auth.dependencies import get_current_user
|
||||
from auth.models import LoginRequest, TokenResponse, CurrentUser
|
||||
from auth.auth_service import auth_service
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
security = HTTPBearer()
|
||||
|
||||
class LogoutResponse(BaseModel):
|
||||
"""Răspuns pentru logout"""
|
||||
message: str
|
||||
success: bool
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(login_request: LoginRequest):
|
||||
"""
|
||||
Autentificare utilizator cu username și parola
|
||||
|
||||
Folosește shared auth service pentru validarea credențialelor
|
||||
și generarea token-urilor JWT
|
||||
"""
|
||||
try:
|
||||
# Folosește shared auth service pentru autentificare
|
||||
success, token_response, error_message = await auth_service.authenticate_and_create_tokens(
|
||||
username=login_request.username,
|
||||
password=login_request.password
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=error_message or "Authentication failed",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return token_response
|
||||
|
||||
except HTTPException:
|
||||
raise # Re-raise HTTP exceptions as-is
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Internal authentication error"
|
||||
)
|
||||
|
||||
@router.post("/logout", response_model=LogoutResponse)
|
||||
async def logout(current_user: CurrentUser = Depends(get_current_user)):
|
||||
"""
|
||||
Logout utilizator
|
||||
|
||||
Pentru moment doar confirmă logout-ul (token-urile JWT nu sunt invalidate server-side)
|
||||
În viitor poate fi extins cu blacklist de token-uri
|
||||
"""
|
||||
try:
|
||||
return LogoutResponse(
|
||||
message=f"Utilizatorul {current_user.username} a fost deconectat cu succes",
|
||||
success=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Eroare la logout: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/me", response_model=CurrentUser)
|
||||
async def get_current_user_info(current_user: CurrentUser = Depends(get_current_user)):
|
||||
"""
|
||||
Obține informațiile utilizatorului curent
|
||||
|
||||
Returnează datele utilizatorului din token-ul JWT
|
||||
"""
|
||||
return current_user
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh_token(refresh_token: str):
|
||||
"""
|
||||
Reîmprospătează token-ul de acces folosind refresh token-ul
|
||||
|
||||
Această funcție va fi implementată în viitor pentru gestionarea
|
||||
completă a ciclului de viață al token-urilor
|
||||
"""
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Refresh token nu este încă implementat"
|
||||
)
|
||||
|
||||
@router.get("/validate")
|
||||
async def validate_token(current_user: CurrentUser = Depends(get_current_user)):
|
||||
"""
|
||||
Validează token-ul curent
|
||||
|
||||
Endpoint util pentru frontend să verifice dacă token-ul este încă valid
|
||||
"""
|
||||
return {
|
||||
"valid": True,
|
||||
"user": current_user.username,
|
||||
"companies": current_user.companies,
|
||||
"message": "Token valid"
|
||||
}
|
||||
116
reports-app/frontend/src/stores/auth.js
Normal file
116
reports-app/frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, computed } from "vue";
|
||||
import { apiService } from "../services/api";
|
||||
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
// State
|
||||
const accessToken = ref(localStorage.getItem("access_token"));
|
||||
const refreshToken = ref(localStorage.getItem("refresh_token"));
|
||||
const user = ref(JSON.parse(localStorage.getItem("user") || "null"));
|
||||
const isLoading = ref(false);
|
||||
const error = ref(null);
|
||||
|
||||
// Getters
|
||||
const isAuthenticated = computed(() => !!accessToken.value);
|
||||
const currentUser = computed(() => user.value);
|
||||
|
||||
// Actions
|
||||
const login = async (credentials) => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const loginData = {
|
||||
username: credentials.username,
|
||||
password: credentials.password,
|
||||
};
|
||||
|
||||
const response = await apiService.post("/auth/login", loginData);
|
||||
const { access_token, refresh_token, user: userData } = response.data;
|
||||
|
||||
accessToken.value = access_token;
|
||||
refreshToken.value = refresh_token;
|
||||
user.value = userData;
|
||||
|
||||
localStorage.setItem("access_token", access_token);
|
||||
localStorage.setItem("refresh_token", refresh_token);
|
||||
localStorage.setItem("user", JSON.stringify(userData));
|
||||
|
||||
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.detail || "Login failed";
|
||||
return { success: false, error: error.value };
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
accessToken.value = null;
|
||||
refreshToken.value = null;
|
||||
user.value = null;
|
||||
error.value = null;
|
||||
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
// Note: selected_company is now per-user and persists across logout/login
|
||||
// It's stored as 'selected_company_${username}' in localStorage
|
||||
|
||||
delete apiService.defaults.headers.common["Authorization"];
|
||||
};
|
||||
|
||||
const refreshAccessToken = async () => {
|
||||
if (!refreshToken.value) {
|
||||
logout();
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiService.post("/auth/refresh", {
|
||||
refresh_token: refreshToken.value,
|
||||
});
|
||||
|
||||
const { access_token } = response.data;
|
||||
|
||||
accessToken.value = access_token;
|
||||
localStorage.setItem("access_token", access_token);
|
||||
apiService.defaults.headers.common["Authorization"] = `Bearer ${access_token}`;
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("Token refresh failed:", err);
|
||||
logout();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const initializeAuth = () => {
|
||||
if (accessToken.value) {
|
||||
apiService.defaults.headers.common["Authorization"] = `Bearer ${accessToken.value}`;
|
||||
}
|
||||
};
|
||||
|
||||
const clearError = () => {
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user,
|
||||
isLoading,
|
||||
error,
|
||||
isAuthenticated,
|
||||
currentUser,
|
||||
login,
|
||||
logout,
|
||||
refreshAccessToken,
|
||||
initializeAuth,
|
||||
clearError,
|
||||
};
|
||||
});
|
||||
223
reports-app/frontend/tests/e2e/auth/login.spec.js
Normal file
223
reports-app/frontend/tests/e2e/auth/login.spec.js
Normal file
@@ -0,0 +1,223 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { LoginPage } from '../../page-objects/LoginPage.js';
|
||||
import { testCredentials } from '../../fixtures/auth.js';
|
||||
|
||||
test.describe('Authentication - Login Flow', () => {
|
||||
let loginPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
loginPage = new LoginPage(page);
|
||||
await loginPage.navigate();
|
||||
});
|
||||
|
||||
test('should display login page correctly', async ({ page }) => {
|
||||
// Check page title and main elements
|
||||
await expect(page).toHaveTitle(/ROA Reports/);
|
||||
|
||||
// Check login form elements are visible
|
||||
await expect(page.locator(loginPage.usernameInput)).toBeVisible();
|
||||
await expect(page.locator(loginPage.passwordInput)).toBeVisible();
|
||||
await expect(page.locator(loginPage.loginButton)).toBeVisible();
|
||||
|
||||
// Check page title
|
||||
const title = await loginPage.getPageTitle();
|
||||
expect(title).toContain('ROA Reports');
|
||||
});
|
||||
|
||||
test('should show validation errors for empty fields', async ({ page }) => {
|
||||
// Check initial state - button should be disabled
|
||||
expect(await loginPage.isLoginButtonDisabled()).toBe(true);
|
||||
|
||||
// Clear any existing content and verify empty state
|
||||
await page.fill(loginPage.usernameInput, '');
|
||||
await page.fill(loginPage.passwordInput, '');
|
||||
await page.click(loginPage.loginCard); // Click outside to trigger validation
|
||||
|
||||
// Wait for Vue reactivity
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Button should remain disabled with empty fields
|
||||
expect(await loginPage.isLoginButtonDisabled()).toBe(true);
|
||||
|
||||
// Verify form validation classes are applied
|
||||
const hasInvalidFields = await loginPage.hasInvalidField();
|
||||
// Note: validation might not show invalid state until user interaction
|
||||
});
|
||||
|
||||
test('should show validation error for empty username', async ({ page }) => {
|
||||
// Fill only password
|
||||
await loginPage.fillCredentials('', 'password123');
|
||||
|
||||
// Trigger validation by clicking outside
|
||||
await page.click(loginPage.loginCard);
|
||||
|
||||
// Check that login button is disabled
|
||||
expect(await loginPage.isLoginButtonDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test('should show validation error for empty password', async ({ page }) => {
|
||||
// Fill only username
|
||||
await loginPage.fillCredentials('username', '');
|
||||
|
||||
// Trigger validation by clicking outside
|
||||
await page.click(loginPage.loginCard);
|
||||
|
||||
// Check that login button is disabled
|
||||
expect(await loginPage.isLoginButtonDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test('should enable login button with valid input', async ({ page: _page }) => {
|
||||
// Fill both fields
|
||||
await loginPage.fillCredentials('testuser', 'testpass');
|
||||
|
||||
// Check that login button is enabled
|
||||
expect(await loginPage.isLoginButtonDisabled()).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle invalid credentials', async ({ page }) => {
|
||||
// Mock the API response for invalid login
|
||||
await page.route('**/api/auth/login', async route => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
detail: 'Invalid credentials'
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Attempt login with invalid credentials
|
||||
await loginPage.login(testCredentials.invalid.username, testCredentials.invalid.password);
|
||||
|
||||
// Wait for response
|
||||
await loginPage.waitForLoginResult();
|
||||
|
||||
// Check that we're still on login page
|
||||
expect(await loginPage.isOnLoginPage()).toBe(true);
|
||||
|
||||
// Check that error message appears (via toast or error div)
|
||||
// Note: Error might be shown via PrimeVue Toast, so we need to check for toast messages
|
||||
const toastError = page.locator('.p-toast-message-error');
|
||||
if (await toastError.isVisible()) {
|
||||
const errorText = await toastError.textContent();
|
||||
expect(errorText).toContain('Eroare');
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle successful login', async ({ page }) => {
|
||||
// Mock the API response for successful login
|
||||
await page.route('**/api/auth/login', async route => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
access_token: 'mock_access_token',
|
||||
refresh_token: 'mock_refresh_token',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
full_name: 'Test User'
|
||||
}
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Mock companies endpoint for dashboard
|
||||
await page.route('**/api/companies', async route => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify([
|
||||
{ code: 'COMP1', name: 'Company 1' },
|
||||
{ code: 'COMP2', name: 'Company 2' }
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
// Attempt login with valid credentials
|
||||
await loginPage.login(testCredentials.valid.username, testCredentials.valid.password);
|
||||
|
||||
// Wait for redirect to dashboard
|
||||
await page.waitForURL('/dashboard', { timeout: 10000 });
|
||||
|
||||
// Check that we're on dashboard page
|
||||
expect(page.url()).toContain('/dashboard');
|
||||
});
|
||||
|
||||
test('should show loading state during login', async ({ page }) => {
|
||||
// Mock slow API response
|
||||
await page.route('**/api/auth/login', async route => {
|
||||
// Delay the response to see loading state
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
access_token: 'mock_access_token',
|
||||
refresh_token: 'mock_refresh_token',
|
||||
user: { id: 1, username: 'testuser' }
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// Fill credentials and submit
|
||||
await loginPage.fillCredentials(testCredentials.valid.username, testCredentials.valid.password);
|
||||
await loginPage.clickLogin();
|
||||
|
||||
// Check loading state appears
|
||||
await expect(page.locator(loginPage.loadingSpinner)).toBeVisible();
|
||||
|
||||
// Wait for loading to finish
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('should handle network errors gracefully', async ({ page }) => {
|
||||
// Mock network error
|
||||
await page.route('**/api/auth/login', async route => {
|
||||
await route.abort('failed');
|
||||
});
|
||||
|
||||
// Attempt login
|
||||
await loginPage.login(testCredentials.valid.username, testCredentials.valid.password);
|
||||
|
||||
// Wait a bit for error handling
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Should still be on login page
|
||||
expect(await loginPage.isOnLoginPage()).toBe(true);
|
||||
|
||||
// Check for error message in toast summary or detail
|
||||
const toastSummary = page.locator('.p-toast-summary');
|
||||
const toastDetail = page.locator('.p-toast-detail');
|
||||
|
||||
// Check if either summary or detail contains error text
|
||||
const summaryVisible = await toastSummary.isVisible();
|
||||
const detailVisible = await toastDetail.isVisible();
|
||||
|
||||
if (summaryVisible || detailVisible) {
|
||||
let errorFound = false;
|
||||
|
||||
if (summaryVisible) {
|
||||
const summaryText = await toastSummary.textContent();
|
||||
if (summaryText && summaryText.toLowerCase().includes('eroare')) {
|
||||
errorFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (detailVisible) {
|
||||
const detailText = await toastDetail.textContent();
|
||||
if (detailText && detailText.toLowerCase().includes('eroare')) {
|
||||
errorFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
expect(errorFound).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('should focus username field on page load', async ({ page }) => {
|
||||
// Check that username field is focused
|
||||
const focusedElement = await page.locator(':focus');
|
||||
await expect(focusedElement).toHaveAttribute('id', 'username');
|
||||
});
|
||||
});
|
||||
60
reports-app/frontend/tests/fixtures/auth.js
vendored
Normal file
60
reports-app/frontend/tests/fixtures/auth.js
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
export const testCredentials = {
|
||||
valid: {
|
||||
username: 'testuser',
|
||||
password: 'testpass123'
|
||||
},
|
||||
invalid: {
|
||||
username: 'wronguser',
|
||||
password: 'wrongpass'
|
||||
},
|
||||
empty: {
|
||||
username: '',
|
||||
password: ''
|
||||
},
|
||||
partialValid: {
|
||||
username: 'testuser',
|
||||
password: ''
|
||||
}
|
||||
};
|
||||
|
||||
export const expectedMessages = {
|
||||
loginSuccess: 'Conectare reușită',
|
||||
loginError: 'Eroare de conectare',
|
||||
usernameRequired: 'Numele de utilizator este obligatoriu',
|
||||
passwordRequired: 'Parola este obligatorie',
|
||||
invalidCredentials: 'Date de conectare incorecte'
|
||||
};
|
||||
|
||||
export const apiEndpoints = {
|
||||
login: '/api/auth/login',
|
||||
logout: '/api/auth/logout',
|
||||
refresh: '/api/auth/refresh',
|
||||
user: '/api/auth/user'
|
||||
};
|
||||
|
||||
export const mockApiResponses = {
|
||||
loginSuccess: {
|
||||
status: 200,
|
||||
body: {
|
||||
access_token: 'mock_access_token',
|
||||
refresh_token: 'mock_refresh_token',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
full_name: 'Test User'
|
||||
}
|
||||
}
|
||||
},
|
||||
loginError: {
|
||||
status: 401,
|
||||
body: {
|
||||
detail: 'Invalid credentials'
|
||||
}
|
||||
},
|
||||
unauthorized: {
|
||||
status: 401,
|
||||
body: {
|
||||
detail: 'Not authenticated'
|
||||
}
|
||||
}
|
||||
};
|
||||
0
reports-app/telegram-bot/app/auth/__init__.py
Normal file
0
reports-app/telegram-bot/app/auth/__init__.py
Normal file
350
reports-app/telegram-bot/app/auth/linking.py
Normal file
350
reports-app/telegram-bot/app/auth/linking.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""
|
||||
Authentication and User Linking Logic
|
||||
|
||||
This module handles the linking process between Telegram users and Oracle ERP accounts.
|
||||
It manages authentication codes, verifies users through the backend API, and maintains
|
||||
user sessions with JWT tokens.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from telegram import User as TelegramUser
|
||||
|
||||
from app.db.operations import (
|
||||
get_user,
|
||||
create_or_update_user,
|
||||
link_user_to_oracle,
|
||||
update_user_tokens,
|
||||
verify_and_use_auth_code,
|
||||
is_user_linked
|
||||
)
|
||||
from app.api.client import get_backend_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def link_telegram_account(
|
||||
telegram_user: TelegramUser,
|
||||
auth_code: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Link a Telegram account to an Oracle ERP account using an authentication code.
|
||||
|
||||
Flow:
|
||||
1. Verify auth code in database (check exists, not used, not expired)
|
||||
2. Extract oracle_username from code
|
||||
3. Call backend API to verify user in Oracle and get JWT token
|
||||
4. Create/update Telegram user record
|
||||
5. Link user to Oracle account with JWT tokens
|
||||
6. Return success with user data
|
||||
|
||||
Args:
|
||||
telegram_user: Telegram User object from python-telegram-bot
|
||||
auth_code: 8-character authentication code from web frontend
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- success: True if linking succeeded
|
||||
- username: Oracle username
|
||||
- jwt_token: JWT access token
|
||||
- companies: List of companies user has access to
|
||||
OR None if linking failed
|
||||
|
||||
Example:
|
||||
result = await link_telegram_account(telegram_user, "ABC12345")
|
||||
if result:
|
||||
print(f"Linked to {result['username']}")
|
||||
else:
|
||||
print("Linking failed")
|
||||
"""
|
||||
try:
|
||||
telegram_user_id = telegram_user.id
|
||||
telegram_username = telegram_user.username
|
||||
first_name = telegram_user.first_name
|
||||
last_name = telegram_user.last_name
|
||||
|
||||
logger.info(
|
||||
f"Attempting to link Telegram user {telegram_user_id} "
|
||||
f"(@{telegram_username}) with code {auth_code}"
|
||||
)
|
||||
|
||||
# Step 1: Verify auth code
|
||||
code_data = await verify_and_use_auth_code(auth_code)
|
||||
|
||||
if not code_data:
|
||||
logger.warning(f"Invalid, expired, or already used auth code: {auth_code}")
|
||||
return None
|
||||
|
||||
oracle_username = code_data.get('oracle_username')
|
||||
logger.info(f"Auth code valid for Oracle user: {oracle_username}")
|
||||
|
||||
# Step 2: Create/update Telegram user record (basic info)
|
||||
user_created = await create_or_update_user(
|
||||
telegram_user_id=telegram_user_id,
|
||||
username=telegram_username,
|
||||
first_name=first_name,
|
||||
last_name=last_name
|
||||
)
|
||||
|
||||
if not user_created:
|
||||
logger.error(f"Failed to create/update Telegram user {telegram_user_id}")
|
||||
return None
|
||||
|
||||
# Step 3: Verify user in Oracle and get JWT token via backend API (auto-linking flow)
|
||||
backend_client = get_backend_client()
|
||||
async with backend_client:
|
||||
user_data = await backend_client.verify_user(
|
||||
oracle_username=oracle_username,
|
||||
linking_code=auth_code
|
||||
)
|
||||
|
||||
if not user_data or not user_data.get('success'):
|
||||
logger.error(f"Failed to verify Oracle user {oracle_username} via backend")
|
||||
return None
|
||||
|
||||
# Extract tokens and user info from response
|
||||
jwt_token = user_data.get('access_token')
|
||||
jwt_refresh_token = user_data.get('refresh_token', jwt_token)
|
||||
user_info = user_data.get('user', {})
|
||||
companies = user_info.get('companies', [])
|
||||
permissions = user_info.get('permissions', [])
|
||||
|
||||
# Token expiration (typically 30 minutes for access token)
|
||||
token_expires_at = datetime.now() + timedelta(minutes=30)
|
||||
|
||||
# Step 4: Link Telegram user to Oracle account
|
||||
linked = await link_user_to_oracle(
|
||||
telegram_user_id=telegram_user_id,
|
||||
oracle_username=oracle_username,
|
||||
jwt_token=jwt_token,
|
||||
jwt_refresh_token=jwt_refresh_token,
|
||||
token_expires_at=token_expires_at
|
||||
)
|
||||
|
||||
if not linked:
|
||||
logger.error(f"Failed to link user {telegram_user_id} to Oracle account")
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f"Successfully linked Telegram user {telegram_user_id} "
|
||||
f"to Oracle user {oracle_username}"
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"telegram_user_id": telegram_user_id,
|
||||
"username": oracle_username,
|
||||
"jwt_token": jwt_token,
|
||||
"jwt_refresh_token": jwt_refresh_token,
|
||||
"companies": companies,
|
||||
"permissions": permissions,
|
||||
"linked_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error linking Telegram account: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def get_user_auth_data(telegram_user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get authentication data for a linked Telegram user.
|
||||
|
||||
This function retrieves the user's Oracle account information and JWT tokens.
|
||||
If the token is expired, it automatically refreshes it.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- telegram_user_id: Telegram user ID
|
||||
- username: Oracle username
|
||||
- jwt_token: Valid JWT access token (refreshed if needed)
|
||||
- jwt_refresh_token: JWT refresh token
|
||||
- companies: List of companies (fetched if not cached)
|
||||
OR None if user is not linked or error occurred
|
||||
|
||||
Example:
|
||||
auth_data = await get_user_auth_data(12345)
|
||||
if auth_data:
|
||||
jwt = auth_data['jwt_token']
|
||||
# Use JWT for API calls
|
||||
"""
|
||||
try:
|
||||
# Get user from database
|
||||
user_data = await get_user(telegram_user_id)
|
||||
|
||||
if not user_data:
|
||||
logger.warning(f"User {telegram_user_id} not found in database")
|
||||
return None
|
||||
|
||||
if not user_data.get('oracle_username'):
|
||||
logger.warning(f"User {telegram_user_id} is not linked to Oracle account")
|
||||
return None
|
||||
|
||||
oracle_username = user_data['oracle_username']
|
||||
jwt_token = user_data['jwt_token']
|
||||
jwt_refresh_token = user_data['jwt_refresh_token']
|
||||
token_expires_at_str = user_data['token_expires_at']
|
||||
|
||||
# Parse token expiration
|
||||
token_expires_at = datetime.fromisoformat(token_expires_at_str) if token_expires_at_str else None
|
||||
|
||||
# Check if token is expired or about to expire (< 5 minutes remaining)
|
||||
token_expired = (
|
||||
token_expires_at is None or
|
||||
datetime.now() >= token_expires_at - timedelta(minutes=5)
|
||||
)
|
||||
|
||||
if token_expired:
|
||||
logger.info(f"Token expired for user {telegram_user_id}, refreshing...")
|
||||
|
||||
# Refresh token via backend API
|
||||
backend_client = get_backend_client()
|
||||
async with backend_client:
|
||||
new_token = await backend_client.refresh_token(jwt_refresh_token)
|
||||
|
||||
if new_token:
|
||||
# Update token in database
|
||||
new_expires_at = datetime.now() + timedelta(minutes=30)
|
||||
await update_user_tokens(
|
||||
telegram_user_id=telegram_user_id,
|
||||
jwt_token=new_token,
|
||||
jwt_refresh_token=jwt_refresh_token, # Keep same refresh token
|
||||
token_expires_at=new_expires_at
|
||||
)
|
||||
|
||||
jwt_token = new_token
|
||||
logger.info(f"Token refreshed for user {telegram_user_id}")
|
||||
else:
|
||||
logger.error(f"Failed to refresh token for user {telegram_user_id}")
|
||||
return None
|
||||
|
||||
# Fetch user companies (fresh from backend)
|
||||
backend_client = get_backend_client()
|
||||
async with backend_client:
|
||||
companies = await backend_client.get_user_companies(jwt_token)
|
||||
|
||||
return {
|
||||
"telegram_user_id": telegram_user_id,
|
||||
"username": oracle_username,
|
||||
"jwt_token": jwt_token,
|
||||
"jwt_refresh_token": jwt_refresh_token,
|
||||
"companies": companies
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user auth data: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def check_user_linked(telegram_user_id: int) -> bool:
|
||||
"""
|
||||
Check if a Telegram user is linked to an Oracle account.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
bool: True if user is linked, False otherwise
|
||||
|
||||
Example:
|
||||
if await check_user_linked(12345):
|
||||
print("User is linked")
|
||||
else:
|
||||
print("User needs to link account")
|
||||
"""
|
||||
try:
|
||||
return await is_user_linked(telegram_user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking if user is linked: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def get_user_companies(telegram_user_id: int) -> Optional[list]:
|
||||
"""
|
||||
Get list of companies a user has access to.
|
||||
|
||||
This is a convenience function that fetches user auth data and returns
|
||||
just the companies list.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
List of company dicts, or None if user not linked
|
||||
|
||||
Example:
|
||||
companies = await get_user_companies(12345)
|
||||
if companies:
|
||||
for company in companies:
|
||||
print(f"{company['id']}: {company['nume_firma']}")
|
||||
"""
|
||||
try:
|
||||
auth_data = await get_user_auth_data(telegram_user_id)
|
||||
|
||||
if auth_data:
|
||||
return auth_data.get('companies', [])
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting user companies: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def unlink_user(telegram_user_id: int) -> bool:
|
||||
"""
|
||||
Unlink a Telegram user from their Oracle account.
|
||||
|
||||
This removes the linking but keeps the Telegram user record.
|
||||
Used for account disconnection or security purposes.
|
||||
|
||||
Args:
|
||||
telegram_user_id: Telegram user ID
|
||||
|
||||
Returns:
|
||||
bool: True if successfully unlinked
|
||||
|
||||
Example:
|
||||
if await unlink_user(12345):
|
||||
print("Account unlinked")
|
||||
"""
|
||||
try:
|
||||
# Set Oracle username and tokens to NULL
|
||||
from app.db.database import DB_PATH
|
||||
import aiosqlite
|
||||
|
||||
async with aiosqlite.connect(DB_PATH) as db:
|
||||
db.row_factory = aiosqlite.Row
|
||||
await db.execute("""
|
||||
UPDATE telegram_users
|
||||
SET oracle_username = NULL,
|
||||
jwt_token = NULL,
|
||||
jwt_refresh_token = NULL,
|
||||
token_expires_at = NULL,
|
||||
linked_at = NULL
|
||||
WHERE telegram_user_id = ?
|
||||
""", (telegram_user_id,))
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(f"User {telegram_user_id} unlinked from Oracle account")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error unlinking user {telegram_user_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Export main functions
|
||||
__all__ = [
|
||||
'link_telegram_account',
|
||||
'get_user_auth_data',
|
||||
'check_user_linked',
|
||||
'get_user_companies',
|
||||
'unlink_user'
|
||||
]
|
||||
Reference in New Issue
Block a user