springboot+shiro+jwt+mybatiesplus前后端分离项目快速入门(二.shiro实战)
传送门>github地址,main分支为项目搭建即第一篇内容,complete分支为整合后的完整代码
类 | 解释 | 重要性 |
---|---|---|
subject | 当前主体 | 高 |
SecurityManager | 管理subject的安全认证管理者 | 高 |
realm | 只做两件事,帮助securityManager’‘认证’‘和’‘授权’’ | 高 |
@Slf4j
@Configuration
public class MyRealm extends AuthorizingRealm {
@Autowired
private AccountService adminService;
@Autowired
private RoleService roleService;
@Autowired
private PermissionService permissionService;
/**
* 用来做授权 只管理权限,查表的地方
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
log.info("---------------- 执行 Shiro 权限获取 ---------------------");
//从Shiro中获取用户名
Subject subject = SecurityUtils.getSubject();
Account currentAccount = (Account) subject.getPrincipal(); // 因为认证时principal用的是account对象,所以可以直接取出来强转
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
// 数据库查询权限
Set<Permission> jurisdictionByAccountName = permissionService.getJurisdictionByAccountName(currentAccount.getAccountName());
Set<String> jurisdictions = jurisdictionByAccountName.stream().map(Permission::getUri).collect(Collectors.toSet());
log.info("当前用户:{} 拥有权限:{}", currentAccount.getAccountName(), jurisdictions.toString());
//设置accountName拥有的权限
authorizationInfo.addStringPermissions(jurisdictions);
//设置accountName拥有的角色
Set<String> roles = roleService.getRolesByAccountName(currentAccount.getAccountName());
authorizationInfo.setRoles(roles);
return authorizationInfo;
}
/**
* 用来做认证 校验账号密码是否正确的地方
*/
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken) throws AuthenticationException {
log.debug("===============Shiro身份认证开始============doGetAuthenticationInfo==========");
//将AuthenticationToken强转成UsernamePasswordToken 这样获取账号和密码更加的方便
UsernamePasswordToken token = (UsernamePasswordToken) authenticationToken;
Object credentials = authenticationToken.getCredentials();
//获取用户在浏览器中输入的账号
String userName = token.getUsername();
String password = new String(token.getPassword());
//认证账号,正常情况我们需要这里从数据库中获取账号的信息,以及其他关键数据,例如账号是否被冻结等等
Account admin = adminService.getOne(new LambdaQueryWrapper<Account>().eq(Account::getAccountName, userName));
if (admin == null) throw new UnknownAccountException("账号不存在");//判断用户账号是否存在
if (admin.getStatus() == -1) throw new LockedAccountException("账号被冻结");
if (!admin.getAccountPassword().equals(password)) throw new IncorrectCredentialsException("账号或密码错误");
//密码认证 shiro做
return new SimpleAuthenticationInfo(admin, credentials, getName());
}
}
目的是完成解析token的操作
@Slf4j
public class UrlPermissionFilter extends AccessControlFilter {
/**
* 表示是否允许访问 ,如果允许访问返回true,否则false; 这里的o参数 有兴趣的可以研究讨论下
*/
@Override
protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception {
Subject subject = getSubject(servletRequest, servletResponse);
String url = getPathWithinApplication(servletRequest);
if ("/error".equals(url)) return true;
log.info("当前用户正在访问的 url => " + url);
// 先判断一下是不是登录 验证token
HttpServletRequest servletRequest1 = (HttpServletRequest) servletRequest;
String token = servletRequest1.getHeader("Authorization");
if (!"/login".equals(url) && StrUtil.isEmpty(token)) {
IdentityException identityException = new IdentityException("请求头缺少或携带无效标识:Authorization");
servletRequest.setAttribute("noneAuthorization",identityException);
throw identityException;
}
if (StrUtil.isNotEmpty(token)) {
if (!SecureUtil.authUser(token)) throw new RuntimeException("token无效错误");
}
return subject.isPermitted(url);
}
/**
* onAccessDenied:表示当访问拒绝时是否已经处理了; 如果返回 true 表示需要继续处理; 如果返回 false
* 表示该拦截器实例已经处理了,将直接返回即可。
*/
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
HttpServletRequest servletRequest1 = (HttpServletRequest) servletRequest;
String msg = SecurityUtils.getSubject().isAuthenticated() ? "当前账户无接口["+servletRequest1.getRequestURI()+"]权限操作" : "账户登录态失效,请在登录后重试";
IdentityException exception = new IdentityException(msg);
servletRequest1.setAttribute("identityException", exception);
throw new RuntimeException(exception);// 这里是使用过滤器坑爹的地方 暂且这样抛出异常后续有解释
}
}
@Configuration
@Setter
public class UrlPermission implements Permission {
private static final Logger logger = LoggerFactory.getLogger(UrlPermission.class);
// 在 Realm 的授权方法中,由数据库查询出来的权限字符串
private String url;
public UrlPermission(String url) {
this.url = url;
}
public UrlPermission() {
}
/**
* 一个很重要的方法,用户判断 Realm 中设置的权限和从数据库或者配置文件中传递进来的权限信息是否匹配
* 如果 Realm 的授权方法中,一个认证主体有多个权限,会进行遍历,直到匹配成功为止
* this.url 是在遍历状态中变化的
* urlPermission.url 是从 subject.isPermitted(url)
* 传递到 UrlPermissionResolver 中传递过来的,就一个固定值
*/
@Override
public boolean implies(Permission permission) {
if (!(permission instanceof UrlPermission)) {
return false;
}
UrlPermission urlPermission = (UrlPermission) permission;
PatternMatcher patternMatcher = new AntPathMatcher();
logger.info("this.url(来自数据库中存放的通配符数据),在 Realm 的授权方法中注入的 => " + this.url);
logger.info("urlPermission.url(来自浏览器正在访问的链接) => " + urlPermission.url);
boolean matches = patternMatcher.matches(this.url, urlPermission.url);
logger.info("matches => " + matches);
return matches;
}
}
/**
*有点多余,暂时没想出来解决办法
*/
public class RolePermissionResolver implements org.apache.shiro.authz.permission.RolePermissionResolver {
@Autowired
RoleService roleService;
@Override
public Collection<Permission> resolvePermissionsInRole(String roleString) {
Set<RoleNameJriDo> jurisdictionsByRoleName = roleService.getJurisdictionsByRoleName(roleString);
Collection<Permission> permissions =new ArrayList<>();
for (RoleNameJriDo roleNameJriDo : jurisdictionsByRoleName) {
if ((roleString+"").equals(roleNameJriDo.getRoleName())){
UrlPermission urlPermission = new UrlPermission(roleNameJriDo.getUri());
permissions.add(urlPermission);
}
}
return permissions;
}
}
public class UrlPermissionResolver implements PermissionResolver {
private static final Logger logger = LoggerFactory.getLogger(UrlPermissionResolver.class);
/**
* 经过调试发现
* subject.isPermitted(url) 中传入的字符串
* 和自定义 Realm 中传入的权限字符串集合都要经过这个 resolver
*/
@Override
public Permission resolvePermission(String s) {
logger.info("s => " + s);
if(s.startsWith("/")) return new UrlPermission(s);
return new WildcardPermission(s);
}
}
public class IdentityException extends RuntimeException{
public IdentityException(String message) {
super(message);
}
}
@Configuration
@Slf4j
public class ShiroConfig {
/**
* 配置Shiro的安全管理器
*/
@Bean
public DefaultWebSecurityManager securityManager(Realm myRealm) {
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
//设置一个Realm,这个Realm是最终用于完成我们的认证号和授权操作的具体对象
securityManager.setRealm(myRealm);
return securityManager;
}
/**
* 配置一个自定义的Realm的bean,最终将使用这个bean返回的对象来完成我们的认证和授权
*/
@Bean
public MyRealm myRealm() {
MyRealm myRealm = new MyRealm();
myRealm.setPermissionResolver(urlPermissionResolver());
myRealm.setRolePermissionResolver(rolePermissionResolver());
return myRealm;
}
// 两个解析器
@Bean
public UrlPermissionResolver urlPermissionResolver() {
return new UrlPermissionResolver();
}
@Bean
public RolePermissionResolver rolePermissionResolver() {
return new RolePermissionResolver();
}
/**
* 配置一个Shiro的过滤器bean,这个bean将配置Shiro相关的一个规则的拦截
* 如什么样的请求可以访问,什么样的请求不可以访问等等
*/
@Bean
public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {
ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean();
shiroFilter.setSecurityManager(securityManager);
/**
* 覆盖默认的user拦截器(默认拦截器解决不了ajax请求 session超时的问题,若有更好的办法请及时反馈作者)
*/
HashMap<String, Filter> myFilters = new HashMap<>();
UrlPermissionFilter uriFilter = new UrlPermissionFilter();
myFilters.put("urlPermissionFilter", uriFilter);//自定义拦截器
shiroFilter.setFilters(myFilters);//添加自定义拦截器
/**
* 配置shiro拦截器
* anon 不需要认证
* authc 需要认证
* user 验证通过或RememberMe登录的都可以
* 当应用开启了rememberMe时,用户下次访问时可以是一个user,但不会是authc,因为authc是需要重新认证的
* 顺序从上到下,优先级依次降低
*/
Map<String, String> hashMap = new LinkedHashMap<>();
hashMap.put("/login", "anon");
hashMap.put("/", "anon");
hashMap.put("/**", "urlPermissionFilter");//anon authc 是shiro自带的内部filter,由于我们重写了filter,所以配置所有uri走我们的filer
shiroFilter.setFilterChainDefinitionMap(hashMap);
return shiroFilter;
}
/**
* Shiro生命周期处理器
*/
@Bean
public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() {
return new LifecycleBeanPostProcessor();
}
/**
* 开启Shiro的注解(如@RequiresRoles,@RequiresPermissions)
* 配置以下两个bean(DefaultAdvisorAutoProxyCreator和AuthorizationAttributeSourceAdvisor)即可实现此功能
* @return
*/
@Bean
public DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator(){
DefaultAdvisorAutoProxyCreator advisorAutoProxyCreator = new DefaultAdvisorAutoProxyCreator();
advisorAutoProxyCreator.setProxyTargetClass(true);
return advisorAutoProxyCreator;
}
@Bean
public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager){
AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor = new AuthorizationAttributeSourceAdvisor();
authorizationAttributeSourceAdvisor.setSecurityManager(securityManager);
return authorizationAttributeSourceAdvisor;
}
}
@RestController
@RequestMapping("/")
public class ShiroController {
@PostMapping("login")
public String login(@RequestBody Account account) {
// 验证账号密码
//创建一个shiro的Subject对象,这个对象始于登录接口,验证通过后shiro保存正确的账号密码,后面再次登录将账号密码封装好token(可不是jwt的密文token)对象扔给shiro做比对即可
Subject subject = SecurityUtils.getSubject();
//创建一个用户账号和密码的Token对象,并设置用户输入的账号和密码
UsernamePasswordToken token = new UsernamePasswordToken(account.getAccountName(), account.getAccountPassword());
try {
//调用login后,Shiro就会自动执行自定义的Realm中的认证方法
subject.login(token);
Session session = subject.getSession();
if (session != null) session.setTimeout(1000 * 60); // 非常关键 这里是shiro缓存session判断登录状态的失效时间,与token失效时间一致即可,不做扩展,如果只想验证token可以传-1跳过认证,只识别token
return "登录成功" + SecureUtil.mkToken(account.getAccountName());
} catch (UnknownAccountException e) {
//表示用户的账号错误,myRealm的认证方法抛出
return "账号不存在";
} catch (LockedAccountException e) {
//表示用户的账号被锁定,myRealm的认证方法抛出
return "账号被锁定";
} catch (IncorrectCredentialsException e) {
//表示用户的密码错误,myRealm的认证方法抛出
return "账号或密码错误";
}
}
@GetMapping("testApi")
public String testApi() {
return "testApi正常访问";
}
}
过滤器异常不会到达全局异常处理器,会重定向至错误页面/error,如果不处理峨口返回格式将不完整,所以对/error路径进行处理
@Controller
public class ErrorControllerImpl implements ErrorController {
@Override
public String getErrorPath() {
return "/error";
}
@RequestMapping("/error")
public void handleError(HttpServletRequest request) throws Throwable {
// Attribute在过滤器里已经传递过了,所以直接强转抛出
if (request.getAttribute("identityException") != null) {
throw (IdentityException) request.getAttribute("identityException");
}
if (request.getAttribute("noneAuthorization") != null) {
throw (IdentityException) request.getAttribute("noneAuthorization");
}
}
}
public class SecureUtil {
public static final String ACCOUNTNAME = "accountName"; //账号名
private static final String AUTH_SECRET = "SEDFGEGEWRWEWYHDG"; //jwt验签盐
// 生成token
public static final String mkToken(String accountName) {
Map<String, Object> map = new HashMap<String, Object>() {
private static final long serialVersionUID = 1L;
{
put(ACCOUNTNAME, accountName);
put("TIMESTAMP", System.currentTimeMillis());
}
};
return JWTUtil.createToken(map, AUTH_SECRET.getBytes());
}
// 验证身份
public static boolean authUser(String token) {
try {
if (StrUtil.isEmpty(token)) return false;
if (!JWTUtil.verify(token, AUTH_SECRET.getBytes())) return false;
Map<String, String> authInfo = SecureUtil.getAuthInfo(token);
if (StrUtil.isEmpty(authInfo.get(ACCOUNTNAME))) return false;
return true;
} catch (Exception e) {
return false;
}
}
// 解析信息
public static Map<String, String> getAuthInfo(String token) {
final JWT jwt = JWTUtil.parseToken(token);
String accountName = jwt.getPayload(ACCOUNTNAME) == null ? null : jwt.getPayload(ACCOUNTNAME).toString();
Map<String, String> map = new HashMap();
map.put(ACCOUNTNAME, accountName);
return map;
}
// 解析token 取出accountName
public static String getAccountName(String token) {
Map<String, String> authInfo = getAuthInfo(token);
if (authInfo.isEmpty() || StrUtil.isEmpty(authInfo.get(ACCOUNTNAME)))
throw new RuntimeException("token未找到accountName");
return authInfo.get(ACCOUNTNAME);
}
}
- 测试样例1 未携带jwt-token访问/testApi接口
这里需要提醒的是,在/login接口中,是指定了登录过期时间的,这个时间是shiro管理的,所以生成token也要定时失效,本实力项目为精简暂未做到统一,请注意,当然直接设置-1可以永久保持登录状态,看项目需求了
关键代码:
if (session != null) session.setTimeout(1000 * 60); // 非常关键 这里是shiro缓存session判断登录状态的失效时间,与token失效时间一致即可,不做扩展
密码错误,账号冻结等情况还请君自测
执行sql给root账户分配接口访问权限:
INSERT INTO account_role(account_id,role_id)VALUES (1,1);
INSERT INTO role(role_name) VALUES ('管理员');
-- 这里的uri,可以是自定义的任何uri
INSERT INTO permission(permission_portrayal,uri)VALUES('访问aipTest接口','/testApi');
INSERT INTO role_permission(role_id,permission_id)VALUES(1,1);
改造testApi:
@RequiresRoles("sysAdmin")
@GetMapping("testApi")
public String testApi() {
return "testApi正常访问";
}
同类注解还有以下注解,请参考其他资料
@RequiresRoles
@RequiresPermissions
@RequiresAuthentication
@RequiresUser
@RequiresGuest
如有疑问请留言,看到必回复