Files
archivmail/internal/api/auth_handlers.go
T
sysopsandClaude Sonnet 5 da79a56b3e feat(PROJ-70): Self-Service IMAP-Rückholung (Backend)
Neuer Opt-in-Endpunkt, mit dem User archivierte Mails per IMAP APPEND
zurück in ihr eigenes externes Postfach (INBOX) kopieren können. Das
Archiv selbst bleibt read-only (nur storage.Load(), kein Schreibzugriff
auf internal/imapserver).

- PATCH /api/auth/imap-restore: Opt-in-Flag umschalten, Aktivierung
  erfordert Passwort-Reverifikation.
- POST /api/mails/{id}/restore: lädt Mail lesend, prüft Mail- und
  Account-Ownership in restoreAccessAllowed() (PROJ-61-Muster), APPEND
  via neuer internal/imap/append.go, kein Admin-Override.
- Audit-Log-Eintrag (EventRestore) pro Versuch, erfolgreich und
  fehlgeschlagen.
- imap_restore_enabled-Spalte (default false) via idempotenter
  initSchema-Migration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 00:36:44 +02:00

159 lines
3.9 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,
TenantID: user.TenantID,
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,
TenantID: user.TenantID,
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
}
// PROJ-70: expose the restore opt-in flag so the frontend can show/hide the
// "Zurück ins Postfach"-Button. Best effort — a lookup error defaults to false.
restoreEnabled, _ := s.users.GetRestoreEnabled(r.Context(), user.ID)
writeJSON(w, http.StatusOK, map[string]interface{}{
"username": user.Username,
"email": user.Email,
"role": user.Role,
"list_page_size": user.ListPageSize,
"imap_restore_enabled": restoreEnabled,
})
}
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,
TenantID: sess.TenantID,
IPAddress: s.remoteIP(r),
Success: true,
})
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}