opnsense-portal/internal/web/handlers_login.go
2026-08-14 09:25:52 +02:00

183 lines
5.3 KiB
Go

package web
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"git.ravensburg.dev/cabele/opnsense-portal/internal/audit"
"git.ravensburg.dev/cabele/opnsense-portal/internal/auth"
)
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
NoStore(w)
// Eine bereits gültige Session überspringt das Formular.
if c, err := r.Cookie(SessionCookieName); err == nil {
if _, ok := s.d.Sessions.Get(c.Value); ok {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}
var data PageData
switch {
case r.URL.Query().Get("expired") != "":
data.Flash, data.FlashKind = T("error_session_expired"), "info"
case r.URL.Query().Get("abgemeldet") != "":
data.Flash, data.FlashKind = T("logout_done"), "info"
}
s.renderPage(w, r, http.StatusOK, "login", nil, data)
}
// loginFailed rendert die Anmeldeseite mit einer Meldung.
func (s *Server) loginFailed(w http.ResponseWriter, r *http.Request, status int, message string) {
s.renderPage(w, r, status, "login", nil, PageData{Flash: message, FlashKind: "error"})
}
func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) {
NoStore(w)
started := time.Now()
ip := ClientIP(r)
if err := r.ParseForm(); err != nil {
s.loginFailed(w, r, http.StatusBadRequest, T("error_generic_login"))
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
// Rate-Limit vor jedem Verzeichniszugriff prüfen.
if wait, ok := s.d.Limiter.Allow(username, ip); !ok {
s.log(audit.Event{
Event: audit.EventRateLimited,
User: audit.UnknownUser, // an dieser Stelle ist der Name ungeprüft
SrcIP: ip,
Detail: wait.Round(time.Second).String(),
})
w.Header().Set("Retry-After", strconv.Itoa(int(wait.Round(time.Second).Seconds())))
s.loginFailed(w, r, http.StatusTooManyRequests,
fmt.Sprintf(T("error_rate_limited"), humanDuration(wait)))
return
}
id, err := s.d.Auth.Authenticate(r.Context(), username, password)
s.equalizeTiming(started)
if err != nil {
s.d.Limiter.RecordFailure(username, ip)
reason, known := reasonAndKnownUser(err)
s.log(audit.Event{
Event: audit.EventLoginFailed,
User: auditUser(username, known),
SrcIP: ip,
Reason: reason,
})
message := T("error_generic_login")
var ae *auth.Error
if errors.As(err, &ae) && ae.UserVisible() {
message = T("error_password_expired")
}
s.loginFailed(w, r, http.StatusUnauthorized, message)
return
}
s.d.Limiter.RecordSuccess(username, ip)
sess, err := s.d.Sessions.Create(id)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, nil,
"error_internal", "error_internal_body")
return
}
s.setCookie(w, sess)
s.log(audit.Event{
Event: audit.EventLoginSuccess,
User: id.Username,
SrcIP: ip,
Session: audit.ShortSession(sess.Token),
})
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request, sess *Session) {
if err := r.ParseForm(); err != nil || !sess.ValidCSRF(r.PostFormValue("csrf_token")) {
s.renderError(w, r, http.StatusForbidden, sess, "error_forbidden", "error_csrf")
return
}
s.d.Sessions.Destroy(sess.Token)
s.clearCookie(w)
s.log(audit.Event{
Event: audit.EventLogout,
User: sess.Identity.Username,
SrcIP: ClientIP(r),
Session: audit.ShortSession(sess.Token),
})
http.Redirect(w, r, "/login?abgemeldet=1", http.StatusSeeOther)
}
// setCookie setzt das gehärtete Session-Cookie.
func (s *Server) setCookie(w http.ResponseWriter, sess *Session) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: sess.Token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(s.d.SessionTTL.Seconds()),
})
}
func (s *Server) clearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName, Value: "", Path: "/",
HttpOnly: true, Secure: true, SameSite: http.SameSiteStrictMode, MaxAge: -1,
})
}
// equalizeTiming hält die Antwortzeit auf einem Mindestwert, damit die Dauer
// nicht verrät, ob ein Konto existiert.
func (s *Server) equalizeTiming(started time.Time) {
if elapsed := time.Since(started); elapsed < s.d.MinLoginDuration {
time.Sleep(s.d.MinLoginDuration - elapsed)
}
}
// reasonAndKnownUser liefert den Audit-Reason und ob der Benutzer im
// Verzeichnis existiert. Nur dann darf sein Name im Klartext geloggt werden.
func reasonAndKnownUser(err error) (reason string, known bool) {
var ae *auth.Error
if !errors.As(err, &ae) {
return auth.ReasonBackendUnavailable, false
}
switch ae.Reason {
case auth.ReasonUserNotFound, auth.ReasonBackendUnavailable, "":
return ae.Reason, false
default:
// Der Benutzer wurde im Verzeichnis gefunden; sein Name ist ein
// echter Kontoname und kein versehentlich eingegebenes Passwort.
return ae.Reason, true
}
}
// auditUser entscheidet, ob der eingegebene Name im Log erscheinen darf.
func auditUser(input string, known bool) string {
if !known {
return audit.UnknownUser
}
return strings.ToLower(input)
}
// humanDuration formatiert Wartezeiten deutsch lesbar.
func humanDuration(d time.Duration) string {
d = d.Round(time.Second)
if d < time.Minute {
return fmt.Sprintf("%d Sekunden", int(d.Seconds()))
}
minutes := int(d.Minutes())
if minutes == 1 {
return "einer Minute"
}
return fmt.Sprintf("%d Minuten", minutes)
}