Spring boot User Registration and OAuth2 Social login with Facebook, Google, LinkedIn and Github – Part 3



Welcome to the 3rd part of the Spring Boot User Registration and OAuth2 social login tutorial series. In this article, we’ll learn how to perform social login using Spring Security.

Creating custom classes for OAuth2 Authentication

CustomOAuth2UserService.java

The CustomOAuth2UserService extends Spring Security’s DefaultOAuth2UserService and implements its loadUser() method. This method is called after an access token is obtained from the OAuth2 provider.

In this method, we first fetch the user’s details from the OAuth2 provider. If a user with the same email already exists in our database then we update his details, otherwise, we register a new user.

If the OAuth2 provider is LinkedIn, then we need to invoke the email address endpoint with the access token to get the user email address since it will not be returned in the response of user-info endpoint.

@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

	@Autowired
	private UserService userService;

	@Autowired
	private Environment env;

	@Override
	public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
		OAuth2User oAuth2User = super.loadUser(oAuth2UserRequest);
		try {
			Map<String, Object> attributes = new HashMap<>(oAuth2User.getAttributes());
			String provider = oAuth2UserRequest.getClientRegistration().getRegistrationId();
			if (provider.equals(SocialProvider.LINKEDIN.getProviderType())) {
				populateEmailAddressFromLinkedIn(oAuth2UserRequest, attributes);
			}
			return userService.processUserRegistration(provider, attributes, null, null);
		} catch (AuthenticationException ex) {
			throw ex;
		} catch (Exception ex) {
			ex.printStackTrace();
			// Throwing an instance of AuthenticationException will trigger the
			// OAuth2AuthenticationFailureHandler
			throw new OAuth2AuthenticationProcessingException(ex.getMessage(), ex.getCause());
		}
	}

	@SuppressWarnings({ "rawtypes", "unchecked" })
	public void populateEmailAddressFromLinkedIn(OAuth2UserRequest oAuth2UserRequest, Map<String, Object> attributes) throws OAuth2AuthenticationException {
		String emailEndpointUri = env.getProperty("linkedin.email-address-uri");
		Assert.notNull(emailEndpointUri, "LinkedIn email address end point required");
		RestTemplate restTemplate = new RestTemplate();
		HttpHeaders headers = new HttpHeaders();
		headers.add(HttpHeaders.AUTHORIZATION, "Bearer " + oAuth2UserRequest.getAccessToken().getTokenValue());
		HttpEntity<?> entity = new HttpEntity<>("", headers);
		ResponseEntity<Map> response = restTemplate.exchange(emailEndpointUri, HttpMethod.GET, entity, Map.class);
		List<?> list = (List<?>) response.getBody().get("elements");
		Map map = (Map<?, ?>) ((Map<?, ?>) list.get(0)).get("handle~");
		attributes.putAll(map);
	}
}

CustomOidcUserService.java

OidcUserService is an implementation of an OAuth2UserService that supports OpenID Connect 1.0 Providers. Google is an OpenID Connect provider. Hence, we are creating this service to load the user with Google’s OAuth 2.0 APIs.

The CustomOidcUserService extends Spring Security’s OidcUserService and implements its loadUser() method. This method is called after an access token is obtained from the OAuth2 provider.

@Service
public class CustomOidcUserService extends OidcUserService {

	@Autowired
	private UserService userService;

	@Override
	public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException {
		OidcUser oidcUser = super.loadUser(userRequest);
		try {
			return userService.processUserRegistration(userRequest.getClientRegistration().getRegistrationId(), oidcUser.getAttributes(), oidcUser.getIdToken(),
					oidcUser.getUserInfo());
		} catch (AuthenticationException ex) {
			throw ex;
		} catch (Exception ex) {
			ex.printStackTrace();
			// Throwing an instance of AuthenticationException will trigger the
			// OAuth2AuthenticationFailureHandler
			throw new OAuth2AuthenticationProcessingException(ex.getMessage(), ex.getCause());
		}
	}
}

OAuth2AccessTokenResponseConverterWithDefaults.java

LinkedIn OAuth2 access token API is returning only the access_token and expires_in values but not the token_type in the response. This results in the following error.

