Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions backend/internal/application/auth/errs.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ var (
ErrIdentityNotFound = errors.New("identity not found")
// ErrIdentityProviderDeleteConflict 表示删除身份源会让用户失去最后一种登录方式。
ErrIdentityProviderDeleteConflict = errors.New("identity provider delete conflict")
// ErrIdentityProviderLogoUnavailable 表示身份源图标不可用或不符合代理安全策略。
ErrIdentityProviderLogoUnavailable = errors.New("identity provider logo unavailable")
// ErrIdentityProviderSuperAdminDefaultRoleNotAllowed 表示非 superadmin 不允许设置 superadmin 默认角色。
ErrIdentityProviderSuperAdminDefaultRoleNotAllowed = errors.New("only superadmin can set superadmin default role")
// ErrTwoFactorSetupExpired 两步验证设置已过期。
Expand Down
127 changes: 0 additions & 127 deletions backend/internal/application/auth/provider.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package auth

import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
Expand All @@ -11,10 +10,8 @@ import (
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"path"
"regexp"
"strings"
"time"
Expand Down Expand Up @@ -80,11 +77,6 @@ type UserIdentityView struct {
LastLoginAt *time.Time
}

type IdentityProviderLogoAsset struct {
ContentType string
Content []byte
}

type UpsertIdentityProviderInput struct {
ActorRole string
Type string
Expand Down Expand Up @@ -745,7 +737,6 @@ const (
providerIntentRegister = "register"
providerIntentBind = "bind"
providerHTTPTimeout = 10 * time.Second
providerLogoMaxBytes = 2 << 20
)

func normalizeProviderSlug(value string) string {
Expand All @@ -767,124 +758,6 @@ func normalizeProviderIntent(value string) string {
}
}

func (s *Service) GetIdentityProviderLogo(ctx context.Context, slug string) (*IdentityProviderLogoAsset, error) {
provider, err := s.repo.GetIdentityProviderBySlug(ctx, normalizeProviderSlug(slug))
if err != nil {
return nil, ErrIdentityProviderLogoUnavailable
}
logoURL := strings.TrimSpace(provider.LogoURL)
parsed, err := url.Parse(logoURL)
if err != nil || parsed == nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, ErrIdentityProviderLogoUnavailable
}

request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")

response, err := s.providerHTTPClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
return nil, ErrIdentityProviderLogoUnavailable
}

content, err := io.ReadAll(io.LimitReader(response.Body, providerLogoMaxBytes+1))
if err != nil {
return nil, err
}
if len(content) == 0 || len(content) > providerLogoMaxBytes {
return nil, ErrIdentityProviderLogoUnavailable
}
contentType := resolveIdentityProviderLogoContentType(response.Header.Get("Content-Type"), parsed.Path, content)
if contentType == "" {
return nil, ErrIdentityProviderLogoUnavailable
}
return &IdentityProviderLogoAsset{
ContentType: contentType,
Content: content,
}, nil
}

func resolveIdentityProviderLogoContentType(headerValue string, requestPath string, content []byte) string {
contentType := normalizeIdentityProviderLogoContentType(headerValue)
if isAllowedIdentityProviderLogoContentType(contentType) {
return contentType
}
if contentType == "" || contentType == "application/octet-stream" {
contentType = normalizeIdentityProviderLogoContentType(http.DetectContentType(content))
if isAllowedIdentityProviderLogoContentType(contentType) {
return contentType
}
contentType = providerLogoContentTypeByExtension(requestPath)
if contentType == "image/svg+xml" && !looksLikeSVG(content) {
return ""
}
if isAllowedIdentityProviderLogoContentType(contentType) {
return contentType
}
}
return ""
}

func normalizeIdentityProviderLogoContentType(value string) string {
contentType, _, err := mime.ParseMediaType(strings.TrimSpace(value))
if err != nil {
contentType = strings.TrimSpace(value)
}
return strings.ToLower(contentType)
}

func isAllowedIdentityProviderLogoContentType(contentType string) bool {
switch contentType {
case "image/avif",
"image/gif",
"image/jpeg",
"image/png",
"image/svg+xml",
"image/vnd.microsoft.icon",
"image/webp",
"image/x-icon":
return true
default:
return false
}
}

func providerLogoContentTypeByExtension(requestPath string) string {
switch strings.ToLower(path.Ext(requestPath)) {
case ".avif":
return "image/avif"
case ".gif":
return "image/gif"
case ".ico":
return "image/x-icon"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".png":
return "image/png"
case ".svg":
return "image/svg+xml"
case ".webp":
return "image/webp"
default:
return ""
}
}

func looksLikeSVG(content []byte) bool {
trimmed := bytes.TrimSpace(content)
if len(trimmed) == 0 {
return false
}
lowered := bytes.ToLower(trimmed)
return bytes.HasPrefix(lowered, []byte("<svg")) || (bytes.HasPrefix(lowered, []byte("<?xml")) && bytes.Contains(lowered, []byte("<svg")))
}

func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
Expand Down
54 changes: 0 additions & 54 deletions backend/internal/application/auth/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -654,60 +654,6 @@ func TestUnlinkCurrentUserIdentityRejectsLastPasswordlessLoginMethod(t *testing.
}
}

