feat(PROJ-25): User-Profil & Einstellungen — Passwort, E-Mail, 2FA
Backend: - PATCH /api/auth/password — Passwort ändern (bcrypt, LDAP-Guard, Audit-Log) - PATCH /api/auth/email — E-Mail ändern (Unique-Check, LDAP-Guard, Audit-Log) - userstore: UpdatePassword, UpdateEmail, GetPasswordHash Frontend: - UserNav.tsx: Dropdown-Menü (Profil & Einstellungen, Abmelden) - navbar.tsx: UserNav eingebunden - /settings: Passwort ändern, E-Mail ändern, 2FA verwalten (QR-Code + Deaktivieren) - api.ts: changePassword, changeEmail, getTOTPSetup, confirmTOTPSetup, disableTOTP Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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: 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: remoteIP(r),
|
||||
Success: true,
|
||||
Detail: "change_email",
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "email": req.Email})
|
||||
}
|
||||
@@ -195,6 +195,10 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("POST /api/pop3/{id}/import", s.auth(s.handleStartPop3Import))
|
||||
s.mux.HandleFunc("GET /api/pop3/{id}/progress", s.auth(s.handlePop3Progress))
|
||||
|
||||
// PROJ-25: Profile routes (password & email change)
|
||||
s.mux.HandleFunc("PATCH /api/auth/password", s.auth(s.handleChangePassword))
|
||||
s.mux.HandleFunc("PATCH /api/auth/email", s.auth(s.handleChangeEmail))
|
||||
|
||||
// PROJ-24: TOTP 2FA routes
|
||||
s.mux.HandleFunc("GET /api/auth/totp/setup", s.auth(s.handleTOTPSetupGet))
|
||||
s.mux.HandleFunc("POST /api/auth/totp/setup", s.auth(s.handleTOTPSetupPost))
|
||||
|
||||
Reference in New Issue
Block a user