[FIX] Fix default redirection URL not taken into account (#600)
* Remove unused mongo docker-compose file. * Default redirection URL was not taken into account. * Fix possible storage options in config template. * Remove useless checks in u2f registration endpoints. * Add default redirection url in config of duo suite. * Fix log line in response handler of 2FA methods. * Fix integration tests. Co-authored-by: Amir Zarrinkafsh <nightah@me.com>pull/606/head^2
parent
05592cbe2d
commit
ea9b408b70
|
@ -245,7 +245,7 @@ regulation:
|
|||
|
||||
# Configuration of the storage backend used to store data and secrets.
|
||||
#
|
||||
# You must use only an available configuration: local, sql
|
||||
# You must use only an available configuration: local, mysql, postgres
|
||||
storage:
|
||||
# The directory where the DB files will be saved
|
||||
## local:
|
||||
|
|
|
@ -2,6 +2,7 @@ package validator
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/authelia/authelia/internal/configuration/schema"
|
||||
)
|
||||
|
@ -23,6 +24,13 @@ func Validate(configuration *schema.Configuration, validator *schema.StructValid
|
|||
configuration.LogsLevel = defaultLogsLevel
|
||||
}
|
||||
|
||||
if configuration.DefaultRedirectionURL != "" {
|
||||
_, err := url.ParseRequestURI(configuration.DefaultRedirectionURL)
|
||||
if err != nil {
|
||||
validator.Push(fmt.Errorf("Unable to parse default redirection url"))
|
||||
}
|
||||
}
|
||||
|
||||
if configuration.JWTSecret == "" {
|
||||
validator.Push(fmt.Errorf("Provide a JWT secret using `jwt_secret` key"))
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
"github.com/authelia/authelia/internal/middlewares"
|
||||
"github.com/authelia/authelia/internal/regulation"
|
||||
"github.com/authelia/authelia/internal/session"
|
||||
"github.com/authelia/authelia/internal/utils"
|
||||
)
|
||||
|
||||
// FirstFactorPost is the handler performing the first factory.
|
||||
|
@ -78,7 +79,7 @@ func FirstFactorPost(ctx *middlewares.AutheliaCtx) {
|
|||
|
||||
// set the cookie to expire in 1 year if "Remember me" was ticked.
|
||||
if *bodyJSON.KeepMeLoggedIn {
|
||||
err = ctx.Providers.SessionProvider.UpdateExpiration(ctx.RequestCtx, time.Duration(31556952 * time.Second))
|
||||
err = ctx.Providers.SessionProvider.UpdateExpiration(ctx.RequestCtx, time.Duration(31556952*time.Second))
|
||||
if err != nil {
|
||||
ctx.Error(fmt.Errorf("Unable to update expiration timer for user %s: %s", bodyJSON.Username, err), authenticationFailedMessage)
|
||||
return
|
||||
|
@ -124,7 +125,7 @@ func FirstFactorPost(ctx *middlewares.AutheliaCtx) {
|
|||
|
||||
ctx.Logger.Debugf("Required level for the URL %s is %d", targetURL.String(), requiredLevel)
|
||||
|
||||
safeRedirection := isRedirectionSafe(*targetURL, ctx.Configuration.Session.Domain)
|
||||
safeRedirection := utils.IsRedirectionSafe(*targetURL, ctx.Configuration.Session.Domain)
|
||||
|
||||
if safeRedirection && requiredLevel <= authorization.OneFactor {
|
||||
ctx.Logger.Debugf("Redirection is safe, redirecting...")
|
||||
|
|
|
@ -45,14 +45,7 @@ func secondFactorU2FIdentityFinish(ctx *middlewares.AutheliaCtx, username string
|
|||
return
|
||||
}
|
||||
|
||||
request := u2f.NewWebRegisterRequest(challenge, []u2f.Registration{})
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(fmt.Errorf("Unable to generate new U2F request for registration: %s", err), operationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetJSONBody(request)
|
||||
ctx.SetJSONBody(u2f.NewWebRegisterRequest(challenge, []u2f.Registration{}))
|
||||
}
|
||||
|
||||
// SecondFactorU2FIdentityFinish the handler for finishing the identity validation
|
||||
|
|
|
@ -33,11 +33,6 @@ func SecondFactorU2FRegister(ctx *middlewares.AutheliaCtx) {
|
|||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(fmt.Errorf("Unable to marshal U2F registration data: %v", err), unableToRegisterSecurityKeyMessage)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Logger.Debugf("Register U2F device for user %s", userSession.Username)
|
||||
|
||||
publicKey := elliptic.Marshal(elliptic.P256(), registration.PubKey.X, registration.PubKey.Y)
|
||||
|
|
|
@ -51,21 +51,6 @@ func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
|
|||
return
|
||||
}
|
||||
|
||||
if requestBody.TargetURL != "" {
|
||||
targetURL, err := url.ParseRequestURI(requestBody.TargetURL)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(fmt.Errorf("Unable to parse target URL: %s", err), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if targetURL != nil && isRedirectionSafe(*targetURL, ctx.Configuration.Session.Domain) {
|
||||
ctx.SetJSONBody(redirectResponse{Redirect: requestBody.TargetURL})
|
||||
} else {
|
||||
ctx.ReplyOK()
|
||||
}
|
||||
} else {
|
||||
ctx.ReplyOK()
|
||||
}
|
||||
HandleAuthResponse(ctx, requestBody.TargetURL)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
@ -92,6 +93,80 @@ func (s *SecondFactorDuoPostSuite) TestShouldCallDuoAPIAndFail() {
|
|||
s.mock.Assert200KO(s.T(), "Authentication failed, please retry later.")
|
||||
}
|
||||
|
||||
func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToDefaultURL() {
|
||||
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||
|
||||
response := duo.Response{}
|
||||
response.Response.Result = "allow"
|
||||
|
||||
duoMock.EXPECT().Call(gomock.Any()).Return(&response, nil)
|
||||
|
||||
s.mock.Ctx.Configuration.DefaultRedirectionURL = "http://redirection.local"
|
||||
|
||||
bodyBytes, err := json.Marshal(signDuoRequestBody{})
|
||||
s.Require().NoError(err)
|
||||
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||
|
||||
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||
s.mock.Assert200OK(s.T(), redirectResponse{
|
||||
Redirect: "http://redirection.local",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SecondFactorDuoPostSuite) TestShouldNotReturnRedirectURL() {
|
||||
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||
|
||||
response := duo.Response{}
|
||||
response.Response.Result = "allow"
|
||||
|
||||
duoMock.EXPECT().Call(gomock.Any()).Return(&response, nil)
|
||||
|
||||
bodyBytes, err := json.Marshal(signDuoRequestBody{})
|
||||
s.Require().NoError(err)
|
||||
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||
|
||||
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||
s.mock.Assert200OK(s.T(), nil)
|
||||
}
|
||||
|
||||
func (s *SecondFactorDuoPostSuite) TestShouldRedirectUserToSafeTargetURL() {
|
||||
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||
|
||||
response := duo.Response{}
|
||||
response.Response.Result = "allow"
|
||||
|
||||
duoMock.EXPECT().Call(gomock.Any()).Return(&response, nil)
|
||||
|
||||
bodyBytes, err := json.Marshal(signDuoRequestBody{
|
||||
TargetURL: "https://mydomain.local",
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||
|
||||
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||
s.mock.Assert200OK(s.T(), redirectResponse{
|
||||
Redirect: "https://mydomain.local",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SecondFactorDuoPostSuite) TestShouldNotRedirectToUnsafeURL() {
|
||||
duoMock := mocks.NewMockAPI(s.mock.Ctrl)
|
||||
|
||||
response := duo.Response{}
|
||||
response.Response.Result = "allow"
|
||||
|
||||
duoMock.EXPECT().Call(gomock.Any()).Return(&response, nil)
|
||||
|
||||
bodyBytes, err := json.Marshal(signDuoRequestBody{
|
||||
TargetURL: "http://mydomain.local",
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||
|
||||
SecondFactorDuoPost(duoMock)(s.mock.Ctx)
|
||||
s.mock.Assert200OK(s.T(), nil)
|
||||
}
|
||||
|
||||
func TestRunSecondFactorDuoPostSuite(t *testing.T) {
|
||||
s := new(SecondFactorDuoPostSuite)
|
||||
suite.Run(t, s)
|
||||
|
|
|
@ -2,58 +2,44 @@ package handlers
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/authelia/authelia/internal/authentication"
|
||||
"github.com/authelia/authelia/internal/middlewares"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
// SecondFactorTOTPPost validate the TOTP passcode provided by the user.
|
||||
func SecondFactorTOTPPost(ctx *middlewares.AutheliaCtx) {
|
||||
bodyJSON := signTOTPRequestBody{}
|
||||
err := ctx.ParseBody(&bodyJSON)
|
||||
func SecondFactorTOTPPost(totpVerifier TOTPVerifier) middlewares.RequestHandler {
|
||||
return func(ctx *middlewares.AutheliaCtx) {
|
||||
bodyJSON := signTOTPRequestBody{}
|
||||
err := ctx.ParseBody(&bodyJSON)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(err, mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
userSession := ctx.GetSession()
|
||||
secret, err := ctx.Providers.StorageProvider.LoadTOTPSecret(userSession.Username)
|
||||
if err != nil {
|
||||
ctx.Error(fmt.Errorf("Unable to load TOTP secret: %s", err), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
isValid := totp.Validate(bodyJSON.Token, secret)
|
||||
|
||||
if !isValid {
|
||||
ctx.Error(fmt.Errorf("Wrong passcode during TOTP validation for user %s", userSession.Username), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
userSession.AuthenticationLevel = authentication.TwoFactor
|
||||
err = ctx.SaveSession(userSession)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(fmt.Errorf("Unable to update the authentication level with TOTP: %s", err), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if bodyJSON.TargetURL != "" {
|
||||
targetURL, err := url.ParseRequestURI(bodyJSON.TargetURL)
|
||||
if err != nil {
|
||||
ctx.Error(fmt.Errorf("Unable to parse URL with TOTP: %s", err), mfaValidationFailedMessage)
|
||||
ctx.Error(err, mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if targetURL != nil && isRedirectionSafe(*targetURL, ctx.Configuration.Session.Domain) {
|
||||
ctx.SetJSONBody(redirectResponse{bodyJSON.TargetURL})
|
||||
} else {
|
||||
ctx.ReplyOK()
|
||||
userSession := ctx.GetSession()
|
||||
secret, err := ctx.Providers.StorageProvider.LoadTOTPSecret(userSession.Username)
|
||||
if err != nil {
|
||||
ctx.Error(fmt.Errorf("Unable to load TOTP secret: %s", err), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
ctx.ReplyOK()
|
||||
|
||||
isValid := totpVerifier.Verify(bodyJSON.Token, secret)
|
||||
|
||||
if !isValid {
|
||||
ctx.Error(fmt.Errorf("Wrong passcode during TOTP validation for user %s", userSession.Username), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
userSession.AuthenticationLevel = authentication.TwoFactor
|
||||
err = ctx.SaveSession(userSession)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(fmt.Errorf("Unable to update the authentication level with TOTP: %s", err), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
HandleAuthResponse(ctx, bodyJSON.TargetURL)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,127 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/authelia/authelia/internal/mocks"
|
||||
"github.com/authelia/authelia/internal/session"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/tstranex/u2f"
|
||||
)
|
||||
|
||||
type HandlerSignTOTPSuite struct {
|
||||
suite.Suite
|
||||
|
||||
mock *mocks.MockAutheliaCtx
|
||||
}
|
||||
|
||||
func (s *HandlerSignTOTPSuite) SetupTest() {
|
||||
s.mock = mocks.NewMockAutheliaCtx(s.T())
|
||||
userSession := s.mock.Ctx.GetSession()
|
||||
userSession.Username = "john"
|
||||
userSession.U2FChallenge = &u2f.Challenge{}
|
||||
userSession.U2FRegistration = &session.U2FRegistration{}
|
||||
s.mock.Ctx.SaveSession(userSession)
|
||||
}
|
||||
|
||||
func (s *HandlerSignTOTPSuite) TearDownTest() {
|
||||
s.mock.Close()
|
||||
}
|
||||
|
||||
func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToDefaultURL() {
|
||||
verifier := NewMockTOTPVerifier(s.mock.Ctrl)
|
||||
|
||||
s.mock.StorageProviderMock.EXPECT().
|
||||
LoadTOTPSecret(gomock.Any()).
|
||||
Return("secret", nil)
|
||||
|
||||
verifier.EXPECT().
|
||||
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
|
||||
Return(true)
|
||||
|
||||
s.mock.Ctx.Configuration.DefaultRedirectionURL = "http://redirection.local"
|
||||
|
||||
bodyBytes, err := json.Marshal(signTOTPRequestBody{
|
||||
Token: "abc",
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||
|
||||
SecondFactorTOTPPost(verifier)(s.mock.Ctx)
|
||||
s.mock.Assert200OK(s.T(), redirectResponse{
|
||||
Redirect: "http://redirection.local",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *HandlerSignTOTPSuite) TestShouldNotReturnRedirectURL() {
|
||||
verifier := NewMockTOTPVerifier(s.mock.Ctrl)
|
||||
|
||||
s.mock.StorageProviderMock.EXPECT().
|
||||
LoadTOTPSecret(gomock.Any()).
|
||||
Return("secret", nil)
|
||||
|
||||
verifier.EXPECT().
|
||||
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
|
||||
Return(true)
|
||||
|
||||
bodyBytes, err := json.Marshal(signTOTPRequestBody{
|
||||
Token: "abc",
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||
|
||||
SecondFactorTOTPPost(verifier)(s.mock.Ctx)
|
||||
s.mock.Assert200OK(s.T(), nil)
|
||||
}
|
||||
|
||||
func (s *HandlerSignTOTPSuite) TestShouldRedirectUserToSafeTargetURL() {
|
||||
verifier := NewMockTOTPVerifier(s.mock.Ctrl)
|
||||
|
||||
s.mock.StorageProviderMock.EXPECT().
|
||||
LoadTOTPSecret(gomock.Any()).
|
||||
Return("secret", nil)
|
||||
|
||||
verifier.EXPECT().
|
||||
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
|
||||
Return(true)
|
||||
|
||||
bodyBytes, err := json.Marshal(signTOTPRequestBody{
|
||||
Token: "abc",
|
||||
TargetURL: "https://mydomain.local",
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||
|
||||
SecondFactorTOTPPost(verifier)(s.mock.Ctx)
|
||||
s.mock.Assert200OK(s.T(), redirectResponse{
|
||||
Redirect: "https://mydomain.local",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *HandlerSignTOTPSuite) TestShouldNotRedirectToUnsafeURL() {
|
||||
verifier := NewMockTOTPVerifier(s.mock.Ctrl)
|
||||
|
||||
s.mock.StorageProviderMock.EXPECT().
|
||||
LoadTOTPSecret(gomock.Any()).
|
||||
Return("secret", nil)
|
||||
|
||||
verifier.EXPECT().
|
||||
Verify(gomock.Eq("abc"), gomock.Eq("secret")).
|
||||
Return(true)
|
||||
|
||||
bodyBytes, err := json.Marshal(signTOTPRequestBody{
|
||||
Token: "abc",
|
||||
TargetURL: "http://mydomain.local",
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||
|
||||
SecondFactorTOTPPost(verifier)(s.mock.Ctx)
|
||||
s.mock.Assert200OK(s.T(), nil)
|
||||
}
|
||||
|
||||
func TestRunHandlerSignTOTPSuite(t *testing.T) {
|
||||
suite.Run(t, new(HandlerSignTOTPSuite))
|
||||
}
|
|
@ -1,74 +1,53 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/elliptic"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/authelia/authelia/internal/authentication"
|
||||
"github.com/authelia/authelia/internal/middlewares"
|
||||
"github.com/tstranex/u2f"
|
||||
)
|
||||
|
||||
// SecondFactorU2FSignPost handler for completing a signing request.
|
||||
func SecondFactorU2FSignPost(ctx *middlewares.AutheliaCtx) {
|
||||
var requestBody signU2FRequestBody
|
||||
err := ctx.ParseBody(&requestBody)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(err, mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
userSession := ctx.GetSession()
|
||||
if userSession.U2FChallenge == nil {
|
||||
ctx.Error(fmt.Errorf("U2F signing has not been initiated yet (no challenge)"), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if userSession.U2FRegistration == nil {
|
||||
ctx.Error(fmt.Errorf("U2F signing has not been initiated yet (no registration)"), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
var registration u2f.Registration
|
||||
registration.KeyHandle = userSession.U2FRegistration.KeyHandle
|
||||
x, y := elliptic.Unmarshal(elliptic.P256(), userSession.U2FRegistration.PublicKey)
|
||||
registration.PubKey.Curve = elliptic.P256()
|
||||
registration.PubKey.X = x
|
||||
registration.PubKey.Y = y
|
||||
|
||||
// TODO(c.michaud): store the counter to help detecting cloned U2F keys.
|
||||
_, err = registration.Authenticate(
|
||||
requestBody.SignResponse, *userSession.U2FChallenge, 0)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(err, mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
userSession.AuthenticationLevel = authentication.TwoFactor
|
||||
err = ctx.SaveSession(userSession)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(fmt.Errorf("Unable to update authentication level with U2F: %s", err), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if requestBody.TargetURL != "" {
|
||||
targetURL, err := url.ParseRequestURI(requestBody.TargetURL)
|
||||
func SecondFactorU2FSignPost(u2fVerifier U2FVerifier) middlewares.RequestHandler {
|
||||
return func(ctx *middlewares.AutheliaCtx) {
|
||||
var requestBody signU2FRequestBody
|
||||
err := ctx.ParseBody(&requestBody)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(fmt.Errorf("Unable to parse target URL with U2F: %s", err), mfaValidationFailedMessage)
|
||||
ctx.Error(err, mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if targetURL != nil && isRedirectionSafe(*targetURL, ctx.Configuration.Session.Domain) {
|
||||
ctx.SetJSONBody(redirectResponse{Redirect: requestBody.TargetURL})
|
||||
} else {
|
||||
ctx.ReplyOK()
|
||||
userSession := ctx.GetSession()
|
||||
if userSession.U2FChallenge == nil {
|
||||
ctx.Error(fmt.Errorf("U2F signing has not been initiated yet (no challenge)"), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
ctx.ReplyOK()
|
||||
|
||||
if userSession.U2FRegistration == nil {
|
||||
ctx.Error(fmt.Errorf("U2F signing has not been initiated yet (no registration)"), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
err = u2fVerifier.Verify(
|
||||
userSession.U2FRegistration.KeyHandle,
|
||||
userSession.U2FRegistration.PublicKey,
|
||||
requestBody.SignResponse,
|
||||
*userSession.U2FChallenge)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(err, mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
userSession.AuthenticationLevel = authentication.TwoFactor
|
||||
err = ctx.SaveSession(userSession)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(fmt.Errorf("Unable to update authentication level with U2F: %s", err), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
HandleAuthResponse(ctx, requestBody.TargetURL)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/authelia/authelia/internal/mocks"
|
||||
"github.com/authelia/authelia/internal/session"
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/tstranex/u2f"
|
||||
)
|
||||
|
||||
type HandlerSignU2FStep2Suite struct {
|
||||
suite.Suite
|
||||
|
||||
mock *mocks.MockAutheliaCtx
|
||||
}
|
||||
|
||||
func (s *HandlerSignU2FStep2Suite) SetupTest() {
|
||||
s.mock = mocks.NewMockAutheliaCtx(s.T())
|
||||
userSession := s.mock.Ctx.GetSession()
|
||||
userSession.Username = "john"
|
||||
userSession.U2FChallenge = &u2f.Challenge{}
|
||||
userSession.U2FRegistration = &session.U2FRegistration{}
|
||||
s.mock.Ctx.SaveSession(userSession)
|
||||
}
|
||||
|
||||
func (s *HandlerSignU2FStep2Suite) TearDownTest() {
|
||||
s.mock.Close()
|
||||
}
|
||||
|
||||
func (s *HandlerSignU2FStep2Suite) TestShouldRedirectUserToDefaultURL() {
|
||||
u2fVerifier := NewMockU2FVerifier(s.mock.Ctrl)
|
||||
|
||||
u2fVerifier.EXPECT().
|
||||
Verify(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(nil)
|
||||
|
||||
s.mock.Ctx.Configuration.DefaultRedirectionURL = "http://redirection.local"
|
||||
|
||||
bodyBytes, err := json.Marshal(signU2FRequestBody{
|
||||
SignResponse: u2f.SignResponse{},
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||
|
||||
SecondFactorU2FSignPost(u2fVerifier)(s.mock.Ctx)
|
||||
s.mock.Assert200OK(s.T(), redirectResponse{
|
||||
Redirect: "http://redirection.local",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *HandlerSignU2FStep2Suite) TestShouldNotReturnRedirectURL() {
|
||||
u2fVerifier := NewMockU2FVerifier(s.mock.Ctrl)
|
||||
|
||||
u2fVerifier.EXPECT().
|
||||
Verify(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(nil)
|
||||
|
||||
bodyBytes, err := json.Marshal(signU2FRequestBody{
|
||||
SignResponse: u2f.SignResponse{},
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||
|
||||
SecondFactorU2FSignPost(u2fVerifier)(s.mock.Ctx)
|
||||
s.mock.Assert200OK(s.T(), nil)
|
||||
}
|
||||
|
||||
func (s *HandlerSignU2FStep2Suite) TestShouldRedirectUserToSafeTargetURL() {
|
||||
u2fVerifier := NewMockU2FVerifier(s.mock.Ctrl)
|
||||
|
||||
u2fVerifier.EXPECT().
|
||||
Verify(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(nil)
|
||||
|
||||
bodyBytes, err := json.Marshal(signU2FRequestBody{
|
||||
SignResponse: u2f.SignResponse{},
|
||||
TargetURL: "https://mydomain.local",
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||
|
||||
SecondFactorU2FSignPost(u2fVerifier)(s.mock.Ctx)
|
||||
s.mock.Assert200OK(s.T(), redirectResponse{
|
||||
Redirect: "https://mydomain.local",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *HandlerSignU2FStep2Suite) TestShouldNotRedirectToUnsafeURL() {
|
||||
u2fVerifier := NewMockU2FVerifier(s.mock.Ctrl)
|
||||
|
||||
u2fVerifier.EXPECT().
|
||||
Verify(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
|
||||
Return(nil)
|
||||
|
||||
bodyBytes, err := json.Marshal(signU2FRequestBody{
|
||||
SignResponse: u2f.SignResponse{},
|
||||
TargetURL: "http://mydomain.local",
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
s.mock.Ctx.Request.SetBody(bodyBytes)
|
||||
|
||||
SecondFactorU2FSignPost(u2fVerifier)(s.mock.Ctx)
|
||||
s.mock.Assert200OK(s.T(), nil)
|
||||
}
|
||||
|
||||
func TestRunHandlerSignU2FStep2Suite(t *testing.T) {
|
||||
suite.Run(t, new(HandlerSignU2FStep2Suite))
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/authelia/authelia/internal/middlewares"
|
||||
"github.com/authelia/authelia/internal/utils"
|
||||
)
|
||||
|
||||
func HandleAuthResponse(ctx *middlewares.AutheliaCtx, targetURI string) {
|
||||
if targetURI != "" {
|
||||
targetURL, err := url.ParseRequestURI(targetURI)
|
||||
|
||||
if err != nil {
|
||||
ctx.Error(fmt.Errorf("Unable to parse target URL: %s", err), mfaValidationFailedMessage)
|
||||
return
|
||||
}
|
||||
|
||||
if targetURL != nil && utils.IsRedirectionSafe(*targetURL, ctx.Configuration.Session.Domain) {
|
||||
ctx.SetJSONBody(redirectResponse{Redirect: targetURI})
|
||||
} else {
|
||||
ctx.ReplyOK()
|
||||
}
|
||||
} else {
|
||||
if ctx.Configuration.DefaultRedirectionURL != "" {
|
||||
ctx.SetJSONBody(redirectResponse{Redirect: ctx.Configuration.DefaultRedirectionURL})
|
||||
} else {
|
||||
ctx.ReplyOK()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
type TOTPVerifier interface {
|
||||
Verify(token, secret string) bool
|
||||
}
|
||||
|
||||
type TOTPVerifierImpl struct{}
|
||||
|
||||
func (tv *TOTPVerifierImpl) Verify(token, secret string) bool {
|
||||
return totp.Validate(token, secret)
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: internal/handlers/totp.go
|
||||
|
||||
// Package handlers is a generated GoMock package.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// MockTOTPVerifier is a mock of TOTPVerifier interface
|
||||
type MockTOTPVerifier struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockTOTPVerifierMockRecorder
|
||||
}
|
||||
|
||||
// MockTOTPVerifierMockRecorder is the mock recorder for MockTOTPVerifier
|
||||
type MockTOTPVerifierMockRecorder struct {
|
||||
mock *MockTOTPVerifier
|
||||
}
|
||||
|
||||
// NewMockTOTPVerifier creates a new mock instance
|
||||
func NewMockTOTPVerifier(ctrl *gomock.Controller) *MockTOTPVerifier {
|
||||
mock := &MockTOTPVerifier{ctrl: ctrl}
|
||||
mock.recorder = &MockTOTPVerifierMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockTOTPVerifier) EXPECT() *MockTOTPVerifierMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Verify mocks base method
|
||||
func (m *MockTOTPVerifier) Verify(token, secret string) bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Verify", token, secret)
|
||||
ret0, _ := ret[0].(bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Verify indicates an expected call of Verify
|
||||
func (mr *MockTOTPVerifierMockRecorder) Verify(token, secret interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Verify", reflect.TypeOf((*MockTOTPVerifier)(nil).Verify), token, secret)
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/elliptic"
|
||||
|
||||
"github.com/tstranex/u2f"
|
||||
)
|
||||
|
||||
type U2FVerifier interface {
|
||||
Verify(keyHandle []byte, publicKey []byte, signResponse u2f.SignResponse, challenge u2f.Challenge) error
|
||||
}
|
||||
|
||||
type U2FVerifierImpl struct{}
|
||||
|
||||
func (uv *U2FVerifierImpl) Verify(keyHandle []byte, publicKey []byte,
|
||||
signResponse u2f.SignResponse, challenge u2f.Challenge) error {
|
||||
var registration u2f.Registration
|
||||
registration.KeyHandle = keyHandle
|
||||
x, y := elliptic.Unmarshal(elliptic.P256(), publicKey)
|
||||
registration.PubKey.Curve = elliptic.P256()
|
||||
registration.PubKey.X = x
|
||||
registration.PubKey.Y = y
|
||||
|
||||
// TODO(c.michaud): store the counter to help detecting cloned U2F keys.
|
||||
_, err := registration.Authenticate(
|
||||
signResponse, challenge, 0)
|
||||
return err
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: internal/handlers/u2f.go
|
||||
|
||||
// Package handlers is a generated GoMock package.
|
||||
package handlers
|
||||
|
||||
import (
|
||||
gomock "github.com/golang/mock/gomock"
|
||||
u2f "github.com/tstranex/u2f"
|
||||
reflect "reflect"
|
||||
)
|
||||
|
||||
// MockU2FVerifier is a mock of U2FVerifier interface
|
||||
type MockU2FVerifier struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockU2FVerifierMockRecorder
|
||||
}
|
||||
|
||||
// MockU2FVerifierMockRecorder is the mock recorder for MockU2FVerifier
|
||||
type MockU2FVerifierMockRecorder struct {
|
||||
mock *MockU2FVerifier
|
||||
}
|
||||
|
||||
// NewMockU2FVerifier creates a new mock instance
|
||||
func NewMockU2FVerifier(ctrl *gomock.Controller) *MockU2FVerifier {
|
||||
mock := &MockU2FVerifier{ctrl: ctrl}
|
||||
mock.recorder = &MockU2FVerifierMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use
|
||||
func (m *MockU2FVerifier) EXPECT() *MockU2FVerifierMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Verify mocks base method
|
||||
func (m *MockU2FVerifier) Verify(keyHandle, publicKey []byte, signResponse u2f.SignResponse, challenge u2f.Challenge) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Verify", keyHandle, publicKey, signResponse, challenge)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Verify indicates an expected call of Verify
|
||||
func (mr *MockU2FVerifierMockRecorder) Verify(keyHandle, publicKey, signResponse, challenge interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Verify", reflect.TypeOf((*MockU2FVerifier)(nil).Verify), keyHandle, publicKey, signResponse, challenge)
|
||||
}
|
|
@ -93,7 +93,7 @@ type IdentityVerificationFinishBody struct {
|
|||
// OKResponse model of a status OK response
|
||||
type OKResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data interface{} `json:"data"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// ErrorResponse model of an error response
|
||||
|
|
|
@ -61,7 +61,7 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi
|
|||
router.POST("/api/secondfactor/totp/identity/finish", autheliaMiddleware(
|
||||
middlewares.RequireFirstFactor(handlers.SecondFactorTOTPIdentityFinish)))
|
||||
router.POST("/api/secondfactor/totp", autheliaMiddleware(
|
||||
middlewares.RequireFirstFactor(handlers.SecondFactorTOTPPost)))
|
||||
middlewares.RequireFirstFactor(handlers.SecondFactorTOTPPost(&handlers.TOTPVerifierImpl{}))))
|
||||
|
||||
// U2F related endpoints
|
||||
router.POST("/api/secondfactor/u2f/identity/start", autheliaMiddleware(
|
||||
|
@ -74,8 +74,9 @@ func StartServer(configuration schema.Configuration, providers middlewares.Provi
|
|||
|
||||
router.POST("/api/secondfactor/u2f/sign_request", autheliaMiddleware(
|
||||
middlewares.RequireFirstFactor(handlers.SecondFactorU2FSignGet)))
|
||||
|
||||
router.POST("/api/secondfactor/u2f/sign", autheliaMiddleware(
|
||||
middlewares.RequireFirstFactor(handlers.SecondFactorU2FSignPost)))
|
||||
middlewares.RequireFirstFactor(handlers.SecondFactorU2FSignPost(&handlers.U2FVerifierImpl{}))))
|
||||
|
||||
// Configure DUO api endpoint only if configuration exists
|
||||
if configuration.DuoAPI != nil {
|
||||
|
|
|
@ -6,6 +6,8 @@ port: 9091
|
|||
|
||||
logs_level: trace
|
||||
|
||||
default_redirection_url: https://home.example.com:8080/
|
||||
|
||||
jwt_secret: very_important_secret
|
||||
|
||||
authentication_backend:
|
||||
|
|
|
@ -12,18 +12,6 @@ logs_level: debug
|
|||
|
||||
jwt_secret: unsecure_secret
|
||||
|
||||
# Default redirection URL
|
||||
#
|
||||
# If user tries to authenticate without any referer, Authelia
|
||||
# does not know where to redirect the user to at the end of the
|
||||
# authentication process.
|
||||
# This parameter allows you to specify the default redirection
|
||||
# URL Authelia will use in such a case.
|
||||
#
|
||||
# Note: this parameter is optional. If not provided, user won't
|
||||
# be redirected upon successful authentication.
|
||||
default_redirection_url: https://home.example.com:8080/
|
||||
|
||||
# TOTP Issuer Name
|
||||
#
|
||||
# This will be the issuer name displayed in Google Authenticator
|
||||
|
|
|
@ -6,8 +6,6 @@ port: 9091
|
|||
|
||||
logs_level: debug
|
||||
|
||||
default_redirection_url: https://home.example.com:8080/
|
||||
|
||||
authentication_backend:
|
||||
file:
|
||||
path: /var/lib/authelia/users.yml
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
package suites
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type DefaultRedirectionURLScenario struct {
|
||||
*SeleniumSuite
|
||||
|
||||
secret string
|
||||
}
|
||||
|
||||
func NewDefaultRedirectionURLScenario() *DefaultRedirectionURLScenario {
|
||||
return &DefaultRedirectionURLScenario{
|
||||
SeleniumSuite: new(SeleniumSuite),
|
||||
}
|
||||
}
|
||||
|
||||
func (drus *DefaultRedirectionURLScenario) SetupSuite() {
|
||||
wds, err := StartWebDriver()
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
drus.WebDriverSession = wds
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
targetURL := fmt.Sprintf("%s/secret.html", AdminBaseURL)
|
||||
drus.secret = drus.doRegisterAndLogin2FA(ctx, drus.T(), "john", "password", false, targetURL)
|
||||
drus.verifySecretAuthorized(ctx, drus.T())
|
||||
}
|
||||
|
||||
func (drus *DefaultRedirectionURLScenario) TearDownSuite() {
|
||||
err := drus.WebDriverSession.Stop()
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (drus *DefaultRedirectionURLScenario) SetupTest() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
drus.doLogout(ctx, drus.T())
|
||||
drus.doVisit(drus.T(), HomeBaseURL)
|
||||
drus.verifyIsHome(ctx, drus.T())
|
||||
}
|
||||
|
||||
func (drus *DefaultRedirectionURLScenario) TestUserIsRedirectedToDefaultURL() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
drus.doLoginTwoFactor(ctx, drus.T(), "john", "password", false, drus.secret, "")
|
||||
drus.verifyURLIs(ctx, drus.T(), HomeBaseURL+"/")
|
||||
}
|
||||
|
||||
func TestShouldRunDefaultRedirectionURLScenario(t *testing.T) {
|
||||
suite.Run(t, NewDefaultRedirectionURLScenario())
|
||||
}
|
|
@ -36,14 +36,14 @@ func (s *DuoPushWebDriverSuite) TearDownSuite() {
|
|||
}
|
||||
|
||||
func (s *DuoPushWebDriverSuite) SetupTest() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s.doLogout(ctx, s.T())
|
||||
}
|
||||
|
||||
func (s *DuoPushWebDriverSuite) TearDownTest() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s.doChangeMethod(ctx, s.T(), "one-time-password")
|
||||
|
@ -72,6 +72,48 @@ func (s *DuoPushWebDriverSuite) TestShouldFailAuthentication() {
|
|||
s.WaitElementLocatedByClassName(ctx, s.T(), "failure-icon")
|
||||
}
|
||||
|
||||
type DuoPushDefaultRedirectionSuite struct {
|
||||
*SeleniumSuite
|
||||
}
|
||||
|
||||
func NewDuoPushDefaultRedirectionSuite() *DuoPushDefaultRedirectionSuite {
|
||||
return &DuoPushDefaultRedirectionSuite{SeleniumSuite: new(SeleniumSuite)}
|
||||
}
|
||||
|
||||
func (s *DuoPushDefaultRedirectionSuite) SetupSuite() {
|
||||
wds, err := StartWebDriver()
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
s.WebDriverSession = wds
|
||||
}
|
||||
|
||||
func (s *DuoPushDefaultRedirectionSuite) TearDownSuite() {
|
||||
err := s.WebDriverSession.Stop()
|
||||
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DuoPushDefaultRedirectionSuite) SetupTest() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s.doLogout(ctx, s.T())
|
||||
}
|
||||
|
||||
func (s *DuoPushDefaultRedirectionSuite) TestUserIsRedirectedToDefaultURL() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
s.doLoginOneFactor(ctx, s.T(), "john", "password", false, "")
|
||||
s.doChangeMethod(ctx, s.T(), "push-notification")
|
||||
s.verifyURLIs(ctx, s.T(), HomeBaseURL+"/")
|
||||
}
|
||||
|
||||
type DuoPushSuite struct {
|
||||
suite.Suite
|
||||
}
|
||||
|
@ -84,6 +126,10 @@ func (s *DuoPushSuite) TestDuoPushWebDriverSuite() {
|
|||
suite.Run(s.T(), NewDuoPushWebDriverSuite())
|
||||
}
|
||||
|
||||
func (s *DuoPushSuite) TestDuoPushRedirectionURLSuite() {
|
||||
suite.Run(s.T(), NewDuoPushDefaultRedirectionSuite())
|
||||
}
|
||||
|
||||
func (s *DuoPushSuite) TestAvailableMethodsScenario() {
|
||||
suite.Run(s.T(), NewAvailableMethodsScenario([]string{
|
||||
"ONE-TIME PASSWORD",
|
||||
|
|
|
@ -14,6 +14,10 @@ func NewShortTimeoutsSuite() *ShortTimeoutsSuite {
|
|||
return &ShortTimeoutsSuite{SeleniumSuite: new(SeleniumSuite)}
|
||||
}
|
||||
|
||||
func (s *ShortTimeoutsSuite) TestDefaultRedirectionURLScenario() {
|
||||
suite.Run(s.T(), NewDefaultRedirectionURLScenario())
|
||||
}
|
||||
|
||||
func (s *ShortTimeoutsSuite) TestInactivityScenario() {
|
||||
suite.Run(s.T(), NewInactivityScenario())
|
||||
}
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
package handlers
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isRedirectionSafe(url url.URL, protectedDomain string) bool {
|
||||
func IsRedirectionSafe(url url.URL, protectedDomain string) bool {
|
||||
if url.Scheme != "https" {
|
||||
return false
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
package handlers
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
|
@ -9,7 +9,7 @@ import (
|
|||
|
||||
func isURLSafe(requestURI string, domain string) bool {
|
||||
url, _ := url.ParseRequestURI(requestURI)
|
||||
return isRedirectionSafe(*url, domain)
|
||||
return IsRedirectionSafe(*url, domain)
|
||||
}
|
||||
|
||||
func TestShouldReturnFalseOnBadScheme(t *testing.T) {
|
Loading…
Reference in New Issue