保存显式授权范围与认证加固中间检查点

This commit is contained in:
xuans 2026-09-15 21:54:04 +08:00
parent 7f567ea23a
commit 49ede22861
28 changed files with 961 additions and 148 deletions

View File

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

View File

@ -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("阿里云权限探测器未初始化,已阻止证书申请")

View File

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

View File

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

View File

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

View File

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

View 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
}

View 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
}

View 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")
}
}

View File

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

View File

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

View File

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

View File

@ -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",
},
},
{

View File

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

View File

@ -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"}},
})

View File

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

View 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)
}
}

View 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("主动退出后不能恢复登录")
}
}

View File

@ -3,6 +3,7 @@ package httpapi
import "time"
const authStateCapacity = 4096
const authUserSessionCapacity = 16
// 调用方持有认证锁;会话表有容量上限,不淘汰仍然有效的用户会话。
func (s *Server) pruneAuthSessionsLocked(now time.Time) {

View File

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

View File

@ -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"}`)))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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