新增功能: - 支持 Google Authenticator 等应用进行 TOTP 二次验证 - 用户可在个人设置中启用/禁用 2FA - 登录时支持 TOTP 验证流程 - 管理后台可全局开关 TOTP 功能 安全增强: - TOTP 密钥使用 AES-256-GCM 加密存储 - 添加 TOTP_ENCRYPTION_KEY 配置项,必须手动配置才能启用功能 - 防止服务重启导致加密密钥变更使用户无法登录 - 验证失败次数限制,防止暴力破解 配置说明: - Docker 部署:在 .env 中设置 TOTP_ENCRYPTION_KEY - 非 Docker 部署:在 config.yaml 中设置 totp.encryption_key - 生成密钥命令:openssl rand -hex 32
161 lines
4.5 KiB
Go
161 lines
4.5 KiB
Go
// Package tlsfingerprint provides TLS fingerprint simulation for HTTP clients.
|
|
//
|
|
// Unit tests for TLS fingerprint dialer.
|
|
// Integration tests that require external network are in dialer_integration_test.go
|
|
// and require the 'integration' build tag.
|
|
//
|
|
// Run unit tests: go test -v ./internal/pkg/tlsfingerprint/...
|
|
// Run integration tests: go test -v -tags=integration ./internal/pkg/tlsfingerprint/...
|
|
package tlsfingerprint
|
|
|
|
import (
|
|
"net/url"
|
|
"testing"
|
|
)
|
|
|
|
// FingerprintResponse represents the response from tls.peet.ws/api/all.
|
|
type FingerprintResponse struct {
|
|
IP string `json:"ip"`
|
|
TLS TLSInfo `json:"tls"`
|
|
HTTP2 any `json:"http2"`
|
|
}
|
|
|
|
// TLSInfo contains TLS fingerprint details.
|
|
type TLSInfo struct {
|
|
JA3 string `json:"ja3"`
|
|
JA3Hash string `json:"ja3_hash"`
|
|
JA4 string `json:"ja4"`
|
|
PeetPrint string `json:"peetprint"`
|
|
PeetPrintHash string `json:"peetprint_hash"`
|
|
ClientRandom string `json:"client_random"`
|
|
SessionID string `json:"session_id"`
|
|
}
|
|
|
|
// TestDialerWithProfile tests that different profiles produce different fingerprints.
|
|
func TestDialerWithProfile(t *testing.T) {
|
|
// Create two dialers with different profiles
|
|
profile1 := &Profile{
|
|
Name: "Profile 1 - No GREASE",
|
|
EnableGREASE: false,
|
|
}
|
|
profile2 := &Profile{
|
|
Name: "Profile 2 - With GREASE",
|
|
EnableGREASE: true,
|
|
}
|
|
|
|
dialer1 := NewDialer(profile1, nil)
|
|
dialer2 := NewDialer(profile2, nil)
|
|
|
|
// Build specs and compare
|
|
// Note: We can't directly compare JA3 without making network requests
|
|
// but we can verify the specs are different
|
|
spec1 := dialer1.buildClientHelloSpec()
|
|
spec2 := dialer2.buildClientHelloSpec()
|
|
|
|
// Profile with GREASE should have more extensions
|
|
if len(spec2.Extensions) <= len(spec1.Extensions) {
|
|
t.Error("expected GREASE profile to have more extensions")
|
|
}
|
|
}
|
|
|
|
// TestHTTPProxyDialerBasic tests HTTP proxy dialer creation.
|
|
// Note: This is a unit test - actual proxy testing requires a proxy server.
|
|
func TestHTTPProxyDialerBasic(t *testing.T) {
|
|
profile := &Profile{
|
|
Name: "Test Profile",
|
|
EnableGREASE: false,
|
|
}
|
|
|
|
// Test that dialer is created without panic
|
|
proxyURL := mustParseURL("http://proxy.example.com:8080")
|
|
dialer := NewHTTPProxyDialer(profile, proxyURL)
|
|
|
|
if dialer == nil {
|
|
t.Fatal("expected dialer to be created")
|
|
}
|
|
if dialer.profile != profile {
|
|
t.Error("expected profile to be set")
|
|
}
|
|
if dialer.proxyURL != proxyURL {
|
|
t.Error("expected proxyURL to be set")
|
|
}
|
|
}
|
|
|
|
// TestSOCKS5ProxyDialerBasic tests SOCKS5 proxy dialer creation.
|
|
// Note: This is a unit test - actual proxy testing requires a proxy server.
|
|
func TestSOCKS5ProxyDialerBasic(t *testing.T) {
|
|
profile := &Profile{
|
|
Name: "Test Profile",
|
|
EnableGREASE: false,
|
|
}
|
|
|
|
// Test that dialer is created without panic
|
|
proxyURL := mustParseURL("socks5://proxy.example.com:1080")
|
|
dialer := NewSOCKS5ProxyDialer(profile, proxyURL)
|
|
|
|
if dialer == nil {
|
|
t.Fatal("expected dialer to be created")
|
|
}
|
|
if dialer.profile != profile {
|
|
t.Error("expected profile to be set")
|
|
}
|
|
if dialer.proxyURL != proxyURL {
|
|
t.Error("expected proxyURL to be set")
|
|
}
|
|
}
|
|
|
|
// TestBuildClientHelloSpec tests ClientHello spec construction.
|
|
func TestBuildClientHelloSpec(t *testing.T) {
|
|
// Test with nil profile (should use defaults)
|
|
spec := buildClientHelloSpecFromProfile(nil)
|
|
|
|
if len(spec.CipherSuites) == 0 {
|
|
t.Error("expected cipher suites to be set")
|
|
}
|
|
if len(spec.Extensions) == 0 {
|
|
t.Error("expected extensions to be set")
|
|
}
|
|
|
|
// Verify default cipher suites are used
|
|
if len(spec.CipherSuites) != len(defaultCipherSuites) {
|
|
t.Errorf("expected %d cipher suites, got %d", len(defaultCipherSuites), len(spec.CipherSuites))
|
|
}
|
|
|
|
// Test with custom profile
|
|
customProfile := &Profile{
|
|
Name: "Custom",
|
|
EnableGREASE: false,
|
|
CipherSuites: []uint16{0x1301, 0x1302},
|
|
}
|
|
spec = buildClientHelloSpecFromProfile(customProfile)
|
|
|
|
if len(spec.CipherSuites) != 2 {
|
|
t.Errorf("expected 2 cipher suites, got %d", len(spec.CipherSuites))
|
|
}
|
|
}
|
|
|
|
// TestToUTLSCurves tests curve ID conversion.
|
|
func TestToUTLSCurves(t *testing.T) {
|
|
input := []uint16{0x001d, 0x0017, 0x0018}
|
|
result := toUTLSCurves(input)
|
|
|
|
if len(result) != len(input) {
|
|
t.Errorf("expected %d curves, got %d", len(input), len(result))
|
|
}
|
|
|
|
for i, curve := range result {
|
|
if uint16(curve) != input[i] {
|
|
t.Errorf("curve %d: expected 0x%04x, got 0x%04x", i, input[i], uint16(curve))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to parse URL without error handling.
|
|
func mustParseURL(rawURL string) *url.URL {
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return u
|
|
}
|