- DB-Migration 002_auth.sql (users + sessions Tabellen) - AuthStore mit Session-Management, bcrypt, EnsureAdminUser - Login/Logout Handler mit Cookie-Session (HttpOnly, SameSite=Lax) - Login-Template (Bulma-Card, deutsche Labels) - Config: AdminPassword, DefaultTenantSlug, DevMode - Fallback-Texte: "Netzwerk offline" → "Server nicht erreichbar" - TENANT-FEATURE-PLAN.md mit 46 Checkboxen als Steuerungsdatei Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
158 lines
4.6 KiB
Go
158 lines
4.6 KiB
Go
package manage
|
||
|
||
import (
|
||
"html/template"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.az-it.net/az/morz-infoboard/server/backend/internal/config"
|
||
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
||
"golang.org/x/crypto/bcrypt"
|
||
)
|
||
|
||
const (
|
||
sessionCookieName = "morz_session"
|
||
sessionTTL = 8 * time.Hour
|
||
)
|
||
|
||
// loginData is the template data for the login page.
|
||
type loginData struct {
|
||
Error string
|
||
Next string
|
||
}
|
||
|
||
// HandleLoginUI renders the login form (GET /login).
|
||
// If a valid session cookie is already present, the user is redirected to /admin
|
||
// (or the tenant dashboard once tenants are wired up in Phase 3).
|
||
func HandleLoginUI(authStore *store.AuthStore) http.HandlerFunc {
|
||
tmpl := template.Must(template.New("login").Parse(loginTmpl))
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
// Redirect if already logged in.
|
||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||
if _, err := authStore.GetSessionUser(r.Context(), cookie.Value); err == nil {
|
||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||
return
|
||
}
|
||
}
|
||
|
||
next := r.URL.Query().Get("next")
|
||
data := loginData{Next: sanitizeNext(next)}
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
_ = tmpl.Execute(w, data)
|
||
}
|
||
}
|
||
|
||
// HandleLoginPost handles form submission (POST /login).
|
||
// It validates credentials, creates a session, sets the session cookie and
|
||
// redirects the user based on their role or the ?next= parameter.
|
||
func HandleLoginPost(authStore *store.AuthStore, cfg config.Config) http.HandlerFunc {
|
||
tmpl := template.Must(template.New("login").Parse(loginTmpl))
|
||
|
||
renderError := func(w http.ResponseWriter, next, msg string) {
|
||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||
w.WriteHeader(http.StatusUnauthorized)
|
||
_ = tmpl.Execute(w, loginData{Error: msg, Next: next})
|
||
}
|
||
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
if err := r.ParseForm(); err != nil {
|
||
renderError(w, "", "Ungültige Anfrage.")
|
||
return
|
||
}
|
||
|
||
username := strings.TrimSpace(r.FormValue("username"))
|
||
password := r.FormValue("password")
|
||
next := sanitizeNext(r.FormValue("next"))
|
||
|
||
if username == "" || password == "" {
|
||
renderError(w, next, "Bitte Benutzername und Passwort eingeben.")
|
||
return
|
||
}
|
||
|
||
user, err := authStore.GetUserByUsername(r.Context(), username)
|
||
if err != nil {
|
||
// Constant-time failure — same message for unknown user and wrong password.
|
||
renderError(w, next, "Benutzername oder Passwort falsch.")
|
||
return
|
||
}
|
||
|
||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||
renderError(w, next, "Benutzername oder Passwort falsch.")
|
||
return
|
||
}
|
||
|
||
session, err := authStore.CreateSession(r.Context(), user.ID, sessionTTL)
|
||
if err != nil {
|
||
renderError(w, next, "Interner Fehler beim Erstellen der Sitzung.")
|
||
return
|
||
}
|
||
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: sessionCookieName,
|
||
Value: session.ID,
|
||
Path: "/",
|
||
MaxAge: int(sessionTTL.Seconds()),
|
||
HttpOnly: true,
|
||
Secure: !cfg.DevMode,
|
||
SameSite: http.SameSiteLaxMode,
|
||
})
|
||
|
||
// Redirect: honour ?next= for relative paths, otherwise role-based default.
|
||
if next != "" {
|
||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||
return
|
||
}
|
||
switch user.Role {
|
||
case "admin":
|
||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||
default:
|
||
// Tenant users – Phase 3 will provide the full tenant slug; for now fall back to /admin.
|
||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||
}
|
||
}
|
||
}
|
||
|
||
// HandleLogoutPost deletes the session and clears the cookie (POST /logout).
|
||
func HandleLogoutPost(authStore *store.AuthStore) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, r *http.Request) {
|
||
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||
_ = authStore.DeleteSession(r.Context(), cookie.Value)
|
||
}
|
||
|
||
// Expire the cookie immediately.
|
||
http.SetCookie(w, &http.Cookie{
|
||
Name: sessionCookieName,
|
||
Value: "",
|
||
Path: "/",
|
||
MaxAge: -1,
|
||
HttpOnly: true,
|
||
SameSite: http.SameSiteLaxMode,
|
||
})
|
||
|
||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||
}
|
||
}
|
||
|
||
// sanitizeNext ensures the redirect target is a safe relative path.
|
||
// Only paths starting with "/" and not containing "//" or a scheme are allowed.
|
||
func sanitizeNext(next string) string {
|
||
if next == "" {
|
||
return ""
|
||
}
|
||
// Reject absolute URLs (contain scheme or authority).
|
||
if strings.Contains(next, "://") || strings.Contains(next, "//") {
|
||
return ""
|
||
}
|
||
// Must start with a slash.
|
||
if !strings.HasPrefix(next, "/") {
|
||
return ""
|
||
}
|
||
// Validate via url.Parse — rejects anything with a host component.
|
||
u, err := url.ParseRequestURI(next)
|
||
if err != nil || u.Host != "" || u.Scheme != "" {
|
||
return ""
|
||
}
|
||
return next
|
||
}
|