feat: add Google Sign-In with JWT auth and Neon DB persistence
This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"tutor/internal/auth"
|
||||
"tutor/internal/config"
|
||||
"tutor/internal/db"
|
||||
"tutor/internal/httpapi"
|
||||
@@ -22,12 +24,14 @@ func NewServer(cfg config.Config) *http.Server {
|
||||
var memoryStore learnermemory.Store
|
||||
var ontologyStore ontology.Store
|
||||
var assetsStore teachingassets.Store
|
||||
var pool *pgxpool.Pool
|
||||
|
||||
if cfg.DatabaseURL != "" {
|
||||
pool, err := db.Open(cfg.DatabaseURL)
|
||||
p, err := db.Open(cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
log.Fatalf("open database: %v", err)
|
||||
}
|
||||
pool = p
|
||||
if err := db.Migrate(pool); err != nil {
|
||||
log.Fatalf("migrate database: %v", err)
|
||||
}
|
||||
@@ -51,8 +55,15 @@ func NewServer(cfg config.Config) *http.Server {
|
||||
service := interview.NewService(interviewStore, runner, memory)
|
||||
handler := httpapi.NewHandler(cfg, service, memory, progress, onto, assets)
|
||||
|
||||
mux := handler.Routes().(*http.ServeMux)
|
||||
if pool != nil && cfg.GoogleClientID != "" && cfg.JWTSecret != "" {
|
||||
authService := auth.NewService(pool, cfg.GoogleClientID, cfg.JWTSecret)
|
||||
authService.RegisterRoutes(mux)
|
||||
log.Println("auth routes registered")
|
||||
}
|
||||
|
||||
return &http.Server{
|
||||
Addr: cfg.HTTPAddr,
|
||||
Handler: handler.Routes(),
|
||||
Handler: mux,
|
||||
}
|
||||
}
|
||||
|
||||
35
internal/auth/handler.go
Normal file
35
internal/auth/handler.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (s *Service) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/auth/google", s.handleGoogleLogin)
|
||||
}
|
||||
|
||||
func (s *Service) handleGoogleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
writeError(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req GoogleLoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, "invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resp, err := s.HandleGoogleLogin(r.Context(), req)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, message string, code int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
128
internal/auth/service.go
Normal file
128
internal/auth/service.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"google.golang.org/api/idtoken"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
pool *pgxpool.Pool
|
||||
googleClientID string
|
||||
jwtSecret string
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, googleClientID, jwtSecret string) *Service {
|
||||
return &Service{
|
||||
pool: pool,
|
||||
googleClientID: googleClientID,
|
||||
jwtSecret: jwtSecret,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) HandleGoogleLogin(ctx context.Context, req GoogleLoginRequest) (SessionResponse, error) {
|
||||
if strings.TrimSpace(req.IDToken) == "" || s.googleClientID == "" {
|
||||
return SessionResponse{}, errors.New("google id token is required")
|
||||
}
|
||||
|
||||
payload, err := idtoken.Validate(ctx, req.IDToken, s.googleClientID)
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
|
||||
email := ""
|
||||
if v, ok := payload.Claims["email"].(string); ok {
|
||||
email = strings.ToLower(strings.TrimSpace(v))
|
||||
}
|
||||
name := ""
|
||||
if v, ok := payload.Claims["name"].(string); ok {
|
||||
name = strings.TrimSpace(v)
|
||||
}
|
||||
subject := payload.Subject
|
||||
|
||||
if email == "" {
|
||||
return SessionResponse{}, errors.New("email not found in token")
|
||||
}
|
||||
if name == "" {
|
||||
name = strings.Split(email, "@")[0]
|
||||
}
|
||||
|
||||
user, err := s.upsertUser(ctx, email, name, subject)
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
|
||||
token, err := SignToken(s.jwtSecret, Claims{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Exp: time.Now().Add(7 * 24 * time.Hour).Unix(),
|
||||
})
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
|
||||
return SessionResponse{Token: token, User: user}, nil
|
||||
}
|
||||
|
||||
func (s *Service) upsertUser(ctx context.Context, email, displayName, subject string) (User, error) {
|
||||
var user User
|
||||
err := s.pool.QueryRow(ctx, `
|
||||
INSERT INTO users (email, display_name, provider, provider_subject)
|
||||
VALUES ($1, $2, 'google', $3)
|
||||
ON CONFLICT (email) DO UPDATE SET
|
||||
display_name = EXCLUDED.display_name,
|
||||
provider_subject = EXCLUDED.provider_subject
|
||||
RETURNING id, email, display_name
|
||||
`, email, displayName, subject).Scan(&user.ID, &user.Email, &user.DisplayName)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("upsert user: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func SignToken(secret string, claims Claims) (string, error) {
|
||||
body, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
payload := base64.RawURLEncoding.EncodeToString(body)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(payload))
|
||||
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return payload + "." + sig, nil
|
||||
}
|
||||
|
||||
func VerifyToken(secret, token string) (Claims, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 2 {
|
||||
return Claims{}, errors.New("invalid token")
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(parts[0]))
|
||||
expected := mac.Sum(nil)
|
||||
actual, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil || !hmac.Equal(expected, actual) {
|
||||
return Claims{}, errors.New("invalid signature")
|
||||
}
|
||||
body, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
var claims Claims
|
||||
if err := json.Unmarshal(body, &claims); err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
if claims.Exp < time.Now().Unix() {
|
||||
return Claims{}, fmt.Errorf("token expired")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
24
internal/auth/types.go
Normal file
24
internal/auth/types.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package auth
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type GoogleLoginRequest struct {
|
||||
IDToken string `json:"id_token"`
|
||||
}
|
||||
|
||||
type SessionResponse struct {
|
||||
Token string `json:"token"`
|
||||
User User `json:"user"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
@@ -19,6 +19,8 @@ type Config struct {
|
||||
ModelKey string
|
||||
ImageModelKey string
|
||||
ThirdOneBin string
|
||||
GoogleClientID string
|
||||
JWTSecret string
|
||||
}
|
||||
|
||||
func LoadFromEnv() Config {
|
||||
@@ -30,6 +32,8 @@ func LoadFromEnv() Config {
|
||||
ModelKey: envOrDefault("TUTOR_MODEL_KEY", defaultModelKey),
|
||||
ImageModelKey: envOrDefault("TUTOR_IMAGE_MODEL_KEY", defaultImageModelKey),
|
||||
ThirdOneBin: envOrDefault("THIRDONE_BIN", defaultThirdOneBin),
|
||||
GoogleClientID: envOrDefault("GOOGLE_CLIENT_ID", ""),
|
||||
JWTSecret: envOrDefault("JWT_SECRET", ""),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,38 @@ package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"embed"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/001_init.sql
|
||||
var initSQL string
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
func Migrate(pool *pgxpool.Pool) error {
|
||||
_, err := pool.Exec(context.Background(), initSQL)
|
||||
files, err := migrationsFS.ReadDir("migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("run migration: %w", err)
|
||||
return fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].Name() < files[j].Name()
|
||||
})
|
||||
|
||||
for _, f := range files {
|
||||
if path.Ext(f.Name()) != ".sql" {
|
||||
continue
|
||||
}
|
||||
data, err := migrationsFS.ReadFile("migrations/" + f.Name())
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", f.Name(), err)
|
||||
}
|
||||
if _, err := pool.Exec(context.Background(), string(data)); err != nil {
|
||||
return fmt.Errorf("run migration %s: %w", f.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
10
internal/db/migrations/002_auth.sql
Normal file
10
internal/db/migrations/002_auth.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Auth: users table
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
provider TEXT NOT NULL DEFAULT 'google',
|
||||
provider_subject TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -25,6 +25,9 @@ const els = {
|
||||
status: document.querySelector("#status-line"),
|
||||
error: document.querySelector("#error-line"),
|
||||
title: document.querySelector("#session-title"),
|
||||
gSignIn: document.querySelector(".g_id_signin"),
|
||||
userInfo: document.querySelector("#user-info"),
|
||||
logoutButton: document.querySelector("#logout-button"),
|
||||
};
|
||||
|
||||
const readinessClassMap = {
|
||||
@@ -359,11 +362,48 @@ function evidenceBlock(evidence = []) {
|
||||
return `<section><h2>Evidence</h2><ul class="small-list">${evidence.map((item) => `<li>${escapeHTML(item.quote || item.id)}</li>`).join("")}</ul></section>`;
|
||||
}
|
||||
|
||||
window.handleCredentialResponse = async (response) => {
|
||||
try {
|
||||
const res = await request("/api/v1/auth/google", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id_token: response.credential }),
|
||||
});
|
||||
localStorage.setItem("tutor_token", res.token);
|
||||
localStorage.setItem("tutor_user", JSON.stringify(res.user));
|
||||
setStatus(`Signed in as ${res.user.email}`);
|
||||
renderAuth();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
function renderAuth() {
|
||||
const user = JSON.parse(localStorage.getItem("tutor_user") || "null");
|
||||
const token = localStorage.getItem("tutor_token");
|
||||
if (user && token) {
|
||||
els.gSignIn.style.display = "none";
|
||||
els.userInfo.style.display = "block";
|
||||
els.userInfo.textContent = user.email;
|
||||
els.logoutButton.style.display = "inline-block";
|
||||
} else {
|
||||
els.gSignIn.style.display = "block";
|
||||
els.userInfo.style.display = "none";
|
||||
els.logoutButton.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
els.logoutButton.addEventListener("click", () => {
|
||||
localStorage.removeItem("tutor_token");
|
||||
localStorage.removeItem("tutor_user");
|
||||
renderAuth();
|
||||
setStatus("Signed out");
|
||||
});
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
const token = localStorage.getItem("tutor_token");
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
const response = await fetch(url, { headers, ...options });
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error || `Request failed: ${response.status}`);
|
||||
@@ -398,3 +438,5 @@ function escapeHTML(value) {
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
renderAuth();
|
||||
|
||||
@@ -7,12 +7,20 @@
|
||||
<link rel="stylesheet" href="/assets/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<script src="https://accounts.google.com/gsi/client" async defer></script>
|
||||
<main class="workspace">
|
||||
<aside class="setup-pane" aria-label="Diagnostic setup">
|
||||
<p class="eyebrow">Tutor Platform</p>
|
||||
<h1>Interview practice</h1>
|
||||
<p class="lede">Start a focused backend interview loop and turn one answer into evidence.</p>
|
||||
|
||||
<div id="auth-area" class="auth-area">
|
||||
<div id="g_id_onload" data-client_id="13671390758-bp1ed6psn43bl86r8a9kv81o40nkea90.apps.googleusercontent.com" data-callback="handleCredentialResponse"></div>
|
||||
<div class="g_id_signin" data-type="standard"></div>
|
||||
<div id="user-info" class="user-info" style="display:none;"></div>
|
||||
<button id="logout-button" class="small-button" type="button" style="display:none;">Sign out</button>
|
||||
</div>
|
||||
|
||||
<form id="session-form" class="stacked-form">
|
||||
<label>
|
||||
User ID
|
||||
|
||||
Reference in New Issue
Block a user