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
+167
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Local mode with AUTH_USER set: a login page (web/login.html) in the colors of the UI and a
|
||||
// session kept in a signed cookie, like the OIDC mode. HTTP Basic credentials are still
|
||||
// accepted so scripts calling the API keep working, but the browser popup is gone.
|
||||
|
||||
const loginPage = "/login.html"
|
||||
|
||||
var loginFailDelay = time.Second // slows down password guessing
|
||||
|
||||
type Local struct {
|
||||
user, pass string
|
||||
ttl time.Duration
|
||||
logo string // LOGIN_LOGO, served at /auth/logo
|
||||
key []byte
|
||||
next http.Handler
|
||||
}
|
||||
|
||||
func newLocal(c authConfig) *Local {
|
||||
// The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session.
|
||||
m := hmac.New(sha256.New, sessionKey(c.dataDir))
|
||||
m.Write([]byte("local\x00" + c.user + "\x00" + c.pass))
|
||||
if c.loginLogo != "" {
|
||||
if _, err := os.Stat(c.loginLogo); err != nil {
|
||||
log.Printf("auth: LOGIN_LOGO: %v", err)
|
||||
}
|
||||
}
|
||||
log.Printf("local authentication enabled (user %s)", c.user)
|
||||
return &Local{user: c.user, pass: c.pass, ttl: c.sessionTTL, logo: c.loginLogo, key: m.Sum(nil)}
|
||||
}
|
||||
|
||||
func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/healthz", "/style.css":
|
||||
l.next.ServeHTTP(w, r)
|
||||
return
|
||||
case "/auth/logo":
|
||||
l.serveLogo(w, r)
|
||||
return
|
||||
case "/auth/login":
|
||||
l.handleLogin(w, r)
|
||||
return
|
||||
case "/auth/logout":
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, Secure: isHTTPS(r)})
|
||||
http.Redirect(w, r, loginPage, http.StatusFound)
|
||||
return
|
||||
}
|
||||
user, ok := l.sessionUser(r)
|
||||
if !ok {
|
||||
if u, p, basic := r.BasicAuth(); basic && l.check(u, p) {
|
||||
user, ok = u, true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case r.URL.Path == loginPage:
|
||||
if ok {
|
||||
http.Redirect(w, r, safeReturn(r.URL.Query().Get("r")), http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
l.next.ServeHTTP(w, r)
|
||||
case ok && r.URL.Path == "/auth/me":
|
||||
writeJSON(w, http.StatusOK, map[string]string{"mode": "local", "user": user})
|
||||
case ok:
|
||||
l.next.ServeHTTP(w, r)
|
||||
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me":
|
||||
target := loginPage
|
||||
if ret := r.URL.RequestURI(); ret != "/" {
|
||||
target += "?" + url.Values{"r": {ret}}.Encode()
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
default:
|
||||
writeAuthRequired(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, loginPage, http.StatusFound)
|
||||
return
|
||||
}
|
||||
user, pass := r.PostFormValue("user"), r.PostFormValue("pass")
|
||||
ret := safeReturn(r.PostFormValue("r"))
|
||||
if !l.check(user, pass) {
|
||||
log.Printf("auth: failed login for %q from %s", user, clientIP(r))
|
||||
time.Sleep(loginFailDelay)
|
||||
q := url.Values{"e": {"1"}}
|
||||
if ret != "/" {
|
||||
q.Set("r", ret)
|
||||
}
|
||||
http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
log.Printf("auth: %s logged in from %s", user, clientIP(r))
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: signCookie(l.key, session{User: user, Exp: time.Now().Add(l.ttl).Unix()}),
|
||||
Path: "/",
|
||||
MaxAge: int(l.ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: isHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, ret, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (l *Local) sessionUser(r *http.Request) (string, bool) {
|
||||
var s session
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || !verifyCookie(l.key, c.Value, &s) || time.Now().Unix() >= s.Exp {
|
||||
return "", false
|
||||
}
|
||||
return s.User, true
|
||||
}
|
||||
|
||||
func (l *Local) check(user, pass string) bool {
|
||||
u := subtle.ConstantTimeCompare([]byte(user), []byte(l.user))
|
||||
p := subtle.ConstantTimeCompare([]byte(pass), []byte(l.pass))
|
||||
return u&p == 1
|
||||
}
|
||||
|
||||
// serveLogo sends LOGIN_LOGO; without it the login page hides the image.
|
||||
func (l *Local) serveLogo(w http.ResponseWriter, r *http.Request) {
|
||||
if l.logo == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFile(w, r, l.logo)
|
||||
}
|
||||
|
||||
// safeReturn keeps the page to open after login inside logstream.
|
||||
func safeReturn(ret string) string {
|
||||
if !strings.HasPrefix(ret, "/") || strings.HasPrefix(ret, "//") || strings.HasPrefix(ret, "/\\") || ret == loginPage {
|
||||
return "/"
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// isHTTPS is true when the browser talks HTTPS, directly or through a reverse proxy.
|
||||
func isHTTPS(r *http.Request) bool {
|
||||
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
if f := r.Header.Get("X-Forwarded-For"); f != "" {
|
||||
return strings.TrimSpace(strings.Split(f, ",")[0])
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
Reference in new issue
Block a user