matsuap commited on
Commit
6ff1138
·
verified ·
1 Parent(s): 907e1f6

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. api/auth.py +194 -84
  2. core/config.py +5 -0
  3. models/db_models.py +2 -1
  4. models/schemas.py +13 -0
api/auth.py CHANGED
@@ -1,81 +1,164 @@
1
- from typing import Optional
2
  from fastapi import APIRouter, Depends, HTTPException, status, Request, Form, Body
3
- from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
4
  from jose import JWTError, jwt
5
  from sqlalchemy.orm import Session
6
- from core.security import create_access_token, verify_password, get_password_hash
7
  from core.config import settings
8
  from core.database import get_db
9
- from models.schemas import UserCreate, Token, TokenData, UserResponse, UserLogin
10
  from models import db_models
 
 
 
 
 
 
11
 
12
  router = APIRouter(prefix="/api/auth", tags=["auth"])
13
 
14
- oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
 
15
 
16
- async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
17
- credentials_exception = HTTPException(
18
- status_code=status.HTTP_401_UNAUTHORIZED,
19
- detail="Could not validate credentials",
20
- headers={"WWW-Authenticate": "Bearer"},
21
- )
22
- try:
23
- payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
24
- email: str = payload.get("sub")
25
- if email is None:
26
- raise credentials_exception
27
- token_data = TokenData(email=email)
28
- except JWTError:
29
- raise credentials_exception
30
-
31
- user = db.query(db_models.User).filter(db_models.User.email == token_data.email).first()
32
- if user is None:
33
- raise credentials_exception
34
- return user
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
- async def get_current_user_ws(token: str, db: Session):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  """
39
- WebSocket authentication - validates JWT token passed as query parameter.
40
- Raises HTTPException if authentication fails.
41
  """
42
  credentials_exception = HTTPException(
43
  status_code=status.HTTP_401_UNAUTHORIZED,
44
  detail="Could not validate credentials",
 
45
  )
 
46
  try:
47
- payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
48
- email: str = payload.get("sub")
49
- if email is None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  raise credentials_exception
51
- token_data = TokenData(email=email)
52
- except JWTError:
 
53
  raise credentials_exception
54
 
55
- user = db.query(db_models.User).filter(db_models.User.email == token_data.email).first()
 
56
  if user is None:
57
- raise credentials_exception
 
 
 
 
 
 
 
 
 
 
58
  return user
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  @router.post("/register", response_model=UserResponse)
61
  async def register(user_in: UserCreate, db: Session = Depends(get_db)):
62
- db_user = db.query(db_models.User).filter(db_models.User.email == user_in.email).first()
63
- if db_user:
64
- raise HTTPException(
65
- status_code=400,
66
- detail="The user with this email already exists in the system.",
67
- )
68
-
69
- hashed_password = get_password_hash(user_in.password)
70
- new_user = db_models.User(
71
- email=user_in.email,
72
- hashed_password=hashed_password,
73
- is_active=True
74
- )
75
- db.add(new_user)
76
- db.commit()
77
- db.refresh(new_user)
78
- return new_user
79
 
80
  @router.post("/login", response_model=Token)
