[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 runningSuiteFile = ".suite"
var headless bool var headless bool
var testPattern string
func init() { func init() {
SuitesTestCmd.Flags().BoolVar(&headless, "headless", false, "Run tests in headless mode") 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. // 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 suite(s) are provided as argument
if len(args) == 1 { if len(args) >= 1 {
suiteArg := args[0] suiteArg := args[0]
if runningSuite != "" && suiteArg != runningSuite { 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")) 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(strings.Split(suiteArg, ","), runningSuite == ""); err != nil {
if err := runMultipleSuitesTests(suiteNames, runningSuite == ""); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} else { } else {
@ -239,7 +240,13 @@ func runSuiteTests(suiteName string, withEnv bool) error {
if suite.TestTimeout > 0 { if suite.TestTimeout > 0 {
timeout = fmt.Sprintf("%ds", int64(suite.TestTimeout/time.Second)) 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.Infof("Running tests of suite %s...", suiteName)
log.Debugf("Running tests with command: %s", testCmdLine) log.Debugf("Running tests with command: %s", testCmdLine)

View File

@ -87,7 +87,7 @@ func startServer() {
} }
clock := utils.RealClock{} clock := utils.RealClock{}
authorizer := authorization.NewAuthorizer(*config.AccessControl) authorizer := authorization.NewAuthorizer(config.AccessControl)
sessionProvider := session.NewProvider(config.Session) sessionProvider := session.NewProvider(config.Session)
regulator := regulation.NewRegulator(config.Regulation, storageProvider, clock) 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) return selectMatchingObjectRules(matchingRules, object)
} }
func policyToLevel(policy string) Level { func PolicyToLevel(policy string) Level {
switch policy { switch policy {
case "bypass": case "bypass":
return Bypass return Bypass
@ -183,7 +183,7 @@ func (p *Authorizer) GetRequiredLevel(subject Subject, requestURL url.URL) Level
}) })
if len(matchingRules) > 0 { 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) 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) { func TestRunSuite(t *testing.T) {
s := AuthorizerSuite{} s := AuthorizerSuite{}
suite.Run(t, &s) suite.Run(t, &s)

View File

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

View File

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

View File

@ -91,3 +91,13 @@ func TestShouldEnsureNotifierConfigIsProvided(t *testing.T) {
require.Len(t, validator.Errors(), 1) require.Len(t, validator.Errors(), 1)
assert.EqualError(t, validator.Errors()[0], "A notifier configuration must be provided") 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 ( import (
"github.com/authelia/authelia/internal/authentication" "github.com/authelia/authelia/internal/authentication"
"github.com/authelia/authelia/internal/authorization"
"github.com/authelia/authelia/internal/middlewares" "github.com/authelia/authelia/internal/middlewares"
) )
type ExtendedConfigurationBody struct { type ExtendedConfigurationBody struct {
AvailableMethods MethodList `json:"available_methods"` 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. // 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) 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) ctx.SetJSONBody(body)
} }

View File

@ -2,15 +2,12 @@ package handlers
import ( import (
"fmt" "fmt"
"net/url"
"time" "time"
"github.com/authelia/authelia/internal/authentication" "github.com/authelia/authelia/internal/authentication"
"github.com/authelia/authelia/internal/authorization"
"github.com/authelia/authelia/internal/middlewares" "github.com/authelia/authelia/internal/middlewares"
"github.com/authelia/authelia/internal/regulation" "github.com/authelia/authelia/internal/regulation"
"github.com/authelia/authelia/internal/session" "github.com/authelia/authelia/internal/session"
"github.com/authelia/authelia/internal/utils"
) )
// FirstFactorPost is the handler performing the first factory. // FirstFactorPost is the handler performing the first factory.
@ -111,30 +108,5 @@ func FirstFactorPost(ctx *middlewares.AutheliaCtx) {
return return
} }
if bodyJSON.TargetURL != "" { Handle1FAResponse(ctx, bodyJSON.TargetURL, userSession.Username, userSession.Groups)
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()
}
} }

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"testing" "testing"
"github.com/authelia/authelia/internal/authorization"
"github.com/authelia/authelia/internal/mocks" "github.com/authelia/authelia/internal/mocks"
"github.com/authelia/authelia/internal/models" "github.com/authelia/authelia/internal/models"
@ -229,7 +230,76 @@ func (s *FirstFactorSuite) TestShouldAuthenticateUserWithRememberMeUnchecked() {
assert.Equal(s.T(), []string{"dev", "admins"}, session.Groups) assert.Equal(s.T(), []string{"dev", "admins"}, session.Groups)
} }
func TestFirstFactorSuite(t *testing.T) { type FirstFactorRedirectionSuite struct {
firstFactorSuite := new(FirstFactorSuite) suite.Suite
suite.Run(t, firstFactorSuite)
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 return
} }
HandleAuthResponse(ctx, requestBody.TargetURL) Handle2FAResponse(ctx, requestBody.TargetURL)
} }
} }

