# SpringBootShiroJwtRedis2
**Repository Path**: hcitlife/SpringBootShiroJwtRedis2
## Basic Information
- **Project Name**: SpringBootShiroJwtRedis2
- **Description**: SpringBoot整合Shiro和JWT和Redis
采用多Realm方式
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 1
- **Forks**: 0
- **Created**: 2020-10-23
- **Last Updated**: 2021-03-26
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# 重要的英文单词
- Authentication:身份证明
- authorization:权限
# 功能介绍
- 完全使用了 Shiro 的注解配置,保持高度的灵活性
- 放弃 Cookie ,Session ,使用JWT进行鉴权,完全实现无状态鉴权
- JWT 密钥支持过期时间
- 对跨域提供支持
# Token
## Token加密说明
- 携带了 `username` 信息在 token 中
- 设定了过期时间
- 使用用户登入密码对 `token` 进行加密
## Token校验流程
1. 获得 `token` 中携带的 `username` 信息
2. 进入数据库搜索这个用户,得到相应的密码
3. 使用用户的密码来检验 `token` 是否正确
# 程序逻辑
1. 我们 POST 用户名与密码到 `/login` 进行登入,如果成功返回一个加密 token,失败的话直接返回 401 错误。
2. 之后用户访问每一个需要权限的网址请求必须在 `header` 中添加 `Authorization` 字段,例如 `Authorization: token` ,`token` 为密钥。
3. 后台会进行 `token` 的校验,如果有误会直接返回 401。
# 代码实现
## 第一步:新建一个 Maven 工程,添加相关的依赖:
```xml
org.springframework.boot
spring-boot-starter-web
org.apache.shiro
shiro-all
1.6.0
org.apache.shiro
shiro-spring-boot-web-starter
1.6.0
org.slf4j
jcl-over-slf4j
1.7.30
org.slf4j
slf4j-log4j12
1.7.30
log4j
log4j
1.2.17
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
org.junit.vintage
junit-vintage-engine
junit
junit
test
org.apache.commons
commons-lang3
3.9
commons-codec
commons-codec
1.13
com.auth0
java-jwt
3.10.2
```
## 第二步:构建模拟数据
| username | password | role | permission |
| -------- | -------- | ----- | ---------- |
| zhangsan | 123456 | admin | get,delete |
| lisi | 123456 | user | get ,edit |
| wanger | 123456 | guest | get |
再构建一个 数据库模拟类:
```java
public class DB {
public static List users = new ArrayList<>();
private static Map> roles = new HashMap<>();
private static Map> auths = new HashMap<>();
//盐值和加密后的密码可以从ShiroUtil类的main方法中获取
static {
User user1 = new User(1001, "zhangsan", "9ce1a8ccb0a21201ccc3560abd93d581", "张先生", "AeGRHHFk", false);
User user2 = new User(1002, "lisi", "bf06cf274733c4fd15bdbb0ffbd40d7a", "李女士", "rVLCYWxi", false);
User user3 = new User(1003, "wanger", "fb61fddf4b3e525d0833cc7868f69eb7", "王女士", "dcDzuoWt", false);
users.add(user1);
users.add(user2);
users.add(user3);
roles.put(1001, Sets.newHashSet("admin"));
roles.put(1002, Sets.newHashSet("user"));
roles.put(1003, Sets.newHashSet("guest"));
auths.put(1002, Sets.newHashSet("storage:get"));
}
public static User getUserByName(String name) {
for (User user : users) {
if (user.getName().equals(name)) {
return user;
}
}
return null;
}
public static Set getRolesByUserId(Integer userId){
return roles.get(userId);
}
public static Set getAuthsByUserId(Integer userId){
return auths.get(userId);
}
}
```
用户实体类:
```java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User implements Serializable {
private static final long serialVersionUID = -1091190204717346101L;
/**
*主键
*/
private Integer id;
/**
*登录用户名
*/
private String name;
/**
*登录密码
*/
private String password;
/**
*昵称
*/
private String nickname;
/**
* 盐值
*/
private String salt;
/**
* 是否被锁定
*/
private Boolean locked;
}
```
## 配置 JWT
### 编写一个简单的 JWT 加密,校验工具类
使用用户自己的盐值充当加密密钥,这样保证了 token 即使被他人截获也无法破解,我们在 `token` 中附带了 `username` 信息,并且设置密钥5分钟就会过期。
```java
public class JWTUtil {
// 过期时间5分钟
private static final long EXPIRE_TIME = 5*60*1000;
/**
* 校验token是否正确
* @param token 密钥
* @param secret 用户的密码
* @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;
}
}
/**
* 获得token中的信息无需secret解密也能获得
* @return token中包含的用户名
*/
public static String getUsername(String token) {
try {
DecodedJWT jwt = JWT.decode(token);
return jwt.getClaim("username").asString();
} catch (JWTDecodeException e) {
return null;
}
}
/**
* 生成签名,5min后过期
* @param username 用户名
* @param secret 用户的密码
* @return 加密的token
*/
public static String sign(String username, String secret) {
try {
Date date = new Date(System.currentTimeMillis()+EXPIRE_TIME);
Algorithm algorithm = Algorithm.HMAC256(secret);
// 附带username信息
return JWT.create()
.withClaim("username", username)
.withExpiresAt(date)
.sign(algorithm);
} catch (UnsupportedEncodingException e) {
return null;
}
}
}
```
## 构建URL
### 建立了一个 `ResponseBean` 来统一返回的格式
```java
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ResponseBean implements Serializable {
private static final long serialVersionUID = -7505939480816454807L;
/**
* 状态码
*/
private int code;
/**
* 消息内容
*/
private String msg;
/**
* 返回数据
*/
private T data;
}
```
**自定义异常**
为了能够手动抛出异常,我自己写了一个 `UnauthorizedException.java`
```java
public class UnauthorizedException extends RuntimeException {
public UnauthorizedException(String msg) {
super(msg);
}
public UnauthorizedException() {
super();
}
}
```
**URL结构**
| URL | 作用 |
| ------------------- | ----------------------- |
| /user/login | 登入 |
| /user/logout | 登出 |
| /storage/list | 所有人都可以访问,但是用户与游客看到的内容不同 |
| /storage/requireAuth | 登入的用户才可以进行访问 |
| /storage/get | 拥有storage[get]权限的用户才可以访问 |
| /storage/requirePermission | 拥有storage[get]和storage[edit]权限的用户才可以访问 |
| /storage/delete | 拥有admin角色的用户才可以访问 |
| /storage/requireRole | 拥有user的角色用户才可以登入 |
**Controller**
```java
@RestController
public class WebController {
private static final Logger LOGGER = LogManager.getLogger(WebController.class);
private UserService userService;
@Autowired
public void setService(UserService userService) {
this.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` 能很好的实现。
```java
@RestControllerAdvice
public class ExceptionController {
// 捕捉shiro的异常
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(ShiroException.class)
public ResponseBean handle401(ShiroException e) {
return new ResponseBean(401, e.getMessage(), null);
}
// 捕捉UnauthorizedException
@ResponseStatus(HttpStatus.UNAUTHORIZED)
@ExceptionHandler(UnauthorizedException.class)
public ResponseBean handle401() {
return new ResponseBean(401, "Unauthorized", null);
}
// 捕捉其他所有异常
@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**
`JWTToken` 差不多是 `Shiro` 用户名密码的载体。因为我们是前后端分离,服务器无需保存用户状态,所以不需要 `RememberMe` 这类功能,我们简单的实现下 `AuthenticationToken` 接口即可。因为 `token` 默认已经包含了用户名等信息,所以此处只创建了一个字段。
```java
public class JWTToken implements AuthenticationToken {
// 密钥
private String token;
public JWTToken(String token) {
this.token = token;
}
@Override
public Object getPrincipal() {
return token;
}
@Override
public Object getCredentials() {
return token;
}
}
```
**实现Realm**
`realm` 的用于处理用户是否合法的这一块,需要我们自己实现。
```java
@Service
public class MyRealm extends AuthorizingRealm {
private static final Logger LOGGER = LogManager.getLogger(MyRealm.class);
private UserService userService;
@Autowired
public void setUserService(UserService userService) {
this.userService = userService;
}
/**
* 大坑!,必须重写此方法,不然Shiro会报错
*/
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof JWTToken;
}
/**
* 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的
*/
@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();
// 解密获得username,用于和数据库进行对比
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");
}
}
```
在 `doGetAuthenticationInfo()` 中用户可以自定义抛出很多异常,详情见文档。
***重写 Filter***
所有的请求都会先经过 `Filter`,所以我们继承官方的 `BasicHttpAuthenticationFilter` ,并且重写鉴权的方法。
代码的执行流程 `preHandle` -> `isAccessAllowed` -> `isLoginAttempt` -> `executeLogin` 。
```java
public class JWTFilter extends BasicHttpAuthenticationFilter {
private Logger LOGGER = LoggerFactory.getLogger(this.getClass());
/**
* 判断用户是否想要登入。
* 检测header里面是否包含Authorization字段即可
*/
@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);
// 提交给realm进行登入,如果错误他会抛出异常并被捕获
getSubject(request, response).login(token);
// 如果没有抛出异常则代表登入成功,返回true
return true;
}
/**
* 这里我们详细说明下为什么最终返回的都是true,即允许访问
* 例如我们提供一个地址 GET /article
* 登入用户和游客看到的内容是不同的
* 如果在这里返回了false,请求会被直接拦截,用户看不到任何东西
* 所以我们在这里返回true,Controller中可以通过 subject.isAuthenticated() 来判断用户是否登入
* 如果有些资源只有登入用户才能访问,我们只需要在方法上面加上 @RequiresAuthentication 注解即可
* 但是这样做有一个缺点,就是不能够对GET,POST等请求进行分别过滤鉴权(因为我们重写了官方的方法),但实际上对应用影响不大
*/
@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"));
// 跨域时会首先发送一个option请求,这里我们给option请求直接返回正常状态
if (httpServletRequest.getMethod().equals(RequestMethod.OPTIONS.name())) {
httpServletResponse.setStatus(HttpStatus.OK.value());
return false;
}
return super.preHandle(request, response);
}
/**
* 将非法请求跳转到 /401
*/
private void response401(ServletRequest req, ServletResponse resp) {
try {
HttpServletResponse httpServletResponse = (HttpServletResponse) resp;
httpServletResponse.sendRedirect("/401");
} catch (IOException e) {
LOGGER.error(e.getMessage());
}
}
}
```
`getSubject(request, response).login(token);` 这一步就是提交给了 `realm` 进行处理。
**配置Shiro**
```java
@Configuration
public class ShiroConfig {
@Bean("securityManager")
public DefaultWebSecurityManager getManager(MyRealm realm) {
DefaultWebSecurityManager manager = new DefaultWebSecurityManager();
// 使用自己的realm
manager.setRealm(realm);
/*
* 关闭shiro自带的session,详情见文档
* http://shiro.apache.org/session-management.html#SessionManagement-StatelessApplications%28Sessionless%29
*/
DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO();
DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator();
defaultSessionStorageEvaluator.setSessionStorageEnabled(false);
subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator);
manager.setSubjectDAO(subjectDAO);
return manager;
}
@Bean("shiroFilter")
public ShiroFilterFactoryBean factory(DefaultWebSecurityManager securityManager) {
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
// 添加自己的过滤器并且取名为jwt
Map filterMap = new HashMap<>();
filterMap.put("jwt", new JWTFilter());
factoryBean.setFilters(filterMap);
factoryBean.setSecurityManager(securityManager);
factoryBean.setUnauthorizedUrl("/401");
/*
* 自定义url规则
* http://shiro.apache.org/web.html#urls-
*/
Map filterRuleMap = new HashMap<>();
// 所有请求通过我们自己的JWT Filter
filterRuleMap.put("/**", "jwt");
// 访问401和404页面不通过我们的Filter
filterRuleMap.put("/401", "anon");
factoryBean.setFilterChainDefinitionMap(filterRuleMap);
return factoryBean;
}
/**
* 下面的代码是添加注解支持
*/
@Bean
@DependsOn("lifecycleBeanPostProcessor")
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
// 强制使用cglib,防止重复代理和可能引起代理出错的问题
// https://zhuanlan.zhihu.com/p/29161098
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;
}
}
```
## 总结
我就说下代码还有哪些可以进步的地方吧
- 没有实现 Shiro 的 `Cache` 功能。
- Shiro 中鉴权失败时不能够直接返回 401 信息,而是通过跳转到 `/401` 地址实现。