springboot2.0整合OAuth2並使用JWT作為token。

光滑的禿頭發表於2020-12-02

之前實現了Springboot之Security前後端分離登入 剛好這段時間有空,乘機整合下OAuth2。記錄下當中遇到的問題和處理方式。

什麼是OAuth2?

OAuth 2.0 的一個簡單解釋

具體程式碼實現

POM檔案

        <dependency>
            <groupId>org.springframework.security.oauth.boot</groupId>
            <artifactId>spring-security-oauth2-autoconfigure</artifactId>
            <version>2.1.3.RELEASE</version>
        </dependency>

授權伺服器

@Configuration
@EnableAuthorizationServer
public class OAuth2ServerConfiguration extends AuthorizationServerConfigurerAdapter {

    private static final String CLIENT_ID = "client";  //客戶端
    private static final String CLIENT_SECRET = "123456";   //secret客戶端安全碼
    private static final String GRANT_TYPE_PASSWORD = "password";   // 密碼模式授權模式
    private static final String AUTHORIZATION_CODE = "authorization_code"; //授權碼模式  授權碼模式使用到了回撥地址,是最為複雜的方式,通常網站中經常出現的微博,qq第三方登入,都會採用這個形式。
    private static final String REFRESH_TOKEN = "refresh_token";  //
    private static final String IMPLICIT = "implicit"; //簡化授權模式
    private static final String GRANT_TYPE = "client_credentials";  //客戶端模式
    private static final String SCOPE_WEB = "web";   //授權範圍  web端
    private static final String SCOPE_IOS = "ios";   //授權範圍  ios端
    private static final String SCOPE_ANDROID = "android";
    private static final String SCOPE_BOOT = "boot"; //授權範圍  專案名稱
    private static final int ACCESS_TOKEN_VALIDITY_SECONDS = 30*24*60*60;       //token 有效時間 一個月
    private static final int REFRESH_TOKEN_VALIDITY_SECONDS = 30*24*60*60;      //重新整理token有效時間 一個月
    /**
     * 描述:注入密碼加密編碼器 進行密碼加密
     */
    @Autowired
    BCryptPasswordEncoder passwordEncoder;
    /**
     * 描述:注入使用者資訊處理類 處理使用者賬號資訊
     */
    @Autowired
    UserDetailsServiceImpl userDetailService;
    /**
     * 描述:注入token生成器  處理token的生成方式
     */
    @Autowired
    TokenStore tokenStore;
    /**
     * 描述: 注入AuthenticationManager管理器
     */
    @Autowired
    AuthenticationManager authenticationManager;
    /**
     * 描述: 注入jwtAccessTokenConverter 增強token
     */
    @Autowired
    JwtAccessTokenConverter jwtAccessTokenConverter;


    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        String secret = new BCryptPasswordEncoder().encode(CLIENT_SECRET);  // 用 BCrypt 對密碼編碼
        //配置客戶端資訊
        clients.inMemory()  // 使用in-memory儲存
                .withClient(CLIENT_ID)    //client_id用來標識客戶的Id
                .authorizedGrantTypes(AUTHORIZATION_CODE,GRANT_TYPE, REFRESH_TOKEN,GRANT_TYPE_PASSWORD,IMPLICIT)  //允許授權型別
                .scopes(SCOPE_WEB,SCOPE_IOS,SCOPE_ANDROID,SCOPE_BOOT)  //允許授權範圍
                .authorities("ROLE_CLIENT")  //客戶端可以使用的許可權
                .secret(secret)  //secret客戶端安全碼
                .autoApprove(true) // 為true 則不會被重定向到授權的頁面,也不需要手動給請求授權,直接自動授權成功返回code
                .accessTokenValiditySeconds(ACCESS_TOKEN_VALIDITY_SECONDS)   //token 時間秒
                .refreshTokenValiditySeconds(REFRESH_TOKEN_VALIDITY_SECONDS);//重新整理token 時間 秒
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security
                // 允許表單登入
                .allowFormAuthenticationForClients()
                // 密碼加密編碼器
                .passwordEncoder(passwordEncoder)
                // 允許所有的checkToken請求
                .checkTokenAccess("permitAll()");
    }
    /**
     * 配置令牌
     * @param endpoints
     * @throws Exception
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        endpoints.tokenStore(tokenStore)
                // 認證管理器 - 在密碼模式必須配置
                .authenticationManager(authenticationManager)
                // 自定義校驗使用者service
                .userDetailsService(userDetailService)
                // 是否能重複使用 refresh_token
                .reuseRefreshTokens(false);
        // 設定令牌增強 JWT 轉換
        TokenEnhancerChain enhancer = new TokenEnhancerChain();
        enhancer.setTokenEnhancers(Arrays.asList(jwtAccessTokenConverter));
        endpoints.tokenEnhancer(enhancer);
    }
}

資源伺服器

@Configuration
@EnableResourceServer
public class OAuth2ResourceConfiguration extends ResourceServerConfigurerAdapter {
   
    @Autowired
    TokenStore tokenStore;
    @Override
    public void configure(HttpSecurity http) throws Exception {
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED).and().csrf().disable();

        // 配置不登入可以訪問 - 放行路徑配置
        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = http
                .authorizeRequests();
 
        registry.antMatchers("/login","/oauth/**").permitAll();
        registry.anyRequest().authenticated();
    }

}

token處理

@Configuration
public class TokenConfig {
    /** JWT金鑰 */
    private String signingKey = "fastboot";

    /**
     * JWT 令牌轉換器
     * @return
     */
    @Bean("jwtAccessTokenConverter")
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter jwt = new JwtAccessTokenConverter(){
            /**
             * 使用者資訊JWT加密
             */
            @Override
            protected String encode(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
                DefaultOAuth2AccessToken token = (DefaultOAuth2AccessToken) accessToken;

                UserInfo user = (UserInfo) authentication.getUserAuthentication().getPrincipal();
                Set<String> tokenScope = token.getScope();
                String scopeTemp = " ";
                if(tokenScope!=null&&tokenScope.size()>0){
                    scopeTemp=tokenScope.iterator().next();
                }
                String scope =scopeTemp;
                //將額外的引數資訊存入,用於生成token
                Map<String, Object> data = new HashMap<String, Object>(4){{
                    put("userId", user.getUserId());
                    put("username", user.getUsername());
                    put("email", user.getEmail());
                    put("roleDtos",user.getRoleDtos());
                    put("nickName", user.getNickName());
                    put("authorities", user.getAuthorities());
                    put("scope",scope);
                }};
                //自定義TOKEN包含的資訊
                token.setAdditionalInformation(data);
                return super.encode(accessToken, authentication);
            }

            /**
             * 使用者資訊JWT
             */
            @Override
            protected Map<String, Object> decode(String token) {
                //解析請求當中的token  可以在解析後的map當中獲取到上面加密的資料資訊
                Map<String, Object> decode = super.decode(token);
                Long userId = (Long)decode.get("userId");
                String username = (String)decode.get("username");
                String email = (String)decode.get("email");
                String nickName = (String)decode.get("nickName");
                String scope = (String)decode.get("scope");
                List<GrantedAuthority> grantedAuthorityList=new ArrayList<>();
                //注意這裡獲取到的許可權 雖然資料庫存的許可權是 "sys:menu:add"  但是這裡就變成了"{authority=sys:menu:add}" 所以使用@PreAuthorize("hasAuthority('{authority=sys:menu:add}')")
                List<LinkedHashMap<String,String>> authorities =(List<LinkedHashMap<String,String>>) decode.get("authorities");
                for (LinkedHashMap<String, String> authority : authorities) {
                    SimpleGrantedAuthority grantedAuthority = new SimpleGrantedAuthority(authority.getOrDefault("authority", "N/A"));
                    grantedAuthorityList.add(grantedAuthority);
                }
                UserInfo userInfo =new UserInfo(username,"N/A",userId, grantedAuthorityList);
                userInfo.setNickName(nickName);
                userInfo.setEmail(email);
                //需要將解析出來的使用者存入全域性當中,不然無法轉換成自定義的user類
                UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userInfo,null, grantedAuthorityList);
                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                decode.put("user_name",userInfo);
                return decode;
            }
        };

        jwt.setSigningKey(signingKey);
        return jwt;
    }


    /**
     * 配置 token 如何生成
     * 1. InMemoryTokenStore 基於記憶體儲存
     * 2. JdbcTokenStore 基於資料庫儲存
     * 3. JwtTokenStore 使用 JWT 儲存 該方式可以讓資源伺服器自己校驗令牌的有效性而不必遠端連線認證伺服器再進行認證
     */
    @Bean
    public TokenStore tokenStore() {
        return new JwtTokenStore(jwtAccessTokenConverter());
    }

    public String getSigningKey() {
        return signingKey;
    }

    public void setSigningKey(String signingKey) {
        this.signingKey = signingKey;
    }
}

