[FIX] Redirect to default URL after 1FA when default policy is one_factor. (#611)

* Redirect to default URL after 1FA when default policy is one_factor.

User is now redirected to the default redirection URL after 1FA if
the default policy is set to one_factor and there is no target URL
or if the target URL is unsafe.

Also, if the default policy is set to one_factor and the user is already
authenticated, if she visits the login portal, the 'already authenticated'
view is displayed with a logout button.

This fixes #581.

* Update users.yml

* Fix permissions issue causing suite test failure
This commit is contained in:
Clément Michaud 2020-02-04 22:18:02 +01:00 committed by GitHub
parent 9c9d8518eb
commit d1d02d9eae
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1484 additions and 120 deletions

View File

@ -27,9 +27,11 @@ var ErrNoRunningSuite = errors.New("no running suite")
var runningSuiteFile = ".suite"
var headless bool
var testPattern string
func init() {
SuitesTestCmd.Flags().BoolVar(&headless, "headless", false, "Run tests in headless mode")
SuitesTestCmd.Flags().StringVar(&testPattern, "test", "", "The single test to run")
}
// SuitesListCmd Command for listing the available suites.
@ -184,15 +186,14 @@ func testSuite(cmd *cobra.Command, args []string) {
}
// If suite(s) are provided as argument
if len(args) == 1 {
if len(args) >= 1 {
suiteArg := args[0]
if runningSuite != "" && suiteArg != runningSuite {
log.Fatal(errors.New("Running suite (" + runningSuite + ") is different than suite(s) to be tested (" + suiteArg + "). Shutdown running suite and retry"))
}
suiteNames := strings.Split(suiteArg, ",")
if err := runMultipleSuitesTests(suiteNames, runningSuite == ""); err != nil {
if err := runMultipleSuitesTests(strings.Split(suiteArg, ","), runningSuite == ""); err != nil {
log.Fatal(err)
}
} else {
@ -239,7 +240,13 @@ func runSuiteTests(suiteName string, withEnv bool) error {
if suite.TestTimeout > 0 {
timeout = fmt.Sprintf("%ds", int64(suite.TestTimeout/time.Second))
}
testCmdLine := fmt.Sprintf("go test -count=1 -v ./internal/suites -timeout %s -run '^(Test%sSuite)$'", timeout, suiteName)
testCmdLine := fmt.Sprintf("go test -count=1 -v ./internal/suites -timeout %s ", timeout)
if testPattern != "" {
testCmdLine += fmt.Sprintf("-run '%s'", testPattern)
} else {
testCmdLine += fmt.Sprintf("-run '^(Test%sSuite)$'", suiteName)
}
log.Infof("Running tests of suite %s...", suiteName)
log.Debugf("Running tests with command: %s", testCmdLine)

View File

@ -87,7 +87,7 @@ func startServer() {
}
clock := utils.RealClock{}
authorizer := authorization.NewAuthorizer(*config.AccessControl)
authorizer := authorization.NewAuthorizer(config.AccessControl)
sessionProvider := session.NewProvider(config.Session)
regulator := regulation.NewRegulator(config.Regulation, storageProvider, clock)

View File

@ -160,7 +160,7 @@ func selectMatchingRules(rules []schema.ACLRule, subject Subject, object Object)
return selectMatchingObjectRules(matchingRules, object)
}
func policyToLevel(policy string) Level {
func PolicyToLevel(policy string) Level {
switch policy {
case "bypass":
return Bypass
@ -183,7 +183,7 @@ func (p *Authorizer) GetRequiredLevel(subject Subject, requestURL url.URL) Level
})
if len(matchingRules) > 0 {
return policyToLevel(matchingRules[0].Policy)
return PolicyToLevel(matchingRules[0].Policy)
}
return policyToLevel(p.configuration.DefaultPolicy)
return PolicyToLevel(p.configuration.DefaultPolicy)
}

View File

@ -255,6 +255,15 @@ func (s *AuthorizerSuite) TestShouldCheckResourceMatching() {
tester.CheckAuthorizations(s.T(), John, "https://resource.example.com/xyz/embedded/abc", Bypass)
}
func (s *AuthorizerSuite) TestPolicyToLevel() {
s.Assert().Equal(Bypass, PolicyToLevel("bypass"))
s.Assert().Equal(OneFactor, PolicyToLevel("one_factor"))
s.Assert().Equal(TwoFactor, PolicyToLevel("two_factor"))
s.Assert().Equal(Denied, PolicyToLevel("deny"))
s.Assert().Equal(Denied, PolicyToLevel("whatever"))
}
func TestRunSuite(t *testing.T) {
s := AuthorizerSuite{}
suite.Run(t, &s)

View File

@ -15,10 +15,10 @@ type Configuration struct {
AuthenticationBackend AuthenticationBackendConfiguration `mapstructure:"authentication_backend"`
Session SessionConfiguration `mapstructure:"session"`
TOTP *TOTPConfiguration `mapstructure:"totp"`
DuoAPI *DuoAPIConfiguration `mapstructure:"duo_api"`
AccessControl *AccessControlConfiguration `mapstructure:"access_control"`
Regulation *RegulationConfiguration `mapstructure:"regulation"`
Storage *StorageConfiguration `mapstructure:"storage"`
Notifier *NotifierConfiguration `mapstructure:"notifier"`
TOTP *TOTPConfiguration `mapstructure:"totp"`
DuoAPI *DuoAPIConfiguration `mapstructure:"duo_api"`
AccessControl AccessControlConfiguration `mapstructure:"access_control"`
Regulation *RegulationConfiguration `mapstructure:"regulation"`
Storage *StorageConfiguration `mapstructure:"storage"`
Notifier *NotifierConfiguration `mapstructure:"notifier"`
}

View File

@ -49,5 +49,9 @@ func Validate(configuration *schema.Configuration, validator *schema.StructValid
ValidateNotifier(configuration.Notifier, validator)
}
if configuration.AccessControl.DefaultPolicy == "" {
configuration.AccessControl.DefaultPolicy = "deny"
}
ValidateSQLStorage(configuration.Storage, validator)
}

View File

@ -91,3 +91,13 @@ func TestShouldEnsureNotifierConfigIsProvided(t *testing.T) {
require.Len(t, validator.Errors(), 1)
assert.EqualError(t, validator.Errors()[0], "A notifier configuration must be provided")
}
func TestShouldAddDefaultAccessControl(t *testing.T) {
validator := schema.NewStructValidator()
config := newDefaultConfig()
Validate(&config, validator)
require.Len(t, validator.Errors(), 0)
assert.NotNil(t, config.AccessControl)
assert.Equal(t, "deny", config.AccessControl.DefaultPolicy)
}

View File

@ -2,11 +2,15 @@ package handlers
import (
"github.com/authelia/authelia/internal/authentication"
"github.com/authelia/authelia/internal/authorization"
"github.com/authelia/authelia/internal/middlewares"
)
type ExtendedConfigurationBody struct {
AvailableMethods MethodList `json:"available_methods"`
// OneFactorDefaultPolicy is set if default policy is 'one_factor'
OneFactorDefaultPolicy bool `json:"one_factor_default_policy"`
}
// ExtendedConfigurationGet get the extended configuration accessible to authenticated users.
@ -18,6 +22,10 @@ func ExtendedConfigurationGet(ctx *middlewares.AutheliaCtx) {
body.AvailableMethods = append(body.AvailableMethods, authentication.Push)
}
ctx.Logger.Debugf("Available methods are %s", body.AvailableMethods)
defaultPolicy := authorization.PolicyToLevel(ctx.Configuration.AccessControl.DefaultPolicy)
body.OneFactorDefaultPolicy = defaultPolicy == authorization.OneFactor
ctx.Logger.Tracef("Default policy set to one factor: %v", body.OneFactorDefaultPolicy)
ctx.Logger.Tracef("Available methods are %s", body.AvailableMethods)
ctx.SetJSONBody(body)
}

View File

@ -2,15 +2,12 @@ package handlers
import (
"fmt"
"net/url"
"time"
"github.com/authelia/authelia/internal/authentication"
"github.com/authelia/authelia/internal/authorization"
"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.
@ -111,30 +108,5 @@ func FirstFactorPost(ctx *middlewares.AutheliaCtx) {
return
}
if bodyJSON.TargetURL != "" {
targetURL, err := url.ParseRequestURI(bodyJSON.TargetURL)
if err != nil {
ctx.Error(fmt.Errorf("Unable to parse target URL %s: %s", bodyJSON.TargetURL, err), authenticationFailedMessage)
return
}
requiredLevel := ctx.Providers.Authorizer.GetRequiredLevel(authorization.Subject{
Username: userSession.Username,
Groups: userSession.Groups,
IP: ctx.RemoteIP(),
}, *targetURL)
ctx.Logger.Debugf("Required level for the URL %s is %d", targetURL.String(), requiredLevel)
safeRedirection := utils.IsRedirectionSafe(*targetURL, ctx.Configuration.Session.Domain)
if safeRedirection && requiredLevel <= authorization.OneFactor {
ctx.Logger.Debugf("Redirection is safe, redirecting...")
response := redirectResponse{bodyJSON.TargetURL}
ctx.SetJSONBody(response)
} else {
ctx.ReplyOK()
}
} else {
ctx.ReplyOK()
}
Handle1FAResponse(ctx, bodyJSON.TargetURL, userSession.Username, userSession.Groups)
}

View File

@ -4,6 +4,7 @@ import (
"fmt"
"testing"
"github.com/authelia/authelia/internal/authorization"
"github.com/authelia/authelia/internal/mocks"
"github.com/authelia/authelia/internal/models"
@ -229,7 +230,76 @@ func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeUnchecked() {
assert.Equal(s.T(), []string{"dev", "admins"}, session.Groups)
}
func TestFirstFactorSuite(t *testing.T) {
firstFactorSuite := new(FirstFactorSuite)
suite.Run(t, firstFactorSuite)
type FirstFactorRedirectionSuite struct {
suite.Suite
mock *mocks.MockAutheliaCtx
}
func (s *FirstFactorRedirectionSuite) SetupTest() {
s.mock = mocks.NewMockAutheliaCtx(s.T())
s.mock.Ctx.Configuration.DefaultRedirectionURL = "https://default.local"
s.mock.Ctx.Configuration.AccessControl.DefaultPolicy = "one_factor"
s.mock.Ctx.Providers.Authorizer = authorization.NewAuthorizer(
s.mock.Ctx.Configuration.AccessControl)
s.mock.UserProviderMock.
EXPECT().
CheckUserPassword(gomock.Eq("test"), gomock.Eq("hello")).
Return(true, nil)
s.mock.UserProviderMock.
EXPECT().
GetDetails(gomock.Eq("test")).
Return(&authentication.UserDetails{
Emails: []string{"test@example.com"},
Groups: []string{"dev", "admins"},
}, nil)
s.mock.StorageProviderMock.
EXPECT().
AppendAuthenticationLog(gomock.Any()).
Return(nil)
}
func (s *FirstFactorRedirectionSuite) TearDownTest() {
s.mock.Close()
}
// When the target url is unknown, default policy is to one_factor and default_redirect_url
// is provided, the user should be redirected to the default url.
func (s *FirstFactorRedirectionSuite) TestShouldRedirectUserToDefaultRedirectionURLWhenNoTargetURLProvided() {
s.mock.Ctx.Request.SetBodyString(`{
"username": "test",
"password": "hello",
"keepMeLoggedIn": false
}`)
FirstFactorPost(s.mock.Ctx)
// Respond with 200.
s.mock.Assert200OK(s.T(), redirectResponse{
Redirect: "https://default.local",
})
}
// When the target url is unsafe, default policy is set to one_factor and default_redirect_url
// is provided, the user should be redirected to the default url.
func (s *FirstFactorRedirectionSuite) TestShouldRedirectUserToDefaultRedirectionURLWhenURLIsUnsafe() {
s.mock.Ctx.Request.SetBodyString(`{
"username": "test",
"password": "hello",
"keepMeLoggedIn": false,
"targetURL": "http://notsafe.local"
}`)
FirstFactorPost(s.mock.Ctx)
// Respond with 200.
s.mock.Assert200OK(s.T(), redirectResponse{
Redirect: "https://default.local",
})
}
func TestFirstFactorSuite(t *testing.T) {
suite.Run(t, new(FirstFactorSuite))
suite.Run(t, new(FirstFactorRedirectionSuite))
}

View File

@ -51,6 +51,6 @@ func SecondFactorDuoPost(duoAPI duo.API) middlewares.RequestHandler {
return
}
HandleAuthResponse(ctx, requestBody.TargetURL)
Handle2FAResponse(ctx, requestBody.TargetURL)
}
}

View File

@ -40,6 +40,6 @@ func SecondFactorTOTPPost(totpVerifier TOTPVerifier) middlewares.RequestHandler
return
}
HandleAuthResponse(ctx, bodyJSON.TargetURL)
Handle2FAResponse(ctx, bodyJSON.TargetURL)
}
}

