How to get custom user info from OAuth2 authorization server /user endpoint

75,159

Solution 1

The solution is the implementation of a custom UserInfoTokenServices

https://github.com/spring-projects/spring-boot/blob/master/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/UserInfoTokenServices.java

Just Provide your custom implementation as a Bean and it will be used instead of the default one.

Inside this UserInfoTokenServices you can build the principal like you want to.

This UserInfoTokenServices is used to extract the UserDetails out of the response of the /usersendpoint of your authorization server. As you can see in

private Object getPrincipal(Map<String, Object> map) {
    for (String key : PRINCIPAL_KEYS) {
        if (map.containsKey(key)) {
            return map.get(key);
        }
    }
    return "unknown";
}

Only the properties specified in PRINCIPAL_KEYS are extracted by default. And thats exactly your problem. You have to extract more than just the username or whatever your property is named. So look for more keys.

private Object getPrincipal(Map<String, Object> map) {
    MyUserDetails myUserDetails = new myUserDetails();
    for (String key : PRINCIPAL_KEYS) {
        if (map.containsKey(key)) {
            myUserDetails.setUserName(map.get(key));
        }
    }
    if( map.containsKey("email") {
        myUserDetails.setEmail(map.get("email"));
    }
    //and so on..
    return myUserDetails;
}

Wiring:

@Autowired
private ResourceServerProperties sso;

@Bean
public ResourceServerTokenServices myUserInfoTokenServices() {
    return new MyUserInfoTokenServices(sso.getUserInfoUri(), sso.getClientId());
}

!!UPDATE with Spring Boot 1.4 things are getting easier!!

With Spring Boot 1.4.0 a PrincipalExtractor was introduced. This class should be implemented to extract a custom principal (see Spring Boot 1.4 Release Notes).

Solution 2

All the data is already in the Principal object, no second request is necessary. Return only what you need. I use the method below for Facebook login:

@RequestMapping("/sso/user")
@SuppressWarnings("unchecked")
public Map<String, String> user(Principal principal) {
    if (principal != null) {
        OAuth2Authentication oAuth2Authentication = (OAuth2Authentication) principal;
        Authentication authentication = oAuth2Authentication.getUserAuthentication();
        Map<String, String> details = new LinkedHashMap<>();
        details = (Map<String, String>) authentication.getDetails();
        logger.info("details = " + details);  // id, email, name, link etc.
        Map<String, String> map = new LinkedHashMap<>();
        map.put("email", details.get("email"));
        return map;
    }
    return null;
}

Solution 3

In the Resource server you can create a CustomPrincipal Class Like this:

public class CustomPrincipal {

    public CustomPrincipal(){};

    private String email;

    //Getters and Setters
    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

}

Implement a CustomUserInfoTokenServices like this:

public class CustomUserInfoTokenServices implements ResourceServerTokenServices {

    protected final Log logger = LogFactory.getLog(getClass());

    private final String userInfoEndpointUrl;

    private final String clientId;

    private OAuth2RestOperations restTemplate;

    private String tokenType = DefaultOAuth2AccessToken.BEARER_TYPE;

    private AuthoritiesExtractor authoritiesExtractor = new FixedAuthoritiesExtractor();

    private PrincipalExtractor principalExtractor = new CustomPrincipalExtractor();

    public CustomUserInfoTokenServices(String userInfoEndpointUrl, String clientId) {
        this.userInfoEndpointUrl = userInfoEndpointUrl;
        this.clientId = clientId;
    }

    public void setTokenType(String tokenType) {
        this.tokenType = tokenType;
    }

    public void setRestTemplate(OAuth2RestOperations restTemplate) {
        this.restTemplate = restTemplate;
    }

    public void setAuthoritiesExtractor(AuthoritiesExtractor authoritiesExtractor) {
        Assert.notNull(authoritiesExtractor, "AuthoritiesExtractor must not be null");
        this.authoritiesExtractor = authoritiesExtractor;
    }

    public void setPrincipalExtractor(PrincipalExtractor principalExtractor) {
        Assert.notNull(principalExtractor, "PrincipalExtractor must not be null");
        this.principalExtractor = principalExtractor;
    }

    @Override
    public OAuth2Authentication loadAuthentication(String accessToken)
            throws AuthenticationException, InvalidTokenException {
        Map<String, Object> map = getMap(this.userInfoEndpointUrl, accessToken);
        if (map.containsKey("error")) {
            if (this.logger.isDebugEnabled()) {
                this.logger.debug("userinfo returned error: " + map.get("error"));
            }
            throw new InvalidTokenException(accessToken);
        }
        return extractAuthentication(map);
    }

    private OAuth2Authentication extractAuthentication(Map<String, Object> map) {
        Object principal = getPrincipal(map);
        List<GrantedAuthority> authorities = this.authoritiesExtractor
                .extractAuthorities(map);
        OAuth2Request request = new OAuth2Request(null, this.clientId, null, true, null,
                null, null, null, null);
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
                principal, "N/A", authorities);
        token.setDetails(map);
        return new OAuth2Authentication(request, token);
    }

    /**
     * Return the principal that should be used for the token. The default implementation
     * delegates to the {@link PrincipalExtractor}.
     * @param map the source map
     * @return the principal or {@literal "unknown"}
     */
    protected Object getPrincipal(Map<String, Object> map) {

        CustomPrincipal customPrincipal = new CustomPrincipal();
        if( map.containsKey("principal") ) {
            Map<String, Object> principalMap = (Map<String, Object>) map.get("principal");
            customPrincipal.setEmail((String) principalMap.get("email"));

        }
        //and so on..
        return customPrincipal;

        /*
        Object principal = this.principalExtractor.extractPrincipal(map);
        return (principal == null ? "unknown" : principal);
        */

    }

    @Override
    public OAuth2AccessToken readAccessToken(String accessToken) {
        throw new UnsupportedOperationException("Not supported: read access token");
    }

    @SuppressWarnings({ "unchecked" })
    private Map<String, Object> getMap(String path, String accessToken) {
        if (this.logger.isDebugEnabled()) {
            this.logger.debug("Getting user info from: " + path);
        }
        try {
            OAuth2RestOperations restTemplate = this.restTemplate;
            if (restTemplate == null) {
                BaseOAuth2ProtectedResourceDetails resource = new BaseOAuth2ProtectedResourceDetails();
                resource.setClientId(this.clientId);
                restTemplate = new OAuth2RestTemplate(resource);
            }
            OAuth2AccessToken existingToken = restTemplate.getOAuth2ClientContext()
                    .getAccessToken();
            if (existingToken == null || !accessToken.equals(existingToken.getValue())) {
                DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(
                        accessToken);
                token.setTokenType(this.tokenType);
                restTemplate.getOAuth2ClientContext().setAccessToken(token);
            }
            return restTemplate.getForEntity(path, Map.class).getBody();
        }
        catch (Exception ex) {
            this.logger.warn("Could not fetch user details: " + ex.getClass() + ", "
                    + ex.getMessage());
            return Collections.<String, Object>singletonMap("error",
                    "Could not fetch user details");
        }
    }

}