func TestGetIdentityProviderLogoFetchesConfiguredImage(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") == "" {
t.Fatal("expected image accept header")
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte{0x89, 0x50, 0x4e, 0x47})
}))
defer server.Close()

repo := &providerLoginRepo{
providersBySlug: map[string]*domainuser.IdentityProvider{
"acme": {
Slug: "acme",
LogoURL: server.URL + "/logo.png",
},
},
}
service := NewService(config.Config{JWTSecret: "test-secret"}, repo, nil)

asset, err := service.GetIdentityProviderLogo(context.Background(), "acme")
if err != nil {
t.Fatalf("expected logo asset, got %v", err)
}
if asset.ContentType != "image/png" {
t.Fatalf("expected image/png, got %q", asset.ContentType)
}
if string(asset.Content) != string([]byte{0x89, 0x50, 0x4e, 0x47}) {
t.Fatalf("unexpected logo content: %#v", asset.Content)
}
}

func TestGetIdentityProviderLogoRejectsHTML(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte("<script>alert(1)</script>"))
}))
defer server.Close()

repo := &providerLoginRepo{
providersBySlug: map[string]*domainuser.IdentityProvider{
"acme": {
Slug: "acme",
LogoURL: server.URL + "/logo.html",
},
},
}
service := NewService(config.Config{JWTSecret: "test-secret"}, repo, nil)

if _, err := service.GetIdentityProviderLogo(context.Background(), "acme"); !errors.Is(err, ErrIdentityProviderLogoUnavailable) {
t.Fatalf("expected unavailable error, got %v", err)
}
}