81
  async def login(
@@ -84,42 +167,69 @@ async def login(
84
  password: Optional[str] = Body(None),
85
  username: Optional[str] = Form(None),
86
  password_form: Optional[str] = Form(None, alias="password"),
87
- db: Session = Depends(get_db)):
88
- """
89
- Unified Login:
90
- - For Web App: Send JSON {"email": "...", "password": "..."}
91
- - For Swagger Popup: Enter Email in 'username' box.
92
- """
93
  final_email = email or username
94
  final_password = password or password_form
 
 
95
 
96
- if not final_email:
97
- try:
98
- if "application/json" in request.headers.get("content-type", ""):
99
- body = await request.json()
100
- final_email = body.get("email")
101
- final_password = body.get("password")
 
 
102
  else:
103
- form_data = await request.form()
104
- final_email = form_data.get("username") or form_data.get("email")
105
- final_password = form_data.get("password")
106
- except:
107
- pass
108
 
109
- if not final_email or not final_password:
110
- raise HTTPException(
111
- status_code=422,
112
- detail="Email and password are required. (In Swagger Popup, put email in 'username' box)"
113
- )
114
 
115
- user = db.query(db_models.User).filter(db_models.User.email == final_email).first()
116
- if not user or not verify_password(final_password, user.hashed_password):
117
- raise HTTPException(
118
- status_code=status.HTTP_401_UNAUTHORIZED,
119
- detail="Incorrect email or password",
120
- headers={"WWW-Authenticate": "Bearer"},
121
- )
122
-
123
- access_token = create_access_token(data={"sub": user.email})
124
- return {"access_token": access_token, "token_type": "bearer"}
125
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional, List
2
  from fastapi import APIRouter, Depends, HTTPException, status, Request, Form, Body
3
+ from fastapi.security import OAuth2PasswordBearer, HTTPBearer, HTTPAuthorizationCredentials
4
  from jose import JWTError, jwt
5
  from sqlalchemy.orm import Session
 
6
  from core.config import settings
7
  from core.database import get_db
8
+ from models.schemas import UserCreate, Token, TokenData, UserResponse, UserLogin, ForgotPasswordRequest, ChangePasswordRequest, VerifyOTPRequest, ResetPasswordRequest
9
  from models import db_models
10
+ from supabase import create_client, Client
11
+ import logging
12
+ import httpx
13
+ from datetime import datetime, timedelta
14
+
15
+ logger = logging.getLogger(__name__)
16
 
17
  router = APIRouter(prefix="/api/auth", tags=["auth"])
18
 
19
+ # Initialize Supabase Client
20
+ supabase: Client = create_client(settings.SUPABASE_URL, settings.SUPABASE_ANON_KEY)
21
 
22
+ # Global JWKS cache
23
+ JWKS_CACHE = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
+ async def get_jwks():
26
+ """Fetches the JWKS public keys from Supabase."""
27
+ global JWKS_CACHE
28
+ if JWKS_CACHE is None:
29
+ try:
30
+ jwks_url = f"{settings.SUPABASE_URL}/auth/v1/.well-known/jwks.json"
31
+ async with httpx.AsyncClient() as client:
32
+ response = await client.get(jwks_url)
33
+ response.raise_for_status()
34
+ JWKS_CACHE = response.json()
35
+ logger.info("Successfully fetched Supabase JWKS")
36
+ except Exception as e:
37
+ logger.error(f"Failed to fetch JWKS: {str(e)}")
38
+ return None
39
+ return JWKS_CACHE
40
 
41
+ # Schemes for Swagger
42
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
43
+ bearer_scheme = HTTPBearer(auto_error=False)
44
+
45
+ async def get_token(
46
+ request: Request,
47
+ oauth_token: Optional[str] = Depends(oauth2_scheme),
48
+ bearer_token: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme)
49
+ ) -> str:
50
+ token = oauth_token or (bearer_token.credentials if bearer_token else None)
51
+ if not token:
52
+ auth_header = request.headers.get("Authorization")
53
+ if auth_header and auth_header.startswith("Bearer "):
54
+ token = auth_header.split(" ")[1]
55
+
56
+ if not token:
57
+ raise HTTPException(
58
+ status_code=status.HTTP_401_UNAUTHORIZED,
59
+ detail="Not authenticated. Please Authorize with a Bearer token.",
60
+ headers={"WWW-Authenticate": "Bearer"},
61
+ )
62
+ return token
63
+
64
+ async def get_current_user(token: str = Depends(get_token), db: Session = Depends(get_db)):
65
  """
66
+ Verifies Supabase JWT using JWKS and returns the local user from Azure SQL.
67
+ Supports both ES256 (asymmetric) and HS256 (symmetric) automatically.
68
  """
69
  credentials_exception = HTTPException(
70
  status_code=status.HTTP_401_UNAUTHORIZED,
71
  detail="Could not validate credentials",
72
+ headers={"WWW-Authenticate": "Bearer"},
73
  )
74
+
75
  try:
76
+ # 1. Get Token Header
77
+ header = jwt.get_unverified_header(token)
78
+ alg = header.get("alg")
79
+
80
+ # 2. Logic based on Algorithm
81
+ if alg == "ES256":
82
+ # Asymmetric: Use JWKS Public Keys
83
+ jwks = await get_jwks()
84
+ if not jwks:
85
+ raise credentials_exception
86
+
87
+ payload = jwt.decode(
88
+ token,
89
+ jwks,
90
+ algorithms=["ES256"],
91
+ options={"verify_aud": False}
92
+ )
93
+ else:
94
+ # Symmetric: Use standard JWT Secret
95
+ payload = jwt.decode(
96
+ token,
97
+ settings.SUPABASE_JWT_SECRET,
98
+ algorithms=["HS256", "HS384", "HS512"],
99
+ options={"verify_aud": False}
100
+ )
101
+
102
+ supabase_id: str = payload.get("sub")
103
+ email: str = payload.get("email")
104
+
105
+ if supabase_id is None:
106
  raise credentials_exception
