ItsSecretz commited on
Commit
764ec4e
·
1 Parent(s): 51c405f

Add IP-based daily free search quota and Anti-DDoS rate limits

Browse files
Files changed (2) hide show
  1. auth.py +138 -54
  2. main.py +64 -17
auth.py CHANGED
@@ -8,18 +8,34 @@ from typing import Optional, Dict, Any, List, Tuple
8
  KEYS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "api_keys.json")
9
  ADMIN_MASTER_KEY = os.environ.get("ADMIN_MASTER_KEY", "secretz_master_admin_2026")
10
  INTERNAL_BOT_KEY = os.environ.get("INTERNAL_BOT_KEY", "sk_internal_telebot_secretz_9921")
11
- REQUIRE_AUTH = os.environ.get("REQUIRE_AUTH", "false").lower() in ("true", "1", "yes")
12
 
13
  _lock = threading.Lock()
14
- # In-memory sliding window for rate limiting: {api_key: [timestamp1, timestamp2, ...]}
 
 
15
  _rate_limits: Dict[str, List[float]] = {}
16
 
 
 
 
 
 
 
 
17
 
18
  def load_keys() -> Dict[str, Dict[str, Any]]:
19
  if os.path.exists(KEYS_FILE):
20
  try:
21
  with open(KEYS_FILE, "r", encoding="utf-8") as f:
22
- return json.load(f)
 
 
 
 
 
 
 
23
  except Exception:
24
  return {}
25
  return {}
@@ -47,12 +63,13 @@ class KeyManager:
47
  expires_at = now + (days_valid * 86400) if (days_valid and days_valid > 0) else None
48
 
49
  key_info = {
 
50
  "api_key": api_key,
51
- "name": name.strip() or "Unnamed Client",
52
  "created_at": now,
53
  "expires_at": expires_at,
54
- "rate_limit_per_min": max(1, int(rate_limit_per_min)),
55
- "total_credits": int(total_credits), # -1 for unlimited
56
  "used_credits": 0,
57
  "status": "active"
58
  }
@@ -68,7 +85,13 @@ class KeyManager:
68
  def list_keys() -> List[Dict[str, Any]]:
69
  with _lock:
70
  keys = load_keys()
71
- return list(keys.values())
 
 
 
 
 
 
72
 
73
  @staticmethod
74
  def revoke_key(api_key: str) -> bool:
@@ -91,66 +114,121 @@ class KeyManager:
91
  return False
92
 
93
  @staticmethod
94
- def validate_and_consume(api_key: Optional[str]) -> Tuple[bool, int, str]:
95
  """
96
- Validates API key, checks expiration, rate limit, and consumes 1 credit.
97
- Returns: (is_valid: bool, error_code: int, error_message: str)
98
  """
99
- if not REQUIRE_AUTH:
100
- return True, 200, "OK"
101
 
102
- if not api_key:
103
- return False, 401, "API Key required. Pass header 'X-API-Key' or query parameter 'api_key'."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
 
105
- # Master admin key bypass
106
- if api_key == ADMIN_MASTER_KEY or api_key == INTERNAL_BOT_KEY:
107
- return True, 200, "OK"
108
 
 
 
 
109
  with _lock:
110
- keys = load_keys()
111
- if api_key not in keys:
112
- return False, 401, "Invalid API Key."
113
-
114
- info = keys[api_key]
115
 
116
- # 1. Status check
117
- if info.get("status") != "active":
118
- return False, 403, "API Key has been revoked or deactivated."
119
 
120
- now = time.time()
 
121
 
122
- # 2. Expiry check
123
- expires_at = info.get("expires_at")
124
- if expires_at and now > expires_at:
125
- info["status"] = "expired"
126
- save_keys(keys)
127
- return False, 403, "API Key has expired."
128
 
129
- # 3. Credit check
130
- total_credits = info.get("total_credits", -1)
131
- used_credits = info.get("used_credits", 0)
132
- if total_credits != -1 and used_credits >= total_credits:
133
- return False, 403, "API Key credit quota exhausted."
134
 
135
- # 4. Rate limit check (sliding window 60s)
136
- rate_limit = info.get("rate_limit_per_min", 60)
137
- if api_key not in _rate_limits:
138
- _rate_limits[api_key] = []
 
 
 
 
139
 
140
- # Keep only timestamps in the last 60 seconds
141
- cutoff = now - 60.0
142
- _rate_limits[api_key] = [t for t in _rate_limits[api_key] if t > cutoff]
 
