File size: 15,359 Bytes
1bd7131
 
 
 
 
 
 
050d8f8
 
 
 
 
1bd7131
050d8f8
e39877e
34f76dc
1bd7131
 
 
 
 
 
 
 
bc8ed4e
1bd7131
bc8ed4e
5e3877e
 
 
1bd7131
bc8ed4e
1bd7131
 
75fb504
5e3877e
 
1bd7131
a42ab7e
e39877e
5e3877e
050d8f8
1bd7131
 
050d8f8
c4fd9c8
050d8f8
1bd7131
050d8f8
 
 
 
 
 
 
 
1bd7131
050d8f8
 
 
 
 
 
34f76dc
 
050d8f8
34f76dc
050d8f8
34f76dc
050d8f8
 
05b100e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1bd7131
 
 
050d8f8
 
 
 
 
1bd7131
 
75fb504
 
 
05b100e
 
050d8f8
75fb504
050d8f8
1bd7131
05b100e
 
 
1bd7131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5e3877e
 
34f76dc
1bd7131
 
5e3877e
 
1bd7131
 
 
 
 
 
 
 
 
 
050d8f8
1bd7131
 
 
050d8f8
1bd7131
 
 
 
 
 
 
 
 
 
34f76dc
 
 
 
8d69ae4
34f76dc
 
 
 
 
 
 
 
8d69ae4
34f76dc
8d69ae4
34f76dc
 
 
 
 
 
1bd7131
 
 
 
 
 
 
 
 
40628e2
1bd7131
 
 
34f76dc
 
 
 
8d69ae4
34f76dc
8d69ae4
34f76dc
 
 
1bd7131
 
5e3877e
 
34f76dc
5e3877e
34f76dc
1bd7131
5e3877e
 
050d8f8
 
1bd7131
75fb504
19e4a8c
75fb504
c4fd9c8
 
be85b16
 
 
1bd7131
75fb504
 
 
 
 
 
 
 
 
 
 
 
178694a
75fb504
 
178694a
 
 
75fb504
 
 
 
178694a
 
 
 
75fb504
178694a
75fb504
 
 
 
178694a
75fb504
 
050d8f8
 
1bd7131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
050d8f8
1bd7131
 
 
 
050d8f8
 
 
 
1bd7131
 
 
 
 
19e4a8c
 
050d8f8
 
 
75fb504
 
1bd7131
 
 
050d8f8
1bd7131
 
 
19e4a8c
75fb504
 
 
 
 
 
 
 
 
 
 
 
 
 
19e4a8c
 
 
75fb504
19e4a8c
 
 
 
 
 
 
75fb504
19e4a8c
 
 
75fb504
 
 
 
 
 
 
19e4a8c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75fb504
 
c4fd9c8
75fb504
 
 
 
 
 
 
 
 
 
 
178694a
 
 
75fb504
 
 
 
178694a
 
 
 
75fb504
178694a
75fb504
 
 
 
 
1bd7131
 
050d8f8
1bd7131
050d8f8
 
 
1bd7131
 
050d8f8
 
1bd7131
050d8f8
 
 
1bd7131
 
19e4a8c
 
1bd7131
050d8f8
1bd7131
19e4a8c
 
 
 
1bd7131
5e3877e
 
34f76dc
5e3877e
1bd7131
5e3877e
 
1bd7131
 
 
 
be85b16
 
 
1bd7131
75fb504
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
"""
Authentication Router - Google OAuth

Endpoints for Google Sign-In authentication flow.
No more secret keys - users authenticate with their Google account.
"""
from fastapi import APIRouter, Depends, HTTPException, status, Request, BackgroundTasks
from fastapi.responses import JSONResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from datetime import datetime
import uuid
import logging

