Navigating through intricate business requirements often leads to unique challenges, as was the case with a recent project of mine. The task at hand? I needed to create a signup process (backend + frontend) allowing users to register either with traditional email and password or through social login options like Google, Twitter, LinkedIn, GitHub, and Facebook.
Initially, I tried setting up the social logins on the website’s frontend using Angular’s OIDC library. But, I struggled due to the lack of guidance on configuring multiple providers (if you find it feasible, I would greatly appreciate any comments, hints, or guidance you can offer in the comments section to assist other readers as well).
So, I shifted focus to the backend, using Spring Boot’s OAuth2 server. With some careful setup, I managed to get everything working smoothly.
Since I know many developers face similar hurdles, I’m happy to share what I learned about the ways of completing this project and generating the necessary requisite credentials. It could save others a lot of time and headaches.
I’ll be breaking down this process into separate blog posts to ensure each step gets the attention it deserves. This part will serve setting up the backend project and preparing configurations.
Setting up a Spring Boot project
Start a new project with Spring Initializr in intelliJ or start.spring.io. I will be using Java 17 and Maven as my dependency manager:
Add the following dependencies to the project:
If you attempt to start the application now, you’ll encounter an error indicating that the datasource configuration is missing. Let’s fix that by adding the following lines to the application.properties file (make sure to customize the database, username and password properties to match your local configuration):
#Database configuration
spring.datasource.url=jdbc:postgresql://localhost:5432/auth
spring.datasource.username=postgres
spring.datasource.password=K29r8Dhc79n2gPG86CRhoVt9NBxTa0Gk
spring.jpa.hibernate.ddl-auto=update
spring.datasource.driver-class-name=org.postgresql.Driver
I would recommend running the PostgreSQL in Docker with the following command:
docker run — name multiple-auth-app-postgres -e POSTGRES_PASSWORD=K29r8Dhc79n2gPG86CRhoVt9NBxTa0Gk -d -p 127.0.0.1:5432:5432 postgres
Also, in my sample properties I am referring to the database “auth” and if you don’t create it manually, the application will produce an error on startup, similar to this:
Unable to determine Dialect without JDBC metadata
To handle this, you can add the database from the visual interface of the IntelliJ database plugin, or just get inside the running postgres container:
docker exec -it multiple-auth-app-postgres bash
Then start the postgres command line session:
psql -U postgres
and create the database:
create database auth;
Initial minor configurations
Let’s start by handling some minor configurations. Begin by setting up a ‘config’ folder where we’ll store various adjustments for our application. Then, implement a straightforward CORS filter. This filter ensures seamless communication between the user interface and the API in our local environments, preventing any CORS-related issues:
package com.anita.multipleauthapi.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class SimpleCORSFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Methods", "POST, GET, PUT, OPTIONS, DELETE");
response.setHeader("Access-Control-Max-Age", "3600");
response.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With, remember-me, Authorization");
filterChain.doFilter(request, response);
}
}
Next, let’s create a custom utility properties class. Don’t forget to annotate the main Spring Boot app class with @EnableConfigurationProperties(AppProperties.class) as well. This ensures that our custom properties class is properly configured and accessible throughout the application:
package com.anita.multipleauthapi.config;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.List;
@Data
@ConfigurationProperties(prefix = "app")
public class AppProperties {
private final Auth auth = new Auth();
private final OAuth2 oAuth2 = new OAuth2();
@Getter
@Setter
public static class Auth {
private String tokenSecret;
private long tokenExpirationMsec;
}
@Getter
public static final class OAuth2 {
private List<String> authorizedRedirectUris = List.of("http://localhost:4200/oauth2/redirect");
public OAuth2 authorizedRedirectUris(List<String> authorizedRedirectUris) {
this.authorizedRedirectUris = authorizedRedirectUris;
return this;
}
}
}
Additionally, we’ll proceed with creating a data serializer. This serializer will allow us to transmit error messages later on by ensuring that Jackson recognizes Java 8 Date & Time API data types:
package com.anita.multipleauthapi.config;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.SerializerProvider;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
public class CustomDateSerializer extends com.fasterxml.jackson.databind.ser.std.StdSerializer<LocalDateTime> {
public CustomDateSerializer() {
this(null);
}
public CustomDateSerializer(Class<LocalDateTime> t) {
super(t);
}
@Override
public void serialize(LocalDateTime value,
JsonGenerator gen,
SerializerProvider arg2) throws IOException, JsonProcessingException {
gen.writeString(value.format(DateTimeFormatter.ofPattern("dd-MM-yyyy hh:mm:ss")));
}
}
Finally, let’s add a global exception handler to our application. This handler will streamline the process of managing exceptions, making it easier to understand and control their handling:
package com.anita.multipleauthapi.config;
import com.anita.multipleauthapi.model.error.BadRequestException;
import com.anita.multipleauthapi.model.error.ErrorMessage;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler
public ResponseEntity<ErrorMessage> handleAuthenticationException(AuthenticationException e) {
ErrorMessage errorMessage = new ErrorMessage(
e.getMessage(),
HttpStatus.UNAUTHORIZED
);
log.error("AuthenticationException: ", e);
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(errorMessage);
}
@ExceptionHandler
public ResponseEntity<ErrorMessage> handleIllegalArgumentException(BadRequestException e) {
ErrorMessage errorMessage = new ErrorMessage(
e.getMessage(),
HttpStatus.BAD_REQUEST
);
log.error("BadRequestException: ", e);
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(errorMessage);
}
}
Here’s a model for the error message:
package com.anita.multipleauthapi.model.error;
import com.anita.multipleauthapi.config.CustomDateSerializer;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import lombok.Data;
import org.springframework.http.HttpStatus;
import java.time.LocalDateTime;
@Data
public class ErrorMessage {
@JsonSerialize(using = CustomDateSerializer.class)
private LocalDateTime time = LocalDateTime.now();
private String message;
private HttpStatus httpStatus;
public ErrorMessage(String message, HttpStatus httpStatus) {
this.message = message;
this.httpStatus = httpStatus;
}
}
and the BadRequestException:
package com.anita.multipleauthapi.model.error;
public class BadRequestException extends RuntimeException {
public BadRequestException(String message) {
super(message);
}
}
User model and token validation
Let’s create the following annotation to retrieve the currently logged-in user, which we’ll reference later:
package com.anita.multipleauthapi.security;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import java.lang.annotation.*;
@Target({ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal
public @interface CurrentUser {
}
Next, we’ll require a user model to serve both persistence needs and general application functionality:
package com.anita.multipleauthapi.model.entity;
import jakarta.persistence.*;
import lombok.Data;
@Data
@Entity
@Table(name="users")
public class User {
@Id
@GeneratedValue(strategy= GenerationType.IDENTITY)
private Long id;
@Column(name = "email")
private String email;
@Column(name = "firstname")
private String firstname;
@Column(name = "lastname")
private String lastname;
@Column(name = "password")
private String password;
@Column(name = "mail_sent")
private Boolean mailSent;
}
and a simple user repository with a method for querying users by email:
package com.anita.multipleauthapi.repository;
import com.anita.multipleauthapi.model.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
}
We’ll proceed by creating a user principal model for security purposes. This model will implement both OAuth2User and UserDetails interfaces from Spring Security library:
package com.anita.multipleauthapi.security;
import com.anita.multipleauthapi.model.entity.User;
import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@Data
public class UserPrincipal implements OAuth2User, UserDetails {
private Long id;
private String email;
private String password;
private Collection<? extends GrantedAuthority> authorities;
private Map<String, Object> attributes;
public UserPrincipal(Long id,
String email,
String password,
Collection<? extends GrantedAuthority> authorities) {
this.id = id;
this.email = email;
this.password = password;
this.authorities = authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return null;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public Map<String, Object> getAttributes() {
return null;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getName() {
return String.valueOf(id);
}
public static UserPrincipal create(User user) {
List<GrantedAuthority> authorities = List.of(); // can be implemented later if needed
return new UserPrincipal(
user.getId(),
user.getEmail(),
user.getPassword(),
authorities
);
}
public static UserPrincipal create(User user, Map<String, Object> attributes) {
UserPrincipal userPrincipal = UserPrincipal.create(user);
userPrincipal.setAttributes(attributes);
return userPrincipal;
}
}
As a side note, it’s worth mentioning that we won’t be implementing any permissions or role-based authorities in our tutorial.
Next, we’ll require a user details service responsible for loading users from the database, if they exist:
package com.anita.multipleauthapi.security;
import com.anita.multipleauthapi.model.entity.User;
import com.anita.multipleauthapi.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository
.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException(String.format("User not found with email: %s.", email)));
return UserPrincipal.create(user);
}
public UserDetails loadUserById(Long id) {
User user = userRepository
.findById(id)
.orElseThrow(() -> new UsernameNotFoundException(String.format("User not found with ID: %s.", id)));
return UserPrincipal.create(user);
}
}
Next, as we will be using JWT to securely transmit information between the parties, let’s proceed to the token validation part. To accomplish this, create the following service responsible for creating, validating, and parsing a token.
First, add the following dependency to pom.
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
And then we will create a helper class:
package com.anita.multipleauthapi.security;
import com.anita.multipleauthapi.config.AppProperties;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
import java.util.Date;
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenProvider {
private final AppProperties appProperties;
private Algorithm ALGORITHM;
public String createToken(Authentication authentication) {
UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
ALGORITHM = Algorithm.HMAC256(appProperties.getAuth().getTokenSecret());
Date now = new Date();
Date expirationDate = new Date(now.getTime() + appProperties.getAuth().getTokenExpirationMsec());
return JWT.create()
.withSubject(Long.toString(userPrincipal.getId()))
.withIssuedAt(now)
.withExpiresAt(expirationDate)
.sign(ALGORITHM);
}
public Long getUserIdFromToken(String token) {
JWTVerifier verifier = JWT.require(ALGORITHM).build();
DecodedJWT decodedJWT = verifier.verify(token);
String subject = decodedJWT.getSubject();
return Long.parseLong(subject);
}
public boolean validateToken(String token) {
try {
JWTVerifier verifier = JWT.require(ALGORITHM).build();
verifier.verify(token);
return true;
} catch (Exception e) {
log.error("Invalid or expired JWT.");
}
return false;
}
}
Now, we can proceed to create an authentication filter. This filter will intercept every incoming request to our application, attempt to retrieve the token, validate it, and update the security context accordingly:
package com.anita.multipleauthapi.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.lang.NonNull;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Slf4j
@Service
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final CustomUserDetailsService userDetailsService;
private String getJWTFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring("Bearer ".length());
}
return null;
}
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
try {
String jwt = getJWTFromRequest(request);
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Long userId = tokenProvider.getUserIdFromToken(jwt);
UserDetails userDetails = userDetailsService.loadUserById(userId);
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
} catch (Exception ex) {
log.error("Could not set user authentication in security context.", ex);
}
filterChain.doFilter(request, response);
}
}
OAuth2 Configuration
Before diving into implementing the security configuration, there’s one crucial task remaining: integrating OAuth2. To effectively manage the data received from the authentication providers, we require a user model. Feel free to adjust the parameters to suit your needs.
package com.anita.multipleauthapi.security.oauth2.user;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Map;
@Getter
@AllArgsConstructor
public abstract class OAuth2UserInfo {
protected Map<String, Object> attributes;
public abstract String getId();
public abstract String getName();
public abstract String getEmail();
public abstract String getImageUrl();
}
We’re gearing up to integrate several providers into our application: Google, GitHub, LinkedIn, Twitter, and Facebook. To facilitate this integration, we’ll create models tailored to each provider.
package com.anita.multipleauthapi.security.oauth2.user;
import java.util.Map;
public class GoogleOAuth2UserInfo extends OAuth2UserInfo {
public GoogleOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("sub");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}
package com.anita.multipleauthapi.security.oauth2.user;
import java.util.Map;
public class GithubOAuth2UserInfo extends OAuth2UserInfo {
public GithubOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return ((Integer) attributes.get("id")).toString();
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("avatar_url");
}
}
package com.anita.multipleauthapi.security.oauth2.user;
import java.util.Map;
public class FacebookOAuth2UserInfo extends OAuth2UserInfo {
public FacebookOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("id");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
if(attributes.containsKey("picture")) {
Map<String, Object> pictureObj = (Map<String, Object>) attributes.get("picture");
if(pictureObj.containsKey("data")) {
Map<String, Object> dataObj = (Map<String, Object>) pictureObj.get("data");
if(dataObj.containsKey("url")) {
return (String) dataObj.get("url");
}
}
}
return null;
}
}
package com.anita.multipleauthapi.security.oauth2.user;
import java.util.Map;
public class LinkedinOAuth2UserInfo extends OAuth2UserInfo {
public LinkedinOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("sub");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}
package com.anita.multipleauthapi.security.oauth2.user;
import java.util.Map;
public class TwitterOAuth2UserInfo extends OAuth2UserInfo {
public TwitterOAuth2UserInfo(Map<String, Object> attributes) {
super(attributes);
}
@Override
public String getId() {
return (String) attributes.get("sub");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getImageUrl() {
return (String) attributes.get("picture");
}
}
Let’s create an enum to represent the providers we plan to integrate:
package com.anita.multipleauthapi.model.enums;
public enum AuthProvider {
google,
facebook,
github,
linkedin,
twitter
}
And create a factory for getting the appropriate user info model:
package com.anita.multipleauthapi.security.oauth2.user;
import com.anita.multipleauthapi.model.enums.AuthProvider;
import com.anita.multipleauthapi.model.error.OAuth2AuthenticationProcessingException;
import java.util.Map;
public class OAuth2UserInfoFactory {
public static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map<String, Object> attributes) {
if (registrationId.equalsIgnoreCase(AuthProvider.google.toString())) {
return new GoogleOAuth2UserInfo(attributes);
} else if (registrationId.equalsIgnoreCase(AuthProvider.facebook.toString())) {
return new FacebookOAuth2UserInfo(attributes);
} else if (registrationId.equalsIgnoreCase(AuthProvider.github.toString())) {
return new GithubOAuth2UserInfo(attributes);
} else if (registrationId.equalsIgnoreCase(AuthProvider.linkedin.toString())) {
return new LinkedinOAuth2UserInfo(attributes);
} else if (registrationId.equalsIgnoreCase(AuthProvider.twitter.toString())) {
return new TwitterOAuth2UserInfo(attributes);
} else {
throw new OAuth2AuthenticationProcessingException(String.format("Login with %s is not supported.", registrationId));
}
}
}
Next, we’ll create our custom service by extending DefaultOAuth2UserService. This service will be responsible for processing the information returned about the user from the authentication providers.
package com.anita.multipleauthapi.security.oauth2.user;
import com.anita.multipleauthapi.model.entity.User;
import com.anita.multipleauthapi.model.error.OAuth2AuthenticationProcessingException;
import com.anita.multipleauthapi.repository.UserRepository;
import com.anita.multipleauthapi.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
try {
return processOAuth2User(userRequest, oAuth2User);
} catch (AuthenticationException ex) {
throw ex;
} catch (Exception ex) {
throw new InternalAuthenticationServiceException(ex.getMessage(), ex.getCause());
}
}
private OAuth2User processOAuth2User(OAuth2UserRequest oAuth2UserRequest, OAuth2User oAuth2User) {
OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(
oAuth2UserRequest.getClientRegistration().getRegistrationId(),
oAuth2User.getAttributes()
);
if (StringUtils.isEmpty(oAuth2UserInfo.getEmail())) {
log.error("Email not found from OAuth2 provider");
throw new OAuth2AuthenticationProcessingException("Email not found from OAuth2 provider");
}
Optional<User> userOptional = userRepository.findByEmail(oAuth2UserInfo.getEmail());
User user;
if (userOptional.isPresent()) {
user = userOptional.get();
} else {
log.error("Email not registered by administrator yet.");
throw new OAuth2AuthenticationProcessingException("Email not registered by administrator yet.");
}
return UserPrincipal.create(user, oAuth2User.getAttributes());
}
}
To handle interactions with cookies, which will be necessary for managing OAuth2 results later on, let’s set up a convenient service. This service will be responsible for interacting with request cookies.
Add the following component:
package com.anita.multipleauthapi.security.oauth2;
import com.anita.multipleauthapi.service.util.CookieUtils;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.stereotype.Component;
@Component
public class HttpCookieOauth2AuthorizationRequestReqpository implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
public static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = "oauth2_auth_request";
public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
private static final int cookieExpireSeconds = 180;
@Override
public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) {
return CookieUtils
.getCookie(request, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME)
.map(cookie -> CookieUtils.deserialize(cookie, OAuth2AuthorizationRequest.class))
.orElse(null);
}
@Override
public void saveAuthorizationRequest(OAuth2AuthorizationRequest authorizationRequest, HttpServletRequest request, HttpServletResponse response) {
if (authorizationRequest == null) {
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
return;
}
CookieUtils.addCookie(response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME, CookieUtils.serialize(authorizationRequest), cookieExpireSeconds);
String redirectUriAfterLogin = request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, redirectUriAfterLogin, cookieExpireSeconds);
}
}
@Override
public OAuth2AuthorizationRequest removeAuthorizationRequest(HttpServletRequest request, HttpServletResponse response) {
return this.loadAuthorizationRequest(request);
}
public void removeAuthorizationRequestCookies(HttpServletRequest request, HttpServletResponse response) {
CookieUtils.deleteCookie(request, response, OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
CookieUtils.deleteCookie(request, response, REDIRECT_URI_PARAM_COOKIE_NAME);
}
}
We also want to customize our handlers for OAuth failure:
package com.anita.multipleauthapi.security.oauth2;
import com.anita.multipleauthapi.service.util.CookieUtils;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import static com.anita.multipleauthapi.security.oauth2.HttpCookieOAuth2AuthorizationRequestReqpository.REDIRECT_URI_PARAM_COOKIE_NAME;
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private final HttpCookieOAuth2AuthorizationRequestReqpository httpCookieOAuth2AuthorizationRequestRepository;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
String targetUrl = CookieUtils
.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue)
.orElse(("/"));
targetUrl = UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("error", exception.getLocalizedMessage())
.build().toUriString();
httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
and success:
package com.anita.multipleauthapi.security.oauth2;
import com.anita.multipleauthapi.config.AppProperties;
import com.anita.multipleauthapi.model.error.BadRequestException;
import com.anita.multipleauthapi.security.TokenProvider;
import com.anita.multipleauthapi.service.util.CookieUtils;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import org.springframework.web.util.UriComponentsBuilder;
import java.io.IOException;
import java.net.URI;
import java.util.Optional;
import static com.anita.multipleauthapi.security.oauth2.HttpCookieOAuth2AuthorizationRequestReqpository.REDIRECT_URI_PARAM_COOKIE_NAME;
@Component
@RequiredArgsConstructor
public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final TokenProvider tokenProvider;
private final AppProperties appProperties;
private final HttpCookieOAuth2AuthorizationRequestReqpository httpCookieOAuth2AuthorizationRequestReqpository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String targetUrl = determineTargetUrl(request, response, authentication);
if (response.isCommitted()) {
logger.debug("Response has already been committed. Unable to redirect to " + targetUrl);
return;
}
clearAuthenticationAttributes(request, response);
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
protected String determineTargetUrl(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
Optional<String> redirectUri = CookieUtils
.getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
.map(Cookie::getValue);
if (redirectUri.isPresent() && !isAuthorizedRedirectUri(redirectUri.get())) {
throw new BadRequestException("Sorry! We've got an Unauthorized Redirect URI and can't proceed with the authentication");
}
String targetUrl = redirectUri.orElse(getDefaultTargetUrl());
String token = tokenProvider.createToken(authentication);
return UriComponentsBuilder.fromUriString(targetUrl)
.queryParam("token", token)
.build().toUriString();
}
protected void clearAuthenticationAttributes(HttpServletRequest request, HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
httpCookieOAuth2AuthorizationRequestReqpository.removeAuthorizationRequestCookies(request, response);
}
private boolean isAuthorizedRedirectUri(String uri) {
URI clientRedirectUri = URI.create(uri);
return appProperties.getOAuth2().getAuthorizedRedirectUris()
.stream()
.anyMatch(authorizedRedirectUri -> {
// Only validate host and port. Let the clients use different paths if they want to
URI authorizedURI = URI.create(authorizedRedirectUri);
return authorizedURI.getHost().equalsIgnoreCase(clientRedirectUri.getHost())
&& authorizedURI.getPort() == clientRedirectUri.getPort();
});
}
}
Add custom access token response converter (will later be used for LinkedIn’s case):
package com.anita.multipleauthapi.security.oauth2;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.oauth2.core.OAuth2AccessToken;
import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.util.StringUtils;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Slf4j
public class CustomAccessTokenResponseConverter implements Converter<Map<String, Object>, OAuth2AccessTokenResponse> {
private static final Set<String> TOKEN_RESPONSE_PARAMETER_NAMES = Stream.of(
OAuth2ParameterNames.ACCESS_TOKEN,
OAuth2ParameterNames.TOKEN_TYPE,
OAuth2ParameterNames.EXPIRES_IN,
OAuth2ParameterNames.REFRESH_TOKEN,
OAuth2ParameterNames.SCOPE
).collect(Collectors.toSet());
@Override
public OAuth2AccessTokenResponse convert(Map<String, Object> tokenResponseParameters) {
String accessToken = (String) tokenResponseParameters.get(OAuth2ParameterNames.ACCESS_TOKEN);
OAuth2AccessToken.TokenType accessTokenType = OAuth2AccessToken.TokenType.BEARER;
long expiresIn = 0;
if (tokenResponseParameters.containsKey(OAuth2ParameterNames.EXPIRES_IN)) {
try {
expiresIn = Long.valueOf((Integer) tokenResponseParameters.get(OAuth2ParameterNames.EXPIRES_IN));
} catch (NumberFormatException ex) {
log.error("Number format exception during access token response conversion, ", ex);
}
}
Set<String> scopes = Collections.emptySet();
if (tokenResponseParameters.containsKey(OAuth2ParameterNames.SCOPE)) {
String scope = (String) tokenResponseParameters.get(OAuth2ParameterNames.SCOPE);
scopes = Arrays.stream(StringUtils.delimitedListToStringArray(scope, " ")).collect(Collectors.toSet());
}
Map<String, Object> additionalParameters = new LinkedHashMap<>();
tokenResponseParameters.entrySet().stream()
.filter(e -> !TOKEN_RESPONSE_PARAMETER_NAMES.contains(e.getKey()))
.forEach(e -> additionalParameters.put(e.getKey(), e.getValue()));
return OAuth2AccessTokenResponse.withToken(accessToken)
.tokenType(accessTokenType)
.expiresIn(expiresIn)
.scopes(scopes)
.additionalParameters(additionalParameters)
.build();
}
}
And finally for this section - custom authorization request resolver:
package com.anita.multipleauthapi.security.oauth2;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.security.crypto.keygen.Base64StringKeyGenerator;
import org.springframework.security.crypto.keygen.StringKeyGenerator;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public class CustomAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
private final OAuth2AuthorizationRequestResolver defaultResolver;
private final StringKeyGenerator secureKeyGenerator = new Base64StringKeyGenerator(Base64.getUrlEncoder().withoutPadding(), 96);
public CustomAuthorizationRequestResolver(ClientRegistrationRepository repo, String authorizationRequestBaseUri) {
defaultResolver = new DefaultOAuth2AuthorizationRequestResolver(repo, authorizationRequestBaseUri);
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
OAuth2AuthorizationRequest req = defaultResolver.resolve(request);
return customizeAuthorizationRequest(req);
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request, String clientRegistrationId) {
return null;
}
private OAuth2AuthorizationRequest customizeAuthorizationRequest(OAuth2AuthorizationRequest req) {
if (Objects.isNull(req)) {
return null;
}
Map<String, Object> attributes = new HashMap<>(req.getAttributes());
Map<String, Object> additionalParameters = new HashMap<>(req.getAdditionalParameters());
addPkceParameters(attributes, additionalParameters);
return OAuth2AuthorizationRequest.from(req)
.attributes(attributes)
.additionalParameters(additionalParameters)
.build();
}
private void addPkceParameters(Map<String, Object> attributes, Map<String, Object> additionalParameters) {
String codeVerifier = this.secureKeyGenerator.generateKey();
attributes.put(PkceParameterNames.CODE_VERIFIER, codeVerifier);
try {
String codeChallenge = createHash(codeVerifier);
additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, codeChallenge);
additionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
} catch (NoSuchAlgorithmException e) {
additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, codeVerifier);
}
}
private static String createHash(String value) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(value.getBytes(StandardCharsets.US_ASCII));
return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
}
}
Security configuration
With all the necessary components in place, we’re ready to proceed with implementing the security configuration.
package com.anita.multipleauthapi.security;
import com.anita.multipleauthapi.security.oauth2.*;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.FormHttpMessageConverter;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.endpoint.DefaultAuthorizationCodeTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.http.OAuth2ErrorResponseErrorHandler;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.client.RestTemplate;
import java.util.Arrays;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final static String OAUTH2_BASE_URI = "/oauth2/authorize";
private final static String OAUTH2_REDIRECTION_ENDPOINT = "/oauth2/callback";
private final CustomOAuth2UserService customOAuth2UserService;
private final OAuth2AuthenticationFailureHandler oAuth2AuthenticationFailureHandler;
private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
private final TokenAuthenticationFilter tokenAuthenticationFilter;
private final ClientRegistrationRepository clientRegistrationRepository;
@Bean
public HttpCookieOAuth2AuthorizationRequestRepository cookieOAuth2AuthorizationRequestRepository() {
return new HttpCookieOAuth2AuthorizationRequestRepository();
}
@Bean
protected SecurityFilterChain configure(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable);
http.cors(Customizer.withDefaults());
http.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.formLogin(AbstractHttpConfigurer::disable);
http.httpBasic(AbstractHttpConfigurer::disable);
http.authorizeHttpRequests(
auth -> auth
.requestMatchers("/token/refresh/**").permitAll()
.requestMatchers("/", "/error").permitAll()
.requestMatchers("/auth/**", "/oauth2/**").permitAll()
.anyRequest()
.authenticated()
);
http
.oauth2Login(oauth2 -> oauth2
.authorizationEndpoint(authorizationEndpointConfig -> authorizationEndpointConfig
.baseUri(OAUTH2_BASE_URI)
.authorizationRequestRepository(httpCookieOAuth2AuthorizationRequestRepository)
.authorizationRequestResolver(new CustomAuthorizationRequestResolver(clientRegistrationRepository, OAUTH2_BASE_URI))
)
.redirectionEndpoint(redirectionEndpointConfig -> redirectionEndpointConfig.baseUri(OAUTH2_REDIRECTION_ENDPOINT))
.userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOAuth2UserService))
.tokenEndpoint(tokenEndpointConfig -> tokenEndpointConfig.accessTokenResponseClient(authorizationCodeTokenResponseClient()))
.successHandler(oAuth2AuthenticationSuccessHandler)
.failureHandler(oAuth2AuthenticationFailureHandler)
);
http.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
private OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> authorizationCodeTokenResponseClient() {
OAuth2AccessTokenResponseHttpMessageConverter tokenResponseHttpMessageConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
tokenResponseHttpMessageConverter.setAccessTokenResponseConverter(new CustomAccessTokenResponseConverter());
RestTemplate restTemplate = new RestTemplate(Arrays.asList(new FormHttpMessageConverter(), tokenResponseHttpMessageConverter));
restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
DefaultAuthorizationCodeTokenResponseClient tokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
tokenResponseClient.setRestOperations(restTemplate);
return tokenResponseClient;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Currently, if you attempt to run the application, you’ll encounter the following error:44
The reason for this error is that we haven’t added our client configuration for any of the authentication providers in the application properties.
We’ll address this issue in the upcoming tutorial. Don’t hesitate to check the code in my GitHub repository if you missed any steps. The repository is organized into branches corresponding to each tutorial part, making it easy to find the appropriate changes for each section. Happy coding!