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(`
%s
`, html.EscapeString(errMsg)) } page := fmt.Sprintf(`此资源已开启访客登录,请使用分配的账号访问。
%s