org.springframework.http.converter.HttpMessageNotReadableException: An error occurred reading the OAuth 2.0 Access Token Response: tokenType cannot be null; nested exception is java.lang.IllegalArgumentException: tokenType cannot be null

The following code snippet throws that error

public OAuth2AccessToken(TokenType tokenType, String tokenValue, Instant issuedAt, Instant expiresAt, Set<String> scopes) {
		super(tokenValue, issuedAt, expiresAt);
		Assert.notNull(tokenType, "tokenType cannot be null");
		this.tokenType = tokenType;
		this.scopes = Collections.unmodifiableSet(
			scopes != null ? scopes : Collections.emptySet());
	}

Hence the default OAuth2AccessTokenResponseConverter class has been copied as OAuth2AccessTokenResponseConverterWithDefaults and modified to set a default token type to resolve this issue

/**
 * @author Joe Grandja
 */
public class OAuth2AccessTokenResponseConverterWithDefaults implements Converter<Map<String, String>, 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());

	private OAuth2AccessToken.TokenType defaultAccessTokenType = OAuth2AccessToken.TokenType.BEARER;

	@Override
	public OAuth2AccessTokenResponse convert(Map<String, String> tokenResponseParameters) {
		String accessToken = tokenResponseParameters.get(OAuth2ParameterNames.ACCESS_TOKEN);

		OAuth2AccessToken.TokenType accessTokenType = this.defaultAccessTokenType;
		if (OAuth2AccessToken.TokenType.BEARER.getValue().equalsIgnoreCase(tokenResponseParameters.get(OAuth2ParameterNames.TOKEN_TYPE))) {
			accessTokenType = OAuth2AccessToken.TokenType.BEARER;
		}

		long expiresIn = 0;
		if (tokenResponseParameters.containsKey(OAuth2ParameterNames.EXPIRES_IN)) {
			try {
				expiresIn = Long.valueOf(tokenResponseParameters.get(OAuth2ParameterNames.EXPIRES_IN));
			} catch (NumberFormatException ex) {
			}
		}

		Set<String> scopes = Collections.emptySet();
		if (tokenResponseParameters.containsKey(OAuth2ParameterNames.SCOPE)) {
			String scope = 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();
	}

	public final void setDefaultAccessTokenType(OAuth2AccessToken.TokenType defaultAccessTokenType) {
		Assert.notNull(defaultAccessTokenType, "defaultAccessTokenType cannot be null");
		this.defaultAccessTokenType = defaultAccessTokenType;
	}
}

OAuth2UserInfo mapping

Every OAuth2 provider returns a different JSON response when we fetch the authenticated user’s details. Spring security parses the response in the form of a generic map of key-value pairs.

The following classes are used to get the required details of the user from the generic map of key-value pairs

OAuth2UserInfo.java

public abstract class OAuth2UserInfo {
	protected Map<String, Object> attributes;

	public OAuth2UserInfo(Map<String, Object> attributes) {
		this.attributes = attributes;
	}

	public Map<String, Object> getAttributes() {
		return attributes;
	}

	public abstract String getId();

	public abstract String getName();

	public abstract String getEmail();

	public abstract String getImageUrl();
}

FacebookOAuth2UserInfo.java

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
	@SuppressWarnings("unchecked")
	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;
	}
}

GoogleOAuth2UserInfo.java

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");
	}
}

GithubOAuth2UserInfo.java

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");
	}
}

LinkedinOAuth2UserInfo.java

public class LinkedinOAuth2UserInfo extends OAuth2UserInfo {

	public LinkedinOAuth2UserInfo(Map<String, Object> attributes) {
		super(attributes);
	}

	@Override
	public String getId() {
		return (String) attributes.get("id");
	}

	@Override
	public String getName() {
		return ((String) attributes.get("localizedFirstName")).concat(" ").concat((String) attributes.get("localizedLastName"));
	}

	@Override
	public String getEmail() {
		return (String) attributes.get("emailAddress");
	}

	@Override
	public String getImageUrl() {
		return (String) attributes.get("pictureUrl");
	}
}

OAuth2UserInfoFactory.java