from core.database import get_db
from core.models import User, AuditLog, ClientUser
from core.schemas import (
    CheckRegistrationRequest,
    GoogleAuthRequest,
    AuthResponse,
    UserInfoResponse,
    TokenRefreshRequest,
    TokenRefreshResponse
)
from services.auth_service.google_provider import (
    GoogleAuthService,
    GoogleUserInfo,
    InvalidTokenError as GoogleInvalidTokenError,
    ConfigurationError as GoogleConfigError,
    get_google_auth_service,
)
from services.auth_service.jwt_provider import (
    JWTService,
    create_access_token,
    create_refresh_token,
    get_jwt_service,
    InvalidTokenError as JWTInvalidTokenError,
)
from core.dependencies import check_rate_limit, get_current_user
from services.drive_service import DriveService
from services.audit_service import AuditService

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/auth", tags=["auth"])
drive_service = DriveService()


@router.post("/check-registration")
async def check_registration(
    request: CheckRegistrationRequest,
    req: Request,
    db: AsyncSession = Depends(get_db)
):
    """
    Check if a temporary user_id has completed registration.
    Useful for frontend to check if user needs to sign in.
    """
    # Rate Limit: 10 requests per minute per IP
    ip = req.client.host
    if not await check_rate_limit(db, ip, "/auth/check-registration", 10, 1):
        raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many requests")

    # Check if this client_user_id has been linked to a server user
    query = select(ClientUser).where(ClientUser.client_user_id == request.user_id)
    result = await db.execute(query)
    client_user = result.scalar_one_or_none()

    return {"is_registered": client_user is not None}



def detect_client_type(request: Request) -> str:
    """
    Detect client type from User-Agent header.
    Browsers get 'web', native apps get 'mobile'.
    """
    user_agent = request.headers.get("user-agent", "").lower()
    
    # Browser indicators
    browser_keywords = ["mozilla", "chrome", "firefox", "safari", "edge", "opera"]
    
    if any(keyword in user_agent for keyword in browser_keywords):
        return "web"
    return "mobile"


