Go + Vue 管理台:Agent 隧道、域名反代、IP 安全、访客验证与监控大盘。 Gitea Actions CI 多架构镜像;纯 Go SQLite、无 CGO Docker 构建。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/wormhole/wormhole/internal/cache"
|
||||
"github.com/wormhole/wormhole/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
visitorLoginPath = "/_wormhole/login"
|
||||
visitorCookie = "wh_visitor"
|
||||
)
|
||||
|
||||
type VisitorClaims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
ResourceType string `json:"resource_type"`
|
||||
ResourceID uint `json:"resource_id"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type VisitorConfig struct {
|
||||
Enabled bool
|
||||
BindMode string
|
||||
UserIDs []uint
|
||||
TTLSeconds int
|
||||
}
|
||||
|
||||
func visitorConfigFromHost(h *store.Host) VisitorConfig {
|
||||
ttl := h.VisitorTTLSeconds
|
||||
if ttl <= 0 {
|
||||
ttl = 7200
|
||||
}
|
||||
return VisitorConfig{
|
||||
Enabled: h.VisitorAuthEnabled,
|
||||
BindMode: h.VisitorBindMode,
|
||||
TTLSeconds: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func visitorConfigFromTunnel(t *store.Tunnel) VisitorConfig {
|
||||
ttl := t.VisitorTTLSeconds
|
||||
if ttl <= 0 {
|
||||
ttl = 7200
|
||||
}
|
||||
return VisitorConfig{
|
||||
Enabled: t.VisitorAuthEnabled,
|
||||
BindMode: t.VisitorBindMode,
|
||||
TTLSeconds: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (ra *ResourceAuth) CheckVisitorHTTP(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint, cfg VisitorConfig, userIDs []uint) bool {
|
||||
if !cfg.Enabled {
|
||||
return true
|
||||
}
|
||||
cfg.UserIDs = userIDs
|
||||
if bindMode := strings.TrimSpace(cfg.BindMode); bindMode == "" {
|
||||
cfg.BindMode = "all"
|
||||
} else {
|
||||
cfg.BindMode = bindMode
|
||||
}
|
||||
|
||||
if r.URL.Path == visitorLoginPath {
|
||||
ra.handleVisitorLogin(w, r, resourceType, resourceID, cfg)
|
||||
return false
|
||||
}
|
||||
|
||||
if claims, ok := ra.parseVisitorCookie(r, resourceType, resourceID); ok {
|
||||
if ra.visitorAllowed(claims.UserID, cfg) {
|
||||
ra.logVisitorAccess(claims.UserID, resourceType, resourceID, "access", r)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
redirect := r.URL.RequestURI()
|
||||
http.Redirect(w, r, visitorLoginPath+"?redirect="+url.QueryEscape(redirect), http.StatusFound)
|
||||
return false
|
||||
}
|
||||
|
||||
func visitorLimiterIP(resourceType string, resourceID uint, ip string) string {
|
||||
return fmt.Sprintf("%s:%d:%s", resourceType, resourceID, ip)
|
||||
}
|
||||
|
||||
func (ra *ResourceAuth) handleVisitorLogin(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint, cfg VisitorConfig) {
|
||||
ip := DirectRemoteIP(r)
|
||||
scopedIP := visitorLimiterIP(resourceType, resourceID, ip)
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodPost:
|
||||
_ = r.ParseForm()
|
||||
username := strings.TrimSpace(r.FormValue("username"))
|
||||
if retry, blocked := ra.limiter.IsBlocked(scopedIP, username); blocked {
|
||||
ra.renderVisitorLoginBlocked(w, r, cfg, retry)
|
||||
return
|
||||
}
|
||||
password := r.FormValue("password")
|
||||
user, err := ra.authenticateVisitor(username, password, cfg)
|
||||
if err != nil {
|
||||
ra.limiter.RecordFailure(scopedIP, username)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
ra.renderVisitorLogin(w, r, cfg, "用户名或密码错误")
|
||||
return
|
||||
}
|
||||
ra.limiter.Reset(scopedIP, username)
|
||||
token, err := ra.signVisitorToken(user.ID, resourceType, resourceID, cfg.TTLSeconds)
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
secure := r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: visitorCookie,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: cfg.TTLSeconds,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
Secure: secure,
|
||||
})
|
||||
redirect := SafeRedirectPath(r.FormValue("redirect"))
|
||||
ra.logVisitorAccess(user.ID, resourceType, resourceID, "login", r)
|
||||
http.Redirect(w, r, redirect, http.StatusFound)
|
||||
case http.MethodGet:
|
||||
if retry, blocked := ra.limiter.IsBlocked(scopedIP, ""); blocked {
|
||||
ra.renderVisitorLoginBlocked(w, r, cfg, retry)
|
||||
return
|
||||
}
|
||||
ra.renderVisitorLogin(w, r, cfg, "")
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (ra *ResourceAuth) authenticateVisitor(username, password string, cfg VisitorConfig) (*store.User, error) {
|
||||
if username == "" || password == "" {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
user, err := ra.store.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
if user.Role != "visitor" || !user.Status {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
if !store.CheckPassword(user.PasswordHash, password) {
|
||||
return nil, errors.New("invalid credentials")
|
||||
}
|
||||
if !ra.visitorAllowed(user.ID, cfg) {
|
||||
return nil, errors.New("not allowed")
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (ra *ResourceAuth) visitorAllowed(userID uint, cfg VisitorConfig) bool {
|
||||
if cfg.BindMode == "all" {
|
||||
return true
|
||||
}
|
||||
for _, id := range cfg.UserIDs {
|
||||
if id == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (ra *ResourceAuth) parseVisitorCookie(r *http.Request, resourceType string, resourceID uint) (*VisitorClaims, bool) {
|
||||
cookie, err := r.Cookie(visitorCookie)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return nil, false
|
||||
}
|
||||
claims, err := ra.parseVisitorToken(cookie.Value)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if claims.ResourceType != resourceType || claims.ResourceID != resourceID {
|
||||
return nil, false
|
||||
}
|
||||
return claims, true
|
||||
}
|
||||
|
||||
func (ra *ResourceAuth) signVisitorToken(userID uint, resourceType string, resourceID uint, ttlSeconds int) (string, error) {
|
||||
if ttlSeconds <= 0 {
|
||||
ttlSeconds = 7200
|
||||
}
|
||||
claims := VisitorClaims{
|
||||
UserID: userID,
|
||||
ResourceType: resourceType,
|
||||
ResourceID: resourceID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(ttlSeconds) * time.Second)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(ra.jwtSecret))
|
||||
}
|
||||
|
||||
func (ra *ResourceAuth) parseVisitorToken(tokenStr string) (*VisitorClaims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &VisitorClaims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(ra.jwtSecret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*VisitorClaims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (ra *ResourceAuth) renderVisitorLogin(w http.ResponseWriter, r *http.Request, cfg VisitorConfig, errMsg string) {
|
||||
redirect := SafeRedirectPath(r.URL.Query().Get("redirect"))
|
||||
errHTML := ""
|
||||
if errMsg != "" {
|
||||
errHTML = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errMsg))
|
||||
}
|
||||
page := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>登录验证</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f1419;color:#e7e9ea}
|
||||
.card{width:100%%;max-width:380px;padding:32px 28px;background:#16181c;border:1px solid #2f3336;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.35)}
|
||||
h1{margin:0 0 8px;font-size:22px;font-weight:600}
|
||||
.sub{margin:0 0 24px;color:#71767b;font-size:14px}
|
||||
label{display:block;margin-bottom:6px;font-size:13px;color:#71767b}
|
||||
input{width:100%%;padding:10px 12px;margin-bottom:14px;border:1px solid #2f3336;border-radius:8px;background:#000;color:#e7e9ea;font-size:15px}
|
||||
input:focus{outline:none;border-color:#1d9bf0}
|
||||
button{width:100%%;padding:11px;border:none;border-radius:999px;background:#1d9bf0;color:#fff;font-size:15px;font-weight:600;cursor:pointer}
|
||||
button:hover{background:#1a8cd8}
|
||||
.error{color:#f4212e;font-size:13px;margin:0 0 12px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>登录验证</h1>
|
||||
<p class="sub">此资源已开启访客登录,请使用分配的账号访问。</p>
|
||||
%s
|
||||
<form method="POST" action="%s">
|
||||
<input type="hidden" name="redirect" value="%s"/>
|
||||
<label>用户名</label>
|
||||
<input name="username" autocomplete="username" required autofocus/>
|
||||
<label>密码</label>
|
||||
<input name="password" type="password" autocomplete="current-password" required/>
|
||||
<button type="submit">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, errHTML, visitorLoginPath, html.EscapeString(redirect))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(page))
|
||||
}
|
||||
|
||||
func (ra *ResourceAuth) renderVisitorLoginBlocked(w http.ResponseWriter, r *http.Request, _ VisitorConfig, retryAfter time.Duration) {
|
||||
redirect := SafeRedirectPath(r.URL.Query().Get("redirect"))
|
||||
minutes := int(retryAfter.Minutes())
|
||||
if minutes < 1 {
|
||||
minutes = 1
|
||||
}
|
||||
msg := fmt.Sprintf("登录尝试次数过多,请 %d 分钟后再试", minutes)
|
||||
if retryAfter < time.Minute {
|
||||
secs := int(retryAfter.Seconds())
|
||||
if secs < 1 {
|
||||
secs = 1
|
||||
}
|
||||
msg = fmt.Sprintf("登录尝试次数过多,请 %d 秒后再试", secs)
|
||||
}
|
||||
retrySecs := int(retryAfter.Seconds()) + 1
|
||||
if retrySecs < 1 {
|
||||
retrySecs = 1
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retrySecs))
|
||||
loginURL := visitorLoginPath + "?redirect=" + url.QueryEscape(redirect)
|
||||
page := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>登录验证</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f1419;color:#e7e9ea}
|
||||
.card{width:100%%;max-width:380px;padding:32px 28px;background:#16181c;border:1px solid #2f3336;border-radius:16px;box-shadow:0 8px 32px rgba(0,0,0,.35)}
|
||||
h1{margin:0 0 8px;font-size:22px;font-weight:600}
|
||||
.sub{margin:0 0 16px;color:#71767b;font-size:14px}
|
||||
.error{color:#f4212e;font-size:14px;margin:0 0 16px;line-height:1.5}
|
||||
a{color:#1d9bf0;text-decoration:none}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>登录验证</h1>
|
||||
<p class="sub">此资源已开启访客登录,请使用分配的账号访问。</p>
|
||||
<p class="error">%s</p>
|
||||
<p class="sub"><a href="%s">稍后再试</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`, html.EscapeString(msg), html.EscapeString(loginURL))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = w.Write([]byte(page))
|
||||
}
|
||||
|
||||
func GetVisitorConfig(c *cache.Manager, resourceType string, resourceID uint) (VisitorConfig, []uint, bool) {
|
||||
switch resourceType {
|
||||
case "host":
|
||||
h, ok := c.GetHost(resourceID)
|
||||
if !ok {
|
||||
return VisitorConfig{}, nil, false
|
||||
}
|
||||
cfg := visitorConfigFromHost(h)
|
||||
ids, _ := c.GetResourceVisitorIDs(resourceType, resourceID)
|
||||
return cfg, ids, true
|
||||
case "tunnel":
|
||||
t, ok := c.GetTunnel(resourceID)
|
||||
if !ok {
|
||||
return VisitorConfig{}, nil, false
|
||||
}
|
||||
cfg := visitorConfigFromTunnel(t)
|
||||
ids, _ := c.GetResourceVisitorIDs(resourceType, resourceID)
|
||||
return cfg, ids, true
|
||||
default:
|
||||
return VisitorConfig{}, nil, false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user