Implementing social login on a website with Spring microservices
Hi. This is the nineteenth part of the diary about developing the “Programmers’ diary” blog. Previous part: https://hashnode.programmersdiary.com/thoughts-on-implementing-local-and-social-logins-on-a-website. The open source code of this project is on https://github.com/TheProgrammersDiary.
The first diary entry explains why this project was done: https://medium.com/@vievaldas/developing-a-website-with-microservices-part-1-the-idea-fe6e0a7a96b5.
Next entry:
—————
2024-12-26
Let’s get the stuff mentioned in the previous part implemented.
Let’s add a pom dependency which will allow us using social logins:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
<version>3.2.0</version>
</dependency>
We need to go to Google console and create an API for Oauth2. Here is how to setup the API: https://support.google.com/googleapi/answer/6158862?hl=en.
When we have client id and client secret, we want to add this structure in application-docker.yaml:
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${google_client_id}
client-secret: ${google_client_secret}
Note that I have environmental variables setup to hide the real values when this file gets posted on Github.
To access env variables, docker-compose.yaml needs changes:
version: "3.9"
services:
blog:
container_name: blog
build:
context: ../monolith
dockerfile: Dockerfile
ports:
- "8080:8080"
depends_on:
- mongodb
- postgres_blog
environment:
google_client_id: ${google_client_id}
google_client_secret: ${google_client_secret}
SPRING_PROFILES_ACTIVE: docker
Docker will inject the env variables to container so Spring app can read them.
As mentioned in previous part, since we can’t know/store Google password and want to use the same table for normal/social login, we need to set password column to nullable. So let’s remove @Column(nullable = false) in UserRepository (default value is true).
Also let’s add another .sql file resources/liquibase/postgresql/changelog/002_user.sql:
ALTER TABLE blog_user ALTER COLUMN password DROP NOT NULL;
resources/liquibase/postgresql/changelog/liquibase.changelog_master.yaml now looks like this:
databaseChangeLog:
- include:
file: classpath:/liquibase/postgresql/changelog/001_user.sql
- include:
file: classpath:/liquibase/postgresql/changelog/002_user.sql
In Security config, we need to introduce OAuth2 setup:
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.exceptionHandling(
exception -> exception.authenticationEntryPoint(
(request, response, authException) -> {
logger.error("Unauthorized error: {}", authException.getMessage());
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Error: Unauthorized");
}
)
)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(
requests -> requests
.requestMatchers("/users/signup", "/users/login", "/actuator/prometheus")
.permitAll()
.requestMatchers(HttpMethod.OPTIONS)
.permitAll()
.anyRequest()
.authenticated()
)
**.oauth2Login(oauth2 -> oauth2.successHandler(oAuth2AuthorizationSuccessHandler()));**
http.authenticationProvider(authenticationProvider());
http.addFilterBefore(loggingFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
We want to have some variables injected to oAuth2AuthorizationSuccessHandler so let’s create a bean:
@Bean
public OAuth2AuthorizationSuccessHandler oAuth2AuthorizationSuccessHandler() {
return new OAuth2AuthorizationSuccessHandler();
}
The OAuth2AuthorizationSuccessHandler:
public class OAuth2AuthorizationSuccessHandler implements AuthenticationSuccessHandler {
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Autowired
private UserRepository userRepository;
@Autowired
private BlacklistedJwtTokenRepository blacklistedJwtTokenRepository;
private static final Loggerlog= LoggerFactory.getLogger(OAuth2AuthorizationSuccessHandler.class);
@Override
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication
) throws IOException {
log.info("Authentication: {}", authentication);
String email;
String username;
String providerName = ((OAuth2AuthenticationToken) authentication).getAuthorizedClientRegistrationId();
if(providerName.equals("google")) {
email = ((DefaultOidcUser)authentication.getPrincipal()).getEmail();
username = ((DefaultOidcUser)authentication.getPrincipal()).getAttribute("name");
}
else {
throw new RuntimeException("Unrecognized Oauth2 provider.");
}
if (!userRepository.existsByEmail(email)) {
userRepository.save(new UserRepository.UserEntry(username, email));
}
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
JwtToken token = JwtToken.create(authToken, blacklistedJwtTokenRepository);
ResponseCookie cookie = ResponseCookie.from("jwt", token.retrieve())
.httpOnly(true)
//.secure(true) //TODO: uncomment then HTTPS is enabled.
.maxAge(Duration.ofMinutes(10))
.path("/")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
response.sendRedirect(
"<http://localhost:3000/auth_login_success>" +
"?username=" + URLEncoder.encode(token.username(), StandardCharsets.UTF_8) +
"&expirationDate="
+ URLEncoder.encode(token.expirationDate().toString(), StandardCharsets.UTF_8)
);
}
}
When we use social login it will sign the user up if it does not exist. The user will be logged in. When the response is formed we will redirect to /auth_login_success where FE can extract username and expirationDate (remember cookie is HttpOnly and JavaScript can’t read it so we need to provide some details via other means like query parameters).
Here is FE auth_login_success/page.tsx:
"use client"
import { signIn } from 'next-auth/react';
import { useSearchParams } from 'next/navigation'
export default function AuthLoginSuccess() {
const searchParams = useSearchParams();
const username = searchParams.get("username");
const expirationDate = searchParams.get("expirationDate");
signIn('credentials',
{ callbackUrl: '/', username: username, expiration: expirationDate}
);
return (<p>Successfully logged in!</p>);
}
After logging in message (”Successfully logged in!”) will be displayed briefly as the user is signed in via FE and redirected to the main page.
We can now sign up with Google:
In FE, go to /login, click Google icon, sign in and FE will show Logged in as [your name].
OK, let’s talk about some security considerations. As of now there is no actual logout. If a hacker sends a non-expired token, BE will consider it valid even if the user logged out from FE. There is no logout from BE. To solve this we can use blacklisting functionality so we can immediately log out user from BE. Short-lived tokens are good, yet I think a decent website should have immediate logout to reduce security risks.
Imagine we scale the website so there are multiple microservices providing user authentication. If blacklisting is stored in the local microservice’s memory, having two instances could pose a security issue: let’s say both services’ instances have the same jwt key. You log in by calling one of the instances and logout by calling instance A. Then a malicious person takes over the victim’s computer and before the token expires he calls some functionality which requires authentication. The request goes to service B and since that service does not have the token as blacklisted the hacker can read/change the victim’s data. We don’t want that. So there are 3 options:
Use single auth microservice instance - but that would kill our scalability which nullifies microservices’ benefits.
Use multiple instances with different jwt keys, each having their own blacklists. Somewhere near (or in) the load-balancer (which directs traffic) there will be proxy telling which request should go to which instance. This logic should be small since pipes ought to be dump, requests should travel quickly, else we will have a bottleneck.
Use distributed caching or publisher/subscriber functionality.
Options 2 and 3 make sense. However, in our website we have posts which should only be written by logged-in users. Therefore Post microservice should also be aware of the token blacklist. If we used option 2 Post microservice would need to know which user authentication service instance it needs to call to check if token is valid. This is overkill, option 2 is too much.
Let’s stick with option 3. We can use Redis. It is said that this service can process up to 100 000 requests in a second according to some benchmarks (as is said in Luc Perkins (with Eric Redmond and Jim R. Wilson) Seven Databases in Seven Weeks) so it should be very efficient and would not be a bottleneck. We could even store valid tokens instead of blacklisted ones, yet I think that would be a security issue if someone got access to Redis, while if we store only blacklisted ones we minimize the risk (attacker could drop Redis cache and automatically re-login user, but only for some limited time).
We could also use Kafka and have a non-functional requirement to ensure user is logged out after 10 seconds pass from the logout request. However, I have tried Kafka and not Redis, so Redis will be more fun.
—————
Thanks for reading.
The project logged in this diary is open source, so if you would like to code or suggest changes, please visit https://github.com/TheProgrammersDiary.
You can check out the results of the project: https://www.programmersdiary.com/.
More diary entries coming soon.