您的当前位置:首页正文

【OAuth2系列】集成微信小程序登录到 Spring Security OAuth 2.0

2024-11-29 来源:个人技术集锦


1. 前言

随着微信小程序在国内的广泛应用,越来越多的企业希望将微信小程序登录功能集成到他们的系统中,以提供更便捷的用户体验。Spring Security OAuth 2.0 是一个强大的框架,可以帮助我们实现这一需求。本文将详细介绍如何在 Spring Security OAuth 2.0 中扩展支持微信小程序登录,通过自定义授权方式实现无缝登录。

2. 总体登录流程

上述流程图描述了小程序集成OAuth2获取Token登录步骤及日常携带token访问接口步骤:

登录步骤:

1. 获取用户基本信息

在小程序端,调用 wx.getUserProfile() 方法来获取用户的基本信息。这一步通常是在用户同意授权后进行的,用于获取用户的头像、昵称等基本资料。

2. 获取登录凭证

通过调用 wx.login() 方法,小程序端可以获取到一个 loginCode,这是一个临时登录凭证,用于后续的认证请求。

3. 获取手机号凭证

调用 getPhoneNumber 方法,小程序端可以获取到一个 phoneCode,这是一个用于获取用户手机号的临时凭证。

4. 发送登录请求

小程序端将 loginCodephoneCode 一起发送给开发者服务器。开发者服务器接收到这些凭证后,开始处理登录请求。

5. 调用微信接口获取 OpenID

