暑假的時候在學習了 Spring Security 併成功運用到了專案中。 在實踐中摸索出了一套結合 json + jwt(json web token) + Spring Boot + Spring Security 技術的許可權方案趁著國慶假期記錄一下。
以下所有步驟的原始碼可以從我的 github 上取得。如果要了解,請閱讀 readme.md。
各個技術的簡要介紹
json : 與前端互動的資料交換格式
個人理解上,它的特點是可以促進 web 前後端解耦,提升團隊的工作效率。 同時也是跟安卓端和 iOS 端互動的工具,目前是沒想出除了 json 和 XML 之外的交流形式誒(或許等以後有空閒時間會看看)。
它的另一個特點是輕量級,簡潔和清晰的層次可以方便我們閱讀和編寫,並且減少伺服器頻寬佔用。
jwt (json web token)
用人話講就是將使用者的身份資訊(賬號名字)、其他資訊(不固定,根據需要增加)在使用者登陸時提取出來,並且通過加密手段加工成一串密文,在使用者登陸成功時帶在返回結果傳送給使用者。以後使用者每次請求時均帶上這串密文,伺服器根據解析這段密文判斷使用者是否有許可權訪問相關資源,並返回相應結果。
從網上摘錄了一些優點,關於 jwt 的更多資料感興趣的讀者可以自行谷歌:
- 相比於session,它無需儲存在伺服器,不佔用伺服器記憶體開銷。
- 無狀態、可擴充性強:比如有3臺機器(A、B、C)組成伺服器叢集,若session存在機器A上,session只能儲存在其中一臺伺服器,此時你便不能訪問機器B、C,因為B、C上沒有存放該Session,而使用token就能夠驗證使用者請求合法性,並且我再加幾臺機器也沒事,所以可擴充性好就是這個意思。
- 由 2 知,這樣做可就支援了跨域訪問。
Spring Boot
Spring Boot 是一個用來簡化 Spring 應用的搭建以及開發過程的框架。用完後會讓你大呼 : "wocao! 怎麼有這麼方便的東西! mama 再也不用擔心我不會配置 xml 配置檔案了!"。
Spring Security
這是 Spring Security 提供的一個安全許可權控制框架,可以根據使用者的需要定製相關的角色身份和身份所具有的許可權,完成黑名單操作、攔截無許可權的操作。配合 Spring Boot 可以快速開發出一套完善的許可權系統。
本次技術方案中 Spring Security 執行流程
從圖中可以看出本次執行流程圍繞著的就是 token。
使用者通過登陸操作獲得我們返回的 token 並儲存在本地。在以後每次請求都在請求頭中帶上 token ,伺服器在收到客戶端傳來的請求時會判斷是否有 token ,若有,解析 token 並寫入許可權到本次會話,若無直接跳過解析 token 的步驟,然後判斷本次訪問的介面是否需要認證,是否需要相應的許可權,並根據本次會話中的認證情況做出反應。
動手實現這個安全框架
步驟一 : 建立專案,配置好資料來源
- 使用 Itellij Idea 建立一個 Spring Boot 專案
選擇 Web 、Security 、 Mybatis 和 JDBC 四個元件。
- 在資料庫中建立所需的資料庫 spring_security
- 在 spring boot 配置檔案 application.properties 中配置好資料來源
- 啟動專案檢視 Spring Boot 是否替我們配置好 Spring Security 了。
若是正確啟動了,可以看到 Spring Security 生成了一段預設密碼。
我們訪問 localhost:8080
會彈出一個 basic 認證框
輸入 使用者名稱 user
密碼 前面自動生成的密碼
便可得到通過的返回訊息(返回 404,因為我們還未建立任何頁面)
輸入 錯誤的使用者名稱或者密碼會返回 401 ,提示未認證
如果你走到了這一步,意味著你已經配置好了所需要的環境,接下來就跟著進入下一步吧!
步驟二 : 生成我們的 jwt
在這一步我們將學習如何根據我們的需要生成我們定製的 token !
- 關閉 Spring Boot 替我們配置好的 Spring Security。(因為預設配置好的 Spring Security 會攔截掉我們定製的登陸介面)
建立 Spring Security 配置類 WebSecurityConfig.java
@Configuration // 宣告為配置類
@EnableWebSecurity // 啟用 Spring Security web 安全的功能
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.anyRequest().permitAll() // 允許所有請求通過
.and()
.csrf()
.disable() // 禁用 Spring Security 自帶的跨域處理
.sessionManagement() // 定製我們自己的 session 策略
.sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 調整為讓 Spring Security 不建立和使用 session
}
}複製程式碼
- 在資料庫中建立相應的使用者和角色。
建立使用者表 user
其中各個屬性和作用如下:
- username : 使用者名稱
- password : 密碼
- role_id : 使用者所屬角色編號
- last_password_change : 最後一次密碼修改時間
- enable : 是否啟用該賬號,可以用來做黑名單
建立角色表 role
其中各個屬性作用如下:
- role_id : 角色相應 id
- role_name : 角色的名稱
- auth : 角色所擁有的許可權
- 編寫相應的登陸密碼判斷邏輯
因為登陸功能很容易實現,這裡就不寫出來佔地方了哎。
- 編寫 token 操作類(生成 token 部分)
因為網上有造好的輪子,我們可以直接拿來做些修改就可以使用了。
使用 maven 匯入網上造好的 jwt 輪子
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.4</version>
</dependency>複製程式碼
建立我們自己的 token 操作類 TokenUtils.java
public class TokenUtils {
private final Logger logger = Logger.getLogger(this.getClass());
@Value("${token.secret}")
private String secret;
@Value("${token.expiration}")
private Long expiration;
/**
* 根據 TokenDetail 生成 Token
*
* @param tokenDetail
* @return
*/
public String generateToken(TokenDetail tokenDetail) {
Map<String, Object> claims = new HashMap<String, Object>();
claims.put("sub", tokenDetail.getUsername());
claims.put("created", this.generateCurrentDate());
return this.generateToken(claims);
}
/**
* 根據 claims 生成 Token
*
* @param claims
* @return
*/
private String generateToken(Map<String, Object> claims) {
try {
return Jwts.builder()
.setClaims(claims)
.setExpiration(this.generateExpirationDate())
.signWith(SignatureAlgorithm.HS512, this.secret.getBytes("UTF-8"))
.compact();
} catch (UnsupportedEncodingException ex) {
//didn't want to have this method throw the exception, would rather log it and sign the token like it was before
logger.warn(ex.getMessage());
return Jwts.builder()
.setClaims(claims)
.setExpiration(this.generateExpirationDate())
.signWith(SignatureAlgorithm.HS512, this.secret)
.compact();
}
}
/**
* token 過期時間
*
* @return
*/
private Date generateExpirationDate() {
return new Date(System.currentTimeMillis() + this.expiration * 1000);
}
/**
* 獲得當前時間
*
* @return
*/
private Date generateCurrentDate() {
return new Date(System.currentTimeMillis());
}
}複製程式碼
這個工具類的目前做的事情是 :
- 把使用者名稱封裝進下載的輪子的 token 的主體 claims 中,並在裡面封裝了當前時間(方便後面判斷 token 是否在修改密碼之前生成的)
- 再計算 token 過期的時間寫入到 輪子的 token 中
- 對 輪子的 token 進行撒鹽加密,生成一串字串,即我們定製的 token
生成定製 token 的方法的入參 TokenDetail
的定義如下
public interface TokenDetail {
//TODO: 這裡封裝了一層,不直接使用 username 做引數的原因是可以方便未來增加其他要封裝到 token 中的資訊
String getUsername();
}
public class TokenDetailImpl implements TokenDetail {
private final String username;
public TokenDetailImpl(String username) {
this.username = username;
}
@Override
public String getUsername() {
return this.username;
}
}複製程式碼
同時這個工具類把加密 token 撒鹽的字串和 token 的過期時間提取到了 application.properties 中
# token 加密金鑰
token.secret=secret
# token 過期時間,以秒為單位,604800 是 一星期
token.expiration=604800複製程式碼
- 至此,我們生成 token 的教程已經完成,至於登陸介面,判斷賬號密碼是否正確的操作就留給讀者去實現,讀者只需在登陸成功時在結果中返回生成好的 token 給使用者即可。
步驟三 : 實現驗證 token 是否有效,並根據 token 獲得賬號詳細資訊(許可權,是否處於封號狀態)的功能
- 分析實現的過程
在步驟二中,我們把使用者的的 username 、 token 建立的時間 、 token 過期的時間封裝到了加密過後的 token 字串中,就是為了服務此時我們驗證使用者許可權的目的。
假設我們此時拿到了使用者傳遞過來的一串 token,並且要根據這串 token 獲得使用者的詳情可以這樣做:
A. 嘗試解析這串 token ,若成功解析出來,進入下一步,否則終止解析過程
B. 根據解析出來的 username 從資料庫中查詢使用者的賬號,最後一次密碼修改的時間,許可權,是否封號等使用者詳情資訊,把這些資訊封裝到一個實體類中(userDetail類)。若查詢不到該使用者,終止解析程式
C. 檢查 userDetail 中記錄的封號狀態,若是賬號已被封號,返回封號結果,終止請求
D. 根據 userDtail 比較 token 是否處於有效期內,若不處於有效期內,終止解析過程,否則繼續
E. 將 userDetail 中記錄的使用者許可權寫入本次請求會話中,解析完成。
可參考下圖理解:
下面開始動手實現
- 嘗試解析 token 獲得 username
/**
* 從 token 中拿到 username
*
* @param token
* @return
*/
public String getUsernameFromToken(String token) {
String username;
try {
final Claims claims = this.getClaimsFromToken(token);
username = claims.getSubject();
} catch (Exception e) {
username = null;
}
return username;
}
/**
* 解析 token 的主體 Claims
*
* @param token
* @return
*/
private Claims getClaimsFromToken(String token) {
Claims claims;
try {
claims = Jwts.parser()
.setSigningKey(this.secret.getBytes("UTF-8"))
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
claims = null;
}
return claims;
}複製程式碼
在這段程式碼中,我們先對 token 進行解密,獲得 token 中封裝好的主體部分 claims (前面第二部引入的 別人造好的輪子),然後嘗試獲得裡面封裝的 username 字串。
- 從資料庫中獲得使用者詳情 userDetail
這裡我們將實現 Spring Security 的一個 UserDetailService 介面,這個介面只有一個方法, loadUserByUsername。流程圖如下
程式碼如下:
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
/**
* 獲取 userDetail
* @param username
* @return
* @throws UsernameNotFoundException
*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = this.userMapper.getUserFromDatabase(username);
if (user == null) {
throw new UsernameNotFoundException(String.format("No user found with username '%s'.", username));
} else {
return SecurityModelFactory.create(user);
}
}
}
public class User implements LoginDetail, TokenDetail {
private String username;
private String password;
private String authorities;
private Long lastPasswordChange;
private char enable;
// 省略構造器和 getter setter 方法
}
public class SecurityModelFactory {
public static UserDetailImpl create(User user) {
Collection<? extends GrantedAuthority> authorities;
try {
authorities = AuthorityUtils.commaSeparatedStringToAuthorityList(user.getAuthorities());
} catch (Exception e) {
authorities = null;
}
Date lastPasswordReset = new Date();
lastPasswordReset.setTime(user.getLastPasswordChange());
return new UserDetailImpl(
user.getUsername(),
user.getUsername(),
user.getPassword(),
lastPasswordReset,
authorities,
user.enable()
);
}
}複製程式碼
其中獲得未處理過的使用者詳細資訊 User 類的 mapper 類定義如下:
public interface UserMapper {
User getUserFromDatabase(@Param("username") String username);
}複製程式碼
相應的 xml 檔案為 :
<select id="getUserFromDatabase" resultMap="getUserFromDatabaseMap">
SELECT
`user`.username,
`user`.`password`,
`user`.role_id,
`user`.enable,
`user`.last_password_change,
`user`.enable,
role.auth
FROM
`user` ,
role
WHERE
`user`.role_id = role.role_id AND
`user`.username = #{username}
</select>
<resultMap id="getUserFromDatabaseMap" type="cn.ssd.wean2016.springsecurity.model.domain.User">
<id column="username" property="username"/>
<result column="password" property="password"/>
<result column="last_password_change" property="lastPasswordChange"/>
<result column="auth" property="authorities"/>
<result column="enable" property="enable"/>
</resultMap>複製程式碼
至此,我們已經完成獲取使用者詳細資訊的的功能了。接下來只要限制介面的訪問許可權,並要求使用者訪問介面時帶上 token 即可實現對許可權的控制。
步驟四 : 定義解析 token 的攔截器
老規矩,上流程圖:
下面定義這個攔截器
public class AuthenticationTokenFilter extends UsernamePasswordAuthenticationFilter {
/**
* json web token 在請求頭的名字
*/
@Value("${token.header}")
private String tokenHeader;
/**
* 輔助操作 token 的工具類
*/
@Autowired
private TokenUtils tokenUtils;
/**
* Spring Security 的核心操作服務類
* 在當前類中將使用 UserDetailsService 來獲取 userDetails 物件
*/
@Autowired
private UserDetailsService userDetailsService;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 將 ServletRequest 轉換為 HttpServletRequest 才能拿到請求頭中的 token
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 嘗試獲取請求頭的 token
String authToken = httpRequest.getHeader(this.tokenHeader);
// 嘗試拿 token 中的 username
// 若是沒有 token 或者拿 username 時出現異常,那麼 username 為 null
String username = this.tokenUtils.getUsernameFromToken(authToken);
// 如果上面解析 token 成功並且拿到了 username 並且本次會話的許可權還未被寫入
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
// 用 UserDetailsService 從資料庫中拿到使用者的 UserDetails 類
// UserDetails 類是 Spring Security 用於儲存使用者許可權的實體類
UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
// 檢查使用者帶來的 token 是否有效
// 包括 token 和 userDetails 中使用者名稱是否一樣, token 是否過期, token 生成時間是否在最後一次密碼修改時間之前
// 若是檢查通過
if (this.tokenUtils.validateToken(authToken, userDetails)) {
// 生成通過認證
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpRequest));
// 將許可權寫入本次會話
SecurityContextHolder.getContext().setAuthentication(authentication);
}
if (!userDetails.isEnabled()){
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
response.getWriter().print("{\"code\":\"452\",\"data\":\"\",\"message\":\"賬號處於黑名單\"}");
return;
}
}
chain.doFilter(request, response);
}
}複製程式碼
其中檢查 token 是否有效的 tokenUtils.validateToken(authToken, userDetails)
方法定義如下:
/**
* 檢查 token 是否處於有效期內
* @param token
* @param userDetails
* @return
*/
public Boolean validateToken(String token, UserDetails userDetails) {
UserDetailImpl user = (UserDetailImpl) userDetails;
final String username = this.getUsernameFromToken(token);
final Date created = this.getCreatedDateFromToken(token);
return (username.equals(user.getUsername()) && !(this.isTokenExpired(token)) && !(this.isCreatedBeforeLastPasswordReset(created, user.getLastPasswordReset())));
}
/**
* 獲得我們封裝在 token 中的 token 建立時間
* @param token
* @return
*/
public Date getCreatedDateFromToken(String token) {
Date created;
try {
final Claims claims = this.getClaimsFromToken(token);
created = new Date((Long) claims.get("created"));
} catch (Exception e) {
created = null;
}
return created;
}
/**
* 獲得我們封裝在 token 中的 token 過期時間
* @param token
* @return
*/
public Date getExpirationDateFromToken(String token) {
Date expiration;
try {
final Claims claims = this.getClaimsFromToken(token);
expiration = claims.getExpiration();
} catch (Exception e) {
expiration = null;
}
return expiration;
}
/**
* 檢查當前時間是否在封裝在 token 中的過期時間之後,若是,則判定為 token 過期
* @param token
* @return
*/
private Boolean isTokenExpired(String token) {
final Date expiration = this.getExpirationDateFromToken(token);
return expiration.before(this.generateCurrentDate());
}
/**
* 檢查 token 是否是在最後一次修改密碼之前建立的(賬號修改密碼之後之前生成的 token 即使沒過期也判斷為無效)
* @param created
* @param lastPasswordReset
* @return
*/
private Boolean isCreatedBeforeLastPasswordReset(Date created, Date lastPasswordReset) {
return (lastPasswordReset != null && created.before(lastPasswordReset));
}複製程式碼
步驟五 : 註冊步驟四的攔截器,使它在 Spring Security 讀取本次會話許可權前將使用者所具有的許可權寫入本次會話中
在 SpringSecurity 的配置類 WebSecurityConfig.java
中新增如下配置
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 註冊 token 轉換攔截器為 bean
* 如果客戶端傳來了 token ,那麼通過攔截器解析 token 賦予使用者許可權
*
* @return
* @throws Exception
*/
@Bean
public AuthenticationTokenFilter authenticationTokenFilterBean() throws Exception {
AuthenticationTokenFilter authenticationTokenFilter = new AuthenticationTokenFilter();
authenticationTokenFilter.setAuthenticationManager(authenticationManagerBean());
return authenticationTokenFilter;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/auth").authenticated() // 需攜帶有效 token
.antMatchers("/admin").hasAuthority("admin") // 需擁有 admin 這個許可權
.antMatchers("/ADMIN").hasRole("ADMIN") // 需擁有 ADMIN 這個身份
.anyRequest().permitAll() // 允許所有請求通過
.and()
.csrf()
.disable() // 禁用 Spring Security 自帶的跨域處理
.sessionManagement() // 定製我們自己的 session 策略
.sessionCreationPolicy(SessionCreationPolicy.STATELESS); // 調整為讓 Spring Security 不建立和使用 session
/**
* 本次 json web token 許可權控制的核心配置部分
* 在 Spring Security 開始判斷本次會話是否有許可權時的前一瞬間
* 通過新增過濾器將 token 解析,將使用者所有的許可權寫入本次 Spring Security 的會話
*/
http
.addFilterBefore(authenticationTokenFilterBean(), UsernamePasswordAuthenticationFilter.class);
}
}複製程式碼
其中我們將步驟四中定義的攔截器註冊到 Spring 中成為一個 bean ,並登記在 Spring Security 開始判斷本次會話是否有許可權時的前一瞬間通過新增過濾器將 token 解析,將使用者所有的許可權寫入本次會話。
其次,我們新增了三個 ant 風格的地址攔截規則 :
- /auth : 要求攜帶有效的 token
- /admin : 要求攜帶 token 所對應的賬號具有 admin 這個許可權
- /ADMIN : 要求攜帶 token 對應的張賬號具有 ROLE_ADMIN 這個身份
啟動程式到 8080 埠,通過 /login 介面登陸 guest 賬號,對 /auth
介面嘗試訪問,結果如下 :
顯然,因為 token 有效,所以成功通過了攔截
接下來嘗試訪問 /admin
介面,結果如下 :
顯然,因為攜帶的 token 不具有 admin 這個許可權,所以請求被攔截攔截
至此,我們已經完成了一套許可權簡單的許可權規則系統,在下一步中,我們將對無許可權訪問的返回結果進行優化,並結束這次總結。
步驟六 : 完善 401 和 403 返回結果
定義 401 處理器,實現 AuthenticationEntryPoint
介面
public class EntryPointUnauthorizedHandler implements AuthenticationEntryPoint {
/**
* 未登入或無許可權時觸發的操作
* 返回 {"code":401,"message":"小弟弟,你沒有攜帶 token 或者 token 無效!","data":""}
* @param httpServletRequest
* @param httpServletResponse
* @param e
* @throws IOException
* @throws ServletException
*/
@Override
public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
//返回json形式的錯誤資訊
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json");
httpServletResponse.getWriter().println("{\"code\":401,\"message\":\"小弟弟,你沒有攜帶 token 或者 token 無效!\",\"data\":\"\"}");
httpServletResponse.getWriter().flush();
}
}複製程式碼
定義 403 處理器,實現 AccessDeniedHandler
介面
@Component
public class MyAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AccessDeniedException e) throws IOException, ServletException {
//返回json形式的錯誤資訊
httpServletResponse.setCharacterEncoding("UTF-8");
httpServletResponse.setContentType("application/json");
httpServletResponse.getWriter().println("{\"code\":403,\"message\":\"小弟弟,你沒有許可權訪問呀!\",\"data\":\"\"}");
httpServletResponse.getWriter().flush();
}
}複製程式碼
將這兩個處理器配置到 SpringSecurity 的配置類中 :
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 註冊 401 處理器
*/
@Autowired
private EntryPointUnauthorizedHandler unauthorizedHandler;
/**
* 註冊 403 處理器
*/
@Autowired
private MyAccessDeniedHandler accessDeniedHandler;
...
@Override
protected void configure(HttpSecurity http) throws Exception {
http
...
// 配置被攔截時的處理
.exceptionHandling()
.authenticationEntryPoint(this.unauthorizedHandler) // 新增 token 無效或者沒有攜帶 token 時的處理
.accessDeniedHandler(this.accessDeniedHandler) //新增無許可權時的處理
...
}
}複製程式碼
嘗試以 guest 的身份訪問 /admin 介面,結果如下:
嘻嘻,顯然任務完成啦!!!(這個介面也可以用 lamda 表示式配置,這個留給大家去探索啦~~~)
溜了溜了……