fix(PROJ-1): bcrypt Cost 12, Rate-Limiting, last_login_at, User Update/Delete
- bcrypt cost erhöht von DefaultCost (10) auf 12
- Rate-Limiting: max 5 Fehlversuche in 15 Min → HTTP 429
- last_login_at in DB gespeichert und bei jedem Login aktualisiert
- login_attempts Tabelle für Fehlversuche
- PATCH /api/users/{id}: Passwort-Reset, Rolle, E-Mail, Active
- DELETE /api/users/{id}: Löschen mit Schutz für letzten Admin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -88,6 +88,8 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("POST /api/auth/logout", s.authMiddleware(s.handleLogout))
|
||||
s.mux.HandleFunc("GET /api/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleListUsers)))
|
||||
s.mux.HandleFunc("POST /api/users", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleCreateUser)))
|
||||
s.mux.HandleFunc("PATCH /api/users/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleUpdateUser)))
|
||||
s.mux.HandleFunc("DELETE /api/users/{id}", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleDeleteUser)))
|
||||
s.mux.HandleFunc("GET /api/search", s.authMiddleware(s.handleSearch))
|
||||
s.mux.HandleFunc("GET /api/audit", s.authMiddleware(s.requireRole(userstore.RoleAuditor, s.handleAuditLog)))
|
||||
s.mux.HandleFunc("GET /api/admin/smtp/status", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSMTPStatus)))
|
||||
@@ -124,6 +126,11 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
const (
|
||||
loginMaxFailures = 5
|
||||
loginWindow = 15 * time.Minute
|
||||
)
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
@@ -134,8 +141,23 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Rate-limiting: block after too many recent failures
|
||||
failures, err := s.users.CountRecentFailures(req.Username, loginWindow)
|
||||
if err == nil && failures >= loginMaxFailures {
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventLogin,
|
||||
Username: req.Username,
|
||||
IPAddress: remoteIP(r),
|
||||
Success: false,
|
||||
Detail: "rate limited",
|
||||
})
|
||||
writeError(w, http.StatusTooManyRequests, "too many failed login attempts, try again later")
|
||||
return
|
||||
}
|
||||
|
||||
token, user, err := s.authMgr.Login(req.Username, req.Password)
|
||||
if err != nil {
|
||||
_ = s.users.RecordLoginAttempt(req.Username, remoteIP(r))
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventLogin,
|
||||
Username: req.Username,
|
||||
@@ -147,6 +169,8 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_ = s.users.UpdateLastLogin(user.ID)
|
||||
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventLogin,
|
||||
Username: user.Username,
|
||||
@@ -268,6 +292,81 @@ func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Email *string `json:"email"`
|
||||
Role *string `json:"role"`
|
||||
Active *bool `json:"active"`
|
||||
Password *string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := s.users.Update(id, userstore.UpdateUserRequest{
|
||||
Email: req.Email,
|
||||
Role: req.Role,
|
||||
Active: req.Active,
|
||||
Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventUserMgmt,
|
||||
Username: sess.Username,
|
||||
IPAddress: remoteIP(r),
|
||||
Detail: fmt.Sprintf("updated user %d", id),
|
||||
Success: true,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"id": updated.ID,
|
||||
"username": updated.Username,
|
||||
"email": updated.Email,
|
||||
"role": updated.Role,
|
||||
"active": updated.Active,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid user id")
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.users.DeleteSafe(id); err != nil {
|
||||
if err.Error() == "userstore: cannot delete last admin" {
|
||||
writeError(w, http.StatusConflict, "cannot delete the last active admin")
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
s.audlog.Log(audit.Entry{
|
||||
EventType: audit.EventUserMgmt,
|
||||
Username: sess.Username,
|
||||
IPAddress: remoteIP(r),
|
||||
Detail: fmt.Sprintf("deleted user %d", id),
|
||||
Success: true,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
fromFilter := r.URL.Query().Get("from")
|
||||
|
||||
Reference in New Issue
Block a user