add tests for SSE-powered login

* add test for sse login
* add test for invalid solution
This commit is contained in:
Henry 2021-03-26 11:50:16 +01:00
parent e9883a049b
commit 7eeca8533d
5 changed files with 253 additions and 29 deletions

View File

@ -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}`
})

View File

@ -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 {

View File

@ -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)
}

View File

@ -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
}

View File

@ -4,13 +4,13 @@
<h1 id="welcome" class="text-lg">{{i18n "AuthWithSSBServerStart"}}</h1>
</div>
<div>
<img src="{{.QRCodeURI}}" alt="QR-Code to pass the challenge to an App" />
<a href="{{.SSBURI}}" target="_blank">{{i18n "GenericOpenLink"}}</a>
<img id="start-auth-qrcode" src="{{.QRCodeURI}}" alt="QR-Code to pass the challenge to an App" />
<a id="start-auth-uri" href="{{.SSBURI}}" target="_blank">{{i18n "GenericOpenLink"}}</a>
<h3>Server events</h3>
<p id="ping"></p>
<p id="failed" class="text-red-500"></p>
</div>
<div id="challenge" ch="{{.ServerChallenge}}"></div>
<div id="challenge" data-sc="{{.ServerChallenge}}"></div>
<script src="/assets/login-events.js"></script>
{{end}}