Merge pull request #607 from patriot1burke/master

change logout behavior
This commit is contained in:
Bill Burke 2014-08-10 09:09:32 -04:00
commit 4a18a32cfc
17 changed files with 225 additions and 96 deletions

View file

@ -12,6 +12,7 @@
<!ENTITY JBossAdapter SYSTEM "modules/jboss-adapter.xml">
<!ENTITY JavascriptAdapter SYSTEM "modules/javascript-adapter.xml">
<!ENTITY InstalledApplications SYSTEM "modules/installed-applications.xml">
<!ENTITY Logout SYSTEM "modules/logout.xml">
<!ENTITY SocialConfig SYSTEM "modules/social-config.xml">
<!ENTITY SocialFacebook SYSTEM "modules/social-facebook.xml">
<!ENTITY SocialGitHub SYSTEM "modules/social-github.xml">
@ -80,6 +81,7 @@ This one is short
&JBossAdapter;
&JavascriptAdapter;
&InstalledApplications;
&Logout;
</chapter>
<chapter>

View file

@ -1,5 +1,16 @@
<chapter id="Migration_from_older_versions">
<title>Migration from older versions</title>
<sect1>
<title>Migrating from 1.0 Beta 4 to RC-1</title>
<itemizedlist>
<listitem>
logout REST API has been refactored. The GET request on the logout URI does not take a session_state
parameter anymore. You must be logged in in order to log out the session.
You can also POST to the lougt REST URI. This action requires a valid refresh token to perform the logout.
The signature is the same as refresh token minus the grant type form parameter. See documentation for details.
</listitem>
</itemizedlist>
</sect1>
<sect1>
<title>Migrating from 1.0 Beta 1 to Beta 4</title>
<itemizedlist>

View file

@ -88,7 +88,7 @@ try {
if (isPublic()) { // if client is public access type
formparams.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, "customer-portal"));
} else {
String authorization = BasicAuthHelper.createHeader("customer-portal", "secret-secret-secret);
String authorization = BasicAuthHelper.createHeader("customer-portal", "secret-secret-secret");
post.setHeader("Authorization", authorization);
}
UrlEncodedFormEntity form = new UrlEncodedFormEntity(formparams, "UTF-8");
@ -125,4 +125,36 @@ GET /my/rest/api
Authorization: Bearer 2YotnFZFEjr1zCsicMWpAA
</programlisting>
</para>
<para>
To logout you must use the refresh token contained in the AccessTokenResponse object.
</para>
<programlisting>
<![CDATA[
List<NameValuePair> formparams = new ArrayList<NameValuePair>();
if (isPublic()) { // if client is public access type
formparams.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, "customer-portal"));
} else {
String authorization = BasicAuthHelper.createHeader("customer-portal", "secret-secret-secret");
post.setHeader("Authorization", authorization);
}
formparams.add(new BasicNameValuePair(OAuth2Constants.REFRESH_TOKEN, tokenResponse.getRefreshToken()));
HttpResponse response = null;
URI logoutUri = KeycloakUriBuilder.fromUri(getBaseUrl(request) + "/auth")
.path(ServiceUrlConstants.TOKEN_SERVICE_LOGOUT_PATH)
.build("demo");
HttpPost post = new HttpPost(logoutUri);
UrlEncodedFormEntity form = new UrlEncodedFormEntity(formparams, "UTF-8");
post.setEntity(form);
response = client.execute(post);
int status = response.getStatusLine().getStatusCode();
HttpEntity entity = response.getEntity();
if (status != 204) {
error(status, entity);
}
if (entity == null) {
return;
}
InputStream is = entity.getContent();
if (is != null) is.close();
]]></programlisting>
</chapter>

View file

@ -0,0 +1,9 @@
<section>
<title>Logout</title>
<para>
There are multiple ways you can logout from a web application. For Java EE servlet containers, you can call
HttpServletRequest.logout().
For any other browser application, you can point the browser at the url <literal>http://auth-server/auth/realms/{realm-name}/tokens/logout?redirect_uri=encodedRedirectUri</literal>.
This will log you out if you have an SSO session with your browser.
</para>
</section>

View file

