package api import ( "encoding/json" "errors" "net/http" "strings" "archivmail/internal/audit" "github.com/jackc/pgx/v5/pgconn" "golang.org/x/crypto/bcrypt" ) // ── PROJ-25: Profile Handlers (Password & Email Change) ───────────────── // handleChangePassword allows a local user to change their own password. // PATCH /api/auth/password func (s *Server) handleChangePassword(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.UserID == 0 { writeError(w, http.StatusUnauthorized, "not authenticated") return } var req struct { CurrentPassword string `json:"current_password"` NewPassword string `json:"new_password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } // Load user user, err := s.users.GetByUsername(sess.Username) if err != nil { s.logger.Error("change_password: user not found", "err", err, "username", sess.Username) writeError(w, http.StatusInternalServerError, "user not found") return } // LDAP users cannot change password here if user.Source == "ldap" { writeError(w, http.StatusBadRequest, "password is managed by LDAP") return } // Verify current password currentHash, err := s.users.GetPasswordHash(r.Context(), user.ID) if err != nil { s.logger.Error("change_password: get hash failed", "err", err) writeError(w, http.StatusInternalServerError, "internal error") return } if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.CurrentPassword)); err != nil { writeError(w, http.StatusForbidden, "current password is incorrect") return } // Validate new password length if len(req.NewPassword) < 8 { writeError(w, http.StatusBadRequest, "new password must be at least 8 characters") return } // Hash new password newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), 12) if err != nil { s.logger.Error("change_password: bcrypt failed", "err", err) writeError(w, http.StatusInternalServerError, "internal error") return } // Persist if err := s.users.UpdatePassword(r.Context(), user.ID, string(newHash)); err != nil { s.logger.Error("change_password: update failed", "err", err) writeError(w, http.StatusInternalServerError, "failed to update password") return } s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, IPAddress: s.remoteIP(r), Success: true, Detail: "change_password", }) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } // handleChangeEmail allows a local user to change their own email address. // PATCH /api/auth/email func (s *Server) handleChangeEmail(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.UserID == 0 { writeError(w, http.StatusUnauthorized, "not authenticated") return } var req struct { Email string `json:"email"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } // Load user user, err := s.users.GetByUsername(sess.Username) if err != nil { s.logger.Error("change_email: user not found", "err", err, "username", sess.Username) writeError(w, http.StatusInternalServerError, "user not found") return } // LDAP users cannot change email here if user.Source == "ldap" { writeError(w, http.StatusBadRequest, "email is managed by LDAP") return } // Basic email validation if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") { writeError(w, http.StatusBadRequest, "invalid email address") return } // Persist if err := s.users.UpdateEmail(r.Context(), user.ID, req.Email); err != nil { // Check for unique constraint violation (duplicate email) var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { writeError(w, http.StatusConflict, "email already in use") return } s.logger.Error("change_email: update failed", "err", err) writeError(w, http.StatusInternalServerError, "failed to update email") return } s.audlog.Log(audit.Entry{ EventType: audit.EventUserMgmt, Username: sess.Username, IPAddress: s.remoteIP(r), Success: true, Detail: "change_email", }) writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "email": req.Email}) }