Files
Wormhole/internal/auth/resource.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

181 lines
5.0 KiB
Go

package auth
import (
"crypto/subtle"
"fmt"
"net"
"net/http"
"sync"
"time"
"github.com/wormhole/wormhole/internal/cache"
"github.com/wormhole/wormhole/internal/config"
"github.com/wormhole/wormhole/internal/store"
)
const accessCookieName = "wh_access"
type ResourceAuth struct {
cache *cache.Manager
store *store.Store
jwtSecret string
limiter *LoginLimiter
mu sync.RWMutex
grants map[string]time.Time
}
func NewResourceAuth(c *cache.Manager, st *store.Store, authCfg *config.AuthConfig) *ResourceAuth {
ra := &ResourceAuth{
cache: c,
store: st,
jwtSecret: authCfg.JWTSecret,
limiter: NewLoginLimiter(authCfg),
grants: make(map[string]time.Time),
}
go ra.cleanupLoop()
return ra
}
func (ra *ResourceAuth) ReloadAuth(cfg *config.AuthConfig) {
ra.mu.Lock()
defer ra.mu.Unlock()
ra.jwtSecret = cfg.JWTSecret
ra.limiter = NewLoginLimiter(cfg)
}
func grantKey(resourceType string, resourceID uint, ip string) string {
return fmt.Sprintf("%s:%d:%s", resourceType, resourceID, ip)
}
func (ra *ResourceAuth) cleanupLoop() {
ticker := time.NewTicker(time.Minute)
for range ticker.C {
now := time.Now()
ra.mu.Lock()
for k, exp := range ra.grants {
if now.After(exp) {
delete(ra.grants, k)
}
}
ra.mu.Unlock()
}
}
func (ra *ResourceAuth) GrantAccess(resourceType string, resourceID uint, ip string, ttl time.Duration) {
ra.mu.Lock()
defer ra.mu.Unlock()
ra.grants[grantKey(resourceType, resourceID, ip)] = time.Now().Add(ttl)
}
func (ra *ResourceAuth) HasAccess(resourceType string, resourceID uint, ip string) bool {
ra.mu.RLock()
defer ra.mu.RUnlock()
exp, ok := ra.grants[grantKey(resourceType, resourceID, ip)]
return ok && time.Now().Before(exp)
}
func (ra *ResourceAuth) CheckHTTP(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uint) bool {
cfg, userIDs, ok := GetVisitorConfig(ra.cache, resourceType, resourceID)
if ok && cfg.Enabled {
return ra.CheckVisitorHTTP(w, r, resourceType, resourceID, cfg, userIDs)
}
ip := DirectRemoteIP(r)
if ra.HasAccess(resourceType, resourceID, ip) {
return true
}
policy, ok := ra.cache.GetAuthPolicy(resourceType, resourceID)
if !ok || !policy.Enabled {
return true
}
switch policy.Type {
case "basic":
_, pass, ok := r.BasicAuth()
if ok && subtle.ConstantTimeCompare([]byte(pass), []byte(policy.Password)) == 1 {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
return true
}
w.Header().Set("WWW-Authenticate", `Basic realm="Wormhole"`)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return false
case "form":
if r.URL.Path == "/_wormhole/auth" && r.Method == http.MethodPost {
_ = r.ParseForm()
pass := r.FormValue("password")
if subtle.ConstantTimeCompare([]byte(pass), []byte(policy.Password)) == 1 {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
http.SetCookie(w, &http.Cookie{Name: accessCookieName, Value: "1", Path: "/", MaxAge: policy.TTLSeconds})
redirect := SafeRedirectPath(r.FormValue("redirect"))
http.Redirect(w, r, redirect, http.StatusFound)
return false
}
}
if cookie, err := r.Cookie(accessCookieName); err == nil && cookie.Value == "1" {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
return true
}
redirect := r.URL.RequestURI()
html := fmt.Sprintf(`<!DOCTYPE html><html><body>
<form method="POST" action="/_wormhole/auth">
<input type="hidden" name="redirect" value="%s"/>
<input type="password" name="password" placeholder="Password"/>
<button type="submit">Login</button>
</form></body></html>`, redirect)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(html))
return false
case "callback":
if policy.CallbackURL != "" {
token := r.URL.Query().Get("token")
if token != "" && token == policy.Password {
ra.GrantAccess(resourceType, resourceID, ip, time.Duration(policy.TTLSeconds)*time.Second)
return true
}
cb := policy.CallbackURL
if stringsContains(cb, "?") {
cb += "&"
} else {
cb += "?"
}
cb += "redirect=" + r.URL.RequestURI()
http.Redirect(w, r, cb, http.StatusFound)
return false
}
}
return true
}
func (ra *ResourceAuth) CheckTCP(remoteAddr net.Addr, resourceType string, resourceID uint) bool {
ip := remoteAddr.String()
if host, _, err := net.SplitHostPort(ip); err == nil {
ip = host
}
if ra.HasAccess(resourceType, resourceID, ip) {
return true
}
policy, ok := ra.cache.GetAuthPolicy(resourceType, resourceID)
if !ok || !policy.Enabled {
return true
}
return false
}
func stringsContains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(sub) == 0 || indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
func (ra *ResourceAuth) CreatePolicy(st *store.Store, p *store.AuthPolicy) error {
return st.CreateAuthPolicy(p)
}