Skip to content

Commit 375a2af

Browse files
v3.0.2
Signed-off-by: Dinger <quantdinger@gmail.com>
1 parent 31b4239 commit 375a2af

6 files changed

Lines changed: 447 additions & 92 deletions

File tree

‎backend_api_python/app/services/billing_service.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ def purchase_membership(self, user_id: int, plan: str) -> Tuple[bool, str, Dict[
283283
updated_at = NOW()
284284
WHERE id = ?
285285
""",
286-
(vip_expires_at, vip_plan, 1 if vip_is_lifetime else 0, user_id),
286+
(vip_expires_at, vip_plan, bool(vip_is_lifetime), user_id),
287287
)
288288

289289
# Credits grants

‎backend_api_python/app/services/oauth_service.py‎

Lines changed: 148 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import secrets
66
import requests
77
from urllib.parse import urlencode, urlparse
8-
from datetime import datetime
8+
from datetime import datetime, timezone, timedelta
99
from typing import Tuple, Optional, Dict, Any
1010
from app.utils.db import get_db_connection
1111
from app.utils.logger import get_logger
@@ -26,9 +26,121 @@ def get_oauth_service():
2626

2727
class OAuthService:
2828
"""OAuth service for Google and GitHub authentication"""
29-
29+
30+
# Class-level cache: only create OAuth state table once per process
31+
_state_schema_ensured: bool = False
32+
3033
def __init__(self):
3134
self._load_config()
35+
36+
def _oauth_state_ttl_minutes(self) -> int:
37+
try:
38+
return max(5, min(120, int(float(os.getenv("OAUTH_STATE_TTL_MINUTES", "20") or 20))))
39+
except Exception:
40+
return 20
41+
42+
def _ensure_oauth_state_schema(self, cur) -> None:
43+
"""Create qd_oauth_states table if missing.
44+
45+
OAuth state MUST be shared across Gunicorn workers / replicas; an
46+
in-memory dict breaks multi-worker deployments (authorize hits worker
47+
A, callback lands on worker B → Invalid state).
48+
"""
49+
if OAuthService._state_schema_ensured:
50+
return
51+
try:
52+
cur.execute(
53+
"""
54+
CREATE TABLE IF NOT EXISTS qd_oauth_states (
55+
state VARCHAR(128) PRIMARY KEY,
56+
provider VARCHAR(20) NOT NULL,
57+
redirect TEXT,
58+
created_at TIMESTAMP DEFAULT NOW(),
59+
expires_at TIMESTAMP NOT NULL
60+
)
61+
"""
62+
)
63+
cur.execute(
64+
"CREATE INDEX IF NOT EXISTS idx_oauth_states_expires ON qd_oauth_states(expires_at)"
65+
)
66+
OAuthService._state_schema_ensured = True
67+
except Exception as e:
68+
logger.debug(f"_ensure_oauth_state_schema: {e}")
69+
70+
def _oauth_state_save(self, state: str, provider: str, redirect: Optional[str]) -> None:
71+
# Store naive UTC for TIMESTAMP WITHOUT TIME ZONE columns.
72+
exp = (
73+
datetime.now(timezone.utc) + timedelta(minutes=self._oauth_state_ttl_minutes())
74+
).replace(tzinfo=None)
75+
red = (redirect or "").strip() if redirect else ""
76+
with get_db_connection() as db:
77+
cur = db.cursor()
78+
self._ensure_oauth_state_schema(cur)
79+
try:
80+
cur.execute("DELETE FROM qd_oauth_states WHERE expires_at < NOW()")
81+
except Exception:
82+
pass
83+
# IMPORTANT: include RETURNING state explicitly. Otherwise the
84+
# PostgresCursor wrapper would auto-append RETURNING id, and this
85+
# table has no "id" column → INSERT fails with UndefinedColumn.
86+
cur.execute(
87+
"""
88+
INSERT INTO qd_oauth_states (state, provider, redirect, expires_at)
89+
VALUES (?, ?, ?, ?)
90+
ON CONFLICT (state) DO UPDATE
91+
SET provider = EXCLUDED.provider,
92+
redirect = EXCLUDED.redirect,
93+
expires_at = EXCLUDED.expires_at
94+
RETURNING state
95+
""",
96+
(state, provider, red, exp),
97+
)
98+
try:
99+
cur.fetchone()
100+
except Exception:
101+
pass
102+
db.commit()
103+
cur.close()
104+
105+
def _oauth_state_peek_redirect(self, state: str) -> str:
106+
if not state:
107+
return ""
108+
try:
109+
with get_db_connection() as db:
110+
cur = db.cursor()
111+
self._ensure_oauth_state_schema(cur)
112+
cur.execute(
113+
"SELECT redirect FROM qd_oauth_states WHERE state = ? AND expires_at > NOW()",
114+
(state,),
115+
)
116+
row = cur.fetchone()
117+
cur.close()
118+
if not row:
119+
return ""
120+
return (row.get("redirect") or "").strip()
121+
except Exception as e:
122+
logger.warning(f"OAuth state peek failed: {e}")
123+
return ""
124+
125+
def _oauth_state_consume(self, state: str, provider: str) -> bool:
126+
"""Delete and validate state in one statement; True iff a row was removed."""
127+
if not state:
128+
return False
129+
try:
130+
with get_db_connection() as db:
131+
cur = db.cursor()
132+
self._ensure_oauth_state_schema(cur)
133+
cur.execute(
134+
"DELETE FROM qd_oauth_states WHERE state = ? AND provider = ? AND expires_at > NOW()",
135+
(state, provider),
136+
)
137+
n = int(getattr(cur, "rowcount", 0) or 0)
138+
db.commit()
139+
cur.close()
140+
return n > 0
141+
except Exception as e:
142+
logger.error(f"OAuth state consume failed: {e}", exc_info=True)
143+
return False
32144

33145
def _load_config(self):
34146
"""Load OAuth configuration from environment variables"""
@@ -57,9 +169,6 @@ def _load_config(self):
57169
if origin:
58170
self.allowed_redirect_origins.add(origin)
59171

60-
# State storage (in-memory for simplicity, could use Redis in production)
61-
self._states = {}
62-
63172
@staticmethod
64173
def _normalize_origin(url: str) -> str:
65174
"""Return scheme://host[:port] for a URL, empty string if invalid."""
@@ -82,10 +191,7 @@ def is_redirect_allowed(self, redirect_url: str) -> bool:
82191

83192
def peek_state_redirect(self, state: str) -> str:
84193
"""Read the redirect URL associated with a pending OAuth state (no deletion)."""
85-
if not state:
86-
return ''
87-
entry = self._states.get(state) or {}
88-
return entry.get('redirect') or ''
194+
return self._oauth_state_peek_redirect(state)
89195

90196
# =========================================================================
91197
# Google OAuth
@@ -106,11 +212,15 @@ def get_google_auth_url(self, state: str = None, redirect_url: str = None) -> Tu
106212
return '', ''
107213

108214
state = state or secrets.token_urlsafe(32)
109-
state_data = {'provider': 'google', 'created_at': datetime.now()}
215+
red = ""
110216
if redirect_url and self.is_redirect_allowed(redirect_url):
111-
state_data['redirect'] = redirect_url
112-
self._states[state] = state_data
113-
217+
red = redirect_url.strip()
218+
try:
219+
self._oauth_state_save(state, "google", red or None)
220+
except Exception as e:
221+
logger.error(f"Failed to persist Google OAuth state: {e}")
222+
return '', ''
223+
114224
params = {
115225
'client_id': self.google_client_id,
116226
'redirect_uri': self.google_redirect_uri,
@@ -135,12 +245,9 @@ def handle_google_callback(self, code: str, state: str) -> Tuple[bool, Dict[str,
135245
Returns:
136246
(success, user_info_or_error)
137247
"""
138-
# Validate state
139-
if state not in self._states or self._states[state].get('provider') != 'google':
248+
if not self._oauth_state_consume(state, "google"):
140249
return False, {'error': 'Invalid state parameter'}
141-
142-
del self._states[state]
143-
250+
144251
try:
145252
# Exchange code for tokens
146253
token_response = requests.post(
@@ -204,11 +311,15 @@ def get_github_auth_url(self, state: str = None, redirect_url: str = None) -> Tu
204311
return '', ''
205312

206313
state = state or secrets.token_urlsafe(32)
207-
state_data = {'provider': 'github', 'created_at': datetime.now()}
314+
red = ""
208315
if redirect_url and self.is_redirect_allowed(redirect_url):
209-
state_data['redirect'] = redirect_url
210-
self._states[state] = state_data
211-
316+
red = redirect_url.strip()
317+
try:
318+
self._oauth_state_save(state, "github", red or None)
319+
except Exception as e:
320+
logger.error(f"Failed to persist GitHub OAuth state: {e}")
321+
return '', ''
322+
212323
params = {
213324
'client_id': self.github_client_id,
214325
'redirect_uri': self.github_redirect_uri,
@@ -230,12 +341,9 @@ def handle_github_callback(self, code: str, state: str) -> Tuple[bool, Dict[str,
230341
Returns:
231342
(success, user_info_or_error)
232343
"""
233-
# Validate state
234-
if state not in self._states or self._states[state].get('provider') != 'github':
344+
if not self._oauth_state_consume(state, "github"):
235345
return False, {'error': 'Invalid state parameter'}
236-
237-
del self._states[state]
238-
346+
239347
try:
240348
# Exchange code for token
241349
token_response = requests.post(
@@ -591,12 +699,16 @@ def unlink_oauth(self, user_id: int, provider: str) -> Tuple[bool, str]:
591699
# =========================================================================
592700

593701
def cleanup_expired_states(self, max_age_minutes: int = 10):
594-
"""Clean up expired OAuth states"""
595-
cutoff = datetime.now()
596-
from datetime import timedelta
597-
cutoff = cutoff - timedelta(minutes=max_age_minutes)
598-
599-
expired = [k for k, v in self._states.items()
600-
if v.get('created_at', datetime.now()) < cutoff]
601-
for k in expired:
602-
del self._states[k]
702+
"""Delete expired OAuth state rows from the DB.
703+
`max_age_minutes` is kept for backward-compat but is ignored — expiry
704+
is driven by qd_oauth_states.expires_at which is set on insert.
705+
"""
706+
try:
707+
with get_db_connection() as db:
708+
cur = db.cursor()
709+
self._ensure_oauth_state_schema(cur)
710+
cur.execute("DELETE FROM qd_oauth_states WHERE expires_at < NOW()")
711+
db.commit()
712+
cur.close()
713+
except Exception as e:
714+
logger.debug(f"cleanup_expired_states: {e}")

0 commit comments

Comments
 (0)