Skip to content

Commit

Permalink
KEYCLOAK-XXX PoC for (re-)validation of credentials after authentication
Browse files Browse the repository at this point in the history
In business application one often sees the requirement to periodically
reauthenticate the user without requiring the user to login again.
A common example for this is that users need to reenter a OTP credential
before performing a certain critical operation.

This PoC proposes a new resource (CredentialValidationService) which
is available under the `$KC_URL/realms/$KC_REALM/credential-validation`
endpoint.

Clients can now revalidate credentials by sending them in POST request in
JSON form. A http response status 200 means that the credentials were correct
otherwise http response 400 bad request will be returned.

The following curl example demonstrates the use of the endpoint.
Note that for this to work one needs to prepare keycloak a bit.

* create a `test-client` with direct access grants enabled.
* create a user `tester` with password `test
* setup an otp code for that user (e.g. by using FreeOTP app for android)
* in the admin-console -> goto realm -> authentication -> flows ->
direct grant -> mark OTP as disabled (otherwise we cannot get
the access-token in the way shown...)

 # Variables for OIDC Requests
```
KC_REALM=otp-validation-test
KC_USERNAME=tester
KC_PASSWORD=test
KC_CLIENT=test-client
KC_CLIENT_SECRET=c57dc179-09bb-4bb7-9128-91b29dd7fc35
KC_URL="http://localhost:8081/auth"

## Request Tokens for credentials
KC_RESPONSE=$( \
   curl -v \
        -d "username=$KC_USERNAME" \
        -d "password=$KC_PASSWORD" \
        -d 'grant_type=password' \
        -d "client_id=$KC_CLIENT" \
        -d "client_secret=$KC_CLIENT_SECRET" \
        "$KC_URL/realms/$KC_REALM/protocol/openid-connect/token" \
    | jq .
)

KC_ACCESS_TOKEN=$(echo $KC_RESPONSE| jq -r .access_token)
KC_ID_TOKEN=$(echo $KC_RESPONSE| jq -r .id_token)
KC_REFRESH_TOKEN=$(echo $KC_RESPONSE| jq -r .refresh_token)

## Validate a `totp` credential
$ curl -v \
     -H "Authorization: Bearer $KC_ACCESS_TOKEN" \
     -H "Content-Type: application/json" \
     -d '[{"type":"totp", "value":"949784"}]' \
     $KC_URL/realms/$KC_REALM/credential-validation
