|
@@ -2,6 +2,7 @@ package handlers
|
|
|
|
|
|
|
|
import (
|
|
import (
|
|
|
"context"
|
|
"context"
|
|
|
|
|
+ "encoding/base64"
|
|
|
"errors"
|
|
"errors"
|
|
|
"log/slog"
|
|
"log/slog"
|
|
|
"net/http"
|
|
"net/http"
|
|
@@ -16,9 +17,11 @@ import (
|
|
|
"github.com/mk6i/open-oscar-server/wire"
|
|
"github.com/mk6i/open-oscar-server/wire"
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
-// testAuthService implements AuthService for ClientLogin tests (only FLAPLogin is exercised).
|
|
|
|
|
|
|
+// testAuthService implements AuthService for ClientLogin tests (only FLAPLogin and
|
|
|
|
|
+// CrackCookie are exercised).
|
|
|
type testAuthService struct {
|
|
type testAuthService struct {
|
|
|
- flapLogin func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error)
|
|
|
|
|
|
|
+ flapLogin func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error)
|
|
|
|
|
+ crackCookie func(authCookie []byte) (state.ServerCookie, error)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func (t *testAuthService) BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error) {
|
|
func (t *testAuthService) BUCPChallenge(ctx context.Context, bodyIn wire.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (wire.SNACMessage, error) {
|
|
@@ -30,9 +33,27 @@ func (t *testAuthService) BUCPLogin(ctx context.Context, bodyIn wire.SNAC_0x17_0
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func (t *testAuthService) CrackCookie(authCookie []byte) (state.ServerCookie, error) {
|
|
func (t *testAuthService) CrackCookie(authCookie []byte) (state.ServerCookie, error) {
|
|
|
|
|
+ if t.crackCookie != nil {
|
|
|
|
|
+ return t.crackCookie(authCookie)
|
|
|
|
|
+ }
|
|
|
return state.ServerCookie{}, nil
|
|
return state.ServerCookie{}, nil
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// signedCookieFor stands in for a CookieBaker-signed cookie naming screenName.
|
|
|
|
|
+func signedCookieFor(screenName string) []byte {
|
|
|
|
|
+ return []byte("signed:" + screenName)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// crackSignedCookie accepts only cookies produced by signedCookieFor, standing in
|
|
|
|
|
+// for the signature check the real baker performs.
|
|
|
|
|
+func crackSignedCookie(authCookie []byte) (state.ServerCookie, error) {
|
|
|
|
|
+ name, ok := strings.CutPrefix(string(authCookie), "signed:")
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ return state.ServerCookie{}, errors.New("bad signature")
|
|
|
|
|
+ }
|
|
|
|
|
+ return state.ServerCookie{ScreenName: state.DisplayScreenName(name)}, nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
func (t *testAuthService) RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, conf func(sess *state.Session)) (*state.SessionInstance, error) {
|
|
func (t *testAuthService) RegisterBOSSession(ctx context.Context, authCookie state.ServerCookie, conf func(sess *state.Session)) (*state.SessionInstance, error) {
|
|
|
return nil, nil
|
|
return nil, nil
|
|
|
}
|
|
}
|
|
@@ -50,85 +71,95 @@ func (t *testAuthService) SignoutChat(ctx context.Context, sess *state.Session)
|
|
|
|
|
|
|
|
func successfulLoginBlock() wire.TLVRestBlock {
|
|
func successfulLoginBlock() wire.TLVRestBlock {
|
|
|
var b wire.TLVRestBlock
|
|
var b wire.TLVRestBlock
|
|
|
- b.Append(wire.NewTLVBE(wire.OServiceTLVTagsLoginCookie, []byte("fake-auth-cookie-bytes")))
|
|
|
|
|
|
|
+ b.Append(wire.NewTLVBE(wire.LoginTLVTagsAuthorizationCookie, loginBlockCookie))
|
|
|
return b
|
|
return b
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-func failedLoginBlock() wire.TLVRestBlock {
|
|
|
|
|
|
|
+// loginBlockCookie is the cookie successfulLoginBlock reports as minted by the auth
|
|
|
|
|
+// service. Handlers must hand this exact value back rather than mint their own.
|
|
|
|
|
+var loginBlockCookie = signedCookieFor("testuser")
|
|
|
|
|
+
|
|
|
|
|
+// blockWithoutCookie is a login response that reports neither an error nor a cookie.
|
|
|
|
|
+func blockWithoutCookie() wire.TLVRestBlock {
|
|
|
var b wire.TLVRestBlock
|
|
var b wire.TLVRestBlock
|
|
|
- b.Append(wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, uint16(1)))
|
|
|
|
|
|
|
+ b.Append(wire.NewTLVBE(wire.LoginTLVTagsScreenName, "testuser"))
|
|
|
return b
|
|
return b
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-type testCookieBaker struct {
|
|
|
|
|
- issue func(data []byte) ([]byte, error)
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-func (t *testCookieBaker) Issue(data []byte) ([]byte, error) {
|
|
|
|
|
- if t.issue != nil {
|
|
|
|
|
- return t.issue(data)
|
|
|
|
|
- }
|
|
|
|
|
- return []byte("issued-cookie"), nil
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-func (t *testCookieBaker) Crack(data []byte) ([]byte, error) {
|
|
|
|
|
- return data, nil
|
|
|
|
|
|
|
+func failedLoginBlock() wire.TLVRestBlock {
|
|
|
|
|
+ var b wire.TLVRestBlock
|
|
|
|
|
+ b.Append(wire.NewTLVBE(wire.LoginTLVTagsErrorSubcode, uint16(1)))
|
|
|
|
|
+ return b
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func TestAuthHandler_GetToken(t *testing.T) {
|
|
func TestAuthHandler_GetToken(t *testing.T) {
|
|
|
|
|
+ validToken := base64.URLEncoding.EncodeToString(signedCookieFor("testuser"))
|
|
|
|
|
+
|
|
|
tests := []struct {
|
|
tests := []struct {
|
|
|
- name string
|
|
|
|
|
- query string
|
|
|
|
|
- cookies []*http.Cookie
|
|
|
|
|
- checkBody func(*testing.T, string)
|
|
|
|
|
- expectedCode int
|
|
|
|
|
|
|
+ name string
|
|
|
|
|
+ query string
|
|
|
|
|
+ cookies []*http.Cookie
|
|
|
|
|
+ checkBody func(*testing.T, string)
|
|
|
}{
|
|
}{
|
|
|
{
|
|
{
|
|
|
- name: "Success_LocalAuthUserCookie",
|
|
|
|
|
|
|
+ name: "Success_TokenCookie",
|
|
|
query: "f=json&attributes=loginId&devId=ao1yOLlHVHhsa3o6&c=_callbacks_._0mq8wqdav",
|
|
query: "f=json&attributes=loginId&devId=ao1yOLlHVHhsa3o6&c=_callbacks_._0mq8wqdav",
|
|
|
cookies: []*http.Cookie{
|
|
cookies: []*http.Cookie{
|
|
|
- {Name: "localAuthUser", Value: "testuser||Test User"},
|
|
|
|
|
|
|
+ {Name: bosTokenCookie, Value: validToken},
|
|
|
},
|
|
},
|
|
|
checkBody: func(t *testing.T, body string) {
|
|
checkBody: func(t *testing.T, body string) {
|
|
|
assert.Contains(t, body, "_callbacks_._0mq8wqdav(")
|
|
assert.Contains(t, body, "_callbacks_._0mq8wqdav(")
|
|
|
assert.Contains(t, body, `"statusCode":200`)
|
|
assert.Contains(t, body, `"statusCode":200`)
|
|
|
assert.Contains(t, body, `"loginId":"testuser"`)
|
|
assert.Contains(t, body, `"loginId":"testuser"`)
|
|
|
- assert.Contains(t, body, `"a":`)
|
|
|
|
|
|
|
+ // The parked token is handed straight back, not re-minted.
|
|
|
|
|
+ assert.Contains(t, body, `"a":"`+validToken+`"`)
|
|
|
|
|
+ assert.Contains(t, body, `"expiresIn":"60"`)
|
|
|
},
|
|
},
|
|
|
- expectedCode: http.StatusOK,
|
|
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
- name: "Unauthorized_NoSession",
|
|
|
|
|
|
|
+ name: "Unauthorized_NoCookie",
|
|
|
query: "f=json&attributes=loginId&devId=ao1yOLlHVHhsa3o6&c=_callbacks_._abc",
|
|
query: "f=json&attributes=loginId&devId=ao1yOLlHVHhsa3o6&c=_callbacks_._abc",
|
|
|
checkBody: func(t *testing.T, body string) {
|
|
checkBody: func(t *testing.T, body string) {
|
|
|
assert.Contains(t, body, `"statusCode":401`)
|
|
assert.Contains(t, body, `"statusCode":401`)
|
|
|
assert.Contains(t, body, `"redirectURL"`)
|
|
assert.Contains(t, body, `"redirectURL"`)
|
|
|
},
|
|
},
|
|
|
- expectedCode: http.StatusOK,
|
|
|
|
|
},
|
|
},
|
|
|
{
|
|
{
|
|
|
- // getToken no longer checks account existence; an unknown screen name
|
|
|
|
|
- // still receives a token. Existence is enforced later by
|
|
|
|
|
- // RegisterBOSSession during startSession.
|
|
|
|
|
- name: "Success_UnknownUserStillIssuesToken",
|
|
|
|
|
|
|
+ // A token past its brief life no longer cracks, which is what makes a
|
|
|
|
|
+ // later visit sign in again.
|
|
|
|
|
+ name: "Unauthorized_UnsignedToken",
|
|
|
query: "f=json&attributes=loginId&devId=dev123&c=_callbacks_._xyz",
|
|
query: "f=json&attributes=loginId&devId=dev123&c=_callbacks_._xyz",
|
|
|
cookies: []*http.Cookie{
|
|
cookies: []*http.Cookie{
|
|
|
- {Name: "localAuthUser", Value: "missing||Missing User"},
|
|
|
|
|
|
|
+ {Name: bosTokenCookie, Value: base64.URLEncoding.EncodeToString([]byte("victim"))},
|
|
|
},
|
|
},
|
|
|
checkBody: func(t *testing.T, body string) {
|
|
checkBody: func(t *testing.T, body string) {
|
|
|
- assert.Contains(t, body, `"statusCode":200`)
|
|
|
|
|
- assert.Contains(t, body, `"loginId":"missing"`)
|
|
|
|
|
- assert.Contains(t, body, `"a":`)
|
|
|
|
|
|
|
+ assert.Contains(t, body, `"statusCode":401`)
|
|
|
|
|
+ assert.Contains(t, body, `"redirectURL"`)
|
|
|
|
|
+ assert.NotContains(t, body, "victim")
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ // The screen name comes only from a signature-verified token, so these
|
|
|
|
|
+ // forgeable plaintext cookies must not authenticate anyone.
|
|
|
|
|
+ name: "Unauthorized_ForgedSSOCookies",
|
|
|
|
|
+ query: "f=json&attributes=loginId&devId=dev123&c=_callbacks_._xyz",
|
|
|
|
|
+ cookies: []*http.Cookie{
|
|
|
|
|
+ {Name: "RSP_USER", Value: "victim"},
|
|
|
|
|
+ {Name: "RSP_LOCAL", Value: "victim"},
|
|
|
|
|
+ {Name: "localAuthUser", Value: "victim||victim"},
|
|
|
|
|
+ },
|
|
|
|
|
+ checkBody: func(t *testing.T, body string) {
|
|
|
|
|
+ assert.Contains(t, body, `"statusCode":401`)
|
|
|
|
|
+ assert.Contains(t, body, `"redirectURL"`)
|
|
|
|
|
+ assert.NotContains(t, body, "victim")
|
|
|
},
|
|
},
|
|
|
- expectedCode: http.StatusOK,
|
|
|
|
|
},
|
|
},
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
for _, tt := range tests {
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
handler := &AuthHandler{
|
|
handler := &AuthHandler{
|
|
|
- AuthService: &testAuthService{},
|
|
|
|
|
- CookieBaker: &testCookieBaker{},
|
|
|
|
|
|
|
+ AuthService: &testAuthService{crackCookie: crackSignedCookie},
|
|
|
Logger: slog.Default(),
|
|
Logger: slog.Default(),
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -141,14 +172,49 @@ func TestAuthHandler_GetToken(t *testing.T) {
|
|
|
rr := httptest.NewRecorder()
|
|
rr := httptest.NewRecorder()
|
|
|
handler.GetToken(rr, req)
|
|
handler.GetToken(rr, req)
|
|
|
|
|
|
|
|
- assert.Equal(t, tt.expectedCode, rr.Code)
|
|
|
|
|
- if tt.checkBody != nil {
|
|
|
|
|
- tt.checkBody(t, rr.Body.String())
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ assert.Equal(t, http.StatusOK, rr.Code)
|
|
|
|
|
+ tt.checkBody(t, rr.Body.String())
|
|
|
|
|
+
|
|
|
|
|
+ // Spent either way, so a reload has nothing to sign in with.
|
|
|
|
|
+ assert.True(t, tokenCookieCleared(rr), "getToken should expire the token cookie")
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// tokenCookieCleared reports whether the response expires the token cookie.
|
|
|
|
|
+func tokenCookieCleared(rr *httptest.ResponseRecorder) bool {
|
|
|
|
|
+ for _, c := range rr.Result().Cookies() {
|
|
|
|
|
+ if c.Name == bosTokenCookie && c.MaxAge < 0 {
|
|
|
|
|
+ return true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return false
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// A second getToken must fail even inside the token's own lifetime: the cookie is
|
|
|
|
|
+// gone after the first, so every reload lands on the login page.
|
|
|
|
|
+func TestAuthHandler_GetToken_IsOneShot(t *testing.T) {
|
|
|
|
|
+ handler := &AuthHandler{
|
|
|
|
|
+ AuthService: &testAuthService{crackCookie: crackSignedCookie},
|
|
|
|
|
+ Logger: slog.Default(),
|
|
|
|
|
+ }
|
|
|
|
|
+ validToken := base64.URLEncoding.EncodeToString(signedCookieFor("testuser"))
|
|
|
|
|
+
|
|
|
|
|
+ get := func(withCookie bool) string {
|
|
|
|
|
+ req := httptest.NewRequest(http.MethodGet, "/auth/getToken?f=json&attributes=loginId&devId=dev1", nil)
|
|
|
|
|
+ if withCookie {
|
|
|
|
|
+ req.AddCookie(&http.Cookie{Name: bosTokenCookie, Value: validToken})
|
|
|
|
|
+ }
|
|
|
|
|
+ rr := httptest.NewRecorder()
|
|
|
|
|
+ handler.GetToken(rr, req)
|
|
|
|
|
+ return rr.Body.String()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ assert.Contains(t, get(true), `"statusCode":200`)
|
|
|
|
|
+ // The browser dropped the cookie, so the follow-up presents nothing.
|
|
|
|
|
+ assert.Contains(t, get(false), `"statusCode":401`)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
func TestAuthHandler_ClientLogin(t *testing.T) {
|
|
func TestAuthHandler_ClientLogin(t *testing.T) {
|
|
|
tests := []struct {
|
|
tests := []struct {
|
|
|
name string
|
|
name string
|
|
@@ -172,6 +238,8 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
|
|
|
expectedStatusCode: http.StatusOK,
|
|
expectedStatusCode: http.StatusOK,
|
|
|
checkResponse: func(t *testing.T, body string) {
|
|
checkResponse: func(t *testing.T, body string) {
|
|
|
assert.Contains(t, body, `"statusCode":200`)
|
|
assert.Contains(t, body, `"statusCode":200`)
|
|
|
|
|
+ // The token is the cookie the auth service minted, not a re-mint.
|
|
|
|
|
+ assert.Contains(t, body, `"a":"`+base64.URLEncoding.EncodeToString(loginBlockCookie)+`"`)
|
|
|
assert.Contains(t, body, `"loginId":"testuser"`)
|
|
assert.Contains(t, body, `"loginId":"testuser"`)
|
|
|
assert.Contains(t, body, `"screenName":"testuser"`)
|
|
assert.Contains(t, body, `"screenName":"testuser"`)
|
|
|
assert.Contains(t, body, `"token"`)
|
|
assert.Contains(t, body, `"token"`)
|
|
@@ -257,6 +325,21 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
|
|
|
assert.Contains(t, body, "invalid JSON format")
|
|
assert.Contains(t, body, "invalid JSON format")
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "Error_LoginResponseHasNoCookie",
|
|
|
|
|
+ method: "POST",
|
|
|
|
|
+ contentType: "application/json",
|
|
|
|
|
+ body: `{"username":"testuser","password":"testpass"}`,
|
|
|
|
|
+ auth: &testAuthService{
|
|
|
|
|
+ flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
|
|
|
|
|
+ return blockWithoutCookie(), nil
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ expectedStatusCode: http.StatusInternalServerError,
|
|
|
|
|
+ checkResponse: func(t *testing.T, body string) {
|
|
|
|
|
+ assert.Contains(t, body, "internal server error")
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
for _, tt := range tests {
|
|
for _, tt := range tests {
|
|
@@ -285,3 +368,45 @@ func TestAuthHandler_ClientLogin(t *testing.T) {
|
|
|
})
|
|
})
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+func TestAuthHandler_ClientLogin_SendsClientIdentity(t *testing.T) {
|
|
|
|
|
+ tests := []struct {
|
|
|
|
|
+ name string
|
|
|
|
|
+ body string
|
|
|
|
|
+ expectedClientID string
|
|
|
|
|
+ }{
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "DevIDNamesTheClient",
|
|
|
|
|
+ body: `{"username":"testuser","password":"testpass","devId":"dev123"}`,
|
|
|
|
|
+ expectedClientID: "dev123",
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ name: "MissingDevIDFallsBack",
|
|
|
|
|
+ body: `{"username":"testuser","password":"testpass"}`,
|
|
|
|
|
+ expectedClientID: "WebAIM",
|
|
|
|
|
+ },
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for _, tt := range tests {
|
|
|
|
|
+ t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
+ var got wire.FLAPSignonFrame
|
|
|
|
|
+ handler := &AuthHandler{
|
|
|
|
|
+ AuthService: &testAuthService{
|
|
|
|
|
+ flapLogin: func(ctx context.Context, inFrame wire.FLAPSignonFrame, advertisedHost string) (wire.TLVRestBlock, error) {
|
|
|
|
|
+ got = inFrame
|
|
|
|
|
+ return successfulLoginBlock(), nil
|
|
|
|
|
+ },
|
|
|
|
|
+ },
|
|
|
|
|
+ Logger: slog.Default(),
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ req := httptest.NewRequest(http.MethodPost, "/auth/clientLogin", strings.NewReader(tt.body))
|
|
|
|
|
+ req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
+ handler.ClientLogin(httptest.NewRecorder(), req)
|
|
|
|
|
+
|
|
|
|
|
+ clientID, ok := got.String(wire.LoginTLVTagsClientIdentity)
|
|
|
|
|
+ assert.True(t, ok, "signon frame should carry a client identity")
|
|
|
|
|
+ assert.Equal(t, tt.expectedClientID, clientID)
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+}
|