public class OAuth2UserInfoFactory {
	public static OAuth2UserInfo getOAuth2UserInfo(String registrationId, Map<String, Object> attributes) {
		if (registrationId.equalsIgnoreCase(SocialProvider.GOOGLE.getProviderType())) {
			return new GoogleOAuth2UserInfo(attributes);
		} else if (registrationId.equalsIgnoreCase(SocialProvider.FACEBOOK.getProviderType())) {
			return new FacebookOAuth2UserInfo(attributes);
		} else if (registrationId.equalsIgnoreCase(SocialProvider.GITHUB.getProviderType())) {
			return new GithubOAuth2UserInfo(attributes);
		} else if (registrationId.equalsIgnoreCase(SocialProvider.LINKEDIN.getProviderType())) {
			return new LinkedinOAuth2UserInfo(attributes);
		} else if (registrationId.equalsIgnoreCase(SocialProvider.TWITTER.getProviderType())) {
			return new GithubOAuth2UserInfo(attributes);
		} else {
			throw new OAuth2AuthenticationProcessingException("Sorry! Login with " + registrationId + " is not supported yet.");
		}
	}
}

PagesController.java

This is our main controller which handles all the incoming requests

@RestController
public class PagesController {

	private final Logger logger = LogManager.getLogger(getClass());

	@Resource
	private MessageService messageService;

	@Autowired
	private UserService userService;

	@GetMapping("/login")
	public ModelAndView login(HttpServletRequest request, HttpServletResponse response, @RequestParam(value = "error", required = false) String error,
			@RequestParam(value = "logout", required = false) String logout, @RequestParam(value = "errorCode", required = false) String errorCode)
			throws ServletException, IOException {
		ModelAndView model = new ModelAndView();
		if (error != null) {
			model.addObject("css", "danger");
			model.addObject("msg", error);
		} else if (logout != null) {
			model.addObject("css", "success");
			model.addObject("msg", messageService.getMessage("message.logout." + logout));
		}
		model.addObject("title", "Login Page");
		model.setViewName("login");
		return model;
	}

	@GetMapping("/register")
	public ModelAndView register(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		return new ModelAndView("register", "userRegistrationForm", new UserRegistrationForm());
	}

	@PostMapping("/register")
	public ModelAndView registerUser(@Valid UserRegistrationForm userRegistrationForm, BindingResult result, final HttpServletRequest request, RedirectAttributes attributes) {
		ModelAndView model = new ModelAndView("register");
		if (!result.hasErrors()) {
			try {
				userService.registerNewUser(userRegistrationForm);
				attributes.addFlashAttribute("css", "success");
				attributes.addFlashAttribute("msg", messageService.getMessage("message.regSucc"));
				model = new ModelAndView("redirect:/login");
			} catch (UserAlreadyExistAuthenticationException e) {
				logger.error(e);
				result.rejectValue("email", "message.regError");
			}
		}
		return model;
	}

	@GetMapping({ "/", "/home" })
	public ModelAndView home(@RequestParam(value = "view", required = false) String view) {
		logger.info("Entering home page");
		ModelAndView model = new ModelAndView("home");
		model.addObject("title", "Home");
		model.addObject("view", view);
		return model;
	}
}

AppConfig.java

@Configuration annotation indicates that a class declares one or more @Bean methods and may be processed by the Spring container to generate bean definitions and service requests for those beans at runtime.

@Bean annotation is applied on a method to specify that it returns a bean to be managed by Spring context

@Configuration
public class AppConfig implements WebMvcConfigurer {

	// beans
	@Bean
	public MessageSource messageSource() {
		ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
		messageSource.setBasename("classpath:messages");
		messageSource.setDefaultEncoding("UTF-8");
		return messageSource;
	}

	@Bean
	public LocaleResolver localeResolver() {
		final CookieLocaleResolver cookieLocaleResolver = new CookieLocaleResolver();
		cookieLocaleResolver.setDefaultLocale(Locale.ENGLISH);
		return cookieLocaleResolver;
	}

	@Override
	public Validator getValidator() {
		LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean();
		validator.setValidationMessageSource(messageSource());
		return validator;
	}
}

WebSecurityConfig.java

Add the annotation @EnableWebSecurity to the class to tell spring that this class is spring security configuration.

