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,55 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DirectRemoteIP returns the direct TCP peer address, ignoring X-Forwarded-For.
|
||||
// Use for security decisions (login rate limit, IP rules, resource auth).
|
||||
func DirectRemoteIP(r *http.Request) string {
|
||||
return HostOnly(r.RemoteAddr)
|
||||
}
|
||||
|
||||
// AddrHost extracts the host portion from a net.Addr string (host:port or [host]:port).
|
||||
func AddrHost(addr net.Addr) string {
|
||||
if addr == nil {
|
||||
return ""
|
||||
}
|
||||
return HostOnly(addr.String())
|
||||
}
|
||||
|
||||
// HostOnly strips the port from an address string.
|
||||
func HostOnly(addr string) string {
|
||||
if addr == "" {
|
||||
return ""
|
||||
}
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return strings.Trim(addr, "[]")
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// ExtractRemoteIP returns the client IP, honoring X-Forwarded-For when present.
|
||||
// Use only when forwarding visitor IP to upstream backends behind a trusted proxy.
|
||||
func ExtractRemoteIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
parts := strings.Split(xff, ",")
|
||||
return strings.TrimSpace(parts[0])
|
||||
}
|
||||
if xri := r.Header.Get("X-Real-IP"); xri != "" {
|
||||
return strings.TrimSpace(xri)
|
||||
}
|
||||
return DirectRemoteIP(r)
|
||||
}
|
||||
|
||||
// SafeRedirectPath validates a post-login redirect path (relative only).
|
||||
func SafeRedirectPath(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || !strings.HasPrefix(raw, "/") || strings.HasPrefix(raw, "//") {
|
||||
return "/"
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/wormhole/wormhole/internal/config"
|
||||
"github.com/wormhole/wormhole/internal/store"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
mu sync.RWMutex
|
||||
cfg *config.AuthConfig
|
||||
limiter *LoginLimiter
|
||||
}
|
||||
|
||||
func NewService(cfg *config.AuthConfig) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
limiter: NewLoginLimiter(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ReloadAuth(cfg *config.AuthConfig) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
*s.cfg = *cfg
|
||||
s.limiter = NewLoginLimiter(cfg)
|
||||
}
|
||||
|
||||
func (s *Service) Login(st *store.Store, ip, username, password string) (string, *store.User, error) {
|
||||
s.mu.RLock()
|
||||
limiter := s.limiter
|
||||
s.mu.RUnlock()
|
||||
if retry, blocked := limiter.IsBlocked(ip, username); blocked {
|
||||
return "", nil, &LoginBlockedError{RetryAfter: retry}
|
||||
}
|
||||
|
||||
user, err := st.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
limiter.RecordFailure(ip, username)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
return "", nil, errors.New("invalid credentials")
|
||||
}
|
||||
if !user.Status {
|
||||
limiter.RecordFailure(ip, username)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
return "", nil, errors.New("invalid credentials")
|
||||
}
|
||||
if !store.CheckPassword(user.PasswordHash, password) {
|
||||
limiter.RecordFailure(ip, username)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
return "", nil, errors.New("invalid credentials")
|
||||
}
|
||||
if user.Role == "visitor" {
|
||||
limiter.RecordFailure(ip, username)
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
return "", nil, errors.New("访客账号无法登录管理后台")
|
||||
}
|
||||
|
||||
limiter.Reset(ip, username)
|
||||
token, err := s.GenerateToken(user)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return token, user, nil
|
||||
}
|
||||
|
||||
func (s *Service) GenerateToken(user *store.User) (string, error) {
|
||||
s.mu.RLock()
|
||||
cfg := s.cfg
|
||||
s.mu.RUnlock()
|
||||
claims := Claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cfg.TokenTTL)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(cfg.JWTSecret))
|
||||
}
|
||||
|
||||
func (s *Service) ParseToken(tokenStr string) (*Claims, error) {
|
||||
s.mu.RLock()
|
||||
secret := s.cfg.JWTSecret
|
||||
s.mu.RUnlock()
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||
return []byte(secret), nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, errors.New("invalid token")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func IsAdmin(role string) bool {
|
||||
return role == "admin"
|
||||
}
|
||||
|
||||
func IsVisitor(role string) bool {
|
||||
return role == "visitor"
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wormhole/wormhole/internal/config"
|
||||
)
|
||||
|
||||
type LoginBlockedError struct {
|
||||
RetryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *LoginBlockedError) Error() string {
|
||||
return "too many login attempts"
|
||||
}
|
||||
|
||||
type loginRecord struct {
|
||||
failures int
|
||||
windowStart time.Time
|
||||
lockedUntil time.Time
|
||||
}
|
||||
|
||||
type LoginLimiter struct {
|
||||
mu sync.Mutex
|
||||
maxAttempts int
|
||||
window time.Duration
|
||||
lockout time.Duration
|
||||
records map[string]*loginRecord
|
||||
}
|
||||
|
||||
func NewLoginLimiter(cfg *config.AuthConfig) *LoginLimiter {
|
||||
if cfg.LoginMaxAttempts <= 0 {
|
||||
return nil
|
||||
}
|
||||
max := cfg.LoginMaxAttempts
|
||||
window := cfg.LoginWindow
|
||||
lockout := cfg.LoginLockout
|
||||
if window <= 0 {
|
||||
window = 5 * time.Minute
|
||||
}
|
||||
if lockout <= 0 {
|
||||
lockout = 15 * time.Minute
|
||||
}
|
||||
return &LoginLimiter{
|
||||
maxAttempts: max,
|
||||
window: window,
|
||||
lockout: lockout,
|
||||
records: make(map[string]*loginRecord),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *LoginLimiter) keys(ip, username string) []string {
|
||||
keys := []string{"ip:" + ip}
|
||||
if username != "" {
|
||||
keys = append(keys, "user:"+username)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func (l *LoginLimiter) IsBlocked(ip, username string) (time.Duration, bool) {
|
||||
if l == nil {
|
||||
return 0, false
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
var maxRetry time.Duration
|
||||
for _, key := range l.keys(ip, username) {
|
||||
rec := l.records[key]
|
||||
if rec == nil {
|
||||
continue
|
||||
}
|
||||
if now.Before(rec.lockedUntil) {
|
||||
retry := time.Until(rec.lockedUntil)
|
||||
if retry > maxRetry {
|
||||
maxRetry = retry
|
||||
}
|
||||
}
|
||||
}
|
||||
return maxRetry, maxRetry > 0
|
||||
}
|
||||
|
||||
func (l *LoginLimiter) RecordFailure(ip, username string) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
for _, key := range l.keys(ip, username) {
|
||||
rec := l.records[key]
|
||||
if rec == nil {
|
||||
rec = &loginRecord{windowStart: now}
|
||||
l.records[key] = rec
|
||||
}
|
||||
if now.Before(rec.lockedUntil) {
|
||||
continue
|
||||
}
|
||||
if now.Sub(rec.windowStart) > l.window {
|
||||
rec.failures = 0
|
||||
rec.windowStart = now
|
||||
}
|
||||
rec.failures++
|
||||
if rec.failures >= l.maxAttempts {
|
||||
rec.lockedUntil = now.Add(l.lockout)
|
||||
rec.failures = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (l *LoginLimiter) Reset(ip, username string) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
for _, key := range l.keys(ip, username) {
|
||||
delete(l.records, key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wormhole/wormhole/internal/config"
|
||||
)
|
||||
|
||||
func TestLoginLimiterBlocksAfterMaxAttempts(t *testing.T) {
|
||||
l := NewLoginLimiter(&config.AuthConfig{
|
||||
LoginMaxAttempts: 3,
|
||||
LoginWindow: time.Minute,
|
||||
LoginLockout: 10 * time.Minute,
|
||||
})
|
||||
|
||||
ip := "192.168.1.1"
|
||||
user := "admin"
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, blocked := l.IsBlocked(ip, user); blocked {
|
||||
t.Fatalf("unexpected block at attempt %d", i+1)
|
||||
}
|
||||
l.RecordFailure(ip, user)
|
||||
}
|
||||
|
||||
retry, blocked := l.IsBlocked(ip, user)
|
||||
if !blocked || retry <= 0 {
|
||||
t.Fatalf("expected block after max attempts, retry=%v blocked=%v", retry, blocked)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginLimiterResetClearsBlock(t *testing.T) {
|
||||
l := NewLoginLimiter(&config.AuthConfig{
|
||||
LoginMaxAttempts: 2,
|
||||
LoginWindow: time.Minute,
|
||||
LoginLockout: time.Minute,
|
||||
})
|
||||
|
||||
ip := "10.0.0.1"
|
||||
l.RecordFailure(ip, "u1")
|
||||
l.RecordFailure(ip, "u1")
|
||||
if _, blocked := l.IsBlocked(ip, "u1"); !blocked {
|
||||
t.Fatal("expected blocked")
|
||||
}
|
||||
|
||||
l.Reset(ip, "u1")
|
||||
if _, blocked := l.IsBlocked(ip, "u1"); blocked {
|
||||
t.Fatal("expected unblocked after reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginBlockedError(t *testing.T) {
|
||||
err := &LoginBlockedError{RetryAfter: 30 * time.Second}
|
||||
if !errors.As(err, new(*LoginBlockedError)) {
|
||||
t.Fatal("LoginBlockedError should be detectable via errors.As")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/wormhole/wormhole/internal/store"
|
||||
)
|
||||
|
||||
func shouldLogVisitorAccess(reqPath string) bool {
|
||||
if reqPath == visitorLoginPath {
|
||||
return false
|
||||
}
|
||||
base := strings.Split(reqPath, "?")[0]
|
||||
ext := strings.ToLower(path.Ext(base))
|
||||
switch ext {
|
||||
case ".js", ".css", ".ico", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
|
||||
".woff", ".woff2", ".ttf", ".eot", ".map", ".wasm":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (ra *ResourceAuth) logVisitorAccess(userID uint, resourceType string, resourceID uint, action string, r *http.Request) {
|
||||
if ra.store == nil {
|
||||
return
|
||||
}
|
||||
reqPath := r.URL.RequestURI()
|
||||
if action == "access" && !shouldLogVisitorAccess(r.URL.Path) {
|
||||
return
|
||||
}
|
||||
if len(reqPath) > 512 {
|
||||
reqPath = reqPath[:512]
|
||||
}
|
||||
ua := r.UserAgent()
|
||||
if len(ua) > 256 {
|
||||
ua = ua[:256]
|
||||
}
|
||||
entry := &store.VisitorAccessLog{
|
||||
UserID: userID,
|
||||
ResourceType: resourceType,
|
||||
ResourceID: resourceID,
|
||||
Action: action,
|
||||
Method: r.Method,
|
||||
Path: reqPath,
|
||||
IP: ExtractRemoteIP(r),
|
||||
UserAgent: ua,
|
||||
}
|
||||
go func() {
|
||||
_ = ra.store.AppendVisitorAccessLog(entry)
|
||||
}()
|
||||
}
|
||||
Reference in New Issue
Block a user