feat: add Google Sign-In with JWT auth and Neon DB persistence
This commit is contained in:
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"`
|
||||
}
|
||||
Reference in New Issue
Block a user