feat(web): Router, Login/Logout, Übersicht, CSRF-geschützter Download, Anleitungen und /healthz
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NBHF4R9EAejDJUMdwr6C68
This commit is contained in:
parent
bb2bc223b9
commit
eb89d78ce2
8 changed files with 1618 additions and 0 deletions
183
internal/web/handlers_login.go
Normal file
183
internal/web/handlers_login.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue