Spring Security-通过BitBucket的OAuth2授权Web应用程序示例

在本文中,我们将考虑一种使用OAuth2协议,使用外部授权服务器(以Bitbucket为例)在Spring Boot上的Web应用程序中授权用户的方法。

我们想得到什么


假设我们正在开发一个安全的Web应用程序,该应用程序可以访问外部服务器(例如,持续集成系统)上的用户资源,并且Bitbucket充当外部服务器,并且我们不希望从外部系统中存储用户的用户名和密码。也就是说,我们需要通过Bitbucket授权用户才能访问他的帐户和资源,还需要检查他是否是我们应用程序的用户并进行注册,以使该用户不会向我们透露他的Bitbucket凭据。我不知道该怎么做-欢迎来到猫。

OAuth2


OAuth2是一种授权协议,可让您向第三方提供对用户受保护资源的有限访问权限,而不必给用户(第三方)提供用户名和密码。

OAuth2定义了4个角色:

  • 资源所有者
  • 资源服务器
  • 授权服务器
  • 客户(申请)

资源所有者是使用客户端应用程序并允许他访问托管在资源服务器上的帐户的用户。应用程序对该帐户的访问受授予的权限限制。

资源服务器主机安全的用户帐户。
授权服务器对资源的所有者进行身份验证并颁发访问令牌。授权服务器可以同时是资源服务器。

客户端应用程序是想要访问用户帐户和资源的应用程序。

在我们的案例中,客户端应用程序是我们正在开发的应用程序,Bitbucket将同时是授权服务器和资源服务器。

OAuth2支持四种类型的授权:授权代码,隐式,资源所有者密码凭证和客户端凭证。我们不会考虑所有内容,我们对授权码的类型感兴趣。授权代码类型针对服务器应用程序进行了优化,在服务器应用程序中,源代码不可公开获得,并且可以对客户端密码进行保密。此类型在重定向的基础上起作用,即,将用户重定向到授权服务器,以确认其身份并允许应用程序使用其帐户。

通过授权码的授权过程由两个请求序列组成:

  • 授权请求
  • 访问令牌请求

授权请求用于确认用户的身份,以及从用户请求我们的应用程序的授权。该请求是具有以下参数的GET请求:

  • response_type-该值必须等于代码
  • client_id-向OAuth2提供程序注册客户端时获得的值
  • redirect_uri-授权后将重定向用户的URL
  • 作用域-一个可选参数,指示请求哪个访问级别
  • 状态-随机生成的字符串以验证响应

请求示例:

GET https://server.example.com/authorize?response_type=code&client_id=CLIENT_ID&state=xyz&redirect_uri=REDIRECT_URI

如果用户确认其身份并允许应用程序访问其资源,则将在客户端注册期间将用户代理重定向到具有附加参数代码的回调URL,该附加参数代码包含授权代码和状态参数以及在请求中传递的值。

回调示例:

GET https://client.example.com/cb?code=AUTH_CODE_HERE&state=xyz

访问代码请求用于将接收到的授权代码交换为对用户资源的访问代码。该请求是具有以下参数的POST请求:

  • grant_type-值必须是授权码
  • 代码-上一步获得的授权代码
  • redirect_uri-必须与上一步中指定的URL匹配
  • client_id-向OAuth2提供程序注册客户端时获得的值
  • client_secret-向OAuth2提供者注册客户端时获得的值

请求示例:

POST https://server.example.com/token
    grant_type=authorization_code&
    code=AUTH_CODE&
    redirect_uri=REDIRECT_URI&
    client_id=CLIENT_ID&
    client_secret=CLIENT_SECRET

服务器响应包含访问代码及其生存期:

{
    "access_token": "2YotnFZFEjr1zCsicMWpAA",
    "expires_in": 3600,
    "refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA"
}

整个过程在Spring Security中已经实现了自动化,我们无需担心其实现。

客户注册


首先,我们将在Bitbucket中将应用程序注册为客户端,以获取密钥(Key)和访问代码(Client Secret)。



输入客户端名称和回调URL。然后,我们注意该客户端可以使用的内容。



