feat: implement VPN platform management and real protocol services

This commit is contained in:
Logan.Li committed 2026-07-22 21:49:13 +08:00
commit 58c036c34c
45 files changed
+9244

No files matched your search

+415
View File
@@ -0,0 +1,415 @@
package api
import (
"crypto/rand"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
"vpn-platform/backend/internal/model"
"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://") {
url = "ldap://" + url
}
conn, err := ldap.DialURL(url)
if err != nil {
return nil, 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 := 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
}
secret, err := store.DecryptSecret(device.Secret)
if err != nil {
return fmt.Errorf("MFA 配置无效")
}
if totp.Validate(strings.TrimSpace(code), secret) {
return nil
}
return fmt.Errorf("需要有效的 MFA 验证码")
}
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)
}
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"`
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.Enabled = req.URL, req.BindDN, req.BaseDN, req.UserFilter, req.UsernameAttribute, 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
}
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": "本地拨号用户密码必填"})
return
}
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
user.Password = string(hash)
} 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
}
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 {
user.Enabled = *req.Enabled
}
if req.Password != "" {
hash, _ := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
user.Password = string(hash)
}
store.DB.Save(&user)
c.JSON(200, user)
}
func (s *Server) deleteVPNUser(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
store.DB.Delete(&model.VPNUser{}, id)
c.JSON(200, gin.H{"ok": true})
}