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:
commit
31a027c93a
225 files changed
+38042
No files matched your search
@@ -0,0 +1,118 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/gorilla/sessions"
|
||||
|
||||
"aether/pkg/middleware"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionName = "aether_session"
|
||||
sessionKeyUser = "user_id"
|
||||
csrfCookieName = "aether_csrf"
|
||||
csrfHeaderName = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
type SessionStore struct {
|
||||
store *sessions.CookieStore
|
||||
maxAge int
|
||||
}
|
||||
|
||||
func NewSessionStore(secret string, maxAge int) *SessionStore {
|
||||
store := sessions.NewCookieStore([]byte(secret))
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: maxAge,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
return &SessionStore{store: store, maxAge: maxAge}
|
||||
}
|
||||
|
||||
func (ss *SessionStore) SetUser(w http.ResponseWriter, r *http.Request, userID uuid.UUID) error {
|
||||
sess, err := ss.store.Get(r, sessionName)
|
||||
if err != nil {
|
||||
sess, _ = ss.store.New(r, sessionName)
|
||||
}
|
||||
sess.Values[sessionKeyUser] = userID.String()
|
||||
sess.Options.Secure = middleware.IsHTTPS(r)
|
||||
return ss.store.Save(r, w, sess)
|
||||
}
|
||||
|
||||
func (ss *SessionStore) GetUserID(r *http.Request) (uuid.UUID, bool) {
|
||||
sess, err := ss.store.Get(r, sessionName)
|
||||
if err != nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
raw, ok := sess.Values[sessionKeyUser].(string)
|
||||
if !ok {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func (ss *SessionStore) Clear(w http.ResponseWriter, r *http.Request) error {
|
||||
sess, err := ss.store.Get(r, sessionName)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sess.Options.MaxAge = -1
|
||||
return ss.store.Save(r, w, sess)
|
||||
}
|
||||
|
||||
// SetCSRFCookie sets a CSRF token as a readable (non-httpOnly) cookie.
|
||||
func SetCSRFCookie(w http.ResponseWriter, r *http.Request) (string, error) {
|
||||
token := make([]byte, 32)
|
||||
if _, err := rand.Read(token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString(token)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: csrfCookieName,
|
||||
Value: encoded,
|
||||
Path: "/",
|
||||
HttpOnly: false, // must be readable by JS
|
||||
Secure: middleware.IsHTTPS(r),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
return encoded, nil
|
||||
}
|
||||
|
||||
// ValidateCSRF prüft, dass das mitgeschickte Token zum CSRF-Cookie passt
|
||||
// (Double-Submit).
|
||||
//
|
||||
// Neben dem Header wird auch ein Formularfeld akzeptiert. Grund: Ein Download
|
||||
// muss aus einer **Navigation** kommen, damit der Browser ihn als Datei
|
||||
// behandelt statt ihn in den Speicher zu laden – und ein abgeschicktes
|
||||
// Formular kann keine Header setzen. Am Schutz ändert das nichts: Verglichen
|
||||
// wird weiterhin gegen das Cookie, an das eine fremde Seite nicht herankommt.
|
||||
func ValidateCSRF(r *http.Request) bool {
|
||||
cookie, err := r.Cookie(csrfCookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return false
|
||||
}
|
||||
if token := r.Header.Get(csrfHeaderName); token != "" {
|
||||
return token == cookie.Value
|
||||
}
|
||||
// Nur bei Formularen nachsehen: Bei anderen Inhaltstypen würde ParseForm
|
||||
// den Rumpf lesen, den der eigentliche Handler danach noch braucht.
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
return false
|
||||
}
|
||||
token := r.PostFormValue("csrf_token")
|
||||
return token != "" && token == cookie.Value
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in new issue
Block a user