feat: implement Iteration 0 foundation (backend + Flutter client)
Backend (Go): - Project structure with chi router, pgxpool, goose migrations - JWT auth (access/refresh tokens) with Firebase token verification - NoopTokenVerifier for local dev without Firebase credentials - PostgreSQL user repository with atomic profile updates (transactions) - Mifflin-St Jeor calorie calculation based on profile data - REST API: POST /auth/login, /auth/refresh, /auth/logout, GET/PUT /profile, GET /health - Middleware: auth, CORS (localhost wildcard), logging, recovery, request_id - Unit tests (51 passing) and integration tests (testcontainers) - Docker Compose setup with postgres healthcheck and graceful shutdown Flutter client: - Riverpod state management with GoRouter navigation - Firebase Auth (email/password + Google sign-in with web popup support) - Platform-aware API URLs (web/Android/iOS) - Dio HTTP client with JWT auth interceptor and concurrent refresh handling - Secure token storage - Screens: Login, Register, Home (tabs: Menu, Recipes, Products, Profile) - Unit tests (17 passing) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
89
backend/internal/auth/firebase.go
Normal file
89
backend/internal/auth/firebase.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
firebase "firebase.google.com/go/v4"
|
||||
firebaseAuth "firebase.google.com/go/v4/auth"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
// TokenVerifier abstracts Firebase token verification for testability.
|
||||
type TokenVerifier interface {
|
||||
VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error)
|
||||
}
|
||||
|
||||
type FirebaseAuth struct {
|
||||
client *firebaseAuth.Client
|
||||
}
|
||||
|
||||
// noopTokenVerifier is used in local development when Firebase credentials are not available.
|
||||
// It rejects all tokens with a clear error message.
|
||||
type noopTokenVerifier struct{}
|
||||
|
||||
func (n *noopTokenVerifier) VerifyToken(_ context.Context, _ string) (uid, email, name, avatarURL string, err error) {
|
||||
return "", "", "", "", fmt.Errorf("Firebase auth is not configured (running in noop mode)")
|
||||
}
|
||||
|
||||
// NewFirebaseAuthOrNoop tries to initialize Firebase auth from the credentials file.
|
||||
// If the file is missing, empty, or not a valid service account, it logs a warning
|
||||
// and returns a noop verifier that rejects all tokens.
|
||||
func NewFirebaseAuthOrNoop(credentialsFile string) (TokenVerifier, error) {
|
||||
data, err := os.ReadFile(credentialsFile)
|
||||
if err != nil || len(data) == 0 {
|
||||
slog.Warn("firebase credentials not found, running without Firebase auth", "file", credentialsFile)
|
||||
return &noopTokenVerifier{}, nil
|
||||
}
|
||||
|
||||
var creds map[string]interface{}
|
||||
if err := json.Unmarshal(data, &creds); err != nil || creds["type"] == nil {
|
||||
slog.Warn("firebase credentials invalid, running without Firebase auth", "file", credentialsFile)
|
||||
return &noopTokenVerifier{}, nil
|
||||
}
|
||||
|
||||
fa, err := NewFirebaseAuth(credentialsFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fa, nil
|
||||
}
|
||||
|
||||
func NewFirebaseAuth(credentialsFile string) (*FirebaseAuth, error) {
|
||||
opt := option.WithCredentialsFile(credentialsFile)
|
||||
app, err := firebase.NewApp(context.Background(), nil, opt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := app.Auth(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &FirebaseAuth{client: client}, nil
|
||||
}
|
||||
|
||||
func (f *FirebaseAuth) VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error) {
|
||||
token, err := f.client.VerifyIDToken(ctx, idToken)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
|
||||
uid = token.UID
|
||||
|
||||
if v, ok := token.Claims["email"].(string); ok {
|
||||
email = v
|
||||
}
|
||||
if v, ok := token.Claims["name"].(string); ok {
|
||||
name = v
|
||||
}
|
||||
if v, ok := token.Claims["picture"].(string); ok {
|
||||
avatarURL = v
|
||||
}
|
||||
|
||||
return uid, email, name, avatarURL, nil
|
||||
}
|
||||
106
backend/internal/auth/handler.go
Normal file
106
backend/internal/auth/handler.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
)
|
||||
|
||||
const maxRequestBodySize = 1 << 20 // 1 MB
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
FirebaseToken string `json:"firebase_token"`
|
||||
}
|
||||
|
||||
type refreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
||||
var req loginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.FirebaseToken == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "firebase_token is required")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.service.Login(r.Context(), req.FirebaseToken)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "authentication failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBodySize)
|
||||
var req refreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if req.RefreshToken == "" {
|
||||
writeErrorJSON(w, http.StatusBadRequest, "refresh_token is required")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.service.Refresh(r.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "invalid refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
userID := middleware.UserIDFromCtx(r.Context())
|
||||
if userID == "" {
|
||||
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Logout(r.Context(), userID); err != nil {
|
||||
writeErrorJSON(w, http.StatusInternalServerError, "logout failed")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
type errorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeErrorJSON(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(errorResponse{Error: msg}); err != nil {
|
||||
slog.Error("failed to write error response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(v); err != nil {
|
||||
slog.Error("failed to write JSON response", "err", err)
|
||||
}
|
||||
}
|
||||
272
backend/internal/auth/handler_integration_test.go
Normal file
272
backend/internal/auth/handler_integration_test.go
Normal file
@@ -0,0 +1,272 @@
|
||||
//go:build integration
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/auth/mocks"
|
||||
"github.com/food-ai/backend/internal/middleware"
|
||||
"github.com/food-ai/backend/internal/testutil"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// testValidator adapts JWTManager to middleware.AccessTokenValidator for tests.
|
||||
type testValidator struct {
|
||||
jm *JWTManager
|
||||
}
|
||||
|
||||
func (v *testValidator) ValidateAccessToken(tokenStr string) (*middleware.TokenClaims, error) {
|
||||
claims, err := v.jm.ValidateAccessToken(tokenStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &middleware.TokenClaims{UserID: claims.UserID, Plan: claims.Plan}, nil
|
||||
}
|
||||
|
||||
func setupIntegrationTest(t *testing.T) (*chi.Mux, *JWTManager) {
|
||||
t.Helper()
|
||||
pool := testutil.SetupTestDB(t)
|
||||
|
||||
verifier := &mocks.MockTokenVerifier{
|
||||
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
|
||||
return "fb-" + idToken, idToken + "@test.com", "Test User", "", nil
|
||||
},
|
||||
}
|
||||
|
||||
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
||||
repo := user.NewRepository(pool)
|
||||
svc := NewService(verifier, repo, jm)
|
||||
handler := NewHandler(svc)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Post("/auth/login", handler.Login)
|
||||
r.Post("/auth/refresh", handler.Refresh)
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.Auth(&testValidator{jm: jm}))
|
||||
r.Post("/auth/logout", handler.Logout)
|
||||
})
|
||||
|
||||
return r, jm
|
||||
}
|
||||
|
||||
func TestIntegration_Login(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
body := `{"firebase_token":"user1"}`
|
||||
req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp LoginResponse
|
||||
json.NewDecoder(rr.Body).Decode(&resp)
|
||||
if resp.AccessToken == "" {
|
||||
t.Error("expected non-empty access token")
|
||||
}
|
||||
if resp.RefreshToken == "" {
|
||||
t.Error("expected non-empty refresh token")
|
||||
}
|
||||
if resp.User == nil {
|
||||
t.Fatal("expected user in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Login_EmptyToken(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
body := `{"firebase_token":""}`
|
||||
req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Login_InvalidBody(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
req := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString("invalid"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Refresh(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
// First login
|
||||
loginBody := `{"firebase_token":"user2"}`
|
||||
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginRR, loginReq)
|
||||
|
||||
var loginResp LoginResponse
|
||||
json.NewDecoder(loginRR.Body).Decode(&loginResp)
|
||||
|
||||
// Then refresh
|
||||
refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: loginResp.RefreshToken})
|
||||
refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
|
||||
refreshReq.Header.Set("Content-Type", "application/json")
|
||||
refreshRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(refreshRR, refreshReq)
|
||||
|
||||
if refreshRR.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", refreshRR.Code, refreshRR.Body.String())
|
||||
}
|
||||
|
||||
var resp RefreshResponse
|
||||
json.NewDecoder(refreshRR.Body).Decode(&resp)
|
||||
if resp.AccessToken == "" {
|
||||
t.Error("expected non-empty access token")
|
||||
}
|
||||
if resp.RefreshToken == loginResp.RefreshToken {
|
||||
t.Error("expected rotated refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Refresh_InvalidToken(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
body := `{"refresh_token":"nonexistent"}`
|
||||
req := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Refresh_EmptyToken(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
body := `{"refresh_token":""}`
|
||||
req := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Logout(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
// Login first
|
||||
loginBody := `{"firebase_token":"user3"}`
|
||||
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginRR, loginReq)
|
||||
|
||||
var loginResp LoginResponse
|
||||
json.NewDecoder(loginRR.Body).Decode(&loginResp)
|
||||
|
||||
// Logout
|
||||
logoutReq := httptest.NewRequest("POST", "/auth/logout", nil)
|
||||
logoutReq.Header.Set("Authorization", "Bearer "+loginResp.AccessToken)
|
||||
logoutRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(logoutRR, logoutReq)
|
||||
|
||||
if logoutRR.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", logoutRR.Code, logoutRR.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Logout_NoAuth(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
req := httptest.NewRequest("POST", "/auth/logout", nil)
|
||||
rr := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_RefreshAfterLogout(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
// Login
|
||||
loginBody := `{"firebase_token":"user4"}`
|
||||
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginRR, loginReq)
|
||||
|
||||
var loginResp LoginResponse
|
||||
json.NewDecoder(loginRR.Body).Decode(&loginResp)
|
||||
|
||||
// Logout
|
||||
logoutReq := httptest.NewRequest("POST", "/auth/logout", nil)
|
||||
logoutReq.Header.Set("Authorization", "Bearer "+loginResp.AccessToken)
|
||||
logoutRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(logoutRR, logoutReq)
|
||||
|
||||
// Try to refresh with old token
|
||||
refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: loginResp.RefreshToken})
|
||||
refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
|
||||
refreshReq.Header.Set("Content-Type", "application/json")
|
||||
refreshRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(refreshRR, refreshReq)
|
||||
|
||||
if refreshRR.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 after logout, got %d", refreshRR.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_OldRefreshTokenInvalid(t *testing.T) {
|
||||
router, _ := setupIntegrationTest(t)
|
||||
|
||||
// Login
|
||||
loginBody := `{"firebase_token":"user5"}`
|
||||
loginReq := httptest.NewRequest("POST", "/auth/login", bytes.NewBufferString(loginBody))
|
||||
loginReq.Header.Set("Content-Type", "application/json")
|
||||
loginRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(loginRR, loginReq)
|
||||
|
||||
var loginResp LoginResponse
|
||||
json.NewDecoder(loginRR.Body).Decode(&loginResp)
|
||||
oldRefreshToken := loginResp.RefreshToken
|
||||
|
||||
// Refresh (rotates token)
|
||||
refreshBody, _ := json.Marshal(refreshRequest{RefreshToken: oldRefreshToken})
|
||||
refreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
|
||||
refreshReq.Header.Set("Content-Type", "application/json")
|
||||
refreshRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(refreshRR, refreshReq)
|
||||
|
||||
// Try old refresh token again
|
||||
oldRefreshReq := httptest.NewRequest("POST", "/auth/refresh", bytes.NewBuffer(refreshBody))
|
||||
oldRefreshReq.Header.Set("Content-Type", "application/json")
|
||||
oldRefreshRR := httptest.NewRecorder()
|
||||
router.ServeHTTP(oldRefreshRR, oldRefreshReq)
|
||||
|
||||
if oldRefreshRR.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401 for old refresh token, got %d", oldRefreshRR.Code)
|
||||
}
|
||||
}
|
||||
69
backend/internal/auth/jwt.go
Normal file
69
backend/internal/auth/jwt.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type JWTManager struct {
|
||||
secret []byte
|
||||
accessDuration time.Duration
|
||||
refreshDuration time.Duration
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Plan string `json:"plan"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewJWTManager(secret string, accessDuration, refreshDuration time.Duration) *JWTManager {
|
||||
return &JWTManager{
|
||||
secret: []byte(secret),
|
||||
accessDuration: accessDuration,
|
||||
refreshDuration: refreshDuration,
|
||||
}
|
||||
}
|
||||
|
||||
func (j *JWTManager) GenerateAccessToken(userID, plan string) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Plan: plan,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(j.accessDuration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(j.secret)
|
||||
}
|
||||
|
||||
func (j *JWTManager) GenerateRefreshToken() (string, time.Time) {
|
||||
token := uuid.NewString()
|
||||
expiresAt := time.Now().Add(j.refreshDuration)
|
||||
return token, expiresAt
|
||||
}
|
||||
|
||||
func (j *JWTManager) ValidateAccessToken(tokenStr string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return j.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (j *JWTManager) AccessDuration() time.Duration {
|
||||
return j.accessDuration
|
||||
}
|
||||
86
backend/internal/auth/jwt_test.go
Normal file
86
backend/internal/auth/jwt_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateAccessToken(t *testing.T) {
|
||||
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
||||
|
||||
token, err := jm.GenerateAccessToken("user-123", "free")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected non-empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAccessToken_Valid(t *testing.T) {
|
||||
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
||||
|
||||
token, _ := jm.GenerateAccessToken("user-123", "free")
|
||||
claims, err := jm.ValidateAccessToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if claims.UserID != "user-123" {
|
||||
t.Errorf("expected user_id 'user-123', got %q", claims.UserID)
|
||||
}
|
||||
if claims.Plan != "free" {
|
||||
t.Errorf("expected plan 'free', got %q", claims.Plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAccessToken_Expired(t *testing.T) {
|
||||
jm := NewJWTManager("test-secret", -1*time.Second, 720*time.Hour)
|
||||
|
||||
token, _ := jm.GenerateAccessToken("user-123", "free")
|
||||
_, err := jm.ValidateAccessToken(token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAccessToken_WrongSecret(t *testing.T) {
|
||||
jm1 := NewJWTManager("secret-1", 15*time.Minute, 720*time.Hour)
|
||||
jm2 := NewJWTManager("secret-2", 15*time.Minute, 720*time.Hour)
|
||||
|
||||
token, _ := jm1.GenerateAccessToken("user-123", "free")
|
||||
_, err := jm2.ValidateAccessToken(token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for wrong secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAccessToken_InvalidToken(t *testing.T) {
|
||||
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
||||
|
||||
_, err := jm.ValidateAccessToken("invalid-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRefreshToken(t *testing.T) {
|
||||
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
||||
|
||||
token, expiresAt := jm.GenerateRefreshToken()
|
||||
if token == "" {
|
||||
t.Fatal("expected non-empty refresh token")
|
||||
}
|
||||
if expiresAt.Before(time.Now()) {
|
||||
t.Fatal("expected future expiration time")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRefreshToken_Unique(t *testing.T) {
|
||||
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
||||
|
||||
token1, _ := jm.GenerateRefreshToken()
|
||||
token2, _ := jm.GenerateRefreshToken()
|
||||
if token1 == token2 {
|
||||
t.Fatal("expected unique refresh tokens")
|
||||
}
|
||||
}
|
||||
11
backend/internal/auth/mocks/token_verifier.go
Normal file
11
backend/internal/auth/mocks/token_verifier.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package mocks
|
||||
|
||||
import "context"
|
||||
|
||||
type MockTokenVerifier struct {
|
||||
VerifyTokenFn func(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error)
|
||||
}
|
||||
|
||||
func (m *MockTokenVerifier) VerifyToken(ctx context.Context, idToken string) (uid, email, name, avatarURL string, err error) {
|
||||
return m.VerifyTokenFn(ctx, idToken)
|
||||
}
|
||||
91
backend/internal/auth/service.go
Normal file
91
backend/internal/auth/service.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
tokenVerifier TokenVerifier
|
||||
userRepo user.UserRepository
|
||||
jwtManager *JWTManager
|
||||
}
|
||||
|
||||
func NewService(tokenVerifier TokenVerifier, userRepo user.UserRepository, jwtManager *JWTManager) *Service {
|
||||
return &Service{
|
||||
tokenVerifier: tokenVerifier,
|
||||
userRepo: userRepo,
|
||||
jwtManager: jwtManager,
|
||||
}
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
User *user.User `json:"user"`
|
||||
}
|
||||
|
||||
type RefreshResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, firebaseToken string) (*LoginResponse, error) {
|
||||
uid, email, name, avatarURL, err := s.tokenVerifier.VerifyToken(ctx, firebaseToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("verify firebase token: %w", err)
|
||||
}
|
||||
|
||||
u, err := s.userRepo.UpsertByFirebaseUID(ctx, uid, email, name, avatarURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("upsert user: %w", err)
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtManager.GenerateAccessToken(u.ID, u.Plan)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate access token: %w", err)
|
||||
}
|
||||
|
||||
refreshToken, expiresAt := s.jwtManager.GenerateRefreshToken()
|
||||
if err := s.userRepo.SetRefreshToken(ctx, u.ID, refreshToken, expiresAt); err != nil {
|
||||
return nil, fmt.Errorf("set refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &LoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: int(s.jwtManager.AccessDuration().Seconds()),
|
||||
User: u,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*RefreshResponse, error) {
|
||||
u, err := s.userRepo.FindByRefreshToken(ctx, refreshToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid refresh token: %w", err)
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtManager.GenerateAccessToken(u.ID, u.Plan)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate access token: %w", err)
|
||||
}
|
||||
|
||||
newRefreshToken, expiresAt := s.jwtManager.GenerateRefreshToken()
|
||||
if err := s.userRepo.SetRefreshToken(ctx, u.ID, newRefreshToken, expiresAt); err != nil {
|
||||
return nil, fmt.Errorf("set refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &RefreshResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: newRefreshToken,
|
||||
ExpiresIn: int(s.jwtManager.AccessDuration().Seconds()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, userID string) error {
|
||||
return s.userRepo.ClearRefreshToken(ctx, userID)
|
||||
}
|
||||
213
backend/internal/auth/service_test.go
Normal file
213
backend/internal/auth/service_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/food-ai/backend/internal/auth/mocks"
|
||||
"github.com/food-ai/backend/internal/user"
|
||||
umocks "github.com/food-ai/backend/internal/user/mocks"
|
||||
)
|
||||
|
||||
func newTestService(verifier *mocks.MockTokenVerifier, repo *umocks.MockUserRepository) *Service {
|
||||
jm := NewJWTManager("test-secret", 15*time.Minute, 720*time.Hour)
|
||||
return NewService(verifier, repo, jm)
|
||||
}
|
||||
|
||||
func TestLogin_Success(t *testing.T) {
|
||||
verifier := &mocks.MockTokenVerifier{
|
||||
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
|
||||
return "fb-uid", "test@example.com", "Test User", "https://avatar.url", nil
|
||||
},
|
||||
}
|
||||
repo := &umocks.MockUserRepository{
|
||||
UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
|
||||
return &user.User{ID: "user-1", Email: email, Name: name, Plan: "free"}, nil
|
||||
},
|
||||
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
resp, err := svc.Login(context.Background(), "firebase-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.AccessToken == "" {
|
||||
t.Error("expected non-empty access token")
|
||||
}
|
||||
if resp.RefreshToken == "" {
|
||||
t.Error("expected non-empty refresh token")
|
||||
}
|
||||
if resp.User.ID != "user-1" {
|
||||
t.Errorf("expected user ID 'user-1', got %q", resp.User.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_InvalidFirebaseToken(t *testing.T) {
|
||||
verifier := &mocks.MockTokenVerifier{
|
||||
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
|
||||
return "", "", "", "", fmt.Errorf("invalid token")
|
||||
},
|
||||
}
|
||||
repo := &umocks.MockUserRepository{}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
_, err := svc.Login(context.Background(), "bad-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid firebase token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_UpsertError(t *testing.T) {
|
||||
verifier := &mocks.MockTokenVerifier{
|
||||
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
|
||||
return "fb-uid", "test@example.com", "Test", "", nil
|
||||
},
|
||||
}
|
||||
repo := &umocks.MockUserRepository{
|
||||
UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
|
||||
return nil, fmt.Errorf("db error")
|
||||
},
|
||||
}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
_, err := svc.Login(context.Background(), "token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for upsert failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_SetRefreshTokenError(t *testing.T) {
|
||||
verifier := &mocks.MockTokenVerifier{
|
||||
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
|
||||
return "fb-uid", "test@example.com", "Test", "", nil
|
||||
},
|
||||
}
|
||||
repo := &umocks.MockUserRepository{
|
||||
UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
|
||||
return &user.User{ID: "user-1", Plan: "free"}, nil
|
||||
},
|
||||
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
return fmt.Errorf("db error")
|
||||
},
|
||||
}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
_, err := svc.Login(context.Background(), "token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for set refresh token failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_Success(t *testing.T) {
|
||||
repo := &umocks.MockUserRepository{
|
||||
FindByRefreshTokenFn: func(ctx context.Context, token string) (*user.User, error) {
|
||||
return &user.User{ID: "user-1", Plan: "free"}, nil
|
||||
},
|
||||
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
verifier := &mocks.MockTokenVerifier{}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
resp, err := svc.Refresh(context.Background(), "valid-refresh-token")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.AccessToken == "" {
|
||||
t.Error("expected non-empty access token")
|
||||
}
|
||||
if resp.RefreshToken == "" {
|
||||
t.Error("expected non-empty refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_InvalidToken(t *testing.T) {
|
||||
repo := &umocks.MockUserRepository{
|
||||
FindByRefreshTokenFn: func(ctx context.Context, token string) (*user.User, error) {
|
||||
return nil, fmt.Errorf("not found")
|
||||
},
|
||||
}
|
||||
verifier := &mocks.MockTokenVerifier{}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
_, err := svc.Refresh(context.Background(), "bad-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid refresh token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefresh_SetRefreshTokenError(t *testing.T) {
|
||||
repo := &umocks.MockUserRepository{
|
||||
FindByRefreshTokenFn: func(ctx context.Context, token string) (*user.User, error) {
|
||||
return &user.User{ID: "user-1", Plan: "free"}, nil
|
||||
},
|
||||
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
return fmt.Errorf("db error")
|
||||
},
|
||||
}
|
||||
verifier := &mocks.MockTokenVerifier{}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
_, err := svc.Refresh(context.Background(), "valid-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogout_Success(t *testing.T) {
|
||||
repo := &umocks.MockUserRepository{
|
||||
ClearRefreshTokenFn: func(ctx context.Context, id string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
verifier := &mocks.MockTokenVerifier{}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
err := svc.Logout(context.Background(), "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogout_Error(t *testing.T) {
|
||||
repo := &umocks.MockUserRepository{
|
||||
ClearRefreshTokenFn: func(ctx context.Context, id string) error {
|
||||
return fmt.Errorf("db error")
|
||||
},
|
||||
}
|
||||
verifier := &mocks.MockTokenVerifier{}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
err := svc.Logout(context.Background(), "user-1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogin_ExpiresIn(t *testing.T) {
|
||||
verifier := &mocks.MockTokenVerifier{
|
||||
VerifyTokenFn: func(ctx context.Context, idToken string) (string, string, string, string, error) {
|
||||
return "fb-uid", "test@example.com", "Test", "", nil
|
||||
},
|
||||
}
|
||||
repo := &umocks.MockUserRepository{
|
||||
UpsertByFirebaseUIDFn: func(ctx context.Context, uid, email, name, avatarURL string) (*user.User, error) {
|
||||
return &user.User{ID: "user-1", Plan: "free"}, nil
|
||||
},
|
||||
SetRefreshTokenFn: func(ctx context.Context, id, token string, expiresAt time.Time) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := newTestService(verifier, repo)
|
||||
resp, _ := svc.Login(context.Background(), "token")
|
||||
if resp.ExpiresIn != 900 {
|
||||
t.Errorf("expected expires_in 900, got %d", resp.ExpiresIn)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user