Spring Security + JWT 认证体系下,集成微信小程序的一键获取手机号登录功能。

核心思路是:

  1. 小程序端: 调用微信提供的 API 获取用户的加密手机号数据。
  2. 小程序端: 将加密数据发送给后端。
  3. 后端:
    • 接收小程序发送的加密数据。
    • 使用微信提供的 SDK 解密手机号。
    • 根据解密后的手机号在 sys_user 表中查找用户。
    • 如果用户存在,则为该用户生成一个 JWT token,并返回给小程序。
    • 如果用户不存在,可以选择:
      • 拒绝登录(用户未注册)。
      • 自动注册新用户(推荐,提供更好的用户体验)。
    • 将生成的 JWT token 返回给前端,前端后续请求带上这个 token 进行认证。

下面我将分步骤为您实现。


第一部分:后端 Spring Boot 实现

1. 准备工作:微信开放平台配置

您需要在微信开放平台(mp.weixin.qq.com 或 open.weixin.qq.com)配置您的小程序:

  • 获取 AppID 和 AppSecret
  • 确认您的小程序已开通“获取用户手机号”的能力。

2. Maven 依赖

在 pom.xml 中添加 weixin-java-miniapp SDK(或类似提供微信 API 接口的库)以及 HTTP 客户端(如 Apache HttpClient 或 OkHttp,如果 SDK 内部没有集成)。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven-apache.org/xsd/maven-4.0.0.xsd">
    <!-- ... 省略其他配置 ... -->

    <dependencies>
        <!-- Spring Boot Web Starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Spring Security Starter -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!-- JWT Dependencies -->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.5</version> <!-- 请使用最新稳定版本 -->
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.5</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.5</version>
        </dependency>

        <!-- Lombok (可选) -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- 数据库相关依赖 (假设您使用的是 MySQL + MyBatis/JPA) -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
            <version>8.0.33</version> <!-- 或更高版本 -->
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>3.0.3</version> <!-- 或更高版本 -->
        </dependency>
        <!-- 或 spring-boot-starter-data-jpa -->

        <!-- 微信小程序 Java SDK -->
        <dependency>
            <groupId>com.github.binarywang</groupId>
            <artifactId>weixin-java-miniapp</artifactId>
            <version>4.6.0</version> <!-- 请使用最新稳定版本 -->
        </dependency>

        <!-- Jasypt (可选,用于加密配置文件中的敏感信息,如AppSecret) -->
        <dependency>
            <groupId>com.github.ulisesbocchio</groupId>
            <artifactId>jasypt-spring-boot-starter</artifactId>
            <version>3.0.4</version>
        </dependency>

        <!-- Other dependencies... -->
    </dependencies>

    <!-- ... 省略其他配置 ... -->
</project>

3. 配置微信小程序参数

在 application.yml 或 application.properties 中添加微信小程序的 AppID 和 AppSecret。强烈建议使用 Jasypt 等工具对敏感信息进行加密。

application.yml 示例:

# Jasypt 加密配置 (可选,但推荐)
jasypt:
  encryptor:
    password: your_jasypt_secret_key # 用于加密和解密的密钥,请替换成强密码

# 微信小程序配置
wechat:
  miniapp:
    configs:
      - app-id: your_wechat_miniapp_appid # 请替换为你的小程序AppID
        # 使用 Jasypt 加密 AppSecret
        # app-secret: ENC(your_encrypted_app_secret_here)
        app-secret: your_wechat_miniapp_appsecret # 未加密示例,生产环境请加密
        token:
        aes-key:
        msg-data-format: JSON

4. 微信小程序配置类

创建一个配置类来初始化 WxMaService

src/main/java/com/example/yourapp/config/WxMaConfiguration.java

package com.example.yourapp.config;

import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.binarywang.wx.miniapp.api.impl.WxMaServiceImpl;
import cn.binarywang.wx.miniapp.config.WxMaConfig;
import cn.binarywang.wx.miniapp.config.impl.WxMaDefaultConfigImpl;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Configuration
@ConfigurationProperties(prefix = "wechat.miniapp")
@Data
public class WxMaConfiguration {

    private List<Map<String, String>> configs;

