diff --git a/client/web/auth.go b/client/web/auth.go new file mode 100644 index 000000000..aff65d793 --- /dev/null +++ b/client/web/auth.go @@ -0,0 +1,202 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package web + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "tailscale.com/client/tailscale/apitype" + "tailscale.com/tailcfg" +) + +const ( + sessionCookieName = "TS-Web-Session" + sessionCookieExpiry = time.Hour * 24 * 30 // 30 days +) + +// 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) +} + +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 +} + +// 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 +} + +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") +} diff --git a/client/web/web.go b/client/web/web.go index 24c303ee0..97f73b683 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -5,10 +5,8 @@ package web import ( - "bytes" "context" "crypto/rand" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -67,61 +65,11 @@ type Server struct { browserSessions sync.Map } -const ( - sessionCookieName = "TS-Web-Session" - sessionCookieExpiry = time.Hour * 24 * 30 // 30 days -) - 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 @@ -316,85 +264,6 @@ 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 @@ -475,59 +344,6 @@ func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) { 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.