func TestUnlinkCurrentUserIdentityAllowsLastIdentityWhenPasswordEnabled(t *testing.T) {
repo := &providerLoginRepo{
credentialsByUserID: map[uint]*domainuser.Credential{
Expand Down
16 changes: 0 additions & 16 deletions backend/internal/transport/http/auth/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,22 +113,6 @@ func (h *Handler) LoginOptions(c *gin.Context) {
response.Success(c, toLoginOptionsResponse(result))
}

func (h *Handler) IdentityProviderLogo(c *gin.Context) {
asset, err := h.service.GetIdentityProviderLogo(c.Request.Context(), c.Param("slug"))
if err != nil {
status := http.StatusBadGateway
if errors.Is(err, appauth.ErrIdentityProviderLogoUnavailable) {
status = http.StatusNotFound
}
response.ErrorFrom(c, status, err)
return
}
c.Header("Cache-Control", "public, max-age=3600")
c.Header("Content-Security-Policy", "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'; script-src 'none'; object-src 'none'; frame-ancestors 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'")
c.Header("X-Content-Type-Options", "nosniff")
c.Data(http.StatusOK, asset.ContentType, asset.Content)
}

// StartEmailRegistration godoc
// @Summary 发送邮箱注册验证码
// @Description 邮箱验证码注册开启时发送验证码;启用 Turnstile 后需要提交 turnstileToken
Expand Down
1 change: 0 additions & 1 deletion backend/internal/transport/http/auth/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ func (m *Module) RegisterPublicRoutes(api *gin.RouterGroup) {
api.GET("/auth/providers/:slug/start", m.Handler.StartProviderLogin)
api.GET("/auth/providers/:slug/callback", m.Handler.ProviderCallback)
api.POST("/auth/providers/:slug/callback", m.Handler.CompleteProviderLogin)
api.GET("/auth/providers/:slug/logo", m.Handler.IdentityProviderLogo)
}

// RegisterProtectedRoutes 注册需登录的鉴权路由。
Expand Down
35 changes: 35 additions & 0 deletions backend/internal/transport/http/middleware/https_redirect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package middleware

import (
"net/http"
"strings"

"github.com/gin-gonic/gin"
)

// HTTPSRedirect 将生产环境的外部 HTTP 请求永久重定向到同主机 HTTPS。
func HTTPSRedirect(env string) gin.HandlerFunc {
production := strings.EqualFold(strings.TrimSpace(env), "prod") ||
strings.EqualFold(strings.TrimSpace(env), "production")

return func(c *gin.Context) {
if !production || c.Request.URL.Path == "/healthz" || c.Request.URL.Path == "/readyz" || requestUsesHTTPS(c) {
c.Next()
return
}

target := *c.Request.URL
target.Scheme = "https"
target.Host = c.Request.Host
c.Redirect(http.StatusPermanentRedirect, target.String())
c.Abort()
}
}

func requestUsesHTTPS(c *gin.Context) bool {
if c.Request.TLS != nil {
return true
}
proto, _, _ := strings.Cut(c.GetHeader("X-Forwarded-Proto"), ",")
return strings.EqualFold(strings.TrimSpace(proto), "https")
}
73 changes: 73 additions & 0 deletions backend/internal/transport/http/middleware/https_redirect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package middleware

import (
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
)

func TestHTTPSRedirectRedirectsProductionHTTP(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(HTTPSRedirect("production"))
router.GET("/chat", func(c *gin.Context) {
c.Status(http.StatusNoContent)
})

recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "http://chat.example.com/chat?id=demo", nil))

if recorder.Code != http.StatusPermanentRedirect {
t.Fatalf("expected status 308, got %d", recorder.Code)
}
if got := recorder.Header().Get("Location"); got != "https://chat.example.com/chat?id=demo" {
t.Fatalf("unexpected redirect location: %q", got)
}
}

func TestHTTPSRedirectAllowsHTTPSAndHealthChecks(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(HTTPSRedirect("prod"))
router.GET("/chat", func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
router.GET("/healthz", func(c *gin.Context) {
c.Status(http.StatusOK)
})

httpsRequest := httptest.NewRequest(http.MethodGet, "https://chat.example.com/chat", nil)
httpsRequest.TLS = &tls.ConnectionState{}
httpsRecorder := httptest.NewRecorder()
router.ServeHTTP(httpsRecorder, httpsRequest)
if httpsRecorder.Code != http.StatusNoContent {
t.Fatalf("expected HTTPS request to pass, got %d", httpsRecorder.Code)
}

healthRecorder := httptest.NewRecorder()
router.ServeHTTP(healthRecorder, httptest.NewRequest(http.MethodGet, "http://chat.example.com/healthz", nil))
if healthRecorder.Code != http.StatusOK {
t.Fatalf("expected HTTP health check to pass, got %d", healthRecorder.Code)
}
}

func TestHTTPSRedirectAllowsForwardedHTTPS(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(HTTPSRedirect("prod"))
router.GET("/chat", func(c *gin.Context) {
c.Status(http.StatusNoContent)
})

request := httptest.NewRequest(http.MethodGet, "http://chat.example.com/chat", nil)
request.Header.Set("X-Forwarded-Proto", "https")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)

if recorder.Code != http.StatusNoContent {
t.Fatalf("expected forwarded HTTPS request to pass, got %d", recorder.Code)
}
}
Loading
Loading