View File

@ -48,6 +48,6 @@ func SecondFactorU2FSignPost(u2fVerifier U2FVerifier) middlewares.RequestHandler
return
}
HandleAuthResponse(ctx, requestBody.TargetURL)
Handle2FAResponse(ctx, requestBody.TargetURL)
}
}

View File

@ -4,29 +4,79 @@ import (
"fmt"
"net/url"
"github.com/authelia/authelia/internal/authorization"
"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})
func Handle1FAResponse(ctx *middlewares.AutheliaCtx, targetURI string, username string, groups []string) {
if targetURI == "" {
if authorization.PolicyToLevel(ctx.Configuration.AccessControl.DefaultPolicy) == authorization.OneFactor &&
ctx.Configuration.DefaultRedirectionURL != "" {
ctx.SetJSONBody(redirectResponse{Redirect: ctx.Configuration.DefaultRedirectionURL})
} else {
ctx.ReplyOK()
}
} else {
return
}
targetURL, err := url.ParseRequestURI(targetURI)
if err != nil {
ctx.Error(fmt.Errorf("Unable to parse target URL %s: %s", targetURI, err), authenticationFailedMessage)
return
}
requiredLevel := ctx.Providers.Authorizer.GetRequiredLevel(authorization.Subject{
Username: username,
Groups: groups,
IP: ctx.RemoteIP(),
}, *targetURL)
ctx.Logger.Debugf("Required level for the URL %s is %d", targetURI, requiredLevel)
if requiredLevel > authorization.OneFactor {
ctx.Logger.Warnf("%s requires more than 1FA, cannot be redirected to", targetURI)
ctx.ReplyOK()
return
}
safeRedirection := utils.IsRedirectionSafe(*targetURL, ctx.Configuration.Session.Domain)
if !safeRedirection {
if authorization.PolicyToLevel(ctx.Configuration.AccessControl.DefaultPolicy) == authorization.OneFactor &&
ctx.Configuration.DefaultRedirectionURL != "" {
ctx.SetJSONBody(redirectResponse{Redirect: ctx.Configuration.DefaultRedirectionURL})
} else {
ctx.ReplyOK()
}
return
}
ctx.Logger.Debugf("Redirection URL %s is safe", targetURI)
response := redirectResponse{Redirect: targetURI}
ctx.SetJSONBody(response)
}
func Handle2FAResponse(ctx *middlewares.AutheliaCtx, targetURI string) {
if targetURI == "" {
if ctx.Configuration.DefaultRedirectionURL != "" {
ctx.SetJSONBody(redirectResponse{Redirect: ctx.Configuration.DefaultRedirectionURL})
} else {
ctx.ReplyOK()
}
return
}
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()
}
}

