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);
+ }
+}