Skip to content

Commit 776b86e

Browse files
committed
replace fastapi name, create google auth, create llm feature
1 parent 08aaac1 commit 776b86e

27 files changed

+707
-31
lines changed

.env

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ FRONTEND_HOST=http://localhost:5173
1313
# Environment: local, staging, production
1414
ENVIRONMENT=local
1515

16-
PROJECT_NAME="Full Stack FastAPI Project"
16+
PROJECT_NAME="TemplateForge AI"
1717
STACK_NAME=full-stack-fastapi-project
1818

1919
# Backend
@@ -40,6 +40,14 @@ POSTGRES_PASSWORD=changethis
4040

4141
SENTRY_DSN=
4242

43+
# Google OAuth (Web client ID)
44+
GOOGLE_OAUTH_CLIENT_ID=179636508862-0kvpljg5vpt61ahm8l0jhl3td619nlhs.apps.googleusercontent.com
45+
46+
# LLM (Gemini)
47+
# Do not commit real keys. Set locally or via CI/CD secret manager.
48+
GEMINI_API_KEY=AIzaSyBQ82eHzeywwTB3OTn7moG4kDrkev8YcAI
49+
GEMINI_MODEL=gemini-2.5-flash-lite
50+
4351
# Configure these with your own Docker registry images
4452
DOCKER_IMAGE_BACKEND=backend
4553
DOCKER_IMAGE_FRONTEND=frontend

backend/app/api/routes/generate.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import logging
12
from typing import Any, NoReturn
23

34
from fastapi import APIRouter, HTTPException
@@ -13,6 +14,7 @@
1314
from app.services.exceptions import ServiceError
1415

1516
router = APIRouter(prefix="/generate", tags=["generate"])
17+
logger = logging.getLogger(__name__)
1618

1719

1820
def _raise_http_from_service_error(exc: ServiceError) -> NoReturn:
@@ -33,6 +35,11 @@ def extract_variables(
3335
extract_in=extract_in,
3436
)
3537
except ServiceError as exc:
38+
logger.warning(
39+
"Generate extract failed (status=%s): %s",
40+
exc.status_code,
41+
exc.detail,
42+
)
3643
_raise_http_from_service_error(exc)
3744

3845

@@ -50,4 +57,9 @@ def render_template(
5057
render_in=render_in,
5158
)
5259
except ServiceError as exc:
60+
logger.warning(
61+
"Generate render failed (status=%s): %s",
62+
exc.status_code,
63+
exc.detail,
64+
)
5365
_raise_http_from_service_error(exc)

