SpringBoot 集成 JWT 和 Apache Shiro
任何后端管理系統軟件都避免不了登錄驗證和權限管理,大多數系統一開始就要設計登錄認證以及權限管理模塊。掌握登錄認證以及權限管理的模塊設計已經成為基礎知識,本篇文章將采用 JWT 與 Apache Shiro 來講解前后端分離中常用的認證與權限管理。
Shiro 簡介
Apache Shiro是一個功能強大且易于使用的Java安全框架,可以執(zhí)行身份驗證、授權、加密和會話管理。
Shiro的體系結構有三個主要概念 Subject、SecurityManager 和 Realms。
Subject
在保護應用程序安全時,最關鍵的問題是:“當前用戶是誰?是否允許當前用戶執(zhí)行X?”?。只有辨識用戶后才能判斷用戶權限。Subject 表示“當前正在執(zhí)行的主體”,該主體可以是另一個系統的調用,也可以是用戶登錄。
獲取當前登錄主體
import org.apache.shiro.subject.Subject;
import org.apache.shiro.SecurityUtils;
...
Subject currentUser = SecurityUtils.getSubject();SecurityManager
Subject 代表用戶的操作,SecurityManager 管理用戶的操作。它是 Shiro 體系結構的核心,其內部有許多安全組件。每個應用程序一般只有一個 SecurityManager 實例,SecurityManager 的部分參數可以使用 .ini 文件進行配置。
使用 shiro.ini
[users]
jane.admin = password, admin
john.editor = password2, editor
zoe.author = password3, author
paul.reader = password4
[roles]
admin = /
editor = /articles
author = /articles/draftsini 配置文件的 [users] 部分定義了 SecurityManager 能夠識別的用戶憑據。格式為: principal (username) = password, role1, role2...。
[roles] 部分聲明角色及其關聯的權限。管理員角色被授予對應用程序的每個部分的權限和訪問權。
使用 IniRealm 來從 shiro.ini 文件加載用戶和角色定義,然后使用它來配置DefaultSecurityManager 對象
IniRealm iniRealm = new IniRealm("classpath:shiro.ini");
SecurityManager securityManager = new DefaultSecurityManager(iniRealm);
SecurityUtils.setSecurityManager(securityManager);
Subject currentUser = SecurityUtils.getSubject();Realms
當需要獲取用戶帳戶數據執(zhí)行身份驗證(登錄)和授權(訪問控制)時,Shiro會從為應用程序配置的領域 Realm 中查找用戶帳戶數據內容。
常用的方式是在 Realm 實現對象中調用 DAO 方法獲取用戶賬戶信息。
public class MyCustomRealm extends AuthorizingRealm {
/**
* 認證
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
String username = token.getUsername();
String password = this.shiroSampleDao.getPasswordByUsername(username);
return new SimpleAuthenticationInfo(username, password, getName());
}
/**
* 授權
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
String username = (String) super.getAvailablePrincipal(principalCollection);
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
Set roles = shiroSampleDao.getRolesByUsername(username);
authorizationInfo.setRoles(roles);
roles.forEach(role -> {
Set permissions = this.shiroSampleDao.getPermissionsByRole(role);
authorizationInfo.addStringPermissions(permissions);
});
return authorizationInfo;
}
} 我們已經了解了 Shiro 的基本環(huán)境,如果想要定制化開發(fā),我們還需要了解兩個重要的概念,Authentication 和 Authorization。
Authentication 認證
身份驗證是驗證用戶身份的過程。當用戶通過應用程序進行身份驗證時,他們在證明自己是他們所說的身份。有時也驗證稱為“登錄”,通常分為三步:
收集用戶的標識信息(稱為身份 principals)和支持身份證明的憑證 (也叫憑據 credentials)。
將身份 principals 和憑據 credentials 提交到系統。
如果提交的憑據 credentials ?與系統對于該用戶身份(principal)的期望匹配,則認為該用戶已通過身份驗證 authentication 。如果它們不匹配,則不認為用戶已通過身份驗證 authentication 。
每個人都熟悉的此過程的一個常見示例是 username/password ?組合。當大多數用戶登錄軟件應用程序時,通常會提供其 username (身份信息 principal)和 password (憑據 credential)。如果存儲在系統中的密碼與用戶指定的密碼匹配,則認為它們已通過身份驗證。
你想通過 Shiro 做的所有事情都可以通過與調用 Subject 的 API 來實現。要實現登錄,可以調用 Subject 的 login 方法,并傳遞一個 AuthenticationToken 實例,該實例代表提交的身份信息和憑據(在本例中為用戶名和密碼)。
Subject Login
//1. Acquire submitted principals and credentials:
AuthenticationToken token = new UsernamePasswordToken(username, password);
//2. Get the current Subject:
Subject currentUser = SecurityUtils.getSubject();
//3. Login:
currentUser.login(token);處理登錄失敗,您可以選擇捕獲各種異常并對異常進行各種處理
//3. Login:
try {
currentUser.login(token);
} catch (IncorrectCredentialsException ice) { …
} catch (LockedAccountException lae) { …
}
…
catch (AuthenticationException ae) {…
}確定允許用戶執(zhí)行的操作稱為授權
Authorization 授權
授權實質上是訪問控制,控制用戶可以在應用程序中訪問的內容(例如資源,網頁等)。大多數用戶通過使用角色和權限等概念來執(zhí)行訪問控制。Subject API 使您可以非常輕松地執(zhí)行角色和權限檢查。
檢查 Subject 是否被分配了特定角色
if ( subject.hasRole(“administrator”) ) {
//show the ‘Create User’ button
} else {
//grey-out the button?
}檢查 Subject 是否被分配了特定權限
if ( subject.isPermitted(“user:create”) ) {
//show the ‘Create User’ button
} else {
//grey-out the button?
}如上,任何角色或用戶被賦予 “user:create”? 權限就可以點擊“創(chuàng)建用戶”按鈕。
Shiro 還支持更細力度的實例級權限檢查
if ( subject.isPermitted(“user:delete:jsmith”) ) {
//delete the ‘jsmith’ user
} else {
//don’t delete ‘jsmith’
}因為 JWT 是無狀態(tài)的,所以本篇就不講解 SessionManager 內容。接下來我們就來了解一下 JWT。
RESTful API 認證方式
一般來說,RESTful API 通過身份驗證和授權來保證 API 的安全性。
認證(Authentication) vs 授權(Authorization)
認證是指用戶的身份認證,授權是確認用戶擁有的操作權限。
認證(Authentication)的方式
Basic Authentication 這意味著將用戶名和密碼直接放入HTTP請求標頭中。這是最簡單的方法,但不建議這樣做。
TOKEN Authentication 這是將JWT令牌直接放入HTTP請求標頭中的最常用方法。這是推薦的方法。
OAuth2.0 這是最安全的方法,也是最復雜的方法。如果沒有必要,不要考慮這種方式。
通常,我們只在項目中使用JWT進行身份驗證。
什么是 JWT
JSON Web Token (JWT)是基于 JSON 的開放標準(RFC 7519),用于創(chuàng)建聲明的訪問令牌,這些令牌 token 包含一系列聲明 claims。例如,服務器可以生成令牌,該令牌聲明持有者具有“以管理員身份登錄”的權利,該令牌會提供給客戶端。然后,客戶端可以使用該令牌來證明它以管理員身份登錄。
令牌一般是由服務器方的私鑰進行簽名,另一方已經通過可靠方式如 Post 請求獲取了相應的公鑰,雙方能夠驗證令牌是否合法。令牌被設計為緊湊,URL 安全的,并且特別是在 Web 瀏覽器單點登錄(SSO)上下文中可用。
JWT 聲明(claims)通??捎糜谠谏矸萏峁┱吆头仗峁┱咧g傳遞經過身份驗證的用戶的身份,官方網站:https?://jwt.io/ ? 。 JWT 由三部分組成,所有這些部分共同形成了 JWS 字符串,如下所示:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOixMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydhVgNF3FkTHFOFJWT 的三部分
Header 頭部
Payload 負載
Signature 簽名
Header 頭
頭部有兩部分
聲明類型 Type?:通常是 JWT
聲明加密算法 algorithm?:典型的加密算法有HMAC和SHA-256 (HS256), RSA簽名和SHA-256 (RS256)。JWA (JSON Web算法) RFC 7518 引入了更多關于身份驗證和加密的內容。
Header 通常如下:
{
'typ': 'JWT',
'alg': 'HS256'
}然后加密 Header ,如下:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9Payload 負載
Payload 包括三個部分
Registered Claim 注冊聲明
Public Claim 公共聲明
Private Claim 私有聲明
Registered Claim
| CODE | NAME | DESCRIPTION |
| iss | Issuer | 標識發(fā)行 JWT 的簽發(fā)者 |
| sub | Subject | 標識 JWT 的主題 |
| aud | Audience | 標識 JWT 接收人。 |
| exp | Expiration Time | 標識過期時間。過期時間之后的 JWT 不會進行處理。 |
| nbf | Not Before | 確定開始接受處理 JWT 的時間 |
| iat | Issued at | 標識發(fā)布JWT的時間。NumericDate 類型 |
| jti | JWT ID | 區(qū)分大小寫的令牌的唯一標識符,即使在不同發(fā)行方之間也是如此。 |
Public Claim
一般來說,公開聲明可以包含任何信息,但不建議在這里添加敏感信息,因為這些信息很容易被解密。
Private Claim
私有聲明是客戶端和服務器端的聲明。敏感信息不建議在這里聲稱。
一個典型的 PayLoad 如下
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}進行 Base64 加密后我們得到了 JWT 的第二段
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9Signature 簽名
Signature 部分對前兩部分簽名,防止數據篡改。
Signature? 包含三部分
header?(加密后的)
payload?(加密后的)
secret
JWT 的第三部分是之前的幾部分的組合
加密后的 header + 加密后的 payload + secret這部分加密后就得到了 JWT 串
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ注意 secret 保存在服務端,不應該被泄露。
在應用程序中使用 JWT
通常,我們將這些 JWT 信息添加到 HTTP 請求頭中
fetch('api/user/1', {
headers: {
'Authorization': 'Bearer ' + token
}
})服務器負責分析用于身份驗證和授權的 HTTP 頭
安全問題
JWT 協議本身沒有安全傳輸功能,所以他必須依賴安全信道 SSL/TLS,所以推薦方式如下
敏感信息不能存儲在 jwt 的 payload 部分,因為這部分客戶端可以解密。
一定要保存好私鑰,不外泄
如果可以的話使用 HTTPS 協議
與 SpringBoot 集成
我們想要實現完全的前后端分離,所以不可能使用 session, cookie 方式進行認證,我們使用 JWT。
程序邏輯
發(fā)送 Post 請求到 /login ,如果成功返回 Token 如果失敗返回 UnauthorizedException 異常。
?用戶訪問每個需要權限的 URL 請求時,必須在頭部添加授權字段,如 Authorization: Token。
后端將會對請求進行驗證,否則返回 401
我們將使用的安全框架:
Apache Shiro
java-jwt
首先添加 Maven 依賴
org.springframework.boot
spring-boot-starter
org.apache.shiro
shiro-spring-boot-web-starter
1.7.1
com.auth0
java-jwt
3.4.1
org.projectlombok
lombok
true
構建模擬數據源
為了專注于如何使用 JWT 而不是 DB,我偽造了一個如下所示的數據源
| username | password | role | permission |
| -------- | -------- | ----- | ---------- |
| smith | smith123 | user | view |
| danny | danny123 | admin | view,edit |通常權限管理模塊 RBAC 需要 5 張數據表,分別是用戶表、角色表、權限表、用戶_角色表、角色_權限表,此處直接偽造了一個關聯查詢后獲取的記錄。
接下來,構建一個 UserService 來模擬數據庫查詢,并將結果放入 UserBean 中。
UserService.java
@Component
public class UserService {
public UserBean getUser(String username) {
// If no such user return null
if (! DataSource.getData().containsKey(username))
return null;
UserBean user = new UserBean();
Map detail = DataSource.getData().get(username);
user.setUsername(username);
user.setPassword(detail.get("password"));
user.setRole(detail.get("role"));
user.setPermission(detail.get("permission"));
return user;
}
} UserBean.java
@Data
public class UserBean {
private String username;
private String password;
private String role;
private String permission;
}配置 JWT
我們構建了一個簡單的 JWT 加密工具,并將用戶密碼作為加密密碼,從而確保令牌即使被竊取也無法破解。另外,我們把用戶名放在令牌里,設置5分鐘后令牌過期。
public class JWTUtil {
// 5 分鐘后過期
private static final long EXPIRE_TIME = 5*60*1000;
/**
* Verify TOKEN
* @param token
* @param secret User password
* @return
*/
public static boolean verify(String token, String username, String secret) {
try {
Algorithm algorithm = Algorithm.HMAC256(secret);
JWTVerifier verifier = JWT.require(algorithm)
.withClaim("username", username)
.build();
DecodedJWT jwt = verifier.verify(token);
return true;
} catch (Exception exception) {
return false;
}
}
/**
* Get username from TOKEN
* @return token contains username information
*/
public static String getUsername(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
return null;
}
}
/**
* 生成 signature, signature 5 分鐘后過期
* @param username
* @param secret
* @return Encryted token
*/
public static String sign(String username, String secret) {
Date date = new Date(System.currentTimeMillis() + EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret);
return JWT.create().withClaim("username", username).withExpiresAt(date).sign(algorithm);
}
}ResponseBean.java
@Data
@AllArgsConstructor
public class ResponseBean {
private int code;
private String msg;
private Object data;
}
自定義異常
public class UnauthorizedException extends RuntimeException {
public UnauthorizedException(String msg) {
super(msg);
}
public UnauthorizedException() {
super();
}
}URL
| URL | FUNCTION |
| /login | 登錄 |
| /article | 每個人都可以訪問,但是不同的角色會得到不同的內容 |
| /require_auth | 登錄用戶可訪問 |
| /require_role | 管理員角色可以訪問 |
| /require_permission | ?查看和編輯角色可以訪問 |
Controller
@RestController
public class WebController {
@Autowired
private UserService userService;
@PostMapping("/login")
public ResponseBean login(@RequestParam("username") String username,
@RequestParam("password") String password) {
UserBean userBean = userService.getUser(username);
if (userBean.getPassword().equals(password)) {
return new ResponseBean(200, "Login success", JWTUtil.sign(username, password));
} else {
throw new UnauthorizedException();
}
}
@GetMapping("/article")
public ResponseBean article() {
Subject subject = SecurityUtils.getSubject();
if (subject.isAuthenticated()) {
return new ResponseBean(200, "You are already logged in", null);
} else {
return new ResponseBean(200, "You are guest", null);
}
}
@GetMapping("/require_auth")
@RequiresAuthentication
public ResponseBean requireAuth() {
return new ResponseBean(200, "You are authenticated", null);
}
@GetMapping("/require_role")
@RequiresRoles("admin")
public ResponseBean requireRole() {
return new ResponseBean(200, "You are visiting require_role", null);
}
@GetMapping("/require_permission")
@RequiresPermissions(logical = Logical.AND, value = {"view", "edit"})
public ResponseBean requirePermission() {
return new ResponseBean(200, "You are visiting permission require edit,view", null);
}
@RequestMapping(path = "/401")
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ResponseBean unauthorized() {
return new ResponseBean(401, "Unauthorized", null);
}
}
異常處理
正如前面提到的,RESTFUL 需要統一的樣式,所以我們需要處理 Spring Boot 異常。
我們可以使用 @RestControllerAdvice 來處理異常
@RestControllerAdvice
public class ExceptionController {
// Catch Shiro Exception
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(ShiroException.class)
public ResponseBean handle401(ShiroException e) {
return new ResponseBean(401, e.getMessage(), null);
}
// Catch UnauthorizedException
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(UnauthorizedException.class)
public ResponseBean handle401() {
return new ResponseBean(401, "Unauthorized", null);
}
// Catch Other Exception
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseBean globalException(HttpServletRequest request, Throwable ex) {
return new ResponseBean(getStatus(request).value(), ex.getMessage(), null);
}
private HttpStatus getStatus(HttpServletRequest request) {
Integer statusCode = (Integer) request.getAttribute("javax.servlet.error.status_code");
if (statusCode == null) {
return HttpStatus.INTERNAL_SERVER_ERROR;
}
return HttpStatus.valueOf(statusCode);
}
}配置 Shiro
JWTToken
public class JWTToken implements AuthenticationToken {
// TOKEN
private String token;
public JWTToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}Realm
@Service
public class MyRealm extends AuthorizingRealm {
@Autowired
private UserService userService;
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
String username = JWTUtil.getUsername(principals.toString());
UserBean user = userService.getUser(username);
SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo();
simpleAuthorizationInfo.addRole(user.getRole());
Set permission = new HashSet<>(Arrays.asList(user.getPermission().split(",")));
simpleAuthorizationInfo.addStringPermissions(permission);
return simpleAuthorizationInfo;
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException {
String token = (String) auth.getCredentials();
String username = JWTUtil.getUsername(token);
if (username == null) {
throw new AuthenticationException("token invalid");
}
UserBean userBean = userService.getUser(username);
if (userBean == null) {
throw new AuthenticationException("User didn't existed!");
}
if (! JWTUtil.verify(token, username, userBean.getPassword())) {
throw new AuthenticationException("Username or password error");
}
return new SimpleAuthenticationInfo(token, token, "my_realm");
}
} 定義 Filter
有的請求都將轉發(fā)給 Filter,我們擴展 BasicHttpAuthenticationFilter 以覆蓋一些方法
執(zhí)行流程:preHandle - > isAccessAllowed - > isLoginAttempt - > executeLogin
public class JWTFilter extends BasicHttpAuthenticationFilter {
@Override
protected boolean isLoginAttempt(ServletRequest request, ServletResponse response) {
HttpServletRequest req = (HttpServletRequest) request;
String authorization = req.getHeader("Authorization");
return authorization != null;
}
@Override
protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
String authorization = httpServletRequest.getHeader("Authorization");
JWTToken token = new JWTToken(authorization);
getSubject(request, response).login(token);
return true;
}
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {
if (isLoginAttempt(request, response)) {
try {
executeLogin(request, response);
} catch (Exception e) {
response401(request, response);
}
}
return true;
}
@Override
protected boolean preHandle(ServletRequest request, ServletResponse response) throws Exception {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
httpServletResponse.setHeader("Access-control-Allow-Origin", httpServletRequest.getHeader("Origin"));
httpServletResponse.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,PUT,DELETE");
httpServletResponse.setHeader("Access-Control-Allow-Headers", httpServletRequest.getHeader("Access-Control-Request-Headers"));
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
/**
* Illege request foward to /401
*/
private void response401(ServletRequest req, ServletResponse resp) {
try {
HttpServletResponse httpServletResponse = (HttpServletResponse) resp;
httpServletResponse.sendRedirect("/401");
} catch (IOException e) {
LOGGER.error(e.getMessage());
}
}
}配置 Shiro
@Configuration
public class ShiroConfig {
@Bean("securityManager")
public DefaultWebSecurityManager getManager(MyRealm realm) {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
manager.setRealm(realm);
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
manager.setSubjectDAO(subjectDAO);
return manager;
}
@Bean("shiroFilterFactoryBean")
public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
// define your filter and name it as jwt
Map filterMap = new HashMap<>();
filterMap.put("jwt", new JWTFilter());
factoryBean.setFilters(filterMap);
factoryBean.setSecurityManager(securityManager);
factoryBean.setUnauthorizedUrl("/401");
/*
* difine custom URL rule
* http://shiro.apache.org/web.html#urls-
*/
Map filterRuleMap = new HashMap<>();
// All the request forword to JWT Filter
filterRuleMap.put("/**", "jwt");
// 401 and 404 page does not forward to our filter
filterRuleMap.put("/401", "anon");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
defaultAdvisorAutoProxyCreator.setProxyTargetClass(true);
return defaultAdvisorAutoProxyCreator;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(DefaultWebSecurityManager securityManager) {
AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor();
advisor.setSecurityManager(securityManager);
return advisor;
}
} 運行
源代碼中有一個 PostMan 的 JSON 接口文件可以直接導入測試
通過發(fā)送 POST 請求獲得令牌
在 HTTP 請求 header 中加入 Token 并獲取返回結果
如果不加入 Token 或使用錯誤 Token 會出現錯誤
源代碼
Git 倉庫地址
https://github.com/wangqinggang/Shiro-JWT.gitGit 倉庫頁面鏈接,喜歡的話給個 Star
參考資料
SpringBoot Integrate With JWT And Apache Shiro
IETF JWT
