Skip to content

Commit

Permalink
Customizable KeyCloak password error validator
Browse files Browse the repository at this point in the history
  • Loading branch information
kenjikikuchi committed May 19, 2024
1 parent 9cf81a7 commit f8375d6
Show file tree
Hide file tree
Showing 8 changed files with 268 additions and 6 deletions.
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)
}

0 comments on commit f8375d6

Please sign in to comment.