107
+
108
+ except JWTError as e:
109
+ logger.error(f"JWT Verification Error: {str(e)}")
110
  raise credentials_exception
111
 
112
+ # Standard DB Syncing Logic
113
+ user = db.query(db_models.User).filter(db_models.User.supabase_id == supabase_id).first()
114
  if user is None:
115
+ user = db.query(db_models.User).filter(db_models.User.email == email).first()
116
+ if user:
117
+ user.supabase_id = supabase_id
118
+ db.commit()
119
+ db.refresh(user)
120
+ else:
121
+ user = db_models.User(email=email, supabase_id=supabase_id, is_active=True)
122
+ db.add(user)
123
+ db.commit()
124
+ db.refresh(user)
125
+
126
  return user
127
 
128
+ async def get_current_user_ws(token: str, db: Session):
129
+ """WS Auth helper (Internal only)."""
130
+ try:
131
+ header = jwt.get_unverified_header(token)
132
+ alg = header.get("alg")
133
+ if alg == "ES256":
134
+ # Use cached JWKS (might fail if cache empty, usually fine after first request)
135
+ jwks = JWKS_CACHE or {}
136
+ payload = jwt.decode(token, jwks, algorithms=["ES256"], options={"verify_aud": False})
137
+ else:
138
+ payload = jwt.decode(token, settings.SUPABASE_JWT_SECRET, algorithms=["HS256"], options={"verify_aud": False})
139
+
140
+ supabase_id = payload.get("sub")
141
+ return db.query(db_models.User).filter(db_models.User.supabase_id == supabase_id).first()
142
+ except:
143
+ return None
144
+
145
  @router.post("/register", response_model=UserResponse)
146
  async def register(user_in: UserCreate, db: Session = Depends(get_db)):
147
+ try:
148
+ auth_response = supabase.auth.sign_up({"email": user_in.email, "password": user_in.password})
149
+ if not auth_response.user: raise HTTPException(status_code=400, detail="Registration failed")
150
+
151
+ supabase_id = auth_response.user.id
152
+ db_user = db.query(db_models.User).filter(db_models.User.email == user_in.email).first()
153
+ if db_user: db_user.supabase_id = supabase_id
154
+ else:
155
+ db_user = db_models.User(email=user_in.email, supabase_id=supabase_id, is_active=True)
156
+ db.add(db_user)
157
+ db.commit()
158
+ db.refresh(db_user)
159
+ return db_user
160
+ except Exception as e:
161
+ raise HTTPException(status_code=400, detail=str(e))
 
 
162
 
163
  @router.post("/login", response_model=Token)
164
  async def login(
 
167
  password: Optional[str] = Body(None),
168
  username: Optional[str] = Form(None),
169
  password_form: Optional[str] = Form(None, alias="password"),
170
+ db: Session = Depends(get_db)
171
+ ):
 
 
 
 
172
  final_email = email or username
173
  final_password = password or password_form
174
+ if not final_email or not final_password:
175
+ raise HTTPException(status_code=422, detail="Email and password required")
176
 
177
+ try:
178
+ response = supabase.auth.sign_in_with_password({"email": final_email, "password": final_password})
179
+ if not response.session: raise HTTPException(status_code=401, detail="Invalid credentials")
180
+
181
+ db_user = db.query(db_models.User).filter(db_models.User.supabase_id == response.user.id).first()
182
+ if not db_user:
183
+ db_user = db.query(db_models.User).filter(db_models.User.email == final_email).first()
184
+ if db_user: db_user.supabase_id = response.user.id
185
  else:
186
+ db_user = db_models.User(email=final_email, supabase_id=response.user.id, is_active=True)
187
+ db.add(db_user)
188
+ db.commit()
 
 
189
 
190
+ return {"access_token": response.session.access_token, "token_type": "bearer"}
191
+ except Exception as e:
192
+ raise HTTPException(status_code=401, detail=f"Login failed: {str(e)}")
 
 
193
 
