mirror of
https://github.com/tailscale/tailscale.git
synced 2025-03-17 02:41:00 +00:00
client/web: move session logic to auth.go
Updates tailscale/corp#14335 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
parent
3a9f5c02bf
commit
32ebc03591
202
client/web/auth.go
Normal file
202
client/web/auth.go
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
// 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")
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
@ -5,10 +5,8 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -67,61 +65,11 @@ type Server struct {
|
|||||||
browserSessions sync.Map
|
browserSessions sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
|
||||||
sessionCookieName = "TS-Web-Session"
|
|
||||||
sessionCookieExpiry = time.Hour * 24 * 30 // 30 days
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
|
exitNodeRouteV4 = netip.MustParsePrefix("0.0.0.0/0")
|
||||||
exitNodeRouteV6 = netip.MustParsePrefix("::/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.
|
// ServerOpts contains options for constructing a new Server.
|
||||||
type ServerOpts struct {
|
type ServerOpts struct {
|
||||||
DevMode bool
|
DevMode bool
|
||||||
@ -316,85 +264,6 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
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
|
||||||
@ -475,59 +344,6 @@ 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
// 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.
|
||||||
|
Loading…
x
Reference in New Issue
Block a user