WebAuthn support for native applications. Support custom FIDO2 origin validation (#23156)

Closes #23155
This commit is contained in:
Charley Wu 2023-10-13 21:25:10 +08:00 committed by GitHub
parent 6074cbf311
commit 31759f9c37
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 145 additions and 20 deletions

View file

@ -138,6 +138,7 @@ public class RealmRepresentation {
protected Integer webAuthnPolicyCreateTimeout;
protected Boolean webAuthnPolicyAvoidSameAuthenticatorRegister;
protected List<String> webAuthnPolicyAcceptableAaguids;
protected List<String> webAuthnPolicyExtraOrigins;
// WebAuthn passwordless properties below
@ -151,6 +152,7 @@ public class RealmRepresentation {
protected Integer webAuthnPolicyPasswordlessCreateTimeout;
protected Boolean webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister;
protected List<String> webAuthnPolicyPasswordlessAcceptableAaguids;
protected List<String> webAuthnPolicyPasswordlessExtraOrigins;
// Client Policies/Profiles
@ -1127,6 +1129,14 @@ public class RealmRepresentation {
this.webAuthnPolicyAcceptableAaguids = webAuthnPolicyAcceptableAaguids;
}
public List<String> getWebAuthnPolicyExtraOrigins(){
return webAuthnPolicyExtraOrigins;
}
public void setWebAuthnPolicyExtraOrigins(List<String> extraOrigins) {
this.webAuthnPolicyExtraOrigins = extraOrigins;
}
// WebAuthn passwordless properties below
@ -1210,6 +1220,14 @@ public class RealmRepresentation {
this.webAuthnPolicyPasswordlessAcceptableAaguids = webAuthnPolicyPasswordlessAcceptableAaguids;
}
public List<String> getWebAuthnPolicyPasswordlessExtraOrigins(){
return webAuthnPolicyPasswordlessExtraOrigins;
}
public void setWebAuthnPolicyPasswordlessExtraOrigins(List<String> extraOrigins) {
this.webAuthnPolicyPasswordlessExtraOrigins = extraOrigins;
}
// Client Policies/Profiles
@JsonIgnore

View file

@ -2386,7 +2386,9 @@
"webAuthnPolicyCreateTimeoutHint": "Timeout needs to be between 0 seconds and 8 hours",
"webAuthnPolicyAvoidSameAuthenticatorRegister": "Avoid same authenticator registration",
"webAuthnPolicyAcceptableAaguids": "Acceptable AAGUIDs",
"webAuthnPolicyExtraOrigins": "Extra Origins",
"addAaguids": "Add AAGUID",
"addOrigins": "Add Origin",
"webAuthnUpdateSuccess": "Updated webauthn policies successfully",
"webAuthnUpdateError": "Could not update webauthn policies due to {{error}}",
"flowName": "Flow name",
@ -2496,6 +2498,7 @@
"webAuthnPolicyCreateTimeoutHelp": "Timeout value for creating user's public key credential in seconds. if set to 0, this timeout option is not adapted.",
"webAuthnPolicyAvoidSameAuthenticatorRegisterHelp": "Avoid registering the authenticator that has already been registered.",
"webAuthnPolicyAcceptableAaguidsHelp": "The list of AAGUID of which an authenticator can be registered.",
"webAuthnPolicyExtraOriginsHelp": "The list of extra origin for non-web application.",
"passwordPoliciesHelp": {
"forceExpiredPasswordChange": "The number of days the password is valid before a new password is required.",
"hashIterations": "The number of times a password is hashed before storage or verification. Default: 27,500.",

View file

@ -603,6 +603,8 @@
"webAuthnPolicyCreateTimeout": "タイムアウト",
"webAuthnPolicyAvoidSameAuthenticatorRegister": "オーセンティケーターの重複登録回避",
"webAuthnPolicyAcceptableAaguids": "許容可能なAAGUID",
"webAuthnPolicyExtraOrigins": "エクストラオリジンズ",
"addOrigins": "オリジンを追加",
"default": "DEFAULT",
"flow": {
"browser": "ブラウザーフロー",
@ -640,6 +642,7 @@
"webAuthnPolicyCreateTimeoutHelp": "ユーザーの公開鍵クレデンシャルの作成に対するタイムアウト値秒単位。0に設定すると、このタイムアウト・オプションは適応されません。",
"webAuthnPolicyAvoidSameAuthenticatorRegisterHelp": "すでに登録されているオーセンティケーターの登録を避けるかどうかを設定します。",
"webAuthnPolicyAcceptableAaguidsHelp": "登録可能なオーセンティケーターのAAGUIDのリスト。",
"webAuthnPolicyExtraOriginsHelp": "非 Web アプリケーションの追加オリジンのリスト。",
"unlinkUsers": "ユーザーのリンクを解除する",
"removeImported": "インポートを削除",
"vendor": "ベンダー",

View file

@ -2342,7 +2342,9 @@
"webAuthnPolicyCreateTimeoutHint": "超时时间需要在 0 秒到 8 小时之间",
"webAuthnPolicyAvoidSameAuthenticatorRegister": "避免相同的身份验证器注册",
"webAuthnPolicyAcceptableAaguids": "可接受的 AAGUID",
"webAuthnPolicyExtraOrigins": "额外的 Origin",
"addAaguids": "添加 AAGUID",
"addOrigins": "添加 Origin",
"webAuthnUpdateSuccess": "已成功更新 webauthn 策略",
"webAuthnUpdateError": "由于{{error}},无法更新 webauthn 策略",
"flowName": "流程名称",
@ -2451,6 +2453,7 @@
"webAuthnPolicyCreateTimeoutHelp": "以秒为单位创建用户公钥凭证的超时值。如果设置为 0则不适应此超时选项。",
"webAuthnPolicyAvoidSameAuthenticatorRegisterHelp": "避免注册已经被注册过的验证器。",
"webAuthnPolicyAcceptableAaguidsHelp": "AAGUID 列表,其中可以注册验证者。",
"webAuthnPolicyExtraOriginsHelp": "额外的 Origin 列表,用于非网络应用程序。",
"密码策略": {
"forceExpiredPasswordChange": "在需要新密码之前,当前密码的有效天数。",
"hashIterations": "密码在存储或验证之前被散列的次数。默认值27,500。",

View file

@ -349,6 +349,22 @@ export const WebauthnPolicy = ({
addButtonLabel="addAaguids"
/>
</FormGroup>
<FormGroup
label={t("webAuthnPolicyExtraOrigins")}
fieldId="webAuthnPolicyExtraOrigins"
labelIcon={
<HelpItem
helpText={t("webAuthnPolicyExtraOriginsHelp")}
fieldLabelId="webAuthnPolicyExtraOrigins"
/>
}
>
<MultiLineInput
name={`${namePrefix}ExtraOrigins`}
aria-label={t("webAuthnPolicyExtraOrigins")}
addButtonLabel="addOrigins"
/>
</FormGroup>
</FormProvider>
<ActionGroup>

View file

@ -961,6 +961,12 @@ public class RealmAdapter implements LegacyRealmModel, JpaModel<RealmEntity> {
acceptableAaguids = Arrays.asList(acceptableAaguidsString.split(","));
policy.setAcceptableAaguids(acceptableAaguids);
String extraOriginsString = getAttribute(RealmAttributes.WEBAUTHN_POLICY_EXTRA_ORIGINS + attributePrefix);
List<String> extraOrigins = new ArrayList<>();
if (extraOriginsString != null && !extraOriginsString.isEmpty())
extraOrigins = Arrays.asList(extraOriginsString.split(","));
policy.setExtraOrigins(extraOrigins);
return policy;
}
@ -1004,6 +1010,14 @@ public class RealmAdapter implements LegacyRealmModel, JpaModel<RealmEntity> {
} else {
removeAttribute(RealmAttributes.WEBAUTHN_POLICY_ACCEPTABLE_AAGUIDS + attributePrefix);
}
List<String> extraOrigins = policy.getExtraOrigins();
if (extraOrigins != null && !extraOrigins.isEmpty()) {
String extraOriginsString = String.join(",", extraOrigins);
setAttribute(RealmAttributes.WEBAUTHN_POLICY_EXTRA_ORIGINS + attributePrefix, extraOriginsString);
} else {
removeAttribute(RealmAttributes.WEBAUTHN_POLICY_EXTRA_ORIGINS + attributePrefix);
}
}
@Override

View file

@ -50,6 +50,7 @@ public interface RealmAttributes {
String WEBAUTHN_POLICY_CREATE_TIMEOUT = "webAuthnPolicyCreateTimeout";
String WEBAUTHN_POLICY_AVOID_SAME_AUTHENTICATOR_REGISTER = "webAuthnPolicyAvoidSameAuthenticatorRegister";
String WEBAUTHN_POLICY_ACCEPTABLE_AAGUIDS = "webAuthnPolicyAcceptableAaguids";
String WEBAUTHN_POLICY_EXTRA_ORIGINS = "webAuthnPolicyExtraOrigins";
String ADMIN_EVENTS_EXPIRATION = "adminEventsExpiration";

View file

@ -1216,6 +1216,9 @@ public class LegacyExportImportManager implements ExportImportManager {
List<String> webAuthnPolicyAcceptableAaguids = rep.getWebAuthnPolicyAcceptableAaguids();
if (webAuthnPolicyAcceptableAaguids != null) webAuthnPolicy.setAcceptableAaguids(webAuthnPolicyAcceptableAaguids);
List<String> webAuthnPolicyExtraOrigins = rep.getWebAuthnPolicyExtraOrigins();
if (webAuthnPolicyExtraOrigins != null) webAuthnPolicy.setExtraOrigins(webAuthnPolicyExtraOrigins);
return webAuthnPolicy;
}
@ -1268,6 +1271,9 @@ public class LegacyExportImportManager implements ExportImportManager {
List<String> webAuthnPolicyAcceptableAaguids = rep.getWebAuthnPolicyPasswordlessAcceptableAaguids();
if (webAuthnPolicyAcceptableAaguids != null) webAuthnPolicy.setAcceptableAaguids(webAuthnPolicyAcceptableAaguids);
List<String> webAuthnPolicyExtraOrigins = rep.getWebAuthnPolicyPasswordlessExtraOrigins();
if (webAuthnPolicyExtraOrigins != null) webAuthnPolicy.setExtraOrigins(webAuthnPolicyExtraOrigins);
return webAuthnPolicy;
}
public static Map<String, String> importAuthenticationFlows(KeycloakSession session, RealmModel newRealm, RealmRepresentation rep) {

View file

@ -30,6 +30,8 @@ public class HotRodWebAuthnPolicyEntity extends AbstractHotRodEntity {
public List<String> acceptableAaguids;
@ProtoField(number = 10)
public List<String> signatureAlgorithms;
@ProtoField(number = 11)
public List<String> extraOrigins;
@Override
public boolean equals(Object o) {
return HotRodWebAuthnPolicyEntityDelegate.entityEquals(this, o);

View file

@ -1419,6 +1419,9 @@ public class MapExportImportManager implements ExportImportManager {
List<String> webAuthnPolicyAcceptableAaguids = rep.getWebAuthnPolicyAcceptableAaguids();
if (webAuthnPolicyAcceptableAaguids != null) webAuthnPolicy.setAcceptableAaguids(webAuthnPolicyAcceptableAaguids);
List<String> webAuthnPolicyExtraOrigins = rep.getWebAuthnPolicyExtraOrigins();
if (webAuthnPolicyExtraOrigins != null) webAuthnPolicy.setExtraOrigins(webAuthnPolicyExtraOrigins);
return webAuthnPolicy;
}
@ -1471,6 +1474,9 @@ public class MapExportImportManager implements ExportImportManager {
List<String> webAuthnPolicyAcceptableAaguids = rep.getWebAuthnPolicyPasswordlessAcceptableAaguids();
if (webAuthnPolicyAcceptableAaguids != null) webAuthnPolicy.setAcceptableAaguids(webAuthnPolicyAcceptableAaguids);
List<String> webAuthnPolicyExtraOrigins = rep.getWebAuthnPolicyPasswordlessExtraOrigins();
if (webAuthnPolicyExtraOrigins != null) webAuthnPolicy.setExtraOrigins(webAuthnPolicyExtraOrigins);
return webAuthnPolicy;
}
public static Map<String, String> importAuthenticationFlows(KeycloakSession session, RealmModel newRealm, RealmRepresentation rep) {

View file

@ -43,6 +43,7 @@ public interface MapWebAuthnPolicyEntity extends UpdatableEntity {
entity.setCreateTimeout(model.getCreateTimeout());
entity.setAvoidSameAuthenticatorRegister(model.isAvoidSameAuthenticatorRegister());
entity.setAcceptableAaguids(model.getAcceptableAaguids());
entity.setExtraOrigins(model.getExtraOrigins());
return entity;
}
@ -60,6 +61,8 @@ public interface MapWebAuthnPolicyEntity extends UpdatableEntity {
model.setAvoidSameAuthenticatorRegister(entity.isAvoidSameAuthenticatorRegister());
List<String> acceptableAaguids = entity.getAcceptableAaguids();
model.setAcceptableAaguids(acceptableAaguids == null ? new LinkedList<>() : new LinkedList<>(acceptableAaguids));
List<String> extraOrigins = entity.getExtraOrigins();
model.setExtraOrigins(extraOrigins == null ? new LinkedList<>() : new LinkedList<>(extraOrigins));
return model;
}
@ -75,6 +78,7 @@ public interface MapWebAuthnPolicyEntity extends UpdatableEntity {
entity.setCreateTimeout(0);
entity.setAvoidSameAuthenticatorRegister(false);
entity.setAcceptableAaguids(new LinkedList<>());
entity.setExtraOrigins(new LinkedList<>());
return entity;
}
@ -107,4 +111,7 @@ public interface MapWebAuthnPolicyEntity extends UpdatableEntity {
List<String> getAcceptableAaguids();
void setAcceptableAaguids(List<String> acceptableAaguids);
List<String> getExtraOrigins();
void setExtraOrigins(List<String> extraOrigins);
}

View file

@ -489,6 +489,7 @@ public class ModelToRepresentation {
rep.setWebAuthnPolicyCreateTimeout(webAuthnPolicy.getCreateTimeout());
rep.setWebAuthnPolicyAvoidSameAuthenticatorRegister(webAuthnPolicy.isAvoidSameAuthenticatorRegister());
rep.setWebAuthnPolicyAcceptableAaguids(webAuthnPolicy.getAcceptableAaguids());
rep.setWebAuthnPolicyExtraOrigins(webAuthnPolicy.getExtraOrigins());
webAuthnPolicy = realm.getWebAuthnPolicyPasswordless();
rep.setWebAuthnPolicyPasswordlessRpEntityName(webAuthnPolicy.getRpEntityName());
@ -501,6 +502,7 @@ public class ModelToRepresentation {
rep.setWebAuthnPolicyPasswordlessCreateTimeout(webAuthnPolicy.getCreateTimeout());
rep.setWebAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister(webAuthnPolicy.isAvoidSameAuthenticatorRegister());
rep.setWebAuthnPolicyPasswordlessAcceptableAaguids(webAuthnPolicy.getAcceptableAaguids());
rep.setWebAuthnPolicyPasswordlessExtraOrigins(webAuthnPolicy.getExtraOrigins());
CibaConfig cibaPolicy = realm.getCibaPolicy();
Map<String, String> attrMap = Optional.ofNullable(rep.getAttributes()).orElse(new HashMap<>());

View file

@ -40,6 +40,7 @@ public class WebAuthnPolicy implements Serializable {
protected int createTimeout = 0; // not specified as option
protected boolean avoidSameAuthenticatorRegister = false;
protected List<String> acceptableAaguids;
protected List<String> extraOrigins;
public WebAuthnPolicy() {
}
@ -130,4 +131,12 @@ public class WebAuthnPolicy implements Serializable {
public void setAcceptableAaguids(List<String> acceptableAaguids) {
this.acceptableAaguids = acceptableAaguids;
}
public List<String> getExtraOrigins(){
return extraOrigins;
}
public void setExtraOrigins(List<String> extraOrigins) {
this.extraOrigins = extraOrigins;
}
}

View file

@ -16,15 +16,24 @@
package org.keycloak.credential;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import com.webauthn4j.WebAuthnAuthenticationManager;
import com.webauthn4j.authenticator.Authenticator;
import com.webauthn4j.authenticator.AuthenticatorImpl;
import com.webauthn4j.converter.util.ObjectConverter;
import com.webauthn4j.data.AuthenticationData;
import com.webauthn4j.data.AuthenticationParameters;
import com.webauthn4j.data.AuthenticatorTransport;
import com.webauthn4j.data.attestation.authenticator.AAGUID;
import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData;
import com.webauthn4j.data.attestation.authenticator.COSEKey;
import com.webauthn4j.data.client.CollectedClientData;
import com.webauthn4j.data.client.Origin;
import com.webauthn4j.server.ServerProperty;
import com.webauthn4j.util.AssertUtil;
import com.webauthn4j.util.exception.WebAuthnException;
import com.webauthn4j.validator.OriginValidatorImpl;
import com.webauthn4j.validator.exception.BadOriginException;
import org.checkerframework.checker.nullness.qual.NonNull;
import org.jboss.logging.Logger;
import org.keycloak.authentication.requiredactions.WebAuthnRegisterFactory;
import org.keycloak.common.util.Base64;
@ -32,18 +41,16 @@ import org.keycloak.common.util.Time;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import com.webauthn4j.authenticator.Authenticator;
import com.webauthn4j.authenticator.AuthenticatorImpl;
import com.webauthn4j.data.AuthenticationData;
import com.webauthn4j.data.AuthenticationParameters;
import com.webauthn4j.data.attestation.authenticator.AAGUID;
import com.webauthn4j.data.attestation.authenticator.AttestedCredentialData;
import com.webauthn4j.data.attestation.authenticator.COSEKey;
import com.webauthn4j.util.exception.WebAuthnException;
import org.keycloak.models.WebAuthnPolicy;
import org.keycloak.models.credential.WebAuthnCredentialModel;
import org.keycloak.models.credential.dto.WebAuthnCredentialData;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Credential provider for WebAuthn 2-factor credential of the user
*/
@ -180,7 +187,7 @@ public class WebAuthnCredentialProvider implements CredentialProvider<WebAuthnCr
WebAuthnCredentialModelInput context = WebAuthnCredentialModelInput.class.cast(input);
List<WebAuthnCredentialModelInput> auths = getWebAuthnCredentialModelList(realm, user);
WebAuthnAuthenticationManager webAuthnAuthenticationManager = new WebAuthnAuthenticationManager();
WebAuthnAuthenticationManager webAuthnAuthenticationManager = getWebAuthnAuthenticationManager();
AuthenticationData authenticationData = null;
try {
@ -233,6 +240,31 @@ public class WebAuthnCredentialProvider implements CredentialProvider<WebAuthnCr
return false;
}
protected WebAuthnAuthenticationManager getWebAuthnAuthenticationManager() {
WebAuthnPolicy policy = getWebAuthnPolicy();
Set<Origin> origins = policy.getExtraOrigins().stream()
.map(Origin::new)
.collect(Collectors.toSet());
WebAuthnAuthenticationManager webAuthnAuthenticationManager = new WebAuthnAuthenticationManager();
webAuthnAuthenticationManager.getAuthenticationDataValidator().setOriginValidator(new OriginValidatorImpl(){
@Override
protected void validate(@NonNull CollectedClientData collectedClientData,
@NonNull ServerProperty serverProperty) {
AssertUtil.notNull(collectedClientData, "collectedClientData must not be null");
AssertUtil.notNull(serverProperty, "serverProperty must not be null");
final Origin clientOrigin = collectedClientData.getOrigin();
if (serverProperty.getOrigins().contains(clientOrigin)) return;
// https://github.com/w3c/webauthn/issues/1297
if (origins.contains(clientOrigin)) return;
throw new BadOriginException("The collectedClientData '" + clientOrigin + "' origin doesn't match any of the preconfigured origins.");
}
});
return webAuthnAuthenticationManager;
}
protected WebAuthnPolicy getWebAuthnPolicy() {
return session.getContext().getRealm().getWebAuthnPolicy();
}
@Override
public String getType() {

View file

@ -16,12 +16,9 @@
package org.keycloak.credential;
import org.keycloak.Config;
import com.webauthn4j.converter.util.ObjectConverter;
import org.keycloak.common.Profile;
import org.keycloak.models.KeycloakSession;
import com.webauthn4j.converter.util.ObjectConverter;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.EnvironmentDependentProviderFactory;
public class WebAuthnCredentialProviderFactory implements CredentialProviderFactory<WebAuthnCredentialProvider>, EnvironmentDependentProviderFactory {

View file

@ -21,6 +21,7 @@ package org.keycloak.credential;
import com.webauthn4j.converter.util.ObjectConverter;
import org.keycloak.authentication.requiredactions.WebAuthnPasswordlessRegisterFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.WebAuthnPolicy;
import org.keycloak.models.credential.WebAuthnCredentialModel;
/**
@ -51,4 +52,9 @@ public class WebAuthnPasswordlessCredentialProvider extends WebAuthnCredentialPr
.removeable(true)
.build(getKeycloakSession());
}
@Override
protected WebAuthnPolicy getWebAuthnPolicy() {
return getKeycloakSession().getContext().getRealm().getWebAuthnPolicyPasswordless();
}
}