Spring Security OAuth2登錄

Spring Security OAuth2登錄

概述

OAuth 2.0 不是身份認證協(xié)議。

什么是身份認證?身份認證是解決“你是誰?”的問題。身份認證會告訴應用當前用戶是誰以及是否在使用此應用。實際中可能還會告訴你用戶的名稱,郵箱,手機號等。

如果對 OAuth 2.0 進行擴展,使得授權服務器和受保護資源發(fā)出的信息能夠傳達與用戶以及他們的身份認證上下文有關的信息,我們就可以為客戶端提供用于用戶安全登錄的所有信息。這種基于OAuth 2.0授權協(xié)議而構建的身份認證方式主要優(yōu)點:

  • 用戶在授權服務器上執(zhí)行身份認證, 最終用戶的原始憑據(jù)不會通過 OAuth 2.0 協(xié)議傳送到客戶端應用。
  • 允許用戶在運行時執(zhí)行同意決策。
  • 用戶還可以將其他受保護 API 與他的身份信息的訪問權限一起授權出去。通過一個調(diào)用,應用就可以知道用戶是否已登錄,如何稱呼用戶,用戶的手機號,郵箱等。

本文我們將通過OAuth 2.0 授權碼模式安全的傳遞授權服務用戶信息,并登錄到客戶端應用。

本文您將學到:

  • 搭建基本的授權服務和客戶端服務

  • 自定義授權服務器訪問令牌,添加角色信息

  • 自定義授權服務器用戶信息端點

  • 客戶端服務使用GrantedAuthoritiesMapper做權限映射

  • 客戶端服務自定義OAuth2UserService實現(xiàn)解析多層Json數(shù)據(jù)

OAuth2授權服務器

本節(jié)我們將使用Spring Authorization Server搭建一個授權服務器。除此之外我們還將會自定義access_token和自定義用戶信息端點。

maven

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
  <version>2.6.7</version>
</dependency>

<dependency>
  <groupId>org.springframework.security</groupId>
  <artifactId>spring-security-oauth2-authorization-server</artifactId>
  <version>0.3.1</version>
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
  <version>2.6.7</version>
</dependency>

配置

首先通過application.yml配置服務端口8080:

server:
  port: 8080


接下來我們將創(chuàng)建OAuth2ServerConfig配置類,定義OAuth2 授權服務所需特定Bean。首先我們注冊一個OAuth2客戶端:

@Bean
public RegisteredClientRepository registeredClientRepository() {
  RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
    .clientId("relive-client")
    .clientSecret("{noop}relive-client")
    .clientAuthenticationMethods(s -> {
      s.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);
      s.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
    })
    .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
    .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
    .redirectUri("http://127.0.0.1:8070/login/oauth2/code/messaging-client-authorization-code")
    .scope(OidcScopes.PROFILE)
    .clientSettings(ClientSettings.builder()
                    .requireAuthorizationConsent(true)
                    .requireProofKey(false)
                    .build())
    .tokenSettings(TokenSettings.builder()
                   .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) 
                   .idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)/
                   .accessTokenTimeToLive(Duration.ofSeconds(30 * 60))
                   .refreshTokenTimeToLive(Duration.ofSeconds(60 * 60))
                   .reuseRefreshTokens(true)
                   .build())
    .build();
  return new InMemoryRegisteredClientRepository(registeredClient);
}

以上將OAuth2客戶端存儲在內(nèi)存中,如果您需要使用數(shù)據(jù)庫持久化,請參考文章將JWT與Spring Security OAuth2結合使用。指定OAuth2客戶端信息如下:

接下來讓我們配置OAuth2授權服務其他默認配置,并對未認證的授權請求重定向到登錄頁面:

@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
  OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);

  return http
    .exceptionHandling(exceptions -> exceptions.
                       authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
    .build();
}


授權服務器token令牌格式使用JWT RFC 7519,所以我們需要用于令牌的簽名密鑰,讓我們生成一個RSA密鑰:

@Bean
public JWKSource<SecurityContext> jwkSource() {
  RSAKey rsaKey = Jwks.generateRsa();
  JWKSet jwkSet = new JWKSet(rsaKey);
  return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}

static class Jwks {

  private Jwks() {
  }

  public static RSAKey generateRsa() {
    KeyPair keyPair = KeyGeneratorUtils.generateRsaKey();
    RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
    RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
    return new RSAKey.Builder(publicKey)
      .privateKey(privateKey)
      .keyID(UUID.randomUUID().toString())
      .build();
  }
}

static class KeyGeneratorUtils {

  private KeyGeneratorUtils() {
  }

  static KeyPair generateRsaKey() {
    KeyPair keyPair;
    try {
      KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
      keyPairGenerator.initialize(2048);
      keyPair = keyPairGenerator.generateKeyPair();
    } catch (Exception ex) {
      throw new IllegalStateException(ex);
    }
    return keyPair;
  }
}


