Login und already-logged-in-Check verwendeten default-Branch für restricted-Rolle, der zum Tenant-Dashboard mit allen Screens führte. Jetzt wie screen_user behandelt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
218 lines
7.2 KiB
Go
218 lines
7.2 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/reqcontext"
|
|
"git.az-it.net/az/morz-infoboard/server/backend/internal/store"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// handleScreenUserRedirect looks up accessible screens for a screen_user and
|
|
// redirects to the first one. If none exist, it redirects to an error page.
|
|
func handleScreenUserRedirect(w http.ResponseWriter, r *http.Request, screenStore *store.ScreenStore, user *store.User) {
|
|
screens, err := screenStore.GetAccessibleScreens(r.Context(), user.ID)
|
|
if err != nil || len(screens) == 0 {
|
|
http.Redirect(w, r, "/login?error=no_screens", http.StatusSeeOther)
|
|
return
|
|
}
|
|
if len(screens) == 1 {
|
|
http.Redirect(w, r, "/manage/"+screens[0].Slug, http.StatusSeeOther)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/manage", http.StatusSeeOther)
|
|
}
|
|
|
|
const sessionTTL = 8 * time.Hour
|
|
|
|
// sessionCookieName ist ein Alias auf die zentrale Konstante (V5).
|
|
const sessionCookieName = reqcontext.SessionCookieName
|
|
|
|
// loginData is the template data for the login page.
|
|
type loginData struct {
|
|
Error string
|
|
Next string
|
|
CSRFToken string
|
|
}
|
|
|
|
// HandleLoginUI renders the login form (GET /login).
|
|
// If a valid session cookie is already present, the user is redirected based on role.
|
|
func HandleLoginUI(authStore *store.AuthStore, screenStore *store.ScreenStore, cfg config.Config) 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 u, err := authStore.GetSessionUser(r.Context(), cookie.Value); err == nil {
|
|
switch u.Role {
|
|
case "admin":
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
case "screen_user", "restricted":
|
|
handleScreenUserRedirect(w, r, screenStore, u)
|
|
default:
|
|
if u.TenantSlug != "" {
|
|
http.Redirect(w, r, "/tenant/"+u.TenantSlug+"/dashboard", http.StatusSeeOther)
|
|
} else {
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
// K1: CSRF-Token für das Login-Formular setzen/erneuern.
|
|
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
|
|
|
|
next := r.URL.Query().Get("next")
|
|
|
|
// K1: ?error= Parameter auswerten und in lesbare Fehlermeldung übersetzen.
|
|
var errorMsg string
|
|
switch r.URL.Query().Get("error") {
|
|
case "no_screens":
|
|
errorMsg = "Ihr Konto hat noch keinen Bildschirm zugewiesen. Bitte wenden Sie sich an den Administrator."
|
|
case "":
|
|
// kein Fehler
|
|
default:
|
|
errorMsg = "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
|
}
|
|
|
|
data := loginData{Error: errorMsg, Next: sanitizeNext(next), CSRFToken: csrfToken}
|
|
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, screenStore *store.ScreenStore, cfg config.Config) http.HandlerFunc {
|
|
tmpl := template.Must(template.New("login").Parse(loginTmpl))
|
|
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
// K1: CSRF-Cookie erneuern und Token für Fehler-Re-Rendering bereitstellen.
|
|
// Das Token muss auch bei Fehlerantworten im Hidden-Field stehen, damit
|
|
// der nächste Submit-Versuch den CSRF-Check besteht.
|
|
csrfToken := setCSRFCookie(w, r, cfg.DevMode)
|
|
|
|
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, CSRFToken: csrfToken})
|
|
}
|
|
|
|
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 {
|
|
// Mitigate user-enumeration timing leak: run a dummy bcrypt
|
|
// comparison so that unknown-user and wrong-password responses
|
|
// take approximately the same time. The dummy hash is a
|
|
// pre-computed bcrypt hash of "dummy" (cost 12).
|
|
const dummyHash = "$2a$12$44H3KPmJUDdgNss7JB7Qneg9GWEl2OgxWwSqVpXRaQdki8T3U9ED2"
|
|
_ = bcrypt.CompareHashAndPassword([]byte(dummyHash), []byte(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)
|
|
case "screen_user", "restricted":
|
|
handleScreenUserRedirect(w, r, screenStore, user)
|
|
default:
|
|
if user.TenantSlug != "" {
|
|
http.Redirect(w, r, "/tenant/"+user.TenantSlug+"/dashboard", http.StatusSeeOther)
|
|
} else {
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// HandleLogoutPost deletes the session and clears the cookie (POST /logout).
|
|
func HandleLogoutPost(authStore *store.AuthStore, cfg config.Config) 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.
|
|
// Secure must match the flag used when the cookie was set so that
|
|
// browsers on HTTPS connections honour the expiry directive.
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: sessionCookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: !cfg.DevMode,
|
|
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
|
|
}
|