diff options
Diffstat (limited to 'client/web/web.go')
| -rw-r--r-- | client/web/web.go | 293 |
1 files changed, 16 insertions, 277 deletions
diff --git a/client/web/web.go b/client/web/web.go index 24c303ee0..4a674ab56 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -5,12 +5,9 @@ package web import ( - "bytes" "context" "crypto/rand" - "encoding/base64" "encoding/json" - "errors" "fmt" "io" "log" @@ -20,7 +17,6 @@ import ( "path/filepath" "slices" "strings" - "sync" "time" "github.com/gorilla/csrf" @@ -53,75 +49,19 @@ type Server struct { assetsHandler http.Handler // serves frontend assets assetsCleanup func() // called from Server.Shutdown - // browserSessions is an in-memory cache of browser sessions for the - // full management web client, which is only accessible over Tailscale. - // - // Users obtain a valid browser session by connecting to the web client - // over Tailscale and verifying their identity by authenticating on the - // control server. - // - // browserSessions get reset on every Server restart. - // - // The map provides a lookup of the session by cookie value - // (browserSession.ID => browserSession). - browserSessions sync.Map + auth authServer } -const ( - sessionCookieName = "TS-Web-Session" - sessionCookieExpiry = time.Hour * 24 * 30 // 30 days -) +type authServer interface { + IsLoggedIn(r *http.Request) bool + ServeLogin(w http.ResponseWriter, r *http.Request) // serves /api/auth back to the frontend +} var ( exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0") exitNodeRouteV6 = netip.MustParsePrefix("::/0") ) -// browserSession holds data about a user's browser session -// on the full management web client. -type browserSession struct { - // ID is the unique identifier for the session. - // It is passed in the user's "TS-Web-Session" browser cookie. - ID string - SrcNode tailcfg.NodeID - SrcUser tailcfg.UserID - AuthID string // from tailcfg.WebClientAuthResponse - AuthURL string // from tailcfg.WebClientAuthResponse - Created time.Time - Authenticated bool -} - -// isAuthorized reports true if the given session is authorized -// to be used by its associated user to access the full management -// web client. -// -// isAuthorized is true only when s.Authenticated is true (i.e. -// the user has authenticated the session) and the session is not -// expired. -// 2023-10-05: Sessions expire by default 30 days after creation. -func (s *browserSession) isAuthorized(now time.Time) bool { - switch { - case s == nil: - return false - case !s.Authenticated: - return false // awaiting auth - case s.isExpired(now): - return false // expired - } - return true -} - -// isExpired reports true if s is expired. -// 2023-10-05: Sessions expire by default 30 days after creation. -func (s *browserSession) isExpired(now time.Time) bool { - return !s.Created.IsZero() && now.After(s.expires()) -} - -// expires reports when the given session expires. -func (s *browserSession) expires() time.Time { - return s.Created.Add(sessionCookieExpiry) -} - // ServerOpts contains options for constructing a new Server. type ServerOpts struct { DevMode bool @@ -136,6 +76,9 @@ type ServerOpts struct { // If nil, a new one will be created. LocalClient *tailscale.LocalClient + // TODO: docs + AuthServer authServer + // TimeNow optionally provides a time function. // time.Now is used as default. TimeNow func() time.Time @@ -158,6 +101,7 @@ func NewServer(opts ServerOpts) (s *Server, err error) { cgiMode: opts.CGIMode, pathPrefix: opts.PathPrefix, timeNow: opts.TimeNow, + auth: opts.AuthServer, } if s.timeNow == nil { s.timeNow = time.Now @@ -165,6 +109,9 @@ func NewServer(opts ServerOpts) (s *Server, err error) { if s.logf == nil { s.logf = log.Printf } + if s.auth == nil { + // todo: default to platform auth? + } s.tsDebugMode = s.debugMode() s.assetsHandler, s.assetsCleanup = assetsHandler(opts.DevMode) @@ -266,11 +213,10 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo case strings.HasPrefix(r.URL.Path, "/api/"): // All other /api/ endpoints require a valid browser session. // - // TODO(sonia): s.getTailscaleBrowserSession calls whois again, + // TODO(sonia): s.auth.IsLoggedIn calls whois again, // should try and use the above call instead of running another // localapi request. - session, _, err := s.getTailscaleBrowserSession(r) - if err != nil || !session.isAuthorized(s.timeNow()) { + if !s.auth.IsLoggedIn(r) { http.Error(w, "no valid session", http.StatusUnauthorized) return false } @@ -316,218 +262,11 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) { return } -var ( - errNoSession = errors.New("no-browser-session") - errNotUsingTailscale = errors.New("not-using-tailscale") - errTaggedRemoteSource = errors.New("tagged-remote-source") - errTaggedLocalSource = errors.New("tagged-local-source") - errNotOwner = errors.New("not-owner") -) - -// getTailscaleBrowserSession retrieves the browser session associated with -// the request, if one exists. -// -// An error is returned in any of the following cases: -// -// - (errNotUsingTailscale) The request was not made over tailscale. -// -// - (errNoSession) The request does not have a session. -// -// - (errTaggedRemoteSource) The source is remote (another node) and tagged. -// Users must use their own user-owned devices to manage other nodes' -// web clients. -// -// - (errTaggedLocalSource) The source is local (the same node) and tagged. -// Tagged nodes can only be remotely managed, allowing ACLs to dictate -// access to web clients. -// -// - (errNotOwner) The source is not the owner of this client (if the -// client is user-owned). Only the owner is allowed to manage the -// node via the web client. -// -// If no error is returned, the browserSession is always non-nil. -// getTailscaleBrowserSession does not check whether the session has been -// authorized by the user. Callers can use browserSession.isAuthorized. -// -// The WhoIsResponse is always populated, with a non-nil Node and UserProfile, -// unless getTailscaleBrowserSession reports errNotUsingTailscale. -func (s *Server) getTailscaleBrowserSession(r *http.Request) (*browserSession, *apitype.WhoIsResponse, error) { - whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr) - status, statusErr := s.lc.StatusWithoutPeers(r.Context()) - switch { - case whoIsErr != nil: - return nil, nil, errNotUsingTailscale - case statusErr != nil: - return nil, whoIs, statusErr - case status.Self == nil: - return nil, whoIs, errors.New("missing self node in tailscale status") - case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID: - return nil, whoIs, errTaggedLocalSource - case whoIs.Node.IsTagged(): - return nil, whoIs, errTaggedRemoteSource - case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID: - return nil, whoIs, errNotOwner - } - srcNode := whoIs.Node.ID - srcUser := whoIs.UserProfile.ID - - cookie, err := r.Cookie(sessionCookieName) - if errors.Is(err, http.ErrNoCookie) { - return nil, whoIs, errNoSession - } else if err != nil { - return nil, whoIs, err - } - v, ok := s.browserSessions.Load(cookie.Value) - if !ok { - return nil, whoIs, errNoSession - } - session := v.(*browserSession) - if session.SrcNode != srcNode || session.SrcUser != srcUser { - // In this case the browser cookie is associated with another tailscale node. - // Maybe the source browser's machine was logged out and then back in as a different node. - // Return errNoSession because there is no session for this user. - return nil, whoIs, errNoSession - } else if session.isExpired(s.timeNow()) { - // Session expired, remove from session map and return errNoSession. - s.browserSessions.Delete(session.ID) - return nil, whoIs, errNoSession - } - return session, whoIs, nil -} - type authResponse struct { OK bool `json:"ok"` // true when user has valid auth session AuthURL string `json:"authUrl,omitempty"` // filled when user has control auth action to take } -func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) { - if r.Method != httpm.GET { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - var resp authResponse - - session, whois, err := s.getTailscaleBrowserSession(r) - switch { - case err != nil && !errors.Is(err, errNoSession): - http.Error(w, err.Error(), http.StatusUnauthorized) - return - case session == nil: - // Create a new session. - d, err := s.getOrAwaitAuth(r.Context(), "", whois.Node.ID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - sid, err := s.newSessionID() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - session := &browserSession{ - ID: sid, - SrcNode: whois.Node.ID, - SrcUser: whois.UserProfile.ID, - AuthID: d.ID, - AuthURL: d.URL, - Created: s.timeNow(), - } - s.browserSessions.Store(sid, session) - // Set the cookie on browser. - http.SetCookie(w, &http.Cookie{ - Name: sessionCookieName, - Value: sid, - Raw: sid, - Path: "/", - Expires: session.expires(), - }) - resp = authResponse{OK: false, AuthURL: d.URL} - case !session.isAuthorized(s.timeNow()): - if r.URL.Query().Get("wait") == "true" { - // Client requested we block until user completes auth. - d, err := s.getOrAwaitAuth(r.Context(), session.AuthID, whois.Node.ID) - if err != nil { - http.Error(w, err.Error(), http.StatusUnauthorized) - // Clean up the session. Doing this on any error from control - // server to avoid the user getting stuck with a bad session - // cookie. - s.browserSessions.Delete(session.ID) - return - } - if d.Complete { - session.Authenticated = d.Complete - s.browserSessions.Store(session.ID, session) - } - } - if session.isAuthorized(s.timeNow()) { - resp = authResponse{OK: true} - } else { - resp = authResponse{OK: false, AuthURL: session.AuthURL} - } - default: - resp = authResponse{OK: true} - } - - if err := json.NewEncoder(w).Encode(resp); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") -} - -func (s *Server) newSessionID() (string, error) { - raw := make([]byte, 16) - for i := 0; i < 5; i++ { - if _, err := rand.Read(raw); err != nil { - return "", err - } - cookie := "ts-web-" + base64.RawURLEncoding.EncodeToString(raw) - if _, ok := s.browserSessions.Load(cookie); !ok { - return cookie, nil - } - } - return "", errors.New("too many collisions generating new session; please refresh page") -} - -// getOrAwaitAuth connects to the control server for user auth, -// with the following behavior: -// -// 1. If authID is provided empty, a new auth URL is created on the control -// server and reported back here, which can then be used to redirect the -// user on the frontend. -// 2. If authID is provided non-empty, the connection to control blocks until -// the user has completed authenticating the associated auth URL, -// or until ctx is canceled. -func (s *Server) getOrAwaitAuth(ctx context.Context, authID string, src tailcfg.NodeID) (*tailcfg.WebClientAuthResponse, error) { - type data struct { - ID string - Src tailcfg.NodeID - } - var b bytes.Buffer - if err := json.NewEncoder(&b).Encode(data{ID: authID, Src: src}); err != nil { - return nil, err - } - url := "http://" + apitype.LocalAPIHost + "/localapi/v0/debug-web-client" - req, err := http.NewRequestWithContext(ctx, "POST", url, &b) - if err != nil { - return nil, err - } - resp, err := s.lc.DoLocalRequest(req) - if err != nil { - return nil, err - } - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed request: %s", body) - } - var authResp *tailcfg.WebClientAuthResponse - if err := json.Unmarshal(body, &authResp); err != nil { - return nil, err - } - return authResp, nil -} - // serveAPI serves requests for the web client api. // It should only be called by Server.ServeHTTP, via Server.apiHandler, // which protects the handler using gorilla csrf. @@ -535,9 +274,9 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-CSRF-Token", csrf.Token(r)) path := strings.TrimPrefix(r.URL.Path, "/api") switch { - case path == "/auth": + case path == "/auth" && r.Method == httpm.GET: if s.tsDebugMode == "full" { // behind debug flag - s.serveTailscaleAuth(w, r) + s.auth.ServeLogin(w, r) return } case path == "/data": |
