472ba6a087
- users.list_page_size (Default 25), PATCH /api/auth/preferences, Whitelist 25/50/100/200, Wert in login/me-Response - Settings-UI mit Select, /search nutzt gespeicherte Seitengröße - /api/search page_size serverseitig auf max. 500 gecappt fix(PROJ-46): login_attempts-Migration nutzte s.db statt s.pool (Backend kompilierte nicht) feat(PROJ-50): DSGVO-Löschersuchen Backend (dsgvo_requests, Handler, cc_addr/bcc_addr Indexerweiterung) — noch nicht QA'd/deployed
151 lines
3.6 KiB
Go
151 lines
3.6 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
|
|
"archivmail/internal/audit"
|
|
)
|
|
|
|
const (
|
|
loginMaxFailures = 5
|
|
loginWindow = 15 * time.Minute
|
|
)
|
|
|
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
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: s.remoteIP(r),
|
|
Success: false,
|
|
Detail: "rate limited",
|
|
})
|
|
writeError(w, http.StatusTooManyRequests, "too many failed login attempts, try again later")
|
|
return
|
|
}
|
|
|
|
token, user, totpRequired, err := s.authMgr.Login(req.Username, req.Password)
|
|
if err != nil {
|
|
_ = s.users.RecordLoginAttempt(req.Username, s.remoteIP(r))
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventLogin,
|
|
Username: req.Username,
|
|
IPAddress: s.remoteIP(r),
|
|
Success: false,
|
|
Detail: classifyLoginError(err),
|
|
})
|
|
writeError(w, http.StatusUnauthorized, "invalid credentials")
|
|
return
|
|
}
|
|
|
|
// PROJ-24: If TOTP is enabled, return a pending token instead of a full session.
|
|
if totpRequired {
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventLogin,
|
|
Username: user.Username,
|
|
IPAddress: s.remoteIP(r),
|
|
Success: true,
|
|
Detail: "totp_pending",
|
|
})
|
|
writeJSON(w, http.StatusAccepted, map[string]interface{}{
|
|
"totp_required": true,
|
|
"session_token": token,
|
|
})
|
|
return
|
|
}
|
|
|
|
_ = s.users.UpdateLastLogin(user.ID)
|
|
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventLogin,
|
|
Username: user.Username,
|
|
IPAddress: s.remoteIP(r),
|
|
Success: true,
|
|
})
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
MaxAge: 8 * 3600,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
Secure: s.cfg.SecureCookies,
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"user": map[string]interface{}{
|
|
"id": user.ID,
|
|
"username": user.Username,
|
|
"email": user.Email,
|
|
"role": user.Role,
|
|
"list_page_size": user.ListPageSize,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
|
|
sess := sessionFromCtx(r.Context())
|
|
|
|
user, err := s.users.GetByUsername(sess.Username)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "user lookup failed")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
"username": user.Username,
|
|
"email": user.Email,
|
|
"role": user.Role,
|
|
"list_page_size": user.ListPageSize,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|
// Read token from cookie first, then Bearer header
|
|
token := ""
|
|
if c, err := r.Cookie(sessionCookieName); err == nil {
|
|
token = c.Value
|
|
}
|
|
if token == "" {
|
|
token = extractBearerToken(r)
|
|
}
|
|
if token != "" {
|
|
_ = s.authMgr.Logout(token)
|
|
}
|
|
|
|
// Clear the session cookie
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
SameSite: http.SameSiteStrictMode,
|
|
Secure: s.cfg.SecureCookies,
|
|
})
|
|
|
|
sess := sessionFromCtx(r.Context())
|
|
s.audlog.Log(audit.Entry{
|
|
EventType: audit.EventLogout,
|
|
Username: sess.Username,
|
|
IPAddress: s.remoteIP(r),
|
|
Success: true,
|
|
})
|
|
|
|
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
|
}
|