@router.post("/google", response_model=AuthResponse)
async def google_auth(
    request: GoogleAuthRequest,
    req: Request,
    background_tasks: BackgroundTasks,
    db: AsyncSession = Depends(get_db)
):
    """
    Authenticate with Google ID token.
    
    Supports two client types:
    - "web": Sets refresh_token in HttpOnly cookie (secure)
    - "mobile": Returns refresh_token in JSON body
    
    Client type is auto-detected from User-Agent if not provided.
    """
    response = JSONResponse(content={})  # Placeholder, will be populated later
    ip = req.client.host
    
    # Auto-detect client type if not explicitly provided
    client_type = request.client_type if request.client_type else detect_client_type(req)
    
    # Rate Limit: 10 attempts per minute per IP
    if not await check_rate_limit(db, ip, "/auth/google", 10, 1):
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail="Too many authentication attempts"
        )
    
    # Verify Google token
    try:
        google_service = get_google_auth_service()
        google_info = google_service.verify_token(request.id_token)
    except GoogleConfigError as e:
        logger.error(f"Google Auth not configured: {e}")
        raise HTTPException(
            status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
            detail="Google authentication is not configured"
        )
    except GoogleInvalidTokenError as e:
        logger.warning(f"Invalid Google token from {ip}: {e}")
        
        # Log failed attempt
        await AuditService.log_event(
            db=db,
            log_type="server",
            action="google_auth",
            status="failed",
            error_message=str(e),
            request=req
        )
        await db.commit()
        
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid Google token. Please try signing in again."
        )
    
    # Check for existing user by email (preserves credits for migrated users)
    query = select(User).where(User.email == google_info.email)
    result = await db.execute(query)
    user = result.scalar_one_or_none()
    
    is_new_user = False
    
    if user:
        # Existing user - update Google info
        if not user.google_id:
            user.google_id = google_info.google_id
            logger.info(f"Linked Google account to existing user: {user.email}")
        
        user.name = google_info.name
        user.profile_picture = google_info.picture
        user.last_used_at = datetime.utcnow()
        
        # Link client_user_id if provided
        if request.temp_user_id:
            # Check if this client mapping exists
            client_query = select(ClientUser).where(
                ClientUser.user_id == user.id,  # Integer FK comparison
                ClientUser.client_user_id == request.temp_user_id
            )
            client_result = await db.execute(client_query)
            existing_client = client_result.scalar_one_or_none()
            
            if not existing_client:
                # Create new client user mapping
                client_user = ClientUser(
                    user_id=user.id,  # Integer FK to users.id
                    client_user_id=request.temp_user_id,
                    ip_address=ip,  # Standardized IP column
                    last_seen_at=datetime.utcnow()
                )
                db.add(client_user)
            else:
                # Update last seen
                existing_client.last_seen_at = datetime.utcnow()
    else:
        # New user - create account
        is_new_user = True
        user = User(
            user_id="usr_" + str(uuid.uuid4()),
            email=google_info.email,
            google_id=google_info.google_id,
            name=google_info.name,
            profile_picture=google_info.picture,
            credits=0
        )
        db.add(user)
        logger.info(f"New user created via Google: {google_info.email}")
        
        # Create client user mapping if temp_user_id provided
        if request.temp_user_id:
            client_user = ClientUser(
                user_id=user.id,  # Integer FK to users.id (will be set after flush)
                client_user_id=request.temp_user_id,
                ip_address=ip,  # Standardized IP column
                last_seen_at=datetime.utcnow()
            )
            db.add(client_user)
    
    # Log successful auth
    await AuditService.log_event(
        db=db,
        log_type="server",
        user_id=user.id,
        client_user_id=request.temp_user_id,
        action="google_auth",
        status="success",
        request=req
    )
    await db.commit()
    
    # Create our JWT access token and refresh token
    access_token = create_access_token(user.user_id, user.email, user.token_version)
    refresh_token = create_refresh_token(user.user_id, user.email, user.token_version)
    
    # Sync DB to Drive (Async)
    from services.backup_service import get_backup_service
    backup_service = get_backup_service()
    background_tasks.add_task(backup_service.backup_async)
    
    # Prepare response data
    response_data = {
        "success": True,
        "access_token": access_token,
        "user_id": user.user_id,
        "email": user.email,
        "name": user.name,
        "credits": user.credits,
        "is_new_user": is_new_user
    }
    
    # Handle token delivery based on client type
    if client_type == "web":
        # Web: Set HttpOnly cookie for refresh token
        response = JSONResponse(content=response_data)
        # Cookie settings for production
        import os
        is_production = os.getenv("ENVIRONMENT", "production") == "production"
        response.set_cookie(
            key="refresh_token",
            value=refresh_token,
            httponly=True,
            secure=is_production,  # True in production (HTTPS), False locally (HTTP)
            samesite="none" if is_production else "lax",  # 'none' for cross-origin in production
            max_age=7 * 24 * 60 * 60,  # 7 days
            domain=None  # Let browser set domain automatically
        )
        logger.info(f"Set refresh_token cookie for web client (production={is_production})")
    else:
        # Mobile: Return refresh token in body
        response_data["refresh_token"] = refresh_token
        response = JSONResponse(content=response_data)
        logger.info(f"Returned refresh_token in body for mobile client")
        
    return response


@router.get("/me", response_model=UserInfoResponse)
async def get_current_user_info(
    user: User = Depends(get_current_user)
):
    """
    Get current authenticated user info.
    
    Requires Authorization: Bearer <token> header.
    """
    return UserInfoResponse(
        user_id=user.user_id,
        email=user.email,
        name=user.name,
        credits=user.credits,
        profile_picture=user.profile_picture
    )


