UI/UX 优化: - 登录页分屏布局 + 可访问性修复 + 内联错误提示 - 侧栏菜单分组(专线运维/资源管理/系统) + 图标修正 + 子路由高亮 - Dashboard 统计卡渐变图标 + 空状态引导 + 统计卡可点击 - 创建向导: 依赖检测门控 + 高级折叠 + 必填校验 + WireGuard推荐标 - 专线列表: 空状态CTA + 操作列「更多」下拉 + 状态筛选 + SSE日志流 - 协议品牌SVG图标(ProtocolIcon组件)替换emoji - 设计令牌系统(tokens.css)统一颜色/间距/圆角/阴影 功能增强: - 在线会话管理(sessionmgr): 跨协议会话同步 + 踢下线 + 连接审计日志 - 流量配额引擎(trafficmgr): 配额检查 + 超额动作(block/warn/throttle) + 周期重置 - 异常流量告警: 突发流量检测(>90MB/15s) + 审计记录 - 节点SSH凭证管理: 密码/私钥认证 + 即时测试连接 + AES-GCM加密存储 - 协议二进制管理(binmgr): FRP/OpenVPN GitHub下载 + 系统包安装引导(apt/yum/dnf) - 右上角修改密码弹窗 + MFA独立验证页 + 密码强度提示 - 用户管理: 批量导入 + LDAP浏览导入 + 平台用户导入VPN拨号用户 - 404路由 + IPSec对接页可达性修复 - MFA备用码持久化弹窗(替代Toast) - 轮询可见性守卫 + ECharts resize修复 后端: - 3张新表: VPNSession/ConnectionLog/TrafficPolicy - monitor增强: 会话同步+配额检查+异常告警+到期审计 - changePassword统一8位+新旧不同校验 - openvpn-otp文案专业化(移除抄袭感) - SSE日志流(token降级query参数)
941 lines
28 KiB
Go
941 lines
28 KiB
Go
package api
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"crypto/subtle"
|
||
"crypto/tls"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net"
|
||
"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/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) {
|
||
url := cfg.URL
|
||
if !strings.HasPrefix(url, "ldap://") && !strings.HasPrefix(url, "ldaps://") {
|
||
// 安全默认:未显式指定协议时按 ldaps,避免口令明文过网
|
||
url = "ldaps://" + url
|
||
}
|
||
conn, err := ldap.DialURL(url)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if cfg.StartTLS && strings.HasPrefix(url, "ldap://") {
|
||
host := strings.TrimPrefix(url, "ldap://")
|
||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||
host = h
|
||
}
|
||
if err := conn.StartTLS(&tls.Config{ServerName: host}); err != nil {
|
||
conn.Close()
|
||
return nil, fmt.Errorf("StartTLS 升级失败: %w", err)
|
||
}
|
||
} else if strings.HasPrefix(url, "ldap://") {
|
||
conn.Close()
|
||
return nil, fmt.Errorf("明文 ldap:// 存在口令泄露风险,请改用 ldaps:// 或开启 StartTLS")
|
||
}
|
||
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 := fmt.Sprintf("(&%s(%s=%s))", cfg.UserFilter, 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
|
||
}
|
||
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) {
|
||
cfg, err := ldapSettings()
|
||
if err == nil {
|
||
conn, connErr := ldapConnect(cfg)
|
||
if connErr == nil {
|
||
conn.Close()
|
||
c.JSON(200, gin.H{"ok": true})
|
||
return
|
||
}
|
||
err = connErr
|
||
}
|
||
c.JSON(400, gin.H{"error": "LDAP 连接失败: " + err.Error()})
|
||
}
|
||
|
||
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"`
|
||
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 == "" {
|
||
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
|
||
}
|
||
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
|
||
}
|
||
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 len(req.Password) < 8 {
|
||
c.JSON(400, gin.H{"error": "本地拨号用户密码至少 8 位"})
|
||
return
|
||
}
|
||
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)
|
||
}
|
||
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),
|
||
})
|
||
}
|