项目实战九-登录功能一
登录有:用户名、密码、验证码
基本登录功能的实现
- 新增登录的vo
-
登录请求LoginReqVo:在req下直接添加
@Data public class LoginReqVo { //用户名 @NotBlank private String username; //密码 @NotBlank private String password; //验证码 @NotBlank private String captcha; }
-
登录响应LoginVo:在vo下直接添加
@Data public class LoginVo { private Integer id; private String nickname; private String username; private String token; }
-
- controller层
-
新增登录、获取验证码方法
@PostMapping("/login") public DataJsonVo<LoginVo> login(@Valid LoginReqVo reqVo, HttpServletRequest request) { //验证验证码是否正确 if (CaptchaUtil.ver(reqVo.getCaptcha(),request)){ return JsonVos.ok(service.login(reqVo)); } //验证码错误 return JsonVos.raise(CodeMsg.WRONG_CAPTCHA); } //生成验证码 @GetMapping("/captcha") public void captcha(HttpServletRequest request, HttpServletResponse response) throws Exception { //返回给前端的验证码图片,CaptchaUtil是使用功能第三方jar包easy-captcha CaptchaUtil.out(request, response); }
-
-
service层
//新增接口 LoginVo login(LoginReqVo reqVo); //impl新增方法实现 @Override public LoginVo login(LoginReqVo reqVo) { //根据用户名查询用户 MpLambdaQueryWrapper<SysUser> wrapper = new MpLambdaQueryWrapper<>(); wrapper.eq(SysUser::getUsername,reqVo.getUsername()); SysUser po = baseMapper.selectOne(wrapper); if (po == null) { //用户名不存在 return JsonVos.raise(CodeMsg.WRONG_USERNAME); } //密码不正确 if (!po.getPassword().equals(reqVo.getPassword())){ return JsonVos.raise(CodeMsg.WRONG_PASSWORD); } //账号锁定 if(po.getStatus() == 1){ return JsonVos.raise(CodeMsg.USER_LOCKED); } //更新登录时间 po.setLoginTime(new Date()); baseMapper.updateById(po); return MapStructs.INSTANCE.po2loginVo(po); }
-
注意:MapStructs需要添加LoginVo-》SysUser转化
LoginVo po2loginVo(SysUser po);
-
后端获取图片验证码
-
给前端提供验证码接口,使用第三方jar包easy-captcha,pom.xml中导入
<!-- 验证码 --> <dependency> <groupId>com.github.whvcse</groupId> <artifactId>easy-captcha</artifactId> <version>1.6.2</version> </dependency>
-
前端必须要传递cookie到后台,才能正确识别验证码,后台要允许跨域以及允许传递cookie
-
后台:目前在WebCfg类中已经全局配置
allowCredentials(true) allowedOrigins(origins): 要指定特定的主机地址,不能使用*
-
前端
Ajaxs.loadPost({ uri: 'sysUsers/login', data: data.field, success: (response) => { //登录成功跳转到首页 location.href = '../index.html' }, //必须要加这个,需要跨域带上cookie xhrFields: { withCredentials: true } })
-
会话管理
下面主要是讲市场上常用的会话管理方案
客户端身份认证-基于Cookie、Session
- 执行流程
- 客户端:发送用户名密码
- 服务器:验证成功后,创建并保留一个跟客户端相关联的Session,返回Session_ID给客户端
- 客户端:将Session_ID保存到Cookie中
- 客户端:后续的请求都带上包含了Session_ID的Cookie
- 服务器:通过验证Cookie中的Session_ID确认用户身份
- 优点
- 服务器、客户端基本自动完成一系列的流程,不用编写太多额外的代码
- 缺点
- 默认不支持分布式架构,在分布式架构下,需要解决分布式Session共享的问题,比如存放到Redis中
- 默认不支持非浏览器环境(没有Cookie机制的环境)
简单的分布式架构
- 一套后台代码服务部署到n台主机上,客户端只需要访问一台服务器的地址(Nginx服务器),这台服务器仅仅用来做负载均衡这个算法,自动计算出到底去访问哪台服务器
- Nginx服务器:负载均衡服务器、反向代理服务器(相当于n台服务器的代理人)
- 分布式缓存:
- n台服务器肯定要访问数据库,数据库本质是一个文件,如果n台服务器都要访问,就需要进行大量的IO操作,效率低下,因此可以将数据放入到缓存中。缓存是放在服务器的内存中,提高效率
- 这样这个缓存就要n台服务器访问,因此专门搞一台服务器搭建缓存,再搞一台服务器存放数据库
- 访问数据时,先到缓存中获取,缓存中没有在到数据库中去查询,数据库查询到之后存放到缓存,以备下次访问
- 注意: n台服务器是不能设置缓存的,因为缓存是需要n台服务器共享,共同访问
- Redis就是一种分布式缓存
- Cookie、Session是不支持分布式的,因为Session在某台服务器的主机中存储,当客户端下次访问时不一定还是到这台主机中查找,如果要做必须将Session存储到分布式缓存中
客户端身份认证-基于token
- 执行流程
- 客户端:发送用户名密码
- 服务器:验证成功后,生成一个跟客户端相关联的token,返回token给客户端
- 客户端:存储token到本地
- 客户端:后续的请求都带上token
- 服务器:通过验证token确认用户身份
- 优点
- 支持分布式架构、支持非浏览器环境(没有Cookie机制的环境)
- 缺点
- 需要客户端手动存储、发送token
- 有些token会比session_id大,需要消耗更多流量
- 有些token方案,默认无法在服务器主动销毁token
- 如果在分布式缓存redis环境下,需要将token存储到redis中去
EhCache
- EhCache是一款简单易用的缓存框架
- 支持3层缓存:Heap(JVM的堆内存,内存会自动管理)、Off-Heap(堆外内存,内存需要手动管理)、Disk(磁盘)
- Off-Heap、Disk要求对象支持序列化和反序列化
- 该缓存可以设置缓存时效
-
pom
<dependency> <groupId>org.ehcache</groupId> <artifactId>ehcache</artifactId> </dependency>
基于token登录控制
- 只有登录过才能访问相关的页面
-
返回给客户端添加token字段LoginVo
//返回给用户token private String token;
- pom.xml添加 EhCache依赖
-
resources下添加ehCache.xml配置文件
<?xml version="1.0" encoding="UTF-8"?> <config xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance' xmlns='http://www.ehcache.org/v3' xsi:schemaLocation="http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core.xsd"> <!-- <persistence directory="F:/ehcache"/>--> <!-- 设置缓存的数据类型,以及缓存的位置--> <cache-template name="common"> <key-type>java.lang.Object</key-type> <value-type>java.lang.Object</value-type> <!-- 设置资源存储到哪里--> <resources> <!-- 默认unit是unit="entries",对象--> <heap>10000</heap> <!-- <offheap unit="MB">50</offheap>--> <!-- <disk unit="GB" persistent="true">1</disk>--> </resources> </cache-template> <!-- 11月1号~11月7号,11月8号过期 11月6号~11月12号,11月13号过期 11月12号~11月18号,11月19号过期 --> <!-- 创建一个缓存别名为token,存放token的缓存:只要7天内不访问,就失效 --> <cache alias="token" uses-template="common"> <!-- 设置时长 ttl:time to leave 生命周期,不管中间有没有访问,从1号开始,到8号一定过期 tti:time to idle 只要1-8号内有访问,声明周期就是从最后一次访问开始直到7天结束, 只要中间有访问,生命周期就延长 --> <expiry> <tti unit="days">7</tti> <!-- <tti unit="seconds">5</tti>--> </expiry> </cache> <!-- 默认缓存:永不过期 --> <cache alias="default" uses-template="common"> <!-- 设置时长--> <expiry> <none/> </expiry> </cache> </config>
-
封装一个Caches类,读取xml文件
//com.zh.jk.common.cache public class Caches { //注意:缓存管理器、缓存对象必须是唯一的!!! private static final CacheManager MGR; private static final Cache<Object, Object> DEFAULT_CACHE; private static final Cache<Object, Object> TOKEN_CACHE; static { // 初始化缓存管理器,读取xml配置 URL url = Caches.class.getClassLoader().getResource("ehcache.xml"); assert url != null; Configuration cfg = new XmlConfiguration(url); MGR = CacheManagerBuilder.newCacheManager(cfg); MGR.init(); // 缓存对象 DEFAULT_CACHE = MGR.getCache("default", Object.class, Object.class); TOKEN_CACHE = MGR.getCache("token", Object.class, Object.class); } public static void put(Object key, Object value) { if (key == null || value == null) return; DEFAULT_CACHE.put(key, value); } public static void remove(Object key) { if (key == null) return; DEFAULT_CACHE.remove(key); } public static <T> T get(Object key) { if (key == null) return null; return (T) DEFAULT_CACHE.get(key); } public static void clear() { DEFAULT_CACHE.clear(); } public static void putToken(Object key, Object value) { if (key == null || value == null) return; TOKEN_CACHE.put(key, value); } public static void removeToken(Object key) { if (key == null) return; TOKEN_CACHE.remove(key); } public static <T> T getToken(Object key) { if (key == null) return null; return (T) TOKEN_CACHE.get(key); } public static void clearToken() { TOKEN_CACHE.clear(); } }
-
service层SysUserServiceImpl的login方法改造
@Override public LoginVo login(LoginReqVo reqVo) { //根据用户名查询用户 MpLambdaQueryWrapper<SysUser> wrapper = new MpLambdaQueryWrapper<>(); wrapper.eq(SysUser::getUsername,reqVo.getUsername()); SysUser po = baseMapper.selectOne(wrapper); if (po == null) { //用户名不存在 return JsonVos.raise(CodeMsg.WRONG_USERNAME); } //密码不正确 if (!po.getPassword().equals(reqVo.getPassword())){ return JsonVos.raise(CodeMsg.WRONG_PASSWORD); } //账号锁定 if(po.getStatus() == Constants.SysUserStatus.LOCKED){ return JsonVos.raise(CodeMsg.USER_LOCKED); } //更新登录时间 po.setLoginTime(new Date()); baseMapper.updateById(po); //生成token,发送token给用户(这里先用一个随便的方法生成token) String token = UUID.randomUUID().toString(); //存储token到缓存中,使用EhCache封装的类 Caches.putToken(token,po); //返回给客户端的具体数据 LoginVo vo = MapStructs.INSTANCE.po2loginVo(po); vo.setToken(token); return vo; }
生成token的常用第三方库
- 以上为随意生成的token,在开发中经常使用生成token的方法:JWT(JSON Web Token),-种基于JSON的token标准
-
库:
<dependency> <groupId>com.authg</groupId> <artifactId>java-jwt</artifactId> <version>3.12.0</version> </dependency>
-
还有一个常用的jwt库
<dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
- 具体使用方法网络查询。
使用shiro鉴权
具体使用详看最后一节shiro讲解
-
pom.xml添加依赖
<!--权限控制--> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring-boot-starter</artifactId> <version>1.7.0</version> </dependency>
-
运行项目,此时会报错
Description: No bean of type 'org.apache.shiro.realm.Realm' found. Action: Please create bean of type 'Realm' or add a shiro.ini in the root classpath (src/main/resources/shiro.ini) or in the META-INF folder (src/main/resources/META-INF/shiro.ini). Disconnected from the target VM, address: '127.0.0.1:65420', transport: 'socket' Process finished with exit code 0
- 就是容器中必须要有个Realm类型的bean对象,或者在classpath、META-INF下添加shiro.ini文件
-
在common下新建一个shiro包
-
新建TokenRealm类,继承自AuthorizingRealm
//com.zh.jk.common.shiro public class TokenRealm extends AuthorizingRealm { //自己定义一个密码的比对算法类(TokenMatcher的子类),传进来 public TokenRealm (TokenMatcher matcher) { super(matcher); } //自己定义如何获取数据授权 @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { return null; } //自己定义如何获取数据认证 @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { return null; } }
-
新建一个ShiroCfg类,专门配置那些bean放入容器,将TokenRealm类的bean放入容器
//com.zh.jk.common.shiro @Configuration public class ShiroCfg { @Bean public Realm realm() { return new TokenRealm(new TokenMatcher()); } /** * ShiroFilterFactoryBean用来告诉Shiro如何进行拦截 * 1.拦截哪些URL * 2.每个URL需要进行哪些filter进行拦截 */ //这里的参数realm会自动注入,注入的是上面的Realm @Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(Realm realm) { ShiroFilterFactoryBean filterBean = new ShiroFilterFactoryBean(); //设置安全管理器 filterBean.setSecurityManager(new DefaultWebSecurityManager(realm)); // 添加一些自定义Filter,每个自定义的filter还需要设置一个别名,比如下面TokenFilter设置了一个别名为token Map<String, Filter> filters = new HashMap<>(); filters.put("token", new TokenFilter()); filterBean.setFilters(filters); // 设置URL如何拦截,必须要用LinkedHashMap,因为是有序存储,HashMap是无序存储 Map<String, String> urlMap = new LinkedHashMap<>(); /*设置哪些请求路径需要拦截*/ //验证码、登录不需要token认证 // 验证码接口,anon代表的是shiro内置的filter: org.apache.shiro.web.filter.authc.AnonymousFilter //不需要认证就可以访问 urlMap.put("/sysUsers/captcha", "anon"); // 用户登录 urlMap.put("/sysUsers/login", "anon"); // 其他所有的请求路径,使用token这个别名自定义的filter进行拦截 urlMap.put("/**", "token"); filterBean.setFilterChainDefinitionMap(urlMap); return filterBean; } }
-
自定义TokenMatcher,如何匹配校验算法
public class TokenMatcher implements CredentialsMatcher { /*自定义实现,客户端传递过来的值与数据库的值进行匹配的算法*/ @Override public boolean doCredentialsMatch(AuthenticationToken token, AuthenticationInfo info) { return false; } }
-
自定义TokenFilter,如何过滤某些url
public class TokenFilter extends AccessControlFilter { @Override protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception { return false; } @Override protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception { return false; } }
-
上面的层级关系如下图
- ShiroFilterFactoryBean: 用来告诉Shiro如何进行拦截url
- 上面对象需要设置一个setSecurityManager-DefaultWebSecurityManager
- 要告诉上面那个manager要使用那个Realm-AuthorizingRealm
- 要告诉AuthorizingRealm使用什么算法匹配CredentialsMatcher
-
TokenFilter
- 在该filter中拦截相应的url,进行token鉴权
/**
* 作用:验证用户的合法性、是否有相关权限
*/
@Slf4j
public class TokenFilter extends AccessControlFilter {
public static final String HEADER_TOKEN = "Token";
/**
* 当请求被TokenFilter拦截时,就会调用这个方法
* 可以在这个方法中做初步判断
*
* 如果返回true:允许访问。可以进入下一个链条调用(比如Filter,可以有n个filter、拦截器、控制器等)
* 如果返回false:不允许访问。会进入onAccessDenied方法,不会进入下一个链条调用(比如Filter、拦截器、控制器等)
*/
@Override
protected boolean isAccessAllowed(ServletRequest servletRequest, ServletResponse servletResponse, Object o) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
// 放行所有的OPTIONS请求
//return "OPTIONS".equals(request.getMethod());
log.debug("TokenFilter - isAccessAllowed - " + request.getRequestURI());
return false;
}
/**
* 当isAccessAllowed返回false时,就会调用这个方法
* 在这个方法中进行token的校验
*
* 如果返回true:允许访问。可以进入下一个链条调用(比如Filter、拦截器、控制器等)
* 如果返回false:不允许访问。不会进入下一个链条调用(比如Filter、拦截器、控制器等)
*/
@Override
protected boolean onAccessDenied(ServletRequest servletRequest, ServletResponse servletResponse) throws Exception {
HttpServletRequest request = (HttpServletRequest) servletRequest;
log.debug("TokenFilter - onAccessDenied - " + request.getRequestURI());
// 取出Token
String token = request.getHeader(HEADER_TOKEN);
// 如果没有Token
if (token == null) {
return JsonVos.raise(CodeMsg.NO_TOKEN);
}
// 如果Token过期了
if (Caches.getToken(token) == null) {
return JsonVos.raise(CodeMsg.TOKEN_EXPIRED);
}
log.debug("TokenFilter - onAccessDenied - " + token);
// 鉴权(进入Realm)
// 这里代码后序添加
return true;
}
}
Filter的异常处理
问题分析
- 问题:当TokenFilter中的onAccessDenied方法中抛出异常时,CommonExceptionHandler是无法拦截到的
- 原因分析:
-
CommonExceptionHandler中的异常拦截
@RestControllerAdvice @Slf4j public class CommonExceptionHandler { @ExceptionHandler(Throwable.class) @ResponseStatus(code = HttpStatus.BAD_REQUEST) public JsonVo handle(Throwable t) { log.error(null,t); return JsonVos.error(t); } }
@RestControllerAdvice
只能拦截到达Controller以后的异常- 当项目中存在filter时,客户端发送请求,先到filter,然后才到controller,所以如果filter出现异常抛出,是无法通过
@RestControllerAdvice
拦截到异常的
-
- 解决方案:
- 搞一个ErrorFilter放在所有filter的最前面
- 然后用这个ErrorFilter拦截所有其他filter的异常信息
- 自定义一个ErrorController,专门用于透传抛出异常
- ErrorFilter拦截到的异常信息通过转发的形式发送给ErrorController
- ErrorController抛出异常,此时CommonExceptionHandler可以拦截到异常
- 这样就实现了CommonExceptionHandler能拦截filter抛出的异常
异常封装
- 具体实现步骤
- 新建ErrorFilter,然后放在所有filter的最前面
- 在WebCfg(WebMvcConfigurer)中,注册ErrorFilter,并设置为最前面的Filter
/* * 作用: * 1. 注册ErrorFilter * 2.保证ErrorFilter在所有filter的最前面 * */ @Bean public FilterRegistrationBean<Filter> filterRegistrationBean() { FilterRegistrationBean<Filter> bean = new FilterRegistrationBean<>(); // 设置Filter bean.setFilter(new ErrorFilter()); //拦截所有的路径 bean.addUrlPatterns("/*"); // 最高权限 bean.setOrder(Ordered.HIGHEST_PRECEDENCE); return bean; }
- 用这个ErrorFilter拦截所有其他filter的异常信息,并将异常转发给ErrorController
- 如何拦截其他filter的异常呢?
- 如果有Filter1、Filter2、Filter3,那么当客户端发送请求时,会先经过Filter1、Filter2、Filter3
-
伪代码如下
Filter1.doFilter { chain.doFilter { Filter2.doFilter { chain.doFilter { Filter3.doFilter { chain.doFilter { Controller.login } } } } } }
-
那么如果在Filter1的doFilter中添加try-catch,就可以捕获Filter2、Filter3的异常信息
Filter1.doFilter { try { chain.doFilter { Filter2.doFilter { chain.doFilter { Filter3.doFilter { chain.doFilter { Controller.login } } } } } } catch (Exception e) { // 将异常提交给ErrorController } }
-
ErrorFilter具体实现:
public class ErrorFilter implements Filter { public static final String ERROR_URI = "/handleError"; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { chain.doFilter(request, response); } catch (Exception e) { //设置异常,临时存储 request.setAttribute(ERROR_URI, e); //将异常转发给ErrorController,转发路径ERROR_URI request.getRequestDispatcher(ERROR_URI).forward(request, response); } } }
- 如何拦截其他filter的异常呢?
-
ErrorController透传抛出异常:
@RestController public class ErrorController { //监听ErrorFilter转发过来的异常 //路径ERROR_URI @RequestMapping(ErrorFilter.ERROR_URI) public void handle(HttpServletRequest request) throws Exception { // 抛出异常 throw (Exception) request.getAttribute(ErrorFilter.ERROR_URI); } }
- 这样CommonExceptionHandler就能拦截filter的异常了
- 新建ErrorFilter,然后放在所有filter的最前面
-
注意: ShiroCfg中的ShiroFilterFactoryBean需要对ErrorController的接收路径ERROR_URI放行,不做token鉴权
// 全局Filter的异常处理 urlMap.put(ErrorFilter.ERROR_URI, "anon");
-
CommonExceptionHandler针对各种异常做丰富处理改造,将JsonVos中的异常类型处理删除,JsonVos只做响应封装
@RestControllerAdvice @Slf4j public class CommonExceptionHandler { @ExceptionHandler(Throwable.class) @ResponseStatus(code = HttpStatus.BAD_REQUEST) public JsonVo handle(Throwable t) { log.error("handle", t); // 一些可以直接处理的异常 if (t instanceof CommonException) { return handle((CommonException) t); } else if (t instanceof BindException) { return handle((BindException) t); } else if (t instanceof ConstraintViolationException) { return handle((ConstraintViolationException) t); } else if (t instanceof AuthorizationException) { return JsonVos.error(CodeMsg.NO_PERMISSION); } // 处理cause异常(导致产生t的异常) Throwable cause = t.getCause(); if (cause != null) { //递归 return handle(cause); } // 其他异常(没有cause的异常) return JsonVos.error(); } private JsonVo handle(CommonException ce) { return JsonVos.error(ce.getCode(), ce.getMessage()); } private JsonVo handle(BindException be) { List<ObjectError> errors = be.getBindingResult().getAllErrors(); // 函数式编程的方式:stream List<String> defaultMsgs = Streams.map(errors, ObjectError::getDefaultMessage); String msg = StringUtils.collectionToDelimitedString(defaultMsgs, ", "); return JsonVos.error(msg); } private JsonVo handle(ConstraintViolationException cve) { List<String> msgs = Streams.map(cve.getConstraintViolations(), ConstraintViolation::getMessage); String msg = StringUtils.collectionToDelimitedString(msgs, ", "); return JsonVos.error(msg); } }