Added audit to admin console

This commit is contained in:
Stian Thorgersen 2014-04-04 14:23:14 +01:00
parent 8caf3fa83a
commit 88ddc8ebca
17 changed files with 285 additions and 29 deletions

View file

@ -11,6 +11,9 @@ table caption {
table tbody tr:nth-child(even) {
background-color: #f6f6f6;
}
table tbody tr:nth-child(odd) {
background-color: #fff;
}
table tbody tr td,
table thead tr th {
font-weight: normal;
@ -138,10 +141,14 @@ table tfoot tr {
table tfoot tr .table-nav {
float: right;
}
table tfoot tr .table-nav a {
table tfoot tr .table-nav a,
table tfoot tr .table-nav button {
display: inline-block;
line-height: 2.4em;
line-height: 22px;
border-left: 1px solid #d9d9d9;
border-right: none;
border-top: none;
border-bottom: none;
width: 3.5em;
background-color: #f3f3f3;
background-image: linear-gradient(top, #fafafa 0%, #ededed 100%);
@ -156,28 +163,35 @@ table tfoot tr .table-nav a {
background-position: left top;
vertical-align: top;
}
table tfoot tr .table-nav a.last {
table tfoot tr .table-nav a.last,
table tfoot tr .table-nav button.last {
background-position: top right;
}
table tfoot tr .table-nav a.prev {
table tfoot tr .table-nav a.prev,
table tfoot tr .table-nav button.prev {
background-position: bottom left;
}
table tfoot tr .table-nav a.next {
table tfoot tr .table-nav a.next,
table tfoot tr .table-nav button.next {
background-position: bottom right;
}
table tfoot tr .table-nav a:hover {
table tfoot tr .table-nav a:hover,
table tfoot tr .table-nav button:hover {
background-image: url(img/sprite-table-nav.png);
background-color: #eeeeee;
}
table tfoot tr .table-nav a:active {
table tfoot tr .table-nav a:active,
table tfoot tr .table-nav button:active {
box-shadow: 0 0 5px 2px rgba(0, 0, 0, 0.25) inset;
}
table tfoot tr .table-nav a.disabled {
table tfoot tr .table-nav a.disabled,
table tfoot tr .table-nav button:disabled {
opacity: 0.5;
filter: alpha(opacity=50);
cursor: default;
}
table tfoot tr .table-nav a.disabled:active {
table tfoot tr .table-nav a.disabled:active,
table tfoot tr .table-nav button:disabled:active {
box-shadow: none;
}
table tfoot tr .table-nav span {
@ -195,3 +209,20 @@ table tfoot tr .table-nav span {
td .form-group {
margin-bottom: 0;
}
td.audit-success {
background-color: #E4F1E1;
}
td.audit-error {
background-color: #F8E7E7;
}
.kc-table-actions .form-group {
margin-top: 5px;
margin-bottom: 5px;
}
.kc-table-actions select {
height: 26px;
}

View file

@ -131,6 +131,15 @@ module.config([ '$routeProvider', function($routeProvider) {
},
controller : 'RealmLdapSettingsCtrl'
})
.when('/realms/:realm/audit', {
templateUrl : 'partials/realm-audit.html',
resolve : {
realm : function(RealmLoader) {
return RealmLoader();
}
},
controller : 'RealmAuditCtrl'
})
.when('/create/user/:realm', {
templateUrl : 'partials/user-detail.html',
resolve : {

View file

@ -42,6 +42,10 @@ module.controller('GlobalCtrl', function($scope, $http, Auth, Current, $location
return getAccess('view-users') || this.manageClients;
},
get viewAudit() {
return getAccess('view-audit') || this.manageClients;
},
get manageRealm() {
return getAccess('manage-realm');
},
@ -56,6 +60,10 @@ module.controller('GlobalCtrl', function($scope, $http, Auth, Current, $location
get manageUsers() {
return getAccess('manage-users');
},
get manageAudit() {
return getAccess('manage-audit');
}
}
})
@ -915,3 +923,48 @@ module.controller('RealmLdapSettingsCtrl', function($scope, Realm, realm, $locat
$scope.changed = false;
};
});
module.controller('RealmAuditCtrl', function($scope, RealmAudit, realm) {
$scope.realm = realm;
$scope.page = 0;
$scope.query = {
id : realm.realm,
max : 5,
first : 0
}
$scope.update = function() {
for (var i in $scope.query) {
if ($scope.query[i] === '') {
delete $scope.query[i];
}
}
console.debug($scope.query.first);
$scope.events = RealmAudit.query($scope.query);
}
$scope.firstPage = function() {
$scope.query.first = 0;
if ($scope.query.first < 0) {
$scope.query.first = 0;
}
$scope.update();
}
$scope.previousPage = function() {
$scope.query.first -= parseInt($scope.query.max);
if ($scope.query.first < 0) {
$scope.query.first = 0;
}
$scope.update();
}
$scope.nextPage = function() {
$scope.query.first += parseInt($scope.query.max);
$scope.update();
}
$scope.update();
});

View file

@ -142,6 +142,12 @@ module.factory('Realm', function($resource) {
});
});
module.factory('RealmAudit', function($resource) {
return $resource('/auth/rest/admin/realms/:id/audit', {
id : '@realm'
});
});
module.factory('ServerInfo', function($resource) {
return $resource('/auth/rest/admin/serverinfo');
});

View file

@ -0,0 +1,100 @@
<div class="bs-sidebar col-sm-3 " data-ng-include data-src="'partials/realm-menu.html'"></div>
<div id="content-area" class="col-sm-9" role="main">
<data-kc-navigation data-kc-current="social" data-kc-realm="realm.realm" data-kc-social="realm.social"></data-kc-navigation>
<div id="content">
<ol class="breadcrumb">
<li><a href="#/realms/{{realm.realm}}">{{realm.realm}}</a></li>
<li><a href="#/realms/{{realm.realm}}">Audit</a></li>
<li class="active">Social</li>
</ol>
<h2><span>{{realm.realm}}</span> Audit Log</h2>
<table class="table">
<thead>
<tr>
<th class="kc-table-actions" colspan="4">
<div class="pull-right">
<select data-ng-model="query.max" data-ng-click="update()" class="btn btn-default">
<option>5</option>
<option>10</option>
<option>50</option>
<option>100</option>
</select>
<button class="btn btn-default" data-ng-click="filter = !filter">
<span class="glyphicon glyphicon-plus" data-ng-show="!filter"></span>
<span class="glyphicon glyphicon-minus" data-ng-show="filter"></span>
Filter
</button>
<button class="btn btn-default btn-primary" data-ng-click="update()">Update</button>
</div>
<form class="form-horizontal">
<div class="form-group" data-ng-show="filter">
<label class="col-sm-2 control-label" for="event">Event</label>
<div class="col-sm-4">
<input class="form-control" type="text" id="event" name="event" data-ng-model="query.event">
</div>
</div>
<div class="form-group" data-ng-show="filter">
<label class="col-sm-2 control-label" for="client">Client</label>
<div class="col-sm-4">
<input class="form-control" type="text" id="client" name="client" data-ng-model="query.client">
</div>
</div>
<div class="form-group" data-ng-show="filter">
<label class="col-sm-2 control-label" for="user">User</label>
<div class="col-sm-4">
<input class="form-control" type="text" id="user" name="user" data-ng-model="query.user">
</div>
</div>
</form>
</th>
</tr>
<tr>
<th width="100px">Time</th>
<th width="180px">Event</th>
<th>Details</th>
</tr>
</thead>
<tfoot>
<tr>
<td colspan="7">
<div class="table-nav">
<button data-ng-click="firstPage()" class="first" ng-disabled="query.first == 0">First page</button>
<button data-ng-click="previousPage()" class="prev" ng-disabled="query.first == 0">Previous page</button>
<button data-ng-click="nextPage()" class="next" ng-disabled="events.length < query.max">Next page</button>
</div>
</td>
</tr>
</tfoot>
<tbody>
<tr ng-repeat="event in events">
<td>{{event.time|date:'shortDate'}}<br>{{event.time|date:'mediumTime'}}</td>
<td data-ng-class="event.error && 'audit-error' || 'audit-success'">{{event.event}}<br/>{{event.error}}</td>
<td>
<table>
<tr><td width="100px">Client</td><td>{{event.clientId}}</td></tr>
<tr><td>User</td><td>{{event.userId}}</td></tr>
<tr><td>IP Address</td><td>{{event.ipAddress}}</td></tr>
<tr>
<td>Details</td>
<td>
<button type="button" class="btn btn-default btn-xs" ng-click="event.collapse = !event.collapse">
<span class="glyphicon glyphicon-plus" data-ng-show="!event.collapse"></span>
<span class="glyphicon glyphicon-minus" data-ng-show="event.collapse"></span>
</button>
<table data-ng-show="event.collapse">
<tr ng-repeat="(key, value) in event.details">
<td>{{key}}</td>
<td>{{value}}</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>

View file

@ -7,4 +7,5 @@
<li data-ng-show="access.viewApplications" data-ng-class="(path[2] == 'applications' || path[1] == 'application' || path[3] == 'applications') && 'active'"><a href="#/realms/{{realm.realm}}/applications">Applications</a></li>
<li data-ng-show="access.viewClients" data-ng-class="(path[2] == 'oauth-clients' || path[1] == 'oauth-client') && 'active'"><a href="#/realms/{{realm.realm}}/oauth-clients">OAuth Clients</a></li>
<li data-ng-show="access.viewRealm" data-ng-class="(path[2] == 'sessions') && 'active'"><a href="#/realms/{{realm.realm}}/sessions/revocation">Sessions</a></li>
<li data-ng-show="access.viewAudit" data-ng-class="(path[2] == 'audit') && 'active'"><a href="#/realms/{{realm.realm}}/audit">Audit</a></li>
</ul>

View file

@ -72,10 +72,6 @@ public class Event {
this.ipAddress = ipAddress;
}
public boolean isError() {
return error != null;
}
public String getError() {
return error;
}

View file

@ -15,6 +15,8 @@ public interface EventQuery {
public EventQuery user(String userId);
public EventQuery ipAddress(String ipAddress);
public EventQuery firstResult(int result);
public EventQuery maxResults(int results);

View file

@ -19,7 +19,7 @@ public class JBossLoggingAuditListener implements AuditListener {
@Override
public void onEvent(Event event) {
Logger.Level level = event.isError() ? Logger.Level.WARN : Logger.Level.INFO;
Logger.Level level = event.getError() != null ? Logger.Level.WARN : Logger.Level.INFO;
if (logger.isEnabled(level)) {
StringBuilder sb = new StringBuilder();
@ -35,7 +35,7 @@ public class JBossLoggingAuditListener implements AuditListener {
sb.append(", ipAddress=");
sb.append(event.getIpAddress());
if (event.isError()) {
if (event.getError() != null) {
sb.append(", error=");
sb.append(event.getError());
}

View file

@ -59,6 +59,12 @@ public class JpaEventQuery implements EventQuery {
return this;
}
@Override
public EventQuery ipAddress(String ipAddress) {
predicates.add(cb.equal(root.get("ipAddress"), ipAddress));
return this;
}
@Override
public EventQuery firstResult(int firstResult) {
this.firstResult = firstResult;

View file

@ -48,6 +48,12 @@ public class MongoEventQuery implements EventQuery {
return this;
}
@Override
public EventQuery ipAddress(String ipAddress) {
query.put("ipAddress", ipAddress);
return this;
}
@Override
public EventQuery firstResult(int firstResult) {
this.firstResult = firstResult;

View file

@ -15,13 +15,15 @@ public class AdminRoles {
public static String VIEW_USERS = "view-users";
public static String VIEW_APPLICATIONS = "view-applications";
public static String VIEW_CLIENTS = "view-clients";
public static String VIEW_AUDIT = "view-audit";
public static String MANAGE_REALM = "manage-realm";
public static String MANAGE_USERS = "manage-users";
public static String MANAGE_APPLICATIONS = "manage-applications";
public static String MANAGE_CLIENTS = "manage-clients";
public static String MANAGE_AUDIT = "manage-audit";
public static String[] ALL_REALM_ROLES = {VIEW_REALM, VIEW_USERS, VIEW_APPLICATIONS, VIEW_CLIENTS, MANAGE_REALM, MANAGE_USERS, MANAGE_APPLICATIONS, MANAGE_CLIENTS};
public static String[] ALL_REALM_ROLES = {VIEW_REALM, VIEW_USERS, VIEW_APPLICATIONS, VIEW_CLIENTS, VIEW_AUDIT, MANAGE_REALM, MANAGE_USERS, MANAGE_APPLICATIONS, MANAGE_CLIENTS, MANAGE_AUDIT};
public static String getAdminApp(RealmModel realm) {
return realm.getName() + APP_SUFFIX;

View file

@ -95,7 +95,7 @@ public class AccountService {
private static final Logger logger = Logger.getLogger(AccountService.class);
private static final String[] AUDIT_EVENTS = {Events.LOGIN, Events.LOGOUT, Events.REGISTER, Events.REMOVE_SOCIAL_LINK, Events.REMOVE_TOTP, Events.SEND_RESET_PASSWORD,
Events.SEND_VERIFY_EMAIL, Events.SOCIAL_LINK, Events.UPDATE_EMAIL, Events.UPDATE_PASSWORD, Events.UPDATE_PASSWORD, Events.UPDATE_TOTP, Events.VERIFY_EMAIL};
Events.SEND_VERIFY_EMAIL, Events.SOCIAL_LINK, Events.UPDATE_EMAIL, Events.UPDATE_PASSWORD, Events.UPDATE_PROFILE, Events.UPDATE_TOTP, Events.VERIFY_EMAIL};
private static final Set<String> AUDIT_DETAILS = new HashSet<String>();
static {

View file

@ -243,7 +243,7 @@ public class SocialResource {
realm.addSocialLink(user, socialLink);
audit.clone().user(user).event(Events.REGISTER)
.detail(Details.REGISTER_METHOD, "social")
.detail(Details.REGISTER_METHOD, "social@" + provider.getId())
.detail(Details.EMAIL, socialUser.getEmail())
.removeDetail("auth_method")
.success();
@ -256,7 +256,7 @@ public class SocialResource {
return oauth.forwardToSecurityFailure("Your account is not enabled.");
}
return oauth.processAccessCode(scope, state, redirectUri, client, user, socialLink.getSocialUserId() + "@" + socialLink.getSocialProvider(), false, "social", audit);
return oauth.processAccessCode(scope, state, redirectUri, client, user, socialLink.getSocialUserId() + "@" + socialLink.getSocialProvider(), false, "social@" + provider.getId(), audit);
}
@GET

View file

@ -2,11 +2,15 @@ package org.keycloak.services.resources.admin;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.logging.Logger;
import org.keycloak.audit.AuditProvider;
import org.keycloak.audit.Event;
import org.keycloak.audit.EventQuery;
import org.keycloak.models.ApplicationModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.representations.adapters.action.SessionStats;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.services.ProviderSession;
import org.keycloak.services.managers.ModelToRepresentation;
import org.keycloak.services.managers.RealmManager;
import org.keycloak.services.managers.ResourceAdminManager;
@ -36,6 +40,9 @@ public class RealmAdminResource {
@Context
protected KeycloakSession session;
@Context
protected ProviderSession providers;
public RealmAdminResource(RealmAuth auth, RealmModel realm, TokenManager tokenManager) {
this.auth = auth;
this.realm = realm;
@ -129,7 +136,7 @@ public class RealmAdminResource {
@GET
@NoCache
@Produces(MediaType.APPLICATION_JSON)
public Map<String,SessionStats> getSessionStats() {
public Map<String, SessionStats> getSessionStats() {
logger.info("session-stats");
auth.requireView();
Map<String, SessionStats> stats = new HashMap<String, SessionStats>();
@ -141,4 +148,37 @@ public class RealmAdminResource {
return stats;
}
@Path("audit")
@GET
@NoCache
@Produces(MediaType.APPLICATION_JSON)
public List<Event> getAudit(@QueryParam("client") String client, @QueryParam("event") String event, @QueryParam("user") String user,
@QueryParam("ipAddress") String ipAddress, @QueryParam("first") Integer firstResult, @QueryParam("max") Integer maxResults) {
auth.init(RealmAuth.Resource.AUDIT).requireView();
AuditProvider audit = providers.getProvider(AuditProvider.class);
EventQuery query = audit.createQuery().realm(realm.getId());
if (client != null) {
query.client(client);
}
if (event != null) {
query.event(event);
}
if (user != null) {
query.user(user);
}
if (ipAddress != null) {
query.ipAddress(ipAddress);
}
if (firstResult != null) {
query.firstResult(firstResult);
}
if (maxResults != null) {
query.maxResults(maxResults);
}
return query.getResultList();
}
}

View file

@ -13,7 +13,7 @@ public class RealmAuth {
private Resource resource;
public enum Resource {
APPLICATION, CLIENT, USER, REALM
APPLICATION, CLIENT, USER, REALM, AUDIT
}
private Auth auth;
@ -65,6 +65,8 @@ public class RealmAuth {
return AdminRoles.VIEW_USERS;
case REALM:
return AdminRoles.VIEW_REALM;
case AUDIT:
return AdminRoles.VIEW_AUDIT;
default:
throw new IllegalStateException();
}
@ -80,6 +82,8 @@ public class RealmAuth {
return AdminRoles.MANAGE_USERS;
case REALM:
return AdminRoles.MANAGE_REALM;
case AUDIT:
return AdminRoles.MANAGE_AUDIT;
default:
throw new IllegalStateException();
}

View file

@ -113,12 +113,12 @@ public class SocialLoginTest {
.user(AssertEvents.isUUID())
.detail(Details.EMAIL, "bob@builder.com")
.detail(Details.RESPONSE_TYPE, "code")
.detail(Details.REGISTER_METHOD, "social")
.detail(Details.REGISTER_METHOD, "social@dummy")
.detail(Details.REDIRECT_URI, AssertEvents.DEFAULT_REDIRECT_URI)
.detail(Details.USERNAME, "1@dummy")
.assertEvent().getUserId();
String codeId = events.expectLogin().user(userId).detail(Details.USERNAME, "1@dummy").detail(Details.AUTH_METHOD, "social").assertEvent().getDetails().get(Details.CODE_ID);
String codeId = events.expectLogin().user(userId).detail(Details.USERNAME, "1@dummy").detail(Details.AUTH_METHOD, "social@dummy").assertEvent().getDetails().get(Details.CODE_ID);
AccessTokenResponse response = oauth.doAccessTokenRequest(oauth.getCurrentQuery().get(OAuth2Constants.CODE), "password");
@ -146,7 +146,7 @@ public class SocialLoginTest {
driver.findElement(By.id("username")).sendKeys("dummy-user1");
driver.findElement(By.id("login")).click();
events.expectLogin().user(userId).detail(Details.USERNAME, "1@dummy").detail(Details.AUTH_METHOD, "social").assertEvent();
events.expectLogin().user(userId).detail(Details.USERNAME, "1@dummy").detail(Details.AUTH_METHOD, "social@dummy").assertEvent();
}
@Test
@ -160,7 +160,7 @@ public class SocialLoginTest {
Assert.assertTrue(loginPage.isCurrent());
Assert.assertEquals("Access denied", loginPage.getWarning());
events.expectLogin().error("rejected_by_user").user((String) null).detail(Details.AUTH_METHOD, "social").removeDetail(Details.USERNAME).removeDetail(Details.CODE_ID).assertEvent();
events.expectLogin().error("rejected_by_user").user((String) null).detail(Details.AUTH_METHOD, "social@dummy").removeDetail(Details.USERNAME).removeDetail(Details.CODE_ID).assertEvent();
loginPage.login("test-user@localhost", "password");
@ -200,19 +200,19 @@ public class SocialLoginTest {
.user(AssertEvents.isUUID())
.detail(Details.EMAIL, "bob@builder.com")
.detail(Details.RESPONSE_TYPE, "code")
.detail(Details.REGISTER_METHOD, "social")
.detail(Details.REGISTER_METHOD, "social@dummy")
.detail(Details.REDIRECT_URI, AssertEvents.DEFAULT_REDIRECT_URI)
.detail(Details.USERNAME, "2@dummy")
.assertEvent().getUserId();
profilePage.update("Dummy", "User", "dummy-user-reg@dummy-social");
events.expectRequiredAction("update_profile").user(userId).detail(Details.AUTH_METHOD, "social").detail(Details.USERNAME, "2@dummy").assertEvent();
events.expectRequiredAction("update_email").user(userId).detail(Details.AUTH_METHOD, "social").detail(Details.USERNAME, "2@dummy").detail(Details.PREVIOUS_EMAIL, "bob@builder.com").detail(Details.UPDATED_EMAIL, "dummy-user-reg@dummy-social").assertEvent();
events.expectRequiredAction("update_profile").user(userId).detail(Details.AUTH_METHOD, "social@dummy").detail(Details.USERNAME, "2@dummy").assertEvent();
events.expectRequiredAction("update_email").user(userId).detail(Details.AUTH_METHOD, "social@dummy").detail(Details.USERNAME, "2@dummy").detail(Details.PREVIOUS_EMAIL, "bob@builder.com").detail(Details.UPDATED_EMAIL, "dummy-user-reg@dummy-social").assertEvent();
Assert.assertEquals(RequestType.AUTH_RESPONSE, appPage.getRequestType());
String codeId = events.expectLogin().user(userId).removeDetail(Details.USERNAME).detail(Details.AUTH_METHOD, "social").detail(Details.USERNAME, "2@dummy").assertEvent().getDetails().get(Details.CODE_ID);
String codeId = events.expectLogin().user(userId).removeDetail(Details.USERNAME).detail(Details.AUTH_METHOD, "social@dummy").detail(Details.USERNAME, "2@dummy").assertEvent().getDetails().get(Details.CODE_ID);
AccessTokenResponse response = oauth.doAccessTokenRequest(oauth.getCurrentQuery().get(OAuth2Constants.CODE), "password");
AccessToken token = oauth.verifyToken(response.getAccessToken());