mirror of
https://github.com/kernelkit/infix.git
synced 2026-08-05 23:23:02 +02:00
Surface the mDNS network browser (netbrowse at network.local) from the WebUI: a radar icon in the topbar and a Network Browser entry under Status > Network, both opening it in a new tab. Like the console, this is a config-gated capability; fold the console and netbrowse enable flags into a single infix-services web read at login (DetectWebShortcuts) and render each entry only when enabled, fail-closed on a read error. The link is a static https://network.local/ since netbrowse is a fixed name-based vhost. Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
151 lines
4.3 KiB
Go
151 lines
4.3 KiB
Go
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
|
|
"infix/webui/internal/handlers"
|
|
"infix/webui/internal/restconf"
|
|
"infix/webui/internal/security"
|
|
)
|
|
|
|
const cookieName = "session"
|
|
|
|
// LoginHandler serves the login page and processes login/logout requests.
|
|
type LoginHandler struct {
|
|
Store *SessionStore
|
|
RC *restconf.Client
|
|
Template *template.Template
|
|
// OnLogin is called after every successful login with a context that
|
|
// carries the authenticated user's credentials. It is invoked in the
|
|
// foreground, so implementations should start their own goroutines for
|
|
// slow work. May be nil.
|
|
OnLogin func(ctx context.Context)
|
|
}
|
|
|
|
type loginData struct {
|
|
Error string
|
|
CsrfToken string
|
|
}
|
|
|
|
// ShowLogin renders the login page (GET /login).
|
|
func (h *LoginHandler) ShowLogin(w http.ResponseWriter, r *http.Request) {
|
|
h.renderLogin(w, r, "")
|
|
}
|
|
|
|
// DoLogin validates credentials against RESTCONF and creates a session (POST /login).
|
|
func (h *LoginHandler) DoLogin(w http.ResponseWriter, r *http.Request) {
|
|
if err := r.ParseForm(); err != nil {
|
|
h.renderLogin(w, r, "Invalid request.")
|
|
return
|
|
}
|
|
|
|
username := r.FormValue("username")
|
|
password := r.FormValue("password")
|
|
|
|
if username == "" || password == "" {
|
|
h.renderLogin(w, r, "Username and password are required.")
|
|
return
|
|
}
|
|
|
|
// Verify credentials by making a RESTCONF call with Basic Auth.
|
|
err := h.RC.CheckAuth(username, password)
|
|
if err != nil {
|
|
log.Printf("login failed for %q: %v", username, err)
|
|
var authErr *restconf.AuthError
|
|
if errors.As(err, &authErr) {
|
|
h.renderLogin(w, r, "Invalid username or password.")
|
|
} else {
|
|
h.renderLogin(w, r, "Unable to reach the device. Please try again later.")
|
|
}
|
|
return
|
|
}
|
|
|
|
// Build an authenticated context for post-login work.
|
|
ctx := restconf.ContextWithCredentials(r.Context(), restconf.Credentials{
|
|
Username: username,
|
|
Password: password,
|
|
})
|
|
|
|
// Probe optional features once at login and bake into the session.
|
|
caps := handlers.DetectCapabilities(ctx, h.RC)
|
|
// The external web-app shortcuts (console/ttyd, netbrowse) are
|
|
// config-gated; fold them into the same feature map so templates gate
|
|
// on .Capabilities.Has "console" / "netbrowse".
|
|
console, netbrowse := handlers.DetectWebShortcuts(ctx, h.RC)
|
|
caps.Features()["console"] = console
|
|
caps.Features()["netbrowse"] = netbrowse
|
|
|
|
// Trigger any post-login hooks (e.g. schema sync) with full credentials.
|
|
if h.OnLogin != nil {
|
|
h.OnLogin(ctx)
|
|
}
|
|
|
|
token, csrfToken, err := h.Store.Create(username, password, caps.Features())
|
|
if err != nil {
|
|
log.Printf("session create error: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: cookieName,
|
|
Value: token,
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
Secure: security.IsSecureRequest(r),
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
security.EnsureToken(w, r, csrfToken)
|
|
|
|
fullRedirect(w, r, "/")
|
|
}
|
|
|
|
// DoLogout destroys the session and redirects to the login page (POST /logout).
|
|
func (h *LoginHandler) DoLogout(w http.ResponseWriter, r *http.Request) {
|
|
if c, err := r.Cookie(cookieName); err == nil {
|
|
h.Store.Delete(c.Value)
|
|
}
|
|
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: cookieName,
|
|
Value: "",
|
|
Path: "/",
|
|
MaxAge: -1,
|
|
HttpOnly: true,
|
|
Secure: security.IsSecureRequest(r),
|
|
SameSite: http.SameSiteLaxMode,
|
|
})
|
|
security.ClearToken(w, r)
|
|
|
|
fullRedirect(w, r, "/login")
|
|
}
|
|
|
|
// fullRedirect forces a full page navigation. When the request comes
|
|
// from htmx (boosted form) we use HX-Redirect so the browser does a
|
|
// real page load instead of an AJAX swap — this is essential for the
|
|
// login/logout transition where the page layout changes completely.
|
|
func fullRedirect(w http.ResponseWriter, r *http.Request, url string) {
|
|
if r.Header.Get("HX-Request") == "true" {
|
|
w.Header().Set("HX-Redirect", url)
|
|
return
|
|
}
|
|
http.Redirect(w, r, url, http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *LoginHandler) renderLogin(w http.ResponseWriter, r *http.Request, errMsg string) {
|
|
data := loginData{
|
|
Error: errMsg,
|
|
CsrfToken: security.TokenFromContext(r.Context()),
|
|
}
|
|
if err := h.Template.ExecuteTemplate(w, "login.html", data); err != nil {
|
|
log.Printf("template error: %v", err)
|
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
|
}
|
|
}
|