Spring 系列 (8) - 在 Spring Boot 專案裡使用 Security 和 OAuth2 搭建授權伺服器(三)
在 “Spring 系列 (6) - 在 Spring Boot 專案裡使用 Security 和 OAuth2 搭建授權伺服器(一)” 裡的專案 SpringbootExample06 完成了一個基於記憶體驗證的授權伺服器。
本文將完全複製 SpringbootExample06 的程式碼和配置到新專案 SpringbootExample08,並在新專案 SpringbootExample08 的基礎上修改程式碼和配置,搭建一個與授權伺服器共存的資源伺服器。
1. 配置 Security & OAuth2 (基於記憶體驗證)
1) Security 配置
對應 src/main/java/com/example/config/WebSecurityConfig.java 檔案
1 package com.example.config; 2 3 import org.springframework.context.annotation.Bean; 4 import org.springframework.context.annotation.Configuration; 5 import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; 6 importorg.springframework.security.config.annotation.web.builders.HttpSecurity; 7 import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 8 import org.springframework.security.authentication.AuthenticationManager; 9 import org.springframework.security.oauth2.provider.token.TokenStore;10 import org.springframework.security.oauth2.provider.token.store.InMemoryTokenStore; 11 12 @Configuration 13 public class WebSecurityConfig extends WebSecurityConfigurerAdapter { 14 15 @Override 16 protected void configure(AuthenticationManagerBuilder auth) throws Exception { 17 18 auth.inMemoryAuthentication() 19 .withUser("admin").password("{noop}123456").roles("ADMIN") 20 .and() 21 .withUser("user").password("{noop}123456").roles("USER"); 22 } 23 24 @Override 25 protected void configure(HttpSecurity http) throws Exception { 26 // 配置認證 27 http.authorizeRequests() 28 .antMatchers("/error", "/lib/**", "/oauth/**").permitAll() 29 .anyRequest().authenticated() 30 .and() 31 .formLogin() 32 .and() 33 .csrf().disable(); // 關閉 csrf 保護功能,預設是開啟的 34 } 35 36 @Bean 37 public AuthenticationManager authenticationManagerBean() throws Exception { 38 return super.authenticationManagerBean(); 39 } 40 41 @Bean 42 public TokenStore tokenStoreBean() { 43 // token儲存在記憶體中(也可以儲存在資料庫、Redis中)。 44 // 如果儲存在中介軟體(資料庫、Redis),那麼資源伺服器與認證伺服器可以不在同一個工程中。 45 // 注意:如果不儲存 access_token,則沒法通過 access_token 取得使用者資訊 46 return new InMemoryTokenStore(); 47 } 48 }
2) Authorization Server 配置
對應 src/main/java/com/example/config/oauth2/AuthorizationServerConfig.java 檔案
1 package com.example.config.oauth2; 2 3 import org.springframework.context.annotation.Configuration; 4 import org.springframework.beans.factory.annotation.Autowired; 5 import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer; 6 import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter; 7 import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer; 8 import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer; 9 import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer; 10 import org.springframework.security.authentication.AuthenticationManager; 11 import org.springframework.security.oauth2.provider.token.TokenStore; 12 13 @Configuration 14 @EnableAuthorizationServer 15 public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter { 16 @Autowired 17 private AuthenticationManager authenticationManager; 18 @Autowired 19 private TokenStore tokenStore; 20 21 @Override 22 public void configure(AuthorizationServerSecurityConfigurer authServer) { 23 // 訪問許可權控制 24 authServer.tokenKeyAccess("permitAll()") 25 .checkTokenAccess("isAuthenticated()") 26 .allowFormAuthenticationForClients(); 27 } 28 29 @Override 30 public void configure(ClientDetailsServiceConfigurer clients) throws Exception { 31 32 // 使記憶體模式 33 clients.inMemory().withClient("1") 34 .secret("{noop}4eti4hAaux") 35 .authorizedGrantTypes("authorization_code", "refresh_token") 36 .scopes("All") 37 //.resourceIds("rids_1") 38 .autoApprove(true) 39 .redirectUris("/oauth/test/code/callback") 40 .and() 41 .withClient("2") 42 .secret("{noop}xGJoD2i2lj") 43 .authorizedGrantTypes("implicit") 44 .scopes("All") 45 //.resourceIds("rids_2") 46 .autoApprove(true) 47 .redirectUris("/oauth/test/implicit/callback") 48 .and() 49 .withClient("3") 50 .secret("{noop}2lo2ijxJ3e") 51 .authorizedGrantTypes("password", "client_credentials") 52 .scopes("All") 53 //.resourceIds("rids_3") 54 .autoApprove(true); 55 } 56 57 @Override 58 public void configure(AuthorizationServerEndpointsConfigurer endpoints) { 59 60 endpoints.authenticationManager(authenticationManager); 61 endpoints.tokenStore(tokenStore); 62 63 } 64 65 }
注:withClient() 設定的 client_id 和 resourceIds() 設定的 resource_id 之間有約束關係,即 client 訪問資源時,如果 Client 和 Resource Server 都設定了 resource Id,就會比對 resource Id。
3) Resource Server 配置
對應 src/main/java/com/example/config/oauth/ResourceServerConfig.java 檔案
1 package com.example.config.oauth2; 2 3 import org.springframework.beans.factory.annotation.Autowired; 4 import org.springframework.context.annotation.Configuration; 5 import org.springframework.security.config.annotation.web.builders.HttpSecurity; 6 import org.springframework.security.config.http.SessionCreationPolicy; 7 import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer; 8 import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter; 9 import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer; 10 import org.springframework.security.oauth2.provider.token.TokenStore; 11 12 @Configuration 13 @EnableResourceServer 14 public class ResourceServerConfig extends ResourceServerConfigurerAdapter { 15 16 @Autowired 17 private TokenStore tokenStore; 18 19 @Override 20 public void configure(ResourceServerSecurityConfigurer resources) throws Exception { 21 22 resources.tokenStore(tokenStore); 23 //resources.tokenStore(tokenStore).resourceId("rids_1"); 24 } 25 26 @Override 27 public void configure(HttpSecurity http) throws Exception { 28 /* 29 注意: 30 31 1. 必須先加上: .requestMatchers().antMatchers(...),表示對資源進行保護,也就是說,在訪問前要進行OAuth認證。 32 2. 接著:訪問受保護的資源時,要具有相關許可權。 33 34 否則,請求只是被 Security 的攔截器攔截,請求根本到不了 OAuth2 的攔截器。 35 36 requestMatchers() 部分說明: 37 38 mvcMatcher(String)}, requestMatchers(), antMatcher(String), regexMatcher(String), and requestMatcher(RequestMatcher). 39 */ 40 41 http 42 // Since we want the protected resources to be accessible in the UI as well we need 43 // session creation to be allowed (it's disabled by default in 2.0.6) 44 // 如果不設定 session,那麼通過瀏覽器訪問被保護的任何資源時,每次是不同的 SessionID,並且將每次請求的歷史都記錄在 OAuth2Authentication 的 details 中 45 .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) 46 .and() 47 .requestMatchers() 48 .antMatchers("/user","/private/**") 49 .and() 50 .authorizeRequests() 51 .antMatchers("/user","/private/**") 52 .authenticated(); 53 } 54 }
注:/private/** 對應 src/main/resources/static/private 目錄及其子目錄,/user 對應一個 JSON 介面。
2. 測試例項 (Web 模式)
1) 授權碼模式(Authorization Code)例項
(1) 建立 src/main/resources/templates/authorization_code.html 檔案
1 <html> 2 <head> 3 <meta charset="UTF-8"> 4 <title th:text="${var}">Title</title> 5 <script language="javascript" th:src="@{/lib/jquery/jquery-3.6.0.min.js}"></script> 6 </head> 7 <body> 8 9 <h4>OAuth 2.0 - Authorization Code</h4> 10 11 <p>Token URL: <br><input type="text" name="url" id="url" style="width: 50%;" th:value="@{/oauth/token}" value="" /></p> 12 <p>Client Id: <br><input type="text" name="client_id" id="client_id" style="width: 50%;" value="1" /></p> 13 <p>Client Secret: <br><input type="text" name="client_secret" id="client_secret" style="width: 50%;" value="4eti4hAaux" /></p> 14 <p>Code: <br><textarea name="code" id="code" rows="5" style="width: 50%;" readonly th:text="${code}"></textarea></p> 15 16 <p> 17 <button type="button" id="btn_get_token" th:unless="${code}==null">Get Token</button> 18 <button type="button" id="btn_refresh_code" th:if="${code}==null">Refresh Code</button> 19 <br><br><textarea name="result" id="result" style="width: 50%;" rows="8"></textarea> 20 </p> 21 22 <p id="user_area" style="display: none;"> 23 <button type="button" id="btn_get_user" class="btn btn-default">Get User Info</button> 24 <br><br><textarea name="user" id="user" class="form-control" style="width: 50%;" rows="5"></textarea> 25 </p> 26 27 <p> </p> 28 <script type="text/javascript"> 29 30 var tokenObj = null; 31 32 $(document).ready(function(){ 33 34 $('#btn_get_token').click(function() { 35 36 var url = $('#url').val(); 37 if (url == '') { 38 alert('Please enter url'); 39 $('#url').focus(); 40 return; 41 } 42 43 var client_id = $('#client_id').val(); 44 if (client_id == '') { 45 alert('Please enter client id'); 46 $('#client_id').focus(); 47 return; 48 } 49 50 var client_secret = $('#client_secret').val(); 51 if (client_secret == '') { 52 alert('Please enter client secret'); 53 $('#client_secret').focus(); 54 return; 55 } 56 57 var code = $('#code').val(); 58 if (code == '') { 59 alert('Please enter code'); 60 $('#code').focus(); 61 return; 62 } 63 64 $('#result').val('Sending post ...'); 65 66 $.ajax({ 67 68 type: 'POST', 69 url: url, 70 data: { 71 grant_type: 'authorization_code', 72 client_id: client_id, 73 client_secret: client_secret, 74 redirect_uri: '/oauth/test/code/callback', 75 code: code, 76 }, 77 success: function(response) { 78 79 console.log(response); 80 $('#result').val(JSON.stringify(response)); 81 82 tokenObj = response; 83 $('#user_area').css('display', ''); 84 }, 85 error: function() { 86 87 $('#result').val('Error: AJAX issue'); 88 89 tokenObj = null; 90 $('#user_area').css('display', 'none'); 91 } 92 }); 93 94 }); 95 96 $('#btn_get_user').click(function() { 97 98 if (tokenObj) { 99 100 $('#user').val('Get user info ...'); 101 102 $.ajax({ 103 type: 'GET', 104 url: "/user", 105 headers: { 'Authorization': 'Bearer ' + tokenObj.access_token }, 106 success: function(response) { 107 108 console.log(response); 109 $('#user').val(JSON.stringify(response)); 110 111 }, 112 error: function(err) { 113 114 console.log(err); 115 $('#user').val('Error: AJAX issue'); 116 117 } 118 }); 119 } else { 120 121 $('#user').val('Error: empty token object'); 122 } 123 124 }); 125 126 $('#btn_refresh_code').click(function() { 127 window.location.href = "/oauth/test/code"; 128 }); 129 130 }); 131 132 </script> 133 134 </body> 135 </html>
(2) 建立 src/main/resources/static/private/res.html 檔案
1 <html> 2 <head> 3 <meta charset="UTF-8"> 4 <title>Title</title> 5 </head> 6 <body> 7 8 <h4>OAuth 2.0 - Resource Server</h4> 9 <p>Resource Page (HTML)</p> 10 11 </body> 12 </html>
訪問 http://localhost:9090/private/res.html,會顯示如下內容:
This XML file does not appear to have any style information associated with it. The document tree is shown below.
<oauth>
<error_description>Full authentication is required to access this resource</error_description>
<error>unauthorized</error>
</oauth>
(3) 修改 src/main/java/com/example/controller/IndexController.java 檔案
1 package com.example.controller; 2 3 import java.security.Principal; 4 import org.springframework.stereotype.Controller; 5 import org.springframework.web.bind.annotation.RequestMapping; 6 import org.springframework.web.bind.annotation.ResponseBody; 7 8 @Controller 9 public class IndexController { 10 @ResponseBody 11 @RequestMapping("/test") 12 public String test() { 13 return "Test Page"; 14 } 15 16 @RequestMapping("/user") 17 @ResponseBody 18 public Principal getUser(Principal principal) { 19 // principal 被 security 攔截後,是 org.springframework.security.authentication.UsernamePasswordAuthenticationToken 20 // 被 OAuth2 攔截後,是 OAuth2Authentication 21 return principal; 22 } 23 }
(4) 建立 src/main/java/com/example/controller/OauthController.java 檔案
1 package com.example.controller; 2 3 import org.springframework.stereotype.Controller; 4 import org.springframework.web.bind.annotation.RequestMapping; 5 import org.springframework.web.bind.annotation.RequestParam; 6 import org.springframework.ui.Model; 7 8 @Controller 9 @RequestMapping("/oauth") 10 public class OauthController { 11 12 @RequestMapping("/test/code") 13 public String testCode() { 14 return "redirect:/oauth/authorize?client_id=1&redirect_uri=/oauth/test/code/callback&response_type=code"; 15 } 16 17 @RequestMapping("/test/code/callback") 18 public String testCodeCallback(@RequestParam String code, Model model) { 19 model.addAttribute("code", code); 20 return "authorization_code"; 21 } 22 }
執行並訪問 http://localhost:9090/oauth/test/code,自動跳轉到 http://localhost:9090/login (Spring security 的預設頁面)。
輸入上文 WebSecurityConfig 裡配置的使用者名稱和密碼登入,登入後跳轉到 http://localhost:9090/test/code/callback,點選 Get Token 按鈕,會在按鈕下方顯示如下資料和 Get User Info 按鈕:
{
"access_token": "0cea651f-4d01-43c0-9421-bc70d4eca081",
"token_type": "bearer",
"refresh_token": "a3418c82-5f80-4ee8-a1dd-9bdbfce52bdc",
"expires_in": 43199,
"scope": "All"
}
點選 Get User Info 按鈕,會在按鈕下方顯示如下資料:
{"authorities":[],"details":{"remoteAddress":"127.0.0.1","sessionId":"6E9C151500E869B04A5395E52A563288","tokenValue":"20bbe84b-0c70-4978-b6b2-24248ca2fb61","tokenType":"Bearer","decodedDetails":null},"authenticated":true,"userAuthentication":null,"oauth2Request":{"clientId":"3","scope":["All"],"requestParameters":{"grant_type":"client_credentials","client_id":"3"},"resourceIds":[],"authorities":[],"approved":true,"refresh":false,"redirectUri":null,"responseTypes":[],"extensions":{},"grantType":"client_credentials","refreshTokenRequest":null},"principal":"3","credentials":"","clientOnly":true,"name":"3"}
訪問 http://localhost:9090/private/res.html?access_token=0cea651f-4d01-43c0-9421-bc70d4eca081
OAuth 2.0 - Resource Server
Resource Page (HTML)
連結上的 access_token 也可以放到 HTTP header 裡(使用 Postman 或 AJAX 測試),格式如下:
Authorization:bearer 0cea651f-4d01-43c0-9421-bc70d4eca081
2) 簡化模式(Implicit)例項
略
3) 密碼模式(Resource Owner Password Credentials)例項
略
4) 客戶端模式(Client Credentials)例項
略
注:以上省略的例項程式碼,請參考 “Spring 系列 (6) - 在 Spring Boot 專案裡使用 Security 和 OAuth2 搭建授權伺服器(一)” 裡的 “6. 測試例項 (Web 模式) ” 和本文的授權碼模式(Authorization Code)例項。