feat(oidc): Profilbild vom Identitätsanbieter übernehmen
Schickt der Anbieter ein `picture`, wird es bei der Anmeldung geholt,
mittig auf 256x256 zugeschnitten und oben rechts angezeigt. Fehlt es
oder scheitert das Holen, bleibt es beim Buchstabenkreis - die
Anmeldung selbst hängt nicht davon ab.
Die Bildadresse kommt aus einem Token, also von außen. Erlaubt ist
deshalb ausschließlich der Ursprung aus OIDC_ISSUER, Weiterleitungen
eingeschlossen; dazu 4 MB und 5 Sekunden als Deckel. Ohne das wäre es
eine Aufforderung an den Server, für einen Fremden beliebige Adressen
im internen Netz anzufragen.
Neu: Migration 000019 (avatar_key/-source/-updated_at), Paket
internal/avatar, Route GET /api/v1/users/{id}/avatar. Der GC durchsucht
"avatars/" mit und kennt die Schlüssel - sonst räumte er die Bilder
nach einem Tag weg.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3019e84681
commit
9f44215698
18 files changed
+814
-26
No files matched your search
@@ -0,0 +1,161 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"aether/internal/auth"
|
||||
"aether/internal/avatar"
|
||||
)
|
||||
|
||||
// avatarMaxAge: Auch wenn sich die Bildadresse nicht ändert, wird höchstens
|
||||
// einmal am Tag nachgesehen. Ein Anbieter, der das Bild unter gleicher Adresse
|
||||
// austauscht (PocketID tut das), soll nicht ewig mit dem alten dastehen –
|
||||
// aber auch nicht bei jeder Anmeldung eine Runde durchs Netz kosten.
|
||||
const avatarMaxAge = 24 * time.Hour
|
||||
|
||||
// syncAvatar holt das Profilbild vom Identitätsanbieter, wenn nötig.
|
||||
//
|
||||
// Fehler bleiben hier: Die Anmeldung ist an dieser Stelle längst gelungen, und
|
||||
// ein nicht erreichbares Bild darf sie nicht scheitern lassen. Es fehlt dann
|
||||
// eben – die Oberfläche zeigt den Buchstaben.
|
||||
func (h *Handler) syncAvatar(ctx context.Context, user *auth.User, pictureURL string, token *oauth2.Token) {
|
||||
if pictureURL == "" {
|
||||
return
|
||||
}
|
||||
key, source, updated, err := h.authStore.AvatarOf(ctx, user.ID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if key != "" && source == pictureURL && time.Since(updated) < avatarMaxAge {
|
||||
return
|
||||
}
|
||||
|
||||
data, err := h.fetchAvatar(ctx, pictureURL, token)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("user", user.Username).Msg("oidc: profilbild nicht geholt")
|
||||
return
|
||||
}
|
||||
png, err := avatar.Process(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("user", user.Username).Msg("oidc: profilbild nicht verwendbar")
|
||||
return
|
||||
}
|
||||
if err := avatar.Store(ctx, h.blobs, h.authStore, user.ID, png, pictureURL); err != nil {
|
||||
log.Warn().Err(err).Str("user", user.Username).Msg("oidc: profilbild nicht gespeichert")
|
||||
return
|
||||
}
|
||||
user.AvatarVer = time.Now().Unix()
|
||||
}
|
||||
|
||||
// fetchAvatar lädt die Bilddatei – aber nur vom Identitätsanbieter selbst.
|
||||
//
|
||||
// Die Adresse kommt aus einem Token, also von außen. Ohne Prüfung wäre das
|
||||
// eine Aufforderung an den Server, eine beliebige Adresse anzufragen (SSRF):
|
||||
// interne Dienste, Metadaten-Endpunkte der Cloud-Umgebung, alles. Deshalb
|
||||
// zählt genau ein Rechnername – der aus OIDC_ISSUER –, und Weiterleitungen
|
||||
// dürfen ihn nicht verlassen.
|
||||
func (h *Handler) fetchAvatar(ctx context.Context, raw string, token *oauth2.Token) ([]byte, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("picture-adresse unlesbar: %w", err)
|
||||
}
|
||||
if err := h.allowedHost(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return fmt.Errorf("zu viele weiterleitungen")
|
||||
}
|
||||
return h.allowedHost(req.URL)
|
||||
},
|
||||
}
|
||||
|
||||
body, status, err := getBody(ctx, client, u.String(), "")
|
||||
// Manche Anbieter geben das Bild nur mit Token heraus. Erst ohne fragen
|
||||
// hält den Zugriffsschlüssel aus Zugriffsprotokollen heraus, wo er nicht
|
||||
// hingehört; nur wenn es sein muss, geht er mit – und dann an genau den
|
||||
// Anbieter, der ihn ausgestellt hat.
|
||||
if (status == http.StatusUnauthorized || status == http.StatusForbidden) && token != nil && token.AccessToken != "" {
|
||||
body, status, err = getBody(ctx, client, u.String(), token.AccessToken)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("picture-adresse antwortet mit %d", status)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func getBody(ctx context.Context, client *http.Client, url, bearer string) ([]byte, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, resp.StatusCode, nil
|
||||
}
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, avatar.MaxBytes))
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return b, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// allowedHost lässt nur den Ursprung durch, der auch der Aussteller ist.
|
||||
//
|
||||
// Verglichen wird mit Port: Derselbe Rechner auf einem anderen Port ist ein
|
||||
// anderer Dienst, und genau dorthin zielte ein Angriff.
|
||||
func (h *Handler) allowedHost(u *url.URL) error {
|
||||
if u.Scheme != "https" && u.Scheme != "http" {
|
||||
return fmt.Errorf("picture-adresse hat kein http(s): %q", u.Scheme)
|
||||
}
|
||||
iss, err := url.Parse(h.cfg.Issuer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.EqualFold(originOf(u), originOf(iss)) {
|
||||
return fmt.Errorf("picture-adresse zeigt auf %q, erlaubt ist nur der Anbieter %q",
|
||||
originOf(u), originOf(iss))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// originOf ergänzt den weggelassenen Standardport, damit
|
||||
// "https://id.example.com" und "https://id.example.com:443" gleich zählen.
|
||||
func originOf(u *url.URL) string {
|
||||
host := u.Host
|
||||
if _, _, err := net.SplitHostPort(host); err != nil {
|
||||
if u.Scheme == "https" {
|
||||
host += ":443"
|
||||
} else {
|
||||
host += ":80"
|
||||
}
|
||||
}
|
||||
return strings.ToLower(u.Scheme + "://" + host)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package oidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"aether/internal/avatar"
|
||||
)
|
||||
|
||||
// Die Bildadresse stammt aus einem Token, also von außen. Erlaubt ist genau
|
||||
// der Ursprung des Ausstellers – alles andere wäre eine Aufforderung an den
|
||||
// Server, für einen Fremden beliebige Adressen anzufragen.
|
||||
func TestAllowedHost(t *testing.T) {
|
||||
h := &Handler{cfg: Config{Issuer: "https://id.example.com"}}
|
||||
|
||||
erlaubt := []string{
|
||||
"https://id.example.com/api/users/1/profile-picture.png",
|
||||
"https://ID.EXAMPLE.COM/bild.png", // Groß-/Kleinschreibung zählt nicht
|
||||
"https://id.example.com:443/bild.png",
|
||||
}
|
||||
for _, raw := range erlaubt {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.allowedHost(u); err != nil {
|
||||
t.Errorf("%s abgelehnt: %v", raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
verboten := []string{
|
||||
"http://169.254.169.254/latest/meta-data/", // Metadaten der Umgebung
|
||||
"http://localhost:8080/api/v1/admin/users", // die eigene App
|
||||
"http://postgres:5432/", // Nachbarcontainer
|
||||
"https://id.example.com.angreifer.net/x.png", // Name nur vorne gleich
|
||||
"https://id.example.com:9200/x.png", // gleicher Rechner, anderer Dienst
|
||||
"file:///etc/passwd",
|
||||
"gopher://id.example.com/",
|
||||
}
|
||||
for _, raw := range verboten {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.allowedHost(u); err == nil {
|
||||
t.Errorf("%s wurde durchgelassen", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ein Anbieter, der selbst auf einem Port läuft, muss zu sich selbst passen.
|
||||
func TestAllowedHostMitPortImIssuer(t *testing.T) {
|
||||
h := &Handler{cfg: Config{Issuer: "http://id.example.com:9000"}}
|
||||
ok, _ := url.Parse("http://id.example.com:9000/bild.png")
|
||||
if err := h.allowedHost(ok); err != nil {
|
||||
t.Errorf("eigener Port abgelehnt: %v", err)
|
||||
}
|
||||
nein, _ := url.Parse("http://id.example.com/bild.png")
|
||||
if err := h.allowedHost(nein); err == nil {
|
||||
t.Error("Port 80 wurde für einen Anbieter auf 9000 durchgelassen")
|
||||
}
|
||||
}
|
||||
|
||||
// Der ganze Weg über das Netz – mit einem Anbieter, der erst 401 sagt und das
|
||||
// Bild nur gegen Token herausgibt. Genau so verhalten sich Anbieter, die
|
||||
// Profilbilder nicht öffentlich stellen.
|
||||
func TestFetchAvatarHoltMitTokenNach(t *testing.T) {
|
||||
var ohneToken, mitToken int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer geheim" {
|
||||
ohneToken++
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
mitToken++
|
||||
w.Write([]byte("PNG-Attrappe"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
h := &Handler{cfg: Config{Issuer: srv.URL}}
|
||||
body, err := h.fetchAvatar(context.Background(), srv.URL+"/bild.png",
|
||||
&oauth2.Token{AccessToken: "geheim"})
|
||||
if err != nil {
|
||||
t.Fatalf("fetchAvatar: %v", err)
|
||||
}
|
||||
if string(body) != "PNG-Attrappe" {
|
||||
t.Errorf("Inhalt %q", body)
|
||||
}
|
||||
if ohneToken != 1 || mitToken != 1 {
|
||||
t.Errorf("erwartet je ein Versuch, gemessen ohne=%d mit=%d", ohneToken, mitToken)
|
||||
}
|
||||
}
|
||||
|
||||
// Eine Weiterleitung darf den Anbieter nicht verlassen – sonst wäre die
|
||||
// Prüfung der ersten Adresse wertlos.
|
||||
func TestFetchAvatarFolgtKeinerFremdenWeiterleitung(t *testing.T) {
|
||||
fremd := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("der fremde Server wurde angefragt")
|
||||
}))
|
||||
defer fremd.Close()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fremd.URL+"/beute", http.StatusFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
h := &Handler{cfg: Config{Issuer: srv.URL}}
|
||||
if _, err := h.fetchAvatar(context.Background(), srv.URL+"/bild.png", nil); err == nil {
|
||||
t.Fatal("Weiterleitung auf einen fremden Rechner wurde befolgt")
|
||||
}
|
||||
}
|
||||
|
||||
// Was der Anbieter schickt, ist unbegrenzt groß denkbar. Mehr als MaxBytes
|
||||
// darf nie in den Speicher wandern.
|
||||
func TestFetchAvatarBegrenztDieGroesse(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
buf := make([]byte, 1<<20)
|
||||
for i := 0; i < 8; i++ {
|
||||
if _, err := w.Write(buf); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
h := &Handler{cfg: Config{Issuer: srv.URL}}
|
||||
body, err := h.fetchAvatar(context.Background(), srv.URL+"/riesig.png", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("fetchAvatar: %v", err)
|
||||
}
|
||||
if len(body) > avatar.MaxBytes {
|
||||
t.Errorf("%d Bytes gelesen, erlaubt sind %d", len(body), avatar.MaxBytes)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"aether/internal/auth"
|
||||
"aether/internal/storage"
|
||||
"aether/pkg/middleware"
|
||||
"aether/pkg/response"
|
||||
)
|
||||
@@ -40,10 +41,11 @@ type Handler struct {
|
||||
|
||||
authStore *auth.Store
|
||||
sessions *auth.SessionStore
|
||||
blobs storage.Storage
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func New(ctx context.Context, cfg Config, authStore *auth.Store, sessions *auth.SessionStore, blobs storage.Storage) (*Handler, error) {
|
||||
if cfg.Issuer == "" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -77,6 +79,7 @@ func New(ctx context.Context, cfg Config, authStore *auth.Store, sessions *auth.
|
||||
|
||||
authStore: authStore,
|
||||
sessions: sessions,
|
||||
blobs: blobs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -140,6 +143,7 @@ func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
Sub string `json:"sub"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
Name string `json:"name"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
http.Redirect(w, r, "/?error=oidc_claims", http.StatusFound)
|
||||
@@ -163,6 +167,14 @@ func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Profilbild übernehmen. Steht es nicht im Token, beim UserInfo-Endpunkt
|
||||
// nachfragen – manche Anbieter führen `picture` nur dort.
|
||||
picture := claims.Picture
|
||||
if picture == "" {
|
||||
picture = h.pictureFromUserInfo(r.Context(), token)
|
||||
}
|
||||
h.syncAvatar(r.Context(), user, picture, token)
|
||||
|
||||
if err := h.sessions.SetUser(w, r, user.ID); err != nil {
|
||||
http.Redirect(w, r, "/?error=oidc_session", http.StatusFound)
|
||||
return
|
||||
@@ -174,6 +186,22 @@ func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
// pictureFromUserInfo fragt den UserInfo-Endpunkt nach der Bildadresse.
|
||||
// Fehlschläge sind stumm: ohne Bild geht es weiter.
|
||||
func (h *Handler) pictureFromUserInfo(ctx context.Context, token *oauth2.Token) string {
|
||||
info, err := h.provider.UserInfo(ctx, oauth2.StaticTokenSource(token))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var c struct {
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
if err := info.Claims(&c); err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Picture
|
||||
}
|
||||
|
||||
// PublicConfig returns whether OIDC is enabled (called without auth).
|
||||
func (h *Handler) IsEnabled() bool { return h != nil }
|
||||
|
||||
|
||||
Reference in new issue
Block a user