diff --git a/config.template.yml b/config.template.yml index 935f66b41..646ab625f 100644 --- a/config.template.yml +++ b/config.template.yml @@ -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: diff --git a/internal/configuration/validator/configuration.go b/internal/configuration/validator/configuration.go index 9b048023f..3385082b2 100644 --- a/internal/configuration/validator/configuration.go +++ b/internal/configuration/validator/configuration.go @@ -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")) } diff --git a/internal/handlers/handler_firstfactor.go b/internal/handlers/handler_firstfactor.go index c6a0c3e1a..9e43ad54f 100644 --- a/internal/handlers/handler_firstfactor.go +++ b/internal/handlers/handler_firstfactor.go @@ -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...") diff --git a/internal/handlers/handler_register_u2f_step1.go b/internal/handlers/handler_register_u2f_step1.go index 47c44fc63..a12f50618 100644 --- a/internal/handlers/handler_register_u2f_step1.go +++ b/internal/handlers/handler_register_u2f_step1.go @@ -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 diff --git a/internal/handlers/handler_register_u2f_step2.go b/internal/handlers/handler_register_u2f_step2.go index b645cc38b..3af2fc279 100644 --- a/internal/handlers/handler_register_u2f_step2.go +++ b/internal/handlers/handler_register_u2f_step2.go @@ -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) diff --git a/internal/handlers/handler_sign_duo.go b/internal/handlers/handler_sign_duo.go index 41d164de4..530c3c40c 100644 --- a/internal/handlers/handler_sign_duo.go +++ b/internal/handlers/handler_sign_duo.go @@ -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) } } diff --git a/internal/handlers/handler_sign_duo_test.go b/internal/handlers/handler_sign_duo_test.go index 203725fcc..27fff591f 100644 --- a/internal/handlers/handler_sign_duo_test.go +++ b/internal/handlers/handler_sign_duo_test.go @@ -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) diff --git a/internal/handlers/handler_sign_totp.go b/internal/handlers/handler_sign_totp.go index 77cc90a58..2d0a9e8d0 100644 --- a/internal/handlers/handler_sign_totp.go +++ b/internal/handlers/handler_sign_totp.go @@ -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) } } diff --git a/internal/handlers/handler_sign_totp_test.go b/internal/handlers/handler_sign_totp_test.go new file mode 100644 index 000000000..1c9893af4 --- /dev/null +++ b/internal/handlers/handler_sign_totp_test.go @@ -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)) +} diff --git a/internal/handlers/handler_sign_u2f_step2.go b/internal/handlers/handler_sign_u2f_step2.go index a1e4857d0..5e5a4ef84 100644 --- a/internal/handlers/handler_sign_u2f_step2.go +++ b/internal/handlers/handler_sign_u2f_step2.go @@ -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) } } diff --git a/internal/handlers/handler_sign_u2f_step2_test.go b/internal/handlers/handler_sign_u2f_step2_test.go new file mode 100644 index 000000000..5453fe95e --- /dev/null +++ b/internal/handlers/handler_sign_u2f_step2_test.go @@ -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)) +} diff --git a/internal/handlers/response.go b/internal/handlers/response.go new file mode 100644 index 000000000..026303950 --- /dev/null +++ b/internal/handlers/response.go @@ -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() + } + } +} diff --git a/internal/handlers/totp.go b/internal/handlers/totp.go new file mode 100644 index 000000000..814afc56c --- /dev/null +++ b/internal/handlers/totp.go @@ -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) +} diff --git a/internal/handlers/totp_mock.go b/internal/handlers/totp_mock.go new file mode 100644 index 000000000..80e5596e6 --- /dev/null +++ b/internal/handlers/totp_mock.go @@ -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) +} diff --git a/internal/handlers/u2f.go b/internal/handlers/u2f.go new file mode 100644 index 000000000..8ad85c99f --- /dev/null +++ b/internal/handlers/u2f.go @@ -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 +} diff --git a/internal/handlers/u2f_mock.go b/internal/handlers/u2f_mock.go new file mode 100644 index 000000000..3e9022710 --- /dev/null +++ b/internal/handlers/u2f_mock.go @@ -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) +} diff --git a/internal/middlewares/types.go b/internal/middlewares/types.go index 9e8b95581..1078538a7 100644 --- a/internal/middlewares/types.go +++ b/internal/middlewares/types.go @@ -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 diff --git a/internal/server/server.go b/internal/server/server.go index 40ce1b8de..4cf62bcdd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 { diff --git a/internal/suites/DuoPush/configuration.yml b/internal/suites/DuoPush/configuration.yml index 286cc76e1..343f4d52d 100644 --- a/internal/suites/DuoPush/configuration.yml +++ b/internal/suites/DuoPush/configuration.yml @@ -6,6 +6,8 @@ port: 9091 logs_level: trace +default_redirection_url: https://home.example.com:8080/ + jwt_secret: very_important_secret authentication_backend: diff --git a/internal/suites/HighAvailability/configuration.yml b/internal/suites/HighAvailability/configuration.yml index 51eeb7774..b3608e2cc 100644 --- a/internal/suites/HighAvailability/configuration.yml +++ b/internal/suites/HighAvailability/configuration.yml @@ -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 diff --git a/internal/suites/Standalone/configuration.yml b/internal/suites/Standalone/configuration.yml index 06fd40a39..6fb5679bb 100644 --- a/internal/suites/Standalone/configuration.yml +++ b/internal/suites/Standalone/configuration.yml @@ -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 diff --git a/internal/suites/scenario_default_redirection_url_test.go b/internal/suites/scenario_default_redirection_url_test.go new file mode 100644 index 000000000..60b184149 --- /dev/null +++ b/internal/suites/scenario_default_redirection_url_test.go @@ -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()) +} diff --git a/internal/suites/suite_duo_push_test.go b/internal/suites/suite_duo_push_test.go index ff61079b7..c6e0396d6 100644 --- a/internal/suites/suite_duo_push_test.go +++ b/internal/suites/suite_duo_push_test.go @@ -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", diff --git a/internal/suites/suite_short_timeouts_test.go b/internal/suites/suite_short_timeouts_test.go index 675638f6d..73e22536c 100644 --- a/internal/suites/suite_short_timeouts_test.go +++ b/internal/suites/suite_short_timeouts_test.go @@ -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()) } diff --git a/internal/handlers/safe_redirection.go b/internal/utils/safe_redirection.go similarity index 68% rename from internal/handlers/safe_redirection.go rename to internal/utils/safe_redirection.go index b03bbf9eb..7a4975dfe 100644 --- a/internal/handlers/safe_redirection.go +++ b/internal/utils/safe_redirection.go @@ -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 } diff --git a/internal/handlers/safe_redirection_test.go b/internal/utils/safe_redirection_test.go similarity index 92% rename from internal/handlers/safe_redirection_test.go rename to internal/utils/safe_redirection_test.go index cd4c575da..5429c0fcc 100644 --- a/internal/handlers/safe_redirection_test.go +++ b/internal/utils/safe_redirection_test.go @@ -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) {