KEYCLOAK-431 View open sessions, and logout all sessions, through account management

This commit is contained in:
Stian Thorgersen 2014-05-14 11:46:08 +01:00
parent f7c3373f75
commit f4f9b1e323
20 changed files with 298 additions and 26 deletions

View file

@ -1,5 +1,7 @@
package org.keycloak.util;
import java.util.Date;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
@ -9,4 +11,8 @@ public class Time {
return (int) (System.currentTimeMillis() / 1000);
}
public static Date toDate(int time) {
return new Date(((long) time ) * 1000);
}
}

View file

@ -7,6 +7,7 @@ import org.codehaus.jackson.map.SerializationConfig;
import org.codehaus.jackson.map.annotate.JsonSerialize;
import org.keycloak.adapters.ServerRequest;
import org.keycloak.adapters.installed.KeycloakInstalled;
import org.keycloak.util.Time;
import java.io.BufferedReader;
import java.io.IOException;
@ -65,7 +66,7 @@ public class CustomerCli {
System.out.println(mapper.writeValueAsString(keycloak.getIdToken()));
} else if (s.equals("refresh")) {
keycloak.refreshToken();
System.out.println("Token refreshed: expires at " + new Date(keycloak.getToken().getExpiration() * 1000));
System.out.println("Token refreshed: expires at " + Time.toDate(keycloak.getToken().getExpiration()));
} else if (s.equals("exit")) {
System.exit(0);
} else {

View file

@ -3,6 +3,7 @@ package org.keycloak.account;
import org.keycloak.audit.Event;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
@ -31,5 +32,7 @@ public interface Account {
Account setEvents(List<Event> events);
Account setSessions(List<UserSessionModel> sessions);
Account setFeatures(boolean social, boolean audit, boolean passwordUpdateSupported);
}

View file

@ -5,6 +5,6 @@ package org.keycloak.account;
*/
public enum AccountPages {
ACCOUNT, PASSWORD, TOTP, SOCIAL, LOG;
ACCOUNT, PASSWORD, TOTP, SOCIAL, LOG, SESSIONS;
}

View file

@ -9,6 +9,7 @@ import org.keycloak.account.freemarker.model.FeaturesBean;
import org.keycloak.account.freemarker.model.LogBean;
import org.keycloak.account.freemarker.model.MessageBean;
import org.keycloak.account.freemarker.model.ReferrerBean;
import org.keycloak.account.freemarker.model.SessionsBean;
import org.keycloak.account.freemarker.model.TotpBean;
import org.keycloak.account.freemarker.model.UrlBean;
import org.keycloak.audit.Event;
@ -19,6 +20,7 @@ import org.keycloak.freemarker.ThemeLoader;
import org.keycloak.models.ApplicationModel;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
@ -43,6 +45,7 @@ public class FreeMarkerAccount implements Account {
private RealmModel realm;
private String[] referrer;
private List<Event> events;
private List<UserSessionModel> sessions;
private boolean social;
private boolean audit;
private boolean passwordUpdateSupported;
@ -100,7 +103,7 @@ public class FreeMarkerAccount implements Account {
attributes.put("referrer", new ReferrerBean(referrer));
}
attributes.put("url", new UrlBean(realm, theme, baseUri, baseQueryUri));
attributes.put("url", new UrlBean(realm, theme, baseUri, baseQueryUri, uriInfo.getRequestUri()));
attributes.put("features", new FeaturesBean(social, audit, passwordUpdateSupported));
@ -116,6 +119,10 @@ public class FreeMarkerAccount implements Account {
break;
case LOG:
attributes.put("log", new LogBean(events));
break;
case SESSIONS:
attributes.put("sessions", new SessionsBean(sessions));
break;
}
try {
@ -178,6 +185,12 @@ public class FreeMarkerAccount implements Account {
return this;
}
@Override
public Account setSessions(List<UserSessionModel> sessions) {
this.sessions = sessions;
return this;
}
@Override
public Account setFeatures(boolean social, boolean audit, boolean passwordUpdateSupported) {
this.social = social;

View file

@ -19,6 +19,8 @@ public class Templates {
return "social.ftl";
case LOG:
return "log.ftl";
case SESSIONS:
return "sessions.ftl";
default:
throw new IllegalArgumentException();
}

View file

@ -0,0 +1,50 @@
package org.keycloak.account.freemarker.model;
import org.keycloak.models.UserSessionModel;
import org.keycloak.util.Time;
import java.util.Date;
import java.util.LinkedList;
import java.util.List;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public class SessionsBean {
private List<UserSessionBean> events;
public SessionsBean(List<UserSessionModel> sessions) {
this.events = new LinkedList<UserSessionBean>();
for (UserSessionModel session : sessions) {
this.events.add(new UserSessionBean(session));
}
}
public List<UserSessionBean> getSessions() {
return events;
}
public static class UserSessionBean {
private UserSessionModel session;
public UserSessionBean(UserSessionModel session) {
this.session = session;
}
public String getIpAddress() {
return session.getIpAddress();
}
public Date getStarted() {
return Time.toDate(session.getStarted());
}
public Date getExpires() {
return Time.toDate(session.getExpires());
}
}
}

View file

@ -2,6 +2,7 @@ package org.keycloak.account.freemarker.model;
import org.keycloak.freemarker.Theme;
import org.keycloak.models.RealmModel;
import org.keycloak.services.resources.TokenService;
import org.keycloak.services.resources.flows.Urls;
import java.net.URI;
@ -15,12 +16,14 @@ public class UrlBean {
private Theme theme;
private URI baseURI;
private URI baseQueryURI;
private URI currentURI;
public UrlBean(RealmModel realm, Theme theme, URI baseURI, URI baseQueryURI) {
public UrlBean(RealmModel realm, Theme theme, URI baseURI, URI baseQueryURI, URI currentURI) {
this.realm = realm.getName();
this.theme = theme;
this.baseURI = baseURI;
this.baseQueryURI = baseQueryURI;
this.currentURI = currentURI;
}
public String getAccessUrl() {
@ -47,12 +50,20 @@ public class UrlBean {
return Urls.accountLogPage(baseQueryURI, realm).toString();
}
public String getSessionsUrl() {
return Urls.accountSessionsPage(baseQueryURI, realm).toString();
}
public String getSessionsLogoutUrl() {
return Urls.accountSessionsLogoutPage(baseQueryURI, realm).toString();
}
public String getTotpRemoveUrl() {
return Urls.accountTotpRemove(baseQueryURI, realm).toString();
}
public String getLogoutUrl() {
return Urls.accountLogout(baseQueryURI, realm).toString();
return Urls.accountLogout(baseQueryURI, currentURI, realm).toString();
}
public String getResourcesPath() {

View file

@ -0,0 +1,33 @@
<#import "template.ftl" as layout>
<@layout.mainLayout active='sessions' bodyClass='sessions'; section>
<div class="row">
<div class="col-md-10">
<h2>Sessions</h2>
</div>
</div>
<table class="table">
<thead>
<tr>
<td>IP</td>
<td>Started</td>
<td>Expires</td>
</tr>
</thead>
<tbody>
<#list sessions.sessions as session>
<tr>
<td>${session.ipAddress}</td>
<td>${session.started?datetime}</td>
<td>${session.expires?datetime}</td>
</tr>
</#list>
</tbody>
</table>
<a id="logout-all-sessions" href="${url.sessionsLogoutUrl}">Logout all sessions</a>
</@layout.mainLayout>

View file

@ -43,6 +43,7 @@
<#if features.passwordUpdateSupported><li class="<#if active=='password'>active</#if>"><a href="${url.passwordUrl}">Password</a></li></#if>
<li class="<#if active=='totp'>active</#if>"><a href="${url.totpUrl}">Authenticator</a></li>
<#if features.social><li class="<#if active=='social'>active</#if>"><a href="${url.socialUrl}">Social</a></li></#if>
<li class="<#if active=='sessions'>active</#if>"><a href="${url.sessionsUrl}">Sessions</a></li>
<#if features.log><li class="<#if active=='log'>active</#if>"><a href="${url.logUrl}">Log</a></li></#if>
</ul>
</div>

View file

@ -257,6 +257,8 @@ public interface RealmModel extends RoleContainerModel, RoleMapperModel, ScopeMa
UserSessionModel getUserSession(String id);
List<UserSessionModel> getUserSessions(UserModel user);
void removeUserSession(UserSessionModel session);
void removeUserSessions(UserModel user);

View file

@ -49,6 +49,7 @@ import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -1405,6 +1406,15 @@ public class RealmAdapter implements RealmModel {
return entity != null ? new UserSessionAdapter(entity) : null;
}
@Override
public List<UserSessionModel> getUserSessions(UserModel user) {
List<UserSessionModel> sessions = new LinkedList<UserSessionModel>();
for (UserSessionEntity e : em.createNamedQuery("getUserSessionByUser", UserSessionEntity.class).setParameter("user", ((UserAdapter) user).getUser()).getResultList()) {
sessions.add(new UserSessionAdapter(e));
}
return sessions;
}
@Override
public void removeUserSession(UserSessionModel session) {
em.remove(((UserSessionAdapter) session).getEntity());

View file

@ -15,6 +15,7 @@ import javax.persistence.NamedQuery;
*/
@Entity
@NamedQueries({
@NamedQuery(name = "getUserSessionByUser", query = "select s from UserSessionEntity s where s.user = :user"),
@NamedQuery(name = "removeUserSessionByUser", query = "delete from UserSessionEntity s where s.user = :user"),
@NamedQuery(name = "removeUserSessionExpired", query = "delete from UserSessionEntity s where s.expires < :currentTime")
})

View file

@ -45,6 +45,7 @@ import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
@ -1375,6 +1376,16 @@ public class RealmAdapter extends AbstractMongoAdapter<MongoRealmEntity> impleme
}
}
@Override
public List<UserSessionModel> getUserSessions(UserModel user) {
DBObject query = new BasicDBObject("user", user.getId());
List<UserSessionModel> sessions = new LinkedList<UserSessionModel>();
for (MongoUserSessionEntity e : getMongoStore().loadEntities(MongoUserSessionEntity.class, query, invocationContext)) {
sessions.add(new UserSessionAdapter(e, this, invocationContext));
}
return sessions;
}
@Override
public void removeUserSession(UserSessionModel session) {
getMongoStore().removeEntity(((UserSessionAdapter) session).getEntity(), invocationContext);

View file

@ -241,10 +241,6 @@ public class AccountService {
return forwardToPage("social", AccountPages.SOCIAL);
}
public static UriBuilder logUrl(UriBuilder base) {
return RealmsResource.accountUrl(base).path(AccountService.class, "logPage");
}
@Path("log")
@GET
public Response logPage() {
@ -269,6 +265,15 @@ public class AccountService {
return forwardToPage("log", AccountPages.LOG);
}
@Path("sessions")
@GET
public Response sessionsPage() {
if (auth != null) {
account.setSessions(realm.getUserSessions(auth.getUser()));
}
return forwardToPage("sessions", AccountPages.SESSIONS);
}
@Path("/")
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@ -314,6 +319,18 @@ public class AccountService {
return account.setSuccess("successTotpRemoved").createResponse(AccountPages.TOTP);
}
@Path("sessions-logout")
@GET
public Response processSessionsLogout() {
require(AccountRoles.MANAGE_ACCOUNT);
UserModel user = auth.getUser();
realm.removeUserSessions(user);
return Response.seeOther(Urls.accountSessionsPage(uriInfo.getBaseUri(), realm.getName())).build();
}
@Path("totp")
@POST
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@ -493,16 +510,6 @@ public class AccountService {
}
}
@Path("logout")
@GET
public Response logout() {
URI redirect = Urls.accountBase(uriInfo.getBaseUri()).build(realm.getName());
return Response.status(302).location(
TokenService.logoutUrl(uriInfo).queryParam("redirect_uri", redirect.toString()).build(realm.getName())
).build();
}
private Response login(String path) {
OAuthRedirect oauth = new OAuthRedirect();
String authUrl = Urls.realmLoginPage(uriInfo.getBaseUri(), realm.getName()).toString();

View file

@ -76,8 +76,16 @@ public class Urls {
return accountBase(baseUri).path(AccountService.class, "logPage").build(realmId);
}
public static URI accountLogout(URI baseUri, String realmId) {
return accountBase(baseUri).path(AccountService.class, "logout").build(realmId);
public static URI accountSessionsPage(URI baseUri, String realmId) {
return accountBase(baseUri).path(AccountService.class, "sessionsPage").build(realmId);
}
public static URI accountSessionsLogoutPage(URI baseUri, String realmId) {
return accountBase(baseUri).path(AccountService.class, "processSessionsLogout").build(realmId);
}
public static URI accountLogout(URI baseUri, URI redirectUri, String realmId) {
return realmLogout(baseUri).queryParam("redirect_uri", redirectUri).build(realmId);
}
public static URI loginActionUpdatePassword(URI baseUri, String realmId) {
@ -128,6 +136,10 @@ public class Urls {
return tokenBase(baseUri).path(TokenService.class, "loginPage").build(realmId);
}
public static UriBuilder realmLogout(URI baseUri) {
return tokenBase(baseUri).path(TokenService.class, "logout");
}
public static URI realmRegisterAction(URI baseUri, String realmId) {
return tokenBase(baseUri).path(TokenService.class, "processRegister").build(realmId);
}

View file

@ -48,6 +48,7 @@ import org.keycloak.testsuite.OAuthClient;
import org.keycloak.testsuite.Retry;
import org.keycloak.testsuite.pages.AccountLogPage;
import org.keycloak.testsuite.pages.AccountPasswordPage;
import org.keycloak.testsuite.pages.AccountSessionsPage;
import org.keycloak.testsuite.pages.AccountTotpPage;
import org.keycloak.testsuite.pages.AccountUpdateProfilePage;
import org.keycloak.testsuite.pages.AppPage;
@ -130,6 +131,9 @@ public class AccountTest {
@WebResource
protected AccountLogPage logPage;
@WebResource
protected AccountSessionsPage sessionsPage;
@WebResource
protected ErrorPage errorPage;
@ -212,7 +216,7 @@ public class AccountTest {
changePasswordPage.logout();
events.expectLogout(sessionId).detail(Details.REDIRECT_URI, ACCOUNT_URL).assertEvent();
events.expectLogout(sessionId).detail(Details.REDIRECT_URI, AccountPasswordPage.PATH).assertEvent();
loginPage.open();
loginPage.login("test-user@localhost", "password");
@ -414,4 +418,41 @@ public class AccountTest {
}
}
@Test
public void sessions() {
loginPage.open();
loginPage.clickRegister();
registerPage.register("view", "sessions", "view-sessions@localhost", "view-sessions", "password", "password");
Event registerEvent = events.expectRegister("view-sessions", "view-sessions@localhost").assertEvent();
String userId = registerEvent.getUserId();
events.expectLogin().user(userId).detail(Details.USERNAME, "view-sessions").assertEvent();
sessionsPage.open();
Assert.assertTrue(sessionsPage.isCurrent());
List<List<String>> sessions = sessionsPage.getSessions();
Assert.assertEquals(1, sessions.size());
Assert.assertEquals("127.0.0.1", sessions.get(0).get(0));
// Create second session
WebDriver driver2 = WebRule.createWebDriver();
OAuthClient oauth2 = new OAuthClient(driver2);
oauth2.doLogin("view-sessions", "password");
Event login2Event = events.expectLogin().user(userId).detail(Details.USERNAME, "view-sessions").assertEvent();
sessionsPage.open();
sessions = sessionsPage.getSessions();
Assert.assertEquals(2, sessions.size());
sessionsPage.logoutAll();
events.expectLogout(registerEvent.getSessionId());
events.expectLogout(login2Event.getSessionId());
}
}

View file

@ -21,11 +21,10 @@
*/
package org.keycloak.testsuite.pages;
import org.keycloak.services.resources.AccountService;
import org.keycloak.services.resources.flows.Urls;
import org.keycloak.testsuite.Constants;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import javax.ws.rs.core.UriBuilder;
import java.util.LinkedList;
@ -36,7 +35,7 @@ import java.util.List;
*/
public class AccountLogPage extends AbstractAccountPage {
private static String PATH = AccountService.logUrl(UriBuilder.fromUri(Constants.AUTH_SERVER_ROOT)).build("test").toString();
private static String PATH = Urls.accountLogPage(UriBuilder.fromUri(Constants.AUTH_SERVER_ROOT).build(), "test").toString();
public boolean isCurrent() {
return driver.getTitle().contains("Account Management") && driver.getCurrentUrl().endsWith("/account/log");

View file

@ -33,7 +33,7 @@ import javax.ws.rs.core.UriBuilder;
*/
public class AccountPasswordPage extends AbstractAccountPage {
private static String PATH = AccountService.passwordUrl(UriBuilder.fromUri(Constants.AUTH_SERVER_ROOT)).build("test").toString();
public static String PATH = AccountService.passwordUrl(UriBuilder.fromUri(Constants.AUTH_SERVER_ROOT)).build("test").toString();
@FindBy(id = "password")
private WebElement passwordInput;

View file

@ -0,0 +1,69 @@
/*
* JBoss, Home of Professional Open Source.
* Copyright 2012, Red Hat, Inc., and individual contributors
* as indicated by the @author tags. See the copyright.txt file in the
* distribution for a full listing of individual contributors.
*
* This is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* This software is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this software; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package org.keycloak.testsuite.pages;
import org.keycloak.services.resources.flows.Urls;
import org.keycloak.testsuite.Constants;
import org.openqa.selenium.By;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.support.FindBy;
import javax.ws.rs.core.UriBuilder;
import java.util.LinkedList;
import java.util.List;
/**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
public class AccountSessionsPage extends AbstractAccountPage {
private static String PATH = Urls.accountSessionsPage(UriBuilder.fromUri(Constants.AUTH_SERVER_ROOT).build(), "test").toString();
@FindBy(id = "logout-all-sessions")
private WebElement logoutAllLink;
public boolean isCurrent() {
return driver.getTitle().contains("Account Management") && driver.getCurrentUrl().endsWith("/account/sessions");
}
public void open() {
driver.navigate().to(PATH);
}
public void logoutAll() {
logoutAllLink.click();
}
public List<List<String>> getSessions() {
List<List<String>> table = new LinkedList<List<String>>();
for (WebElement r : driver.findElements(By.tagName("tr"))) {
List<String> row = new LinkedList<String>();
for (WebElement col : r.findElements(By.tagName("td"))) {
row.add(col.getText());
}
table.add(row);
}
table.remove(0);
return table;
}
}