Skip to content

Commit 4ce4bb6

Browse files
committed
client/web: limit authorization checks to API calls
This completes the migration to setting up authentication state in the client first before fetching any node data or rendering the client view. Notable changes: - `authorizeRequest` is now only enforced on `/api/*` calls (with the exception of /api/auth, which is handled early because it's needed to initially setup auth, particularly for synology) - re-separate the App and WebClient components to ensure that auth is completed before moving on - refactor platform auth (synology and QNAP) to fit into this new structure. Synology no longer returns redirect for auth, but returns authResponse instructing the client to fetch a SynoToken Updates tailscale/corp#14335 Signed-off-by: Will Norris <will@tailscale.com>
1 parent f27b2cf commit 4ce4bb6

File tree

6 files changed

+97
-94
lines changed

6 files changed

+97
-94
lines changed

client/web/qnap.go

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ package web
99
import (
1010
"crypto/tls"
1111
"encoding/xml"
12+
"errors"
1213
"fmt"
1314
"io"
1415
"log"
@@ -18,21 +19,17 @@ import (
1819

1920
// authorizeQNAP authenticates the logged-in QNAP user and verifies that they
2021
// are authorized to use the web client.
21-
// It reports true if the request is authorized to continue, and false otherwise.
22-
// authorizeQNAP manages writing out any relevant authorization errors to the
23-
// ResponseWriter itself.
24-
func authorizeQNAP(w http.ResponseWriter, r *http.Request) (ok bool) {
22+
// If the user is not authorized to use the client, an error is returned.
23+
func authorizeQNAP(r *http.Request) (ar authResponse, err error) {
2524
_, resp, err := qnapAuthn(r)
2625
if err != nil {
27-
http.Error(w, err.Error(), http.StatusUnauthorized)
28-
return false
26+
return ar, err
2927
}
3028
if resp.IsAdmin == 0 {
31-
http.Error(w, "user is not an admin", http.StatusForbidden)
32-
return false
29+
return ar, errors.New("user is not an admin")
3330
}
3431

35-
return true
32+
return authResponse{OK: true}, nil
3633
}
3734

3835
type qnapAuthResponse struct {

client/web/src/components/app.tsx

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,36 @@ import React from "react"
33
import LegacyClientView from "src/components/views/legacy-client-view"
44
import LoginClientView from "src/components/views/login-client-view"
55
import ReadonlyClientView from "src/components/views/readonly-client-view"
6-
import useAuth from "src/hooks/auth"
6+
import useAuth, { AuthResponse } from "src/hooks/auth"
77
import useNodeData from "src/hooks/node-data"
88
import ManagementClientView from "./views/management-client-view"
99

1010
export default function App() {
11-
const { data, refreshData, updateNode } = useNodeData()
1211
const { data: auth, loading: loadingAuth, waitOnAuth } = useAuth()
1312

1413
return (
1514
<div className="flex flex-col items-center min-w-sm max-w-lg mx-auto py-14">
16-
{!data || loadingAuth ? (
15+
{loadingAuth ? (
16+
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
17+
) : (
18+
<WebClient auth={auth} waitOnAuth={waitOnAuth} />
19+
)}
20+
</div>
21+
)
22+
}
23+
24+
function WebClient({
25+
auth,
26+
waitOnAuth,
27+
}: {
28+
auth?: AuthResponse
29+
waitOnAuth: () => Promise<void>
30+
}) {
31+
const { data, refreshData, updateNode } = useNodeData()
32+
33+
return (
34+
<>
35+
{!data ? (
1736
<div className="text-center py-14">Loading...</div> // TODO(sonia): add a loading view
1837
) : data?.Status === "NeedsLogin" || data?.Status === "NoState" ? (
1938
// Client not on a tailnet, render login.
@@ -35,8 +54,8 @@ export default function App() {
3554
updateNode={updateNode}
3655
/>
3756
)}
38-
{data && !loadingAuth && <Footer licensesURL={data.LicensesURL} />}
39-
</div>
57+
{data && <Footer licensesURL={data.LicensesURL} />}
58+
</>
4059
)
4160
}
4261

client/web/src/hooks/auth.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,27 @@ export type AuthResponse = {
1616
// for the web client.
1717
export default function useAuth() {
1818
const [data, setData] = useState<AuthResponse>()
19-
const [loading, setLoading] = useState<boolean>(false)
19+
const [loading, setLoading] = useState<boolean>(true)
2020

2121
const loadAuth = useCallback((wait?: boolean) => {
2222
const url = wait ? "/auth?wait=true" : "/auth"
2323
setLoading(true)
2424
return apiFetch(url, "GET")
2525
.then((r) => r.json())
2626
.then((d) => {
27-
if ((d as AuthResponse).authNeeded == AuthType.synology) {
28-
fetch("/webman/login.cgi")
29-
.then((r) => r.json())
30-
.then((data) => {
31-
setSynoToken(data.SynoToken)
32-
})
33-
}
34-
35-
setLoading(false)
3627
setData(d)
28+
switch ((d as AuthResponse).authNeeded) {
29+
case AuthType.synology:
30+
fetch("/webman/login.cgi")
31+
.then((r) => r.json())
32+
.then((a) => {
33+
setSynoToken(a.SynoToken)
34+
setLoading(false)
35+
})
36+
break
37+
default:
38+
setLoading(false)
39+
}
3740
})
3841
.catch((error) => {
3942
setLoading(false)

client/web/synology.go

Lines changed: 17 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
package web
88

99
import (
10+
"errors"
1011
"fmt"
1112
"net/http"
1213
"os/exec"
@@ -17,62 +18,44 @@ import (
1718

1819
// authorizeSynology authenticates the logged-in Synology user and verifies
1920
// that they are authorized to use the web client.
20-
// It reports true if the request is authorized to continue, and false otherwise.
21-
// authorizeSynology manages writing out any relevant authorization errors to the
22-
// ResponseWriter itself.
23-
func authorizeSynology(w http.ResponseWriter, r *http.Request) (ok bool) {
24-
if synoTokenRedirect(w, r) {
25-
return false
21+
// The returned authResponse indicates if the user is authorized,
22+
// and if additional steps are needed to authenticate the user.
23+
// If the user is authenticated, but not authorized to use the client, an error is returned.
24+
func authorizeSynology(r *http.Request) (resp authResponse, err error) {
25+
if !hasSynoToken(r) {
26+
return authResponse{OK: false, AuthNeeded: synoAuth}, nil
2627
}
2728

2829
// authenticate the Synology user
2930
cmd := exec.Command("/usr/syno/synoman/webman/modules/authenticate.cgi")
3031
out, err := cmd.CombinedOutput()
3132
if err != nil {
32-
http.Error(w, fmt.Sprintf("auth: %v: %s", err, out), http.StatusUnauthorized)
33-
return false
33+
return resp, fmt.Errorf("auth: %v: %s", err, out)
3434
}
3535
user := strings.TrimSpace(string(out))
3636

3737
// check if the user is in the administrators group
3838
isAdmin, err := groupmember.IsMemberOfGroup("administrators", user)
3939
if err != nil {
40-
http.Error(w, err.Error(), http.StatusForbidden)
41-
return false
40+
return resp, err
4241
}
4342
if !isAdmin {
44-
http.Error(w, "not a member of administrators group", http.StatusForbidden)
45-
return false
43+
return resp, errors.New("not a member of administrators group")
4644
}
4745

48-
return true
46+
return authResponse{OK: true}, nil
4947
}
5048

51-
func synoTokenRedirect(w http.ResponseWriter, r *http.Request) bool {
49+
// hasSynoToken returns true if the request include a SynoToken used for synology auth.
50+
func hasSynoToken(r *http.Request) bool {
5251
if r.Header.Get("X-Syno-Token") != "" {
53-
return false
52+
return true
5453
}
5554
if r.URL.Query().Get("SynoToken") != "" {
56-
return false
55+
return true
5756
}
5857
if r.Method == "POST" && r.FormValue("SynoToken") != "" {
59-
return false
58+
return true
6059
}
61-
// We need a SynoToken for authenticate.cgi.
62-
// So we tell the client to get one.
63-
_, _ = fmt.Fprint(w, synoTokenRedirectHTML)
64-
return true
60+
return false
6561
}
66-
67-
const synoTokenRedirectHTML = `<html>
68-
Redirecting with session token...
69-
<script>
70-
fetch("/webman/login.cgi")
71-
.then(r => r.json())
72-
.then(data => {
73-
u = new URL(window.location)
74-
u.searchParams.set("SynoToken", data.SynoToken)
75-
document.location = u
76-
})
77-
</script>
78-
`

client/web/web.go

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -178,10 +178,15 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
178178
}
179179

180180
func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
181-
if ok := s.authorizeRequest(w, r); !ok {
182-
return
183-
}
184181
if strings.HasPrefix(r.URL.Path, "/api/") {
182+
if r.Method == httpm.GET && r.URL.Path == "/api/auth" {
183+
s.serveAPIAuth(w, r)
184+
return
185+
}
186+
if ok := s.authorizeRequest(w, r); !ok {
187+
http.Error(w, "not authorized", http.StatusUnauthorized)
188+
return
189+
}
185190
// Pass API requests through to the API handler.
186191
s.apiHandler.ServeHTTP(w, r)
187192
return
@@ -208,9 +213,6 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
208213
case r.URL.Path == "/api/data" && r.Method == httpm.GET:
209214
// Readonly endpoint allowed without browser session.
210215
return true
211-
case r.URL.Path == "/api/auth":
212-
// Endpoint for browser to request auth allowed without browser session.
213-
return true
214216
case strings.HasPrefix(r.URL.Path, "/api/"):
215217
// All other /api/ endpoints require a valid browser session.
216218
//
@@ -229,15 +231,13 @@ func (s *Server) authorizeRequest(w http.ResponseWriter, r *http.Request) (ok bo
229231
}
230232
}
231233
// Client using system-specific auth.
232-
d := distro.Get()
233-
switch {
234-
case strings.HasPrefix(r.URL.Path, "/assets/") && r.Method == httpm.GET:
235-
// Don't require authorization for static assets.
236-
return true
237-
case d == distro.Synology:
238-
return authorizeSynology(w, r)
239-
case d == distro.QNAP:
240-
return authorizeQNAP(w, r)
234+
switch distro.Get() {
235+
case distro.Synology:
236+
resp, _ := authorizeSynology(r)
237+
return resp.OK
238+
case distro.QNAP:
239+
resp, _ := authorizeQNAP(r)
240+
return resp.OK
241241
default:
242242
return true // no additional auth for this distro
243243
}
@@ -252,11 +252,6 @@ func (s *Server) serveLoginAPI(w http.ResponseWriter, r *http.Request) {
252252
http.Error(w, "invalid endpoint", http.StatusNotFound)
253253
return
254254
}
255-
if r.URL.Path != "/api/auth" {
256-
// empty JSON response until we serve auth for the login client
257-
fmt.Fprintf(w, "{}")
258-
return
259-
}
260255
switch r.Method {
261256
case httpm.GET:
262257
// TODO(soniaappasamy): we may want a minimal node data response here
@@ -282,7 +277,9 @@ type authResponse struct {
282277
AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
283278
}
284279

285-
func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) {
280+
// serverAPIAuth handles requests to the /api/auth endpoint
281+
// and returns an authResponse indicating the current auth state and any steps the user needs to take.
282+
func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
286283
if r.Method != httpm.GET {
287284
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
288285
return
@@ -291,6 +288,24 @@ func (s *Server) serveTailscaleAuth(w http.ResponseWriter, r *http.Request) {
291288

292289
session, whois, err := s.getSession(r)
293290
switch {
291+
case err != nil && errors.Is(err, errNotUsingTailscale):
292+
// not using tailscale, so perform platform auth
293+
switch distro.Get() {
294+
case distro.Synology:
295+
resp, err = authorizeSynology(r)
296+
if err != nil {
297+
http.Error(w, err.Error(), http.StatusUnauthorized)
298+
return
299+
}
300+
case distro.QNAP:
301+
resp, err = authorizeQNAP(r)
302+
if err != nil {
303+
http.Error(w, err.Error(), http.StatusUnauthorized)
304+
return
305+
}
306+
default:
307+
resp.OK = true // no additional auth for this distro
308+
}
294309
case err != nil && !errors.Is(err, errNoSession):
295310
http.Error(w, err.Error(), http.StatusUnauthorized)
296311
return
@@ -341,14 +356,6 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
341356
w.Header().Set("X-CSRF-Token", csrf.Token(r))
342357
path := strings.TrimPrefix(r.URL.Path, "/api")
343358
switch {
344-
case path == "/auth":
345-
if s.tsDebugMode == "full" { // behind debug flag
346-
s.serveTailscaleAuth(w, r)
347-
} else {
348-
// empty JSON response until we serve auth for other modes
349-
fmt.Fprintf(w, "{}")
350-
}
351-
return
352359
case path == "/data":
353360
switch r.Method {
354361
case httpm.GET:

client/web/web_test.go

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -369,12 +369,6 @@ func TestAuthorizeRequest(t *testing.T) {
369369
wantOkNotOverTailscale: false,
370370
wantOkWithoutSession: false,
371371
wantOkWithSession: true,
372-
}, {
373-
reqPath: "/api/auth",
374-
reqMethod: httpm.GET,
375-
wantOkNotOverTailscale: false,
376-
wantOkWithoutSession: true,
377-
wantOkWithSession: true,
378372
}, {
379373
reqPath: "/api/somethingelse",
380374
reqMethod: httpm.GET,
@@ -587,7 +581,7 @@ func TestServeTailscaleAuth(t *testing.T) {
587581
r.RemoteAddr = remoteIP
588582
r.AddCookie(&http.Cookie{Name: sessionCookieName, Value: tt.cookie})
589583
w := httptest.NewRecorder()
590-
s.serveTailscaleAuth(w, r)
584+
s.serveAPIAuth(w, r)
591585
res := w.Result()
592586
defer res.Body.Close()
593587

0 commit comments

Comments
 (0)