接下來我們將自定義access_token 訪問令牌,并在令牌中添加角色信息:

@Configuration(proxyBeanMethods = false)
public class AccessTokenCustomizerConfig {

    @Bean
    public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
        return (context) -> {
            if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
                context.getClaims().claims(claim -> {
                    claim.put("role", context.getPrincipal().getAuthorities().stream()
                            .map(GrantedAuthority::getAuthority).collect(Collectors.toSet()));
                });
            }
        };
    }
}

可以看到Spring Security為我們提供了OAuth2TokenCustomizer用于擴展令牌信息,我們從OAuth2TokenContext獲取到當前用戶信息,并從中提取Authorities權限信息添加到JWT的claim。


下面我們將創(chuàng)建Spring Security配置類,配置授權服務基本的認證能力。

@Configuration(proxyBeanMethods = false)
public class DefaultSecurityConfig {

    @Bean
    public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .antMatchers("/userInfo")
                .access("hasAnyAuthority('SCOPE_profile')")
                .mvcMatchers("/userInfo")
                .access("hasAuthority('SCOPE_profile')")
                .anyRequest().authenticated()
                .and()
                .formLogin(Customizer.withDefaults())
                .oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
        return http.build();
    }

    @Bean
    public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
        return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
    }

    @Bean
    public UserDetailsService users() {
        UserDetails user = User.withDefaultPasswordEncoder()
                .username("admin")
                .password("password")
                .roles("ADMIN")
                .build();
        return new InMemoryUserDetailsManager(user);
    }
}

在上述配置類中,我們做了以下幾件事。1.啟用Form認證方式;2.配置登錄用戶名密碼;3.使用oauth2ResourceServer()配置JWT驗證,并聲明JwtDecoder;4.保護/userInfo端點需要profile權限進行訪問。


此時我們還需要創(chuàng)建Controller類,用于提供給OAuth2客戶端服務獲取用戶信息:

@RestController
public class UserInfoController {

    @PostMapping("/userInfo")
    public Map<String, Object> getUserInfo(@AuthenticationPrincipal Jwt jwt) {
        return Collections.singletonMap("data", jwt.getClaims());
    }
}

我們將用戶信息使用以下JSON格式返回:

{
  "data":{
    "sub":"admin"
    ...
  }
}

OAuth2客戶端服務

本節(jié)將使用Spring Security配置OAuth2客戶端登錄;并且我們將使用GrantedAuthoritiesMapper映射權限信息;還將通過自定義實現(xiàn)OAuth2UserService替換原有DefaultOAuth2UserService,用于解析多層JSON 用戶信息數(shù)據(jù)。

maven

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-web</artifactId>
  <version>2.6.7</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-security</artifactId>
  <version>2.6.7</version>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-oauth2-client</artifactId>
  <version>2.6.7</version>  
</dependency>

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-thymeleaf</artifactId>
  <version>2.6.7</version>  
</dependency>

配置

首先我們指定客戶端服務端口號8070,并配置OAuth2客戶端相關信息:

server:
  port: 8070
  servlet:
    session:
      cookie:
        name: CLIENT-SESSION

spring:
  security:
    oauth2:
      client:
        registration:
          messaging-client-authorization-code:
            provider: client-provider
            client-id: relive-client
            client-secret: relive-client
            authorization-grant-type: authorization_code
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
            scope: profile
            client-name: messaging-client-authorization-code
        provider:
          client-provider:
            authorization-uri: http://127.0.0.1:8080/oauth2/authorize
            token-uri: http://127.0.0.1:8080/oauth2/token
            user-info-uri: http://127.0.0.1:8080/userInfo
            user-name-attribute: data.sub
            user-info-authentication-method: form


接下來配置Spring Security相關Bean,首先我們先啟用Form表單認證和OAuth2登錄能力:

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
  http.authorizeHttpRequests()
    .anyRequest()
    .authenticated()
    .and()
    .formLogin(from -> {
      from.defaultSuccessUrl("/home");
    })
    .oauth2Login(Customizer.withDefaults())
    .csrf().disable();
  return http.build();
}

這里我們指定認證成功后重定向到/home路徑下。


下面我們使用GrantedAuthoritiesMapper映射用戶權限:

