1. 前言

欢迎来到【Spring Security系列】!在当今数字化时代,安全是任何应用程序都必须优先考虑的核心问题之一。而在众多安全框架中,Spring Security 作为一个功能强大且广泛应用的安全框架,为 Java 应用程序提供了全面的身份验证、授权、攻击防护等功能。而随着移动应用的普及,小程序作为一种轻量级、跨平台的应用形式,其安全性也成为了开发者们关注的焦点。本文将带领您深入探索如何使用 Spring Security 来保护小程序的登录认证,旨在为您提供全方位的学习体验和实践指导。

2. 小程序登录涉及SpringSecurity核心组件介绍

如果要在SpringSecurity默认的用户名密码模式登录模式上扩展小程序登录,涉及到的核心组件如下:

  1. AuthenticationProvider

    创建自定义的AuthenticationProvider,负责处理从微信开放平台获取的用户信息,并进行身份验证。
  2. UserDetailsService

    调整UserDetailsService来获取并管理基于微信OpenID的用户信息。
  3. AuthenticationManager

    确保您的自定义AuthenticationProvider被正确注册到AuthenticationManager中,以便处理小程序登录请求。
  4. SecurityConfigurer

    创建一个SecurityConfigurer来配置Spring Security以支持小程序登录,并将其添加到Spring Security的配置类中。
  5. Filter

    创建一个自定义的过滤器来拦截和处理小程序登录请求,提取微信登录凭证,并将其传递给您的自定义AuthenticationProvider进行处理。

要扩展Spring Security以支持小程序登录,您需要创建自定义的AuthenticationProvider并调整UserDetailsService以处理微信OpenID的用户信息。

3. SpringSecurity集成小程序登录原理

3.1. 小程序登录流程

以下是微信官方文档中小程序登录的流程:

【Spring Security系列】权限之旅:SpringSecurity小程序登录深度探索-LMLPHP

由上图可看出,小程序登录使用微信提供的登录凭证 code,通过微信开放平台的接口获取用户的唯一标识 OpenID 和会话密钥 SessionKey。在集成小程序登录时,我们需要将这些凭证传递给后端服务器,由后端服务器进行校验和处理,最终完成用户的登录认证。

3.2. SpringSecurity集成小程序登录流程梳理

结合SpringSecurity原理,在SpringSecurity中集成小程序登录的流程如下:

  • 小程序端:通过微信登录接口获取登录凭证 code,这里取名为loginCode。
  • 小程序端,通过手机号快速验证组件获取phoneCode。
  • 小程序端,获取用户昵称(nickName)和用户头像(imageUrl)地址。
  • 小程序端:将登录凭证 loginCodephoneCodenickNameimageUrl发送给后端服务器。
  • 后端服务器:接收到登录凭证 loginCode后,调用微信开放平台的接口,换取用户的唯一标识 OpenID 和会话密钥 SessionKey
  • 后端服务器:根据 OpenID 查询用户信息,如果用户不存在,则创建新用户;如果用户已存在,则返回用户信息。
  • 后端服务器:生成用户的身份认证信息JWT Token,返回给小程序端。
  • 小程序端:存储用户的身份认证信息,后续请求携带该信息进行访问控制。

大体流程只是在3.1小程序登录流程上做了细化,图我就不画了(因为懒)。

3.3. 小程序登录接口设计

小程序登录接口如下图所示:

【Spring Security系列】权限之旅:SpringSecurity小程序登录深度探索-LMLPHP

由上图所示,我们需要传入loginCode,phoneCode(获取手机号),nickName(昵称用于登录后展示),imageUrl(头像用于登录后展示)这几个必传参数。

4. 核心代码讲解

4.1. 小程序端获取必要参数

1. 小程序端调用微信登录接口,获取用户登录凭证 loginCode。

wx.login({
  success (res) {
    if (res.code) {
      //发起网络请求
      wx.request({
        url: 'https://example.com/onLogin',
        data: {
          code: res.code
        }
      })
    } else {
      console.log('登录失败!' + res.errMsg)
    }
  }
})

