Cuong2004 commited on
Commit
9882d96
·
1 Parent(s): d7a7993
app/auth/__init__.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authentication models."""
2
+
3
+ from datetime import datetime
4
+ from pydantic import BaseModel, Field
5
+
6
+
7
+ class GoogleLoginRequest(BaseModel):
8
+ """Google OAuth login request."""
9
+ access_token: str = Field(..., description="Google OAuth access token")
10
+
11
+
12
+ class LoginResponse(BaseModel):
13
+ """Login response."""
14
+ user_id: str = Field(..., description="User ID (UUID)")
15
+ email: str = Field(..., description="User email")
16
+ full_name: str = Field(..., description="User's full name")
17
+ avatar_url: str | None = Field(None, description="Avatar URL")
18
+ token: str = Field(..., description="JWT token")
19
+ message: str = "Login successful"
20
+
21
+
22
+ class LogoutResponse(BaseModel):
23
+ """Logout response."""
24
+ message: str = "Logout successful"
app/auth/controls.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authentication control functions."""
2
+
3
+ import httpx
4
+ from fastapi import HTTPException
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+ from sqlalchemy import text
7
+ from datetime import datetime, timedelta
8
+ import jwt
9
+ import os
10
+ from uuid import uuid4
11
+
12
+ # Google OAuth verification URL
13
+ GOOGLE_VERIFY_URL = "https://www.googleapis.com/oauth2/v3/userinfo?access_token="
14
+
15
+ # JWT settings (should be in environment variables)
16
+ JWT_SECRET = os.getenv("JWT_SECRET", "your-secret-key-change-in-production")
17
+ JWT_ALGORITHM = "HS256"
18
+ JWT_EXPIRATION_HOURS = 24
19
+
20
+
21
+ async def login_control(access_token: str, db: AsyncSession) -> dict:
22
+ """
23
+ Login with Google OAuth access token.
24
+
25
+ Steps:
26
+ 1. Verify access token with Google
27
+ 2. Get user info from Google
28
+ 3. Check if user exists in database
29
+ 4. Create user if not exists
30
+ 5. Generate JWT token
31
+ 6. Return user info and token
32
+
33
+ Args:
34
+ access_token: Google OAuth access token
35
+ db: Database session
36
+
37
+ Returns:
38
+ dict: User info and JWT token
39
+
40
+ Raises:
41
+ HTTPException: If token is invalid or verification fails
42
+ """
43
+ # Verify token with Google
44
+ async with httpx.AsyncClient() as client:
45
+ try:
46
+ response = await client.get(f"{GOOGLE_VERIFY_URL}{access_token}")
47
+
48
+ if response.status_code != 200:
49
+ raise HTTPException(
50
+ status_code=401,
51
+ detail="Invalid access token"
52
+ )
53
+
54
+ google_user_info = response.json()
55
+
56
+ except httpx.RequestError as e:
57
+ raise HTTPException(
58
+ status_code=500,
59
+ detail=f"Failed to verify token with Google: {str(e)}"
60
+ )
61
+
62
+ # Extract user info from Google response
63
+ email = google_user_info.get("email")
64
+ full_name = google_user_info.get("name", "")
65
+ avatar_url = google_user_info.get("picture")
66
+
67
+ if not email:
68
+ raise HTTPException(
69
+ status_code=400,
70
+ detail="Email not provided by Google"
71
+ )
72
+
73
+ # Check if user exists
74
+ result = await db.execute(
75
+ text("""
76
+ SELECT id, full_name, avatar_url, role
77
+ FROM profiles
78
+ WHERE id = (
79
+ SELECT id FROM auth.users WHERE email = :email
80
+ )
81
+ """),
82
+ {"email": email}
83
+ )
84
+ row = result.fetchone()
85
+
86
+ if row:
87
+ # User exists - update avatar if changed
88
+ user_id = str(row.id)
89
+
90
+ if avatar_url and avatar_url != row.avatar_url:
91
+ await db.execute(
92
+ text("""
93
+ UPDATE profiles
94
+ SET avatar_url = :avatar_url, updated_at = NOW()
95
+ WHERE id = :user_id
96
+ """),
97
+ {"avatar_url": avatar_url, "user_id": user_id}
98
+ )
99
+ await db.commit()
100
+ else:
101
+ # Create new user
102
+ user_id = str(uuid4())
103
+
104
+ # Create auth.users entry (assuming Supabase-like schema)
105
+ await db.execute(
106
+ text("""
107
+ INSERT INTO auth.users (id, email, created_at)
108
+ VALUES (:id, :email, NOW())
109
+ ON CONFLICT (email) DO UPDATE SET email = :email
110
+ RETURNING id
111
+ """),
112
+ {"id": user_id, "email": email}
113
+ )
114
+
115
+ # Create profile
116
+ await db.execute(
117
+ text("""
118
+ INSERT INTO profiles (id, full_name, avatar_url, role, locale, created_at, updated_at)
119
+ VALUES (:id, :full_name, :avatar_url, 'tourist', 'vi_VN', NOW(), NOW())
120
+ """),
121
+ {
122
+ "id": user_id,
123
+ "full_name": full_name,
124
+ "avatar_url": avatar_url
125
+ }
126
+ )
127
+
128
+ await db.commit()
129
+
130
+ # Generate JWT token
131
+ token_payload = {
132
+ "user_id": user_id,
133
+ "email": email,
134
+ "exp": datetime.utcnow() + timedelta(hours=JWT_EXPIRATION_HOURS)
135
+ }
136
+ token = jwt.encode(token_payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
137
+
138
+ return {
139
+ "user_id": user_id,
140
+ "email": email,
141
+ "full_name": full_name,
142
+ "avatar_url": avatar_url,
143
+ "token": token
144
+ }
145
+
146
+
147
+ async def logout_control(user_id: str, db: AsyncSession) -> dict:
148
+ """
149
+ Logout user.
150
+
151
+ For now, this is a simple logout that just confirms the action.
152
+ In a production system, you might want to:
153
+ - Blacklist the JWT token
154
+ - Clear server-side sessions
155
+ - Log the logout event
156
+
157
+ Args:
158
+ user_id: User ID
159
+ db: Database session
160
+
161
+ Returns:
162
+ dict: Logout confirmation message
163
+ """
164
+ # Optional: Log logout event
165
+ await db.execute(
166
+ text("""
167
+ INSERT INTO auth.audit_log (user_id, action, timestamp)
168
+ VALUES (:user_id, 'logout', NOW())
169
+ """),
170
+ {"user_id": user_id}
171
+ )
172
+
173
+ # Note: The above will fail if audit_log table doesn't exist
174
+ # Comment it out if not needed or create the table
175
+
176
+ return {"message": "Logout successful"}
app/auth/router.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Authentication Router."""
2
+
3
+ from fastapi import APIRouter, HTTPException, Depends, Query
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+
6
+ from app.shared.db.session import get_db
7
+ from app.auth import GoogleLoginRequest, LoginResponse, LogoutResponse
8
+ from app.auth.controls import login_control, logout_control
9
+
10
+
11
+ router = APIRouter(prefix="/auth", tags=["Authentication"])
12
+
13
+
14
+ @router.post(
15
+ "/login",
16
+ response_model=LoginResponse,
17
+ summary="Login with Google OAuth",
18
+ description="Authenticate user with Google OAuth access token and return JWT token.",
19
+ )
20
+ async def login(
21
+ request: GoogleLoginRequest,
22
+ db: AsyncSession = Depends(get_db),
23
+ ) -> LoginResponse:
24
+ """
25
+ Login with Google OAuth.
26
+
27
+ Verifies the Google access token, creates or updates the user profile,
28
+ and returns a JWT token for authentication.
29
+ """
30
+ try:
31
+ result = await login_control(request.access_token, db)
32
+
33
+ return LoginResponse(
34
+ user_id=result["user_id"],
35
+ email=result["email"],
36
+ full_name=result["full_name"],
37
+ avatar_url=result["avatar_url"],
38
+ token=result["token"],
39
+ message="Login successful"
40
+ )
41
+ except HTTPException:
42
+ raise
43
+ except Exception as e:
44
+ raise HTTPException(
45
+ status_code=500,
46
+ detail=f"Login failed: {str(e)}"
47
+ )
48
+
49
+
50
+ @router.post(
51
+ "/logout",
52
+ response_model=LogoutResponse,
53
+ summary="Logout user",
54
+ description="Logout the current user.",
55
+ )
56
+ async def logout(
57
+ user_id: str = Query(..., description="User ID (from JWT token)"),
58
+ db: AsyncSession = Depends(get_db),
59
+ ) -> LogoutResponse:
60
+ """
61
+ Logout user.
62
+
63
+ Performs logout operations such as logging the event.
64
+ Client should discard the JWT token after this call.
65
+ """
66
+ try:
67
+ result = await logout_control(user_id, db)
68
+
69
+ return LogoutResponse(message=result["message"])
70
+ except Exception as e:
71
+ raise HTTPException(
72
+ status_code=500,
73
+ detail=f"Logout failed: {str(e)}"
74
+ )
app/main.py CHANGED
@@ -13,6 +13,7 @@ from app.api.router import router as api_router
13
  from app.planner.router import router as planner_router
14
  from app.users.router import router as users_router
15
  from app.itineraries.router import router as itineraries_router
 
16
  from app.shared.db.session import engine
17
  from app.shared.integrations.neo4j_client import neo4j_client
18
 
@@ -75,6 +76,7 @@ app.include_router(api_router, prefix="/api/v1", tags=["Chat"])
75
  app.include_router(planner_router, prefix="/api/v1", tags=["Trip Planner"])
76
  app.include_router(users_router, prefix="/api/v1", tags=["Users"])
77
  app.include_router(itineraries_router, prefix="/api/v1", tags=["Itineraries"])
 
78
 
79
  # Upload router
80
  from app.upload import router as upload_router
 
13
  from app.planner.router import router as planner_router
14
  from app.users.router import router as users_router
15
  from app.itineraries.router import router as itineraries_router
16
+ from app.auth.router import router as auth_router
17
  from app.shared.db.session import engine
18
  from app.shared.integrations.neo4j_client import neo4j_client
19
 
 
76
  app.include_router(planner_router, prefix="/api/v1", tags=["Trip Planner"])
77
  app.include_router(users_router, prefix="/api/v1", tags=["Users"])
78
  app.include_router(itineraries_router, prefix="/api/v1", tags=["Itineraries"])
79
+ app.include_router(auth_router, prefix="/api/v1", tags=["Authentication"])
80
 
81
  # Upload router
82
  from app.upload import router as upload_router
pyproject.toml CHANGED
@@ -18,6 +18,7 @@ dependencies = [
18
  "pgvector>=0.3.0",
19
  "python-dotenv>=1.0.0",
20
  "httpx>=0.28.0",
 
21
  "python-multipart>=0.0.9",
22
  # Image embedding (SigLIP local)
23
  "torch>=2.0.0",
 
18
  "pgvector>=0.3.0",
19
  "python-dotenv>=1.0.0",
20
  "httpx>=0.28.0",
21
+ "pyjwt>=2.9.0",
22
  "python-multipart>=0.0.9",
23
  # Image embedding (SigLIP local)
24
  "torch>=2.0.0",