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 /client/web | |
| 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>
Diffstat (limited to 'client/web')
| -rw-r--r-- | client/web/web.go | 293 | ||||
| -rw-r--r-- | client/web/web_test.go | 533 |
2 files changed, 54 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 } |
