api auth
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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'}}}},
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
Reference in New Issue
Block a user