前言
近期要跑一个关于微信登录的Demo(使用 Spring+Angular),为了省事,打算从已有的项目上复制粘贴。我进行了以下操作:
第一步:初始化项目,粘贴基本功能,如简单的页面、实体、必要的服务和控制器等,如果出现依赖,则视情况粘贴依赖或删掉代码
第二步:粘贴登录功能,当前这个项目用的和之前学的已经不一样了,使用的是 SpringSecurity、SpringSession 和x-auth-token,所以第一反应就是粘贴所有的过滤器和拦截器,登录功能可能会正常运行
第三步:实现 Demo 的微信登录的功能
然后由于某种问题,在第二步出现了密码登录失败的情况,具体表现在:
①输入正确的用户名密码后,网页提示登录失败
②控制台network提示401,没有响应的 body,console 提示登录失败
③ 后端的控制台没有任何输出
④ 如果在 login 方法上打断点,这个断点并不会被触发
我找到原来正常的项目,在 login 方法上打断点发现:
⑤输入正确密码的情况下,后端 C层 login 会被触发,否则不会
于是推出:密码的正确性应该是过滤器来校验,而不是用 login 方法校验,login 只负责校验成功后更新登录信息
排查
那么问题可能出在这些过滤器上,于是在后端尽可能打断点来排查
既然鉴权功能一般是过滤器和拦截器来实现的,于是排查方法就是把所有处理请求的方法打上断点
打完断点重启项目,就可以看到真正的执行顺序,分为两个阶段
一是后端启动时进行的一系列初始化,二是发起请求时的处理过程
先来看启动时是如何初始化的(省去了微信相关的步骤):
首先是加载主类
public static void main(String[] args) {
SpringApplication.run(WebSoctetAndStompStudyApplication.class, args);
}
过滤器执行构造函数(不止一个过滤器)
public WechatAuthFilter(WxMaService wxMaService, UserRepository userRepository) {
this.wxMaService = wxMaService;
this.userRepository = userRepository;
}
校验工具执行构造函数
public SuperPasswordBCryptPasswordEncoder(OneTimePassword oneTimePassword) {
super();
this.oneTimePassword = oneTimePassword;
}
header 处理
/**
* 使用header认证来替换默认的cookie认证
*/
@Bean
public HttpSessionStrategy httpSessionStrategy() {
return new HeaderAndParamHttpSessionStrategy();
}
密码工具
@Bean
PasswordEncoder passwordEncoder() {
return this.passwordEncoder;
}
SpringSecurity 设置路由
/**
* 设置开放权限的路由
* https://spring.io/guides/gs/securing-web/
*
* @param http http安全
* @throws Exception 异常
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
// 开放端口
.antMatchers("/h2-console/**").permitAll()
.antMatchers("/Data").permitAll()
.antMatchers("/user/resetPassword").permitAll()
.antMatchers("/user/getLoginQrCode/**").permitAll()
.antMatchers("/wechat/**").permitAll()
.antMatchers("/websocket/**").permitAll()
.antMatchers("/user/sendVerificationCode", "/favicon.ico").permitAll()
.anyRequest().authenticated()
.and()
// 添加通过header获取host信息的过滤器
// 过滤器执行链请参考:https://docs.spring.io/spring-security/site/docs/5.5.1/reference/html5/#servlet-security-filters
.addFilterBefore(this.headerRequestHostFilter, BasicAuthenticationFilter.class)
// 添加微信认证过滤器
.addFilterBefore(this.wechatAuthFilter, BasicAuthenticationFilter.class)
.httpBasic()
.and().cors()
.and().csrf().disable();
http.headers().frameOptions().disable();
}
url处理
/**
* URL忽略大小写
*
* @param configurer 配置信息
*/
@Override
public void configurePathMatch(final PathMatchConfigurer configurer) {
final AntPathMatcher pathMatcher = new AntPathMatcher();
pathMatcher.setCaseSensitive(false);
configurer.setPathMatcher(pathMatcher);
}
jsonview
/**
* 配置JsonView
*/
@Override
public void configureMessageConverters(final List> converters) {
final ObjectMapper mapper = Jackson2ObjectMapperBuilder.json().defaultViewInclusion(true).build();
converters.add(new MappingJackson2HttpMessageConverter(mapper));
}
以上大概是项目启动时执行的流程,不用细看。
然后是前端发起请求的时的流程
我找到那个运行正常的生产项目,正常情况下应该是:
①获取 session 数据
@Override
public String getRequestedSessionId(HttpServletRequest request) {
String token = request.getHeader(this.headerName);
return (token != null && !token.isEmpty()) ? token : request.getParameter(this.headerName);
}
② header 过滤器
public HostHeaderHttpServletRequest(HttpServletRequest request) {
super(request);
this.request = request;
}
③微信过滤器
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final boolean debug = this.logger.isDebugEnabled();
String code = request.getHeader(this.codeKey);
if (code != null) {
try {
WxMaJscode2SessionResult wxMaJscode2SessionResult = wxMaService.getUserService().getSessionInfo(code);
String openid = wxMaJscode2SessionResult.getOpenid();
Optional optionalUser = userRepository.findByOpenid(openid);
WeChatUser wechatUser = new WeChatUser(new User(), wxMaJscode2SessionResult.getSessionKey(), wxMaJscode2SessionResult.getOpenid());
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken;
if (optionalUser.isPresent()) {
wechatUser.setUser(optionalUser.get());
}
// 设置认证用户:微信用户、安全令牌设置为openid、认证权限为空(后期可变更为正确的微信权限名称)
usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
wechatUser,
openid,
wechatUser.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
} catch (WxErrorException exception) {
this.logger.warn("虽然接收到了code,但是没有通过code换取有效的微信数据: " + exception.getMessage());
exception.printStackTrace();
}
}
filterChain.doFilter(request, response);
}
④ 比对用户名密码
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
if (rawPassword == null) {
throw new IllegalArgumentException("rawPassword cannot be null");
}
if (oneTimePassword.matches(rawPassword, encodedPassword)) {
return true;
}
return super.matches(rawPassword, encodedPassword);
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
// 增加微信扫码后使用webSocket uuid充当用户名与密码进行认证
if (this.userService.checkWeChatLoginUuidIsValid(rawPassword.toString())) {
if (this.logger.isDebugEnabled()) {
this.logger.info("校验微信扫码登录成功");
}
return true;
}
// 当有一次性密码(每个密码仅能用一次)且未使用时,验证用户是否输入了超密
Optional oneTimePassword = this.getPassword();
return oneTimePassword.isPresent() && oneTimePassword.get().equals(rawPassword.toString());
}
⑤ 密码比对正确后,拦截器放行,访问到controller
@RequestMapping("login")
@JsonView(LoginJsonView.class)
public User login(Principal user) {
return this.userService.getByUsername(user.getName());
}
⑥调用Service,return,登录成功
但在有问题的Demo 中,我遇到的情况是,在第三步(微信过滤器)执行之后就直接返回了,没有密码比对的过程,C 层 login 的断点始终不会被触发,后端的控制台也没有任何报错,只有网页上显示登录失败
然后我又检查了一遍所有过滤器和配置文件,均没有发现问题
用户
正当不知道怎么办的时候,我发现遗漏了一个地方:过滤器和拦截器怎么能取出用户名和密码呢?必然不能直接取出吧?肯定是调用了service 来取出的,但 UserService 是能通过编译的,说明没有问题,当时感觉很费解,然后找到那个正常的项目,把UserService所有方法打上断点
果然有新的发现,在③微信过滤器处理和④比对密码之间,UserService的 loadUserByUsername方法断点被击中了,这一步就是刚才猜测的“取出用户”的过程
但仔细看,这个方法并没有被直接调用,所以显示no usage
我回到那个有问题的项目一看,这个方法是这样的:
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return null;
}
粘贴的时候一看没有 usage 就觉得不会被调用,于是随便写,直接返回空了
把正确的方法粘过去,却发现这个UserServiceImpl并不只是实现了UserService,还有UserDetailsService,这个类实现了两个接口,而上面的方法是还有UserDetailsService这个接口提供的
// 定义类的语句
public class UserServiceImpl implements UserService, UserDetailsService {
....
}
而这个 UserDetailService 也不出意料的是一个内部类:
所以才会出现 usage 是0,但它实实在在的被调用了的情况,我们可以继续分析,这个方法是取出用户的,如果 return 是 null,那么无论输入什么用户名密码,都会密码错误,所以我们看到的“没有任何报错”的情况,实际上被系统认为“用户名密码输入错误”的情况,系统认为这是一个正常的情况
至于为什么密码不正确返回的401没有 response 信息?观察代码会发现是拦截器对401进行了处理,阻止401导致的报错
/**
* 在非cors的情况下,阻止浏览器在接收到401时弹窗
*/
export class Prevent401Popup implements HttpInterceptor {
intercept(req: HttpRequest, next: HttpHandler): Observable> {
let headers = req.headers;
headers = headers.append('X-Requested-With', 'XMLHttpRequest');
req = req.clone({headers});
return next.handle(req);
}
}
这样一来总算都走的通了,登录也正常了
由于第一次出现这种没有错误信息的情况,因此在此文中把排查步骤记录了下来。
总结和反思
反思:
①为什么没有问?因为疑难杂症的 debug 太麻烦,而且别人不知道自己埋的坑在哪,所以选择了自己排查
②为什么没有查?因为问题无从下手,不知道如何组织语言去 goooogle
总结:
①不同于手写代码,框架有它自己的调用方式,并且属于“我写了它就调,没写也不报错”的情况,换言之,如果我的代码实现了框架的接口、或者继承了框架的内部类、或者加了对应的注解,它就能正常执行
②被内部类调用的方法不会有显示调用的提示(即usage 为0),但不能忽视
③在我们对项目的细节什么都不知道的情况下,如果想排查问题,可以使用大量打断点的方式,从表面上弄明白执行顺序
④如果想深入理解执行逻辑,最好的办法还是系统的学习框架
⑤ debug 的时候必须同时观察 前端的页面、浏览器的 network、浏览器的 console、后端的 console、后端的断点,才能更好的发现问题
⑥通过这次打断点的操作,对于代码的执行过程有了更多的理解
demo 地址:https://github.com/liuyuxuan6...