143
 
144
- if len(_rate_limits[api_key]) >= rate_limit:
145
- retry_after = round(60.0 - (now - _rate_limits[api_key][0]), 1)
146
- return False, 429, f"Rate limit exceeded ({rate_limit} req/min). Retry after {retry_after}s."
147
 
148
- # Record request & consume credit
149
- _rate_limits[api_key].append(now)
150
- info["used_credits"] = used_credits + 1
151
- save_keys(keys)
152
-
153
- return True, 200, "OK"
 
 
 
 
 
 
 
 
 
 
154
 
155
  @staticmethod
156
  def get_key_status(api_key: str) -> Optional[Dict[str, Any]]:
@@ -161,4 +239,10 @@ class KeyManager:
161
 
162
  with _lock:
163
  keys = load_keys()
164
- return keys.get(api_key)
 
 
 
 
 
 
 
8
  KEYS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "api_keys.json")
9
  ADMIN_MASTER_KEY = os.environ.get("ADMIN_MASTER_KEY", "secretz_master_admin_2026")
10
  INTERNAL_BOT_KEY = os.environ.get("INTERNAL_BOT_KEY", "sk_internal_telebot_secretz_9921")
11
+ DAILY_FREE_LIMIT = int(os.environ.get("DAILY_FREE_LIMIT", "5"))
12
 
13
  _lock = threading.Lock()
14
+
15
+ # Rate limiting sliding windows
16
+ # {api_key: [timestamp1, timestamp2, ...]}
17
  _rate_limits: Dict[str, List[float]] = {}
18
 
19
+ # Public IP Anti-DDoS sliding windows
20
+ # {ip: [timestamp1, timestamp2, ...]}
21
+ _public_ip_burst: Dict[str, List[float]] = {}
22
+
23
+ # Public IP Daily usage: {ip: {"date": "YYYY-MM-DD", "count": int}}
24
+ _public_ip_daily: Dict[str, Dict[str, Any]] = {}
25
+
26
 
27
  def load_keys() -> Dict[str, Dict[str, Any]]:
28
  if os.path.exists(KEYS_FILE):
29
  try:
30
  with open(KEYS_FILE, "r", encoding="utf-8") as f:
31
+ data = json.load(f)
32
+ # Ensure all keys have both 'key' and 'api_key' fields
33
+ for k, v in data.items():
34
+ if "key" not in v:
35
+ v["key"] = k
36
+ if "api_key" not in v:
37
+ v["api_key"] = k
38
+ return data
39
  except Exception:
40
  return {}
41
  return {}
 
63
  expires_at = now + (days_valid * 86400) if (days_valid and days_valid > 0) else None
64
 
65
  key_info = {
66
+ "key": api_key,
67
  "api_key": api_key,
68
+ "name": name,
69
  "created_at": now,
70
  "expires_at": expires_at,
71
+ "rate_limit_per_min": rate_limit_per_min,
72
+ "total_credits": total_credits,
73
  "used_credits": 0,
74
  "status": "active"
75
  }
 
85
  def list_keys() -> List[Dict[str, Any]]:
86
  with _lock:
87
  keys = load_keys()
88
+ result = []
89
+ for k, v in keys.items():
90
+ item = dict(v)
91
+ item["key"] = k
92
+ item["api_key"] = k
93
+ result.append(item)
94
+ return result
95
 
96
  @staticmethod
97
  def revoke_key(api_key: str) -> bool:
 
114
  return False
115
 
116
  @staticmethod
117
+ def validate_request(api_key: Optional[str], client_ip: str) -> Tuple[bool, int, str, Dict[str, Any]]:
118
  """
119
+ Validates an incoming request either by API Key or Public Free Tier (IP-based).
120
+ Returns: (is_valid: bool, error_code: int, error_message: str, meta: dict)
121
  """
122
+ now = time.time()
 
123
 
