Spring Security - Ein Beispiel für eine OAuth2-Autorisierungs-Webanwendung über BitBucket

In diesem Artikel wird eine Möglichkeit zum Autorisieren von Benutzern in Webanwendungen in Spring Boot mithilfe des OAuth2-Protokolls mithilfe eines externen Autorisierungsservers (am Beispiel von Bitbucket) beschrieben.

Was wollen wir bekommen?


Angenommen, wir entwickeln eine sichere Webanwendung, die Zugriff auf Benutzerressourcen auf einem externen Server hat, z. B. einem kontinuierlichen Integrationssystem, und Bitbucket fungiert als externer Server, und wir möchten den Benutzernamen und das Kennwort des Benutzers nicht vom externen System speichern. Das heißt, wir müssen den Benutzer über Bitbucket autorisieren, um Zugriff auf sein Konto und seine Ressourcen zu erhalten. Überprüfen Sie außerdem, ob er Benutzer unserer Anwendung ist, und stellen Sie sicher, dass der Benutzer uns seine Anmeldeinformationen von Bitbucket nicht mitteilt. Ich frage mich, wie das geht - willkommen bei cat.

OAuth2


OAuth2 ist ein Autorisierungsprotokoll, mit dem Sie einem Dritten eingeschränkten Zugriff auf die geschützten Ressourcen eines Benutzers gewähren können, ohne ihm (einem Dritten) einen Benutzernamen und ein Kennwort geben zu müssen.

OAuth2 definiert 4 Rollen:

  • Ressourcenbesitzer
  • Ressourcenserver
  • Autorisierungsserver
  • Client (Anwendung)

Ein Ressourcenbesitzer ist ein Benutzer, der eine Clientanwendung verwendet und ihm den Zugriff auf sein auf einem Ressourcenserver gehostetes Konto ermöglicht. Der Anwendungszugriff auf das Konto wird durch erteilte Berechtigungen eingeschränkt.

Der Ressourcenserver hostet sichere Benutzerkonten.
Der Autorisierungsserver authentifiziert den Eigentümer der Ressource und stellt Zugriffstoken aus. Der Autorisierungsserver kann gleichzeitig ein Ressourcenserver sein.

Eine Clientanwendung ist eine Anwendung, die auf das Konto und die Ressourcen eines Benutzers zugreifen möchte.

In unserem Fall ist die Clientanwendung die Anwendung, die wir entwickeln, und Bitbucket ist sowohl ein Autorisierungsserver als auch ein Ressourcenserver.

OAuth2 unterstützt vier Arten von Autorisierungen: Autorisierungscode, Implizit, Kennwortanmeldeinformationen für Ressourceneigentümer und Clientanmeldeinformationen. Wir werden sie nicht alle berücksichtigen, wir sind an der Art des Autorisierungscodes interessiert. Der Autorisierungscodetyp ist für Serveranwendungen optimiert, bei denen der Quellcode nicht öffentlich verfügbar ist und der Client-Geheimcode vertraulich behandelt werden kann. Dieser Typ basiert auf der Umleitung, dh der Benutzer wird zum Autorisierungsserver umgeleitet, um seine Identität zu bestätigen und der Anwendung die Verwendung seines Kontos zu ermöglichen.

Der Autorisierungsprozess über den Autorisierungscode besteht aus einer Folge von zwei Anforderungen:

  • Autorisierungsanfrage
  • Zugriffstoken-Anforderung

Eine Autorisierungsanfrage wird verwendet, um die Identität des Benutzers zu bestätigen sowie die Autorisierung unserer Anwendung vom Benutzer anzufordern. Diese Anforderung ist eine GET-Anforderung mit den folgenden Parametern:

  • response_type - Der Wert muss gleich dem Code sein
  • client_id - Wert, der beim Registrieren eines Clients beim OAuth2-Anbieter erhalten wird
  • redirect_uri - URL, unter der der Benutzer nach der Autorisierung umgeleitet wird
  • scope - Ein optionaler Parameter, der angibt, welche Zugriffsebene angefordert wird
  • state - zufällig generierte Zeichenfolge zur Überprüfung der Antwort

Beispiel anfordern:

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

Wenn der Benutzer seine Identität bestätigt und der Anwendung Zugriff auf seine Ressourcen gewährt, wird der Benutzeragent mit dem zusätzlichen Parametercode, der den Autorisierungscode und den Statusparameter mit dem in der Anforderung übergebenen Wert enthält, an die bei der Registrierung des Clients angegebene Rückruf-URL umgeleitet.

