保存显式授权范围与认证加固中间检查点
This commit is contained in:
parent
7f567ea23a
commit
49ede22861
@ -17,6 +17,12 @@ type scopedAuthorizationChecker struct {
|
||||
calls []string
|
||||
}
|
||||
|
||||
type scopedServiceCredentials struct{}
|
||||
|
||||
func (scopedServiceCredentials) Get(context.Context, string) (map[string]string, error) {
|
||||
return map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret", "managed_zones": "example.com,example.org", "allowed_domains": "example.com,other.example.org"}, nil
|
||||
}
|
||||
|
||||
func (c *scopedAuthorizationChecker) VerifyDomain(_ context.Context, _ string, name string, _ map[string]string) error {
|
||||
c.calls = append(c.calls, name)
|
||||
if name == c.denied {
|
||||
@ -37,7 +43,7 @@ func TestServiceRejectsUnauthorizedSANBeforeIssuerOrFileWrites(t *testing.T) {
|
||||
provider := &recordingProviderFactory{}
|
||||
repo := &serviceRepository{item: item}
|
||||
checker := &scopedAuthorizationChecker{denied: denied}
|
||||
service := NewService(cfg, repo, serviceCredentials{}, provider, serviceAccounts{})
|
||||
service := NewService(cfg, repo, scopedServiceCredentials{}, provider, serviceAccounts{})
|
||||
service.SetDNSAuthorizationChecker(checker)
|
||||
service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer {
|
||||
t.Fatal("任意 SAN 权限拒绝后不得创建 ACME 签发器")
|
||||
@ -62,7 +68,7 @@ func TestServiceRejectsUnauthorizedSANBeforeIssuerOrFileWrites(t *testing.T) {
|
||||
|
||||
func TestSetupRejectsUnauthorizedDomainBeforeIssuer(t *testing.T) {
|
||||
cfg := certificateTestConfig(t.TempDir())
|
||||
service := NewService(cfg, nil, serviceCredentials{}, serviceProviderFactory{}, serviceAccounts{})
|
||||
service := NewService(cfg, nil, scopedServiceCredentials{}, serviceProviderFactory{}, serviceAccounts{})
|
||||
service.SetDNSAuthorizationChecker(&scopedAuthorizationChecker{denied: "other.example.org"})
|
||||
service.newIssuer = func(*AccountUser, challenge.Provider, string) Issuer {
|
||||
t.Fatal("初始化证书中的未授权域名必须在 ACME 请求前拒绝")
|
||||
@ -86,7 +92,7 @@ func TestAliyunMissingAuthorizationCheckerBlocksIssueAndSetup(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
provider := &recordingProviderFactory{}
|
||||
service := NewService(cfg, &serviceRepository{item: item}, serviceCredentials{}, provider, serviceAccounts{})
|
||||
service := NewService(cfg, &serviceRepository{item: item}, scopedServiceCredentials{}, provider, serviceAccounts{})
|
||||
if _, err := service.Issue(t.Context(), item.ID); err == nil || !strings.Contains(err.Error(), "探测器未初始化") {
|
||||
t.Fatalf("缺失检查器必须阻止签发:%v", err)
|
||||
}
|
||||
@ -100,3 +106,17 @@ func TestAliyunMissingAuthorizationCheckerBlocksIssueAndSetup(t *testing.T) {
|
||||
t.Fatal("缺失检查器时不得创建 DNS 挑战 Provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunWholeOrderScopeValidationBeforeAnyCloudProbe(t *testing.T) {
|
||||
cfg := certificateTestConfig(t.TempDir())
|
||||
checker := &scopedAuthorizationChecker{}
|
||||
service := NewService(cfg, nil, scopedServiceCredentials{}, serviceProviderFactory{}, serviceAccounts{})
|
||||
service.SetDNSAuthorizationChecker(checker)
|
||||
_, err := service.IssueForSetup(t.Context(), SetupCertificateRequest{
|
||||
Domains: []string{"example.com", "outside.net"}, CertificateType: "san",
|
||||
ProviderID: "aliyun", CredentialRef: "secret://dns/test", Environment: "staging",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "outside.net") || len(checker.calls) != 0 {
|
||||
t.Fatalf("部分越界必须零云调用拒绝整单:%v %v", err, checker.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@ -272,6 +272,10 @@ func (s *Service) IssueForSetup(ctx context.Context, request SetupCertificateReq
|
||||
}
|
||||
|
||||
func (s *Service) verifyDNSAuthorization(ctx context.Context, providerID string, domains []string, credentials map[string]string) error {
|
||||
// 必须先校验全部 SAN;后续域名越界时也不能先写入前面的域名。
|
||||
if err := dnsprovider.ValidateAliyunDomainScope(providerID, domains, credentials); err != nil {
|
||||
return err
|
||||
}
|
||||
if s == nil || s.authorization == nil {
|
||||
if strings.EqualFold(strings.TrimSpace(providerID), dnsprovider.Aliyun) {
|
||||
return errors.New("阿里云权限探测器未初始化,已阻止证书申请")
|
||||
|
||||
@ -71,6 +71,11 @@ func newDNSPermissionCommand(verify dnsPermissionVerifier, readOnly bool) *cobra
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取阿里云 DNS 凭据失败:%w", err)
|
||||
}
|
||||
if !readOnly {
|
||||
if err := dnsprovider.ValidateAliyunDomainScope(dnsprovider.Aliyun, []string{requestedDomain}, credentials); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 45*time.Second)
|
||||
defer cancel()
|
||||
report, verifyErr := verify(ctx, requestedDomain, credentials)
|
||||
|
||||
@ -30,6 +30,19 @@ func TestDNSCheckRequiresExplicitWriteConfirmation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSCheckRejectsOutsideScopeBeforeVerifier(t *testing.T) {
|
||||
prepareDNSCheckConfig(t)
|
||||
command := newDNSCheckCommand(func(context.Context, string, map[string]string) (dnsprovider.AuthorizationReport, error) {
|
||||
t.Fatal("CLI 越界不得进入云端验证")
|
||||
return dnsprovider.AuthorizationReport{}, nil
|
||||
})
|
||||
command.SilenceUsage, command.SilenceErrors = true, true
|
||||
command.SetArgs([]string{"--domain", "outside.net", "--confirm-dns-write"})
|
||||
if err := command.Execute(); err == nil {
|
||||
t.Fatal("确认写入不等于允许扩大范围")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSInspectDoesNotRequireWriteOrPromoteReadableToVerified(t *testing.T) {
|
||||
prepareDNSCheckConfig(t)
|
||||
command := newDNSInspectCommand(func(context.Context, string, map[string]string) (dnsprovider.AuthorizationReport, error) {
|
||||
@ -138,7 +151,7 @@ func prepareDNSCheckConfig(t *testing.T) config.Config {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := store.Put(t.Context(), cfg.DNS.CredentialRef, map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret"}); err != nil {
|
||||
if err := store.Put(t.Context(), cfg.DNS.CredentialRef, map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret", "managed_zones": "example.com", "allowed_domains": "example.com"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
|
||||
@ -47,10 +47,11 @@ type AuthorizationReport struct {
|
||||
type AliyunAuthorizationChecker struct {
|
||||
now func() time.Time
|
||||
verifyDomainFunc func(context.Context, string, map[string]string) error
|
||||
recordAPI func(map[string]string) (aliyunRecordAPI, error)
|
||||
}
|
||||
|
||||
func NewAliyunAuthorizationChecker() *AliyunAuthorizationChecker {
|
||||
return &AliyunAuthorizationChecker{now: time.Now}
|
||||
return &AliyunAuthorizationChecker{now: time.Now, recordAPI: newAliyunRecordAPI}
|
||||
}
|
||||
|
||||
func (c *AliyunAuthorizationChecker) Supports(providerID string) bool {
|
||||
@ -83,6 +84,19 @@ func (c *AliyunAuthorizationChecker) Inspect(ctx context.Context, requestedDomai
|
||||
if !domain.IsValidName(requestedDomain) {
|
||||
return report, fmt.Errorf("待检查托管区格式无效:%s", requestedDomain)
|
||||
}
|
||||
scope, err := ParseAliyunScope(credentials)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
explicit := false
|
||||
for _, zone := range scope.ManagedZones {
|
||||
if zone == requestedDomain {
|
||||
explicit = true
|
||||
}
|
||||
}
|
||||
if !explicit {
|
||||
return report, errors.New("只允许读取显式配置的托管区,未调用阿里云 API")
|
||||
}
|
||||
report.Domains = []AuthorizationDomain{{Domain: requestedDomain, Status: "等待读取权限检查"}}
|
||||
dnsClient, err := newAliyunClient(credentials)
|
||||
if err != nil {
|
||||
@ -105,52 +119,63 @@ func (c *AliyunAuthorizationChecker) Inspect(ctx context.Context, requestedDomai
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// Verify 从目标域名到父级托管区逐级执行最小写入和精确清理。
|
||||
// Verify 只在用户显式允许的域名及托管区中执行一次写入和精确清理。
|
||||
func (c *AliyunAuthorizationChecker) Verify(ctx context.Context, requestedDomain string, credentials map[string]string) (AuthorizationReport, error) {
|
||||
requestedDomain = normalizeAuthorizationDomain(requestedDomain)
|
||||
report := AuthorizationReport{Provider: Aliyun, RequestedDomain: requestedDomain, CheckedAt: c.currentTime().UTC()}
|
||||
if !domain.IsValidName(requestedDomain) {
|
||||
return report, fmt.Errorf("待验证域名格式无效:%s", requestedDomain)
|
||||
}
|
||||
for _, candidate := range authorizationZoneCandidates(requestedDomain) {
|
||||
report.Domains = append(report.Domains, AuthorizationDomain{
|
||||
Domain: candidate, Status: "待验证",
|
||||
})
|
||||
}
|
||||
client, err := newAliyunClient(credentials)
|
||||
scope, err := ParseAliyunScope(credentials)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for index, candidate := range authorizationZoneCandidates(requestedDomain) {
|
||||
report.Domains[index].Status = "正在证明写入权限"
|
||||
recordID, probeErr := addAuthorizationRecord(ctx, client, candidate)
|
||||
if probeErr != nil {
|
||||
lastErr = probeErr
|
||||
report.Domains[index].Status = "不可操作"
|
||||
report.Domains[index].Error = probeErr.Error()
|
||||
continue
|
||||
}
|
||||
report.Domains[index].Writable = true
|
||||
report.MatchedDomain = candidate
|
||||
cleanupContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), aliyunAuthorizationTimeout)
|
||||
cleanupErr := deleteAuthorizationRecord(cleanupContext, client, recordID)
|
||||
cancel()
|
||||
if cleanupErr != nil {
|
||||
markAuthorizationFailure(&report, candidate, fmt.Errorf("清理授权探测 TXT 记录失败:%w", cleanupErr))
|
||||
return report, cleanupErr
|
||||
}
|
||||
report.Domains[index].Cleanup = true
|
||||
report.Domains[index].Status = "可操作"
|
||||
report.Verified = true
|
||||
return report, nil
|
||||
zone, err := scope.Resolve(requestedDomain)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("未找到可验证的阿里云 DNS 托管根域名")
|
||||
if err := ctx.Err(); err != nil {
|
||||
return report, err
|
||||
}
|
||||
report.Diagnosis = DiagnoseAliyunError(lastErr, requestedDomain)
|
||||
return report, fmt.Errorf("阿里云 RAM 账号无法写入域名 %s:%w(建议:%s)", requestedDomain, lastErr, report.Diagnosis)
|
||||
report.Domains = []AuthorizationDomain{{Domain: zone, Status: "待验证"}}
|
||||
if c == nil || c.recordAPI == nil {
|
||||
return report, errors.New("阿里云权限探测器未初始化")
|
||||
}
|
||||
api, err := c.recordAPI(credentials)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
suffix, err := randomAuthorizationValue(18)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
value, err := randomAuthorizationValue(32)
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
rr := "_scert-authorization-" + suffix
|
||||
base := strings.TrimPrefix(requestedDomain, "*.")
|
||||
if base != zone {
|
||||
rr += "." + strings.TrimSuffix(base, "."+zone)
|
||||
}
|
||||
recordID, err := api.Add(ctx, zone, rr, value)
|
||||
if err != nil {
|
||||
markAuthorizationFailure(&report, zone, err)
|
||||
report.Diagnosis = DiagnoseAliyunError(err, zone)
|
||||
return report, err
|
||||
}
|
||||
report.Domains[0].Writable = true
|
||||
report.MatchedDomain = zone
|
||||
cleanupContext, cancel := context.WithTimeout(context.WithoutCancel(ctx), aliyunAuthorizationTimeout)
|
||||
defer cancel()
|
||||
if err := api.Delete(cleanupContext, recordID); err != nil {
|
||||
markAuthorizationFailure(&report, zone, fmt.Errorf("清理授权探测 TXT 记录失败:%w", err))
|
||||
return report, err
|
||||
}
|
||||
report.Domains[0].Cleanup = true
|
||||
report.Domains[0].Status = "可操作"
|
||||
report.Verified = true
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// VerifyDomain 每次重新证明写入和清理能力,不使用跨请求缓存掩盖撤权。
|
||||
@ -171,6 +196,9 @@ func (c *AliyunAuthorizationChecker) VerifyDomain(ctx context.Context, providerI
|
||||
if _, err := ValidateCredentials(Aliyun, credentials); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ValidateAliyunDomainScope(Aliyun, []string{requestedDomain}, credentials); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.verifyDomainFunc != nil {
|
||||
return c.verifyDomainFunc(ctx, requestedDomain, credentials)
|
||||
}
|
||||
@ -248,31 +276,6 @@ func listAliyunDomains(ctx context.Context, dnsClient *client.Client) ([]string,
|
||||
return uniqueStrings(result), nil
|
||||
}
|
||||
|
||||
func addAuthorizationRecord(ctx context.Context, dnsClient *client.Client, zone string) (string, error) {
|
||||
suffix, err := randomAuthorizationValue(18)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成授权探测标识失败:%w", err)
|
||||
}
|
||||
value, err := randomAuthorizationValue(32)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成授权探测值失败:%w", err)
|
||||
}
|
||||
request := new(client.AddDomainRecordRequest).
|
||||
SetDomainName(zone).
|
||||
SetRR("_scert-authorization-" + suffix).
|
||||
SetType("TXT").
|
||||
SetValue(value).
|
||||
SetTTL(aliyunAuthorizationTTL)
|
||||
response, err := client.AddDomainRecordWithContext(ctx, dnsClient, request, &dara.RuntimeOptions{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if response == nil || response.Body == nil || response.Body.RecordId == nil || strings.TrimSpace(*response.Body.RecordId) == "" {
|
||||
return "", errors.New("阿里云未返回授权探测记录 ID")
|
||||
}
|
||||
return strings.TrimSpace(*response.Body.RecordId), nil
|
||||
}
|
||||
|
||||
func deleteAuthorizationRecord(ctx context.Context, dnsClient *client.Client, recordID string) error {
|
||||
if strings.TrimSpace(recordID) == "" {
|
||||
return errors.New("授权探测记录 ID 为空")
|
||||
@ -296,19 +299,6 @@ func matchManagedDomain(requested string, domains []AuthorizationDomain) string
|
||||
return best
|
||||
}
|
||||
|
||||
func authorizationZoneCandidates(requested string) []string {
|
||||
requested = strings.TrimPrefix(normalizeAuthorizationDomain(requested), "*.")
|
||||
labels := strings.Split(requested, ".")
|
||||
result := make([]string, 0, len(labels)-1)
|
||||
for index := 0; index < len(labels)-1; index++ {
|
||||
candidate := strings.Join(labels[index:], ".")
|
||||
if domain.IsValidName(candidate) {
|
||||
result = append(result, candidate)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func markAuthorizationFailure(report *AuthorizationReport, domainName string, err error) {
|
||||
for index := range report.Domains {
|
||||
if report.Domains[index].Domain == domainName {
|
||||
|
||||
@ -7,19 +7,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAuthorizationZoneCandidatesPreferSpecificZones(t *testing.T) {
|
||||
got := authorizationZoneCandidates("*.www.example.com.")
|
||||
want := []string{"www.example.com", "example.com"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("候选托管区数量错误:got=%v want=%v", got, want)
|
||||
}
|
||||
for index := range want {
|
||||
if got[index] != want[index] {
|
||||
t.Fatalf("候选托管区顺序错误:got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunAuthorizationCheckerRechecksEveryRequestAndDetectsRevocation(t *testing.T) {
|
||||
now := time.Date(2026, 9, 14, 12, 0, 0, 0, time.UTC)
|
||||
calls := 0
|
||||
@ -36,6 +23,8 @@ func TestAliyunAuthorizationCheckerRechecksEveryRequestAndDetectsRevocation(t *t
|
||||
credentials := map[string]string{
|
||||
"access_key_id": "test-id",
|
||||
"access_key_secret": "test-secret",
|
||||
"managed_zones": "example.com",
|
||||
"allowed_domains": "example.com,www.example.com",
|
||||
}
|
||||
|
||||
if err := checker.VerifyDomain(context.Background(), Aliyun, "www.example.com", credentials); err != nil {
|
||||
@ -67,6 +56,8 @@ func TestAliyunAuthorizationCheckerInvalidatesAfterPermissionFailure(t *testing.
|
||||
credentials := map[string]string{
|
||||
"access_key_id": "test-id",
|
||||
"access_key_secret": "test-secret",
|
||||
"managed_zones": "example.com",
|
||||
"allowed_domains": "example.com",
|
||||
}
|
||||
|
||||
if err := checker.VerifyDomain(context.Background(), Aliyun, "example.com", credentials); err != nil {
|
||||
@ -91,6 +82,8 @@ func TestAliyunAuthorizationCheckerDoesNotCacheFailedAuthorization(t *testing.T)
|
||||
credentials := map[string]string{
|
||||
"access_key_id": "test-id",
|
||||
"access_key_secret": "test-secret",
|
||||
"managed_zones": "example.com",
|
||||
"allowed_domains": "example.com",
|
||||
}
|
||||
|
||||
if err := checker.VerifyDomain(context.Background(), Aliyun, "example.com", credentials); err == nil {
|
||||
@ -112,6 +105,8 @@ func TestAliyunAuthorizationCheckerCacheIsSafeForConcurrentUse(t *testing.T) {
|
||||
credentials := map[string]string{
|
||||
"access_key_id": "test-id",
|
||||
"access_key_secret": "test-secret",
|
||||
"managed_zones": "example.com",
|
||||
"allowed_domains": "example.com",
|
||||
}
|
||||
done := make(chan struct{})
|
||||
for index := 0; index < 20; index++ {
|
||||
|
||||
132
backend/internal/dnsprovider/aliyun_provider.go
Normal file
132
backend/internal/dnsprovider/aliyun_provider.go
Normal file
@ -0,0 +1,132 @@
|
||||
package dnsprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alibabacloud-go/tea/dara"
|
||||
"github.com/go-acme/alidns-20150109/v5/client"
|
||||
"github.com/go-acme/lego/v5/challenge/dns01"
|
||||
)
|
||||
|
||||
type aliyunRecordAPI interface {
|
||||
Add(context.Context, string, string, string) (string, error)
|
||||
Delete(context.Context, string) error
|
||||
}
|
||||
|
||||
type aliyunRecords struct{ client *client.Client }
|
||||
|
||||
func (a aliyunRecords) Add(ctx context.Context, zone, rr, value string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, aliyunAuthorizationTimeout)
|
||||
defer cancel()
|
||||
request := new(client.AddDomainRecordRequest).SetDomainName(zone).SetRR(rr).
|
||||
SetType("TXT").SetValue(value).SetTTL(aliyunAuthorizationTTL)
|
||||
response, err := client.AddDomainRecordWithContext(ctx, a.client, request, &dara.RuntimeOptions{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if response == nil || response.Body == nil || response.Body.RecordId == nil || strings.TrimSpace(*response.Body.RecordId) == "" {
|
||||
return "", errors.New("阿里云未返回新增记录 ID,状态未确认,请人工核对本次记录")
|
||||
}
|
||||
return strings.TrimSpace(*response.Body.RecordId), nil
|
||||
}
|
||||
|
||||
func (a aliyunRecords) Delete(ctx context.Context, id string) error {
|
||||
return deleteAuthorizationRecord(ctx, a.client, id)
|
||||
}
|
||||
|
||||
func newAliyunRecordAPI(credentials map[string]string) (aliyunRecordAPI, error) {
|
||||
dnsClient, err := newAliyunClient(credentials)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return aliyunRecords{client: dnsClient}, nil
|
||||
}
|
||||
|
||||
// 不使用自动发现托管区的上游适配器,也不沿 CNAME 自动扩大写入范围。
|
||||
type scopedAliyunProvider struct {
|
||||
scope AliyunScope
|
||||
api aliyunRecordAPI
|
||||
mu sync.Mutex
|
||||
records map[[32]byte]string
|
||||
challengeInfo func(context.Context, string, string) (string, string, string)
|
||||
}
|
||||
|
||||
func newScopedAliyunProvider(credentials map[string]string) (*scopedAliyunProvider, error) {
|
||||
scope, err := ParseAliyunScope(credentials)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
api, err := newAliyunRecordAPI(credentials)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &scopedAliyunProvider{scope: scope, api: api, records: make(map[[32]byte]string), challengeInfo: aliyunChallengeInfo}, nil
|
||||
}
|
||||
|
||||
func aliyunChallengeInfo(ctx context.Context, domainName, keyAuth string) (string, string, string) {
|
||||
info := dns01.GetChallengeInfo(ctx, domainName, keyAuth)
|
||||
return info.FQDN, info.EffectiveFQDN, info.Value
|
||||
}
|
||||
|
||||
func (p *scopedAliyunProvider) Present(ctx context.Context, name, token, keyAuth string) error {
|
||||
name = normalizeAuthorizationDomain(name)
|
||||
zone, err := p.scope.Resolve(name)
|
||||
// ACME 的通配符授权以去掉通配符的标识符进入 Provider;业务层已逐项验证 SAN。
|
||||
if err != nil && !strings.HasPrefix(name, "*.") {
|
||||
zone, err = p.scope.Resolve("*." + name)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
base := strings.TrimPrefix(name, "*.")
|
||||
fqdn, effective, value := p.challengeInfo(ctx, base, keyAuth)
|
||||
expected := "_acme-challenge." + base
|
||||
if normalizeAuthorizationDomain(fqdn) != expected || normalizeAuthorizationDomain(effective) != expected {
|
||||
return errors.New("DNS 挑战存在 CNAME 重定向,已拒绝自动跟随;请显式配置受限的 CNAME 委派服务商")
|
||||
}
|
||||
rr := strings.TrimSuffix(expected, "."+zone)
|
||||
if rr == expected {
|
||||
return errors.New("DNS 挑战记录不属于显式托管区")
|
||||
}
|
||||
key := sha256.Sum256([]byte(name + "\x00" + token + "\x00" + keyAuth))
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if _, ok := p.records[key]; ok {
|
||||
return errors.New("同一 DNS 挑战已有待清理记录,拒绝重复新增")
|
||||
}
|
||||
id, err := p.api.Add(ctx, zone, rr, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.records[key] = id
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *scopedAliyunProvider) CleanUp(ctx context.Context, name, token, keyAuth string) error {
|
||||
key := sha256.Sum256([]byte(normalizeAuthorizationDomain(name) + "\x00" + token + "\x00" + keyAuth))
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
id, ok := p.records[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), aliyunAuthorizationTimeout)
|
||||
defer cancel()
|
||||
if err := p.api.Delete(cleanupCtx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(p.records, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *scopedAliyunProvider) Timeout() (time.Duration, time.Duration) {
|
||||
return 120 * time.Second, 2 * time.Second
|
||||
}
|
||||
116
backend/internal/dnsprovider/aliyun_scope.go
Normal file
116
backend/internal/dnsprovider/aliyun_scope.go
Normal file
@ -0,0 +1,116 @@
|
||||
package dnsprovider
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/domain"
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
// AliyunScope 是用户授权的上限,不是从 RAM 读取结果自动推导的权限。
|
||||
// 范围随凭据引用不可变地保存;修改范围必须重新在线验证并获得新的引用。
|
||||
type AliyunScope struct {
|
||||
ManagedZones []string `json:"managed_zones"`
|
||||
AllowedDomains []string `json:"allowed_domains"`
|
||||
}
|
||||
|
||||
func ParseAliyunScope(credentials map[string]string) (AliyunScope, error) {
|
||||
var scope AliyunScope
|
||||
var err error
|
||||
scope.ManagedZones, err = parseScopeNames(credentials["managed_zones"], false)
|
||||
if err != nil {
|
||||
return scope, fmt.Errorf("请显式配置阿里云托管区:%w", err)
|
||||
}
|
||||
scope.AllowedDomains, err = parseScopeNames(credentials["allowed_domains"], true)
|
||||
if err != nil {
|
||||
return scope, fmt.Errorf("请显式配置允许操作的域名:%w", err)
|
||||
}
|
||||
for _, name := range scope.AllowedDomains {
|
||||
if scope.zoneFor(strings.TrimPrefix(name, "*.")) == "" {
|
||||
return AliyunScope{}, fmt.Errorf("允许域名 %s 不属于任何显式托管区", name)
|
||||
}
|
||||
}
|
||||
return scope, nil
|
||||
}
|
||||
|
||||
func parseScopeNames(value string, wildcard bool) ([]string, error) {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil, errors.New("旧配置或空范围不允许 DNS 操作,请补充范围并重新验证")
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
if len(parts) > 128 {
|
||||
return nil, errors.New("每个范围最多允许 128 项")
|
||||
}
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
name := normalizeAuthorizationDomain(part)
|
||||
base := strings.TrimPrefix(name, "*.")
|
||||
if !domain.IsValidName(name) || (!wildcard && name != base) {
|
||||
return nil, errors.New("范围应为逗号分隔的有效域名,托管区不能含通配符")
|
||||
}
|
||||
if _, err := publicsuffix.EffectiveTLDPlusOne(base); err != nil {
|
||||
return nil, fmt.Errorf("公共后缀不能作为操作范围:%s", name)
|
||||
}
|
||||
result = append(result, name)
|
||||
}
|
||||
return uniqueStrings(result), nil
|
||||
}
|
||||
|
||||
func (s AliyunScope) zoneFor(name string) string {
|
||||
best := ""
|
||||
for _, zone := range s.ManagedZones {
|
||||
if (name == zone || strings.HasSuffix(name, "."+zone)) && len(zone) > len(best) {
|
||||
best = zone
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// Resolve 只选择最具体的显式托管区;失败后绝不回退到父区。
|
||||
// 通配授权只包含一层子域和对应通配证书,不隐含根域或多层子域。
|
||||
func (s AliyunScope) Resolve(requested string) (string, error) {
|
||||
requested = normalizeAuthorizationDomain(requested)
|
||||
if !domain.IsValidName(requested) {
|
||||
return "", errors.New("待操作域名格式无效")
|
||||
}
|
||||
allowed := false
|
||||
for _, name := range s.AllowedDomains {
|
||||
if requested == name {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
if strings.HasPrefix(name, "*.") && !strings.HasPrefix(requested, "*.") {
|
||||
suffix := name[1:]
|
||||
if strings.HasSuffix(requested, suffix) {
|
||||
label := strings.TrimSuffix(requested, suffix)
|
||||
allowed = label != "" && !strings.Contains(label, ".")
|
||||
if allowed {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
zone := s.zoneFor(strings.TrimPrefix(requested, "*."))
|
||||
if !allowed || zone == "" {
|
||||
return "", fmt.Errorf("域名 %s 不在用户显式允许范围内,未调用阿里云 API", requested)
|
||||
}
|
||||
return zone, nil
|
||||
}
|
||||
|
||||
func ValidateAliyunDomainScope(providerID string, domains []string, credentials map[string]string) error {
|
||||
if !strings.EqualFold(strings.TrimSpace(providerID), Aliyun) {
|
||||
return nil
|
||||
}
|
||||
scope, err := ParseAliyunScope(credentials)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range domains {
|
||||
if _, err := scope.Resolve(name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
140
backend/internal/dnsprovider/aliyun_scope_test.go
Normal file
140
backend/internal/dnsprovider/aliyun_scope_test.go
Normal file
@ -0,0 +1,140 @@
|
||||
package dnsprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func scopeCredentials() map[string]string {
|
||||
return map[string]string{"access_key_id": "test-id", "access_key_secret": "test-secret", "managed_zones": "example.com,sub.example.com", "allowed_domains": "www.sub.example.com,*.example.com"}
|
||||
}
|
||||
|
||||
func TestAliyunScopeBoundaries(t *testing.T) {
|
||||
scope, err := ParseAliyunScope(scopeCredentials())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct{ name, zone string }{
|
||||
{"www.sub.example.com", "sub.example.com"},
|
||||
{"WWW.EXAMPLE.COM.", "example.com"},
|
||||
{"*.example.com", "example.com"},
|
||||
{"example.com", ""},
|
||||
{"evil-example.com", ""},
|
||||
{"example.com.evil.net", ""},
|
||||
{"deep.www.example.com", ""},
|
||||
{"*.sub.example.com", ""},
|
||||
{"pcux.cn", ""},
|
||||
{"com", ""},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
zone, err := scope.Resolve(tc.name)
|
||||
if zone != tc.zone || (err != nil) != (tc.zone == "") {
|
||||
t.Fatalf("范围校验错误:zone=%s err=%v", zone, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, value := range []string{"", "com", "co.uk", "*.example.com", "example.com,", "example.com,co.uk"} {
|
||||
credentials := scopeCredentials()
|
||||
credentials["managed_zones"] = value
|
||||
if _, err := ParseAliyunScope(credentials); err == nil {
|
||||
t.Fatalf("不安全托管区未拒绝:%s", value)
|
||||
}
|
||||
}
|
||||
credentials := scopeCredentials()
|
||||
credentials["allowed_domains"] = "outside.net"
|
||||
if _, err := ParseAliyunScope(credentials); err == nil {
|
||||
t.Fatal("越界的允许范围必须整体拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeAliyunRecords struct {
|
||||
zones, rrs, deleted []string
|
||||
addErr, deleteErr error
|
||||
canceledCleanup bool
|
||||
}
|
||||
|
||||
func (f *fakeAliyunRecords) Add(_ context.Context, zone, rr, _ string) (string, error) {
|
||||
f.zones = append(f.zones, zone)
|
||||
f.rrs = append(f.rrs, rr)
|
||||
return "created-record-id", f.addErr
|
||||
}
|
||||
|
||||
func (f *fakeAliyunRecords) Delete(ctx context.Context, id string) error {
|
||||
f.deleted = append(f.deleted, id)
|
||||
f.canceledCleanup = ctx.Err() != nil
|
||||
return f.deleteErr
|
||||
}
|
||||
|
||||
func TestAliyunProbeNeverFallsBackOrCallsCloudOutsideScope(t *testing.T) {
|
||||
api := &fakeAliyunRecords{addErr: errors.New("Forbidden.RAM")}
|
||||
checker := NewAliyunAuthorizationChecker()
|
||||
constructed := 0
|
||||
checker.recordAPI = func(map[string]string) (aliyunRecordAPI, error) { constructed++; return api, nil }
|
||||
credentials := scopeCredentials()
|
||||
for _, name := range []string{"example.com", "outside.net", "pcux.cn", "*.sub.example.com"} {
|
||||
if _, err := checker.Verify(t.Context(), name, credentials); err == nil {
|
||||
t.Fatal("越界请求未拒绝")
|
||||
}
|
||||
}
|
||||
if constructed != 0 {
|
||||
t.Fatal("越界请求不能创建云客户端")
|
||||
}
|
||||
if _, err := checker.Verify(t.Context(), "www.sub.example.com", credentials); err == nil {
|
||||
t.Fatal("RAM 拒绝必须返回失败")
|
||||
}
|
||||
if len(api.zones) != 1 || api.zones[0] != "sub.example.com" || !strings.HasSuffix(api.rrs[0], ".www") {
|
||||
t.Fatalf("必须仅写授权子域:%v %v", api.zones, api.rrs)
|
||||
}
|
||||
if len(api.deleted) != 0 {
|
||||
t.Fatal("新增失败不能猜测记录 ID 删除")
|
||||
}
|
||||
api.addErr = nil
|
||||
api.deleteErr = errors.New("Forbidden.RAM")
|
||||
report, err := checker.Verify(t.Context(), "www.sub.example.com", credentials)
|
||||
if err == nil || report.Verified || !report.Domains[0].Writable || report.Domains[0].Cleanup {
|
||||
t.Fatal("清理失败不能证明权限通过")
|
||||
}
|
||||
api.deleteErr = nil
|
||||
report, err = checker.Verify(t.Context(), "www.sub.example.com", credentials)
|
||||
if err != nil || !report.Verified || api.deleted[len(api.deleted)-1] != "created-record-id" {
|
||||
t.Fatal("必须删除本次新增记录")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAliyunProviderPinsZoneAndCleanupIdentity(t *testing.T) {
|
||||
scope, _ := ParseAliyunScope(scopeCredentials())
|
||||
api := &fakeAliyunRecords{}
|
||||
p := &scopedAliyunProvider{scope: scope, api: api, records: make(map[[32]byte]string), challengeInfo: func(_ context.Context, name, _ string) (string, string, string) {
|
||||
fqdn := "_acme-challenge." + name + "."
|
||||
return fqdn, fqdn, "txt-value"
|
||||
}}
|
||||
if err := p.Present(t.Context(), "outside.net", "token", "key"); err == nil || len(api.zones) != 0 {
|
||||
t.Fatal("实际 Provider 必须零调用拒绝越界")
|
||||
}
|
||||
if err := p.Present(t.Context(), "www.sub.example.com", "token", "key"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(api.zones) != 1 || api.zones[0] != "sub.example.com" || api.rrs[0] != "_acme-challenge.www" {
|
||||
t.Fatal("实际写入没有锁定指定托管区")
|
||||
}
|
||||
if err := p.Present(t.Context(), "www.sub.example.com", "token", "key"); err == nil {
|
||||
t.Fatal("重复挑战不能覆盖待清理 ID")
|
||||
}
|
||||
_ = p.CleanUp(t.Context(), "www.sub.example.com", "other", "key")
|
||||
if len(api.deleted) != 0 {
|
||||
t.Fatal("不能清理其他挑战")
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
cancel()
|
||||
if err := p.CleanUp(ctx, "www.sub.example.com", "token", "key"); err != nil || api.canceledCleanup || len(api.deleted) != 1 {
|
||||
t.Fatal("请求取消后仍需有界清理本次 ID")
|
||||
}
|
||||
p.challengeInfo = func(context.Context, string, string) (string, string, string) {
|
||||
return "_acme-challenge.www.sub.example.com.", "_acme-challenge.outside.net.", "value"
|
||||
}
|
||||
if err := p.Present(t.Context(), "www.sub.example.com", "new", "key"); err == nil || len(api.zones) != 1 {
|
||||
t.Fatal("CNAME 越界不能调用云 API")
|
||||
}
|
||||
}
|
||||
@ -48,11 +48,13 @@ var descriptors = map[string]Descriptor{
|
||||
},
|
||||
Aliyun: {
|
||||
ID: Aliyun, Name: "阿里云 DNS", Available: true,
|
||||
Description: "使用 RAM AccessKey 创建和清理 DNS-01 测试记录。",
|
||||
Description: "仅操作显式托管区内的允许域名。范围用英文逗号分隔;通配符只允许一层子域,不包含根域。旧配置需补充范围后重新验证。",
|
||||
CredentialFields: []CredentialField{
|
||||
{Name: "access_key_id", Label: "AccessKey ID", Required: true},
|
||||
{Name: "access_key_secret", Label: "AccessKey Secret", Secret: true, Required: true},
|
||||
{Name: "security_token", Label: "Security Token", Secret: true},
|
||||
{Name: "managed_zones", Label: "明确托管区", Required: true, Placeholder: "例如 example.com,example.net;不自动尝试父域"},
|
||||
{Name: "allowed_domains", Label: "允许操作的域名", Required: true, Placeholder: "例如 www.example.com,*.example.net;根域须单独填写"},
|
||||
},
|
||||
},
|
||||
DNSPod: {
|
||||
|
||||
@ -7,7 +7,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/go-acme/lego/v5/challenge"
|
||||
"github.com/go-acme/lego/v5/providers/dns/alidns"
|
||||
"github.com/go-acme/lego/v5/providers/dns/cloudflare"
|
||||
"github.com/go-acme/lego/v5/providers/dns/gcloud"
|
||||
"github.com/go-acme/lego/v5/providers/dns/huaweicloud"
|
||||
@ -73,15 +72,7 @@ func (LegoFactory) Create(providerID string, credentials map[string]string) (cha
|
||||
}
|
||||
return provider, nil
|
||||
case Aliyun:
|
||||
cfg := alidns.NewDefaultConfig()
|
||||
cfg.APIKey = cleaned["access_key_id"]
|
||||
cfg.SecretKey = cleaned["access_key_secret"]
|
||||
cfg.SecurityToken = cleaned["security_token"]
|
||||
provider, err := alidns.NewDNSProviderConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建阿里云 DNS Provider 失败:%w", err)
|
||||
}
|
||||
return provider, nil
|
||||
return newScopedAliyunProvider(cleaned)
|
||||
case DNSPod, TencentCloud:
|
||||
cfg := tencentcloud.NewDefaultConfig()
|
||||
cfg.SecretID = cleaned["secret_id"]
|
||||
|
||||
@ -102,6 +102,9 @@ func (t *Tester) VerifyWithReport(ctx context.Context, providerID, testDomain st
|
||||
if err != nil {
|
||||
return report, err
|
||||
}
|
||||
if err := ValidateAliyunDomainScope(providerID, []string{testDomain}, cleaned); err != nil {
|
||||
return report, err
|
||||
}
|
||||
provider, err := t.factory.Create(providerID, cleaned)
|
||||
if err != nil {
|
||||
return report, err
|
||||
|
||||
@ -175,6 +175,8 @@ func TestLegoFactoryCreatesConfiguredProviders(t *testing.T) {
|
||||
credentials: map[string]string{
|
||||
"access_key_id": "id",
|
||||
"access_key_secret": "secret",
|
||||
"managed_zones": "example.com",
|
||||
"allowed_domains": "example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -33,6 +33,7 @@ type aliyunScopeResponse struct {
|
||||
DiscoveredDomains []dnsprovider.AuthorizationDomain `json:"discovered_domains,omitempty"`
|
||||
ManagedDomains []string `json:"managed_domains"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
UserScope *dnsprovider.AliyunScope `json:"user_scope,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) getAliyunDNSScope(c *gin.Context) {
|
||||
@ -63,6 +64,14 @@ func (s *Server) getAliyunDNSScope(c *gin.Context) {
|
||||
CredentialRef: credentialRef,
|
||||
CheckedAt: s.now().UTC().Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
scope, scopeErr := dnsprovider.ParseAliyunScope(credentials)
|
||||
if scopeErr != nil {
|
||||
response.PolicyMode = "scope_required"
|
||||
response.Message = scopeErr.Error()
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
response.UserScope = &scope
|
||||
|
||||
// 读取当前系统内已经纳管的域名列表
|
||||
if s.repository != nil {
|
||||
@ -136,6 +145,10 @@ func (s *Server) checkAliyunDNSDomain(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := dnsprovider.ValidateAliyunDomainScope(provider, []string{targetDomain}, credentials); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"verified": false, "domain": targetDomain, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
report, verifyErr := s.aliyunChecker.Verify(c.Request.Context(), targetDomain, credentials)
|
||||
report = sanitizeAliyunReport(report, targetDomain)
|
||||
if verifyErr != nil {
|
||||
@ -162,7 +175,7 @@ func (s *Server) checkAliyunDNSDomain(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainName string) error {
|
||||
func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNames ...string) error {
|
||||
if s == nil {
|
||||
return errors.New("域名授权检查服务未初始化")
|
||||
}
|
||||
@ -189,10 +202,14 @@ func (s *Server) validateDomainAliyunAuthorization(ctx context.Context, domainNa
|
||||
if _, err := dnsprovider.ValidateCredentials(dnsprovider.Aliyun, credentials); err != nil {
|
||||
return fmt.Errorf("阿里云 DNS 凭据无效,不能确认域名授权:%w", err)
|
||||
}
|
||||
|
||||
if err := s.aliyunChecker.VerifyDomain(ctx, dnsprovider.Aliyun, domainName, credentials); err != nil {
|
||||
diagnosis := dnsprovider.DiagnoseAliyunError(err, domainName)
|
||||
return fmt.Errorf("阿里云 RAM 账号无法操作域名 %s:%s", domainName, diagnosis)
|
||||
if err := dnsprovider.ValidateAliyunDomainScope(provider, domainNames, credentials); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, domainName := range domainNames {
|
||||
if err := s.aliyunChecker.VerifyDomain(ctx, dnsprovider.Aliyun, domainName, credentials); err != nil {
|
||||
diagnosis := dnsprovider.DiagnoseAliyunError(err, domainName)
|
||||
return fmt.Errorf("阿里云 RAM 账号无法操作域名 %s:%s", domainName, diagnosis)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -81,6 +81,8 @@ func TestAliyunScopeAPIsAndDomainValidation(t *testing.T) {
|
||||
"secret://dns/aliyun/ref1": {
|
||||
"access_key_id": "LTAI_test",
|
||||
"access_key_secret": "test_secret",
|
||||
"managed_zones": "solit.cn,unauth.com,forbidden.cn",
|
||||
"allowed_domains": "solit.cn,*.solit.cn,unauth.com,forbidden.cn",
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -208,7 +210,7 @@ func TestAliyunDomainAPIRejectsPartiallyUnauthorizedSAN(t *testing.T) {
|
||||
server := New(Dependencies{
|
||||
Config: cfg, Repository: &stubDomainRepository{},
|
||||
Secrets: &fakeSecretReaderStore{data: map[string]map[string]string{
|
||||
cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret"},
|
||||
cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret", "managed_zones": "example.com", "allowed_domains": "example.com"},
|
||||
}},
|
||||
AliyunChecker: &fakeAliyunScopeChecker{allowedZones: []string{"example.com"}},
|
||||
})
|
||||
|
||||
@ -69,6 +69,12 @@ func (s *Server) authLogin(c *gin.Context) {
|
||||
s.writeError(c, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试", nil)
|
||||
return
|
||||
}
|
||||
if !s.reserveLogin(c.Request, key) {
|
||||
c.Header("Retry-After", "60")
|
||||
s.writeError(c, http.StatusTooManyRequests, "登录请求过于频繁或服务繁忙,请稍后再试", nil)
|
||||
return
|
||||
}
|
||||
defer s.releaseLogin(c.Request, key)
|
||||
|
||||
account, userID, passwordHash, err := s.authenticateAccount(c.Request.Context(), request, cfg)
|
||||
if err != nil {
|
||||
@ -92,9 +98,15 @@ func (s *Server) authLogin(c *gin.Context) {
|
||||
expiresAt := minTime(now.Add(authSessionLifetime), absoluteExpiresAt)
|
||||
s.authMu.Lock()
|
||||
s.pruneAuthSessionsLocked(now)
|
||||
if len(s.authSessions) >= authStateCapacity {
|
||||
userSessions := 0
|
||||
for _, session := range s.authSessions {
|
||||
if session.UserID == userID {
|
||||
userSessions++
|
||||
}
|
||||
}
|
||||
if len(s.authSessions) >= authStateCapacity || userSessions >= authUserSessionCapacity {
|
||||
s.authMu.Unlock()
|
||||
s.writeError(c, http.StatusServiceUnavailable, "登录会话数量已达上限,请稍后重试", nil)
|
||||
s.writeError(c, http.StatusServiceUnavailable, "登录会话数量已达上限,请退出其他设备或稍后重试", nil)
|
||||
return
|
||||
}
|
||||
s.authSessions[token] = authSession{
|
||||
@ -180,7 +192,7 @@ func (s *Server) currentAuthSession(c *gin.Context) (authSession, bool) {
|
||||
s.authMu.RLock()
|
||||
session, ok := s.authSessions[token]
|
||||
s.authMu.RUnlock()
|
||||
if !ok || !session.ExpiresAt.After(now) || (!session.AbsoluteExpiresAt.IsZero() && !session.AbsoluteExpiresAt.After(now)) {
|
||||
if !ok || !session.ExpiresAt.After(now) || session.AbsoluteExpiresAt.IsZero() || !session.AbsoluteExpiresAt.After(now) || session.ExpiresAt.After(session.AbsoluteExpiresAt) {
|
||||
if ok {
|
||||
s.authMu.Lock()
|
||||
delete(s.authSessions, token)
|
||||
@ -198,7 +210,7 @@ func (s *Server) currentAuthSession(c *gin.Context) (authSession, bool) {
|
||||
newExpiresAt := minTime(now.Add(authSessionLifetime), session.AbsoluteExpiresAt)
|
||||
s.authMu.Lock()
|
||||
current, exists := s.authSessions[token]
|
||||
if !exists {
|
||||
if !exists || current.UserID != session.UserID || current.PasswordHash != session.PasswordHash || current.Role != session.Role || current.Username != session.Username || current.Email != session.Email || !current.AbsoluteExpiresAt.Equal(session.AbsoluteExpiresAt) {
|
||||
s.authMu.Unlock()
|
||||
return authSession{}, false
|
||||
}
|
||||
@ -216,11 +228,11 @@ func (s *Server) currentAuthSession(c *gin.Context) (authSession, bool) {
|
||||
}
|
||||
|
||||
func (s *Server) authSessionAccountIsValid(ctx context.Context, session authSession) bool {
|
||||
if session.UserID == "" {
|
||||
return true
|
||||
if strings.TrimSpace(session.UserID) == "" || strings.TrimSpace(session.PasswordHash) == "" || strings.TrimSpace(session.Username) == "" {
|
||||
return false
|
||||
}
|
||||
if session.PasswordHash == "" {
|
||||
return true
|
||||
if session.Role != repository.UserRoleUser && session.Role != repository.UserRoleAdmin && session.Role != repository.UserRoleSuperAdmin {
|
||||
return false
|
||||
}
|
||||
if session.UserID == "primary-super-admin" {
|
||||
s.configMu.RLock()
|
||||
@ -290,7 +302,7 @@ func (s *Server) allowLogin(key string) bool {
|
||||
if !attempt.BlockedTill.IsZero() && attempt.BlockedTill.After(now) {
|
||||
return false
|
||||
}
|
||||
if attempt.WindowStart.IsZero() || now.Sub(attempt.WindowStart) >= loginWindow {
|
||||
if attempt.WindowStart.IsZero() || now.Sub(attempt.WindowStart) >= loginWindow || (!attempt.BlockedTill.IsZero() && !attempt.BlockedTill.After(now)) {
|
||||
delete(s.loginAttempts, key)
|
||||
}
|
||||
return true
|
||||
|
||||
67
backend/internal/httpapi/auth_admission.go
Normal file
67
backend/internal/httpapi/auth_admission.go
Normal file
@ -0,0 +1,67 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
loginConcurrentLimit = 8
|
||||
loginSourceConcurrentLimit = 2
|
||||
loginSourceRequestLimit = 30
|
||||
)
|
||||
|
||||
type loginSource struct {
|
||||
WindowStart time.Time
|
||||
Requests int
|
||||
InFlight int
|
||||
}
|
||||
|
||||
// 在密码计算和数据库查询之前预约容量,不建立无限等待队列。
|
||||
// 来源只取真实 TCP 对端;不信任任意转发头。代理部署应另外配置边缘限流。
|
||||
func (s *Server) reserveLogin(request *http.Request, key string) bool {
|
||||
sourceKey := strings.SplitN(loginAttemptKey(request, ""), "|", 2)[0]
|
||||
now := s.now()
|
||||
s.loginMu.Lock()
|
||||
defer s.loginMu.Unlock()
|
||||
for name, source := range s.loginSources {
|
||||
if source.InFlight == 0 && now.Sub(source.WindowStart) >= time.Minute {
|
||||
delete(s.loginSources, name)
|
||||
}
|
||||
}
|
||||
source, exists := s.loginSources[sourceKey]
|
||||
attempt := s.loginAttempts[key]
|
||||
if attempt.BlockedTill.After(now) || attempt.Failures+s.loginPending[key] >= loginFailureLimit ||
|
||||
s.loginInFlight >= loginConcurrentLimit || source.InFlight >= loginSourceConcurrentLimit ||
|
||||
(!exists && len(s.loginSources) >= authStateCapacity) {
|
||||
return false
|
||||
}
|
||||
if source.WindowStart.IsZero() || now.Sub(source.WindowStart) >= time.Minute {
|
||||
source.WindowStart = now
|
||||
source.Requests = 0
|
||||
}
|
||||
if source.Requests >= loginSourceRequestLimit {
|
||||
return false
|
||||
}
|
||||
source.Requests++
|
||||
source.InFlight++
|
||||
s.loginSources[sourceKey] = source
|
||||
s.loginInFlight++
|
||||
s.loginPending[key]++
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) releaseLogin(request *http.Request, key string) {
|
||||
sourceKey := strings.SplitN(loginAttemptKey(request, ""), "|", 2)[0]
|
||||
s.loginMu.Lock()
|
||||
defer s.loginMu.Unlock()
|
||||
source := s.loginSources[sourceKey]
|
||||
source.InFlight--
|
||||
s.loginSources[sourceKey] = source
|
||||
s.loginInFlight--
|
||||
s.loginPending[key]--
|
||||
if s.loginPending[key] == 0 {
|
||||
delete(s.loginPending, key)
|
||||
}
|
||||
}
|
||||
185
backend/internal/httpapi/auth_load_test.go
Normal file
185
backend/internal/httpapi/auth_load_test.go
Normal file
@ -0,0 +1,185 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIncompleteSessionsFailClosed(t *testing.T) {
|
||||
cfg := readyTestConfig(t.TempDir())
|
||||
cfg.Admin.Username = "admin"
|
||||
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
|
||||
for _, field := range []string{"user_id", "username", "hash", "role", "absolute", "inverted"} {
|
||||
t.Run(field, func(t *testing.T) {
|
||||
s := New(Dependencies{Config: cfg})
|
||||
cookie := loginAuthTest(t, s, "admin", "AdminPass123", false).Result().Cookies()[0]
|
||||
session := s.authSessions[cookie.Value]
|
||||
switch field {
|
||||
case "user_id":
|
||||
session.UserID = ""
|
||||
case "username":
|
||||
session.Username = ""
|
||||
case "hash":
|
||||
session.PasswordHash = ""
|
||||
case "role":
|
||||
session.Role = "unknown"
|
||||
case "absolute":
|
||||
session.AbsoluteExpiresAt = time.Time{}
|
||||
case "inverted":
|
||||
session.AbsoluteExpiresAt = session.ExpiresAt.Add(-time.Second)
|
||||
}
|
||||
s.authSessions[cookie.Value] = session
|
||||
if response := authSessionRequest(s, cookie); response.Code != http.StatusUnauthorized {
|
||||
t.Fatal("不完整会话必须拒绝")
|
||||
}
|
||||
if _, ok := s.authSessions[cookie.Value]; ok {
|
||||
t.Fatal("失效会话必须移除")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAdmissionConcurrentBurstAndRecovery(t *testing.T) {
|
||||
s := New(Dependencies{Config: readyTestConfig(t.TempDir())})
|
||||
const workers = 512
|
||||
start, release := make(chan struct{}), make(chan struct{})
|
||||
var attempted sync.WaitGroup
|
||||
var done sync.WaitGroup
|
||||
var accepted atomic.Int32
|
||||
attempted.Add(workers)
|
||||
done.Add(workers)
|
||||
for i := 0; i < workers; i++ {
|
||||
go func(i int) {
|
||||
defer done.Done()
|
||||
<-start
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
|
||||
r.RemoteAddr = fmt.Sprintf("192.0.2.%d:1234", i%250+1)
|
||||
key := loginAttemptKey(r, fmt.Sprint(i))
|
||||
ok := s.reserveLogin(r, key)
|
||||
if ok {
|
||||
accepted.Add(1)
|
||||
}
|
||||
attempted.Done()
|
||||
if ok {
|
||||
<-release
|
||||
s.releaseLogin(r, key)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
close(start)
|
||||
attempted.Wait()
|
||||
if accepted.Load() != loginConcurrentLimit {
|
||||
t.Errorf("并发容量错误:%d", accepted.Load())
|
||||
}
|
||||
close(release)
|
||||
done.Wait()
|
||||
if s.loginInFlight != 0 || len(s.loginPending) != 0 {
|
||||
t.Fatal("并发预约没有完整释放")
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
|
||||
r.RemoteAddr = "198.51.100.1:1234"
|
||||
key := loginAttemptKey(r, "normal")
|
||||
if !s.reserveLogin(r, key) {
|
||||
t.Fatal("突发结束后正常用户无法恢复")
|
||||
}
|
||||
s.releaseLogin(r, key)
|
||||
}
|
||||
|
||||
func TestRandomIdentifiersCannotExhaustGlobalLoginState(t *testing.T) {
|
||||
cfg := readyTestConfig(t.TempDir())
|
||||
cfg.Admin.Username = "admin"
|
||||
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
|
||||
s := New(Dependencies{Config: cfg})
|
||||
now := time.Now()
|
||||
s.now = func() time.Time { return now }
|
||||
for i := 0; i < 5000; i++ {
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(fmt.Sprintf(`{"identifier":"unknown-%d","password":"wrong"}`, i)))
|
||||
r.RemoteAddr = "192.0.2.1:1234"
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
if i >= loginSourceRequestLimit && w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("随机账号绕过来源限流:%d", w.Code)
|
||||
}
|
||||
}
|
||||
if len(s.loginAttempts) > loginSourceRequestLimit {
|
||||
t.Fatal("单一来源挤占了全局失败表")
|
||||
}
|
||||
normalRequest := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`))
|
||||
normalRequest.RemoteAddr = "198.51.100.1:1234"
|
||||
response := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(response, normalRequest)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatal("独立来源正常用户不能登录")
|
||||
}
|
||||
now = now.Add(2 * time.Minute)
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`))
|
||||
r.RemoteAddr = "192.0.2.1:1234"
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, r)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("来源窗口结束后无法恢复:%d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginAttemptTableSaturationRecoversAfterWindow(t *testing.T) {
|
||||
s := New(Dependencies{Config: readyTestConfig(t.TempDir())})
|
||||
now := time.Now()
|
||||
s.now = func() time.Time { return now }
|
||||
for i := 0; i < authStateCapacity; i++ {
|
||||
s.recordLoginFailure(fmt.Sprint(i))
|
||||
}
|
||||
if s.allowLogin("normal") {
|
||||
t.Fatal("满容量不能无界增加新键")
|
||||
}
|
||||
now = now.Add(loginWindow + time.Second)
|
||||
if !s.allowLogin("normal") || len(s.loginAttempts) != 0 {
|
||||
t.Fatal("窗口到期后未恢复")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginBlockExpiresAndUserSessionQuotaRecovers(t *testing.T) {
|
||||
cfg := readyTestConfig(t.TempDir())
|
||||
cfg.Admin.Username = "admin"
|
||||
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "AdminPass123")
|
||||
s := New(Dependencies{Config: cfg})
|
||||
now := time.Now()
|
||||
s.now = func() time.Time { return now }
|
||||
r := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
|
||||
key := loginAttemptKey(r, "admin")
|
||||
for i := 0; i < loginFailureLimit; i++ {
|
||||
s.recordLoginFailure(key)
|
||||
}
|
||||
if s.allowLogin(key) {
|
||||
t.Fatal("封禁期间不能登录")
|
||||
}
|
||||
now = now.Add(loginBlockDuration + time.Second)
|
||||
if !s.allowLogin(key) || !s.reserveLogin(r, key) {
|
||||
t.Fatal("封禁到期后必须恢复预约")
|
||||
}
|
||||
s.releaseLogin(r, key)
|
||||
var first *http.Cookie
|
||||
for i := 0; i < authUserSessionCapacity; i++ {
|
||||
cookie := loginAuthTest(t, s, "admin", "AdminPass123", false).Result().Cookies()[0]
|
||||
if first == nil {
|
||||
first = cookie
|
||||
}
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"identifier":"admin","password":"AdminPass123"}`))
|
||||
w := httptest.NewRecorder()
|
||||
s.Handler().ServeHTTP(w, request)
|
||||
if w.Code != http.StatusServiceUnavailable {
|
||||
t.Fatal("单账号不能占满全局会话容量")
|
||||
}
|
||||
logout := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
|
||||
logout.AddCookie(first)
|
||||
s.Handler().ServeHTTP(httptest.NewRecorder(), logout)
|
||||
if loginAuthTest(t, s, "admin", "AdminPass123", false).Code != http.StatusOK {
|
||||
t.Fatal("主动退出后不能恢复登录")
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ package httpapi
|
||||
import "time"
|
||||
|
||||
const authStateCapacity = 4096
|
||||
const authUserSessionCapacity = 16
|
||||
|
||||
// 调用方持有认证锁;会话表有容量上限,不淘汰仍然有效的用户会话。
|
||||
func (s *Server) pruneAuthSessionsLocked(now time.Time) {
|
||||
|
||||
@ -41,6 +41,8 @@ func newRestoreAPITestServer(t *testing.T, restore func(context.Context, string,
|
||||
audit := &restoreAuditRepository{}
|
||||
cfg := config.Default(t.TempDir())
|
||||
cfg.SetupReady = true
|
||||
cfg.Admin.Username = "admin"
|
||||
cfg.Admin.PasswordHash = httpUserPasswordHash(t, "RestoreTest123")
|
||||
server := New(Dependencies{
|
||||
Config: cfg,
|
||||
Repository: &stubDomainRepository{},
|
||||
@ -49,9 +51,13 @@ func newRestoreAPITestServer(t *testing.T, restore func(context.Context, string,
|
||||
Audit: audit,
|
||||
})
|
||||
server.authSessions["test-session"] = authSession{
|
||||
Username: "admin",
|
||||
Role: "super_admin",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
UserID: "primary-super-admin",
|
||||
PasswordHash: cfg.Admin.PasswordHash,
|
||||
Email: cfg.Admin.Email,
|
||||
Username: "admin",
|
||||
Role: "super_admin",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
AbsoluteExpiresAt: time.Now().Add(2 * time.Hour),
|
||||
}
|
||||
return server, audit
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ func TestAliyunReportHidesSDKErrorAndPreservesCleanupFailure(t *testing.T) {
|
||||
cfg.DNS.CredentialRef = "secret://test/only"
|
||||
server := New(Dependencies{
|
||||
Config: cfg, AliyunChecker: &unsafeReportChecker{},
|
||||
Secrets: &fakeSecretReaderStore{data: map[string]map[string]string{cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret"}}},
|
||||
Secrets: &fakeSecretReaderStore{data: map[string]map[string]string{cfg.DNS.CredentialRef: {"access_key_id": "test-id", "access_key_secret": "test-secret", "managed_zones": "example.test", "allowed_domains": "example.test"}}},
|
||||
})
|
||||
response := httptest.NewRecorder()
|
||||
serveAdminHTTP(server, response, httptest.NewRequest(http.MethodPost, "/api/v1/dns/aliyun/check", strings.NewReader(`{"domain":"example.test"}`)))
|
||||
|
||||
@ -106,6 +106,9 @@ type Server struct {
|
||||
authMu sync.RWMutex
|
||||
loginAttempts map[string]loginAttempt
|
||||
loginMu sync.Mutex
|
||||
loginSources map[string]loginSource
|
||||
loginPending map[string]int
|
||||
loginInFlight int
|
||||
loginPrunedAt time.Time
|
||||
now func() time.Time
|
||||
httpServer *http.Server
|
||||
@ -144,6 +147,8 @@ func New(deps Dependencies) *Server {
|
||||
resetLimiter: newResetRateLimiter(),
|
||||
authSessions: make(map[string]authSession),
|
||||
loginAttempts: make(map[string]loginAttempt),
|
||||
loginSources: make(map[string]loginSource),
|
||||
loginPending: make(map[string]int),
|
||||
now: time.Now,
|
||||
}
|
||||
router.Use(server.requestMiddleware())
|
||||
@ -361,10 +366,17 @@ func (s *Server) ready(c *gin.Context) {
|
||||
continue
|
||||
}
|
||||
checked[reference] = struct{}{}
|
||||
if _, err := reader.Get(ctx, reference); err != nil {
|
||||
credentials, err := reader.Get(ctx, reference)
|
||||
if err != nil {
|
||||
s.writeReadinessFailure(c, "credential_unavailable")
|
||||
return
|
||||
}
|
||||
if reference == dnsReference && cfg.DNS.Provider == dnsprovider.Aliyun {
|
||||
if _, err := dnsprovider.ParseAliyunScope(credentials); err != nil {
|
||||
s.writeReadinessFailure(c, "dns_scope_required")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{cfg.Server.TLSCert, cfg.Server.TLSKey} {
|
||||
@ -460,19 +472,10 @@ func (s *Server) createDomain(c *gin.Context) {
|
||||
s.writeError(c, http.StatusBadRequest, "域名配置无效", err)
|
||||
return
|
||||
}
|
||||
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), item.Name); err != nil {
|
||||
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), append([]string{item.Name}, item.SANs...)...); err != nil {
|
||||
s.writeError(c, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
}
|
||||
for _, san := range item.SANs {
|
||||
if san == item.Name {
|
||||
continue
|
||||
}
|
||||
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), san); err != nil {
|
||||
s.writeError(c, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.repository.Create(c.Request.Context(), item); err != nil {
|
||||
if repository.IsConflict(err) {
|
||||
s.writeError(c, http.StatusConflict, "域名已经存在", err)
|
||||
@ -507,19 +510,10 @@ func (s *Server) updateDomain(c *gin.Context) {
|
||||
s.writeError(c, http.StatusBadRequest, "域名配置无效", err)
|
||||
return
|
||||
}
|
||||
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), item.Name); err != nil {
|
||||
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), append([]string{item.Name}, item.SANs...)...); err != nil {
|
||||
s.writeError(c, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
}
|
||||
for _, san := range item.SANs {
|
||||
if san == item.Name {
|
||||
continue
|
||||
}
|
||||
if err := s.validateDomainAliyunAuthorization(c.Request.Context(), san); err != nil {
|
||||
s.writeError(c, http.StatusBadRequest, err.Error(), err)
|
||||
return
|
||||
}
|
||||
}
|
||||
item.ID = existing.ID
|
||||
item.CreatedAt = existing.CreatedAt
|
||||
item.Status = "pending"
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/certificate"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/dnsprovider"
|
||||
)
|
||||
|
||||
type settingsView struct {
|
||||
@ -54,9 +55,11 @@ type settingsACMEView struct {
|
||||
}
|
||||
|
||||
type settingsDNSView struct {
|
||||
Provider string `json:"provider"`
|
||||
VerificationState string `json:"verification_state"`
|
||||
CredentialConfigured bool `json:"credential_configured"`
|
||||
Provider string `json:"provider"`
|
||||
VerificationState string `json:"verification_state"`
|
||||
CredentialConfigured bool `json:"credential_configured"`
|
||||
UserScope *dnsprovider.AliyunScope `json:"user_scope,omitempty"`
|
||||
ScopeMessage string `json:"scope_message,omitempty"`
|
||||
}
|
||||
|
||||
type settingsNotificationView struct {
|
||||
@ -163,7 +166,24 @@ func (s *Server) getSettings(c *gin.Context) {
|
||||
s.configMu.RLock()
|
||||
cfg := s.config
|
||||
s.configMu.RUnlock()
|
||||
c.JSON(http.StatusOK, settingsViewFromConfig(cfg))
|
||||
view := settingsViewFromConfig(cfg)
|
||||
if cfg.DNS.Provider == dnsprovider.Aliyun {
|
||||
credentials, err := s.readDNSCredentials(c.Request.Context(), cfg.DNS.CredentialRef)
|
||||
if err == nil {
|
||||
scope, scopeErr := dnsprovider.ParseAliyunScope(credentials)
|
||||
if scopeErr == nil {
|
||||
view.DNS.UserScope = &scope
|
||||
} else {
|
||||
view.DNS.ScopeMessage = scopeErr.Error()
|
||||
}
|
||||
} else {
|
||||
view.DNS.ScopeMessage = "无法读取已保存授权范围,请检查凭据存储或重新验证"
|
||||
}
|
||||
if view.DNS.UserScope == nil {
|
||||
view.DNS.VerificationState = "scope_required"
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, view)
|
||||
}
|
||||
|
||||
func (s *Server) updateSettings(c *gin.Context) {
|
||||
|
||||
@ -3,6 +3,7 @@ package httpapi
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@ -134,16 +135,59 @@ func serveUserRequest(server *Server, cookie *http.Cookie, method, path, body st
|
||||
|
||||
func httpUserSessionCookie(server *Server, userID, role string) *http.Cookie {
|
||||
token := userID + "-session"
|
||||
now := time.Now()
|
||||
now := server.now()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("FixturePass123"), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
user := repository.User{ID: userID, Username: userID, Role: role, Status: repository.UserStatusActive, PasswordHash: string(hash)}
|
||||
if userID == "primary-super-admin" {
|
||||
user.Username = server.config.Admin.Username
|
||||
user.Email = server.config.Admin.Email
|
||||
user.PasswordHash = server.config.Admin.PasswordHash
|
||||
} else {
|
||||
if server.users == nil {
|
||||
server.users = &sessionFixtureUsers{items: make(map[string]repository.User)}
|
||||
}
|
||||
if stored, err := server.users.GetUserByID(context.Background(), userID); err == nil {
|
||||
user = stored
|
||||
} else if err := server.users.CreateUser(context.Background(), user); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
server.authMu.Lock()
|
||||
server.authSessions[token] = authSession{
|
||||
UserID: userID, Username: userID, Role: role,
|
||||
UserID: user.ID, Username: user.Username, Email: user.Email, Role: user.Role, PasswordHash: user.PasswordHash,
|
||||
ExpiresAt: now.Add(time.Hour), AbsoluteExpiresAt: now.Add(2 * time.Hour),
|
||||
}
|
||||
server.authMu.Unlock()
|
||||
return &http.Cookie{Name: authCookieName, Value: token, Path: "/"}
|
||||
}
|
||||
|
||||
type sessionFixtureUsers struct {
|
||||
repository.UserRepository
|
||||
items map[string]repository.User
|
||||
}
|
||||
|
||||
func (r *sessionFixtureUsers) GetUserByID(_ context.Context, id string) (repository.User, error) {
|
||||
if user, ok := r.items[id]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return repository.User{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (r *sessionFixtureUsers) CreateUser(_ context.Context, user repository.User) error {
|
||||
r.items[user.ID] = user
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *sessionFixtureUsers) EnsureSuperAdmin(_ context.Context, user repository.User) error {
|
||||
user.Role = repository.UserRoleSuperAdmin
|
||||
user.Status = repository.UserStatusActive
|
||||
r.items[user.ID] = user
|
||||
return nil
|
||||
}
|
||||
|
||||
func httpUserPasswordHash(t *testing.T, password string) string {
|
||||
t.Helper()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
|
||||
@ -11,6 +11,27 @@ type fakeDNSTester struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func TestDNSVerificationBindsScopeToNewCredentialReference(t *testing.T) {
|
||||
store := &memorySecretWriter{}
|
||||
manager := NewDNSVerificationManager(fakeDNSTester{}, store)
|
||||
input := DNSVerificationInput{ProviderID: "aliyun", TestDomain: "www.example.com", Credentials: map[string]string{"managed_zones": "example.com", "allowed_domains": "www.example.com"}}
|
||||
first, err := manager.Verify(t.Context(), input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input.Credentials["allowed_domains"] = "*.example.com"
|
||||
second, err := manager.Verify(t.Context(), input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.CredentialRef == second.CredentialRef || store.values[first.CredentialRef]["allowed_domains"] != "www.example.com" {
|
||||
t.Fatal("范围变更不能覆盖已验证引用")
|
||||
}
|
||||
if err := manager.Check(first.VerificationToken, "aliyun", second.CredentialRef, input.TestDomain); err == nil {
|
||||
t.Fatal("旧令牌不能授权新范围")
|
||||
}
|
||||
}
|
||||
|
||||
func (f fakeDNSTester) Verify(context.Context, string, string, map[string]string) (DNSVerificationReport, error) {
|
||||
return DNSVerificationReport{}, f.err
|
||||
}
|
||||
|
||||
@ -53,6 +53,18 @@ test("管理设置拒绝过期验证令牌", async (t) => {
|
||||
assert.equal(state.dnsReadyToSave.value, false);
|
||||
});
|
||||
|
||||
test("管理设置变更托管区不能复用验证令牌", async (t) => {
|
||||
const state = await setup(t, () => verifiedResponse());
|
||||
await fill(state);
|
||||
state.settingsForm.value.dns.credentials.managed_zones = "example.test";
|
||||
state.settingsForm.value.dns.credentials.allowed_domains = "example.test";
|
||||
await state.testDNS();
|
||||
assert.equal(state.dnsReadyToSave.value, true);
|
||||
state.settingsForm.value.dns.credentials.managed_zones = "other.test";
|
||||
assert.equal(state.dnsReadyToSave.value, false);
|
||||
assert.equal(state.settingsForm.value.dns.verification_token, "");
|
||||
});
|
||||
|
||||
test("关闭设置后不显示旧 DNS 请求的成功提示", async (t) => {
|
||||
const pending = deferred();
|
||||
const state = await setup(t, () => pending.promise);
|
||||
|
||||
@ -538,6 +538,14 @@ const {
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="settingsForm.dns.scope_message" class="form-error">
|
||||
{{ settingsForm.dns.scope_message }}
|
||||
</p>
|
||||
<div v-if="settingsForm.dns.user_scope" class="field-hint">
|
||||
<p>已保存托管区:{{ settingsForm.dns.user_scope.managed_zones.join(",") }}</p>
|
||||
<p>已保存允许域名:{{ settingsForm.dns.user_scope.allowed_domains.join(",") }}</p>
|
||||
<p>修改范围需重新填写凭据并在线验证,不会自动扩大已有授权。</p>
|
||||
</div>
|
||||
<div v-if="selectedDNSProvider" class="dns-test-actions">
|
||||
<button
|
||||
class="secondary-button"
|
||||
|
||||
@ -61,6 +61,17 @@ test("初始化当前 DNS 成功有效,修改域名立即失效", async (t) =>
|
||||
assert.equal(state.dnsVerificationValid.value, false);
|
||||
});
|
||||
|
||||
test("初始化修改显式授权范围立即作废旧验证", async (t) => {
|
||||
const { state } = await setup(t, () => verifiedResponse());
|
||||
state.dnsCredentials.value.managed_zones = "example.test";
|
||||
state.dnsCredentials.value.allowed_domains = "example.test";
|
||||
await state.testDNS();
|
||||
assert.equal(state.dnsVerificationValid.value, true);
|
||||
state.dnsCredentials.value.allowed_domains = "*.example.test";
|
||||
assert.equal(state.dnsVerificationValid.value, false);
|
||||
assert.equal(state.dnsVerification.value, null);
|
||||
});
|
||||
|
||||
test("初始化卸载后不恢复旧 DNS 验证结果", async (t) => {
|
||||
const pending = deferred();
|
||||
const { state, unmount } = await setup(t, () => pending.promise);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user