This commit is contained in:
Tomas Dvorak
2026-05-05 09:48:07 +02:00
parent d854614a87
commit 48c3e15a38
295 changed files with 178381 additions and 1039 deletions
@@ -0,0 +1,513 @@
package handlers
import (
"bookra/apps/auth-service/internal/auth"
"bookra/apps/auth-service/internal/billing"
"bookra/apps/auth-service/internal/config"
"bookra/apps/auth-service/internal/db"
"bookra/apps/auth-service/internal/email"
"bookra/apps/auth-service/internal/oauth"
"context"
"crypto/rand"
"encoding/base64"
"errors"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type Handler struct {
authSvc *auth.Service
neon *auth.NeonVerifier
billingSvc *billing.Service
google *oauth.GoogleProvider
cfg *config.Config
db *db.DB
}
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Locale string `json:"locale,omitempty"`
}
type VerifyRequest struct {
Token string `json:"token" binding:"required"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
type PasswordRegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8"`
Name string `json:"name" binding:"required"`
}
type PasswordLoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
}
type CheckoutRequest struct {
PlanCode string `json:"planCode,omitempty"`
Currency string `json:"currency,omitempty"`
}
func New(db *db.DB, emailSvc *email.Service, cfg *config.Config) (*Handler, error) {
neonVerifier, err := auth.NewNeonVerifier(cfg.NeonAuthURL)
if err != nil {
return nil, err
}
return &Handler{
authSvc: auth.NewService(db, emailSvc, cfg.JWTSecret, cfg.FrontendURL),
neon: neonVerifier,
billingSvc: billing.NewService(cfg, db),
google: oauth.NewGoogleProvider(cfg),
cfg: cfg,
db: db,
}, nil
}
func (h *Handler) RegisterRoutes(r *gin.Engine) {
// Auth API
api := r.Group("/api/auth")
{
api.POST("/magic-link", h.SendMagicLink)
api.POST("/verify", h.VerifyMagicLink)
api.POST("/register", h.RegisterWithPassword)
api.POST("/login", h.LoginWithPassword)
api.POST("/refresh", h.RefreshToken)
api.GET("/me", h.RequireAuth(), h.GetMe)
api.POST("/logout", h.RequireAuth(), h.Logout)
api.GET("/providers", h.ListProviders)
api.GET("/oauth/google", h.GoogleAuth)
api.GET("/oauth/google/callback", h.GoogleCallback)
}
// Billing API
billingAPI := r.Group("/api/billing")
{
billingAPI.POST("/webhook", h.StripeWebhook)
billingAPI.GET("/subscription", h.RequireAuth(), h.GetSubscription)
billingAPI.POST("/checkout", h.RequireAuth(), h.CreateCheckoutSession)
billingAPI.POST("/refresh", h.RequireAuth(), h.RefreshSubscription)
billingAPI.GET("/plans", h.ListPlans)
}
// Admin Dashboard (Visual Management)
admin := NewAdminDashboard(h.cfg, h.db)
admin.RegisterRoutes(r)
}
func (h *Handler) SendMagicLink(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Detect locale from request: JSON body > Accept-Language header > default "en"
locale := req.Locale
if locale == "" {
locale = detectLocale(c)
}
if err := h.authSvc.GenerateMagicLink(c.Request.Context(), req.Email, locale); err != nil {
log.Printf("magic link failed for %s: %v", req.Email, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send magic link"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Magic link sent to your email"})
}
// detectLocale extracts locale from Accept-Language header
func detectLocale(c *gin.Context) string {
acceptLang := c.GetHeader("Accept-Language")
if strings.HasPrefix(acceptLang, "cs") || strings.Contains(acceptLang, "cs-") {
return "cs"
}
// Default to English
return "en"
}
func (h *Handler) VerifyMagicLink(c *gin.Context) {
var req VerifyRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokens, err := h.authSvc.VerifyMagicLink(c.Request.Context(), req.Token)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"})
return
}
c.JSON(http.StatusOK, tokens)
}
func (h *Handler) RegisterWithPassword(c *gin.Context) {
var req PasswordRegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokens, err := h.authSvc.RegisterWithPassword(c.Request.Context(), req.Email, req.Password, req.Name)
if err != nil {
if strings.Contains(err.Error(), "already registered") {
c.JSON(http.StatusConflict, gin.H{"error": "Email already registered"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"})
return
}
c.JSON(http.StatusCreated, tokens)
}
func (h *Handler) LoginWithPassword(c *gin.Context) {
var req PasswordLoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokens, err := h.authSvc.LoginWithPassword(c.Request.Context(), req.Email, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"})
return
}
c.JSON(http.StatusOK, tokens)
}
func (h *Handler) RefreshToken(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
refreshToken := strings.TrimSpace(req.RefreshToken)
if refreshToken == "" {
authHeader := c.GetHeader("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
refreshToken = strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer "))
}
}
if refreshToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing refresh token"})
return
}
tokens, err := h.authSvc.RefreshTokens(c.Request.Context(), refreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid refresh token"})
return
}
c.JSON(http.StatusOK, tokens)
}
func (h *Handler) GetMe(c *gin.Context) {
claims, exists := c.Get("claims")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"})
return
}
userClaims := claims.(*auth.Claims)
c.JSON(http.StatusOK, gin.H{
"id": userClaims.UserID,
"email": userClaims.Email,
"name": userClaims.Name,
"role": userClaims.Role,
})
}
func (h *Handler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
}
func (h *Handler) ListProviders(c *gin.Context) {
providers := []gin.H{}
if h.google.Enabled() {
providers = append(providers, gin.H{
"id": "google",
"name": "Google",
"url": "/api/auth/oauth/google",
})
}
providers = append(providers, gin.H{
"id": "email",
"name": "Email Magic Link",
})
c.JSON(http.StatusOK, gin.H{"providers": providers})
}
func (h *Handler) GoogleAuth(c *gin.Context) {
if !h.google.Enabled() {
c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"})
return
}
state := generateState()
url := h.google.GetAuthURL(state)
c.SetCookie("oauth_state", state, 600, "/", "", oauthCookieSecure(c, h.cfg), true)
c.Redirect(http.StatusTemporaryRedirect, url)
}
func (h *Handler) GoogleCallback(c *gin.Context) {
if !h.google.Enabled() {
c.JSON(http.StatusNotFound, gin.H{"error": "Google OAuth not configured"})
return
}
state := c.Query("state")
expectedState, err := c.Cookie("oauth_state")
if err != nil || state == "" || state != expectedState {
c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid OAuth state"})
return
}
c.SetCookie("oauth_state", "", -1, "/", "", oauthCookieSecure(c, h.cfg), true)
code := c.Query("code")
if code == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing code"})
return
}
user, err := h.google.ExchangeCode(c.Request.Context(), code)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "OAuth failed"})
return
}
providerID, email, name := h.google.ParseUser(user)
tokens, err := h.authSvc.OAuthLoginOrCreate(c.Request.Context(), "google", providerID, email, name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process login"})
return
}
redirectURL := h.cfg.FrontendURL + "/auth/callback?token=" + url.QueryEscape(tokens.AccessToken)
if tokens.RefreshToken != "" {
redirectURL += "&refresh_token=" + url.QueryEscape(tokens.RefreshToken)
}
c.Redirect(http.StatusTemporaryRedirect, redirectURL)
}
func (h *Handler) GetSubscription(c *gin.Context) {
claims, ok := h.claimsFromContext(c)
if !ok {
return
}
snapshot, err := h.billingSvc.GetSubscription(c.Request.Context(), claims.UserID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load subscription"})
return
}
c.JSON(http.StatusOK, snapshot)
}
func (h *Handler) CreateCheckoutSession(c *gin.Context) {
claims, ok := h.claimsFromContext(c)
if !ok {
return
}
var req CheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
response, err := h.billingSvc.CreateCheckoutSession(c.Request.Context(), billing.UserIdentity{
ID: claims.UserID,
Email: claims.Email,
Name: claims.Name,
}, req.PlanCode, req.Currency)
if err != nil {
switch {
case errors.Is(err, billing.ErrPlanNotConfigured):
c.JSON(http.StatusBadRequest, gin.H{"error": "Billing plan is not configured"})
case errors.Is(err, billing.ErrStripeNotConfigured):
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create checkout session"})
}
return
}
c.JSON(http.StatusOK, response)
}
func (h *Handler) RefreshSubscription(c *gin.Context) {
claims, ok := h.claimsFromContext(c)
if !ok {
return
}
snapshot, err := h.billingSvc.Refresh(c.Request.Context(), claims.UserID)
if err != nil {
if errors.Is(err, billing.ErrStripeNotConfigured) {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Stripe is not configured"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh subscription"})
return
}
c.JSON(http.StatusOK, snapshot)
}
// ListPlans returns available billing plans and their configuration status
func (h *Handler) ListPlans(c *gin.Context) {
plans := []gin.H{
{"code": "starter", "name": "Starter", "description": "For individuals and small teams"},
{"code": "pro", "name": "Pro", "description": "For growing businesses"},
{"code": "business", "name": "Business", "description": "For multi-location operations"},
}
// Check which plans are configured
configured := make(map[string]bool)
for planCode, priceID := range h.cfg.StripePriceIDs {
if priceID != "" {
configured[planCode] = true
}
}
for i, plan := range plans {
code := plan["code"].(string)
plan["czkConfigured"] = configured[code+":czk"] || configured[code]
plan["usdConfigured"] = configured[code+":usd"] || configured[code]
plans[i] = plan
}
c.JSON(http.StatusOK, gin.H{
"plans": plans,
"stripeConfigured": h.cfg.StripeCheckoutReady(),
"secretConfigured": h.cfg.StripeSecretConfigured(),
"webhookConfigured": h.cfg.StripeWebhookConfigured(),
"pricesConfigured": h.cfg.StripeHasAnyPriceConfigured(),
"checkoutReady": h.cfg.StripeCheckoutReady(),
"currencies": []string{"czk", "usd"},
})
}
func (h *Handler) StripeWebhook(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20)
payload, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "Webhook payload is too large"})
return
}
if err := h.billingSvc.HandleWebhook(c.Request.Context(), c.GetHeader("Stripe-Signature"), payload); err != nil {
switch {
case errors.Is(err, billing.ErrStripeWebhookMissing), errors.Is(err, billing.ErrStripeSignatureMissing):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Stripe webhook"})
}
return
}
c.JSON(http.StatusOK, gin.H{"received": true})
}
func (h *Handler) RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
tokenString := ""
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
}
if tokenString == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing token"})
c.Abort()
return
}
claims, err := h.verifyBearerToken(tokenString)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
c.Set("claims", claims)
c.Next()
}
}
func (h *Handler) verifyBearerToken(tokenString string) (*auth.Claims, error) {
if h.neon != nil && h.neon.Enabled() {
return h.neon.Verify(tokenString)
}
if h.cfg.AppEnv == "development" {
return h.authSvc.VerifyToken(tokenString)
}
return nil, errors.New("neon auth is not configured")
}
func (h *Handler) claimsFromContext(c *gin.Context) (*auth.Claims, bool) {
claims, exists := c.Get("claims")
if !exists {
c.JSON(http.StatusInternalServerError, gin.H{"error": "claims not found"})
return nil, false
}
userClaims, ok := claims.(*auth.Claims)
if !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
return nil, false
}
return userClaims, true
}
func generateState() string {
buffer := make([]byte, 24)
if _, err := rand.Read(buffer); err != nil {
return "state_" + time.Now().Format("20060102150405")
}
return "state_" + strings.TrimRight(base64.URLEncoding.EncodeToString(buffer), "=")
}
func oauthCookieSecure(c *gin.Context, cfg *config.Config) bool {
if c.Request.TLS != nil {
return true
}
if strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
return true
}
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(cfg.FrontendURL)), "https://")
}
func timeoutMiddleware(duration time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), duration)
defer cancel()
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}