如何从OAuth2授权服务器的/user端点获取自定义用户信息

27

我已经使用@EnableResourceServer注释配置了一个资源服务器,并通过以下方式使用user-info-uri参数引用授权服务器:

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

授权服务器/用户端点返回org.springframework.security.core.userdetails.User的扩展,其中包括电子邮件等信息。
{  
   "password":null,
   "username":"myuser",
    ...
   "email":"me@company.com"
}

无论何时访问某个资源服务器端点,Spring 都会在幕后通过调用授权服务器的 /user 端点来验证访问令牌,并实际上获取到增强的用户信息(其中包含例如电子邮件信息,我已经用 Wireshark 进行了验证)。那么问题是,如何在没有显式第二次调用授权服务器的 /user 端点的情况下获取此自定义用户信息。在授权后,Spring 是否会将其存储在资源服务器的本地某处,或者最好的实现方式是什么,如果没有可用的开箱即用解决方案,应该如何实现此类用户信息存储?

你想为你的ResourceServer创建一个会话吗? - Yannic Bürgmann
@YannicKlem 其实不是这样的,我想要自定义从请求中获取的Principal,以便它还包含定制的用户信息。默认情况下,这个Principal实现只包含我的用户名和一些其他基本信息。我的意思是,这个Principal是从授权响应中构建的,但Spring默认实现削减了我所有的自定义用户信息。 - S. Pauk
好的,我有点困惑是因为“所以问题是如何在不显式调用授权服务器的/user端点的情况下获取此自定义用户信息”。我会在几分钟内提供答案。 - Yannic Bürgmann
如果我有什么表述不清楚的地方,请告诉我。我会尽力详细解释。 - Yannic Bürgmann
6个回答

25

解决方案是实现一个自定义的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

只需提供您自定义的实现作为Bean,它将被用于替换默认实现。
在这个UserInfoTokenServices中,您可以按照自己的方式构建principal。
这个UserInfoTokenServices用于从授权服务器的/users端点响应中提取UserDetails。正如您在...中看到的那样。
private Object getPrincipal(Map<String, Object> map) {
    for (String key : PRINCIPAL_KEYS) {
        if (map.containsKey(key)) {
            return map.get(key);
        }
    }
    return "unknown";
}

默认情况下,仅提取PRINCIPAL_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;
}

连线:

@Autowired
private ResourceServerProperties sso;

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

!!更新:使用Spring Boot 1.4更加容易了!!

在Spring Boot 1.4.0中,引入了PrincipalExtractor。应该实现这个类来提取自定义的principal(请参阅Spring Boot 1.4 Release Notes)。


看起来这个类的实现没有考虑到可能的扩展...有太多私有内容了。我的类应该扩展UserInfoTokenServices还是实现ResourceServerTokenServices就足够了?security.oauth2.resource.prefer-token-info=false是什么意思? - S. Pauk
实现ResourceServerTokenServices应该足够了,但我通过扩展UserInfoTokenServices来实现它。两种方法都可以。有关属性,请查看:https://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html - Yannic Bürgmann
我不认为这个类能够有效地扩展。基本上,你需要复制粘贴原始代码的3/4 :) 这就是你所做的吗? - S. Pauk
嗯,我看了一下 https://github.com/spring-projects/spring-boot/blob/master/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/security/oauth2/resource/ResourceServerTokenServicesConfiguration.java 似乎只有在提供了 token-info-uri 的情况下才需要设置 prefer-token-info=false - Yannic Bürgmann
1
只是指出,当使用标准的外部oauth2提供程序,如Google和Facebook时,根据此示例:https://spring.io/guides/tutorials/spring-boot-oauth2/,实现自定义UserInfoTokenServices仅在使用EnableOAuth2Client注释的手动配置时有效,而不是在使用EnableOAuth2Sso注释的自动配置时。 - Shlomi Uziel
显示剩余2条评论

8

所有数据已经在Principal对象中,不需要第二个请求。只返回你需要的内容。我使用以下方法进行Facebook登录:

@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;
}