    @Bean
    public WxMaService wxMaService() {
        if (configs == null || configs.isEmpty()) {
            throw new RuntimeException("请在 application.yml 中配置 wechat.miniapp.configs 属性");
        }

        WxMaService service = new WxMaServiceImpl();
        service.setMultiConfigs(configs.stream()
            .map(a -> {
                WxMaDefaultConfigImpl config = new WxMaDefaultConfigImpl();
                config.setAppid(a.get("app-id"));
                config.setSecret(a.get("app-secret"));
                if (a.get("token") != null) config.setToken(a.get("token"));
                if (a.get("aes-key") != null) config.setAesKey(a.get("aes-key"));
                if (a.get("msg-data-format") != null) config.setMsgDataFormat(a.get("msg-data-format"));
                return config;
            })
            .collect(Collectors.toMap(WxMaConfig::getAppid, a -> a)));
        return service;
    }
}

5. 现有 JWT 相关类 (假设已存在)

  • JwtTokenUtil / JwtService: 用于生成和验证 JWT token。
  • UserDetailsServiceImpl: 实现 UserDetailsService,从数据库加载用户信息。
  • JwtAuthenticationFilter: 解析请求头中的 JWT token 并设置 Spring Security 上下文。
  • SecurityConfig: 配置 Spring Security 链,允许特定路径(如登录接口)无需认证,并添加 JWT 过滤器。

这里只给出 JwtTokenUtil 的示例,其他 Spring Security 和 JWT 相关的类,请根据您现有项目结构进行调整。

src/main/java/com/example/yourapp/security/JwtTokenUtil.java

package com.example.yourapp.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

@Component
public class JwtTokenUtil {

    @Value("${jwt.secret}")
    private String secret; // 从配置文件读取,请确保足够复杂
    @Value("${jwt.expiration}")
    private long expiration; // token 有效期,单位毫秒

    // 从 token 中获取用户名
    public String getUsernameFromToken(String token) {
        return getClaimFromToken(token, Claims::getSubject);
    }

    // 从 token 中获取过期时间
    public Date getExpirationDateFromToken(String token) {
        return getClaimFromToken(token, Claims::getExpiration);
    }

