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
@@ -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