终于找到了!我在网上找了好久!logger.info("details map is: {}", map);给了我details map is: {email=myemailaddress@gmail.com}:-) - rich p
我非常愿意说,我的配置可能在某个地方缺少了一些东西(我不得不自定义很多东西来满足我的要求),但是无论如何,我能从OAuth2Authentication中获得的最好的结果就是OAuth2AuthenticationDetails,然后从那里获取令牌值。然后我必须手动拆分和解码。非常...笨拙。 - demaniak

4
在资源服务器中,您可以创建一个CustomPrincipal类,如下所示:
public class CustomPrincipal {

    public CustomPrincipal(){};

    private String email;

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

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

}

实现一个类似于CustomUserInfoTokenServices的自定义用户信息令牌服务,代码如下:
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");
        }
    }

}

自定义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;
    }

}

在你的@Configuration文件中定义一个像这个的bean:
@Bean
    public ResourceServerTokenServices myUserInfoTokenServices() {
        return new CustomUserInfoTokenServices(sso.getUserInfoUri(), sso.getClientId());
    }

在资源服务器配置中:

@Configuration
public class OAuth2ResourceServerConfig extends ResourceServerConfigurerAdapter {


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

    //etc....

如果一切设置正确,您可以在控制器中执行以下操作:
String userEmail = ((CustomPrincipal) SecurityContextHolder.getContext().getAuthentication().getPrincipal()).getEmail();

希望这有帮助。

在想要使OAuth2用户信息的作用域在OAuth2Authentication对象中可用的问题上遇到了类似的问题。这提供了一个很好的起点,我只需要在extractAuthentication中做一些更改即可。 - sme
我有一个使用RemoteTokenService的资源服务器。我可以同时设置RemoteTokenService和CustomUserInfoTokenServices吗? - 027
作用域的值为null。我们如何在调用用户端点后保持作用域可用?'extractAuthentication'方法需要进行哪些更改? - Arun M R Nair

3

代表主体的Authentication对象中可用的JSON对象的Map表示可以从userdetails端点返回的JSON对象获得:

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

如果您想为日志记录、存储或缓存而捕获它,我建议通过实现ApplicationListener来捕获它。例如:

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

  }
}

如果您想从JSON中自定义提取主体或授权,则可以分别实现org.springframework.boot.autoconfigure.security.oauth2.resource.PrincipalExtractor和/或org.springframework.boot.autoconfigure.security.oauth2.resource.AuthoritiesExtractor

然后,在 @Configuration 类中,您需要将您的实现暴露为bean:

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

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

1
我们从SecurityContextHolder的getContext方法中检索它,该方法是静态的,因此可以从任何地方检索。
// this is userAuthentication's principal
Map<?, ?> getUserAuthenticationFromSecurityContextHolder() {
    Map<?, ?> userAuthentication = new HashMap<>();
    try {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (!(authentication instanceof OAuth2Authentication)) {
            return userAuthentication;
        }
        OAuth2Authentication oauth2Authentication = (OAuth2Authentication) authentication;
        Authentication userauthentication = oauth2Authentication.getUserAuthentication();
        if (userauthentication == null) {
            return userAuthentication;
        }
        Map<?, ?> details = (HashMap<?, ?>) userauthentication.getDetails();    //this effect in the new RW OAUTH2 userAuthentication
        Object principal = details.containsKey("principal") ? details.get("principal") : userAuthentication; //this should be effect in the common OAUTH2 userAuthentication
        if (!(principal instanceof Map)) {
            return userAuthentication;
        }
        userAuthentication = (Map<?, ?>) principal;
    } catch (Exception e) {
        logger.error("Got exception while trying to obtain user info from security context.", e);
    }
    return userAuthentication;
}

1
你可以使用JWT令牌。您无需数据存储,其中存储了所有用户信息,而是可以将其他信息编码到令牌本身中。当令牌被解码时,您的应用程序将能够使用Principal对象访问所有这些信息。

我们使用相对长寿的访问令牌,因此JWT不是一个选项。 - S. Pauk

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接