The local mode now shows a login page in the colors of the UI (light/dark theme, FR/EN) with a signed session cookie and the log out button, like the OIDC mode. LOGIN_LOGO shows a PNG mounted in the container on that page. SESSION_TTL applies to both modes (OIDC_SESSION_TTL still works). HTTP Basic credentials are still accepted for scripts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
143 lines
5.3 KiB
Go
143 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// newLocalApp puts the local login in front of a handler that echoes "app" and returns a
|
|
// browser (client with cookies) that does not follow redirects.
|
|
func newLocalApp(t *testing.T, c authConfig) (string, *http.Client) {
|
|
t.Helper()
|
|
loginFailDelay = 0
|
|
c.mode, c.dataDir = "local", t.TempDir()
|
|
h, err := newAuth(c, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("app " + r.URL.Path)) }))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
jar, _ := cookiejar.New(nil)
|
|
return "http://app.test", &http.Client{
|
|
Jar: jar,
|
|
Transport: hosts{"app.test": h},
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
|
}
|
|
}
|
|
|
|
func login(t *testing.T, c *http.Client, app, user, pass, ret string) *http.Response {
|
|
t.Helper()
|
|
res, err := c.PostForm(app+"/auth/login", url.Values{"user": {user}, "pass": {pass}, "r": {ret}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res.Body.Close()
|
|
return res
|
|
}
|
|
|
|
func TestLocalLoginFlow(t *testing.T) {
|
|
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
|
|
|
|
res, _ := c.Get(app + "/api/logs?q=x")
|
|
if res.StatusCode != http.StatusUnauthorized || res.Header.Get("WWW-Authenticate") != "" {
|
|
t.Fatalf("API without session: %d %q", res.StatusCode, res.Header.Get("WWW-Authenticate"))
|
|
}
|
|
res, _ = c.Get(app + "/?q=disk")
|
|
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusFound || loc != "/login.html?r=%2F%3Fq%3Ddisk" {
|
|
t.Fatalf("page without session: %d %q", res.StatusCode, loc)
|
|
}
|
|
for _, p := range []string{"/login.html", "/style.css", "/healthz"} {
|
|
if code, body := get(t, c, app+p); code != http.StatusOK || body != "app "+p {
|
|
t.Errorf("%s without session: %d %q", p, code, body)
|
|
}
|
|
}
|
|
|
|
res = login(t, c, app, "admin", "wrong", "/?q=disk")
|
|
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusSeeOther || !strings.Contains(loc, "e=1") {
|
|
t.Fatalf("wrong password: %d %q", res.StatusCode, loc)
|
|
}
|
|
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
|
|
t.Fatal("session created by a wrong password")
|
|
}
|
|
|
|
res = login(t, c, app, "admin", "pw", "//evil.example/")
|
|
if loc := res.Header.Get("Location"); loc != "/" {
|
|
t.Fatalf("open redirect: %q", loc)
|
|
}
|
|
res = login(t, c, app, "admin", "pw", "/?q=disk")
|
|
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusSeeOther || loc != "/?q=disk" {
|
|
t.Fatalf("login: %d %q", res.StatusCode, loc)
|
|
}
|
|
if code, body := get(t, c, app+"/api/logs"); code != http.StatusOK || body != "app /api/logs" {
|
|
t.Fatalf("API with session: %d %q", code, body)
|
|
}
|
|
if code, body := get(t, c, app+"/auth/me"); code != http.StatusOK || !strings.Contains(body, `"user":"admin"`) || !strings.Contains(body, `"mode":"local"`) {
|
|
t.Fatalf("/auth/me: %d %s", code, body)
|
|
}
|
|
if res, _ := c.Get(app + "/login.html"); res.StatusCode != http.StatusFound {
|
|
t.Errorf("login page while logged in: %d", res.StatusCode)
|
|
}
|
|
|
|
if res, _ := c.Get(app + "/auth/logout"); res.Header.Get("Location") != "/login.html" {
|
|
t.Fatalf("logout: %q", res.Header.Get("Location"))
|
|
}
|
|
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
|
|
t.Fatal("session still valid after logout")
|
|
}
|
|
}
|
|
|
|
func TestLocalBasicAuthForScripts(t *testing.T) {
|
|
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
|
|
req, _ := http.NewRequest(http.MethodGet, app+"/api/logs", nil)
|
|
req.SetBasicAuth("admin", "pw")
|
|
if res, _ := c.Do(req); res.StatusCode != http.StatusOK {
|
|
t.Fatalf("basic auth: %d", res.StatusCode)
|
|
}
|
|
req.SetBasicAuth("admin", "nope")
|
|
if res, _ := c.Do(req); res.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("wrong basic auth: %d", res.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestLocalPasswordChangeEndsSessions(t *testing.T) {
|
|
dir := t.TempDir()
|
|
loginFailDelay = 0
|
|
echo := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
|
|
h1, _ := newAuth(authConfig{mode: "local", user: "admin", pass: "old", dataDir: dir}, echo)
|
|
h2, _ := newAuth(authConfig{mode: "local", user: "admin", pass: "new", dataDir: dir}, echo)
|
|
cookie := signCookie(h1.(*Local).key, session{User: "admin", Exp: 9999999999})
|
|
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
|
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: cookie})
|
|
if _, ok := h1.(*Local).sessionUser(r); !ok {
|
|
t.Fatal("session refused with the same password")
|
|
}
|
|
if _, ok := h2.(*Local).sessionUser(r); ok {
|
|
t.Fatal("session kept after a password change")
|
|
}
|
|
}
|
|
|
|
func TestLocalLogo(t *testing.T) {
|
|
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
|
|
if code, _ := get(t, c, app+"/auth/logo"); code != http.StatusNotFound {
|
|
t.Errorf("no LOGIN_LOGO: %d", code)
|
|
}
|
|
png := filepath.Join(t.TempDir(), "logo.png")
|
|
_ = os.WriteFile(png, []byte("\x89PNG\r\n\x1a\nfake"), 0o644)
|
|
app, c = newLocalApp(t, authConfig{user: "admin", pass: "pw", loginLogo: png})
|
|
res, _ := c.Get(app + "/auth/logo")
|
|
if res.StatusCode != http.StatusOK || res.Header.Get("Content-Type") != "image/png" {
|
|
t.Errorf("LOGIN_LOGO: %d %q", res.StatusCode, res.Header.Get("Content-Type"))
|
|
}
|
|
}
|
|
|
|
func TestLocalWithoutUserIsOpen(t *testing.T) {
|
|
if h, _ := newAuth(authConfig{mode: "local"}, http.NotFoundHandler()); h == nil || isLocal(h) {
|
|
t.Error("local mode without AUTH_USER should not protect anything")
|
|
}
|
|
}
|
|
|
|
func isLocal(h http.Handler) bool { _, ok := h.(*Local); return ok }
|