mirror of
https://github.com/keycloak/keycloak.git
synced 2025-12-17 04:24:48 -06:00
Update per feedback review
Signed-off-by: mposolda <mposolda@gmail.com>
This commit is contained in:
committed by
Bruno Oliveira da Silva
parent
bba869b3d5
commit
57972d85d3
@@ -47,6 +47,12 @@ public class PasswordForm extends UsernamePasswordForm implements CredentialVali
|
|||||||
context.success();
|
context.success();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setup webauthn data when passkeys enabled
|
||||||
|
if (isConditionalPasskeysEnabled(context.getUser())) {
|
||||||
|
webauthnAuth.fillContextForm(context);
|
||||||
|
}
|
||||||
|
|
||||||
Response challengeResponse = context.form().createLoginPassword();
|
Response challengeResponse = context.form().createLoginPassword();
|
||||||
context.challenge(challengeResponse);
|
context.challenge(challengeResponse);
|
||||||
}
|
}
|
||||||
@@ -54,6 +60,7 @@ public class PasswordForm extends UsernamePasswordForm implements CredentialVali
|
|||||||
@Override
|
@Override
|
||||||
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
|
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
|
||||||
return user.credentialManager().isConfiguredFor(getCredentialProvider(session).getType())
|
return user.credentialManager().isConfiguredFor(getCredentialProvider(session).getType())
|
||||||
|
|| (isConditionalPasskeysEnabled(user))
|
||||||
|| alreadyAuthenticatedUsingPasswordlessCredential(session.getContext().getAuthenticationSession());
|
|| alreadyAuthenticatedUsingPasswordlessCredential(session.getContext().getAuthenticationSession());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public final class UsernameForm extends UsernamePasswordForm {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void authenticate(AuthenticationFlowContext context) {
|
public void authenticate(AuthenticationFlowContext context) {
|
||||||
if (context.getUser() != null && !isConditionalPasskeysEnabled()) {
|
if (context.getUser() != null) {
|
||||||
// We can skip the form when user is re-authenticating. Unless current user has some IDP set, so he can re-authenticate with that IDP
|
// We can skip the form when user is re-authenticating. Unless current user has some IDP set, so he can re-authenticate with that IDP
|
||||||
if (!this.hasLinkedBrokers(context)) {
|
if (!this.hasLinkedBrokers(context)) {
|
||||||
context.success();
|
context.success();
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator impl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// setup webauthn data when passkeys enabled
|
// setup webauthn data when passkeys enabled
|
||||||
if (isConditionalPasskeysEnabled()) {
|
if (isConditionalPasskeysEnabled(context.getUser())) {
|
||||||
webauthnAuth.fillContextForm(context);
|
webauthnAuth.fillContextForm(context);
|
||||||
}
|
}
|
||||||
Response challengeResponse = challenge(context, formData);
|
Response challengeResponse = challenge(context, formData);
|
||||||
@@ -134,7 +134,7 @@ public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator impl
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected Response challenge(AuthenticationFlowContext context, String error, String field) {
|
protected Response challenge(AuthenticationFlowContext context, String error, String field) {
|
||||||
if (isConditionalPasskeysEnabled()) {
|
if (isConditionalPasskeysEnabled(context.getUser())) {
|
||||||
// setup webauthn data when possible
|
// setup webauthn data when possible
|
||||||
webauthnAuth.fillContextForm(context);
|
webauthnAuth.fillContextForm(context);
|
||||||
}
|
}
|
||||||
@@ -157,8 +157,9 @@ public class UsernamePasswordForm extends AbstractUsernameFormAuthenticator impl
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected boolean isConditionalPasskeysEnabled() {
|
protected boolean isConditionalPasskeysEnabled(UserModel currentUser) {
|
||||||
return webauthnAuth != null && webauthnAuth.isPasskeysEnabled();
|
return webauthnAuth != null && webauthnAuth.isPasskeysEnabled() &&
|
||||||
|
(currentUser == null || currentUser.credentialManager().isConfiguredFor(webauthnAuth.getCredentialType()));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,8 +97,7 @@ public class WebAuthnAuthenticator implements Authenticator, CredentialValidator
|
|||||||
|
|
||||||
UserModel user = context.getUser();
|
UserModel user = context.getUser();
|
||||||
boolean isUserIdentified = false;
|
boolean isUserIdentified = false;
|
||||||
|
if (user != null) {
|
||||||
if (shouldShowWebAuthnAuthenticators(context)) {
|
|
||||||
// in 2 Factor Scenario where the user has already been identified
|
// in 2 Factor Scenario where the user has already been identified
|
||||||
WebAuthnAuthenticatorsBean authenticators = new WebAuthnAuthenticatorsBean(context.getSession(), context.getRealm(), user, getCredentialType());
|
WebAuthnAuthenticatorsBean authenticators = new WebAuthnAuthenticatorsBean(context.getSession(), context.getRealm(), user, getCredentialType());
|
||||||
if (authenticators.getAuthenticators().isEmpty()) {
|
if (authenticators.getAuthenticators().isEmpty()) {
|
||||||
@@ -121,14 +120,6 @@ public class WebAuthnAuthenticator implements Authenticator, CredentialValidator
|
|||||||
return form;
|
return form;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param context authentication context
|
|
||||||
* @return true if the available webauthn authenticators should be shown on the screen. Typically during 2-factor authentication for example
|
|
||||||
*/
|
|
||||||
protected boolean shouldShowWebAuthnAuthenticators(AuthenticationFlowContext context) {
|
|
||||||
return context.getUser() != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected WebAuthnPolicy getWebAuthnPolicy(AuthenticationFlowContext context) {
|
protected WebAuthnPolicy getWebAuthnPolicy(AuthenticationFlowContext context) {
|
||||||
return context.getRealm().getWebAuthnPolicy();
|
return context.getRealm().getWebAuthnPolicy();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,9 +60,4 @@ public class WebAuthnConditionalUIAuthenticator extends WebAuthnPasswordlessAuth
|
|||||||
return Profile.isFeatureEnabled(Profile.Feature.PASSKEYS) &&
|
return Profile.isFeatureEnabled(Profile.Feature.PASSKEYS) &&
|
||||||
Boolean.TRUE.equals(session.getContext().getRealm().getWebAuthnPolicyPasswordless().isPasskeysEnabled());
|
Boolean.TRUE.equals(session.getContext().getRealm().getWebAuthnPolicyPasswordless().isPasskeysEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Do not show authenticators during login with conditional passkeys (For example during username/password)
|
|
||||||
protected boolean shouldShowWebAuthnAuthenticators(AuthenticationFlowContext context) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -307,6 +307,10 @@ public class PasskeysOrganizationAuthenticationTest extends AbstractWebAuthnVirt
|
|||||||
.open();
|
.open();
|
||||||
WaitUtils.waitForPageToLoad();
|
WaitUtils.waitForPageToLoad();
|
||||||
|
|
||||||
|
loginPage.assertCurrent();
|
||||||
|
MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(true));
|
||||||
|
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
|
||||||
|
webAuthnLoginPage.clickAuthenticate();
|
||||||
appPage.assertCurrent();
|
appPage.assertCurrent();
|
||||||
|
|
||||||
events.expectLogin()
|
events.expectLogin()
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import org.keycloak.representations.idm.RealmRepresentation;
|
|||||||
import org.keycloak.representations.idm.UserRepresentation;
|
import org.keycloak.representations.idm.UserRepresentation;
|
||||||
import org.keycloak.testsuite.Assert;
|
import org.keycloak.testsuite.Assert;
|
||||||
import org.keycloak.testsuite.admin.AbstractAdminTest;
|
import org.keycloak.testsuite.admin.AbstractAdminTest;
|
||||||
|
import org.keycloak.testsuite.admin.ApiUtil;
|
||||||
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
import org.keycloak.testsuite.arquillian.annotation.EnableFeature;
|
||||||
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
|
import org.keycloak.testsuite.arquillian.annotation.IgnoreBrowserDriver;
|
||||||
import org.keycloak.testsuite.util.WaitUtils;
|
import org.keycloak.testsuite.util.WaitUtils;
|
||||||
@@ -51,6 +52,9 @@ import org.openqa.selenium.By;
|
|||||||
import org.openqa.selenium.NoSuchElementException;
|
import org.openqa.selenium.NoSuchElementException;
|
||||||
import org.openqa.selenium.firefox.FirefoxDriver;
|
import org.openqa.selenium.firefox.FirefoxDriver;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.nullValue;
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @author rmartinc
|
* @author rmartinc
|
||||||
@@ -66,6 +70,7 @@ public class PasskeysUsernameFormTest extends AbstractWebAuthnVirtualTest {
|
|||||||
makePasswordlessRequiredActionDefault(realmRepresentation);
|
makePasswordlessRequiredActionDefault(realmRepresentation);
|
||||||
switchExecutionInBrowser(realmRepresentation);
|
switchExecutionInBrowser(realmRepresentation);
|
||||||
|
|
||||||
|
configureTestRealm(realmRepresentation);
|
||||||
testRealms.add(realmRepresentation);
|
testRealms.add(realmRepresentation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,8 +203,10 @@ public class PasskeysUsernameFormTest extends AbstractWebAuthnVirtualTest {
|
|||||||
// login OK now
|
// login OK now
|
||||||
loginPage.loginUsername(USERNAME);
|
loginPage.loginUsername(USERNAME);
|
||||||
loginPage.assertCurrent();
|
loginPage.assertCurrent();
|
||||||
|
// Passkeys available on password-form as well. Allows to login only with the passkey of current user
|
||||||
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is(USERNAME));
|
MatcherAssert.assertThat(loginPage.getAttemptedUsername(), Matchers.is(USERNAME));
|
||||||
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
|
MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(true));
|
||||||
|
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
|
||||||
loginPage.login(getPassword(USERNAME));
|
loginPage.login(getPassword(USERNAME));
|
||||||
appPage.assertCurrent();
|
appPage.assertCurrent();
|
||||||
events.expectLogin()
|
events.expectLogin()
|
||||||
@@ -307,6 +314,11 @@ public class PasskeysUsernameFormTest extends AbstractWebAuthnVirtualTest {
|
|||||||
.open();
|
.open();
|
||||||
WaitUtils.waitForPageToLoad();
|
WaitUtils.waitForPageToLoad();
|
||||||
|
|
||||||
|
loginPage.assertCurrent();
|
||||||
|
MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(true));
|
||||||
|
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
|
||||||
|
webAuthnLoginPage.clickAuthenticate();
|
||||||
|
|
||||||
appPage.assertCurrent();
|
appPage.assertCurrent();
|
||||||
|
|
||||||
events.expectLogin()
|
events.expectLogin()
|
||||||
@@ -319,4 +331,67 @@ public class PasskeysUsernameFormTest extends AbstractWebAuthnVirtualTest {
|
|||||||
logout();
|
logout();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void passwordLogin_reauthenticationOfUserWithoutPasskey() throws Exception {
|
||||||
|
// use a default resident key which is not shown in conditional UI
|
||||||
|
getVirtualAuthManager().useAuthenticator(DefaultVirtualAuthOptions.DEFAULT_RESIDENT_KEY.getOptions());
|
||||||
|
|
||||||
|
// set passwordless policy for discoverable keys
|
||||||
|
try (Closeable c = getWebAuthnRealmUpdater()
|
||||||
|
.setWebAuthnPolicyRpEntityName("localhost")
|
||||||
|
.setWebAuthnPolicyRequireResidentKey(Constants.WEBAUTHN_POLICY_OPTION_YES)
|
||||||
|
.setWebAuthnPolicyUserVerificationRequirement(Constants.WEBAUTHN_POLICY_OPTION_REQUIRED)
|
||||||
|
.setWebAuthnPolicyPasskeysEnabled(Boolean.TRUE)
|
||||||
|
.update()) {
|
||||||
|
|
||||||
|
// Login with password
|
||||||
|
oauth.openLoginForm();
|
||||||
|
WaitUtils.waitForPageToLoad();
|
||||||
|
|
||||||
|
// WebAuthn elements available, user is not yet known. Password not available as on username-form
|
||||||
|
loginPage.assertCurrent();
|
||||||
|
MatcherAssert.assertThat(loginPage.isPasswordInputPresent(), Matchers.is(false));
|
||||||
|
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), Matchers.notNullValue());
|
||||||
|
|
||||||
|
// Login with password. WebAuthn elements not available on password screen as user does not have passkeys
|
||||||
|
loginPage.loginUsername("test-user@localhost");
|
||||||
|
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
|
||||||
|
loginPage.login(getPassword("test-user@localhost"));
|
||||||
|
appPage.assertCurrent();
|
||||||
|
|
||||||
|
events.clear();
|
||||||
|
|
||||||
|
// Re-authentication now with prompt=login. Passkeys login should not be available on the page as this user does not have passkey
|
||||||
|
oauth.loginForm()
|
||||||
|
.prompt(OIDCLoginProtocol.PROMPT_VALUE_LOGIN)
|
||||||
|
.open();
|
||||||
|
WaitUtils.waitForPageToLoad();
|
||||||
|
|
||||||
|
loginPage.assertCurrent();
|
||||||
|
assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage());
|
||||||
|
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
|
||||||
|
|
||||||
|
// Incorrect password (password of different user)
|
||||||
|
loginPage.login(getPassword("john-doh@localhost"));
|
||||||
|
MatcherAssert.assertThat(loginPage.getPasswordInputError(), Matchers.is("Invalid password."));
|
||||||
|
|
||||||
|
events.clear();
|
||||||
|
|
||||||
|
// Login with password
|
||||||
|
loginPage.login(getPassword("test-user@localhost"));
|
||||||
|
appPage.assertCurrent();
|
||||||
|
|
||||||
|
UserRepresentation testUser = ApiUtil.findUserByUsernameId(testRealm(), "test-user@localhost").toRepresentation();
|
||||||
|
|
||||||
|
events.expectLogin()
|
||||||
|
.user(testUser.getId())
|
||||||
|
.detail(Details.USERNAME, testUser.getUsername())
|
||||||
|
.detail(WebAuthnConstants.USER_VERIFICATION_CHECKED, nullValue())
|
||||||
|
.assertEvent();
|
||||||
|
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import org.keycloak.testsuite.util.WaitUtils;
|
|||||||
import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest;
|
import org.keycloak.testsuite.webauthn.AbstractWebAuthnVirtualTest;
|
||||||
import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions;
|
import org.keycloak.testsuite.webauthn.authenticators.DefaultVirtualAuthOptions;
|
||||||
import org.openqa.selenium.By;
|
import org.openqa.selenium.By;
|
||||||
|
import org.openqa.selenium.NoSuchElementException;
|
||||||
import org.openqa.selenium.firefox.FirefoxDriver;
|
import org.openqa.selenium.firefox.FirefoxDriver;
|
||||||
|
|
||||||
import static org.hamcrest.Matchers.nullValue;
|
import static org.hamcrest.Matchers.nullValue;
|
||||||
@@ -288,12 +289,7 @@ public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTes
|
|||||||
|
|
||||||
// WebAuthn elements not available
|
// WebAuthn elements not available
|
||||||
loginPage.assertCurrent();
|
loginPage.assertCurrent();
|
||||||
try {
|
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
|
||||||
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), nullValue());
|
|
||||||
fail("Not expected to have webauthn button");
|
|
||||||
} catch (Exception nsee) {
|
|
||||||
// expected
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login with password
|
// Login with password
|
||||||
loginPage.login("test-user@localhost", getPassword("test-user@localhost"));
|
loginPage.login("test-user@localhost", getPassword("test-user@localhost"));
|
||||||
@@ -309,12 +305,7 @@ public class PasskeysUsernamePasswordFormTest extends AbstractWebAuthnVirtualTes
|
|||||||
|
|
||||||
loginPage.assertCurrent();
|
loginPage.assertCurrent();
|
||||||
assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage());
|
assertEquals("Please re-authenticate to continue", loginPage.getInfoMessage());
|
||||||
try {
|
Assert.assertThrows(NoSuchElementException.class, () -> driver.findElement(By.xpath("//form[@id='webauth']")));
|
||||||
MatcherAssert.assertThat(driver.findElement(By.xpath("//form[@id='webauth']")), nullValue());
|
|
||||||
fail("Not expected to have webauthn button");
|
|
||||||
} catch (Exception nsee) {
|
|
||||||
// expected
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login with password
|
// Login with password
|
||||||
loginPage.login(getPassword("test-user@localhost"));
|
loginPage.login(getPassword("test-user@localhost"));
|
||||||
|
|||||||
@@ -8,6 +8,13 @@
|
|||||||
<input type="hidden" id="userHandle" name="userHandle"/>
|
<input type="hidden" id="userHandle" name="userHandle"/>
|
||||||
<input type="hidden" id="error" name="error"/>
|
<input type="hidden" id="error" name="error"/>
|
||||||
</form>
|
</form>
|
||||||
|
<#if authenticators??>
|
||||||
|
<form id="authn_select" class="${properties.kcFormClass!}">
|
||||||
|
<#list authenticators.authenticators as authenticator>
|
||||||
|
<input type="hidden" name="authn_use_chk" value="${authenticator.credentialId}"/>
|
||||||
|
</#list>
|
||||||
|
</form>
|
||||||
|
</#if>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { authenticateByWebAuthn } from "${url.resourcesPath}/js/webauthnAuthenticate.js";
|
import { authenticateByWebAuthn } from "${url.resourcesPath}/js/webauthnAuthenticate.js";
|
||||||
import { initAuthenticate } from "${url.resourcesPath}/js/passkeysConditionalAuth.js";
|
import { initAuthenticate } from "${url.resourcesPath}/js/passkeysConditionalAuth.js";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<#import "template.ftl" as layout>
|
<#import "template.ftl" as layout>
|
||||||
<#import "field.ftl" as field>
|
<#import "field.ftl" as field>
|
||||||
<#import "buttons.ftl" as buttons>
|
<#import "buttons.ftl" as buttons>
|
||||||
|
<#import "passkeys.ftl" as passkeys>
|
||||||
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('password'); section>
|
<@layout.registrationLayout displayMessage=!messagesPerField.existsError('password'); section>
|
||||||
<!-- template: login-password.ftl -->
|
<!-- template: login-password.ftl -->
|
||||||
<#if section = "header">
|
<#if section = "header">
|
||||||
@@ -14,6 +15,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<@passkeys.conditionalUIData />
|
||||||
</#if>
|
</#if>
|
||||||
|
|
||||||
</@layout.registrationLayout>
|
</@layout.registrationLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user