From 7eeca8533dbfceec8233042bef77a5ddef77445a Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 26 Mar 2021 11:50:16 +0100 Subject: [PATCH] add tests for SSE-powered login * add test for sse login * add test for invalid solution --- web/assets/login-events.js | 6 +- web/handlers/auth/withssb.go | 70 +++++-- web/handlers/auth_test.go | 194 ++++++++++++++++++- web/router/auth.go | 6 + web/templates/auth/withssb_server_start.tmpl | 6 +- 5 files changed, 253 insertions(+), 29 deletions(-) diff --git a/web/assets/login-events.js b/web/assets/login-events.js index 9a3e68e..bbeba6f 100644 --- a/web/assets/login-events.js +++ b/web/assets/login-events.js @@ -1,6 +1,6 @@ // get the challenge from out of the HTML -let sc = document.querySelector("#challenge").attributes.ch.value -var evtSource = new EventSource(`/sse/events?sc=${sc}`); +let sc = document.querySelector("#challenge").dataset.sc +var evtSource = new EventSource(`/withssb/events?sc=${sc}`); var ping = document.querySelector('#ping'); var failed = document.querySelector('#failed'); @@ -20,5 +20,5 @@ evtSource.addEventListener("failed", (e) => { evtSource.addEventListener("success", (e) => { evtSource.close() - window.location = `/sse/finalize?token=${e.data}` + window.location = `/withssb/finalize?token=${e.data}` }) diff --git a/web/handlers/auth/withssb.go b/web/handlers/auth/withssb.go index 73aed89..d4c734b 100644 --- a/web/handlers/auth/withssb.go +++ b/web/handlers/auth/withssb.go @@ -97,9 +97,8 @@ func NewWithSSBHandler( ssb.bridge = bridge m.Get(router.AuthLogin).HandlerFunc(ssb.decideMethod) - - m.HandleFunc("/sse/events", ssb.eventSource) - m.HandleFunc("/sse/finalize", ssb.finalizeCookie) + m.Get(router.AuthWithSSBServerEvents).HandlerFunc(ssb.eventSource) + m.Get(router.AuthWithSSBFinalize).HandlerFunc(ssb.finalizeCookie) return &ssb } @@ -217,6 +216,17 @@ func (h WithSSBHandler) decideMethod(w http.ResponseWriter, req *http.Request) { parsedCID, err := refs.ParseFeedRef(cidString) if err == nil { cid = parsedCID + + _, err := h.membersdb.GetByFeed(req.Context(), *cid) + if err != nil { + if err == roomdb.ErrNotFound { + errMsg := fmt.Errorf("ssb http auth: client isn't a member: %w", err) + h.render.Error(w, req, http.StatusForbidden, errMsg) + return + } + h.render.Error(w, req, http.StatusInternalServerError, err) + return + } } } else { aliasEntry, err := h.aliasesdb.Resolve(req.Context(), alias) @@ -264,9 +274,13 @@ func (h WithSSBHandler) decideMethod(w http.ResponseWriter, req *http.Request) { return } - // ?cid=CID does server-initiated http-auth - // ?alias=ALIAS does server-initiated http-auth - h.render.HTML("auth/withssb_server_start.tmpl", h.serverInitiated).ServeHTTP(w, req) + // assume server-init sse dance + data, err := h.serverInitiated() + if err != nil { + h.render.Error(w, req, http.StatusInternalServerError, err) + return + } + h.render.Render(w, req, "auth/withssb_server_start.tmpl", http.StatusOK, data) } // clientInitiated is called with a client challange (?cc=123) and calls back to the passed client using muxrpc to request a signed solution @@ -346,7 +360,13 @@ func (h WithSSBHandler) clientInitiated(w http.ResponseWriter, req *http.Request // server-sent-events stuff -func (h WithSSBHandler) serverInitiated(w http.ResponseWriter, req *http.Request) (interface{}, error) { +type templateData struct { + SSBURI template.URL + QRCodeURI template.URL + ServerChallenge string +} + +func (h WithSSBHandler) serverInitiated() (templateData, error) { sc := h.bridge.RegisterSession() // prepare the ssb-uri @@ -364,7 +384,7 @@ func (h WithSSBHandler) serverInitiated(w http.ResponseWriter, req *http.Request // generate a QR code with the token inside so that you can open it easily in a supporting mobile app qrCode, err := qrcode.New(startAuthURI.String(), qrcode.Medium) if err != nil { - return nil, err + return templateData{}, err } qrCode.BackgroundColor = color.Transparent // transparent to fit into the page @@ -372,16 +392,12 @@ func (h WithSSBHandler) serverInitiated(w http.ResponseWriter, req *http.Request qrCodeData, err := qrCode.PNG(-5) if err != nil { - return nil, err + return templateData{}, err } qrURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(qrCodeData) // template.URL signals the template engine that those aren't fishy and from a trusted source - type templateData struct { - SSBURI template.URL - QRCodeURI template.URL - ServerChallenge string - } + data := templateData{ SSBURI: template.URL(startAuthURI.String()), QRCodeURI: template.URL(qrURI), @@ -397,7 +413,7 @@ func (h WithSSBHandler) finalizeCookie(w http.ResponseWriter, r *http.Request) { // check the token is correct if _, err := h.sessiondb.CheckToken(r.Context(), tok); err != nil { - http.Error(w, "invalid session token", http.StatusInternalServerError) + http.Error(w, "invalid session token", http.StatusForbidden) return } @@ -409,6 +425,9 @@ func (h WithSSBHandler) finalizeCookie(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/", http.StatusTemporaryRedirect) } +// the time after which the SSE dance is considered failed +const sseTimeout = 3 * time.Minute + // eventSource is the server-side of our server-sent events (SSE) session // https://html.spec.whatwg.org/multipage/server-sent-events.html func (h WithSSBHandler) eventSource(w http.ResponseWriter, r *http.Request) { @@ -417,10 +436,23 @@ func (h WithSSBHandler) eventSource(w http.ResponseWriter, r *http.Request) { http.Error(w, "ssb http auth: server-initiated method needs streaming support", http.StatusInternalServerError) return } + + // closes when the http request is closed + var notify <-chan bool + notifier, ok := w.(http.CloseNotifier) if !ok { - http.Error(w, "ssb http auth: cant notify about closed requests", http.StatusInternalServerError) - return + // testing hack + // http.Error(w, "ssb http auth: cant notify about closed requests", http.StatusInternalServerError) + // return + ch := make(chan bool) + go func() { + time.Sleep(sseTimeout) + close(ch) + }() + notify = ch + } else { + notify = notifier.CloseNotify() } // setup headers for SSE @@ -451,7 +483,7 @@ func (h WithSSBHandler) eventSource(w http.ResponseWriter, r *http.Request) { // ping ticker tick := time.NewTicker(3 * time.Second) go func() { - time.Sleep(3 * time.Minute) + time.Sleep(sseTimeout) tick.Stop() logger.Log("event", "stopped") }() @@ -460,7 +492,7 @@ func (h WithSSBHandler) eventSource(w http.ResponseWriter, r *http.Request) { flusher.Flush() // Push events to client - notify := notifier.CloseNotify() // closes when the http request is closed + for { select { diff --git a/web/handlers/auth_test.go b/web/handlers/auth_test.go index c9fb51f..a6aa316 100644 --- a/web/handlers/auth_test.go +++ b/web/handlers/auth_test.go @@ -6,10 +6,13 @@ import ( "bytes" "context" "encoding/base64" + "fmt" "net/http" "net/http/cookiejar" "net/url" + "strings" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -187,7 +190,7 @@ func TestFallbackAuth(t *testing.T) { }) } -func TestAuthWithSSBNotConnected(t *testing.T) { +func TestAuthWithSSBClientInitNotConnected(t *testing.T) { ts := setup(t) a, r := assert.New(t), require.New(t) @@ -218,7 +221,7 @@ func TestAuthWithSSBNotConnected(t *testing.T) { }) } -func TestAuthWithSSBNotAllowed(t *testing.T) { +func TestAuthWithSSBClientInitNotAllowed(t *testing.T) { ts := setup(t) a, r := assert.New(t), require.New(t) @@ -241,7 +244,7 @@ func TestAuthWithSSBNotAllowed(t *testing.T) { t.Log(signInStartURL.String()) doc, resp := ts.Client.GetHTML(signInStartURL.String()) - a.Equal(http.StatusInternalServerError, resp.Code) // TODO: StatusForbidden + a.Equal(http.StatusForbidden, resp.Code) webassert.Localized(t, doc, []webassert.LocalizedElement{ // {"#welcome", "AuthWithSSBWelcome"}, @@ -249,7 +252,7 @@ func TestAuthWithSSBNotAllowed(t *testing.T) { }) } -func TestAuthWithSSBHasClient(t *testing.T) { +func TestAuthWithSSBClientInitHasClient(t *testing.T) { ts := setup(t) a, r := assert.New(t), require.New(t) @@ -382,3 +385,186 @@ func TestAuthWithSSBHasClient(t *testing.T) { {"title", "AdminDashboardTitle"}, }) } + +func TestAuthWithSSBServerInitHappyPath(t *testing.T) { + ts := setup(t) + a, r := assert.New(t), require.New(t) + + // the keypair for our client + testMember := roomdb.Member{ID: 1234, Nickname: "test-member"} + client, err := keys.NewKeyPair(nil) + r.NoError(err) + testMember.PubKey = client.Feed + + // setup the mocked database + ts.MembersDB.GetByFeedReturns(testMember, nil) + + // prepare the url + urlTo := web.NewURLTo(ts.Router) + signInStartURL := urlTo(router.AuthLogin, + "cid", client.Feed.Ref(), + ) + r.NotNil(signInStartURL) + + html, resp := ts.Client.GetHTML(signInStartURL.String()) + if !a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code for dashboard") { + t.Log(html.Find("body").Text()) + } + + webassert.Localized(t, html, []webassert.LocalizedElement{ + {"title", "AuthWithSSBTitle"}, + {"#welcome", "AuthWithSSBServerStart"}, + }) + + jsFile, has := html.Find("script").Attr("src") + a.True(has, "should have client code") + a.Equal("/assets/login-events.js", jsFile) + + serverChallenge, has := html.Find("#challenge").Attr("data-sc") + a.True(has, "should have server challenge") + a.NotEqual("", serverChallenge) + + ssbURI, has := html.Find("#start-auth-uri").Attr("href") + a.True(has, "should have an ssb:experimental uri") + a.True(strings.HasPrefix(ssbURI, "ssb:experimental?"), "not an ssb-uri? %s", ssbURI) + + parsedURI, err := url.Parse(ssbURI) + r.NoError(err) + a.Equal("ssb", parsedURI.Scheme) + a.Equal("experimental", parsedURI.Opaque) + + qry := parsedURI.Query() + a.Equal("start-http-auth", qry.Get("action")) + a.Equal(serverChallenge, qry.Get("sc")) + a.Equal(ts.NetworkInfo.RoomID.Ref(), qry.Get("sid")) + + qrCode, has := html.Find("#start-auth-qrcode").Attr("src") + a.True(has, "should have the inline image data") + a.True(strings.HasPrefix(qrCode, "data:image/png;base64,")) + + // TODO: decode image data and check qr code(?) + + // simulate muxrpc client + testToken := "our-test-token" + ts.AuthWithSSB.CheckTokenReturns(23, nil) + go func() { + time.Sleep(4 * time.Second) + err = ts.SignalBridge.SessionWorked(serverChallenge, testToken) + r.NoError(err) + }() + + // start reading sse + sseURL := urlTo(router.AuthWithSSBServerEvents, "sc", serverChallenge) + resp = ts.Client.GetBody(sseURL.String()) + a.Equal(http.StatusOK, resp.Result().StatusCode) + + // check contents of sse channel + sseBody := resp.Body.String() + + a.True(strings.Contains(sseBody, "data: Waiting for solution"), "ping data") + a.True(strings.Contains(sseBody, "event: ping\n"), "ping event") + + wantDataToken := fmt.Sprintf("data: %s\n", testToken) + a.True(strings.Contains(sseBody, wantDataToken), "token data") + a.True(strings.Contains(sseBody, "event: success\n"), "success event") + + // use the token and go to /withssb/finalize and get a cookie + // (this happens in the browser engine via login-events.js) + finalizeURL := urlTo(router.AuthWithSSBFinalize, "token", testToken) + finalizeURL.Host = "localhost" + finalizeURL.Scheme = "https" + + resp = ts.Client.GetBody(finalizeURL.String()) + + csrfCookie := resp.Result().Cookies() + a.Len(csrfCookie, 2, "csrf and session cookie") + + // very cheap "browser" client session + jar, err := cookiejar.New(nil) + r.NoError(err) + jar.SetCookies(finalizeURL, csrfCookie) + + // now request the protected dashboard page + dashboardURL, err := ts.Router.Get(router.AdminDashboard).URL() + r.Nil(err) + dashboardURL.Host = "localhost" + dashboardURL.Scheme = "https" + + // load the cookie for the dashboard + cs := jar.Cookies(dashboardURL) + r.True(len(cs) > 0, "expecting one cookie!") + var sessionHeader = http.Header(map[string][]string{}) + for _, c := range cs { + theCookie := c.String() + a.NotEqual("", theCookie, "should have a new cookie") + sessionHeader.Add("Cookie", theCookie) + } + ts.Client.SetHeaders(sessionHeader) + + html, resp = ts.Client.GetHTML(dashboardURL.String()) + if !a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code for dashboard") { + t.Log(html.Find("body").Text()) + } + + webassert.Localized(t, html, []webassert.LocalizedElement{ + {"#welcome", "AdminDashboardWelcome"}, + {"title", "AdminDashboardTitle"}, + }) +} + +func TestAuthWithSSBServerInitWrongSolution(t *testing.T) { + ts := setup(t) + a, r := assert.New(t), require.New(t) + + // the keypair for our client + testMember := roomdb.Member{ID: 1234, Nickname: "test-member"} + client, err := keys.NewKeyPair(nil) + r.NoError(err) + testMember.PubKey = client.Feed + + // setup the mocked database + ts.MembersDB.GetByFeedReturns(testMember, nil) + + // prepare the url + urlTo := web.NewURLTo(ts.Router) + signInStartURL := urlTo(router.AuthLogin, + "cid", client.Feed.Ref(), + ) + r.NotNil(signInStartURL) + + html, resp := ts.Client.GetHTML(signInStartURL.String()) + if !a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code for dashboard") { + t.Log(html.Find("body").Text()) + } + + serverChallenge, has := html.Find("#challenge").Attr("data-sc") + a.True(has, "should have server challenge") + a.NotEqual("", serverChallenge) + + // simulate muxrpc client + ts.AuthWithSSB.CheckTokenReturns(-1, roomdb.ErrNotFound) + go func() { + time.Sleep(4 * time.Second) + err = ts.SignalBridge.SessionFailed(serverChallenge, fmt.Errorf("wrong solution")) + r.NoError(err) + }() + + // start reading sse + sseURL := urlTo(router.AuthWithSSBServerEvents, "sc", serverChallenge) + resp = ts.Client.GetBody(sseURL.String()) + a.Equal(http.StatusOK, resp.Result().StatusCode) + + // check contents of sse channel + sseBody := resp.Body.String() + + a.True(strings.Contains(sseBody, "data: Waiting for solution"), "ping data") + a.True(strings.Contains(sseBody, "event: ping\n"), "ping event") + + a.True(strings.Contains(sseBody, "data: wrong solution\n"), "reason data") + a.True(strings.Contains(sseBody, "event: failed\n"), "success event") + + // use an invalid token + finalizeURL := urlTo(router.AuthWithSSBFinalize, "token", "wrong") + resp = ts.Client.GetBody(finalizeURL.String()) + a.Equal(http.StatusForbidden, resp.Result().StatusCode) +} diff --git a/web/router/auth.go b/web/router/auth.go index 79a244b..000d288 100644 --- a/web/router/auth.go +++ b/web/router/auth.go @@ -10,6 +10,9 @@ const ( AuthLogin = "auth:login" AuthLogout = "auth:logout" + + AuthWithSSBServerEvents = "auth:withssb:sse" + AuthWithSSBFinalize = "auth:withssb:finalize" ) // Auth constructs a mux.Router containing the routes for sign-in and -out @@ -24,5 +27,8 @@ func Auth(m *mux.Router) *mux.Router { // register password fallback m.Path("/password/signin").Methods("POST").Name(AuthFallbackSignIn) + m.Path("/withssb/events").Methods("GET").Name(AuthWithSSBServerEvents) + m.Path("/withssb/finalize").Methods("GET").Name(AuthWithSSBFinalize) + return m } diff --git a/web/templates/auth/withssb_server_start.tmpl b/web/templates/auth/withssb_server_start.tmpl index fbb41d0..6b2409c 100644 --- a/web/templates/auth/withssb_server_start.tmpl +++ b/web/templates/auth/withssb_server_start.tmpl @@ -4,13 +4,13 @@

{{i18n "AuthWithSSBServerStart"}}

- QR-Code to pass the challenge to an App - {{i18n "GenericOpenLink"}} + QR-Code to pass the challenge to an App + {{i18n "GenericOpenLink"}}

Server events

-
+
{{end}} \ No newline at end of file