Grafana Auth Proxy With Nginx 完整配置指南

Grafana Auth Proxy with Nginx 完整配置指南

在现代监控系统中,集中认证和单点登录(SSO)是保障安全的重要组成部分。本指南将详细介绍如何使用 Nginx 作为反向代理,通过 auth_request 模块实现 Grafana 的 auth.proxy 认证机制。

架构概述

我们将构建一个包含以下组件的系统:

  1. Grafana - 监控可视化平台
  2. Nginx - 反向代理服务器
  3. Auth Service - 自定义认证服务(JWT验证)

数据流向:

1
Client → Nginx → Auth Service (验证) → Grafana

1. Grafana 配置

首先需要在 Grafana 中启用 auth.proxy 认证:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
[server]
domain = localhost
http_port = 3000
root_url = %(protocol)s://%(domain)s:%(http_port)s/grafana/
serve_from_sub_path = true

[auth.proxy]
enabled = true
header_name = X-WEBAUTH-USER
header_property = username
auto_sign_up = true
sync_ttl = 60
whitelist = 172.0.0.0/8, 192.168.0.0/16, 10.0.0.0/8
headers = Name:X-WEBAUTH-NAME
enable_login_token = false

[auth]
disable_login_form = false
disable_signout_menu = true

[users]
auto_assign_org = true
auto_assign_org_id = 1
auto_assign_org_role = Editor

关键配置项说明:

  • enabled: 启用代理认证
  • header_name: 指定包含用户名的 HTTP 头
  • auto_sign_up: 是否自动创建用户
  • whitelist: 允许使用代理认证的 IP 白名单

2. Nginx 配置

