feat: require regional W-ID login for browser pages #1
@@ -169,3 +169,43 @@ UDP 示例:
|
||||
git remote add origin <gitea-repo-url>
|
||||
git push -u origin codex/go-network-stability-tester
|
||||
```
|
||||
|
||||
### Regional browser login
|
||||
|
||||
The default HTML page and all static assets now require this instance's W-ID
|
||||
browser session. Anonymous requests receive a plain login page without the
|
||||
business bundle or tracking information. Configure these environment values
|
||||
at runtime (never embed credentials in a binary or a command line):
|
||||
|
||||
- `NETSTABLE_PUBLIC_URL`: this deployment's canonical HTTPS origin.
|
||||
- `NETSTABLE_WID_ISSUER`: its regional W-ID HTTPS origin.
|
||||
- `NETSTABLE_WID_CLIENT_ID`: the client registered in that same realm.
|
||||
- `NETSTABLE_WID_CLIENT_SECRET`: optional, only for a confidential client.
|
||||
|
||||
Register the exact callback `NETSTABLE_PUBLIC_URL/auth/callback` with W-ID,
|
||||
allow authorization-code + S256 PKCE, and allow `openid profile`. Authorization,
|
||||
token and userinfo use the issuer's `/oauth/authorize`, `/oauth/token` and
|
||||
`/oauth/userinfo` endpoints. The public origin and issuer cannot include a path,
|
||||
query, fragment or embedded credentials. A missing configuration returns a
|
||||
minimal login-unavailable page with HTTP 503; partial/invalid configuration
|
||||
stops startup. No browser session, W-ID account or product role is fabricated.
|
||||
|
||||
State is browser-bound, one-use and expires after five minutes. The opaque
|
||||
HttpOnly/Secure host-only session lasts at most 15 minutes or the upstream token
|
||||
lifetime, whichever is shorter. Both maps are bounded and held in memory;
|
||||
restarts require login again. Tokens and identity data are not persisted.
|
||||
Only a successful token exchange and authenticated W-ID userinfo response with
|
||||
`sub` create a session. Upstream requests have a ten-second timeout and do not
|
||||
follow redirects. W-ID realm changes require a process restart and thus clear
|
||||
old sessions.
|
||||
|
||||
This change protects browser HTML/static resources. Existing documented public
|
||||
CLI downloads, test/queue/transfer/iperf3 APIs, anonymized record API and health
|
||||
stream keep their existing protocol and access rules. It does not introduce
|
||||
platform administrator rights or product tenant memberships.
|
||||
|
||||
Production activation is a separate operation. The binary observed on cn244
|
||||
on 2026-11-09 identifies itself as commit `4df04d1` with local modifications.
|
||||
The current source baseline `4d8a295` also contains later record-completion and
|
||||
legacy-SSE removals. Reconcile the production dirty source before replacing it;
|
||||
this source change alone is not evidence that activation preserves that runtime.
|
||||
|
||||
@@ -49,6 +49,15 @@ func runServer(args []string) {
|
||||
iperf3Path := flags.String("iperf3-path", "iperf3", "iperf3 executable path for one-off CLI iperf3 sessions")
|
||||
_ = flags.Parse(args)
|
||||
|
||||
browserLogin, err := appweb.NewBrowserLogin(appweb.BrowserAuth{
|
||||
PublicURL: os.Getenv("NETSTABLE_PUBLIC_URL"),
|
||||
Issuer: os.Getenv("NETSTABLE_WID_ISSUER"),
|
||||
ClientID: os.Getenv("NETSTABLE_WID_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("NETSTABLE_WID_CLIENT_SECRET"),
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal("invalid browser login configuration: ", err)
|
||||
}
|
||||
store := recordstore.New(*dataPath)
|
||||
var resolvers []appweb.ClientResolver
|
||||
if db, err := ipdata.FileSystem().ReadFile("ip2region_v4.xdb"); err != nil {
|
||||
@@ -71,6 +80,7 @@ func runServer(args []string) {
|
||||
resolver := chainResolvers(resolvers...)
|
||||
|
||||
options := appweb.Options{
|
||||
BrowserLogin: browserLogin,
|
||||
Store: store,
|
||||
ClientResolver: resolver,
|
||||
TrustProxyHeaders: *trustProxyHeaders,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BrowserAuth configures this instance's W-ID realm; it never grants tenant or platform roles.
|
||||
type BrowserAuth struct {
|
||||
PublicURL string
|
||||
Issuer string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
}
|
||||
|
||||
type loginAttempt struct {
|
||||
verifier string
|
||||
expires time.Time
|
||||
}
|
||||
type BrowserLogin struct {
|
||||
config BrowserAuth
|
||||
client *http.Client
|
||||
mu sync.Mutex
|
||||
pending map[string]loginAttempt
|
||||
sessions map[string]time.Time
|
||||
}
|
||||
|
||||
// NewBrowserLogin validates explicit regional endpoints, without remote discovery at startup.
|
||||
func NewBrowserLogin(config BrowserAuth) (*BrowserLogin, error) {
|
||||
a := &BrowserLogin{config: config, client: &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}, pending: make(map[string]loginAttempt), sessions: make(map[string]time.Time)}
|
||||
if config == (BrowserAuth{}) {
|
||||
return a, nil
|
||||
}
|
||||
if config.ClientID == "" {
|
||||
return nil, errors.New("W-ID client ID is required")
|
||||
}
|
||||
for i, raw := range []string{config.PublicURL, config.Issuer} {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.Fragment != "" || u.RawQuery != "" {
|
||||
return nil, errors.New("W-ID URLs must be explicit HTTPS URLs without credentials, query or fragment")
|
||||
}
|
||||
if i == 0 && u.Path != "" && u.Path != "/" {
|
||||
return nil, errors.New("public URL must be an HTTPS origin")
|
||||
}
|
||||
if i == 1 && u.Path != "" && u.Path != "/" {
|
||||
return nil, errors.New("W-ID issuer must be an HTTPS origin")
|
||||
}
|
||||
}
|
||||
a.config.PublicURL = strings.TrimRight(config.PublicURL, "/")
|
||||
a.config.Issuer = strings.TrimRight(config.Issuer, "/")
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func randomLoginToken() (string, error) {
|
||||
var b [32]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
func (a *BrowserLogin) pruneLocked(now time.Time) {
|
||||
for key, value := range a.pending {
|
||||
if !now.Before(value.expires) {
|
||||
delete(a.pending, key)
|
||||
}
|
||||
}
|
||||
for key, expiry := range a.sessions {
|
||||
if !now.Before(expiry) {
|
||||
delete(a.sessions, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loginCookie(w http.ResponseWriter, name, value, path string, lifetime int) {
|
||||
http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: path, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: lifetime})
|
||||
}
|
||||
|
||||
var loginPage = template.Must(template.New("login").Parse(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>登录</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f5f6f8;color:#20242c;font:16px system-ui}main{text-align:center;padding:36px;border-radius:18px;background:white;box-shadow:0 8px 36px #0001}a{display:inline-block;background:#20242c;color:white;text-decoration:none;border-radius:9px;padding:12px 30px}p{color:#68707c}</style><main><h1>登录</h1>{{if .}}<a href="/auth/login">使用 W-ID 登录</a>{{else}}<p>登录暂不可用</p>{{end}}</main></html>`))
|
||||
|
||||
func (a *BrowserLogin) page(w http.ResponseWriter, r *http.Request, status int) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.WriteHeader(status)
|
||||
if r.Method != http.MethodHead {
|
||||
_ = loginPage.Execute(w, a.config.ClientID != "")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *BrowserLogin) protect(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.pruneLocked(time.Now())
|
||||
cookie, err := r.Cookie("__Host-netstable-session")
|
||||
authenticated := false
|
||||
if err == nil {
|
||||
expiry, ok := a.sessions[cookie.Value]
|
||||
authenticated = ok && time.Now().Before(expiry)
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if authenticated {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if a.config.ClientID == "" {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
a.page(w, r, status)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *BrowserLogin) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if a.config.ClientID == "" {
|
||||
a.page(w, r, http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
state, err := randomLoginToken()
|
||||
if err != nil {
|
||||
http.Error(w, "登录暂不可用", 503)
|
||||
return
|
||||
}
|
||||
verifier, err := randomLoginToken()
|
||||
if err != nil {
|
||||
http.Error(w, "登录暂不可用", 503)
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.pruneLocked(time.Now())
|
||||
if len(a.pending) >= 4096 {
|
||||
a.mu.Unlock()
|
||||
http.Error(w, "登录暂不可用", 503)
|
||||
return
|
||||
}
|
||||
a.pending[state] = loginAttempt{verifier: verifier, expires: time.Now().Add(5 * time.Minute)}
|
||||
a.mu.Unlock()
|
||||
loginCookie(w, "__Host-netstable-state", state, "/", 300)
|
||||
challenge := sha256.Sum256([]byte(verifier))
|
||||
u, _ := url.Parse(a.config.Issuer + "/oauth/authorize")
|
||||
q := u.Query()
|
||||
q.Set("client_id", a.config.ClientID)
|
||||
q.Set("response_type", "code")
|
||||
q.Set("scope", "openid profile")
|
||||
q.Set("redirect_uri", a.config.PublicURL+"/auth/callback")
|
||||
q.Set("state", state)
|
||||
q.Set("code_challenge_method", "S256")
|
||||
q.Set("code_challenge", base64.RawURLEncoding.EncodeToString(challenge[:]))
|
||||
u.RawQuery = q.Encode()
|
||||
http.Redirect(w, r, u.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *BrowserLogin) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
cookie, err := r.Cookie("__Host-netstable-state")
|
||||
state := r.URL.Query().Get("state")
|
||||
if err != nil || state == "" || cookie.Value != state {
|
||||
http.Error(w, "登录失败,请重新登录", 400)
|
||||
return
|
||||
}
|
||||
loginCookie(w, "__Host-netstable-state", "", "/", -1)
|
||||
a.mu.Lock()
|
||||
a.pruneLocked(time.Now())
|
||||
attempt, ok := a.pending[state]
|
||||
delete(a.pending, state)
|
||||
a.mu.Unlock()
|
||||
code := r.URL.Query().Get("code")
|
||||
if !ok || code == "" || len(code) > 4096 || r.URL.Query().Get("error") != "" {
|
||||
http.Error(w, "登录失败,请重新登录", 400)
|
||||
return
|
||||
}
|
||||
form := url.Values{"grant_type": {"authorization_code"}, "client_id": {a.config.ClientID}, "code": {code}, "code_verifier": {attempt.verifier}, "redirect_uri": {a.config.PublicURL + "/auth/callback"}}
|
||||
if a.config.ClientSecret != "" {
|
||||
form.Set("client_secret", a.config.ClientSecret)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, a.config.Issuer+"/oauth/token", strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
http.Error(w, "登录暂不可用", 503)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
var token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
if err := a.fetch(req, &token); err != nil || token.AccessToken == "" || !strings.EqualFold(token.TokenType, "Bearer") || token.ExpiresIn <= 0 {
|
||||
http.Error(w, "登录凭证交换失败,请重新登录", 502)
|
||||
return
|
||||
}
|
||||
req, err = http.NewRequestWithContext(r.Context(), http.MethodGet, a.config.Issuer+"/oauth/userinfo", nil)
|
||||
if err != nil {
|
||||
http.Error(w, "登录暂不可用", 503)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
||||
var identity struct {
|
||||
Subject string `json:"sub"`
|
||||
}
|
||||
if err := a.fetch(req, &identity); err != nil || strings.TrimSpace(identity.Subject) == "" {
|
||||
http.Error(w, "登录验证失败,请重新登录", 502)
|
||||
return
|
||||
}
|
||||
session, err := randomLoginToken()
|
||||
if err != nil {
|
||||
http.Error(w, "登录暂不可用", 503)
|
||||
return
|
||||
}
|
||||
lifetime := int64(900)
|
||||
if token.ExpiresIn < lifetime {
|
||||
lifetime = token.ExpiresIn
|
||||
}
|
||||
a.mu.Lock()
|
||||
a.pruneLocked(time.Now())
|
||||
if len(a.sessions) >= 4096 {
|
||||
a.mu.Unlock()
|
||||
http.Error(w, "登录暂不可用", 503)
|
||||
return
|
||||
}
|
||||
if old, err := r.Cookie("__Host-netstable-session"); err == nil {
|
||||
delete(a.sessions, old.Value)
|
||||
}
|
||||
a.sessions[session] = time.Now().Add(time.Duration(lifetime) * time.Second)
|
||||
a.mu.Unlock()
|
||||
loginCookie(w, "__Host-netstable-session", session, "/", int(lifetime))
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (a *BrowserLogin) fetch(req *http.Request, target any) error {
|
||||
response, err := a.client.Do(req)
|
||||
if err != nil {
|
||||
return errors.New("W-ID request failed")
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return errors.New("W-ID rejected request")
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, 65537))
|
||||
if err != nil || len(body) > 65536 {
|
||||
return errors.New("invalid W-ID response")
|
||||
}
|
||||
return json.Unmarshal(body, target)
|
||||
}
|
||||
+13
-2
@@ -35,6 +35,7 @@ type RecordStore interface {
|
||||
type ClientResolver func(ctx context.Context, ip string) (recordstore.ClientInfo, error)
|
||||
|
||||
type Options struct {
|
||||
BrowserLogin *BrowserLogin
|
||||
Store RecordStore
|
||||
ClientResolver ClientResolver
|
||||
TrustProxyHeaders bool
|
||||
@@ -46,6 +47,7 @@ type Options struct {
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
browserLogin *BrowserLogin
|
||||
store RecordStore
|
||||
clientResolver ClientResolver
|
||||
trustProxyHeaders bool
|
||||
@@ -178,11 +180,15 @@ type iperfSession struct {
|
||||
const maxCompleteBodyBytes = 64 * 1024 * 1024
|
||||
|
||||
func New(options Options) *Server {
|
||||
if options.BrowserLogin == nil {
|
||||
options.BrowserLogin, _ = NewBrowserLogin(BrowserAuth{})
|
||||
}
|
||||
iperf3Path := strings.TrimSpace(options.Iperf3Path)
|
||||
if iperf3Path == "" {
|
||||
iperf3Path = "iperf3"
|
||||
}
|
||||
return &Server{
|
||||
browserLogin: options.BrowserLogin,
|
||||
store: options.Store,
|
||||
clientResolver: options.ClientResolver,
|
||||
trustProxyHeaders: options.TrustProxyHeaders,
|
||||
@@ -198,6 +204,8 @@ func New(options Options) *Server {
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/auth/login", s.browserLogin.handleLogin)
|
||||
mux.HandleFunc("/auth/callback", s.browserLogin.handleCallback)
|
||||
mux.HandleFunc("/api/config", s.handleConfig)
|
||||
mux.HandleFunc("/api/health/events", s.handleHealthEvents)
|
||||
mux.HandleFunc("/api/download", s.handleDownload)
|
||||
@@ -209,9 +217,12 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("/api/tests/", s.handleTestRoute)
|
||||
mux.HandleFunc("/downloads/", s.handleBinaryDownload)
|
||||
if s.staticFS != nil {
|
||||
mux.Handle("/", http.FileServer(s.staticFS))
|
||||
mux.Handle("/", s.browserLogin.protect(http.FileServer(s.staticFS)))
|
||||
} else if s.staticDir != "" {
|
||||
mux.Handle("/", http.FileServer(http.Dir(s.staticDir)))
|
||||
mux.Handle("/", s.browserLogin.protect(http.FileServer(http.Dir(s.staticDir))))
|
||||
}
|
||||
if s.staticFS == nil && s.staticDir == "" {
|
||||
mux.Handle("/", s.browserLogin.protect(http.NotFoundHandler()))
|
||||
}
|
||||
return mux
|
||||
}
|
||||
|
||||
@@ -847,7 +847,7 @@ func TestSSEUnknownSessionReturnsNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerServesStaticFilesFromFS(t *testing.T) {
|
||||
func TestHandlerServesLoginInsteadOfAnonymousStaticFiles(t *testing.T) {
|
||||
staticFS := http.FS(fstest.MapFS{
|
||||
"index.html": &fstest.MapFile{Data: []byte("<html>dashboard</html>")},
|
||||
})
|
||||
@@ -857,11 +857,11 @@ func TestHandlerServesStaticFilesFromFS(t *testing.T) {
|
||||
res := httptest.NewRecorder()
|
||||
server.Handler().ServeHTTP(res, req)
|
||||
|
||||
if res.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", res.Code, res.Body.String())
|
||||
if res.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503; body=%s", res.Code, res.Body.String())
|
||||
}
|
||||
if !strings.Contains(res.Body.String(), "dashboard") {
|
||||
t.Fatalf("body = %q, want dashboard content", res.Body.String())
|
||||
if strings.Contains(res.Body.String(), "dashboard") || !strings.Contains(res.Body.String(), "登录暂不可用") {
|
||||
t.Fatalf("body = %q, want only the login-unavailable page", res.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user