Files
opsx-vpn/backend/internal/api/identity.go
T
opsx ef7eb378d2 feat: 新增 GRE 隧道协议,管理端口调整为 18080
- backend: 新增 GRE 隧道实现(ip tunnel 点对点互联),binmgr 支持 iproute2
- backend: api/identity 调整,各协议 service(gre/ikev2/ipsec/l2tp/openvpn)统一改动
- backend: OpenVPN 认证辅助与用户口令同步逻辑完善
- frontend: 协议图标/向导/编辑器支持 GRE,VPN 用户页重构(234 行)
- frontend: 新增 RouteListInput 路由列表输入组件,Dashboard/Layout 优化
- i18n: 中英文文案同步更新
- test: 新增 binmgr/common/gre 单测,openvpn 测试补充
- docs: README 补充 GRE 说明,默认监听端口 8080 → 18080
- chore: .gitignore 增加 opsx-vpn.previous(构建备份二进制)
2026-10-20 20:58:58 +08:00

1113 lines
34 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"crypto/rand"
"crypto/subtle"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
"vpn-platform/backend/internal/ldaputil"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service/ikev2"
"vpn-platform/backend/internal/service/l2tp"
"vpn-platform/backend/internal/service/openvpn"
"vpn-platform/backend/internal/store"
)
type pendingMFA struct {
secret string
backups []string
expires time.Time
}
var (
pendingMFAMu sync.Mutex
pendingMFAs = map[uint]pendingMFA{}
)
func requireRole(roles ...string) gin.HandlerFunc {
return func(c *gin.Context) {
role := c.GetString("role")
for _, allowed := range roles {
if role == allowed {
c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "权限不足"})
}
}
func (s *Server) authenticateUser(username, password string) (*model.User, error) {
var user model.User
err := store.DB.Where("username = ?", username).First(&user).Error
if err == nil && user.Source != "ldap" {
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil {
return &user, nil
}
return nil, fmt.Errorf("invalid password")
}
if user.Source == "ldap" || err != nil {
return s.authenticateLDAP(username, password)
}
return nil, err
}
func ldapSettings() (*model.LDAPConfig, error) {
var cfg model.LDAPConfig
if err := store.DB.First(&cfg).Error; err != nil || !cfg.Enabled {
return nil, fmt.Errorf("LDAP 未启用")
}
plain, err := store.DecryptSecret(cfg.BindPassword)
if err != nil {
return nil, fmt.Errorf("LDAP 配置密钥无效")
}
cfg.BindPassword = plain
if cfg.UsernameAttribute == "" {
cfg.UsernameAttribute = "uid"
}
if cfg.UserFilter == "" {
cfg.UserFilter = "(objectClass=inetOrgPerson)"
}
return &cfg, nil
}
func ldapConnect(cfg *model.LDAPConfig) (*ldap.Conn, error) {
dialURL, err := ldaputil.DialURL(cfg.URL)
if err != nil {
return nil, err
}
conn, err := ldap.DialURL(dialURL)
if err != nil {
return nil, err
}
if cfg.StartTLS && strings.HasPrefix(dialURL, "ldap://") {
host := ldaputil.Hostname(dialURL)
if err := conn.StartTLS(&tls.Config{ServerName: host}); err != nil {
conn.Close()
return nil, fmt.Errorf("StartTLS 升级失败: %w", err)
}
}
if cfg.BindDN != "" {
if err := conn.Bind(cfg.BindDN, cfg.BindPassword); err != nil {
conn.Close()
return nil, err
}
}
return conn, nil
}
func (s *Server) authenticateLDAP(username, password string) (*model.User, error) {
cfg, err := ldapSettings()
if err != nil || password == "" {
return nil, fmt.Errorf("LDAP 认证不可用")
}
conn, err := ldapConnect(cfg)
if err != nil {
return nil, err
}
defer conn.Close()
filter := cfg.UserFilter
if strings.Contains(filter, "%s") {
filter = fmt.Sprintf(filter, ldap.EscapeFilter(username))
} else {
filter = fmt.Sprintf("(&%s(%s=%s))", filter, cfg.UsernameAttribute, ldap.EscapeFilter(username))
}
result, err := conn.Search(ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 5, false, filter, []string{"dn", cfg.UsernameAttribute, "cn", "mail"}, nil))
if err != nil || len(result.Entries) != 1 {
return nil, fmt.Errorf("LDAP 用户不存在")
}
entry := result.Entries[0]
if err := conn.Bind(entry.DN, password); err != nil {
return nil, fmt.Errorf("LDAP 密码错误")
}
var user model.User
err = store.DB.Where("username = ?", username).First(&user).Error
if err != nil {
user = model.User{Username: username, Email: entry.GetAttributeValue("mail"), Source: "ldap", Role: "viewer", Enabled: true}
if err := store.DB.Create(&user).Error; err != nil {
return nil, err
}
}
return &user, nil
}
func verifyMFA(user *model.User, code string) error {
var device model.MFADevice
if err := store.DB.Where("user_id = ? AND enabled = ?", user.ID, true).First(&device).Error; err != nil {
return nil
}
code = strings.TrimSpace(code)
if code == "" {
return fmt.Errorf("需要有效的 MFA 验证码")
}
secret, err := store.DecryptSecret(device.Secret)
if err != nil {
return fmt.Errorf("MFA 配置无效")
}
if totp.Validate(code, secret) {
return nil
}
// 备用码一次性消费(丢失验证器时恢复)
if consumeAdminBackupCode(&device, code) {
store.Audit(user.Username, "mfa_backup_used", user.Username, "使用备用码登录")
return nil
}
return fmt.Errorf("需要有效的 MFA 验证码")
}
func consumeAdminBackupCode(device *model.MFADevice, code string) bool {
plain, err := store.DecryptSecret(device.BackupCodes)
if err != nil || plain == "" {
return false
}
var codes []string
if json.Unmarshal([]byte(plain), &codes) != nil {
return false
}
normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(code), " ", ""))
for i, bc := range codes {
if subtle.ConstantTimeCompare([]byte(strings.ToUpper(bc)), []byte(normalized)) == 1 {
codes = append(codes[:i], codes[i+1:]...)
raw, _ := json.Marshal(codes)
if enc, err := store.EncryptSecret(string(raw)); err == nil {
store.DB.Model(device).Update("backup_codes", enc)
}
return true
}
}
return false
}
func (s *Server) mfaStatus(c *gin.Context) {
var device model.MFADevice
err := store.DB.Where("user_id = ?", c.GetUint("user_id")).First(&device).Error
c.JSON(200, gin.H{"enabled": err == nil && device.Enabled})
}
func backupCodes() []string {
codes := make([]string, 10)
for i := range codes {
raw := make([]byte, 5)
_, _ = rand.Read(raw)
codes[i] = fmt.Sprintf("%X", raw)
}
return codes
}
func (s *Server) mfaSetup(c *gin.Context) {
var user model.User
if err := store.DB.First(&user, c.GetUint("user_id")).Error; err != nil {
c.JSON(404, gin.H{"error": "用户不存在"})
return
}
key, err := totp.Generate(totp.GenerateOpts{Issuer: "OPSX VPN", AccountName: user.Username})
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
pendingMFAMu.Lock()
pendingMFAs[user.ID] = pendingMFA{secret: key.Secret(), backups: backupCodes(), expires: time.Now().Add(15 * time.Minute)}
pendingMFAMu.Unlock()
c.JSON(200, gin.H{"otpauth_url": key.URL(), "secret": key.Secret(), "expires_in": 900})
}
func (s *Server) mfaEnable(c *gin.Context) {
var req struct {
Code string `json:"code"`
}
if c.ShouldBindJSON(&req) != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
userID := c.GetUint("user_id")
pendingMFAMu.Lock()
pending, ok := pendingMFAs[userID]
delete(pendingMFAs, userID)
pendingMFAMu.Unlock()
if !ok || time.Now().After(pending.expires) || !totp.Validate(req.Code, pending.secret) {
c.JSON(400, gin.H{"error": "验证码无效或设置已过期"})
return
}
secret, _ := store.EncryptSecret(pending.secret)
raw, _ := json.Marshal(pending.backups)
backups, _ := store.EncryptSecret(string(raw))
device := model.MFADevice{UserID: userID, Secret: secret, BackupCodes: backups, Enabled: true}
store.DB.Where("user_id = ?", userID).Assign(device).FirstOrCreate(&device)
store.Audit(c.GetString("username"), "enable_mfa", c.GetString("username"), "")
c.JSON(200, gin.H{"ok": true, "backup_codes": pending.backups})
}
func (s *Server) mfaDisable(c *gin.Context) {
store.DB.Where("user_id = ?", c.GetUint("user_id")).Delete(&model.MFADevice{})
store.Audit(c.GetString("username"), "disable_mfa", c.GetString("username"), "")
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) listUsers(c *gin.Context) {
var users []model.User
store.DB.Order("id").Find(&users)
c.JSON(200, users)
}
func (s *Server) createUser(c *gin.Context) {
var req struct {
Username, Password, Email, Role string
Enabled bool `json:"enabled"`
}
if c.ShouldBindJSON(&req) != nil || req.Username == "" || len(req.Password) < 8 {
c.JSON(400, gin.H{"error": "用户名及至少 8 位的密码必填"})
return
}
if req.Role == "" {
req.Role = "viewer"
}
if req.Role != "admin" && req.Role != "operator" && req.Role != "viewer" {
c.JSON(400, gin.H{"error": "角色无效"})
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
user := model.User{Username: req.Username, Password: string(hash), Email: req.Email, Role: req.Role, Source: "local", Enabled: req.Enabled}
if err := store.DB.Create(&user).Error; err != nil {
c.JSON(400, gin.H{"error": "用户创建失败:" + err.Error()})
return
}
store.Audit(c.GetString("username"), "create_user", user.Username, "")
c.JSON(200, user)
}
func (s *Server) updateUser(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.User
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "用户不存在"})
return
}
var req struct {
Email, Role, Password string
Enabled *bool `json:"enabled"`
}
if c.ShouldBindJSON(&req) != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
if req.Email != "" {
user.Email = req.Email
}
if req.Role != "" {
user.Role = req.Role
}
if req.Enabled != nil {
user.Enabled = *req.Enabled
}
if req.Password != "" {
if len(req.Password) < 8 {
c.JSON(400, gin.H{"error": "密码至少 8 位"})
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
user.Password = string(hash)
user.MustChangePassword = true // 管理员重置后强制改密
}
store.DB.Save(&user)
c.JSON(200, user)
}
func (s *Server) deleteUser(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
if uint(id) == c.GetUint("user_id") {
c.JSON(400, gin.H{"error": "不能删除当前用户"})
return
}
store.DB.Delete(&model.User{}, id)
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) getLDAPConfig(c *gin.Context) {
var cfg model.LDAPConfig
if store.DB.First(&cfg).Error != nil {
c.JSON(200, gin.H{"enabled": false})
return
}
c.JSON(200, cfg)
}
func (s *Server) saveLDAPConfig(c *gin.Context) {
var req struct {
URL string `json:"url"`
BindDN string `json:"bind_dn"`
BindPassword string `json:"bind_password"`
BaseDN string `json:"base_dn"`
UserFilter string `json:"user_filter"`
UsernameAttribute string `json:"username_attribute"`
StartTLS bool `json:"starttls"`
Enabled bool `json:"enabled"`
}
if c.ShouldBindJSON(&req) != nil || (req.Enabled && (req.URL == "" || req.BaseDN == "")) {
c.JSON(400, gin.H{"error": "LDAP 地址与 Base DN 必填"})
return
}
if req.Enabled {
if _, err := ldaputil.DialURL(req.URL); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
}
var cfg model.LDAPConfig
store.DB.First(&cfg)
cfg.URL, cfg.BindDN, cfg.BaseDN, cfg.UserFilter, cfg.UsernameAttribute, cfg.StartTLS, cfg.Enabled =
req.URL, req.BindDN, req.BaseDN, req.UserFilter, req.UsernameAttribute, req.StartTLS, req.Enabled
if req.BindPassword != "" {
cfg.BindPassword, _ = store.EncryptSecret(req.BindPassword)
}
store.DB.Save(&cfg)
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) testLDAP(c *gin.Context) {
var req struct {
URL string `json:"url"`
BindDN string `json:"bind_dn"`
BindPassword string `json:"bind_password"`
StartTLS bool `json:"starttls"`
}
if c.ShouldBindJSON(&req) != nil || strings.TrimSpace(req.URL) == "" {
c.JSON(400, gin.H{"error": "LDAP 服务器地址必填"})
return
}
cfg := &model.LDAPConfig{URL: strings.TrimSpace(req.URL), BindDN: strings.TrimSpace(req.BindDN), BindPassword: req.BindPassword, StartTLS: req.StartTLS}
if cfg.BindPassword == "" {
var saved model.LDAPConfig
if store.DB.First(&saved).Error == nil && saved.BindPassword != "" {
plain, err := store.DecryptSecret(saved.BindPassword)
if err != nil {
c.JSON(400, gin.H{"error": "LDAP 配置密钥无效"})
return
}
cfg.BindPassword = plain
}
}
conn, err := ldapConnect(cfg)
if err != nil {
c.JSON(400, gin.H{"error": "LDAP 连接失败: " + err.Error()})
return
}
conn.Close()
c.JSON(200, gin.H{"ok": true})
}
func (s *Server) listVPNUsers(c *gin.Context) {
var users []model.VPNUser
q := store.DB.Order("id desc")
if id := c.Query("tunnel_id"); id != "" {
q = q.Where("tunnel_id = ?", id)
}
q.Find(&users)
c.JSON(200, users)
}
func (s *Server) createVPNUser(c *gin.Context) {
var req struct {
TunnelID uint `json:"tunnel_id"`
PlatformUserID uint `json:"platform_user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Source string `json:"source"`
LDAPDN string `json:"ldap_dn"`
Password string `json:"password"`
Enabled *bool `json:"enabled"`
}
if c.ShouldBindJSON(&req) != nil || req.TunnelID == 0 || (req.Username == "" && req.PlatformUserID == 0) {
c.JSON(400, gin.H{"error": "隧道和用户名必填"})
return
}
if req.Source == "" {
req.Source = "local"
}
if req.Source != "local" && req.Source != "ldap" {
c.JSON(400, gin.H{"error": "来源必须为 local 或 ldap"})
return
}
// 手工创建仅支持独立本地拨号账号。LDAP 身份必须从用户管理批量导入,
// 这样可以复用平台身份源,并由服务端查找、固定对应的 LDAP DN。
if req.PlatformUserID == 0 && req.Source == "ldap" {
c.JSON(400, gin.H{"error": "LDAP 用户请通过“批量导入”从用户管理导入"})
return
}
t, err := store.GetTunnel(req.TunnelID)
if err != nil {
c.JSON(404, gin.H{"error": "隧道不存在"})
return
}
switch t.Protocol {
case "openvpn", "l2tp", "ikev2":
default:
c.JSON(400, gin.H{"error": "该协议不支持拨号用户(支持 OpenVPN / L2TP / IKEv2)"})
return
}
if req.PlatformUserID != 0 {
if t.Protocol != "openvpn" {
c.JSON(400, gin.H{"error": "平台用户导入仅支持 OpenVPN;L2TP/IKEv2 需要独立拨号密码"})
return
}
var account model.User
if err := store.DB.First(&account, req.PlatformUserID).Error; err != nil {
c.JSON(404, gin.H{"error": "平台用户不存在"})
return
}
if !account.Enabled {
c.JSON(400, gin.H{"error": "平台用户已禁用,无法导入"})
return
}
req.Username, req.Email, req.Source = account.Username, account.Email, account.Source
if req.DisplayName == "" {
req.DisplayName = account.Username
}
if req.Source == "ldap" {
dn, err := findLDAPUserDN(account.Username)
if err != nil {
c.JSON(400, gin.H{"error": "LDAP 平台用户导入失败: " + err.Error()})
return
}
req.LDAPDN = dn
}
}
user := model.VPNUser{TunnelID: req.TunnelID, Username: req.Username, DisplayName: req.DisplayName, Email: req.Email, Source: req.Source, LDAPDN: req.LDAPDN, Enabled: true}
if req.Enabled != nil {
user.Enabled = *req.Enabled
}
if user.Source == "local" {
if req.PlatformUserID != 0 {
var account model.User
if err := store.DB.First(&account, req.PlatformUserID).Error; err != nil || account.Password == "" {
c.JSON(400, gin.H{"error": "平台本地用户密码不可用"})
return
}
user.Password = account.Password
} else if len(req.Password) < 8 {
c.JSON(400, gin.H{"error": "本地拨号用户密码至少 8 位"})
return
} else {
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
user.Password = string(hash)
// L2TP/IKEv2 需要可逆拨号密钥;OpenVPN 仅用 bcrypt,同时存一份便于统一导出说明。
if enc, err := store.EncryptSecret(req.Password); err == nil {
user.DialSecret = enc
}
}
} else if user.LDAPDN == "" {
c.JSON(400, gin.H{"error": "LDAP 拨号用户必须填写 LDAP DN"})
return
}
if err := store.DB.Create(&user).Error; err != nil {
c.JSON(400, gin.H{"error": "创建失败:" + err.Error()})
return
}
_ = refreshDialAuth(t)
c.JSON(200, user)
}
// importPlatformVPNUsers imports selected platform local/LDAP accounts into an
// OpenVPN tunnel. L2TP/IKEv2 use CHAP/EAP and require a dedicated dial-in
// secret, so they intentionally remain manual-user only.
func (s *Server) importPlatformVPNUsers(c *gin.Context) {
var req struct {
TunnelID uint `json:"tunnel_id"`
PlatformUserIDs []uint `json:"platform_user_ids"`
}
if c.ShouldBindJSON(&req) != nil || req.TunnelID == 0 || len(req.PlatformUserIDs) == 0 {
c.JSON(400, gin.H{"error": "请选择目标 OpenVPN 隧道和至少一个平台用户"})
return
}
t, err := store.GetTunnel(req.TunnelID)
if err != nil {
c.JSON(404, gin.H{"error": "隧道不存在"})
return
}
if t.Protocol != "openvpn" {
c.JSON(400, gin.H{"error": "平台用户批量导入仅支持 OpenVPN;L2TP/IKEv2 请创建独立拨号账号"})
return
}
var accounts []model.User
if err := store.DB.Where("id IN ? AND enabled = ?", req.PlatformUserIDs, true).Find(&accounts).Error; err != nil {
c.JSON(500, gin.H{"error": "读取平台用户失败: " + err.Error()})
return
}
created, skipped := 0, []string{}
for _, account := range accounts {
var exists int64
store.DB.Model(&model.VPNUser{}).Where("tunnel_id = ? AND username = ?", t.ID, account.Username).Count(&exists)
if exists > 0 {
skipped = append(skipped, account.Username+"(已存在)")
continue
}
user := model.VPNUser{TunnelID: t.ID, Username: account.Username, DisplayName: account.Username, Email: account.Email, Source: account.Source, Enabled: true}
if account.Source == "ldap" {
dn, err := findLDAPUserDN(account.Username)
if err != nil {
skipped = append(skipped, account.Username+"(LDAP DN 获取失败)")
continue
}
user.LDAPDN = dn
} else {
user.Password = account.Password
}
if err := store.DB.Create(&user).Error; err != nil {
skipped = append(skipped, account.Username+"(创建失败)")
continue
}
created++
}
store.Audit(c.GetString("username"), "import_vpn_users", t.Name, fmt.Sprintf("成功 %d,跳过 %d", created, len(skipped)))
c.JSON(200, gin.H{"created": created, "skipped": skipped})
}
func (s *Server) batchDeleteVPNUsers(c *gin.Context) {
var req struct {
IDs []uint `json:"ids"`
}
if c.ShouldBindJSON(&req) != nil || len(req.IDs) == 0 {
c.JSON(400, gin.H{"error": "请选择要删除的拨号用户"})
return
}
var users []model.VPNUser
store.DB.Where("id IN ?", req.IDs).Find(&users)
tunnels := map[uint]bool{}
for _, user := range users {
revokeOpenVPNCert(&user)
tunnels[user.TunnelID] = true
}
if err := store.DB.Delete(&model.VPNUser{}, req.IDs).Error; err != nil {
c.JSON(500, gin.H{"error": "批量删除失败: " + err.Error()})
return
}
for tunnelID := range tunnels {
if t, err := store.GetTunnel(tunnelID); err == nil {
_ = refreshDialAuth(t)
}
}
store.Audit(c.GetString("username"), "batch_delete_vpn_users", "VPN 拨号用户", fmt.Sprintf("删除 %d 个", len(users)))
c.JSON(200, gin.H{"deleted": len(users)})
}
// findLDAPUserDN resolves a platform LDAP account to the DN required by the
// OpenVPN authentication helper. The password is never read or stored here.
func findLDAPUserDN(username string) (string, error) {
cfg, err := ldapSettings()
if err != nil {
return "", err
}
conn, err := ldapConnect(cfg)
if err != nil {
return "", err
}
defer conn.Close()
filter := cfg.UserFilter
if strings.Contains(filter, "%s") {
filter = fmt.Sprintf(filter, ldap.EscapeFilter(username))
} else {
filter = fmt.Sprintf("(&%s(%s=%s))", filter, cfg.UsernameAttribute, ldap.EscapeFilter(username))
}
result, err := conn.Search(ldap.NewSearchRequest(cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 1, 5, false, filter, []string{"dn"}, nil))
if err != nil || len(result.Entries) != 1 {
return "", fmt.Errorf("未找到 LDAP 用户 %q", username)
}
return result.Entries[0].DN, nil
}
func (s *Server) updateVPNUser(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
var req struct {
DisplayName, Email, Password string
Enabled *bool `json:"enabled"`
}
if c.ShouldBindJSON(&req) != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
user.DisplayName, user.Email = req.DisplayName, req.Email
if req.Enabled != nil {
if !*req.Enabled && user.Enabled {
revokeOpenVPNCert(&user)
}
user.Enabled = *req.Enabled
}
if req.Password != "" {
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
user.Password = string(hash)
if enc, err := store.EncryptSecret(req.Password); err == nil {
user.DialSecret = enc
}
}
store.DB.Save(&user)
if t, err := store.GetTunnel(user.TunnelID); err == nil {
_ = refreshDialAuth(t)
}
c.JSON(200, user)
}
func (s *Server) deleteVPNUser(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error == nil {
revokeOpenVPNCert(&user)
tunnelID := user.TunnelID
store.DB.Delete(&model.VPNUser{}, id)
if t, err := store.GetTunnel(tunnelID); err == nil {
_ = refreshDialAuth(t)
}
c.JSON(200, gin.H{"ok": true})
return
}
store.DB.Delete(&model.VPNUser{}, id)
c.JSON(200, gin.H{"ok": true})
}
func revokeOpenVPNCert(user *model.VPNUser) {
if user.Certificate == "" {
return
}
t, err := store.GetTunnel(user.TunnelID)
if err != nil || t.Protocol != "openvpn" {
return
}
if err := openvpn.New().RevokeClientCertificate(t, user); err != nil {
store.Audit("system", "revoke_cert_failed", user.Username, err.Error())
}
}
// vpnUserMFASetup 为 OpenVPN 拨号用户生成 TOTP 密钥(待确认)。
func (s *Server) vpnUserMFASetup(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
t, err := store.GetTunnel(user.TunnelID)
if err != nil || t.Protocol != "openvpn" {
c.JSON(400, gin.H{"error": "仅 OpenVPN 拨号用户支持 MFA"})
return
}
// TOTP 标准参数(RFC 6238):SHA1、30s、6 位、base32,兼容主流 Authenticator
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "OPSX-OpenVPN",
AccountName: fmt.Sprintf("%s@%s", user.Username, t.Name),
Period: 30,
Digits: otp.DigitsSix,
Algorithm: otp.AlgorithmSHA1,
})
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
pendingMFAMu.Lock()
// 用 user_id 偏移避免与管理端 MFA 冲突:拨号用户用高位 key 空间
pendingMFAs[vpnUserPendingKey(user.ID)] = pendingMFA{
secret: key.Secret(), backups: backupCodes(), expires: time.Now().Add(15 * time.Minute),
}
pendingMFAMu.Unlock()
c.JSON(200, gin.H{
"otpauth_url": key.URL(),
"secret": key.Secret(),
"period": 30,
"digits": 6,
"algorithm": "SHA1",
"encoding": "base32",
"expires_in": 900,
"hint": "兼容主流 Authenticator 应用:扫描二维码或手动输入 base32 密钥",
})
}
func vpnUserPendingKey(vpnUserID uint) uint {
// 管理端 MFA 用真实 user_id;拨号 MFA pending 用高位偏移隔离。
return 1_000_000_000 + vpnUserID
}
func (s *Server) vpnUserMFAEnable(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
var req struct {
Code string `json:"code"`
}
if c.ShouldBindJSON(&req) != nil || req.Code == "" {
c.JSON(400, gin.H{"error": "请输入验证码"})
return
}
pendingMFAMu.Lock()
pending, ok := pendingMFAs[vpnUserPendingKey(user.ID)]
delete(pendingMFAs, vpnUserPendingKey(user.ID))
pendingMFAMu.Unlock()
if !ok || time.Now().After(pending.expires) || !totp.Validate(strings.TrimSpace(req.Code), pending.secret) {
c.JSON(400, gin.H{"error": "验证码无效或设置已过期,请重新扫描二维码"})
return
}
secret, err := store.EncryptSecret(pending.secret)
if err != nil {
c.JSON(500, gin.H{"error": "加密 MFA 密钥失败"})
return
}
raw, _ := json.Marshal(pending.backups)
backups, err := store.EncryptSecret(string(raw))
if err != nil {
c.JSON(500, gin.H{"error": "加密备用码失败"})
return
}
user.MFASecret = secret
user.MFABackupCodes = backups
user.MFAEnabled = true
if err := store.DB.Save(&user).Error; err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "enable_vpn_user_mfa", user.Username, "")
c.JSON(200, gin.H{"ok": true, "backup_codes": pending.backups, "mfa_enabled": true})
}
func (s *Server) vpnUserMFADisable(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
store.DB.Model(&user).Updates(map[string]interface{}{
"mfa_enabled": false, "mfa_secret": "", "mfa_backup_codes": "",
"mfa_pin": "", "mfa_has_pin": false, "mfa_fail_count": 0, "mfa_locked_until": nil,
"mfa_last_counter": 0, "mfa_last_used_at": nil,
})
store.Audit(c.GetString("username"), "disable_vpn_user_mfa", user.Username, "")
c.JSON(200, gin.H{"ok": true, "mfa_enabled": false})
}
// vpnUserMFAStatus OTP Manager:查看拨号用户 MFA 状态。
func (s *Server) vpnUserMFAStatus(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
locked := user.MFALockedUntil != nil && time.Now().Before(*user.MFALockedUntil)
c.JSON(200, gin.H{
"mfa_enabled": user.MFAEnabled,
"mfa_has_pin": user.MFAHasPin,
"mfa_fail_count": user.MFAFailCount,
"mfa_locked": locked,
"mfa_locked_until": user.MFALockedUntil,
"mfa_last_used_at": user.MFALastUsedAt,
"compatible": "TOTP 标准(兼容 Google/Microsoft Authenticator,sha1:base32:6:30s)",
})
}
// vpnUserMFAPin 设置或清除静态 PIN(连接时第二因子 = PIN+OTP)。
func (s *Server) vpnUserMFAPin(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
if !user.MFAEnabled {
c.JSON(400, gin.H{"error": "请先启用 MFA 再设置 PIN"})
return
}
var req struct {
Pin string `json:"pin"` // 空字符串表示清除
}
if c.ShouldBindJSON(&req) != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
if err := openvpn.SetUserMFAPin(&user, req.Pin); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "set_vpn_user_mfa_pin", user.Username, "")
c.JSON(200, gin.H{"ok": true, "mfa_has_pin": user.MFAHasPin})
}
// vpnUserMFABackupCodes 重新生成备用码。
func (s *Server) vpnUserMFABackupCodes(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
codes, err := openvpn.RegenerateBackupCodes(&user)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "regen_vpn_user_mfa_backup", user.Username, "")
c.JSON(200, gin.H{"ok": true, "backup_codes": codes})
}
// vpnUserMFAUnlock 清除 MFA 锁定(失败次数过多后)。
func (s *Server) vpnUserMFAUnlock(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
if err := openvpn.UnlockUserMFA(&user); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
store.Audit(c.GetString("username"), "unlock_vpn_user_mfa", user.Username, "")
c.JSON(200, gin.H{"ok": true})
}
// vpnUserOTPSecretsLine 导出 TOTP 密钥行(标准 otp-secrets 格式,便于迁移与备份)。
func (s *Server) vpnUserOTPSecretsLine(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var user model.VPNUser
if store.DB.First(&user, id).Error != nil {
c.JSON(404, gin.H{"error": "拨号用户不存在"})
return
}
line, err := openvpn.GenerateOTPSecretsLine(&user)
if err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{
"line": line,
"format": "user server type:hash:encoding:key:pin:udid client",
"note": "标准 otp-secrets 格式密钥行;密钥敏感,请妥善保管,切勿泄露",
})
}
// refreshDialAuth 在拨号用户变更后重写 L2TP/IKEv2 认证文件。
func refreshDialAuth(t *model.Tunnel) error {
if t == nil {
return nil
}
switch t.Protocol {
case "l2tp":
return l2tp.New().RefreshAuth(t)
case "ikev2":
return ikev2.New().RefreshAuth(t)
}
return nil
}
// ---- 用户批量导入 / LDAP 导入 ----
// listLDAPUsers 浏览 LDAP 目录中的用户列表,供导入选择。复用 ldapSettings + ldapConnect。
func (s *Server) listLDAPUsers(c *gin.Context) {
cfg, err := ldapSettings()
if err != nil {
c.JSON(400, gin.H{"error": "LDAP 未启用或配置不完整:" + err.Error()})
return
}
conn, err := ldapConnect(cfg)
if err != nil {
c.JSON(500, gin.H{"error": "LDAP 连接失败:" + err.Error()})
return
}
defer conn.Close()
// 搜索 BaseDN 下所有匹配用户过滤器的条目
attrs := []string{"dn", cfg.UsernameAttribute, "cn", "mail"}
result, err := conn.Search(ldap.NewSearchRequest(
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
cfg.UserFilter, attrs, nil,
))
if err != nil {
c.JSON(500, gin.H{"error": "LDAP 搜索失败:" + err.Error()})
return
}
// 查询本地已有的 LDAP 用户名,标记已导入
var existingNames []string
store.DB.Model(&model.User{}).Where("source = ?", "ldap").Pluck("username", &existingNames)
existingMap := map[string]bool{}
for _, n := range existingNames {
existingMap[n] = true
}
type ldapUser struct {
DN string `json:"dn"`
UID string `json:"uid"`
CN string `json:"cn"`
Mail string `json:"mail"`
Imported bool `json:"imported"`
}
users := make([]ldapUser, 0, len(result.Entries))
for _, entry := range result.Entries {
uid := entry.GetAttributeValue(cfg.UsernameAttribute)
if uid == "" {
continue
}
users = append(users, ldapUser{
DN: entry.DN,
UID: uid,
CN: entry.GetAttributeValue("cn"),
Mail: entry.GetAttributeValue("mail"),
Imported: existingMap[uid],
})
}
c.JSON(200, users)
}
// batchCreateUsers 批量创建本地用户。
func (s *Server) batchCreateUsers(c *gin.Context) {
var req struct {
Users []struct {
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
Role string `json:"role"`
} `json:"users"`
DefaultRole string `json:"default_role"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
if req.DefaultRole == "" {
req.DefaultRole = "viewer"
}
type failInfo struct {
Username string `json:"username"`
Error string `json:"error"`
}
var success, skipped, failed int
var fails []failInfo
for _, u := range req.Users {
if u.Username == "" {
failed++
fails = append(fails, failInfo{Error: "用户名为空"})
continue
}
// 检查是否已存在
var count int64
store.DB.Model(&model.User{}).Where("username = ?", u.Username).Count(&count)
if count > 0 {
skipped++
continue
}
pwd := u.Password
if len(pwd) < 8 {
failed++
fails = append(fails, failInfo{Username: u.Username, Error: "密码至少 8 位"})
continue
}
role := u.Role
if role == "" {
role = req.DefaultRole
}
if role != "admin" && role != "operator" && role != "viewer" {
role = "viewer"
}
hash, _ := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost)
user := model.User{
Username: u.Username, Password: string(hash), Email: u.Email,
Role: role, Source: "local", Enabled: true,
}
if err := store.DB.Create(&user).Error; err != nil {
failed++
fails = append(fails, failInfo{Username: u.Username, Error: err.Error()})
continue
}
store.Audit(c.GetString("username"), "batch_create_user", u.Username, "")
success++
}
c.JSON(200, gin.H{
"success": success, "skipped": skipped, "failed": failed,
"errors": fails, "total": len(req.Users),
})
}
// importLDAPUsers 从 LDAP 导入选中的用户到本地 DB(source=ldap)。
func (s *Server) importLDAPUsers(c *gin.Context) {
var req struct {
DNs []string `json:"dns"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "参数错误"})
return
}
cfg, err := ldapSettings()
if err != nil {
c.JSON(400, gin.H{"error": "LDAP 未启用:" + err.Error()})
return
}
conn, err := ldapConnect(cfg)
if err != nil {
c.JSON(500, gin.H{"error": "LDAP 连接失败:" + err.Error()})
return
}
defer conn.Close()
type failInfo struct {
DN string `json:"dn"`
Error string `json:"error"`
}
var success, skipped, failed int
var fails []failInfo
for _, dn := range req.DNs {
// 按 DN 查询单个条目
result, err := conn.Search(ldap.NewSearchRequest(
dn, ldap.ScopeBaseObject, ldap.NeverDerefAliases, 0, 0, false,
"(objectClass=*)", []string{cfg.UsernameAttribute, "cn", "mail"}, nil,
))
if err != nil || len(result.Entries) == 0 {
failed++
fails = append(fails, failInfo{DN: dn, Error: "查询失败"})
continue
}
entry := result.Entries[0]
uid := entry.GetAttributeValue(cfg.UsernameAttribute)
if uid == "" {
failed++
fails = append(fails, failInfo{DN: dn, Error: "无 uid 属性"})
continue
}
// 检查本地是否已存在
var count int64
store.DB.Model(&model.User{}).Where("username = ?", uid).Count(&count)
if count > 0 {
skipped++
continue
}
user := model.User{
Username: uid, Email: entry.GetAttributeValue("mail"),
Role: "viewer", Source: "ldap", Enabled: true,
}
if err := store.DB.Create(&user).Error; err != nil {
failed++
fails = append(fails, failInfo{DN: dn, Error: err.Error()})
continue
}
store.Audit(c.GetString("username"), "import_ldap_user", uid, dn)
success++
}
c.JSON(200, gin.H{
"success": success, "skipped": skipped, "failed": failed,
"errors": fails, "total": len(req.DNs),
})
}