This commit is contained in:
Mateusz Gruszczyński
2026-07-13 15:04:24 +02:00
parent 8023f60b1e
commit 5f6e4770c1
7 changed files with 138 additions and 4 deletions
+15
View File
@@ -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. 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. 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 <token>
```
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.
+18
View File
@@ -58,11 +58,29 @@ def create_app(test_config=None):
response.headers["Cache-Control"] = "public, max-age=2592000, immutable" response.headers["Cache-Control"] = "public, max-age=2592000, immutable"
elif response.mimetype == "text/html": elif response.mimetype == "text/html":
response.headers["Cache-Control"] = "no-store, no-cache, private, must-revalidate" response.headers["Cache-Control"] = "no-store, no-cache, private, must-revalidate"
response.headers["Pragma"] = "no-cache"
response.headers["Expires"] = "0"
return response return response
@login_manager.user_loader @login_manager.user_loader
def load_user(user_id): return db.session.get(User, int(user_id)) 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 @app.context_processor
def inject_theme(): def inject_theme():
themes = _themes(app) themes = _themes(app)
+17 -1
View File
@@ -2,7 +2,7 @@ from collections import defaultdict
from datetime import datetime, date from datetime import datetime, date
from decimal import Decimal from decimal import Decimal
from functools import wraps 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 flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy import extract from sqlalchemy import extract
from sqlalchemy.exc import IntegrityError 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 .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 .station_catalog import sync_station_catalog
from . import THEMES from . import THEMES
from .api_tokens import create_access_token
api = Blueprint('api', __name__, url_prefix='/api') api = Blueprint('api', __name__, url_prefix='/api')
FUEL_TYPES = ('PB95','PB98','DIESEL','LPG') 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) 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') 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') @api.post('/auth/logout')
@login_required @login_required
def logout(): logout_user(); return response({'redirect':url_for('auth.login')},'Wylogowano') def logout(): logout_user(); return response({'redirect':url_for('auth.login')},'Wylogowano')
+42
View File
@@ -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
+1
View File
@@ -35,6 +35,7 @@ class Config:
SECRET_KEY = env("SECRET_KEY", "dev-secret") SECRET_KEY = env("SECRET_KEY", "dev-secret")
SQLALCHEMY_DATABASE_URI = env("DATABASE_URL", "sqlite:////app/instance/fueltrack.db") SQLALCHEMY_DATABASE_URI = env("DATABASE_URL", "sqlite:////app/instance/fueltrack.db")
SQLALCHEMY_TRACK_MODIFICATIONS = False 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) HTTP_TIMEOUT_SECONDS = env_float("HTTP_TIMEOUT_SECONDS", 30)
ORLEN_TIMEOUT_SECONDS = env_float("ORLEN_TIMEOUT_SECONDS", 15) ORLEN_TIMEOUT_SECONDS = env_float("ORLEN_TIMEOUT_SECONDS", 15)
+8 -3
View File
@@ -5,15 +5,20 @@ docs = Blueprint('openapi', __name__)
@docs.get('/openapi.json') @docs.get('/openapi.json')
def spec(): def spec():
return jsonify({ 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).'}, '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':{'sessionCookie':{'type':'apiKey','in':'cookie','name':'session'}},'schemas':{ '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':{}}}, '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'}}}, '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']}}} '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':{ '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/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/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/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'}}}}, '/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'}}}},
+37
View File
@@ -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'}) 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.status_code == 200
assert response.json['ok'] is True 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']