@Bean
GrantedAuthoritiesMapper userAuthoritiesMapper() {
  //角色映射關系,授權服務器ADMIN角色對應客戶端OPERATION角色
  Map<String, String> roleMapping = new HashMap<>();
  roleMapping.put("ROLE_ADMIN", "ROLE_OPERATION");
  return (authorities) -> {
    Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
    authorities.forEach(authority -> {
      if (OAuth2UserAuthority.class.isInstance(authority)) {
        OAuth2UserAuthority oauth2UserAuthority = (OAuth2UserAuthority) authority;
        Map<String, Object> userAttributes = oauth2UserAuthority.getAttributes();
        List<String> role = (List) userAttributes.get("role");
        role.stream().map(roleMapping::get)
          .filter(StringUtils::hasText)
          .map(SimpleGrantedAuthority::new)
          .forEach(mappedAuthorities::add);
      }
    });
    return mappedAuthorities;
  };
}

上述將OAuth2授權服務ADMIN角色映射為客戶端角色OPERATION。當然你同樣可以擴展為數(shù)據(jù)庫操作,那么需要你維護授權服務角色與客戶端服務角色映射表,這里將不展開。

GrantedAuthoritiesMapper作為權限映射器在OAuth2登錄,CAS登錄,SAML和LDAP多方使用。

GrantedAuthoritiesMapperOAuth2LoginAuthenticationProvider中源碼如下:

@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    OAuth2LoginAuthenticationToken loginAuthenticationToken = (OAuth2LoginAuthenticationToken) authentication;
    //...省略部分源碼
  
    /* map authorities */
    Collection<? extends GrantedAuthority> mappedAuthorities = this.authoritiesMapper
      .mapAuthorities(oauth2User.getAuthorities());
    /* map authorities */
  
    OAuth2LoginAuthenticationToken authenticationResult = new OAuth2LoginAuthenticationToken(
      loginAuthenticationToken.getClientRegistration(), loginAuthenticationToken.getAuthorizationExchange(),
      oauth2User, mappedAuthorities, accessToken, authorizationCodeAuthenticationToken.getRefreshToken());
    authenticationResult.setDetails(loginAuthenticationToken.getDetails());
    return authenticationResult;
}

所以當我們自定義實現(xiàn)GrantedAuthoritiesMapper后,OAuth2 登錄成功后將映射后的權限信息存儲在認證信息Authentication的子類OAuth2LoginAuthenticationToken中,在后續(xù)流程中需要時獲取。


接下來將實現(xiàn)OAuth2UserService自定義DefaultJsonOAuth2UserService類。當然Spring Security提供了DefaultOAuth2UserService,那么為什么不使用它呢?原因很簡單,首先讓我們回顧授權服務器返回用戶信息格式:

{
  "data":{
    "sub":"admin"
    ...
  }
}

不錯,用戶信息嵌套data字段中,而DefaultOAuth2UserService處理用戶信息響應時并沒有處理這個格式,以下是DefaultOAuth2UserService源碼:

public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        Assert.notNull(userRequest, "userRequest cannot be null");
        if (!StringUtils.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
            OAuth2Error oauth2Error = new OAuth2Error("missing_user_info_uri", "Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), (String)null);
            throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
        } else {
            String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
            if (!StringUtils.hasText(userNameAttributeName)) {
                OAuth2Error oauth2Error = new OAuth2Error("missing_user_name_attribute", "Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: " + userRequest.getClientRegistration().getRegistrationId(), (String)null);
                throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
            } else {
                RequestEntity<?> request = (RequestEntity)this.requestEntityConverter.convert(userRequest);
               /* 獲取用戶信息 */  
              ResponseEntity<Map<String, Object>> response = this.getResponse(userRequest, request);
                //在這里直接獲取響應體信息,默認此userAttributes包含相關用戶信息,并沒有解析多層JSON
                Map<String, Object> userAttributes = (Map)response.getBody();
               /* 獲取用戶信息 */  
                Set<GrantedAuthority> authorities = new LinkedHashSet();
                authorities.add(new OAuth2UserAuthority(userAttributes));
                OAuth2AccessToken token = userRequest.getAccessToken();
                Iterator var8 = token.getScopes().iterator();

                while(var8.hasNext()) {
                    String authority = (String)var8.next();
                    authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
                }

                return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
            }
        }
    }

而最后創(chuàng)建DefaultOAuth2User時,你可能會收到以下錯誤信息

Missing attribute 'sub' in attributes

通過上面源碼,Spring Security 所希望返回的用戶信息格式:

{
  "sub":"admin",
  ...
}

但是實際中,我們開發(fā)時通常會統(tǒng)一返回響應格式。例如:

{
  "code":200,
  "message":"success",
  "data":{
    "sub":"admin",
    ...
  }
}


下面我們是我們通過以userNameAttributeName以 . 為分割符,提取用戶信息實現(xiàn),以下只展示部分代碼,其余代碼和DefaultOAuth2UserServicey源碼相同。

首先我們新建工具類JsonHelper用于解析Json

@Slf4j
public class JsonHelper {
    private static final JsonHelper.MapTypeReference MAP_TYPE = new JsonHelper.MapTypeReference();

    private static ObjectMapper mapper;

