Files
marktvogt.de/backend/internal/domain/auth/handler.go
T
vikingowl 841a69c59a feat: add password change endpoint, random medieval display names
- Add PUT /auth/password for setting/changing passwords (handles both
  first-time set for magic link/OAuth users and change for password users)
- Generate random medieval display names (e.g. Gaukler1025) for new
  magic link and OAuth users instead of leaving display_name empty
- Add has_password field to ProfileData response
2026-02-27 14:38:02 +01:00

211 lines
5.8 KiB
Go

package auth
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"marktvogt.de/backend/internal/domain/user"
"marktvogt.de/backend/internal/pkg/apierror"
"marktvogt.de/backend/internal/pkg/validate"
)
type Handler struct {
service *Service
userRepo user.Repository
}
func NewHandler(service *Service, userRepo user.Repository) *Handler {
return &Handler{service: service, userRepo: userRepo}
}
func (h *Handler) Register(c *gin.Context) {
var req RegisterRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
data, err := h.service.Register(c.Request.Context(), req, c.ClientIP(), c.GetHeader("User-Agent"))
if err != nil {
if errors.Is(err, user.ErrEmailAlreadyTaken) {
apiErr := apierror.Conflict("email already registered")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("registration failed")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusCreated, AuthResponse{Data: data})
}
func (h *Handler) Login(c *gin.Context) {
var req LoginRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
data, err := h.service.Login(c.Request.Context(), req, c.ClientIP(), c.GetHeader("User-Agent"))
if err != nil {
msg := err.Error()
if msg == "invalid credentials" {
apiErr := apierror.Unauthorized("invalid email or password")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
if msg == "2fa_required" {
apiErr := apierror.BadRequest("2fa_required", "2FA code required")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("login failed")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, AuthResponse{Data: data})
}
func (h *Handler) Logout(c *gin.Context) {
sessionToken := extractSessionToken(c)
if sessionToken == "" {
apiErr := apierror.Unauthorized("session token required")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
tokenHash := HashToken(sessionToken)
if err := h.service.Logout(c.Request.Context(), tokenHash); err != nil {
apiErr := apierror.Internal("logout failed")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "logged out"}})
}
func (h *Handler) Refresh(c *gin.Context) {
sessionToken := extractSessionToken(c)
if sessionToken == "" {
apiErr := apierror.Unauthorized("session token required")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
data, err := h.service.RefreshToken(c.Request.Context(), sessionToken, c.ClientIP(), c.GetHeader("User-Agent"))
if err != nil {
apiErr := apierror.Unauthorized("invalid or expired session")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, AuthResponse{Data: data})
}
func (h *Handler) SetupTOTP(c *gin.Context) {
userID := GetUserID(c)
u, err := h.userRepo.GetByID(c.Request.Context(), userID)
if err != nil {
apiErr := apierror.Internal("failed to get user")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
data, err := h.service.SetupTOTP(c.Request.Context(), userID, u.Email)
if err != nil {
apiErr := apierror.Internal("failed to setup 2FA")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, TOTPSetupResponse{Data: data})
}
func (h *Handler) VerifyTOTP(c *gin.Context) {
var req TOTPVerifyRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
userID := GetUserID(c)
if err := h.service.VerifyTOTPSetup(c.Request.Context(), userID, req.Code); err != nil {
apiErr := apierror.BadRequest("invalid_code", err.Error())
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "2FA enabled"}})
}
func (h *Handler) ChangePassword(c *gin.Context) {
var req ChangePasswordRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
userID := GetUserID(c)
if err := h.service.ChangePassword(c.Request.Context(), userID, req); err != nil {
msg := err.Error()
if msg == "current password required" || msg == "current password incorrect" {
apiErr := apierror.BadRequest("invalid_password", msg)
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
apiErr := apierror.Internal("failed to change password")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "password updated"}})
}
func (h *Handler) DisableTOTP(c *gin.Context) {
var req TOTPVerifyRequest
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
userID := GetUserID(c)
if err := h.service.DisableTOTP(c.Request.Context(), userID, req.Code); err != nil {
apiErr := apierror.BadRequest("invalid_code", err.Error())
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
return
}
c.JSON(http.StatusOK, MessageResponse{Data: MessageData{Message: "2FA disabled"}})
}
func extractSessionToken(c *gin.Context) string {
header := c.GetHeader("X-Session-Token")
if header != "" {
return header
}
// Also check Authorization with Bearer prefix for refresh
auth := c.GetHeader("Authorization")
if strings.HasPrefix(auth, "Session ") {
return strings.TrimPrefix(auth, "Session ")
}
return ""
}
func GetUserID(c *gin.Context) uuid.UUID {
v, exists := c.Get("user_id")
if !exists {
return uuid.Nil
}
id, ok := v.(uuid.UUID)
if !ok {
return uuid.Nil
}
return id
}