@ -102,17 +102,23 @@ public class AdminClient {
try {
HttpGet get = new HttpGet(KeycloakUriBuilder.fromUri(getBaseUrl(request) + "/auth")
HttpPost post = new HttpPost(KeycloakUriBuilder.fromUri(getBaseUrl(request) + "/auth")
.path(ServiceUrlConstants.TOKEN_SERVICE_LOGOUT_PATH)
.queryParam("session_state", res.getSessionState())
.build("demo"));
HttpResponse response = client.execute(get);
List<NameValuePair> formparams = new ArrayList<NameValuePair>();
formparams.add(new BasicNameValuePair(OAuth2Constants.REFRESH_TOKEN, res.getRefreshToken()));
formparams.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, "admin-client"));
HttpResponse response = client.execute(post);
boolean status = response.getStatusLine().getStatusCode() != 204;
HttpEntity entity = response.getEntity();
if (entity == null) {
return;
}
InputStream is = entity.getContent();
if (is != null) is.close();
if (status) {
throw new RuntimeException("failed to logout");
}
} finally {
client.getConnectionManager().shutdown();
}

View file

@ -42,6 +42,14 @@ public class RefreshableKeycloakSecurityContext extends KeycloakSecurityContext
return super.getTokenString();
}
public void logout(KeycloakDeployment deployment) {
try {
ServerRequest.invokeLogout(deployment, refreshToken);
} catch (Exception e) {
log.error("failed to invoke remote logout", e);
}
}
public boolean isActive() {
return this.token.isActive() && this.token.getIssuedAt() > deployment.getNotBefore();
}

View file

@ -48,19 +48,41 @@ public class ServerRequest {
}
}
public static void invokeLogout(KeycloakDeployment deployment, String sessionId) throws IOException, HttpFailure {
URI uri = deployment.getLogoutUrl().clone().queryParam("session_state", sessionId).build();
HttpGet logout = new HttpGet(uri);
HttpResponse response = deployment.getClient().execute(logout);
public static void invokeLogout(KeycloakDeployment deployment, String refreshToken) throws IOException, HttpFailure {
String client_id = deployment.getResourceName();
Map<String, String> credentials = deployment.getResourceCredentials();
HttpClient client = deployment.getClient();
URI uri = deployment.getLogoutUrl().clone().build();
List<NameValuePair> formparams = new ArrayList<NameValuePair>();
for (Map.Entry<String, String> entry : credentials.entrySet()) {
formparams.add(new BasicNameValuePair(entry.getKey(), entry.getValue()));
}
formparams.add(new BasicNameValuePair(OAuth2Constants.REFRESH_TOKEN, refreshToken));
HttpResponse response = null;
HttpPost post = new HttpPost(uri);
if (!deployment.isPublicClient()) {
String clientSecret = credentials.get(CredentialRepresentation.SECRET);
if (clientSecret != null) {
String authorization = BasicAuthHelper.createHeader(client_id, clientSecret);
post.setHeader("Authorization", authorization);
}
} else {
formparams.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, client_id));
}
UrlEncodedFormEntity form = new UrlEncodedFormEntity(formparams, "UTF-8");
post.setEntity(form);
response = client.execute(post);
int status = response.getStatusLine().getStatusCode();
HttpEntity entity = response.getEntity();
if (status != 200) {
if (status != 204) {
error(status, entity);
}
if (entity == null) {
return;
}
entity.getContent().close();
InputStream is = entity.getContent();
if (is != null) is.close();
}
public static AccessTokenResponse invokeAccessCodeToToken(KeycloakDeployment deployment, String code, String redirectUri) throws HttpFailure, IOException {

View file

@ -64,10 +64,8 @@ public class KeycloakAuthenticatorValve extends FormAuthenticator implements Lif
Session session = request.getSessionInternal(false);
if (session != null) {
session.removeNote(KeycloakSecurityContext.class.getName());
try {
ServerRequest.invokeLogout(deploymentContext.getDeployment(), ksc.getToken().getSessionState());
} catch (Exception e) {
log.error("failed to invoke remote logout", e);
if (ksc instanceof RefreshableKeycloakSecurityContext) {
((RefreshableKeycloakSecurityContext)ksc).logout(deploymentContext.getDeployment());
}
}
}

View file

@ -81,11 +81,8 @@ public class ServletKeycloakAuthMech extends UndertowKeycloakAuthMech {
if (account == null) return;
session.removeAttribute(KeycloakSecurityContext.class.getName());
session.removeAttribute(KeycloakUndertowAccount.class.getName());
String sessionId = account.getKeycloakSecurityContext().getToken().getSessionState();
try {
ServerRequest.invokeLogout(deploymentContext.getDeployment(), sessionId);
} catch (Exception e) {
log.error("failed to invoke remote logout", e);
if (account.getKeycloakSecurityContext() != null) {
account.getKeycloakSecurityContext().logout(deploymentContext.getDeployment());
}
}
};

View file

@ -72,11 +72,8 @@ public abstract class UndertowKeycloakAuthMech implements AuthenticationMechanis
KeycloakUndertowAccount account = (KeycloakUndertowAccount)session.getAttribute(KeycloakUndertowAccount.class.getName());
if (account == null) return;
session.removeAttribute(KeycloakUndertowAccount.class.getName());
String sessionId = account.getKeycloakSecurityContext().getToken().getSessionState();
try {
ServerRequest.invokeLogout(deploymentContext.getDeployment(), sessionId);
} catch (Exception e) {
log.error("failed to invoke remote logout", e);
if (account.getKeycloakSecurityContext() != null) {
account.getKeycloakSecurityContext().logout(deploymentContext.getDeployment());
}
}
};

