8000 change logout behavior by patriot1burke · Pull Request #607 · keycloak/keycloak · GitHub
[go: up one dir, main page]

Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docbook/reference/en/en-US/master.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down Expand Up @@ -80,6 +81,7 @@ This one is short
&JBossAdapter;
&JavascriptAdapter;
&InstalledApplications;
&Logout;
</chapter>

<chapter>
Expand Down
11 changes: 11 additions & 0 deletions docbook/reference/en/en-US/modules/MigrationFromOlderVersions.xml
Original file line number Diff line number Diff line change
@@ -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>
Expand Down
34 changes: 33 additions & 1 deletion docbook/reference/en/en-US/modules/direct-access.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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>
9 changes: 9 additions & 0 deletions docbook/reference/en/en-US/modules/logout.xml
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
Expand Up @@ -102,17 +102,23 @@ public static void logout(HttpServletRequest request, AccessTokenResponse res) t


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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ public String getTokenString() {
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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,41 @@ public String getError() {
}
}

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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,8 @@ public void logout(Request request) throws ServletException {
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());
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,8 @@ public void handleNotification(SecurityNotification notification) {
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());
}
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,8 @@ public void handleNotification(SecurityNotification notification) {
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());
}
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,23 +64,7 @@ public AccessCode createAccessCode(String scopeParam, String state, String redir
}

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());

Expand Down Expand Up @@ -122,6 +106,27 @@ public AccessToken refreshAccessToken(KeycloakSession session, UriInfo uriInfo,
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
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;
Expand Down Expand Up @@ -1040,39 +1041,29 @@ public Response registerPage(final @QueryParam("response_type") String responseT
}

/**
* 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) {
Expand All @@ -1088,6 +1079,61 @@ public Response logout(final @QueryParam("session_state") String sessionState, f
}
}

/**
* 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();
Expand Down
Loading
0