Merge pull request 'Allow users to change their password' (#30) from dev into main

This commit was merged in pull request #30.
This commit is contained in:
2026-08-29 17:24:01 +02:00
5 changed files with 97 additions and 9 deletions
+49 -2
View File
@@ -11,10 +11,11 @@ import (
type UserHandler struct { type UserHandler struct {
UserService *domain.UserService UserService *domain.UserService
AuthService *domain.AuthService
} }
func NewUserHandler(userService *domain.UserService) *UserHandler { func NewUserHandler(userService *domain.UserService, authService *domain.AuthService) *UserHandler {
return &UserHandler{UserService: userService} return &UserHandler{UserService: userService, AuthService: authService}
} }
// CreateUser handles the creation of a user // CreateUser handles the creation of a user
@@ -49,3 +50,49 @@ func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
slog.InfoContext(ctx, "created user successfully", slog.Any("user_id", user.ID)) slog.InfoContext(ctx, "created user successfully", slog.Any("user_id", user.ID))
response.JSON(ctx, w, http.StatusCreated, user) response.JSON(ctx, w, http.StatusCreated, user)
} }
// ChangePassword handles changing a users password
//
// @Summary Change password route
// @Description Change password for the authenticated user
// @Tags User
// @Accept json
// @Produce json
// @Param payload body request.ChangePasswordPayload true "Change password payload"
// @Success 204
// @Error 400 {object} response.ErrorResponse "failed to decode request body"
// @Error 500 {object} response.ErrorResponse "could not get auth context"
// @Error 500 {object} response.ErrorResponse "failed to change password"
// @Router /users/password [post]
func (h *UserHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
payload, err := request.DecodeJSON[request.ChangePasswordPayload](r)
if err != nil {
slog.ErrorContext(ctx, "failed to decode change password payload", slog.Any("error", err))
response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body")
return
}
authContext, err := domain.GetAuthContext(ctx)
if err != nil {
slog.ErrorContext(ctx, "failed to get auth context", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "could not get auth context")
return
}
err = h.UserService.ChangePassword(ctx, authContext.UserID, payload.Password)
if err != nil {
slog.ErrorContext(ctx, "failed to change password", slog.Any("error", err))
response.Error(ctx, w, http.StatusInternalServerError, "failed to change password")
return
}
err = h.AuthService.LogoutAllDevices(ctx, authContext.UserID)
if err != nil {
slog.WarnContext(ctx, "failed to logout user from all devices after password change", slog.Any("error", err))
}
slog.InfoContext(ctx, "password changed successfully", slog.Any("user_id", authContext.UserID))
response.Status(ctx, w, http.StatusNoContent)
}
+4
View File
@@ -5,3 +5,7 @@ type CreateUserPayload struct {
Name string `json:"name"` Name string `json:"name"`
Password string `json:"password"` Password string `json:"password"`
} }
type ChangePasswordPayload struct {
Password string `json:"password"`
}
+5 -4
View File
@@ -16,14 +16,14 @@ type Config struct {
} }
func NewMux(cfg Config) http.Handler { func NewMux(cfg Config) http.Handler {
userRepo := repository.NewUserRepo(cfg.Database)
userService := domain.NewUserService(userRepo)
userHandler := handler.NewUserHandler(userService)
authRepo := repository.NewAuthRepo(cfg.Database) authRepo := repository.NewAuthRepo(cfg.Database)
authService := domain.NewAuthService(authRepo, []byte(cfg.JWTSecret)) authService := domain.NewAuthService(authRepo, []byte(cfg.JWTSecret))
authHandler := handler.NewAuthHandler(authService) authHandler := handler.NewAuthHandler(authService)
userRepo := repository.NewUserRepo(cfg.Database)
userService := domain.NewUserService(userRepo)
userHandler := handler.NewUserHandler(userService, authService)
listRepo := repository.NewListRepo(cfg.Database) listRepo := repository.NewListRepo(cfg.Database)
listService := domain.NewListService(listRepo) listService := domain.NewListService(listRepo)
listHandler := handler.NewListHandler(listService, userService) listHandler := handler.NewListHandler(listService, userService)
@@ -40,6 +40,7 @@ func NewMux(cfg Config) http.Handler {
apiMux.HandleFunc("POST /logout/all", authHandler.LogoutAllDevices) apiMux.HandleFunc("POST /logout/all", authHandler.LogoutAllDevices)
apiMux.Handle("POST /users", protected(userHandler.CreateUser)) apiMux.Handle("POST /users", protected(userHandler.CreateUser))
apiMux.Handle("POST /users/password", protected(userHandler.ChangePassword))
apiMux.Handle("POST /lists", protected(listHandler.CreateList)) apiMux.Handle("POST /lists", protected(listHandler.CreateList))
apiMux.Handle("DELETE /lists/{id}", protected(listHandler.DeleteList)) apiMux.Handle("DELETE /lists/{id}", protected(listHandler.DeleteList))
+27 -3
View File
@@ -8,6 +8,13 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
var (
ErrUserIDMissing = errors.New("user id is missing")
ErrEmailMissing = errors.New("email is required")
ErrNameMissing = errors.New("name is required")
ErrPasswordMissing = errors.New("password is required")
)
type User struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Email string `json:"email"` Email string `json:"email"`
@@ -17,6 +24,7 @@ type User struct {
type UserRepository interface { type UserRepository interface {
CreateUser(ctx context.Context, email string, name string, passwordHash string) (*User, error) CreateUser(ctx context.Context, email string, name string, passwordHash string) (*User, error)
ChangePassword(ctx context.Context, userID string, passwordHash string) error
GetUserByEmail(ctx context.Context, email string) (*User, error) GetUserByEmail(ctx context.Context, email string) (*User, error)
} }
@@ -30,13 +38,13 @@ func NewUserService(r UserRepository) *UserService {
func (s *UserService) CreateUser(ctx context.Context, email string, name string, password string) (*User, error) { func (s *UserService) CreateUser(ctx context.Context, email string, name string, password string) (*User, error) {
if len(email) == 0 { if len(email) == 0 {
return nil, errors.New("email is required") return nil, ErrEmailMissing
} }
if len(name) == 0 { if len(name) == 0 {
return nil, errors.New("name is required") return nil, ErrNameMissing
} }
if len(password) == 0 { if len(password) == 0 {
return nil, errors.New("password is required") return nil, ErrPasswordMissing
} }
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
@@ -47,6 +55,22 @@ func (s *UserService) CreateUser(ctx context.Context, email string, name string,
return s.repo.CreateUser(ctx, email, name, string(hash)) return s.repo.CreateUser(ctx, email, name, string(hash))
} }
func (s *UserService) ChangePassword(ctx context.Context, userID string, password string) error {
if len(userID) == 0 {
return ErrUserIDMissing
}
if len(password) == 0 {
return ErrPasswordMissing
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
return s.repo.ChangePassword(ctx, userID, string(hash))
}
func (s *UserService) GetUserByEmail(ctx context.Context, email string) (*User, error) { func (s *UserService) GetUserByEmail(ctx context.Context, email string) (*User, error) {
return s.repo.GetUserByEmail(ctx, email) return s.repo.GetUserByEmail(ctx, email)
} }
+12
View File
@@ -40,6 +40,18 @@ func (r *UserRepo) CreateUser(ctx context.Context, email string, name string, pa
return user, nil return user, nil
} }
func (r *UserRepo) ChangePassword(ctx context.Context, userID string, passwordHash string) error {
_, err := r.db.ExecContext(ctx,
"UPDATE users SET password_hash = $1 WHERE id = $2",
passwordHash, userID,
)
if err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
return nil
}
func (r *UserRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) { func (r *UserRepo) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
user := &domain.User{} user := &domain.User{}