Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f30c353b46 | ||
|
|
84f8b9f9ad |
No files matched your search
+13
-1
@@ -4,9 +4,21 @@ HTTP_PORT=8080
|
|||||||
TZ=Europe/Paris
|
TZ=Europe/Paris
|
||||||
# How long logs are kept (e.g. 7d, 30d, 12w, 1y)
|
# How long logs are kept (e.g. 7d, 30d, 12w, 1y)
|
||||||
RETENTION=30d
|
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_USER=
|
||||||
AUTH_PASS=
|
AUTH_PASS=
|
||||||
|
# oidc mode: issuer URL exactly as the provider announces it
|
||||||
|
# (Keycloak: https://sso.example.org/realms/<realm>, Authentik: https://auth.example.org/application/o/<slug>/)
|
||||||
|
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)
|
# Reverse DNS: show host names instead of IP addresses (on/off)
|
||||||
RDNS=on
|
RDNS=on
|
||||||
# DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver
|
# DNS server used for reverse lookups (e.g. your router: 192.168.1.1). Empty = system resolver
|
||||||
|
|||||||
+47
-3
@@ -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
|
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
|
ê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
|
`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)
|
## Noms d'hôtes (DNS inverse)
|
||||||
|
|
||||||
@@ -265,7 +302,13 @@ résolutions.
|
|||||||
| `SYSLOG_PORT` | `514` | port syslog publié sur l'hôte |
|
| `SYSLOG_PORT` | `514` | port syslog publié sur l'hôte |
|
||||||
| `HTTP_PORT` | `8080` | port de l'interface web |
|
| `HTTP_PORT` | `8080` | port de l'interface web |
|
||||||
| `RETENTION` | `30d` | durée de conservation des logs dans VictoriaLogs |
|
| `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 |
|
| `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`) |
|
| `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 |
|
| `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 |
|
| 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 |
|
| `syslog.go` | écoute UDP/TCP et analyse RFC 3164 / 5424 |
|
||||||
| `store.go` | insertions par lots dans VictoriaLogs et requêtes LogsQL |
|
| `store.go` | insertions par lots dans VictoriaLogs et requêtes LogsQL |
|
||||||
| `query.go` | traduit les filtres de l'interface en LogsQL ; filtre du direct |
|
| `query.go` | traduit les filtres de l'interface en LogsQL ; filtre du direct |
|
||||||
|
|||||||
@@ -219,8 +219,42 @@ remembered per browser.
|
|||||||
- **Data**: "Delete all logs" permanently erases every stored log (you must type
|
- **Data**: "Delete all logs" permanently erases every stored log (you must type
|
||||||
`PURGE` to confirm). Tags and settings are kept. VictoriaLogs needs `-delete.enable`
|
`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.
|
(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
|
Anyone who can open the UI can purge: turn on [authentication](#authentication) if the UI
|
||||||
by others.
|
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)
|
## 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 |
|
| `SYSLOG_PORT` | `514` | syslog port published on the host |
|
||||||
| `HTTP_PORT` | `8080` | web UI port |
|
| `HTTP_PORT` | `8080` | web UI port |
|
||||||
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
|
| `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 |
|
| `RDNS` | `on` | resolve IP hosts to DNS names |
|
||||||
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
|
| `DNS_SERVER` | empty | DNS server for reverse lookups (`ip` or `ip:port`) |
|
||||||
| `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings |
|
| `ALLOW_PURGE` | `true` | allow "Delete all logs" in Settings |
|
||||||
@@ -294,7 +334,8 @@ To update one of them:
|
|||||||
|
|
||||||
| File | Contents |
|
| 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 |
|
| `syslog.go` | UDP/TCP listeners and RFC 3164 / 5424 parsing |
|
||||||
| `store.go` | batched inserts into VictoriaLogs and LogsQL queries |
|
| `store.go` | batched inserts into VictoriaLogs and LogsQL queries |
|
||||||
| `query.go` | turns UI filters into LogsQL; live-view filter |
|
| `query.go` | turns UI filters into LogsQL; live-view filter |
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+230
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,8 +14,15 @@ services:
|
|||||||
VLOGS_URL: http://victorialogs:9428
|
VLOGS_URL: http://victorialogs:9428
|
||||||
SYSLOG_PUBLIC_PORT: ${SYSLOG_PORT:-514} # le port d'ecoute syslog par defaut (attention aux ports <1024)
|
SYSLOG_PUBLIC_PORT: ${SYSLOG_PORT:-514} # le port d'ecoute syslog par defaut (attention aux ports <1024)
|
||||||
TZ: ${TZ:-Europe/Paris}
|
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_USER: ${AUTH_USER:-} # vide = pas d'authentification, on delegue ca au reverse proxy traefik
|
||||||
AUTH_PASS: ${AUTH_PASS:-}
|
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
|
RDNS: ${RDNS:-on} # resol dns
|
||||||
DNS_SERVER: ${DNS_SERVER:-} # si resolv directe
|
DNS_SERVER: ${DNS_SERVER:-} # si resolv directe
|
||||||
ALLOW_PURGE: ${ALLOW_PURGE:-true}
|
ALLOW_PURGE: ${ALLOW_PURGE:-true}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
|
||||||
"embed"
|
"embed"
|
||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -29,8 +28,7 @@ type config struct {
|
|||||||
httpAddr string
|
httpAddr string
|
||||||
vlogsURL string
|
vlogsURL string
|
||||||
dataDir string
|
dataDir string
|
||||||
authUser string
|
auth authConfig
|
||||||
authPass string
|
|
||||||
rdns bool
|
rdns bool
|
||||||
dnsServer string
|
dnsServer string
|
||||||
allowPurge bool
|
allowPurge bool
|
||||||
@@ -82,8 +80,17 @@ func main() {
|
|||||||
httpAddr: getenv("HTTP_ADDR", ":8080"),
|
httpAddr: getenv("HTTP_ADDR", ":8080"),
|
||||||
vlogsURL: getenv("VLOGS_URL", "http://victorialogs:9428"),
|
vlogsURL: getenv("VLOGS_URL", "http://victorialogs:9428"),
|
||||||
dataDir: getenv("DATA_DIR", "/data"),
|
dataDir: getenv("DATA_DIR", "/data"),
|
||||||
authUser: os.Getenv("AUTH_USER"),
|
auth: authConfig{
|
||||||
authPass: os.Getenv("AUTH_PASS"),
|
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),
|
rdns: getenvBool("RDNS", true),
|
||||||
dnsServer: os.Getenv("DNS_SERVER"),
|
dnsServer: os.Getenv("DNS_SERVER"),
|
||||||
allowPurge: getenvBool("ALLOW_PURGE", true),
|
allowPurge: getenvBool("ALLOW_PURGE", true),
|
||||||
@@ -98,6 +105,8 @@ func main() {
|
|||||||
flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond,
|
flushEvery: time.Duration(getenvInt("FLUSH_MS", 1000)) * time.Millisecond,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cfg.auth.dataDir = cfg.dataDir
|
||||||
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
@@ -149,9 +158,16 @@ func main() {
|
|||||||
api.Routes(mux)
|
api.Routes(mux)
|
||||||
mux.Handle("GET /", http.FileServer(http.FS(static)))
|
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{
|
srv := &http.Server{
|
||||||
Addr: cfg.httpAddr,
|
Addr: cfg.httpAddr,
|
||||||
Handler: basicAuth(cfg.authUser, cfg.authPass, mux),
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
// Requests inherit the global context so SSE streams end on shutdown.
|
// Requests inherit the global context so SSE streams end on shutdown.
|
||||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||||
@@ -170,25 +186,3 @@ func main() {
|
|||||||
<-storeDone
|
<-storeDone
|
||||||
log.Println("shutdown complete")
|
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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+14
-1
@@ -14,6 +14,7 @@ const I18N = {
|
|||||||
liveUnavailable: 'Live view is not available in LogsQL mode',
|
liveUnavailable: 'Live view is not available in LogsQL mode',
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
theme: 'Light / dark theme',
|
theme: 'Light / dark theme',
|
||||||
|
logout: 'Log out',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
rangeAria: 'Time range', severityAria: 'Severity', hostAria: 'Host', appAria: 'Application',
|
rangeAria: 'Time range', severityAria: 'Severity', hostAria: 'Host', appAria: 'Application',
|
||||||
histoAria: 'Log volume over time',
|
histoAria: 'Log volume over time',
|
||||||
@@ -157,6 +158,7 @@ const I18N = {
|
|||||||
liveUnavailable: 'Le direct n\'est pas disponible en mode LogsQL',
|
liveUnavailable: 'Le direct n\'est pas disponible en mode LogsQL',
|
||||||
settings: 'Paramètres',
|
settings: 'Paramètres',
|
||||||
theme: 'Thème clair / sombre',
|
theme: 'Thème clair / sombre',
|
||||||
|
logout: 'Se déconnecter',
|
||||||
close: 'Fermer',
|
close: 'Fermer',
|
||||||
rangeAria: 'Période', severityAria: 'Sévérité', hostAria: 'Hôte', appAria: 'Application',
|
rangeAria: 'Période', severityAria: 'Sévérité', hostAria: 'Hôte', appAria: 'Application',
|
||||||
histoAria: 'Volume de logs dans le temps',
|
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.
|
// Known error codes are translated; otherwise the server message is shown.
|
||||||
function apiError(res, text, data) {
|
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;
|
let msg = (data && data.error) || text || res.statusText;
|
||||||
if (data && data.code && I18N[lang]['err_' + data.code]) {
|
if (data && data.code && I18N[lang]['err_' + data.code]) {
|
||||||
msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : '');
|
msg = t('err_' + data.code) + (data.detail ? (lang === 'fr' ? ' : ' : ': ') + data.detail : '');
|
||||||
@@ -452,7 +456,7 @@ const list = $('#list');
|
|||||||
function applyLang() {
|
function applyLang() {
|
||||||
document.documentElement.lang = lang;
|
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]')) 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-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 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));
|
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';
|
if (!$('#range').value) $('#range').value = '1h';
|
||||||
$('#severity').value = store.get('severity', '');
|
$('#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 () => {
|
(async () => {
|
||||||
await loadTags();
|
await loadTags();
|
||||||
loadFacets();
|
loadFacets();
|
||||||
|
|||||||
@@ -41,6 +41,10 @@
|
|||||||
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
<svg class="sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
|
||||||
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
<svg class="moon" viewBox="0 0 24 24"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<a id="logoutBtn" class="icon-btn" href="/auth/logout" hidden data-i18n-title="logout" data-i18n-aria="logout">
|
||||||
|
<!-- Log out icon (Lucide "log-out", ISC license) -->
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/></svg>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress" aria-hidden="true"></div>
|
<div class="progress" aria-hidden="true"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in new issue
Block a user