tailscale/client/web/auth.go
Sonia Appasamy 7a725bb4f0 client/web: move more session logic to auth.go
Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
2023-11-01 18:35:43 -04:00

249 lines
8.0 KiB
Go

// 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")
)
// getSession 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) getSession(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
}
// newSession creates a new session associated with the given source user/node,
// and stores it back to the session cache. Creating of a new session includes
// generating a new auth URL from the control server.
func (s *Server) newSession(ctx context.Context, src *apitype.WhoIsResponse) (*browserSession, error) {
d, err := s.getOrAwaitAuth(ctx, "", src.Node.ID)
if err != nil {
return nil, err
}
sid, err := s.newSessionID()
if err != nil {
return nil, err
}
session := &browserSession{
ID: sid,
SrcNode: src.Node.ID,
SrcUser: src.UserProfile.ID,
AuthID: d.ID,
AuthURL: d.URL,
Created: s.timeNow(),
}
s.browserSessions.Store(sid, session)
return session, nil
}
// awaitUserAuth blocks until the given session auth has been completed
// by the user on the control server, then updates the session cache upon
// completion. An error is returned if control auth failed for any reason.
func (s *Server) awaitUserAuth(ctx context.Context, session *browserSession) error {
if session.isAuthorized(s.timeNow()) {
return nil // already authorized
}
d, err := s.getOrAwaitAuth(ctx, session.AuthID, session.SrcNode)
if err != nil {
// 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 err
}
if d.Complete {
session.Authenticated = d.Complete
s.browserSessions.Store(session.ID, session)
}
return 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")
}