View File

@ -66,9 +66,7 @@ func NewMockAutheliaCtx(t *testing.T) *MockAutheliaCtx {
datetime, _ := time.Parse("2006-Jan-02", "2013-Feb-03")
mockAuthelia.Clock.Set(datetime)
configuration := schema.Configuration{
AccessControl: new(schema.AccessControlConfiguration),
}
configuration := schema.Configuration{}
configuration.Session.Name = "authelia_session"
configuration.AccessControl.DefaultPolicy = "deny"
configuration.AccessControl.Rules = []schema.ACLRule{schema.ACLRule{
@ -98,7 +96,7 @@ func NewMockAutheliaCtx(t *testing.T) *MockAutheliaCtx {
providers.Notifier = mockAuthelia.NotifierMock
providers.Authorizer = authorization.NewAuthorizer(
*configuration.AccessControl)
configuration.AccessControl)
providers.SessionProvider = session.NewProvider(
configuration.Session)

View File

@ -0,0 +1,35 @@
###############################################################
# Authelia minimal configuration #
###############################################################
port: 9091
logs_level: debug
default_redirection_url: https://home.example.com:8080/
jwt_secret: unsecure_secret
authentication_backend:
file:
path: /var/lib/authelia/users.yml
session:
secret: unsecure_session_secret
domain: example.com
expiration: 3600 # 1 hour
inactivity: 300 # 5 minutes
storage:
local:
path: /var/lib/authelia/db.sqlite
access_control:
default_policy: one_factor
notifier:
smtp:
host: smtp
port: 1025
sender: admin@example.com
disable_require_tls: true

View File

@ -0,0 +1,6 @@
version: '3'
services:
authelia-backend:
volumes:
- './internal/suites/OneFactorDefaultPolicy/configuration.yml:/etc/authelia/configuration.yml:ro'
- './internal/suites/OneFactorDefaultPolicy/users.yml:/var/lib/authelia/users.yml'

View File

@ -0,0 +1,29 @@
###############################################################
# Users Database #
###############################################################
# This file can be used if you do not have an LDAP set up.
# List of users
users:
john:
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: john.doe@authelia.com
groups:
- admins
- dev
harry:
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: harry.potter@authelia.com
groups: []
bob:
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: bob.dylan@authelia.com
groups:
- dev
james:
password: "$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/"
email: james.dean@authelia.com

View File

@ -7,3 +7,5 @@ services:
volumes:
- './internal/suites/Standalone/configuration.yml:/etc/authelia/configuration.yml:ro'
- './internal/suites/Standalone/users.yml:/var/lib/authelia/users.yml'
- '/tmp:/tmp'
user: ${USER_ID}:${GROUP_ID}

View File

@ -1,19 +1,19 @@
users:
bob:
password: '$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/'
password: $6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/
email: bob.dylan@authelia.com
groups:
- dev
harry:
password: '$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/'
password: $6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/
email: harry.potter@authelia.com
groups: []
james:
password: '$6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/'
password: $6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/
email: james.dean@authelia.com
groups: []
john:
password: '$6$rounds=50000$LnfgDsc2WD8F2qNf$0gcCt8jlqAGZRv2ee3mCFsfAr1P4N7kESWEf36Xtw6OjkhAcQuGVOBHXp0lFuZbppa7YlgHk3VD28aSQu9U9S1'
password: $6$rounds=500000$jgiCMRyGXzoqpxS3$w2pJeZnnH8bwW3zzvoMWtTRfQYsHbWbD/hquuQ5vUeIyl9gdwBIt6RWk2S6afBA0DPakbeWgD/4SZPiS0hYtU/
email: john.doe@authelia.com
groups:
- admins

View File

@ -70,7 +70,7 @@ func (s *RedirectionCheckScenario) TestShouldRedirectOnlyWhenDomainIsHandledByAu
if redirected {
s.verifySecretAuthorized(ctx, t)
} else {
s.WaitElementLocatedByClassName(ctx, t, "success-icon")
s.verifyIsAuthenticatedPage(ctx, t)
}
s.doLogout(ctx, t)
})

View File

@ -65,17 +65,17 @@ func (s *UserPreferencesScenario) TestShouldRememberLastUsed2FAMethod() {
// Then go back to portal.
s.doVisit(s.T(), LoginBaseURL)
s.verifyIsSecondFactorPage(ctx, s.T())
// And check the latest method is still used.
s.WaitElementLocatedByID(ctx, s.T(), "push-notification-method")
// Meaning the authentication is successful
s.WaitElementLocatedByClassName(ctx, s.T(), "success-icon")
s.verifyIsHome(ctx, s.T())
// Logout the user and see what user 'harry' sees.
s.doLogout(ctx, s.T())
s.doLoginOneFactor(ctx, s.T(), "harry", "password", false, "")
s.verifyIsSecondFactorPage(ctx, s.T())
s.WaitElementLocatedByID(ctx, s.T(), "one-time-password-method")
s.doLogout(ctx, s.T())
s.verifyIsFirstFactorPage(ctx, s.T())
@ -83,7 +83,10 @@ func (s *UserPreferencesScenario) TestShouldRememberLastUsed2FAMethod() {
s.doLoginOneFactor(ctx, s.T(), "john", "password", false, "")
s.verifyIsSecondFactorPage(ctx, s.T())
s.WaitElementLocatedByID(ctx, s.T(), "push-notification-method")
s.WaitElementLocatedByClassName(ctx, s.T(), "success-icon")
s.verifyIsHome(ctx, s.T())
s.doLogout(ctx, s.T())
s.doLoginOneFactor(ctx, s.T(), "john", "password", false, "")
// Eventually restore the default method
s.doChangeMethod(ctx, s.T(), "one-time-password")

View File

@ -46,6 +46,9 @@ func (s *DuoPushWebDriverSuite) TearDownTest() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.doLogout(ctx, s.T())
s.doLoginOneFactor(ctx, s.T(), "john", "password", false, "")
s.verifyIsSecondFactorPage(ctx, s.T())
s.doChangeMethod(ctx, s.T(), "one-time-password")
s.WaitElementLocatedByID(ctx, s.T(), "one-time-password-method")
}
@ -58,7 +61,7 @@ func (s *DuoPushWebDriverSuite) TestShouldSucceedAuthentication() {
s.doLoginOneFactor(ctx, s.T(), "john", "password", false, "")
s.doChangeMethod(ctx, s.T(), "push-notification")
s.WaitElementLocatedByClassName(ctx, s.T(), "success-icon")
s.verifyIsHome(ctx, s.T())
}
func (s *DuoPushWebDriverSuite) TestShouldFailAuthentication() {

View File

@ -0,0 +1,56 @@
package suites
import (
"fmt"
"time"
)
var oneFactorDefaultPolicySuiteName = "OneFactorDefaultPolicy"
func init() {
dockerEnvironment := NewDockerEnvironment([]string{
"docker-compose.yml",
"internal/suites/OneFactorDefaultPolicy/docker-compose.yml",
"example/compose/authelia/docker-compose.backend.{}.yml",
"example/compose/authelia/docker-compose.frontend.{}.yml",
"example/compose/nginx/backend/docker-compose.yml",
"example/compose/nginx/portal/docker-compose.yml",
})
setup := func(suitePath string) error {
if err := dockerEnvironment.Up(); err != nil {
return err
}
return waitUntilAutheliaBackendIsReady(dockerEnvironment)
}
onSetupTimeout := func() error {
backendLogs, err := dockerEnvironment.Logs("authelia-backend", nil)
if err != nil {
return err
}
fmt.Println(backendLogs)
frontendLogs, err := dockerEnvironment.Logs("authelia-frontend", nil)
if err != nil {
return err
}
fmt.Println(frontendLogs)
return nil
}
teardown := func(suitePath string) error {
return dockerEnvironment.Down()
}
GlobalRegistry.Register(oneFactorDefaultPolicySuiteName, Suite{
SetUp: setup,
SetUpTimeout: 5 * time.Minute,
OnSetupTimeout: onSetupTimeout,
TestTimeout: 1 * time.Minute,
TearDown: teardown,
TearDownTimeout: 2 * time.Minute,
Description: "This suite has been created to test Authelia with a one factor default policy on all resources",
})
}

View File

@ -0,0 +1,84 @@
package suites
import (
"context"
"log"
"testing"
"time"
"github.com/stretchr/testify/suite"
)
type OneFactorDefaultPolicySuite struct {
suite.Suite
}
type OneFactorDefaultPolicyWebSuite struct {
*SeleniumSuite
}
func NewOneFactorDefaultPolicyWebSuite() *OneFactorDefaultPolicyWebSuite {
return &OneFactorDefaultPolicyWebSuite{SeleniumSuite: new(SeleniumSuite)}
}
func (s *OneFactorDefaultPolicyWebSuite) SetupSuite() {
wds, err := StartWebDriver()
if err != nil {
log.Fatal(err)
}
s.WebDriverSession = wds
}
func (s *OneFactorDefaultPolicyWebSuite) TearDownSuite() {
err := s.WebDriverSession.Stop()
if err != nil {
log.Fatal(err)
}
}
func (s *OneFactorDefaultPolicyWebSuite) SetupTest() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.doLogout(ctx, s.T())
}
// No target url is provided, then the user should be redirect to the default url.
func (s *OneFactorDefaultPolicyWebSuite) TestShouldRedirectUserToDefaultURL() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.doLoginOneFactor(ctx, s.T(), "john", "password", false, "")
s.verifyURLIs(ctx, s.T(), HomeBaseURL+"/")
}
// Unsafe URL is provided, then the user should be redirect to the default url.
func (s *OneFactorDefaultPolicyWebSuite) TestShouldRedirectUserToDefaultURLWhenURLIsUnsafe() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.doLoginOneFactor(ctx, s.T(), "john", "password", false, "http://unsafe.local")
s.verifyURLIs(ctx, s.T(), HomeBaseURL+"/")
}
// When use logged in and visit the portal again, she gets redirect to the authenticated view.
func (s *OneFactorDefaultPolicyWebSuite) TestShouldDisplayAuthenticatedView() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
s.doLoginOneFactor(ctx, s.T(), "john", "password", false, "http://unsafe.local")
s.verifyURLIs(ctx, s.T(), HomeBaseURL+"/")
s.doVisit(s.T(), LoginBaseURL)
s.verifyIsAuthenticatedPage(ctx, s.T())
}
func (s *OneFactorDefaultPolicySuite) TestWeb() {
suite.Run(s.T(), NewOneFactorDefaultPolicyWebSuite())
}
func TestOneFactorDefaultPolicySuite(t *testing.T) {
suite.Run(t, new(OneFactorDefaultPolicySuite))
}

