You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@syncope.apache.org by mm...@apache.org on 2020/10/05 08:09:54 UTC

[syncope] branch master updated: SYNCOPE-1589: WA WebAuthN Device Registration APIs (#216)

This is an automated email from the ASF dual-hosted git repository.

mmoayyed pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/syncope.git


The following commit(s) were added to refs/heads/master by this push:
     new adbcff7  SYNCOPE-1589: WA WebAuthN Device Registration APIs (#216)
adbcff7 is described below

commit adbcff75e3d5f2acc0f48350311819aec49ab6ec
Author: Misagh Moayyed <mm...@gmail.com>
AuthorDate: Mon Oct 5 12:09:43 2020 +0400

    SYNCOPE-1589: WA WebAuthN Device Registration APIs (#216)
---
 .../syncope/common/lib/types/AMEntitlement.java    |  10 ++
 .../syncope/common/lib/types/WebAuthnAccount.java  | 124 +++++++++++++++
 .../common/lib/types/WebAuthnDeviceCredential.java | 122 +++++++++++++++
 .../service/wa/WebAuthnRegistrationService.java    |  98 ++++++++++++
 .../logic/WebAuthnRegistrationServiceLogic.java    | 172 +++++++++++++++++++++
 .../wa/WebAuthnRegistrationServiceImpl.java        |  81 ++++++++++
 .../persistence/api/entity/auth/AuthProfile.java   |   5 +
 .../jpa/entity/auth/JPAAuthProfile.java            |  17 ++
 .../persistence/jpa/inner/AuthProfileTest.java     |  51 ++++++
 .../org/apache/syncope/fit/AbstractITCase.java     |   4 +
 .../syncope/fit/core/WebAuthnAccountITCase.java    | 102 ++++++++++++
 .../wa/starter/config/SyncopeWAConfiguration.java  |  10 ++
 .../SyncopeWAWebAuthnCredentialRepository.java     | 144 +++++++++++++++++
 13 files changed, 940 insertions(+)

diff --git a/common/am/lib/src/main/java/org/apache/syncope/common/lib/types/AMEntitlement.java b/common/am/lib/src/main/java/org/apache/syncope/common/lib/types/AMEntitlement.java
index 2be85c9..1aa4888 100644
--- a/common/am/lib/src/main/java/org/apache/syncope/common/lib/types/AMEntitlement.java
+++ b/common/am/lib/src/main/java/org/apache/syncope/common/lib/types/AMEntitlement.java
@@ -130,6 +130,16 @@ public final class AMEntitlement {
 
     public static final String WA_CONFIG_PUSH = "WA_CONFIG_PUSH";
 
+    public static final String WEBAUTHN_DELETE_DEVICE = "WEBAUTHN_DELETE_DEVICE";
+
+    public static final String WEBAUTHN_READ_DEVICE = "WEBAUTHN_READ_DEVICE";
+
+    public static final String WEBAUTHN_UPDATE_DEVICE = "WEBAUTHN_UPDATE_DEVICE";
+
+    public static final String WEBAUTHN_CREATE_DEVICE = "WEBAUTHN_CREATE_DEVICE";
+
+    public static final String WEBAUTHN_LIST_DEVICE = "WEBAUTHN_LIST_DEVICE";
+
     private static final Set<String> VALUES;
 
     static {
diff --git a/common/am/lib/src/main/java/org/apache/syncope/common/lib/types/WebAuthnAccount.java b/common/am/lib/src/main/java/org/apache/syncope/common/lib/types/WebAuthnAccount.java
new file mode 100644
index 0000000..1115f00
--- /dev/null
+++ b/common/am/lib/src/main/java/org/apache/syncope/common/lib/types/WebAuthnAccount.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.apache.syncope.common.lib.types;
+
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.apache.commons.lang3.builder.HashCodeBuilder;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.syncope.common.lib.BaseBean;
+
+import java.util.List;
+
+public class WebAuthnAccount implements BaseBean {
+
+    private static final long serialVersionUID = 2285073386484048953L;
+
+    private String key;
+
+    private List<WebAuthnDeviceCredential> records;
+
+    private String owner;
+
+    public String getKey() {
+        return key;
+    }
+
+    public void setKey(final String key) {
+        this.key = key;
+    }
+
+    public List<WebAuthnDeviceCredential> getRecords() {
+        return records;
+    }
+
+    public void setRecords(final List<WebAuthnDeviceCredential> record) {
+        this.records = record;
+    }
+
+    public String getOwner() {
+        return owner;
+    }
+
+    public void setOwner(final String owner) {
+        this.owner = owner;
+    }
+
+    @Override
+    public int hashCode() {
+        return new HashCodeBuilder()
+            .appendSuper(super.hashCode())
+            .append(key)
+            .append(records)
+            .append(owner)
+            .toHashCode();
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (obj == this) {
+            return true;
+        }
+        if (obj.getClass() != getClass()) {
+            return false;
+        }
+        WebAuthnAccount rhs = (WebAuthnAccount) obj;
+        return new EqualsBuilder()
+            .appendSuper(super.equals(obj))
+            .append(this.key, rhs.key)
+            .append(this.records, rhs.records)
+            .append(this.owner, rhs.owner)
+            .isEquals();
+    }
+
+    @Override
+    public String toString() {
+        return new ToStringBuilder(this)
+            .append("key", key)
+            .append("records", records)
+            .append("owner", owner)
+            .toString();
+    }
+
+    public static class Builder {
+
+        private final WebAuthnAccount instance = new WebAuthnAccount();
+
+        public WebAuthnAccount.Builder records(final List<WebAuthnDeviceCredential> records) {
+            instance.setRecords(records);
+            return this;
+        }
+
+        public WebAuthnAccount.Builder owner(final String owner) {
+            instance.setOwner(owner);
+            return this;
+        }
+
+        public WebAuthnAccount.Builder key(final String key) {
+            instance.setKey(key);
+            return this;
+        }
+
+        public WebAuthnAccount build() {
+            return instance;
+        }
+    }
+}
diff --git a/common/am/lib/src/main/java/org/apache/syncope/common/lib/types/WebAuthnDeviceCredential.java b/common/am/lib/src/main/java/org/apache/syncope/common/lib/types/WebAuthnDeviceCredential.java
new file mode 100644
index 0000000..4a8225e
--- /dev/null
+++ b/common/am/lib/src/main/java/org/apache/syncope/common/lib/types/WebAuthnDeviceCredential.java
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.apache.syncope.common.lib.types;
+
+import org.apache.commons.lang3.builder.EqualsBuilder;
+import org.apache.commons.lang3.builder.HashCodeBuilder;
+import org.apache.commons.lang3.builder.ToStringBuilder;
+import org.apache.syncope.common.lib.BaseBean;
+
+public class WebAuthnDeviceCredential implements BaseBean {
+
+    private static final long serialVersionUID = 1185073386484048953L;
+
+    private String json;
+
+    private String owner;
+
+    private String identifier;
+
+    public String getIdentifier() {
+        return identifier;
+    }
+
+    public void setIdentifier(final String identifier) {
+        this.identifier = identifier;
+    }
+
+    public String getJson() {
+        return json;
+    }
+
+    public void setJson(final String json) {
+        this.json = json;
+    }
+
+    public String getOwner() {
+        return owner;
+    }
+
+    public void setOwner(final String owner) {
+        this.owner = owner;
+    }
+
+    @Override
+    public int hashCode() {
+        return new HashCodeBuilder()
+            .appendSuper(super.hashCode())
+            .append(json)
+            .append(identifier)
+            .append(owner)
+            .toHashCode();
+    }
+
+    @Override
+    public boolean equals(final Object obj) {
+        if (obj == null) {
+            return false;
+        }
+        if (obj == this) {
+            return true;
+        }
+        if (obj.getClass() != getClass()) {
+            return false;
+        }
+        WebAuthnDeviceCredential rhs = (WebAuthnDeviceCredential) obj;
+        return new EqualsBuilder()
+            .appendSuper(super.equals(obj))
+            .append(this.json, rhs.json)
+            .append(this.identifier, rhs.identifier)
+            .append(this.owner, rhs.owner)
+            .isEquals();
+    }
+
+    @Override
+    public String toString() {
+        return new ToStringBuilder(this)
+            .append("records", json)
+            .append("identifier", identifier)
+            .append("owner", owner)
+            .toString();
+    }
+
+    public static class Builder {
+
+        private final WebAuthnDeviceCredential instance = new WebAuthnDeviceCredential();
+
+        public WebAuthnDeviceCredential.Builder json(final String json) {
+            instance.setJson(json);
+            return this;
+        }
+
+        public WebAuthnDeviceCredential.Builder owner(final String owner) {
+            instance.setOwner(owner);
+            return this;
+        }
+
+        public WebAuthnDeviceCredential.Builder identifier(final String identifier) {
+            instance.setIdentifier(identifier);
+            return this;
+        }
+
+        public WebAuthnDeviceCredential build() {
+            return instance;
+        }
+    }
+}
diff --git a/common/am/rest-api/src/main/java/org/apache/syncope/common/rest/api/service/wa/WebAuthnRegistrationService.java b/common/am/rest-api/src/main/java/org/apache/syncope/common/rest/api/service/wa/WebAuthnRegistrationService.java
new file mode 100644
index 0000000..fb3da8f
--- /dev/null
+++ b/common/am/rest-api/src/main/java/org/apache/syncope/common/rest/api/service/wa/WebAuthnRegistrationService.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.apache.syncope.common.rest.api.service.wa;
+
+import io.swagger.v3.oas.annotations.headers.Header;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import io.swagger.v3.oas.annotations.responses.ApiResponses;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.security.SecurityRequirements;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.apache.syncope.common.lib.types.WebAuthnAccount;
+import org.apache.syncope.common.rest.api.RESTHeaders;
+import org.apache.syncope.common.rest.api.service.JAXRSService;
+
+import javax.validation.constraints.NotNull;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.PUT;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+
+import java.util.List;
+
+@Tag(name = "WA Registrations")
+@SecurityRequirements({
+    @SecurityRequirement(name = "BasicAuthentication"),
+    @SecurityRequirement(name = "Bearer")})
+@Path("wa/webauthn")
+public interface WebAuthnRegistrationService extends JAXRSService {
+    @GET
+    @Consumes({MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML})
+    @Produces({MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML})
+    List<WebAuthnAccount> list();
+
+    @GET
+    @Path("{key}")
+    @Consumes({MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML})
+    @Produces({MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML})
+    WebAuthnAccount read(@NotNull @PathParam("key") String key);
+
+    @GET
+    @Consumes({MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML})
+    @Produces({MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML})
+    @Path("users/${owner}")
+    WebAuthnAccount findAccountFor(@NotNull @PathParam("owner") String owner);
+
+    @DELETE
+    @Consumes({MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML})
+    @Produces({MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML})
+    @Path("${owner}")
+    Response delete(@NotNull @PathParam("owner") String owner);
+
+    @DELETE
+    @Consumes({MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML})
+    @Produces({MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML})
+    @Path("${owner}/${credentialId}")
+    Response delete(@NotNull @PathParam("owner") String owner, @NotNull @PathParam("credentialId") String credentialId);
+
+    @ApiResponses({
+        @ApiResponse(responseCode = "201",
+            description = "WebAuthn successfully created", headers = {
+            @Header(name = RESTHeaders.RESOURCE_KEY, schema =
+            @Schema(type = "string"),
+                description = "UUID generated for the entity created")})})
+    @POST
+    @Consumes({MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML})
+    @Produces({MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML})
+    Response create(WebAuthnAccount account);
+
+    @PUT
+    @Consumes({MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML})
+    @Produces({MediaType.APPLICATION_JSON, RESTHeaders.APPLICATION_YAML, MediaType.APPLICATION_XML})
+    void update(@NotNull WebAuthnAccount account);
+
+}
diff --git a/core/am/logic/src/main/java/org/apache/syncope/core/logic/WebAuthnRegistrationServiceLogic.java b/core/am/logic/src/main/java/org/apache/syncope/core/logic/WebAuthnRegistrationServiceLogic.java
new file mode 100644
index 0000000..f644460
--- /dev/null
+++ b/core/am/logic/src/main/java/org/apache/syncope/core/logic/WebAuthnRegistrationServiceLogic.java
@@ -0,0 +1,172 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.apache.syncope.core.logic;
+
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.syncope.common.lib.to.AuthProfileTO;
+import org.apache.syncope.common.lib.types.AMEntitlement;
+import org.apache.syncope.common.lib.types.IdRepoEntitlement;
+import org.apache.syncope.common.lib.types.WebAuthnAccount;
+import org.apache.syncope.common.lib.types.WebAuthnDeviceCredential;
+import org.apache.syncope.core.persistence.api.dao.NotFoundException;
+import org.apache.syncope.core.persistence.api.dao.auth.AuthProfileDAO;
+import org.apache.syncope.core.persistence.api.entity.EntityFactory;
+import org.apache.syncope.core.persistence.api.entity.auth.AuthProfile;
+import org.apache.syncope.core.provisioning.api.data.AuthProfileDataBinder;
+import org.apache.syncope.core.spring.security.SecureRandomUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.lang.reflect.Method;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+@Component
+public class WebAuthnRegistrationServiceLogic extends AbstractTransactionalLogic<AuthProfileTO> {
+    @Autowired
+    private AuthProfileDAO authProfileDAO;
+
+    @Autowired
+    private EntityFactory entityFactory;
+
+    @Autowired
+    private AuthProfileDataBinder authProfileDataBinder;
+
+    @PreAuthorize("hasRole('" + AMEntitlement.WEBAUTHN_READ_DEVICE + "') "
+        + "or hasRole('" + IdRepoEntitlement.ANONYMOUS + "')")
+    @Transactional(readOnly = true)
+    public WebAuthnAccount read(final String key) {
+        return authProfileDAO.findAll().
+            stream().
+            map(AuthProfile::getWebAuthnAccount).
+            filter(Objects::nonNull).
+            filter(record -> record.getKey().equals(key)).
+            findFirst().
+            orElse(null);
+    }
+
+    @Override
+    protected AuthProfileTO resolveReference(final Method method, final Object... args)
+        throws UnresolvedReferenceException {
+        String key = null;
+        if (ArrayUtils.isNotEmpty(args)) {
+            for (int i = 0; key == null && i < args.length; i++) {
+                if (args[i] instanceof String) {
+                    key = (String) args[i];
+                } else if (args[i] instanceof AuthProfileTO) {
+                    key = ((AuthProfileTO) args[i]).getKey();
+                }
+            }
+        }
+
+        if (key != null) {
+            try {
+                return authProfileDAO.findByKey(key).
+                    map(authProfileDataBinder::getAuthProfileTO).
+                    orElseThrow();
+            } catch (final Throwable e) {
+                LOG.debug("Unresolved reference", e);
+                throw new UnresolvedReferenceException(e);
+            }
+        }
+        throw new UnresolvedReferenceException();
+    }
+
+    @PreAuthorize("hasRole('" + AMEntitlement.WEBAUTHN_LIST_DEVICE + "') "
+        + "or hasRole('" + IdRepoEntitlement.ANONYMOUS + "')")
+    @Transactional(readOnly = true)
+    public List<WebAuthnAccount> list() {
+        return authProfileDAO.findAll().stream().
+            map(AuthProfile::getWebAuthnAccount).
+            filter(Objects::nonNull).
+            collect(Collectors.toList());
+    }
+
+    @PreAuthorize("hasRole('" + AMEntitlement.WEBAUTHN_READ_DEVICE + "') "
+        + "or hasRole('" + IdRepoEntitlement.ANONYMOUS + "')")
+    @Transactional(readOnly = true)
+    public WebAuthnAccount findAccountBy(final String owner) {
+        return authProfileDAO.findByOwner(owner).
+            stream().
+            map(AuthProfile::getWebAuthnAccount).
+            filter(Objects::nonNull).
+            findFirst().
+            orElseThrow(() -> new NotFoundException("Could not find account for Owner " + owner));
+    }
+
+    @PreAuthorize("hasRole('" + AMEntitlement.WEBAUTHN_DELETE_DEVICE + "') "
+        + "or hasRole('" + IdRepoEntitlement.ANONYMOUS + "')")
+    public void delete(final String owner) {
+        authProfileDAO.findByOwner(owner).ifPresent(profile -> {
+            profile.setWebAuthnAccount(null);
+            authProfileDAO.save(profile);
+        });
+    }
+
+    @PreAuthorize("hasRole('" + AMEntitlement.WEBAUTHN_DELETE_DEVICE + "') "
+        + "or hasRole('" + IdRepoEntitlement.ANONYMOUS + "')")
+    public void delete(final String owner, final String credentialId) {
+        authProfileDAO.findByOwner(owner).
+            stream().
+            filter(Objects::nonNull).
+            findFirst().
+            ifPresent(profile -> {
+                WebAuthnAccount webAuthnAccount = profile.getWebAuthnAccount();
+                final List<WebAuthnDeviceCredential> accounts = webAuthnAccount.getRecords();
+                if (accounts.removeIf(acct -> acct.getIdentifier().equals(credentialId))) {
+                    profile.setWebAuthnAccount(webAuthnAccount);
+                    authProfileDAO.save(profile);
+                }
+            });
+    }
+
+    @PreAuthorize("hasRole('" + AMEntitlement.WEBAUTHN_CREATE_DEVICE + "') "
+        + "or hasRole('" + IdRepoEntitlement.ANONYMOUS + "')")
+    public WebAuthnAccount create(final WebAuthnAccount acct) {
+        AuthProfile profile = authProfileDAO.findByOwner(acct.getOwner()).
+            orElseGet(() -> {
+                final AuthProfile authProfile = entityFactory.newEntity(AuthProfile.class);
+                authProfile.setOwner(acct.getOwner());
+                return authProfile;
+            });
+
+        if (acct.getKey() == null) {
+            acct.setKey(SecureRandomUtils.generateRandomUUID().toString());
+        }
+        profile.setWebAuthnAccount(acct);
+        authProfileDAO.save(profile);
+        return profile.getWebAuthnAccount();
+    }
+
+    @PreAuthorize("hasRole('" + AMEntitlement.WEBAUTHN_UPDATE_DEVICE + "') "
+        + "or hasRole('" + IdRepoEntitlement.ANONYMOUS + "')")
+    public void update(final WebAuthnAccount account) {
+        List<AuthProfile> profiles = authProfileDAO.findAll();
+        profiles.forEach(profile -> {
+            if (profile.getWebAuthnAccount() != null) {
+                profile.setWebAuthnAccount(account);
+                authProfileDAO.save(profile);
+            }
+        });
+    }
+}
diff --git a/core/am/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/wa/WebAuthnRegistrationServiceImpl.java b/core/am/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/wa/WebAuthnRegistrationServiceImpl.java
new file mode 100644
index 0000000..2f45bfb
--- /dev/null
+++ b/core/am/rest-cxf/src/main/java/org/apache/syncope/core/rest/cxf/service/wa/WebAuthnRegistrationServiceImpl.java
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.apache.syncope.core.rest.cxf.service.wa;
+
+import org.apache.syncope.common.lib.types.WebAuthnAccount;
+import org.apache.syncope.common.rest.api.RESTHeaders;
+import org.apache.syncope.common.rest.api.service.wa.WebAuthnRegistrationService;
+import org.apache.syncope.core.logic.WebAuthnRegistrationServiceLogic;
+import org.apache.syncope.core.rest.cxf.service.AbstractServiceImpl;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import javax.ws.rs.core.Response;
+
+import java.net.URI;
+import java.util.List;
+
+@Service
+public class WebAuthnRegistrationServiceImpl extends AbstractServiceImpl implements WebAuthnRegistrationService {
+    @Autowired
+    private WebAuthnRegistrationServiceLogic logic;
+
+    @Override
+    public List<WebAuthnAccount> list() {
+        return logic.list();
+    }
+
+    @Override
+    public WebAuthnAccount read(final String key) {
+        return logic.read(key);
+    }
+
+    @Override
+    public WebAuthnAccount findAccountFor(final String owner) {
+        return logic.findAccountBy(owner);
+    }
+
+    @Override
+    public Response delete(final String owner) {
+        logic.delete(owner);
+        return Response.noContent().build();
+    }
+
+    @Override
+    public Response delete(final String owner, final String credentialId) {
+        logic.delete(owner, credentialId);
+        return Response.noContent().build();
+    }
+
+    @Override
+    public Response create(final WebAuthnAccount account) {
+        final WebAuthnAccount token = logic.create(account);
+        URI location = uriInfo.getAbsolutePathBuilder().path(token.getKey()).build();
+        return Response.created(location).
+            header(RESTHeaders.RESOURCE_KEY, token.getKey()).
+            entity(token).
+            build();
+    }
+
+    @Override
+    public void update(final WebAuthnAccount account) {
+        logic.update(account);
+    }
+}
diff --git a/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/entity/auth/AuthProfile.java b/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/entity/auth/AuthProfile.java
index b6428b7..2cdb4e0 100644
--- a/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/entity/auth/AuthProfile.java
+++ b/core/persistence-api/src/main/java/org/apache/syncope/core/persistence/api/entity/auth/AuthProfile.java
@@ -22,6 +22,7 @@ package org.apache.syncope.core.persistence.api.entity.auth;
 import org.apache.syncope.common.lib.types.GoogleMfaAuthAccount;
 import org.apache.syncope.common.lib.types.GoogleMfaAuthToken;
 import org.apache.syncope.common.lib.types.U2FRegisteredDevice;
+import org.apache.syncope.common.lib.types.WebAuthnAccount;
 import org.apache.syncope.core.persistence.api.entity.Entity;
 
 import java.util.List;
@@ -44,6 +45,10 @@ public interface AuthProfile extends Entity {
 
     void setGoogleMfaAuthAccounts(List<GoogleMfaAuthAccount> accounts);
 
+    WebAuthnAccount getWebAuthnAccount();
+
+    void setWebAuthnAccount(WebAuthnAccount accounts);
+
     void add(GoogleMfaAuthToken token);
 
     void add(GoogleMfaAuthAccount account);
diff --git a/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/auth/JPAAuthProfile.java b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/auth/JPAAuthProfile.java
index ab0b5d2..d3b913e 100644
--- a/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/auth/JPAAuthProfile.java
+++ b/core/persistence-jpa/src/main/java/org/apache/syncope/core/persistence/jpa/entity/auth/JPAAuthProfile.java
@@ -29,6 +29,7 @@ import javax.persistence.UniqueConstraint;
 import org.apache.syncope.common.lib.types.GoogleMfaAuthAccount;
 import org.apache.syncope.common.lib.types.GoogleMfaAuthToken;
 import org.apache.syncope.common.lib.types.U2FRegisteredDevice;
+import org.apache.syncope.common.lib.types.WebAuthnAccount;
 import org.apache.syncope.core.persistence.api.entity.auth.AuthProfile;
 import org.apache.syncope.core.persistence.jpa.entity.AbstractGeneratedKeyEntity;
 import org.apache.syncope.core.provisioning.api.serialization.POJOHelper;
@@ -51,6 +52,9 @@ public class JPAAuthProfile extends AbstractGeneratedKeyEntity implements AuthPr
     @Lob
     private String googleMfaAuthTokens;
 
+    @Lob
+    private String webAuthnAccount;
+
     @Column(nullable = false)
     private String owner;
 
@@ -112,6 +116,19 @@ public class JPAAuthProfile extends AbstractGeneratedKeyEntity implements AuthPr
     }
 
     @Override
+    public WebAuthnAccount getWebAuthnAccount() {
+        return webAuthnAccount == null
+            ? null
+            : POJOHelper.deserialize(webAuthnAccount, new TypeReference<WebAuthnAccount>() {
+        });
+    }
+
+    @Override
+    public void setWebAuthnAccount(final WebAuthnAccount accounts) {
+        this.webAuthnAccount = POJOHelper.serialize(accounts);
+    }
+
+    @Override
     public void add(final GoogleMfaAuthAccount account) {
         checkType(account, GoogleMfaAuthAccount.class);
         List<GoogleMfaAuthAccount> accounts = getGoogleMfaAuthAccounts();
diff --git a/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/inner/AuthProfileTest.java b/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/inner/AuthProfileTest.java
index 137e799..cd5c56e 100644
--- a/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/inner/AuthProfileTest.java
+++ b/core/persistence-jpa/src/test/java/org/apache/syncope/core/persistence/jpa/inner/AuthProfileTest.java
@@ -21,6 +21,8 @@ package org.apache.syncope.core.persistence.jpa.inner;
 import org.apache.syncope.common.lib.types.GoogleMfaAuthAccount;
 import org.apache.syncope.common.lib.types.GoogleMfaAuthToken;
 import org.apache.syncope.common.lib.types.U2FRegisteredDevice;
+import org.apache.syncope.common.lib.types.WebAuthnDeviceCredential;
+import org.apache.syncope.common.lib.types.WebAuthnAccount;
 import org.apache.syncope.core.persistence.api.dao.auth.AuthProfileDAO;
 import org.apache.syncope.core.persistence.api.entity.EntityFactory;
 import org.apache.syncope.core.persistence.api.entity.auth.AuthProfile;
@@ -98,6 +100,44 @@ public class AuthProfileTest extends AbstractTest {
     }
 
     @Test
+    public void webAuthnRegisteredDevice() {
+        String id = SecureRandomUtils.generateRandomUUID().toString();
+        String record = "[ {" +
+            "    \"userIdentity\" : {" +
+            "      \"name\" : \"casuser\"," +
+            "      \"displayName\" : \"casuser\"" +
+            "    }," +
+            "    \"credential\" : {" +
+            "      \"credentialId\" : \"fFGyV3K5x1\"" +
+            "    }," +
+            "    \"username\" : \"casuser\"" +
+            "  } ]";
+
+        WebAuthnDeviceCredential credential = new WebAuthnDeviceCredential.Builder().
+            json(record).
+            owner(id).
+            identifier("fFGyV3K5x1").
+            build();
+        
+        createAuthProfileWithWebAuthnDevice(id, List.of(credential));
+
+        Optional<AuthProfile> result = authProfileDAO.findByOwner(id);
+        assertTrue(result.isPresent());
+
+        assertFalse(authProfileDAO.findAll().isEmpty());
+
+        AuthProfile authProfile = result.get();
+        result = authProfileDAO.findByKey(authProfile.getKey());
+        assertTrue(result.isPresent());
+
+        authProfile.setOwner("SyncopeCreate-NewU2F");
+        authProfile.setWebAuthnAccount(null);
+        authProfileDAO.save(authProfile);
+
+        assertFalse(authProfileDAO.findByOwner(id).isPresent());
+    }
+
+    @Test
     public void googleMfaAccount() {
         String id = SecureRandomUtils.generateRandomUUID().toString();
 
@@ -146,6 +186,17 @@ public class AuthProfileTest extends AbstractTest {
         return authProfileDAO.save(profile);
     }
 
+    private AuthProfile createAuthProfileWithWebAuthnDevice(final String owner, final List<WebAuthnDeviceCredential> records) {
+        AuthProfile profile = entityFactory.newEntity(AuthProfile.class);
+        profile.setOwner(owner);
+        WebAuthnAccount account = new WebAuthnAccount.Builder()
+            .records(records)
+            .owner(owner)
+            .build();
+        profile.setWebAuthnAccount(account);
+        return authProfileDAO.save(profile);
+    }
+
     private AuthProfile createAuthProfileWithAccount(final String owner) {
         AuthProfile profile = entityFactory.newEntity(AuthProfile.class);
         profile.setOwner(owner);
diff --git a/fit/core-reference/src/test/java/org/apache/syncope/fit/AbstractITCase.java b/fit/core-reference/src/test/java/org/apache/syncope/fit/AbstractITCase.java
index b4bac44..904b6c0 100644
--- a/fit/core-reference/src/test/java/org/apache/syncope/fit/AbstractITCase.java
+++ b/fit/core-reference/src/test/java/org/apache/syncope/fit/AbstractITCase.java
@@ -145,6 +145,7 @@ import org.apache.syncope.common.rest.api.service.SRARouteService;
 import org.apache.syncope.common.rest.api.service.UserWorkflowTaskService;
 import org.apache.syncope.common.rest.api.service.wa.U2FRegistrationService;
 import org.apache.syncope.common.rest.api.service.wa.WAConfigService;
+import org.apache.syncope.common.rest.api.service.wa.WebAuthnRegistrationService;
 import org.apache.syncope.fit.core.CoreITContext;
 import org.apache.syncope.fit.core.UserITCase;
 import org.junit.jupiter.api.BeforeAll;
@@ -343,6 +344,8 @@ public abstract class AbstractITCase {
 
     protected static WAConfigService waConfigService;
 
+    protected static WebAuthnRegistrationService webAuthnRegistrationService;
+
     @BeforeAll
     public static void securitySetup() {
         try (InputStream propStream = AbstractITCase.class.getResourceAsStream("/security.properties")) {
@@ -423,6 +426,7 @@ public abstract class AbstractITCase {
         oidcJWKSService = adminClient.getService(OIDCJWKSService.class);
         u2FRegistrationService = adminClient.getService(U2FRegistrationService.class);
         waConfigService = adminClient.getService(WAConfigService.class);
+        webAuthnRegistrationService = adminClient.getService(WebAuthnRegistrationService.class);
     }
 
     @Autowired
diff --git a/fit/core-reference/src/test/java/org/apache/syncope/fit/core/WebAuthnAccountITCase.java b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/WebAuthnAccountITCase.java
new file mode 100644
index 0000000..ff872a0
--- /dev/null
+++ b/fit/core-reference/src/test/java/org/apache/syncope/fit/core/WebAuthnAccountITCase.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.apache.syncope.fit.core;
+
+import org.apache.syncope.common.lib.SyncopeClientException;
+import org.apache.syncope.common.lib.types.WebAuthnAccount;
+import org.apache.syncope.common.lib.types.WebAuthnDeviceCredential;
+import org.apache.syncope.core.spring.security.SecureRandomUtils;
+import org.apache.syncope.fit.AbstractITCase;
+import org.junit.jupiter.api.Test;
+
+import javax.ws.rs.core.Response;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class WebAuthnAccountITCase extends AbstractITCase {
+
+    private static WebAuthnAccount createWebAuthnRegisteredAccount() {
+        String id = SecureRandomUtils.generateRandomUUID().toString();
+        String record = "[ {" +
+            "    \"userIdentity\" : {" +
+            "      \"name\" : \"%s\"," +
+            "      \"displayName\" : \"%s\"" +
+            "    }," +
+            "    \"credential\" : {" +
+            "      \"credentialId\" : \"fFGyV3K5x1\"" +
+            "    }," +
+            "    \"username\" : \"%s\"" +
+            "  } ]";
+        WebAuthnDeviceCredential credential = new WebAuthnDeviceCredential.Builder().
+            json(String.format(record, id, id, id)).
+            owner(id).
+            identifier("fFGyV3K5x1").
+            build();
+        return new WebAuthnAccount.Builder()
+            .owner(id)
+            .records(List.of(credential))
+            .build();
+    }
+
+    @Test
+    public void listAndFind() {
+        WebAuthnAccount acct = createWebAuthnRegisteredAccount();
+        webAuthnRegistrationService.create(acct);
+        assertFalse(webAuthnRegistrationService.list().isEmpty());
+        assertNotNull(webAuthnRegistrationService.findAccountFor(acct.getOwner()));
+    }
+
+    @Test
+    public void deleteByOwner() {
+        WebAuthnAccount acct = createWebAuthnRegisteredAccount();
+        webAuthnRegistrationService.create(acct);
+        assertNotNull(webAuthnRegistrationService.delete(acct.getOwner()));
+        assertThrows(SyncopeClientException.class, () -> webAuthnRegistrationService.findAccountFor(acct.getOwner()));
+    }
+
+    @Test
+    public void deleteByAcct() {
+        WebAuthnAccount acct = createWebAuthnRegisteredAccount();
+        webAuthnRegistrationService.create(acct);
+        assertNotNull(webAuthnRegistrationService.delete(acct.getOwner(), acct.getRecords().get(0).getIdentifier()));
+        acct = webAuthnRegistrationService.findAccountFor(acct.getOwner());
+        assertTrue(acct.getRecords().isEmpty());
+    }
+
+    @Test
+    public void create() {
+        WebAuthnAccount acct = createWebAuthnRegisteredAccount();
+        assertDoesNotThrow(() -> {
+            Response response = webAuthnRegistrationService.create(acct);
+            if (response.getStatusInfo().getStatusCode() != Response.Status.CREATED.getStatusCode()) {
+                Exception ex = clientFactory.getExceptionMapper().fromResponse(response);
+                if (ex != null) {
+                    throw ex;
+                }
+            }
+        });
+    }
+}
diff --git a/wa/starter/src/main/java/org/apache/syncope/wa/starter/config/SyncopeWAConfiguration.java b/wa/starter/src/main/java/org/apache/syncope/wa/starter/config/SyncopeWAConfiguration.java
index 421b2a5..b7977ec 100644
--- a/wa/starter/src/main/java/org/apache/syncope/wa/starter/config/SyncopeWAConfiguration.java
+++ b/wa/starter/src/main/java/org/apache/syncope/wa/starter/config/SyncopeWAConfiguration.java
@@ -65,6 +65,8 @@ import org.apereo.cas.support.saml.idp.metadata.locator.SamlIdPMetadataLocator;
 import org.apereo.cas.support.saml.idp.metadata.writer.SamlIdPCertificateAndKeyWriter;
 import org.apereo.cas.util.DateTimeUtils;
 import org.apereo.cas.util.crypto.CipherExecutor;
+
+import org.apache.syncope.wa.starter.webauthn.SyncopeWAWebAuthnCredentialRepository;
 import org.pac4j.core.client.Client;
 import org.springframework.beans.factory.ObjectProvider;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -82,6 +84,7 @@ import java.util.Map;
 import org.apache.syncope.wa.starter.events.SyncopeWAEventRepository;
 import org.apereo.cas.support.events.CasEventRepository;
 import org.apereo.cas.support.events.CasEventRepositoryFilter;
+import org.apereo.cas.webauthn.storage.WebAuthnCredentialRepository;
 
 public class SyncopeWAConfiguration {
 
@@ -244,6 +247,13 @@ public class SyncopeWAConfiguration {
         return new SyncopeWAOIDCJWKSGeneratorService(restClient, size, algorithm);
     }
 
+    @RefreshScope
+    @Bean
+    @Autowired
+    public WebAuthnCredentialRepository webAuthnCredentialRepository(final WARestClient restClient) {
+        return new SyncopeWAWebAuthnCredentialRepository(casProperties, restClient);
+    }
+
     @Bean
     @Autowired
     @RefreshScope
diff --git a/wa/starter/src/main/java/org/apache/syncope/wa/starter/webauthn/SyncopeWAWebAuthnCredentialRepository.java b/wa/starter/src/main/java/org/apache/syncope/wa/starter/webauthn/SyncopeWAWebAuthnCredentialRepository.java
new file mode 100644
index 0000000..0a4bb78
--- /dev/null
+++ b/wa/starter/src/main/java/org/apache/syncope/wa/starter/webauthn/SyncopeWAWebAuthnCredentialRepository.java
@@ -0,0 +1,144 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.apache.syncope.wa.starter.webauthn;
+
+import org.apereo.cas.configuration.CasConfigurationProperties;
+import org.apereo.cas.util.crypto.CipherExecutor;
+import org.apereo.cas.webauthn.storage.BaseWebAuthnCredentialRepository;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.yubico.webauthn.data.CredentialRegistration;
+import lombok.val;
+import org.apache.syncope.common.lib.SyncopeClientException;
+import org.apache.syncope.common.lib.types.ClientExceptionType;
+import org.apache.syncope.common.lib.types.WebAuthnDeviceCredential;
+import org.apache.syncope.common.lib.types.WebAuthnAccount;
+import org.apache.syncope.common.rest.api.service.wa.WebAuthnRegistrationService;
+import org.apache.syncope.wa.bootstrap.WARestClient;
+import org.jooq.lambda.Unchecked;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public class SyncopeWAWebAuthnCredentialRepository extends BaseWebAuthnCredentialRepository {
+    private static final Logger LOG = LoggerFactory.getLogger(SyncopeWAWebAuthnCredentialRepository.class);
+
+    private final WARestClient waRestClient;
+
+    public SyncopeWAWebAuthnCredentialRepository(final CasConfigurationProperties properties,
+                                                 final WARestClient waRestClient) {
+        super(properties, CipherExecutor.noOpOfStringToString());
+        this.waRestClient = waRestClient;
+    }
+
+    @Override
+    public boolean removeRegistrationByUsername(final String username,
+                                                final CredentialRegistration credentialRegistration) {
+        val id = credentialRegistration.getCredential().getCredentialId().getHex();
+        getService().delete(username, id);
+        return true;
+    }
+
+    @Override
+    public boolean removeAllRegistrations(final String username) {
+        getService().delete(username);
+        return true;
+    }
+
+    @Override
+    protected Stream<CredentialRegistration> load() {
+        return getService().list().
+            stream().
+            map(WebAuthnAccount::getRecords).
+            flatMap(Collection::stream).
+            map(Unchecked.function(record -> {
+                String json = getCipherExecutor().decode(record.getJson());
+                return getObjectMapper().readValue(json, new TypeReference<>() {
+                });
+            }));
+    }
+
+    @Override
+    protected void update(final String username, final Collection<CredentialRegistration> records) {
+        try {
+            List<WebAuthnDeviceCredential> devices = records.stream().
+                map(Unchecked.function(record -> {
+                    String json = getCipherExecutor().encode(getObjectMapper().writeValueAsString(record));
+                    return new WebAuthnDeviceCredential.Builder().
+                        json(json).
+                        owner(username).
+                        identifier(record.getCredential().getCredentialId().getHex()).
+                        build();
+                })).
+                collect(Collectors.toList());
+
+            WebAuthnAccount account = getService().findAccountFor(username);
+            if (account != null) {
+                account.setRecords(devices);
+                getService().update(account);
+            } else {
+                account = new WebAuthnAccount.Builder()
+                    .owner(username)
+                    .records(devices)
+                    .build();
+                getService().create(account);
+            }
+        } catch (final Exception e) {
+            LOG.error(e.getMessage(), e);
+        }
+    }
+
+    @Override
+    public Collection<CredentialRegistration> getRegistrationsByUsername(final String username) {
+        try {
+            WebAuthnAccount account = getService().findAccountFor(username);
+            if (account != null) {
+
+                return account.getRecords().stream().
+                    map(Unchecked.function(record -> {
+                        String json = getCipherExecutor().decode(record.getJson());
+                        return getObjectMapper().readValue(json, new TypeReference<CredentialRegistration>() {
+                        });
+                    })).
+                    collect(Collectors.toList());
+            }
+        } catch (final SyncopeClientException e) {
+            if (e.getType() == ClientExceptionType.NotFound) {
+                LOG.info("Could not locate account for {}", username);
+            } else {
+                LOG.error(e.getMessage(), e);
+            }
+        } catch (final Exception e) {
+            LOG.error(e.getMessage(), e);
+        }
+        return List.of();
+    }
+
+    private WebAuthnRegistrationService getService() {
+        if (!WARestClient.isReady()) {
+            throw new RuntimeException("Syncope core is not yet ready");
+        }
+        return waRestClient.getSyncopeClient().getService(WebAuthnRegistrationService.class);
+    }
+}