124
+ # ========================================================
125
+ # Case 1: Master Admin or Internal Bot Key
126
+ # ========================================================
127
+ if api_key in (ADMIN_MASTER_KEY, INTERNAL_BOT_KEY):
128
+ return True, 200, "OK", {"tier": "admin", "remaining": -1}
129
+
130
+ # ========================================================
131
+ # Case 2: User provided a custom API Key
132
+ # ========================================================
133
+ if api_key:
134
+ with _lock:
135
+ keys = load_keys()
136
+ if api_key not in keys:
137
+ return False, 401, "Invalid API Key. Please verify your credentials.", {}
138
+
139
+ info = keys[api_key]
140
+
141
+ # Status check
142
+ if info.get("status") != "active":
143
+ return False, 403, "API Key has been revoked or deactivated.", {}
144
+
145
+ # Expiry check
146
+ expires_at = info.get("expires_at")
147
+ if expires_at and now > expires_at:
148
+ info["status"] = "expired"
149
+ save_keys(keys)
150
+ return False, 403, "API Key has expired.", {}
151
+
152
+ # Credits check
153
+ total_credits = info.get("total_credits", -1)
154
+ used_credits = info.get("used_credits", 0)
155
+ if total_credits != -1 and used_credits >= total_credits:
156
+ return False, 403, "API Key search credits exhausted.", {}
157
+
158
+ # Rate limit (sliding window 60s)
159
+ rate_limit = info.get("rate_limit_per_min", 60)
160
+ if api_key not in _rate_limits:
161
+ _rate_limits[api_key] = []
162
+
163
+ cutoff = now - 60.0
164
+ _rate_limits[api_key] = [t for t in _rate_limits[api_key] if t > cutoff]
165
+
166
+ if len(_rate_limits[api_key]) >= rate_limit:
167
+ retry_after = round(60.0 - (now - _rate_limits[api_key][0]), 1)
168
+ return False, 429, f"Rate limit exceeded ({rate_limit} req/min). Retry after {retry_after}s.", {}
169
+
170
+ # Deduct credit
171
+ _rate_limits[api_key].append(now)
172
+ info["used_credits"] = used_credits + 1
173
+ save_keys(keys)
174
 
175
+ remaining = (total_credits - info["used_credits"]) if total_credits != -1 else -1
176
+ return True, 200, "OK", {"tier": "custom", "remaining": remaining}
 
177
 
178
+ # ========================================================
179
+ # Case 3: Public / Anonymous User (Bina Key ke - Daily Free Limit & Anti-DDoS)
180
+ # ========================================================
181
  with _lock:
182
+ # 1. Anti-DDoS / Rapid Burst Check (Max 1 request per 2 seconds, max 6 per minute)
183
+ if client_ip not in _public_ip_burst:
184
+ _public_ip_burst[client_ip] = []
 
 
185
 
186
+ cutoff = now - 60.0
187
+ _public_ip_burst[client_ip] = [t for t in _public_ip_burst[client_ip] if t > cutoff]
 
188
 
189
+ if _public_ip_burst[client_ip] and (now - _public_ip_burst[client_ip][-1] < 1.5):
190
+ return False, 429, "Anti-DDoS: Rapid requests detected. Please wait 2 seconds between queries.", {}
191
 
192
+ if len(_public_ip_burst[client_ip]) >= 6:
193
+ return False, 429, "Public rate limit exceeded (max 6 req/min). Please slow down.", {}
 
 
 
 
194
 
195
+ # 2. Daily Free Search Limit (Reset at 00:00 UTC)
196
+ today = time.strftime("%Y-%m-%d", time.gmtime())
197
+ if client_ip not in _public_ip_daily or _public_ip_daily[client_ip].get("date") != today:
198
+ _public_ip_daily[client_ip] = {"date": today, "count": 0}
 
199
 
200
+ used_today = _public_ip_daily[client_ip]["count"]
201
+ if used_today >= DAILY_FREE_LIMIT:
202
+ return (
203
+ False,
204
+ 429,
205
+ f"Daily free limit reached ({DAILY_FREE_LIMIT}/{DAILY_FREE_LIMIT} used today). Resets at midnight UTC, or enter an Access Key for unlimited searches.",
206
+ {"tier": "free", "remaining": 0}
207
+ )
208
 
209
+ # Record usage
210
+ _public_ip_burst[client_ip].append(now)
211
+ _public_ip_daily[client_ip]["count"] = used_today + 1
212
+ remaining = max(0, DAILY_FREE_LIMIT - _public_ip_daily[client_ip]["count"])
213
 
214
+ return True, 200, "OK", {"tier": "free", "remaining": remaining}
 
 
215
 
