Page de connexion pour AUTH_MODE=local #8

Merged
claude Bot merged 1 commits from feat/login-page into main 2026-10-03 11:12:29 +02:00
11 changed files with 533 additions and 61 deletions
Showing only changes of commit 7aebb1120f - Show all commits

No files matched your search

+7 -3
View File
@@ -4,11 +4,16 @@ 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: local (HTTP Basic below, or none) or oidc (OpenID Connect provider) # Web UI authentication: local (login page with the account below, or none) or oidc (OpenID Connect provider)
AUTH_MODE=local AUTH_MODE=local
# local mode: user and password (empty = no authentication) # local mode: user and password (empty = no authentication)
AUTH_USER= AUTH_USER=
AUTH_PASS= AUTH_PASS=
# local mode: PNG logo shown on the login page, path inside the container (empty = no logo).
# Mount the file in docker-compose.yml, e.g. ./logo.png:/config/logo.png:ro, then LOGIN_LOGO=/config/logo.png
LOGIN_LOGO=
# Session lifetime, both modes (e.g. 8h, 24h)
SESSION_TTL=12h
# oidc mode: issuer URL exactly as the provider announces it # 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>/) # (Keycloak: https://sso.example.org/realms/<realm>, Authentik: https://auth.example.org/application/o/<slug>/)
OIDC_ISSUER= OIDC_ISSUER=
@@ -16,9 +21,8 @@ OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET= OIDC_CLIENT_SECRET=
# Callback URL of logstream, to register in the provider (path free, /auth/callback recommended) # Callback URL of logstream, to register in the provider (path free, /auth/callback recommended)
OIDC_REDIRECT_URL=https://logs.example.org/auth/callback OIDC_REDIRECT_URL=https://logs.example.org/auth/callback
# Requested scopes (openid is always added) and session lifetime (e.g. 8h, 24h) # Requested scopes (openid is always added)
OIDC_SCOPES=openid profile email 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
+29 -7
View File
@@ -250,11 +250,31 @@ couleur, est mémorisé par navigateur.
`AUTH_MODE` choisit comment l'interface et l'API sont protégées (`/healthz` reste toujours ouvert) : `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 - **`local`** (par défaut) : une page de connexion avec le compte `AUTH_USER` / `AUTH_PASS` ; laissez-les
vides pour n'avoir aucune authentification (par exemple derrière un reverse proxy qui contrôle déjà). 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…), - **`oidc`** : connexion par un fournisseur OpenID Connect (Keycloak, Authentik, Authelia, Zitadel…),
flux « authorization code » avec PKCE. flux « authorization code » avec PKCE.
En mode `local`, la page de connexion suit le thème et la langue de l'interface. La session dure
`SESSION_TTL` (12 h par défaut), survit aux redémarrages (sa clé de signature est dans
`/data/session.key`) et se termine quand `AUTH_USER` ou `AUTH_PASS` change ; le bouton de
déconnexion (en haut à droite) y met fin. Les échecs de connexion sont écrits dans les logs avec
l'adresse du client (`auth: failed login for "bob" from 192.0.2.7`). Les scripts peuvent toujours
appeler l'API avec des identifiants HTTP Basic (`curl -u utilisateur:motdepasse`).
Pour afficher votre logo sur la page de connexion, montez un PNG dans le conteneur et indiquez
son chemin dans `LOGIN_LOGO` :
```yaml
# docker-compose.yml, service logstream
volumes:
- ./logo.png:/config/logo.png:ro
```
```bash
# .env
LOGIN_LOGO=/config/logo.png
```
Pour utiliser OIDC : Pour utiliser OIDC :
1. Dans le fournisseur, créez un client **confidentiel** (avec secret) pour logstream et déclarez 1. Dans le fournisseur, créez un client **confidentiel** (avec secret) pour logstream et déclarez
@@ -271,7 +291,7 @@ Pour utiliser OIDC :
laquelle le fournisseur n'a pas pu être lu (issuer incorrect, injoignable…). 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. 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 La session dure `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 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 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. déconnexion du fournisseur s'il en a une.
@@ -302,13 +322,14 @@ 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_MODE` | `local` | `local` (HTTP Basic) ou `oidc`, voir [Authentification](#authentification) | | `AUTH_MODE` | `local` | `local` (page de connexion) ou `oidc`, voir [Authentification](#authentification) |
| `AUTH_USER` / `AUTH_PASS` | vide | authentification HTTP Basic pour l'interface (mode `local`) | | `AUTH_USER` / `AUTH_PASS` | vide | compte de la page de connexion (mode `local`) ; vide = pas d'authentification |
| `LOGIN_LOGO` | vide | PNG affiché sur la page de connexion, chemin dans le conteneur (mode `local`) |
| `SESSION_TTL` | `12h` | durée de la session (les deux modes ; `OIDC_SESSION_TTL` fonctionne toujours) |
| `OIDC_ISSUER` | vide | URL de l'issuer du fournisseur OpenID Connect (mode `oidc`) | | `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_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_REDIRECT_URL` | vide | URL de retour de logstream, ex. `https://logs.example.org/auth/callback` |
| `OIDC_SCOPES` | `openid profile email` | scopes demandés | | `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 |
@@ -366,7 +387,8 @@ Pour mettre à jour l'une d'elles :
| Fichier | Contenu | | Fichier | Contenu |
|---|---| |---|---|
| `main.go` | configuration, démarrage | | `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) | | `auth.go` | authentification : OpenID Connect (découverte, PKCE, contrôle de l'ID token) et cookie de session signé |
| `auth_local.go` | mode `local` : page de connexion (`web/login.html`), cookie de session, `LOGIN_LOGO` |
| `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 |
@@ -379,7 +401,7 @@ Pour mettre à jour l'une d'elles :
| `hostlogs.go`, `journal.go` | logs système de l'hôte : lecteur du journal systemd (sans `journalctl`) et suivi de `/var/log` | | `hostlogs.go`, `journal.go` | logs système de l'hôte : lecteur du journal systemd (sans `journalctl`) et suivi de `/var/log` |
| `tags.go` | stockage des tags de couleur | | `tags.go` | stockage des tags de couleur |
| `api.go` | routes HTTP `/api/*` | | `api.go` | routes HTTP `/api/*` |
| `web/` | interface (HTML, CSS, JavaScript simple, sans étape de build), embarquée dans le binaire ; les traductions sont dans `web/app.js` (`I18N`) | | `web/` | interface (HTML, CSS, JavaScript simple, sans étape de build), embarquée dans le binaire ; les traductions sont dans `web/app.js` (`I18N`), et dans `web/login.html` pour la page de connexion |
## Remarque ## Remarque
+27 -7
View File
@@ -226,11 +226,29 @@ remembered per browser.
`AUTH_MODE` picks how the UI and the API are protected (`/healthz` always stays open): `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 - **`local`** (default): a login page with the account `AUTH_USER` / `AUTH_PASS`; leave them
empty to have no authentication (for instance behind a reverse proxy that already checks). 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…), - **`oidc`**: login through an OpenID Connect provider (Keycloak, Authentik, Authelia, Zitadel…),
authorization code flow with PKCE. authorization code flow with PKCE.
In `local` mode the login page follows the theme and language of the UI. The session lasts
`SESSION_TTL` (12 h by default), survives restarts (its signing key is in `/data/session.key`) and
ends when `AUTH_USER` or `AUTH_PASS` changes; the log out button (top right) ends it. Failed logins
are written in the logs with the client address (`auth: failed login for "bob" from 192.0.2.7`).
Scripts can still call the API with HTTP Basic credentials (`curl -u user:pass`).
To show your logo on the login page, mount a PNG in the container and point `LOGIN_LOGO` to it:
```yaml
# docker-compose.yml, logstream service
volumes:
- ./logo.png:/config/logo.png:ro
```
```bash
# .env
LOGIN_LOGO=/config/logo.png
```
To use OIDC: To use OIDC:
1. In the provider, create a **confidential** client (with a secret) for logstream and register 1. In the provider, create a **confidential** client (with a secret) for logstream and register
@@ -247,7 +265,7 @@ To use OIDC:
provider could not be read (wrong issuer, unreachable…). 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 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 lasts `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 `/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. (top right) ends the logstream session, then opens the provider's log out page if it has one.
@@ -274,13 +292,14 @@ 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_MODE` | `local` | `local` (HTTP Basic) or `oidc`, see [Authentication](#authentication) | | `AUTH_MODE` | `local` | `local` (login page) or `oidc`, see [Authentication](#authentication) |
| `AUTH_USER` / `AUTH_PASS` | empty | HTTP Basic authentication for the UI (`local` mode) | | `AUTH_USER` / `AUTH_PASS` | empty | account of the login page (`local` mode); empty = no authentication |
| `LOGIN_LOGO` | empty | PNG shown on the login page, path inside the container (`local` mode) |
| `SESSION_TTL` | `12h` | session lifetime (both modes; `OIDC_SESSION_TTL` still works) |
| `OIDC_ISSUER` | empty | issuer URL of the OpenID Connect provider (`oidc` 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_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_REDIRECT_URL` | empty | callback URL of logstream, e.g. `https://logs.example.org/auth/callback` |
| `OIDC_SCOPES` | `openid profile email` | requested scopes | | `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 |
@@ -335,7 +354,8 @@ To update one of them:
| File | Contents | | File | Contents |
|---|---| |---|---|
| `main.go` | configuration, startup | | `main.go` | configuration, startup |
| `auth.go` | authentication: HTTP Basic or OpenID Connect (discovery, PKCE, ID token checks, session cookie) | | `auth.go` | authentication: OpenID Connect (discovery, PKCE, ID token checks) and the signed session cookie |
| `auth_local.go` | `local` mode: login page (`web/login.html`), session cookie, `LOGIN_LOGO` |
| `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 |
@@ -348,7 +368,7 @@ To update one of them:
| `hostlogs.go`, `journal.go` | host system logs: systemd journal reader (no `journalctl`) and `/var/log` follower | | `hostlogs.go`, `journal.go` | host system logs: systemd journal reader (no `journalctl`) and `/var/log` follower |
| `tags.go` | color tag storage | | `tags.go` | color tag storage |
| `api.go` | `/api/*` HTTP routes | | `api.go` | `/api/*` HTTP routes |
| `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`) | | `web/` | UI (HTML, CSS, plain JavaScript, no build step), embedded in the binary; translations live in `web/app.js` (`I18N`), and in `web/login.html` for the login page |
## Note ## Note
+31 -40
View File
@@ -27,8 +27,8 @@ import (
"time" "time"
) )
// Web UI authentication. AUTH_MODE=local (default) keeps the optional HTTP Basic // Web UI authentication. AUTH_MODE=local (default) shows a login page when AUTH_USER /
// authentication (AUTH_USER / AUTH_PASS); AUTH_MODE=oidc delegates the login to an // AUTH_PASS are set (auth_local.go); AUTH_MODE=oidc delegates the login to an
// OpenID Connect provider (Keycloak, Authentik, Authelia…) with the authorization code // OpenID Connect provider (Keycloak, Authentik, Authelia…) with the authorization code
// flow and PKCE. Only the standard library is used. // flow and PKCE. Only the standard library is used.
@@ -49,13 +49,23 @@ type authConfig struct {
scopes string scopes string
sessionTTL time.Duration sessionTTL time.Duration
dataDir string dataDir string
loginLogo string // local mode: PNG shown on the login page
} }
// newAuth returns the middleware that protects the UI and the API (except /healthz). // newAuth returns the middleware that protects the UI and the API (except /healthz).
func newAuth(c authConfig, next http.Handler) (http.Handler, error) { func newAuth(c authConfig, next http.Handler) (http.Handler, error) {
if c.sessionTTL <= 0 {
c.sessionTTL = 12 * time.Hour
}
switch strings.ToLower(c.mode) { switch strings.ToLower(c.mode) {
case "", "local": case "", "local":
return basicAuth(c.user, c.pass, next), nil if c.user == "" {
return next, nil
}
l := newLocal(c)
l.next = next
return l, nil
case "oidc": case "oidc":
o, err := newOIDC(c) o, err := newOIDC(c)
if err != nil { if err != nil {
@@ -68,28 +78,6 @@ func newAuth(c authConfig, next http.Handler) (http.Handler, error) {
return nil, fmt.Errorf("AUTH_MODE=%q: expected local or oidc", c.mode) 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 { type oidcMeta struct {
Issuer string `json:"issuer"` Issuer string `json:"issuer"`
AuthEndpoint string `json:"authorization_endpoint"` AuthEndpoint string `json:"authorization_endpoint"`
@@ -136,9 +124,6 @@ func newOIDC(c authConfig) (*OIDC, error) {
if !strings.Contains(" "+c.scopes+" ", " openid ") { if !strings.Contains(" "+c.scopes+" ", " openid ") {
c.scopes = "openid " + c.scopes c.scopes = "openid " + c.scopes
} }
if c.sessionTTL <= 0 {
c.sessionTTL = 12 * time.Hour
}
return &OIDC{ return &OIDC{
cfg: c, cfg: c,
callback: ru.Path, callback: ru.Path,
@@ -166,7 +151,7 @@ func sessionKey(dir string) []byte {
log.Fatalf("session key: %v", err) log.Fatalf("session key: %v", err)
} }
if err := os.WriteFile(path, k, 0o600); err != nil { if err := os.WriteFile(path, k, 0o600); err != nil {
log.Printf("oidc: cannot save %s (%v): sessions end when logstream restarts", path, err) log.Printf("auth: cannot save %s (%v): sessions end when logstream restarts", path, err)
} }
return k return k
} }
@@ -176,6 +161,14 @@ type session struct {
Exp int64 `json:"e"` Exp int64 `json:"e"`
} }
// writeAuthRequired answers API calls without a session; the UI turns it into a reload
// (and so into a new login).
func writeAuthRequired(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"authentication required","code":"auth"}` + "\n"))
}
type loginState struct { type loginState struct {
Nonce string `json:"n"` Nonce string `json:"n"`
Verifier string `json:"v"` Verifier string `json:"v"`
@@ -196,7 +189,7 @@ func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return return
} }
var s session var s session
if c, err := r.Cookie(sessionCookie); err == nil && o.verifyCookie(c.Value, &s) && time.Now().Unix() < s.Exp { if c, err := r.Cookie(sessionCookie); err == nil && verifyCookie(o.key, c.Value, &s) && time.Now().Unix() < s.Exp {
if r.URL.Path == "/auth/me" { if r.URL.Path == "/auth/me" {
writeJSON(w, http.StatusOK, map[string]string{"mode": "oidc", "user": s.User}) writeJSON(w, http.StatusOK, map[string]string{"mode": "oidc", "user": s.User})
return return
@@ -210,9 +203,7 @@ func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
o.startLogin(w, r) o.startLogin(w, r)
return return
} }
w.Header().Set("Content-Type", "application/json") writeAuthRequired(w)
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"authentication required","code":"auth"}` + "\n"))
} }
func (o *OIDC) startLogin(w http.ResponseWriter, r *http.Request) { func (o *OIDC) startLogin(w http.ResponseWriter, r *http.Request) {
@@ -229,7 +220,7 @@ func (o *OIDC) startLogin(w http.ResponseWriter, r *http.Request) {
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: loginCookie + state, Name: loginCookie + state,
Value: o.signCookie(loginState{Nonce: nonce, Verifier: verifier, Return: ret, Exp: time.Now().Add(loginTTL).Unix()}), Value: signCookie(o.key, loginState{Nonce: nonce, Verifier: verifier, Return: ret, Exp: time.Now().Add(loginTTL).Unix()}),
Path: "/", Path: "/",
MaxAge: int(loginTTL.Seconds()), MaxAge: int(loginTTL.Seconds()),
HttpOnly: true, HttpOnly: true,
@@ -260,7 +251,7 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
state := q.Get("state") state := q.Get("state")
var ls loginState var ls loginState
c, err := r.Cookie(loginCookie + state) c, err := r.Cookie(loginCookie + state)
if state == "" || err != nil || !o.verifyCookie(c.Value, &ls) || time.Now().Unix() > ls.Exp { if state == "" || err != nil || !verifyCookie(o.key, c.Value, &ls) || time.Now().Unix() > ls.Exp {
http.Error(w, "login expired or started in another browser: open logstream again", http.StatusBadRequest) http.Error(w, "login expired or started in another browser: open logstream again", http.StatusBadRequest)
return return
} }
@@ -275,7 +266,7 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
log.Printf("oidc: %s logged in", user) log.Printf("oidc: %s logged in", user)
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Name: sessionCookie,
Value: o.signCookie(session{User: user, Exp: time.Now().Add(o.cfg.sessionTTL).Unix()}), Value: signCookie(o.key, session{User: user, Exp: time.Now().Add(o.cfg.sessionTTL).Unix()}),
Path: "/", Path: "/",
MaxAge: int(o.cfg.sessionTTL.Seconds()), MaxAge: int(o.cfg.sessionTTL.Seconds()),
HttpOnly: true, HttpOnly: true,
@@ -564,15 +555,15 @@ func (o *OIDC) getJSON(u string, v any) error {
} }
// Cookies are base64url(JSON) + "." + base64url(HMAC-SHA256). // Cookies are base64url(JSON) + "." + base64url(HMAC-SHA256).
func (o *OIDC) signCookie(v any) string { func signCookie(key []byte, v any) string {
b, _ := json.Marshal(v) b, _ := json.Marshal(v)
p := base64.RawURLEncoding.EncodeToString(b) p := base64.RawURLEncoding.EncodeToString(b)
m := hmac.New(sha256.New, o.key) m := hmac.New(sha256.New, key)
m.Write([]byte(p)) m.Write([]byte(p))
return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil)) return p + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil))
} }
func (o *OIDC) verifyCookie(s string, v any) bool { func verifyCookie(key []byte, s string, v any) bool {
p, sig, ok := strings.Cut(s, ".") p, sig, ok := strings.Cut(s, ".")
if !ok { if !ok {
return false return false
@@ -581,7 +572,7 @@ func (o *OIDC) verifyCookie(s string, v any) bool {
if err != nil { if err != nil {
return false return false
} }
m := hmac.New(sha256.New, o.key) m := hmac.New(sha256.New, key)
m.Write([]byte(p)) m.Write([]byte(p))
if !hmac.Equal(got, m.Sum(nil)) { if !hmac.Equal(got, m.Sum(nil)) {
return false return false
+167
View File
@@ -0,0 +1,167 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"log"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// Local mode with AUTH_USER set: a login page (web/login.html) in the colors of the UI and a
// session kept in a signed cookie, like the OIDC mode. HTTP Basic credentials are still
// accepted so scripts calling the API keep working, but the browser popup is gone.
const loginPage = "/login.html"
var loginFailDelay = time.Second // slows down password guessing
type Local struct {
user, pass string
ttl time.Duration
logo string // LOGIN_LOGO, served at /auth/logo
key []byte
next http.Handler
}
func newLocal(c authConfig) *Local {
// The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session.
m := hmac.New(sha256.New, sessionKey(c.dataDir))
m.Write([]byte("local\x00" + c.user + "\x00" + c.pass))
if c.loginLogo != "" {
if _, err := os.Stat(c.loginLogo); err != nil {
log.Printf("auth: LOGIN_LOGO: %v", err)
}
}
log.Printf("local authentication enabled (user %s)", c.user)
return &Local{user: c.user, pass: c.pass, ttl: c.sessionTTL, logo: c.loginLogo, key: m.Sum(nil)}
}
func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/healthz", "/style.css":
l.next.ServeHTTP(w, r)
return
case "/auth/logo":
l.serveLogo(w, r)
return
case "/auth/login":
l.handleLogin(w, r)
return
case "/auth/logout":
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, Secure: isHTTPS(r)})
http.Redirect(w, r, loginPage, http.StatusFound)
return
}
user, ok := l.sessionUser(r)
if !ok {
if u, p, basic := r.BasicAuth(); basic && l.check(u, p) {
user, ok = u, true
}
}
switch {
case r.URL.Path == loginPage:
if ok {
http.Redirect(w, r, safeReturn(r.URL.Query().Get("r")), http.StatusFound)
return
}
w.Header().Set("Cache-Control", "no-store")
l.next.ServeHTTP(w, r)
case ok && r.URL.Path == "/auth/me":
writeJSON(w, http.StatusOK, map[string]string{"mode": "local", "user": user})
case ok:
l.next.ServeHTTP(w, r)
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me":
target := loginPage
if ret := r.URL.RequestURI(); ret != "/" {
target += "?" + url.Values{"r": {ret}}.Encode()
}
http.Redirect(w, r, target, http.StatusFound)
default:
writeAuthRequired(w)
}
}
func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, loginPage, http.StatusFound)
return
}
user, pass := r.PostFormValue("user"), r.PostFormValue("pass")
ret := safeReturn(r.PostFormValue("r"))
if !l.check(user, pass) {
log.Printf("auth: failed login for %q from %s", user, clientIP(r))
time.Sleep(loginFailDelay)
q := url.Values{"e": {"1"}}
if ret != "/" {
q.Set("r", ret)
}
http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther)
return
}
log.Printf("auth: %s logged in from %s", user, clientIP(r))
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: signCookie(l.key, session{User: user, Exp: time.Now().Add(l.ttl).Unix()}),
Path: "/",
MaxAge: int(l.ttl.Seconds()),
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, ret, http.StatusSeeOther)
}
func (l *Local) sessionUser(r *http.Request) (string, bool) {
var s session
c, err := r.Cookie(sessionCookie)
if err != nil || !verifyCookie(l.key, c.Value, &s) || time.Now().Unix() >= s.Exp {
return "", false
}
return s.User, true
}
func (l *Local) check(user, pass string) bool {
u := subtle.ConstantTimeCompare([]byte(user), []byte(l.user))
p := subtle.ConstantTimeCompare([]byte(pass), []byte(l.pass))
return u&p == 1
}
// serveLogo sends LOGIN_LOGO; without it the login page hides the image.
func (l *Local) serveLogo(w http.ResponseWriter, r *http.Request) {
if l.logo == "" {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "no-cache")
http.ServeFile(w, r, l.logo)
}
// safeReturn keeps the page to open after login inside logstream.
func safeReturn(ret string) string {
if !strings.HasPrefix(ret, "/") || strings.HasPrefix(ret, "//") || strings.HasPrefix(ret, "/\\") || ret == loginPage {
return "/"
}
return ret
}
// isHTTPS is true when the browser talks HTTPS, directly or through a reverse proxy.
func isHTTPS(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func clientIP(r *http.Request) string {
if f := r.Header.Get("X-Forwarded-For"); f != "" {
return strings.TrimSpace(strings.Split(f, ",")[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
+142
View File
@@ -0,0 +1,142 @@
package main
import (
"net/http"
"net/http/cookiejar"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
// newLocalApp puts the local login in front of a handler that echoes "app" and returns a
// browser (client with cookies) that does not follow redirects.
func newLocalApp(t *testing.T, c authConfig) (string, *http.Client) {
t.Helper()
loginFailDelay = 0
c.mode, c.dataDir = "local", t.TempDir()
h, err := newAuth(c, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("app " + r.URL.Path)) }))
if err != nil {
t.Fatal(err)
}
jar, _ := cookiejar.New(nil)
return "http://app.test", &http.Client{
Jar: jar,
Transport: hosts{"app.test": h},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
}
func login(t *testing.T, c *http.Client, app, user, pass, ret string) *http.Response {
t.Helper()
res, err := c.PostForm(app+"/auth/login", url.Values{"user": {user}, "pass": {pass}, "r": {ret}})
if err != nil {
t.Fatal(err)
}
res.Body.Close()
return res
}
func TestLocalLoginFlow(t *testing.T) {
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
res, _ := c.Get(app + "/api/logs?q=x")
if res.StatusCode != http.StatusUnauthorized || res.Header.Get("WWW-Authenticate") != "" {
t.Fatalf("API without session: %d %q", res.StatusCode, res.Header.Get("WWW-Authenticate"))
}
res, _ = c.Get(app + "/?q=disk")
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusFound || loc != "/login.html?r=%2F%3Fq%3Ddisk" {
t.Fatalf("page without session: %d %q", res.StatusCode, loc)
}
for _, p := range []string{"/login.html", "/style.css", "/healthz"} {
if code, body := get(t, c, app+p); code != http.StatusOK || body != "app "+p {
t.Errorf("%s without session: %d %q", p, code, body)
}
}
res = login(t, c, app, "admin", "wrong", "/?q=disk")
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusSeeOther || !strings.Contains(loc, "e=1") {
t.Fatalf("wrong password: %d %q", res.StatusCode, loc)
}
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
t.Fatal("session created by a wrong password")
}
res = login(t, c, app, "admin", "pw", "//evil.example/")
if loc := res.Header.Get("Location"); loc != "/" {
t.Fatalf("open redirect: %q", loc)
}
res = login(t, c, app, "admin", "pw", "/?q=disk")
if loc := res.Header.Get("Location"); res.StatusCode != http.StatusSeeOther || loc != "/?q=disk" {
t.Fatalf("login: %d %q", res.StatusCode, loc)
}
if code, body := get(t, c, app+"/api/logs"); code != http.StatusOK || body != "app /api/logs" {
t.Fatalf("API with session: %d %q", code, body)
}
if code, body := get(t, c, app+"/auth/me"); code != http.StatusOK || !strings.Contains(body, `"user":"admin"`) || !strings.Contains(body, `"mode":"local"`) {
t.Fatalf("/auth/me: %d %s", code, body)
}
if res, _ := c.Get(app + "/login.html"); res.StatusCode != http.StatusFound {
t.Errorf("login page while logged in: %d", res.StatusCode)
}
if res, _ := c.Get(app + "/auth/logout"); res.Header.Get("Location") != "/login.html" {
t.Fatalf("logout: %q", res.Header.Get("Location"))
}
if code, _ := get(t, c, app+"/api/logs"); code != http.StatusUnauthorized {
t.Fatal("session still valid after logout")
}
}
func TestLocalBasicAuthForScripts(t *testing.T) {
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
req, _ := http.NewRequest(http.MethodGet, app+"/api/logs", nil)
req.SetBasicAuth("admin", "pw")
if res, _ := c.Do(req); res.StatusCode != http.StatusOK {
t.Fatalf("basic auth: %d", res.StatusCode)
}
req.SetBasicAuth("admin", "nope")
if res, _ := c.Do(req); res.StatusCode != http.StatusUnauthorized {
t.Fatalf("wrong basic auth: %d", res.StatusCode)
}
}
func TestLocalPasswordChangeEndsSessions(t *testing.T) {
dir := t.TempDir()
loginFailDelay = 0
echo := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
h1, _ := newAuth(authConfig{mode: "local", user: "admin", pass: "old", dataDir: dir}, echo)
h2, _ := newAuth(authConfig{mode: "local", user: "admin", pass: "new", dataDir: dir}, echo)
cookie := signCookie(h1.(*Local).key, session{User: "admin", Exp: 9999999999})
r, _ := http.NewRequest(http.MethodGet, "/", nil)
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: cookie})
if _, ok := h1.(*Local).sessionUser(r); !ok {
t.Fatal("session refused with the same password")
}
if _, ok := h2.(*Local).sessionUser(r); ok {
t.Fatal("session kept after a password change")
}
}
func TestLocalLogo(t *testing.T) {
app, c := newLocalApp(t, authConfig{user: "admin", pass: "pw"})
if code, _ := get(t, c, app+"/auth/logo"); code != http.StatusNotFound {
t.Errorf("no LOGIN_LOGO: %d", code)
}
png := filepath.Join(t.TempDir(), "logo.png")
_ = os.WriteFile(png, []byte("\x89PNG\r\n\x1a\nfake"), 0o644)
app, c = newLocalApp(t, authConfig{user: "admin", pass: "pw", loginLogo: png})
res, _ := c.Get(app + "/auth/logo")
if res.StatusCode != http.StatusOK || res.Header.Get("Content-Type") != "image/png" {
t.Errorf("LOGIN_LOGO: %d %q", res.StatusCode, res.Header.Get("Content-Type"))
}
}
func TestLocalWithoutUserIsOpen(t *testing.T) {
if h, _ := newAuth(authConfig{mode: "local"}, http.NotFoundHandler()); h == nil || isLocal(h) {
t.Error("local mode without AUTH_USER should not protect anything")
}
}
func isLocal(h http.Handler) bool { _, ok := h.(*Local); return ok }
+5 -2
View File
@@ -14,15 +14,16 @@ 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_MODE: ${AUTH_MODE:-local} # local (page de connexion, compte 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:-}
LOGIN_LOGO: ${LOGIN_LOGO:-} # PNG affiche sur la page de connexion (chemin dans le conteneur, voir volumes)
OIDC_ISSUER: ${OIDC_ISSUER:-} OIDC_ISSUER: ${OIDC_ISSUER:-}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-} OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-} OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-}
OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-} OIDC_REDIRECT_URL: ${OIDC_REDIRECT_URL:-}
OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email} OIDC_SCOPES: ${OIDC_SCOPES:-openid profile email}
OIDC_SESSION_TTL: ${OIDC_SESSION_TTL:-12h} SESSION_TTL: ${SESSION_TTL:-${OIDC_SESSION_TTL:-12h}} # duree de la session (local et oidc)
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}
@@ -38,6 +39,8 @@ services:
# logs systeme de l'hote, en lecture seule (source a activer dans Reglages > Sources) # logs systeme de l'hote, en lecture seule (source a activer dans Reglages > Sources)
- /var/log:/host/var/log:ro # journal systemd persistant et fichiers texte - /var/log:/host/var/log:ro # journal systemd persistant et fichiers texte
- /run/log/journal:/host/run/log/journal:ro # journal systemd volatile - /run/log/journal:/host/run/log/journal:ro # journal systemd volatile
# logo de la page de connexion, avec LOGIN_LOGO=/config/logo.png dans .env
# - ./logo.png:/config/logo.png:ro
labels: labels:
logstream.exclude: "true" # pas de collect des logs logstream logstream.exclude: "true" # pas de collect des logs logstream
+3 -1
View File
@@ -89,7 +89,9 @@ func main() {
clientSecret: os.Getenv("OIDC_CLIENT_SECRET"), clientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
redirectURL: os.Getenv("OIDC_REDIRECT_URL"), redirectURL: os.Getenv("OIDC_REDIRECT_URL"),
scopes: os.Getenv("OIDC_SCOPES"), scopes: os.Getenv("OIDC_SCOPES"),
sessionTTL: getenvDuration("OIDC_SESSION_TTL", 12*time.Hour), // SESSION_TTL applies to both modes; OIDC_SESSION_TTL is its former name.
sessionTTL: getenvDuration("SESSION_TTL", getenvDuration("OIDC_SESSION_TTL", 12*time.Hour)),
loginLogo: os.Getenv("LOGIN_LOGO"),
}, },
rdns: getenvBool("RDNS", true), rdns: getenvBool("RDNS", true),
dnsServer: os.Getenv("DNS_SERVER"), dnsServer: os.Getenv("DNS_SERVER"),
+1 -1
View File
@@ -2103,7 +2103,7 @@ $('#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. // With a login (local or OIDC), show who is logged in and the log out button.
fetch('/auth/me').then((res) => (res.ok ? res.json() : null)).then((me) => { fetch('/auth/me').then((res) => (res.ok ? res.json() : null)).then((me) => {
if (!me || !me.user) return; if (!me || !me.user) return;
const btn = $('#logoutBtn'); const btn = $('#logoutBtn');
+95
View File
@@ -0,0 +1,95 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Logstream</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M8 10h16M8 16h11M8 22h14' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css">
<script>
// Same saved theme and language as the UI, applied before first paint.
try {
var t = localStorage.getItem('logstream.theme');
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
var l = localStorage.getItem('logstream.lang');
if (l) document.documentElement.lang = l;
} catch (e) {}
</script>
</head>
<body class="login-page">
<div class="login-tools">
<div class="seg" role="radiogroup" id="langSwitch">
<button type="button" role="radio" data-lang="fr">FR</button>
<button type="button" role="radio" data-lang="en">EN</button>
</div>
<button id="themeBtn" class="icon-btn" type="button" data-i18n-title="theme" data-i18n-aria="theme">
<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>
</div>
<main class="login-card">
<img id="logo" class="login-logo" src="auth/logo" alt="" hidden>
<div class="brand">
<svg viewBox="0 0 32 32" aria-hidden="true"><rect width="32" height="32" rx="8"/><path d="M8 10h16M8 16h11M8 22h14"/></svg>
<span>Logstream</span>
</div>
<p class="muted" data-i18n="intro">Sign in to view the logs.</p>
<form method="post" action="auth/login">
<input type="hidden" name="r" id="ret">
<label for="user" data-i18n="user">User</label>
<input id="user" name="user" type="text" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
<label for="pass" data-i18n="pass">Password</label>
<input id="pass" name="pass" type="password" autocomplete="current-password" required>
<p id="err" class="login-err" role="alert" data-i18n="error" hidden>Wrong user or password.</p>
<button class="btn primary" type="submit" data-i18n="submit">Sign in</button>
</form>
</main>
<script>
(function () {
var I18N = {
en: { intro: 'Sign in to view the logs.', user: 'User', pass: 'Password', submit: 'Sign in',
error: 'Wrong user or password.', theme: 'Light / dark theme' },
fr: { intro: 'Connectez-vous pour consulter les logs.', user: 'Utilisateur', pass: 'Mot de passe',
submit: 'Se connecter', error: 'Utilisateur ou mot de passe incorrect.', theme: 'Thème clair / sombre' },
};
function get(k) { try { return localStorage.getItem('logstream.' + k); } catch (e) { return null; } }
function set(k, v) { try { localStorage.setItem('logstream.' + k, v); } catch (e) {} }
var lang = get('lang');
if (!I18N[lang]) lang = /^fr\b/i.test(navigator.language || '') ? 'fr' : 'en';
function applyLang() {
var d = I18N[lang];
document.documentElement.lang = lang;
document.querySelectorAll('[data-i18n]').forEach(function (el) { el.textContent = d[el.dataset.i18n]; });
document.querySelectorAll('[data-i18n-title]').forEach(function (el) { el.title = d[el.dataset.i18nTitle]; });
document.querySelectorAll('[data-i18n-aria]').forEach(function (el) { el.setAttribute('aria-label', d[el.dataset.i18nAria]); });
document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) { b.setAttribute('aria-checked', String(b.dataset.lang === lang)); });
}
document.querySelectorAll('#langSwitch [data-lang]').forEach(function (b) {
b.addEventListener('click', function () { lang = b.dataset.lang; set('lang', lang); applyLang(); });
});
document.getElementById('themeBtn').addEventListener('click', function () {
var root = document.documentElement;
var dark = root.dataset.theme ? root.dataset.theme === 'dark' : matchMedia('(prefers-color-scheme: dark)').matches;
root.dataset.theme = dark ? 'light' : 'dark';
set('theme', root.dataset.theme);
});
var q = new URLSearchParams(location.search);
document.getElementById('ret').value = q.get('r') || '/';
document.getElementById('err').hidden = q.get('e') !== '1';
// LOGIN_LOGO: shown only when the server has one.
var logo = document.getElementById('logo');
logo.addEventListener('load', function () { logo.hidden = false; });
if (logo.complete && logo.naturalWidth) logo.hidden = false;
applyLang();
})();
</script>
</body>
</html>
+26
View File
@@ -672,3 +672,29 @@ input.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2
.tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; } .tag-row .opts { grid-column: 1 / -1; grid-row: 2; flex-wrap: wrap; }
.tag-row [data-del] { grid-column: 3; grid-row: 1; } .tag-row [data-del] { grid-column: 3; grid-row: 1; }
} }
/* ---------- Login page (AUTH_MODE=local) ---------- */
body.login-page {
min-height: 100vh; padding: 16px;
display: grid; place-items: center;
}
.login-tools { position: fixed; top: 12px; right: 16px; display: flex; align-items: center; gap: 8px; }
.login-card {
width: 100%; max-width: 360px;
display: flex; flex-direction: column; align-items: center; gap: 10px;
padding: 32px 28px 28px;
background: var(--panel); border: 1px solid var(--border); border-radius: 14px; box-shadow: var(--shadow);
}
.login-logo { max-width: 200px; max-height: 96px; object-fit: contain; margin-bottom: 6px; }
.login-card .brand { font-size: 20px; }
.login-card .brand svg { width: 32px; height: 32px; }
.login-card > p { margin: 0 0 8px; text-align: center; }
.login-card form { width: 100%; display: flex; flex-direction: column; gap: 6px; }
.login-card label { font-size: 13px; font-weight: 550; }
.login-card input {
height: 38px; padding: 0 11px; margin-bottom: 6px;
border: 1px solid var(--border); border-radius: 9px; background: var(--bg);
}
.login-card input:focus { outline: 0; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.login-card .btn.primary { height: 38px; justify-content: center; margin-top: 6px; font-size: 14px; }
.login-err { margin: 0; color: var(--sev-err); font-size: 13px; }