Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
183 lines
5.3 KiB
Go
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)
|
|
}
|