diff --git a/README.md b/README.md index 6eed52c..b556b7b 100644 --- a/README.md +++ b/README.md @@ -148,3 +148,18 @@ Usunięcie użytkownika jest dostępne w panelu administracyjnym. System nie poz Panel **Administracja → Firmy i karty** używa listy firm z wyszukiwaniem i paginacją. Po wybraniu firmy po prawej stronie wyświetlane są tylko jej ustawienia, karty paliwowe i ulubione stacje. Dzięki temu liczba formularzy w DOM nie rośnie wraz z liczbą firm. W **Predefiniowanych stacjach paliw** administrator może wskazać firmę bezpośrednio w ustawieniach każdej pozycji. Zmiana firmy przeładowuje kontekst tabeli i pokazuje właściwe ulubione, dozwolone stacje oraz warunki kart. + +## Uwierzytelnianie API bez cookie + +Integracje zewnętrzne mogą pobrać czasowy token Bearer przez `POST /api/auth/token`. +Endpoint przyjmuje formularz OAuth2 (`username` = e-mail, `password`) lub JSON (`email`, `password`). +Następnie token należy wysyłać w nagłówku: + +```text +Authorization: Bearer +``` + +Swagger UI pod `/docs` obsługuje ten mechanizm przez przycisk **Authorize** i schemat `oauth2Password`. +Domyślna ważność tokenu wynosi 8 godzin i może być zmieniona przez `API_TOKEN_MAX_AGE_SECONDS`. +Zmiana hasła użytkownika automatycznie unieważnia wcześniej wydane tokeny. +W środowisku produkcyjnym ustaw mocny, losowy `SECRET_KEY` i korzystaj wyłącznie z HTTPS. diff --git a/app/__init__.py b/app/__init__.py index a57f9fe..3861724 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -58,11 +58,29 @@ def create_app(test_config=None): response.headers["Cache-Control"] = "public, max-age=2592000, immutable" elif response.mimetype == "text/html": response.headers["Cache-Control"] = "no-store, no-cache, private, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" return response @login_manager.user_loader def load_user(user_id): return db.session.get(User, int(user_id)) + @login_manager.request_loader + def load_user_from_request(req): + header = req.headers.get("Authorization", "") + scheme, _, token = header.partition(" ") + if scheme.lower() != "bearer" or not token.strip(): + return None + from .api_tokens import load_access_token + return load_access_token(token.strip()) + + @login_manager.unauthorized_handler + def unauthorized(): + if request.path.startswith("/api/"): + return {"ok": False, "message": "Wymagane uwierzytelnienie Bearer lub aktywna sesja"}, 401 + from flask import redirect + return redirect(url_for("auth.login", next=request.url)) + @app.context_processor def inject_theme(): themes = _themes(app) diff --git a/app/api.py b/app/api.py index d2a5b05..195fa3a 100644 --- a/app/api.py +++ b/app/api.py @@ -2,7 +2,7 @@ from collections import defaultdict from datetime import datetime, date from decimal import Decimal from functools import wraps -from flask import Blueprint, jsonify, request, url_for +from flask import Blueprint, jsonify, request, url_for, current_app from flask_login import current_user, login_required, login_user, logout_user from sqlalchemy import extract from sqlalchemy.exc import IntegrityError @@ -11,6 +11,7 @@ from .models import User, Vehicle, FuelEntry, CompanySettings, AppSetting, Orlen from .services import calculate_costs, fetch_orlen_price, fetch_orlen_range, invoice_period, fetch_ure_stations, aggregate_ure_companies, POLISH_REGIONS from .station_catalog import sync_station_catalog from . import THEMES +from .api_tokens import create_access_token api = Blueprint('api', __name__, url_prefix='/api') FUEL_TYPES = ('PB95','PB98','DIESEL','LPG') @@ -57,6 +58,21 @@ def login(): if not user or not user.check_password(d.get('password') or '') or not user.active: return fail('Nieprawidłowy e-mail lub hasło',401) login_user(user); return response({'user':user_dict(user),'redirect':url_for('main.dashboard')},'Zalogowano') +@api.post('/auth/token') +def token_login(): + d = payload() + email = (d.get('email') or d.get('username') or '').strip().lower() + user = User.query.filter_by(email=email).first() + if not user or not user.check_password(d.get('password') or '') or not user.active: + return jsonify({'error':'invalid_grant','error_description':'Nieprawidłowy e-mail lub hasło'}), 401 + expires_in = int(current_app.config['API_TOKEN_MAX_AGE_SECONDS']) + return jsonify({ + 'access_token': create_access_token(user), + 'token_type': 'Bearer', + 'expires_in': expires_in, + 'user': user_dict(user), + }) + @api.post('/auth/logout') @login_required def logout(): logout_user(); return response({'redirect':url_for('auth.login')},'Wylogowano') diff --git a/app/api_tokens.py b/app/api_tokens.py new file mode 100644 index 0000000..a233ec2 --- /dev/null +++ b/app/api_tokens.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import hashlib +from typing import Optional + +from flask import current_app +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer + +from .extensions import db +from .models import User + +TOKEN_SALT = "fueltrack-api-access-token-v1" + + +def _serializer() -> URLSafeTimedSerializer: + return URLSafeTimedSerializer(current_app.config["SECRET_KEY"], salt=TOKEN_SALT) + + +def _password_stamp(user: User) -> str: + """Unieważnia token po zmianie hasła bez przechowywania tokenów w bazie.""" + return hashlib.sha256(user.password_hash.encode("utf-8")).hexdigest()[:16] + + +def create_access_token(user: User) -> str: + return _serializer().dumps({"uid": user.id, "pwd": _password_stamp(user)}) + + +def load_access_token(token: str) -> Optional[User]: + try: + payload = _serializer().loads( + token, + max_age=int(current_app.config["API_TOKEN_MAX_AGE_SECONDS"]), + ) + user = db.session.get(User, int(payload["uid"])) + except (BadSignature, SignatureExpired, KeyError, TypeError, ValueError): + return None + + if not user or not user.active: + return None + if payload.get("pwd") != _password_stamp(user): + return None + return user diff --git a/app/config.py b/app/config.py index 67b4375..9f28684 100644 --- a/app/config.py +++ b/app/config.py @@ -35,6 +35,7 @@ class Config: SECRET_KEY = env("SECRET_KEY", "dev-secret") SQLALCHEMY_DATABASE_URI = env("DATABASE_URL", "sqlite:////app/instance/fueltrack.db") SQLALCHEMY_TRACK_MODIFICATIONS = False + API_TOKEN_MAX_AGE_SECONDS = env_int("API_TOKEN_MAX_AGE_SECONDS", 28800) HTTP_TIMEOUT_SECONDS = env_float("HTTP_TIMEOUT_SECONDS", 30) ORLEN_TIMEOUT_SECONDS = env_float("ORLEN_TIMEOUT_SECONDS", 15) diff --git a/app/openapi.py b/app/openapi.py index e508548..a29cc1c 100644 --- a/app/openapi.py +++ b/app/openapi.py @@ -5,15 +5,20 @@ docs = Blueprint('openapi', __name__) @docs.get('/openapi.json') def spec(): return jsonify({ - 'openapi':'3.0.3','info':{'title':'FuelTrack API','version':'0.0.1','description':'REST API używane przez interfejs FuelTrack. Autoryzacja odbywa się przez sesję Flask (cookie).'}, - 'components':{'securitySchemes':{'sessionCookie':{'type':'apiKey','in':'cookie','name':'session'}},'schemas':{ + 'openapi':'3.0.3','info':{'title':'FuelTrack API','version':'0.1.0','description':'REST API FuelTrack. Dla integracji użyj logowania e-mailem i hasłem w OAuth2 Password, które zwraca token Bearer. Interfejs WWW może nadal używać sesji cookie.'}, + 'components':{'securitySchemes':{ + 'oauth2Password':{'type':'oauth2','flows':{'password':{'tokenUrl':'/api/auth/token','scopes':{}}}}, + 'bearerAuth':{'type':'http','scheme':'bearer','bearerFormat':'signed token'}, + 'sessionCookie':{'type':'apiKey','in':'cookie','name':'session'} + },'schemas':{ 'ApiResponse':{'type':'object','properties':{'ok':{'type':'boolean'},'message':{'type':'string'},'data':{}}}, 'User':{'type':'object','properties':{'id':{'type':'integer'},'name':{'type':'string'},'email':{'type':'string','format':'email'},'role':{'type':'string','enum':['user','boss','admin']},'active':{'type':'boolean'}}}, 'Vehicle':{'type':'object','properties':{'id':{'type':'integer'},'name':{'type':'string'},'registration':{'type':'string'},'fuel_type':{'type':'string','enum':['PB95','PB98','DIESEL','LPG']}}} }}, - 'security':[{'sessionCookie':[]}], + 'security':[{'oauth2Password':[]},{'bearerAuth':[]},{'sessionCookie':[]}], 'paths':{ '/api/auth/login':{'post':{'tags':['Auth'],'security':[],'summary':'Logowanie','requestBody':{'required':True,'content':{'application/json':{'schema':{'type':'object','required':['email','password'],'properties':{'email':{'type':'string'},'password':{'type':'string'}}}}}},'responses':{'200':{'description':'Zalogowano'},'401':{'description':'Błędne dane'}}}}, + '/api/auth/token':{'post':{'tags':['Auth'],'security':[],'summary':'Pobranie tokenu Bearer','description':'Endpoint zgodny z OAuth2 Password używany przez przycisk Authorize w Swagger UI. W polu username wpisz adres e-mail.','requestBody':{'required':True,'content':{'application/x-www-form-urlencoded':{'schema':{'type':'object','required':['username','password'],'properties':{'username':{'type':'string','format':'email','description':'Adres e-mail'},'password':{'type':'string','format':'password'}}}},'application/json':{'schema':{'type':'object','required':['email','password'],'properties':{'email':{'type':'string','format':'email'},'password':{'type':'string','format':'password'}}}}}},'responses':{'200':{'description':'Token wydany','content':{'application/json':{'schema':{'type':'object','properties':{'access_token':{'type':'string'},'token_type':{'type':'string','example':'Bearer'},'expires_in':{'type':'integer'}}}}}},'401':{'description':'Błędne dane logowania'}}}}, '/api/auth/logout':{'post':{'tags':['Auth'],'summary':'Wylogowanie','responses':{'200':{'description':'Wylogowano'}}}}, '/api/me':{'get':{'tags':['Auth'],'summary':'Bieżący użytkownik','responses':{'200':{'description':'Użytkownik'}}}}, '/api/vehicles':{'get':{'tags':['Vehicles'],'summary':'Lista dostępnych pojazdów','responses':{'200':{'description':'Lista'}}},'post':{'tags':['Vehicles'],'summary':'Dodanie pojazdu','responses':{'201':{'description':'Utworzono'}}}}, diff --git a/tests/test_app.py b/tests/test_app.py index b43583c..2820dab 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -163,3 +163,40 @@ def test_only_admin_can_change_global_vat(tmp_path): response = client.post('/admin/global-vat', data={'enabled':'on','name':'CPN','rate':'8','start':'2026-07-01','end':'2026-07-31'}, headers={'X-Requested-With':'XMLHttpRequest'}) assert response.status_code == 200 assert response.json['ok'] is True + + +def test_api_bearer_token_authentication(tmp_path): + app = make_app(tmp_path, "bearer.db") + client = app.test_client() + + token_response = client.post('/api/auth/token', data={ + 'username': 'admin@example.com', + 'password': 'admin123!', + }) + assert token_response.status_code == 200 + token = token_response.json['access_token'] + assert token_response.json['token_type'] == 'Bearer' + + fresh_client = app.test_client() + response = fresh_client.get('/api/me', headers={ + 'Authorization': f'Bearer {token}', + }) + assert response.status_code == 200 + assert response.json['data']['email'] == 'admin@example.com' + + +def test_api_rejects_invalid_bearer_token(tmp_path): + app = make_app(tmp_path, "invalid-bearer.db") + response = app.test_client().get('/api/me', headers={ + 'Authorization': 'Bearer invalid-token', + }) + assert response.status_code == 401 + assert response.json['ok'] is False + + +def test_openapi_contains_password_flow(tmp_path): + app = make_app(tmp_path, "openapi-auth.db") + spec = app.test_client().get('/openapi.json').json + password_flow = spec['components']['securitySchemes']['oauth2Password']['flows']['password'] + assert password_flow['tokenUrl'] == '/api/auth/token' + assert '/api/auth/token' in spec['paths']