获得的Key和ClientSecret值存储在application.properties中:

client_id=ZJsdANfWkJ7jcktw2x
client_secret=28uUrJ9m43svbkcnXVNj8qeBjFtd8jaD

配置Spring Security


接下来,让我们设置Spring Security。为了使OAuth2正常工作,您必须创建一个ClientRegistration对象。ClientRegistration存储有关在OAuth2提供程序中注册的客户端的信息。在这里,我们需要在上一步中获得的client_id和client_secret。由于通常可以有多个这样的ClientRegistration对象,因此Spring Security使用ClientRegistrationRepository对象来存储和访问它们。让我们也创建它。我们还指出,只有授权用户才能调用任何请求,并通过其实现重新定义UserService。

SecurityConfig.java
@Configuration
public class SecurityConfig {

    @Value("${client_id}")
    private String clientId;

    @Value("${client_secret}")
    private String clientSecret;

    @Bean
    public ClientRegistration clientRegistration() {
        return ClientRegistration
                .withRegistrationId("bitbucket")
                .clientId(clientId)
                .clientSecret(clientSecret)
                .userNameAttributeName("username")
                .clientAuthenticationMethod(BASIC)
                .authorizationGrantType(AUTHORIZATION_CODE)
                .userInfoUri("https://api.bitbucket.org/2.0/user")
                .tokenUri("https://bitbucket.org/site/oauth2/access_token")
                .authorizationUri("https://bitbucket.org/site/oauth2/authorize")
                .redirectUriTemplate("{baseUrl}/login/oauth2/code/{registrationId}")
                .build();
    }

    @Bean
    @Autowired
    public MyOAuth2UserService oAuth2userService(UserService userService) {
        return new MyOAuth2UserService(userService);
    }

    @Bean
    @Autowired
    public ClientRegistrationRepository clientRegistrationRepository(List<ClientRegistration> registrations) {
        return new InMemoryClientRegistrationRepository(registrations);
    }

    @Configuration
    @EnableWebSecurity
    public static class AuthConfig extends WebSecurityConfigurerAdapter {

        private final MyOAuth2UserService userService;

        @Autowired
        public AuthConfig(MyOAuth2UserService userService) {
            this.userService = userService;
        }

        @Override
        protected void configure(HttpSecurity http) throws Exception {
            http
                    .authorizeRequests(authorizeRequests -> authorizeRequests
                            .anyRequest().authenticated()
                    )
                    .oauth2Login(oauth2Login -> oauth2Login
                            .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint.userService(userService))
                    );
        }
    }
}


定制用户服务


Spring Security不仅完全实现了授权过程,而且还提供了对其进行自定义的功能。例如,定制授权请求请求访问代码的可能性以及对访问代码请求响应的定制后处理的可能性。成功授权后,Spring Security使用UserInfo Endpoint从授权服务器检索用户属性。特别是,为此使用OAuth2UserService接口实现。

我们将创建此服务的自己的实现,以便在授权服务器上对用户进行授权之后,我们还要检查他是否是我们应用程序的用户,或者如果所有人都可以注册,则进行注册。默认情况下,Spring Security使用DefaultOAuth2UserService的实现。他将构成我们实施的基础。

MyOAuth2UserService.java
public class MyOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {

    private static final String MISSING_USER_INFO_URI_ERROR_CODE = "missing_user_info_uri";

    private static final String INVALID_USER_INFO_RESPONSE_ERROR_CODE = "invalid_user_info_response";

    private static final String MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE = "missing_user_name_attribute";

    private static final ParameterizedTypeReference<Map<String, Object>> PARAMETERIZED_RESPONSE_TYPE = new ParameterizedTypeReference<Map<String, Object>>() {
    };

    private final UserService userService;

    private final RestOperations restOperations;

    private final Converter<OAuth2UserRequest, RequestEntity<?>> requestEntityConverter = new OAuth2UserRequestEntityConverter();

