Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Customizable KeyCloak password error validator #1275

Merged
merged 2 commits into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -708,6 +708,31 @@ http_attempts_count = 3
http_retry_delay = 1
region = us-east-1
```

For KeyCloak, 2 more parameters are available to end a failed authentication process.
- `kc_auth_error_element` - configures what HTTP element saml2aws looks for in authentication error responses. Defaults to "span#input-error" and looks for `<span id=input-error>xxx</span>`. Goquery is used. "span#id-name" looks for `<span id=id-name>xxx</span>`. "span.class-name" looks for `<span class=class-name>xxx</span>`.
- `kc_auth_error_message` - works with the `kc_auth_error_element` and configures what HTTP message saml2aws looks for in authentication error responses. Defaults to "Invalid username or password." and looks for `<xxx>Invalid username or password.</xxx>`. [Regular expressions](https://github.com/google/re2/wiki/Syntax) are accepted.

Example: If your KeyCloak server returns the authentication error message "Invalid username or password." in a different language in the `<span class=kc-feedback-text>xxx</span>` element, these parameters would look like:
```
[default]
url = https://id.customer.cloud
username = user@versent.com.au
provider = KeyCloak
...
kc_auth_error_element = span.kc-feedback-text
kc_auth_error_message = "Ungültiger Benutzername oder Passwort."
```
If your KeyCloak server returns a different error message depending on an authentication error type, use a pipe as a separator and add multiple messages to the `kc_auth_error_message`:
```
[default]
url = https://id.customer.cloud
username = user@versent.com.au
provider = KeyCloak
...
kc_auth_error_message = "Invalid username or password.|Account is disabled, contact your administrator."
```

## Building

### macOS
Expand Down
2 changes: 2 additions & 0 deletions pkg/cfg/cfg.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ type IDPAccount struct {
BrowserDriverDir string `ini:"browser_driver_dir,omitempty"` // used by browser; hide from user if not set
Headless bool `ini:"headless"` // used by browser
Prompter string `ini:"prompter"`
KCAuthErrorMessage string `ini:"kc_auth_error_message,omitempty"` // used by KeyCloak; hide from user if not set
KCAuthErrorElement string `ini:"kc_auth_error_element,omitempty"` // used by KeyCloak; hide from user if not set
}

func (ia IDPAccount) String() string {
Expand Down
22 changes: 22 additions & 0 deletions pkg/provider/keycloak/example/authError-accountDisabled.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<head>
<body>
<form id="xx-login" onsubmit="login.disabled = true; return true;" action="https://auth.xxx/auth/realms/xx/login-actions/authenticate?session_code=OKcPhDRTJdHbIgzuUl0wUGdKB2HaPuTjC_JpEyUa9GU&amp;execution=0fe1a2f0-cf8a-4943-8de5-f6b1b84d6189&amp;client_id=urn%3Abcdefg%3Awebservices&amp;tab_id=Lj7xB_NeuSQ" method="post">
<div class="form-group">
<label for="username" class="pf-c-form__label pf-c-form__label-text">Username or E-Mail</label>
<input tabindex="1" id="username" class="pf-c-form-control" name="username" value="xx" type="text" autofocus autocomplete="off" aria-invalid="true"/>
<span id="input-error" class="pf-c-form__helper-text pf-m-error required kc-feedback-text" aria-live="polite">
Account is disabled, contact your administrator.
</span>
</div>
<div class="form-group">
<label for="password" class="pf-c-form__label pf-c-form__label-text">Password</label>
<input tabindex="2" id="password" class="pf-c-form-control" name="password" type="password" autocomplete="off" aria-invalid="true"/>
</div>
<div id="kc-form-buttons" class="form-group">
<input type="hidden" id="id-hidden-input" name="credentialId" />
<input tabindex="4" class="pf-c-button pf-m-primary pf-m-block btn-lg" name="login" id="kc-login" type="submit" value="Login"/>
</div>
</form>
</body>
</html>
22 changes: 22 additions & 0 deletions pkg/provider/keycloak/example/authError-accountDisabled_ja.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<head>
<body>
<form id="xx-login" onsubmit="login.disabled = true; return true;" action="https://auth.xxx/auth/realms/xx/login-actions/authenticate?session_code=OKcPhDRTJdHbIgzuUl0wUGdKB2HaPuTjC_JpEyUa9GU&amp;execution=0fe1a2f0-cf8a-4943-8de5-f6b1b84d6189&amp;client_id=urn%3Abcdefg%3Awebservices&amp;tab_id=Lj7xB_NeuSQ" method="post">
<div class="form-group">
<label for="username" class="pf-c-form__label pf-c-form__label-text">ユーザー名 または メールアドレス</label>
<input tabindex="1" id="username" class="pf-c-form-control" name="username" value="xx" type="text" autofocus autocomplete="off" aria-invalid="true"/>
<span class="kc-feedback-text" aria-live="polite">
無効なユーザー名またはパスワードです。
</span>
</div>
<div class="form-group">
<label for="password" class="pf-c-form__label pf-c-form__label-text">パスワード</label>
<input tabindex="2" id="password" class="pf-c-form-control" name="password" type="password" autocomplete="off" aria-invalid="true"/>
</div>
<div id="kc-form-buttons" class="form-group">
<input type="hidden" id="id-hidden-input" name="credentialId" />
<input tabindex="4" class="pf-c-button pf-m-primary pf-m-block btn-lg" name="login" id="kc-login" type="submit" value="ログイン"/>
</div>
</form>
</body>
</html>
22 changes: 22 additions & 0 deletions pkg/provider/keycloak/example/authError-invalidPassword.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<head>
<body>
<form id="xx-login" onsubmit="login.disabled = true; return true;" action="https://auth.xxx/auth/realms/xx/login-actions/authenticate?session_code=OKcPhDRTJdHbIgzuUl0wUGdKB2HaPuTjC_JpEyUa9GU&amp;execution=0fe1a2f0-cf8a-4943-8de5-f6b1b84d6189&amp;client_id=urn%3Abcdefg%3Awebservices&amp;tab_id=Lj7xB_NeuSQ" method="post">
<div class="form-group">
<label for="username" class="pf-c-form__label pf-c-form__label-text">Username or E-Mail</label>
<input tabindex="1" id="username" class="pf-c-form-control" name="username" value="xx" type="text" autofocus autocomplete="off" aria-invalid="true"/>
<span id="input-error" class="pf-c-form__helper-text pf-m-error required kc-feedback-text" aria-live="polite">
Invalid username or password.
</span>
</div>
<div class="form-group">
<label for="password" class="pf-c-form__label pf-c-form__label-text">Password</label>
<input tabindex="2" id="password" class="pf-c-form-control" name="password" type="password" autocomplete="off" aria-invalid="true"/>
</div>
<div id="kc-form-buttons" class="form-group">
<input type="hidden" id="id-hidden-input" name="credentialId" />
<input tabindex="4" class="pf-c-button pf-m-primary pf-m-block btn-lg" name="login" id="kc-login" type="submit" value="Login"/>
</div>
</form>
</body>
</html>
22 changes: 22 additions & 0 deletions pkg/provider/keycloak/example/authError-invalidPassword_ja.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!DOCTYPE html>
<head>
<body>
<form id="xx-login" onsubmit="login.disabled = true; return true;" action="https://auth.xxx/auth/realms/xx/login-actions/authenticate?session_code=OKcPhDRTJdHbIgzuUl0wUGdKB2HaPuTjC_JpEyUa9GU&amp;execution=0fe1a2f0-cf8a-4943-8de5-f6b1b84d6189&amp;client_id=urn%3Abcdefg%3Awebservices&amp;tab_id=Lj7xB_NeuSQ" method="post">
<div class="form-group">
<label for="username" class="pf-c-form__label pf-c-form__label-text">ユーザー名 または メールアドレス</label>
<input tabindex="1" id="username" class="pf-c-form-control" name="username" value="xx" type="text" autofocus autocomplete="off" aria-invalid="true"/>
<span class="kc-feedback-text" aria-live="polite">
無効なユーザー名またはパスワードです。
</span>
</div>
<div class="form-group">
<label for="password" class="pf-c-form__label pf-c-form__label-text">パスワード</label>
<input tabindex="2" id="password" class="pf-c-form-control" name="password" type="password" autocomplete="off" aria-invalid="true"/>
</div>
<div id="kc-form-buttons" class="form-group">
<input type="hidden" id="id-hidden-input" name="credentialId" />
<input tabindex="4" class="pf-c-button pf-m-primary pf-m-block btn-lg" name="login" id="kc-login" type="submit" value="ログイン"/>
</div>
</form>
</body>
</html>
50 changes: 44 additions & 6 deletions pkg/provider/keycloak/keycloak.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,18 @@ var logger = logrus.WithField("provider", "Keycloak")
type Client struct {
provider.ValidateBase

client *provider.HTTPClient
client *provider.HTTPClient
authErrorValidator *authErrorValidator
}

const (
DefaultAuthErrorElement = "span#input-error"
DefaultAuthErrorMessage = "Invalid username or password."
)

type authErrorValidator struct {
httpMessageRE *regexp.Regexp
httpElement string
}

type authContext struct {
Expand All @@ -47,11 +58,38 @@ func New(idpAccount *cfg.IDPAccount) (*Client, error) {
return nil, errors.Wrap(err, "error building http client")
}

authErrorValidator, err := CustomizeAuthErrorValidator(idpAccount)
if err != nil {
return nil, errors.Wrap(err, "error customizing auth error validator")
}

return &Client{
client: client,
client: client,
authErrorValidator: authErrorValidator,
}, nil
}

func CustomizeAuthErrorValidator(account *cfg.IDPAccount) (*authErrorValidator, error) {
customValidator := &authErrorValidator{}
var err error

message := account.KCAuthErrorMessage
if message == "" {
message = DefaultAuthErrorMessage
}
customValidator.httpMessageRE, err = regexp.Compile(message)
if err != nil {
return nil, errors.Wrap(err, "could not compile regular expression")
}

customValidator.httpElement = account.KCAuthErrorElement
if customValidator.httpElement == "" {
customValidator.httpElement = DefaultAuthErrorElement
}

return customValidator, nil
}

// Authenticate logs into KeyCloak and returns a SAML response
func (kc *Client) Authenticate(loginDetails *creds.LoginDetails) (string, error) {
return kc.doAuthenticate(&authContext{loginDetails.MFAToken, 0, true}, loginDetails)
Expand Down Expand Up @@ -104,7 +142,7 @@ func (kc *Client) doAuthenticate(authCtx *authContext, loginDetails *creds.Login
}

samlResponse, err := extractSamlResponse(doc)
if err != nil && authCtx.authenticatorIndexValid && passwordValid(doc) {
if err != nil && authCtx.authenticatorIndexValid && passwordValid(doc, kc.authErrorValidator) {
return kc.doAuthenticate(authCtx, loginDetails)
}
return samlResponse, err
Expand Down Expand Up @@ -355,11 +393,11 @@ func extractSamlResponse(doc *goquery.Document) (string, error) {
return samlAssertion, err
}

func passwordValid(doc *goquery.Document) bool {
func passwordValid(doc *goquery.Document, authErrorValidator *authErrorValidator) bool {
var valid = true
doc.Find("span#input-error").Each(func(i int, s *goquery.Selection) {
doc.Find(authErrorValidator.httpElement).Each(func(i int, s *goquery.Selection) {
text := s.Text()
if strings.Contains(text, "Invalid username or password.") {
if authErrorValidator.httpMessageRE.MatchString(text) {
valid = false
return
}
Expand Down
109 changes: 109 additions & 0 deletions pkg/provider/keycloak/keycloak_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/stretchr/testify/require"
"github.com/versent/saml2aws/v2/mocks"
"github.com/versent/saml2aws/v2/pkg/cfg"
"github.com/versent/saml2aws/v2/pkg/creds"
"github.com/versent/saml2aws/v2/pkg/prompter"
"github.com/versent/saml2aws/v2/pkg/provider"
Expand Down Expand Up @@ -262,3 +263,111 @@ func TestClient_extractWebauthnParameters(t *testing.T) {
require.Equal(t, "J3NKWZPkSmqXuoKLtzzshg", challenge)
require.Equal(t, "localhost", rpID)
}

func TestClient_CustomizeAuthErrorValidator_DefaultSetup(t *testing.T) {
// Test with the default auth error message and the default HTTP element
idpAccount := cfg.IDPAccount{
KCAuthErrorMessage: "",
KCAuthErrorElement: "",
}
authErrorValidator, err := CustomizeAuthErrorValidator(&idpAccount)
require.Nil(t, err)
require.Equal(t, authErrorValidator.httpMessageRE.String(), DefaultAuthErrorMessage)
require.Equal(t, authErrorValidator.httpElement, DefaultAuthErrorElement)
}

func TestClient_CustomizeAuthErrorValidator_CustomSetup(t *testing.T) {
// Test with multiple auth error messages and the default HTTP element
ErrMessage1 := "Invalid username or password."
ErrMessage2 := "Account is disabled, contact your administrator."
httpElement := ""
idpAccount := cfg.IDPAccount{
KCAuthErrorMessage: ErrMessage1 + "|" + ErrMessage2,
KCAuthErrorElement: httpElement,
}
authErrorValidator, err := CustomizeAuthErrorValidator(&idpAccount)
require.Nil(t, err)
require.Equal(t, authErrorValidator.httpMessageRE.String(), ErrMessage1+"|"+ErrMessage2)
require.Equal(t, authErrorValidator.httpElement, DefaultAuthErrorElement)

// Test with multiple auth error messages in a non-English language and a customized HTTP element
ErrMessage1 = "無効なユーザー名またはパスワードです。" // "Invalid username or password." in Japanese
ErrMessage2 = "アカウントは無効です。管理者に連絡してください。" // "Account is disabled, contact your administrator." in Japanese
httpElement = "span.kc-feedback-text"
idpAccount = cfg.IDPAccount{
KCAuthErrorMessage: ErrMessage1 + "|" + ErrMessage2,
KCAuthErrorElement: httpElement,
}
authErrorValidator, err = CustomizeAuthErrorValidator(&idpAccount)
require.Nil(t, err)
require.Equal(t, authErrorValidator.httpMessageRE.String(), ErrMessage1+"|"+ErrMessage2)
require.Equal(t, authErrorValidator.httpElement, httpElement)
}
func TestClient_passwordValid_DefaultValidator(t *testing.T) {
// Test with the default auth error message and the default HTTP element
idpAccount := cfg.IDPAccount{
KCAuthErrorMessage: "",
KCAuthErrorElement: "",
}
authErrorValidator, err := CustomizeAuthErrorValidator(&idpAccount)
require.Nil(t, err)

data, err := os.ReadFile("example/authError-invalidPassword.html")
require.Nil(t, err)

doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data))
require.Nil(t, err)

require.Equal(t, passwordValid(doc, authErrorValidator), false)
}

func TestClient_passwordValid_CustomValidator(t *testing.T) {
// Test with multiple auth error messages and the default HTTP element
idpAccount := cfg.IDPAccount{
KCAuthErrorMessage: "Invalid username or password.|Account is disabled, contact your administrator.",
KCAuthErrorElement: "",
}
authErrorValidator, err := CustomizeAuthErrorValidator(&idpAccount)
require.Nil(t, err)

// Test with "Invalid username or password."
data, err := os.ReadFile("example/authError-invalidPassword.html")
require.Nil(t, err)

doc, err := goquery.NewDocumentFromReader(bytes.NewReader(data))
require.Nil(t, err)
require.Equal(t, passwordValid(doc, authErrorValidator), false)

// Test with "Account is disabled, contact your administrator."
data, err = os.ReadFile("example/authError-accountDisabled.html")
require.Nil(t, err)

doc, err = goquery.NewDocumentFromReader(bytes.NewReader(data))
require.Nil(t, err)
require.Equal(t, passwordValid(doc, authErrorValidator), false)

// Test with multiple auth error messages in a non-English language and a customized HTTP element
idpAccount = cfg.IDPAccount{
// "Invalid username or password.|Account is disabled, contact your administrator." in Japanese
KCAuthErrorMessage: "無効なユーザー名またはパスワードです。|アカウントは無効です。管理者に連絡してください。",
KCAuthErrorElement: "span.kc-feedback-text",
}
authErrorValidator, err = CustomizeAuthErrorValidator(&idpAccount)
require.Nil(t, err)

// Test with "Invalid username or password." in Japanese
data, err = os.ReadFile("example/authError-invalidPassword_ja.html")
require.Nil(t, err)

doc, err = goquery.NewDocumentFromReader(bytes.NewReader(data))
require.Nil(t, err)
require.Equal(t, passwordValid(doc, authErrorValidator), false)

// Test with "Account is disabled, contact your administrator." in Japanese
data, err = os.ReadFile("example/authError-accountDisabled_ja.html")
require.Nil(t, err)

doc, err = goquery.NewDocumentFromReader(bytes.NewReader(data))
require.Nil(t, err)
require.Equal(t, passwordValid(doc, authErrorValidator), false)
}
Loading