View file

@ -64,23 +64,7 @@ public class TokenManager {
}
public AccessToken refreshAccessToken(KeycloakSession session, UriInfo uriInfo, ClientConnection connection, RealmModel realm, ClientModel client, String encodedRefreshToken, Audit audit) throws OAuthErrorException {
JWSInput jws = new JWSInput(encodedRefreshToken);
RefreshToken refreshToken = null;
try {
if (!RSAProvider.verify(jws, realm.getPublicKey())) {
throw new RuntimeException("Invalid refresh token");
}
refreshToken = jws.readJsonContent(RefreshToken.class);
} catch (IOException e) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token", e);
}
if (refreshToken.isExpired()) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh token expired");
}
if (refreshToken.getIssuedAt() < realm.getNotBefore()) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale refresh token");
}
RefreshToken refreshToken = verifyRefreshToken(realm, encodedRefreshToken);
audit.user(refreshToken.getSubject()).session(refreshToken.getSessionState()).detail(Details.REFRESH_TOKEN_ID, refreshToken.getId());
@ -122,6 +106,27 @@ public class TokenManager {
return accessToken;
}
public RefreshToken verifyRefreshToken(RealmModel realm, String encodedRefreshToken) throws OAuthErrorException {
JWSInput jws = new JWSInput(encodedRefreshToken);
RefreshToken refreshToken = null;
try {
if (!RSAProvider.verify(jws, realm.getPublicKey())) {
throw new RuntimeException("Invalid refresh token");
}
refreshToken = jws.readJsonContent(RefreshToken.class);
} catch (IOException e) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Invalid refresh token", e);
}
if (refreshToken.isExpired()) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Refresh token expired");
}
if (refreshToken.getIssuedAt() < realm.getNotBefore()) {
throw new OAuthErrorException(OAuthErrorException.INVALID_GRANT, "Stale refresh token");
}
return refreshToken;
}
public AccessToken createClientAccessToken(Set<RoleModel> requestedRoles, RealmModel realm, ClientModel client, UserModel user, UserSessionModel session) {
AccessToken token = initToken(realm, client, user, session);
for (RoleModel role : requestedRoles) {

View file

@ -31,6 +31,7 @@ import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.KeycloakModelUtils;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.representations.RefreshToken;
import org.keycloak.representations.idm.CredentialRepresentation;
import org.keycloak.ClientConnection;
import org.keycloak.services.managers.AccessCode;
@ -1040,39 +1041,29 @@ public class TokenService {
}
/**
* Logout user session.
* Logout user session. User must be logged in via a session cookie.
*
* @param sessionState
* @param redirectUri
* @return
*/
@Path("logout")
@GET
@NoCache
public Response logout(final @QueryParam("session_state") String sessionState, final @QueryParam("redirect_uri") String redirectUri) {
public Response logout(final @QueryParam("redirect_uri") String redirectUri) {
// todo do we care if anybody can trigger this?
audit.event(EventType.LOGOUT);
if (redirectUri != null) {
audit.detail(Details.REDIRECT_URI, redirectUri);
}
if (sessionState != null) {
audit.session(sessionState);
}
// authenticate identity cookie, but ignore an access token timeout as we're logging out anyways.
AuthenticationManager.AuthResult authResult = authManager.authenticateIdentityCookie(session, realm, uriInfo, clientConnection, headers, false);
if (authResult != null) {
logout(authResult.getSession());
} else if (sessionState != null) {
UserSessionModel userSession = session.sessions().getUserSession(realm, sessionState);
if (userSession != null) {
logout(userSession);
} else {
audit.error(Errors.USER_SESSION_NOT_FOUND);
}
} else {
audit.error(Errors.USER_NOT_LOGGED_IN);
OAuthFlows oauth = Flows.oauth(session, realm, request, uriInfo, clientConnection, authManager, tokenManager);
return oauth.forwardToSecurityFailure("Not logged in.");
}
if (redirectUri != null) {
@ -1088,6 +1079,61 @@ public class TokenService {
}
}
/**
* Logout a session via a non-browser invocation. Similar signature to refresh token except there is no grant_type.
* You must pass in the refresh token and
* authenticate the client if it is not public.
*
* If the client is a confidential client
* you must include the client-id (application name or oauth client name) and secret in an Basic Auth Authorization header.
*
* If the client is a public client, then you must include a "client_id" form parameter with the app's or oauth client's name.
*
* returns 204 if successful, 400 if not with a json error response.
*
* @param authorizationHeader
* @param form
* @return
*/
@Path("logout")
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response logoutToken(final @HeaderParam(HttpHeaders.AUTHORIZATION) String authorizationHeader,
final MultivaluedMap<String, String> form) {
logger.info("--> logoutToken");
if (!checkSsl()) {
throw new NotAcceptableException("HTTPS required");
}
audit.event(EventType.LOGOUT);
ClientModel client = authorizeClient(authorizationHeader, form, audit);
String refreshToken = form.getFirst(OAuth2Constants.REFRESH_TOKEN);
if (refreshToken == null) {
Map<String, String> error = new HashMap<String, String>();
error.put(OAuth2Constants.ERROR, OAuthErrorException.INVALID_REQUEST);
error.put(OAuth2Constants.ERROR_DESCRIPTION, "No refresh token");
audit.error(Errors.INVALID_TOKEN);
logger.error("OAuth Error: no refresh token");
return Response.status(Response.Status.BAD_REQUEST).entity(error).type("application/json").build();
}
try {
RefreshToken token = tokenManager.verifyRefreshToken(realm, refreshToken);
UserSessionModel userSessionModel = session.sessions().getUserSession(realm, token.getSessionState());
if (userSessionModel != null) {
logout(userSessionModel);
}
} catch (OAuthErrorException e) {
Map<String, String> error = new HashMap<String, String>();
error.put(OAuth2Constants.ERROR, e.getError());
if (e.getDescription() != null) error.put(OAuth2Constants.ERROR_DESCRIPTION, e.getDescription());
audit.error(Errors.INVALID_TOKEN);
logger.error("OAuth Error", e);
return Response.status(Response.Status.BAD_REQUEST).entity(error).type("application/json").build();
}
return Cors.add(request, Response.noContent()).auth().allowedOrigins(client).allowedMethods("POST").exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS).build();
}
private void logout(UserSessionModel userSession) {
authManager.logout(session, realm, userSession, uriInfo, clientConnection);
audit.user(userSession.getUser()).session(userSession).success();

View file

@ -171,11 +171,31 @@ public class OAuthClient {
return new AccessTokenResponse(client.execute(post));
}
public HttpResponse doLogout(String redirectUri, String sessionState) throws IOException {
public HttpResponse doLogout(String refreshToken, String clientSecret) throws IOException {
HttpClient client = new DefaultHttpClient();
HttpGet get = new HttpGet(getLogoutUrl(redirectUri, sessionState));
HttpPost post = new HttpPost(getLogoutUrl(null, null));
return client.execute(get);
List<NameValuePair> parameters = new LinkedList<NameValuePair>();
if (refreshToken != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.REFRESH_TOKEN, refreshToken));
}
if (clientId != null && clientSecret != null) {
String authorization = BasicAuthHelper.createHeader(clientId, clientSecret);
post.setHeader("Authorization", authorization);
}
else if (clientId != null) {
parameters.add(new BasicNameValuePair(OAuth2Constants.CLIENT_ID, clientId));
}
UrlEncodedFormEntity formEntity;
try {
formEntity = new UrlEncodedFormEntity(parameters, "UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException(e);
}
post.setEntity(formEntity);
return client.execute(post);
}
public AccessTokenResponse doRefreshTokenRequest(String refreshToken, String password) {

View file

@ -214,7 +214,8 @@ public class AdapterTest {
driver.navigate().to("http://localhost:8081/customer-portal");
Assert.assertTrue(driver.getCurrentUrl().startsWith(LOGIN_URL));
String currentUrl = driver.getCurrentUrl();
Assert.assertTrue(currentUrl.startsWith(LOGIN_URL));
driver.navigate().to("http://localhost:8081/product-portal");
Assert.assertTrue(driver.getCurrentUrl().startsWith(LOGIN_URL));

View file

@ -124,32 +124,15 @@ public class LogoutTest {
String sessionId = events.expectLogin().assertEvent().getSessionId();
// Login session 2
WebDriver driver2 = WebRule.createWebDriver();
OAuthClient oauth2 = new OAuthClient(driver2);
oauth2.doLogin("test-user@localhost", "password");
String sessionId2 = events.expectLogin().assertEvent().getSessionId();
assertNotEquals(sessionId, sessionId2);
// Check session 1 logged-in
oauth.openLoginForm();
events.expectLogin().session(sessionId).detail(Details.AUTH_METHOD, "sso").removeDetail(Details.USERNAME).assertEvent();
// Check session 2 logged-in
oauth2.openLoginForm();
events.expectLogin().session(sessionId2).detail(Details.AUTH_METHOD, "sso").removeDetail(Details.USERNAME).assertEvent();
// Logout session 1 by redirect
// Logout session 1 by redirect
driver.navigate().to(oauth.getLogoutUrl(AppPage.baseUrl, null));
events.expectLogout(sessionId).detail(Details.REDIRECT_URI, AppPage.baseUrl).assertEvent();
// Check session 2 logged-in
oauth2.openLoginForm();
events.expectLogin().session(sessionId2).detail(Details.AUTH_METHOD, "sso").removeDetail(Details.USERNAME).assertEvent();
// Check session 1 not logged-in
// Check session 1 not logged-in
oauth.openLoginForm();
assertEquals(oauth.getLoginFormUrl(), driver.getCurrentUrl());
@ -157,19 +140,10 @@ public class LogoutTest {
oauth.doLogin("test-user@localhost", "password");
String sessionId3 = events.expectLogin().assertEvent().getSessionId();
assertNotEquals(sessionId, sessionId3);
assertNotEquals(sessionId2, sessionId3);
// Logout session 2 by session_state
oauth2.doLogout(null, sessionId2);
events.expectLogout(sessionId2).removeDetail(Details.REDIRECT_URI).assertEvent();
// Check session 3 logged-in
oauth.openLoginForm();
events.expectLogin().session(sessionId3).detail(Details.AUTH_METHOD, "sso").removeDetail(Details.USERNAME).assertEvent();
// Check session 2 not logged-in
oauth2.openLoginForm();
assertEquals(oauth2.getLoginFormUrl(), driver2.getCurrentUrl());
}
}

View file

@ -305,8 +305,13 @@ public class AccessTokenTest {
{
builder = UriBuilder.fromUri(org.keycloak.testsuite.Constants.AUTH_SERVER_ROOT);
URI logoutUri = TokenService.logoutUrl(builder).build("test");
Response response = client.target(logoutUri).queryParam("session_state", tokenResponse.getSessionState()).request().get();
Assert.assertEquals(200, response.getStatus());
String header = BasicAuthHelper.createHeader("test-app", "password");
Form form = new Form();
form.param("refresh_token", tokenResponse.getRefreshToken());
Response response = client.target(logoutUri).request()
.header(HttpHeaders.AUTHORIZATION, header)
.post(Entity.form(form));
Assert.assertEquals(204, response.getStatus());
response.close();
}
{

View file

@ -131,13 +131,9 @@ public class ResourceOwnerPasswordCredentialsGrantTest {
.removeDetail(Details.REDIRECT_URI)
.assertEvent();
HttpResponse logoutResponse = oauth.doLogout(null, accessToken.getSessionState());
assertEquals(200, logoutResponse.getStatusLine().getStatusCode());
events.expectLogout(accessToken.getSessionState()).removeDetail(Details.REDIRECT_URI).assertEvent();
logoutResponse = oauth.doLogout(null, accessToken.getSessionState());
assertEquals(200, logoutResponse.getStatusLine().getStatusCode());
events.expectLogout(accessToken.getSessionState()).user((String) null).removeDetail(Details.REDIRECT_URI).error(Errors.USER_SESSION_NOT_FOUND).assertEvent();
HttpResponse logoutResponse = oauth.doLogout(response.getRefreshToken(), "secret");
assertEquals(204, logoutResponse.getStatusLine().getStatusCode());
events.expectLogout(accessToken.getSessionState()).client("resource-owner").removeDetail(Details.REDIRECT_URI).assertEvent();
response = oauth.doRefreshTokenRequest(response.getRefreshToken(), "secret");
assertEquals(400, response.getStatusCode());