View File

@ -62,10 +62,7 @@ func (s *StandaloneWebDriverSuite) TestShouldLetUserKnowHeIsAlreadyAuthenticated
// Visit the login page and wait for redirection to 2FA page with success icon displayed
s.doVisit(s.T(), LoginBaseURL)
s.verifyIsSecondFactorPage(ctx, s.T())
// Check whether the success icon is displayed
s.WaitElementLocatedByClassName(ctx, s.T(), "success-icon")
s.verifyIsAuthenticatedPage(ctx, s.T())
}
func (s *StandaloneWebDriverSuite) TestShouldCheckUserIsAskedToRegisterDevice() {

View File

@ -0,0 +1,10 @@
package suites
import (
"context"
"testing"
)
func (wds *WebDriverSession) verifyIsAuthenticatedPage(ctx context.Context, t *testing.T) {
wds.WaitElementLocatedByID(ctx, t, "authenticated-stage")
}

View File

@ -16,11 +16,12 @@
"@types/node": "12.12.12",
"@types/qrcode.react": "^1.0.0",
"@types/query-string": "^6.3.0",
"@types/react": "16.9.12",
"@types/react": "^16.9.19",
"@types/react-dom": "16.9.4",
"@types/react-ga": "^2.3.0",
"@types/react-router-dom": "^5.1.2",
"axios": "^0.19.0",
"babel-preset-react-app": "^9.1.1",
"chai": "^4.2.0",
"classnames": "^2.2.6",
"enzyme": "^3.10.0",

View File

@ -1,5 +1,6 @@
export const FirstFactorRoute = "/";
export const AuthenticatedRoute = "/authenticated";
export const SecondFactorRoute = "/2fa";
export const SecondFactorU2FRoute = "/2fa/security-key";

View File

@ -2,9 +2,7 @@ import React from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
export interface Props { }
export default function (props: Props) {
export default function () {
return (
<FontAwesomeIcon icon={faCheckCircle} size="4x" color="green" className="success-icon" />
)

View File

@ -6,4 +6,5 @@ export interface Configuration {
export interface ExtendedConfiguration {
available_methods: Set<SecondFactorMethod>;
one_factor_default_policy: boolean;
}

View File

@ -9,6 +9,7 @@ export async function getConfiguration(): Promise<Configuration> {
interface ExtendedConfigurationPayload {
available_methods: Method2FA[];
one_factor_default_policy: boolean;
}
export async function getExtendedConfiguration(): Promise<ExtendedConfiguration> {

View File

@ -0,0 +1,22 @@
import React from "react";
import SuccessIcon from "../../components/SuccessIcon";
import { Typography, makeStyles } from "@material-ui/core";
export default function () {
const classes = useStyles();
return (
<div id="authenticated-stage">
<div className={classes.iconContainer}>
<SuccessIcon />
</div>
<Typography>Authenticated</Typography>
</div>
)
}
const useStyles = makeStyles(theme => ({
iconContainer: {
marginBottom: theme.spacing(2),
flex: "0 0 100%"
}
}))

View File

@ -0,0 +1,47 @@
import React from "react";
import { Grid, makeStyles, Button } from "@material-ui/core";
import { useHistory } from "react-router";
import LoginLayout from "../../../layouts/LoginLayout";
import { LogoutRoute as SignOutRoute } from "../../../Routes";
import Authenticated from "../Authenticated";
export interface Props {
username: string;
}
export default function (props: Props) {
const style = useStyles();
const history = useHistory();
const handleLogoutClick = () => {
history.push(SignOutRoute);
}
return (
<LoginLayout
id="authenticated-stage"
title={`Hi ${props.username}`}
showBrand>
<Grid container>
<Grid item xs={12}>
<Button color="secondary" onClick={handleLogoutClick} id="logout-button">
Logout
</Button>
</Grid>
<Grid item xs={12} className={style.mainContainer}>
<Authenticated />
</Grid>
</Grid>
</LoginLayout>
)
}
const useStyles = makeStyles(theme => ({
mainContainer: {
border: "1px solid #d6d6d6",
borderRadius: "10px",
padding: theme.spacing(4),
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
}
}))

View File

@ -4,7 +4,7 @@ import FirstFactorForm from "./FirstFactor/FirstFactorForm";
import SecondFactorForm from "./SecondFactor/SecondFactorForm";
import {
FirstFactorRoute, SecondFactorRoute, SecondFactorTOTPRoute,
SecondFactorPushRoute, SecondFactorU2FRoute
SecondFactorPushRoute, SecondFactorU2FRoute, AuthenticatedRoute
} from "../../Routes";
import { useAutheliaState } from "../../hooks/State";
import LoadingPage from "../LoadingPage/LoadingPage";
@ -14,6 +14,7 @@ import { useRedirectionURL } from "../../hooks/RedirectionURL";
import { useUserPreferences as userUserInfo } from "../../hooks/UserInfo";
import { SecondFactorMethod } from "../../models/Methods";
import { useExtendedConfiguration } from "../../hooks/Configuration";
import AuthenticatedView from "./AuthenticatedView/AuthenticatedView";
export default function () {
const history = useHistory();
@ -77,19 +78,23 @@ export default function () {
if (state.authentication_level === AuthenticationLevel.Unauthenticated) {
setFirstFactorDisabled(false);
redirect(`${FirstFactorRoute}${redirectionSuffix}`);
} else if (state.authentication_level >= AuthenticationLevel.OneFactor && userInfo) {
if (userInfo.method === SecondFactorMethod.U2F) {
redirect(`${SecondFactorU2FRoute}${redirectionSuffix}`);
} else if (userInfo.method === SecondFactorMethod.MobilePush) {
redirect(`${SecondFactorPushRoute}${redirectionSuffix}`);
} else if (state.authentication_level >= AuthenticationLevel.OneFactor && userInfo && configuration) {
if (configuration.one_factor_default_policy) {
redirect(AuthenticatedRoute);
} else {
redirect(`${SecondFactorTOTPRoute}${redirectionSuffix}`);
if (userInfo.method === SecondFactorMethod.U2F) {
redirect(`${SecondFactorU2FRoute}${redirectionSuffix}`);
} else if (userInfo.method === SecondFactorMethod.MobilePush) {
redirect(`${SecondFactorPushRoute}${redirectionSuffix}`);
} else {
redirect(`${SecondFactorTOTPRoute}${redirectionSuffix}`);
}
}
}
}
}, [state, redirectionURL, redirect, userInfo, setFirstFactorDisabled]);
}, [state, redirectionURL, redirect, userInfo, setFirstFactorDisabled, configuration]);
const handleFirstFactorSuccess = async (redirectionURL: string | undefined) => {
const handleAuthSuccess = async (redirectionURL: string | undefined) => {
if (redirectionURL) {
// Do an external redirection pushed by the server.
window.location.href = redirectionURL;
@ -99,15 +104,6 @@ export default function () {
}
}
const handleSecondFactorSuccess = async (redirectionURL: string | undefined) => {
if (redirectionURL) {
// Do an external redirection pushed by the server.
window.location.href = redirectionURL;
} else {
fetchState();
}
}
const firstFactorReady = state !== undefined &&
state.authentication_level === AuthenticationLevel.Unauthenticated &&
location.pathname === FirstFactorRoute;
@ -120,7 +116,7 @@ export default function () {
disabled={firstFactorDisabled}
onAuthenticationStart={() => setFirstFactorDisabled(true)}
onAuthenticationFailure={() => setFirstFactorDisabled(false)}
onAuthenticationSuccess={handleFirstFactorSuccess} />
onAuthenticationSuccess={handleAuthSuccess} />
</ComponentOrLoading>
</Route>
<Route path={SecondFactorRoute}>
@ -130,7 +126,10 @@ export default function () {
userInfo={userInfo}
configuration={configuration}
onMethodChanged={() => fetchUserInfo()}
onAuthenticationSuccess={handleSecondFactorSuccess} /> : null}
onAuthenticationSuccess={handleAuthSuccess} /> : null}
</Route>
<Route path={AuthenticatedRoute} exact>
{state ? <AuthenticatedView username={state.username} /> : null}
</Route>
<Route path="/">
<Redirect to={FirstFactorRoute} />

View File

@ -1,8 +1,8 @@
import React, { ReactNode, Fragment } from "react";
import { makeStyles, Typography, Link, useTheme } from "@material-ui/core";
import SuccessIcon from "../../../components/SuccessIcon";
import InformationIcon from "../../../components/InformationIcon";
import classnames from "classnames";
import Authenticated from "../Authenticated";
export enum State {
ALREADY_AUTHENTICATED = 1,
@ -27,7 +27,7 @@ export default function (props: Props) {
let stateClass: string = '';
switch (props.state) {
case State.ALREADY_AUTHENTICATED:
container = <AlreadyAuthenticatedContainer />
container = <Authenticated />
stateClass = "state-already-authenticated";
break;
case State.NOT_REGISTERED:
@ -42,6 +42,7 @@ export default function (props: Props) {
break;
}
return (
<div id={props.id}>
<Typography variant="h6">{props.title}</Typography>
@ -76,16 +77,6 @@ const useStyles = makeStyles(theme => ({
}
}));
function AlreadyAuthenticatedContainer() {
const theme = useTheme();
return (
<Fragment>
<div style={{ marginBottom: theme.spacing(2), flex: "0 0 100%" }}><SuccessIcon /></div>
<Typography style={{ color: "green" }}>Authenticated!</Typography>
</Fragment>
)
}
function NotRegisteredContainer() {
const theme = useTheme();
return (

File diff suppressed because it is too large Load Diff