View File

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

View File

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

View File

@ -4,29 +4,79 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"github.com/authelia/authelia/internal/authorization"
"github.com/authelia/authelia/internal/middlewares" "github.com/authelia/authelia/internal/middlewares"
"github.com/authelia/authelia/internal/utils" "github.com/authelia/authelia/internal/utils"
) )
func HandleAuthResponse(ctx *middlewares.AutheliaCtx, targetURI string) { func Handle1FAResponse(ctx *middlewares.AutheliaCtx, targetURI string, username string, groups []string) {
if targetURI != "" { if targetURI == "" {
targetURL, err := url.ParseRequestURI(targetURI) if authorization.PolicyToLevel(ctx.Configuration.AccessControl.DefaultPolicy) == authorization.OneFactor &&
ctx.Configuration.DefaultRedirectionURL != "" {
if err != nil { ctx.SetJSONBody(redirectResponse{Redirect: ctx.Configuration.DefaultRedirectionURL})
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 { } else {
ctx.ReplyOK() 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 != "" { if ctx.Configuration.DefaultRedirectionURL != "" {
ctx.SetJSONBody(redirectResponse{Redirect: ctx.Configuration.DefaultRedirectionURL}) ctx.SetJSONBody(redirectResponse{Redirect: ctx.Configuration.DefaultRedirectionURL})
} else { } else {
ctx.ReplyOK() 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") datetime, _ := time.Parse("2006-Jan-02", "2013-Feb-03")
mockAuthelia.Clock.Set(datetime) mockAuthelia.Clock.Set(datetime)
configuration := schema.Configuration{ configuration := schema.Configuration{}
AccessControl: new(schema.AccessControlConfiguration),
}
configuration.Session.Name = "authelia_session" configuration.Session.Name = "authelia_session"
configuration.AccessControl.DefaultPolicy = "deny" configuration.AccessControl.DefaultPolicy = "deny"
configuration.AccessControl.Rules = []schema.ACLRule{schema.ACLRule{ configuration.AccessControl.Rules = []schema.ACLRule{schema.ACLRule{
@ -98,7 +96,7 @@ func NewMockAutheliaCtx(t *testing.T) *MockAutheliaCtx {
providers.Notifier = mockAuthelia.NotifierMock providers.Notifier = mockAuthelia.NotifierMock
providers.Authorizer = authorization.NewAuthorizer( providers.Authorizer = authorization.NewAuthorizer(
*configuration.AccessControl) configuration.AccessControl)
providers.SessionProvider = session.NewProvider( providers.SessionProvider = session.NewProvider(
configuration.Session) 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

@ -6,4 +6,6 @@ services:
- AUTHELIA_SESSION_SECRET=unsecure_session_secret - AUTHELIA_SESSION_SECRET=unsecure_session_secret
volumes: volumes:
- './internal/suites/Standalone/configuration.yml:/etc/authelia/configuration.yml:ro' - './internal/suites/Standalone/configuration.yml:/etc/authelia/configuration.yml:ro'
- './internal/suites/Standalone/users.yml:/var/lib/authelia/users.yml' - './internal/suites/Standalone/users.yml:/var/lib/authelia/users.yml'
- '/tmp:/tmp'
user: ${USER_ID}:${GROUP_ID}

View File

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

View File

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

View File

@ -65,17 +65,17 @@ func (s *UserPreferencesScenario) TestShouldRememberLastUsed2FAMethod() {
// Then go back to portal. // Then go back to portal.
s.doVisit(s.T(), LoginBaseURL) s.doVisit(s.T(), LoginBaseURL)
s.verifyIsSecondFactorPage(ctx, s.T()) s.verifyIsSecondFactorPage(ctx, s.T())
// And check the latest method is still used. // And check the latest method is still used.
s.WaitElementLocatedByID(ctx, s.T(), "push-notification-method") s.WaitElementLocatedByID(ctx, s.T(), "push-notification-method")
// Meaning the authentication is successful // 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. // Logout the user and see what user 'harry' sees.
s.doLogout(ctx, s.T()) s.doLogout(ctx, s.T())
s.doLoginOneFactor(ctx, s.T(), "harry", "password", false, "") s.doLoginOneFactor(ctx, s.T(), "harry", "password", false, "")
s.verifyIsSecondFactorPage(ctx, s.T()) s.verifyIsSecondFactorPage(ctx, s.T())
s.WaitElementLocatedByID(ctx, s.T(), "one-time-password-method") s.WaitElementLocatedByID(ctx, s.T(), "one-time-password-method")
s.doLogout(ctx, s.T()) s.doLogout(ctx, s.T())
s.verifyIsFirstFactorPage(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.doLoginOneFactor(ctx, s.T(), "john", "password", false, "")
s.verifyIsSecondFactorPage(ctx, s.T()) s.verifyIsSecondFactorPage(ctx, s.T())
s.WaitElementLocatedByID(ctx, s.T(), "push-notification-method") 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 // Eventually restore the default method
s.doChangeMethod(ctx, s.T(), "one-time-password") 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) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() 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.doChangeMethod(ctx, s.T(), "one-time-password")
s.WaitElementLocatedByID(ctx, s.T(), "one-time-password-method") 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.doLoginOneFactor(ctx, s.T(), "john", "password", false, "")
s.doChangeMethod(ctx, s.T(), "push-notification") s.doChangeMethod(ctx, s.T(), "push-notification")
s.WaitElementLocatedByClassName(ctx, s.T(), "success-icon") s.verifyIsHome(ctx, s.T())
} }
func (s *DuoPushWebDriverSuite) TestShouldFailAuthentication() { 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 // Visit the login page and wait for redirection to 2FA page with success icon displayed
s.doVisit(s.T(), LoginBaseURL) s.doVisit(s.T(), LoginBaseURL)
s.verifyIsSecondFactorPage(ctx, s.T()) s.verifyIsAuthenticatedPage(ctx, s.T())
// Check whether the success icon is displayed
s.WaitElementLocatedByClassName(ctx, s.T(), "success-icon")
} }
func (s *StandaloneWebDriverSuite) TestShouldCheckUserIsAskedToRegisterDevice() { 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/node": "12.12.12",
"@types/qrcode.react": "^1.0.0", "@types/qrcode.react": "^1.0.0",
"@types/query-string": "^6.3.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-dom": "16.9.4",
"@types/react-ga": "^2.3.0", "@types/react-ga": "^2.3.0",
"@types/react-router-dom": "^5.1.2", "@types/react-router-dom": "^5.1.2",
"axios": "^0.19.0", "axios": "^0.19.0",
"babel-preset-react-app": "^9.1.1",
"chai": "^4.2.0", "chai": "^4.2.0",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"enzyme": "^3.10.0", "enzyme": "^3.10.0",

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
import React, { ReactNode, Fragment } from "react"; import React, { ReactNode, Fragment } from "react";
import { makeStyles, Typography, Link, useTheme } from "@material-ui/core"; import { makeStyles, Typography, Link, useTheme } from "@material-ui/core";
import SuccessIcon from "../../../components/SuccessIcon";
import InformationIcon from "../../../components/InformationIcon"; import InformationIcon from "../../../components/InformationIcon";
import classnames from "classnames"; import classnames from "classnames";
import Authenticated from "../Authenticated";
export enum State { export enum State {
ALREADY_AUTHENTICATED = 1, ALREADY_AUTHENTICATED = 1,
@ -27,7 +27,7 @@ export default function (props: Props) {
let stateClass: string = ''; let stateClass: string = '';
switch (props.state) { switch (props.state) {
case State.ALREADY_AUTHENTICATED: case State.ALREADY_AUTHENTICATED:
container = <AlreadyAuthenticatedContainer /> container = <Authenticated />
stateClass = "state-already-authenticated"; stateClass = "state-already-authenticated";
break; break;
case State.NOT_REGISTERED: case State.NOT_REGISTERED:
@ -42,6 +42,7 @@ export default function (props: Props) {
break; break;
} }
return ( return (
<div id={props.id}> <div id={props.id}>
<Typography variant="h6">{props.title}</Typography> <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() { function NotRegisteredContainer() {
const theme = useTheme(); const theme = useTheme();
return ( return (

File diff suppressed because it is too large Load Diff