A Custom PrincipalExtractor:

public class CustomPrincipalExtractor implements PrincipalExtractor {

    private static final String[] PRINCIPAL_KEYS = new String[] {
            "user", "username", "principal",
            "userid", "user_id",
            "login", "id",
            "name", "uuid",
            "email"};

    @Override
    public Object extractPrincipal(Map<String, Object> map) {
        for (String key : PRINCIPAL_KEYS) {
            if (map.containsKey(key)) {
                return map.get(key);
            }
        }
        return null;
    }

    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();

        daoAuthenticationProvider.setForcePrincipalAsString(false);
        return daoAuthenticationProvider;
    }

}

In your @Configuration file define a bean like this one

@Bean
    public ResourceServerTokenServices myUserInfoTokenServices() {
        return new CustomUserInfoTokenServices(sso.getUserInfoUri(), sso.getClientId());
    }

And in the Resource Server Configuration:

@Configuration
public class OAuth2ResourceServerConfig extends ResourceServerConfigurerAdapter {


    @Override
    public void configure(ResourceServerSecurityConfigurer config) {
        config.tokenServices(myUserInfoTokenServices());
    }

    //etc....

If everything is set correctly you can do something like this in your controller:

String userEmail = ((CustomPrincipal) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getEmail();

Hope this helps.

Solution 4

A Map representation of the JSON object returned by the userdetails endpoint is available from the Authentication object that represents the Principal:

Map<String, Object> details = (Map<String,Object>)oauth2.getUserAuthentication().getDetails();

If you want to capture it for logging, storage or cacheing I'd recommend capturing it by implementing an ApplicationListener. For example:

@Component
public class AuthenticationSuccessListener implements ApplicationListener<AuthenticationSuccessEvent> {

  private Logger log = LoggerFactory.getLogger(this.getClass()); 

  @Override
  public void onApplicationEvent(AuthenticationSuccessEvent event) {
    Authentication auth = event.getAuthentication();
    log.debug("Authentication class: "+auth.getClass().toString());

    if(auth instanceof OAuth2Authentication){

        OAuth2Authentication oauth2 = (OAuth2Authentication)auth;

        @SuppressWarnings("unchecked")
        Map<String, Object> details = (Map<String, Object>)oauth2.getUserAuthentication().getDetails();         

        log.info("User {} logged in: {}", oauth2.getName(), details);
        log.info("User {} has authorities {} ", oauth2.getName(), oauth2.getAuthorities());



    } else {
        log.warn("User authenticated by a non OAuth2 mechanism. Class is "+auth.getClass());
    }

  }
}

If you specifically want to customize the extraction of the principal from the JSON or the authorities then you could implement org.springframework.boot.autoconfigure.security.oauth2.resource.PrincipalExtractor and/ org.springframework.boot.autoconfigure.security.oauth2.resource.AuthoritiesExtractor respectively.

Then, in a @Configuration class you would expose your implementations as beans:

@Bean
public PrincipalExtractor merckPrincipalExtractor() {
        return new MyPrincipalExtractor();
}

@Bean 
public AuthoritiesExtractor merckAuthoritiesExtractor() {
        return new MyAuthoritiesExtractor(); 
}

Solution 5

You can use JWT tokens. You won't need datastore where all user information is stored instead you can encode additional information into the token itself. When token is decoded you app will be able to access all this information using Principal object

Share:
75,159

Related videos on Youtube

S. Pauk
Author by

S. Pauk

https://www.linkedin.com/in/sergeypauk/

Updated on July 09, 2022

Comments

  • S. Pauk
    S. Pauk almost 2 years

    I have a resource server configured with @EnableResourceServer annotation and it refers to authorization server via user-info-uri parameter as follows:

    security:
      oauth2:
        resource:
          user-info-uri: http://localhost:9001/user
    


    Authorization server /user endpoint returns an extension of org.springframework.security.core.userdetails.User which has e.g. an email:

    {  
       "password":null,
       "username":"myuser",
        ...
       "email":"[email protected]"
    }
    


    Whenever some resource server endpoint is accessed Spring verifies the access token behind the scenes by calling the authorization server's /user endpoint and it actually gets back the enriched user info (which contains e.g. email info, I've verified that with Wireshark).

