416 lines
12 KiB
Go
416 lines
12 KiB
Go
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})
|
|
}
|