Realtime user tracking in Spring Boot
By: Mihaita Tinta
Tags: Realtime, Spring Boot, Web Development, Charts, Events

In this article we will build an end to end example to see all the logged in users from an admin page. Every-time someone logs in, a dashboard should reflect the change with no need for the admin to refresh the page — realtime. We will also monitor the number of user login failures. This might be useful to detect some brute force attacks. Ideally you already have in mind adding a lock out mechanism.

In this end-to-end implementation we need:

a login page — for simplicity reasons we will use the default spring security form login page. Our security java config should also restrict access to the admin pages because only the admin should see the user dashboard.

@Configuration

@EnableWebSecurity

public class SecurityConfig extends WebSecurityConfigurerAdapter {

//... @Override

protected void configure(HttpSecurity http) throws Exception {

http

.formLogin()

.and()

.authorizeRequests()

.antMatchers("/admin/**")

.hasRole("ADMIN")

.and()

.authorizeRequests()

.anyRequest()

.authenticated()

;

two types of users — we are adding a simple user service with one user and one admin. You can check out the `PasswordEncoderFactories` to see additional options for the password encoding methods available. Don’t use noop for production, the recommended value is bcrypt.

@Configuration

@EnableWebSecurity

public class SecurityConfig extends WebSecurityConfigurerAdapter {@Override

protected void configure(AuthenticationManagerBuilder auth) throws Exception {



auth.inMemoryAuthentication()

.withUser("user")

.password("{noop}user")

.roles("USER")

.and()

.withUser("admin")

.password("{noop}admin")

.roles("ADMIN");

} //...

}

an admin page with a chart showing the number of logged-in users. You can find here how to set up a simple chart like this. There are also many other types you can explore.

We can display the doughnut above with the javascript code below:

<script type="application/javascript">



var ctx = document.getElementById("myChart");

var chart = new Chart(ctx, {

type: 'doughnut',

data: {

labels: [

'Authentication success',

'Authentication failure'

],

datasets: [{

label: 'Users',

data: [6, 10],

backgroundColor: [

'rgb(54, 162, 235)',

'rgb(255, 205, 86)'

],

hoverOffset: 4

}]

}

});



</script>

The next challenge we have is to get the login events when something happens and update the chart.data.datasets.data array. Programatically, when we change the data attribute and call the update method, we can see the dashboard changing.

chart.data.datasets[0].data = [2,10]

chart.update();

We will expose all our HTML pages from the classpath.

@Configuration

class WebConfig implements WebMvcConfigurer {



@Override

public void addResourceHandlers(ResourceHandlerRegistry registry) {

registry.addResourceHandler("/", "/index.html")

.addResourceLocations("classpath:/static/")

.setCacheControl(CacheControl.noCache()).resourceChain(false)

.addResolver(new EncodedResourceResolver());



registry.addResourceHandler("/**").addResourceLocations("classpath:/static/")

.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic())

.resourceChain(false).addResolver(new EncodedResourceResolver());

}



@Override

public void addViewControllers(ViewControllerRegistry registry) {

registry.addViewController("/").setViewName("redirect:/index.html");

}

}

We can bind to some spring security events in a spring bean with the @EventListener annotation. Every time someone logs in, we get an AuthenticationSuccessEvent telling us who just logged in. That is the moment when to use our SseTemplate to broadcast the statistics to all the admin connections. We are doing the same thing when there is a login failure.

private static final Map<String, Integer> MAP = new ConcurrentHashMap<>();



static {

MAP.put("success", 0);

MAP.put("failure", 0);

}



@EventListener

public void authenticationSuccess(AuthenticationSuccessEvent event) {



log.debug("authenticationSuccess - event: {}", event);



MAP.put("success", MAP.compute("success", (key, value) -> ++value));



template.broadcast("users", SseEmitter.event()

.name("success")

.data(Map.of("current", event.getAuthentication().getName(),

"total", MAP)));

}



@EventListener

public void authenticationFailed(AbstractAuthenticationFailureEvent event) {



log.debug("authenticationFailed - event: {}", event);



MAP.put("failure", MAP.compute("failure", (key, value) -> ++value));



template.broadcast("users", SseEmitter.event()

.name("failure")

.data(Map.of("current", event.getAuthentication().getName(),

"total", MAP)));

}

After we create a Text Event Stream endpoint, we can see in the browser some data. You can check out my previous article on how this works. The SseTemplate is our bean capable to create new topic-specific connections with the browsers and broadcast messages.

@RestController

@RequestMapping("/admin")

public class AdminController {



@Autowired

SseTemplate template;



@GetMapping(path = "/users", produces = MediaType.TEXT_EVENT_STREAM_VALUE)

public SseEmitter sseUsers() {

return template.newSseEmitter("users");

}



}

From javascript, we need to use the EventSource interface.

An EventSource instance opens a persistent connection to an HTTP server, which sends events in text/event-stream format. The connection remains open until closed by calling EventSource.close() .

var source = new EventSource('/admin/users');

source.addEventListener('success', e => console.log('success', e));

If we add the code above in the developer console (Chrome — F12) and then login with some user we can see the log below.

From the network tab, we can see the connection remains open, waiting for messages to arrive.

In the EventStream tab, we can browse all the messages we receive

At this moment we can put it all together, the javascript event source event can change our dashboard in real time.

const source = new EventSource('/admin/users');

const onEvent = function (e) {

console.log('onEvent', e);

const data = JSON.parse(e.data);

chart.data.datasets[0].data = [data.total.success, data.total.failure];

chart.update();

}

source.addEventListener('success', onEvent);

source.addEventListener('failure', onEvent);

The 4 login failures attempts are reflected in the admin page:

While this might be useful, there are still some additional things to consider. The backend should also close any sse connections when an admin logs out for example. By default, you can use the /logout page to logout and login again to test multiple times and see the chart being updated.

Thanks for reading and you can find the working example here