    private JsonHelper() {
    }

    static {
        mapper = new ObjectMapper();
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    }

    public static JsonNode getFirstNode(final JsonNode node, final String path) {
        JsonNode resultNode = null;
        if (path != null) {
            resultNode = getElement(node, path);
        }
        return resultNode;
    }

    public static JsonNode getElement(final JsonNode json, final String name) {
        if (json != null && name != null) {
            JsonNode node = json;
            for (String nodeName : name.split("\\.")) {
                if (node != null) {
                    if (nodeName.matches("\\d+")) {
                        node = node.get(Integer.parseInt(nodeName));
                    } else {
                        node = node.get(nodeName);
                    }
                }
            }
            if (node != null) {
                return node;
            }
        }
        return null;
    }


    public static Map<String, Object> parseMap(String json) {
        try {
            return mapper.readValue(json, MAP_TYPE);
        } catch (JsonProcessingException e) {
            log.error("Cannot convert json to map");
        }
        return null;
    }

    private static class MapTypeReference extends TypeReference<Map<String, Object>> {
        private MapTypeReference() {
        }
    }
}

新建DefaultJsonOAuth2UserService實現(xiàn)OAuth2UserService,添加多層JSON提取用戶信息邏輯:

public class DefaultJsonOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
  
    //...
  
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        //...省略部分代碼
        RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
        ResponseEntity<JsonNode> response = getResponse(userRequest, request);
        JsonNode responseBody = response.getBody();

        //多層JSON提取用戶信息屬性
        Map<String, Object> userAttributes = new HashMap<>();
        if (userNameAttributeName.contains(".")) {
          String firstNodePath = userNameAttributeName.substring(0, userNameAttributeName.lastIndexOf("."));
          userAttributes = this.extractUserAttribute(responseBody, firstNodePath);
          userNameAttributeName = userNameAttributeName.substring(firstNodePath.length() + 1);
        } else {
          userAttributes = JsonHelper.parseMap(responseBody.toString());
        }

        //...省略部分代碼
    }
}

如您需要參考詳細代碼,請查閱文末源碼鏈接獲取。


最后我們創(chuàng)建Controller類,使用thymeleaf引擎構建首頁信息,不同權限信息看到首頁列表結果不同:

@Controller
public class HomeController {

    private static Map<String, List<String>> articles = new HashMap<>();

    static {
        articles.put("ROLE_OPERATION", Arrays.asList("Java"));
        articles.put("ROLE_SYSTEM", Arrays.asList("Java", "Python", "C++"));
    }

    @GetMapping("/home")
    public String home(Authentication authentication, Model model) {
        String authority = authentication.getAuthorities().iterator().next().getAuthority();
        model.addAttribute("articles", articles.get(authority));
        return "home";
    }
}

測試

我們啟動服務后,訪問http://127.0.0.1:8070/login, 首先使用用戶名密碼登錄,您將會看到:

form-login-home.png

之后我們退出登錄使,用OAuth2 登錄,您將會看到不同信息:

oauth2-login-home.png

結論

我們使用OAuth2.0 授權協(xié)議上構建身份認證證明是可行的。但是我們不能忽略在這之間的陷阱。

  1. 令牌本身并不傳遞有關身份認證事件的信息。令牌可能是直接頒發(fā)給客戶端的,使用的是無須用戶交互的 OAuth 2.0 客戶端憑據(jù)模式。

  2. 客戶端都無法從訪問令牌中得到關于用戶及其登錄狀態(tài)的信息。OAuth 2.0 訪問令牌的目標受眾是資源服務器。(在本文中我們使用JWT訪問令牌,通過自定義訪問令牌信息使客戶端服務獲取用戶權限等信息,但是OAuth2.0 協(xié)議中并沒有定義訪問令牌格式,我們僅是使用了JWT的特性來做到這一點。)

  3. 客戶端可以出示訪問令牌給資源服務獲取用戶信息,所以很容易就認為只要擁有一個有效的訪問令牌,就能證明用戶已登錄,這一思路僅在某些情況下是正確的,即用戶在授權服務器上完成身份認證,剛生成訪問令牌的時候。(因為訪問令牌有效期可能遠長與身份認證會話有效期)

  4. 基于OAuth2.0的用戶信息API的最大問題是,不同身份提供者實現(xiàn)用戶信息API必然不同。用戶的唯一標識可能是“user_id",也可能是“sub”。

所以我們需要統(tǒng)一的OAuth2.0為基礎的標準身份認證協(xié)議。OpenID Connect 是一個開放標準,它定義了一種使用 OAuth 2.0 執(zhí)行用戶身份認證的互通方式。這將在后續(xù)文章中介紹它。

與往常一樣,本文中使用的源代碼可在 GitHub 上獲得。

?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容