主配置文件 (nginx.conf)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
events {
    worker_connections 1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" '
                    '"$http_user_agent" "$http_x_forwarded_for" '
                    '"$http_cookie"';

    log_format detailed '$remote_addr - $remote_user [$time_local] "$request" '
                        '$status $body_bytes_sent "$http_referer" '
                        '"$http_user_agent" "$http_x_forwarded_for" '
                        '"$http_cookie" "$upstream_http_x_auth_user"';

    access_log /var/log/nginx/access.log main;
    error_log /var/log/nginx/error.log debug;

    sendfile        on;
    keepalive_timeout  65;

    upstream grafana {
        server grafana:3000;
    }

    upstream auth-service {
        server auth-service:8080;
    }

    include /etc/nginx/conf.d/*.conf;
}

站点配置文件 (conf.d/grafana.conf)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
map $http_authorization $jwt_token {
    ~Bearer(.*) $1;
    default "";
}

server {
    listen 80;
    server_name localhost;

    # 主要的 Grafana 路由
    location /grafana/ {
        # 发送到认证服务进行验证
        auth_request /auth;
        
        # 从认证服务获取用户信息
        auth_request_set $auth_user $upstream_http_x_auth_user;
        auth_request_set $auth_name $upstream_http_x_auth_name;
        
        # 设置 Grafana 需要的头部
        proxy_set_header X-WEBAUTH-USER $auth_user;
        proxy_set_header X-WEBAUTH-NAME $auth_name;
        
        # 其他必要头部
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        
        # 代理到 Grafana
        proxy_pass http://grafana/;
        proxy_redirect / /grafana/;
    }
    
    # Grafana 认证端点
    location /grafana/auth {
        proxy_pass http://auth-service/grafana/auth;
    }
    
    # 认证端点
    location = /auth {
        internal;
        
        # 启用详细日志
        access_log /var/log/nginx/auth_access.log main;
        error_log /var/log/nginx/auth_error.log debug;
        
        # 确保 cookie 被传递到认证服务
        proxy_pass http://auth-service/verify;
        proxy_pass_request_body off;
        proxy_set_header Content-Length "";
        proxy_set_header X-Original-URI $request_uri;
        proxy_set_header X-Original-Method $request_method;
        proxy_set_header Authorization $http_authorization;
        proxy_set_header X-JWT-Token $jwt_token;
        # 确保 cookie 被正确传递
        proxy_set_header Cookie $http_cookie;
    }
    
    # 登录端点 - 用于获取 JWT token
    location /login {
        proxy_pass http://auth-service/login;
    }
    
    # 前端登录页面
    location / {
        proxy_pass http://auth-service/;
    }
    
    # 健康检查
    location /health {
        return 200 "OK";
        add_header Content-Type text/plain;
    }
}

3. 认证服务实现

认证服务使用 Go 语言实现,支持 JWT token 验证和前端登录页面:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"os"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

var jwtSecret = []byte("mysecretkey")

type User struct {
	ID    int    `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email"`
	Role  string `json:"role"`
}

type Claims struct {
	UserID int    `json:"user_id"`
	Email  string `json:"email"`
	Name   string `json:"name"`
	jwt.RegisteredClaims
}

type LoginRequest struct {
	Username string `json:"username"`
	Password string `json:"password"`
}

type LoginResponse struct {
	Token string `json:"token"`
	User  User   `json:"user"`
}

// 模拟用户数据库
var users = map[string]User{
	"admin": {
		ID:    1,
		Name:  "Admin User",
		Email: "[email protected]",
		Role:  "Admin",
	},
	"editor": {
		ID:    2,
		Name:  "Editor User",
		Email: "[email protected]",
		Role:  "Editor",
	},
	"viewer": {
		ID:    3,
		Name:  "Viewer User",
		Email: "[email protected]",
		Role:  "Viewer",
	},
}

func main() {
	// 从环境变量获取密钥
	if secret := os.Getenv("JWT_SECRET"); secret != "" {
		jwtSecret = []byte(secret)
	}

	http.HandleFunc("/login", loginHandler)
	http.HandleFunc("/verify", verifyHandler)
	http.HandleFunc("/", loginPageHandler)
	http.HandleFunc("/grafana/", grafanaAuthHandler)

	log.Println("Auth service starting on :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func loginPageHandler(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path != "/" {
		http.NotFound(w, r)
		return
	}

	html := `
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Grafana SSO Login</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            max-width: 400px;
            margin: 100px auto;
            padding: 20px;
            background-color: #f5f5f5;
        }
        .login-container {
            background: white;
            padding: 30px;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        h2 {
            text-align: center;
            color: #333;
            margin-bottom: 30px;
        }
        .form-group {
            margin-bottom: 20px;
        }
        label {
            display: block;
            margin-bottom: 5px;
            color: #555;
        }
        input {
            width: 100%;
            padding: 12px;
            border: 1px solid #ddd;
            border-radius: 4px;
            box-sizing: border-box;
        }
        button {
            width: 100%;
            padding: 12px;
            background-color: #337ab7;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 16px;
        }
        button:hover {
            background-color: #286090;
        }
        .message {
            margin-top: 20px;
            padding: 10px;
            border-radius: 4px;
        }
        .error {
            background-color: #f2dede;
            color: #a94442;
            border: 1px solid #ebccd1;
        }
        .success {
            background-color: #dff0d8;
            color: #3c763d;
            border: 1px solid #d6e9c6;
        }
        .hidden {
            display: none;
        }
        .grafana-link {
            text-align: center;
            margin-top: 20px;
        }
        .grafana-link a {
            display: inline-block;
            padding: 10px 20px;
            background-color: #e65252;
            color: white;
            text-decoration: none;
            border-radius: 4px;
        }
        .grafana-link a:hover {
            background-color: #cc3333;
        }
    </style>
</head>
<body>
    <div class="login-container">
        <h2>Grafana SSO Login</h2>
        <form id="loginForm">
            <div class="form-group">
                <label for="username">Username</label>
                <input type="text" id="username" name="username" required>
            </div>
            <div class="form-group">
                <label for="password">Password</label>
                <input type="password" id="password" name="password" required>
            </div>
            <button type="submit">Login</button>
        </form>
        <div id="message" class="message hidden"></div>
        <div id="grafanaLink" class="grafana-link hidden">
            <a href="#" id="grafanaLinkBtn">Access Grafana</a>
        </div>
    </div>

    <script>
        // 页面加载时检查是否有保存的token
        window.addEventListener('DOMContentLoaded', function() {
            const token = localStorage.getItem('grafana_jwt_token');
            if (token) {
                document.getElementById('grafanaLink').classList.remove('hidden');
            }
        });

        document.getElementById('loginForm').addEventListener('submit', function(e) {
            e.preventDefault();
            
            const username = document.getElementById('username').value;
            const password = document.getElementById('password').value;
            const messageDiv = document.getElementById('message');
            const grafanaLinkDiv = document.getElementById('grafanaLink');
            
            // 清除之前的消息
            messageDiv.className = 'message hidden';
            
            fetch('/login', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: JSON.stringify({ username, password }),
            })
            .then(response => response.json())
            .then(data => {
                if (data.token) {
                    // 保存 token 到 localStorage
                    localStorage.setItem('grafana_jwt_token', data.token);
                    
                    // 显示成功消息
                    messageDiv.className = 'message success';
                    messageDiv.textContent = 'Login successful! You can now access Grafana.';
                    messageDiv.classList.remove('hidden');
                    
                    // 显示 Grafana 链接
                    grafanaLinkDiv.classList.remove('hidden');
                } else {
                    // 显示错误消息
                    messageDiv.className = 'message error';
                    messageDiv.textContent = 'Login failed: ' + (data.error || 'Unknown error');
                    messageDiv.classList.remove('hidden');
                }
            })
            .catch(error => {
                // 显示错误消息
                messageDiv.className = 'message error';
                messageDiv.textContent = 'Error: ' + error.message;
                messageDiv.classList.remove('hidden');
            });
        });
        
        // 为 Grafana 链接添加事件处理器
        document.getElementById('grafanaLinkBtn').addEventListener('click', function(e) {
            e.preventDefault();
            const token = localStorage.getItem('grafana_jwt_token');
            if (token) {
                // 通过一个中间页面传递 token 到 Grafana
                window.open('/grafana/auth?token=' + encodeURIComponent(token), '_blank');
            } else {
                alert('No token found. Please login first.');
            }
        });
    </script>
</body>
</html>
`
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	fmt.Fprint(w, html)
}

func grafanaAuthHandler(w http.ResponseWriter, r *http.Request) {
	log.Printf("Grafana auth handler called with URL: %s", r.URL.String())
	
	// 从查询参数获取 token
	tokenString := r.URL.Query().Get("token")
	log.Printf("Token from URL query parameter: %s", tokenString)
	
	if tokenString == "" {
		// 如果没有 token,尝试从 Authorization 头获取
		authHeader := r.Header.Get("Authorization")
		log.Printf("Authorization header: %s", authHeader)
		if strings.HasPrefix(authHeader, "Bearer ") {
			tokenString = authHeader[7:]
		}
	}
	
	if tokenString == "" {
		log.Println("No token found in request")
		// 如果仍然没有 token,返回错误页面
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		fmt.Fprint(w, `
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Access Denied</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            text-align: center;
            margin-top: 100px;
        }
        .error {
            color: #d9534f;
            font-size: 18px;
        }
        .back-link {
            margin-top: 20px;
        }
        .back-link a {
            color: #337ab7;
            text-decoration: none;
        }
    </style>
</head>
<body>
    <div class="error">
        <h2>Access Denied</h2>
        <p>No valid authentication token found.</p>
        <p>Please <a href="/">login</a> first to access Grafana.</p>
    </div>
</body>
</html>
`)
		return
	}
	
	log.Println("Token found, validating...")
	// 验证 token
	claims := &Claims{}
	token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		return jwtSecret, nil
	})
	
	if err != nil {
		log.Printf("Token parsing error: %v", err)
		// Token 无效,返回错误页面
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		fmt.Fprint(w, `
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Access Denied</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            text-align: center;
            margin-top: 100px;
        }
        .error {
            color: #d9534f;
            font-size: 18px;
        }
        .back-link {
            margin-top: 20px;
        }
        .back-link a {
            color: #337ab7;
            text-decoration: none;
        }
    </style>
</head>
<body>
    <div class="error">
        <h2>Access Denied</h2>
        <p>Invalid or expired authentication token.</p>
        <p>Please <a href="/">login</a> again to access Grafana.</p>
    </div>
</body>
</html>
`)
		return
	}
	
	if !token.Valid {
		log.Println("Token is invalid")
		// Token 无效,返回错误页面
		w.Header().Set("Content-Type", "text/html; charset=utf-8")
		fmt.Fprint(w, `
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Access Denied</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            text-align: center;
            margin-top: 100px;
        }
        .error {
            color: #d9534f;
            font-size: 18px;
        }
        .back-link {
            margin-top: 20px;
        }
        .back-link a {
            color: #337ab7;
            text-decoration: none;
        }
    </style>
</head>
<body>
    <div class="error">
        <h2>Access Denied</h2>
        <p>Invalid or expired authentication token.</p>
        <p>Please <a href="/">login</a> again to access Grafana.</p>
    </div>
</body>
</html>
`)
		return
	}
	
	log.Printf("Token is valid for user: %s", claims.Subject)
	
	// Token 有效,设置认证 cookie 并重定向到 Grafana
	// 修复 cookie 设置,确保路径和域正确
	cookie := &http.Cookie{
		Name:     "grafana_jwt_token",
		Value:    tokenString,
		Path:     "/",  // 更改路径为根路径,确保所有路径都能访问
		Domain:   "",   // 空字符串表示当前域
		HttpOnly: false, // 设置为 false 以便 JavaScript 可以访问
		MaxAge:   86400, // 24小时
		SameSite: http.SameSiteLaxMode,
	}
	http.SetCookie(w, cookie)
	
	log.Printf("Cookie set: %+v", cookie)
	
	// 重定向到 Grafana
	// 使用 JavaScript 重定向,确保 cookie 被正确设置
	htmlResponse := fmt.Sprintf(`
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Redirecting...</title>
</head>
<body>
    <p>Authentication successful. Redirecting to Grafana...</p>
    <script>
        // 确保 cookie 已设置
        document.cookie = "grafana_jwt_token=%s; path=/; max-age=86400; sameSite=Lax";
        // 重定向到 Grafana
        window.location.href = "http://localhost/grafana/";
    </script>
</body>
</html>
`, tokenString)
	
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	fmt.Fprint(w, htmlResponse)
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
	log.Printf("Login handler called with method: %s", r.Method)
	
	if r.Method != http.MethodPost {
		log.Printf("Invalid method: %s", r.Method)
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var req LoginRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		log.Printf("Error decoding request body: %v", err)
		http.Error(w, "Invalid request body", http.StatusBadRequest)
		return
	}

	log.Printf("Login request for user: %s", req.Username)
	
	user, ok := users[req.Username]
	if !ok {
		log.Printf("User not found: %s", req.Username)
		http.Error(w, "Invalid credentials", http.StatusUnauthorized)
		return
	}

	// 简单密码验证(实际应用中应使用加密密码)
	if req.Password != "password" {
		log.Printf("Invalid password for user: %s", req.Username)
		http.Error(w, "Invalid credentials", http.StatusUnauthorized)
		return
	}

	// 生成 JWT token
	expirationTime := time.Now().Add(24 * time.Hour)
	claims := &Claims{
		UserID: user.ID,
		Email:  user.Email,
		Name:   user.Name,
		RegisteredClaims: jwt.RegisteredClaims{
			ExpiresAt: jwt.NewNumericDate(expirationTime),
			IssuedAt:  jwt.NewNumericDate(time.Now()),
			Subject:   req.Username,
		},
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tokenString, err := token.SignedString(jwtSecret)
	if err != nil {
		log.Printf("Error generating token: %v", err)
		http.Error(w, "Could not generate token", http.StatusInternalServerError)
		return
	}

	log.Printf("Token generated successfully for user: %s", req.Username)
	
	response := LoginResponse{
		Token: tokenString,
		User:  user,
	}

	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

func verifyHandler(w http.ResponseWriter, r *http.Request) {
	log.Printf("Verify handler called with method: %s", r.Method)
	log.Printf("Request headers: Authorization=%s, X-JWT-Token=%s", 
		r.Header.Get("Authorization"), r.Header.Get("X-JWT-Token"))
	
	// 记录所有 cookies
	log.Printf("All cookies:")
	for _, cookie := range r.Cookies() {
		log.Printf("  Cookie %s=%s", cookie.Name, cookie.Value)
	}
	
	// 首先尝试从 cookie 获取 token
	tokenString := ""
	if cookie, err := r.Cookie("grafana_jwt_token"); err == nil {
		tokenString = cookie.Value
		log.Printf("Token found in cookie: %s", tokenString)
	} else {
		log.Printf("No grafana_jwt_token cookie found: %v", err)
	}
	
	// 如果 cookie 中没有 token,则尝试从 header 获取
	if tokenString == "" {
		authHeader := r.Header.Get("Authorization")
		jwtToken := r.Header.Get("X-JWT-Token")

		log.Printf("Headers - Authorization: %s, X-JWT-Token: %s", authHeader, jwtToken)

		if authHeader == "" && jwtToken == "" {
			log.Println("Missing authorization header")
			http.Error(w, "Missing authorization header", http.StatusUnauthorized)
			return
		}

		// 如果没有从 Authorization 头获取,则从自定义头获取
		if authHeader == "" && jwtToken != "" {
			authHeader = "Bearer " + jwtToken
		}

		// 提取 token
		if strings.HasPrefix(authHeader, "Bearer ") {
			tokenString = authHeader[7:]
			log.Printf("Token extracted from header: %s", tokenString)
		} else {
			log.Println("Invalid authorization header format")
			http.Error(w, "Invalid authorization header", http.StatusUnauthorized)
			return
		}
	}

	if tokenString == "" {
		log.Println("No token found in request")
		http.Error(w, "No token provided", http.StatusUnauthorized)
		return
	}

	// 解析和验证 token
	claims := &Claims{}
	token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		return jwtSecret, nil
	})

	if err != nil {
		log.Printf("Token parsing error: %v", err)
		http.Error(w, "Invalid token", http.StatusUnauthorized)
		return
	}

	if !token.Valid {
		log.Println("Token is invalid")
		http.Error(w, "Invalid token", http.StatusUnauthorized)
		return
	}

	log.Printf("Token verified successfully for user: %s", claims.Subject)
	
	// 设置认证用户信息头部
	w.Header().Set("X-Auth-User", claims.Subject)
	w.Header().Set("X-Auth-Name", claims.Name)
	w.WriteHeader(http.StatusOK)
}

func calculateHMAC(message, secret []byte) string {
	h := hmac.New(sha256.New, secret)
	h.Write(message)
	return base64.StdEncoding.EncodeToString(h.Sum(nil))
}

认证服务 Dockerfile

为了构建认证服务的 Docker 镜像,我们需要创建一个 Dockerfile:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY . .
RUN go mod tidy
RUN go build -o auth-service .

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/auth-service .
CMD ["./auth-service"]

4. Docker Compose 部署配置

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
version: '3.8'

services:
  grafana:
    image: grafana/grafana-enterprise
    container_name: grafana
    ports:
      - "3000:3000"
    volumes:
      - ./grafana/grafana.ini:/etc/grafana/grafana.ini
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    networks:
      - grafana-net

  nginx:
    image: nginx:alpine
    container_name: nginx-proxy
    ports:
      - "80:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
      - ./nginx/conf.d:/etc/nginx/conf.d
    depends_on:
      - grafana
      - auth-service
    networks:
      - grafana-net

  auth-service:
    build:
      context: ./auth-service
      dockerfile: Dockerfile
    container_name: auth-service
    ports:
      - "8080:8080"
    environment:
      - JWT_SECRET=mysecretkey
    networks:
      - grafana-net

networks:
  grafana-net:
    driver: bridge
}

5. APISIX 配置(可选)

如果您使用 APISIX 作为网关,可以使用以下配置来优化认证流程,将认证信息传递给所有页面:

根据您提供的 Grafana 配置和架构说明(仅包含 APISIX、Grafana 和认证服务),我为您创建了以下 APISIX 配置,该配置可以实现将认证信息传递给所有页面:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# Grafana 路由配置 - 将认证信息传递给所有页面
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
  name: grafana-all
  namespace: monitoring
spec:
  http:
  # 所有 Grafana 路由 - 对所有请求启用 forward-auth
  - name: grafana-all
    match:
      paths:
        - /*
      hosts:
        - grafana.kbsonlong.com
    backends:
      - serviceName: grafana-service
        servicePort: 3000
        weight: 10
    plugins:
      - name: forward-auth
        enable: true
        config:
          uri: http://sysop.kbsonlong.com/ws/aquaman/grafanaLoginAuth
          request_headers:
            - Authorization
            - Cookie
          client_headers:
            - Location
            - Set-Cookie
          upstream_headers:
            - X-WEBAUTH-USER
            - X-WEBAUTH-NAME
      - name: client-control
        enable: true
        config:
          max_body_size: 50000000
    websocket: true

6. APISIX 性能优化方案

由于每次请求都需要经过认证服务,这会给认证服务带来很大的压力。以下是几种优化方案:

方案一:使用 JWT 插件直接验证 Token

使用 APISIX 的 JWT 插件直接验证 JWT Token,避免每次都调用认证服务:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# Grafana 路由配置 - 使用 JWT 插件直接验证 token
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
  name: grafana-jwt
  namespace: monitoring
spec:
  http:
  # 所有 Grafana 路由 - 使用 JWT 插件验证 token
  - name: grafana-jwt
    match:
      paths:
        - /*
      hosts:
        - grafana.kbsonlong.com
    backends:
      - serviceName: grafana-service
        servicePort: 3000
        weight: 10
    plugins:
      - name: jwt-auth
        enable: true
        config:
          key: grafana-jwt-key
          secret: mysecretkey
          algorithm: HS256
      - name: proxy-rewrite
        enable: true
        config:
          headers:
            X-WEBAUTH-USER: $jwt_claim_subject
            X-WEBAUTH-NAME: $jwt_claim_name
      - name: client-control
        enable: true
        config:
          max_body_size: 50000000
    websocket: true
---
apiVersion: apisix.apache.org/v2
kind: ApisixConsumer
metadata:
  name: grafana-user
  namespace: monitoring
spec:
  authParameter:
    jwtAuth:
      secretRef:
        name: grafana-jwt-secret
---
apiVersion: v1
kind: Secret
metadata:
  name: grafana-jwt-secret
  namespace: monitoring
type: Opaque
data:
  key: Z3JhZmFuYS1qd3Qta2V5
  secret: bXlzZWNyZXRrZXk=

方案二:使用缓存机制减少认证服务压力

使用 APISIX 的缓存机制来减少对认证服务的调用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# Grafana 路由配置 - 带缓存机制的认证
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
  name: grafana-cached-auth
  namespace: monitoring
spec:
  http:
  # 所有 Grafana 路由 - 使用带缓存的 forward-auth
  - name: grafana-cached-auth
    match:
      paths:
        - /*
      hosts:
        - grafana.kbsonlong.com
    backends:
      - serviceName: grafana-service
        servicePort: 3000
        weight: 10
    plugins:
      - name: forward-auth
        enable: true
        config:
          uri: http://sysop.kbsonlong.com/ws/aquaman/grafanaLoginAuth
          request_headers:
            - Authorization
            - Cookie
          client_headers:
            - Location
            - Set-Cookie
          upstream_headers:
            - X-WEBAUTH-USER
            - X-WEBAUTH-NAME
      - name: proxy-cache
        enable: true
        config:
          cache_method: 
            - GET
            - HEAD
          cache_http_status:
            - 200
            - 301
            - 302
          cache_zone: disk_cache_one
          cache_key: 
            - "$host"
            - "$request_uri"
            - "$request_method"
          cache_bypass:
            - "$http_authorization"
          no_cache:
            - "$http_authorization"
      - name: client-control
        enable: true
        config:
          max_body_size: 50000000
    websocket: true

方案三:混合认证策略

采用混合策略,只对特定路径使用认证服务,其他路径使用 JWT 验证:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# Grafana 路由配置 - 混合认证策略
apiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
  name: grafana-hybrid-auth
  namespace: monitoring
spec:
  http:
  # 登录相关路径 - 使用 forward-auth 插件
  - name: grafana-login-auth
    match:
      paths:
        - /login
        - /grafana/auth
      hosts:
        - grafana.kbsonlong.com
    backends:
      - serviceName: grafana-service
        servicePort: 3000
        weight: 10
    plugins:
      - name: forward-auth
        enable: true
        config:
          uri: http://sysop.kbsonlong.com/ws/aquaman/grafanaLoginAuth
          request_headers:
            - Authorization
            - Cookie
          client_headers:
            - Location
            - Set-Cookie
          upstream_headers:
            - X-WEBAUTH-USER
            - X-WEBAUTH-NAME
      - name: client-control
        enable: true
        config:
          max_body_size: 50000000

  # API 路径 - 使用 JWT 插件直接验证
  - name: grafana-api-auth
    match:
      paths:
        - /api/*
      hosts:
        - grafana.kbsonlong.com
    backends:
      - serviceName: grafana-service
        servicePort: 3000
        weight: 10
    plugins:
      - name: jwt-auth
        enable: true
        config:
          key: grafana-jwt-key
          secret: mysecretkey
          algorithm: HS256
      - name: proxy-rewrite
        enable: true
        config:
          headers:
            X-WEBAUTH-USER: $jwt_claim_subject
            X-WEBAUTH-NAME: $jwt_claim_name
      - name: client-control
        enable: true
        config:
          max_body_size: 50000000

  # 其他路径 - 使用简化认证(基于 Cookie)
  - name: grafana-main
    match:
      paths:
        - /*
      hosts:
        - grafana.kbsonlong.com
    backends:
      - serviceName: grafana-service
        servicePort: 3000
        weight: 10
    plugins:
      - name: openid-connect
        enable: true
        config:
          client_id: grafana
          client_secret: mysecretkey
          discovery: http://sysop.kbsonlong.com/ws/aquaman/.well-known/openid-configuration
          scope: openid profile email
          bearer_only: true
          realm: grafana
          introspection_endpoint: http://sysop.kbsonlong.com/ws/aquaman/introspect
          introspection_endpoint_auth_method: client_secret_post
      - name: client-control
        enable: true
        config:
          max_body_size: 50000000
    websocket: true

7. 部署步骤

  1. 创建项目目录结构:
1
2
3
mkdir grafana-auth-proxy
cd grafana-auth-proxy
mkdir -p nginx/conf.d auth-service grafana
  1. 在相应目录中创建配置文件:

  2. 启动服务:

1
docker-compose up -d

8. 测试方法

通过浏览器登录

  1. 打开浏览器访问 http://localhost
  2. 输入用户名和密码(例如:admin/password)
  3. 点击登录按钮
  4. 登录成功后,点击 “Access Grafana” 链接访问 Grafana

通过 API 获取访问令牌

1
2
3
4
# 获取 admin 用户的 JWT token
curl -X POST http://localhost/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"password"}'

使用令牌访问 Grafana

1
2
3
# 使用获得的 token 访问 Grafana
curl -X GET http://localhost/grafana/api/user \
  -H "Authorization: Bearer YOUR_JWT_TOKEN_HERE"

9. 安全注意事项

  1. IP 白名单: 确保只有受信任的代理可以设置 X-WEBAUTH-USER
  2. 生产环境: 不要允许客户端直接设置认证头
  3. HTTPS: 在生产环境中始终使用 HTTPS
  4. 密钥管理: 使用强密钥并定期更换
  5. 日志记录: 记录认证相关信息以便审计

10. 故障排除

查看日志

1
2
3
4
5
6
7
# 查看所有服务日志
docker-compose logs -f

# 查看特定服务日志
docker-compose logs -f nginx
docker-compose logs -f grafana
docker-compose logs -f auth-service

常见问题

  1. 无法访问 Grafana: 检查 Nginx 配置和 Grafana 的子路径配置
  2. 认证失败: 确认 JWT token 有效且未过期
  3. 用户未创建: 检查 Grafana 的 auto_sign_up 配置

通过以上配置,您可以成功实现基于 Nginx auth_request 模块的 Grafana auth.proxy 认证机制,为 Grafana 提供安全可靠的单点登录功能。

0%