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>
181 lines
5.0 KiB
Go
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)
|
|
}
|