    So the question is how do I get this custom user info without an explicit second call to the authorization server's /user endpoint. Does Spring store it somewhere locally on the resource server after authorization or what is the best way to implement this kind of user info storing if there's nothing available out of the box?

    • Yannic Bürgmann
      Yannic Bürgmann over 8 years
      You want to create a session for your ResourceServer?
    • S. Pauk
      S. Pauk over 8 years
      @YannicKlem Not really, i'd like to customize the Principal I get from the request so that it contains custom user info as well. Out of the box this Principal implementation contains just my username and some other basic stuff. I mean that this Principal is built from the authorization response behind the scenes but Spring default implementation cuts down all my custom user info.
    • Yannic Bürgmann
      Yannic Bürgmann over 8 years
      Oh okay.. was confused because of "So the question is how do I get this custom user info without an explicit second call to the authorization server's /user endpoint". i will provide an answer in a few minutes
    • Yannic Bürgmann
      Yannic Bürgmann over 8 years
      let me know if i left something unclear. I will try to explain it in detail
  • S. Pauk
    S. Pauk over 8 years
    We use relatively long-living access tokens so JWT is not an option.
  • S. Pauk
    S. Pauk over 8 years
    Looks like this class has been implemented without a thought about possible extension.. so much private stuff. Should my class extend UserInfoTokenServices or implementing ResourceServerTokenServices is enough? What is security.oauth2.resource.prefer-token-info=false about?
  • Yannic Bürgmann
    Yannic Bürgmann over 8 years
    implementing ResourceServerTokenServices should be enough, however I implemented it by extending UserInfoTokenServices. Both sould work. For the properties have a look at: docs.spring.io/spring-boot/docs/current/reference/html/…
  • S. Pauk
    S. Pauk over 8 years
    don't see how this class could be efficiently extended. Basically you would have to copy paste about 3/4 of the original code :) Is that what you have done?
  • Yannic Bürgmann
    Yannic Bürgmann over 8 years
    That's right .. in fact that's what i did most of the time ;) I wasn't sure if Spring's OAuth2 expect a UserInfoTokenServices-Bean
  • Yannic Bürgmann
    Yannic Bürgmann over 8 years
    Please, let me know if implementing ResourceServerTokenServices works for you
  • Yannic Bürgmann
    Yannic Bürgmann over 8 years
    Mh, i had a look at github.com/spring-projects/spring-boot/blob/master/… seems that prefer-token-info= false is only required if you provided a token-info-uri
  • Shlomi Uziel
    Shlomi Uziel over 7 years
    Just pointing out that when using standard external oauth2 providers such as Google and Facebook, according to this example: spring.io/guides/tutorials/spring-boot-oauth2, implementing a custom UserInfoTokenServices only works when using manual configuration with the EnableOAuth2Client annotation, and not when using the auto configuration with the EnableOAuth2Sso annotation.
  • rich p
    rich p almost 7 years
    Finally! I've been looking all over the web for this! ` logger.info("details map is: {}", map);` gives me details map is: {[email protected]} :-)
  • sme
    sme almost 7 years
    Had a similar problem in wanting to make the scopes from the OAuth2 user info available in the OAuth2Authentication object. This provided a good starting point, I just had to do some changes in extractAuthentication.
  • 027
    027 almost 7 years
    I have resource serve which uses RemoteTokenServie. Can I set both RemoteTokenSerice as well as CustomUserInfoTokenServices?
  • demaniak
    demaniak over 6 years
    I would be very willing so say that my configuration might be lacking something somewhere (I had to customize a lot of stuff to meet my requirements), but regardless, the best I can get out of the OAuth2Authentication is the OAuth2AuthenticationDetails, and from there the token value. Which I then must manually split and decode. Very...kludgy.
  • Arun M R Nair
    Arun M R Nair about 6 years
    Scope value getting as null. how we keep the scope available after invoking user endpoint? what changes required in 'extractAuthentication' method