194
+ @router.post("/forgot-password")
195
+ async def forgot_password(request: ForgotPasswordRequest):
196
+ try:
197
+ supabase.auth.reset_password_for_email(request.email)
198
+ return {"message": "Verification code has been sent."}
199
+ except Exception as e:
200
+ raise HTTPException(status_code=400, detail=str(e))
201
+
202
+ @router.post("/verify-otp")
203
+ async def verify_otp(request: VerifyOTPRequest):
204
+ try:
205
+ verify_response = supabase.auth.verify_otp({
206
+ "email": request.email,
207
+ "token": request.otp,
208
+ "type": "recovery"
209
+ })
210
+ if not verify_response.session: raise HTTPException(status_code=400, detail="Invalid code")
211
+ return {
212
+ "access_token": verify_response.session.access_token,
213
+ "token_type": "bearer",
214
+ "message": "OTP verified."
215
+ }
216
+ except Exception as e:
217
+ raise HTTPException(status_code=400, detail=str(e))
218
+
219
+ @router.post("/reset-password")
220
+ async def reset_password(request: ResetPasswordRequest, token: str = Depends(get_token)):
221
+ try:
222
+ temp_supabase = create_client(settings.SUPABASE_URL, settings.SUPABASE_ANON_KEY)
223
+ temp_supabase.auth.set_session(token, "")
224
+ temp_supabase.auth.update_user({"password": request.new_password})
225
+ return {"message": "Password successfully reset."}
226
+ except Exception as e:
227
+ raise HTTPException(status_code=400, detail=str(e))
228
+
229
+ @router.post("/change-password")
230
+ async def change_password(request: ChangePasswordRequest, current_user: db_models.User = Depends(get_current_user)):
231
+ try:
232
+ supabase.auth.update_user({"password": request.new_password})
233
+ return {"message": "Password updated successfully"}
234
+ except Exception as e:
235
+ raise HTTPException(status_code=400, detail=str(e))
core/config.py CHANGED
@@ -42,6 +42,11 @@ class Settings(BaseSettings):
42
  AZURE_OPENAI_DEPLOYMENT_NAME: Optional[str] = None
43
  AZURE_OPENAI_API_VERSION: Optional[str] = None
44
 
 
 
 
 
 
45
  model_config = SettingsConfigDict(
46
  env_file=".env",
47
  extra="ignore"
 
42
  AZURE_OPENAI_DEPLOYMENT_NAME: Optional[str] = None
43
  AZURE_OPENAI_API_VERSION: Optional[str] = None
44
 
45
+ # Supabase Settings (Unified Auth)
46
+ SUPABASE_URL: Optional[str] = None
47
+ SUPABASE_ANON_KEY: Optional[str] = None
48
+ SUPABASE_JWT_SECRET: Optional[str] = None
49
+
50
  model_config = SettingsConfigDict(
51
  env_file=".env",
52
  extra="ignore"
models/db_models.py CHANGED
@@ -8,7 +8,8 @@ class User(Base):
8
 
9
  id = Column(Integer, primary_key=True, index=True)
10
  email = Column(Unicode(255), unique=True, index=True, nullable=False)
11
- hashed_password = Column(String(255), nullable=False)
 
12
  is_active = Column(Boolean, default=True)
13
  created_at = Column(DateTime(timezone=True), server_default=func.now())
14
 
 
8
 
9
  id = Column(Integer, primary_key=True, index=True)
10
  email = Column(Unicode(255), unique=True, index=True, nullable=False)
11
+ supabase_id = Column(String(255), unique=True, index=True, nullable=True) # Linked Supabase UID
12
+ hashed_password = Column(String(255), nullable=True) # Set to nullable since Supabase handles auth
13
  is_active = Column(Boolean, default=True)
14
  created_at = Column(DateTime(timezone=True), server_default=func.now())
15
 
models/schemas.py CHANGED
@@ -20,6 +20,19 @@ class UserResponse(UserBase):
20
  class Config:
21
  from_attributes = True
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  # Token Schemas
24
  class Token(BaseModel):
25
  access_token: str
 
20
  class Config:
21
  from_attributes = True
22
 
23
+ class ForgotPasswordRequest(BaseModel):
24
+ email: EmailStr
25
+
26
+ class VerifyOTPRequest(BaseModel):
27
+ email: EmailStr
28
+ otp: str
29
+
30
+ class ResetPasswordRequest(BaseModel):
31
+ new_password: str
32
+
33
+ class ChangePasswordRequest(BaseModel):
34
+ new_password: str
35
+
36
  # Token Schemas
37
  class Token(BaseModel):
38
  access_token: str