作者 freewolf
原創文章轉載請標明出處
關鍵詞
Spring Boot
、OAuth 2.0
、JWT
、Spring Security
、SSO
、UAA
寫在前面
最近安靜下來,重新學習一些東西,最近一年幾乎沒寫過程式碼。整天疲於奔命的日子終於結束了。坐下來,弄杯咖啡,思考一些問題,挺好。這幾天有人問我Spring Boot結合Spring Security實現OAuth認證的問題,寫了個Demo,順便分享下。Spring 2之後就沒再用過Java,主要是xml太麻煩,就投入了Node.js的懷抱,現在Java倒是好過之前很多,無論是執行效率還是其他什麼。感謝Pivotal團隊在Spring boot上的努力,感謝Josh Long,一個有意思的攻城獅。
我又搞Java也是為了去折騰微服務,因為目前看國內就Java程式猿最好找,雖然水平好的難找,但是至少能找到,不像其他程式語言,找個會世界上最好的程式語言PHP的人真的不易。
Spring Boot
有了Spring Boot這樣的神器,可以很簡單的使用強大的Spring框架。你需要關心的事兒只是建立應用,不必再配置了,“Just run!”,這可是Josh Long
每次演講必說的,他的另一句必須說的就是“make jar not war”
,這意味著,不用太關心是Tomcat還是Jetty或者Undertow了。專心解決邏輯問題,這當然是個好事兒,部署簡單了很多。
建立Spring Boot應用
有很多方法去建立Spring Boot專案,官方也推薦用:
start.spring.io
可以方便選擇你要用的元件,命令列工具當然也可以。目前Spring Boot已經到了1.53,我是懶得去更新依賴,繼續用1.52版本。雖然阿里也有了中央庫的國內版本不知道是否穩定。如果你感興趣,可以自己嘗試下。你可以選Maven或者Gradle成為你專案的構建工具,Gradle優雅一些,使用了Groovy語言進行描述。
開啟start.spring.io
,建立的專案只需要一個Dependency,也就是Web,然後下載專案,用IntellJ IDEA
開啟。我的Java版本是1.8。
這裡看下整個專案的pom.xml
檔案中的依賴部分:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>複製程式碼
所有Spring Boot相關的依賴都是以starter形式出現,這樣你無需關心版本和相關的依賴,所以這樣大大簡化了開發過程。
當你在pom檔案中整合了spring-boot-maven-plugin外掛後你可以使用Maven相關的命令來run你的應用。例如mvn spring-boot:run
,這樣會啟動一個嵌入式的Tomcat,並執行在8080埠,直接訪問你當然會獲得一個Whitelabel Error Page
,這說明Tomcat已經啟動了。
建立一個Web 應用
這還是一篇關於Web安全的文章,但是也得先有個簡單的HTTP請求響應。我們先弄一個可以返回JSON的Controller。修改程式的入口檔案:
@SpringBootApplication
@RestController
@EnableAutoConfiguration
public class DemoApplication {
// main函式,Spring Boot程式入口
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
// 根目錄對映 Get訪問方式 直接返回一個字串
@RequestMapping("/")
Map<String, String> hello() {
// 返回map會變成JSON key value方式
Map<String,String> map=new HashMap<String,String>();
map.put("content", "hello freewolf~");
return map;
}
}複製程式碼
這裡我儘量的寫清楚,讓不瞭解Spring Security的人通過這個例子可以瞭解這個東西,很多人都覺得它很複雜,而投向了Apache Shiro
,其實這個並不難懂。知道主要的處理流程,和這個流程中哪些類都起了哪些作用就好了。
Spring Boot
對於開發人員最大的好處在於可以對Spring
應用進行自動配置。Spring Boot
會根據應用中宣告的第三方依賴來自動配置Spring
框架,而不需要進行顯式的宣告。Spring Boot
推薦採用基於Java
註解的配置方式,而不是傳統的XML
。只需要在主配置 Java 類上新增@EnableAutoConfiguration
註解就可以啟用自動配置。Spring Boot
的自動配置功能是沒有侵入性的,只是作為一種基本的預設實現。
這個入口類我們新增@RestController
和@EnableAutoConfiguration
兩個註解。@RestController
註解相當於@ResponseBody
和@Controller
合在一起的作用。
run整個專案。訪問http://localhost:8080/
就能看到這個JSON的輸出。使用Chrome瀏覽器可以裝JSON Formatter這個外掛,顯示更PL
一些。
{
"content": "hello freewolf~"
}複製程式碼
為了顯示統一的JSON返回,這裡建立一個JSONResult類進行,簡單的處理。首先修改pom.xml,加入org.json
相關依賴。
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
</dependency>複製程式碼
然後在我們的程式碼中加入一個新的類,裡面只有一個結果集處理方法,因為只是個Demo,所有這裡都放在一個檔案中。這個類只是讓返回的JSON結果變為三部分:
- status - 返回狀態碼 0 代表正常返回,其他都是錯誤
- message - 一般顯示錯誤資訊
- result - 結果集
class JSONResult{
public static String fillResultString(Integer status, String message, Object result){
JSONObject jsonObject = new JSONObject(){{
put("status", status);
put("message", message);
put("result", result);
}};
return jsonObject.toString();
}
}複製程式碼
然後我們引入一個新的@RestController
並返回一些簡單的結果,後面我們將對這些內容進行訪問控制,這裡用到了上面的結果集處理類。這裡多放兩個方法,後面我們來測試許可權和角色的驗證用。
@RestController
class UserController {
// 路由對映到/users
@RequestMapping(value = "/users", produces="application/json;charset=UTF-8")
public String usersList() {
ArrayList<String> users = new ArrayList<String>(){{
add("freewolf");
add("tom");
add("jerry");
}};
return JSONResult.fillResultString(0, "", users);
}
@RequestMapping(value = "/hello", produces="application/json;charset=UTF-8")
public String hello() {
ArrayList<String> users = new ArrayList<String>(){{ add("hello"); }};
return JSONResult.fillResultString(0, "", users);
}
@RequestMapping(value = "/world", produces="application/json;charset=UTF-8")
public String world() {
ArrayList<String> users = new ArrayList<String>(){{ add("world"); }};
return JSONResult.fillResultString(0, "", users);
}
}複製程式碼
重新run這個檔案,訪問http://localhost:8080/users
就看到了下面的結果:
{
"result": [
"freewolf",
"tom",
"jerry"
],
"message": "",
"status": 0
}複製程式碼
如果你細心,你會發現這裡的JSON返回時,Chrome的格式化外掛好像並沒有識別?這是為什麼呢?我們藉助curl
分別看一下我們寫的兩個方法的Header
資訊.
curl -I http://127.0.0.1:8080/
curl -I http://127.0.0.1:8080/users複製程式碼
可以看到第一個方法hello
,由於返回值是Map
Content-Type: application/json;charset=UTF-8複製程式碼
第二個方法usersList
由於返回時String,由於是@RestControler
已經含有了@ResponseBody
也就是直接返回內容,並不模板。所以就是:
Content-Type: text/plain;charset=UTF-8複製程式碼
那怎麼才能讓它變成JSON呢,其實也很簡單隻需要補充一下相關注解:
@RequestMapping(value = "/users", produces="application/json;charset=UTF-8")複製程式碼
這樣就好了。
使用JWT保護你的Spring Boot應用
終於我們開始介紹正題,這裡我們會對/users
進行訪問控制,先通過申請一個JWT(JSON Web Token讀jot)
,然後通過這個訪問/users,才能拿到資料。
關於JWT
,出門奔向以下內容,這些不在本文討論範圍內:
JWT
很大程度上還是個新技術,通過使用HMAC(Hash-based Message Authentication Code)
計算資訊摘要,也可以用RSA公私鑰中的私鑰進行簽名。這個根據業務場景進行選擇。
新增Spring Security
根據上文我們說過我們要對/users
進行訪問控制,讓使用者在/login
進行登入並獲得Token
。這裡我們需要將spring-boot-starter-security
加入pom.xml
。加入後,我們的Spring Boot
專案將需要提供身份驗證,相關的pom.xml
如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.7.0</version>
</dependency>複製程式碼
至此我們之前所有的路由都需要身份驗證。我們將引入一個安全設定類WebSecurityConfig
,這個類需要從WebSecurityConfigurerAdapter
類繼承。
@Configuration
@EnableWebSecurity
class WebSecurityConfig extends WebSecurityConfigurerAdapter {
// 設定 HTTP 驗證規則
@Override
protected void configure(HttpSecurity http) throws Exception {
// 關閉csrf驗證
http.csrf().disable()
// 對請求進行認證
.authorizeRequests()
// 所有 / 的所有請求 都放行
.antMatchers("/").permitAll()
// 所有 /login 的POST請求 都放行
.antMatchers(HttpMethod.POST, "/login").permitAll()
// 許可權檢查
.antMatchers("/hello").hasAuthority("AUTH_WRITE")
// 角色檢查
.antMatchers("/world").hasRole("ADMIN")
// 所有請求需要身份認證
.anyRequest().authenticated()
.and()
// 新增一個過濾器 所有訪問 /login 的請求交給 JWTLoginFilter 來處理 這個類處理所有的JWT相關內容
.addFilterBefore(new JWTLoginFilter("/login", authenticationManager()),
UsernamePasswordAuthenticationFilter.class)
// 新增一個過濾器驗證其他請求的Token是否合法
.addFilterBefore(new JWTAuthenticationFilter(),
UsernamePasswordAuthenticationFilter.class);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// 使用自定義身份驗證元件
auth.authenticationProvider(new CustomAuthenticationProvider());
}
}複製程式碼
先放兩個基本類,一個負責儲存使用者名稱密碼,另一個是一個許可權型別,負責儲存許可權和角色。
class AccountCredentials {
private String username;
private String password;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
class GrantedAuthorityImpl implements GrantedAuthority{
private String authority;
public GrantedAuthorityImpl(String authority) {
this.authority = authority;
}
public void setAuthority(String authority) {
this.authority = authority;
}
@Override
public String getAuthority() {
return this.authority;
}
}複製程式碼
在上面的安全設定類中,我們設定所有人都能訪問/
和POST
方式訪問/login
,其他的任何路由都需要進行認證。然後將所有訪問/login
的請求,都交給JWTLoginFilter
過濾器來處理。稍後我們會建立這個過濾器和其他這裡需要的JWTAuthenticationFilter
和CustomAuthenticationProvider
兩個類。
先建立一個JWT生成,和驗籤的類
class TokenAuthenticationService {
static final long EXPIRATIONTIME = 432_000_000; // 5天
static final String SECRET = "P@ssw02d"; // JWT密碼
static final String TOKEN_PREFIX = "Bearer"; // Token字首
static final String HEADER_STRING = "Authorization";// 存放Token的Header Key
// JWT生成方法
static void addAuthentication(HttpServletResponse response, String username) {
// 生成JWT
String JWT = Jwts.builder()
// 儲存許可權(角色)
.claim("authorities", "ROLE_ADMIN,AUTH_WRITE")
// 使用者名稱寫入標題
.setSubject(username)
// 有效期設定
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATIONTIME))
// 簽名設定
.signWith(SignatureAlgorithm.HS512, SECRET)
.compact();
// 將 JWT 寫入 body
try {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_OK);
response.getOutputStream().println(JSONResult.fillResultString(0, "", JWT));
} catch (IOException e) {
e.printStackTrace();
}
}
// JWT驗證方法
static Authentication getAuthentication(HttpServletRequest request) {
// 從Header中拿到token
String token = request.getHeader(HEADER_STRING);
if (token != null) {
// 解析 Token
Claims claims = Jwts.parser()
// 驗籤
.setSigningKey(SECRET)
// 去掉 Bearer
.parseClaimsJws(token.replace(TOKEN_PREFIX, ""))
.getBody();
// 拿使用者名稱
String user = claims.getSubject();
// 得到 許可權(角色)
List<GrantedAuthority> authorities = AuthorityUtils.commaSeparatedStringToAuthorityList((String) claims.get("authorities"));
// 返回驗證令牌
return user != null ?
new UsernamePasswordAuthenticationToken(user, null, authorities) :
null;
}
return null;
}
}複製程式碼
這個類就兩個static
方法,一個負責生成JWT,一個負責認證JWT最後生成驗證令牌。註釋已經寫得很清楚了,這裡不多說了。
下面來看自定義驗證元件,這裡簡單寫了,這個類就是提供密碼驗證功能,在實際使用時換成自己相應的驗證邏輯,從資料庫中取出、比對、賦予使用者相應許可權。
// 自定義身份認證驗證元件
class CustomAuthenticationProvider implements AuthenticationProvider {
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
// 獲取認證的使用者名稱 & 密碼
String name = authentication.getName();
String password = authentication.getCredentials().toString();
// 認證邏輯
if (name.equals("admin") && password.equals("123456")) {
// 這裡設定許可權和角色
ArrayList<GrantedAuthority> authorities = new ArrayList<>();
authorities.add( new GrantedAuthorityImpl("ROLE_ADMIN") );
authorities.add( new GrantedAuthorityImpl("AUTH_WRITE") );
// 生成令牌
Authentication auth = new UsernamePasswordAuthenticationToken(name, password, authorities);
return auth;
}else {
throw new BadCredentialsException("密碼錯誤~");
}
}
// 是否可以提供輸入型別的認證服務
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}複製程式碼
下面實現JWTLoginFilter
這個Filter比較簡單,除了建構函式需要重寫三個方法。
- attemptAuthentication - 登入時需要驗證時候呼叫
- successfulAuthentication - 驗證成功後呼叫
- unsuccessfulAuthentication - 驗證失敗後呼叫,這裡直接灌入500錯誤返回,由於同一JSON返回,HTTP就都返回200了
class JWTLoginFilter extends AbstractAuthenticationProcessingFilter {
public JWTLoginFilter(String url, AuthenticationManager authManager) {
super(new AntPathRequestMatcher(url));
setAuthenticationManager(authManager);
}
@Override
public Authentication attemptAuthentication(
HttpServletRequest req, HttpServletResponse res)
throws AuthenticationException, IOException, ServletException {
// JSON反序列化成 AccountCredentials
AccountCredentials creds = new ObjectMapper().readValue(req.getInputStream(), AccountCredentials.class);
// 返回一個驗證令牌
return getAuthenticationManager().authenticate(
new UsernamePasswordAuthenticationToken(
creds.getUsername(),
creds.getPassword()
)
);
}
@Override
protected void successfulAuthentication(
HttpServletRequest req,
HttpServletResponse res, FilterChain chain,
Authentication auth) throws IOException, ServletException {
TokenAuthenticationService.addAuthentication(res, auth.getName());
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_OK);
response.getOutputStream().println(JSONResult.fillResultString(500, "Internal Server Error!!!", JSONObject.NULL));
}
}複製程式碼
再完成最後一個類JWTAuthenticationFilter
,這也是個攔截器,它攔截所有需要JWT
的請求,然後呼叫TokenAuthenticationService
類的靜態方法去做JWT
驗證。
class JWTAuthenticationFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain filterChain)
throws IOException, ServletException {
Authentication authentication = TokenAuthenticationService
.getAuthentication((HttpServletRequest)request);
SecurityContextHolder.getContext()
.setAuthentication(authentication);
filterChain.doFilter(request,response);
}
}複製程式碼
現在程式碼就寫完了,整個Spring Security
結合JWT
基本就差不多了,下面我們來測試下,並說下整體流程。
開始測試,先執行整個專案,這裡介紹下過程:
- 先程式啟動 - main函式
- 註冊驗證元件 -
WebSecurityConfig
類configure(AuthenticationManagerBuilder auth)
方法,這裡我們註冊了自定義驗證元件 - 設定驗證規則 -
WebSecurityConfig
類configure(HttpSecurity http)
方法,這裡設定了各種路由訪問規則 - 初始化過濾元件 -
JWTLoginFilter
和JWTAuthenticationFilter
類會初始化
首先測試獲取Token,這裡使用CURL命令列工具來測試。
curl -H "Content-Type: application/json" -X POST -d '{"username":"admin","password":"123456"}' http://127.0.0.1:8080/login複製程式碼
結果:
{
"result": "eyJhbGciOiJIUzUxMiJ9.eyJhdXRob3JpdGllcyI6IlJPTEVfQURNSU4sQVVUSF9XUklURSIsInN1YiI6ImFkbWluIiwiZXhwIjoxNDkzNzgyMjQwfQ.HNfV1CU2CdAnBTH682C5-KOfr2P71xr9PYLaLpDVhOw8KWWSJ0lBo0BCq4LoNwsK_Y3-W3avgbJb0jW9FNYDRQ",
"message": "",
"status": 0
}複製程式碼
這裡我們得到了相關的JWT
,反Base64之後,就是下面的內容,標準JWT
。
{"alg":"HS512"}{"authorities":"ROLE_ADMIN,AUTH_WRITE","sub":"admin","exp":1493782240}ͽ]BS`pS6~hCVH%
ܬ)֝ଖoE5р複製程式碼
整個過程如下:
- 拿到傳入JSON,解析使用者名稱密碼 -
JWTLoginFilter
類attemptAuthentication
方法 - 自定義身份認證驗證元件,進行身份認證 -
CustomAuthenticationProvider
類authenticate
方法 - 鹽城成功 -
JWTLoginFilter
類successfulAuthentication
方法 - 生成JWT -
TokenAuthenticationService
類addAuthentication
方法
再測試一個訪問資源的:
curl -H "Content-Type: application/json" -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJhdXRob3JpdGllcyI6IlJPTEVfQURNSU4sQVVUSF9XUklURSIsInN1YiI6ImFkbWluIiwiZXhwIjoxNDkzNzgyMjQwfQ.HNfV1CU2CdAnBTH682C5-KOfr2P71xr9PYLaLpDVhOw8KWWSJ0lBo0BCq4LoNwsK_Y3-W3avgbJb0jW9FNYDRQ" http://127.0.0.1:8080/users複製程式碼
結果:
{
"result":["freewolf","tom","jerry"],
"message":"",
"status":0
}複製程式碼
說明我們的Token生效可以正常訪問。其他的結果您可以自己去測試。再回到處理流程:
- 接到請求進行攔截 -
JWTAuthenticationFilter
中的方法 - 驗證JWT -
TokenAuthenticationService
類getAuthentication
方法 - 訪問Controller
這樣本文的主要流程就結束了,本文主要介紹了,如何用Spring Security
結合JWT
保護你的Spring Boot
應用。如何使用Role
和Authority
,這裡多說一句其實在Spring Security
中,對於GrantedAuthority
介面實現類來說是不區分是Role
還是Authority
,二者區別就是如果是hasAuthority
判斷,就是判斷整個字串,判斷hasRole
時,系統自動加上ROLE_
到判斷的Role
字串上,也就是說hasRole("CREATE")
和hasAuthority('ROLE_CREATE')
是相同的。利用這些可以搭建完整的RBAC
體系。本文到此,你已經會用了本文介紹的知識點。
程式碼整理後我會上傳到Github