@router.post("/refresh", response_model=TokenRefreshResponse)
async def refresh_token(
    request: TokenRefreshRequest,
    req: Request,
    db: AsyncSession = Depends(get_db)
):
    """
    Refresh an access token.
    
    Use this when the current token is about to expire
    (or has recently expired) to get a new one without
    requiring the user to sign in again.
    
    Validates that the token_version is still valid before refreshing.
    """
    ip = req.client.host
    
    # Rate Limit: 20 refreshes per minute per IP (increased for proactive refresh on page load)
    if not await check_rate_limit(db, ip, "/auth/refresh", 20, 1):
        raise HTTPException(
            status_code=status.HTTP_429_TOO_MANY_REQUESTS,
            detail="Too many refresh attempts"
        )
    
    try:
        jwt_service = get_jwt_service()
        
        # Get token from body or cookie
        token_to_refresh = request.token
        using_cookie = False
        
        if not token_to_refresh:
            token_to_refresh = req.cookies.get("refresh_token")
            using_cookie = True
            
        if not token_to_refresh:
             raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Refresh token missing"
            )
        
        # Decode the token (without verifying expiry) to get user info
        import jwt as pyjwt
        payload = pyjwt.decode(
            token_to_refresh,
            jwt_service.secret_key,
            algorithms=[jwt_service.algorithm],
            options={"verify_exp": False}
        )
        
        user_id = payload.get("sub")
        token_version = payload.get("tv", 1)
        token_type = payload.get("type", "access")
        
        if not user_id:
            raise JWTInvalidTokenError("Token missing required claims")
            
        # Verify it's a refresh token
        if token_type != "refresh":
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Invalid token type. Expected refresh token."
            )
        
        # Check if user exists and token version is still valid
        query = select(User).where(User.user_id == user_id, User.is_active == True)
        result = await db.execute(query)
        user = result.scalar_one_or_none()
        
        if not user:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="User not found or inactive"
            )
        
        # Validate token version
        if token_version < user.token_version:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Token has been invalidated. Please sign in again."
            )
        
        # Create new access token
        new_access_token = create_access_token(user.user_id, user.email, user.token_version)
        
        # ROTATION: Issue new refresh token
        new_refresh_token = create_refresh_token(user.user_id, user.email, user.token_version)
        
        response_data = {
            "success": True,
            "access_token": new_access_token
        }
        
        if using_cookie:
            # If came from cookie, rotate cookie
            response = JSONResponse(content=response_data)
            # Cookie settings for production
            import os
            is_production = os.getenv("ENVIRONMENT", "production") == "production"
            response.set_cookie(
                key="refresh_token",
                value=new_refresh_token,
                httponly=True,
                secure=is_production,  # True in production (HTTPS), False locally (HTTP)
                samesite="none" if is_production else "lax",  # 'none' for cross-origin in production
                max_age=7 * 24 * 60 * 60,
                domain=None  # Let browser set domain automatically
            )
            logger.info(f"Rotated refresh_token cookie (production={is_production})")
            return response
        else:
            # If came from body, return in body
            response_data["refresh_token"] = new_refresh_token
            return TokenRefreshResponse(**response_data)
    except JWTInvalidTokenError as e:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail=f"Cannot refresh token: {str(e)}"
        )


@router.post("/logout")
async def logout(
    req: Request,
    background_tasks: BackgroundTasks,
    user: User = Depends(get_current_user),
    db: AsyncSession = Depends(get_db)
):
    """
    Logout current user.
    
    Increments the user's token_version which invalidates ALL existing
    tokens for this user. This provides instant logout across all devices.
    """
    ip = req.client.host
    
    # Increment token version to invalidate all existing tokens
    user.token_version += 1
    logger.info(f"User {user.user_id} logged out. Token version incremented to {user.token_version}")
    
    # Log logout
    await AuditService.log_event(
        db=db,
        log_type="server",
        user_id=user.id,
        action="logout",
        status="success",
        request=req
    )
    await db.commit()
    
    # Sync DB to Drive (Async)
    from services.backup_service import get_backup_service
    backup_service = get_backup_service()
    background_tasks.add_task(backup_service.backup_async)
    
    response = JSONResponse(content={"success": True, "message": "Logged out successfully. All sessions invalidated."})
    response.delete_cookie(key="refresh_token")
    return response