Spring Security - Un exemple d'application Web d'autorisation OAuth2 via BitBucket

Dans cet article, nous examinerons un moyen d'autoriser des utilisateurs dans des applications Web sur Spring Boot en utilisant le protocole OAuth2 en utilisant un serveur d'autorisation externe (en utilisant l'exemple de Bitbucket).

Que voulons-nous obtenir


Supposons que nous développons une application Web sécurisée qui a accès aux ressources des utilisateurs sur un serveur externe, par exemple, un système d'intégration continue, et Bitbucket agit comme un serveur externe, et nous ne voudrions pas stocker le nom d'utilisateur et le mot de passe de l'utilisateur à partir du système externe. Autrement dit, nous devons autoriser l'utilisateur via Bitbucket afin d'accéder à son compte et à ses ressources, vérifier en outre qu'il est un utilisateur de notre application et faire en sorte que l'utilisateur ne nous divulgue pas ses informations d'identification de Bitbucket. Je me demande comment faire - bienvenue au chat.

OAuth2


OAuth2 est un protocole d'autorisation qui vous permet de fournir à un tiers un accès limité aux ressources protégées d'un utilisateur sans avoir à lui donner (un tiers) un nom d'utilisateur et un mot de passe.

OAuth2 définit 4 rôles:

  • Propriétaire de la ressource
  • Serveur de ressources
  • Serveur d'autorisation
  • Client (application)

Un propriétaire de ressource est un utilisateur qui utilise une application cliente et lui permet d'accéder à son compte hébergé sur un serveur de ressources. L'accès des applications au compte est limité par les autorisations accordées.

Le serveur de ressources héberge des comptes d'utilisateurs sécurisés.
Le serveur d'autorisation authentifie le propriétaire de la ressource et émet des jetons d'accès. Le serveur d'autorisation peut être simultanément un serveur de ressources.

Une application cliente est une application qui souhaite accéder au compte et aux ressources d'un utilisateur.

Dans notre cas, l'application cliente est l'application que nous développons, et Bitbucket sera à la fois un serveur d'autorisation et un serveur de ressources.

OAuth2 prend en charge quatre types d'autorisation: code d'autorisation, implicite, informations d'identification du mot de passe du propriétaire de la ressource et informations d'identification du client. Nous ne les considérerons pas tous, nous sommes intéressés par le type de code d'autorisation. Le type de code d'autorisation est optimisé pour les applications serveur dans lesquelles le code source n'est pas accessible au public et le code secret client peut rester confidentiel. Ce type fonctionne sur la base d'une redirection, c'est-à-dire que l'utilisateur sera redirigé vers le serveur d'autorisation afin de confirmer son identité et permettre à l'application d'utiliser son compte.

Le processus d'autorisation via le code d'autorisation consiste en une séquence de deux demandes:

  • Demande d'autorisation
  • Demande de jeton d'accès

Une demande d'autorisation est utilisée pour confirmer l'identité de l'utilisateur, ainsi que pour demander l'autorisation de notre application à l'utilisateur. Cette demande est une demande GET avec les paramètres suivants:

  • response_type - la valeur doit être égale au code
  • client_id - valeur obtenue lors de l'enregistrement d'un client auprès du fournisseur OAuth2
  • redirect_uri - URL où l'utilisateur sera redirigé après autorisation
  • scope - un paramètre facultatif indiquant quel niveau d'accès est demandé
  • état - chaîne générée aléatoirement pour vérifier la réponse

Exemple de demande:

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

Si l'utilisateur confirme son identité et autorise l'application à accéder à ses ressources, l'agent utilisateur sera redirigé vers l'URL de rappel spécifiée lors de l'inscription du client avec le code de paramètre supplémentaire contenant le code d'autorisation et le paramètre d'état avec la valeur transmise dans la demande.

Exemple de rappel:

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

Une demande de code d'accès est utilisée pour échanger le code d'autorisation reçu contre un code d'accès aux ressources utilisateur. Cette demande est une demande POST avec les paramètres suivants:

  • grant_type - la valeur doit être code_autorisation
  • code - code d'autorisation obtenu à l'étape précédente
  • redirect_uri - doit correspondre à l'URL spécifiée à l'étape précédente
  • client_id - valeur obtenue lors de l'enregistrement d'un client auprès du fournisseur OAuth2
  • client_secret - valeur obtenue lors de l'enregistrement d'un client auprès du fournisseur OAuth2

Exemple de demande:

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

La réponse du serveur contient le code d'accès et sa durée de vie:

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

L'ensemble de ce processus est déjà automatisé dans Spring Security et nous n'avons pas à nous soucier de sa mise en œuvre.

Inscription client


Tout d'abord, nous enregistrerons notre application en tant que client dans Bitbucket pour obtenir une clé (Key) et un code d'accès (Client Secret).



Saisissez le nom du client et l'URL de rappel. Ensuite, nous notons ce qui sera disponible pour ce client.



Les valeurs Key et ClientSecret obtenues sont stockées dans application.properties:

client_id=ZJsdANfWkJ7jcktw2x
client_secret=28uUrJ9m43svbkcnXVNj8qeBjFtd8jaD

Configurer Spring Security


Ensuite, configurons Spring Security. Pour que OAuth2 fonctionne, vous devez créer un objet ClientRegistration. ClientRegistration stocke des informations sur le client enregistré auprès du fournisseur OAuth2. Ici, nous avons besoin du client_id et du client_secret obtenus à l'étape précédente. Puisqu'il peut y avoir plusieurs de ces objets ClientRegistration en général, Spring Security utilise l'objet ClientRegistrationRepository pour les stocker et y accéder. Créons-le aussi. Nous indiquons également que seul un utilisateur autorisé peut appeler une demande et redéfinir UserService avec sa mise en œuvre.

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


UserService de personnalisation


Spring Security non seulement implémente entièrement le processus d'autorisation, mais offre également la possibilité de le personnaliser. Par exemple, la possibilité de personnaliser une demande d'autorisation , de demander un code d'accès , ainsi que la possibilité de post-traitement personnalisé d'une réponse à une demande de code d'accès . Une fois l'autorisation réussie, Spring Security utilise UserInfo Endpoint pour récupérer les attributs utilisateur du serveur d'autorisation. En particulier, l'implémentation de l'interface OAuth2UserService est utilisée à cet effet.

Nous allons créer notre propre implémentation de ce service, afin qu'après autorisation de l'utilisateur sur le serveur d'autorisation, nous vérifions également s'il est un utilisateur de notre application, ou l'enregistrons si l'inscription est ouverte à tous. Par défaut, Spring Security utilise l'implémentation de DefaultOAuth2UserService. Il constituera la base de notre mise en œuvre.

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


Point final de test


Il est temps de créer un point de terminaison pour tester la santé de ce que nous venons de faire. Notre point de terminaison consistera en une seule demande, qui accueillera l'utilisateur actuel.

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


Bilan de santé


Nous lançons l'application et nous nous rendons à l'adresse http: // localhost: 8080 et constatons que nous avons été redirigés vers le site Web de Bitbucket pour confirmer notre compte. Saisissez le nom d'utilisateur et le mot de passe.



Nous devons maintenant autoriser notre application à accéder à notre compte et à nos ressources.



Le message d'accueil de l'utilisateur contient à la fois des attributs du serveur d'autorisation et l'ID utilisateur dans notre application.



La source


Le code source complet de cette application est sur Github .

Références



All Articles