add tests for SSE-powered login
* add test for sse login * add test for invalid solution
This commit is contained in:
parent
e9883a049b
commit
7eeca8533d
|
@ -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}`
|
||||
})
|
||||
|
|
|
@ -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 {
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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}}
|
Loading…
Reference in New Issue