backend/app/api/routes/login.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,14 @@
88
from app.api.deps import CurrentUser, SessionDep, get_current_active_superuser
99
from app.core import security
1010
from app.core.config import settings
11-
from app.models import Message, NewPassword, Token, UserPublic, UserUpdate
11+
from app.models import (
12+
GoogleLoginRequest,
13+
Message,
14+
NewPassword,
15+
Token,
16+
UserPublic,
17+
UserUpdate,
18+
)
1219
from app.services import auth_service, user_service
1320
from app.utils import (
1421
generate_password_reset_token,
@@ -42,6 +49,26 @@ def login_access_token(
4249
)
4350

4451

52+
@router.post("/login/google")
53+
def login_google(session: SessionDep, body: GoogleLoginRequest) -> Token:
54+
"""
55+
Google ID token login. Validates token with Google and returns local JWT.
56+
"""
57+
try:
58+
user = auth_service.authenticate_google_id_token(
59+
session=session, id_token=body.id_token
60+
)
61+
except auth_service.GoogleAuthError as exc:
62+
raise HTTPException(status_code=exc.status_code, detail=exc.detail)
63+
64+
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
65+
return Token(
66+
access_token=security.create_access_token(
67+
user.id, expires_delta=access_token_expires
68+
)
69+
)
70+
71+
4572
@router.post("/login/test-token", response_model=UserPublic)
4673
def test_token(current_user: CurrentUser) -> Any:
4774
"""

backend/app/core/config.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,13 @@ def all_cors_origins(self) -> list[str]:
5050

5151
PROJECT_NAME: str
5252
SENTRY_DSN: HttpUrl | None = None
53+
LOG_LEVEL: str = "INFO"
54+
LOG_REQUESTS: bool = True
55+
GOOGLE_OAUTH_CLIENT_ID: str | None = None
56+
GOOGLE_AUTH_TIMEOUT_SECONDS: float = 10.0
57+
GEMINI_API_KEY: str | None = None
58+
GEMINI_MODEL: str = "gemini-2.5-flash-lite"
59+
GEMINI_TIMEOUT_SECONDS: float = 30.0
5360
POSTGRES_SERVER: str
5461
POSTGRES_PORT: int = 5432
5562
POSTGRES_USER: str

backend/app/core/logging.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import logging
2+
import logging.config
3+
4+
from app.core.config import settings
5+
6+
7+
def setup_logging() -> None:
8+
level = settings.LOG_LEVEL.upper()
9+
10+
logging.config.dictConfig(
11+
{
12+
"version": 1,
13+
"disable_existing_loggers": False,
14+
"formatters": {
15+
"standard": {
16+
"format": (
17+
"%(asctime)s | %(levelname)s | %(name)s | %(message)s"
18+
)
19+
}
20+
},
21+
"handlers": {
22+
"default": {
23+
"class": "logging.StreamHandler",
24+
"formatter": "standard",
25+
"level": level,
26+
}
27+
},
28+
"root": {"handlers": ["default"], "level": level},
29+
"loggers": {
30+
"uvicorn.error": {"level": level},
31+
"uvicorn.access": {"level": "INFO"},
32+
"httpx": {"level": "WARNING"},
33+
},
34+
}
35+
)
36+

backend/app/main.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1+
import logging
2+
import time
3+
14
import sentry_sdk
2-
from fastapi import FastAPI
5+
from fastapi import FastAPI, Request
36
from fastapi.routing import APIRoute
47
from starlette.middleware.cors import CORSMiddleware
58

69
from app.api.main import api_router
710
from app.core.config import settings
11+
from app.core.logging import setup_logging
12+
13+
setup_logging()
14+
logger = logging.getLogger(__name__)
815

916

1017
def custom_generate_unique_id(route: APIRoute) -> str:
@@ -31,3 +38,44 @@ def custom_generate_unique_id(route: APIRoute) -> str:
3138
)
3239

3340
app.include_router(api_router, prefix=settings.API_V1_STR)
41+
42+
43+
@app.middleware("http")
44+
async def request_logging_middleware(request: Request, call_next): # type: ignore[no-untyped-def]
45+
if not settings.LOG_REQUESTS:
46+
return await call_next(request)
47+
48+
start = time.perf_counter()
49+
try:
50+
response = await call_next(request)
51+
except Exception:
52+
duration_ms = (time.perf_counter() - start) * 1000
53+
logger.exception(
54+
"HTTP %s %s failed after %.1fms",
55+
request.method,
56+
request.url.path,
57+
duration_ms,
58+
)
59+
raise
60+
61+
duration_ms = (time.perf_counter() - start) * 1000
62+
logger.info(
63+
"HTTP %s %s -> %s (%.1fms)",
64+
request.method,
65+
request.url.path,
66+
response.status_code,
67+
duration_ms,
68+
)
69+
return response
70+
71+
72+
@app.on_event("startup")
73+
def log_startup() -> None:
74+
logger.info(
75+
"App startup: project=%s env=%s api_prefix=%s gemini_enabled=%s model=%s",
76+
settings.PROJECT_NAME,
77+
settings.ENVIRONMENT,
78+
settings.API_V1_STR,
79+
bool(settings.GEMINI_API_KEY and settings.GEMINI_API_KEY.strip()),
80+
settings.GEMINI_MODEL,
81+
)

backend/app/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,10 @@ class Message(SQLModel):
358358
message: str
359359

360360

361+
class GoogleLoginRequest(SQLModel):
362+
id_token: str = Field(min_length=1)
363+
364+
361365
# JSON payload containing access token
362366
class Token(SQLModel):
363367
access_token: str

backend/app/services/auth_service.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,68 @@
1+
import logging
2+
import secrets
3+
4+
import httpx
15
from sqlmodel import Session
26

3-
from app.core.security import verify_password
7+
from app.core.config import settings
8+
from app.core.security import get_password_hash, verify_password
49
from app.models import User
510
from app.repositories import user_repository
611

712
# Dummy hash to use for timing attack prevention when user is not found.
813
# This is an Argon2 hash of a random password.
914
DUMMY_HASH = "$argon2id$v=19$m=65536,t=3,p=4$MjQyZWE1MzBjYjJlZTI0Yw$YTU4NGM5ZTZmYjE2NzZlZjY0ZWY3ZGRkY2U2OWFjNjk"
1015

16+
logger = logging.getLogger(__name__)
17+
18+
19+
class GoogleAuthError(RuntimeError):
20+
def __init__(self, detail: str, status_code: int = 400) -> None:
21+
super().__init__(detail)
22+
self.detail = detail
23+
self.status_code = status_code
24+
25+
26+
def _verify_google_id_token(*, id_token: str) -> dict[str, str]:
27+
client_id = settings.GOOGLE_OAUTH_CLIENT_ID
28+
if not client_id or not client_id.strip():
29+
raise GoogleAuthError("Google login is not configured", status_code=503)
30+
31+
try:
32+
response = httpx.get(
33+
"https://oauth2.googleapis.com/tokeninfo",
34+
params={"id_token": id_token},
35+
timeout=settings.GOOGLE_AUTH_TIMEOUT_SECONDS,
36+
)
37+
except httpx.HTTPError as exc:
38+
logger.warning("Google token verification HTTP error: %s", exc)
39+
raise GoogleAuthError(
40+
"Google token verification failed", status_code=502
41+
) from exc
42+
43+
if response.status_code >= 400:
44+
logger.warning(
45+
"Google token verification rejected (status=%s): %s",
46+
response.status_code,
47+
response.text[:500],
48+
)
49+
raise GoogleAuthError("Invalid Google login token", status_code=401)
50+
51+
payload = response.json()
52+
if not isinstance(payload, dict):
53+
raise GoogleAuthError("Invalid Google token response", status_code=502)
54+
55+
audience = str(payload.get("aud", "")).strip()
56+
if audience != client_id:
57+
logger.warning(
58+
"Google token audience mismatch (expected=%s got=%s)",
59+
client_id,
60+
audience,
61+
)
62+
raise GoogleAuthError("Invalid Google token audience", status_code=401)
63+
64+
return {str(k): str(v) for k, v in payload.items()}
65+
1166

1267
def authenticate(*, session: Session, email: str, password: str) -> User | None:
1368
user = user_repository.get_by_email(session=session, email=email)
@@ -23,3 +78,37 @@ def authenticate(*, session: Session, email: str, password: str) -> User | None:
2378
user.hashed_password = updated_password_hash
2479
user_repository.save(session=session, user=user)
2580
return user
81+
82+
83+
def authenticate_google_id_token(*, session: Session, id_token: str) -> User:
84+
payload = _verify_google_id_token(id_token=id_token)
85+
86+
email = payload.get("email", "").strip().lower()
87+
email_verified = payload.get("email_verified", "").strip().lower() == "true"
88+
full_name = payload.get("name", "").strip() or None
89+
90+
if not email:
91+
raise GoogleAuthError("Google account email is missing", status_code=401)
92+
if not email_verified:
93+
raise GoogleAuthError("Google account email is not verified", status_code=401)
94+
95+
user = user_repository.get_by_email(session=session, email=email)
96+
if user is None:
97+
random_password = secrets.token_urlsafe(24)
98+
logger.info("Creating local user from Google login (email=%s)", email)
99+
user = user_repository.create(
100+
session=session,
101+
user=User(
102+
email=email,
103+
full_name=full_name,
104+
hashed_password=get_password_hash(random_password),
105+
),
106+
)
107+
elif not user.is_active:
108+
raise GoogleAuthError("Inactive user", status_code=400)
109+
elif not user.full_name and full_name:
110+
user.full_name = full_name
111+
user = user_repository.save(session=session, user=user)
112+
113+
logger.info("Google login successful (email=%s user_id=%s)", user.email, user.id)
114+
return user

0 commit comments

Comments
 (0)