216
+ @staticmethod
217
+ def get_public_quota(client_ip: str) -> Dict[str, Any]:
218
+ """Returns current daily quota status for an IP without consuming credit."""
219
+ today = time.strftime("%Y-%m-%d", time.gmtime())
220
+ with _lock:
221
+ record = _public_ip_daily.get(client_ip, {})
222
+ if record.get("date") != today:
223
+ used = 0
224
+ else:
225
+ used = record.get("count", 0)
226
+ return {
227
+ "tier": "free",
228
+ "daily_limit": DAILY_FREE_LIMIT,
229
+ "used_today": used,
230
+ "remaining_today": max(0, DAILY_FREE_LIMIT - used)
231
+ }
232
 
233
  @staticmethod
234
  def get_key_status(api_key: str) -> Optional[Dict[str, Any]]:
 
239
 
240
  with _lock:
241
  keys = load_keys()
242
+ info = keys.get(api_key)
243
+ if info:
244
+ res = dict(info)
245
+ res["key"] = api_key
246
+ res["api_key"] = api_key
247
+ return res
248
+ return None
main.py CHANGED
@@ -71,13 +71,27 @@ class CreateKeyRequest(BaseModel):
71
  # ---------------------------------------------------------
72
  # Authentication Dependency
73
  # ---------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def verify_api_key(
75
  request: Request,
76
  api_key_query: Optional[str] = Query(None, alias="api_key"),
77
  x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
78
  authorization: Optional[str] = Header(None)
79
- ) -> str:
80
- """Extracts and verifies API key from Header or Query Param."""
81
  token = None
82
  if x_api_key:
83
  token = x_api_key
