KEYCLOAK-847 Add support for step up authentication (#7897)
KEYCLOAK-847 Fix behavior of unknown not essential acr claim Co-authored-by: Georg Romstorfer <georg.romstorfer@gmail.com> Co-authored-by: Marek Posolda <mposolda@gmail.com>
This commit is contained in:
parent
dfcf46ca60
commit
dff79cee3c
28 changed files with 1203 additions and 54 deletions
|
@ -0,0 +1,154 @@
|
|||
/*
|
||||
* Copyright 2021 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.representations;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
/**
|
||||
* Claims parameter as described in the OIDC specification https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter
|
||||
*
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public class ClaimsRepresentation {
|
||||
|
||||
@JsonProperty("id_token")
|
||||
private Map<String, ClaimValue> idTokenClaims;
|
||||
|
||||
@JsonProperty("userinfo")
|
||||
private Map<String, ClaimValue> userinfoClaims;
|
||||
|
||||
public Map<String, ClaimValue> getIdTokenClaims() {
|
||||
return idTokenClaims;
|
||||
}
|
||||
|
||||
public void setIdTokenClaims(Map<String, ClaimValue> idTokenClaims) {
|
||||
this.idTokenClaims = idTokenClaims;
|
||||
}
|
||||
|
||||
public Map<String, ClaimValue> getUserinfoClaims() {
|
||||
return userinfoClaims;
|
||||
}
|
||||
|
||||
public void setUserinfoClaims(Map<String, ClaimValue> userinfoClaims) {
|
||||
this.userinfoClaims = userinfoClaims;
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
/**
|
||||
*
|
||||
* @param claimName
|
||||
* @param ctx Whether we ask for claim to be presented in idToken or userInfo
|
||||
* @return true if claim is presented in the claims parameter either as "null" claim (See OIDC specification for definition of null claim) or claim with some value
|
||||
*/
|
||||
public boolean isPresent(String claimName, ClaimContext ctx) {
|
||||
if (ctx == ClaimContext.ID_TOKEN) {
|
||||
return idTokenClaims != null && idTokenClaims.containsKey(claimName);
|
||||
} else if (ctx == ClaimContext.USERINFO){
|
||||
return userinfoClaims != null && userinfoClaims.containsKey(claimName);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Invalid claim context");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param claimName
|
||||
* @param ctx Whether we ask for claim to be presented in idToken or userInfo
|
||||
* @return true if claim is presented in the claims parameter as "null" claim (See OIDC specification for definition of null claim)
|
||||
*/
|
||||
public boolean isPresentAsNullClaim(String claimName, ClaimContext ctx) {
|
||||
if (!isPresent(claimName, ctx)) return false;
|
||||
|
||||
if (ctx == ClaimContext.ID_TOKEN) {
|
||||
return idTokenClaims.get(claimName) == null;
|
||||
} else if (ctx == ClaimContext.USERINFO){
|
||||
return userinfoClaims.get(claimName) == null;
|
||||
} else {
|
||||
throw new IllegalArgumentException("Invalid claim context");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param claimName
|
||||
* @param ctx Whether we ask for claim to be presented in idToken or userInfo
|
||||
* @param claimType claimType class
|
||||
* @return Claim value
|
||||
*/
|
||||
public <CLAIM_TYPE> ClaimValue<CLAIM_TYPE> getClaimValue(String claimName, ClaimContext ctx, Class<CLAIM_TYPE> claimType) {
|
||||
if (!isPresent(claimName, ctx)) return null;
|
||||
|
||||
if (ctx == ClaimContext.ID_TOKEN) {
|
||||
return (ClaimValue<CLAIM_TYPE>) idTokenClaims.get(claimName);
|
||||
} else if (ctx == ClaimContext.USERINFO){
|
||||
return (ClaimValue<CLAIM_TYPE>) userinfoClaims.get(claimName);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Invalid claim context");
|
||||
}
|
||||
}
|
||||
|
||||
public enum ClaimContext {
|
||||
ID_TOKEN, USERINFO
|
||||
}
|
||||
|
||||
/**
|
||||
* @param <CLAIM_TYPE> Specifies the type of the claim
|
||||
*/
|
||||
public static class ClaimValue<CLAIM_TYPE> {
|
||||
|
||||
private Boolean essential;
|
||||
|
||||
private CLAIM_TYPE value;
|
||||
|
||||
private List<CLAIM_TYPE> values;
|
||||
|
||||
public Boolean getEssential() {
|
||||
return essential;
|
||||
}
|
||||
|
||||
public boolean isEssential() {
|
||||
return essential != null && essential;
|
||||
}
|
||||
|
||||
public void setEssential(Boolean essential) {
|
||||
this.essential = essential;
|
||||
}
|
||||
|
||||
public CLAIM_TYPE getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
public void setValue(CLAIM_TYPE value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public List<CLAIM_TYPE> getValues() {
|
||||
return values;
|
||||
}
|
||||
|
||||
public void setValues(List<CLAIM_TYPE> values) {
|
||||
this.values = values;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -19,6 +19,8 @@ package org.keycloak;
|
|||
|
||||
import org.junit.Assert;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.common.util.ObjectUtil;
|
||||
import org.keycloak.representations.ClaimsRepresentation;
|
||||
import org.keycloak.representations.IDToken;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.representations.adapters.config.AdapterConfig;
|
||||
|
@ -217,5 +219,48 @@ public class JsonParserTest {
|
|||
Assert.assertNull(configRep.getConfigAsMap().get("not-existing-option"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testReadClaimsParameter() throws Exception {
|
||||
InputStream is = getClass().getClassLoader().getResourceAsStream("sample-claims.json");
|
||||
ClaimsRepresentation claimsRep = JsonSerialization.readValue(is, ClaimsRepresentation.class);
|
||||
|
||||
Assert.assertTrue(claimsRep.isPresent("auth_time", ClaimsRepresentation.ClaimContext.ID_TOKEN));
|
||||
Assert.assertFalse(claimsRep.isPresent("auth_time", ClaimsRepresentation.ClaimContext.USERINFO));
|
||||
|
||||
Assert.assertFalse(claimsRep.isPresentAsNullClaim("auth_time", ClaimsRepresentation.ClaimContext.ID_TOKEN));
|
||||
Assert.assertTrue(claimsRep.isPresentAsNullClaim("nickname", ClaimsRepresentation.ClaimContext.USERINFO));
|
||||
Assert.assertNull(claimsRep.getClaimValue("nickname", ClaimsRepresentation.ClaimContext.USERINFO, String.class));
|
||||
|
||||
ClaimsRepresentation.ClaimValue<String> email = claimsRep.getClaimValue("email", ClaimsRepresentation.ClaimContext.USERINFO, String.class);
|
||||
assertClaimValue(email, true, null);
|
||||
|
||||
ClaimsRepresentation.ClaimValue<Boolean> emailVerified = claimsRep.getClaimValue("email_verified", ClaimsRepresentation.ClaimContext.USERINFO, Boolean.class);
|
||||
assertClaimValue(emailVerified, true, null);
|
||||
Assert.assertTrue(emailVerified.isEssential());
|
||||
|
||||
emailVerified = claimsRep.getClaimValue("email_verified", ClaimsRepresentation.ClaimContext.ID_TOKEN, Boolean.class);
|
||||
assertClaimValue(emailVerified, false, true);
|
||||
Assert.assertFalse(emailVerified.isEssential());
|
||||
|
||||
ClaimsRepresentation.ClaimValue<String> sub = claimsRep.getClaimValue("sub", ClaimsRepresentation.ClaimContext.ID_TOKEN, String.class);
|
||||
assertClaimValue(sub, null, "248289761001");
|
||||
Assert.assertFalse(sub.isEssential());
|
||||
|
||||
ClaimsRepresentation.ClaimValue<String> acr = claimsRep.getClaimValue("acr", ClaimsRepresentation.ClaimContext.ID_TOKEN, String.class);
|
||||
assertClaimValue(acr, null, null, "urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:gold");
|
||||
}
|
||||
|
||||
private <T> void assertClaimValue(ClaimsRepresentation.ClaimValue<T> claimVal, Boolean expectedEssential, T expectedValue, T... expectedValues) {
|
||||
Assert.assertTrue(ObjectUtil.isEqualOrBothNull(expectedEssential, claimVal.getEssential()));
|
||||
Assert.assertTrue(ObjectUtil.isEqualOrBothNull(expectedValue, claimVal.getValue()));
|
||||
|
||||
if (expectedValues == null) {
|
||||
Assert.assertNull(claimVal.getValues());
|
||||
} else {
|
||||
for (int i = 0; i<expectedValues.length ; i++) {
|
||||
Assert.assertEquals(expectedValues[i], claimVal.getValues().get(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
18
core/src/test/resources/sample-claims.json
Normal file
18
core/src/test/resources/sample-claims.json
Normal file
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"userinfo":
|
||||
{
|
||||
"given_name": {"essential": true},
|
||||
"nickname": null,
|
||||
"email": {"essential": true},
|
||||
"email_verified": {"essential": true},
|
||||
"picture": null,
|
||||
"http://example.info/claims/groups": null
|
||||
},
|
||||
"id_token":
|
||||
{
|
||||
"auth_time": {"essential": true},
|
||||
"sub": {"value": "248289761001"},
|
||||
"email_verified": {"essential": false, "value": true },
|
||||
"acr": {"values": ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:gold"] }
|
||||
}
|
||||
}
|
|
@ -23,6 +23,7 @@ import static org.keycloak.quarkus.runtime.configuration.ConfigArgsConfigSource.
|
|||
|
||||
import java.io.File;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
|
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* Copyright 2021 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.authentication;
|
||||
|
||||
/**
|
||||
* Callback to be triggered during various lifecycle events of authentication flow.
|
||||
*
|
||||
* The {@link AuthenticatorFactory}, which creates this Authenticator should implement {@link AuthenticationFlowCallbackFactory} interface.
|
||||
*
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public interface AuthenticationFlowCallback extends Authenticator {
|
||||
|
||||
/**
|
||||
* Triggered after the authentication flow is successfully finished. The target authentication flow is the one where this
|
||||
* authenticator is configured. Authenticator should finish successfully in the flow (or being evaluated to true in case of Conditional Authenticator)
|
||||
* in order to trigger this callback at the successful end of the flow
|
||||
*
|
||||
* @param context which encapsulate various useful data
|
||||
*/
|
||||
void onParentFlowSuccess(AuthenticationFlowContext context);
|
||||
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright 2021 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.authentication;
|
||||
|
||||
/**
|
||||
* Factory to create {@link AuthenticationFlowCallback} instances. Mostly used as marker interface.
|
||||
*
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public interface AuthenticationFlowCallbackFactory extends AuthenticatorFactory {
|
||||
}
|
|
@ -130,4 +130,12 @@ public final class Constants {
|
|||
public static final String CLIENT_PROFILES = "client-policies.profiles";
|
||||
public static final String CLIENT_POLICIES = "client-policies.policies";
|
||||
|
||||
|
||||
public static final String LEVEL_OF_AUTHENTICATION = "level-of-authentication";
|
||||
public static final String REQUESTED_LEVEL_OF_AUTHENTICATION = "requested-level-of-authentication";
|
||||
public static final String FORCE_LEVEL_OF_AUTHENTICATION = "force-level-of-authentication";
|
||||
public static final String ACR_LOA_MAP = "acr.loa.map";
|
||||
public static final int MINIMUM_LOA = 0;
|
||||
public static final int MAXIMUM_LOA = Integer.MAX_VALUE;
|
||||
public static final int NO_LOA = -1;
|
||||
}
|
||||
|
|
|
@ -200,7 +200,7 @@ class AuthenticationSelectionResolver {
|
|||
|
||||
// For conditional execution, we must check if condition is true. Otherwise return false, which means trying next
|
||||
// requiredExecution in the list
|
||||
return !flow.isConditionalSubflowDisabled(ex);
|
||||
return !flow.isConditionalSubflowDisabled(ex, false);
|
||||
|
||||
}).findFirst().orElse(null);
|
||||
|
||||
|
|
43
services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java
Executable file
43
services/src/main/java/org/keycloak/authentication/AuthenticatorUtil.java
Executable file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright 2021 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.authentication;
|
||||
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
|
||||
public class AuthenticatorUtil {
|
||||
|
||||
public static boolean isLevelOfAuthenticationForced(AuthenticationSessionModel authSession) {
|
||||
return Boolean.parseBoolean(authSession.getClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION));
|
||||
}
|
||||
|
||||
public static int getRequestedLevelOfAuthentication(AuthenticationSessionModel authSession) {
|
||||
String requiredLoa = authSession.getClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION);
|
||||
return requiredLoa == null ? Constants.NO_LOA : Integer.parseInt(requiredLoa);
|
||||
}
|
||||
|
||||
public static int getCurrentLevelOfAuthentication(AuthenticationSessionModel authSession) {
|
||||
String authSessionLoaNote = authSession.getAuthNote(Constants.LEVEL_OF_AUTHENTICATION);
|
||||
return authSessionLoaNote == null ? Constants.NO_LOA : Integer.parseInt(authSessionLoaNote);
|
||||
}
|
||||
|
||||
public static boolean isLevelOfAuthenticationSatisfied(AuthenticationSessionModel authSession) {
|
||||
return AuthenticatorUtil.getRequestedLevelOfAuthentication(authSession)
|
||||
<= AuthenticatorUtil.getCurrentLevelOfAuthentication(authSession);
|
||||
}
|
||||
}
|
|
@ -26,6 +26,7 @@ import org.keycloak.models.Constants;
|
|||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.services.ServicesLogger;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
import org.keycloak.sessions.CommonClientSessionModel;
|
||||
|
||||
import javax.ws.rs.HttpMethod;
|
||||
import javax.ws.rs.core.MultivaluedMap;
|
||||
|
@ -146,7 +147,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
checkAndValidateParentFlow(model);
|
||||
return processFlow();
|
||||
} else {
|
||||
processor.getAuthenticationSession().setExecutionStatus(model.getId(), AuthenticationSessionModel.ExecutionStatus.CHALLENGED);
|
||||
setExecutionStatus(model, AuthenticationSessionModel.ExecutionStatus.CHALLENGED);
|
||||
return flowChallenge;
|
||||
}
|
||||
}
|
||||
|
@ -169,6 +170,8 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
/**
|
||||
* Called after "actionExecutionModel" execution is finished (Either successful or attempted). Find the next appropriate authentication
|
||||
* flow where the authentication should continue and continue with authentication process.
|
||||
* The method recursively continues with the parent flow
|
||||
* until finally the top flow is processed.
|
||||
*
|
||||
* @param actionExecutionModel
|
||||
* @return Response if some more forms should be displayed during authentication. Null otherwise.
|
||||
|
@ -185,8 +188,8 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
} else {
|
||||
Response response = processSingleFlowExecutionModel(parentFlowExecution, false);
|
||||
if (response == null) {
|
||||
processor.getAuthenticationSession().removeAuthNote(AuthenticationProcessor.CURRENT_AUTHENTICATION_EXECUTION);
|
||||
return processFlow();
|
||||
// the parent flow is now the last action that has been executed, continue with that until the top flow is reached
|
||||
return continueAuthenticationAfterSuccessfulAction(parentFlowExecution);
|
||||
} else {
|
||||
return response;
|
||||
}
|
||||
|
@ -214,10 +217,10 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
requiredExecutions, alternativeExecutions);
|
||||
|
||||
// Note: If we evaluate alternative execution, we will also doublecheck that there are not required elements in same subflow
|
||||
if ((model.isRequired() && requiredExecutions.stream().allMatch(processor::isSuccessful)) ||
|
||||
if (((model.isRequired() || model.isConditional()) && requiredExecutions.stream().allMatch(processor::isSuccessful)) ||
|
||||
(model.isAlternative() && alternativeExecutions.stream().anyMatch(processor::isSuccessful) && requiredExecutions.isEmpty())) {
|
||||
logger.debugf("Flow '%s' successfully finished after children executions success", logExecutionAlias(parentFlowExecutionModel));
|
||||
processor.getAuthenticationSession().setExecutionStatus(parentFlowExecutionModel.getId(), AuthenticationSessionModel.ExecutionStatus.SUCCESS);
|
||||
setExecutionStatus(parentFlowExecutionModel, AuthenticationSessionModel.ExecutionStatus.SUCCESS);
|
||||
|
||||
// Flow is successfully finished. Recursively check whether it's parent flow is now successful as well
|
||||
model = parentFlowExecutionModel;
|
||||
|
@ -246,7 +249,8 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
while (requiredIListIterator.hasNext()) {
|
||||
AuthenticationExecutionModel required = requiredIListIterator.next();
|
||||
//Conditional flows must be considered disabled (non-existent) if their condition evaluates to false.
|
||||
if (required.isConditional() && isConditionalSubflowDisabled(required)) {
|
||||
//If the flow has been processed before it will not be removed to consider its execution status.
|
||||
if (required.isConditional() && !isProcessed(required) && isConditionalSubflowDisabled(required, true)) {
|
||||
requiredIListIterator.remove();
|
||||
continue;
|
||||
}
|
||||
|
@ -284,7 +288,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
} catch (AuthenticationFlowException afe) {
|
||||
//consuming the error is not good here from an administrative point of view, but the user, since he has alternatives, should be able to go to another alternative and continue
|
||||
afeList.add(afe);
|
||||
processor.getAuthenticationSession().setExecutionStatus(alternative.getId(), AuthenticationSessionModel.ExecutionStatus.ATTEMPTED);
|
||||
setExecutionStatus(alternative, AuthenticationSessionModel.ExecutionStatus.ATTEMPTED);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -322,9 +326,10 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
/**
|
||||
* Checks if the conditional subflow passed in parameter is disabled.
|
||||
* @param model
|
||||
* @param storeResult whether to store the result of the conditional evaluations
|
||||
* @return
|
||||
*/
|
||||
boolean isConditionalSubflowDisabled(AuthenticationExecutionModel model) {
|
||||
boolean isConditionalSubflowDisabled(AuthenticationExecutionModel model, boolean storeResult) {
|
||||
if (model == null || !model.isAuthenticatorFlow() || !model.isConditional()) {
|
||||
return false;
|
||||
};
|
||||
|
@ -335,7 +340,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
.filter(s -> s.isEnabled())
|
||||
.collect(Collectors.toList());
|
||||
return conditionalAuthenticatorList.isEmpty() || conditionalAuthenticatorList.stream()
|
||||
.anyMatch(m -> conditionalNotMatched(m, modelList));
|
||||
.anyMatch(m -> conditionalNotMatched(m, modelList, storeResult));
|
||||
}
|
||||
|
||||
private boolean isConditionalAuthenticator(AuthenticationExecutionModel model) {
|
||||
|
@ -350,7 +355,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
return factory;
|
||||
}
|
||||
|
||||
private boolean conditionalNotMatched(AuthenticationExecutionModel model, List<AuthenticationExecutionModel> executionList) {
|
||||
private boolean conditionalNotMatched(AuthenticationExecutionModel model, List<AuthenticationExecutionModel> executionList, boolean storeResult) {
|
||||
AuthenticatorFactory factory = getAuthenticatorFactory(model);
|
||||
ConditionalAuthenticator authenticator = (ConditionalAuthenticator) createAuthenticator(factory);
|
||||
AuthenticationProcessor.Result context = processor.createAuthenticatorContext(model, authenticator, executionList);
|
||||
|
@ -364,9 +369,11 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
matchCondition = false;
|
||||
} else {
|
||||
matchCondition = authenticator.matchCondition(context);
|
||||
processor.getAuthenticationSession().setExecutionStatus(model.getId(),
|
||||
if (storeResult) {
|
||||
setExecutionStatus(model,
|
||||
matchCondition ? AuthenticationSessionModel.ExecutionStatus.EVALUATED_TRUE : AuthenticationSessionModel.ExecutionStatus.EVALUATED_FALSE);
|
||||
}
|
||||
}
|
||||
|
||||
return !matchCondition;
|
||||
}
|
||||
|
@ -390,14 +397,14 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
if (flowChallenge == null) {
|
||||
if (authenticationFlow.isSuccessful()) {
|
||||
logger.debugf("Flow '%s' successfully finished", logExecutionAlias(model));
|
||||
processor.getAuthenticationSession().setExecutionStatus(model.getId(), AuthenticationSessionModel.ExecutionStatus.SUCCESS);
|
||||
setExecutionStatus(model, AuthenticationSessionModel.ExecutionStatus.SUCCESS);
|
||||
} else {
|
||||
logger.debugf("Flow '%s' failed", logExecutionAlias(model));
|
||||
processor.getAuthenticationSession().setExecutionStatus(model.getId(), AuthenticationSessionModel.ExecutionStatus.FAILED);
|
||||
setExecutionStatus(model, AuthenticationSessionModel.ExecutionStatus.FAILED);
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
processor.getAuthenticationSession().setExecutionStatus(model.getId(), AuthenticationSessionModel.ExecutionStatus.CHALLENGED);
|
||||
setExecutionStatus(model, AuthenticationSessionModel.ExecutionStatus.CHALLENGED);
|
||||
return flowChallenge;
|
||||
}
|
||||
}
|
||||
|
@ -434,7 +441,7 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
if (factory.isUserSetupAllowed() && model.isRequired() && authenticator.areRequiredActionsEnabled(processor.getSession(), processor.getRealm())) {
|
||||
//This means that having even though the user didn't validate the
|
||||
logger.debugv("authenticator SETUP_REQUIRED: {0}", factory.getId());
|
||||
processor.getAuthenticationSession().setExecutionStatus(model.getId(), AuthenticationSessionModel.ExecutionStatus.SETUP_REQUIRED);
|
||||
setExecutionStatus(model, AuthenticationSessionModel.ExecutionStatus.SETUP_REQUIRED);
|
||||
authenticator.setRequiredActions(processor.getSession(), processor.getRealm(), processor.getAuthenticationSession().getAuthenticatedUser());
|
||||
return null;
|
||||
} else {
|
||||
|
@ -485,12 +492,12 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
switch (status) {
|
||||
case SUCCESS:
|
||||
logger.debugv("authenticator SUCCESS: {0}", execution.getAuthenticator());
|
||||
processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.SUCCESS);
|
||||
setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.SUCCESS);
|
||||
return null;
|
||||
case FAILED:
|
||||
logger.debugv("authenticator FAILED: {0}", execution.getAuthenticator());
|
||||
processor.logFailure();
|
||||
processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.FAILED);
|
||||
setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.FAILED);
|
||||
if (result.getChallenge() != null) {
|
||||
return sendChallenge(result, execution);
|
||||
}
|
||||
|
@ -501,16 +508,16 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
throw new ForkFlowException(result.getSuccessMessage(), result.getErrorMessage());
|
||||
case FORCE_CHALLENGE:
|
||||
case CHALLENGE:
|
||||
processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.CHALLENGED);
|
||||
setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.CHALLENGED);
|
||||
return sendChallenge(result, execution);
|
||||
case FAILURE_CHALLENGE:
|
||||
logger.debugv("authenticator FAILURE_CHALLENGE: {0}", execution.getAuthenticator());
|
||||
processor.logFailure();
|
||||
processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.CHALLENGED);
|
||||
setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.CHALLENGED);
|
||||
return sendChallenge(result, execution);
|
||||
case ATTEMPTED:
|
||||
logger.debugv("authenticator ATTEMPTED: {0}", execution.getAuthenticator());
|
||||
processor.getAuthenticationSession().setExecutionStatus(execution.getId(), AuthenticationSessionModel.ExecutionStatus.ATTEMPTED);
|
||||
setExecutionStatus(execution, AuthenticationSessionModel.ExecutionStatus.ATTEMPTED);
|
||||
return null;
|
||||
case FLOW_RESET:
|
||||
processor.resetFlow();
|
||||
|
@ -536,4 +543,28 @@ public class DefaultAuthenticationFlow implements AuthenticationFlow {
|
|||
public List<AuthenticationFlowException> getFlowExceptions(){
|
||||
return afeList;
|
||||
}
|
||||
|
||||
private void setExecutionStatus(AuthenticationExecutionModel authExecutionModel, CommonClientSessionModel.ExecutionStatus status) {
|
||||
this.processor.getAuthenticationSession().setExecutionStatus(authExecutionModel.getId(), status);
|
||||
|
||||
if (authExecutionModel.isAuthenticatorFlow() && status == CommonClientSessionModel.ExecutionStatus.SUCCESS) {
|
||||
// Trigger callbacks after flow was successfully finished
|
||||
processor.getRealm().getAuthenticationExecutionsStream(authExecutionModel.getFlowId()).forEach(this::checkAuthCallback);
|
||||
}
|
||||
}
|
||||
|
||||
private void checkAuthCallback(AuthenticationExecutionModel execution) {
|
||||
// We will trigger the callback just if particular authenticator, which corresponds to this callback, was finished with SUCCESS or condition was evaluated to true
|
||||
CommonClientSessionModel.ExecutionStatus executionStatus = processor.getAuthenticationSession().getExecutionStatus().get(execution.getId());
|
||||
if (executionStatus == CommonClientSessionModel.ExecutionStatus.SUCCESS || executionStatus == CommonClientSessionModel.ExecutionStatus.EVALUATED_TRUE) {
|
||||
if (!execution.isAuthenticatorFlow()) {
|
||||
AuthenticatorFactory authFactory = getAuthenticatorFactory(execution);
|
||||
if (authFactory instanceof AuthenticationFlowCallbackFactory) {
|
||||
AuthenticationFlowCallback authCallback = (AuthenticationFlowCallback) createAuthenticator(authFactory);
|
||||
logger.tracef("Will trigger callback '%s' after successful finish of the flow '%s'", authFactory.getId(), execution.getParentFlow());
|
||||
authCallback.onParentFlowSuccess(processor.createAuthenticatorContext(execution, authCallback, null)); // no need to have executions filled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,8 @@ package org.keycloak.authentication.authenticators.browser;
|
|||
|
||||
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||
import org.keycloak.authentication.Authenticator;
|
||||
import org.keycloak.authentication.AuthenticatorUtil;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
|
@ -44,16 +46,24 @@ public class CookieAuthenticator implements Authenticator {
|
|||
if (authResult == null) {
|
||||
context.attempted();
|
||||
} else {
|
||||
AuthenticationSessionModel clientSession = context.getAuthenticationSession();
|
||||
LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, clientSession.getProtocol());
|
||||
AuthenticationSessionModel authSession = context.getAuthenticationSession();
|
||||
LoginProtocol protocol = context.getSession().getProvider(LoginProtocol.class, authSession.getProtocol());
|
||||
authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, authResult.getSession().getNote(Constants.LEVEL_OF_AUTHENTICATION));
|
||||
context.setUser(authResult.getUser());
|
||||
|
||||
// Cookie re-authentication is skipped if re-authentication is required
|
||||
if (protocol.requireReauthentication(authResult.getSession(), clientSession)) {
|
||||
if (protocol.requireReauthentication(authResult.getSession(), authSession)) {
|
||||
// Full re-authentication, so we start with no loa
|
||||
authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(Constants.NO_LOA));
|
||||
context.attempted();
|
||||
} else if (!AuthenticatorUtil.isLevelOfAuthenticationSatisfied(authSession)) {
|
||||
// Step-up authentication, we keep the loa from the existing user session.
|
||||
// The cookie alone is not enough and other authentications must follow.
|
||||
context.attempted();
|
||||
} else {
|
||||
context.getAuthenticationSession().setAuthNote(AuthenticationManager.SSO_AUTH, "true");
|
||||
|
||||
context.setUser(authResult.getUser());
|
||||
// Cookie only authentication, no loa is returned
|
||||
authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(Constants.NO_LOA));
|
||||
authSession.setAuthNote(AuthenticationManager.SSO_AUTH, "true");
|
||||
context.attachUserSession(authResult.getSession());
|
||||
context.success();
|
||||
}
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* Copyright 2021 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.authentication.authenticators.conditional;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.authentication.AuthenticationFlowCallback;
|
||||
import org.keycloak.authentication.AuthenticationFlowContext;
|
||||
import org.keycloak.authentication.AuthenticatorUtil;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
|
||||
public class ConditionalLoaAuthenticator implements ConditionalAuthenticator, AuthenticationFlowCallback {
|
||||
|
||||
public static final String LEVEL = "loa-condition-level";
|
||||
public static final String STORE_IN_USER_SESSION = "loa-store-in-user-session";
|
||||
|
||||
private static final Logger logger = Logger.getLogger(ConditionalLoaAuthenticator.class);
|
||||
|
||||
@Override
|
||||
public boolean matchCondition(AuthenticationFlowContext context) {
|
||||
AuthenticationSessionModel authSession = context.getAuthenticationSession();
|
||||
int currentLoa = AuthenticatorUtil.getCurrentLevelOfAuthentication(authSession);
|
||||
int requestedLoa = AuthenticatorUtil.getRequestedLevelOfAuthentication(authSession);
|
||||
Integer configuredLoa = getConfiguredLoa(context);
|
||||
return (currentLoa < Constants.MINIMUM_LOA && requestedLoa < Constants.MINIMUM_LOA)
|
||||
|| ((configuredLoa == null || currentLoa < configuredLoa) && currentLoa < requestedLoa);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onParentFlowSuccess(AuthenticationFlowContext context) {
|
||||
AuthenticationSessionModel authSession = context.getAuthenticationSession();
|
||||
Integer newLoa = getConfiguredLoa(context);
|
||||
if (newLoa == null) {
|
||||
return;
|
||||
}
|
||||
logger.tracef("Updating LoA to '%d' when authenticating session '%s'", newLoa, authSession.getParentSession().getId());
|
||||
authSession.setAuthNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(newLoa));
|
||||
if (isStoreInUserSession(context)) {
|
||||
authSession.setUserSessionNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(newLoa));
|
||||
}
|
||||
}
|
||||
|
||||
private Integer getConfiguredLoa(AuthenticationFlowContext context) {
|
||||
try {
|
||||
return Integer.parseInt(context.getAuthenticatorConfig().getConfig().get(LEVEL));
|
||||
} catch (NullPointerException | NumberFormatException e) {
|
||||
logger.errorv("Invalid configuration: {0}", LEVEL);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isStoreInUserSession(AuthenticationFlowContext context) {
|
||||
try {
|
||||
return Boolean.parseBoolean(context.getAuthenticatorConfig().getConfig().get(STORE_IN_USER_SESSION));
|
||||
} catch (NullPointerException | NumberFormatException e) {
|
||||
logger.errorv("Invalid configuration: {0}", STORE_IN_USER_SESSION);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void action(AuthenticationFlowContext context) { }
|
||||
|
||||
@Override
|
||||
public boolean requiresUser() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { }
|
||||
|
||||
@Override
|
||||
public void close() { }
|
||||
}
|
|
@ -0,0 +1,106 @@
|
|||
/*
|
||||
* Copyright 2021 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.authentication.authenticators.conditional;
|
||||
|
||||
import java.util.List;
|
||||
import org.keycloak.Config;
|
||||
import org.keycloak.authentication.AuthenticationFlowCallbackFactory;
|
||||
import org.keycloak.models.AuthenticationExecutionModel;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.provider.ProviderConfigProperty;
|
||||
import org.keycloak.provider.ProviderConfigurationBuilder;
|
||||
|
||||
public class ConditionalLoaAuthenticatorFactory implements ConditionalAuthenticatorFactory, AuthenticationFlowCallbackFactory {
|
||||
|
||||
public static final String PROVIDER_ID = "conditional-level-of-authentication";
|
||||
private static final ConditionalLoaAuthenticator SINGLETON = new ConditionalLoaAuthenticator();
|
||||
private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = new AuthenticationExecutionModel.Requirement[]{
|
||||
AuthenticationExecutionModel.Requirement.REQUIRED,
|
||||
AuthenticationExecutionModel.Requirement.DISABLED
|
||||
};
|
||||
|
||||
private static final List<ProviderConfigProperty> CONFIG = ProviderConfigurationBuilder.create()
|
||||
.property()
|
||||
.name(ConditionalLoaAuthenticator.LEVEL)
|
||||
.label(ConditionalLoaAuthenticator.LEVEL)
|
||||
.helpText(ConditionalLoaAuthenticator.LEVEL + ".tooltip")
|
||||
.type(ProviderConfigProperty.STRING_TYPE)
|
||||
.add()
|
||||
.property()
|
||||
.name(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION)
|
||||
.label(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION)
|
||||
.helpText(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION + ".tooltip")
|
||||
.type(ProviderConfigProperty.BOOLEAN_TYPE)
|
||||
.defaultValue("true")
|
||||
.add()
|
||||
.build();
|
||||
|
||||
@Override
|
||||
public void init(Config.Scope config) { }
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) { }
|
||||
|
||||
@Override
|
||||
public void close() { }
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return PROVIDER_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getDisplayType() {
|
||||
return "Condition - Level of Authentication";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getReferenceCategory() {
|
||||
return "condition";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isConfigurable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
|
||||
return REQUIREMENT_CHOICES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isUserSetupAllowed() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getHelpText() {
|
||||
return "Flow is executed only if the configured LOA or a higher one has been requested but not yet satisfied. After the flow is successfully finished, the LOA in the session will be updated to value prescribed by this condition.";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ProviderConfigProperty> getConfigProperties() {
|
||||
return CONFIG;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConditionalAuthenticator getSingleton() {
|
||||
return SINGLETON;
|
||||
}
|
||||
}
|
|
@ -24,6 +24,7 @@ import org.keycloak.OAuth2Constants;
|
|||
import org.keycloak.OAuthErrorException;
|
||||
import org.keycloak.TokenCategory;
|
||||
import org.keycloak.TokenVerifier;
|
||||
import org.keycloak.authentication.AuthenticatorUtil;
|
||||
import org.keycloak.broker.oidc.OIDCIdentityProvider;
|
||||
import org.keycloak.broker.provider.IdentityBrokerException;
|
||||
import org.keycloak.cluster.ClusterProvider;
|
||||
|
@ -43,6 +44,7 @@ import org.keycloak.models.AuthenticatedClientSessionModel;
|
|||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.ClientScopeModel;
|
||||
import org.keycloak.models.ClientSessionContext;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.RoleModel;
|
||||
|
@ -57,6 +59,7 @@ import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenMapper;
|
|||
import org.keycloak.protocol.oidc.mappers.OIDCAccessTokenResponseMapper;
|
||||
import org.keycloak.protocol.oidc.mappers.OIDCIDTokenMapper;
|
||||
import org.keycloak.protocol.oidc.mappers.UserInfoTokenMapper;
|
||||
import org.keycloak.protocol.oidc.utils.AcrUtils;
|
||||
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.representations.AccessTokenResponse;
|
||||
|
@ -555,6 +558,7 @@ public class TokenManager {
|
|||
userSession.setNote(entry.getKey(), entry.getValue());
|
||||
}
|
||||
|
||||
clientSession.setNote(Constants.LEVEL_OF_AUTHENTICATION, String.valueOf(AuthenticatorUtil.getCurrentLevelOfAuthentication(authSession)));
|
||||
clientSession.setTimestamp(Time.currentTime());
|
||||
|
||||
// Remove authentication session now
|
||||
|
@ -832,10 +836,7 @@ public class TokenManager {
|
|||
token.setNonce(clientSessionCtx.getAttribute(OIDCLoginProtocol.NONCE_PARAM, String.class));
|
||||
token.setScope(clientSessionCtx.getScopeString());
|
||||
|
||||
// Best effort for "acr" value. Use 0 if clientSession was authenticated through cookie ( SSO )
|
||||
// TODO: Add better acr support. See KEYCLOAK-3314
|
||||
String acr = (AuthenticationManager.isSSOAuthentication(clientSession)) ? "0" : "1";
|
||||
token.setAcr(acr);
|
||||
token.setAcr(getAcr(clientSession));
|
||||
|
||||
String authTime = session.getNote(AuthenticationManager.AUTH_TIME);
|
||||
if (authTime != null) {
|
||||
|
@ -852,6 +853,29 @@ public class TokenManager {
|
|||
return token;
|
||||
}
|
||||
|
||||
private String getAcr(AuthenticatedClientSessionModel clientSession) {
|
||||
int loa = Integer.parseInt(clientSession.getNote(Constants.LEVEL_OF_AUTHENTICATION));
|
||||
if (loa < Constants.MINIMUM_LOA) {
|
||||
loa = AuthenticationManager.isSSOAuthentication(clientSession) ? 0 : 1;
|
||||
}
|
||||
|
||||
Map<String, Integer> acrLoaMap = AcrUtils.getAcrLoaMap(clientSession.getClient());
|
||||
String acr = AcrUtils.mapLoaToAcr(loa, acrLoaMap, AcrUtils.getRequiredAcrValues(
|
||||
clientSession.getNote(OIDCLoginProtocol.CLAIMS_PARAM)));
|
||||
if (acr == null) {
|
||||
acr = AcrUtils.mapLoaToAcr(loa, acrLoaMap, AcrUtils.getAcrValues(
|
||||
clientSession.getNote(OIDCLoginProtocol.CLAIMS_PARAM),
|
||||
clientSession.getNote(OIDCLoginProtocol.ACR_PARAM)));
|
||||
if (acr == null) {
|
||||
acr = AcrUtils.mapLoaToAcr(loa, acrLoaMap, acrLoaMap.keySet());
|
||||
if (acr == null) {
|
||||
acr = String.valueOf(loa);
|
||||
}
|
||||
}
|
||||
}
|
||||
return acr;
|
||||
}
|
||||
|
||||
private int getTokenExpiration(RealmModel realm, ClientModel client, UserSessionModel userSession,
|
||||
AuthenticatedClientSessionModel clientSession, boolean offlineTokenRequested) {
|
||||
boolean implicitFlow = false;
|
||||
|
|
|
@ -35,6 +35,7 @@ import org.keycloak.protocol.AuthorizationEndpointBase;
|
|||
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
|
||||
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequest;
|
||||
import org.keycloak.protocol.oidc.endpoints.request.AuthorizationEndpointRequestParserProcessor;
|
||||
import org.keycloak.protocol.oidc.utils.AcrUtils;
|
||||
import org.keycloak.protocol.oidc.grants.device.endpoints.DeviceEndpoint;
|
||||
import org.keycloak.protocol.oidc.utils.OIDCRedirectUriBuilder;
|
||||
import org.keycloak.protocol.oidc.utils.OIDCResponseMode;
|
||||
|
@ -57,6 +58,11 @@ import javax.ws.rs.core.MediaType;
|
|||
import javax.ws.rs.core.MultivaluedMap;
|
||||
import javax.ws.rs.core.Response;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
|
@ -286,6 +292,26 @@ public class AuthorizationEndpoint extends AuthorizationEndpointBase {
|
|||
if (request.getCodeChallenge() != null) authenticationSession.setClientNote(OIDCLoginProtocol.CODE_CHALLENGE_PARAM, request.getCodeChallenge());
|
||||
if (request.getCodeChallengeMethod() != null) authenticationSession.setClientNote(OIDCLoginProtocol.CODE_CHALLENGE_METHOD_PARAM, request.getCodeChallengeMethod());
|
||||
|
||||
Map<String, Integer> acrLoaMap = AcrUtils.getAcrLoaMap(authenticationSession.getClient());
|
||||
List<String> acrValues = AcrUtils.getRequiredAcrValues(request.getClaims());
|
||||
|
||||
if (acrValues.isEmpty()) {
|
||||
acrValues = AcrUtils.getAcrValues(request.getClaims(), request.getAcr());
|
||||
} else {
|
||||
authenticationSession.setClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION, "true");
|
||||
}
|
||||
|
||||
acrValues.stream().mapToInt(acr -> {
|
||||
try {
|
||||
Integer loa = acrLoaMap.get(acr);
|
||||
return loa == null ? Integer.parseInt(acr) : loa;
|
||||
} catch (NumberFormatException e) {
|
||||
// this is an unknown acr, we assume in case of an essential claim a very high LoA, and a minimum LoA if not essential
|
||||
return Boolean.parseBoolean(authenticationSession.getClientNote(Constants.FORCE_LEVEL_OF_AUTHENTICATION))
|
||||
? Constants.MAXIMUM_LOA : Constants.MINIMUM_LOA;
|
||||
}
|
||||
}).min().ifPresent(loa -> authenticationSession.setClientNote(Constants.REQUESTED_LEVEL_OF_AUTHENTICATION, String.valueOf(loa)));
|
||||
|
||||
if (request.getAdditionalReqParams() != null) {
|
||||
for (String paramName : request.getAdditionalReqParams().keySet()) {
|
||||
authenticationSession.setClientNote(LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + paramName, request.getAdditionalReqParams().get(paramName));
|
||||
|
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* Copyright 2021 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.protocol.oidc.utils;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.representations.ClaimsRepresentation;
|
||||
import org.keycloak.representations.IDToken;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
public class AcrUtils {
|
||||
|
||||
private static final Logger LOGGER = Logger.getLogger(AcrUtils.class);
|
||||
|
||||
public static List<String> getRequiredAcrValues(String claimsParam) {
|
||||
return getAcrValues(claimsParam, null, false);
|
||||
}
|
||||
|
||||
public static List<String> getAcrValues(String claimsParam, String acrValuesParam) {
|
||||
return getAcrValues(claimsParam, acrValuesParam, true);
|
||||
}
|
||||
|
||||
private static List<String> getAcrValues(String claimsParam, String acrValuesParam, boolean notEssential) {
|
||||
List<String> acrValues = new ArrayList<>();
|
||||
if (acrValuesParam != null && notEssential) {
|
||||
acrValues.addAll(Arrays.asList(acrValuesParam.split(" ")));
|
||||
}
|
||||
if (claimsParam != null) {
|
||||
try {
|
||||
ClaimsRepresentation claims = JsonSerialization.readValue(claimsParam, ClaimsRepresentation.class);
|
||||
ClaimsRepresentation.ClaimValue<String> acrClaim = claims.getClaimValue(IDToken.ACR, ClaimsRepresentation.ClaimContext.ID_TOKEN, String.class);
|
||||
if (acrClaim != null) {
|
||||
if (notEssential || acrClaim.isEssential()) {
|
||||
if (acrClaim.getValues() != null) {
|
||||
acrValues.addAll(acrClaim.getValues());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.warn("Invalid claims parameter", e);
|
||||
}
|
||||
}
|
||||
return acrValues;
|
||||
}
|
||||
|
||||
public static Map<String, Integer> getAcrLoaMap(ClientModel client) {
|
||||
String map = client.getAttribute(Constants.ACR_LOA_MAP);
|
||||
if (map == null || map.isEmpty()) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
try {
|
||||
return JsonSerialization.readValue(map, new TypeReference<Map<String, Integer>>() {});
|
||||
} catch (IOException e) {
|
||||
LOGGER.warn("Invalid client configuration (ACR-LOA map)");
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
}
|
||||
|
||||
public static String mapLoaToAcr(int loa, Map<String, Integer> acrLoaMap, Collection<String> acrValues) {
|
||||
String acr = null;
|
||||
if (!acrLoaMap.isEmpty() && !acrValues.isEmpty()) {
|
||||
int maxLoa = 0;
|
||||
for (String acrValue : acrValues) {
|
||||
Integer mappedLoa = acrLoaMap.get(acrValue);
|
||||
// if there is no mapping for the acrValue, it may be an integer itself
|
||||
if (mappedLoa == null) {
|
||||
try {
|
||||
mappedLoa = Integer.parseInt(acrValue);
|
||||
} catch (NumberFormatException e) {
|
||||
// the acrValue cannot be mapped
|
||||
}
|
||||
}
|
||||
if (mappedLoa != null && mappedLoa > maxLoa && loa >= mappedLoa) {
|
||||
acr = acrValue;
|
||||
maxLoa = mappedLoa;
|
||||
}
|
||||
}
|
||||
}
|
||||
return acr;
|
||||
}
|
||||
}
|
|
@ -229,6 +229,8 @@ public class Messages {
|
|||
|
||||
public static final String IDENTITY_PROVIDER_LOGIN_FAILURE = "identityProviderLoginFailure";
|
||||
|
||||
public static final String INSUFFICIENT_LEVEL_OF_AUTHENTICATION = "insufficientLevelOfAuthentication";
|
||||
|
||||
public static final String FAILED_LOGOUT = "failedLogout";
|
||||
|
||||
public static final String CONSENT_DENIED="consentDenied";
|
||||
|
|
|
@ -25,6 +25,7 @@ org.keycloak.authentication.authenticators.browser.SpnegoAuthenticatorFactory
|
|||
org.keycloak.authentication.authenticators.browser.IdentityProviderAuthenticatorFactory
|
||||
org.keycloak.authentication.authenticators.conditional.ConditionalRoleAuthenticatorFactory
|
||||
org.keycloak.authentication.authenticators.conditional.ConditionalUserConfiguredAuthenticatorFactory
|
||||
org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory
|
||||
org.keycloak.authentication.authenticators.directgrant.ValidateOTP
|
||||
org.keycloak.authentication.authenticators.directgrant.ValidatePassword
|
||||
org.keycloak.authentication.authenticators.directgrant.ValidateUsername
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* Copyright 2021 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*
|
||||
*/
|
||||
|
||||
package org.keycloak.testsuite.pages;
|
||||
|
||||
import org.keycloak.testsuite.util.DroneUtils;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import org.openqa.selenium.support.FindBy;
|
||||
|
||||
/**
|
||||
* Page shown during processing of the PushButtonAuthenticator
|
||||
*
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public class PushTheButtonPage extends AbstractPage {
|
||||
|
||||
@FindBy(name = "submit1")
|
||||
private WebElement submitButton;
|
||||
|
||||
@Override
|
||||
public void open() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCurrent() {
|
||||
return DroneUtils.getCurrentDriver().getTitle().equals("PushTheButton")
|
||||
&& !driver.findElements(By.name("submit1")).isEmpty();
|
||||
}
|
||||
|
||||
public void submit() {
|
||||
submitButton.click();
|
||||
}
|
||||
}
|
|
@ -73,6 +73,7 @@ import org.keycloak.protocol.oidc.representations.OIDCConfigurationRepresentatio
|
|||
import org.keycloak.protocol.oidc.utils.OIDCResponseType;
|
||||
import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.representations.AuthorizationResponseToken;
|
||||
import org.keycloak.representations.ClaimsRepresentation;
|
||||
import org.keycloak.representations.IDToken;
|
||||
import org.keycloak.representations.JsonWebToken;
|
||||
import org.keycloak.representations.RefreshToken;
|
||||
|
@ -92,6 +93,7 @@ import java.io.IOException;
|
|||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyStore;
|
||||
|
@ -174,6 +176,8 @@ public class OAuthClient {
|
|||
|
||||
private String requestUri;
|
||||
|
||||
private String claims;
|
||||
|
||||
private Map<String, String> requestHeaders;
|
||||
|
||||
private Map<String, JSONWebKeySet> publicKeys = new HashMap<>();
|
||||
|
@ -256,6 +260,7 @@ public class OAuthClient {
|
|||
nonce = null;
|
||||
request = null;
|
||||
requestUri = null;
|
||||
claims = null;
|
||||
// https://tools.ietf.org/html/rfc7636#section-4
|
||||
codeVerifier = null;
|
||||
codeChallenge = null;
|
||||
|
@ -755,6 +760,9 @@ public class OAuthClient {
|
|||
if (request != null) {
|
||||
parameters.add(new BasicNameValuePair(OIDCLoginProtocol.REQUEST_PARAM, request));
|
||||
}
|
||||
if (claims != null) {
|
||||
parameters.add(new BasicNameValuePair(OIDCLoginProtocol.CLAIMS_PARAM, claims));
|
||||
}
|
||||
if (additionalParams != null) {
|
||||
for (Map.Entry<String, String> entry : additionalParams.entrySet()) {
|
||||
parameters.add(new BasicNameValuePair(entry.getKey(), entry.getValue()));
|
||||
|
@ -1121,6 +1129,9 @@ public class OAuthClient {
|
|||
if (requestUri != null) {
|
||||
parameters.add(new BasicNameValuePair(OIDCLoginProtocol.REQUEST_URI_PARAM, requestUri));
|
||||
}
|
||||
if (claims != null) {
|
||||
parameters.add(new BasicNameValuePair(OIDCLoginProtocol.CLAIMS_PARAM, claims));
|
||||
}
|
||||
if (codeChallenge != null) {
|
||||
parameters.add(new BasicNameValuePair(OAuth2Constants.CODE_CHALLENGE, codeChallenge));
|
||||
}
|
||||
|
@ -1403,6 +1414,9 @@ public class OAuthClient {
|
|||
if (requestUri != null) {
|
||||
b.queryParam(OIDCLoginProtocol.REQUEST_URI_PARAM, requestUri);
|
||||
}
|
||||
if (claims != null) {
|
||||
b.queryParam(OIDCLoginProtocol.CLAIMS_PARAM, claims);
|
||||
}
|
||||
// https://tools.ietf.org/html/rfc7636#section-4.3
|
||||
if (codeChallenge != null) {
|
||||
b.queryParam(OAuth2Constants.CODE_CHALLENGE, codeChallenge);
|
||||
|
@ -1623,6 +1637,15 @@ public class OAuthClient {
|
|||
return this;
|
||||
}
|
||||
|
||||
public OAuthClient claims(ClaimsRepresentation claims) {
|
||||
try {
|
||||
this.claims = URLEncoder.encode(JsonSerialization.writeValueAsString(claims), "UTF-8");
|
||||
} catch (IOException ioe) {
|
||||
throw new RuntimeException(ioe);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public String getRealm() {
|
||||
return realm;
|
||||
}
|
||||
|
|
|
@ -222,6 +222,9 @@ public class ProvidersTest extends AbstractAuthenticationTest {
|
|||
addProviderInfo(result, "allow-access-authenticator", "Allow access",
|
||||
"Authenticator will always successfully authenticate. Useful for example in the conditional flows to be used after satisfying the previous conditions");
|
||||
|
||||
addProviderInfo(result, "conditional-level-of-authentication", "Condition - Level of Authentication",
|
||||
"Flow is executed only if the configured LOA or a higher one has been requested but not yet satisfied. After the flow is successfully finished, the LOA in the session will be updated to value prescribed by this condition.");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,294 @@
|
|||
/*
|
||||
* Copyright 2021 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.keycloak.testsuite.forms;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import javax.ws.rs.core.UriBuilder;
|
||||
import org.jboss.arquillian.graphene.page.Page;
|
||||
import org.junit.After;
|
||||
import org.junit.Assert;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.keycloak.authentication.authenticators.browser.PasswordFormFactory;
|
||||
import org.keycloak.authentication.authenticators.browser.UsernameFormFactory;
|
||||
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticator;
|
||||
import org.keycloak.authentication.authenticators.conditional.ConditionalLoaAuthenticatorFactory;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.models.AuthenticationExecutionModel.Requirement;
|
||||
import org.keycloak.models.Constants;
|
||||
import org.keycloak.representations.ClaimsRepresentation;
|
||||
import org.keycloak.representations.IDToken;
|
||||
import org.keycloak.representations.idm.EventRepresentation;
|
||||
import org.keycloak.representations.idm.RealmRepresentation;
|
||||
import org.keycloak.testsuite.AbstractTestRealmKeycloakTest;
|
||||
import org.keycloak.testsuite.AssertEvents;
|
||||
import org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude;
|
||||
import org.keycloak.testsuite.authentication.PushButtonAuthenticatorFactory;
|
||||
import org.keycloak.testsuite.pages.ErrorPage;
|
||||
import org.keycloak.testsuite.pages.LoginUsernameOnlyPage;
|
||||
import org.keycloak.testsuite.pages.PasswordPage;
|
||||
import org.keycloak.testsuite.pages.PushTheButtonPage;
|
||||
import org.keycloak.testsuite.util.FlowUtil;
|
||||
import org.keycloak.util.JsonSerialization;
|
||||
|
||||
import static org.keycloak.testsuite.arquillian.annotation.AuthServerContainerExclude.AuthServer.REMOTE;
|
||||
|
||||
/**
|
||||
* Tests for Level Of Assurance conditions in authentication flow.
|
||||
*
|
||||
* @author <a href="mailto:sebastian.zoescher@prime-sign.com">Sebastian Zoescher</a>
|
||||
*/
|
||||
@AuthServerContainerExclude(REMOTE)
|
||||
public class LevelOfAssuranceFlowTest extends AbstractTestRealmKeycloakTest {
|
||||
|
||||
@Rule
|
||||
public AssertEvents events = new AssertEvents(this);
|
||||
|
||||
@Page
|
||||
protected LoginUsernameOnlyPage loginUsernameOnlyPage;
|
||||
|
||||
@Page
|
||||
protected PasswordPage passwordPage;
|
||||
|
||||
@Page
|
||||
protected PushTheButtonPage pushTheButtonPage;
|
||||
|
||||
@Page
|
||||
protected ErrorPage errorPage;
|
||||
|
||||
@Override
|
||||
public void configureTestRealm(RealmRepresentation testRealm) {
|
||||
try {
|
||||
Map<String, Integer> acrLoaMap = new HashMap<>();
|
||||
acrLoaMap.put("copper", 0);
|
||||
acrLoaMap.put("silver", 1);
|
||||
acrLoaMap.put("gold", 2);
|
||||
findTestApp(testRealm).setAttributes(Collections.singletonMap(Constants.ACR_LOA_MAP, JsonSerialization.writeValueAsString(acrLoaMap)));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
public void setupFlow() {
|
||||
final String newFlowAlias = "browser - Level of Authentication FLow";
|
||||
testingClient.server(TEST_REALM_NAME).run(session -> FlowUtil.inCurrentRealm(session).copyBrowserFlow(newFlowAlias));
|
||||
testingClient.server(TEST_REALM_NAME)
|
||||
.run(session -> FlowUtil.inCurrentRealm(session).selectFlow(newFlowAlias).inForms(forms -> forms.clear()
|
||||
// level 1 authentication
|
||||
.addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> {
|
||||
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
|
||||
config -> {
|
||||
config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "1");
|
||||
config.getConfig().put(ConditionalLoaAuthenticator.STORE_IN_USER_SESSION, "true");
|
||||
});
|
||||
|
||||
// username input for level 1
|
||||
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, UsernameFormFactory.PROVIDER_ID);
|
||||
})
|
||||
|
||||
// level 2 authentication
|
||||
.addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> {
|
||||
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
|
||||
config -> config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "2"));
|
||||
|
||||
// password required for level 2
|
||||
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, PasswordFormFactory.PROVIDER_ID);
|
||||
})
|
||||
|
||||
// level 3 authentication
|
||||
.addSubFlowExecution(Requirement.CONDITIONAL, subFlow -> {
|
||||
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, ConditionalLoaAuthenticatorFactory.PROVIDER_ID,
|
||||
config -> config.getConfig().put(ConditionalLoaAuthenticator.LEVEL, "3"));
|
||||
|
||||
// simply push button for level 3
|
||||
subFlow.addAuthenticatorExecution(Requirement.REQUIRED, PushButtonAuthenticatorFactory.PROVIDER_ID);
|
||||
})
|
||||
|
||||
).defineAsBrowserFlow());
|
||||
}
|
||||
|
||||
@After
|
||||
public void after() {
|
||||
BrowserFlowTest.revertFlows(testRealm(), "browser - Level of Authentication FLow");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loginWithoutAcr() {
|
||||
oauth.openLoginForm();
|
||||
// Authentication without specific LOA results in level 1 authentication
|
||||
authenticateWithUsername();
|
||||
assertLoggedInWithAcr("silver");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loginWithAcr1() {
|
||||
// username input for level 1
|
||||
openLoginFormWithAcrClaim(true, "silver");
|
||||
authenticateWithUsername();
|
||||
assertLoggedInWithAcr("silver");
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loginWithAcr2() {
|
||||
// username and password input for level 2
|
||||
openLoginFormWithAcrClaim(true, "gold");
|
||||
authenticateWithUsername();
|
||||
authenticateWithPassword();
|
||||
assertLoggedInWithAcr("gold");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void loginWithAcr3() {
|
||||
// username, password input and finally push button for level 3
|
||||
openLoginFormWithAcrClaim(true, "3");
|
||||
authenticateWithUsername();
|
||||
authenticateWithPassword();
|
||||
authenticateWithButton();
|
||||
// ACR 3 is returned because it was requested, although there is no mapping for it
|
||||
assertLoggedInWithAcr("3");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void stepupAuthentication() {
|
||||
// logging in to level 1
|
||||
openLoginFormWithAcrClaim(true, "silver");
|
||||
authenticateWithUsername();
|
||||
assertLoggedInWithAcr("silver");
|
||||
// doing step-up authentication to level 2
|
||||
openLoginFormWithAcrClaim(true, "gold");
|
||||
authenticateWithPassword();
|
||||
authenticateWithButton();
|
||||
assertLoggedInWithAcr("gold");
|
||||
// step-up to level 3 needs password authentication because level 2 is not stored in user session
|
||||
openLoginFormWithAcrClaim(true, "3");
|
||||
authenticateWithPassword();
|
||||
authenticateWithButton();
|
||||
assertLoggedInWithAcr("3");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reauthenticationWithNoAcr() {
|
||||
openLoginFormWithAcrClaim(true, "silver");
|
||||
authenticateWithUsername();
|
||||
assertLoggedInWithAcr("silver");
|
||||
oauth.openLoginForm();
|
||||
assertLoggedInWithAcr("0");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reauthenticationWithReachedAcr() {
|
||||
openLoginFormWithAcrClaim(true, "silver");
|
||||
authenticateWithUsername();
|
||||
assertLoggedInWithAcr("silver");
|
||||
openLoginFormWithAcrClaim(true, "silver");
|
||||
assertLoggedInWithAcr("0");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reauthenticationWithOptionalUnknownAcr() {
|
||||
openLoginFormWithAcrClaim(true, "silver");
|
||||
authenticateWithUsername();
|
||||
assertLoggedInWithAcr("silver");
|
||||
openLoginFormWithAcrClaim(false, "iron");
|
||||
assertLoggedInWithAcr("0");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void optionalClaimNotReachedSucceeds() {
|
||||
openLoginFormWithAcrClaim(false, "4");
|
||||
authenticateWithUsername();
|
||||
authenticateWithPassword();
|
||||
authenticateWithButton();
|
||||
// the reached loa is 3, but there is no mapping for it, and it was not explicitly
|
||||
// requested, so the highest known and reached ACR is returned
|
||||
assertLoggedInWithAcr("gold");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void optionalUnknownClaimSucceeds() {
|
||||
openLoginFormWithAcrClaim(false, "iron");
|
||||
authenticateWithUsername();
|
||||
assertLoggedInWithAcr("silver");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acrValuesQueryParameter() {
|
||||
driver.navigate().to(UriBuilder.fromUri(oauth.getLoginFormUrl())
|
||||
.queryParam("acr_values", "gold 3")
|
||||
.build().toString());
|
||||
authenticateWithUsername();
|
||||
authenticateWithPassword();
|
||||
assertLoggedInWithAcr("gold");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void multipleEssentialAcrValues() {
|
||||
openLoginFormWithAcrClaim(true, "gold", "3");
|
||||
authenticateWithUsername();
|
||||
authenticateWithPassword();
|
||||
assertLoggedInWithAcr("gold");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void multipleOptionalAcrValues() {
|
||||
openLoginFormWithAcrClaim(false, "gold", "3");
|
||||
authenticateWithUsername();
|
||||
authenticateWithPassword();
|
||||
assertLoggedInWithAcr("gold");
|
||||
}
|
||||
|
||||
private void openLoginFormWithAcrClaim(boolean essential, String... acrValues) {
|
||||
ClaimsRepresentation.ClaimValue<String> acrClaim = new ClaimsRepresentation.ClaimValue<>();
|
||||
acrClaim.setEssential(essential);
|
||||
acrClaim.setValues(Arrays.asList(acrValues));
|
||||
|
||||
ClaimsRepresentation claims = new ClaimsRepresentation();
|
||||
claims.setIdTokenClaims(Collections.singletonMap("acr", acrClaim));
|
||||
|
||||
oauth.claims(claims);
|
||||
oauth.openLoginForm();
|
||||
}
|
||||
|
||||
private void authenticateWithUsername() {
|
||||
loginUsernameOnlyPage.assertCurrent();
|
||||
loginUsernameOnlyPage.login("test-user@localhost");
|
||||
}
|
||||
|
||||
private void authenticateWithPassword() {
|
||||
passwordPage.assertCurrent();
|
||||
passwordPage.login("password");
|
||||
}
|
||||
|
||||
private void authenticateWithButton() {
|
||||
pushTheButtonPage.assertCurrent();
|
||||
pushTheButtonPage.submit();
|
||||
}
|
||||
|
||||
private void assertLoggedInWithAcr(String acr) {
|
||||
EventRepresentation loginEvent = events.expectLogin().detail(Details.USERNAME, "test-user@localhost").assertEvent();
|
||||
IDToken idToken = sendTokenRequestAndGetIDToken(loginEvent);
|
||||
Assert.assertEquals(acr, idToken.getAcr());
|
||||
}
|
||||
}
|
|
@ -1339,6 +1339,12 @@ otp-token-period=OTP Token Period
|
|||
otp-token-period.tooltip=How many seconds should an OTP token be valid? Defaults to 30 seconds.
|
||||
otp-supported-applications=Supported Applications
|
||||
otp-supported-applications.tooltip=Applications that are known to work with the current OTP policy
|
||||
loa-level=Level of Authentication
|
||||
loa-level.tooltip=Sets the Level of Authentication to the specified value.
|
||||
loa-store-in-user-session=Store LoA in user session
|
||||
loa-store-in-user-session.tooltip=Additionally stores the LoA in the user session. If true, it means that subsequent authentications in same browser will see this level and they won't repeat authenticating the user with this level. If this is false, it means that subsequent authentications won't see this level and hence they will repeat authentications for this level again. This is useful if some functionality in the application (e.g. send payment) always require authentication with the particular level.
|
||||
loa-condition-level=Level of Authentication (LoA)
|
||||
loa-condition-level.tooltip=The condition evaluates to true if user does not yet have this authentication level and this level is requested. This level of authentication will be set to the session after the subflow, where this condition is configured, is successfully finished.
|
||||
table-of-password-policies=Table of Password Policies
|
||||
add-policy.placeholder=Add policy...
|
||||
policy-type=Policy Type
|
||||
|
@ -1926,6 +1932,9 @@ pkce-code-challenge-method.tooltip=Choose which code challenge method for PKCE i
|
|||
use-idtoken-as-detached-signature=Use ID Token as a Detached Signature
|
||||
use-idtoken-as-detached-signature.tooltip=This makes ID token returned from Authorization Endpoint in OIDC Hybrid flow use as a detached signature defined in FAPI 1.0 Advanced Security Profile. Therefore, this ID token does not include an authenticated user's information.
|
||||
|
||||
acr-loa-map=ACR to LoA Mapping
|
||||
acr-loa-map.tooltip=Define which ACR (Authentication Context Class Reference) value is mapped to which LoA (Level of Authentication). The ACR can be any value, whereas the LoA must be numeric.
|
||||
|
||||
key-not-allowed-here=Key '{{character}}' is not allowed here.
|
||||
|
||||
# KEYCLOAK-10927 Implement LDAPv3 Password Modify Extended Operation
|
||||
|
|
|
@ -1469,6 +1469,12 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
|
|||
} else {
|
||||
$scope.client.requestUris = [];
|
||||
}
|
||||
|
||||
try {
|
||||
$scope.acrLoaMap = JSON.parse($scope.client.attributes["acr.loa.map"] || "{}");
|
||||
} catch (e) {
|
||||
$scope.acrLoaMap = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (!$scope.create) {
|
||||
|
@ -1606,6 +1612,24 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
|
|||
$scope.clientEdit.attributes['pkce.code.challenge.method'] = $scope.pkceCodeChallengeMethod;
|
||||
};
|
||||
|
||||
$scope.$watch('newAcr', function() {
|
||||
$scope.changed = isChanged();
|
||||
}, true);
|
||||
$scope.$watch('newLoa', function() {
|
||||
$scope.changed = isChanged();
|
||||
}, true);
|
||||
$scope.deleteAcrLoaMapping = function(acr) {
|
||||
delete $scope.acrLoaMap[acr];
|
||||
$scope.changed = true;
|
||||
}
|
||||
$scope.addAcrLoaMapping = function() {
|
||||
if ($scope.newLoa.match(/^[0-9]+$/)) {
|
||||
$scope.acrLoaMap[$scope.newAcr] = $scope.newLoa;
|
||||
$scope.newAcr = $scope.newLoa = "";
|
||||
$scope.changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
$scope.changeCibaBackchannelAuthRequestSigningAlg = function() {
|
||||
if ($scope.cibaBackchannelAuthRequestSigningAlg === 'any') {
|
||||
$scope.clientEdit.attributes['ciba.backchannel.auth.request.signing.alg'] = null;
|
||||
|
@ -1649,6 +1673,9 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
|
|||
if ($scope.newRequestUri && $scope.newRequestUri.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if ($scope.newAcr && $scope.newAcr.length > 0 && $scope.newLoa && $scope.newLoa.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -1811,6 +1838,11 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
|
|||
} else {
|
||||
$scope.clientEdit.attributes["saml.artifact.binding"] = "false";
|
||||
}
|
||||
|
||||
if ($scope.newAcr && $scope.newAcr.length > 0 && $scope.newLoa && $scope.newLoa.length > 0) {
|
||||
$scope.addAcrLoaMapping();
|
||||
}
|
||||
|
||||
if ($scope.samlServerSignature == true) {
|
||||
$scope.clientEdit.attributes["saml.server.signature"] = "true";
|
||||
} else {
|
||||
|
@ -1941,6 +1973,8 @@ module.controller('ClientDetailCtrl', function($scope, realm, client, flows, $ro
|
|||
$scope.clientEdit.attributes["backchannel.logout.revoke.offline.tokens"] = "false";
|
||||
}
|
||||
|
||||
$scope.clientEdit.attributes["acr.loa.map"] = JSON.stringify($scope.acrLoaMap);
|
||||
|
||||
$scope.clientEdit.protocol = $scope.protocol;
|
||||
$scope.clientEdit.attributes['saml.signature.algorithm'] = $scope.signatureAlgorithm;
|
||||
$scope.clientEdit.attributes['saml_name_id_format'] = $scope.nameIdFormat;
|
||||
|
|
|
@ -925,6 +925,26 @@
|
|||
<kc-tooltip>{{:: 'require-pushed-authorization-requests.tooltip' | translate}}</kc-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="form-group clearfix block" data-ng-show="(!clientEdit.bearerOnly && protocol == 'openid-connect') && (clientEdit.standardFlowEnabled || clientEdit.directAccessGrantsEnabled || clientEdit.implicitFlowEnabled)">
|
||||
<label class="col-md-2 control-label" for="newAcr">{{:: 'acr-loa-map' | translate}}</label>
|
||||
<div class="col-sm-6">
|
||||
<div class="input-group input-map" ng-repeat="(acr, loa) in acrLoaMap">
|
||||
<input class="form-control" readonly value="{{acr}}">
|
||||
<input class="form-control" ng-model="acrLoaMap[acr]">
|
||||
<div class="input-group-btn">
|
||||
<button class="btn btn-default" type="button" data-ng-click="deleteAcrLoaMapping(acr)"><span class="fa fa-minus"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group input-map">
|
||||
<input class="form-control" ng-model="newAcr" id="newAcr" placeholder="ACR">
|
||||
<input class="form-control" ng-model="newLoa" id="newLoa" placeholder="LOA">
|
||||
<div class="input-group-btn">
|
||||
<button class="btn btn-default" type="button" data-ng-click="newAcr.length > 0 && newLoa.length > 0 && addAcrLoaMapping()"><span class="fa fa-plus"></span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<kc-tooltip>{{:: 'acr-loa-map.tooltip' | translate}}</kc-tooltip>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
|
|
|
@ -314,6 +314,7 @@ invalidAccessCodeMessage=Invalid access code.
|
|||
sessionNotActiveMessage=Session not active.
|
||||
invalidCodeMessage=An error occurred, please login again through your application.
|
||||
cookieNotFoundMessage=Cookie not found. Please make sure cookies are enabled in your browser.
|
||||
insufficientLevelOfAuthentication=The requested level of authentication has not been satisfied.
|
||||
identityProviderUnexpectedErrorMessage=Unexpected error when authenticating with identity provider
|
||||
identityProviderMissingStateMessage=Missing state parameter in response from identity provider.
|
||||
identityProviderNotFoundMessage=Could not find an identity provider with the identifier.
|
||||
|
|
|
@ -427,6 +427,10 @@ table.kc-authz-table-expanded {
|
|||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-map input.form-control {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
/* Deactivation styles for user-group membership tree models */
|
||||
|
||||
div[tree-model] li .deactivate {
|
||||
|
|
|
@ -1,21 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="20px" height="20px" viewBox="0 0 20 20" style="enable-background:new 0 0 20 20;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{clip-path:url(#SVGID_2_);fill:#F05133;}
|
||||
</style>
|
||||
<g>
|
||||
<defs>
|
||||
<rect id="SVGID_1_" x="0" y="0" width="20" height="20"/>
|
||||
</defs>
|
||||
<clipPath id="SVGID_2_">
|
||||
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
|
||||
</clipPath>
|
||||
<path class="st0" d="M19.6,9.1l-8.7-8.7c-0.5-0.5-1.3-0.5-1.8,0L7.3,2.2l2.3,2.3c0.5-0.2,1.1-0.1,1.6,0.4c0.4,0.4,0.5,1,0.4,1.6
|
||||
l2.2,2.2c0.5-0.2,1.2-0.1,1.6,0.4c0.6,0.6,0.6,1.6,0,2.2c-0.6,0.6-1.6,0.6-2.2,0c-0.5-0.5-0.6-1.1-0.3-1.7l-2.1-2.1v5.4
|
||||
c0.1,0.1,0.3,0.2,0.4,0.3c0.6,0.6,0.6,1.6,0,2.2C10.5,16,9.6,16,9,15.4c-0.6-0.6-0.6-1.6,0-2.2c0.1-0.1,0.3-0.3,0.5-0.3V7.4
|
||||
C9.3,7.3,9.1,7.2,9,7C8.5,6.6,8.4,5.9,8.6,5.3L6.4,3.1l-6,6c-0.5,0.5-0.5,1.3,0,1.8l8.7,8.7c0.5,0.5,1.3,0.5,1.8,0l8.7-8.7
|
||||
C20.1,10.4,20.1,9.6,19.6,9.1"/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.1 KiB |
Loading…
Reference in a new issue