diff --git a/server/admin/api_cert.go b/server/admin/api_cert.go index 664bbfd..e4c9af3 100644 --- a/server/admin/api_cert.go +++ b/server/admin/api_cert.go @@ -2,10 +2,12 @@ package admin import ( "encoding/json" + "errors" "fmt" "io" "net/http" "os" + "strconv" "github.com/bjdgyc/anylink/base" "github.com/bjdgyc/anylink/dbdata" @@ -97,3 +99,209 @@ func CreatCert(w http.ResponseWriter, r *http.Request) { } RespSucess(w, "生成证书成功") } + +// 初始化客户端 CA +func InitClientCA(w http.ResponseWriter, r *http.Request) { + // 检查 CA 文件是否已存在 + caExists := true + if _, err := os.Stat(base.Cfg.ClientCertCAFile); errors.Is(err, os.ErrNotExist) { + caExists = false + } + keyExists := true + if _, err := os.Stat(base.Cfg.ClientCertCAKeyFile); errors.Is(err, os.ErrNotExist) { + keyExists = false + } + + if caExists && keyExists { + RespError(w, RespInternalErr, "客户端 CA 已存在,请勿重复初始化,如需强制初始化可在服务器后台删除客户端CA文件") + return + } + err := dbdata.GenerateClientCA() + if err != nil { + RespError(w, RespInternalErr, fmt.Sprintf("客户端 CA 生成失败: %v", err)) + return + } + RespSucess(w, "客户端 CA 初始化成功") +} + +// 生成客户端证书 +func GenerateClientCert(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("username") + if username == "" { + RespError(w, RespInternalErr, "用户名不能为空") + return + } + + // 检查用户是否存在 + user := &dbdata.User{} + err := dbdata.One("Username", username, user) + if err != nil { + RespError(w, RespInternalErr, "用户不存在") + return + } + + // 生成客户端证书 + certData, err := dbdata.GenerateClientCert(username) + if err != nil { + RespError(w, RespInternalErr, fmt.Sprintf("证书生成失败: %v", err)) + return + } + + RespSucess(w, certData) +} + +// 下载客户端 P12 证书 +func DownloadClientP12(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("username") + password := r.FormValue("password") + + if username == "" { + RespError(w, RespInternalErr, "用户名不能为空") + return + } + + // if password == "" { + // password = "123456" // 默认密码 + // } + + // 生成 P12 证书 + p12Data, err := dbdata.GenerateClientP12FromDB(username, password) + if err != nil { + RespError(w, RespInternalErr, fmt.Sprintf("证书下载失败: %v", err)) + return + } + + // 设置下载响应头 + w.Header().Set("Content-Type", "application/x-pkcs12") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.p12", username)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(p12Data))) + w.Write(p12Data) +} + +// 切换客户端证书状态(禁用/启用) +func ChangeClientCertStatus(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("username") + if username == "" { + RespError(w, RespInternalErr, "用户名不能为空") + return + } + + clientCert, err := dbdata.GetClientCert(username) + if err != nil { + RespError(w, RespInternalErr, "证书不存在") + return + } + + err = clientCert.ChangeStatus() + if err != nil { + RespError(w, RespInternalErr, fmt.Sprintf("证书状态切换失败: %v", err)) + return + } + + statusText := "启用" + if clientCert.Status == dbdata.CertStatusDisabled { + statusText = "禁用" + } + + RespSucess(w, fmt.Sprintf("证书%s成功", statusText)) +} + +// // 禁用客户端证书 +// func DisableClientCert(w http.ResponseWriter, r *http.Request) { +// username := r.FormValue("username") +// if username == "" { +// RespError(w, RespInternalErr, "用户名不能为空") +// return +// } + +// // 获取证书并禁用 +// clientCert, err := dbdata.GetClientCert(username) +// if err != nil { +// RespError(w, RespInternalErr, "证书不存在") +// return +// } + +// err = clientCert.Disable() +// if err != nil { +// RespError(w, RespInternalErr, fmt.Sprintf("证书禁用失败: %v", err)) +// return +// } + +// RespSucess(w, "证书禁用成功") +// } + +// 删除客户端证书 +func DeleteClientCert(w http.ResponseWriter, r *http.Request) { + username := r.FormValue("username") + if username == "" { + RespError(w, RespInternalErr, "用户名不能为空") + return + } + + clientCert, err := dbdata.GetClientCert(username) + if err != nil { + RespError(w, RespInternalErr, "证书不存在") + return + } + + err = clientCert.Delete() + if err != nil { + RespError(w, RespInternalErr, fmt.Sprintf("证书删除失败: %v", err)) + return + } + + RespSucess(w, "证书删除成功") +} + +// // 启用客户端证书 +// func EnableClientCert(w http.ResponseWriter, r *http.Request) { +// username := r.FormValue("username") +// if username == "" { +// RespError(w, RespInternalErr, "用户名不能为空") +// return +// } + +// clientCert, err := dbdata.GetClientCert(username) +// if err != nil { +// RespError(w, RespInternalErr, "证书不存在") +// return +// } + +// if err := clientCert.Enable(); err != nil { +// RespError(w, RespInternalErr, fmt.Sprintf("证书启用失败: %v", err)) +// return +// } + +// RespSucess(w, nil) +// } + +// 获取客户端证书列表 +func GetClientCertList(w http.ResponseWriter, r *http.Request) { + pageSize := 10 + pageIndex := 1 + + if r.FormValue("page_size") != "" { + if ps, err := strconv.Atoi(r.FormValue("page_size")); err == nil { + pageSize = ps + } + } + + if r.FormValue("page_index") != "" { + if pi, err := strconv.Atoi(r.FormValue("page_index")); err == nil { + pageIndex = pi + } + } + + certs, total, err := dbdata.GetClientCertList(pageSize, pageIndex) + if err != nil { + RespError(w, RespInternalErr, fmt.Sprintf("获取证书列表失败: %v", err)) + return + } + + data := map[string]any{ + "list": certs, + "total": total, + } + + RespSucess(w, data) +} diff --git a/server/admin/server.go b/server/admin/server.go index dae1a66..6e2641b 100644 --- a/server/admin/server.go +++ b/server/admin/server.go @@ -59,6 +59,14 @@ func StartAdmin() { r.HandleFunc("/set/other/createcert", CreatCert) r.HandleFunc("/set/other/getcertset", GetCertSetting) r.HandleFunc("/set/other/customcert", CustomCert) + r.HandleFunc("/set/client_cert/init_ca", InitClientCA) + r.HandleFunc("/set/client_cert/generate", GenerateClientCert) + r.HandleFunc("/set/client_cert/download", DownloadClientP12) + r.HandleFunc("/set/client_cert/list", GetClientCertList) + r.HandleFunc("/set/client_cert/changecertstatus", ChangeClientCertStatus) + // r.HandleFunc("/set/client_cert/enable", EnableClientCert) + // r.HandleFunc("/set/client_cert/disable", DisableClientCert) + r.HandleFunc("/set/client_cert/delete", DeleteClientCert) r.HandleFunc("/user/list", UserList) r.HandleFunc("/user/detail", UserDetail) diff --git a/server/base/cfg.go b/server/base/cfg.go index 65cb448..8fda823 100644 --- a/server/base/cfg.go +++ b/server/base/cfg.go @@ -34,29 +34,31 @@ var ( type ServerConfig struct { // LinkAddr string `json:"link_addr"` - Conf string `json:"conf"` - Profile string `json:"profile"` - ProfileName string `json:"profile_name"` - ServerAddr string `json:"server_addr"` - ServerDTLS bool `json:"server_dtls"` - ServerDTLSAddr string `json:"server_dtls_addr"` - AdvertiseDTLSAddr string `json:"advertise_dtls_addr"` - AdminAddr string `json:"admin_addr"` - ProxyProtocol bool `json:"proxy_protocol"` - DbType string `json:"db_type"` - DbSource string `json:"db_source"` - CertFile string `json:"cert_file"` - CertKey string `json:"cert_key"` - FilesPath string `json:"files_path"` - LogPath string `json:"log_path"` - LogLevel string `json:"log_level"` - HttpServerLog bool `json:"http_server_log"` - Pprof bool `json:"pprof"` - Issuer string `json:"issuer"` - AdminUser string `json:"admin_user"` - AdminPass string `json:"admin_pass"` - AdminOtp string `json:"admin_otp"` - JwtSecret string `json:"jwt_secret"` + Conf string `json:"conf"` + Profile string `json:"profile"` + ProfileName string `json:"profile_name"` + ServerAddr string `json:"server_addr"` + ServerDTLS bool `json:"server_dtls"` + ServerDTLSAddr string `json:"server_dtls_addr"` + AdvertiseDTLSAddr string `json:"advertise_dtls_addr"` + AdminAddr string `json:"admin_addr"` + ProxyProtocol bool `json:"proxy_protocol"` + DbType string `json:"db_type"` + DbSource string `json:"db_source"` + CertFile string `json:"cert_file"` + CertKey string `json:"cert_key"` + ClientCertCAFile string `json:"client_ca_file"` + ClientCertCAKeyFile string `json:"client_ca_key_file"` + FilesPath string `json:"files_path"` + LogPath string `json:"log_path"` + LogLevel string `json:"log_level"` + HttpServerLog bool `json:"http_server_log"` + Pprof bool `json:"pprof"` + Issuer string `json:"issuer"` + AdminUser string `json:"admin_user"` + AdminPass string `json:"admin_pass"` + AdminOtp string `json:"admin_otp"` + JwtSecret string `json:"jwt_secret"` LinkMode string `json:"link_mode"` // tun tap macvtap ipvtap Ipv4Master string `json:"ipv4_master"` // eth0 diff --git a/server/base/config.go b/server/base/config.go index d5ee700..92e5e6f 100644 --- a/server/base/config.go +++ b/server/base/config.go @@ -33,6 +33,8 @@ var configs = []config{ {Typ: cfgStr, Name: "db_source", Usage: "数据库source", ValStr: "./conf/anylink.db"}, {Typ: cfgStr, Name: "cert_file", Usage: "证书文件", ValStr: "./conf/vpn_cert.pem"}, {Typ: cfgStr, Name: "cert_key", Usage: "证书密钥", ValStr: "./conf/vpn_cert.key"}, + {Typ: cfgStr, Name: "client_ca_file", Usage: "客户端证书CA证书", ValStr: "./conf/client_ca.pem"}, + {Typ: cfgStr, Name: "client_ca_key_file", Usage: "客户端证书CA密钥", ValStr: "./conf/client_ca.key"}, {Typ: cfgStr, Name: "files_path", Usage: "外部下载文件路径", ValStr: "./conf/files"}, {Typ: cfgStr, Name: "log_path", Usage: "日志文件路径,默认标准输出", ValStr: ""}, {Typ: cfgStr, Name: "log_level", Usage: "日志等级 [debug info warn error]", ValStr: "debug"}, diff --git a/server/conf/profile.xml b/server/conf/profile.xml index a821a88..b594b7b 100644 --- a/server/conf/profile.xml +++ b/server/conf/profile.xml @@ -20,6 +20,7 @@ ClientAuth + User diff --git a/server/conf/server.toml b/server/conf/server.toml index d937bf3..d9947c2 100644 --- a/server/conf/server.toml +++ b/server/conf/server.toml @@ -11,6 +11,11 @@ cert_file = "./conf/vpn_cert.pem" cert_key = "./conf/vpn_cert.key" files_path = "./conf/files" +#客户端证书CA证书 +client_cert_ca_file = "./conf/client_ca.pem" +#客户端证书CA密钥 +client_cert_ca_key_file = "./conf/client_ca.key" + #日志目录,默认为空写入标准输出 #log_path = "./log" log_level = "debug" diff --git a/server/dbdata/cert_client.go b/server/dbdata/cert_client.go new file mode 100644 index 0000000..b416ccf --- /dev/null +++ b/server/dbdata/cert_client.go @@ -0,0 +1,429 @@ +package dbdata + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "math/big" + "os" + "slices" + "sync" + "time" + + "github.com/bjdgyc/anylink/base" + "software.sslmate.com/src/go-pkcs12" +) + +// 客户端证书数据结构 +type ClientCertData struct { + Id int `json:"id" xorm:"pk autoincr not null"` + Username string `json:"username" xorm:"varchar(60) not null"` + // GroupName string `json:"group_name" xorm:"varchar(60)"` + Certificate string `json:"certificate" xorm:"text not null"` + PrivateKey string `json:"private_key" xorm:"text not null"` + SerialNumber string `json:"serial_number" xorm:"varchar(100) not null"` + NotAfter time.Time `json:"not_after" xorm:"datetime not null"` + Status int `json:"status" xorm:"int default 0"` + CreatedAt time.Time `json:"created_at" xorm:"datetime created"` +} + +var ( + clientCACert *x509.Certificate + clientCAKey *rsa.PrivateKey + caMutex sync.Mutex +) + +// 证书状态 +const ( + CertStatusActive = 0 // 有效 + CertStatusDisabled = 1 // 禁用 + CertStatusExpired = 2 // 过期 +) + +// 获取证书状态描述 +func (c *ClientCertData) GetStatusText() string { + switch c.GetStatus() { + case CertStatusActive: + return "有效" + case CertStatusDisabled: + return "禁用" + case CertStatusExpired: + return "过期" + default: + return "未知" + } +} + +// 获取证书状态 +func (c *ClientCertData) GetStatus() int { + return c.Status +} + +// 保存客户端证书 +func (c *ClientCertData) Save() error { + return Add(c) +} + +// 禁用证书 +func (c *ClientCertData) Disable() error { + return c.UpdateStatus(CertStatusDisabled) +} + +// 启用证书 +func (c *ClientCertData) Enable() error { + return c.UpdateStatus(CertStatusActive) +} + +// 删除证书记录 +func (c *ClientCertData) Delete() error { + return Del(c) +} + +// 切换证书状态 +func (c *ClientCertData) ChangeStatus() error { + switch c.Status { + case CertStatusActive: + return c.Disable() + case CertStatusDisabled: + return c.Enable() + } + return fmt.Errorf("证书已过期,无法切换状态") +} + +// 更新客户端证书状态 +func (c *ClientCertData) UpdateStatus(status int) error { + c.Status = status + if err := Set(c); err != nil { + return fmt.Errorf("更新客户端证书状态失败: %v", err) + } + return nil +} + +// 检查并更新证书状态为过期 +func (c *ClientCertData) CheckAndUpdateStatus() error { + if c.Status != CertStatusExpired && time.Now().After(c.NotAfter) { + if err := c.UpdateStatus(CertStatusExpired); err != nil { + return fmt.Errorf("更新证书状态为过期失败: %v", err) + } + base.Info("检测到证书过期,已更新状态:", c.Username) + } + return nil +} + +// 获取客户端证书列表 +func GetClientCertList(pageSize int, pageIndex int) ([]ClientCertData, int64, error) { + var certs []ClientCertData + session := GetXdb().NewSession() + defer session.Close() + total, err := FindAndCount(session, &certs, pageSize, pageIndex) + if err != nil { + return nil, 0, fmt.Errorf("获取客户端证书列表失败: %v", err) + } + return certs, total, nil +} + +// 获取客户端证书 +func GetClientCert(username string) (*ClientCertData, error) { + clientCert := &ClientCertData{ + Username: username, + } + err := One("Username", username, clientCert) + return clientCert, err +} + +// 生成客户端 CA 证书 +func GenerateClientCA() error { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "AnyLink Client CA"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 365 * 10), // 10年有效期 + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return err + } + + // 写入 CA 证书文件 + certOut, err := os.OpenFile(base.Cfg.ClientCertCAFile, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0600) + if err != nil { + return err + } + defer certOut.Close() + pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + + // 写入 CA 私钥文件 + keyOut, err := os.OpenFile(base.Cfg.ClientCertCAKeyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + defer keyOut.Close() + return pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)}) +} + +// 生成客户端证书并保存到数据库 +func GenerateClientCert(username string) (*ClientCertData, error) { + // 检查是否已存在证书记录 + _, err := GetClientCert(username) + if err != nil { + if !errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf("获取用户证书失败: %v", err) + } + } else { + // 用户已有证书记录,不允许重复生成 + return nil, fmt.Errorf("用户 %s 已存在证书,请先删除现有证书", username) + } + + // 确保客户端 CA 已加载 + if err := LoadClientCA(); err != nil { + return nil, fmt.Errorf("无法加载客户端 CA: %v", err) + } + + // 生成客户端私钥 + clientKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + + // 创建客户端证书模板 + template := x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{ + CommonName: username, + Organization: []string{"AnyLink VPN"}, + Country: []string{"CN"}, + Province: []string{"Beijing"}, + Locality: []string{"Beijing"}, + }, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour * 24 * 365), // 1年有效期 + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + DNSNames: []string{username}, + BasicConstraintsValid: true, + IsCA: false, + } + + // 签发客户端证书 + certDER, err := x509.CreateCertificate(rand.Reader, &template, clientCACert, &clientKey.PublicKey, clientCAKey) + if err != nil { + return nil, err + } + + // 编码证书和私钥为 PEM 格式 + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(clientKey)}) + + // 保存到数据库 + clientCertData := &ClientCertData{ + Username: username, + Certificate: string(certPEM), + PrivateKey: string(keyPEM), + SerialNumber: template.SerialNumber.String(), + NotAfter: template.NotAfter, + CreatedAt: time.Now(), + Status: CertStatusActive, // 初始状态为有效 + } + + if err := clientCertData.Save(); err != nil { + return nil, fmt.Errorf("保存客户端证书失败: %v", err) + } + + return clientCertData, nil +} + +// 生成 PKCS#12 格式证书文件 +func GenerateClientP12FromDB(username string, password string) ([]byte, error) { + // 从数据库获取证书 + clientCert, err := GetClientCert(username) + if err != nil { + return nil, err + } + // 检查并更新证书状态 + if err := clientCert.CheckAndUpdateStatus(); err != nil { + base.Error("检查并更新证书状态失败:", err) + } + // 检查证书状态 + if clientCert.GetStatus() != CertStatusActive { + return nil, fmt.Errorf("用户 %s 的证书状态为:%s", username, clientCert.GetStatusText()) + } + + // 确保客户端 CA 已加载 + if err := LoadClientCA(); err != nil { + return nil, fmt.Errorf("无法加载客户端 CA: %v", err) + } + + // 解析证书和私钥 + certBlock, _ := pem.Decode([]byte(clientCert.Certificate)) + cert, err := x509.ParseCertificate(certBlock.Bytes) + if err != nil { + return nil, err + } + + keyBlock, _ := pem.Decode([]byte(clientCert.PrivateKey)) + key, err := x509.ParsePKCS1PrivateKey(keyBlock.Bytes) + if err != nil { + return nil, err + } + + // 打包为 .p12 格式 + p12Data, err := pkcs12.Modern.Encode(key, cert, []*x509.Certificate{clientCACert}, password) + if err != nil { + return nil, err + } + + return p12Data, nil +} + +// 验证客户端证书 +func ValidateClientCert(cert *x509.Certificate, userAgent string) bool { + // 获取用户和证书信息 + user := &User{ + Username: cert.Subject.CommonName, + } + err := One("Username", user.Username, user) + if err != nil { + if errors.Is(err, ErrNotFound) { + base.Error("证书验证失败:用户不存在", cert.Subject.CommonName) + } else { + base.Error("证书验证失败:查询用户失败:", err) + } + return false + } + + // 检查用户状态是否启用 + if user.Status != 1 { + base.Error("证书验证失败:用户已禁用:", user.Username) + return false + } + + // 获取客户端证书记录 + clientCertData, err := GetClientCert(user.Username) + if err != nil { + base.Error("证书验证失败:获取客户端证书失败:", err) + return false + } + + // 检查证书状态 + if clientCertData.GetStatus() != CertStatusActive { + base.Error("证书验证失败:证书状态为", clientCertData.GetStatusText()) + return false + } + + // 检查证书是否过期 + if time.Now().After(cert.NotAfter) { + base.Error("证书验证失败:证书已过期:", cert.NotAfter) + return false + } + + // 验证证书指纹 + storedCertBlock, _ := pem.Decode([]byte(clientCertData.Certificate)) + storedCert, err := x509.ParseCertificate(storedCertBlock.Bytes) + if err != nil { + base.Error("证书验证失败:解析存储证书失败:", err) + return false + } + + // 比较证书的完整内容 + if !bytes.Equal(cert.Raw, storedCert.Raw) { + base.Error("证书验证失败:证书内容不匹配") + return false + } + + // 验证证书链 + verifyOptions := x509.VerifyOptions{ + Roots: LoadClientCAPool(), + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + if _, err := cert.Verify(verifyOptions); err != nil { + base.Error("证书验证失败:证书链验证失败:", err) + return false + } + // 检查扩展密钥用途 + hasClientAuth := slices.Contains(cert.ExtKeyUsage, x509.ExtKeyUsageClientAuth) + if !hasClientAuth { + base.Error("证书验证失败:证书缺少客户端认证扩展") + return false + } + + return true +} + +// 加载客户端 CA 证书池 +func LoadClientCAPool() *x509.CertPool { + if err := LoadClientCA(); err != nil { + return nil + } + caCertPool := x509.NewCertPool() + caCertPool.AddCert(clientCACert) + return caCertPool +} + +// 加载客户端 CA 证书和私钥 +func LoadClientCA() error { + caMutex.Lock() + defer caMutex.Unlock() + + // 如果证书已经加载到内存中,则直接返回 + if clientCACert != nil && clientCAKey != nil { + return nil + } + + caCertPEM, readErr := os.ReadFile(base.Cfg.ClientCertCAFile) + if readErr != nil { + base.Warn("无法读取客户端 CA 证书,请初始化CA:", readErr) + return fmt.Errorf("无法读取客户端 CA 证书,请初始化CA: %w", readErr) + } + caKeyPEM, readErr := os.ReadFile(base.Cfg.ClientCertCAKeyFile) + if readErr != nil { + return fmt.Errorf("无法读取客户端 CA 私钥: %w", readErr) + } + + caCertBlock, _ := pem.Decode(caCertPEM) + if caCertBlock == nil { + return errors.New("无法解析客户端 CA 证书 PEM 块") + } + + var parseErr error + clientCACert, parseErr = x509.ParseCertificate(caCertBlock.Bytes) + if parseErr != nil { + return fmt.Errorf("无法解析客户端 CA 证书: %w", parseErr) + } + + caKeyBlock, _ := pem.Decode(caKeyPEM) + if caKeyBlock == nil { + return errors.New("无法解析客户端 CA 私钥 PEM 块") + } + + var parseKeyErr error + clientCAKey, parseKeyErr = x509.ParsePKCS1PrivateKey(caKeyBlock.Bytes) + if parseKeyErr != nil { + // 解析为PKCS8 + pkcs8Key, pkcs8Err := x509.ParsePKCS8PrivateKey(caKeyBlock.Bytes) + if pkcs8Err != nil { + return fmt.Errorf("无法解析客户端 CA 私钥 (PKCS1 or PKCS8): %w", parseKeyErr) + } + var ok bool + clientCAKey, ok = pkcs8Key.(*rsa.PrivateKey) + if !ok { + return errors.New("解析私钥成功,但不是 RSA 类型") + } + } + + return nil +} diff --git a/server/dbdata/db.go b/server/dbdata/db.go index 764638d..675d11d 100644 --- a/server/dbdata/db.go +++ b/server/dbdata/db.go @@ -36,7 +36,7 @@ func initDb() { } // 初始化数据库 - err = xdb.Sync2(&User{}, &Setting{}, &Group{}, &IpMap{}, &AccessAudit{}, &Policy{}, &StatsNetwork{}, &StatsCpu{}, &StatsMem{}, &StatsOnline{}, &UserActLog{}) + err = xdb.Sync2(&User{}, &Setting{}, &Group{}, &IpMap{}, &AccessAudit{}, &Policy{}, &StatsNetwork{}, &StatsCpu{}, &StatsMem{}, &StatsOnline{}, &UserActLog{}, &ClientCertData{}) if err != nil { base.Fatal(err) } diff --git a/server/go.mod b/server/go.mod index 601825d..813f6d0 100644 --- a/server/go.mod +++ b/server/go.mod @@ -114,5 +114,6 @@ require ( gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + software.sslmate.com/src/go-pkcs12 v0.6.0 xorm.io/builder v0.3.13 // indirect ) diff --git a/server/go.sum b/server/go.sum index b0cce5a..50e5c22 100644 --- a/server/go.sum +++ b/server/go.sum @@ -418,6 +418,8 @@ modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw modernc.org/token v1.0.1 h1:A3qvTqOwexpfZZeyI0FeGPDlSWX5pjZu9hF4lU+EKWg= modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +software.sslmate.com/src/go-pkcs12 v0.6.0 h1:f3sQittAeF+pao32Vb+mkli+ZyT+VwKaD014qFGq6oU= +software.sslmate.com/src/go-pkcs12 v0.6.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= xorm.io/builder v0.3.13 h1:a3jmiVVL19psGeXx8GIurTp7p0IIgqeDmwhcR6BAOAo= xorm.io/builder v0.3.13/go.mod h1:aUW0S9eb9VCaPohFCH3j7czOx1PMW3i1HrSzbLYGBSE= xorm.io/xorm v1.3.9 h1:TUovzS0ko+IQ1XnNLfs5dqK1cJl1H5uHpWbWqAQ04nU= diff --git a/server/handler/link_auth.go b/server/handler/link_auth.go index a9c8b97..6d5da28 100644 --- a/server/handler/link_auth.go +++ b/server/handler/link_auth.go @@ -55,6 +55,19 @@ func LinkAuth(w http.ResponseWriter, r *http.Request) { return } base.Trace(fmt.Sprintf("%+v \n", cr)) + // 用户活动日志 + ua := &dbdata.UserActLog{ + Username: cr.Auth.Username, + GroupName: cr.GroupSelect, + RemoteAddr: r.RemoteAddr, + Status: dbdata.UserAuthSuccess, + DeviceType: cr.DeviceId.DeviceType, + PlatformVersion: cr.DeviceId.PlatformVersion, + } + sessionData := &AuthSession{ + ClientRequest: cr, + UserActLog: ua, + } // setCommonHeader(w) if cr.Type == "logout" { // 退出删除session信息 @@ -64,6 +77,25 @@ func LinkAuth(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) return } + // 检查客户端证书认证 + if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 { + clientCert := r.TLS.PeerCertificates[0] + username := clientCert.Subject.CommonName + + // 验证证书有效性和用户状态 + if dbdata.ValidateClientCert(clientCert, userAgent) { + // 证书认证成功,创建会话 + base.Info("用户通过证书认证:", username) + + ua.Username = username + ua.Info = "用户通过证书认证登录" + ua.Status = dbdata.UserConnected + dbdata.UserActLogIns.Add(*ua, userAgent) + + CreateSession(w, r, sessionData) + return + } + } if cr.Type == "init" { w.WriteHeader(http.StatusOK) @@ -84,22 +116,8 @@ func LinkAuth(w http.ResponseWriter, r *http.Request) { return } - // 用户活动日志 - ua := &dbdata.UserActLog{ - Username: cr.Auth.Username, - GroupName: cr.GroupSelect, - RemoteAddr: r.RemoteAddr, - Status: dbdata.UserAuthSuccess, - DeviceType: cr.DeviceId.DeviceType, - PlatformVersion: cr.DeviceId.PlatformVersion, - } - - sessionData := &AuthSession{ - ClientRequest: cr, - UserActLog: ua, - } // TODO 用户密码校验 - ext := map[string]interface{}{"mac_addr": cr.MacAddressList.MacAddress} + ext := map[string]any{"mac_addr": cr.MacAddressList.MacAddress} err = dbdata.CheckUser(cr.Auth.Username, cr.Auth.Password, cr.GroupSelect, ext) if err != nil { lockManager.UpdateLoginStatus(cr.Auth.Username, r.RemoteAddr, false) // 记录登录失败状态 diff --git a/server/handler/server.go b/server/handler/server.go index 4d9a409..57cf418 100644 --- a/server/handler/server.go +++ b/server/handler/server.go @@ -66,6 +66,8 @@ func startTls() { NextProtos: []string{"http/1.1"}, MinVersion: tls.VersionTLS12, CipherSuites: selectedCipherSuites, + ClientAuth: tls.VerifyClientCertIfGiven, // 验证客户端证书 + ClientCAs: dbdata.LoadClientCAPool(), // 加载客户端CA证书 GetCertificate: func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) { base.Trace("GetCertificate ServerName", chi.ServerName) return dbdata.GetCertificateBySNI(chi.ServerName) diff --git a/web/src/pages/set/Other.vue b/web/src/pages/set/Other.vue index 2e7ebea..66d7f89 100644 --- a/web/src/pages/set/Other.vue +++ b/web/src/pages/set/Other.vue @@ -2,13 +2,7 @@ - + @@ -19,11 +13,7 @@ - + @@ -36,31 +26,18 @@ - 保存 - + 保存 + 重置 - + - + 秒 请手动修改配置文件中的 audit_interval 参数后,再重启服务, @@ -68,13 +45,8 @@ - + 天 范围: 0 ~ 365天 , @@ -82,106 +54,50 @@ - + - 保存 - + 保存 + 重置 - + - + - - 证书文件 - - + + 证书文件 + + - - 私钥文件 - - + + 私钥文件 + + - 上传 - + 上传 + - + @@ -195,77 +111,99 @@ cloudflare - - + + - + - 申请 - + 申请 + 重置 + + + + + + + + + + + + 取消 + 确定生成 + + + + + 初始化客户端 CA + + + + 生成证书 + + + + + + + + + + + + {{ getStatusText(scope.row.status) }} + + + + + + 下载 + + {{ scope.row.status === 0 ? '禁用' : '启用' }} + + 删除 + + + + + + + - + - + - + - + @@ -273,29 +211,18 @@ - + - + - 保存 - + 保存 + 重置 @@ -339,19 +266,19 @@ export default { authToken: "", }, }, - customCert: {cert: "", key: ""}, + customCert: { cert: "", key: "" }, dataOther: {}, rules: { - host: {required: true, message: "请输入服务器地址", trigger: "blur"}, + host: { required: true, message: "请输入服务器地址", trigger: "blur" }, port: [ - {required: true, message: "请输入服务器端口", trigger: "blur"}, + { required: true, message: "请输入服务器端口", trigger: "blur" }, { type: "number", message: "请输入正确的服务器端口", trigger: ["blur", "change"], }, ], - issuer: {required: true, message: "请输入系统名称", trigger: "blur"}, + issuer: { required: true, message: "请输入系统名称", trigger: "blur" }, domain: { required: true, message: "请输入需要申请证书的域名", @@ -362,7 +289,7 @@ export default { message: "请输入申请证书的邮箱地址", trigger: "blur", }, - name: {required: true, message: "请选择域名服务商", trigger: "blur"}, + name: { required: true, message: "请选择域名服务商", trigger: "blur" }, }, certUpload: "/set/other/customcert", dnsProvider: { @@ -428,6 +355,17 @@ export default { }, ], }, + generateCertDialog: false, + generateForm: { + username: '' + }, + userList: [], + clientCertList: [], + pagination: { + current: 1, + size: 10, + total: 0 + } }; }, methods: { @@ -443,6 +381,9 @@ export default { case "letsCert": this.getletsCert(); break; + case "clientCert": + this.loadClientCertList(); + break; case "dataOther": this.getOther(); break; @@ -464,71 +405,257 @@ export default { }, getSmtp() { axios - .get("/set/other/smtp") - .then((resp) => { - let rdata = resp.data; - console.log(rdata); - if (rdata.code !== 0) { - this.$message.error(rdata.msg); - return; - } - this.dataSmtp = rdata.data; - }) - .catch((error) => { - this.$message.error("哦,请求出错"); - console.log(error); - }); + .get("/set/other/smtp") + .then((resp) => { + let rdata = resp.data; + console.log(rdata); + if (rdata.code !== 0) { + this.$message.error(rdata.msg); + return; + } + this.dataSmtp = rdata.data; + }) + .catch((error) => { + this.$message.error("哦,请求出错"); + console.log(error); + }); }, getAuditLog() { axios - .get("/set/other/audit_log") - .then((resp) => { - let rdata = resp.data; - console.log(rdata); - if (rdata.code !== 0) { - this.$message.error(rdata.msg); - return; - } - this.dataAuditLog = rdata.data; - }) - .catch((error) => { - this.$message.error("哦,请求出错"); - console.log(error); - }); + .get("/set/other/audit_log") + .then((resp) => { + let rdata = resp.data; + console.log(rdata); + if (rdata.code !== 0) { + this.$message.error(rdata.msg); + return; + } + this.dataAuditLog = rdata.data; + }) + .catch((error) => { + this.$message.error("哦,请求出错"); + console.log(error); + }); }, getletsCert() { axios - .get("/set/other/getcertset") - .then((resp) => { - let rdata = resp.data; - console.log(rdata); - if (rdata.code !== 0) { - this.$message.error(rdata.msg); - return; - } - this.letsCert = Object.assign({}, this.letsCert, rdata.data); - }) - .catch((error) => { - this.$message.error("哦,请求出错"); - console.log(error); - }); + .get("/set/other/getcertset") + .then((resp) => { + let rdata = resp.data; + console.log(rdata); + if (rdata.code !== 0) { + this.$message.error(rdata.msg); + return; + } + this.letsCert = Object.assign({}, this.letsCert, rdata.data); + }) + .catch((error) => { + this.$message.error("哦,请求出错"); + console.log(error); + }); + }, + // 初始化客户端 CA + initClientCA() { + this.$confirm('确定要初始化客户端 CA 吗?', '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }).then(() => { + axios.post('/set/client_cert/init_ca').then(resp => { + if (resp.data.code === 0) { + this.$message.success('客户端 CA 初始化成功'); + } else { + this.$message.error(resp.data.msg); + } + }); + }); + }, + + // 生成客户端证书 + generateClientCert() { + this.generateCertDialog = true; + this.generateForm.username = ''; + axios.get('/user/list', { + params: { + page_size: 100, + page_index: 1 + } + }).then(resp => { + if (resp.data.code === 0) { + this.userList = resp.data.data.datas || []; + } + }).catch(error => { + console.error('加载用户列表失败:', error); + this.$message.error('加载用户列表失败'); + }); + }, + confirmGenerateCert() { + if (!this.generateForm.username) { + this.$message.error('请选择或输入用户名'); + return; + } + + const formData = new FormData(); + formData.append('username', this.generateForm.username); + + axios.post('/set/client_cert/generate', formData).then(resp => { + if (resp.data.code === 0) { + this.$message.success('证书生成成功'); + this.generateCertDialog = false; + this.loadClientCertList(); + } else { + this.$message.error(resp.data.msg); + } + }); + }, + + downloadCert(row) { + this.$prompt('请输入证书密码,留空则不使用密码:', { + confirmButtonText: '下载', + cancelButtonText: '取消', + inputValue: '', + inputType: 'password', + inputPlaceholder: '留空则不使用密码', + }).then(({ value }) => { + const params = new URLSearchParams(); + params.append('username', row.username); + params.append('password', value || ''); + + axios({ + method: 'get', + url: '/set/client_cert/download?' + params.toString(), + responseType: 'blob' + }).then(response => { + const blob = new Blob([response.data], { type: 'application/x-pkcs12' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `${row.username}.p12`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + this.$message.success('证书下载成功'); + }).catch(error => { + if (error.response && error.response.data && error.response.data.msg) { + this.$message.error(error.response.data.msg); + } else { + this.$message.error('证书下载失败'); + } + }); + }).catch(() => { + this.$message.info('已取消下载'); + }); + }, + // 加载证书列表 + loadClientCertList() { + const params = { + page_size: this.pagination.size, + page_index: this.pagination.current + }; + + axios.get('/set/client_cert/list', { params }).then(resp => { + if (resp.data.code === 0) { + this.clientCertList = resp.data.data.list; + this.pagination.total = resp.data.data.total; + } + }); + }, + + // 分页处理 + handleSizeChange(val) { + this.pagination.size = val; + this.loadClientCertList(); + }, + + handleCurrentChange(val) { + this.pagination.current = val; + this.loadClientCertList(); + }, + + handleUserSelect(item) { + this.generateForm.username = item.username; + }, + // 日期格式化 + dateFormat(row, column, cellValue) { + return new Date(cellValue).toLocaleString(); + }, + // 获取状态文本 + getStatusText(status) { + const statusMap = { + 0: '启用', + 1: '禁用', + 2: '过期' + }; + return statusMap[status] || '未知'; + }, + + // 获取状态类型(用于标签颜色) + getStatusType(status) { + const typeMap = { + 0: 'success', // 启用 - 绿色 + 1: 'warning', // 禁用 - 橙色 + 2: 'danger' // 过期 - 红色 + }; + return typeMap[status] || ''; + }, + // 切换证书状态 + changeCertStatus(row) { + const action = row.status === 0 ? '禁用' : '启用'; + this.$confirm(`确定要${action}用户 ${row.username} 的证书吗?`, '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }).then(() => { + const formData = new FormData(); + formData.append('username', row.username); + + axios.post('/set/client_cert/changecertstatus', formData).then(resp => { + if (resp.data.code === 0) { + this.$message.success(`证书${action}成功`); + this.loadClientCertList(); + } else { + this.$message.error(resp.data.msg); + } + }); + }); + }, + // 删除证书 + deleteCert(row) { + this.$confirm(`确定要删除用户 ${row.username} 的证书吗?`, '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning' + }).then(() => { + const formData = new FormData(); + formData.append('username', row.username); + + axios.post('/set/client_cert/delete', formData).then(resp => { + if (resp.data.code === 0) { + this.$message.success('证书删除成功'); + this.loadClientCertList(); + } else { + this.$message.error(resp.data.msg); + } + }); + }); }, getOther() { axios - .get("/set/other") - .then((resp) => { - let rdata = resp.data; - console.log(rdata); - if (rdata.code !== 0) { - this.$message.error(rdata.msg); - return; - } - this.dataOther = rdata.data; - }) - .catch((error) => { - this.$message.error("哦,请求出错"); - console.log(error); - }); + .get("/set/other") + .then((resp) => { + let rdata = resp.data; + console.log(rdata); + if (rdata.code !== 0) { + this.$message.error(rdata.msg); + return; + } + this.dataOther = rdata.data; + }) + .catch((error) => { + this.$message.error("哦,请求出错"); + console.log(error); + }); }, submitForm(formName) { this.$refs[formName].validate((valid) => { @@ -550,16 +677,16 @@ export default { break; case "dataAuditLog": axios - .post("/set/other/audit_log/edit", this.dataAuditLog) - .then((resp) => { - var rdata = resp.data; - console.log(rdata); - if (rdata.code === 0) { - this.$message.success(rdata.msg); - } else { - this.$message.error(rdata.msg); - } - }); + .post("/set/other/audit_log/edit", this.dataAuditLog) + .then((resp) => { + var rdata = resp.data; + console.log(rdata); + if (rdata.code === 0) { + this.$message.success(rdata.msg); + } else { + this.$message.error(rdata.msg); + } + }); break; case "letsCert": var loading = this.$loading({
请手动修改配置文件中的 audit_interval 参数后,再重启服务, @@ -68,13 +45,8 @@
范围: 0 ~ 365天 , @@ -82,106 +54,50 @@