mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-03 20:13:00 +00:00
514 lines
14 KiB
Go
514 lines
14 KiB
Go
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()
|
|
}
|
|
}
|