Files
Wormhole/internal/auth/visitor.go
T
rose_cat707 0d84b07f68
CI / docker (push) Successful in 1m58s
feat: Wormhole 内网穿透与反向代理网关
Go + Vue 管理台:Agent 隧道、域名反代、IP 安全、访客验证与监控大盘。
Gitea Actions CI 多架构镜像;纯 Go SQLite、无 CGO Docker 构建。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 17:52:43 +08:00

338 lines
10 KiB
Go

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
}
}