Apache Shiro 是一个功能强大且易于使用的Java安全框架,可进行身份验证,授权,加密和会话管理等。
在使用 shiro 之前, 我们先了解一下 shiro 权限管理的方式:
基于角色的访问控制
RBAC (Role-Based Access Control) ,
通过角色将用户和权限关联起来, 即一个用户可以拥有多个角色, 一个角色拥有多个权限.
所以先建立五张表:
// 管理员表
admin(id, username, password)
// 角色表
role(id, role_name)
// 权限表
permission(id, permission_name)
// 管理员-角色关联表
admin_role(id, admin_id, role_id)
// 角色-权限关联表
role_permission(id, role_id, permission_id)
实现相关的查询方法
public interface SysAdminService{
/**
* 这里是通过用户名直接将用户信息和其所有的角色和权限信息都查了出来, 然后封装在 AdminInfo 的对象中
* 具体实现就是根据用户名查用户, 然后根据用户 id 查角色, 再查权限, 最后封装, 如果用户不存在就直接返回 null
*/
AdminInfo getInfoByUsername(String username);
}
/**
* 用户身份信息类, 封装了其角色和权限
*/
public class AdminInfo implements Serializable {
private static final long serialVersionUID = 7899578061660728259L;
private Integer id;
private String username;
// 返回密码是为了进行身份认证
@JsonIgnore // 该注解是在将对象转化成 json 返回给前端的时候, 忽视该字段
private String password;
// 为了方便, 这里一个用户只对应一个角色
private String role;
private Integer roleId;
private Set<String> permissions;
/*
* 省略 get, set 方法
*/
}
然后就开始在SpringBoot 中搭建 shiro 了,
首先是引入依赖:
<!-- shiro 权限管理 -->
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.0</version>
</dependency>
定义 Realm
Realm 是 shiro 用来进行身份认证的获取角色权限信息的类, 我们通过继承 AuthorizingRealm
重写下面两个方法来获取用户的身份认证和权限信息.
public class ShiroRealm extends AuthorizingRealm {
@Autowired
private SysAdminService sysAdminService;
/**
* 根据 token 获取身份信息
* AuthenticationToken 是封装了登陆的 用户名和密码, 后面登陆的时候用到
* @throws AuthenticationException
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
// 获取用户名
String username = (String) token.getPrincipal();
AdminInfo admin = sysAdminService.getInfoByUsername(username);
// 用户不存在就直接返回 null 值
if (admin == null) {
return null;
}
/*
* 返回用户身份认证信息
* 构造方法为 SimpleAuthenticationInfo(Object principal, Object credentials, String realmName)
* 第一个参数是用户主体, 也可以存用户名
* 第二个参数用户凭据, 即密码
* 第三个就是 realm 的名字, 直接用 getName() 当前 realm
*/
return new SimpleAuthenticationInfo(admin, admin.getPassword(), getName());
}
/**
* 根据上面返回的身份信息获取授权信息
*
* @param principals
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
// 直接从用户主体信息中获取权限, 如果上面存用户名, 就还得在查一下权限
AdminInfo adminInfo = (AdminInfo) principals.getPrimaryPrincipal();
Set<String> permissions = adminInfo.getPermissions();
// 在授权信息中添加用户的角色和权限信息
SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
info.addRole(adminInfo.getRole());
info.setStringPermissions(permissions);
return info;
}
}
在 SpringBoot 中配置 Shiro
在这个 ShiroConfig 主要对 Shiro 的 realm, session, security, 过滤器等进行配置.
/**
* shiro 配置类
* springboot 会自动扫描该类中 @Bean 注解的对象, 并加载到spring容器中
*/
@Configuration
public class shiroConfig {
// 定义 shiro 的 cookie 的名字
private static String COOKIE_NAME = "shiro-session-cookie";
/**
* shiro 过滤器的配置
*/
@Bean
public ShiroFilterFactoryBean shiroFilter() {
ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();
shiroFilterFactoryBean.setSecurityManager(securityManager());
// 过滤器的map, 用于添加自定义的过滤器, 没有特殊需求不用
// Map<String, Filter> filterMap = new LinkedHashMap<>();
// 第一个参数定义的过滤器的名字, 第二个过滤器实例
// filterMap.put("myLogout", myLogout());
// shiroFilterFactoryBean.setFilters(filterMap);
// 配置过滤链
Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();
// /**表示匹配所有url, anon 表示可以匿名访问, 注意顺序
filterChainDefinitionMap.put("/**", "anon");
// roles[root], roles -> 过滤器类型, root -> 传递给过滤器的参数, 表只允许拥有 root 角色的用户访问
// filterChain.put("/manage/**/clean", "roles[root]");
// filterChain.put("/manage/**", "authc"); // 表示需要登陆的 url
shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);
// shiroFilterFactoryBean.setLoginUrl("/login"); // 未登陆则跳转到该url, 我不用哈哈, 让前端跳去
// shiroFilterFactoryBean.setUnauthorizedUrl("/error"); /// 默认的未授权页面, 权限不足则跳转到该页, 同上
return shiroFilterFactoryBean;
}
/**
* 安全管理的 Bean
*/
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(shiroRealm()); // 设置自己的 realm
securityManager.setSessionManager(shiroSessionManager());
return securityManager;
}
/**
* 自己实现的 realm
*/
@Bean
public ShiroRealm shiroRealm() {
ShiroRealm shiroRealm = new ShiroRealm();
// 设置密码加密器, 避免数据库存明文密码
shiroRealm.setCredentialsMatcher(hashedCredentialsMatcher());
return shiroRealm;
}
/**
* 定义 hash 的加密方式
*/
@Bean
public HashedCredentialsMatcher hashedCredentialsMatcher() {
HashedCredentialsMatcher matcher = new HashedCredentialsMatcher();
matcher.setHashAlgorithmName("md5"); // 加密算法的名字
matcher.setHashIterations(1); // 加密次数
matcher.setStoredCredentialsHexEncoded(true);
return matcher;
}
/**
* 定义会话管理, 也是自定义的一个
* 可以使用默认的 DefaultWebSessionManager
*/
@Bean
public ShiroSessionManager shiroSessionManager() {
ShiroSessionManager sessionManager = new ShiroSessionManager();
// shiro 全局的 session 过期时间
sessionManager.setGlobalSessionTimeout(SESSION_TIMEOUT);
// 验证 session 是否过期的时间间隔
sessionManager.setSessionValidationInterval(20 * 60 * 1000);
// 设置自定义的 sessionDao, 没需求就不需要设置
sessionManager.setSessionDAO(shiroSessionDAO());
// 设置 sessionId 不写入 url
sessionManager.setSessionIdUrlRewritingEnabled(false);
sessionManager.setSessionIdCookie(shiroCookie());
return sessionManager;
}
/**
* 自定义的 sessionDao, 即 session 的增删改查
*/
@Bean
public ShiroSessionDAO shiroSessionDAO() {
return new ShiroSessionDAO();
}
@Bean
public Cookie shiroCookie() {
SimpleCookie cookie = new SimpleCookie();
cookie.setHttpOnly(true);
cookie.setMaxAge(7 * 24 * 60 * 60);
cookie.setName(COOKIE_NAME);
return cookie;
}
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
@Bean
@DependsOn("lifecycleBeanPostProcessor")
// @ConditionalOnMissingBean
public DefaultAdvisorAutoProxyCreator defaultAdvisorAutoProxyCreator() {
DefaultAdvisorAutoProxyCreator proxyCreator = new DefaultAdvisorAutoProxyCreator();
proxyCreator.setProxyTargetClass(true);
return proxyCreator;
}
// 开启注解
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor() {
AuthorizationAttributeSourceAdvisor sourceAdvisor = new AuthorizationAttributeSourceAdvisor();
sourceAdvisor.setSecurityManager(securityManager());
return sourceAdvisor;
}
}
使用
登陆
// 注意是这个类
import org.apache.shiro.subject.Subject;
// 获取当前用户主体
Subject subject = SecurityUtils.getSubject();
// 使用 用户名和密码构造 token 用于 shiro 登陆
UsernamePasswordToken token = new UsernamePasswordToken(username, password);
try {
subject.login(token);
} catch (AuthenticationException e) {
// TODO 登陆信息错误
}
// 没报错就登陆成功了
添加或修改用户密码
因为之前设置了 md5 的密码加密方式, 所以我们在存储密码的时候就应该对密码进行加密,
import org.apache.shiro.crypto.hash.Md5Hash;
public boolean addAdmin(AdminForm adminForm){
SysAdmin admin = new SysAdmin();
admin.setUsername(adminForm.getUsername());
admin.setPassword(new Md5Hash(adminForm.getPassword()).toString());
// ...
}
获取当前用户信息
有时候我们需要获取当前登陆的用户信息, 就可以这样做
// 获取当前登陆的主体
Subject subject = SecurityUtils.getSubject();
// 从主体获取身份信息, 如果之前存的是用户名, 就应该转成字符串
AdminInfo admin = (AdminInfo) subject.getPrincipal();
// 然后就可以干你想干的事了
使用注解管理接口权限
主要使用以下注解:
- @RequiresRoles(value=“role”), 需要拥有 role 角色才能访问
- @RequiresPermission(value=“permission”), 需要 permission 权限
- @RequiresAuthentication, 必须需要登陆
- @RequiresUser, 存在登陆用户, (和上面的区别是, 有记住我的选项时, 这个不登录也能访问)
注: value 是字符串数组类型, 如果有多个, 则需要同时拥有那几个角色或权限.
具体用法:
/**
* 只有 admin 角色才能访问该接口
* 注解也可以放在类上, 表示这个类的接口都需要该权限
*/
@RequiresRoles(value = "admin")
@PostMapping("/upload")
public Result uploadFile(MultipartFile file){
// TODO
}
那么问题来了, 权限不足怎么办, 前面自动跳转的设置被我注掉了. 所以这个时候就要在全局异常捕获里面捕获对应的异常了.
捕获 shiro 异常
因为现在项目大部分是前后端分离, 所以页面跳转那些工作都交给前端同学去做, 我们只管返回数据就行了
@ControllerAdvice
public class GlobalExceptionHandler {
/*
* 省略其他异常捕获
*/
/**
* shiro 未登陆异常
* 该方法只捕获 @ExceptionHandler指定的异常
* 返回已定义好的结果
*/
@ExceptionHandler(value = UnauthenticatedException.class)
@ResponseBody
public Result handle(UnauthenticatedException e) {
return Result.error(ErrorCodeEnum.USER_UN_LOGIN);
}
/**
* shiro 未授权异常
*/
@ExceptionHandler(value = UnauthorizedException.class)
@ResponseBody
public Result handle(UnauthorizedException e) {
return Result.error(ErrorCodeEnum.HAS_NO_PERMISSION);
}
}
基本的使用就是这样了, 下面在补充一些.
自定义过滤器
有时候就是有一些特殊需求, 需要自己定义过滤器放在 shiro 的过滤链里面, 就可以继承 AccessControlFilter
类
public class CustomFilter extends AccessControlFilter {
/**
* 是否放行, 返回 true 则放行, false 则拦下来
* mappedValue 参数对应在配置过滤链配置中,中括号里面的字符串, 如
* filterChain.put("/**", "custom[root, admin]");
*/
@Override
protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception {
String[] urls = (String[]) mapperValue;
// 另一种获取登陆主体的方式
Subject subject = getSubject(servletRequest, servletResponse);
// TODO 过滤判断
return true;
}
/**
* 被上面拦截了是否还继续让其他过滤器处理
*/
@Override
protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {
// 还可以干点其他的事, 比如在响应体里写点东西
response.getWriter().write("你被拦下来啦");
return false;
}
}
自定义 sessionDao
当我们服务以集群的方式部署时, shiro 的 session 需要统一存在一个地方, 这个时候就需要我们重写 sessionDao 了, 以 Redis 存储为例:
顺便贴一下 redis 的配置
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- redis 的 lettuce 连接池 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
application.yml
# redis
spring:
redis:
host: 127.0.0.1
port: 6379
password: ****** # 如果是服务器的话, 配一下密码吧, 身边有好几个人因为开放了 redis 端口但没设密码导致服务器中毒了
timeout: 5000
lettuce:
pool:
min-idle: 1
重写的 ShiroSessionDAO.java
/**
* 需要继承 AbstractSessionDAO, 并且该类会自动注册到 spring 容器中
* 所以可以直接在里面使用其他的 bean
*/
public class ShiroSessionDAO extends AbstractSessionDAO {
/**
* 使用 redisTemplate 操作 redis
*/
@Autowired
private RedisTemplate<Object, Object> redisTemplate;
@Override
protected Serializable doCreate(Session session) {
// 根据 session 生成一个 sessionId
Serializable sessionId = generateSessionId(session);
// 将 sessionId 注册到 session 上
assignSessionId(session, sessionId);
// 这里使用的是 redis 的 hash 数据结构, SHIRO_SESSION_KEY 在常量类中定义的一个字符串常量
redisTemplate.opsForHash().put(SHIRO_SESSION_KEY, sessionId, session);
return sessionId;
}
@Override
protected Session doReadSession(Serializable sessionId) {
if (sessionId == null) {
return null;
}
Session session = (Session) redisTemplate.opsForHash().get(SHIRO_SESSION_KEY, sessionId);
return session;
}
@Override
public void update(Session session) throws UnknownSessionException {
if (session instanceof ValidatingSession && !((ValidatingSession) session).isValid()) {
return;
}
redisTemplate.opsForHash().put(SHIRO_SESSION_KEY, session.getId(), session);
}
@Override
public void delete(Session session) {
redisTemplate.opsForHash().delete(SHIRO_SESSION_KEY, session.getId());
}
/**
* 获取所有存活的 session, 可以通过这个获取在线人数
*/
@Override
public Collection<Session> getActiveSessions() {
List<Object> activeSession = redisTemplate.opsForHash().values(SHIRO_SESSION_KEY);
Collection<Session> activeSessions = new ArrayList<>();
Session session;
long now = System.currentTimeMillis();
for (Object o : activeSession) {
if (o instanceof Session) {
session = (Session) o;
if (isActive(session, now)) {
activeSessions.add(session);
}
}
}
return activeSessions;
}
private boolean isActive(Session session, long nowTime) {
long last = session.getLastAccessTime().getTime();
long timeout = session.getTimeout();
return nowTime < last + timeout;
}
}
记住登陆
很多网站在登陆的时候都会有一个记住我的选项, 使用 shiro 也可以实现该功能. 也是通过设置 cookie 实现的.
话不多说, 看代码
shiroConfig.java
/**
* 在 securityManager 里添加一个 rememberMeManger
*/
@Bean
public SecurityManager securityManager() {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(shiroRealm()); // 设置自己的 realm
securityManager.setSessionManager(shiroSessionManager());
// 记住我的管理
securityManager.setRememberMeManager(rememberMeManager());
return securityManager;
}
@Bean
public CookieRememberMeManager rememberMeManager(){
CookieRememberMeManager rememberMeManager = new CookieRememberMeManager();
// rememberme cookie加密的密钥, 可以通过以下代码可以获取, 使用AES算法生成
// try {
// KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
// SecretKey deskey = keyGenerator.generateKey();
// System.out.println("cookie 密钥:" + Base64.encodeToString(deskey.getEncoded()));
// } catch (NoSuchAlgorithmException e) {
// e.printStackTrace();
// }
byte[] cipherKey = Base64.decode("XTx6CKLo/SdSgub+OPHSrw==");
// 设置密钥
rememberMeManager.setCipherKey(cipherKey);
// 配置 rememberMe 的 cookie
rememberMeManager.setCookie(simpleCookie());
return rememberMeManager;
}
@Bean
public SimpleCookie simpleCookie(){
// cookie 的名字
SimpleCookie simpleCookie = new SimpleCookie("shiro-rememberMe");
simpleCookie.setMaxAge(7 * 24 * 60 * 60); // 设置一周的过期时间, 单位为秒
return simpleCookie;
}
修改登陆接口
public Result login(@RequestParam("name")String name,
@RequestParam("password") String password,
@RequestParam(value = "rememberMe", required = false, defaultValue = "false") boolean rememberMe){
UsernamePasswordToken token = new UsernamePasswordToken(name, password);
Subject subject = SecurityUtils.getSubject();
// 就多了这一步
token.setRememberMe(rememberMe);
try{
subject.login(token);
} catch (AuthenticationException e){
// TODO 登陆信息错误
}
// ...
}