Rückrufbeispiel:

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

Eine Zugangscode-Anforderung wird verwendet, um den empfangenen Autorisierungscode gegen einen Zugangscode gegen Benutzerressourcen auszutauschen. Diese Anforderung ist eine POST-Anforderung mit den folgenden Parametern:

  • grant_type - Wert muss Autorisierungscode sein
  • Code - Autorisierungscode, der im vorherigen Schritt erhalten wurde
  • redirect_uri - muss mit der im vorherigen Schritt angegebenen URL übereinstimmen
  • client_id - Wert, der beim Registrieren eines Clients beim OAuth2-Anbieter erhalten wird
  • client_secret - Wert, der beim Registrieren eines Clients beim OAuth2-Anbieter erhalten wird

Beispiel anfordern:

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

Die Serverantwort enthält den Zugangscode und seine Lebensdauer:

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

Dieser gesamte Prozess ist in Spring Security bereits automatisiert, und wir müssen uns nicht um seine Implementierung kümmern.

Kundenregistrierung


Zunächst registrieren wir unsere Anwendung als Client in Bitbucket, um einen Schlüssel (Key) und einen Zugangscode (Client Secret) zu erhalten.



Geben Sie den Kundennamen und die Rückruf-URL ein. Dann notieren wir, was diesem Kunden zur Verfügung steht.



Die erhaltenen Key- und ClientSecret-Werte werden in application.properties gespeichert:

client_id=ZJsdANfWkJ7jcktw2x
client_secret=28uUrJ9m43svbkcnXVNj8qeBjFtd8jaD

Konfigurieren Sie die Federsicherheit


Als nächstes richten wir Spring Security ein. Damit OAuth2 funktioniert, müssen Sie ein ClientRegistration-Objekt erstellen. ClientRegistration speichert Informationen über den beim OAuth2-Anbieter registrierten Client. Hier benötigen wir die im vorherigen Schritt erhaltenen client_id und client_secret. Da es im Allgemeinen mehrere solcher ClientRegistration-Objekte geben kann, verwendet Spring Security das ClientRegistrationRepository-Objekt, um sie zu speichern und darauf zuzugreifen. Lass es uns auch schaffen. Wir weisen auch darauf hin, dass nur ein autorisierter Benutzer eine Anforderung aufrufen und UserService mit seiner Implementierung neu definieren kann.

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


Anpassung UserService


Spring Security implementiert den Autorisierungsprozess nicht nur vollständig, sondern bietet auch die Möglichkeit, ihn anzupassen. Zum Beispiel die Möglichkeit, eine Autorisierungsanforderung anzupassen , einen Zugangscode anzufordern , sowie die Möglichkeit der benutzerdefinierten Nachbearbeitung einer Antwort auf eine Zugangscode-Anfrage . Nach erfolgreicher Autorisierung verwendet Spring Security UserInfo Endpoint , um Benutzerattribute vom Autorisierungsserver abzurufen. Hierfür wird insbesondere die OAuth2UserService-Schnittstellenimplementierung verwendet.

Wir werden eine eigene Implementierung dieses Dienstes erstellen, sodass wir nach der Benutzerautorisierung auf dem Autorisierungsserver zusätzlich prüfen, ob er ein Benutzer unserer Anwendung ist, oder ihn registrieren, wenn die Registrierung für alle offen ist. Standardmäßig verwendet Spring Security die Implementierung von DefaultOAuth2UserService. Er wird die Grundlage unserer Umsetzung bilden.

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


Endpunkt testen


Es ist Zeit, einen Endpunkt zu erstellen, um die Gesundheit dessen zu testen, was wir gerade getan haben. Unser Endpunkt besteht aus nur einer Anfrage, die den aktuellen Benutzer willkommen heißt.

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


Gesundheitskontrolle


Wir starten die Anwendung und gehen zur Adresse http: // localhost: 8080 und sehen, dass wir zur Bestätigung unseres Kontos auf die Bitbucket-Website weitergeleitet wurden. Geben Sie Benutzername und Passwort ein.



Jetzt müssen wir unserer Anwendung Zugriff auf unser Konto und unsere Ressourcen gewähren.



Die Begrüßung des Benutzers enthält sowohl Attribute vom Autorisierungsserver als auch die Benutzer-ID in unserer Anwendung.



Quelle


Der vollständige Quellcode für diese Anwendung befindet sich auf Github .

Verweise



All Articles