    public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = getAllClaimsFromToken(token);
        return claimsResolver.apply(claims);
    }

    // 解析 JWT 的 Claims
    private Claims getAllClaimsFromToken(String token) {
        return Jwts.parserBuilder().setSigningKey(getSigningKey()).build().parseClaimsJws(token).getBody();
    }

    // 检查 token 是否过期
    private Boolean isTokenExpired(String token) {
        final Date expiration = getExpirationDateFromToken(token);
        return expiration.before(new Date());
    }

    // 生成 token
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        // 可以在 claims 中添加更多用户信息,例如 userId, role 等
        return doGenerateToken(claims, userDetails.getUsername());
    }

    // 生成 token 的具体实现
    private String doGenerateToken(Map<String, Object> claims, String subject) {
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(subject)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    // 验证 token
    public Boolean validateToken(String token, UserDetails userDetails) {
        final String username = getUsernameFromToken(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

    // 获取签名密钥
    private Key getSigningKey() {
        byte[] keyBytes = Decoders.BASE64.decode(this.secret);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

在 application.yml 中添加 JWT 配置:

jwt:
  # JWT 签名密钥,请替换为一个足够长和复杂的随机字符串,生产环境务必加密或从环境变量获取
  secret: your_jwt_secret_key_that_is_at_least_256_bit_long_for_HS256_algorithm
  expiration: 3600000 # token 有效期,1小时 (毫秒)

6. 用户实体和 Mapper (假设 sys_user 表)

假设您有一个 SysUser 实体,其中包含 phone 字段。

src/main/java/com/example/yourapp/model/SysUser.java

package com.example.yourapp.model;

import lombok.Data;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Collections; // 假设默认没有权限

@Data
public class SysUser implements UserDetails {
    private Long id;
    private String username;
    private String password; // 存储加密后的密码
    private String phone;    // 手机号
    private Boolean enabled; // 用户是否可用

    // 实现 UserDetails 接口方法
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 返回用户权限列表,此处简化为无权限
        return Collections.emptyList();
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return this.enabled != null ? this.enabled : true;
    }
}

src/main/java/com/example/yourapp/mapper/SysUserMapper.java (使用 MyBatis 示例)

package com.example.yourapp.mapper;

import com.example.yourapp.model.SysUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Options;

@Mapper
public interface SysUserMapper {

    @Select("SELECT id, username, password, phone, enabled FROM sys_user WHERE username = #{username}")
    SysUser findByUsername(@Param("username") String username);

    @Select("SELECT id, username, password, phone, enabled FROM sys_user WHERE phone = #{phone}")
    SysUser findByPhone(@Param("phone") String phone);

    @Insert("INSERT INTO sys_user(username, password, phone, enabled) VALUES(#{username}, #{password}, #{phone}, #{enabled})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    void insertUser(SysUser user);
}

7. 用户服务 (UserService)

src/main/java/com/example/yourapp/service/UserService.java

package com.example.yourapp.service;

import com.example.yourapp.mapper.SysUserMapper;
import com.example.yourapp.model.SysUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.security.crypto.password.PasswordEncoder; // 用于自动注册时生成虚拟密码

import java.util.UUID;

@Service
public class UserService implements UserDetailsService {

    @Autowired
    private SysUserMapper sysUserMapper;

    @Autowired
    private PasswordEncoder passwordEncoder; // 注入 PasswordEncoder

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser user = sysUserMapper.findByUsername(username);
        if (user == null) {
            throw new UsernameNotFoundException("用户不存在: " + username);
        }
        return user;
    }

    public SysUser findUserByPhone(String phone) {
        return sysUserMapper.findByPhone(phone);
    }

    /**
     * 注册新用户 (用于手机号登录时自动注册)
     * @param phone 手机号
     * @return 注册成功的用户
     */
    public SysUser registerNewUser(String phone) {
        SysUser newUser = new SysUser();
        // 自动生成一个用户名,例如 phone 或者 random UUID
        newUser.setUsername("wxuser_" + phone);
        // 生成一个随机且加密的密码,因为微信登录不需要密码,但Spring Security需要
        newUser.setPassword(passwordEncoder.encode(UUID.randomUUID().toString()));
        newUser.setPhone(phone);
        newUser.setEnabled(true);
        sysUserMapper.insertUser(newUser);
        return newUser;
    }
}

8. 微信手机号登录 DTO

src/main/java/com/example/yourapp/dto/WechatPhoneLoginRequest.java

package com.example.yourapp.dto;

import lombok.Data;

@Data
public class WechatPhoneLoginRequest {
    private String code;       // 小程序登录凭证
    private String encryptedData; // 小程序获取手机号的加密数据
    private String iv;         // 加密算法的初始向量
}

src/main/java/com/example/yourapp/dto/AuthResponse.java

package com.example.yourapp.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class AuthResponse {
    private String token;
    private String message;
    private String username;
    private String phone;
    // 可以添加更多用户信息
}

9. 认证控制器

src/main/java/com/example/yourapp/controller/AuthController.java

package com.example.yourapp.controller;

import cn.binarywang.wx.miniapp.api.WxMaService;
import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult;
import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo;
import cn.binarywang.wx.miniapp.util.WxMaConfigHolder;
import com.example.yourapp.dto.AuthResponse;
import com.example.yourapp.dto.WechatPhoneLoginRequest;
import com.example.yourapp.model.SysUser;
import com.example.yourapp.security.JwtTokenUtil;
import com.example.yourapp.service.UserService;
import me.chanjar.weixin.common.error.WxErrorException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/auth")
@CrossOrigin(origins = "*") // 允许跨域
public class AuthController {

    private static final Logger logger = LoggerFactory.getLogger(AuthController.class);

    @Autowired
    private WxMaService wxMaService;

    @Autowired
    private UserService userService;

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    // 假设您的用户名/密码登录接口已存在
    // @PostMapping("/login")
    // public ResponseEntity<?> createAuthenticationToken(@RequestBody JwtRequest authenticationRequest) throws Exception { ... }


    /**
     * 微信小程序手机号登录接口
     *
     * @param request 包含 code, encryptedData, iv
     * @return JWT Token
     */
    @PostMapping("/wx/phoneLogin")
    public ResponseEntity<?> wechatPhoneLogin(@RequestBody WechatPhoneLoginRequest request) {
        String appid = wxMaService.getWxMaConfig().getAppid(); // 获取当前配置的AppID
        logger.info("微信手机号登录请求:AppID={}, code={}, encryptedData={}, iv={}", appid, request.getCode(), request.getEncryptedData(), request.getIv());

        // 1. 获取 session_key 和 openid
        WxMaJscode2SessionResult session;
        try {
            session = wxMaService.getUserService().getSessionInfo(request.getCode());
        } catch (WxErrorException e) {
            logger.error("微信jscode2session失败: {}", e.getMessage(), e);
            return ResponseEntity.badRequest().body(new AuthResponse(null, "获取微信会话失败", null, null));
        }

        String sessionKey = session.getSessionKey();
        logger.info("获取到sessionKey: {}", sessionKey);

        // 2. 解密手机号
        WxMaPhoneNumberInfo phoneNumberInfo;
        try {
            // 注意:SDK 内部会根据 appid 找到对应的 WxMaConfigImpl,从而获取到 appSecret。
            // 但解密手机号操作实际需要 sessionKey 和加密数据。
            // WxMaService 默认的解密方法需要提供 sessionKey。
            WxMaConfigHolder.set(appid); // 确保当前线程使用正确的WxMaConfig
            phoneNumberInfo = wxMaService.getUserService().getPhoneNumberInfo(sessionKey, request.getEncryptedData(), request.getIv());
        } catch (Exception e) { // 捕获更广泛的异常,因为解密可能抛出 IllegalArgumentException
            logger.error("解密微信手机号失败: encryptedData={}, iv={}, sessionKey={}, error={}",
                    request.getEncryptedData(), request.getIv(), sessionKey, e.getMessage(), e);
            return ResponseEntity.badRequest().body(new AuthResponse(null, "解密手机号失败,请重试", null, null));
        } finally {
            WxMaConfigHolder.remove(); // 清除当前线程的WxMaConfig
        }


        String phoneNumber = phoneNumberInfo.getPhoneNumber();
        if (phoneNumber == null || phoneNumber.isEmpty()) {
            logger.warn("微信小程序手机号为空,无法登录。encryptedData={}, iv={}", request.getEncryptedData(), request.getIv());
            return ResponseEntity.badRequest().body(new AuthResponse(null, "未能获取到有效的手机号", null, null));
        }
        logger.info("成功解密手机号: {}", phoneNumber);

        // 3. 根据手机号查找或注册用户
        SysUser user = userService.findUserByPhone(phoneNumber);
        if (user == null) {
            // 用户不存在,自动注册
            user = userService.registerNewUser(phoneNumber);
            logger.info("新用户注册成功,手机号: {}", phoneNumber);
        }

        // 4. 生成 JWT Token
        // Spring Security 认证 (可选,如果只是生成token可省略)
        UserDetails userDetails = userService.loadUserByUsername(user.getUsername());
        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
        SecurityContextHolder.getContext().setAuthentication(authentication); // 将认证信息放入安全上下文

        String token = jwtTokenUtil.generateToken(userDetails);
        logger.info("用户 {} (手机号: {}) 登录成功,生成JWT Token。", user.getUsername(), phoneNumber);

        return ResponseEntity.ok(new AuthResponse(token, "登录成功", user.getUsername(), phoneNumber));
    }
}

10. Security 配置类 (示例,请根据您现有配置调整)

确保 /auth/wx/phoneLogin 路径是允许未认证访问的。

src/main/java/com/example/yourapp/config/SecurityConfig.java

package com.example.yourapp.config;

import com.example.yourapp.security.JwtAuthenticationEntryPoint;
import com.example.yourapp.security.JwtRequestFilter; // 假设您的JWT过滤器类名为JwtRequestFilter
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Autowired
    private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;

    @Autowired
    private JwtRequestFilter jwtRequestFilter; // 注入您的JWT过滤器

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable) // 禁用 CSRF
            .authorizeHttpRequests(authorize -> authorize
                // 允许未经认证访问的接口
                .requestMatchers("/auth/login", "/auth/wx/phoneLogin", "/public/**").permitAll()
                // 所有其他请求都需要认证
                .anyRequest().authenticated()
            )
            .exceptionHandling(exception -> exception
                .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 配置认证入口点
            )
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无状态会话
            );

        // 添加 JWT 过滤器在 UsernamePasswordAuthenticationFilter 之前
        http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

JwtRequestFilter.java (示例)

package com.example.yourapp.security;

import com.example.yourapp.service.UserService;
import io.jsonwebtoken.ExpiredJwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtRequestFilter extends OncePerRequestFilter {

    @Autowired
    private UserService userService; // 使用 UserService 来加载 UserDetails

    @Autowired
    private JwtTokenUtil jwtTokenUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        final String requestTokenHeader = request.getHeader("Authorization");

        String username = null;
        String jwtToken = null;

        // JWT Token 在 "Bearer token" 形式
        if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) {
            jwtToken = requestTokenHeader.substring(7);
            try {
                username = jwtTokenUtil.getUsernameFromToken(jwtToken);
            } catch (IllegalArgumentException e) {
                logger.warn("JWT Token 不合法", e);
            } catch (ExpiredJwtException e) {
                logger.warn("JWT Token 已过期", e);
            }
        } else {
            logger.warn("JWT Token 不在 Bearer 格式");
        }

        // 验证 Token
        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {

            UserDetails userDetails = this.userService.loadUserByUsername(username);

            // 如果 token 有效,则配置 Spring Security 来手动设置认证
            if (jwtTokenUtil.validateToken(jwtToken, userDetails)) {
                UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(
                        userDetails, null, userDetails.getAuthorities());
                usernamePasswordAuthenticationToken
                        .setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                // 在上下文设置认证后,当前用户被认为是已认证。
                // 这通过 Spring Security Conext 进行后续检查
                SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
            }
        }
        chain.doFilter(request, response);
    }
}