In this configuration class, we have also configured the OAuth2AccessTokenResponseClient with our custom OAuth2AccessTokenResponseConverterWithDefaults

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

	private static final String[] IGNORED_RESOURCE_LIST = new String[] { "/fonts/**", "/webjars/**", "/files/**", "/static/**", "/robots.txt" };

	@Autowired
	private AuthenticationFailureHandler authenticationFailureHandler;

	@Autowired
	private LogoutSuccessHandler logoutSuccessHandler;

	@Autowired
	private UserDetailsService userDetailsService;

	@Autowired
	private CustomOAuth2UserService customOAuth2UserService;

	@Autowired
	CustomOidcUserService customOidcUserService;

	@Autowired
	public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
		auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
	}

	@Override
	protected void configure(HttpSecurity http) throws Exception {

		http.csrf().disable();

		http.authorizeRequests().antMatchers("/", "/home").hasRole(Role.USER);
		// Pages do not require login
		http.authorizeRequests().antMatchers("/**").permitAll();

		// Form Login config
		http.authorizeRequests().and().formLogin()
				.loginPage("/login")//
				.defaultSuccessUrl("/home")//
				.failureUrl("/login?error=true").failureHandler(authenticationFailureHandler)//
				.usernameParameter("j_username")//
				.passwordParameter("j_password").and().oauth2Login().loginPage("/login").failureHandler(authenticationFailureHandler).defaultSuccessUrl("/home").userInfoEndpoint()
				.oidcUserService(customOidcUserService).userService(customOAuth2UserService).and().tokenEndpoint()
				.accessTokenResponseClient(authorizationCodeTokenResponseClient());
		// Logout Config
		http.authorizeRequests().and().logout().deleteCookies("JSESSIONID").logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
		//
		http.sessionManagement().invalidSessionUrl("/login").maximumSessions(1).expiredUrl("/login?session=expired");
	}

	// This bean is used to load the user specific data when form login is used.
	@Override
	public UserDetailsService userDetailsService() {
		return userDetailsService;
	}

	@Bean
	public PasswordEncoder passwordEncoder() {
		return new BCryptPasswordEncoder(10);
	}

	@Override
	public void configure(WebSecurity web) throws Exception {
		web.ignoring().antMatchers(IGNORED_RESOURCE_LIST);
	}

	private OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> authorizationCodeTokenResponseClient() {
		OAuth2AccessTokenResponseHttpMessageConverter tokenResponseHttpMessageConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
		tokenResponseHttpMessageConverter.setTokenResponseConverter(new OAuth2AccessTokenResponseConverterWithDefaults());
		RestTemplate restTemplate = new RestTemplate(Arrays.asList(new FormHttpMessageConverter(), tokenResponseHttpMessageConverter));
		restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
		DefaultAuthorizationCodeTokenResponseClient tokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
		tokenResponseClient.setRestOperations(restTemplate);
		return tokenResponseClient;
	}
}

SetupDataLoader.java

This class is responsible for inserting the user role into the DB if it doesn’t exist on application startup

@Component is the most generic Spring annotation. A Java class decorated with this annotation is found during classpath scanning and registered in the context as a Spring bean

@Component
public class SetupDataLoader implements ApplicationListener<ContextRefreshedEvent> {

	private boolean alreadySetup = false;

	@Autowired
	private RoleRepository roleRepository;

	@Override
	@Transactional
	public void onApplicationEvent(final ContextRefreshedEvent event) {
		if (alreadySetup) {
			return;
		}

		// Create initial roles
		createRoleIfNotFound(Role.ROLE_USER);

		alreadySetup = true;
	}

	@Transactional
	private final Role createRoleIfNotFound(final String name) {
		Role role = roleRepository.findByName(name);
		if (role == null) {
			role = new Role(name);
		}
		role = roleRepository.save(role);
		return role;
	}
}

DemoApplication.java

@SpringBootApplication Indicates a configuration class that declares one or more @Bean methods and also triggers auto-configuration and component scanning. This is a convenience annotation that is equivalent to declaring @Configuration, @EnableAutoConfiguration and @ComponentScan.

@EnableJpaRepositories annotation is used to enable JPA repositories. It will scan the package of the annotated configuration class for Spring Data repositories by default.

@EnableTransactionManagement enables Spring’s annotation-driven transaction management capability, similar to the support found in Spring’s <tx:*> XML namespace.

