前面我们实现了前后分离项目基础的数据交互以及前端数据展示。用户登录部分一直是模拟登录,今天我们实现系统的身份认证部分让系统不在裸奔。
1、系统认证授权
认证就是要核验用户的身份,比如说通过用户名和密码来检验用户的身份。说简单一些,认证就是登陆。登陆之后Shiro要记录用户成功登陆的凭证。
授权是比认证更加精细度的划分用户的行为。比如说一个教务管理系统中,学生登陆之后只能查看信息,不能修改信息。而班主任就可以修改学生的信息。这就是利用授权来限定不同身份用户的行为。
安全是应用中不可缺少的功能,相较于其他认证与授权框架,Shiro设计的非常简单,所以广受好评。任意JavaWeb项目都可以使用Shiro框架。
我们采用shiro + JWT架构来实现系统安全认证。只使用jwt只能实现基础验证功能,所以我们把token存储再redis中,利用redis我们可以实现token踢出、token刷新等功能。
Shiro可以利用HttpSession或者Redis存储用户的登陆凭证,以及角色或者身份信息。然后利用过滤器(Filter),对每个Http请求过滤,检查请求对应的HttpSession或者Redis中的认证与授权信息。如果用户没有登陆,或者权限不够,那么Shiro会向客户端返回错误信息。
JWT(Json Web Token), 是为了在网络应用环境间传递声明而执行的一种基于JSON的开放标准。JWT一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。
如果用户的登陆凭证经过加密(Token)保存在客户端,客户端每次提交请求的时候,把Token上传给后端服务器节点。即便后端项目使用了负载均衡,每个后端节点接收到客户端上传的Token之后,经过检测,是有效的Token,于是就断定用户已经成功登陆,接下来就可以提供后端服务了。
传统的HttpSession依靠浏览器的Cookie存放SessionId,所以要求客户端必须是浏览器。现在的JavaWeb系统,客户端可以是浏览器、APP、小程序,以及物联网设备。为了让这些设备都能访问到JavaWeb项目,就必须要引入JWT技术。JWT的Token是纯字符串,至于客户端怎么保存,没有具体要求。只要客户端发起请求的时候,附带上Token即可。所以像物联网设备,我们可以用SQLite存储Token数据。
认证流程
2、SpringBoot集成redis
redis是一个key-value
2.1、添加pom依赖
org.springframework.boot spring-boot-starter-data-redis io.lettuce lettuce-core redis.clients jedis
2.2 添加启动类
@Configurationpublic class RedisConfig { @Bean public RedisTemplate redisTemplate(RedisConnectionFactory factory) { RedisTemplate template = new RedisTemplate(); template.setConnectionFactory(factory); // key采用String的序列化方式 template.setKeySerializer(new StringRedisSerializer()); // hash的key也采用String的序列化方式 template.setHashKeySerializer(new StringRedisSerializer()); // value序列化方式采用jackson template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // hash的value序列化方式采用jackson template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet(); return template; }
2.3、配置文件
spring.redis.host=127.0.0.1spring.redis.port=6379spring.redis.timeout=1000spring.redis.database=0
3、SpringBoot集成shiro
3.1、添加依赖
1.2.4 org.apache.shiro shiro-core ${shiro.version} org.apache.shiro shiro-ehcache ${shiro.version} org.apache.shiro shiro-spring ${shiro.version} org.apache.shiro shiro-web ${shiro.version} io.jsonwebtoken jjwt 0.7.0 commons-io commons-io 1.3.2 org.apache.commons commons-lang3 3.8 com.alibaba fastjson 1.2.58
3.2、添加启动类
@Configurationpublic class ShiroConfig { @Bean("shiroFilter") public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager) { ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean(); shiroFilterFactoryBean.setSecurityManager(securityManager); //拦截器 Map filterChainDefinitionMap = new LinkedHashMap(); // 配置不会被拦截的链接 顺序判断 filterChainDefinitionMap.put("/druid/**", "anon"); filterChainDefinitionMap.put("/swagger**/**", "anon"); filterChainDefinitionMap.put("/webjars/**", "anon"); filterChainDefinitionMap.put("/v2/**", "anon"); filterChainDefinitionMap.put("/doc.html", "anon"); filterChainDefinitionMap.put("/document.html", "anon"); filterChainDefinitionMap.put("/configuration/ui", "anon"); filterChainDefinitionMap.put("/swagger-resources", "anon"); filterChainDefinitionMap.put("/authentication/**", "anon"); filterChainDefinitionMap.put("/js/**", "anon"); filterChainDefinitionMap.put("/css/**", "anon"); filterChainDefinitionMap.put("/actuator/**", "anon"); filterChainDefinitionMap.put("/sys/login/login", "anon"); Map filterMap = new HashMap(1); filterMap.put("authc", new JwtFilter()); shiroFilterFactoryBean.setFilters(filterMap); //<!-- 过滤链定义,从上向下顺序执行,一般将/**放在最为下边 filterChainDefinitionMap.put("/**", "authc"); shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap); return shiroFilterFactoryBean; } @Bean("securityManager") public SecurityManager securityManager(MyRealm myRealm) { DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager(); securityManager.setRealm(myRealm); DefaultSubjectDAO subjectDAO = new DefaultSubjectDAO(); DefaultSessionStorageEvaluator defaultSessionStorageEvaluator = new DefaultSessionStorageEvaluator(); defaultSessionStorageEvaluator.setSessionStorageEnabled(false); subjectDAO.setSessionStorageEvaluator(defaultSessionStorageEvaluator); securityManager.setSubjectDAO(subjectDAO); return securityManager; }}
3.3、shiro实现
3.3.1、MyRealm实现类
@Componentpublic class MyRealm extends AuthorizingRealm { @Autowired private ISysUserService sysUserService; @Autowired private RedisTemplate redisTemplate; /** * 必须重写此方法,不然Shiro会报错 */ @Override public boolean supports(AuthenticationToken token) { return token instanceof JwtToken; } /** * 只有当需要检测用户权限的时候才会调用此方法,例如checkRole,checkPermission之类的 */ @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { return new SimpleAuthorizationInfo(); } /** * 默认使用此方法进行用户名正确与否验证,错误抛出异常即可 */ @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken auth) throws AuthenticationException { String token = (String) auth.getCredentials(); if (token == null) { throw new AuthenticationException("token为空!"); } // 校验token有效性 Object user = this.checkToken(token); return new SimpleAuthenticationInfo(user, token, getName()); } /** * 校验token的有效性 */ public Object checkToken(String token) throws AuthenticationException { // 解密获得username,用于和数据库进行对比 String userId = JwtUtil.getUserId(token); if (StringUtils.isBlank(userId)) { throw new AuthenticationException("token无效"); } SysUser sysUser = sysUserService.getById(userId); if (sysUser == null) { throw new AuthenticationException("用户不存在!"); } // 判断用户状态 if (Constant.no.equals(sysUser.getStatus())) { throw new AuthenticationException("账号已被锁定,请联系管理员!"); } // 校验token是否超时失效 & 或者账号密码是否错误 if (!jwtTokenRefresh(token, String.valueOf(sysUser.getId()))) { throw new AuthenticationException("Token失效,请重新登录!"); } return sysUser; } /** * 刷新token */ public boolean jwtTokenRefresh(String token, String userId) { String cacheToken = (String) redisTemplate.opsForValue().get(Constant.CATCHE_TOKEN + token); if (StringUtils.isNotEmpty(cacheToken)) { // 校验token有效性 if (!JwtUtil.verify(cacheToken)) { String newToken = JwtUtil.sign(userId); // 设置超时时间 redisTemplate.opsForValue().set(Constant.CATCHE_TOKEN + token, newToken, JwtUtil.expire * 2, TimeUnit.SECONDS); } return true; } return false; } }
3.3.2、过滤器实现
public class JwtFilter extends BasicHttpAuthenticationFilter { /** * 执行登录认证 */ @Override protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) { if(((HttpServletRequest) request).getMethod().equals(RequestMethod.OPTIONS.name())){ return true; } return super.isAccessAllowed(request, response, mappedValue); } /** *认证失败回调方法 */ @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"); try { //处理登录失败的异常 Throwable throwable = e.getCause() == null ? e : e.getCause(); String json = JSONObject.toJSONString(Result.noAccess().info(throwable.getMessage())); httpResponse.getWriter().print(json); } catch (Exception exp) { exp.printStackTrace(); } return false; } @Overrideprotected AuthenticationToken createToken(ServletRequest request,ServletResponse response) { //获取请求token String token = getRequestToken((HttpServletRequest) request); if(StringUtils.isBlank(token)){ return null; } return new JwtToken(token);} /** * 处理未经身份验证的请求 */@Overrideprotected boolean onAccessDenied(ServletRequest request,ServletResponse response) throws Exception { //获取请求token,如果token不存在,直接返回401 String token = getRequestToken((HttpServletRequest) request); if(StringUtils.isBlank(token)){ HttpServletResponse httpResponse = (HttpServletResponse) response; httpResponse.setHeader("Access-Control-Allow-Credentials", "true"); httpResponse.setContentType("application/json;charset=utf-8"); String json = JSONObject.toJSONString(Result.noAccess()); httpResponse.getWriter().print(json); return false; } return executeLogin(request, response);} /** * 获取请求中的token */ private String getRequestToken(HttpServletRequest httpRequest){ //从header中获取token String token = httpRequest.getHeader(Constant.token); //如果header中不存在token,则从参数中获取token if(StringUtils.isBlank(token)||("null").equals(token)){ token = httpRequest.getParameter(Constant.token); } //如果header中不存在token,则从参数中获取token if(StringUtils.isBlank(token)||("null").equals(token)){ Cookie[] cookies = httpRequest.getCookies(); if(cookies != null){ for(Cookie cookie : cookies){ if(cookie.getName().equals(Constant.token)){ token = cookie.getValue(); } } } } return token; }}
3.3.3、JwtUtil工具类
@Slf4j@ConfigurationProperties(prefix = "mir.jwt")@Component@Datapublic class JwtUtil { private static String secret; public static long expire; /** * 生成jwt token */ public static String sign(String userId) { //过期时间 Date expireDate = new Date(System.currentTimeMillis() + expire * 1000); return Jwts.builder() .setHeaderParam("typ", "JWT") .setSubject(userId) .setIssuedAt(new Date()) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } /** * 校验token */ public static boolean verify(String token) { try { Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); return true; }catch (Exception e){ return false; } } /** * 获取userId */ public static String getUserId(String token) { try { Claims claims=Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); return claims.getSubject(); }catch (ExpiredJwtException e){ Claims claims = e.getClaims(); return claims.getSubject(); }catch (Exception e){ return null; } } public String getSecret() { return secret; } public void setSecret(String secret) { JwtUtil.secret = secret; } public long getExpire() { return expire; } public void setExpire(long expire) { JwtUtil.expire = expire; }}
3.3.4、JwtToken实体
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; }}
3.3.5、自定义配置文件
mir.jwt.secret = 123456mir.jwt.expire = 900
4、登录功能实现
4.1、后端登录接口实现
@ApiOperation(value = "账号密码方式登录")@PostMapping("/login")public Result login(@Valid LoginParam loginParam)throws Exception { try { User user=new User(); user.setAccount(loginParam.getAccount()); List list = userService.list(new QueryWrapper(user)); if(list!=null&&list.size()>0){ user=list.get(0); if(Constant.LOCK.equals(user.getStatus())){ if(user.getLockTime()!=null) { int min = (int) (System.currentTimeMillis() - user.getLockTime().getTime())/(1000 * 60); if(min>=lock_time){ user.setLockTime(null); user.setStatus(Constant.YES); user.setErrNum(0); userService.updateById(user); } } } if(Constant.YES.equals(user.getStatus())){ if(loginParam.getPassword().equals(user.getPassword())){ if(user.getErrNum()!=null&&user.getErrNum()>0){ user.setErrNum(0); user.setLockTime(null); userService.updateById(user); } String token= JwtUtil.sign(String.valueOf(user.getId())); redisTemplate.opsForValue().set(Constant.CATCHE_TOKEN + token,token,JwtUtil.expire * 2, TimeUnit.SECONDS); HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse(); Cookie cookie = new Cookie("token", token); cookie.setMaxAge(604800); cookie.setHttpOnly(true); cookie.setPath("/"); response.addCookie(cookie); return Result.ok().info(token); }else{ Integer errNum=0; if(user.getErrNum()!=null){ errNum=user.getErrNum()+1; }else{ errNum=errNum+1; } if(errNum>=max_err_num){ user.setLockTime(new Date()); user.setStatus(Constant.LOCK); }else{ user.setErrNum(errNum); } userService.updateById(user); return Result.error().message("用户密码错误,再错误"+(max_err_num-errNum)+"次,用户将锁定"); } }else if(Constant.LOCK.equals(user.getStatus())){ return Result.error().message("账号已锁定"); }else { return Result.error().message("账号已停用"); } }else{ return Result.error().message("账号或密码错误"); } } catch (Exception e){ log.error("登录异常", e); return Result.error().message("登录失败"); }}
4.2、前端统一请求
4.2.1、请求方法封装
创建http工具类
import Vue from 'vue'import axios from 'axios'import router from '@/router'import qs from 'qs'import merge from 'lodash/merge'import { clearLoginInfo } from '@/utils' const http = axios.create({ timeout: 1000 * 3, withCredentials: true, headers: { 'Content-Type': 'application/json; charset=utf-8' }}) /** * 请求拦截 */http.interceptors.request.use(config => { config.headers['token'] = Vue.cookie.get('token') // 请求头带上token return config}, error => { return Promise.reject(error)}) /** * * 响应拦截 */http.interceptors.response.use(response => { if (response.data.code === 401) { // 401, token失效 clearLoginInfo() router.push({ name: 'login' ,params: { data: response.data.messages }}) } return response}, error => { return Promise.reject(error)}) /** * 请求地址处理 * @param {*} actionName action方法名称 */http.adornUrl = (actionName) => { // 非生产环境 && 开启代理, 接口前缀统一使用[/proxyApi/]前缀做代理拦截! return (process.env.OPEN_PROXY ? '/proxyApi' : window.SITE_CONFIG.baseUrl) + actionName}/** * 请求方法处理 * @param methodName * @returns {*} */http.adornMethod = (methodName) =>{ return methodName;}/** * get请求参数处理 * @param {*} params 参数对象 * @param {*} openDefultParams 是否开启默认参数? */http.adornParams = (params = {}, openDefultParams = true) => { var defaults = { 't': new Date().getTime() } return openDefultParams ? merge(defaults, params) : params} /** * post请求数据处理 * @param {*} data 数据对象 * @param {*} openDefultdata 是否开启默认数据? * @param {*} contentType 数据格式 * json: 'application/json; charset=utf-8' * form: 'application/x-www-form-urlencoded; charset=utf-8' */http.adornData = (data = {}, openDefultdata = true, contentType = 'json') => { var defaults = { 't': new Date().getTime() } data = openDefultdata ? merge(defaults, data) : data return contentType === 'json' ? JSON.stringify(data) : qs.stringify(data)} export default http
4.2.2、请求地址代理方式
config/index.js
const devEnv = require('./dev.env') proxyTable: devEnv.OPEN_PROXY === false ? {} : { '/proxyApi': { target: 'http://127.0.0.1:8888/', changeOrigin: true, pathRewrite: { '^/proxyApi': '/' } }},
4.2.3、请求地址常量方式
static/config/index.js
;(function () { window.SITE_CONFIG = {}; // api接口请求地址 window.SITE_CONFIG['baseUrl'] = 'http://localhost:8888'; // cdn地址 = 域名 + 版本号 window.SITE_CONFIG['domain'] = './'; // 域名 window.SITE_CONFIG['version'] = ''; // 版本号(年月日时分) window.SITE_CONFIG['cdnUrl'] = window.SITE_CONFIG.domain + window.SITE_CONFIG.version;})();
Index.html引入index.js
4.3、登录页面实现
使用统一请求工具类
this.$http({ url: this.$http.adornUrl("/sys/login"), method: 'post', params: this.$http.adornParams(this.dataForm)}).then(({data}) => { if (data.success) { this.$router.replace({ name: 'home' }) }else{ this.dataForm.password=""; this.$message.error(data.message) this.loading = false; }}).catch(data => { this.loading = false; this.dataForm.password=""; this.$message.error(this.tips.error);})
4.4密码加密传输
当前密码为明文传输,很不安全,所以需要加密传输
4.4.1、前端加密
npm install jsencrypt –d
工具类
import JSEncrypt from 'jsencrypt/bin/jsencrypt.min'const publicKey = 'MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdH\n' + 'nzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ==' // 加密export function encrypt(txt) { const encryptor = new JSEncrypt() encryptor.setPublicKey(publicKey) // 设置公钥 return encryptor.encrypt(txt) // 对数据进行加密}
登录页面
import { encrypt } from '@/utils/jsencrypt'this.dataForm.password=encrypt(this.dataForm.password)
4.4.2后端解密
commons-codec commons-codec 1.10
添加工具类
package com.sq.auth.utils; import lombok.extern.slf4j.Slf4j;import org.apache.commons.codec.binary.Base64;import javax.crypto.Cipher;import java.security.KeyFactory;import java.security.interfaces.RSAPrivateKey;import java.security.interfaces.RSAPublicKey;import java.security.spec.PKCS8EncodedKeySpec;import java.security.spec.X509EncodedKeySpec;@Slf4jpublic class RSAEncrypt { // 密钥对生成 http://web.chacuo.net/netrsakeypair private static String publicKey = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKoR8mX0rGKLqzcWmOzbfj64K8ZIgOdH\n" + "nzkXSOVOZbFu/TJhZ7rFAN+eaGkl3C4buccQd/EjEsj9ir7ijT7h96MCAwEAAQ=="; private static String privateKey = "MIIBVAIBADANBgkqhkiG9w0BAQEFAASCAT4wggE6AgEAAkEAqhHyZfSsYourNxaY\n" + "7Nt+PrgrxkiA50efORdI5U5lsW79MmFnusUA355oaSXcLhu5xxB38SMSyP2KvuKN\n" + "PuH3owIDAQABAkAfoiLyL+Z4lf4Myxk6xUDgLaWGximj20CUf+5BKKnlrK+Ed8gA\n" + "kM0HqoTt2UZwA5E2MzS4EI2gjfQhz5X28uqxAiEA3wNFxfrCZlSZHb0gn2zDpWow\n" + "cSxQAgiCstxGUoOqlW8CIQDDOerGKH5OmCJ4Z21v+F25WaHYPxCFMvwxpcw99Ecv\n" + "DQIgIdhDTIqD2jfYjPTY8Jj3EDGPbH2HHuffvflECt3Ek60CIQCFRlCkHpi7hthh\n" + "YhovyloRYsM+IS9h/0BzlEAuO0ktMQIgSPT3aFAgJYwKpqRYKlLDVcflZFCKY7u3\n" + "UP8iWi1Qw0Y="; /** * RSA公钥加密 * @param str 加密字符串 * @param publicKey 公钥 * @return 密文 * @throws Exception 加密过程中的异常信息 */ public static String encrypt( String str, String publicKey ) throws Exception{ //base64编码的公钥 byte[] decoded = Base64.decodeBase64(publicKey); RSAPublicKey pubKey = (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded)); //RSA加密 Cipher cipher = Cipher.getInstance("RSA"); cipher.init(Cipher.ENCRYPT_MODE, pubKey); String outStr = Base64.encodeBase64String(cipher.doFinal(str.getBytes("UTF-8"))); return outStr; } /** * RSA私钥解密 * @param str 加密字符串 * @return 铭文 * @throws Exception 解密过程中的异常信息 */ public static String decrypt(String str) throws Exception{ String outStr=""; try { //64位解码加密后的字符串 byte[] inputByte = Base64.decodeBase64(str.getBytes("UTF-8")); //base64编码的私钥 byte[] decoded = Base64.decodeBase64(privateKey); RSAPrivateKey priKey = (RSAPrivateKey) KeyFactory.getInstance("RSA").generatePrivate(new PKCS8EncodedKeySpec(decoded)); //RSA解密 Cipher cipher = Cipher.getInstance("RSA"); cipher.init(Cipher.DECRYPT_MODE, priKey); outStr = new String(cipher.doFinal(inputByte)); }catch (Exception e){ log.error("RSA解密失败",e); }finally { return outStr; } }}
4.5、登出实现
this.$http({ url: this.$http.adornUrl('/sys/logout'), method: 'post', data: this.$http.adornData()}).then(({data}) => { if (data && data.success === true) { this.$router.push({ name: 'login' }) }}).catch(() => { this.$message.error(this.tips.error);})
关注公众号”小猿架构“,发送 “前后分离架构” ,下载课程视频+课程源码+课件。