JwtAuthenticationEntryPoint.java (示例)

package com.example.yourapp.security;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.io.Serializable;

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException) throws IOException, ServletException {
        // 当用户尝试访问需要认证的资源,但未提供或提供了无效的认证凭据时,此方法会被调用
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }
}

第二部分:微信小程序端实现

1. 页面 (WXML)

<!-- pages/login/login.wxml -->
<view class="container">
  <button
    wx:if="{{canIUseGetUserPhoneNumber}}"
    open-type="getPhoneNumber"
    bindgetphonenumber="getPhoneNumber"
    class="wechat-phone-login-button"
  >
    微信一键登录
  </button>
  <view wx:else class="tip-text">
    请升级微信版本以使用一键登录功能。
  </view>

  <!-- 如果您还需要传统的手机号+密码登录 -->
  <view class="divider">
    <text>或</text>
  </view>
  <input class="login-input" placeholder="请输入用户名" bindinput="handleUsernameInput" />
  <input class="login-input" placeholder="请输入密码" password bindinput="handlePasswordInput" />
  <button bindtap="loginByUsernamePassword" class="normal-login-button">
    用户名密码登录
  </button>
</view>

2. 逻辑 (JS)

pages/login/login.js

// pages/login/login.js
const app = getApp(); // 获取全局 app 实例

