Login page for AUTH_MODE=local instead of the Basic Auth popup
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>
This commit is contained in:
1 parent
ab38a54d54
commit
7aebb1120f
11 files changed
+533
-61
No files matched your search
@@ -27,8 +27,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Web UI authentication. AUTH_MODE=local (default) keeps the optional HTTP Basic
|
||||
// authentication (AUTH_USER / AUTH_PASS); AUTH_MODE=oidc delegates the login to an
|
||||
// Web UI authentication. AUTH_MODE=local (default) shows a login page when AUTH_USER /
|
||||
// AUTH_PASS are set (auth_local.go); AUTH_MODE=oidc delegates the login to an
|
||||
// OpenID Connect provider (Keycloak, Authentik, Authelia…) with the authorization code
|
||||
// flow and PKCE. Only the standard library is used.
|
||||
|
||||
@@ -49,13 +49,23 @@ type authConfig struct {
|
||||
scopes string
|
||||
sessionTTL time.Duration
|
||||
dataDir string
|
||||
loginLogo string // local mode: PNG shown on the login page
|
||||
|
||||
}
|
||||
|
||||
// newAuth returns the middleware that protects the UI and the API (except /healthz).
|
||||
func newAuth(c authConfig, next http.Handler) (http.Handler, error) {
|
||||
if c.sessionTTL <= 0 {
|
||||
c.sessionTTL = 12 * time.Hour
|
||||
}
|
||||
switch strings.ToLower(c.mode) {
|
||||
case "", "local":
|
||||
return basicAuth(c.user, c.pass, next), nil
|
||||
if c.user == "" {
|
||||
return next, nil
|
||||
}
|
||||
l := newLocal(c)
|
||||
l.next = next
|
||||
return l, nil
|
||||
case "oidc":
|
||||
o, err := newOIDC(c)
|
||||
if err != nil {
|
||||
@@ -68,28 +78,6 @@ func newAuth(c authConfig, next http.Handler) (http.Handler, error) {
|
||||
return nil, fmt.Errorf("AUTH_MODE=%q: expected local or oidc", c.mode)
|
||||
}
|
||||
|
||||
// basicAuth protects the UI when AUTH_USER is set (except /healthz).
|
||||
func basicAuth(user, pass string, next http.Handler) http.Handler {
|
||||
if user == "" {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/healthz" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
u, p, ok := r.BasicAuth()
|
||||
if !ok ||
|
||||
subtle.ConstantTimeCompare([]byte(u), []byte(user)) != 1 ||
|
||||
subtle.ConstantTimeCompare([]byte(p), []byte(pass)) != 1 {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="logstream"`)
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type oidcMeta struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthEndpoint string `json:"authorization_endpoint"`
|
||||
@@ -136,9 +124,6 @@ func newOIDC(c authConfig) (*OIDC, error) {
|
||||
if !strings.Contains(" "+c.scopes+" ", " openid ") {
|
||||
c.scopes = "openid " + c.scopes
|
||||
}
|
||||
if c.sessionTTL <= 0 {
|
||||
c.sessionTTL = 12 * time.Hour
|
||||
}
|
||||
return &OIDC{
|
||||
cfg: c,
|
||||
callback: ru.Path,
|
||||
@@ -166,7 +151,7 @@ func sessionKey(dir string) []byte {
|
||||
log.Fatalf("session key: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, k, 0o600); err != nil {
|
||||
log.Printf("oidc: cannot save %s (%v): sessions end when logstream restarts", path, err)
|
||||
log.Printf("auth: cannot save %s (%v): sessions end when logstream restarts", path, err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
@@ -176,6 +161,14 @@ type session struct {
|
||||
Exp int64 `json:"e"`
|
||||
}
|
||||
|
||||
// writeAuthRequired answers API calls without a session; the UI turns it into a reload
|
||||
// (and so into a new login).
|
||||
func writeAuthRequired(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"authentication required","code":"auth"}` + "\n"))
|
||||
}
|
||||
|
||||
type loginState struct {
|
||||
Nonce string `json:"n"`
|
||||
Verifier string `json:"v"`
|
||||
@@ -196,7 +189,7 @@ func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var s session
|
||||
if c, err := r.Cookie(sessionCookie); err == nil && o.verifyCookie(c.Value, &s) && time.Now().Unix() < s.Exp {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil && verifyCookie(o.key, c.Value, &s) && time.Now().Unix() < s.Exp {
|
||||
if r.URL.Path == "/auth/me" {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"mode": "oidc", "user": s.User})
|
||||
return
|
||||
@@ -210,9 +203,7 @@ func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
o.startLogin(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"authentication required","code":"auth"}` + "\n"))
|
||||
writeAuthRequired(w)
|
||||
}
|
||||
|
||||
func (o *OIDC) startLogin(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -229,7 +220,7 @@ func (o *OIDC) startLogin(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: loginCookie + state,
|
||||
Value: o.signCookie(loginState{Nonce: nonce, Verifier: verifier, Return: ret, Exp: time.Now().Add(loginTTL).Unix()}),
|
||||
Value: signCookie(o.key, loginState{Nonce: nonce, Verifier: verifier, Return: ret, Exp: time.Now().Add(loginTTL).Unix()}),
|
||||
Path: "/",
|
||||
MaxAge: int(loginTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
@@ -260,7 +251,7 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := q.Get("state")
|
||||
var ls loginState
|
||||
c, err := r.Cookie(loginCookie + state)
|
||||
if state == "" || err != nil || !o.verifyCookie(c.Value, &ls) || time.Now().Unix() > ls.Exp {
|
||||
if state == "" || err != nil || !verifyCookie(o.key, c.Value, &ls) || time.Now().Unix() > ls.Exp {
|
||||
http.Error(w, "login expired or started in another browser: open logstream again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -275,7 +266,7 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("oidc: %s logged in", user)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: o.signCookie(session{User: user, Exp: time.Now().Add(o.cfg.sessionTTL).Unix()}),
|
||||
Value: signCookie(o.key, session{User: user, Exp: time.Now().Add(o.cfg.sessionTTL).Unix()}),
|
||||
Path: "/",
|
||||
MaxAge: int(o.cfg.sessionTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
@@ -564,15 +555,15 @@ func (o *OIDC) getJSON(u string, v any) error {
|
||||
}
|
||||
|
||||
// Cookies are base64url(JSON) + "." + base64url(HMAC-SHA256).
|
||||
func (o *OIDC) signCookie(v any) string {
|
||||
func signCookie(key []byte, v any) string {
|
||||
b, _ := json.Marshal(v)
|
||||
p := base64.RawURLEncoding.EncodeToString(b)
|
||||
m := hmac.New(sha256.New, o.key)
|
||||
m := hmac.New(sha256.New, key)
|
||||
m.Write([]byte(p))
|
||||
return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil))
|
||||
}
|
||||
|
||||
func (o *OIDC) verifyCookie(s string, v any) bool {
|
||||
func verifyCookie(key []byte, s string, v any) bool {
|
||||
p, sig, ok := strings.Cut(s, ".")
|
||||
if !ok {
|
||||
return false
|
||||
@@ -581,7 +572,7 @@ func (o *OIDC) verifyCookie(s string, v any) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
m := hmac.New(sha256.New, o.key)
|
||||
m := hmac.New(sha256.New, key)
|
||||
m.Write([]byte(p))
|
||||
if !hmac.Equal(got, m.Sum(nil)) {
|
||||
return false
|
||||
|
||||
Reference in new issue
Block a user