From 841a69c59a6bdda4e180ef189c793f2db8b4d9dc Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 27 Feb 2026 14:38:02 +0100 Subject: [PATCH] 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 --- backend/internal/domain/auth/dto.go | 5 ++++ backend/internal/domain/auth/handler.go | 23 +++++++++++++++++ backend/internal/domain/auth/magiclink.go | 2 +- backend/internal/domain/auth/oauth.go | 7 +++++- backend/internal/domain/auth/routes.go | 3 +++ backend/internal/domain/auth/service.go | 28 +++++++++++++++++++++ backend/internal/domain/user/displayname.go | 22 ++++++++++++++++ backend/internal/domain/user/dto.go | 2 ++ 8 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 backend/internal/domain/user/displayname.go diff --git a/backend/internal/domain/auth/dto.go b/backend/internal/domain/auth/dto.go index cc08972..96b0bef 100644 --- a/backend/internal/domain/auth/dto.go +++ b/backend/internal/domain/auth/dto.go @@ -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"` } diff --git a/backend/internal/domain/auth/handler.go b/backend/internal/domain/auth/handler.go index 70fe80f..9fae2a7 100644 --- a/backend/internal/domain/auth/handler.go +++ b/backend/internal/domain/auth/handler.go @@ -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 { diff --git a/backend/internal/domain/auth/magiclink.go b/backend/internal/domain/auth/magiclink.go index b2f529f..f6ad1a4 100644 --- a/backend/internal/domain/auth/magiclink.go +++ b/backend/internal/domain/auth/magiclink.go @@ -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) { diff --git a/backend/internal/domain/auth/oauth.go b/backend/internal/domain/auth/oauth.go index e45a383..ea84d2c 100644 --- a/backend/internal/domain/auth/oauth.go +++ b/backend/internal/domain/auth/oauth.go @@ -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)) diff --git a/backend/internal/domain/auth/routes.go b/backend/internal/domain/auth/routes.go index 4a1a946..daf5d49 100644 --- a/backend/internal/domain/auth/routes.go +++ b/backend/internal/domain/auth/routes.go @@ -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) diff --git a/backend/internal/domain/auth/service.go b/backend/internal/domain/auth/service.go index 4c6d3fa..340f9a6 100644 --- a/backend/internal/domain/auth/service.go +++ b/backend/internal/domain/auth/service.go @@ -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 } diff --git a/backend/internal/domain/user/displayname.go b/backend/internal/domain/user/displayname.go new file mode 100644 index 0000000..fc38eb9 --- /dev/null +++ b/backend/internal/domain/user/displayname.go @@ -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) +} diff --git a/backend/internal/domain/user/dto.go b/backend/internal/domain/user/dto.go index c38e667..314e7df 100644 --- a/backend/internal/domain/user/dto.go +++ b/backend/internal/domain/user/dto.go @@ -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"), } }