Allow users to change their password #30
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user