mirror of
https://github.com/spiral-project/ihatemoney.git
synced 2025-04-28 17:32:38 +02:00
API Limit preventing Abuse
This commit is contained in:
parent
56bee93346
commit
c5b02b866f
1 changed files with 70 additions and 36 deletions
|
@ -1,8 +1,11 @@
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from flask import current_app, request
|
from flask import current_app, request
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
from flask_limiter import Limiter
|
||||||
from flask_restful import Resource, abort
|
from flask_restful import Resource, abort
|
||||||
from werkzeug.security import check_password_hash
|
from werkzeug.security import check_password_hash
|
||||||
|
from hmac import compare_digest
|
||||||
from wtforms.fields import BooleanField
|
from wtforms.fields import BooleanField
|
||||||
|
|
||||||
from ihatemoney.currency_convertor import CurrencyConverter
|
from ihatemoney.currency_convertor import CurrencyConverter
|
||||||
|
@ -11,52 +14,83 @@ from ihatemoney.forms import EditProjectForm, MemberForm, ProjectForm, get_billf
|
||||||
from ihatemoney.models import Bill, Person, Project, db
|
from ihatemoney.models import Bill, Person, Project, db
|
||||||
|
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Request limiter configuration
|
||||||
|
# Limiter to prevent abuse of the API
|
||||||
|
limiter = Limiter(
|
||||||
|
key_func=get_remote_address,
|
||||||
|
default_limits=[
|
||||||
|
"100 per day",
|
||||||
|
"5 per minute"
|
||||||
|
],
|
||||||
|
storge_uri="redis://localhost:6379"
|
||||||
|
storage_options={"socket_connection_timeout": 30},
|
||||||
|
strategy="fixed-window-elastic-expiry"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def need_auth(f):
|
def need_auth(f):
|
||||||
"""Check the request for basic authentication for a given project.
|
@limiter.limit("5 per minute", key_func=lambda: request.authorization.username if request.authorization else get_remote_address())
|
||||||
|
|
||||||
Return the project if the authorization is good, abort the request with a 401 otherwise
|
|
||||||
"""
|
|
||||||
|
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
auth = request.authorization
|
try:
|
||||||
project_id = kwargs.get("project_id").lower()
|
project_id = kwargs.get("project_id", "").lower()
|
||||||
|
if not project_id:
|
||||||
|
abort(400, message="Invalid request")
|
||||||
|
|
||||||
# Use Basic Auth
|
# Bearer token auth
|
||||||
if auth and project_id and auth.username.lower() == project_id:
|
|
||||||
project = Project.query.get(auth.username.lower())
|
|
||||||
if project and check_password_hash(project.password, auth.password):
|
|
||||||
# The whole project object will be passed instead of project_id
|
|
||||||
kwargs.pop("project_id")
|
|
||||||
return f(*args, project=project, **kwargs)
|
|
||||||
else:
|
|
||||||
# Use Bearer token Auth
|
|
||||||
auth_header = request.headers.get("Authorization", "")
|
auth_header = request.headers.get("Authorization", "")
|
||||||
auth_token = ""
|
if auth_header.startswith("Bearer "):
|
||||||
try:
|
token = auth_header.split(" ", 1)[1].strip() # More secure split
|
||||||
auth_token = auth_header.split(" ")[1]
|
if not token or len(token) > 512: # Prevent token-based DoS
|
||||||
except IndexError:
|
abort(401, message="Invalid token")
|
||||||
abort(401)
|
|
||||||
project_id = Project.verify_token(
|
verified_project_id = Project.verify_token(
|
||||||
auth_token, token_type="auth", project_id=project_id
|
token,
|
||||||
)
|
token_type="auth",
|
||||||
if auth_token and project_id:
|
project_id=project_id,
|
||||||
project = Project.query.get(project_id)
|
max_age=current_app.config.get('TOKEN_EXPIRY', 86400)
|
||||||
if project:
|
)
|
||||||
|
if verified_project_id:
|
||||||
|
project = Project.query.get(verified_project_id)
|
||||||
|
if project:
|
||||||
|
kwargs.pop("project_id")
|
||||||
|
return f(*args, project=project, **kwargs)
|
||||||
|
|
||||||
|
# Basic auth with constant-time comparisons
|
||||||
|
auth = request.authorization
|
||||||
|
if auth and project_id:
|
||||||
|
if not compare_digest(auth.username.lower(), project_id):
|
||||||
|
current_app.logger.warning(f"Invalid username attempt for project {project_id}")
|
||||||
|
abort(401, message="Authentication failed")
|
||||||
|
|
||||||
|
project = Project.query.get(auth.username.lower())
|
||||||
|
dummy_hash = "pbkdf2:sha256:260000$dummyhashdummyhash"
|
||||||
|
password_hash = project.password if project else dummy_hash
|
||||||
|
|
||||||
|
if project and check_password_hash(password_hash, auth.password):
|
||||||
kwargs.pop("project_id")
|
kwargs.pop("project_id")
|
||||||
return f(*args, project=project, **kwargs)
|
return f(*args, project=project, **kwargs)
|
||||||
abort(401)
|
|
||||||
|
abort(401, message="Authentication required")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(
|
||||||
|
f"Authentication error: {type(e).__name__}",
|
||||||
|
extra={
|
||||||
|
"ip": get_remote_address(),
|
||||||
|
"project_id": project_id,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
abort(401, message="Authentication failed")
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
class CurrenciesHandler(Resource):
|
|
||||||
currency_helper = CurrencyConverter()
|
|
||||||
|
|
||||||
def get(self):
|
|
||||||
return self.currency_helper.get_currencies()
|
|
||||||
|
|
||||||
|
|
||||||
class ProjectsHandler(Resource):
|
class ProjectsHandler(Resource):
|
||||||
def post(self):
|
def post(self):
|
||||||
form = ProjectForm(meta={"csrf": False})
|
form = ProjectForm(meta={"csrf": False})
|
||||||
|
|
Loading…
Reference in a new issue