Page({
  data: {
    canIUseGetUserPhoneNumber: wx.canIUse('button.open-type.getPhoneNumber'),
    username: '',
    password: ''
  },

  onLoad: function() {
    // 可以在这里检查登录状态,例如是否有缓存的 token
    // wx.getStorageSync('jwt_token');
  },

  handleUsernameInput(e) {
    this.setData({ username: e.detail.value });
  },

  handlePasswordInput(e) {
    this.setData({ password: e.detail.value });
  },

  loginByUsernamePassword() {
    const { username, password } = this.data;
    if (!username || !password) {
      wx.showToast({
        title: '请输入用户名和密码',
        icon: 'none'
      });
      return;
    }

    wx.request({
      url: `${app.globalData.apiBaseUrl}/auth/login`, // 你的用户名密码登录接口
      method: 'POST',
      header: {
        'content-type': 'application/json'
      },
      data: {
        username: username,
        password: password
      },
      success: (res) => {
        if (res.statusCode === 200 && res.data && res.data.token) {
          wx.setStorageSync('jwt_token', res.data.token);
          wx.showToast({
            title: '登录成功',
            icon: 'success',
            duration: 1500,
            success: () => {
              // 登录成功后跳转到主页
              setTimeout(() => {
                wx.switchTab({
                  url: '/pages/index/index' // 你的主页路径
                });
              }, 1500);
            }
          });
        } else {
          wx.showToast({
            title: res.data.message || '登录失败,请检查用户名或密码',
            icon: 'none'
          });
        }
      },
      fail: (err) => {
        console.error('用户名密码登录请求失败', err);
        wx.showToast({
          title: '网络错误,请稍后重试',
          icon: 'none'
        });
      }
    });
  },

  getPhoneNumber: function (e) {
    // 获取到用户授权的加密数据
    console.log('getPhoneNumber event:', e);
    if (e.detail.errMsg === 'getPhoneNumber:fail user deny') {
      wx.showToast({
        title: '用户拒绝授权获取手机号',
        icon: 'none'
      });
      return;
    }

    // 1. 获取微信登录凭证 code
    wx.login({
      success: (loginRes) => {
        if (loginRes.code) {
          console.log('wx.login success, code:', loginRes.code);
          // 2. 将 code 和加密数据发送到后端
          this.sendPhoneNumberToServer(loginRes.code, e.detail.encryptedData, e.detail.iv);
        } else {
          console.error('wx.login failed:', loginRes.errMsg);
          wx.showToast({
            title: '微信登录失败',
            icon: 'none'
          });
        }
      },
      fail: (err) => {
        console.error('wx.login call failed:', err);
        wx.showToast({
          title: '微信登录接口调用失败',
          icon: 'none'
        });
      }
    });
  },

  sendPhoneNumberToServer: function (code, encryptedData, iv) {
    wx.showLoading({
      title: '登录中...',
      mask: true
    });

    wx.request({
      url: `${app.globalData.apiBaseUrl}/auth/wx/phoneLogin`, // 你的后端接口
      method: 'POST',
      header: {
        'content-type': 'application/json'
      },
      data: {
        code: code,
        encryptedData: encryptedData,
        iv: iv
      },
      success: (res) => {
        wx.hideLoading();
        if (res.statusCode === 200 && res.data && res.data.token) {
          wx.setStorageSync('jwt_token', res.data.token);
          wx.showToast({
            title: '登录成功',
            icon: 'success',
            duration: 1500,
            success: () => {
              // 登录成功后跳转到主页
              setTimeout(() => {
                wx.switchTab({
                  url: '/pages/index/index' // 你的主页路径,请根据实际情况修改
                });
              }, 1500);
            }
          });
        } else {
          wx.showToast({
            title: res.data.message || '登录失败,请重试',
            icon: 'none'
          });
        }
      },
      fail: (err) => {
        wx.hideLoading();
        console.error('请求后端接口失败', err);
        wx.showToast({
          title: '网络错误,请稍后重试',
          icon: 'none'
        });
      }
    });
  }
});

