841a69c59a
- 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
211 lines
5.8 KiB
Go
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
|
|
}
|