0d84b07f68
CI / docker (push) Successful in 1m58s
Go + Vue 管理台:Agent 隧道、域名反代、IP 安全、访客验证与监控大盘。 Gitea Actions CI 多架构镜像;纯 Go SQLite、无 CGO Docker 构建。 Co-authored-by: Cursor <cursoragent@cursor.com>
338 lines
10 KiB
Go
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
|
|
}
|
|
}
|