3. app.js (全局配置)

// app.js
App({
  onLaunch() {
    // ... 其他初始化逻辑
  },
  globalData: {
    // 后端 API 地址,请替换为您的实际地址
    apiBaseUrl: 'http://localhost:8080'
  }
});

4. 授权配置

在小程序的 app.json 中,需要配置 requiredPrivateInfos 以声明需要获取的个人信息接口权限。

{
  "pages": [
    "pages/login/login",
    "pages/index/index"
    // ... 其他页面
  ],
  "requiredPrivateInfos": [
    "getPhoneNumber"
  ],
  "window": {
    "backgroundTextStyle": "light",
    "navigationBarBackgroundColor": "#fff",
    "navigationBarTitleText": "我的应用",
    "navigationBarTextStyle": "black"
  },
  "sitemapLocation": "sitemap.json"
}

复制


总结与注意事项:

  1. 安全性:
    • AppSecret 保护: AppSecret 绝对不能泄露到前端。在后端使用 Jasypt 加密 application.yml 中的 AppSecret 是一个好习惯。
    • JWT Secret 保护: jwt.secret 同样需要是复杂且安全的字符串,不应泄露。
    • HTTPS: 生产环境必须使用 HTTPS,保护通信安全。
  2. 用户体验:
    • 自动注册: 如果小程序用户第一次使用手机号登录,自动注册可以大大简化流程。您可以为这些用户生成一个默认的用户名(例如 wx_手机号)和随机密码(加密后存储)。
    • 错误提示: 完善的错误提示对用户很重要。
  3. 日志: 后端打印详细日志,方便问题排查。
  4. 前端 Token 管理: 小程序端获取到 JWT 后,应存储在 wx.setStorageSync 中,并在后续的请求中通过 header: { 'Authorization': 'Bearer ' + token } 发送给后端。
  5. 多个小程序/公众号: 如果您有多个小程序或公众号,WxMaConfiguration 需要能够管理多个 WxMaConfig 实例,通常通过 AppID 来区分。本示例已支持多配置,但默认只使用第一个配置。
  6. WxMaConfigHolder weixin-java-miniapp 库在多小程序环境下,通常需要使用 WxMaConfigHolder.set(appid) 来指定当前线程操作的是哪个小程序的配置,以防止混淆。
  7. UserService.loadUserByUsername Spring Security 的 UserDetailsService 接口通常只通过 username 加载用户。在您的小程序登录场景中,您可能会通过手机号查找用户,然后用该用户的 username 调用 loadUserByUsername 来生成 UserDetails
  8. 数据库表: 确保您的 sys_user 表有 phone 字段,并且 phone 字段最好是唯一的(加唯一索引)。
  9. 测试: 在开发过程中,确保您的微信小程序 AppID、AppSecret 配置正确,并且小程序开发工具可以正常调试。

这个方案应该能帮助您在现有 Spring Security + JWT 体系下实现微信小程序手机号登录功能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值