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:
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
22
backend/internal/domain/user/displayname.go
Normal file
22
backend/internal/domain/user/displayname.go
Normal 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)
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user