```
  • Loading branch information
thomasdarimont committed Nov 10, 2016
1 parent 52a4509 commit e891ca6
Show file tree
Hide file tree
Showing 3 changed files with 181 additions and 0 deletions.
2 changes: 2 additions & 0 deletions server-spi/src/main/java/org/keycloak/events/EventType.java
Expand Up @@ -59,6 +59,8 @@ public enum EventType {
UPDATE_PROFILE_ERROR(true),
UPDATE_PASSWORD(true),
UPDATE_PASSWORD_ERROR(true),
VERIFY_TOTP(true),
VERIFY_TOTP_ERROR(true),
UPDATE_TOTP(true),
UPDATE_TOTP_ERROR(true),
VERIFY_EMAIL(true),
Expand Down
@@ -0,0 +1,167 @@
package org.keycloak.services.resources;

import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.OAuthErrorException;
import org.keycloak.RSATokenVerifier;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.VerificationException;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventType;
import org.keycloak.models.ClientModel;
import org.keycloak.models.ClientSessionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.services.ErrorResponseException;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;

import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import javax.ws.rs.ext.Providers;
import java.util.List;

/**
* Service for validating credentials after authentication.
*
* @author <a href="mailto:thomas.darimont@gmail.com">Thomas Darimont</a>
*/
public class CredentialValidationService {

private RealmModel realm;

@Context
private HttpRequest request;

@Context
protected HttpHeaders headers;

@Context
private UriInfo uriInfo;

@Context
private ClientConnection clientConnection;

@Context
protected Providers providers;

@Context
protected KeycloakSession session;

private final EventBuilder event;

private final AppAuthManager appAuthManager;

public CredentialValidationService(RealmModel realm, EventBuilder event) {
this.realm = realm;
this.event = event.event(EventType.VERIFY_TOTP);
this.appAuthManager = new AppAuthManager();
}

@POST
@Consumes(MediaType.APPLICATION_JSON)
public Response validateCredentials(List<CredentialRepresentation> credentialRepresentations) {

String accessTokenString = this.appAuthManager.extractAuthorizationHeaderToken(request.getHttpHeaders());

if (accessTokenString == null) {
event.error(Errors.INVALID_TOKEN);
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Token not provided", Response.Status.BAD_REQUEST);
}

AccessToken accessToken = verifyToken(accessTokenString, event);

UserSessionModel userSession = session.sessions().getUserSession(realm, accessToken.getSessionState());

if (userSession == null) {
event.error(Errors.USER_SESSION_NOT_FOUND);
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "User session not found", Response.Status.BAD_REQUEST);
}
event.session(userSession);

UserModel userModel = userSession.getUser();
if (userModel == null) {
event.error(Errors.USER_NOT_FOUND);
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "User not found", Response.Status.BAD_REQUEST);
}
event.user(userModel)
.detail(Details.USERNAME, userModel.getUsername());

ClientSessionModel clientSession = session.sessions().getClientSession(accessToken.getClientSession());
if (clientSession == null || !AuthenticationManager.isSessionValid(realm, userSession)) {
event.error(Errors.SESSION_EXPIRED);
throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Session expired", Response.Status.UNAUTHORIZED);
}

ClientModel clientModel = realm.getClientByClientId(accessToken.getIssuedFor());
if (clientModel == null) {
event.error(Errors.CLIENT_NOT_FOUND);
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Client not found", Response.Status.BAD_REQUEST);
}
event.client(clientModel);

if (!clientModel.isEnabled()) {
event.error(Errors.CLIENT_DISABLED);
throw new ErrorResponseException(OAuthErrorException.INVALID_REQUEST, "Client disabled", Response.Status.BAD_REQUEST);
}

UserModel user = session.users().getUserById(accessToken.getSubject(), realm);

if (user == null) {
return Response.status(Response.Status.BAD_REQUEST).build();
}

boolean allValid = validateCredentials(credentialRepresentations, user);

if (allValid) {
event.success();
Response.ResponseBuilder responseBuilder = Response.ok();
return Cors.add(request, responseBuilder).auth().allowedOrigins(accessToken).build();
}

event.error(Errors.INVALID_REQUEST);

return Response.status(Response.Status.BAD_REQUEST).build();
}

private AccessToken verifyToken(String accessToken, EventBuilder event) {
try {
RSATokenVerifier verifier = RSATokenVerifier.create(accessToken)
.realmUrl(Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName()));
String kid = verifier.getHeader().getKeyId();
verifier.publicKey(session.keys().getPublicKey(realm, kid));
return verifier.verify().getToken();
} catch (VerificationException e) {
event.error(Errors.INVALID_TOKEN);
throw new ErrorResponseException(OAuthErrorException.INVALID_TOKEN, "Token invalid: " + e.getMessage(), Response.Status.UNAUTHORIZED);
}
}

private boolean validateCredentials(List<CredentialRepresentation> credentialRepresentations, UserModel user) {

boolean allValid = true;
for (CredentialRepresentation credentialRepresentation : credentialRepresentations) {

UserCredentialModel credentials = new UserCredentialModel();
credentials.setType(credentialRepresentation.getType());
credentials.setValue(credentialRepresentation.getValue());
boolean credentialValid = session.userCredentialManager().isValid(realm, user, credentials);

allValid &= credentialValid;
}

return allValid;
}
}
Expand Up @@ -28,6 +28,7 @@
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.utils.CredentialValidation;
import org.keycloak.protocol.LoginProtocol;
import org.keycloak.protocol.LoginProtocolFactory;
import org.keycloak.services.clientregistration.ClientRegistrationService;
Expand Down Expand Up @@ -167,6 +168,17 @@ public Response getRedirect(final @PathParam("realm") String realmName, final @P
return Response.seeOther(targetUri).build();
}

@Path("{realm}/credential-validation")
public CredentialValidationService getCredentialsValidationService(final @PathParam("realm") String name){

RealmModel realm = init(name);
EventBuilder event = new EventBuilder(realm, session, clientConnection);
CredentialValidationService service = new CredentialValidationService(realm, event);
ResteasyProviderFactory.getInstance().injectProperties(service);

return service;
}

@Path("{realm}/login-actions")
public LoginActionsService getLoginActionsService(final @PathParam("realm") String name) {
RealmModel realm = init(name);
Expand Down

0 comments on commit e891ca6

Please sign in to comment.