auth.go
⎇
Raw
1package web
2
3import (
4 "context"
5 "crypto/hmac"
6 "crypto/rand"
7 "crypto/sha256"
8 "crypto/subtle"
9 "encoding/hex"
10 "fmt"
11 "net/http"
12 "strconv"
13 "strings"
14 "time"
15
16 "golang.org/x/crypto/bcrypt"
17)
18
19// Sitzung = signiertes Cookie (Ablaufzeit + HMAC), kein Serverzustand.
20// Der Schlüssel enthält ein gespeichertes Geheimnis; Abmelden erneuert es und
21// macht damit alle bisherigen Cookies ungültig (z. B. verlorenes Tablet).
22const (
23 sessionCookie = "physiobase_session"
24 sessionTTL = 30 * 24 * time.Hour
25 maxConcurrentLogins = 2 // bcrypt ist absichtlich langsam
26 loginWait = 3 * time.Second // so lange wird auf einen Slot gewartet
27 maxLoginBody = 4 << 10
28)
29
30func randomHex(n int) string {
31 b := make([]byte, n)
32 rand.Read(b)
33 return hex.EncodeToString(b)
34}
35
36func (s *Server) loadSessionSecret(ctx context.Context) error {
37 secret, err := s.st.Setting(ctx, "session_secret")
38 if err != nil {
39 return err
40 }
41 if secret == "" {
42 secret = randomHex(32)
43 if err := s.st.SetSetting(ctx, "session_secret", secret); err != nil {
44 return err
45 }
46 }
47 s.sessionSecret = secret
48 return nil
49}
50
51func (s *Server) rotateSessionSecret(ctx context.Context) error {
52 secret := randomHex(32)
53 if err := s.st.SetSetting(ctx, "session_secret", secret); err != nil {
54 return err
55 }
56 s.sessionMu.Lock()
57 s.sessionSecret = secret
58 s.sessionMu.Unlock()
59 return nil
60}
61
62func (s *Server) sessionKey() []byte {
63 s.sessionMu.RLock()
64 secret := s.sessionSecret
65 s.sessionMu.RUnlock()
66 // Benutzername/Hash mit drin: Passwortwechsel beendet alle Sitzungen
67 sum := sha256.Sum256([]byte(s.cfg.Username + ":" + s.cfg.PasswordHash + ":" + secret))
68 return sum[:]
69}
70
71func (s *Server) sign(exp int64) string {
72 m := hmac.New(sha256.New, s.sessionKey())
73 m.Write([]byte(strconv.FormatInt(exp, 10)))
74 return hex.EncodeToString(m.Sum(nil))
75}
76
77func (s *Server) setCookie(w http.ResponseWriter, value string, maxAge int) {
78 // Secure nur hinter HTTPS (PHYSIOBASE_BASE_URL=https://…); im LAN läuft die App auch per HTTP.
79 http.SetCookie(w, &http.Cookie{ //nolint:gosec // G124: Secure hängt bewusst von der Konfiguration ab
80 Name: sessionCookie, Value: value, Path: "/", MaxAge: maxAge,
81 HttpOnly: true, Secure: s.cfg.IsHTTPS(), SameSite: http.SameSiteLaxMode,
82 })
83}
84
85func (s *Server) hasSession(r *http.Request) bool {
86 c, err := r.Cookie(sessionCookie)
87 if err != nil {
88 return false
89 }
90 raw, mac, ok := strings.Cut(c.Value, "|")
91 if !ok {
92 return false
93 }
94 exp, err := strconv.ParseInt(raw, 10, 64)
95 if err != nil || !hmac.Equal([]byte(mac), []byte(s.sign(exp))) {
96 return false
97 }
98 return time.Now().Unix() < exp
99}
100
101func publicPath(p string) bool {
102 return p == "/login" || p == "/healthz" || strings.HasPrefix(p, "/static/")
103}
104
105func (s *Server) requireAuth(next http.Handler) http.Handler {
106 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
107 if publicPath(r.URL.Path) || s.hasSession(r) {
108 next.ServeHTTP(w, r)
109 return
110 }
111 http.Redirect(w, r, "/login", http.StatusSeeOther)
112 })
113}
114
115func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
116 if s.hasSession(r) {
117 http.Redirect(w, r, "/", http.StatusSeeOther)
118 return
119 }
120 s.render(w, r, "login", http.StatusOK, nil)
121}
122
123func (s *Server) login(w http.ResponseWriter, r *http.Request) {
124 r.Body = http.MaxBytesReader(w, r.Body, maxLoginBody)
125 if err := r.ParseForm(); err != nil {
126 http.Error(w, "Formular ungültig", http.StatusBadRequest)
127 return
128 }
129 // Erst einen Slot holen, dann hashen – begrenzt die CPU-Last bei vielen Versuchen.
130 select {
131 case s.loginSem <- struct{}{}:
132 defer func() { <-s.loginSem }()
133 case <-time.After(loginWait):
134 s.render(w, r, "login", http.StatusTooManyRequests, map[string]any{"Error": "Zu viele Anmeldeversuche, bitte gleich nochmal."})
135 return
136 }
137 // Passwort immer prüfen, auch bei falschem Benutzernamen (gleiche Laufzeit).
138 userOK := subtle.ConstantTimeCompare([]byte(r.PostFormValue("username")), []byte(s.cfg.Username)) == 1
139 passOK := bcrypt.CompareHashAndPassword([]byte(s.cfg.PasswordHash), []byte(r.PostFormValue("password"))) == nil
140 if !userOK || !passOK {
141 s.log.Warn("fehlgeschlagene anmeldung", "remote", r.RemoteAddr)
142 time.Sleep(time.Second)
143 s.render(w, r, "login", http.StatusUnauthorized, map[string]any{"Error": "Benutzername oder Passwort falsch."})
144 return
145 }
146 exp := time.Now().Add(sessionTTL).Unix()
147 s.setCookie(w, fmt.Sprintf("%d|%s", exp, s.sign(exp)), int(sessionTTL.Seconds()))
148 http.Redirect(w, r, "/", http.StatusSeeOther)
149}
150
151func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
152 if s.hasSession(r) {
153 if err := s.rotateSessionSecret(r.Context()); err != nil {
154 s.fail(w, r, err)
155 return
156 }
157 }
158 s.setCookie(w, "", -1)
159 http.Redirect(w, r, "/login", http.StatusSeeOther)
160}
161
162// HashPassword wird vom Unterbefehl "hash-password" benutzt.
163func HashPassword(pw string) (string, error) {
164 h, err := bcrypt.GenerateFromPassword([]byte(pw), 12)
165 return string(h), err
166}
167