@SpringBootApplication(scanBasePackages = "com.javachinna")
@EnableJpaRepositories
@EnableTransactionManagement
public class DemoApplication extends SpringBootServletInitializer {

	public static void main(String[] args) {
		SpringApplicationBuilder app = new SpringApplicationBuilder(DemoApplication.class);
		app.run();
	}

	@Override
	protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
		return application.sources(DemoApplication.class);
	}
}

application.properties

Replace the <your-client-id> and <your-client-secret> with your app credentials from the respective social login provider

# Database configuration props
spring.datasource.url=jdbc:mysql://localhost:3306/demo?createDatabaseIfNotExist=true
spring.datasource.username=root
spring.datasource.password=chinna44
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# Hibernate props
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=create
#spring.jpa.hibernate.ddl-auto=validate
spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQL5InnoDBDialect

spring.mvc.view.prefix=/WEB-INF/views/pages/
spring.mvc.view.suffix=.jsp

# Social login provider props
spring.security.oauth2.client.registration.google.clientId=<your-client-id>
spring.security.oauth2.client.registration.google.clientSecret=<your-client-secret>
spring.security.oauth2.client.registration.facebook.clientId=<your-client-id>
spring.security.oauth2.client.registration.facebook.clientSecret=<your-client-secret>
spring.security.oauth2.client.provider.facebook.user-info-uri=https://graph.facebook.com/me?fields=id,name,email,picture
spring.security.oauth2.client.registration.github.clientId=<your-client-id>
spring.security.oauth2.client.registration.github.clientSecret=<your-client-secret>
spring.security.oauth2.client.registration.linkedin.clientId=<your-client-id>
spring.security.oauth2.client.registration.linkedin.clientSecret=<your-client-secret>
spring.security.oauth2.client.registration.linkedin.client-authentication-method=post
spring.security.oauth2.client.registration.linkedin.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.linkedin.scope=r_liteprofile, r_emailaddress
spring.security.oauth2.client.registration.linkedin.redirect-uri-template={baseUrl}/login/oauth2/code/{registrationId}
spring.security.oauth2.client.registration.linkedin.client-name=Linkedin
spring.security.oauth2.client.registration.linkedin.provider=linkedin
spring.security.oauth2.client.provider.linkedin.authorization-uri=https://www.linkedin.com/oauth/v2/authorization
spring.security.oauth2.client.provider.linkedin.token-uri=https://www.linkedin.com/oauth/v2/accessToken
spring.security.oauth2.client.provider.linkedin.user-info-uri=https://api.linkedin.com/v2/me
spring.security.oauth2.client.provider.linkedin.user-name-attribute=id
linkedin.email-address-uri=https://api.linkedin.com/v2/emailAddress?q=members&amp;projection=(elements*(handle~))

# For detailed logging during development
logging.level.com=TRACE
logging.level.org.springframework=TRACE
logging.level.org.hibernate.SQL=TRACE
logging.level.org.hibernate.type=TRACE

Run with Maven

You can run the application using mvn clean spring-boot:run and visit http://localhost:8080

Source code

https://github.com/JavaChinna/spring-boot-oauth2

References

https://www.callicoder.com/spring-boot-security-oauth2-social-login-part-2/

https://hellokoding.com/registration-and-login-example-with-spring-security-spring-boot-spring-data-jpa-hsql-jsp/

Conclusion

That’s all folks! In this tutorial series, you’ve learned how to add social as well as email and password based login to your spring boot application.

I hope you enjoyed this series. Please share it with your friends if you like this article. Thank you for reading.

This Post Has 2 Comments

  1. Pankaj kumar Jha

    in application.properties folder (spring.security.oauth2.client.registration.linkedin.redirect-uri-template={baseUrl}/login/oauth2/code/{registrationId} ) what is registration id how we get the registration id

    1. Chinna

      Hi Pankaj,

      Property key contains the registrationId linkedin which I’ve highlighted below and we don’t need to get it.
      spring.security.oauth2.client.registration.linkedin.clientId=

      This redirect URI is a template and Spring Security will replace {baseUrl} and {registrationId} with application base url and linkedin respectively. For example, the final URL will be like the one below
      https://www.some-domain-name.com/login/oauth2/code/linkedin

      Please let me know if you need more clarifications.

Leave a Reply