2. 获取PhoneCode

【Spring Security系列】权限之旅:SpringSecurity小程序登录深度探索-LMLPHP

3. 获取头像昵称

【Spring Security系列】权限之旅:SpringSecurity小程序登录深度探索-LMLPHP

4.2. 编写WeChatAuthenticationFilter

public class WeChatAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    private final String loginCode = "loginCode";
    private final String phoneCode="phoneCode";
    private final String nickName="nickName";
    private final String imageUrl="imageUrl";

    public WeChatAuthenticationFilter(String appId, String secret) {
        super(new AntPathRequestMatcher("/wx/login", "POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        String loginCode = obtainLoginCode(request)==null?"":obtainLoginCode(request).trim();
        String phoneCode=obtainPhoneCode(request)==null?"":obtainPhoneCode(request).trim();
        String nickName=obtainNickName(request)==null?"":obtainNickName(request).trim();
        String imageUrl=obtainImageUrl(request)==null?"":obtainImageUrl(request).trim();


        WechatAuthenticationToken authRequest = new WechatAuthenticationToken(loginCode,phoneCode,nickName,imageUrl);

        return this.getAuthenticationManager().authenticate(authRequest);
    }

    protected String obtainLoginCode(HttpServletRequest request) {
        return request.getParameter(loginCode);
    }

    protected String obtainPhoneCode(HttpServletRequest request){return request.getParameter(phoneCode);}

    protected String obtainNickName(HttpServletRequest request){return request.getParameter(nickName);}

    protected String obtainImageUrl(HttpServletRequest request){return request.getParameter(imageUrl);}
}

以上代码定义了一个名为WeChatAuthenticationFilter的类,它继承自AbstractAuthenticationProcessingFilter类,用于处理微信登录认证。在构造函数中,指定了请求匹配路径为"/wx/login",请求方法为POST。类中定义了四个常量:loginCode、phoneCode、nickName和imageUrl,分别表示登录码、手机号码、昵称和头像URL。

attemptAuthentication方法中,首先通过obtainLoginCode、obtainPhoneCode、obtainNickName和obtainImageUrl方法获取请求中的登录码、手机号码、昵称和头像URL,并进行了空值处理。然后将这些信息封装到WechatAuthenticationToken对象中,并通过getAuthenticationManager().authenticate方法进行认证。

4.3. 编写WeChatAuthenticationProvider

@Slf4j
public class WeChatAuthenticationProvider implements AuthenticationProvider {
    private final WechatConfig wechatConfig;
    private  RestTemplate restTemplate;
    private final WeChatService weChatService;
    private final ISysUserAuthService sysUserAuthService;

    public WeChatAuthenticationProvider(WechatConfig wechatConfig, WeChatService weChatService,ISysUserAuthService sysUserAuthService) {
        this.wechatConfig = wechatConfig;
        this.weChatService = weChatService;
        this.sysUserAuthService=sysUserAuthService;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {

        WechatAuthenticationToken wechatAuthenticationToken = (WechatAuthenticationToken) authentication;
        String loginCode = wechatAuthenticationToken.getPrincipal().toString();
        log.info("loginCode is {}",loginCode);
        String phoneCode=wechatAuthenticationToken.getPhoneCode().toString();
        log.info("phoneCode is {}",phoneCode);
        String nickName=wechatAuthenticationToken.getNickName().toString();
        log.info("nickName is {}",nickName);
        String imageUrl=wechatAuthenticationToken.getImageUrl().toString();
        log.info("imageUrl is {}",imageUrl);
        restTemplate=new RestTemplate();
        //获取openId
        JwtUser jwtUser=null;
        String url = "https://api.weixin.qq.com/sns/jscode2session?appid={appid}&secret={secret}&js_code={code}&grant_type=authorization_code";
        Map<String, String> requestMap = new HashMap<>();
        requestMap.put("appid", wechatConfig.getAppid());
        requestMap.put("secret", wechatConfig.getSecret());
        requestMap.put("code", loginCode);
        ResponseEntity<String> responseEntity = restTemplate.getForEntity(url, String.class,requestMap);
        JSONObject jsonObject= JSONObject.parseObject(responseEntity.getBody());
        log.info(JSONObject.toJSONString(jsonObject));
        String openId=jsonObject.getString("openid");
        if(StringUtils.isBlank(openId)) {
            throw new BadCredentialsException("weChat get openId error");
        }
        if(sysUserAuthService.getUserAuthCountByIdentifier(openId)>0){
            jwtUser = (JwtUser) weChatService.getUserByOpenId(openId);
            if(!jwtUser.isEnabled()){
                throw new BadCredentialsException("用户已失效");
            }
            return getauthenticationToken(jwtUser,jwtUser.getAuthorities());
        }
        //获取手机号第一步,获取accessToken
        String accessTokenUrl="https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appid}&secret={secret}";
        Map<String, String> accessTokenRequestMap = new HashMap<>();
        accessTokenRequestMap.put("appid", wechatConfig.getAppid());
        accessTokenRequestMap.put("secret", wechatConfig.getSecret());
        ResponseEntity<String>  accessTokenResponseEntity = restTemplate.getForEntity(accessTokenUrl, String.class,accessTokenRequestMap);
        JSONObject  accessTokenJsonObject= JSONObject.parseObject(accessTokenResponseEntity.getBody());
        log.info(JSONObject.toJSONString(accessTokenJsonObject));
        String  accessToken=accessTokenJsonObject.getString("access_token");
        if(StringUtils.isBlank(accessToken)) {
            throw new BadCredentialsException("weChat get accessToken error");
        }
        //获取手机号第二部,远程请求获取手机号
        String pohoneUrl="https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token="+accessToken+"";
        JSONObject phoneJson=new JSONObject();
        phoneJson.put("code",phoneCode);
        String resPhoneStr= RestTemplateUtil.postForJson(pohoneUrl,phoneJson,restTemplate);
        log.info(resPhoneStr);
        JSONObject resPhonJson= JSON.parseObject(resPhoneStr);
        JSONObject phoneInfo=resPhonJson.getJSONObject("phone_info");
        String mobile=phoneInfo.getString("phoneNumber");
        if(StringUtils.isBlank(mobile)){
            throw new BadCredentialsException("Wechat get mobile error");
        }
        jwtUser= (JwtUser) weChatService.getUserByMobile(mobile,nickName,imageUrl);
        sysUserAuthService.saveUserAuth(new AddUserAuthReq(jwtUser.getUid(),"wechat",openId));
        return getauthenticationToken(jwtUser,jwtUser.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return WechatAuthenticationToken.class.isAssignableFrom(authentication);
    }
    public WechatAuthenticationToken getauthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities){
        WechatAuthenticationToken authenticationToken=new WechatAuthenticationToken(principal,authorities);
        LinkedHashMap<Object, Object> linkedHashMap = new LinkedHashMap<>();
        linkedHashMap.put("principal", authenticationToken.getPrincipal());
        authenticationToken.setDetails(linkedHashMap);
        return authenticationToken;
    }
}

上述代码是一个自定义的认证提供者类,名为WeChatAuthenticationProvider。其主要功能是处理微信登录认证。在authenticate方法中,首先从传入的Authentication对象中提取出微信登录所需的参数,包括登录码、手机号码、昵称和头像URL。然后通过RestTemplate发送HTTP请求到微信API获取用户的openId,以验证用户身份。若成功获取openId,则检查系统中是否存在该用户的认证信息,若存在则直接返回认证token;若不存在,则继续获取用户的手机号,并根据手机号获取用户信息,并保存用户认证信息。最后,返回经过认证的token。

4.4. 编写WechatAuthenticationToken

public class WechatAuthenticationToken extends AbstractAuthenticationToken {

    private final Object principal;

    private  Object phoneCode;

    private Object nickName;

    private Object imageUrl;

    public WechatAuthenticationToken(String loginCode,String phoneCode,String nickName,String imageUrl) {
        super(null);
        this.principal = loginCode;
        this.phoneCode=phoneCode;
        this.nickName=nickName;
        this.imageUrl=imageUrl;
        setAuthenticated(false);
    }

    public WechatAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        super.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

    public Object getPhoneCode() {
        return phoneCode;
    }

    public Object getNickName() {
        return nickName;
    }

    public Object getImageUrl() {
        return imageUrl;
    }
}

4.5. WechatConfig

@Data
@Component
@ConfigurationProperties(prefix="wechat")
public class WechatConfig {
    private String appid;
    private String secret;
}

4.6. 更新WebSecurityConfigurer

@Configuration
public class WebSecurityConfigurer extends WebSecurityConfigurerAdapter {

    @Autowired
    @Qualifier("authUserDetailsServiceImpl")
    private UserDetailsService userDetailsService;

    @Autowired
    private SecurOncePerRequestFilter securOncePerRequestFilter;
    @Autowired
    private SecurAuthenticationEntryPoint securAuthenticationEntryPoint;
    @Autowired
    private SecurAccessDeniedHandler securAccessDeniedHandler;

    //登录成功处理器
    @Autowired
    private SecurAuthenticationSuccessHandler securAuthenticationSuccessHandler;
    @Autowired
    private SecurAuthenticationFailureHandler securAuthenticationFailureHandler;

    //退出处理器
    @Autowired
    private SecurLogoutHandler securLogoutHandler;
    @Autowired
    private SecurLogoutSuccessHandler securLogoutSuccessHandler;

    @Autowired
    BCryptPasswordEncoderUtil bCryptPasswordEncoderUtil;


    @Value("${wechat.appid}")
    private String appId;
    @Value("${wechat.secret}")
    private String secret;

    @Autowired
    WechatConfig wechatConfig;

    @Autowired
    private  WeChatService weChatService;
    @Autowired
    private ISysUserAuthService sysUserAuthService;

//    @Autowired
//    DynamicPermission dynamicPermission;

    /**
     * 从容器中取出 AuthenticationManagerBuilder,执行方法里面的逻辑之后,放回容器
     *
     * @param authenticationManagerBuilder
     * @throws Exception
     */
    @Autowired
    public void configureAuthentication(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
        authenticationManagerBuilder.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoderUtil);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        //第1步:解决跨域问题。cors 预检请求放行,让Spring security 放行所有preflight request(cors 预检请求)
        http.authorizeRequests().requestMatchers(CorsUtils::isPreFlightRequest).permitAll();

        //第2步:让Security永远不会创建HttpSession,它不会使用HttpSession来获取SecurityContext
        http.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().headers().cacheControl();

        //第3步:请求权限配置
        //放行注册API请求,其它任何请求都必须经过身份验证.
        http.authorizeRequests()
//                .antMatchers("/**").permitAll()
                .antMatchers(HttpMethod.POST,"/sys-user/register").permitAll()
                .antMatchers(HttpMethod.GET,"/temp/create","/department/enable-department","/instance/**","/file/download/**").permitAll()
                .antMatchers("/css/**", "/js/**", "/images/**", "/fonts/**","/editor-app/**","/model/**","/editor/**").permitAll()
                .antMatchers("/modeler.html/**").permitAll()
                .antMatchers("/feign/**").permitAll()
                //ROLE_ADMIN可以操作任何事情
                .antMatchers("/v2/api-docs", "/v2/feign-docs",
                        "/swagger-resources/configuration/ui",
                        "/swagger-resources","/swagger-resources/configuration/security",
                        "/swagger-ui.html", "/webjars/**").permitAll()
                .antMatchers(HttpMethod.POST, "/user/wx/login").permitAll()
                .anyRequest().authenticated();

//                .antMatchers("/**").hasAnyAuthority("USER","SUPER_ADMIN","ADMIN");
                /*
                 由于使用动态资源配置,以上代码在数据库中配置如下:
                 在sys_backend_api_table中添加一条记录
                 backend_api_id=1,
                 backend_api_name = 所有API,
                 backend_api_url=/**,
                 backend_api_method=GET,POST,PUT,DELETE
                 */
                //动态加载资源
//                .anyRequest().access("@dynamicPermission.checkPermisstion(request,authentication)");


        //第4步:拦截账号、密码。覆盖 UsernamePasswordAuthenticationFilter过滤器
        http.addFilterAt(securUsernamePasswordAuthenticationFilter() , UsernamePasswordAuthenticationFilter.class);

        //第5步:拦截token,并检测。在 UsernamePasswordAuthenticationFilter 之前添加 JwtAuthenticationTokenFilter
        http.addFilterBefore(securOncePerRequestFilter, UsernamePasswordAuthenticationFilter.class);

        http.addFilterBefore(weChatAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);


        //第6步:处理异常情况:认证失败和权限不足
        http.exceptionHandling().authenticationEntryPoint(securAuthenticationEntryPoint).accessDeniedHandler(securAccessDeniedHandler);

        //第7步:登录,因为使用前端发送JSON方式进行登录,所以登录模式不设置也是可以的。
        http.formLogin();

        //第8步:退出
        http.logout().addLogoutHandler(securLogoutHandler).logoutSuccessHandler(securLogoutSuccessHandler);

    }

    @Bean
    public WeChatAuthenticationFilter weChatAuthenticationFilter() throws Exception {
        WeChatAuthenticationFilter filter = new WeChatAuthenticationFilter(appId, secret);
        filter.setAuthenticationManager(authenticationManagerBean());
        filter.setAuthenticationSuccessHandler(securAuthenticationSuccessHandler);
        filter.setAuthenticationFailureHandler(securAuthenticationFailureHandler);
        return filter;
    }
    /**
     * 手动注册账号、密码拦截器
     * @return
     * @throws Exception
     */
    @Bean
    SecurUsernamePasswordAuthenticationFilter securUsernamePasswordAuthenticationFilter() throws Exception {
        SecurUsernamePasswordAuthenticationFilter filter = new SecurUsernamePasswordAuthenticationFilter();
        //成功后处理
        filter.setAuthenticationSuccessHandler(securAuthenticationSuccessHandler);
        //失败后处理
        filter.setAuthenticationFailureHandler(securAuthenticationFailureHandler);

        filter.setAuthenticationManager(authenticationManagerBean());
        return filter;
    }
    @Bean
    public WeChatAuthenticationProvider weChatAuthenticationProvider() {
        return new WeChatAuthenticationProvider(wechatConfig,weChatService,sysUserAuthService);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 添加微信登录认证提供者
        auth.authenticationProvider(weChatAuthenticationProvider());
        // 添加用户名密码登录认证提供者
        auth.authenticationProvider(daoAuthenticationProvider());
    }

    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(new BCryptPasswordEncoder());
        return provider;
    }
}

说一个容易踩坑的地方:在Spring Security中,当你配置了自定义的认证提供者(如weChatAuthenticationProvider())来处理特定类型的认证(如微信登录),如果没有同时配置默认的认证提供者(如daoAuthenticationProvider()),则原有的基于用户名和密码的认证机制不会自动生效。这是因为Spring Security的认证机制是基于一个可配置的AuthenticationManager,它管理一个AuthenticationProvider列表,这些提供者会依次尝试认证用户提交的Authentication请求。

5. 结语

在本文中以流程讲解和代码实操讲解了如何在已有用户名和密码登录的基础上,实现微信小程序登录集成。下期将介绍基于OAuth2框架如何实现小程序登录,感兴趣的同学动动你们发财的小手点点关注吧~

【Spring Security系列】权限之旅:SpringSecurity小程序登录深度探索-LMLPHP

 6. 参考链接 

开放能力 / 用户信息 / 手机号快速验证组件 (qq.com)

05-25 11:55