controller介面

@RestController
@RequestMapping("/oauth")
public class OauthController {
    @Autowired
    TokenEndpoint tokenEndpoint;

    @PostMapping(value = "/token")
    public ResultInfo<OAuth2AccessToken> token(Principal principal, @RequestParam Map<String, String> parameters) throws Exception {
        ResponseEntity<OAuth2AccessToken> accessToken = tokenEndpoint.postAccessToken(principal, parameters);
        OAuth2AccessToken token = accessToken.getBody();
        // TODO 可以考慮將返回的TOKEN資訊存入redis或者資料庫
        return ResultInfo.success(token);
    }
    @PostMapping("/t1")
    @PreAuthorize("hasAuthority('{authority=sys:menu:add}')")
    public String getDemo(String name){
        if(SecurityContextHolder.getContext() == null) {
            return null;
        }
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        UserInfo userInfo = (UserInfo) authentication.getPrincipal();

//        return ResultInfo.success(userInfo);
        return userInfo.toString();
    }
}

上面基本上就是完整的程式碼了。其他的類如:UserDetailsServiceImpl,UserInfo 略!!!

遇到的問題

1 token過期時間設定 可以在OAuth2ServerConfiguration 當中設定accessTokenValiditySeconds(秒) 也可以在TokenConfig 裡面進行jwt加密的時候進行設定,token.setExpiration(); 設定後會覆蓋OAuth2ServerConfiguration 當中的。

2 許可權不匹配問題 雖然資料庫存的許可權是 “sys:menu:add” 但是oauth2取的時候變成了"{authority=sys:menu:add}" 所以使用介面上使用@PreAuthorize(“hasAuthority(’{authority=sys:menu:add}’)”)進行許可權匹配。

3 serurity的User類無法轉換為自定義的user子類的問題,需要在 JWT解密的時候,重新構建然後存入全域性當中。(PS:無法在獲取token當中獲取到自定義的user子類)

相關文章