@@ -86,11 +100,12 @@ def verify_api_key(
86
  elif api_key_query:
87
  token = api_key_query
88
 
89
- is_valid, code, msg = KeyManager.validate_and_consume(token)
 
90
  if not is_valid:
91
  raise HTTPException(status_code=code, detail=msg)
92
 
93
- return token or "unauthenticated"
94
 
95
 
96
  def verify_admin_key(
@@ -121,6 +136,13 @@ async def health_check():
121
  return {"status": "ok", "timestamp": time.time()}
122
 
123
 
 
 
 
 
 
 
 
124
  @app.get("/api/stats")
125
  async def get_system_stats():
126
  """Returns engine health, cache status, and database metrics."""
@@ -143,34 +165,59 @@ async def get_system_stats():
143
 
144
 
145
  # ---------------------------------------------------------
146
- # Protected Search Endpoints (Requires Valid API Key)
147
  # ---------------------------------------------------------
148
  @app.get("/api/lookup")
149
  async def unified_lookup(
150
  q: Optional[str] = Query(None, description="Search query (phone or aadhar)"),
151
  phone: Optional[str] = Query(None, description="Direct 10-digit phone number"),
152
  aadhar: Optional[str] = Query(None, description="Direct 12-digit aadhar number"),
153
- _key: str = Depends(verify_api_key)
154
  ):
 
 
 
 
 
 
 
 
 
155
  eng = get_engine()
156
- if phone:
157
- return eng.query_by_phone(phone)
158
- elif aadhar:
159
- return eng.query_by_aadhar(aadhar)
160
- elif q:
161
- return eng.auto_lookup(q)
162
  else:
163
- raise HTTPException(status_code=400, detail="Missing parameter: provide 'q', 'phone', or 'aadhar'.")
 
 
 
 
 
 
164
 
165
 
166
  @app.get("/api/phone/{phone_number}")
167
- async def lookup_by_phone(phone_number: str, _key: str = Depends(verify_api_key)):
168
- return get_engine().query_by_phone(phone_number)
 
 
 
 
 
 
 
169
 
170
 
171
  @app.get("/api/aadhar/{aadhar_number}")
172
- async def lookup_by_aadhar(aadhar_number: str, _key: str = Depends(verify_api_key)):
173
- return get_engine().query_by_aadhar(aadhar_number)
 
 
 
 
 
 
 
174
 
175
 
176
  @app.get("/api/key/status")
 
71
  # ---------------------------------------------------------
72
  # Authentication Dependency
73
  # ---------------------------------------------------------
74
+ def get_client_ip(request: Request) -> str:
75
+ """Extracts client IP considering Cloudflare, proxies, and standard headers."""
76
+ cf_ip = request.headers.get("cf-connecting-ip")
77
+ if cf_ip:
78
+ return cf_ip.strip()
79
+ forwarded = request.headers.get("x-forwarded-for")
80
+ if forwarded:
81
+ return forwarded.split(",")[0].strip()
82
+ real_ip = request.headers.get("x-real-ip")
83
+ if real_ip:
84
+ return real_ip.strip()
85
+ return request.client.host if request.client else "127.0.0.1"
86
+
87
+
88
  def verify_api_key(
89
  request: Request,
90
  api_key_query: Optional[str] = Query(None, alias="api_key"),
91
  x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
92
  authorization: Optional[str] = Header(None)
93
+ ) -> Dict[str, Any]:
94
+ """Extracts and verifies API key or applies IP-based daily free search with anti-DDoS."""
95
  token = None
96
  if x_api_key:
97
  token = x_api_key
 
100
  elif api_key_query:
101
  token = api_key_query
102
 
103
+ client_ip = get_client_ip(request)
104
+ is_valid, code, msg, meta = KeyManager.validate_request(token, client_ip)
105
  if not is_valid:
106
  raise HTTPException(status_code=code, detail=msg)
107
 
108
+ return meta
109
 
110
 
111
  def verify_admin_key(
 
136
  return {"status": "ok", "timestamp": time.time()}
137
 
138
 
139
+ @app.get("/api/public/quota")
140
+ async def check_public_quota(request: Request):
141
+ """Returns current daily free search quota for the caller IP."""
142
+ client_ip = get_client_ip(request)
143
+ return KeyManager.get_public_quota(client_ip)
144
+
145
+
146
  @app.get("/api/stats")
147
  async def get_system_stats():
148
  """Returns engine health, cache status, and database metrics."""
 
165
 
166
 
167
  # ---------------------------------------------------------
168
+ # Search Endpoints (Public Free Tier or Key Authorized)
169
  # ---------------------------------------------------------
170
  @app.get("/api/lookup")
171
  async def unified_lookup(
172
  q: Optional[str] = Query(None, description="Search query (phone or aadhar)"),
173
  phone: Optional[str] = Query(None, description="Direct 10-digit phone number"),
174
  aadhar: Optional[str] = Query(None, description="Direct 12-digit aadhar number"),
175
+ auth_meta: Dict[str, Any] = Depends(verify_api_key)
176
  ):
177
+ target = phone or aadhar or q
178
+ if not target:
179
+ raise HTTPException(status_code=400, detail="Missing parameter: provide 'q', 'phone', or 'aadhar'.")
180
+
181
+ # Anti-abuse sanitization
182
+ clean = "".join(c for c in str(target) if c.isdigit())
183
+ if len(clean) not in (10, 12):
184
+ raise HTTPException(status_code=400, detail="Invalid query. Please provide a 10-digit phone or 12-digit Aadhaar number.")
185
+
186
  eng = get_engine()
187
+ if len(clean) == 12:
188
+ res = eng.query_by_aadhar(clean)
 
 
 
 
189
  else:
190
+ res = eng.query_by_phone(clean)
191
+
192
+ if isinstance(res, dict):
193
+ res["tier"] = auth_meta.get("tier", "free")
194
+ res["remaining_credits"] = auth_meta.get("remaining", -1)
195
+
196
+ return res
197
 
198
 
199
  @app.get("/api/phone/{phone_number}")
200
+ async def lookup_by_phone(phone_number: str, auth_meta: Dict[str, Any] = Depends(verify_api_key)):
201
+ clean = "".join(c for c in phone_number if c.isdigit())
202
+ if len(clean) != 10:
203
+ raise HTTPException(status_code=400, detail="Phone number must be exactly 10 digits.")
204
+ res = get_engine().query_by_phone(clean)
205
+ if isinstance(res, dict):
206
+ res["tier"] = auth_meta.get("tier", "free")
207
+ res["remaining_credits"] = auth_meta.get("remaining", -1)
208
+ return res
209
 
210
 
211
  @app.get("/api/aadhar/{aadhar_number}")
212
+ async def lookup_by_aadhar(aadhar_number: str, auth_meta: Dict[str, Any] = Depends(verify_api_key)):
213
+ clean = "".join(c for c in aadhar_number if c.isdigit())
214
+ if len(clean) != 12:
215
+ raise HTTPException(status_code=400, detail="Aadhaar number must be exactly 12 digits.")
216
+ res = get_engine().query_by_aadhar(clean)
217
+ if isinstance(res, dict):
218
+ res["tier"] = auth_meta.get("tier", "free")
219
+ res["remaining_credits"] = auth_meta.get("remaining", -1)
220
+ return res
221
 
222
 
223
  @app.get("/api/key/status")