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
This commit is contained in:
2026-02-27 14:38:02 +01:00
parent fd879ba026
commit 841a69c59a
8 changed files with 90 additions and 2 deletions

View File

@@ -29,6 +29,11 @@ type TOTPVerifyRequest struct {
Code string `json:"code" validate:"required,len=6"`
}
type ChangePasswordRequest struct {
CurrentPassword string `json:"current_password" validate:"omitempty,min=8,max=128"`
NewPassword string `json:"new_password" validate:"required,min=8,max=128"`
}
type AuthResponse struct {
Data AuthData `json:"data"`
}

View File

@@ -144,6 +144,29 @@ func (h *Handler) VerifyTOTP(c *gin.Context) {
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 {

View File

@@ -165,7 +165,7 @@ func (h *MagicLinkHandler) findOrCreateUser(ctx context.Context, email string) (
}
// Create new user without password
return h.userRepo.CreateOAuthUser(ctx, email, "", true)
return h.userRepo.CreateOAuthUser(ctx, email, user.GenerateDisplayName(), true)
}
func RegisterMagicLinkRoutes(rg *gin.RouterGroup, h *MagicLinkHandler) {

View File

@@ -148,11 +148,16 @@ func (h *OAuthHandler) Callback(c *gin.Context) {
}
// New OAuth account — find or create user
displayName := info.Name
if displayName == "" {
displayName = user.GenerateDisplayName()
}
var u user.User
u, err = h.userRepo.GetByEmail(ctx, info.Email)
if errors.Is(err, user.ErrUserNotFound) {
// Create new user
u, err = h.userRepo.CreateOAuthUser(ctx, info.Email, info.Name, info.EmailVerified)
u, err = h.userRepo.CreateOAuthUser(ctx, info.Email, displayName, info.EmailVerified)
if err != nil {
apiErr := apierror.Internal("failed to create user")
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))

View File

@@ -10,6 +10,9 @@ func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc
auth.POST("/logout", requireAuth, h.Logout)
auth.POST("/refresh", h.Refresh)
// Password
auth.PUT("/password", requireAuth, h.ChangePassword)
// 2FA
auth.POST("/2fa/setup", requireAuth, h.SetupTOTP)
auth.POST("/2fa/verify", requireAuth, h.VerifyTOTP)

View File

@@ -155,6 +155,34 @@ func (s *Service) validateTOTP(ctx context.Context, userID uuid.UUID, code strin
return nil
}
func (s *Service) ChangePassword(ctx context.Context, userID uuid.UUID, req ChangePasswordRequest) error {
u, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return fmt.Errorf("user not found: %w", err)
}
if u.PasswordHash != nil {
if req.CurrentPassword == "" {
return fmt.Errorf("current password required")
}
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(req.CurrentPassword)); err != nil {
return fmt.Errorf("current password incorrect")
}
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hashing password: %w", err)
}
_, err = s.userRepo.Update(ctx, userID, map[string]any{"password_hash": string(hash)})
if err != nil {
return fmt.Errorf("updating password: %w", err)
}
return nil
}
func (s *Service) TokenService() *TokenService {
return s.tokenSvc
}

View File

@@ -0,0 +1,22 @@
package user
import (
"crypto/rand"
"fmt"
"math/big"
)
var medievalWords = []string{
"Gaukler", "Ritter", "Barde", "Knappe", "Schmied",
"Falke", "Bogner", "Spielmann", "Landsknecht", "Alchemist",
"Minnesaenger", "Herold", "Schildmaid", "Falkner", "Marktschreier",
"Kesselflicker", "Schwertfeger", "Turmwaechter", "Glockengiesser", "Pergamentmacher",
"Fackeltraeger", "Kraemer", "Tavernenwirt", "Waffenmeister", "Zinngiesser",
}
func GenerateDisplayName() string {
wordIdx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(medievalWords))))
num, _ := rand.Int(rand.Reader, big.NewInt(9000))
return fmt.Sprintf("%s%d", medievalWords[wordIdx.Int64()], num.Int64()+1000)
}

View File

@@ -13,6 +13,7 @@ type ProfileData struct {
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
Role string `json:"role"`
HasPassword bool `json:"has_password"`
CreatedAt string `json:"created_at"`
}
@@ -29,6 +30,7 @@ func ToProfileData(u User) ProfileData {
DisplayName: u.DisplayName,
AvatarURL: u.AvatarURL,
Role: u.Role,
HasPassword: u.PasswordHash != nil,
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"),
}
}