Skip to content

Commit 57792de

Browse files
committed
feat: Phase 2.2 企业集成 — LDAP/OAuth2 框架 + 通知渠道扩展
- pkg/auth/ldap.go: LDAP/AD 认证器 (minimal LDAP BIND protocol, no lib dependency) - pkg/auth/oauth2.go: 通用 OAuth2 认证器 (auth_url + token exchange + userinfo) - pkg/notifier/: Provider interface + 3 个内置驱动 - email: SMTP 发送 - dingtalk: 钉钉机器人 webhook - wecom: 企业微信机器人 webhook - alert_service.go: 新增 dingtalk/wecom 渠道类型支持 - 通过 notifier.Send() 统一分发通知
1 parent 2c1661a commit 57792de

8 files changed

Lines changed: 371 additions & 0 deletions

File tree

platform-backend/internal/services/alert_service.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/google/uuid"
1313
"github.com/monkeycode/mysql-ops-platform/internal/models"
1414
"github.com/monkeycode/mysql-ops-platform/internal/repositories"
15+
"github.com/monkeycode/mysql-ops-platform/pkg/notifier"
1516
)
1617

1718
type AlertService struct {
@@ -224,6 +225,28 @@ func (s *AlertService) SendNotification(ctx context.Context, req SendNotificatio
224225
continue
225226
}
226227
successCount++
228+
case "dingtalk":
229+
webhookURL, _ := cfg["webhook_url"].(string)
230+
if webhookURL == "" {
231+
failedChannels = append(failedChannels, channelID+" (webhook_url not configured)")
232+
continue
233+
}
234+
if err := notifier.Send("dingtalk", webhookURL, req.AlertID, req.Message); err != nil {
235+
failedChannels = append(failedChannels, channelID+" ("+err.Error()+")")
236+
continue
237+
}
238+
successCount++
239+
case "wecom":
240+
webhookURL, _ := cfg["webhook_url"].(string)
241+
if webhookURL == "" {
242+
failedChannels = append(failedChannels, channelID+" (webhook_url not configured)")
243+
continue
244+
}
245+
if err := notifier.Send("wecom", webhookURL, req.AlertID, req.Message); err != nil {
246+
failedChannels = append(failedChannels, channelID+" ("+err.Error()+")")
247+
continue
248+
}
249+
successCount++
227250
default:
228251
failedChannels = append(failedChannels, channelID+" (unsupported channel_type)")
229252
}

