client/web: add login client mode to web.Server

Adds new LoginOnly server option and swaps out API handler depending
on whether running in login mode or full web client mode.

Also includes some minor refactoring to the synology/qnap authorization
logic to allow for easier sharing between serveLoginAPI and serveAPI.

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
pull/9572/head
Sonia Appasamy 1 year ago committed by Sonia Appasamy
parent 354455e8be
commit 5d62b17cc5

@ -16,21 +16,23 @@ import (
"net/url" "net/url"
) )
// authorizeQNAP authenticates the logged-in QNAP user and verifies // authorizeQNAP authenticates the logged-in QNAP user and verifies that they
// that they are authorized to use the web client. It returns true if the // are authorized to use the web client.
// request was handled and no further processing is required. // It reports true if the request is authorized to continue, and false otherwise.
func authorizeQNAP(w http.ResponseWriter, r *http.Request) (handled bool) { // authorizeQNAP manages writing out any relevant authorization errors to the
// ResponseWriter itself.
func authorizeQNAP(w http.ResponseWriter, r *http.Request) (ok bool) {
_, resp, err := qnapAuthn(r) _, resp, err := qnapAuthn(r)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized) http.Error(w, err.Error(), http.StatusUnauthorized)
return true return false
} }
if resp.IsAdmin == 0 { if resp.IsAdmin == 0 {
http.Error(w, "user is not an admin", http.StatusForbidden) http.Error(w, "user is not an admin", http.StatusForbidden)
return true return false
} }
return false return true
} }
type qnapAuthResponse struct { type qnapAuthResponse struct {

@ -16,11 +16,13 @@ import (
) )
// authorizeSynology authenticates the logged-in Synology user and verifies // authorizeSynology authenticates the logged-in Synology user and verifies
// that they are authorized to use the web client. It returns true if the // that they are authorized to use the web client.
// request was handled and no further processing is required. // It reports true if the request is authorized to continue, and false otherwise.
func authorizeSynology(w http.ResponseWriter, r *http.Request) (handled bool) { // authorizeSynology manages writing out any relevant authorization errors to the
// ResponseWriter itself.
func authorizeSynology(w http.ResponseWriter, r *http.Request) (ok bool) {
if synoTokenRedirect(w, r) { if synoTokenRedirect(w, r) {
return true return false
} }
// authenticate the Synology user // authenticate the Synology user
@ -28,7 +30,7 @@ func authorizeSynology(w http.ResponseWriter, r *http.Request) (handled bool) {
out, err := cmd.CombinedOutput() out, err := cmd.CombinedOutput()
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("auth: %v: %s", err, out), http.StatusUnauthorized) http.Error(w, fmt.Sprintf("auth: %v: %s", err, out), http.StatusUnauthorized)
return true return false
} }
user := strings.TrimSpace(string(out)) user := strings.TrimSpace(string(out))
@ -36,14 +38,14 @@ func authorizeSynology(w http.ResponseWriter, r *http.Request) (handled bool) {
isAdmin, err := groupmember.IsMemberOfGroup("administrators", user) isAdmin, err := groupmember.IsMemberOfGroup("administrators", user)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusForbidden) http.Error(w, err.Error(), http.StatusForbidden)
return true return false
} }
if !isAdmin { if !isAdmin {
http.Error(w, "not a member of administrators group", http.StatusForbidden) http.Error(w, "not a member of administrators group", http.StatusForbidden)
return true return false
} }
return false return true
} }
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool { func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {

@ -36,6 +36,7 @@ type Server struct {
lc *tailscale.LocalClient lc *tailscale.LocalClient
devMode bool devMode bool
loginOnly bool
cgiMode bool cgiMode bool
pathPrefix string pathPrefix string
@ -48,6 +49,10 @@ type Server struct {
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
@ -67,8 +72,8 @@ func NewServer(ctx context.Context, opts ServerOpts) (s *Server, cleanup func())
} }
s = &Server{ s = &Server{
devMode: opts.DevMode, devMode: opts.DevMode,
loginOnly: opts.LoginOnly,
lc: opts.LocalClient, lc: opts.LocalClient,
cgiMode: opts.CGIMode,
pathPrefix: opts.PathPrefix, pathPrefix: opts.PathPrefix,
} }
s.assetsHandler, cleanup = assetsHandler(opts.DevMode) s.assetsHandler, cleanup = assetsHandler(opts.DevMode)
@ -79,9 +84,16 @@ func NewServer(ctx context.Context, opts ServerOpts) (s *Server, cleanup func())
// The client is secured by limiting the interface it listens on, // The client is secured by limiting the interface it listens on,
// or by authenticating requests before they reach the web client. // or by authenticating requests before they reach the web client.
csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false)) csrfProtect := csrf.Protect(s.csrfKey(), csrf.Secure(false))
if opts.LoginOnly {
// For the login client, we don't serve the full web client API,
// only the login endpoints.
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveLoginAPI))
s.lc.IncrementCounter(context.Background(), "web_login_client_initialization", 1)
} else {
s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI)) s.apiHandler = csrfProtect(http.HandlerFunc(s.serveAPI))
s.lc.IncrementCounter(context.Background(), "web_client_initialization", 1) s.lc.IncrementCounter(context.Background(), "web_client_initialization", 1)
}
return s, cleanup return s, cleanup
} }
@ -97,46 +109,69 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handler(w, r) handler(w, r)
} }
// authorize checks if the request is authorized to access the web client for those platforms that support it. func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
func authorize(w http.ResponseWriter, r *http.Request) (handled bool) { if strings.HasPrefix(r.URL.Path, "/api/") {
if strings.HasPrefix(r.URL.Path, "/assets/") { // Pass API requests through to the API handler.
// don't require authorization for static assets s.apiHandler.ServeHTTP(w, r)
return false return
} }
if !s.devMode {
s.lc.IncrementCounter(context.Background(), "web_client_page_load", 1)
}
s.assetsHandler.ServeHTTP(w, r)
}
// authorizePlatformRequest reports whether the request from the web client
// is authorized to access the client for those platforms that support it.
// It reports true if the request is authorized, and false otherwise.
// authorizePlatformRequest manages writing out any relevant authorization
// errors to the ResponseWriter itself.
func authorizePlatformRequest(w http.ResponseWriter, r *http.Request) (ok bool) {
switch distro.Get() { switch distro.Get() {
case distro.Synology: case distro.Synology:
return authorizeSynology(w, r) return authorizeSynology(w, r)
case distro.QNAP: case distro.QNAP:
return authorizeQNAP(w, r) return authorizeQNAP(w, r)
} }
return true
return false
} }
func (s *Server) serve(w http.ResponseWriter, r *http.Request) { // serveLoginAPI serves requests for the web login client.
switch { // It should only be called by Server.ServeHTTP, via Server.apiHandler,
case authorize(w, r): // which protects the handler using gorilla csrf.
// Authenticate and authorize the request for platforms that support it. func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
// Return if the request was processed. // The login client is run directly from client plugins,
// so first authenticate and authorize the request for the host platform.
if ok := authorizePlatformRequest(w, r); !ok {
return return
case strings.HasPrefix(r.URL.Path, "/api/"): }
// Pass API requests through to the API handler.
s.apiHandler.ServeHTTP(w, r) w.Header().Set("X-CSRF-Token", csrf.Token(r))
if r.URL.Path != "/api/data" { // only endpoint allowed for login client
http.Error(w, "invalid endpoint", http.StatusNotFound)
return return
}
switch r.Method {
case httpm.GET:
// TODO(soniaappasamy): implement
case httpm.POST:
// TODO(soniaappasamy): implement
default: default:
if !s.devMode { http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
s.lc.IncrementCounter(context.Background(), "web_client_page_load", 1)
} }
s.assetsHandler.ServeHTTP(w, r)
return return
}
} }
// 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.
func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) { func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
// TODO(sonia,2023-09-26): Currently the full web client is served
// directly from platform plugins, so uses platform native auth.
if ok := authorizePlatformRequest(w, r); !ok {
return
}
w.Header().Set("X-CSRF-Token", csrf.Token(r)) w.Header().Set("X-CSRF-Token", csrf.Token(r))
path := strings.TrimPrefix(r.URL.Path, "/api") path := strings.TrimPrefix(r.URL.Path, "/api")
switch { switch {

Loading…
Cancel
Save