SpringBoot 2.x 开发案例之前后端分离鉴权
前言
阅读本文需要一定的前后端开发基础,前后端分离已成为互联网项目开发的业界标准使用方式,通过Nginx代理+Tomcat
的方式有效的进行解耦,并且前后端分离会为以后的大型分布式架构、弹性计算架构、微服务架构、多端化服务(多种客户端,例如:浏览器,小程序,安卓,IOS等等)打下坚实的基础。这个步骤是系统架构从猿进化成人的必经之路。
其核心思想是前端页面通过AJAX
调用后端的API
接口并使用JSON
数据进行交互。
原始模式
开发者通常使用Servlet、Jsp、Velocity、Freemaker、Thymeleaf
以及各种框架模板标签的方式实现前端效果展示。通病就是,后端开发者从后端撸到前端,前端只负责切切页面,修修图,更有甚者,一些团队都没有所谓的前端。
分离模式
在传统架构模式中,前后端代码存放于同一个代码库中,甚至是同一工程目录下。页面中还夹杂着后端代码。前后端分离以后,前后端分成了两个不同的代码库,通常使用 Vue、React、Angular、Layui
等一系列前端框架实现。
权限校验
回到文章的主题,这里我们使用目前最流行的跨域认证解决方案JSON Web Token
(缩写 JWT
)
pom.xml
引入:
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
工具类,签发JWT
,可以存储简单的用户基础信息,比如用户ID、用户名等等,只要能识别用户信息即可,重要的角色权限不建议存储:
/** * JWT加密和解密的工具类 */ public class JwtUtils { /** * 加密字符串 禁泄漏 */ public static final String SECRET = "e3f4e0ffc5e04432a63730a65f0792b0"; public static final int JWT_ERROR_CODE_NULL = 4000; // Token不存在 public static final int JWT_ERROR_CODE_EXPIRE = 4001; // Token过期 public static final int JWT_ERROR_CODE_FAIL = 4002; // 验证不通过 /** * 签发JWT * @param id * @param subject * @param ttlMillis * @return String */ public static String createJWT(String id, String subject, long ttlMillis) { SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256; long nowMillis = System.currentTimeMillis(); Date now = new Date(nowMillis); SecretKey secretKey = generalKey(); JwtBuilder builder = Jwts.builder() .setId(id) .setSubject(subject) // 主题 .setIssuer("爪哇笔记") // 签发者 .setIssuedAt(now) // 签发时间 .signWith(signatureAlgorithm, secretKey); // 签名算法以及密匙 if (ttlMillis >= 0) { long expMillis = nowMillis + ttlMillis; Date expDate = new Date(expMillis); builder.setExpiration(expDate); // 过期时间 } return builder.compact(); } /** * 验证JWT * @param jwtStr * @return CheckResult */ public static CheckResult validateJWT(String jwtStr) { CheckResult checkResult = new CheckResult(); Claims claims; try { claims = parseJWT(jwtStr); checkResult.setSuccess(true); checkResult.setClaims(claims); } catch (ExpiredJwtException e) { checkResult.setErrCode(JWT_ERROR_CODE_EXPIRE); checkResult.setSuccess(false); } catch (SignatureException e) { checkResult.setErrCode(JWT_ERROR_CODE_FAIL); checkResult.setSuccess(false); } catch (Exception e) { checkResult.setErrCode(JWT_ERROR_CODE_FAIL); checkResult.setSuccess(false); } return checkResult; } /** * 密钥 * @return */ public static SecretKey generalKey() { byte[] encodedKey = Base64.decode(SECRET); SecretKey key = new SecretKeySpec(encodedKey, 0, encodedKey.length, "AES"); return key; } /** * 解析JWT字符串 * @param jwt * @return * @throws Exception Claims */ public static Claims parseJWT(String jwt) { SecretKey secretKey = generalKey(); return Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(jwt) .getBody(); } }
验证实体信息:
/** * 验证信息 */ public class CheckResult { private int errCode; private boolean success; private Claims claims; public int getErrCode() { return errCode; } public void setErrCode(int errCode) { this.errCode = errCode; } public boolean isSuccess() { return success; } public void setSuccess(boolean success) { this.success = success; } public Claims getClaims() { return claims; } public void setClaims(Claims claims) { this.claims = claims; } }
拦截访问配置,跨域访问设置以及请求拦截过滤:
/** * 拦截访问配置 */ @Configuration public class SafeConfig implements WebMvcConfigurer { @Bean public SysInterceptor myInterceptor(){ return new SysInterceptor(); } @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("*") .allowedMethods("GET", "HEAD", "POST", "PUT", "DELETE","OPTIONS") .allowCredentials(false).maxAge(3600); } @Override public void addInterceptors(InterceptorRegistry registry) { String[] patterns = new String[] { "/user/login","/*.html"}; registry.addInterceptor(myInterceptor()) .addPathPatterns("/**") .excludePathPatterns(patterns); } }
拦截器统一权限校验:
/** * 认证拦截器 */ public class SysInterceptor implements HandlerInterceptor { private static final Logger logger = LoggerFactory.getLogger(SysInterceptor.class); @Autowired private SysUserService sysUserService; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler){ if (handler instanceof HandlerMethod){ String authHeader = request.getHeader("token"); if (StringUtils.isEmpty(authHeader)) { logger.info("验证失败"); print(response,Result.error(JwtUtils.JWT_ERROR_CODE_NULL,"签名验证不存在,请重新登录")); return false; }else{ CheckResult checkResult = JwtUtils.validateJWT(authHeader); if (checkResult.isSuccess()) { /** * 权限验证 */ String userId = checkResult.getClaims().getId(); HandlerMethod handlerMethod = (HandlerMethod) handler; Annotation roleAnnotation= handlerMethod.getMethod().getAnnotation(RequiresRoles.class); if(roleAnnotation!=null){ String[] role = handlerMethod.getMethod().getAnnotation(RequiresRoles.class).value(); Logical logical = handlerMethod.getMethod().getAnnotation(RequiresRoles.class).logical(); List<String> list = sysUserService.getRoleSignByUserId(Integer.parseInt(userId)); int count = 0; for(int i=0;i<role.length;i++){ if(list.contains(role[i])){ count++; if(logical==Logical.OR){ continue; } } } if(logical==Logical.OR){ if(count==0){ print(response,Result.error("无权限操作")); return false; } }else{ if(count!=role.length){ print(response,Result.error("无权限操作")); return false; } } } return true; } else { switch (checkResult.getErrCode()) { case SystemConstant.JWT_ERROR_CODE_FAIL: logger.info("签名验证不通过"); print(response,Result.error(checkResult.getErrCode(),"签名验证不通过,请重新登录")); break; case SystemConstant.JWT_ERROR_CODE_EXPIRE: logger.info("签名过期"); print(response,Result.error(checkResult.getErrCode(),"签名过期,请重新登录")); break; default: break; } return false; } } }else{ return true; } } /** * 打印输出 * @param response * @param message void */ public void print(HttpServletResponse response,Object message){ try { response.setStatus(HttpStatus.OK.value()); response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE); response.setHeader("Cache-Control", "no-cache, must-revalidate"); response.setHeader("Access-Control-Allow-Origin", "*"); PrintWriter writer = response.getWriter(); writer.write(JSONObject.toJSONString(message)); writer.flush(); writer.close(); } catch (IOException e) { e.printStackTrace(); } } }
配置角色注解,可以直接把安全框架Shiro
的拷贝过来,如果有需要,菜单权限也可以配置上:
/** * 权限注解 */ @Target({ElementType.TYPE, ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface RequiresRoles { /** * A single String role name or multiple comma-delimitted role names required in order for the method * invocation to be allowed. */ String[] value(); /** * The logical operation for the permission check in case multiple roles are specified. AND is the default * @since 1.1.0 */ Logical logical() default Logical.OR; }
模拟演示代码:
@RestController @RequestMapping("/user") public class UserController { /** * 列表 * @return */ @RequestMapping("/list") @RequiresRoles(value="admin") public Result list() { return Result.ok("十万亿个用户"); } /** * 登录 * @return */ @RequestMapping("/login") public Result login() { /** * 模拟登录过程并返回token */ String token = JwtUtils.createJWT("101","爪哇笔记",1000*60*60); return Result.ok(token); } }
前端请求模拟,发送请求之前在Header
中附带token
信息,更多代码见源码案例:
function login(){ $.ajax({ url : "/user/login", type : "post", dataType : "json", success : function(data) { if(data.code==0){ $.cookie(‘token‘, data.msg); } }, error : function(XMLHttpRequest, textStatus, errorThrown) { } }); } function user(){ $.ajax({ url : "/user/list", type : "post", dataType : "json", success : function(data) { alert(data.msg) }, beforeSend: function(request) { request.setRequestHeader("token", $.cookie(‘token‘)); }, error : function(XMLHttpRequest, textStatus, errorThrown) { } }); } </script>
安全说明
JWT
本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT
的有效期建议设置的相对短一些。对于一些比较重要的权限,使用时应该再次对用户进行数据库认证。为了减少盗用,JWT
强烈建议使用 HTTPS
协议传输。
由于服务器不保存用户状态,因此无法在使用过程中注销某个 token
,或者更改 token
的权限。也就是说,一旦 JWT
签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。