Spaces:
Running
Running
Fix Google Auth and Registration flow
Browse files- app/api/routes/auth.py +27 -25
- app/api/routes/users.py +19 -2
- app/core/settings.py +2 -1
- app/services/user_service.py +17 -0
app/api/routes/auth.py
CHANGED
|
@@ -11,6 +11,7 @@ from app.core.security import create_access_token
|
|
| 11 |
from app.db.repositories.user_repo import UserRepository
|
| 12 |
from datetime import timedelta
|
| 13 |
import uuid
|
|
|
|
| 14 |
|
| 15 |
router = APIRouter()
|
| 16 |
|
|
@@ -35,17 +36,24 @@ async def login_google(request: Request):
|
|
| 35 |
detail="Google OAuth is not configured on the server."
|
| 36 |
)
|
| 37 |
|
| 38 |
-
#
|
| 39 |
-
#
|
| 40 |
redirect_uri = settings.GOOGLE_REDIRECT_URI
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
logger.info(f"Initiating Google OAuth login")
|
| 43 |
logger.info(f"Using Google Redirect URI: {redirect_uri}")
|
| 44 |
logger.info(f"Request Host Header: {request.headers.get('host')}")
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
|
| 50 |
try:
|
| 51 |
return await oauth.google.authorize_redirect(request, redirect_uri)
|
|
@@ -57,19 +65,22 @@ async def login_google(request: Request):
|
|
| 57 |
)
|
| 58 |
|
| 59 |
|
| 60 |
-
from app.core.logging import logger
|
| 61 |
-
|
| 62 |
@router.get("/google/callback")
|
| 63 |
async def auth_google(request: Request, db: Session = Depends(get_db)):
|
| 64 |
"""Gère le retour de Google après authentification"""
|
| 65 |
logger.info("Google callback route reached")
|
| 66 |
|
| 67 |
-
#
|
| 68 |
-
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
try:
|
| 71 |
logger.info(f"Callback full URL: {request.url}")
|
| 72 |
-
logger.info(f"Callback Params: {dict(request.query_params)}")
|
| 73 |
|
| 74 |
token = await oauth.google.authorize_access_token(request)
|
| 75 |
user_info = token.get('userinfo')
|
|
@@ -82,7 +93,7 @@ async def auth_google(request: Request, db: Session = Depends(get_db)):
|
|
| 82 |
)
|
| 83 |
|
| 84 |
email = user_info.get('email')
|
| 85 |
-
name = user_info.get('name') or email.split('@')[0] if email else "User"
|
| 86 |
|
| 87 |
logger.info(f"Google login successful for email: {email}")
|
| 88 |
|
|
@@ -93,19 +104,10 @@ async def auth_google(request: Request, db: Session = Depends(get_db)):
|
|
| 93 |
detail="Email not provided by Google."
|
| 94 |
)
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
|
|
|
| 98 |
|
| 99 |
-
if not user:
|
| 100 |
-
logger.info(f"Creating new user for email: {email}")
|
| 101 |
-
user = user_repo.create_user(
|
| 102 |
-
email=email,
|
| 103 |
-
name=name,
|
| 104 |
-
hashed_password=None, # OAuth users don't have a local password
|
| 105 |
-
institution=None,
|
| 106 |
-
role="user"
|
| 107 |
-
)
|
| 108 |
-
|
| 109 |
access_token = create_access_token(
|
| 110 |
data={"sub": str(user.id)},
|
| 111 |
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
|
|
|
| 11 |
from app.db.repositories.user_repo import UserRepository
|
| 12 |
from datetime import timedelta
|
| 13 |
import uuid
|
| 14 |
+
from app.core.logging import logger
|
| 15 |
|
| 16 |
router = APIRouter()
|
| 17 |
|
|
|
|
| 36 |
detail="Google OAuth is not configured on the server."
|
| 37 |
)
|
| 38 |
|
| 39 |
+
# If settings.GOOGLE_REDIRECT_URI is set, use it.
|
| 40 |
+
# Otherwise, derive it from the current request URL (base) + callback path.
|
| 41 |
redirect_uri = settings.GOOGLE_REDIRECT_URI
|
| 42 |
+
if not redirect_uri:
|
| 43 |
+
# Construct redirect URI from the request base URL
|
| 44 |
+
base_url = str(request.base_url).rstrip('/')
|
| 45 |
+
# Ensure we use https if we are behind a proxy that terminates SSL
|
| 46 |
+
if "hf.space" in base_url or request.headers.get("x-forwarded-proto") == "https":
|
| 47 |
+
base_url = base_url.replace("http://", "https://")
|
| 48 |
+
redirect_uri = f"{base_url}/api/v1/auth/google/callback"
|
| 49 |
|
| 50 |
logger.info(f"Initiating Google OAuth login")
|
| 51 |
logger.info(f"Using Google Redirect URI: {redirect_uri}")
|
| 52 |
logger.info(f"Request Host Header: {request.headers.get('host')}")
|
| 53 |
+
|
| 54 |
+
# Ensure Authlib uses https for the state param if we are in production
|
| 55 |
+
if "https" in redirect_uri:
|
| 56 |
+
request.scope["scheme"] = "https"
|
| 57 |
|
| 58 |
try:
|
| 59 |
return await oauth.google.authorize_redirect(request, redirect_uri)
|
|
|
|
| 65 |
)
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
| 68 |
@router.get("/google/callback")
|
| 69 |
async def auth_google(request: Request, db: Session = Depends(get_db)):
|
| 70 |
"""Gère le retour de Google après authentification"""
|
| 71 |
logger.info("Google callback route reached")
|
| 72 |
|
| 73 |
+
# Determine the redirect_uri used during the initial request
|
| 74 |
+
redirect_uri = settings.GOOGLE_REDIRECT_URI
|
| 75 |
+
if not redirect_uri:
|
| 76 |
+
base_url = str(request.base_url).rstrip('/')
|
| 77 |
+
if "hf.space" in base_url or request.headers.get("x-forwarded-proto") == "https":
|
| 78 |
+
base_url = base_url.replace("http://", "https://")
|
| 79 |
+
request.scope["scheme"] = "https"
|
| 80 |
+
redirect_uri = f"{base_url}/api/v1/auth/google/callback"
|
| 81 |
+
|
| 82 |
try:
|
| 83 |
logger.info(f"Callback full URL: {request.url}")
|
|
|
|
| 84 |
|
| 85 |
token = await oauth.google.authorize_access_token(request)
|
| 86 |
user_info = token.get('userinfo')
|
|
|
|
| 93 |
)
|
| 94 |
|
| 95 |
email = user_info.get('email')
|
| 96 |
+
name = user_info.get('name') or (email.split('@')[0] if email else "User")
|
| 97 |
|
| 98 |
logger.info(f"Google login successful for email: {email}")
|
| 99 |
|
|
|
|
| 104 |
detail="Email not provided by Google."
|
| 105 |
)
|
| 106 |
|
| 107 |
+
from app.services.user_service import UserService
|
| 108 |
+
service = UserService(db)
|
| 109 |
+
user = service.register_user_oauth(email=email, name=name)
|
| 110 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
access_token = create_access_token(
|
| 112 |
data={"sub": str(user.id)},
|
| 113 |
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
app/api/routes/users.py
CHANGED
|
@@ -38,6 +38,7 @@ async def create_user(user: UserCreate, db: Session = Depends(get_db)):
|
|
| 38 |
@router.post("/register", response_model=dict, status_code=status.HTTP_201_CREATED)
|
| 39 |
async def register(user: UserCreate, db: Session = Depends(get_db)):
|
| 40 |
"""Enregistre un nouvel utilisateur avec mot de passe"""
|
|
|
|
| 41 |
try:
|
| 42 |
service = UserService(db)
|
| 43 |
new_user = service.register_user(
|
|
@@ -47,6 +48,7 @@ async def register(user: UserCreate, db: Session = Depends(get_db)):
|
|
| 47 |
institution=user.institution,
|
| 48 |
role=user.role
|
| 49 |
)
|
|
|
|
| 50 |
return {
|
| 51 |
"message": "User created successfully",
|
| 52 |
"user": {
|
|
@@ -56,20 +58,29 @@ async def register(user: UserCreate, db: Session = Depends(get_db)):
|
|
| 56 |
}
|
| 57 |
}
|
| 58 |
except ValueError as e:
|
|
|
|
| 59 |
raise HTTPException(
|
| 60 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 61 |
detail=str(e)
|
| 62 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
@router.post("/login", response_model=dict)
|
| 66 |
async def login(user_login: UserLogin, db: Session = Depends(get_db)):
|
| 67 |
"""Connecte un utilisateur par email/password et retourne un JWT"""
|
|
|
|
| 68 |
try:
|
| 69 |
service = UserService(db)
|
| 70 |
user = service.login_user(user_login.email, user_login.password)
|
| 71 |
|
| 72 |
if not user:
|
|
|
|
| 73 |
raise HTTPException(
|
| 74 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 75 |
detail="Invalid email or password."
|
|
@@ -79,12 +90,15 @@ async def login(user_login: UserLogin, db: Session = Depends(get_db)):
|
|
| 79 |
data={"sub": str(user.id)},
|
| 80 |
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 81 |
)
|
|
|
|
| 82 |
return {"access_token": token, "token_type": "bearer", "user": {"id": str(user.id), "name": user.name, "email": user.email}}
|
| 83 |
except HTTPException:
|
| 84 |
raise
|
| 85 |
except Exception as e:
|
|
|
|
| 86 |
# Fallback to Demo Mode ONLY if explicitly enabled or for testing connection issues
|
| 87 |
if ("OperationalError" in str(type(e)) or "connection" in str(e).lower()) and settings.ENVIRONMENT == "development":
|
|
|
|
| 88 |
mock_id = str(uuid.uuid4())
|
| 89 |
token = create_access_token(
|
| 90 |
data={"sub": mock_id, "demo": True},
|
|
@@ -102,6 +116,7 @@ async def login(user_login: UserLogin, db: Session = Depends(get_db)):
|
|
| 102 |
@router.post("/register-and-login", response_model=dict, status_code=status.HTTP_201_CREATED)
|
| 103 |
async def register_and_login(user: UserCreate, db: Session = Depends(get_db)):
|
| 104 |
"""Enregistre un nouvel utilisateur et retourne un JWT directement"""
|
|
|
|
| 105 |
try:
|
| 106 |
service = UserService(db)
|
| 107 |
new_user = service.register_user(
|
|
@@ -115,12 +130,14 @@ async def register_and_login(user: UserCreate, db: Session = Depends(get_db)):
|
|
| 115 |
data={"sub": str(new_user.id)},
|
| 116 |
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 117 |
)
|
|
|
|
| 118 |
return {"access_token": token, "token_type": "bearer", "user": {"id": str(new_user.id), "name": new_user.name, "email": new_user.email}}
|
| 119 |
except Exception as e:
|
| 120 |
-
logger.error(f"Registration error: {str(e)}")
|
| 121 |
|
| 122 |
# Capture conflict errors (user already exists)
|
| 123 |
if isinstance(e, ValueError) or "unique constraint" in str(e).lower() or "already exists" in str(e).lower():
|
|
|
|
| 124 |
raise HTTPException(
|
| 125 |
status_code=status.HTTP_409_CONFLICT,
|
| 126 |
detail="User with this email already exists."
|
|
@@ -144,7 +161,7 @@ async def register_and_login(user: UserCreate, db: Session = Depends(get_db)):
|
|
| 144 |
"mode": "demo"
|
| 145 |
}
|
| 146 |
|
| 147 |
-
logger.exception("Unexpected error during registration")
|
| 148 |
raise HTTPException(
|
| 149 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 150 |
detail=f"Registration failed: {str(e)}"
|
|
|
|
| 38 |
@router.post("/register", response_model=dict, status_code=status.HTTP_201_CREATED)
|
| 39 |
async def register(user: UserCreate, db: Session = Depends(get_db)):
|
| 40 |
"""Enregistre un nouvel utilisateur avec mot de passe"""
|
| 41 |
+
logger.info(f"Registering new user: {user.email}")
|
| 42 |
try:
|
| 43 |
service = UserService(db)
|
| 44 |
new_user = service.register_user(
|
|
|
|
| 48 |
institution=user.institution,
|
| 49 |
role=user.role
|
| 50 |
)
|
| 51 |
+
logger.info(f"User registered successfully: {user.email}")
|
| 52 |
return {
|
| 53 |
"message": "User created successfully",
|
| 54 |
"user": {
|
|
|
|
| 58 |
}
|
| 59 |
}
|
| 60 |
except ValueError as e:
|
| 61 |
+
logger.warning(f"Registration conflict for {user.email}: {str(e)}")
|
| 62 |
raise HTTPException(
|
| 63 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 64 |
detail=str(e)
|
| 65 |
)
|
| 66 |
+
except Exception as e:
|
| 67 |
+
logger.error(f"Unexpected error during registration for {user.email}: {str(e)}", exc_info=True)
|
| 68 |
+
raise HTTPException(
|
| 69 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 70 |
+
detail=f"Registration failed: {str(e)}"
|
| 71 |
+
)
|
| 72 |
|
| 73 |
|
| 74 |
@router.post("/login", response_model=dict)
|
| 75 |
async def login(user_login: UserLogin, db: Session = Depends(get_db)):
|
| 76 |
"""Connecte un utilisateur par email/password et retourne un JWT"""
|
| 77 |
+
logger.info(f"Login attempt for: {user_login.email}")
|
| 78 |
try:
|
| 79 |
service = UserService(db)
|
| 80 |
user = service.login_user(user_login.email, user_login.password)
|
| 81 |
|
| 82 |
if not user:
|
| 83 |
+
logger.warning(f"Failed login attempt for: {user_login.email}")
|
| 84 |
raise HTTPException(
|
| 85 |
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 86 |
detail="Invalid email or password."
|
|
|
|
| 90 |
data={"sub": str(user.id)},
|
| 91 |
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 92 |
)
|
| 93 |
+
logger.info(f"Successful login for: {user_login.email}")
|
| 94 |
return {"access_token": token, "token_type": "bearer", "user": {"id": str(user.id), "name": user.name, "email": user.email}}
|
| 95 |
except HTTPException:
|
| 96 |
raise
|
| 97 |
except Exception as e:
|
| 98 |
+
logger.error(f"Error during login for {user_login.email}: {str(e)}", exc_info=True)
|
| 99 |
# Fallback to Demo Mode ONLY if explicitly enabled or for testing connection issues
|
| 100 |
if ("OperationalError" in str(type(e)) or "connection" in str(e).lower()) and settings.ENVIRONMENT == "development":
|
| 101 |
+
logger.warning("Database unavailable. Falling back to Demo Mode for login.")
|
| 102 |
mock_id = str(uuid.uuid4())
|
| 103 |
token = create_access_token(
|
| 104 |
data={"sub": mock_id, "demo": True},
|
|
|
|
| 116 |
@router.post("/register-and-login", response_model=dict, status_code=status.HTTP_201_CREATED)
|
| 117 |
async def register_and_login(user: UserCreate, db: Session = Depends(get_db)):
|
| 118 |
"""Enregistre un nouvel utilisateur et retourne un JWT directement"""
|
| 119 |
+
logger.info(f"Register-and-login attempt for: {user.email}")
|
| 120 |
try:
|
| 121 |
service = UserService(db)
|
| 122 |
new_user = service.register_user(
|
|
|
|
| 130 |
data={"sub": str(new_user.id)},
|
| 131 |
expires_delta=timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 132 |
)
|
| 133 |
+
logger.info(f"Successful register-and-login for: {user.email}")
|
| 134 |
return {"access_token": token, "token_type": "bearer", "user": {"id": str(new_user.id), "name": new_user.name, "email": new_user.email}}
|
| 135 |
except Exception as e:
|
| 136 |
+
logger.error(f"Registration error for {user.email}: {str(e)}")
|
| 137 |
|
| 138 |
# Capture conflict errors (user already exists)
|
| 139 |
if isinstance(e, ValueError) or "unique constraint" in str(e).lower() or "already exists" in str(e).lower():
|
| 140 |
+
logger.warning(f"Registration conflict: user {user.email} already exists")
|
| 141 |
raise HTTPException(
|
| 142 |
status_code=status.HTTP_409_CONFLICT,
|
| 143 |
detail="User with this email already exists."
|
|
|
|
| 161 |
"mode": "demo"
|
| 162 |
}
|
| 163 |
|
| 164 |
+
logger.exception(f"Unexpected error during registration for {user.email}")
|
| 165 |
raise HTTPException(
|
| 166 |
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 167 |
detail=f"Registration failed: {str(e)}"
|
app/core/settings.py
CHANGED
|
@@ -76,7 +76,8 @@ class Settings(BaseSettings):
|
|
| 76 |
# Google OAuth
|
| 77 |
GOOGLE_CLIENT_ID: Optional[str] = None
|
| 78 |
GOOGLE_CLIENT_SECRET: Optional[str] = None
|
| 79 |
-
|
|
|
|
| 80 |
|
| 81 |
model_config = SettingsConfigDict(
|
| 82 |
env_file=".env",
|
|
|
|
| 76 |
# Google OAuth
|
| 77 |
GOOGLE_CLIENT_ID: Optional[str] = None
|
| 78 |
GOOGLE_CLIENT_SECRET: Optional[str] = None
|
| 79 |
+
# If not set, the app will try to construct it from the request URL
|
| 80 |
+
GOOGLE_REDIRECT_URI: Optional[str] = None
|
| 81 |
|
| 82 |
model_config = SettingsConfigDict(
|
| 83 |
env_file=".env",
|
app/services/user_service.py
CHANGED
|
@@ -24,6 +24,23 @@ class UserService:
|
|
| 24 |
hashed_password = hash_password(password)
|
| 25 |
return self.user_repo.create_user(email, name, hashed_password, institution, role)
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
def login_user(self, email: str, password: str):
|
| 28 |
"""Connecte un utilisateur"""
|
| 29 |
user = self.user_repo.authenticate(email, password)
|
|
|
|
| 24 |
hashed_password = hash_password(password)
|
| 25 |
return self.user_repo.create_user(email, name, hashed_password, institution, role)
|
| 26 |
|
| 27 |
+
def register_user_oauth(self, email: str, name: str):
|
| 28 |
+
"""
|
| 29 |
+
Enregistre ou récupère un utilisateur via OAuth.
|
| 30 |
+
Si l'utilisateur n'existe pas, il est créé sans mot de passe.
|
| 31 |
+
"""
|
| 32 |
+
user = self.user_repo.get_by_email(email)
|
| 33 |
+
if not user:
|
| 34 |
+
# Create new user for OAuth
|
| 35 |
+
user = self.user_repo.create_user(
|
| 36 |
+
email=email,
|
| 37 |
+
name=name,
|
| 38 |
+
hashed_password=None, # No local password for OAuth-only accounts
|
| 39 |
+
institution="OAuth",
|
| 40 |
+
role="user"
|
| 41 |
+
)
|
| 42 |
+
return user
|
| 43 |
+
|
| 44 |
def login_user(self, email: str, password: str):
|
| 45 |
"""Connecte un utilisateur"""
|
| 46 |
user = self.user_repo.authenticate(email, password)
|