diff options
| author | Sonia Appasamy <sonia@tailscale.com> | 2023-10-31 18:07:50 -0400 |
|---|---|---|
| committer | Sonia Appasamy <sonia@tailscale.com> | 2023-11-01 12:57:00 -0400 |
| commit | c612cd0822146adbdf5b43bec1224fbfaf542ad8 (patch) | |
| tree | 747409af3169ef9ec4bb1121dd2f909a85b99ead | |
| parent | 44175653dcd273741a59a0648b55cd062c940cd1 (diff) | |
| download | tailscale-soniaappasamy/web-auth-restructure.tar.xz tailscale-soniaappasamy/web-auth-restructure.zip | |
Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
| -rw-r--r-- | client/web/web.go | 293 | ||||
| -rw-r--r-- | client/web/web_test.go | 533 | ||||
| -rw-r--r-- | ipn/ipnlocal/web_client.go | 3 | ||||
| -rw-r--r-- | ipn/webauth/webauth.go | 324 | ||||
| -rw-r--r-- | ipn/webauth/webauth_test.go | 496 |
5 files changed, 877 insertions, 772 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": diff --git a/client/web/web_test.go b/client/web/web_test.go index 372ebcb92..f0672e786 100644 --- a/client/web/web_test.go +++ b/client/web/web_test.go @@ -5,7 +5,6 @@ package web import ( "encoding/json" - "errors" "fmt" "io" "net/http" @@ -15,13 +14,10 @@ import ( "testing" "time" - "github.com/google/go-cmp/cmp" "tailscale.com/client/tailscale" "tailscale.com/client/tailscale/apitype" - "tailscale.com/ipn/ipnstate" "tailscale.com/net/memnet" "tailscale.com/tailcfg" - "tailscale.com/types/views" "tailscale.com/util/httpm" ) @@ -139,200 +135,34 @@ func TestServeAPI(t *testing.T) { } } -func TestGetTailscaleBrowserSession(t *testing.T) { - userA := &tailcfg.UserProfile{ID: tailcfg.UserID(1)} - userB := &tailcfg.UserProfile{ID: tailcfg.UserID(2)} - - userANodeIP := "100.100.100.101" - userBNodeIP := "100.100.100.102" - taggedNodeIP := "100.100.100.103" - - var selfNode *ipnstate.PeerStatus - tags := views.SliceOf([]string{"tag:server"}) - tailnetNodes := map[string]*apitype.WhoIsResponse{ - userANodeIP: { - Node: &tailcfg.Node{ID: 1, StableID: "1"}, - UserProfile: userA, - }, - userBNodeIP: { - Node: &tailcfg.Node{ID: 2, StableID: "2"}, - UserProfile: userB, - }, - taggedNodeIP: { - Node: &tailcfg.Node{ID: 3, StableID: "3", Tags: tags.AsSlice()}, - }, - } - - lal := memnet.Listen("local-tailscaled.sock:80") - defer lal.Close() - localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }) - defer localapi.Close() - go localapi.Serve(lal) - - s := &Server{ - timeNow: time.Now, - lc: &tailscale.LocalClient{Dial: lal.Dial}, - } - - // Add some browser sessions to cache state. - userASession := &browserSession{ - ID: "cookie1", - SrcNode: 1, - SrcUser: userA.ID, - Created: time.Now(), - Authenticated: false, // not yet authenticated - } - userBSession := &browserSession{ - ID: "cookie2", - SrcNode: 2, - SrcUser: userB.ID, - Created: time.Now().Add(-2 * sessionCookieExpiry), - Authenticated: true, // expired - } - userASessionAuthorized := &browserSession{ - ID: "cookie3", - SrcNode: 1, - SrcUser: userA.ID, - Created: time.Now(), - Authenticated: true, // authenticated and not expired - } - s.browserSessions.Store(userASession.ID, userASession) - s.browserSessions.Store(userBSession.ID, userBSession) - s.browserSessions.Store(userASessionAuthorized.ID, userASessionAuthorized) - - tests := []struct { - name string - selfNode *ipnstate.PeerStatus - remoteAddr string - cookie string - - wantSession *browserSession - wantError error - wantIsAuthorized bool // response from session.isAuthorized - }{ - { - name: "not-connected-over-tailscale", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, - remoteAddr: "77.77.77.77", - wantSession: nil, - wantError: errNotUsingTailscale, - }, - { - name: "no-session-user-self-node", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, - remoteAddr: userANodeIP, - cookie: "not-a-cookie", - wantSession: nil, - wantError: errNoSession, - }, - { - name: "no-session-tagged-self-node", - selfNode: &ipnstate.PeerStatus{ID: "self", Tags: &tags}, - remoteAddr: userANodeIP, - wantSession: nil, - wantError: errNoSession, - }, - { - name: "not-owner", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, - remoteAddr: userBNodeIP, - wantSession: nil, - wantError: errNotOwner, - }, - { - name: "tagged-remote-source", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, - remoteAddr: taggedNodeIP, - wantSession: nil, - wantError: errTaggedRemoteSource, - }, - { - name: "tagged-local-source", - selfNode: &ipnstate.PeerStatus{ID: "3"}, - remoteAddr: taggedNodeIP, // same node as selfNode - wantSession: nil, - wantError: errTaggedLocalSource, - }, - { - name: "not-tagged-local-source", - selfNode: &ipnstate.PeerStatus{ID: "1", UserID: userA.ID}, - remoteAddr: userANodeIP, // same node as selfNode - cookie: userASession.ID, - wantSession: userASession, - wantError: nil, // should not error - }, - { - name: "has-session", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, - remoteAddr: userANodeIP, - cookie: userASession.ID, - wantSession: userASession, - wantError: nil, - }, - { - name: "has-authorized-session", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, - remoteAddr: userANodeIP, - cookie: userASessionAuthorized.ID, - wantSession: userASessionAuthorized, - wantError: nil, - wantIsAuthorized: true, - }, - { - name: "session-associated-with-different-source", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID}, - remoteAddr: userBNodeIP, - cookie: userASession.ID, - wantSession: nil, - wantError: errNoSession, - }, - { - name: "session-expired", - selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID}, - remoteAddr: userBNodeIP, - cookie: userBSession.ID, - wantSession: nil, - wantError: errNoSession, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - selfNode = tt.selfNode - r := &http.Request{RemoteAddr: tt.remoteAddr, Header: http.Header{}} - if tt.cookie != "" { - r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) - } - session, _, err := s.getTailscaleBrowserSession(r) - if !errors.Is(err, tt.wantError) { - t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err) - } - if diff := cmp.Diff(session, tt.wantSession); diff != "" { - t.Errorf("wrong session; (-got+want):%v", diff) - } - if gotIsAuthorized := session.isAuthorized(s.timeNow()); gotIsAuthorized != tt.wantIsAuthorized { - t.Errorf("wrong isAuthorized; want=%v, got=%v", tt.wantIsAuthorized, gotIsAuthorized) - } - }) - } -} - // TestAuthorizeRequest tests the s.authorizeRequest function. // 2023-10-18: These tests currently cover tailscale auth mode (not platform auth). func TestAuthorizeRequest(t *testing.T) { - // Create self and remoteNode owned by same user. - // See TestGetTailscaleBrowserSession for tests of - // browser sessions w/ different users. - user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)} - self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID} - remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{StableID: "node"}, UserProfile: user} - remoteIP := "100.100.100.101" + remoteTSAddr := "100.100.100.101" lal := memnet.Listen("local-tailscaled.sock:80") defer lal.Close() - localapi := mockLocalAPI(t, - map[string]*apitype.WhoIsResponse{remoteIP: remoteNode}, - func() *ipnstate.PeerStatus { return self }, - ) + localapi := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/localapi/v0/whois": + // Just passes back a whois response when request was made from `remoteTSAddr`. + if addr := r.URL.Query().Get("addr"); addr == remoteTSAddr { + if err := json.NewEncoder(w).Encode(&apitype.WhoIsResponse{ + Node: &tailcfg.Node{StableID: "node"}, + UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(1)}, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + return + } + http.Error(w, "not a node", http.StatusUnauthorized) + return + default: + t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path) + } + })} defer localapi.Close() go localapi.Serve(lal) @@ -340,15 +170,8 @@ func TestAuthorizeRequest(t *testing.T) { lc: &tailscale.LocalClient{Dial: lal.Dial}, tsDebugMode: "full", timeNow: time.Now, + auth: &mockAuthServer{}, } - validCookie := "ts-cookie" - s.browserSessions.Store(validCookie, &browserSession{ - ID: validCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: time.Now(), - Authenticated: true, - }) tests := []struct { reqPath string @@ -393,7 +216,7 @@ func TestAuthorizeRequest(t *testing.T) { r := httptest.NewRequest(tt.reqMethod, tt.reqPath, nil) r.RemoteAddr = remoteAddr if cookie != "" { - r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: cookie}) + r.AddCookie(&http.Cookie{Name: mockCookieName, Value: cookie}) } w := httptest.NewRecorder() return s.authorizeRequest(w, r) @@ -403,311 +226,31 @@ func TestAuthorizeRequest(t *testing.T) { t.Errorf("wantOkNotOverTailscale; want=%v, got=%v", tt.wantOkNotOverTailscale, gotOk) } // Do request from Tailscale IP w/o associated session. - if gotOk := doAuthorize(remoteIP, ""); gotOk != tt.wantOkWithoutSession { + if gotOk := doAuthorize(remoteTSAddr, ""); gotOk != tt.wantOkWithoutSession { t.Errorf("wantOkWithoutSession; want=%v, got=%v", tt.wantOkWithoutSession, gotOk) } // Do request from Tailscale IP w/ associated session. - if gotOk := doAuthorize(remoteIP, validCookie); gotOk != tt.wantOkWithSession { + if gotOk := doAuthorize(remoteTSAddr, mockValidCookie); gotOk != tt.wantOkWithSession { t.Errorf("wantOkWithSession; want=%v, got=%v", tt.wantOkWithSession, gotOk) } }) } } -func TestServeTailscaleAuth(t *testing.T) { - user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)} - self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID} - remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{ID: 1}, UserProfile: user} - remoteIP := "100.100.100.101" - - lal := memnet.Listen("local-tailscaled.sock:80") - defer lal.Close() - localapi := mockLocalAPI(t, - map[string]*apitype.WhoIsResponse{remoteIP: remoteNode}, - func() *ipnstate.PeerStatus { return self }, - ) - defer localapi.Close() - go localapi.Serve(lal) - - timeNow := time.Now() - oneHourAgo := timeNow.Add(-time.Hour) - sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2) - - s := &Server{ - lc: &tailscale.LocalClient{Dial: lal.Dial}, - tsDebugMode: "full", - timeNow: func() time.Time { return timeNow }, - } - - successCookie := "ts-cookie-success" - s.browserSessions.Store(successCookie, &browserSession{ - ID: successCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathSuccess, - AuthURL: testControlURL + testAuthPathSuccess, - }) - failureCookie := "ts-cookie-failure" - s.browserSessions.Store(failureCookie, &browserSession{ - ID: failureCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathError, - AuthURL: testControlURL + testAuthPathError, - }) - expiredCookie := "ts-cookie-expired" - s.browserSessions.Store(expiredCookie, &browserSession{ - ID: expiredCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: sixtyDaysAgo, - AuthID: "/a/old-auth-url", - AuthURL: testControlURL + "/a/old-auth-url", - }) - - tests := []struct { - name string - cookie string - query string - wantStatus int - wantResp *authResponse - wantNewCookie bool // new cookie generated - wantSession *browserSession // session associated w/ cookie at end of request - }{ - { - name: "new-session-created", - wantStatus: http.StatusOK, - wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath}, - wantNewCookie: true, - wantSession: &browserSession{ - ID: "GENERATED_ID", // gets swapped for newly created ID by test - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: timeNow, - AuthID: testAuthPath, - AuthURL: testControlURL + testAuthPath, - Authenticated: false, - }, - }, - { - name: "query-existing-incomplete-session", - cookie: successCookie, - wantStatus: http.StatusOK, - wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPathSuccess}, - wantSession: &browserSession{ - ID: successCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathSuccess, - AuthURL: testControlURL + testAuthPathSuccess, - Authenticated: false, - }, - }, - { - name: "transition-to-successful-session", - cookie: successCookie, - // query "wait" indicates the FE wants to make - // local api call to wait until session completed. - query: "wait=true", - wantStatus: http.StatusOK, - wantResp: &authResponse{OK: true}, - wantSession: &browserSession{ - ID: successCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathSuccess, - AuthURL: testControlURL + testAuthPathSuccess, - Authenticated: true, - }, - }, - { - name: "query-existing-complete-session", - cookie: successCookie, - wantStatus: http.StatusOK, - wantResp: &authResponse{OK: true}, - wantSession: &browserSession{ - ID: successCookie, - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: oneHourAgo, - AuthID: testAuthPathSuccess, - AuthURL: testControlURL + testAuthPathSuccess, - Authenticated: true, - }, - }, - { - name: "transition-to-failed-session", - cookie: failureCookie, - query: "wait=true", - wantStatus: http.StatusUnauthorized, - wantResp: nil, - wantSession: nil, // session deleted - }, - { - name: "failed-session-cleaned-up", - cookie: failureCookie, - wantStatus: http.StatusOK, - wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath}, - wantNewCookie: true, - wantSession: &browserSession{ - ID: "GENERATED_ID", - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: timeNow, - AuthID: testAuthPath, - AuthURL: testControlURL + testAuthPath, - Authenticated: false, - }, - }, - { - name: "expired-cookie-gets-new-session", - cookie: expiredCookie, - wantStatus: http.StatusOK, - wantResp: &authResponse{OK: false, AuthURL: testControlURL + testAuthPath}, - wantNewCookie: true, - wantSession: &browserSession{ - ID: "GENERATED_ID", - SrcNode: remoteNode.Node.ID, - SrcUser: user.ID, - Created: timeNow, - AuthID: testAuthPath, - AuthURL: testControlURL + testAuthPath, - Authenticated: false, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - r := httptest.NewRequest("GET", "/api/auth", nil) - r.URL.RawQuery = tt.query - r.RemoteAddr = remoteIP - r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) - w := httptest.NewRecorder() - s.serveTailscaleAuth(w, r) - res := w.Result() - defer res.Body.Close() - - // Validate response status/data. - if gotStatus := res.StatusCode; tt.wantStatus != gotStatus { - t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus) - } - var gotResp *authResponse - if res.StatusCode == http.StatusOK { - body, err := io.ReadAll(res.Body) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(body, &gotResp); err != nil { - t.Fatal(err) - } - } - if diff := cmp.Diff(gotResp, tt.wantResp); diff != "" { - t.Errorf("wrong response; (-got+want):%v", diff) - } - // Validate cookie creation. - sessionID := tt.cookie - var gotCookie bool - for _, c := range w.Result().Cookies() { - if c.Name == sessionCookieName { - gotCookie = true - sessionID = c.Value - break - } - } - if gotCookie != tt.wantNewCookie { - t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie) - } - // Validate browser session contents. - var gotSesson *browserSession - if s, ok := s.browserSessions.Load(sessionID); ok { - gotSesson = s.(*browserSession) - } - if tt.wantSession != nil && tt.wantSession.ID == "GENERATED_ID" { - // If requested, swap in the generated session ID before - // comparing got/want. - tt.wantSession.ID = sessionID - } - if diff := cmp.Diff(gotSesson, tt.wantSession); diff != "" { - t.Errorf("wrong session; (-got+want):%v", diff) - } - }) - } -} +type mockAuthServer struct{} var ( - testControlURL = "http://localhost:8080" - testAuthPath = "/a/12345" - testAuthPathSuccess = "/a/will-succeed" - testAuthPathError = "/a/will-error" + mockCookieName = "TS-Web-Session" + mockValidCookie = "ts-cookie-valid" ) -// mockLocalAPI constructs a test localapi handler that can be used -// to simulate localapi responses without a functioning tailnet. -// -// self accepts a function that resolves to a self node status, -// so that tests may swap out the /localapi/v0/status response -// as desired. -func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus) *http.Server { - return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case "/localapi/v0/whois": - addr := r.URL.Query().Get("addr") - if addr == "" { - t.Fatalf("/whois call missing \"addr\" query") - } - if node := whoIs[addr]; node != nil { - if err := json.NewEncoder(w).Encode(&node); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - return - } - http.Error(w, "not a node", http.StatusUnauthorized) - return - case "/localapi/v0/status": - status := ipnstate.Status{Self: self()} - if err := json.NewEncoder(w).Encode(status); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - return - case "/localapi/v0/debug-web-client": // used by TestServeTailscaleAuth - type reqData struct { - ID string - Src tailcfg.NodeID - } - var data reqData - if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - http.Error(w, "invalid JSON body", http.StatusBadRequest) - return - } - if data.Src == 0 { - http.Error(w, "missing Src node", http.StatusBadRequest) - return - } - var resp *tailcfg.WebClientAuthResponse - if data.ID == "" { - resp = &tailcfg.WebClientAuthResponse{ID: testAuthPath, URL: testControlURL + testAuthPath} - } else if data.ID == testAuthPathSuccess { - resp = &tailcfg.WebClientAuthResponse{Complete: true} - } else if data.ID == testAuthPathError { - http.Error(w, "authenticated as wrong user", http.StatusUnauthorized) - return - } - if err := json.NewEncoder(w).Encode(resp); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") - return - default: - t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path) - } - })} +func (s *mockAuthServer) IsLoggedIn(r *http.Request) bool { + c, err := r.Cookie(mockCookieName) + return err == nil && c.Value == mockValidCookie +} +func (s *mockAuthServer) ServeLogin(w http.ResponseWriter, r *http.Request) { + // Not used by any tests. + // Leaving unimplemented until needed. + http.Error(w, "unimplemented", http.StatusInternalServerError) + return } diff --git a/ipn/ipnlocal/web_client.go b/ipn/ipnlocal/web_client.go index 9708ed166..fdda95b07 100644 --- a/ipn/ipnlocal/web_client.go +++ b/ipn/ipnlocal/web_client.go @@ -14,6 +14,7 @@ import ( "tailscale.com/client/tailscale" "tailscale.com/client/web" "tailscale.com/envknob" + "tailscale.com/ipn/webauth" "tailscale.com/net/netutil" ) @@ -56,6 +57,8 @@ func (b *LocalBackend) WebClientInit() (err error) { // TODO(sonia): allow passing back dev mode flag LocalClient: b.webClient.lc, Logf: b.logf, + TimeNow: b.clock.Now, + AuthServer: webauth.NewServer(b.webClient.lc, b.clock.Now), }); err != nil { return fmt.Errorf("web.NewServer: %w", err) } diff --git a/ipn/webauth/webauth.go b/ipn/webauth/webauth.go new file mode 100644 index 000000000..a27f92575 --- /dev/null +++ b/ipn/webauth/webauth.go @@ -0,0 +1,324 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// TODO: package docs +package webauth + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "sync" + "time" + + "tailscale.com/client/tailscale" + "tailscale.com/client/tailscale/apitype" + "tailscale.com/tailcfg" + "tailscale.com/util/httpm" +) + +type Server struct { + // sessions is an in-memory cache of user browser sessions. + // + // Users obtain a valid browser session by connecting to the + // app's UI over Tailscale and verifying their identity by + // authenticating on the control server. + // + // sessions get reset on every webAuthServer initialization. + // + // The map provides a lookup of the session by cookie value + // (browserSession.ID => browserSession). + sessions sync.Map + + lc *tailscale.LocalClient + timeNow func() time.Time +} + +func NewServer(lc *tailscale.LocalClient, timeNow func() time.Time) *Server { + return &Server{ + lc: lc, + timeNow: timeNow, + } +} + +func (s *Server) IsLoggedIn(r *http.Request) bool { + session, _, err := s.getTailscaleBrowserSession(r) + if err != nil { + return false + } + return session.isAuthorized(s.timeNow()) +} + +type LoginResponse struct { + OK bool `json:"ok"` // true when user is already logged in + AuthURL string `json:"authUrl,omitempty"` // filled when user has control login action to take +} + +func (s *Server) ServeLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != httpm.GET { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var resp LoginResponse + + session, whois, err := s.getTailscaleBrowserSession(r) + switch { + case err != nil && !errors.Is(err, errNoSession): + http.Error(w, err.Error(), http.StatusUnauthorized) + return + case session.isAuthorized(s.timeNow()): + resp = LoginResponse{OK: true} // already logged in + case session == nil: + // Create a new session for the user to log in. + 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.sessions.Store(sid, session) + // Set the cookie on browser. + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: sid, + Raw: sid, + Path: "/", + Expires: session.expires(), + }) + resp = LoginResponse{OK: false, AuthURL: session.AuthURL} + default: + // Otherwise there's already an active ongoing login. + // If the user has requested that this request "wait" for login, + // we block until the user control auth has been completed. + // Otherwise we directly return the login URL. + if r.URL.Query().Get("wait") != "true" { + resp = LoginResponse{OK: false, AuthURL: session.AuthURL} + break // quick return + } + // 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.sessions.Delete(session.ID) + return + } + if d.Complete { + session.Authenticated = d.Complete + s.sessions.Store(session.ID, session) + } + if session.isAuthorized(s.timeNow()) { + resp = LoginResponse{OK: true} + } else { + resp = LoginResponse{OK: false, AuthURL: session.AuthURL} + } + } + + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") +} + +// browserSession holds data about a user's browser session. +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 +} + +const ( + sessionCookieName = "TS-Web-Session" // default session cookie name; TODO(sonia): make configurable, pass through NewServer + sessionCookieExpiry = time.Hour * 24 * 30 // default session expiry, 30 days +) + +// 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") + // TODO: these whois rules would not be general... + 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.sessions.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.sessions.Delete(session.ID) + return nil, whoIs, errNoSession + } + return session, whoIs, 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.sessions.Load(cookie); !ok { + return cookie, nil + } + } + return "", errors.New("webAuthServer.newSessionID: 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 +} diff --git a/ipn/webauth/webauth_test.go b/ipn/webauth/webauth_test.go new file mode 100644 index 000000000..bbdeb884d --- /dev/null +++ b/ipn/webauth/webauth_test.go @@ -0,0 +1,496 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package webauth + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "tailscale.com/client/tailscale" + "tailscale.com/client/tailscale/apitype" + "tailscale.com/ipn/ipnstate" + "tailscale.com/net/memnet" + "tailscale.com/tailcfg" + "tailscale.com/types/views" +) + +func TestGetTailscaleBrowserSession(t *testing.T) { + userA := &tailcfg.UserProfile{ID: tailcfg.UserID(1)} + userB := &tailcfg.UserProfile{ID: tailcfg.UserID(2)} + + userANodeIP := "100.100.100.101" + userBNodeIP := "100.100.100.102" + taggedNodeIP := "100.100.100.103" + + var selfNode *ipnstate.PeerStatus + tags := views.SliceOf([]string{"tag:server"}) + tailnetNodes := map[string]*apitype.WhoIsResponse{ + userANodeIP: { + Node: &tailcfg.Node{ID: 1, StableID: "1"}, + UserProfile: userA, + }, + userBNodeIP: { + Node: &tailcfg.Node{ID: 2, StableID: "2"}, + UserProfile: userB, + }, + taggedNodeIP: { + Node: &tailcfg.Node{ID: 3, StableID: "3", Tags: tags.AsSlice()}, + }, + } + + lal := memnet.Listen("local-tailscaled.sock:80") + defer lal.Close() + localapi := mockLocalAPI(t, tailnetNodes, func() *ipnstate.PeerStatus { return selfNode }) + defer localapi.Close() + go localapi.Serve(lal) + + s := &Server{ + timeNow: time.Now, + lc: &tailscale.LocalClient{Dial: lal.Dial}, + } + + // Add some browser sessions to cache state. + userASession := &browserSession{ + ID: "cookie1", + SrcNode: 1, + SrcUser: userA.ID, + Created: time.Now(), + Authenticated: false, // not yet authenticated + } + userBSession := &browserSession{ + ID: "cookie2", + SrcNode: 2, + SrcUser: userB.ID, + Created: time.Now().Add(-2 * sessionCookieExpiry), + Authenticated: true, // expired + } + userASessionAuthorized := &browserSession{ + ID: "cookie3", + SrcNode: 1, + SrcUser: userA.ID, + Created: time.Now(), + Authenticated: true, // authenticated and not expired + } + s.sessions.Store(userASession.ID, userASession) + s.sessions.Store(userBSession.ID, userBSession) + s.sessions.Store(userASessionAuthorized.ID, userASessionAuthorized) + + tests := []struct { + name string + selfNode *ipnstate.PeerStatus + remoteAddr string + cookie string + + wantSession *browserSession + wantError error + wantIsAuthorized bool // response from session.isAuthorized + }{ + { + name: "not-connected-over-tailscale", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: "77.77.77.77", + wantSession: nil, + wantError: errNotUsingTailscale, + }, + { + name: "no-session-user-self-node", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: userANodeIP, + cookie: "not-a-cookie", + wantSession: nil, + wantError: errNoSession, + }, + { + name: "no-session-tagged-self-node", + selfNode: &ipnstate.PeerStatus{ID: "self", Tags: &tags}, + remoteAddr: userANodeIP, + wantSession: nil, + wantError: errNoSession, + }, + { + name: "not-owner", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: userBNodeIP, + wantSession: nil, + wantError: errNotOwner, + }, + { + name: "tagged-remote-source", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: taggedNodeIP, + wantSession: nil, + wantError: errTaggedRemoteSource, + }, + { + name: "tagged-local-source", + selfNode: &ipnstate.PeerStatus{ID: "3"}, + remoteAddr: taggedNodeIP, // same node as selfNode + wantSession: nil, + wantError: errTaggedLocalSource, + }, + { + name: "not-tagged-local-source", + selfNode: &ipnstate.PeerStatus{ID: "1", UserID: userA.ID}, + remoteAddr: userANodeIP, // same node as selfNode + cookie: userASession.ID, + wantSession: userASession, + wantError: nil, // should not error + }, + { + name: "has-session", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: userANodeIP, + cookie: userASession.ID, + wantSession: userASession, + wantError: nil, + }, + { + name: "has-authorized-session", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userA.ID}, + remoteAddr: userANodeIP, + cookie: userASessionAuthorized.ID, + wantSession: userASessionAuthorized, + wantError: nil, + wantIsAuthorized: true, + }, + { + name: "session-associated-with-different-source", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID}, + remoteAddr: userBNodeIP, + cookie: userASession.ID, + wantSession: nil, + wantError: errNoSession, + }, + { + name: "session-expired", + selfNode: &ipnstate.PeerStatus{ID: "self", UserID: userB.ID}, + remoteAddr: userBNodeIP, + cookie: userBSession.ID, + wantSession: nil, + wantError: errNoSession, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + selfNode = tt.selfNode + r := &http.Request{RemoteAddr: tt.remoteAddr, Header: http.Header{}} + if tt.cookie != "" { + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) + } + session, _, err := s.getTailscaleBrowserSession(r) + if !errors.Is(err, tt.wantError) { + t.Errorf("wrong error; want=%v, got=%v", tt.wantError, err) + } + if diff := cmp.Diff(session, tt.wantSession); diff != "" { + t.Errorf("wrong session; (-got+want):%v", diff) + } + if gotIsAuthorized := session.isAuthorized(s.timeNow()); gotIsAuthorized != tt.wantIsAuthorized { + t.Errorf("wrong isAuthorized; want=%v, got=%v", tt.wantIsAuthorized, gotIsAuthorized) + } + }) + } +} + +func TestServeTailscaleAuth(t *testing.T) { + user := &tailcfg.UserProfile{ID: tailcfg.UserID(1)} + self := &ipnstate.PeerStatus{ID: "self", UserID: user.ID} + remoteNode := &apitype.WhoIsResponse{Node: &tailcfg.Node{ID: 1}, UserProfile: user} + remoteIP := "100.100.100.101" + + lal := memnet.Listen("local-tailscaled.sock:80") + defer lal.Close() + localapi := mockLocalAPI(t, + map[string]*apitype.WhoIsResponse{remoteIP: remoteNode}, + func() *ipnstate.PeerStatus { return self }, + ) + defer localapi.Close() + go localapi.Serve(lal) + + timeNow := time.Now() + oneHourAgo := timeNow.Add(-time.Hour) + sixtyDaysAgo := timeNow.Add(-sessionCookieExpiry * 2) + + s := &Server{ + lc: &tailscale.LocalClient{Dial: lal.Dial}, + timeNow: func() time.Time { return timeNow }, + } + + successCookie := "ts-cookie-success" + s.sessions.Store(successCookie, &browserSession{ + ID: successCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: oneHourAgo, + AuthID: testAuthPathSuccess, + AuthURL: testControlURL + testAuthPathSuccess, + }) + failureCookie := "ts-cookie-failure" + s.sessions.Store(failureCookie, &browserSession{ + ID: failureCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: oneHourAgo, + AuthID: testAuthPathError, + AuthURL: testControlURL + testAuthPathError, + }) + expiredCookie := "ts-cookie-expired" + s.sessions.Store(expiredCookie, &browserSession{ + ID: expiredCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: sixtyDaysAgo, + AuthID: "/a/old-auth-url", + AuthURL: testControlURL + "/a/old-auth-url", + }) + + tests := []struct { + name string + cookie string + query string + wantStatus int + wantResp *LoginResponse + wantNewCookie bool // new cookie generated + wantSession *browserSession // session associated w/ cookie at end of request + }{ + { + name: "new-session-created", + wantStatus: http.StatusOK, + wantResp: &LoginResponse{OK: false, AuthURL: testControlURL + testAuthPath}, + wantNewCookie: true, + wantSession: &browserSession{ + ID: "GENERATED_ID", // gets swapped for newly created ID by test + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: timeNow, + AuthID: testAuthPath, + AuthURL: testControlURL + testAuthPath, + Authenticated: false, + }, + }, + { + name: "query-existing-incomplete-session", + cookie: successCookie, + wantStatus: http.StatusOK, + wantResp: &LoginResponse{OK: false, AuthURL: testControlURL + testAuthPathSuccess}, + wantSession: &browserSession{ + ID: successCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: oneHourAgo, + AuthID: testAuthPathSuccess, + AuthURL: testControlURL + testAuthPathSuccess, + Authenticated: false, + }, + }, + { + name: "transition-to-successful-session", + cookie: successCookie, + // query "wait" indicates the FE wants to make + // local api call to wait until session completed. + query: "wait=true", + wantStatus: http.StatusOK, + wantResp: &LoginResponse{OK: true}, + wantSession: &browserSession{ + ID: successCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: oneHourAgo, + AuthID: testAuthPathSuccess, + AuthURL: testControlURL + testAuthPathSuccess, + Authenticated: true, + }, + }, + { + name: "query-existing-complete-session", + cookie: successCookie, + wantStatus: http.StatusOK, + wantResp: &LoginResponse{OK: true}, + wantSession: &browserSession{ + ID: successCookie, + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: oneHourAgo, + AuthID: testAuthPathSuccess, + AuthURL: testControlURL + testAuthPathSuccess, + Authenticated: true, + }, + }, + { + name: "transition-to-failed-session", + cookie: failureCookie, + query: "wait=true", + wantStatus: http.StatusUnauthorized, + wantResp: nil, + wantSession: nil, // session deleted + }, + { + name: "failed-session-cleaned-up", + cookie: failureCookie, + wantStatus: http.StatusOK, + wantResp: &LoginResponse{OK: false, AuthURL: testControlURL + testAuthPath}, + wantNewCookie: true, + wantSession: &browserSession{ + ID: "GENERATED_ID", + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: timeNow, + AuthID: testAuthPath, + AuthURL: testControlURL + testAuthPath, + Authenticated: false, + }, + }, + { + name: "expired-cookie-gets-new-session", + cookie: expiredCookie, + wantStatus: http.StatusOK, + wantResp: &LoginResponse{OK: false, AuthURL: testControlURL + testAuthPath}, + wantNewCookie: true, + wantSession: &browserSession{ + ID: "GENERATED_ID", + SrcNode: remoteNode.Node.ID, + SrcUser: user.ID, + Created: timeNow, + AuthID: testAuthPath, + AuthURL: testControlURL + testAuthPath, + Authenticated: false, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := httptest.NewRequest("GET", "/api/auth", nil) + r.URL.RawQuery = tt.query + r.RemoteAddr = remoteIP + r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie}) + w := httptest.NewRecorder() + s.ServeLogin(w, r) + res := w.Result() + defer res.Body.Close() + + // Validate response status/data. + if gotStatus := res.StatusCode; tt.wantStatus != gotStatus { + t.Errorf("wrong status; want=%v, got=%v", tt.wantStatus, gotStatus) + } + var gotResp *LoginResponse + if res.StatusCode == http.StatusOK { + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(body, &gotResp); err != nil { + t.Fatal(err) + } + } + if diff := cmp.Diff(gotResp, tt.wantResp); diff != "" { + t.Errorf("wrong response; (-got+want):%v", diff) + } + // Validate cookie creation. + sessionID := tt.cookie + var gotCookie bool + for _, c := range w.Result().Cookies() { + if c.Name == sessionCookieName { + gotCookie = true + sessionID = c.Value + break + } + } + if gotCookie != tt.wantNewCookie { + t.Errorf("wantNewCookie wrong; want=%v, got=%v", tt.wantNewCookie, gotCookie) + } + // Validate browser session contents. + var gotSesson *browserSession + if s, ok := s.sessions.Load(sessionID); ok { + gotSesson = s.(*browserSession) + } + if tt.wantSession != nil && tt.wantSession.ID == "GENERATED_ID" { + // If requested, swap in the generated session ID before + // comparing got/want. + tt.wantSession.ID = sessionID + } + if diff := cmp.Diff(gotSesson, tt.wantSession); diff != "" { + t.Errorf("wrong session; (-got+want):%v", diff) + } + }) + } +} + +var ( + testControlURL = "http://localhost:8080" + testAuthPath = "/a/12345" + testAuthPathSuccess = "/a/will-succeed" + testAuthPathError = "/a/will-error" +) + +// mockLocalAPI constructs a test localapi handler that can be used +// to simulate localapi responses without a functioning tailnet. +// +// self accepts a function that resolves to a self node status, +// so that tests may swap out the /localapi/v0/status response +// as desired. +func mockLocalAPI(t *testing.T, whoIs map[string]*apitype.WhoIsResponse, self func() *ipnstate.PeerStatus) *http.Server { + return &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/localapi/v0/whois": + addr := r.URL.Query().Get("addr") + if addr == "" { + t.Fatalf("/whois call missing \"addr\" query") + } + if node := whoIs[addr]; node != nil { + if err := json.NewEncoder(w).Encode(&node); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + return + } + http.Error(w, "not a node", http.StatusUnauthorized) + return + case "/localapi/v0/status": + status := ipnstate.Status{Self: self()} + if err := json.NewEncoder(w).Encode(status); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + return + case "/localapi/v0/debug-web-client": // used by TestServeTailscaleAuth + type reqData struct { + ID string + Src tailcfg.NodeID + } + var data reqData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + http.Error(w, "invalid JSON body", http.StatusBadRequest) + return + } + if data.Src == 0 { + http.Error(w, "missing Src node", http.StatusBadRequest) + return + } + var resp *tailcfg.WebClientAuthResponse + if data.ID == "" { + resp = &tailcfg.WebClientAuthResponse{ID: testAuthPath, URL: testControlURL + testAuthPath} + } else if data.ID == testAuthPathSuccess { + resp = &tailcfg.WebClientAuthResponse{Complete: true} + } else if data.ID == testAuthPathError { + http.Error(w, "authenticated as wrong user", http.StatusUnauthorized) + return + } + if err := json.NewEncoder(w).Encode(resp); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + return + default: + t.Fatalf("unhandled localapi test endpoint %q, add to localapi handler func in test", r.URL.Path) + } + })} +} |