    public MyOAuth2UserService(UserService userService) {
        this.userService = requireNonNull(userService);
        this.restOperations = createRestTemplate();
    }

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        checkNotNull(userRequest, "userRequest cannot be null");
        if (!StringUtils.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
            OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE, "Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
        }

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
        if (!StringUtils.hasText(userNameAttributeName)) {
            OAuth2Error oauth2Error = new OAuth2Error(
                    MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
                    "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " + registrationId, null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
        }

        ResponseEntity<Map<String, Object>> response;
        try {
            // OAuth2UserRequestEntityConverter cannot return null values.
            //noinspection ConstantConditions
            response = this.restOperations.exchange(requestEntityConverter.convert(userRequest), PARAMETERIZED_RESPONSE_TYPE);
        } catch (OAuth2AuthorizationException ex) {
            OAuth2Error oauth2Error = ex.getError();
            StringBuilder errorDetails = new StringBuilder();
            errorDetails.append("Error details: [");
            errorDetails.append("UserInfo Uri: ").append(
                    userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri());
            errorDetails.append(", Error Code: ").append(oauth2Error.getErrorCode());
            if (oauth2Error.getDescription() != null) {
                errorDetails.append(", Error Description: ").append(oauth2Error.getDescription());
            }
            errorDetails.append("]");
            oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
                    "An error occurred while attempting to retrieve the UserInfo Resource: " + errorDetails.toString(), null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
        } catch (RestClientException ex) {
            OAuth2Error oauth2Error = new OAuth2Error(INVALID_USER_INFO_RESPONSE_ERROR_CODE,
                    "An error occurred while attempting to retrieve the UserInfo Resource: " + ex.getMessage(), null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString(), ex);
        }

        Map<String, Object> userAttributes = emptyIfNull(response.getBody());

        Set<GrantedAuthority> authorities = new LinkedHashSet<>();
        authorities.add(new OAuth2UserAuthority(userAttributes));

        for (String authority : userRequest.getAccessToken().getScopes()) {
            authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
        }

        //     ,   
        //          ,
        //    
        User user = findOrCreate(userAttributes);
        userAttributes.put(MyOAuth2User.ID_ATTR, user.getId());
        return new MyOAuth2User(userNameAttributeName, userAttributes, authorities);
    }

    private User findOrCreate(Map<String, Object> userAttributes) {
        String login = (String) userAttributes.get("username");
        String username = (String) userAttributes.get("display_name");

        Optional<User> userOpt = userService.findByLogin(login);
        if (!userOpt.isPresent()) {
            User user = new User();
            user.setLogin(login);
            user.setName(username);
            return userService.create(user);
        }
        return userOpt.get();
    }

    private RestTemplate createRestTemplate() {
        RestTemplate restTemplate = new RestTemplate();
        restTemplate.setErrorHandler(new OAuth2ErrorResponseErrorHandler());
        return restTemplate;
    }
}


测试端点


现在是时候创建一个端点来测试我们所做操作的运行状况了。我们的端点仅包含一个请求,该请求将欢迎当前用户。

WelcomeEndpoint.java
@Path("/")
public class WelcomeEndpoint {

    @Autowired
    private UserService userService;

    @GET
    public String welcome() {
        User currentUser = getCurrentUser();
        return String.format("Welcome, %s! (user id: %s, user login: %s)",
                currentUser.getName(), currentUser.getId(), currentUser.getLogin());
    }

    public User getCurrentUser() {
        return userService.findByLogin(getAuthenticatedUser().getName()).orElseThrow(() -> new RuntimeException("No user logged in."));
    }

    private Authentication getAuthentication() {
        return SecurityContextHolder.getContext().getAuthentication();
    }

    private MyOAuth2User getAuthenticatedUser() {
        return (MyOAuth2User) getAuthentication().getPrincipal();
    }
}


健康检查


我们启动该应用程序,然后转到地址http://本地主机:8080,然后看到我们已重定向到Bitbucket网站以确认我们的帐户。输入用户名和密码。



现在,我们需要允许我们的应用程序访问我们的帐户和资源。



用户的问候语包含授权服务器的属性和应用程序中的用户ID。



资源


该应用程序的完整源代码在Github上

参考文献



All Articles