Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Update URL to allow user to complete WebAuthn registration in app #1638

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ public interface ConstantKeys {
// Passwordless keys.
String WEBAUTHN_SKIPPED_KEY = "webAuthnRegistrationSkipped";
String WEBAUTHN_CREDENTIAL_ID_CONTEXT_KEY = "webAuthnCredentialId";
String WEBAUTHN_REDIRECT_URI = "redirect_uri";
String WEBAUTHN_REGISTRATION_TOKEN = "registration_token";
String PARAM_AUTHENTICATOR_ATTACHMENT_KEY = "authenticatorAttachment";
String PASSWORDLESS_AUTH_COMPLETED_KEY = "passwordlessAuthCompleted";
String PASSWORDLESS_CHALLENGE_KEY = "challenge";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@
import io.gravitee.am.gateway.handler.common.vertx.web.auth.handler.OAuth2AuthHandler;
import io.gravitee.am.gateway.handler.common.vertx.web.auth.provider.OAuth2AuthProvider;
import io.gravitee.am.gateway.handler.common.vertx.web.handler.ErrorHandler;
import io.gravitee.am.jwt.JWTBuilder;
import io.gravitee.am.model.Domain;
import io.gravitee.common.service.AbstractService;
import io.vertx.reactivex.core.Vertx;
import io.vertx.reactivex.ext.web.Router;
import io.vertx.reactivex.ext.web.handler.BodyHandler;
import io.vertx.reactivex.ext.web.handler.CorsHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.ApplicationContext;

/**
Expand Down Expand Up @@ -64,6 +66,10 @@ public class AccountProvider extends AbstractService<ProtocolProvider> implement
@Autowired
private CorsHandler corsHandler;

@Autowired
@Qualifier("managementJwtBuilder")
private JWTBuilder jwtBuilder;

@Override
protected void doStart() throws Exception {
super.doStart();
Expand Down Expand Up @@ -133,13 +139,19 @@ protected void doStart() throws Exception {

// WebAuthn credentials routes
AccountWebAuthnCredentialsEndpointHandler accountWebAuthnCredentialsEndpointHandler =
new AccountWebAuthnCredentialsEndpointHandler(accountService);
new AccountWebAuthnCredentialsEndpointHandler(accountService, jwtBuilder);
accountRouter.get(AccountRoutes.WEBAUTHN_CREDENTIALS.getRoute())
.handler(accountHandler::getUser)
.handler(accountWebAuthnCredentialsEndpointHandler::listEnrolledWebAuthnCredentials);
accountRouter.get(AccountRoutes.WEBAUTHN_CREDENTIALS_BY_ID.getRoute())
.handler(accountHandler::getUser)
.handler(accountWebAuthnCredentialsEndpointHandler::getEnrolledWebAuthnCredential);
accountRouter.delete(AccountRoutes.WEBAUTHN_CREDENTIALS_BY_ID.getRoute())
.handler(accountHandler::getUser)
.handler(accountWebAuthnCredentialsEndpointHandler::deleteWebAuthnCredential);
accountRouter.post(AccountRoutes.API_TOKEN.getRoute())
.handler(accountHandler::getUser)
.handler(accountWebAuthnCredentialsEndpointHandler::createToken);

// error handler
accountRouter.route().failureHandler(new ErrorHandler());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,33 @@
*/
package io.gravitee.am.gateway.handler.account.resources;

import io.gravitee.am.gateway.handler.account.services.AccountService;
import io.gravitee.am.common.jwt.Claims;
import io.gravitee.am.common.jwt.JWT;
import io.gravitee.am.common.utils.ConstantKeys;
import io.gravitee.am.gateway.handler.account.services.AccountService;
import io.gravitee.am.jwt.JWTBuilder;
import io.gravitee.am.model.User;
import io.gravitee.am.model.oidc.Client;
import io.vertx.core.json.JsonObject;
import io.vertx.reactivex.ext.web.RoutingContext;

import java.util.Map;
import java.util.concurrent.TimeUnit;

import static io.gravitee.am.common.utils.ConstantKeys.WEBAUTHN_REDIRECT_URI;

/**
* @author Titouan COMPIEGNE (titouan.compiegne at graviteesource.com)
* @author GraviteeSource Team
*/
public class AccountWebAuthnCredentialsEndpointHandler {

private AccountService accountService;
private JWTBuilder jwtBuilder;

public AccountWebAuthnCredentialsEndpointHandler(AccountService accountService) {
public AccountWebAuthnCredentialsEndpointHandler(AccountService accountService, JWTBuilder jwtBuilder) {
this.accountService = accountService;
this.jwtBuilder = jwtBuilder;
}

/**
Expand Down Expand Up @@ -60,4 +72,42 @@ public void getEnrolledWebAuthnCredential(RoutingContext routingContext) {
error -> routingContext.fail(error)
);
}

/**
* Delete enrolled WebAuthn credential detail for the current user
* @param routingContext the routingContext holding the current user
*/
public void deleteWebAuthnCredential(RoutingContext routingContext) {
final String id = routingContext.request().getParam("credentialId");

accountService.removeWebAuthnCredential(id)
.subscribe(
() -> AccountResponseHandler.handleNoBodyResponse(routingContext),
error -> routingContext.fail(error)
);
}

/**
* Create a JWT token with the redirect uri found in the request
* @param routingContext he routingContext holding the current user
*/
public void createToken(RoutingContext routingContext) {
final String token = "token";
final User user = routingContext.get(ConstantKeys.USER_CONTEXT_KEY);
final Client client = routingContext.get(ConstantKeys.CLIENT_CONTEXT_KEY);
final JsonObject requestBody = routingContext.getBodyAsJson();

final long iatValue = System.currentTimeMillis() / 1000;
final long expValue = iatValue + TimeUnit.MINUTES.toMillis(2) / 1000;

final Map<String, Object> claims = Map.of(
Claims.sub, user.getId(),
Claims.aud, client.getId(),
Claims.iat, iatValue,
Claims.exp, expValue,
WEBAUTHN_REDIRECT_URI, requestBody.getString(WEBAUTHN_REDIRECT_URI)
);

AccountResponseHandler.handleDefaultResponse(routingContext, Map.of(token, jwtBuilder.sign(new JWT(claims))));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ public enum AccountRoutes {
FACTORS_RECOVERY_CODE("/api/auth/recovery_code"),
FACTORS_VERIFY("/api/factors/:factorId/verify"),
WEBAUTHN_CREDENTIALS("/api/webauthn/credentials"),
WEBAUTHN_CREDENTIALS_BY_ID("/api/webauthn/credentials/:credentialId");
WEBAUTHN_CREDENTIALS_BY_ID("/api/webauthn/credentials/:credentialId"),
API_TOKEN("/api/token");

private String route;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,7 @@ public interface AccountService {
Single<List<Credential>> getWebAuthnCredentials(User user);

Single<Credential> getWebAuthnCredential(String id);

Completable removeWebAuthnCredential(String id);

}
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,11 @@ public Single<Credential> getWebAuthnCredential(String id) {
});
}

@Override
public Completable removeWebAuthnCredential(String id) {
return credentialService.delete(id);
}

private io.gravitee.am.identityprovider.api.User convert(io.gravitee.am.model.User user) {
DefaultUser idpUser = new DefaultUser(user.getUsername());
idpUser.setId(user.getExternalId());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Copyright (C) 2015 The Gravitee team (http://gravitee.io)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.gravitee.am.gateway.handler.account.resources;

import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;
import io.gravitee.am.common.jwt.Claims;
import io.gravitee.am.common.jwt.JWT;
import io.gravitee.am.common.jwt.SignatureAlgorithm;
import io.gravitee.am.common.utils.ConstantKeys;
import io.gravitee.am.gateway.handler.account.services.AccountService;
import io.gravitee.am.gateway.handler.common.vertx.RxWebTestBase;
import io.gravitee.am.gateway.handler.common.vertx.web.handler.ErrorHandler;
import io.gravitee.am.jwt.DefaultJWTBuilder;
import io.gravitee.am.jwt.DefaultJWTParser;
import io.gravitee.am.jwt.JWTBuilder;
import io.gravitee.am.jwt.JWTParser;
import io.gravitee.am.model.User;
import io.gravitee.am.model.oidc.Client;
import io.reactivex.Completable;
import io.vertx.core.http.HttpMethod;
import io.vertx.core.json.Json;
import io.vertx.core.json.JsonObject;
import io.vertx.reactivex.core.buffer.Buffer;
import io.vertx.reactivex.ext.web.handler.BodyHandler;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;

import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.util.Map;

import static io.gravitee.am.common.utils.ConstantKeys.WEBAUTHN_REDIRECT_URI;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

/**
* @author Ashraful Hasan (ashraful.hasan at graviteesource.com)
* @author GraviteeSource Team
*/
@RunWith(MockitoJUnitRunner.class)
public class AccountWebAuthnCredentialsEndpointHandlerTest extends RxWebTestBase {

@Mock
AccountService accountService;

private AccountWebAuthnCredentialsEndpointHandler accountWebAuthnCredentialsEndpointHandler;
private JWTParser jwtParser;

@Override
public void setUp() throws Exception {
super.setUp();

final User user = new User();
user.setId("someUserid");
final Client client = new Client();
client.setClientId("someClientId");
client.setId("someAudienceId");

RSAKey rsaJWK = new RSAKeyGenerator(2048)
.keyID("123")
.generate();
RSAPrivateKey rsaPrivateKey = rsaJWK.toRSAPrivateKey();
RSAPublicKey rsaPublicKey = rsaJWK.toRSAPublicKey();
JWTBuilder jwtBuilder = new DefaultJWTBuilder(rsaPrivateKey, SignatureAlgorithm.RS256.getValue(), rsaJWK.getKeyID());

jwtParser = new DefaultJWTParser(rsaPublicKey);
accountWebAuthnCredentialsEndpointHandler = new AccountWebAuthnCredentialsEndpointHandler(accountService, jwtBuilder);

router.route()
.handler(ctx -> {
ctx.put(ConstantKeys.CLIENT_CONTEXT_KEY, client);
ctx.put(ConstantKeys.USER_CONTEXT_KEY, user);
ctx.next();
})
.handler(BodyHandler.create())
.failureHandler(new ErrorHandler());
}

@Test
public void shouldBeAbleToDelete() throws Exception {
final String requestPath = "/api/webauthn/credentials/1234";
when(accountService.removeWebAuthnCredential(any())).thenReturn(Completable.complete());

router.route(requestPath)
.handler(accountWebAuthnCredentialsEndpointHandler::deleteWebAuthnCredential)
.handler(rc -> rc.response().end());

testRequest(HttpMethod.DELETE,
requestPath,
req -> {
},
resp -> resp.bodyHandler(body -> {
verify(accountService).removeWebAuthnCredential(any());
}),
204,
"No Content", null);
}

@Test
public void shouldBeAbleToCreateToken() throws Exception {
final String requestPath = "/self-account-management-test/account/api/token";
router.route(requestPath)
.handler(accountWebAuthnCredentialsEndpointHandler::createToken)
.handler(rc -> rc.response().end());

testRequest(HttpMethod.POST,
requestPath,
req -> {
final Buffer buffer = Buffer.buffer();
buffer.appendString(createTokenPayLoad());
req.headers().set("content-length", String.valueOf(buffer.length()));
req.headers().set("content-type", "application/json");
req.write(buffer);
},
resp -> resp.bodyHandler(body -> {
final Map<String, Object> data = Json.decodeValue(body.toString(), Map.class);
final JWT parsedJWT = jwtParser.parse((String) data.get("token"));

assertTrue("should have 'iat' key", parsedJWT.containsKey(Claims.iat));
assertTrue("should have 'aud' key", parsedJWT.containsKey(Claims.aud));
assertEquals("subject value should be 'someUserid'", "someUserid", parsedJWT.getSub());
assertEquals("audience value should be 'someAudienceId'", "someAudienceId", parsedJWT.getAud());
assertEquals("redirect uri value should 'https://someuri.com'", "https://someuri.com", parsedJWT.get(WEBAUTHN_REDIRECT_URI));
}),
200,
"OK", null);
}

private String createTokenPayLoad() {
return new JsonObject()
.put("redirect_uri", "https://someuri.com").toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -336,15 +336,15 @@ protected void doStart() throws Exception {
rootRouter.route(PATH_WEBAUTHN_REGISTER)
.handler(clientRequestParseHandler)
.handler(webAuthnAccessHandler)
.handler(new WebAuthnRegisterEndpoint(domain, userAuthenticationManager, webAuthn, thymeleafTemplateEngine));
.handler(new WebAuthnRegisterEndpoint(domain, userAuthenticationManager, webAuthn, thymeleafTemplateEngine, userService));
rootRouter.route(PATH_WEBAUTHN_LOGIN)
.handler(clientRequestParseHandler)
.handler(webAuthnAccessHandler)
.handler(new WebAuthnLoginEndpoint(domain, userAuthenticationManager, webAuthn, thymeleafTemplateEngine, deviceIdentifierManager, deviceService));
rootRouter.post(PATH_WEBAUTHN_RESPONSE)
.handler(clientRequestParseHandler)
.handler(webAuthnAccessHandler)
.handler(new WebAuthnResponseEndpoint(userAuthenticationManager, webAuthn, credentialService, domain));
.handler(new WebAuthnResponseEndpoint(userAuthenticationManager, webAuthn, credentialService, domain, userService));

// Registration route
Handler<RoutingContext> registerAccessHandler = new RegisterAccessHandler(domain);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,32 +18,40 @@
import io.gravitee.am.common.exception.authentication.UsernameNotFoundException;
import io.gravitee.am.gateway.handler.common.auth.user.UserAuthenticationManager;
import io.gravitee.am.gateway.handler.root.resources.endpoint.AbstractEndpoint;
import io.gravitee.am.gateway.handler.root.service.user.UserService;
import io.gravitee.am.gateway.handler.root.service.user.model.UserToken;
import io.gravitee.am.model.User;
import io.gravitee.am.model.oidc.Client;
import io.gravitee.gateway.api.Request;
import io.vertx.core.AsyncResult;
import io.vertx.core.Future;
import io.vertx.core.Handler;
import io.vertx.core.json.JsonObject;
import io.vertx.reactivex.core.MultiMap;
import io.vertx.reactivex.ext.web.RoutingContext;
import io.vertx.reactivex.ext.web.common.template.TemplateEngine;

import static io.gravitee.am.common.utils.ConstantKeys.WEBAUTHN_REGISTRATION_TOKEN;

/**
* @author Titouan COMPIEGNE (titouan.compiegne at graviteesource.com)
* @author GraviteeSource Team
*/
public abstract class WebAuthnEndpoint extends AbstractEndpoint implements Handler<RoutingContext> {

protected final UserAuthenticationManager userAuthenticationManager;
private UserService userService;

WebAuthnEndpoint(TemplateEngine templateEngine, UserAuthenticationManager userAuthenticationManager) {
WebAuthnEndpoint(TemplateEngine templateEngine, UserAuthenticationManager userAuthenticationManager, UserService userService) {
super(templateEngine);
this.userAuthenticationManager = userAuthenticationManager;
this.userService = userService;
}

WebAuthnEndpoint(UserAuthenticationManager userAuthenticationManager) {
WebAuthnEndpoint(UserAuthenticationManager userAuthenticationManager, UserService userService) {
super(null);
this.userAuthenticationManager = userAuthenticationManager;
this.userService = userService;
}

/**
Expand Down Expand Up @@ -90,4 +98,17 @@ protected static boolean isEmptyObject(JsonObject json, String key) {
return true;
}
}

protected boolean isSelfRegistration(MultiMap queryParams) {
return queryParams.get(WEBAUTHN_REGISTRATION_TOKEN) != null;
}

protected void validateToken(String token, Handler<AsyncResult<UserToken>> handler) {
userService.verifyToken(token)
.subscribe(
userToken ->
handler.handle(Future.succeededFuture(userToken)),
error -> handler.handle(Future.failedFuture(error)));
}

}
Loading