点击上方Java开发联盟,选择“星标公众号”
优质文章,第一时间送达
简介:
前后端分离:
要实现前后端分离,需要考虑以下2个问题:1. 项目不再基于session了,如何知道访问者是谁?2. 如何确认访问者的权限?
前后端分离,一般都是通过token实现,本项目也是一样;用户登录时,生成token及 token过期时间,token与用户是一一对应关系,调用接口的时候,把token放到header或 请求参数中,服务端就知道是谁在调用接口。
后台基于:Springboot +JPA +Knife4j +Shiro
前端基于:VUE +ElementUI
代码已上传到Git:
后台代码:https://github.com/FENGZHIJIE1998/shiro-auth
前端代码:https://github.com/FENGZHIJIE1998/shiro-vue
注意:主要观察token的使用方法!
1、新建SpringBoot工程
项目目录结构:
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 org.springframework.boot spring-boot-starter-parent 2.3.2.RELEASE com.example.demo2 demo2 0.0.1-SNAPSHOT demo2 Demo project for Spring Boot 1.8 org.springframework.boot spring-boot-starter org.springframework.boot spring-boot-starter-test test org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-validation org.springframework.boot spring-boot-starter-data-jpa org.springframework.boot spring-boot-starter-jdbc org.projectlombok lombok org.apache.shiro shiro-spring 1.3.2 mysql mysql-connector-java runtime com.alibaba druid-spring-boot-starter 1.1.10 com.spring4all swagger-spring-boot-starter 1.8.0.RELEASE com.github.xiaoymin knife4j-spring-boot-starter 2.0.2 commons-lang commons-lang 2.6 org.springframework.boot spring-boot-maven-plugin
1、application.yml文件
server: port: 8080 tomcat: uri-encoding: utf-8spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/myweb?serverTimezone=UTC&useSSL=false username: root password: 123456 resources: static-locations: classpath:/templates
这里的数据库地址一定要填写正确,还有就是你连接数据库的用户名和密码!
为了方便这里不做密码加盐加密:
@RestControllerpublic class ShiroController { private final ShiroService shiroService; public ShiroController(ShiroService shiroService) { this.shiroService = shiroService; } /** * 登录 */ @ApiOperation(value = "登陆", notes = "参数:用户名 密码") @PostMapping("/sys/login") public Map login(@RequestBody @Validated LoginDTO loginDTO, BindingResult bindingResult) { Map result = new HashMap<>(); if (bindingResult.hasErrors()) { result.put("status", 400); result.put("msg", bindingResult.getFieldError().getDefaultMessage()); return result; } String username = loginDTO.getUsername(); String password = loginDTO.getPassword(); //用户信息 User user = shiroService.findByUsername(username); //账号不存在、密码错误 if (user == null || !user.getPassword().equals(password)) { result.put("status", 400); result.put("msg", "账号或密码有误"); } else { //生成token,并保存到数据库 result = shiroService.createToken(user.getUserId()); result.put("status", 200); result.put("msg", "登陆成功"); } return result; } /** * 退出 */ @ApiOperation(value = "登出", notes = "参数:token") @PostMapping("/sys/logout") public Map logout(@RequestHeader("token")String token) { Map result = new HashMap<>(); shiroService.logout(token); result.put("status", 200); result.put("msg", "您已安全退出系统"); return result; }}
主要是生成一个token返回给前端。
@Servicepublic class ShiroServiceImpl implements ShiroService { @Autowired private UserRepository userRepository; @Autowired private SysTokenRepository sysTokenRepository; /** * 根据username查找用户 * * @param username * @return User */ @Override public User findByUsername(String username) { User user = userRepository.findByUsername(username); return user; } //12小时后过期 private final static int EXPIRE = 3600 * 12; @Override /** * 生成一个token *@param [userId] *@return Result */ public Map createToken(Integer userId) { Map result = new HashMap<>(); //生成一个token String token = TokenGenerator.generateValue(); //当前时间 Date now = new Date(); //过期时间 Date expireTime = new Date(now.getTime() + EXPIRE * 1000); //判断是否生成过token SysToken tokenEntity = sysTokenRepository.findByUserId(userId); if (tokenEntity == null) { tokenEntity = new SysToken(); tokenEntity.setUserId(userId); tokenEntity.setToken(token); tokenEntity.setUpdateTime(now); tokenEntity.setExpireTime(expireTime); //保存token sysTokenRepository.save(tokenEntity); } else { tokenEntity.setToken(token); tokenEntity.setUpdateTime(now); tokenEntity.setExpireTime(expireTime); //更新token sysTokenRepository.save(tokenEntity); } result.put("token", token); result.put("expire", EXPIRE); return result; } @Override public void logout(String token) { SysToken byToken = findByToken(token); //生成一个token token = TokenGenerator.generateValue(); //修改token SysToken tokenEntity = new SysToken(); tokenEntity.setUserId(byToken.getUserId()); tokenEntity.setToken(token); sysTokenRepository.save(tokenEntity); } @Override public SysToken findByToken(String accessToken) { return sysTokenRepository.findByToken(accessToken); } @Override public User findByUserId(Integer userId) { return userRepository.findByUserId(userId); }}
@Configurationpublic class ShiroConfig { @Bean("securityManager") public SecurityManager securityManager(AuthRealm authRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(authRealm); securityManager.setRememberMeManager(null); return securityManager; } @Bean("shiroFilter") public ShiroFilterFactoryBean shirFilter(SecurityManager securityManager) { ShiroFilterFactoryBean shiroFilter = new ShiroFilterFactoryBean(); shiroFilter.setSecurityManager(securityManager); //oauth过滤 Map filters = new HashMap<>(); filters.put("auth", new AuthFilter()); shiroFilter.setFilters(filters); Map filterMap = new LinkedHashMap<>(); filterMap.put("/webjars/**", "anon"); filterMap.put("/druid/**", "anon"); filterMap.put("/sys/login", "anon"); filterMap.put("/swagger/**", "anon"); filterMap.put("/v2/api-docs", "anon"); filterMap.put("/swagger-ui.html", "anon"); filterMap.put("/swagger-resources/**", "anon"); filterMap.put("/**", "auth"); shiroFilter.setFilterChainDefinitionMap(filterMap); return shiroFilter; } @Bean("lifecycleBeanPostProcessor") public LifecycleBeanPostProcessor lifecycleBeanPostProcessor() { return new LifecycleBeanPostProcessor(); } @Bean public AuthorizationAttributeSourceAdvisor authorizationAttributeSourceAdvisor(SecurityManager securityManager) { AuthorizationAttributeSourceAdvisor advisor = new AuthorizationAttributeSourceAdvisor(); advisor.setSecurityManager(securityManager); return advisor; }}
阅读AuthenticatingFilter抽象类中executeLogin方法,我们发现调用 了subject.login(token),这是shiro的登录方法,且需要token参数,我们自定义 AuthToken类,只要实现AuthenticationToken接口,就可以了。
//AuthenticatingFilter中的executeLogin()protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { AuthenticationToken token = createToken(request, response); if (token == null) { String msg = "createToken method implementation returned null. A valid non-null AuthenticationToken " + "must be created in order to execute a login attempt."; throw new IllegalStateException(msg); } try { Subject subject = getSubject(request, response); //重点! subject.login(token); return onLoginSuccess(token, subject, request, response); } catch (AuthenticationException e) { return onLoginFailure(token, e, request, response); } }
/** * 自定义AuthenticationToken类 */public class AuthToken extends UsernamePasswordToken{ private String token; public AuthToken(String token) { this.token = token; } @Override public Object getPrincipal() { return token; } @Override public Object getCredentials() { return token; }}
这里我实现的时候出现了Token不匹配的Bug。DeBug下可以查到源头是代码是用 UsernamePasswordToken.class和我自定义的AuthToken.class配对。按道理应该是true,却返回了false...于是我就把自定义的AuthToken不实现AuthenticationToken,转为继承UsernamePasswordToken,就可以了。(renren-fast中却可以,可能是版本的问题)
随后修改:为了避免误导,将上诉代码 AuthenticationToken 修改为 UsernamePasswordToken,并且走了一下源码,发现这个getAuthenticationTokenClass()实际上获取到的是UsernamePasswordToken.class
再回头看看renren-fast中的源码,原来他重写了supports方法!
@Componentpublic class AuthRealm extends AuthorizingRealm { @Autowired private ShiroService shiroService; @Override /** * 授权 获取用户的角色和权限 *@param [principals] *@return org.apache.shiro.authz.AuthorizationInfo */ protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { //1. 从 PrincipalCollection 中来获取登录用户的信息 User user = (User) principals.getPrimaryPrincipal(); //Integer userId = user.getUserId(); //2.添加角色和权限 SimpleAuthorizationInfo simpleAuthorizationInfo = new SimpleAuthorizationInfo(); for (Role role : user.getRoles()) { //2.1添加角色 simpleAuthorizationInfo.addRole(role.getRoleName()); for (Permission permission : role.getPermissions()) { //2.1.1添加权限 simpleAuthorizationInfo.addStringPermission(permission.getPermission()); } } return simpleAuthorizationInfo; } @Override /** * 认证 判断token的有效性 *@param [token] *@return org.apache.shiro.authc.AuthenticationInfo */ protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { //获取token,既前端传入的token String accessToken = (String) token.getPrincipal(); //1. 根据accessToken,查询用户信息 SysToken tokenEntity = shiroService.findByToken(accessToken); //2. token失效 if (tokenEntity == null || tokenEntity.getExpireTime().getTime() < System.currentTimeMillis()) { throw new IncorrectCredentialsException("token失效,请重新登录"); } //3. 调用数据库的方法, 从数据库中查询 username 对应的用户记录 User user = shiroService.findByUserId(tokenEntity.getUserId()); //4. 若用户不存在, 则可以抛出 UnknownAccountException 异常 if (user == null) { throw new UnknownAccountException("用户不存在!"); } //5. 根据用户的情况, 来构建 AuthenticationInfo 对象并返回. 通常使用的实现类为: SimpleAuthenticationInfo SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(user, accessToken, this.getName()); return info; }}
/** * Shiro自定义auth过滤器 */@Componentpublic class AuthFilter extends AuthenticatingFilter { // 定义jackson对象 private static final ObjectMapper MAPPER = new ObjectMapper(); /** * 生成自定义token * * @param request * @param response * @return * @throws Exception */ @Override protected AuthenticationToken createToken(ServletRequest request, ServletResponse response) throws Exception { //获取请求token String token = TokenUtil.getRequestToken((HttpServletRequest) request); return new AuthToken(token); } /** * 步骤1.所有请求全部拒绝访问 * * @param request * @param response * @param mappedValue * @return */ @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { if (((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name())) { return true; } return false; } /** * 步骤2,拒绝访问的请求,会调用onAccessDenied方法,onAccessDenied方法先获取 token,再调用executeLogin方法 * * @param request * @param response * @return * @throws Exception */ @Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { //获取请求token,如果token不存在,直接返回 String token = TokenUtil.getRequestToken((HttpServletRequest) request); if (StringUtils.isBlank(token)) { HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setHeader("Access-Control-Allow-Credentials", "true"); httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtil.getOrigin()); httpResponse.setCharacterEncoding("UTF-8"); Map result = new HashMap<>(); result.put("status", 400); result.put("msg", "请先登录"); String json = MAPPER.writeValueAsString(result); httpResponse.getWriter().print(json); return false; } return executeLogin(request, response); } /** * token失效时候调用 */ @Override protected boolean onLoginFailure(AuthenticationToken token, AuthenticationException e, ServletRequest request, ServletResponse response) { HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setContentType("application/json;charset=utf-8"); httpResponse.setHeader("Access-Control-Allow-Credentials", "true"); httpResponse.setHeader("Access-Control-Allow-Origin", HttpContextUtil.getOrigin()); httpResponse.setCharacterEncoding("UTF-8"); try { //处理登录失败的异常 Throwable throwable = e.getCause() == null ? e : e.getCause(); Map result = new HashMap<>(); result.put("status", 400); result.put("msg", "登录凭证已失效,请重新登录"); String json = MAPPER.writeValueAsString(result); httpResponse.getWriter().print(json); } catch (IOException e1) { } return false; }}
接着我们打上断点按照代码走走。
1、前端发起请求首先会进入AuthFilter的 isAccessAllowed(),除了OPTION方法,其余都拦截。
2、拦截之后进入AuthFilter的onAccessDenied(),这里获取token后判断token是否isBlank。如果是,代表请求未携带token,直接默认返回400,未登录给前端,流程就结束了。如果携带了token则进入第三步,继续流程。
3. 接着进入AuthFilter的createToken,这里生成我们自定义的AuthToken对象。
4. 接着就会来到AuthRealm中的doGetAuthenticationInfo(),在这个方法中继续token的有效性校验,例如过期、和数据库的token对不上(用户已退出)的情况。如果校验失败,进入第5步,否则进入第6步。
5. token失效后回到AuthFilter中的onLoginFailure(),返回400以及msg,流程结束。
6. Token校验成功后进入AuthRealm的doGetAuthorizationInfo(),进行获取当前用户拥有的权限,之后底层代码会进行权限验证。如果用户有权限则会进入请求方法,否则抛出异常。到这一步校验过程就结束了。
熬完上面的步骤了,这时候总体的架构已经确立好了,下面让我们来看看效果如何
DTO
/** * 登录传输类 */@Datapublic class LoginDTO { @NotBlank(message = "用户名不能为空") private String username; @NotBlank(message = "密码不能为空") private String password;}
实体类
@Getter@Setter@Entitypublic class User { @Id private Integer userId; private String username; private String password; @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "user_role", joinColumns = {@JoinColumn(name = "USER_ID", referencedColumnName = "userId")}, inverseJoinColumns = {@JoinColumn(name = "ROLE_ID", referencedColumnName = "roleId")}) private Set roles;}@Getter@Setter@Entitypublic class Role { @Id private Integer roleId; private String roleName; @ManyToMany(fetch = FetchType.EAGER) @JoinTable(name = "role_permission", joinColumns = {@JoinColumn(name = "ROLE_ID", referencedColumnName = "roleId")}, inverseJoinColumns = {@JoinColumn(name = "PERMISSION_ID", referencedColumnName = "permissionId")}) private Set permissions;}@Getter@Setter@Entitypublic class Permission { @Id private Integer permissionId; private String permissionName; private String permission;}@Getter@Setter@Entitypublic class SysToken{ @Id private Integer userId; private String token; private Date expireTime; private Date updateTime}
以及给实体类附上权限:
我定义了三个用户
用户 | 角色 | 权限 |
Jack | SVIP | select;save;delete;update |
Rose | VIP | select;save;update |
Paul | P | select |
/*Navicat MySQL Data TransferSource Server : localhostSource Server Version : 50549Source Host : localhost:3306Source Database : shiroTarget Server Type : MYSQLTarget Server Version : 50549File Encoding : 65001*/SET FOREIGN_KEY_CHECKS=0;-- ------------------------------ Table structure for permission-- ----------------------------DROP TABLE IF EXISTS `permission`;CREATE TABLE `permission` ( `permission_id` int(11) NOT NULL, `permission` varchar(255) DEFAULT NULL, `permission_name` varchar(255) DEFAULT NULL, PRIMARY KEY (`permission_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;-- ------------------------------ Records of permission-- ----------------------------INSERT INTO `permission` VALUES ('1', 'select', '查看');INSERT INTO `permission` VALUES ('2', 'update', '更新');INSERT INTO `permission` VALUES ('3', 'delete', '删除');INSERT INTO `permission` VALUES ('4', 'save', '新增');-- ------------------------------ Table structure for role-- ----------------------------DROP TABLE IF EXISTS `role`;CREATE TABLE `role` ( `role_id` int(11) NOT NULL, `role_name` varchar(255) DEFAULT NULL, PRIMARY KEY (`role_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;-- ------------------------------ Records of role-- ----------------------------INSERT INTO `role` VALUES ('1', 'svip');INSERT INTO `role` VALUES ('2', 'vip');INSERT INTO `role` VALUES ('3', 'p');-- ------------------------------ Table structure for role_permission-- ----------------------------DROP TABLE IF EXISTS `role_permission`;CREATE TABLE `role_permission` ( `role_id` int(11) NOT NULL, `permission_id` int(11) NOT NULL, PRIMARY KEY (`role_id`,`permission_id`), KEY `FKf8yllw1ecvwqy3ehyxawqa1qp` (`permission_id`), CONSTRAINT `FKa6jx8n8xkesmjmv6jqug6bg68` FOREIGN KEY (`role_id`) REFERENCES `role` (`role_id`), CONSTRAINT `FKf8yllw1ecvwqy3ehyxawqa1qp` FOREIGN KEY (`permission_id`) REFERENCES `permission` (`permission_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;-- ------------------------------ Records of role_permission-- ----------------------------INSERT INTO `role_permission` VALUES ('1', '1');INSERT INTO `role_permission` VALUES ('2', '1');INSERT INTO `role_permission` VALUES ('3', '1');INSERT INTO `role_permission` VALUES ('1', '2');INSERT INTO `role_permission` VALUES ('2', '2');INSERT INTO `role_permission` VALUES ('1', '3');INSERT INTO `role_permission` VALUES ('1', '4');INSERT INTO `role_permission` VALUES ('2', '4');-- ------------------------------ Table structure for user-- ----------------------------DROP TABLE IF EXISTS `user`;CREATE TABLE `user` ( `user_id` int(11) NOT NULL, `password` varchar(255) DEFAULT NULL, `username` varchar(255) DEFAULT NULL, PRIMARY KEY (`user_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;-- ------------------------------ Records of user-- ----------------------------INSERT INTO `user` VALUES ('1', '123', 'Jack');INSERT INTO `user` VALUES ('2', '123', 'Rose');INSERT INTO `user` VALUES ('3', '123', 'Paul');-- ------------------------------ Table structure for user_role-- ----------------------------DROP TABLE IF EXISTS `user_role`;CREATE TABLE `user_role` ( `user_id` int(11) NOT NULL, `role_id` int(11) NOT NULL, PRIMARY KEY (`user_id`,`role_id`), KEY `FKa68196081fvovjhkek5m97n3y` (`role_id`), CONSTRAINT `FK859n2jvi8ivhui0rl0esws6o` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`), CONSTRAINT `FKa68196081fvovjhkek5m97n3y` FOREIGN KEY (`role_id`) REFERENCES `role` (`role_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;-- ------------------------------ Table structure for sys_token-- ----------------------------CREATE TABLE `sys_token` ( `user_id` int(11) NOT NULL, `expire_time` datetime DEFAULT NULL, `token` varchar(255) DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`user_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8;-- ------------------------------ Records of user_role-- ----------------------------INSERT INTO `user_role` VALUES ('1', '1');INSERT INTO `user_role` VALUES ('2', '2');INSERT INTO `user_role` VALUES ('3', '3');
测试类:因为我是用Swagger来测试,所以为了方便就直接传递token参数。具体开发时候可由前端把接收到的token放入Header。
@RestController("/test")public class TestController { @RequiresPermissions({"save"}) //没有的话 AuthorizationException @PostMapping("/save") public Map save(String token) { System.out.println("save"); Map map = new HashMap(); map.put("status", 200); map.put("msg", "当前用户有save的权力"); return map; } @RequiresPermissions({"delete"}) //没有的话 AuthorizationException @DeleteMapping("/delete") public Map delete(String token) { System.out.println("delete"); Map map = new HashMap(); map.put("status", 200); map.put("msg", "当前用户有delete的权力"); return map; } @RequiresPermissions({"update"}) //没有的话 AuthorizationException @PutMapping("update") public Map update(String token) { System.out.println("update"); Map map = new HashMap(); map.put("status", 200); map.put("msg", "当前用户有update的权力"); return map; } @RequiresPermissions({"select"}) //没有的话 AuthorizationException @GetMapping("select") public Map select(String token, HttpSession session) { System.out.println("select"); Map map = new HashMap(); map.put("status", 200); map.put("msg", "当前用户有select的权力"); return map; } @RequiresRoles({"vip"}) //没有的话 AuthorizationException @GetMapping("/vip") public Map vip(String token) { System.out.println("vip"); Map map = new HashMap(); map.put("status", 200); map.put("msg", "当前用户有VIP角色"); return map; } @RequiresRoles({"svip"}) //没有的话 AuthorizationException @GetMapping("/svip") public Map svip(String token) { System.out.println("svip"); Map map = new HashMap(); map.put("status", 200); map.put("msg", "当前用户有SVIP角色"); return map; } @RequiresRoles({"p"}) //没有的话 AuthorizationException @GetMapping("/p") public Map p(String token) { System.out.println("p"); Map map = new HashMap(); map.put("status", 200); map.put("msg", "当前用户有P角色"); return map; }}
ExceptionHandler 异常处理器,用于捕获无权限时候的异常。
@ControllerAdvicepublic class MyExceptionHandler { @ExceptionHandler(value = AuthorizationException.class) @ResponseBody public Map handleException(AuthorizationException e) { //e.printStackTrace(); Map result = new HashMap(); result.put("status", "400"); //获取错误中中括号的内容 String message = e.getMessage(); String msg=message.substring(message.indexOf("[")+1,message.indexOf("]")); //判断是角色错误还是权限错误 if (message.contains("role")) { result.put("msg", "对不起,您没有" + msg + "角色"); } else if (message.contains("permission")) { result.put("msg", "对不起,您没有" + msg + "权限"); } else { result.put("msg", "对不起,您的权限有误"); } return result; }}
启动项目来看看效果: 访问 localhost:98080/shiro/doc.html
登陆成功:
登录成功后会返回token,记得带上token访问以下接口
有某个角色时候:
没有某个角色的时候:
有某个权力时候:
没有某个权力的时候:
退出系统:
原本的token就失效了,我们再访问原本可以访问的接口看看
正常访问:
非法访问:
重点:当未登录时候访问项目内部页面,由前端控制路由返回登录页,并不会出现可恶的login.jsp,这里我们故意改变数据库token来展示效果。
至于最后没有权利或角色返回的json字符串是因为他抛出AuthorizationException。可以自定义全局异常处理器进行处理。通过这种token达到即可达到前后端分离开发。各位客官,点个赞吧
简单小例子,仅供学习参考!
温暖提示为了方便大家更好的学习,本公众号经常分享项目干货源码案例给大家去练习,如果本公众号没有你要学习的功能案例,你可以联系小编(微信:wcy18898375730)提供你帮助哦!
推荐案例1、springboot+Mybatis+vue前后端分离开发:作业管理系统
2、SpringMVC +Spring+ Mybatis 的完整小项目
3、springboot+mybatis实现excel文件上传
4、springboot+mybatis+vue实现注册登录功能
5、Java多线程总结(一)
6、多线程之间实现同步(二)