Login page for AUTH_MODE=local instead of the Basic Auth popup
The local mode now shows a login page in the colors of the UI (light/dark theme, FR/EN) with a signed session cookie and the log out button, like the OIDC mode. LOGIN_LOGO shows a PNG mounted in the container on that page. SESSION_TTL applies to both modes (OIDC_SESSION_TTL still works). HTTP Basic credentials are still accepted for scripts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ab38a54d54
commit
7aebb1120f
11 files changed
+533
-61
No files matched your search
+7
-3
@@ -4,11 +4,16 @@ HTTP_PORT=8080
|
||||
TZ=Europe/Paris
|
||||
# How long logs are kept (e.g. 7d, 30d, 12w, 1y)
|
||||
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
|
||||
# local mode: user and password (empty = no authentication)
|
||||
AUTH_USER=
|
||||
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
|
||||
# (Keycloak: https://sso.example.org/realms/<realm>, Authentik: https://auth.example.org/application/o/<slug>/)
|
||||
OIDC_ISSUER=
|
||||
@@ -16,9 +21,8 @@ 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)
|
||||
# Requested scopes (openid is always added)
|
||||
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
|
||||
|
||||
+29
-7
@@ -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) :
|
||||
|
||||
- **`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à).
|
||||
- **`oidc`** : connexion par un fournisseur OpenID Connect (Keycloak, Authentik, Authelia, Zitadel…),
|
||||
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 :
|
||||
|
||||
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…).
|
||||
|
||||
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
|
||||
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.
|
||||
@@ -302,13 +322,14 @@ 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_MODE` | `local` | `local` (HTTP Basic) ou `oidc`, voir [Authentification](#authentification) |
|
||||
| `AUTH_USER` / `AUTH_PASS` | vide | authentification HTTP Basic pour l'interface (mode `local`) |
|
||||
| `AUTH_MODE` | `local` | `local` (page de connexion) ou `oidc`, voir [Authentification](#authentification) |
|
||||
| `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_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 |
|
||||
@@ -366,7 +387,8 @@ Pour mettre à jour l'une d'elles :
|
||||
| Fichier | Contenu |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `store.go` | insertions par lots dans VictoriaLogs et requêtes LogsQL |
|
||||
| `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` |
|
||||
| `tags.go` | stockage des tags de couleur |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -226,11 +226,29 @@ remembered per browser.
|
||||
|
||||
`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).
|
||||
- **`oidc`**: login through an OpenID Connect provider (Keycloak, Authentik, Authelia, Zitadel…),
|
||||
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:
|
||||
|
||||
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…).
|
||||
|
||||
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
|
||||
(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 |
|
||||
| `HTTP_PORT` | `8080` | web UI port |
|
||||
| `RETENTION` | `30d` | how long VictoriaLogs keeps logs |
|
||||
| `AUTH_MODE` | `local` | `local` (HTTP Basic) or `oidc`, see [Authentication](#authentication) |
|
||||
| `AUTH_USER` / `AUTH_PASS` | empty | HTTP Basic authentication for the UI (`local` mode) |
|
||||
| `AUTH_MODE` | `local` | `local` (login page) or `oidc`, see [Authentication](#authentication) |
|
||||
| `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_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 |
|
||||
@@ -335,7 +354,8 @@ To update one of them:
|
||||
| File | Contents |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `store.go` | batched inserts into VictoriaLogs and LogsQL queries |
|
||||
| `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 |
|
||||
| `tags.go` | color tag storage |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ import (
|
||||
"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
|
||||
// Web UI authentication. AUTH_MODE=local (default) shows a login page when AUTH_USER /
|
||||
// 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
|
||||
// flow and PKCE. Only the standard library is used.
|
||||
|
||||
@@ -49,13 +49,23 @@ type authConfig struct {
|
||||
scopes string
|
||||
sessionTTL time.Duration
|
||||
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).
|
||||
func newAuth(c authConfig, next http.Handler) (http.Handler, error) {
|
||||
if c.sessionTTL <= 0 {
|
||||
c.sessionTTL = 12 * time.Hour
|
||||
}
|
||||
switch strings.ToLower(c.mode) {
|
||||
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":
|
||||
o, err := newOIDC(c)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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"`
|
||||
@@ -136,9 +124,6 @@ func newOIDC(c authConfig) (*OIDC, error) {
|
||||
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,
|
||||
@@ -166,7 +151,7 @@ func sessionKey(dir string) []byte {
|
||||
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)
|
||||
log.Printf("auth: cannot save %s (%v): sessions end when logstream restarts", path, err)
|
||||
}
|
||||
return k
|
||||
}
|
||||
@@ -176,6 +161,14 @@ type session struct {
|
||||
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 {
|
||||
Nonce string `json:"n"`
|
||||
Verifier string `json:"v"`
|
||||
@@ -196,7 +189,7 @@ func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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" {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"mode": "oidc", "user": s.User})
|
||||
return
|
||||
@@ -210,9 +203,7 @@ func (o *OIDC) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
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"))
|
||||
writeAuthRequired(w)
|
||||
}
|
||||
|
||||
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{
|
||||
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: "/",
|
||||
MaxAge: int(loginTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
@@ -260,7 +251,7 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
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 {
|
||||
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)
|
||||
return
|
||||
}
|
||||
@@ -275,7 +266,7 @@ func (o *OIDC) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
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()}),
|
||||
Value: signCookie(o.key, session{User: user, Exp: time.Now().Add(o.cfg.sessionTTL).Unix()}),
|
||||
Path: "/",
|
||||
MaxAge: int(o.cfg.sessionTTL.Seconds()),
|
||||
HttpOnly: true,
|
||||
@@ -564,15 +555,15 @@ func (o *OIDC) getJSON(u string, v any) error {
|
||||
}
|
||||
|
||||
// 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)
|
||||
p := base64.RawURLEncoding.EncodeToString(b)
|
||||
m := hmac.New(sha256.New, o.key)
|
||||
m := hmac.New(sha256.New, key)
|
||||
m.Write([]byte(p))
|
||||
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, ".")
|
||||
if !ok {
|
||||
return false
|
||||
@@ -581,7 +572,7 @@ func (o *OIDC) verifyCookie(s string, v any) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
m := hmac.New(sha256.New, o.key)
|
||||
m := hmac.New(sha256.New, key)
|
||||
m.Write([]byte(p))
|
||||
if !hmac.Equal(got, m.Sum(nil)) {
|
||||
return false
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Local mode with AUTH_USER set: a login page (web/login.html) in the colors of the UI and a
|
||||
// session kept in a signed cookie, like the OIDC mode. HTTP Basic credentials are still
|
||||
// accepted so scripts calling the API keep working, but the browser popup is gone.
|
||||
|
||||
const loginPage = "/login.html"
|
||||
|
||||
var loginFailDelay = time.Second // slows down password guessing
|
||||
|
||||
type Local struct {
|
||||
user, pass string
|
||||
ttl time.Duration
|
||||
logo string // LOGIN_LOGO, served at /auth/logo
|
||||
key []byte
|
||||
next http.Handler
|
||||
}
|
||||
|
||||
func newLocal(c authConfig) *Local {
|
||||
// The key depends on the credentials: changing AUTH_USER or AUTH_PASS ends every session.
|
||||
m := hmac.New(sha256.New, sessionKey(c.dataDir))
|
||||
m.Write([]byte("local\x00" + c.user + "\x00" + c.pass))
|
||||
if c.loginLogo != "" {
|
||||
if _, err := os.Stat(c.loginLogo); err != nil {
|
||||
log.Printf("auth: LOGIN_LOGO: %v", err)
|
||||
}
|
||||
}
|
||||
log.Printf("local authentication enabled (user %s)", c.user)
|
||||
return &Local{user: c.user, pass: c.pass, ttl: c.sessionTTL, logo: c.loginLogo, key: m.Sum(nil)}
|
||||
}
|
||||
|
||||
func (l *Local) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/healthz", "/style.css":
|
||||
l.next.ServeHTTP(w, r)
|
||||
return
|
||||
case "/auth/logo":
|
||||
l.serveLogo(w, r)
|
||||
return
|
||||
case "/auth/login":
|
||||
l.handleLogin(w, r)
|
||||
return
|
||||
case "/auth/logout":
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Path: "/", MaxAge: -1, HttpOnly: true, Secure: isHTTPS(r)})
|
||||
http.Redirect(w, r, loginPage, http.StatusFound)
|
||||
return
|
||||
}
|
||||
user, ok := l.sessionUser(r)
|
||||
if !ok {
|
||||
if u, p, basic := r.BasicAuth(); basic && l.check(u, p) {
|
||||
user, ok = u, true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case r.URL.Path == loginPage:
|
||||
if ok {
|
||||
http.Redirect(w, r, safeReturn(r.URL.Query().Get("r")), http.StatusFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
l.next.ServeHTTP(w, r)
|
||||
case ok && r.URL.Path == "/auth/me":
|
||||
writeJSON(w, http.StatusOK, map[string]string{"mode": "local", "user": user})
|
||||
case ok:
|
||||
l.next.ServeHTTP(w, r)
|
||||
case r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/auth/me":
|
||||
target := loginPage
|
||||
if ret := r.URL.RequestURI(); ret != "/" {
|
||||
target += "?" + url.Values{"r": {ret}}.Encode()
|
||||
}
|
||||
http.Redirect(w, r, target, http.StatusFound)
|
||||
default:
|
||||
writeAuthRequired(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Local) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Redirect(w, r, loginPage, http.StatusFound)
|
||||
return
|
||||
}
|
||||
user, pass := r.PostFormValue("user"), r.PostFormValue("pass")
|
||||
ret := safeReturn(r.PostFormValue("r"))
|
||||
if !l.check(user, pass) {
|
||||
log.Printf("auth: failed login for %q from %s", user, clientIP(r))
|
||||
time.Sleep(loginFailDelay)
|
||||
q := url.Values{"e": {"1"}}
|
||||
if ret != "/" {
|
||||
q.Set("r", ret)
|
||||
}
|
||||
http.Redirect(w, r, loginPage+"?"+q.Encode(), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
log.Printf("auth: %s logged in from %s", user, clientIP(r))
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookie,
|
||||
Value: signCookie(l.key, session{User: user, Exp: time.Now().Add(l.ttl).Unix()}),
|
||||
Path: "/",
|
||||
MaxAge: int(l.ttl.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: isHTTPS(r),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
http.Redirect(w, r, ret, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (l *Local) sessionUser(r *http.Request) (string, bool) {
|
||||
var s session
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || !verifyCookie(l.key, c.Value, &s) || time.Now().Unix() >= s.Exp {
|
||||
return "", false
|
||||
}
|
||||
return s.User, true
|
||||
}
|
||||
|
||||
func (l *Local) check(user, pass string) bool {
|
||||
u := subtle.ConstantTimeCompare([]byte(user), []byte(l.user))
|
||||
p := subtle.ConstantTimeCompare([]byte(pass), []byte(l.pass))
|
||||
return u&p == 1
|
||||
}
|
||||
|
||||
// serveLogo sends LOGIN_LOGO; without it the login page hides the image.
|
||||
func (l *Local) serveLogo(w http.ResponseWriter, r *http.Request) {
|
||||
if l.logo == "" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFile(w, r, l.logo)
|
||||
}
|
||||
|
||||
// safeReturn keeps the page to open after login inside logstream.
|
||||
func safeReturn(ret string) string {
|
||||
if !strings.HasPrefix(ret, "/") || strings.HasPrefix(ret, "//") || strings.HasPrefix(ret, "/\\") || ret == loginPage {
|
||||
return "/"
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
// isHTTPS is true when the browser talks HTTPS, directly or through a reverse proxy.
|
||||
func isHTTPS(r *http.Request) bool {
|
||||
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
}
|
||||
|
||||
func clientIP(r *http.Request) string {
|
||||
if f := r.Header.Get("X-Forwarded-For"); f != "" {
|
||||
return strings.TrimSpace(strings.Split(f, ",")[0])
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -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
@@ -14,15 +14,16 @@ 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_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_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_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}
|
||||
SESSION_TTL: ${SESSION_TTL:-${OIDC_SESSION_TTL:-12h}} # duree de la session (local et oidc)
|
||||
RDNS: ${RDNS:-on} # resol dns
|
||||
DNS_SERVER: ${DNS_SERVER:-} # si resolv directe
|
||||
ALLOW_PURGE: ${ALLOW_PURGE:-true}
|
||||
@@ -38,6 +39,8 @@ services:
|
||||
# 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
|
||||
- /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:
|
||||
logstream.exclude: "true" # pas de collect des logs logstream
|
||||
|
||||
|
||||
@@ -89,7 +89,9 @@ func main() {
|
||||
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),
|
||||
// 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),
|
||||
dnsServer: os.Getenv("DNS_SERVER"),
|
||||
|
||||
+1
-1
@@ -2103,7 +2103,7 @@ $('#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.
|
||||
// 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) => {
|
||||
if (!me || !me.user) return;
|
||||
const btn = $('#logoutBtn');
|
||||
|
||||
@@ -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>
|
||||
@@ -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 [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; }
|
||||
Reference in new issue
Block a user