Files
archivmail/internal/api/profile_handlers.go
T
sysops 0fbb1924bb fix(security): W-02 Secure-Cookie-Flag + W-03 TrustedProxies für X-Forwarded-For
W-02: Cookie Secure-Flag ist nun über config.yml steuerbar.
      api.secure_cookies: true/false — default false (kein Breaking Change).
      Alle 3 SetCookie-Aufrufe (Login, Logout, TOTP) nutzen s.cfg.SecureCookies.

W-03: remoteIP() ist jetzt eine Methode und prüft api.trusted_proxies.
      X-Forwarded-For wird nur ausgewertet wenn der direkte Peer in der
      trusted_proxies-Liste steht (IP oder CIDR). Sonst wird r.RemoteAddr
      verwendet — kein Spoofing mehr möglich.
      Neue Hilfsfunktion: isTrustedProxy(ip, proxies).

config.go: APIConfig um SecureCookies bool + TrustedProxies []string erweitert.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 01:10:24 +01:00

153 lines
4.3 KiB
Go

package api
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/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})
}