From f30c353b46113cedfcccfba2ad9d83f263c76cfd Mon Sep 17 00:00:00 2001 From: Cedric Date: Sat, 3 Oct 2026 10:39:54 +0200 Subject: [PATCH] OpenID Connect login (AUTH_MODE=oidc) AUTH_MODE=local keeps the HTTP Basic authentication (unchanged default); AUTH_MODE=oidc logs in through an OpenID Connect provider with the authorization code flow and PKCE, standard library only: discovery, ID token signature (RS/PS/ES) and claims checks, signed session cookie whose key is kept in DATA_DIR. The UI gets a log out button and reloads into the login when the session ends. Co-Authored-By: Claude Opus 5.5 (1M context) --- .env.example | 14 +- README.fr.md | 50 +++- README.md | 49 +++- auth.go | 631 +++++++++++++++++++++++++++++++++++++++++++++ auth_test.go | 230 +++++++++++++++++ docker-compose.yml | 7 + main.go | 50 ++-- web/app.js | 15 +- web/index.html | 4 + 9 files changed, 1013 insertions(+), 37 deletions(-) create mode 100644 auth.go create mode 100644 auth_test.go diff --git a/.env.example b/.env.example index 299c193..4817a4d 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,21 @@ HTTP_PORT=8080 TZ=Europe/Paris # How long logs are kept (e.g. 7d, 30d, 12w, 1y) RETENTION=30d -# Web UI authentication (empty = disabled) +# Web UI authentication: local (HTTP Basic below, or none) or oidc (OpenID Connect provider) +AUTH_MODE=local +# local mode: user and password (empty = no authentication) AUTH_USER= AUTH_PASS= +# oidc mode: issuer URL exactly as the provider announces it +# (Keycloak: https://sso.example.org/realms/, Authentik: https://auth.example.org/application/o//) +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +# Callback URL of logstream, to register in the provider (path free, /auth/callback recommended) +OIDC_REDIRECT_URL=https://logs.example.org/auth/callback +# Requested scopes (openid is always added) and session lifetime (e.g. 8h, 24h) +OIDC_SCOPES=openid profile email +OIDC_SESSION_TTL=12h # Reverse DNS: show host names instead of IP addresses (on/off) RDNS=on # DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver diff --git a/README.fr.md b/README.fr.md index 4a05475..9e45f08 100644 --- a/README.fr.md +++ b/README.fr.md @@ -243,7 +243,44 @@ couleur, est mémorisé par navigateur. taper `PURGE` pour confirmer). Les tags et les paramètres sont conservés. VictoriaLogs doit être lancé avec `-delete.enable` (déjà présent dans `docker-compose.yml`) ; mettez `ALLOW_PURGE=false` pour désactiver la fonction. Toute personne qui peut ouvrir l'interface - peut purger : définissez `AUTH_USER` / `AUTH_PASS` si l'interface est accessible à d'autres. + peut purger : activez l'[authentification](#authentification) si l'interface est accessible + à d'autres. + +## Authentification + +`AUTH_MODE` choisit comment l'interface et l'API sont protégées (`/healthz` reste toujours ouvert) : + +- **`local`** (par défaut) : authentification HTTP Basic avec `AUTH_USER` / `AUTH_PASS` ; laissez-les + vides pour n'avoir aucune authentification (par exemple derrière un reverse proxy qui contrôle déjà). +- **`oidc`** : connexion par un fournisseur OpenID Connect (Keycloak, Authentik, Authelia, Zitadel…), + flux « authorization code » avec PKCE. + +Pour utiliser OIDC : + +1. Dans le fournisseur, créez un client **confidentiel** (avec secret) pour logstream et déclarez + l'URL de retour `https://logs.example.org/auth/callback` (votre adresse). +2. Dans `.env` : + ```bash + AUTH_MODE=oidc + OIDC_ISSUER=https://sso.example.org/realms/maison # exactement l'« issuer » du fournisseur + OIDC_CLIENT_ID=logstream + OIDC_CLIENT_SECRET=... + OIDC_REDIRECT_URL=https://logs.example.org/auth/callback + ``` +3. `docker compose up -d`. Les logs affichent `oidc authentication enabled`, ou la raison pour + laquelle le fournisseur n'a pas pu être lu (issuer incorrect, injoignable…). + +Ouvrir l'interface renvoie vers la page de connexion du fournisseur, puis revient sur logstream. +La session dure `OIDC_SESSION_TTL` (12 h par défaut) et survit aux redémarrages (sa clé de +signature est dans `/data/session.key`) ; à son expiration, la page repasse par la connexion. Le +bouton de déconnexion (en haut à droite) termine la session logstream, puis ouvre la page de +déconnexion du fournisseur s'il en a une. + +Tout utilisateur accepté par le fournisseur pour ce client peut se connecter : restreignez l'accès +dans le fournisseur (Keycloak : rôles du client ou realm dédié ; Authentik : liaisons de +l'application). Les connexions sont écrites dans les logs de logstream (`oidc: alice logged in`). +Avec une URL de retour en `https`, les cookies ne sont envoyés qu'en HTTPS : logstream doit être +joint à travers un reverse proxy TLS. ## Noms d'hôtes (DNS inverse) @@ -265,7 +302,13 @@ résolutions. | `SYSLOG_PORT` | `514` | port syslog publié sur l'hôte | | `HTTP_PORT` | `8080` | port de l'interface web | | `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs | -| `AUTH_USER` / `AUTH_PASS` | vide | authentification HTTP Basic pour l'interface | +| `AUTH_MODE` | `local` | `local` (HTTP Basic) ou `oidc`, voir [Authentification](#authentification) | +| `AUTH_USER` / `AUTH_PASS` | vide | authentification HTTP Basic pour l'interface (mode `local`) | +| `OIDC_ISSUER` | vide | URL de l'issuer du fournisseur OpenID Connect (mode `oidc`) | +| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | vide | client déclaré dans le fournisseur | +| `OIDC_REDIRECT_URL` | vide | URL de retour de logstream, ex. `https://logs.example.org/auth/callback` | +| `OIDC_SCOPES` | `openid profile email` | scopes demandés | +| `OIDC_SESSION_TTL` | `12h` | durée de la session | | `RDNS` | `on` | résoudre les hôtes donnés par leur IP en noms DNS | | `DNS_SERVER` | vide | serveur DNS pour les résolutions inverses (`ip` ou `ip:port`) | | `ALLOW_PURGE` | `true` | autoriser « Supprimer tous les logs » dans les Paramètres | @@ -322,7 +365,8 @@ Pour mettre à jour l'une d'elles : | Fichier | Contenu | |---|---| -| `main.go` | configuration, démarrage, authentification | +| `main.go` | configuration, démarrage | +| `auth.go` | authentification : HTTP Basic ou OpenID Connect (découverte, PKCE, contrôle de l'ID token, cookie de session) | | `syslog.go` | écoute UDP/TCP et analyse RFC 3164 / 5424 | | `store.go` | insertions par lots dans VictoriaLogs et requêtes LogsQL | | `query.go` | traduit les filtres de l'interface en LogsQL ; filtre du direct | diff --git a/README.md b/README.md index 05fafd7..886250a 100644 --- a/README.md +++ b/README.md @@ -219,8 +219,42 @@ remembered per browser. - **Data**: "Delete all logs" permanently erases every stored log (you must type `PURGE` to confirm). Tags and settings are kept. VictoriaLogs needs `-delete.enable` (already set in `docker-compose.yml`); set `ALLOW_PURGE=false` to disable the feature. - Anyone who can open the UI can purge: set `AUTH_USER` / `AUTH_PASS` if the UI is reachable - by others. + Anyone who can open the UI can purge: turn on [authentication](#authentication) if the UI + is reachable by others. + +## Authentication + +`AUTH_MODE` picks how the UI and the API are protected (`/healthz` always stays open): + +- **`local`** (default): HTTP Basic authentication with `AUTH_USER` / `AUTH_PASS`; leave them + empty to have no authentication (for instance behind a reverse proxy that already checks). +- **`oidc`**: login through an OpenID Connect provider (Keycloak, Authentik, Authelia, Zitadel…), + authorization code flow with PKCE. + +To use OIDC: + +1. In the provider, create a **confidential** client (with a secret) for logstream and register + the redirect URL `https://logs.example.org/auth/callback` (your address). +2. In `.env`: + ```bash + AUTH_MODE=oidc + OIDC_ISSUER=https://sso.example.org/realms/home # exactly the "issuer" of the provider + OIDC_CLIENT_ID=logstream + OIDC_CLIENT_SECRET=... + OIDC_REDIRECT_URL=https://logs.example.org/auth/callback + ``` +3. `docker compose up -d`. The logs show `oidc authentication enabled`, or the reason the + provider could not be read (wrong issuer, unreachable…). + +Opening the UI sends you to the provider's login page, then back to logstream. The session +lasts `OIDC_SESSION_TTL` (12 h by default) and survives restarts (its signing key is in +`/data/session.key`); when it ends, the page goes through the login again. The log out button +(top right) ends the logstream session, then opens the provider's log out page if it has one. + +Every user the provider accepts for this client can log in: restrict access in the provider +(Keycloak: client roles or a dedicated realm; Authentik: application bindings). Logins are written +in the logstream logs (`oidc: alice logged in`). With an `https` redirect URL, the cookies are +only sent over HTTPS: logstream must be reached through a TLS reverse proxy. ## Host names (reverse DNS) @@ -240,7 +274,13 @@ are only known by your router or a local DNS (Pi-hole, AdGuard, Unbound…), set | `SYSLOG_PORT` | `514` | syslog port published on the host | | `HTTP_PORT` | `8080` | web UI port | | `RETENTION` | `30d` | how long VictoriaLogs keeps logs | -| `AUTH_USER` / `AUTH_PASS` | empty | HTTP Basic authentication for the UI | +| `AUTH_MODE` | `local` | `local` (HTTP Basic) or `oidc`, see [Authentication](#authentication) | +| `AUTH_USER` / `AUTH_PASS` | empty | HTTP Basic authentication for the UI (`local` mode) | +| `OIDC_ISSUER` | empty | issuer URL of the OpenID Connect provider (`oidc` mode) | +| `OIDC_CLIENT_ID` / `OIDC_CLIENT_SECRET` | empty | client registered in the provider | +| `OIDC_REDIRECT_URL` | empty | callback URL of logstream, e.g. `https://logs.example.org/auth/callback` | +| `OIDC_SCOPES` | `openid profile email` | requested scopes | +| `OIDC_SESSION_TTL` | `12h` | session lifetime | | `RDNS` | `on` | resolve IP hosts to DNS names | | `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) | | `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings | @@ -294,7 +334,8 @@ To update one of them: | File | Contents | |---|---| -| `main.go` | configuration, startup, authentication | +| `main.go` | configuration, startup | +| `auth.go` | authentication: HTTP Basic or OpenID Connect (discovery, PKCE, ID token checks, session cookie) | | `syslog.go` | UDP/TCP listeners and RFC 3164 / 5424 parsing | | `store.go` | batched inserts into VictoriaLogs and LogsQL queries | | `query.go` | turns UI filters into LogsQL; live-view filter | diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..1e43793 --- /dev/null +++ b/auth.go @@ -0,0 +1,631 @@ +package main + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/hmac" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + _ "crypto/sha512" // SHA-384/512 for RS384, ES384, RS512… + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "math/big" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "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 +// OpenID Connect provider (Keycloak, Authentik, Authelia…) with the authorization code +// flow and PKCE. Only the standard library is used. + +const ( + sessionCookie = "logstream_session" + loginCookie = "logstream_login_" // + state: one cookie per login in progress + loginTTL = 10 * time.Minute + clockSkew = time.Minute +) + +type authConfig struct { + mode string + user, pass string // local mode + issuer string + clientID string + clientSecret string + redirectURL string + scopes string + sessionTTL time.Duration + dataDir string +} + +// newAuth returns the middleware that protects the UI and the API (except /healthz). +func newAuth(c authConfig, next http.Handler) (http.Handler, error) { + switch strings.ToLower(c.mode) { + case "", "local": + return basicAuth(c.user, c.pass, next), nil + case "oidc": + o, err := newOIDC(c) + if err != nil { + return nil, err + } + o.next = next + log.Printf("oidc authentication enabled (issuer %s)", c.issuer) + return o, nil + } + 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"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` + EndSession string `json:"end_session_endpoint"` + TokenAuthMethods []string `json:"token_endpoint_auth_methods_supported"` +} + +type OIDC struct { + cfg authConfig + callback string // path of OIDC_REDIRECT_URL + secure bool // cookies only sent over HTTPS + key []byte // signs the session and login cookies + client *http.Client + next http.Handler + + mu sync.Mutex + meta *oidcMeta + keys map[string]crypto.PublicKey + keysAt time.Time +} + +func newOIDC(c authConfig) (*OIDC, error) { + var missing []string + for _, v := range [][2]string{ + {"OIDC_ISSUER", c.issuer}, {"OIDC_CLIENT_ID", c.clientID}, + {"OIDC_CLIENT_SECRET", c.clientSecret}, {"OIDC_REDIRECT_URL", c.redirectURL}, + } { + if v[1] == "" { + missing = append(missing, v[0]) + } + } + if len(missing) > 0 { + return nil, fmt.Errorf("AUTH_MODE=oidc: missing %s", strings.Join(missing, ", ")) + } + ru, err := url.Parse(c.redirectURL) + if err != nil || ru.Host == "" || ru.Path == "" || ru.Path == "/" { + return nil, fmt.Errorf("OIDC_REDIRECT_URL=%q: expected a full URL such as https://logs.example.org/auth/callback", c.redirectURL) + } + if c.scopes == "" { + c.scopes = "openid profile email" + } + 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, + secure: ru.Scheme == "https", + key: sessionKey(c.dataDir), + client: &http.Client{Timeout: 10 * time.Second}, + }, nil +} + +// checkProvider reads the provider configuration at startup so a mistake shows in the logs. +func (o *OIDC) checkProvider() { + if _, err := o.discover(); err != nil { + log.Printf("oidc: %v", err) + } +} + +// sessionKey is kept in DATA_DIR so sessions survive a restart. +func sessionKey(dir string) []byte { + path := filepath.Join(dir, "session.key") + if k, err := os.ReadFile(path); err == nil && len(k) >= 32 { + return k + } + k := make([]byte, 32) + if _, err := rand.Read(k); err != nil { + 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) + } + return k +} + +type session struct { + User string `json:"u"` + Exp int64 `json:"e"` +} + +type loginState struct { + Nonce string `json:"n"` + Verifier string `json:"v"` + Return string `json:"r"` + Exp int64 `json:"e"` +} + +func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/healthz": + o.next.ServeHTTP(w, r) + return + case o.callback: + o.handleCallback(w, r) + return + case "/auth/logout": + o.handleLogout(w, r) + return + } + var s session + if c, err := r.Cookie(sessionCookie); err == nil && o.verifyCookie(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 + } + o.next.ServeHTTP(w, r) + return + } + // Not logged in: pages go to the provider, API calls get a 401 that the UI turns + // into a reload (and so into a new login). + if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me" { + 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")) +} + +func (o *OIDC) startLogin(w http.ResponseWriter, r *http.Request) { + meta, err := o.discover() + if err != nil { + log.Printf("oidc: %v", err) + http.Error(w, "identity provider unreachable, try again later", http.StatusBadGateway) + return + } + state, nonce, verifier := randomString(), randomString(), randomString()+randomString() + ret := r.URL.RequestURI() + if !strings.HasPrefix(ret, "/") || strings.HasPrefix(ret, "//") { + ret = "/" + } + http.SetCookie(w, &http.Cookie{ + Name: loginCookie + state, + Value: o.signCookie(loginState{Nonce: nonce, Verifier: verifier, Return: ret, Exp: time.Now().Add(loginTTL).Unix()}), + Path: "/", + MaxAge: int(loginTTL.Seconds()), + HttpOnly: true, + Secure: o.secure, + SameSite: http.SameSiteLaxMode, // sent back on the redirect from the provider + }) + challenge := sha256.Sum256([]byte(verifier)) + q := url.Values{ + "response_type": {"code"}, + "client_id": {o.cfg.clientID}, + "redirect_uri": {o.cfg.redirectURL}, + "scope": {o.cfg.scopes}, + "state": {state}, + "nonce": {nonce}, + "code_challenge": {base64.RawURLEncoding.EncodeToString(challenge[:])}, + "code_challenge_method": {"S256"}, + } + http.Redirect(w, r, addQuery(meta.AuthEndpoint, q), http.StatusFound) +} + +func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if e := q.Get("error"); e != "" { + log.Printf("oidc: login refused by the provider: %s %s", e, q.Get("error_description")) + http.Error(w, "login refused by the identity provider: "+e, http.StatusForbidden) + return + } + 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 { + http.Error(w, "login expired or started in another browser: open logstream again", http.StatusBadRequest) + return + } + http.SetCookie(w, &http.Cookie{Name: loginCookie + state, Path: "/", MaxAge: -1, HttpOnly: true, Secure: o.secure}) + + user, err := o.exchange(r, q.Get("code"), ls) + if err != nil { + log.Printf("oidc: login failed: %v", err) + http.Error(w, "login failed, see the logstream logs", http.StatusForbidden) + return + } + 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()}), + Path: "/", + MaxAge: int(o.cfg.sessionTTL.Seconds()), + HttpOnly: true, + Secure: o.secure, + SameSite: http.SameSiteLaxMode, + }) + http.Redirect(w, r, ls.Return, http.StatusFound) +} + +// The session ends here; the provider's own session ends on its logout page if it has one. +func (o *OIDC) handleLogout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, Secure: o.secure}) + if meta, err := o.discover(); err == nil && meta.EndSession != "" { + http.Redirect(w, r, addQuery(meta.EndSession, url.Values{"client_id": {o.cfg.clientID}}), http.StatusFound) + return + } + http.Redirect(w, r, "/", http.StatusFound) +} + +// exchange trades the code for tokens and returns the user name from the verified ID token. +func (o *OIDC) exchange(r *http.Request, code string, ls loginState) (string, error) { + if code == "" { + return "", errors.New("no code in the callback") + } + meta, err := o.discover() + if err != nil { + return "", err + } + form := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "redirect_uri": {o.cfg.redirectURL}, + "code_verifier": {ls.Verifier}, + } + // client_secret_basic is the default; some providers only accept client_secret_post. + post := len(meta.TokenAuthMethods) > 0 && !contains(meta.TokenAuthMethods, "client_secret_basic") && contains(meta.TokenAuthMethods, "client_secret_post") + if post { + form.Set("client_id", o.cfg.clientID) + form.Set("client_secret", o.cfg.clientSecret) + } + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, meta.TokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + if !post { + req.SetBasicAuth(url.QueryEscape(o.cfg.clientID), url.QueryEscape(o.cfg.clientSecret)) + } + res, err := o.client.Do(req) + if err != nil { + return "", fmt.Errorf("token endpoint: %w", err) + } + defer res.Body.Close() + body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if res.StatusCode != http.StatusOK { + return "", fmt.Errorf("token endpoint: %s: %s", res.Status, bytes.TrimSpace(body)) + } + var tok struct { + IDToken string `json:"id_token"` + } + if err := json.Unmarshal(body, &tok); err != nil || tok.IDToken == "" { + return "", errors.New("token endpoint: no id_token in the response") + } + claims, err := o.verifyIDToken(tok.IDToken, ls.Nonce) + if err != nil { + return "", err + } + for _, k := range []string{"preferred_username", "email", "name", "sub"} { + if v, _ := claims[k].(string); v != "" { + return v, nil + } + } + return "", errors.New("id_token: no sub") +} + +// verifyIDToken checks the signature (keys from jwks_uri) and the claims of an ID token. +func (o *OIDC) verifyIDToken(raw, nonce string) (map[string]any, error) { + parts := strings.Split(raw, ".") + if len(parts) != 3 { + return nil, errors.New("id_token: not a JWT") + } + var hdr struct { + Alg string `json:"alg"` + Kid string `json:"kid"` + } + if err := decodeSegment(parts[0], &hdr); err != nil { + return nil, fmt.Errorf("id_token header: %w", err) + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return nil, errors.New("id_token: bad signature encoding") + } + key, err := o.keyFor(hdr.Kid) + if err != nil { + return nil, err + } + if err := verifySignature(hdr.Alg, key, []byte(parts[0]+"."+parts[1]), sig); err != nil { + return nil, fmt.Errorf("id_token: %w", err) + } + var claims map[string]any + if err := decodeSegment(parts[1], &claims); err != nil { + return nil, fmt.Errorf("id_token claims: %w", err) + } + if iss, _ := claims["iss"].(string); iss != o.cfg.issuer { + return nil, fmt.Errorf("id_token: issuer %q, expected %q", iss, o.cfg.issuer) + } + var aud []string + switch v := claims["aud"].(type) { + case string: + aud = []string{v} + case []any: + for _, a := range v { + if s, ok := a.(string); ok { + aud = append(aud, s) + } + } + } + if !contains(aud, o.cfg.clientID) { + return nil, fmt.Errorf("id_token: audience %v does not include %q", aud, o.cfg.clientID) + } + if azp, ok := claims["azp"].(string); ok && len(aud) > 1 && azp != o.cfg.clientID { + return nil, fmt.Errorf("id_token: azp %q", azp) + } + now := time.Now() + exp, _ := claims["exp"].(float64) + if exp == 0 || now.After(time.Unix(int64(exp), 0).Add(clockSkew)) { + return nil, errors.New("id_token: expired (check the clocks)") + } + if iat, ok := claims["iat"].(float64); ok && time.Unix(int64(iat), 0).After(now.Add(clockSkew)) { + return nil, errors.New("id_token: issued in the future (check the clocks)") + } + if n, _ := claims["nonce"].(string); subtle.ConstantTimeCompare([]byte(n), []byte(nonce)) != 1 { + return nil, errors.New("id_token: wrong nonce") + } + return claims, nil +} + +func verifySignature(alg string, key crypto.PublicKey, signed, sig []byte) error { + if len(alg) != 5 { + return fmt.Errorf("unsupported algorithm %q", alg) + } + var h crypto.Hash + switch alg[2:] { + case "256": + h = crypto.SHA256 + case "384": + h = crypto.SHA384 + case "512": + h = crypto.SHA512 + } + if h == 0 { + return fmt.Errorf("unsupported algorithm %q", alg) + } + hh := h.New() + hh.Write(signed) + digest := hh.Sum(nil) + switch k := key.(type) { + case *rsa.PublicKey: + switch alg[:2] { + case "RS": + return rsa.VerifyPKCS1v15(k, h, digest, sig) + case "PS": + return rsa.VerifyPSS(k, h, digest, sig, &rsa.PSSOptions{SaltLength: rsa.PSSSaltLengthEqualsHash}) + } + case *ecdsa.PublicKey: + size := (k.Curve.Params().BitSize + 7) / 8 + if alg[:2] != "ES" || len(sig) != 2*size { + break + } + r, s := new(big.Int).SetBytes(sig[:size]), new(big.Int).SetBytes(sig[size:]) + if ecdsa.Verify(k, digest, r, s) { + return nil + } + return errors.New("bad signature") + } + return fmt.Errorf("algorithm %q does not match the key", alg) +} + +// discover reads the provider configuration once (and again after a failure). +func (o *OIDC) discover() (*oidcMeta, error) { + o.mu.Lock() + defer o.mu.Unlock() + if o.meta != nil { + return o.meta, nil + } + u := strings.TrimSuffix(o.cfg.issuer, "/") + "/.well-known/openid-configuration" + var m oidcMeta + if err := o.getJSON(u, &m); err != nil { + return nil, fmt.Errorf("discovery: %w", err) + } + if m.Issuer != o.cfg.issuer { + return nil, fmt.Errorf("discovery: the provider says its issuer is %q, set OIDC_ISSUER to that exact value", m.Issuer) + } + if m.AuthEndpoint == "" || m.TokenEndpoint == "" || m.JWKSURI == "" { + return nil, errors.New("discovery: incomplete provider configuration") + } + o.meta = &m + return o.meta, nil +} + +// keyFor returns the signing key kid; the key set is reloaded when the provider rotates its keys. +func (o *OIDC) keyFor(kid string) (crypto.PublicKey, error) { + meta, err := o.discover() + if err != nil { + return nil, err + } + o.mu.Lock() + defer o.mu.Unlock() + pick := func() crypto.PublicKey { + if k, ok := o.keys[kid]; ok { + return k + } + if kid == "" && len(o.keys) == 1 { + for _, k := range o.keys { + return k + } + } + return nil + } + if k := pick(); k != nil { + return k, nil + } + if time.Since(o.keysAt) < 10*time.Second { + return nil, fmt.Errorf("id_token: unknown key %q", kid) + } + var set struct { + Keys []struct { + Kty string `json:"kty"` + Kid string `json:"kid"` + Use string `json:"use"` + N string `json:"n"` + E string `json:"e"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + } `json:"keys"` + } + if err := o.getJSON(meta.JWKSURI, &set); err != nil { + return nil, fmt.Errorf("jwks: %w", err) + } + keys := map[string]crypto.PublicKey{} + for _, k := range set.Keys { + if k.Use != "" && k.Use != "sig" { + continue + } + switch k.Kty { + case "RSA": + n, e := decodeBig(k.N), decodeBig(k.E) + if n != nil && e != nil && e.IsInt64() { + keys[k.Kid] = &rsa.PublicKey{N: n, E: int(e.Int64())} + } + case "EC": + var c elliptic.Curve + switch k.Crv { + case "P-256": + c = elliptic.P256() + case "P-384": + c = elliptic.P384() + case "P-521": + c = elliptic.P521() + } + x, y := decodeBig(k.X), decodeBig(k.Y) + if c != nil && x != nil && y != nil && c.IsOnCurve(x, y) { + keys[k.Kid] = &ecdsa.PublicKey{Curve: c, X: x, Y: y} + } + } + } + o.keys, o.keysAt = keys, time.Now() + if k := pick(); k != nil { + return k, nil + } + return nil, fmt.Errorf("id_token: unknown key %q", kid) +} + +func (o *OIDC) getJSON(u string, v any) error { + res, err := o.client.Get(u) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return fmt.Errorf("%s: %s", u, res.Status) + } + return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(v) +} + +// Cookies are base64url(JSON) + "." + base64url(HMAC-SHA256). +func (o *OIDC) signCookie(v any) string { + b, _ := json.Marshal(v) + p := base64.RawURLEncoding.EncodeToString(b) + m := hmac.New(sha256.New, o.key) + m.Write([]byte(p)) + return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil)) +} + +func (o *OIDC) verifyCookie(s string, v any) bool { + p, sig, ok := strings.Cut(s, ".") + if !ok { + return false + } + got, err := base64.RawURLEncoding.DecodeString(sig) + if err != nil { + return false + } + m := hmac.New(sha256.New, o.key) + m.Write([]byte(p)) + if !hmac.Equal(got, m.Sum(nil)) { + return false + } + return decodeSegment(p, v) == nil +} + +func decodeSegment(s string, v any) error { + b, err := base64.RawURLEncoding.DecodeString(s) + if err != nil { + return err + } + return json.Unmarshal(b, v) +} + +func decodeBig(s string) *big.Int { + b, err := base64.RawURLEncoding.DecodeString(s) + if err != nil || len(b) == 0 { + return nil + } + return new(big.Int).SetBytes(b) +} + +func randomString() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b) +} + +func addQuery(endpoint string, q url.Values) string { + sep := "?" + if strings.Contains(endpoint, "?") { + sep = "&" + } + return endpoint + sep + q.Encode() +} + +func contains(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..0ffe84d --- /dev/null +++ b/auth_test.go @@ -0,0 +1,230 @@ +package main + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// hosts routes requests to in-memory handlers (no listening socket needed). +type hosts map[string]http.Handler + +func (h hosts) RoundTrip(r *http.Request) (*http.Response, error) { + rec := httptest.NewRecorder() + h[r.URL.Host].ServeHTTP(rec, r) + res := rec.Result() + res.Request = r + return res, nil +} + +// fakeIdP is a minimal OpenID provider: it logs in "alice" without asking. +type fakeIdP struct { + mux *http.ServeMux + rsaKey *rsa.PrivateKey + ecKey *ecdsa.PrivateKey + useEC bool + codes map[string]url.Values // code -> authorize request + claims func(map[string]any) // last-minute changes to the ID token + tokenErr bool +} + +func newFakeIdP(t *testing.T) *fakeIdP { + rk, _ := rsa.GenerateKey(rand.Reader, 2048) + ek, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + p := &fakeIdP{rsaKey: rk, ecKey: ek, codes: map[string]url.Values{}} + mux := http.NewServeMux() + p.mux = mux + iss := "http://idp.test/realm" + mux.HandleFunc("/realm/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "issuer": iss, "authorization_endpoint": iss + "/auth", "token_endpoint": iss + "/token", + "jwks_uri": iss + "/jwks", "end_session_endpoint": iss + "/logout", + }) + }) + mux.HandleFunc("/realm/jwks", func(w http.ResponseWriter, r *http.Request) { + b := func(i *big.Int) string { return base64.RawURLEncoding.EncodeToString(i.Bytes()) } + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []any{ + map[string]string{"kty": "RSA", "kid": "r1", "use": "sig", "n": b(rk.N), "e": "AQAB"}, + map[string]string{"kty": "EC", "kid": "e1", "crv": "P-256", "x": b(ek.X), "y": b(ek.Y)}, + }}) + }) + mux.HandleFunc("/realm/auth", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + code := randomString() + p.codes[code] = q + http.Redirect(w, r, q.Get("redirect_uri")+"?code="+code+"&state="+q.Get("state"), http.StatusFound) + }) + mux.HandleFunc("/realm/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + id, secret, _ := r.BasicAuth() + authz, ok := p.codes[r.Form.Get("code")] + sum := sha256.Sum256([]byte(r.Form.Get("code_verifier"))) + if p.tokenErr || !ok || id != "logstream" || secret != "s3cret" || + base64.RawURLEncoding.EncodeToString(sum[:]) != authz.Get("code_challenge") || + r.Form.Get("redirect_uri") != authz.Get("redirect_uri") { + http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest) + return + } + delete(p.codes, r.Form.Get("code")) + c := map[string]any{ + "iss": iss, "aud": "logstream", "sub": "123", "preferred_username": "alice", + "exp": time.Now().Add(5 * time.Minute).Unix(), "iat": time.Now().Unix(), "nonce": authz.Get("nonce"), + } + if p.claims != nil { + p.claims(c) + } + _ = json.NewEncoder(w).Encode(map[string]string{"access_token": "x", "id_token": p.sign(c)}) + }) + return p +} + +func (p *fakeIdP) sign(claims map[string]any) string { + alg, kid := "RS256", "r1" + if p.useEC { + alg, kid = "ES256", "e1" + } + h, _ := json.Marshal(map[string]string{"alg": alg, "kid": kid, "typ": "JWT"}) + c, _ := json.Marshal(claims) + in := base64.RawURLEncoding.EncodeToString(h) + "." + base64.RawURLEncoding.EncodeToString(c) + d := sha256.Sum256([]byte(in)) + var sig []byte + if p.useEC { + r, s, _ := ecdsa.Sign(rand.Reader, p.ecKey, d[:]) + sig = make([]byte, 64) + r.FillBytes(sig[:32]) + s.FillBytes(sig[32:]) + } else { + sig, _ = rsa.SignPKCS1v15(rand.Reader, p.rsaKey, crypto.SHA256, d[:]) + } + return in + "." + base64.RawURLEncoding.EncodeToString(sig) +} + +// newOIDCApp puts logstream's auth in front of a handler that echoes "app" and returns +// a browser (client with cookies) that reaches both the app and the provider. +func newOIDCApp(t *testing.T, idp *fakeIdP) (string, *http.Client) { + h, err := newAuth(authConfig{ + mode: "oidc", issuer: "http://idp.test/realm", clientID: "logstream", clientSecret: "s3cret", + redirectURL: "http://app.test/auth/callback", dataDir: t.TempDir(), + }, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("app " + r.URL.Path)) })) + if err != nil { + t.Fatal(err) + } + net := hosts{"app.test": h, "idp.test": idp.mux} + h.(*OIDC).client.Transport = net + jar, _ := cookiejar.New(nil) + return "http://app.test", &http.Client{Jar: jar, Transport: net} +} + +func get(t *testing.T, c *http.Client, u string) (int, string) { + t.Helper() + res, err := c.Get(u) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var b strings.Builder + buf := make([]byte, 4096) + for { + n, err := res.Body.Read(buf) + b.Write(buf[:n]) + if err != nil { + break + } + } + return res.StatusCode, b.String() +} + +func TestOIDCLoginFlow(t *testing.T) { + for _, ec := range []bool{false, true} { + idp := newFakeIdP(t) + idp.useEC = ec + app, c := newOIDCApp(t, idp) + + if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized { + t.Fatalf("api without session: %d", code) + } + if code, body := get(t, c, app+"/healthz"); code != 200 || body != "app /healthz" { + t.Fatalf("healthz: %d %q", code, body) + } + // A page goes through the provider and comes back to the page asked for. + if code, body := get(t, c, app+"/index.html?x=1"); code != 200 || body != "app /index.html" { + t.Fatalf("login (ec=%v): %d %q", ec, code, body) + } + if code, body := get(t, c, app+"/api/logs"); code != 200 || body != "app /api/logs" { + t.Fatalf("api with session: %d %q", code, body) + } + if code, body := get(t, c, app+"/auth/me"); code != 200 || !strings.Contains(body, `"user":"alice"`) { + t.Fatalf("me: %d %q", code, body) + } + // Logout drops the session (the fake provider has no logout page: 404). + get(t, c, app+"/auth/logout") + if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized { + t.Fatalf("api after logout: %d", code) + } + } +} + +func TestOIDCRejectsBadTokens(t *testing.T) { + cases := map[string]func(map[string]any){ + "wrong nonce": func(c map[string]any) { c["nonce"] = "x" }, + "wrong audience": func(c map[string]any) { c["aud"] = "other" }, + "wrong issuer": func(c map[string]any) { c["iss"] = "https://evil" }, + "expired": func(c map[string]any) { c["exp"] = time.Now().Add(-time.Hour).Unix() }, + } + for name, change := range cases { + idp := newFakeIdP(t) + idp.claims = change + app, c := newOIDCApp(t, idp) + if code, _ := get(t, c, app+"/"); code != http.StatusForbidden { + t.Errorf("%s: login gave %d, expected 403", name, code) + } + if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized { + t.Errorf("%s: session created", name) + } + } +} + +func TestOIDCCallbackNeedsLoginCookie(t *testing.T) { + idp := newFakeIdP(t) + app, c := newOIDCApp(t, idp) + if code, _ := get(t, c, app+"/auth/callback?code=abc&state=forged"); code != http.StatusBadRequest { + t.Fatalf("forged callback: %d", code) + } +} + +func TestOIDCForgedSessionCookie(t *testing.T) { + idp := newFakeIdP(t) + app, c := newOIDCApp(t, idp) + u, _ := url.Parse(app) + payload := base64.RawURLEncoding.EncodeToString([]byte(`{"u":"mallory","e":9999999999}`)) + c.Jar.SetCookies(u, []*http.Cookie{{Name: sessionCookie, Value: payload + ".AAAA"}}) + if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized { + t.Fatalf("forged session accepted: %d", code) + } +} + +func TestAuthModeConfig(t *testing.T) { + next := http.NotFoundHandler() + if _, err := newAuth(authConfig{mode: "oidc"}, next); err == nil || !strings.Contains(err.Error(), "OIDC_CLIENT_ID") { + t.Errorf("missing variables not reported: %v", err) + } + if _, err := newAuth(authConfig{mode: "ldap"}, next); err == nil { + t.Error("unknown mode accepted") + } + if h, err := newAuth(authConfig{mode: "local"}, next); err != nil || h == nil { + t.Errorf("local mode: %v", err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index b8f15ae..4561a1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,8 +14,15 @@ services: VLOGS_URL: http://victorialogs:9428 SYSLOG_PUBLIC_PORT: ${SYSLOG_PORT:-514} # le port d'ecoute syslog par defaut (attention aux ports <1024) TZ: ${TZ:-Europe/Paris} + AUTH_MODE: ${AUTH_MODE:-local} # local (Basic Auth ci-dessous) ou oidc AUTH_USER: ${AUTH_USER:-} # vide = pas d'authentification, on delegue ca au reverse proxy traefik AUTH_PASS: ${AUTH_PASS:-} + OIDC_ISSUER: ${OIDC_ISSUER:-} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} + OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-} + OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email} + OIDC_SESSION_TTL: ${OIDC_SESSION_TTL:-12h} RDNS: ${RDNS:-on} # resol dns DNS_SERVER: ${DNS_SERVER:-} # si resolv directe ALLOW_PURGE: ${ALLOW_PURGE:-true} diff --git a/main.go b/main.go index 9849d22..3470bea 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,6 @@ package main import ( "context" - "crypto/subtle" "embed" "errors" "io/fs" @@ -29,8 +28,7 @@ type config struct { httpAddr string vlogsURL string dataDir string - authUser string - authPass string + auth authConfig rdns bool dnsServer string allowPurge bool @@ -82,8 +80,17 @@ func main() { httpAddr: getenv("HTTP_ADDR", ":8080"), vlogsURL: getenv("VLOGS_URL", "http://victorialogs:9428"), dataDir: getenv("DATA_DIR", "/data"), - authUser: os.Getenv("AUTH_USER"), - authPass: os.Getenv("AUTH_PASS"), + auth: authConfig{ + mode: getenv("AUTH_MODE", "local"), + user: os.Getenv("AUTH_USER"), + pass: os.Getenv("AUTH_PASS"), + issuer: os.Getenv("OIDC_ISSUER"), + clientID: os.Getenv("OIDC_CLIENT_ID"), + clientSecret: os.Getenv("OIDC_CLIENT_SECRET"), + redirectURL: os.Getenv("OIDC_REDIRECT_URL"), + scopes: os.Getenv("OIDC_SCOPES"), + sessionTTL: getenvDuration("OIDC_SESSION_TTL", 12*time.Hour), + }, rdns: getenvBool("RDNS", true), dnsServer: os.Getenv("DNS_SERVER"), allowPurge: getenvBool("ALLOW_PURGE", true), @@ -98,6 +105,8 @@ func main() { flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond, } + cfg.auth.dataDir = cfg.dataDir + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -149,9 +158,16 @@ func main() { api.Routes(mux) mux.Handle("GET /", http.FileServer(http.FS(static))) + handler, err := newAuth(cfg.auth, mux) + if err != nil { + log.Fatalf("auth: %v", err) + } + if o, ok := handler.(*OIDC); ok { + go o.checkProvider() + } srv := &http.Server{ Addr: cfg.httpAddr, - Handler: basicAuth(cfg.authUser, cfg.authPass, mux), + Handler: handler, ReadHeaderTimeout: 10 * time.Second, // Requests inherit the global context so SSE streams end on shutdown. BaseContext: func(net.Listener) context.Context { return ctx }, @@ -170,25 +186,3 @@ func main() { <-storeDone log.Println("shutdown complete") } - -// 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) - }) -} diff --git a/web/app.js b/web/app.js index 21bb7bc..4437560 100644 --- a/web/app.js +++ b/web/app.js @@ -14,6 +14,7 @@ const I18N = { liveUnavailable: 'Live view is not available in LogsQL mode', settings: 'Settings', theme: 'Light / dark theme', + logout: 'Log out', close: 'Close', rangeAria: 'Time range', severityAria: 'Severity', hostAria: 'Host', appAria: 'Application', histoAria: 'Log volume over time', @@ -157,6 +158,7 @@ const I18N = { liveUnavailable: 'Le direct n\'est pas disponible en mode LogsQL', settings: 'Paramètres', theme: 'Thème clair / sombre', + logout: 'Se déconnecter', close: 'Fermer', rangeAria: 'Période', severityAria: 'Sévérité', hostAria: 'Hôte', appAria: 'Application', histoAria: 'Volume de logs dans le temps', @@ -409,6 +411,8 @@ async function api(url, opts = {}) { // Known error codes are translated; otherwise the server message is shown. function apiError(res, text, data) { + // OIDC session expired: reloading the page goes through the login again. + if (res.status === 401 && data && data.code === 'auth') location.reload(); let msg = (data && data.error) || text || res.statusText; if (data && data.code && I18N[lang]['err_' + data.code]) { msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : ''); @@ -452,7 +456,7 @@ const list = $('#list'); function applyLang() { document.documentElement.lang = lang; for (const el of document.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n); - for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle); + for (const el of document.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle) + (el.dataset.user ? ` (${el.dataset.user})` : ''); for (const el of document.querySelectorAll('[data-i18n-aria]')) el.setAttribute('aria-label', t(el.dataset.i18nAria)); for (const el of document.querySelectorAll('[data-i18n-ph]')) el.placeholder = t(el.dataset.i18nPh); for (const b of document.querySelectorAll('#langSwitch [data-lang]')) b.setAttribute('aria-checked', String(b.dataset.lang === lang)); @@ -2099,6 +2103,15 @@ $('#range').value = store.get('range', '1h'); if (!$('#range').value) $('#range').value = '1h'; $('#severity').value = store.get('severity', ''); +// With OIDC login, show who is logged in and the log out button. +fetch('/auth/me').then((res) => (res.ok ? res.json() : null)).then((me) => { + if (!me || !me.user) return; + const btn = $('#logoutBtn'); + btn.hidden = false; + btn.dataset.user = me.user; + btn.title = `${t('logout')} (${me.user})`; +}).catch(() => {}); + (async () => { await loadTags(); loadFacets(); diff --git a/web/index.html b/web/index.html index 17dd07e..5bfb619 100644 --- a/web/index.html +++ b/web/index.html @@ -41,6 +41,10 @@ + -- 2.54.0