I am trying to extend keycloak by creating a new endpoint to authenticate users.
The point is, user is not stored in keycloak, the user is stored in an external system.
The external system will call the new endpoint and provide token (will contains user info), clientId, and clientSecret. and (somehow) we will verify the existence of the user.
The challenge I am facing right now is I cannot create a session for the user. (seems the session requires existed user in keycloak, I am using InMemoryUser)
package com.mhewedy.auth;
import org.jboss.logging.Logger;
import org.jboss.resteasy.spi.HttpRequest;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.keycloak.Config;
import org.keycloak.events.EventBuilder;
import org.keycloak.models.*;
import org.keycloak.protocol.AuthorizationEndpointBase;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AccessTokenResponse;
import org.keycloak.services.resource.RealmResourceProvider;
import org.keycloak.services.resource.RealmResourceProviderFactory;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.util.DefaultClientSessionContext;
import org.keycloak.storage.adapter.InMemoryUserAdapter;
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.UUID;
import static org.keycloak.services.resources.Cors.ACCESS_CONTROL_ALLOW_ORIGIN;
import static org.keycloak.utils.MediaType.APPLICATION_JSON_TYPE;
public class MyEndpoint extends AuthorizationEndpointBase implements RealmResourceProvider {
private final Logger logger = Logger.getLogger(MyEndpoint.class);
private final TokenManager tokenManager = new TokenManager();
public MyEndpoint(RealmModel realm, EventBuilder event) {
super(realm, event);
}
#Override
public Object getResource() {
return this;
}
#Override
public void close() {
}
#GET
#Path("authenticate")
#Produces(MediaType.APPLICATION_JSON)
#Consumes(MediaType.APPLICATION_FORM_URLENCODED)
public Response authenticate(#FormParam("token") String token,
#FormParam("client_id") String clientId,
#FormParam("client_secret") String clientSecret,
#Context HttpRequest request) {
// validate client_id & client_secret
// validate token
logger.info("generating access token...");
String userId = UUID.randomUUID().toString();
UserModel userModel =
new InMemoryUserAdapter(session, session.getContext().getRealm(), userId);
userModel.setUsername(token);
userModel.setEnabled(true);
// this session object doesn't contain the userModel, cause it seems it lookups the user by id and doesn't find it
UserSessionModel userSession = session.sessions().createOfflineUserSession(session.sessions().createUserSession(
session.getContext().getRealm(),
userModel,
token,
"192.168.1.1",
"My",
false,
null,
null
));
ClientModel clientModel = realm.getClientByClientId(clientId);
logger.infof("Configurable token requested for username=%s and client=%s on realm=%s",
userModel.getUsername(), clientModel.getClientId(), realm.getName());
AuthenticatedClientSessionModel clientSession = session.sessions().createClientSession(realm, clientModel, userSession);
ClientSessionContext clientSessionContext =
DefaultClientSessionContext.fromClientSessionScopeParameter(clientSession, session);
AccessToken newToken = tokenManager
.createClientAccessToken(session, realm, clientModel, userModel, userSession, clientSessionContext);
newToken.expiration(10 * 10 * 10 * 10);
EventBuilder eventBuilder = new EventBuilder(realm, session, session.getContext().getConnection());
AccessTokenResponse response = tokenManager
.responseBuilder(realm, clientModel, eventBuilder, session, userSession, clientSessionContext)
.accessToken(newToken)
.build();
return buildCorsResponse(request, response);
}
private Response buildCorsResponse(#Context HttpRequest request, AccessTokenResponse response) {
Cors cors = Cors.add(request)
.auth()
.allowedMethods("POST")
.auth()
.exposedHeaders(Cors.ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN)
.allowAllOrigins();
return cors.builder(Response.ok(response).type(APPLICATION_JSON_TYPE)).build();
}
// ----------------------------------------------------------------------------------------------------------------
public static class MyEndpointFactory implements RealmResourceProviderFactory {
#Override
public RealmResourceProvider create(KeycloakSession session) {
KeycloakContext context = session.getContext();
RealmModel realm = context.getRealm();
EventBuilder event = new EventBuilder(realm, session, context.getConnection());
MyEndpoint provider = new MyEndpoint(realm, event);
ResteasyProviderFactory.getInstance().injectProperties(provider);
return provider;
}
#Override
public void init(Config.Scope config) {
}
#Override
public void postInit(KeycloakSessionFactory factory) {
}
#Override
public void close() {
}
#Override
public String getId() {
return "MyEndpoint";
}
}
}
I am using code from here but the use case is differnt.
I solved by saving the user in the cache (db) if not exist:
String username = getUsernameFromToken(token);
String userId = "my-" + username;
UserModel userModel = new InMemoryUserAdapter(session, session.getContext().getRealm(), username);
userModel.setUsername(username);
userModel.setEnabled(true);
if (session.users().getUserById(realm, userId) == null) {
session.userCache().addUser(realm, userId, username, false, false);
}
Related
What I'm going to do is like this.
post additional parameter when user submit on consent page.
send posted additional parameter to redirect uri.
I've checked through AuthorizationRequestConverter that OAuth2AuthorizationCodeRequestAuthenticationToken has the parameter which user has uploaded.
But I don't know why OAuth2Authorization doesn't save additional parameters on Database.
Cause of above I cant' reach to additional parameters and can't post data to redirect URI.
I've referred to this repository.
sjohnr/spring-authorization-server
Here's my auth server configuration
AuthorizationServerConfig
#Bean
#Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer<HttpSecurity> authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer<>();
RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();
authorizationServerConfigurer
.authorizationEndpoint(authorizationEndPoint ->
authorizationEndPoint
.consentPage(CUSTOM_CONSENT_PAGE_URI)
.authorizationRequestConverter(customAuthorizationRequestConverter())
.authorizationResponseHandler(authorizationResponseHandler())
)
.withObjectPostProcessor(new ObjectPostProcessor<OAuth2AuthorizationCodeRequestAuthenticationProvider>() {
#Override
public <O extends OAuth2AuthorizationCodeRequestAuthenticationProvider> O postProcess(O object) {
object.setAuthenticationValidatorResolver(createDefaultAuthenticationValidatorResolver());
return object;
}
});
http
.requestMatcher(endpointsMatcher)
.authorizeRequests(authorizeRequest ->
authorizeRequest.anyRequest().authenticated()
)
.formLogin(Customizer.withDefaults())
.csrf(csrf -> csrf.ignoringRequestMatchers(endpointsMatcher))
.apply(authorizationServerConfigurer);
return http.build();
}
private AuthenticationConverter customAuthorizationRequestConverter() {
final OAuth2AuthorizationCodeRequestAuthenticationConverter delegate = new OAuth2AuthorizationCodeRequestAuthenticationConverter();
return (request) -> {
OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication =
(OAuth2AuthorizationCodeRequestAuthenticationToken) delegate.convert(request);
return authorizationCodeRequestAuthentication;
};
}
// ... validator and authorizationResponseHandler are same as reference
Authentication Provider
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component;
#RequiredArgsConstructor
#Component
#Slf4j
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final UserMapper userMapper;
#Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String username = authentication.getName();
String password = authentication.getCredentials().toString();
User user = getUser(username);
validatePassword(username, user.getPassword(), password);
return new UsernamePasswordAuthenticationToken(username, password, user.getAuthorities());
}
private User getUser(String username) {
User user = userMapper.fetchUserByUsername(username);
if (user == null)
throw new BusinessException("oauth.user.0100", HttpStatus.NOT_FOUND);
return user;
}
private void validatePassword(String username, String userPassword, String reqPassword) {
if(StringUtils.equals(userPassword, userMapper.fetchEncryptPassword(reqPassword)))
return;
userMapper.updateFailedLoginCount(username);
throw new BusinessException("oauth.user.0100", HttpStatus.NOT_FOUND);
}
#Override
public boolean supports(Class<?> authentication) {
return authentication.equals(
UsernamePasswordAuthenticationToken.class);
}
}
I've added to my storefront a new extension based on commercewebservices and I've tested several sample services directly through swagger and the ones that doesn't need any kind of authorization works perfect. However, the webservices annotated with #ApiBaseSiteIdAndUserIdParam when I set the userId and siteParam the controller that interecepts this petition doesn't set in session the user I pass, it always returns anonymous user. I've tried creating special OAuth credentials but it doesn't work it always returns anonymous user.
#Secured({ "ROLE_CUSTOMERGROUP", "ROLE_TRUSTED_CLIENT", "ROLE_CUSTOMERMANAGERGROUP" })
#GetMapping(value = "/test")
#ResponseBody
#ApiBaseSiteIdAndUserIdParam
public TestListWsDTO getTest(
#RequestParam(required = false, defaultValue = DEFAULT_FIELD_SET) final String fields) {
final CustomerData customerData = customerFacade.getCurrentCustomer();
if (userFacade.isAnonymousUser()) {
throw new AccessDeniedException("Anonymous user is not allowed");
}
The test#test.com is a registered user.
Why the customer I indicate through swagger is not being captured by customerFacade.getCurrentCustomer() and it always return anonymous?
AS per #Neil it's correct in case of OCC V2 context user is getting determined by the OAuth token.
For OCC there are also configured filters which used to configure or put user users in session if it got found otherwise it will set anonymous.
Please have a look of UserMatchingFilter.
/*
* [y] hybris Platform
*
* Copyright (c) 2017 SAP SE or an SAP affiliate company. All rights reserved.
*
* This software is the confidential and proprietary information of SAP
* ("Confidential Information"). You shall not disclose such Confidential
* Information and shall use it only in accordance with the terms of the
* license agreement you entered into with SAP.
*/
package de.hybris.platform.ycommercewebservices.v2.filter;
import de.hybris.platform.core.model.user.UserModel;
import de.hybris.platform.servicelayer.exceptions.UnknownIdentifierException;
import de.hybris.platform.servicelayer.session.SessionService;
import de.hybris.platform.servicelayer.user.UserService;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Required;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* Filter that puts user from the requested url into the session.
*/
public class UserMatchingFilter extends AbstractUrlMatchingFilter
{
public static final String ROLE_ANONYMOUS = "ROLE_ANONYMOUS";
public static final String ROLE_CUSTOMERGROUP = "ROLE_CUSTOMERGROUP";
public static final String ROLE_CUSTOMERMANAGERGROUP = "ROLE_CUSTOMERMANAGERGROUP";
public static final String ROLE_TRUSTED_CLIENT = "ROLE_TRUSTED_CLIENT";
private static final String CURRENT_USER = "current";
private static final String ANONYMOUS_USER = "anonymous";
private static final String ACTING_USER_UID = "ACTING_USER_UID";
private static final Logger LOG = Logger.getLogger(UserMatchingFilter.class);
private String regexp;
private UserService userService;
private SessionService sessionService;
#Override
protected void doFilterInternal(final HttpServletRequest request, final HttpServletResponse response,
final FilterChain filterChain) throws ServletException, IOException
{
final Authentication auth = getAuth();
if (hasRole(ROLE_CUSTOMERGROUP, auth) || hasRole(ROLE_CUSTOMERMANAGERGROUP, auth))
{
getSessionService().setAttribute(ACTING_USER_UID, auth.getPrincipal());
}
final String userID = getValue(request, regexp);
if (userID == null)
{
if (hasRole(ROLE_CUSTOMERGROUP, auth) || hasRole(ROLE_CUSTOMERMANAGERGROUP, auth))
{
setCurrentUser((String) auth.getPrincipal());
}
else
{
// fallback to anonymous
setCurrentUser(userService.getAnonymousUser());
}
}
else if (userID.equals(ANONYMOUS_USER))
{
setCurrentUser(userService.getAnonymousUser());
}
else if (hasRole(ROLE_TRUSTED_CLIENT, auth) || hasRole(ROLE_CUSTOMERMANAGERGROUP, auth))
{
setCurrentUser(userID);
}
else if (hasRole(ROLE_CUSTOMERGROUP, auth))
{
if (userID.equals(CURRENT_USER) || userID.equals(auth.getPrincipal()))
{
setCurrentUser((String) auth.getPrincipal());
}
else
{
throw new AccessDeniedException("Access is denied");
}
}
else
{
// could not match any authorized role
throw new AccessDeniedException("Access is denied");
}
filterChain.doFilter(request, response);
}
protected Authentication getAuth()
{
return SecurityContextHolder.getContext().getAuthentication();
}
protected String getRegexp()
{
return regexp;
}
#Required
public void setRegexp(final String regexp)
{
this.regexp = regexp;
}
protected UserService getUserService()
{
return userService;
}
#Required
public void setUserService(final UserService userService)
{
this.userService = userService;
}
protected SessionService getSessionService()
{
return sessionService;
}
#Required
public void setSessionService(final SessionService sessionService)
{
this.sessionService = sessionService;
}
protected boolean hasRole(final String role, final Authentication auth)
{
if (auth != null)
{
for (final GrantedAuthority ga : auth.getAuthorities())
{
if (ga.getAuthority().equals(role))
{
return true;
}
}
}
return false;
}
protected void setCurrentUser(final String uid)
{
try
{
final UserModel userModel = userService.getUserForUID(uid);
userService.setCurrentUser(userModel);
}
catch (final UnknownIdentifierException ex)
{
LOG.debug(ex.getMessage());
throw ex;
}
}
protected void setCurrentUser(final UserModel user)
{
userService.setCurrentUser(user);
}
}
In an OCC context, the current user is determined by the OAuth token. If you just have the client credentials then you are Anonymous. However after a user-specific login you have a different token that correlates to the user that authenticated with OAuth. There should be a filter in the commercewebservices stack that checks the token & maps to a current user in the temporary session. As far as I'm aware only that user will appear as the current customer, not the one passed in the URL ... you probably want to check that the current customer is the same user (or should have permission to see details about that user)
If you are using OCC web services, these services are stateless. So you cannot get any values from session variables. Generally facades using by storefront and storefronts are using sessions.
I have adapted the code from here to call a MitreID OIDC server.
My controller:
public final String home(Principal p) {
final String username = SecurityContextHolder.getContext().getAuthentication().getName();
...
returns null and is null for all userdetails.
I have also tried:
public final String home(#AuthenticationPrincipal OpenIdConnectUserDetails user) {
final String username = user.getUsername();
and
#RequestMapping(value = "/username", method = RequestMethod.GET)
#ResponseBody
public String currentUserNameSimple(HttpServletRequest request) {
Principal principal = request.getUserPrincipal();
return "username: " + principal.getName();
}
Everything is null but the authentication is returning an access and user token.
My security config is:
#Configuration
#EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
#Autowired
private OAuth2RestTemplate restTemplate;
#Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/resources/**");
}
#Bean
public OpenIdConnectFilter myFilter() {
final OpenIdConnectFilter filter = new OpenIdConnectFilter("/openid_connect_login");
filter.setRestTemplate(restTemplate);
return filter;
}
#Override
protected void configure(HttpSecurity http) throws Exception {
// #formatter:off
http
.addFilterAfter(new OAuth2ClientContextFilter(), AbstractPreAuthenticatedProcessingFilter.class)
.addFilterAfter(myFilter(), OAuth2ClientContextFilter.class)
.httpBasic().authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/openid_connect_login"))
.and()
.authorizeRequests()
.antMatchers("/","/index*").permitAll()
.anyRequest().authenticated()
;
// #formatter:on
}
}
So why can my controller not access the userdetails?
EDIT: as requested, OpenIdConnectFilter:
package org.baeldung.security;
import java.io.IOException;
import java.net.URL;
import java.security.interfaces.RSAPublicKey;
import java.util.Date;
import java.util.Map;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.jwt.Jwt;
import org.springframework.security.jwt.JwtHelper;
import org.springframework.security.jwt.crypto.sign.RsaVerifier;
import org.springframework.security.oauth2.client.OAuth2RestOperations;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.exceptions.OAuth2Exception;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import com.auth0.jwk.Jwk;
import com.auth0.jwk.JwkProvider;
import com.auth0.jwk.UrlJwkProvider;
import com.fasterxml.jackson.databind.ObjectMapper;
public class OpenIdConnectFilter extends AbstractAuthenticationProcessingFilter {
#Value("${oidc.clientId}")
private String clientId;
#Value("${oidc.issuer}")
private String issuer;
#Value("${oidc.jwkUrl}")
private String jwkUrl;
public OAuth2RestOperations restTemplate;
public OpenIdConnectFilter(String defaultFilterProcessesUrl) {
super(defaultFilterProcessesUrl);
setAuthenticationManager(new NoopAuthenticationManager());
}
#Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
OAuth2AccessToken accessToken;
logger.info("ewd here: b " );
try {
accessToken = restTemplate.getAccessToken();
} catch (final OAuth2Exception e) {
throw new BadCredentialsException("Could not obtain access token", e);
}
try {
logger.info("ewd access token: " + accessToken);
final String idToken = accessToken.getAdditionalInformation().get("id_token").toString();
String kid = JwtHelper.headers(idToken)
.get("kid");
final Jwt tokenDecoded = JwtHelper.decodeAndVerify(idToken, verifier(kid));
final Map<String, String> authInfo = new ObjectMapper().readValue(tokenDecoded.getClaims(), Map.class);
verifyClaims(authInfo);
final OpenIdConnectUserDetails user = new OpenIdConnectUserDetails(authInfo, accessToken);
logger.info("ewd user token: " + tokenDecoded);
return new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
} catch (final Exception e) {
throw new BadCredentialsException("Could not obtain user details from token", e);
}
}
public void verifyClaims(Map claims) {
int exp = (int) claims.get("exp");
Date expireDate = new Date(exp * 1000L);
Date now = new Date();
if (expireDate.before(now) || !claims.get("iss").equals(issuer) || !claims.get("aud").equals(clientId)) {
throw new RuntimeException("Invalid claims");
}
}
private RsaVerifier verifier(String kid) throws Exception {
JwkProvider provider = new UrlJwkProvider(new URL(jwkUrl));
Jwk jwk = provider.get(kid);
return new RsaVerifier((RSAPublicKey) jwk.getPublicKey());
}
public void setRestTemplate(OAuth2RestTemplate restTemplate2) {
restTemplate = restTemplate2;
}
private static class NoopAuthenticationManager implements AuthenticationManager {
#Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
throw new UnsupportedOperationException("No authentication should be done with this AuthenticationManager");
}
}
}
In the tutorial you refer to, Google OpenId Connect is used. This service also returns the extra scope email variable that is read in:
OpenIdConnectUserDetails.class
public OpenIdConnectUserDetails(Map<String, String> userInfo, OAuth2AccessToken token) {
this.userId = userInfo.get("sub");
this.username = userInfo.get("email");
this.token = token;
}
Without knowing the specific configuration of your MitreID OIDC server maybe the openId server is not returning the email variable
I am a new auth0 user and had the same issue, trying to get some User information in the Spring Controller. I am able to access the Claims from the token using this code.
#GetMapping
public List<Task> getTasks(AuthenticationJsonWebToken authentication) {
logger.debug("getTasks called.");
DecodedJWT jwt = JWT.decode(authentication.getToken());
Map<String, Claim> claims = jwt.getClaims();
for (Object key: claims.keySet()) {
logger.debug("key: {}, value: {}", key.toString(), claims.get(key).asString());
}
return taskRepository.findAll();
}
Hope this helps.
If you need username, you can get it from JwtAuthenticationToken object as below:
#GetMapping("/home")
public String home(JwtAuthenticationToken user) {
String name = user.getName();
If you need some other information from user's profile, you can call your auth server's /userinfo endpoint with the access token as below:
This will fetch info only if you had included profile scope in your authorize call.
#GetMapping("/home")
public String home(JwtAuthenticationToken user) {
HttpHeaders headers = new HttpHeaders();
headers.add(HttpHeaders.AUTHORIZATION, "Bearer "+user.getToken().getTokenValue());
HttpEntity entity = new HttpEntity(headers);
ResponseEntity<Map> userinfo = template.exchange("https://your-auth-server/default/v1/userinfo", HttpMethod.GET, entity, Map.class);
String name = (String) userinfo.getBody().get("given_name");
You can retrieve all profile attributes from this response.
For Auth0, I was able to get user information in two ways:
First one is using JwtAuthenticationToken directly on the controller as shown below.
#GetMapping("/info")
public void users(JwtAuthenticationToken token) {
System.out.println(token.getName());
System.out.println(token.getTokenAttributes().get("name"));
}
Here, token.getName() prints the user id from auth0 and token.getTokenAttributes() returns a map from which we can retrieve any information that we need. For example to print user name, we can use token.getTokenAttributes().get("name").
Casting Authentication object to JwtAuthenticationToken:
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
JwtAuthenticationToken token = (JwtAuthenticationToken) authentication;
String username = token.getTokenAttributes().get("name");
you can retrieve the user or profile related properties that were defined when creating the okta oidc application through the OidcUser class, which can be used with the AuthenticationPrincipal annotation.Follow below steps
**My Controller:**
#GetMapping("/user")
public User user(#AuthenticationPrincipal OidcUser oidcUser) {
System.out.println("oidcUser :: " + oidcUser + "\n\n");
User user = new User();
System.out.println("Attributes :: " + oidcUser.getAttributes() + "\n\n");
user.setFirstName(oidcUser.getAttribute("given_name"));
user.setLastName(oidcUser.getAttribute("family_name"));
user.setName(oidcUser.getAttribute("name"));
user.setPreferred_username(oidcUser.getAttribute("preferred_username"));
user.setEmail(oidcUser.getAttribute("email"));
user.setGroups(getGroupsFromCurrentUser());
System.out.println(user.toString() + "\n\n");
return user;
}
private List<String> getGroupsFromCurrentUser() {
List<String> groups = new ArrayList<>();
Collection<? extends GrantedAuthority> authorities = SecurityContextHolder.getContext().getAuthentication().getAuthorities();
System.out.println("\n\n"+authorities+"\n\n");
for (GrantedAuthority auth : authorities) {
groups.add(auth.getAuthority());
}
System.out.println("\n\n"+"groups :: "+groups+"\n\n");
return groups;
}
I am trying to integrate Google sign-in into an existing Spring security application. The goal is to have a Google sign-in button that will allow a user to log in along with the standard login using the username/password combination.
Based on the guide that Google provides (https://developers.google.com/identity/sign-in/web/backend-auth) it looks like all I need to do is extend the login form (that currently only has the login and the password input fields) with an extra field "id_token" and submit it to the server.
Would it be a good security practice? I searched the web and I am surprised I cannot find any similar implementations on the web.
Here is my take on the required spring-security components:
filter:
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.util.Assert;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
public class GoogleIdAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final long serialVersionUID = 1L;
private String tokenParamName = "googleIdToken";
/**
* Creates an instance which will authenticate against the supplied
* {#code AuthenticationManager} and which will ignore failed authentication attempts,
* allowing the request to proceed down the filter chain.
*
* #param authenticationManager the bean to submit authentication requests to
* #param defaultFilterProcessesUrl the url to check for auth requests on (e.g. /login/google)
*/
public GoogleIdAuthenticationFilter(AuthenticationManager authenticationManager, String defaultFilterProcessesUrl) {
super(defaultFilterProcessesUrl);
Assert.notNull(authenticationManager, "authenticationManager cannot be null");
setAuthenticationManager(authenticationManager);
}
#Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
String token = request.getParameter(tokenParamName);
if (token == null) {
return null;
}
if (this.logger.isDebugEnabled()) {
this.logger.debug("Google ID Token Authorization parameter found with value '" + token + "'");
}
Object details = this.authenticationDetailsSource.buildDetails(request);
GoogleIdAuthenticationToken authRequest = new GoogleIdAuthenticationToken(token, details);
Authentication authResult = getAuthenticationManager().authenticate(authRequest);
if (this.logger.isDebugEnabled()) {
this.logger.debug("Authentication success: " + authResult);
}
return authResult;
}
public String getTokenParamName() {
return tokenParamName;
}
public void setTokenParamName(String tokenParamName) {
this.tokenParamName = tokenParamName;
}
}
authentication provider:
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.apache.ApacheHttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.jackson2.JacksonFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import javax.annotation.Resource;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.util.Collections;
public class GoogleIdAuthenticationProvider implements AuthenticationProvider {
private static final Logger logger = LoggerFactory.getLogger(GoogleIdAuthenticationProvider.class);
private String clientId;
#Resource
private UserDetailsService userDetailsService;
private HttpTransport httpTransport = new ApacheHttpTransport();
private JsonFactory jsonFactory = new JacksonFactory();
protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
#Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
if (!supports(authentication.getClass())) {
if (logger.isDebugEnabled()) {
logger.debug(String.format("This authentication provider does not support instances of type %s", authentication.getClass().getName()));
}
return null;
}
GoogleIdAuthenticationToken googleIdAuthenticationToken = (GoogleIdAuthenticationToken) authentication;
if (logger.isDebugEnabled())
logger.debug(String.format("Validating google login with token '%s'", googleIdAuthenticationToken.getCredentials()));
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(httpTransport, jsonFactory)
.setAudience(Collections.singletonList(getClientId()))
.build();
GoogleIdToken googleIdToken = null;
try {
googleIdToken = verifier.verify((String) googleIdAuthenticationToken.getCredentials());
if (googleIdToken == null) {
throw new BadCredentialsException("Unable to verify token");
}
} catch (IOException|GeneralSecurityException e) {
throw new BadCredentialsException("Unable to verify token", e);
}
Payload payload = googleIdToken.getPayload();
// Get profile information from payload
String email = payload.getEmail();
if (logger.isDebugEnabled()) {
logger.debug(String.format("Loading user details for email '%s'", email));
}
UserDetails userDetails = null;
try {
userDetails = userDetailsService.loadUserByUsername(email);
if (!userDetails.isAccountNonLocked()) {
throw new LockedException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.locked", "User account is locked"));
}
if (!userDetails.isEnabled()) {
throw new DisabledException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.disabled", "User is disabled"));
}
if (!userDetails.isAccountNonExpired()) {
throw new AccountExpiredException(messages.getMessage("AbstractUserDetailsAuthenticationProvider.expired", "User account has expired"));
}
} catch (UsernameNotFoundException e) {
// provision a new user?
throw e;
}
return new GoogleIdAuthenticationToken((String) googleIdAuthenticationToken.getCredentials(), userDetails.getUsername(), userDetails.getAuthorities(), authentication.getDetails());
}
#Override
public boolean supports(Class<? extends Object> authentication) {
return (GoogleIdAuthenticationToken.class.isAssignableFrom(authentication));
}
public String getClientId() {
return clientId;
}
public void setClientId(String clientId) {
this.clientId = clientId;
}
}
token:
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import java.util.ArrayList;
import java.util.Collection;
public class GoogleIdAuthenticationToken extends AbstractAuthenticationToken {
private String credentials;
private Object principal;
public GoogleIdAuthenticationToken(String token, Object details) {
super(new ArrayList<>());
this.credentials = token;
setDetails(details);
setAuthenticated(false);
}
GoogleIdAuthenticationToken(String token, String principal, Collection<? extends GrantedAuthority> authorities, Object details) {
super(authorities);
this.credentials = token;
this.principal = principal;
setDetails(details);
setAuthenticated(true);
}
#Override
public Object getCredentials() {
return credentials;
}
#Override
public Object getPrincipal() {
return principal;
}
}
After plugging in the above you'll just need to POST to "/login/google" (or whatever you've configured) with the token returned by Google in the 'googleIdToken' (or whatever you've configured).
Here I am sharing some code which I have used for server-side Google sign token verification using spring. It is a working example :
#Autowired
HttpTransport transport;
private static final JsonFactory jsonFactory = new JacksonFactory();
public void verify(String idTokenString)
{
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(transport, jsonFactory).setAudience(Collections.singletonList(GOOGLE_CLIENT_ID))
.build();
GoogleIdToken idToken = verifier.verify(idTokenString);
IdToken.Payload payload = idToken.getPayload();
Boolean emailVerified = (Boolean) payload.get("email_verified");
if (if (idToken != null) {
String email = (String) payload.get("email");
String fname = (String) payload.get("given_name");
String pictureUrl = (String) payload.get("picture");
String lname = (String) payload.get("family_name");
}
Dependencies:
<dependency>
<groupId>com.google.api-client</groupId>
<artifactId>google-api-client</artifactId>
<version>1.31.1</version>
</dependency>
<dependency>
<groupId>com.google.http-client</groupId>
<artifactId>google-http-client</artifactId>
<version>1.40.0</version>
</dependency>
So, the right answer turned out to be not extending the existing auth filter/provider but defining/adding another {Token Authentication class + token auth filter + token auth provider (provider is kind of optional)}
This works fine until I have to test a service that needs a logged in user, how do I add user to context :
#RunWith(SpringJUnit4ClassRunner.class)
#ContextConfiguration("classpath:applicationContext-test.xml")
#WebAppConfiguration
public class FooTest {
#Autowired
private WebApplicationContext webApplicationContext;
private MockMvc mockMvc;
#Resource(name = "aService")
private AService aService; //uses logged in user
#Before
public void setup() {
this.mockMvc = webAppContextSetup(this.webApplicationContext).build();
}
If you want to use MockMVC with the latest spring security test package, try this code:
Principal principal = new Principal() {
#Override
public String getName() {
return "TEST_PRINCIPAL";
}
};
getMockMvc().perform(get("http://your-url.com").principal(principal))
.andExpect(status().isOk()));
Keep in mind that you have to be using Principal based authentication for this to work.
If successful authentication yields some cookie, then you can capture that (or just all cookies), and pass it along in the next tests:
#Autowired
private WebApplicationContext wac;
#Autowired
private FilterChainProxy filterChain;
private MockMvc mockMvc;
#Before
public void setup() {
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.wac)
.addFilter(filterChain).build();
}
#Test
public void testSession() throws Exception {
// Login and save the cookie
MvcResult result = mockMvc.perform(post("/session")
.param("username", "john").param("password", "s3cr3t")).andReturn();
Cookie c = result.getResponse().getCookie("my-cookie");
assertThat(c.getValue().length(), greaterThan(10));
// No cookie; 401 Unauthorized
mockMvc.perform(get("/personal").andExpect(status().isUnauthorized());
// With cookie; 200 OK
mockMvc.perform(get("/personal").cookie(c)).andExpect(status().isOk());
// Logout, and ensure we're told to wipe the cookie
result = mockMvc.perform(delete("/session").andReturn();
c = result.getResponse().getCookie("my-cookie");
assertThat(c.getValue().length(), is(0));
}
Though I know I'm not making any HTTP requests here, I kind of like the stricter separation of the above integration test and my controllers and Spring Security implementation.
To make the code a bit less verbose, I use the following to merge the cookies after making each request, and then pass those cookies along in each subsequent request:
/**
* Merges the (optional) existing array of Cookies with the response in the
* given MockMvc ResultActions.
* <p>
* This only adds or deletes cookies. Officially, we should expire old
* cookies. But we don't keep track of when they were created, and this is
* not currently required in our tests.
*/
protected static Cookie[] updateCookies(final Cookie[] current,
final ResultActions result) {
final Map<String, Cookie> currentCookies = new HashMap<>();
if (current != null) {
for (Cookie c : current) {
currentCookies.put(c.getName(), c);
}
}
final Cookie[] newCookies = result.andReturn().getResponse().getCookies();
for (Cookie newCookie : newCookies) {
if (StringUtils.isBlank(newCookie.getValue())) {
// An empty value implies we're told to delete the cookie
currentCookies.remove(newCookie.getName());
} else {
// Add, or replace:
currentCookies.put(newCookie.getName(), newCookie);
}
}
return currentCookies.values().toArray(new Cookie[currentCookies.size()]);
}
...and a small helper as cookie(...) needs at least one cookie:
/**
* Creates an array with a dummy cookie, useful as Spring MockMvc
* {#code cookie(...)} does not like {#code null} values or empty arrays.
*/
protected static Cookie[] initCookies() {
return new Cookie[] { new Cookie("unittest-dummy", "dummy") };
}
...to end up with:
Cookie[] cookies = initCookies();
ResultActions actions = mockMvc.perform(get("/personal").cookie(cookies)
.andExpect(status().isUnauthorized());
cookies = updateCookies(cookies, actions);
actions = mockMvc.perform(post("/session").cookie(cookies)
.param("username", "john").param("password", "s3cr3t"));
cookies = updateCookies(cookies, actions);
actions = mockMvc.perform(get("/personal").cookie(cookies))
.andExpect(status().isOk());
cookies = updateCookies(cookies, actions);
You should be able to just add the user to the security context:
List<GrantedAuthority> list = new ArrayList<GrantedAuthority>();
list.add(new GrantedAuthorityImpl("ROLE_USER"));
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(user, password,list);
SecurityContextHolder.getContext().setAuthentication(auth);
Somewhy solution with principal didn't worked for me, thus, I'd like to mention another way out:
mockMvc.perform(get("your/url/{id}", 5).with(user("anyUserName")))
With spring 4 this solution mock the formLogin and logout using sessions and not cookies because spring security test not returning cookies.
Because it's not a best practice to inherit tests you can #Autowire this component in your tests and call it's methods.
With this solution each perform operation on the mockMvc will be called as authenticated if you called the performLogin on the end of the test you can call performLogout.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.stereotype.Component;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import javax.servlet.Filter;
import static com.condix.SessionLogoutRequestBuilder.sessionLogout;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestBuilders.formLogin;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated;
import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated;
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
#Component
public class SessionBasedMockMvc {
private static final String HOME_PATH = "/";
private static final String LOGOUT_PATH = "/login?logout";
#Autowired
private WebApplicationContext webApplicationContext;
#Autowired
private Filter springSecurityFilterChain;
private MockMvc mockMvc;
public MockMvc createSessionBasedMockMvc() {
final MockHttpServletRequestBuilder defaultRequestBuilder = get("/dummy-path");
this.mockMvc = MockMvcBuilders.webAppContextSetup(this.webApplicationContext)
.defaultRequest(defaultRequestBuilder)
.alwaysDo(result -> setSessionBackOnRequestBuilder(defaultRequestBuilder, result.getRequest()))
.apply(springSecurity(springSecurityFilterChain))
.build();
return this.mockMvc;
}
public void performLogin(final String username, final String password) throws Exception {
final ResultActions resultActions = this.mockMvc.perform(formLogin().user(username).password(password));
this.assertSuccessLogin(resultActions);
}
public void performLogout() throws Exception {
final ResultActions resultActions = this.mockMvc.perform(sessionLogout());
this.assertSuccessLogout(resultActions);
}
private MockHttpServletRequest setSessionBackOnRequestBuilder(final MockHttpServletRequestBuilder requestBuilder,
final MockHttpServletRequest request) {
requestBuilder.session((MockHttpSession) request.getSession());
return request;
}
private void assertSuccessLogin(final ResultActions resultActions) throws Exception {
resultActions.andExpect(status().isFound())
.andExpect(authenticated())
.andExpect(redirectedUrl(HOME_PATH));
}
private void assertSuccessLogout(final ResultActions resultActions) throws Exception {
resultActions.andExpect(status().isFound())
.andExpect(unauthenticated())
.andExpect(redirectedUrl(LOGOUT_PATH));
}
}
Because default LogoutRequestBuilder doesn't support session we need to create another logout request builder.
import org.springframework.beans.Mergeable;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.request.ConfigurableSmartRequestBuilder;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import org.springframework.test.web.servlet.request.RequestPostProcessor;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import javax.servlet.ServletContext;
import java.util.ArrayList;
import java.util.List;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
/**
* This is a logout request builder which allows to send the session on the request.<br/>
* It also has more than one post processors.<br/>
* <br/>
* Unfortunately it won't trigger {#link org.springframework.security.core.session.SessionDestroyedEvent} because
* that is triggered by {#link org.apache.catalina.session.StandardSessionFacade#invalidate()} in Tomcat and
* for mocks it's handled by #{{#link MockHttpSession#invalidate()}} so the log out message won't be visible for tests.
*/
public final class SessionLogoutRequestBuilder implements
ConfigurableSmartRequestBuilder<SessionLogoutRequestBuilder>, Mergeable {
private final List<RequestPostProcessor> postProcessors = new ArrayList<>();
private String logoutUrl = "/logout";
private MockHttpSession session;
private SessionLogoutRequestBuilder() {
this.postProcessors.add(csrf());
}
static SessionLogoutRequestBuilder sessionLogout() {
return new SessionLogoutRequestBuilder();
}
#Override
public MockHttpServletRequest buildRequest(final ServletContext servletContext) {
return post(this.logoutUrl).session(session).buildRequest(servletContext);
}
public SessionLogoutRequestBuilder logoutUrl(final String logoutUrl) {
this.logoutUrl = logoutUrl;
return this;
}
public SessionLogoutRequestBuilder session(final MockHttpSession session) {
Assert.notNull(session, "'session' must not be null");
this.session = session;
return this;
}
#Override
public boolean isMergeEnabled() {
return true;
}
#SuppressWarnings("unchecked")
#Override
public Object merge(final Object parent) {
if (parent == null) {
return this;
}
if (parent instanceof MockHttpServletRequestBuilder) {
final MockHttpServletRequestBuilder parentBuilder = (MockHttpServletRequestBuilder) parent;
if (this.session == null) {
this.session = (MockHttpSession) ReflectionTestUtils.getField(parentBuilder, "session");
}
final List postProcessors = (List) ReflectionTestUtils.getField(parentBuilder, "postProcessors");
this.postProcessors.addAll(0, (List<RequestPostProcessor>) postProcessors);
} else if (parent instanceof SessionLogoutRequestBuilder) {
final SessionLogoutRequestBuilder parentBuilder = (SessionLogoutRequestBuilder) parent;
if (!StringUtils.hasText(this.logoutUrl)) {
this.logoutUrl = parentBuilder.logoutUrl;
}
if (this.session == null) {
this.session = parentBuilder.session;
}
this.postProcessors.addAll(0, parentBuilder.postProcessors);
} else {
throw new IllegalArgumentException("Cannot merge with [" + parent.getClass().getName() + "]");
}
return this;
}
#Override
public SessionLogoutRequestBuilder with(final RequestPostProcessor postProcessor) {
Assert.notNull(postProcessor, "postProcessor is required");
this.postProcessors.add(postProcessor);
return this;
}
#Override
public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) {
for (final RequestPostProcessor postProcessor : this.postProcessors) {
request = postProcessor.postProcessRequest(request);
if (request == null) {
throw new IllegalStateException(
"Post-processor [" + postProcessor.getClass().getName() + "] returned null");
}
}
return request;
}
}
After calling the performLogin operation all your request in the test will be automatically performed as logged in user.
Yet another way... I use the following annotations:
#RunWith(SpringJUnit4ClassRunner.class)
#ContextConfiguration
#TestExecutionListeners(listeners={ServletTestExecutionListener.class,
DependencyInjectionTestExecutionListener.class,
DirtiesContextTestExecutionListener.class,
TransactionalTestExecutionListener.class,
WithSecurityContextTestExcecutionListener.class})
#WithMockUser
public class WithMockUserTests {
...
}
(source)