discrete user accounts
Some checks are pending
check / check (push) Waiting to run

This commit is contained in:
KS Jannette
2026-03-03 19:10:24 -05:00
parent e4e859d051
commit 91336130b9
8 changed files with 248 additions and 65 deletions

View File

@@ -2,6 +2,15 @@ package domain
import "time"
type User struct {
ID string `json:"id"`
FirebaseUID string `json:"-"`
Email string `json:"email"`
DisplayName *string `json:"display_name"` //nolint:tagliatelle
CreatedAt time.Time `json:"created_at"` //nolint:tagliatelle
UpdatedAt time.Time `json:"updated_at"` //nolint:tagliatelle
}
type Address struct {
ID int `json:"id"`
UserID string `json:"user_id"` //nolint:tagliatelle

View File

@@ -9,6 +9,7 @@ import (
"strings"
fbauth "github.com/kjannette/koin-ping/backend-go/internal/firebase"
"github.com/kjannette/koin-ping/backend-go/internal/models"
)
type contextKey string
@@ -26,62 +27,77 @@ type errorResponse struct {
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
json.NewEncoder(w).Encode(v) //nolint:errcheck
}
func Authenticate(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
log.Println("No Authorization header or invalid format")
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "No authentication token provided",
})
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if token == "" {
log.Println("Empty token")
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "Invalid token format",
})
return
}
log.Println("Verifying Firebase token...")
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token)
if err != nil {
log.Printf("Token verification failed: %v", err)
errMsg := err.Error()
if strings.Contains(errMsg, "expired") {
// Authenticate verifies the Firebase ID token and auto-provisions a local user
// record. The local user UUID (not the Firebase UID) is placed into context so
// all downstream handlers use it as the canonical user identifier.
func Authenticate(userModel *models.UserModel) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
log.Println("No Authorization header or invalid format")
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "TOKEN_EXPIRED",
Message: "Authentication token has expired",
Error: "UNAUTHORIZED",
Message: "No authentication token provided",
})
return
}
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "Failed to verify authentication token",
})
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if token == "" {
log.Println("Empty token")
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "Invalid token format",
})
return
}
userID := decoded.UID
email, _ := decoded.Claims["email"].(string)
log.Println("Verifying Firebase token...")
decoded, err := fbauth.Auth().VerifyIDToken(r.Context(), token)
if err != nil {
log.Printf("Token verification failed: %v", err)
log.Printf("Token verified! User ID: %s, Email: %s", userID, email)
errMsg := err.Error()
if strings.Contains(errMsg, "expired") {
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "TOKEN_EXPIRED",
Message: "Authentication token has expired",
})
return
}
ctx := context.WithValue(r.Context(), UserIDKey, userID)
ctx = context.WithValue(ctx, UserEmailKey, email)
writeJSON(w, http.StatusUnauthorized, errorResponse{
Error: "UNAUTHORIZED",
Message: "Failed to verify authentication token",
})
return
}
next.ServeHTTP(w, r.WithContext(ctx))
})
firebaseUID := decoded.UID
email, _ := decoded.Claims["email"].(string)
user, err := userModel.FindOrCreateByFirebaseUID(r.Context(), firebaseUID, email)
if err != nil {
log.Printf("Failed to provision local user for Firebase UID %s: %v", firebaseUID, err)
writeJSON(w, http.StatusInternalServerError, errorResponse{
Error: "INTERNAL_ERROR",
Message: "Failed to initialize user account",
})
return
}
log.Printf("Token verified! User UUID: %s, Email: %s", user.ID, email)
ctx := context.WithValue(r.Context(), UserIDKey, user.ID)
ctx = context.WithValue(ctx, UserEmailKey, email)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetUserID(ctx context.Context) string {

View File

@@ -0,0 +1,53 @@
package models
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/kjannette/koin-ping/backend-go/internal/domain"
)
type UserModel struct {
pool *pgxpool.Pool
}
func NewUserModel(pool *pgxpool.Pool) *UserModel {
return &UserModel{pool: pool}
}
// FindOrCreateByFirebaseUID returns the local user for a Firebase UID,
// creating one if it doesn't exist yet. On conflict (returning user) the
// updated_at timestamp is refreshed.
func (m *UserModel) FindOrCreateByFirebaseUID(ctx context.Context, firebaseUID, email string) (*domain.User, error) {
var u domain.User
err := m.pool.QueryRow(ctx,
`INSERT INTO users (firebase_uid, email)
VALUES ($1, $2)
ON CONFLICT (firebase_uid) DO UPDATE SET updated_at = NOW()
RETURNING id, firebase_uid, email, display_name, created_at, updated_at`,
firebaseUID, email,
).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
return nil, err
}
return &u, nil
}
func (m *UserModel) GetByID(ctx context.Context, id string) (*domain.User, error) {
var u domain.User
err := m.pool.QueryRow(ctx,
`SELECT id, firebase_uid, email, display_name, created_at, updated_at
FROM users
WHERE id = $1`,
id,
).Scan(&u.ID, &u.FirebaseUID, &u.Email, &u.DisplayName, &u.CreatedAt, &u.UpdatedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &u, nil
}