diff --git a/internal/api/handler/user.go b/internal/api/handler/user.go index 7a3e92d..6df0278 100644 --- a/internal/api/handler/user.go +++ b/internal/api/handler/user.go @@ -11,10 +11,11 @@ import ( type UserHandler struct { UserService *domain.UserService + AuthService *domain.AuthService } -func NewUserHandler(userService *domain.UserService) *UserHandler { - return &UserHandler{UserService: userService} +func NewUserHandler(userService *domain.UserService, authService *domain.AuthService) *UserHandler { + return &UserHandler{UserService: userService, AuthService: authService} } // 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)) 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) +} diff --git a/internal/api/request/user.go b/internal/api/request/user.go index bca21b0..90a97e9 100644 --- a/internal/api/request/user.go +++ b/internal/api/request/user.go @@ -5,3 +5,7 @@ type CreateUserPayload struct { Name string `json:"name"` Password string `json:"password"` } + +type ChangePasswordPayload struct { + Password string `json:"password"` +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 105013b..a23e28f 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -16,14 +16,14 @@ type Config struct { } func NewMux(cfg Config) http.Handler { - userRepo := repository.NewUserRepo(cfg.Database) - userService := domain.NewUserService(userRepo) - userHandler := handler.NewUserHandler(userService) - authRepo := repository.NewAuthRepo(cfg.Database) authService := domain.NewAuthService(authRepo, []byte(cfg.JWTSecret)) authHandler := handler.NewAuthHandler(authService) + userRepo := repository.NewUserRepo(cfg.Database) + userService := domain.NewUserService(userRepo) + userHandler := handler.NewUserHandler(userService, authService) + listRepo := repository.NewListRepo(cfg.Database) listService := domain.NewListService(listRepo) listHandler := handler.NewListHandler(listService, userService) @@ -40,6 +40,7 @@ func NewMux(cfg Config) http.Handler { apiMux.HandleFunc("POST /logout/all", authHandler.LogoutAllDevices) apiMux.Handle("POST /users", protected(userHandler.CreateUser)) + apiMux.Handle("POST /users/password", protected(userHandler.ChangePassword)) apiMux.Handle("POST /lists", protected(listHandler.CreateList)) apiMux.Handle("DELETE /lists/{id}", protected(listHandler.DeleteList)) diff --git a/internal/domain/user.go b/internal/domain/user.go index 4f57065..d859dae 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -8,6 +8,13 @@ import ( "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 { ID string `json:"id"` Email string `json:"email"` @@ -17,6 +24,7 @@ type User struct { type UserRepository interface { 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) } @@ -30,13 +38,13 @@ func NewUserService(r UserRepository) *UserService { func (s *UserService) CreateUser(ctx context.Context, email string, name string, password string) (*User, error) { if len(email) == 0 { - return nil, errors.New("email is required") + return nil, ErrEmailMissing } if len(name) == 0 { - return nil, errors.New("name is required") + return nil, ErrNameMissing } if len(password) == 0 { - return nil, errors.New("password is required") + return nil, ErrPasswordMissing } 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)) } +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) { return s.repo.GetUserByEmail(ctx, email) } diff --git a/internal/repository/user.go b/internal/repository/user.go index 177a465..eff6efe 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -40,6 +40,18 @@ func (r *UserRepo) CreateUser(ctx context.Context, email string, name string, pa 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) { user := &domain.User{}