platform-backend/pkg/auth/ldap.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package auth
2+
3+
import (
4+
"crypto/tls"
5+
"fmt"
6+
"net"
7+
"strings"
8+
"time"
9+
)
10+
11+
type LDAPConfig struct {
12+
Server string `json:"server"`
13+
Port int `json:"port"`
14+
BaseDN string `json:"base_dn"`
15+
BindDN string `json:"bind_dn"`
16+
BindPass string `json:"bind_pass"`
17+
Filter string `json:"filter"`
18+
UseTLS bool `json:"use_tls"`
19+
Timeout int `json:"timeout"`
20+
}
21+
22+
type LDAPAuthenticator struct {
23+
config LDAPConfig
24+
}
25+
26+
func NewLDAPAuthenticator(config LDAPConfig) *LDAPAuthenticator {
27+
if config.Timeout == 0 {
28+
config.Timeout = 10
29+
}
30+
if config.Port == 0 {
31+
config.Port = 389
32+
}
33+
if config.Filter == "" {
34+
config.Filter = "(uid=%s)"
35+
}
36+
return &LDAPAuthenticator{config: config}
37+
}
38+
39+
func (a *LDAPAuthenticator) Authenticate(username, password string) (map[string]string, error) {
40+
addr := fmt.Sprintf("%s:%d", a.config.Server, a.config.Port)
41+
conn, err := net.DialTimeout("tcp", addr, time.Duration(a.config.Timeout)*time.Second)
42+
if err != nil {
43+
return nil, fmt.Errorf("ldap connect failed: %w", err)
44+
}
45+
defer conn.Close()
46+
47+
if a.config.UseTLS {
48+
tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true})
49+
if err := tlsConn.Handshake(); err != nil {
50+
return nil, fmt.Errorf("ldap tls handshake failed: %w", err)
51+
}
52+
conn = tlsConn
53+
}
54+
55+
ldapMsg := buildBindRequest(a.config.BindDN, a.config.BindPass)
56+
if _, err := conn.Write(ldapMsg); err != nil {
57+
return nil, fmt.Errorf("ldap bind write failed: %w", err)
58+
}
59+
60+
resp := make([]byte, 4096)
61+
n, err := conn.Read(resp)
62+
if err != nil {
63+
return nil, fmt.Errorf("ldap bind read failed: %w", err)
64+
}
65+
if n < 2 || resp[1] != 0x00 {
66+
return nil, fmt.Errorf("ldap bind failed: server returned error code")
67+
}
68+
69+
userDN := fmt.Sprintf(a.config.Filter, username)
70+
if !strings.Contains(a.config.Filter, "%s") {
71+
userDN = a.config.Filter
72+
}
73+
74+
ldapMsg = buildBindRequest(userDN, password)
75+
if _, err := conn.Write(ldapMsg); err != nil {
76+
return nil, fmt.Errorf("ldap user bind failed: %w", err)
77+
}
78+
79+
n, err = conn.Read(resp)
80+
if err != nil {
81+
return nil, fmt.Errorf("ldap user bind read failed: %w", err)
82+
}
83+
if n < 2 || resp[1] != 0x00 {
84+
return nil, fmt.Errorf("invalid ldap credentials")
85+
}
86+
87+
return map[string]string{
88+
"username": username,
89+
"dn": userDN,
90+
}, nil
91+
}
92+
93+
func buildBindRequest(dn, password string) []byte {
94+
// Minimal LDAP BIND request (simple auth)
95+
var req []byte
96+
req = append(req, 0x30)
97+
body := append([]byte{0x02, 0x01, 0x01}, // version 3
98+
append([]byte{0x60}, encodeLength(len(dn)+2)...)...) // bind request tag
99+
body = append(body, append([]byte{0x04}, encodeLength(len(dn))...)...)
100+
body = append(body, []byte(dn)...)
101+
body = append(body, 0x80)
102+
body = append(body, encodeLength(len(password))...)
103+
body = append(body, []byte(password)...)
104+
req = append(req, encodeLength(len(body))...)
105+
req = append(req, body...)
106+
return req
107+
}
108+
109+
func encodeLength(n int) []byte {
110+
if n < 128 {
111+
return []byte{byte(n)}
112+
}
113+
return []byte{0x81, byte(n)}
114+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"strings"
10+
"time"
11+
)
12+
13+
type OAuth2Config struct {
14+
Provider string `json:"provider"`
15+
ClientID string `json:"client_id"`
16+
ClientSecret string `json:"client_secret"`
17+
AuthURL string `json:"auth_url"`
18+
TokenURL string `json:"token_url"`
19+
UserInfoURL string `json:"user_info_url"`
20+
RedirectURL string `json:"redirect_url"`
21+
Scopes string `json:"scopes"`
22+
}
23+
24+
type OAuth2Authenticator struct {
25+
config OAuth2Config
26+
client *http.Client
27+
}
28+
29+
func NewOAuth2Authenticator(config OAuth2Config) *OAuth2Authenticator {
30+
if config.Scopes == "" {
31+
config.Scopes = "openid,profile,email"
32+
}
33+
return &OAuth2Authenticator{
34+
config: config,
35+
client: &http.Client{Timeout: 10 * time.Second},
36+
}
37+
}
38+
39+
func (a *OAuth2Authenticator) AuthURL(state string) string {
40+
return fmt.Sprintf("%s?client_id=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s",
41+
a.config.AuthURL, a.config.ClientID, a.config.RedirectURL, a.config.Scopes, state)
42+
}
43+
44+
func (a *OAuth2Authenticator) Exchange(ctx context.Context, code string) (string, error) {
45+
body := fmt.Sprintf("code=%s&client_id=%s&client_secret=%s&redirect_uri=%s&grant_type=authorization_code",
46+
code, a.config.ClientID, a.config.ClientSecret, a.config.RedirectURL)
47+
req, err := http.NewRequestWithContext(ctx, "POST", a.config.TokenURL, strings.NewReader(body))
48+
if err != nil {
49+
return "", fmt.Errorf("oauth2 token request failed: %w", err)
50+
}
51+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
52+
53+
resp, err := a.client.Do(req)
54+
if err != nil {
55+
return "", fmt.Errorf("oauth2 token exchange failed: %w", err)
56+
}
57+
defer resp.Body.Close()
58+
59+
data, _ := io.ReadAll(resp.Body)
60+
var result struct {
61+
AccessToken string `json:"access_token"`
62+
TokenType string `json:"token_type"`
63+
IDToken string `json:"id_token"`
64+
}
65+
if err := json.Unmarshal(data, &result); err != nil {
66+
return "", fmt.Errorf("oauth2 token parse failed: %w", err)
67+
}
68+
if result.AccessToken == "" {
69+
return "", fmt.Errorf("oauth2 token response missing access_token")
70+
}
71+
return result.AccessToken, nil
72+
}
73+
74+
func (a *OAuth2Authenticator) GetUserInfo(ctx context.Context, accessToken string) (map[string]interface{}, error) {
75+
req, err := http.NewRequestWithContext(ctx, "GET", a.config.UserInfoURL, nil)
76+
if err != nil {
77+
return nil, fmt.Errorf("oauth2 userinfo request failed: %w", err)
78+
}
79+
req.Header.Set("Authorization", "Bearer "+accessToken)
80+
81+
resp, err := a.client.Do(req)
82+
if err != nil {
83+
return nil, fmt.Errorf("oauth2 userinfo failed: %w", err)
84+
}
85+
defer resp.Body.Close()
86+
87+
data, _ := io.ReadAll(resp.Body)
88+
var info map[string]interface{}
89+
if err := json.Unmarshal(data, &info); err != nil {
90+
return nil, fmt.Errorf("oauth2 userinfo parse failed: %w", err)
91+
}
92+
return info, nil
93+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package notifier
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"time"
9+
)
10+
11+
type DingTalkProvider struct {
12+
client *http.Client
13+
}
14+
15+
func NewDingTalkProvider() *DingTalkProvider {
16+
return &DingTalkProvider{client: &http.Client{Timeout: 10 * time.Second}}
17+
}
18+
19+
type dingtalkMsg struct {
20+
MsgType string `json:"msgtype"`
21+
Text struct {
22+
Content string `json:"content"`
23+
} `json:"text"`
24+
}
25+
26+
func (p *DingTalkProvider) Send(webhookURL, title, content string) error {
27+
msg := dingtalkMsg{MsgType: "text"}
28+
msg.Text.Content = title + "\n" + content
29+
body, _ := json.Marshal(msg)
30+
31+
resp, err := p.client.Post(webhookURL, "application/json", bytes.NewReader(body))
32+
if err != nil {
33+
return fmt.Errorf("dingtalk send failed: %w", err)
34+
}
35+
defer resp.Body.Close()
36+
37+
if resp.StatusCode != http.StatusOK {
38+
return fmt.Errorf("dingtalk returned status %d", resp.StatusCode)
39+
}
40+
return nil
41+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package notifier
2+
3+
import (
4+
"fmt"
5+
"net/smtp"
6+
"strings"
7+
)
8+
9+
type EmailProvider struct{}
10+
11+
func (p *EmailProvider) Send(config, title, content string) error {
12+
parts := strings.SplitN(config, "|", 4)
13+
if len(parts) < 4 {
14+
return fmt.Errorf("invalid email config: want smtp_host|smtp_port|from|to, got %q", config)
15+
}
16+
host, port, from, to := parts[0], parts[1], parts[2], parts[3]
17+
msg := []byte(fmt.Sprintf("Subject: %s\r\n\r\n%s", title, content))
18+
return smtp.SendMail(host+":"+port, nil, from, strings.Split(to, ","), msg)
19+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package notifier
2+
3+
type Provider interface {
4+
Send(channelConfig string, title, content string) error
5+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package notifier
2+
3+
import (
4+
"fmt"
5+
"sync"
6+
)
7+
8+
var (
9+
mu sync.RWMutex
10+
providers = map[string]Provider{
11+
"email": &EmailProvider{},
12+
"dingtalk": NewDingTalkProvider(),
13+
"wecom": NewWeComProvider(),
14+
}
15+
)
16+
17+
func Register(name string, p Provider) {
18+
mu.Lock()
19+
providers[name] = p
20+
mu.Unlock()
21+
}
22+
23+
func Get(name string) Provider {
24+
mu.RLock()
25+
defer mu.RUnlock()
26+
return providers[name]
27+
}
28+
29+
func Send(channelType, channelConfig, title, content string) error {
30+
p := Get(channelType)
31+
if p == nil {
32+
return fmt.Errorf("unknown channel type: %s", channelType)
33+
}
34+
return p.Send(channelConfig, title, content)
35+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package notifier
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"time"
9+
)
10+
11+
type WeComProvider struct {
12+
client *http.Client
13+
}
14+
15+
func NewWeComProvider() *WeComProvider {
16+
return &WeComProvider{client: &http.Client{Timeout: 10 * time.Second}}
17+
}
18+
19+
type wecomMsg struct {
20+
MsgType string `json:"msgtype"`
21+
Text struct {
22+
Content string `json:"content"`
23+
} `json:"text"`
24+
}
25+
26+
func (p *WeComProvider) Send(webhookURL, title, content string) error {
27+
msg := wecomMsg{MsgType: "text"}
28+
msg.Text.Content = title + "\n" + content
29+
body, _ := json.Marshal(msg)
30+
31+
resp, err := p.client.Post(webhookURL, "application/json", bytes.NewReader(body))
32+
if err != nil {
33+
return fmt.Errorf("wecom send failed: %w", err)
34+
}
35+
defer resp.Body.Close()
36+
37+
if resp.StatusCode != http.StatusOK {
38+
return fmt.Errorf("wecom returned status %d", resp.StatusCode)
39+
}
40+
return nil
41+
}

0 commit comments

Comments
 (0)