297 lines
8.1 KiB
Go
297 lines
8.1 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const clientVersion = "2.0.1"
|
|
|
|
type appConfig struct {
|
|
DisplayName string
|
|
AllowedAPIs []string
|
|
}
|
|
|
|
var appRegistry = map[string]appConfig{
|
|
"order_site": {
|
|
DisplayName: "订单后台",
|
|
AllowedAPIs: []string{
|
|
"https://ks.khhao.com",
|
|
"https://order.khhao.com",
|
|
"http://localhost",
|
|
"http://127.0.0.1",
|
|
"http://localhost:5173",
|
|
"http://127.0.0.1:5173",
|
|
"http://localhost:3000",
|
|
"http://127.0.0.1:3000",
|
|
"http://192.168.10.6",
|
|
"http://192.168.10.6:5173",
|
|
},
|
|
},
|
|
"live_hub": {
|
|
DisplayName: "直播运营后台",
|
|
AllowedAPIs: []string{
|
|
"https://live.khhao.com",
|
|
"http://localhost:5174",
|
|
"http://127.0.0.1:5174",
|
|
"http://localhost:8800",
|
|
"http://127.0.0.1:8800",
|
|
},
|
|
},
|
|
"affiliate_dash": {
|
|
DisplayName: "皮肤分销后台",
|
|
AllowedAPIs: []string{
|
|
"https://skin.khhao.com",
|
|
"http://localhost:15173",
|
|
"http://127.0.0.1:15173",
|
|
},
|
|
},
|
|
}
|
|
|
|
type identity struct {
|
|
privateKey ed25519.PrivateKey
|
|
publicKey ed25519.PublicKey
|
|
deviceID string
|
|
}
|
|
|
|
type authorizeRequest struct {
|
|
App string `json:"app"`
|
|
ChallengeID string `json:"challengeId"`
|
|
Challenge string `json:"challenge"`
|
|
DeviceID string `json:"deviceId"`
|
|
PublicKey string `json:"publicKey"`
|
|
Signature string `json:"signature"`
|
|
Timestamp int64 `json:"timestamp"`
|
|
ClientVersion string `json:"clientVersion"`
|
|
}
|
|
|
|
func main() {
|
|
runClient(findProtocolArg(os.Args[1:]))
|
|
}
|
|
|
|
type authorizeTarget struct {
|
|
App string
|
|
ChallengeID string
|
|
Challenge string
|
|
APIBase string
|
|
}
|
|
|
|
func findProtocolArg(args []string) string {
|
|
for _, arg := range args {
|
|
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(arg)), "dcauth://") {
|
|
return strings.TrimSpace(arg)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseAuthorizeURL(raw string) (authorizeTarget, error) {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
return authorizeTarget{}, fmt.Errorf("无法解析协议地址")
|
|
}
|
|
if !strings.EqualFold(parsed.Scheme, "dcauth") {
|
|
return authorizeTarget{}, fmt.Errorf("协议 scheme 不是 dcauth")
|
|
}
|
|
if !strings.EqualFold(parsed.Host, "authorize") && !strings.EqualFold(strings.Trim(parsed.Path, "/"), "authorize") {
|
|
return authorizeTarget{}, fmt.Errorf("不支持的协议动作")
|
|
}
|
|
|
|
query := parsed.Query()
|
|
appID := strings.TrimSpace(query.Get("app"))
|
|
challengeID := strings.TrimSpace(query.Get("challenge_id"))
|
|
challenge := strings.TrimSpace(query.Get("challenge"))
|
|
apiBase := strings.TrimRight(strings.TrimSpace(query.Get("api")), "/")
|
|
|
|
if appID == "" || challengeID == "" || challenge == "" || apiBase == "" {
|
|
return authorizeTarget{}, fmt.Errorf("缺少 app、challenge_id、challenge 或 api 参数")
|
|
}
|
|
if len(challengeID) > 128 || len(challenge) > 512 {
|
|
return authorizeTarget{}, fmt.Errorf("challenge 参数过长")
|
|
}
|
|
|
|
config, ok := appRegistry[appID]
|
|
if !ok {
|
|
return authorizeTarget{}, fmt.Errorf("未知应用:%s", appID)
|
|
}
|
|
origin, err := normalizeOrigin(apiBase)
|
|
if err != nil {
|
|
return authorizeTarget{}, err
|
|
}
|
|
if !containsOrigin(config.AllowedAPIs, origin) {
|
|
return authorizeTarget{}, fmt.Errorf("应用 %s 不允许访问 %s", appID, origin)
|
|
}
|
|
|
|
return authorizeTarget{
|
|
App: appID,
|
|
ChallengeID: challengeID,
|
|
Challenge: challenge,
|
|
APIBase: origin,
|
|
}, nil
|
|
}
|
|
|
|
func normalizeOrigin(raw string) (string, error) {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil || parsed.Host == "" {
|
|
return "", fmt.Errorf("api 地址无效")
|
|
}
|
|
if parsed.Scheme != "https" && parsed.Scheme != "http" {
|
|
return "", fmt.Errorf("api 地址必须使用 http 或 https")
|
|
}
|
|
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
return "", fmt.Errorf("api 地址不允许携带用户信息、查询串或片段")
|
|
}
|
|
path := strings.TrimRight(parsed.EscapedPath(), "/")
|
|
if path != "" {
|
|
return "", fmt.Errorf("api 必须是不带路径的 origin")
|
|
}
|
|
return strings.ToLower(parsed.Scheme) + "://" + strings.ToLower(parsed.Host), nil
|
|
}
|
|
|
|
func containsOrigin(allowed []string, origin string) bool {
|
|
for _, item := range allowed {
|
|
if strings.EqualFold(strings.TrimRight(item, "/"), origin) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func submitAuthorization(id identity, target authorizeTarget) error {
|
|
timestamp := time.Now().Unix()
|
|
canonical := buildAuthorizationCanonical(id, target, timestamp)
|
|
signature := ed25519.Sign(id.privateKey, []byte(canonical))
|
|
|
|
payload := authorizeRequest{
|
|
App: target.App,
|
|
ChallengeID: target.ChallengeID,
|
|
Challenge: target.Challenge,
|
|
DeviceID: id.deviceID,
|
|
PublicKey: base64.RawURLEncoding.EncodeToString(id.publicKey),
|
|
Signature: base64.RawURLEncoding.EncodeToString(signature),
|
|
Timestamp: timestamp,
|
|
ClientVersion: clientVersion,
|
|
}
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("生成请求失败")
|
|
}
|
|
|
|
request, err := http.NewRequest(http.MethodPost, target.APIBase+"/api/dcauth/authorize", bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("生成请求失败")
|
|
}
|
|
request.Header.Set("Content-Type", "application/json")
|
|
request.Header.Set("User-Agent", "DCAuth/"+clientVersion)
|
|
|
|
client := &http.Client{Timeout: 15 * time.Second}
|
|
response, err := client.Do(request)
|
|
if err != nil {
|
|
return fmt.Errorf("无法连接服务器:%w", err)
|
|
}
|
|
defer response.Body.Close()
|
|
responseBody, _ := io.ReadAll(io.LimitReader(response.Body, 64*1024))
|
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
|
return fmt.Errorf("服务器拒绝本机验证(HTTP %d):%s", response.StatusCode, summarizeResponse(responseBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildAuthorizationCanonical(id identity, target authorizeTarget, timestamp int64) string {
|
|
return strings.Join([]string{
|
|
"dcauth.v2",
|
|
target.App,
|
|
target.ChallengeID,
|
|
target.Challenge,
|
|
id.deviceID,
|
|
fmt.Sprintf("%d", timestamp),
|
|
}, "\n")
|
|
}
|
|
|
|
func summarizeResponse(body []byte) string {
|
|
var payload map[string]any
|
|
if json.Unmarshal(body, &payload) == nil {
|
|
for _, key := range []string{"msg", "message", "detail", "error"} {
|
|
if value, ok := payload[key].(string); ok && strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
}
|
|
text := strings.TrimSpace(string(body))
|
|
if len(text) > 200 {
|
|
text = text[:200] + "..."
|
|
}
|
|
if text == "" {
|
|
return "无响应内容"
|
|
}
|
|
return text
|
|
}
|
|
|
|
func deviceKeyPath() (string, error) {
|
|
base := strings.TrimSpace(os.Getenv("LOCALAPPDATA"))
|
|
if base == "" {
|
|
configDir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("无法定位用户配置目录")
|
|
}
|
|
base = configDir
|
|
}
|
|
dir := filepath.Join(base, "DCAuth")
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return "", fmt.Errorf("无法创建设备密钥目录:%w", err)
|
|
}
|
|
return filepath.Join(dir, "device.key"), nil
|
|
}
|
|
|
|
func loadOrCreateIdentity() (identity, error) {
|
|
keyPath, err := deviceKeyPath()
|
|
if err != nil {
|
|
return identity{}, err
|
|
}
|
|
|
|
var privateKey ed25519.PrivateKey
|
|
encrypted, readErr := os.ReadFile(keyPath)
|
|
if readErr == nil {
|
|
raw, err := unprotectData(encrypted)
|
|
if err != nil {
|
|
return identity{}, fmt.Errorf("设备密钥无法解密,请确认当前 Windows 用户未变化")
|
|
}
|
|
if len(raw) != ed25519.PrivateKeySize {
|
|
return identity{}, fmt.Errorf("设备密钥文件损坏,请删除 %s 后重试", keyPath)
|
|
}
|
|
privateKey = ed25519.PrivateKey(raw)
|
|
} else if os.IsNotExist(readErr) {
|
|
_, generated, err := ed25519.GenerateKey(nil)
|
|
if err != nil {
|
|
return identity{}, fmt.Errorf("生成设备密钥失败")
|
|
}
|
|
privateKey = generated
|
|
protected, err := protectData([]byte(privateKey))
|
|
if err != nil {
|
|
return identity{}, fmt.Errorf("保护设备密钥失败:%w", err)
|
|
}
|
|
if err := os.WriteFile(keyPath, protected, 0o600); err != nil {
|
|
return identity{}, fmt.Errorf("保存设备密钥失败:%w", err)
|
|
}
|
|
} else {
|
|
return identity{}, fmt.Errorf("读取设备密钥失败:%w", readErr)
|
|
}
|
|
|
|
publicKey := privateKey.Public().(ed25519.PublicKey)
|
|
return identity{
|
|
privateKey: privateKey,
|
|
publicKey: publicKey,
|
|
deviceID: buildDeviceID(publicKey),
|
|
}, nil
|
|
}
|