feat(aether): initial commit – self-hosted Cloud, Phasen 1–7

Go-Backend (chi, pgx, golang-migrate) + React/TS-Frontend als PWA.
Enthalten: Auth inkl. OIDC, Dateien mit Versionen und Papierkorb,
Freigaben (Links + intern), WebDAV, Gruppenordner, Admin/Theming,
Volltextsuche mit OCR, eigener PDF-Betrachter und WOPI-Office
(Collabora/OnlyOffice).

Nicht im Repo: deploy/.env (Sitzungsschlüssel, DB- und Admin-Passwort)
sowie alle Daten- und Build-Verzeichnisse – siehe .gitignore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
HoneyandClaude Opus 5 committed 2026-08-24 09:25:36 +02:00
commit 31a027c93a
225 files changed
+38042

No files matched your search

+196
View File
@@ -0,0 +1,196 @@
package oidc
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
gooidc "github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"aether/internal/auth"
"aether/pkg/middleware"
"aether/pkg/response"
)
const stateCookie = "oidc_state"
// Config holds OIDC provider settings (all required if OIDC is enabled).
type Config struct {
Issuer string
ClientID string
ClientSecret string
RedirectURL string
// LinkByUsername: bestehendes Konto gleichen Namens übernehmen statt ein
// zweites anzulegen. Siehe auth.Store.GetOrCreateByOIDC.
LinkByUsername bool
}
// Handler implements /auth/oidc/login and /auth/oidc/callback.
type Handler struct {
cfg Config
provider *gooidc.Provider
oauth2 *oauth2.Config
verifier *gooidc.IDTokenVerifier
authStore *auth.Store
sessions *auth.SessionStore
}
// New creates an OIDC handler. Returns nil if cfg.Issuer is empty (OIDC disabled).
func New(ctx context.Context, cfg Config, authStore *auth.Store, sessions *auth.SessionStore) (*Handler, error) {
if cfg.Issuer == "" {
return nil, nil
}
provider, err := gooidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
return nil, fmt.Errorf("oidc provider: %w", err)
}
oauthCfg := &oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{gooidc.ScopeOpenID, "profile"},
}
return &Handler{
cfg: cfg,
provider: provider,
oauth2: oauthCfg,
verifier: provider.Verifier(&gooidc.Config{ClientID: cfg.ClientID}),
authStore: authStore,
sessions: sessions,
}, nil
}
// GET /api/v1/auth/oidc/login – redirect to OIDC provider.
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
state, err := randomState()
if err != nil {
response.Error(w, http.StatusInternalServerError, "state generation failed")
return
}
// Secure wie bei Sitzungs- und CSRF-Cookie: Hinter dem Reverse Proxy sieht
// die App selbst kein TLS, deshalb über IsHTTPS (das X-Forwarded-Proto
// auswertet – aber nur von vertrauenswürdigen Proxys). Ohne das ginge der
// Zustandswert auch über eine unverschlüsselte Verbindung mit.
http.SetCookie(w, &http.Cookie{
Name: stateCookie, Value: state, Path: "/",
MaxAge: 300, HttpOnly: true, SameSite: http.SameSiteLaxMode,
Secure: middleware.IsHTTPS(r),
})
http.Redirect(w, r, h.oauth2.AuthCodeURL(state), http.StatusFound)
}
// GET /api/v1/auth/oidc/callback – exchange code, create session, redirect to SPA.
func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
// Validate state.
stateCk, err := r.Cookie(stateCookie)
if err != nil || r.URL.Query().Get("state") != stateCk.Value {
http.Redirect(w, r, "/?error=oidc_state", http.StatusFound)
return
}
// Clear state cookie.
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: "/", MaxAge: -1})
code := r.URL.Query().Get("code")
if code == "" {
http.Redirect(w, r, "/?error=oidc_no_code", http.StatusFound)
return
}
// Exchange code for tokens.
token, err := h.oauth2.Exchange(r.Context(), code)
if err != nil {
http.Redirect(w, r, "/?error=oidc_exchange", http.StatusFound)
return
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
http.Redirect(w, r, "/?error=oidc_no_id_token", http.StatusFound)
return
}
idToken, err := h.verifier.Verify(r.Context(), rawIDToken)
if err != nil {
http.Redirect(w, r, "/?error=oidc_verify", http.StatusFound)
return
}
// Extract claims.
var claims struct {
Sub string `json:"sub"`
PreferredUsername string `json:"preferred_username"`
Name string `json:"name"`
}
if err := idToken.Claims(&claims); err != nil {
http.Redirect(w, r, "/?error=oidc_claims", http.StatusFound)
return
}
// Derive username: prefer preferred_username, fall back to sub.
username := sanitizeUsername(claims.PreferredUsername)
if username == "" {
username = sanitizeUsername(claims.Sub)
}
if username == "" {
http.Redirect(w, r, "/?error=oidc_no_username", http.StatusFound)
return
}
// Find or create user.
user, err := h.authStore.GetOrCreateByOIDC(r.Context(), claims.Sub, username, claims.Name, h.cfg.LinkByUsername)
if err != nil {
http.Redirect(w, r, "/?error=oidc_user", http.StatusFound)
return
}
if err := h.sessions.SetUser(w, r, user.ID); err != nil {
http.Redirect(w, r, "/?error=oidc_session", http.StatusFound)
return
}
// Set CSRF cookie so the SPA can function immediately after redirect.
_, _ = auth.SetCSRFCookie(w, r)
http.Redirect(w, r, "/", http.StatusFound)
}
// PublicConfig returns whether OIDC is enabled (called without auth).
func (h *Handler) IsEnabled() bool { return h != nil }
// ConfigJSON writes the OIDC config state (for login page).
func ConfigJSON(enabled bool, cloudName string) []byte {
b, _ := json.Marshal(map[string]any{
"oidc_enabled": enabled,
"cloud_name": cloudName,
})
return b
}
// ── helpers ───────────────────────────────────────────────────────────────────
func randomState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
func sanitizeUsername(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
var out strings.Builder
for _, r := range s {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' || r == '.' {
out.WriteRune(r)
}
}
return out.String()
}