@ -5,8 +5,10 @@
package web
package web
import (
import (
"bytes"
"context"
"context"
"crypto/rand"
"crypto/rand"
"encoding/base64"
"encoding/json"
"encoding/json"
"errors"
"errors"
"fmt"
"fmt"
@ -19,6 +21,7 @@ import (
"slices"
"slices"
"strings"
"strings"
"sync"
"sync"
"sync/atomic"
"time"
"time"
"github.com/gorilla/csrf"
"github.com/gorilla/csrf"
@ -58,7 +61,8 @@ type Server struct {
//
//
// The map provides a lookup of the session by cookie value
// The map provides a lookup of the session by cookie value
// (browserSession.ID => browserSession).
// (browserSession.ID => browserSession).
browserSessions sync . Map
browserSessions sync . Map
controlServerURL atomic . Value // access through getControlServerURL
}
}
const (
const (
@ -77,25 +81,26 @@ type browserSession struct {
// ID is the unique identifier for the session.
// ID is the unique identifier for the session.
// It is passed in the user's "TS-Web-Session" browser cookie.
// It is passed in the user's "TS-Web-Session" browser cookie.
ID string
ID string
SrcNode tailcfg . Stable NodeID
SrcNode tailcfg . NodeID
SrcUser tailcfg . UserID
SrcUser tailcfg . UserID
AuthURL string // control server URL for user to authenticate the session
AuthURL string // control server URL for user to authenticate the session
Authenticated time . Time // when zero, authentication not complete
Created time . Time
Authenticated bool
}
}
// isAuthorized reports true if the given session is authorized
// isAuthorized reports true if the given session is authorized
// to be used by its associated user to access the full management
// to be used by its associated user to access the full management
// web client.
// web client.
//
//
// isAuthorized is true only when s.Authenticated is non-zero
// isAuthorized is true only when s.Authenticated is true (i.e.
// (i.e. the user has authenticated the session) and the session
// the user has authenticated the session) and the session is not
// is not expired.
// expired.
// 2023-10-05: Sessions expire by default after 30 days .
// 2023-10-05: Sessions expire by default 30 days after creation .
func ( s * browserSession ) isAuthorized ( ) bool {
func ( s * browserSession ) isAuthorized ( ) bool {
switch {
switch {
case s == nil :
case s == nil :
return false
return false
case s . Authenticated . IsZero ( ) :
case ! s . Authenticated :
return false // awaiting auth
return false // awaiting auth
case s . isExpired ( ) : // TODO: add time field to server?
case s . isExpired ( ) : // TODO: add time field to server?
return false // expired
return false // expired
@ -104,20 +109,20 @@ func (s *browserSession) isAuthorized() bool {
}
}
// isExpired reports true if s is expired.
// isExpired reports true if s is expired.
// 2023-10-05: Sessions expire by default after 30 days.
// 2023-10-05: Sessions expire by default 30 days after creation.
// If s.Authenticated is zero, isExpired reports false.
func ( s * browserSession ) isExpired ( ) bool {
func ( s * browserSession ) isExpired ( ) bool {
return ! s . Authenticated . IsZero ( ) && s . Authenticated . Before ( time . Now ( ) . Add ( - sessionCookieExpiry ) ) // TODO: add time field to server?
return ! s . Created . IsZero ( ) && time . Now ( ) . After ( s . expires ( ) ) // TODO: add time field to server?
}
// 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.
// ServerOpts contains options for constructing a new Server.
type ServerOpts struct {
type ServerOpts struct {
DevMode bool
DevMode bool
// LoginOnly indicates that the server should only serve the minimal
// login client and not the full web client.
LoginOnly bool
// CGIMode indicates if the server is running as a CGI script.
// CGIMode indicates if the server is running as a CGI script.
CGIMode bool
CGIMode bool
@ -223,7 +228,11 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
return true
return true
case strings . HasPrefix ( r . URL . Path , "/api/" ) :
case strings . HasPrefix ( r . URL . Path , "/api/" ) :
// All other /api/ endpoints require a valid browser session.
// All other /api/ endpoints require a valid browser session.
session , err := s . getTailscaleBrowserSession ( r )
//
// TODO(sonia): s.getTailscaleBrowserSession 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 ( ) {
if err != nil || ! session . isAuthorized ( ) {
http . Error ( w , "no valid session" , http . StatusUnauthorized )
http . Error ( w , "no valid session" , http . StatusUnauthorized )
return false
return false
@ -275,6 +284,7 @@ var (
errNotUsingTailscale = errors . New ( "not-using-tailscale" )
errNotUsingTailscale = errors . New ( "not-using-tailscale" )
errTaggedSource = errors . New ( "tagged-source" )
errTaggedSource = errors . New ( "tagged-source" )
errNotOwner = errors . New ( "not-owner" )
errNotOwner = errors . New ( "not-owner" )
errFailedAuth = errors . New ( "failed-auth" )
)
)
// getTailscaleBrowserSession retrieves the browser session associated with
// getTailscaleBrowserSession retrieves the browser session associated with
@ -296,70 +306,122 @@ var (
// If no error is returned, the browserSession is always non-nil.
// If no error is returned, the browserSession is always non-nil.
// getTailscaleBrowserSession does not check whether the session has been
// getTailscaleBrowserSession does not check whether the session has been
// authorized by the user. Callers can use browserSession.isAuthorized.
// authorized by the user. Callers can use browserSession.isAuthorized.
func ( s * Server ) getTailscaleBrowserSession ( r * http . Request ) ( * browserSession , error ) {
//
// 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 , err := s . lc . WhoIs ( r . Context ( ) , r . RemoteAddr )
whoIs , err := s . lc . WhoIs ( r . Context ( ) , r . RemoteAddr )
switch {
switch {
case err != nil :
case err != nil :
return nil , errNotUsingTailscale
return nil , nil , errNotUsingTailscale
case whoIs . Node . IsTagged ( ) :
case whoIs . Node . IsTagged ( ) :
return nil , errTaggedSource
return nil , whoIs, errTaggedSource
}
}
srcNode := whoIs . Node . Stable ID
srcNode := whoIs . Node . ID
srcUser := whoIs . UserProfile . ID
srcUser := whoIs . UserProfile . ID
status , err := s . lc . StatusWithoutPeers ( r . Context ( ) )
status , err := s . lc . StatusWithoutPeers ( r . Context ( ) )
switch {
switch {
case err != nil :
case err != nil :
return nil , err
return nil , whoIs, err
case status . Self == nil :
case status . Self == nil :
return nil , errors. New ( "missing self node in tailscale status" )
return nil , whoIs, errors. New ( "missing self node in tailscale status" )
case ! status . Self . IsTagged ( ) && status . Self . UserID != srcUser :
case ! status . Self . IsTagged ( ) && status . Self . UserID != srcUser :
return nil , errNotOwner
return nil , whoIs, errNotOwner
}
}
cookie , err := r . Cookie ( sessionCookieName )
cookie , err := r . Cookie ( sessionCookieName )
if errors . Is ( err , http . ErrNoCookie ) {
if errors . Is ( err , http . ErrNoCookie ) {
return nil , errNoSession
return nil , whoIs, errNoSession
} else if err != nil {
} else if err != nil {
return nil , err
return nil , whoIs, err
}
}
v , ok := s . browserSessions . Load ( cookie . Value )
v , ok := s . browserSessions . Load ( cookie . Value )
if ! ok {
if ! ok {
return nil , errNoSession
return nil , whoIs, errNoSession
}
}
session := v . ( * browserSession )
session := v . ( * browserSession )
if session . SrcNode != srcNode || session . SrcUser != srcUser {
if session . SrcNode != srcNode || session . SrcUser != srcUser {
// In this case the browser cookie is associated with another tailscale node.
// 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.
// 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 errNoSession because there is no session for this user.
return nil , errNoSession
return nil , whoIs, errNoSession
} else if session . isExpired ( ) {
} else if session . isExpired ( ) {
// Session expired, remove from session map and return errNoSession.
// Session expired, remove from session map and return errNoSession.
s . browserSessions . Delete ( session . ID )
s . browserSessions . Delete ( session . ID )
return nil , errNoSession
return nil , whoIs, errNoSession
}
}
return session , nil
return session , whoIs , nil
}
}
type authResponse struct {
type authResponse struct {
OK bool ` json:"ok" ` // true when user has valid auth session
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
AuthURL string ` json:"authUrl,omitempty" ` // filled when user has control auth action to take
Error string ` json:"error,omitempty" ` // filled when Ok is false
}
}
func ( s * Server ) serveTailscaleAuth ( w http . ResponseWriter , r * http . Request ) {
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
var resp authResponse
session , err := s . getTailscaleBrowserSession ( r )
session , whois, err := s . getTailscaleBrowserSession ( r )
switch {
switch {
case err != nil && ! errors . Is ( err , errNoSession ) :
case err != nil && ! errors . Is ( err , errNoSession ) :
resp = authResponse { OK : false , Error : err . Error ( ) }
http . Error ( w , err . Error ( ) , http . StatusUnauthorized )
return
case session == nil :
case session == nil :
// TODO(tailscale/corp#14335): Create a new auth path from control,
// Create a new session.
// and store back to s.browserSessions and request cookie.
d , err := s . getOrAwaitAuthURL ( 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 ,
AuthURL : d . URL ,
Created : time . Now ( ) ,
}
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 ( ) :
case ! session . isAuthorized ( ) :
// TODO(tailscale/corp#14335): Check on the session auth path status from control,
if r . URL . Query ( ) . Get ( "wait" ) == "true" {
// and store back to s.browserSessions.
// Client requested we block until user completes auth.
d , err := s . getOrAwaitAuthURL ( r . Context ( ) , session . AuthURL , whois . Node . ID )
if errors . Is ( err , errFailedAuth ) {
http . Error ( w , "user is unauthorized" , http . StatusUnauthorized )
s . browserSessions . Delete ( session . ID ) // clean up the failed session
return
} else if err != nil {
http . Error ( w , err . Error ( ) , http . StatusInternalServerError )
return
}
if d . Complete {
session . Authenticated = d . Complete
s . browserSessions . Store ( session . ID , session )
}
}
if session . isAuthorized ( ) {
resp = authResponse { OK : true }
} else {
resp = authResponse { OK : false , AuthURL : session . AuthURL }
}
default :
default :
resp = authResponse { OK : true }
resp = authResponse { OK : true }
}
}
@ -371,6 +433,84 @@ func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) {
w . Header ( ) . Set ( "Content-Type" , "application/json" )
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" )
}
func ( s * Server ) getControlServerURL ( ctx context . Context ) ( string , error ) {
if v := s . controlServerURL . Load ( ) ; v != nil {
v , _ := v . ( string )
return v , nil
}
prefs , err := s . lc . GetPrefs ( ctx )
if err != nil {
return "" , err
}
url := prefs . ControlURLOrDefault ( )
s . controlServerURL . Store ( url )
return url , nil
}
// getOrAwaitAuthURL connects to the control server for user auth,
// with the following behavior:
//
// 1. If authURL 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 authURL is provided non-empty, the connection to control
// blocks until the user has completed the URL. getOrAwaitAuthURL
// terminates when either the URL is completed, or ctx is canceled.
func ( s * Server ) getOrAwaitAuthURL ( ctx context . Context , authURL string , src tailcfg . NodeID ) ( * tailcfg . WebClientAuthResponse , error ) {
serverURL , err := s . getControlServerURL ( ctx )
if err != nil {
return nil , err
}
type data struct {
ID string
Src tailcfg . NodeID
}
var b bytes . Buffer
if err := json . NewEncoder ( & b ) . Encode ( data {
ID : strings . TrimPrefix ( authURL , serverURL ) ,
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 . StatusUnauthorized {
// User completed auth, but control server reported
// them unauthorized to manage this node.
return nil , errFailedAuth
} else 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.
// serveAPI serves requests for the web client api.
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
// It should only be called by Server.ServeHTTP, via Server.apiHandler,
// which protects the handler using gorilla csrf.
// which protects the handler using gorilla csrf.