feat: Betrieb hinter einem Anmelde-Proxy, Schlüssel für die App-Liste
Marc stellt Pangolin davor und will keinen zweiten Anmeldeschirm. - HONEYOS_PROXY_AUTH=true: HoneyOS prüft nicht mehr selbst, der Start sagt deutlich, dass der Container dann nur über den Proxy erreichbar sein darf. Mit HONEYOS_USER_HEADER bekommt jeder Name seine eigenen Einstellungen. - HONEYOS_APPS_KEY schützt /api/apps. Nötig, weil HoneyOS auf einer anderen Site liegt als die Apps (duckdns.org steht auf der Public-Suffix-Liste) – der Browser schickt die Sitzung dort nicht mit, und der Pfad muss im Proxy von der Anmeldung befreit sein. Beim Durchspielen mit zwei echten Hostnamen fiel ein Loch auf: Ohne eigene Anmeldung gilt jede Anfrage als angemeldet, also gab der Rückfall auf diese Prüfung die Liste an jeden heraus – auch ohne oder mit falschem Schlüssel. Der Rückfall greift jetzt nur noch, wo es wirklich eine Sitzung geben kann. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
905b136516
commit
1c3218939b
8 files changed
+374
-37
No files matched your search
+37
-17
@@ -24,23 +24,43 @@ HONEYOS_SESSION_SECRET=
|
|||||||
HONEYOS_DATA_DIR=/srv/honeyos
|
HONEYOS_DATA_DIR=/srv/honeyos
|
||||||
|
|
||||||
# ── Anmeldung ────────────────────────────────────────────────────────────────
|
# ── Anmeldung ────────────────────────────────────────────────────────────────
|
||||||
# Leerer HONEYOS_OIDC_ISSUER = KEINE Anmeldung, jeder kommt rein. Das ist zum
|
# Genau einer der beiden Wege:
|
||||||
# Entwickeln gedacht und gehört nicht ins offene Netz.
|
|
||||||
#
|
#
|
||||||
# In PocketID einen Client anlegen und dort als Rückrufadresse exakt
|
# A) Ein Proxy davor meldet an (Pangolin, Authelia, …) — HoneyOS fragt nicht
|
||||||
# https://os.honeyy.duckdns.org/api/auth/callback
|
# noch einmal. Dann NUR das hier setzen:
|
||||||
# eintragen. HoneyOS schickt PKCE mit, ein Client-Geheimnis braucht es
|
HONEYOS_PROXY_AUTH=true
|
||||||
# trotzdem — also keinen „public client" anlegen.
|
# Optional: Kopfzeile, in die der Proxy den Nutzernamen schreibt. Ohne sie
|
||||||
HONEYOS_OIDC_ISSUER=https://id.honeyy.duckdns.org
|
# teilen sich alle einen Satz Einstellungen — bei einer Person völlig ok.
|
||||||
HONEYOS_OIDC_CLIENT_ID=honeyos
|
# HONEYOS_USER_HEADER=Remote-User
|
||||||
HONEYOS_OIDC_CLIENT_SECRET=
|
#
|
||||||
# Nur nötig, wenn die Rückrufadresse von PUBLIC_URL abweicht:
|
# ⚠ In diesem Betrieb prüft HoneyOS selbst nichts mehr. Der Container darf
|
||||||
# HONEYOS_OIDC_REDIRECT_URL=
|
# dann NUR über den Proxy erreichbar sein — nicht zusätzlich auf einem
|
||||||
|
# offenen Port im Netz hängen.
|
||||||
|
#
|
||||||
|
# B) HoneyOS meldet selbst an, mit eigenem Anmeldeschirm. Dann PROXY_AUTH
|
||||||
|
# weglassen und stattdessen:
|
||||||
|
# HONEYOS_SESSION_SECRET= # openssl rand -hex 32
|
||||||
|
# HONEYOS_OIDC_ISSUER=https://id.example.com
|
||||||
|
# HONEYOS_OIDC_CLIENT_ID=honeyos
|
||||||
|
# HONEYOS_OIDC_CLIENT_SECRET=
|
||||||
|
# HONEYOS_OIDC_REDIRECT_URL= # nur wenn abweichend von PUBLIC_URL
|
||||||
|
# HONEYOS_BUTTON_LABEL=Anmelden mit PocketID
|
||||||
|
# HONEYOS_ALLOWED_USERS=marc # zweiter Riegel neben den IdP-Gruppen
|
||||||
|
|
||||||
# Beschriftung des einen Knopfes auf dem Anmeldeschirm.
|
# ── Schlüssel für die App-Liste ──────────────────────────────────────────────
|
||||||
HONEYOS_BUTTON_LABEL=Anmelden mit PocketID
|
# Nötig, sobald die Leiste in einer App läuft, die auf einer ANDEREN Site liegt
|
||||||
|
# als HoneyOS — etwa honeyos.duckdns.org gegenüber cloud.honeyy.duckdns.org.
|
||||||
|
# Der Browser schickt die Sitzung dann nicht mit, und ohne zweiten Ausweis
|
||||||
|
# bekäme die Leiste nie eine Liste.
|
||||||
|
#
|
||||||
|
# Ist der Pfad /api/apps im Proxy von der Anmeldung befreit, ist dieser
|
||||||
|
# Schlüssel das Einzige, was davorsteht. Dann setzen.
|
||||||
|
# openssl rand -hex 16
|
||||||
|
HONEYOS_APPS_KEY=
|
||||||
|
|
||||||
# Zweiter Riegel neben den Gruppen in PocketID: nur diese Konten dürfen rein
|
# Derselbe Wert gehört in das <script>-Tag der App:
|
||||||
# (Benutzername oder sub, kommagetrennt). Leer = jeder, den der Anbieter
|
# <script defer src="https://honeyos.duckdns.org/honeybar/honeybar.js"
|
||||||
# durchlässt.
|
# data-app="aether" data-key="…"></script>
|
||||||
# HONEYOS_ALLOWED_USERS=marc
|
#
|
||||||
|
# Zusätzlich lässt HoneyOS die Liste nur an Ursprünge heraus, die selbst als App
|
||||||
|
# eingetragen sind. Der Schlüssel allein nützt einer fremden Seite also nichts.
|
||||||
@@ -29,33 +29,73 @@ und die Einstellungen je Nutzer. Sichere sie zusammen mit der `.env` — ohne
|
|||||||
außer dass `X-Forwarded-Proto: https` durchkommt — sonst setzt HoneyOS das Sitzungscookie
|
außer dass `X-Forwarded-Proto: https` durchkommt — sonst setzt HoneyOS das Sitzungscookie
|
||||||
ohne `Secure`.
|
ohne `Secure`.
|
||||||
|
|
||||||
## Anmeldung
|
## Anmeldung — zwei Wege, einer davon reicht
|
||||||
|
|
||||||
In PocketID einen Client anlegen und dort als Rückrufadresse **exakt** eintragen:
|
**A · Ein Proxy davor macht die Tür** (Pangolin, Authelia, …). Dann in der `.env`:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
HONEYOS_PROXY_AUTH=true
|
||||||
|
# HONEYOS_USER_HEADER=Remote-User # optional, trennt die Einstellungen je Nutzer
|
||||||
```
|
```
|
||||||
https://os.honeyy.duckdns.org/api/auth/callback
|
|
||||||
```
|
HoneyOS fragt dann nicht noch einmal — kein zweiter Anmeldeschirm. **Der
|
||||||
|
Container darf in diesem Betrieb nur über den Proxy erreichbar sein**, denn er
|
||||||
|
prüft selbst nichts mehr.
|
||||||
|
|
||||||
HoneyOS schickt PKCE mit, braucht aber trotzdem ein Client-Geheimnis — also **keinen
|
**B · HoneyOS meldet selbst an**, mit eigenem Anmeldeschirm über deinen
|
||||||
„public client"** anlegen. Danach in die `.env`:
|
OIDC-Anbieter. In PocketID einen Client anlegen und als Rückrufadresse **exakt**
|
||||||
|
`<PUBLIC_URL>/api/auth/callback` eintragen. HoneyOS schickt PKCE mit, braucht
|
||||||
|
aber trotzdem ein Client-Geheimnis — also **keinen „public client"**.
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
HONEYOS_PUBLIC_URL=https://os.honeyy.duckdns.org
|
|
||||||
HONEYOS_SESSION_SECRET=… # openssl rand -hex 32
|
HONEYOS_SESSION_SECRET=… # openssl rand -hex 32
|
||||||
HONEYOS_OIDC_ISSUER=https://id.honeyy.duckdns.org
|
HONEYOS_OIDC_ISSUER=https://id.example.com
|
||||||
HONEYOS_OIDC_CLIENT_ID=honeyos
|
HONEYOS_OIDC_CLIENT_ID=honeyos
|
||||||
HONEYOS_OIDC_CLIENT_SECRET=…
|
HONEYOS_OIDC_CLIENT_SECRET=…
|
||||||
HONEYOS_BUTTON_LABEL=Anmelden mit PocketID
|
HONEYOS_BUTTON_LABEL=Anmelden mit PocketID
|
||||||
# HONEYOS_ALLOWED_USERS=marc # zweiter Riegel neben den PocketID-Gruppen
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Wer nicht angemeldet ist, sieht **nur** den Anmeldeschirm — keine App-Liste, keine Namen.
|
Wer nicht angemeldet ist, sieht nur den Anmeldeschirm — keine App-Liste, keine
|
||||||
Die Sitzung ist ein signiertes Cookie ohne Gegenstück auf dem Server: ein Neustart wirft
|
Namen. Die Sitzung ist ein signiertes Cookie ohne Gegenstück im Server: ein
|
||||||
niemanden hinaus, und es gibt nichts aufzuräumen. Sie hält 30 Tage.
|
Neustart wirft niemanden hinaus. Sie hält 30 Tage.
|
||||||
|
|
||||||
**Ohne `HONEYOS_OIDC_ISSUER` läuft HoneyOS ganz ohne Anmeldung.** Das ist zum Entwickeln
|
**Weder A noch B** heißt: offen für jeden. Sagt beim Start deutlich, dass das
|
||||||
gedacht und sagt beim Start deutlich, dass es so nicht ins offene Netz gehört.
|
nur zum Entwickeln taugt.
|
||||||
|
|
||||||
|
## Wenn HoneyOS auf einer anderen Site liegt als die Apps
|
||||||
|
|
||||||
|
Der Browser behandelt `honeyos.duckdns.org` und `cloud.honeyy.duckdns.org` als
|
||||||
|
**fremde Sites** — `duckdns.org` steht auf der Public-Suffix-Liste, die
|
||||||
|
gemeinsame Endung zählt also nicht. Folge: Bei der Anfrage aus einer App heraus
|
||||||
|
schickt der Browser die HoneyOS-Sitzung **nicht** mit.
|
||||||
|
|
||||||
|
Damit die Leiste trotzdem eine App-Liste bekommt:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
HONEYOS_APPS_KEY=… # openssl rand -hex 16
|
||||||
|
```
|
||||||
|
|
||||||
|
und derselbe Wert im Skript-Tag der App:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script defer src="https://honeyos.duckdns.org/honeybar/honeybar.js"
|
||||||
|
data-app="aether" data-key="…"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Schlüssel steht damit in der App, die ohnehin nur Berechtigte sehen. Ohne
|
||||||
|
ihn antwortet `/api/apps` mit 401 — auch im Proxy-Betrieb, gerade weil dieser
|
||||||
|
Pfad im Proxy von der Anmeldung befreit sein muss.
|
||||||
|
|
||||||
|
Zweite Schranke, unabhängig davon: HoneyOS gibt die Liste nur an Ursprünge
|
||||||
|
heraus, die **selbst als App eingetragen** sind. Eine fremde Seite scheitert
|
||||||
|
also an CORS, selbst mit richtigem Schlüssel.
|
||||||
|
|
||||||
|
**Was auf zwei Sites nicht geht:** der Anmeldestand auf den Kacheln des
|
||||||
|
Schreibtischs (fragt über die Domaingrenze). Der Name *in der Leiste* geht
|
||||||
|
weiterhin — den holt sie bei der App selbst, gleicher Ursprung.
|
||||||
|
|
||||||
|
Liegen alle Adressen unter derselben Domain (`os.honeyy…`, `cloud.honeyy…`),
|
||||||
|
entfällt der Schlüssel und alles funktioniert ohne Einschränkung.
|
||||||
|
|
||||||
## Apps anlegen
|
## Apps anlegen
|
||||||
|
|
||||||
|
|||||||
@@ -109,9 +109,18 @@ func (a *Auth) clearSession(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SubFromRequest liest die Sitzung. Zweiter Rückgabewert sagt, ob sie gilt.
|
// SubFromRequest liest die Sitzung. Zweiter Rückgabewert sagt, ob sie gilt.
|
||||||
|
//
|
||||||
|
// Ohne eigene Anmeldung (Proxy davor oder offener Betrieb) gilt jede Anfrage
|
||||||
|
// als angemeldet — die Prüfung hat dann jemand anders gemacht, oder es gibt
|
||||||
|
// keine. Nennt der Proxy einen Namen, bekommt der seine eigenen Einstellungen.
|
||||||
func (a *Auth) SubFromRequest(r *http.Request) (string, bool) {
|
func (a *Auth) SubFromRequest(r *http.Request) (string, bool) {
|
||||||
if !a.Enabled() {
|
if !a.Enabled() {
|
||||||
return localSub, true // offener Betrieb, siehe Config.Open
|
if a.cfg.UserHeader != "" {
|
||||||
|
if u := strings.TrimSpace(r.Header.Get(a.cfg.UserHeader)); u != "" {
|
||||||
|
return "proxy:" + u, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return localSub, true
|
||||||
}
|
}
|
||||||
c, err := r.Cookie(sessionCookie)
|
c, err := r.Cookie(sessionCookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+154
@@ -386,3 +386,157 @@ func signIn(t *testing.T, h http.Handler) (sess, csrf *http.Cookie) {
|
|||||||
}
|
}
|
||||||
return sess, csrf
|
return sess, csrf
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Betrieb hinter einem Anmelde-Proxy ─────────────────────────────────────
|
||||||
|
// Kein eigener Anmeldeschirm: Pangolin o. ä. hat die Tür schon zugemacht.
|
||||||
|
|
||||||
|
func proxyServer(t *testing.T, cfg Config) *Server {
|
||||||
|
t.Helper()
|
||||||
|
store, err := OpenStore(dataPath(t.TempDir()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg.SessionSecret = strings.Repeat("k", 40)
|
||||||
|
return &Server{cfg: cfg, auth: NewAuth(cfg), store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProxyBetriebLässtDurch(t *testing.T) {
|
||||||
|
h := proxyServer(t, Config{ProxyAuth: true}).routes()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/me", nil))
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("/api/me hinter dem Proxy: %d", rec.Code)
|
||||||
|
}
|
||||||
|
// Kein Anmeldeschirm: /api/public darf nicht behaupten, es gäbe OIDC.
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, httptest.NewRequest("GET", "/api/public", nil))
|
||||||
|
var pub struct{ OIDC, SignedIn bool }
|
||||||
|
_ = json.Unmarshal(rec.Body.Bytes(), &pub)
|
||||||
|
if pub.OIDC || !pub.SignedIn {
|
||||||
|
t.Errorf("die Oberfläche würde einen Anmeldeschirm zeigen: %+v", pub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nennt der Proxy einen Namen, bekommt jeder Name seine eigenen Einstellungen.
|
||||||
|
func TestProxyKopfzeileTrenntEinstellungen(t *testing.T) {
|
||||||
|
s := proxyServer(t, Config{ProxyAuth: true, UserHeader: "Remote-User"})
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
put := func(user, body string) {
|
||||||
|
req := httptest.NewRequest("PUT", "/api/settings", strings.NewReader(body))
|
||||||
|
if user != "" {
|
||||||
|
req.Header.Set("Remote-User", user)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("PUT für %q: %d", user, rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put("marc", `{"phosphor":"green"}`)
|
||||||
|
put("gast", `{"phosphor":"ice"}`)
|
||||||
|
|
||||||
|
if got := s.store.User("proxy:marc").Settings["phosphor"]; got != "green" {
|
||||||
|
t.Errorf("marc: %q", got)
|
||||||
|
}
|
||||||
|
if got := s.store.User("proxy:gast").Settings["phosphor"]; got != "ice" {
|
||||||
|
t.Errorf("gast: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Schlüssel für die App-Liste ────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestAppListeBrauchtDenSchlüssel(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t, "honeyos")
|
||||||
|
s := newTestServer(t, idp.URL)
|
||||||
|
s.cfg.AppsKey = "geheim123"
|
||||||
|
s.auth = NewAuth(s.cfg)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
get := func(mod func(*http.Request)) int {
|
||||||
|
req := httptest.NewRequest("GET", "/api/apps", nil)
|
||||||
|
if mod != nil {
|
||||||
|
mod(req)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := get(nil); code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("ohne Schlüssel: %d, erwartet 401", code)
|
||||||
|
}
|
||||||
|
if code := get(func(r *http.Request) { r.Header.Set("X-HoneyOS-Key", "falsch") }); code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("falscher Schlüssel: %d, erwartet 401", code)
|
||||||
|
}
|
||||||
|
if code := get(func(r *http.Request) { r.Header.Set("X-HoneyOS-Key", "geheim123") }); code != 200 {
|
||||||
|
t.Errorf("richtiger Schlüssel: %d, erwartet 200", code)
|
||||||
|
}
|
||||||
|
if code := get(func(r *http.Request) { r.URL.RawQuery = "key=geheim123" }); code != 200 {
|
||||||
|
t.Errorf("Schlüssel als Parameter: %d, erwartet 200", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Schlüssel öffnet nur das Lesen der Liste, nicht das Schreiben.
|
||||||
|
func TestSchlüsselDarfNichtSchreiben(t *testing.T) {
|
||||||
|
idp := newFakeIdP(t, "honeyos")
|
||||||
|
s := newTestServer(t, idp.URL)
|
||||||
|
s.cfg.AppsKey = "geheim123"
|
||||||
|
s.auth = NewAuth(s.cfg)
|
||||||
|
h := s.routes()
|
||||||
|
|
||||||
|
req := httptest.NewRequest("PUT", "/api/apps", strings.NewReader(`[{"id":"x","url":"https://a.test"}]`))
|
||||||
|
req.Header.Set("X-HoneyOS-Key", "geheim123")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("Schreiben mit Schlüssel: %d, erwartet 401", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Oberfläche muss den Schlüssel mitbekommen, sonst fragt sie sich selbst aus.
|
||||||
|
func TestStartseiteTrägtDenSchlüssel(t *testing.T) {
|
||||||
|
s := proxyServer(t, Config{ProxyAuth: true, AppsKey: "geheim123"})
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
||||||
|
|
||||||
|
if !strings.Contains(rec.Body.String(), `name="honeyos-key" content="geheim123"`) {
|
||||||
|
t.Fatal("Schlüssel steht nicht in der Startseite")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne Schlüssel darf auch nichts drinstehen.
|
||||||
|
s2 := proxyServer(t, Config{ProxyAuth: true})
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
s2.routes().ServeHTTP(rec, httptest.NewRequest("GET", "/", nil))
|
||||||
|
if strings.Contains(rec.Body.String(), "honeyos-key") {
|
||||||
|
t.Error("ohne Schlüssel steht trotzdem eine Meta-Angabe in der Seite")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Fall, der beim Durchspielen aufgefallen ist: Ohne eigene Anmeldung gilt
|
||||||
|
// jede Anfrage als angemeldet. Der Schlüssel darf dann NICHT auf diese Prüfung
|
||||||
|
// zurückfallen, sonst gibt der freigegebene Pfad die Liste an jeden heraus.
|
||||||
|
func TestSchlüsselGiltAuchHinterDemProxy(t *testing.T) {
|
||||||
|
h := proxyServer(t, Config{ProxyAuth: true, AppsKey: "geheim123"}).routes()
|
||||||
|
|
||||||
|
get := func(key string) int {
|
||||||
|
req := httptest.NewRequest("GET", "/api/apps", nil)
|
||||||
|
if key != "" {
|
||||||
|
req.Header.Set("X-HoneyOS-Key", key)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := get(""); code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("ohne Schlüssel hinter dem Proxy: %d, erwartet 401", code)
|
||||||
|
}
|
||||||
|
if code := get("falsch"); code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("falscher Schlüssel hinter dem Proxy: %d, erwartet 401", code)
|
||||||
|
}
|
||||||
|
if code := get("geheim123"); code != 200 {
|
||||||
|
t.Errorf("richtiger Schlüssel: %d, erwartet 200", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,3 +30,6 @@ services:
|
|||||||
- HONEYOS_OIDC_REDIRECT_URL=${HONEYOS_OIDC_REDIRECT_URL:-}
|
- HONEYOS_OIDC_REDIRECT_URL=${HONEYOS_OIDC_REDIRECT_URL:-}
|
||||||
- HONEYOS_BUTTON_LABEL=${HONEYOS_BUTTON_LABEL:-Anmelden}
|
- HONEYOS_BUTTON_LABEL=${HONEYOS_BUTTON_LABEL:-Anmelden}
|
||||||
- HONEYOS_ALLOWED_USERS=${HONEYOS_ALLOWED_USERS:-}
|
- HONEYOS_ALLOWED_USERS=${HONEYOS_ALLOWED_USERS:-}
|
||||||
|
- HONEYOS_PROXY_AUTH=${HONEYOS_PROXY_AUTH:-}
|
||||||
|
- HONEYOS_USER_HEADER=${HONEYOS_USER_HEADER:-}
|
||||||
|
- HONEYOS_APPS_KEY=${HONEYOS_APPS_KEY:-}
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/subtle"
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -27,12 +29,30 @@ type Config struct {
|
|||||||
PublicURL string
|
PublicURL string
|
||||||
DataDir string
|
DataDir string
|
||||||
SessionSecret string
|
SessionSecret string
|
||||||
|
|
||||||
|
// Anmeldung — genau einer der beiden Wege:
|
||||||
|
//
|
||||||
|
// OIDCIssuer gesetzt → HoneyOS meldet selbst an, eigener Anmeldeschirm.
|
||||||
|
// ProxyAuth gesetzt → ein vorgelagerter Proxy (Pangolin o. ä.) hat das
|
||||||
|
// schon erledigt. HoneyOS fragt dann nicht nach.
|
||||||
|
//
|
||||||
|
// Keines von beiden heißt: offen. Das ist zum Entwickeln gedacht und sagt
|
||||||
|
// beim Start deutlich, dass es so nicht ins Netz gehört.
|
||||||
OIDCIssuer string
|
OIDCIssuer string
|
||||||
OIDCClientID string
|
OIDCClientID string
|
||||||
OIDCClientSecret string
|
OIDCClientSecret string
|
||||||
OIDCRedirect string
|
OIDCRedirect string
|
||||||
ButtonLabel string
|
ButtonLabel string
|
||||||
AllowedUsers []string
|
AllowedUsers []string
|
||||||
|
|
||||||
|
ProxyAuth bool
|
||||||
|
// UserHeader nennt die Kopfzeile, in die der Proxy den Nutzernamen schreibt
|
||||||
|
// (z. B. Remote-User). Leer heißt: ein einziger Nutzer für alle.
|
||||||
|
UserHeader string
|
||||||
|
|
||||||
|
// AppsKey schützt die App-Liste, wenn ihr Pfad im Proxy von der Anmeldung
|
||||||
|
// befreit ist. Ohne Schlüssel liest sie jeder, der die Adresse kennt.
|
||||||
|
AppsKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c Config) RedirectURL() string {
|
func (c Config) RedirectURL() string {
|
||||||
@@ -62,6 +82,9 @@ func main() {
|
|||||||
OIDCRedirect: env("HONEYOS_OIDC_REDIRECT_URL", ""),
|
OIDCRedirect: env("HONEYOS_OIDC_REDIRECT_URL", ""),
|
||||||
ButtonLabel: env("HONEYOS_BUTTON_LABEL", "Anmelden"),
|
ButtonLabel: env("HONEYOS_BUTTON_LABEL", "Anmelden"),
|
||||||
AllowedUsers: splitList(env("HONEYOS_ALLOWED_USERS", "")),
|
AllowedUsers: splitList(env("HONEYOS_ALLOWED_USERS", "")),
|
||||||
|
ProxyAuth: env("HONEYOS_PROXY_AUTH", "") == "true",
|
||||||
|
UserHeader: env("HONEYOS_USER_HEADER", ""),
|
||||||
|
AppsKey: env("HONEYOS_APPS_KEY", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.OIDCIssuer != "" {
|
if cfg.OIDCIssuer != "" {
|
||||||
@@ -75,11 +98,23 @@ func main() {
|
|||||||
case cfg.PublicURL == "" && cfg.OIDCRedirect == "":
|
case cfg.PublicURL == "" && cfg.OIDCRedirect == "":
|
||||||
log.Fatal("HONEYOS_PUBLIC_URL fehlt — daraus entsteht die Rückrufadresse")
|
log.Fatal("HONEYOS_PUBLIC_URL fehlt — daraus entsteht die Rückrufadresse")
|
||||||
}
|
}
|
||||||
|
} else if cfg.ProxyAuth {
|
||||||
|
log.Println("Anmeldung: übernimmt der vorgelagerte Proxy (HONEYOS_PROXY_AUTH=true).")
|
||||||
|
if cfg.UserHeader != "" {
|
||||||
|
log.Printf("Nutzername aus der Kopfzeile %s", cfg.UserHeader)
|
||||||
} else {
|
} else {
|
||||||
log.Println("⚠ kein HONEYOS_OIDC_ISSUER: HoneyOS läuft OHNE Anmeldung.")
|
log.Println("Kein HONEYOS_USER_HEADER: alle teilen einen Satz Einstellungen.")
|
||||||
|
}
|
||||||
|
log.Println("⚠ HoneyOS prüft dann selbst nichts mehr — es darf nur über den Proxy erreichbar sein.")
|
||||||
|
} else {
|
||||||
|
log.Println("⚠ weder HONEYOS_OIDC_ISSUER noch HONEYOS_PROXY_AUTH: HoneyOS läuft OHNE Anmeldung.")
|
||||||
log.Println("⚠ Das ist für die Entwicklung gedacht. Nicht ins offene Netz stellen.")
|
log.Println("⚠ Das ist für die Entwicklung gedacht. Nicht ins offene Netz stellen.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.AppsKey == "" && (cfg.ProxyAuth || cfg.OIDCIssuer == "") {
|
||||||
|
log.Println("Hinweis: ohne HONEYOS_APPS_KEY liest die App-Liste jeder, der ihren Pfad kennt.")
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil {
|
||||||
log.Fatalf("Datenverzeichnis %s: %v", cfg.DataDir, err)
|
log.Fatalf("Datenverzeichnis %s: %v", cfg.DataDir, err)
|
||||||
}
|
}
|
||||||
@@ -119,7 +154,7 @@ func (s *Server) routes() http.Handler {
|
|||||||
|
|
||||||
mux.HandleFunc("GET /api/me", s.requireAuth(s.handleMe))
|
mux.HandleFunc("GET /api/me", s.requireAuth(s.handleMe))
|
||||||
mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings))
|
mux.HandleFunc("PUT /api/settings", s.requireAuth(s.handleSetSettings))
|
||||||
mux.HandleFunc("GET /api/apps", s.requireAuth(s.handleApps))
|
mux.HandleFunc("GET /api/apps", s.appsReadable(s.handleApps))
|
||||||
mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps))
|
mux.HandleFunc("PUT /api/apps", s.requireAuth(s.handleSetApps))
|
||||||
|
|
||||||
web, err := fs.Sub(webFS, "web")
|
web, err := fs.Sub(webFS, "web")
|
||||||
@@ -127,11 +162,43 @@ func (s *Server) routes() http.Handler {
|
|||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
files := http.FileServer(http.FS(web))
|
files := http.FileServer(http.FS(web))
|
||||||
mux.Handle("GET /", noCache(files))
|
index := s.indexHTML(web)
|
||||||
|
|
||||||
|
// Die Startseite kommt aus dem Speicher, weil der Schlüssel hineingehört.
|
||||||
|
// Alles andere liefert der Dateiserver unverändert aus.
|
||||||
|
mux.Handle("GET /", noCache(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(index))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files.ServeHTTP(w, r)
|
||||||
|
})))
|
||||||
|
|
||||||
return s.cors(mux)
|
return s.cors(mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// indexHTML holt die Startseite aus dem Binary und legt den Schlüssel als
|
||||||
|
// Meta-Angabe hinein. Die Seite selbst ist im Proxy geschützt; wer sie sieht,
|
||||||
|
// darf die Liste ohnehin lesen.
|
||||||
|
func (s *Server) indexHTML(web fs.FS) []byte {
|
||||||
|
raw, err := fs.ReadFile(web, "index.html")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("index.html fehlt im Binary: %v", err)
|
||||||
|
}
|
||||||
|
if s.cfg.AppsKey == "" {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
meta := `<meta name="honeyos-key" content="` + htmlAttr(s.cfg.AppsKey) + `" />`
|
||||||
|
return bytes.Replace(raw, []byte("</head>"), []byte(" "+meta+"\n </head>"), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmlAttr entschärft den Wert für ein Attribut in doppelten Anführungszeichen.
|
||||||
|
func htmlAttr(v string) string {
|
||||||
|
r := strings.NewReplacer(`&`, "&", `"`, """, `<`, "<", `>`, ">")
|
||||||
|
return r.Replace(v)
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Zwischenschichten ───────────────────────────────────────────────────── */
|
/* ── Zwischenschichten ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
// cors öffnet die Leiste für die eingetragenen Apps — und nur für die. Die
|
// cors öffnet die Leiste für die eingetragenen Apps — und nur für die. Die
|
||||||
@@ -144,7 +211,7 @@ func (s *Server) cors(next http.Handler) http.Handler {
|
|||||||
h := w.Header()
|
h := w.Header()
|
||||||
h.Set("Access-Control-Allow-Origin", origin)
|
h.Set("Access-Control-Allow-Origin", origin)
|
||||||
h.Set("Access-Control-Allow-Credentials", "true")
|
h.Set("Access-Control-Allow-Credentials", "true")
|
||||||
h.Set("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token")
|
h.Set("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token, X-HoneyOS-Key")
|
||||||
h.Set("Access-Control-Allow-Methods", "GET, PUT, POST")
|
h.Set("Access-Control-Allow-Methods", "GET, PUT, POST")
|
||||||
h.Add("Vary", "Origin")
|
h.Add("Vary", "Origin")
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
@@ -163,6 +230,36 @@ func noCache(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// appsReadable lässt die App-Liste zusätzlich mit dem Schlüssel durch.
|
||||||
|
//
|
||||||
|
// Der Grund ist eine Browserregel, keine Bequemlichkeit: Liegt HoneyOS auf
|
||||||
|
// einer anderen Site als die Apps, schickt der Browser die Sitzung bei der
|
||||||
|
// Anfrage aus einer App heraus nicht mit. Ohne einen zweiten Ausweis bekäme die
|
||||||
|
// Leiste dort nie eine Liste. Der Schlüssel steht im Skript-Tag der App und ist
|
||||||
|
// damit nur für den lesbar, der ohnehin an die App herankommt.
|
||||||
|
func (s *Server) appsReadable(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if key := s.cfg.AppsKey; key != "" {
|
||||||
|
given := r.Header.Get("X-HoneyOS-Key")
|
||||||
|
if given == "" {
|
||||||
|
given = r.URL.Query().Get("key")
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(given), []byte(key)) == 1 {
|
||||||
|
next(w, r, localSub)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Ohne eigene Anmeldung gibt es keine Sitzung, die den Schlüssel
|
||||||
|
// ersetzen könnte: dort gilt jede Anfrage als angemeldet, und der
|
||||||
|
// Rückfall würde die Liste an jeden herausgeben. Hier ist Schluss.
|
||||||
|
if !s.auth.Enabled() {
|
||||||
|
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "Schlüssel fehlt oder passt nicht"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.requireAuth(next)(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) requireAuth(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
|
func (s *Server) requireAuth(next func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
sub, ok := s.auth.SubFromRequest(r)
|
sub, ok := s.auth.SubFromRequest(r)
|
||||||
|
|||||||
@@ -13,6 +13,10 @@
|
|||||||
* data-Attribute am <script>:
|
* data-Attribute am <script>:
|
||||||
* data-app (Pflicht) id der App, in der die Leiste steckt
|
* data-app (Pflicht) id der App, in der die Leiste steckt
|
||||||
* data-apps URL der App-Liste; ohne das /api/apps neben dem Skript
|
* data-apps URL der App-Liste; ohne das /api/apps neben dem Skript
|
||||||
|
* data-key Schlüssel für die App-Liste. Nötig, wenn HoneyOS auf einer
|
||||||
|
* anderen Site liegt als die App: dann schickt der Browser die
|
||||||
|
* HoneyOS-Sitzung nicht mit, und ohne zweiten Ausweis bliebe die
|
||||||
|
* Leiste ohne Liste.
|
||||||
* data-home URL des HoneyOS-Desktops
|
* data-home URL des HoneyOS-Desktops
|
||||||
* data-push "body" (Standard) schiebt den Seiteninhalt nach unten,
|
* data-push "body" (Standard) schiebt den Seiteninhalt nach unten,
|
||||||
* "none" legt die Leiste darüber
|
* "none" legt die Leiste darüber
|
||||||
@@ -46,6 +50,7 @@
|
|||||||
// geladen wurde. Er gibt die Liste nur an angemeldete Browser heraus und
|
// geladen wurde. Er gibt die Liste nur an angemeldete Browser heraus und
|
||||||
// nur an Ursprünge, die selbst als App eingetragen sind.
|
// nur an Ursprünge, die selbst als App eingetragen sind.
|
||||||
const appsUrl = d("apps", script ? new URL("../api/apps", script.src).href : "");
|
const appsUrl = d("apps", script ? new URL("../api/apps", script.src).href : "");
|
||||||
|
const appsKey = d("key", "");
|
||||||
|
|
||||||
let apps = DEFAULT_APPS;
|
let apps = DEFAULT_APPS;
|
||||||
|
|
||||||
@@ -270,7 +275,11 @@
|
|||||||
try {
|
try {
|
||||||
// credentials: die Sitzung liegt auf der HoneyOS-Domain, und die ist
|
// credentials: die Sitzung liegt auf der HoneyOS-Domain, und die ist
|
||||||
// dieselbe Site wie diese hier — sonst käme das Cookie nicht mit.
|
// dieselbe Site wie diese hier — sonst käme das Cookie nicht mit.
|
||||||
const res = await fetch(appsUrl, { cache: "no-store", credentials: "include" });
|
const res = await fetch(appsUrl, {
|
||||||
|
cache: "no-store",
|
||||||
|
credentials: "include",
|
||||||
|
headers: appsKey ? { "X-HoneyOS-Key": appsKey } : {},
|
||||||
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const list = await res.json();
|
const list = await res.json();
|
||||||
if (Array.isArray(list) && list.length) {
|
if (Array.isArray(list) && list.length) {
|
||||||
|
|||||||
@@ -185,8 +185,13 @@ function glyphSvg(name) {
|
|||||||
* ihr Wert steht in einem absichtlich lesbaren Cookie, das nur bekommt, wer
|
* ihr Wert steht in einem absichtlich lesbaren Cookie, das nur bekommt, wer
|
||||||
* die Seite wirklich geöffnet hat.
|
* die Seite wirklich geöffnet hat.
|
||||||
*/
|
*/
|
||||||
|
/** Der Schlüssel für die App-Liste steht als Meta-Angabe in der Seite — der
|
||||||
|
* Server legt ihn dort hinein, wenn einer eingerichtet ist. */
|
||||||
|
const APPS_KEY = document.querySelector('meta[name="honeyos-key"]')?.content || "";
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
async function api(path, opts = {}) {
|
||||||
const headers = { ...(opts.headers || {}) };
|
const headers = { ...(opts.headers || {}) };
|
||||||
|
if (APPS_KEY) headers["X-HoneyOS-Key"] = APPS_KEY;
|
||||||
if (opts.method && opts.method !== "GET") {
|
if (opts.method && opts.method !== "GET") {
|
||||||
headers["Content-Type"] = "application/json";
|
headers["Content-Type"] = "application/json";
|
||||||
const tok = cookie("honeyos_csrf");
|
const tok = cookie("honeyos_csrf");
|
||||||
|
|||||||
Reference in new issue
Block a user