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) <noreply@anthropic.com>
This commit is contained in:
1 parent
84f8b9f9ad
commit
f30c353b46
9 files changed
+1013
-37
No files matched your search
+13
-1
@@ -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/<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)
|
||||
RDNS=on
|
||||
# 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
|
||||
ê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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
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}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
+14
-1
@@ -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();
|
||||
|
||||
@@ -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="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>
|
||||
<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 class="progress" aria-hidden="true"></div>
|
||||
</header>
|
||||
|
||||
Reference in new issue
Block a user