mirror of
https://github.com/tailscale/tailscale.git
synced 2026-03-25 17:53:03 -04:00
This amends the session creation and auth status querying logic of the device UI backend. On creation of new browser sessions we now store a PendingAuth flag as part of the session that indicates a pending auth process that needs to be awaited. On auth status queries, the server initiates a polling for the auth result if it finds this flag to be true. Once the polling is completes, the flag is set to false. Why this change was necessary: with regular browser settings, the device UI frontend opens the control auth URL in a new tab and starts polling for the results of the auth flow in the current tab. With certain browser settings (that we still want to support), however, the auth URL opens in the same tab, thus aborting the subsequent call to auth/session/wait that initiates the polling, and preventing successful registration of the auth results in the session status. The new logic ensures the polling happens on the next call to /api/auth in these kinds of scenarios. In addition to ensuring the auth wait happens, we now also revalidate the auth state whenever an open tab regains focus, so that auth changes effected in one tab propagate to other tabs without the need to refresh. This improves the experience for all users of the web client when they've got multiple tabs open, regardless of their browser settings. Fixes #11905 Signed-off-by: Gesa Stupperich <gesa@tailscale.com>
351 lines
11 KiB
Go
351 lines
11 KiB
Go
// Copyright (c) Tailscale Inc & contributors
|
|
// SPDX-License-Identifier: BSD-3-Clause
|
|
|
|
package web
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"tailscale.com/client/tailscale/apitype"
|
|
"tailscale.com/ipn/ipnstate"
|
|
"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
|
|
PendingAuth 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, *ipnstate.Status, error) {
|
|
whoIs, whoIsErr := s.lc.WhoIs(r.Context(), r.RemoteAddr)
|
|
status, statusErr := s.lc.StatusWithoutPeers(r.Context())
|
|
switch {
|
|
case whoIsErr != nil:
|
|
return nil, nil, status, errNotUsingTailscale
|
|
case statusErr != nil:
|
|
return nil, whoIs, nil, statusErr
|
|
case status.Self == nil:
|
|
return nil, whoIs, status, errors.New("missing self node in tailscale status")
|
|
case whoIs.Node.IsTagged() && whoIs.Node.StableID == status.Self.ID:
|
|
return nil, whoIs, status, errTaggedLocalSource
|
|
case whoIs.Node.IsTagged():
|
|
return nil, whoIs, status, errTaggedRemoteSource
|
|
case !status.Self.IsTagged() && status.Self.UserID != whoIs.UserProfile.ID:
|
|
return nil, whoIs, status, errNotOwner
|
|
}
|
|
srcNode := whoIs.Node.ID
|
|
srcUser := whoIs.UserProfile.ID
|
|
|
|
cookie, err := r.Cookie(sessionCookieName)
|
|
if errors.Is(err, http.ErrNoCookie) {
|
|
return nil, whoIs, status, errNoSession
|
|
} else if err != nil {
|
|
return nil, whoIs, status, err
|
|
}
|
|
v, ok := s.browserSessions.Load(cookie.Value)
|
|
if !ok {
|
|
return nil, whoIs, status, 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, status, errNoSession
|
|
} else if session.isExpired(s.timeNow()) {
|
|
// Session expired, remove from session map and return errNoSession.
|
|
s.browserSessions.Delete(session.ID)
|
|
return nil, whoIs, status, errNoSession
|
|
}
|
|
return session, whoIs, status, 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) {
|
|
sid, err := s.newSessionID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
session := &browserSession{
|
|
ID: sid,
|
|
SrcNode: src.Node.ID,
|
|
SrcUser: src.UserProfile.ID,
|
|
Created: s.timeNow(),
|
|
}
|
|
|
|
if s.controlSupportsCheckMode(ctx) {
|
|
// control supports check mode, so get a new auth URL and return.
|
|
a, err := s.newAuthURL(ctx, src.Node.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
session.AuthID = a.ID
|
|
session.AuthURL = a.URL
|
|
session.PendingAuth = true
|
|
} else {
|
|
// control does not support check mode, so there is no additional auth we can do.
|
|
session.Authenticated = true
|
|
}
|
|
|
|
s.browserSessions.Store(sid, session)
|
|
|
|
return session, nil
|
|
}
|
|
|
|
// controlSupportsCheckMode returns whether the current control server supports web client check mode, to verify a user's identity.
|
|
// We assume that only "tailscale.com" control servers support check mode.
|
|
// This allows the web client to be used with non-standard control servers.
|
|
// If an error occurs getting the control URL, this method returns true to fail closed.
|
|
//
|
|
// TODO(juanfont/headscale#1623): adjust or remove this when headscale supports check mode.
|
|
func (s *Server) controlSupportsCheckMode(ctx context.Context) bool {
|
|
prefs, err := s.lc.GetPrefs(ctx)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
controlURL, err := url.Parse(prefs.ControlURLOrDefault(s.polc))
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return strings.HasSuffix(controlURL.Host, ".tailscale.com")
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
a, err := s.waitAuthURL(ctx, session.AuthID, session.SrcNode)
|
|
if err != nil {
|
|
// Don't delete the session on context cancellation, as this is expected
|
|
// when users navigate away or refresh the page.
|
|
if errors.Is(err, context.Canceled) {
|
|
return err
|
|
}
|
|
|
|
// Clean up the session for non-cancellation errors from control server
|
|
// to avoid the user getting stuck with a bad session cookie.
|
|
s.browserSessions.Delete(session.ID)
|
|
return err
|
|
}
|
|
|
|
if a.Complete {
|
|
session.Authenticated = a.Complete
|
|
session.PendingAuth = false
|
|
s.browserSessions.Store(session.ID, session)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) newSessionID() (string, error) {
|
|
raw := make([]byte, 16)
|
|
for range 5 {
|
|
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")
|
|
}
|
|
|
|
// peerCapabilities holds information about what a source
|
|
// peer is allowed to edit via the web UI.
|
|
//
|
|
// map value is true if the peer can edit the given feature.
|
|
// Only capFeatures included in validCaps will be included.
|
|
type peerCapabilities map[capFeature]bool
|
|
|
|
// canEdit is true if the peerCapabilities grant edit access
|
|
// to the given feature.
|
|
func (p peerCapabilities) canEdit(feature capFeature) bool {
|
|
if p == nil {
|
|
return false
|
|
}
|
|
if p[capFeatureAll] {
|
|
return true
|
|
}
|
|
return p[feature]
|
|
}
|
|
|
|
// isEmpty is true if p is either nil or has no capabilities
|
|
// with value true.
|
|
func (p peerCapabilities) isEmpty() bool {
|
|
if p == nil {
|
|
return true
|
|
}
|
|
for _, v := range p {
|
|
if v == true {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
type capFeature string
|
|
|
|
const (
|
|
// The following values should not be edited.
|
|
// New caps can be added, but existing ones should not be changed,
|
|
// as these exact values are used by users in tailnet policy files.
|
|
//
|
|
// IMPORTANT: When adding a new cap, also update validCaps slice below.
|
|
|
|
capFeatureAll capFeature = "*" // grants peer management of all features
|
|
capFeatureSSH capFeature = "ssh" // grants peer SSH server management
|
|
capFeatureSubnets capFeature = "subnets" // grants peer subnet routes management
|
|
capFeatureExitNodes capFeature = "exitnodes" // grants peer ability to advertise-as and use exit nodes
|
|
capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
|
|
)
|
|
|
|
// validCaps contains the list of valid capabilities used in the web client.
|
|
// Any capabilities included in a peer's grants that do not fall into this
|
|
// list will be ignored.
|
|
var validCaps []capFeature = []capFeature{
|
|
capFeatureAll,
|
|
capFeatureSSH,
|
|
capFeatureSubnets,
|
|
capFeatureExitNodes,
|
|
capFeatureAccount,
|
|
}
|
|
|
|
type capRule struct {
|
|
CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit
|
|
}
|
|
|
|
// toPeerCapabilities parses out the web ui capabilities from the
|
|
// given whois response.
|
|
func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (peerCapabilities, error) {
|
|
if whois == nil || status == nil {
|
|
return peerCapabilities{}, nil
|
|
}
|
|
if whois.Node.IsTagged() {
|
|
// We don't allow management *from* tagged nodes, so ignore caps.
|
|
// The web client auth flow relies on having a true user identity
|
|
// that can be verified through login.
|
|
return peerCapabilities{}, nil
|
|
}
|
|
|
|
if !status.Self.IsTagged() {
|
|
// User owned nodes are only ever manageable by the owner.
|
|
if status.Self.UserID != whois.UserProfile.ID {
|
|
return peerCapabilities{}, nil
|
|
} else {
|
|
return peerCapabilities{capFeatureAll: true}, nil // owner can edit all features
|
|
}
|
|
}
|
|
|
|
// For tagged nodes, we actually look at the granted capabilities.
|
|
caps := peerCapabilities{}
|
|
rules, err := tailcfg.UnmarshalCapJSON[capRule](whois.CapMap, tailcfg.PeerCapabilityWebUI)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal capability: %v", err)
|
|
}
|
|
for _, c := range rules {
|
|
for _, f := range c.CanEdit {
|
|
cap := capFeature(strings.ToLower(f))
|
|
if slices.Contains(validCaps, cap) {
|
|
caps[cap] = true
|
|
}
|
|
}
|
|
}
|
|
return caps, nil
|
|
}
|