开发者服务器使用 appidappsecret 以及 loginCode 调用微信的登录凭证校验接口(https://api.weixin.qq.com/sns/jscode2session),微信返回 session_keyopenid 等信息。

6. 检查 OpenID

开发者服务器查询本地的 user_auth 表,检查是否存在对应的 openid。如果 openid 已经存在,说明用户已经注册过,直接进行登录操作。

7. 获取 AccessToken

如果 openid 不存在,开发者服务器需要调用微信的接口获取 access_tokenhttps://api.weixin.qq.com/cgi-bin/token)。

8. 获取用户手机号

使用获取到的 access_tokenphoneCode,开发者服务器调用微信接口(https://api.weixin.qq.com/wxa/business/getuserphonenumber)来获取用户的手机号。

9. 用户注册或绑定

开发者服务器将获取到的手机号与 user 表中的数据进行比对。如果存在匹配的手机号,则在 user_auth 表中新增一条记录,将 openid 绑定到用户账户上。如果不存在匹配的手机号,则创建一个新的用户账户,并将 openid 与该新账户绑定。

10. 自定义登录状态

开发者服务器根据用户的注册或绑定结果,创建并返回一个自定义的 OAuth2 登录状态(OAuth2Authentication 对象),并生成相应的 Token。

11. 返回 Token 和用户基本信息

开发者服务器将生成的 Token 和用户的基本信息返回给小程序端。

携带token访问接口步骤:

1. 业务请求

小程序端在后续的业务请求中,将 Token 放在请求头中发送给开发者服务器。

2. 验证登录状态

开发者服务器在接收到业务请求后,验证请求头中的 Token 以确认用户的登录状态,并处理相应的业务逻辑。

3. 返回业务数据

验证通过后,开发者服务器返回相应的业务数据给小程序端,完成整个流程。

通过以上步骤,实现了微信小程序与 OAuth2 的无缝集成,确保了用户的便捷登录和系统的安全性。


3. 数据表设计

3.1. sys_user表

CREATE TABLE "public"."sys_user" (
  "id" "pg_catalog"."varchar" COLLATE "pg_catalog"."default" NOT NULL,
  "username" "pg_catalog"."varchar" COLLATE "pg_catalog"."default" NOT NULL,
  "password" "pg_catalog"."varchar" COLLATE "pg_catalog"."default",
  "is_enabled" "pg_catalog"."int4",
  "mobile" "pg_catalog"."varchar" COLLATE "pg_catalog"."default",
  "create_time" "pg_catalog"."timestamp",
  "update_time" "pg_catalog"."timestamp",
  "version" "pg_catalog"."int4" DEFAULT 1,
  "department_id" "pg_catalog"."varchar" COLLATE "pg_catalog"."default",
  "name" "pg_catalog"."varchar" COLLATE "pg_catalog"."default" NOT NULL,
  "image_url" "pg_catalog"."varchar" COLLATE "pg_catalog"."default",
  CONSTRAINT "sys_user_intranet_pkey" PRIMARY KEY ("id")
)
;


COMMENT ON COLUMN "public"."sys_user"."id" IS '用户 ID';

COMMENT ON COLUMN "public"."sys_user"."username" IS '用户名';

COMMENT ON COLUMN "public"."sys_user"."password" IS '密码,加密存储, admin/1234';

COMMENT ON COLUMN "public"."sys_user"."is_enabled" IS '帐户是否可用(1 可用,0 删除用户)';

COMMENT ON COLUMN "public"."sys_user"."mobile" IS '注册手机号';

COMMENT ON COLUMN "public"."sys_user"."create_time" IS '创建时间';

COMMENT ON COLUMN "public"."sys_user"."update_time" IS '更新时间';

COMMENT ON COLUMN "public"."sys_user"."version" IS '乐观锁';

COMMENT ON COLUMN "public"."sys_user"."name" IS '真实姓名';

COMMENT ON TABLE "public"."sys_user" IS '用户信息表';

3.2. user_auth表

CREATE TABLE "public"."sys_user_auth" (
  "id" "pg_catalog"."varchar" COLLATE "pg_catalog"."default" NOT NULL,
  "user_id" "pg_catalog"."varchar" COLLATE "pg_catalog"."default" NOT NULL,
  "identity_type" "pg_catalog"."varchar" COLLATE "pg_catalog"."default" NOT NULL,
  "identifier" "pg_catalog"."varchar" COLLATE "pg_catalog"."default",
  "credential" "pg_catalog"."varchar" COLLATE "pg_catalog"."default",
  "log_time" "pg_catalog"."timestamp",
  "is_phone_verified" "pg_catalog"."int2",
  "is_email_verified" "pg_catalog"."int2",
  CONSTRAINT "sys_user_auth_pkey" PRIMARY KEY ("id"),
  CONSTRAINT "user_auth_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "public"."sys_user" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION
)
;

 上表中的identifier字段为小程序登录时的openId:

3.3. 表关系

在上图中可看出sys_user表的id与sys_user_auth中的user_id为逻辑外键的关系。 


4. OAuth2 扩展实现小程序登录

 
4.1 OAuth2登录涉及到的重要组件讲解

1. TokenEndpoint

Token请求的入口是 TokenEndpoint。客户端通过 /oauth/token 发送请求来获取访问令牌。

2. ClientDetailsService

ClientDetailsService 负责加载客户端的详细信息。InMemoryClientDetailsService 是一种典型的实现方式,它从内存中加载客户端详细信息,但也可以使用其他实现,例如从数据库加载数据的 JdbcClientDetailsService

3. TokenRequest

加载客户端详细信息后,会创建一个 TokenRequest 对象,该对象包含关于客户端请求令牌的信息,例如客户端ID、授权类型、范围等。

4. TokenGranter

TokenGranter 接口定义了授予令牌的机制。CompositeTokenGranter 是一种典型的实现,根据授权类型(例如授权码、密码、客户端凭证等)委派给其他 TokenGranter 实现。

5. OAuth2Request

OAuth2Request 代表客户端发起的OAuth2请求。该对象封装了客户端请求的所有参数。

6. Authentication

认证过程验证客户端凭据以及其他必要的认证步骤(例如,如果是密码授权类型,还需要验证用户的凭据)。

7. OAuth2Authentication

OAuth2Authentication 对象将 OAuth2Request 与认证的主体(用户详细信息或客户端详细信息)结合起来。这个对象用于创建访问令牌。

8. AuthorizationServerTokenServices

AuthorizationServerTokenServices 接口定义了发放令牌的操作。DefaultTokenServices 是一种典型的实现,处理令牌的创建和持久化。

9. TokenStore 和 TokenEnhancer

TokenStore 接口定义了如何存储和检索令牌(例如,内存、数据库、JWT等)。TokenEnhancer 允许在令牌发放之前添加额外的信息。

10. OAuth2AccessToken

这个令牌包含访问令牌本身,以及其他信息如过期时间、刷新令牌、范围等。

4.2 微信小程序自定义登录

OAuth2默认授权方式为5种(授权码模式、简化模式、密码模式、客户端凭据模式和刷新令牌模式),不包含小程序登录,需要编写自定义授权代码,拓展AbstractTokenGranter,步骤如下:

1.在原有的五种授权模式上新增WechatTokenGranter,集成自AbstractTokenGranter:

package com.geoscene.ynbackoauth2server.oauth2.granter;

/**
 * @version 1.0
 * @description: TODO
 * @author: xfc
 * @date 2022-10-10 15:12
 */

import com.geoscene.ynbackoauth2server.oauth2.authentication.WechatAuthenticationToken;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.common.exceptions.InvalidGrantException;
import org.springframework.security.oauth2.provider.*;
import org.springframework.security.oauth2.provider.token.AbstractTokenGranter;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
import java.util.LinkedHashMap;
import java.util.Map;


public class WechatTokenGranter extends AbstractTokenGranter {

    // 自定义授权方式为 wechat
    private static final String GRANT_TYPE = "wechat";

    private final AuthenticationManager authenticationManager;

    public WechatTokenGranter(AuthenticationManager authenticationManager,
                              AuthorizationServerTokenServices tokenServices, ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory) {
        this(authenticationManager, tokenServices, clientDetailsService, requestFactory, GRANT_TYPE);
    }

    protected WechatTokenGranter(AuthenticationManager authenticationManager, AuthorizationServerTokenServices tokenServices,
                                 ClientDetailsService clientDetailsService, OAuth2RequestFactory requestFactory, String grantType) {
        super(tokenServices, clientDetailsService, requestFactory, grantType);
        this.authenticationManager = authenticationManager;
    }

    @Override
    protected OAuth2Authentication getOAuth2Authentication(ClientDetails client, TokenRequest tokenRequest) {

        Map<String, String> parameters = new LinkedHashMap(tokenRequest.getRequestParameters());
        String loginCode = parameters.get("loginCode");
        String phoneCode=parameters.get("phoneCode");
//        String encryptedData = parameters.get("encryptedData");
//        String iv = parameters.get("iv");

        // 移除后续无用参数
        parameters.remove("loginCode");
        parameters.remove("phoneCode");
//        parameters.remove("encryptedData");
//        parameters.remove("iv");

        Authentication userAuth = new WechatAuthenticationToken(loginCode,phoneCode); // 未认证状态
        ((AbstractAuthenticationToken) userAuth).setDetails(parameters);

        try {
            userAuth = this.authenticationManager.authenticate(userAuth); // 认证中
        } catch (Exception e) {
            throw new InvalidGrantException(e.getMessage());
        }

        if (userAuth != null && userAuth.isAuthenticated()) { // 认证成功
            OAuth2Request storedOAuth2Request = this.getRequestFactory().createOAuth2Request(client, tokenRequest);
            return new OAuth2Authentication(storedOAuth2Request, userAuth);
        } else { // 认证失败
            throw new InvalidGrantException("Could not authenticate code: " + loginCode);
        }
    }
}

WechatTokenGranter 类通过自定义的授权类型 "wechat" 实现了微信小程序登录的认证流程。它扩展了 AbstractTokenGranter,并通过重写 getOAuth2Authentication 方法来处理微信特有的认证逻辑。该类确保了在微信小程序登录过程中,能够正确处理 loginCodephoneCode,并通过 authenticationManager 进行认证,最终返回 OAuth2 的认证结果。 

2.新增WechatAuthenticationProvider用于验证WechatAuthenticationToken:

package com.geoscene.ynbackoauth2server.oauth2.authentication;

/**
 * @version 1.0
 * @description: TODO
 * @author: xfc
 * @date 2022-10-10 15:30
 */

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.geoscene.ynbackapi.entities.SysUser;
import com.geoscene.ynbackapi.feign.IFeignSystemController;
import com.geoscene.ynbackapi.req.AddUserAuthReq;
import com.geoscene.ynbackoauth2server.oauth2.config.WechatConfig;
import com.geoscene.ynbackoauth2server.oauth2.service.JwtUser;
import com.geoscene.ynbackoauth2server.oauth2.service.WeChatService;
import com.geoscene.ynbackoauth2server.web.utils.RedisUtils;
import com.geoscene.ynbackoauth2server.web.utils.RestTemplateUtil;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import java.util.*;

@Slf4j
@Component
public class WechatAuthenticationProvider implements AuthenticationProvider {

    @Autowired
    private RedisUtils redisUtils;

    @Autowired
    private WechatConfig wechatConfig;

    @Autowired
    private WeChatService weChatService;

    @Autowired
    RestTemplate restTemplate;

    @Autowired
    IFeignSystemController feignSystemController;


    @Override
    @SneakyThrows
    public Authentication authenticate(Authentication authentication) {

        WechatAuthenticationToken wechatAuthenticationToken = (WechatAuthenticationToken) authentication;
        String loginCode = wechatAuthenticationToken.getPrincipal().toString();
        log.info("loginCode为:{}",loginCode);
        String phoneCode=wechatAuthenticationToken.getPhoneCode().toString();
        log.info("phoneCode为:{}",phoneCode);
        //获取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(feignSystemController.getuserAuthCountByIdentifier(openId)>0){
             jwtUser = (JwtUser) weChatService.getUserByOpenId(openId);
             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);
        feignSystemController.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;
    }
}

代码讲解: 

1. 根据前端传入的code,结合appid和secret,远程调用获取openId;

2.根据获取的openId查看user_auth表中identifier是否有对应openId,有直接登录返回token,没有则调用获取手机号接口;

3.根据appid和secret调用获取accessToken;

4.根据前端传来的code(与获取openId的code不同)结合accessToken调用获取手机号;

5.将获取的手机号与user表中手机号进行对比,存在则在user_auth表中新增一条数据,并返回token;

6.不存在手机号则在user表中新增一条数据,用户名为手机号,权限为普通用户,同时在user_auth,user_role中新增一条记录,并登录返回token;

 5. 结语

通过本文的介绍,我们成功地在 Spring Security OAuth 2.0 中实现了微信小程序的登录扩展。通过自定义授权器和验证器,我们能够处理微信小程序特有的登录流程,确保用户能够安全、便捷地通过小程序登录到我们的系统中。这不仅提升了用户体验,也增强了系统的安全性和灵活性。如果你在实际操作中遇到任何问题或有更好的建议,欢迎交流与探讨。

显示全文