作者:后端小肥肠
创作不易,未经允许严禁转载。
姊妹篇:
【Spring Security系列】权限之旅:SpringSecurity小程序登录深度探索_spring security 微信小程序登录-CSDN博客
1. 前言
随着微信小程序在国内的广泛应用,越来越多的企业希望将微信小程序登录功能集成到他们的系统中,以提供更便捷的用户体验。Spring Security OAuth 2.0 是一个强大的框架,可以帮助我们实现这一需求。本文将详细介绍如何在 Spring Security OAuth 2.0 中扩展支持微信小程序登录,通过自定义授权方式实现无缝登录。
2. 总体登录流程
上述流程图描述了小程序集成OAuth2获取Token登录步骤及日常携带token访问接口步骤:
登录步骤:
- 获取用户基本信息
在小程序端,调用 wx.getUserProfile() 方法来获取用户的基本信息。这一步通常是在用户同意授权后进行的,用于获取用户的头像、昵称等基本资料。
通过调用 wx.login() 方法,小程序端可以获取到一个 loginCode,这是一个临时登录凭证,用于后续的认证请求。
- 获取手机号凭证
调用 getPhoneNumber 方法,小程序端可以获取到一个 phoneCode,这是一个用于获取用户手机号的临时凭证。
小程序端将 loginCode 和 phoneCode 一起发送给开发者服务器。开发者服务器接收到这些凭证后,开始处理登录请求。
- 调用微信接口获取 OpenID
开发者服务器使用 appid 和 appsecret 以及 loginCode 调用微信的登录凭证校验接口(https://api.weixin.qq.com/sns/jscode2session),微信返回 session_key 和 openid 等信息。
- ****检查 OpenID
开发者服务器查询本地的 user_auth 表,检查是否存在对应的 openid。如果 openid 已经存在,说明用户已经注册过,直接进行登录操作。
- 获取 AccessToken
如果 openid 不存在,开发者服务器需要调用微信的接口获取 access_token(https://api.weixin.qq.com/cgi-bin/token)。
- 获取用户手机号
使用获取到的 access_token 和 phoneCode,开发者服务器调用微信接口(https://api.weixin.qq.com/wxa/business/getuserphonenumber)来获取用户的手机号。
- 用户注册或绑定
开发者服务器将获取到的手机号与 user 表中的数据进行比对。如果存在匹配的手机号,则在 user_auth 表中新增一条记录,将 openid 绑定到用户账户上。如果不存在匹配的手机号,则创建一个新的用户账户,并将 openid 与该新账户绑定。
- 自定义登录状态
开发者服务器根据用户的注册或绑定结果,创建并返回一个自定义的 OAuth2 登录状态(OAuth2Authentication 对象),并生成相应的 Token。
- 返回 Token 和用户基本信息
开发者服务器将生成的 Token 和用户的基本信息返回给小程序端。
携带token访问接口步骤:
- 业务请求
小程序端在后续的业务请求中,将 Token 放在请求头中发送给开发者服务器。
- 验证登录状态
开发者服务器在接收到业务请求后,验证请求头中的 Token 以确认用户的登录状态,并处理相应的业务逻辑。
- 返回业务数据
验证通过后,开发者服务器返回相应的业务数据给小程序端,完成整个流程。
通过以上步骤,实现了微信小程序与 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登录涉及到的重要组件讲解
- TokenEndpoint
Token请求的入口是 TokenEndpoint。客户端通过 /oauth/token 发送请求来获取访问令牌。
- ClientDetailsService
ClientDetailsService 负责加载客户端的详细信息。InMemoryClientDetailsService 是一种典型的实现方式,它从内存中加载客户端详细信息,但也可以使用其他实现,例如从数据库加载数据的 JdbcClientDetailsService。
- TokenRequest
加载客户端详细信息后,会创建一个 TokenRequest 对象,该对象包含关于客户端请求令牌的信息,例如客户端ID、授权类型、范围等。
- TokenGranter
TokenGranter 接口定义了授予令牌的机制。CompositeTokenGranter 是一种典型的实现,根据授权类型(例如授权码、密码、客户端凭证等)委派给其他 TokenGranter 实现。
- OAuth2Request
OAuth2Request 代表客户端发起的OAuth2请求。该对象封装了客户端请求的所有参数。
- Authentication
认证过程验证客户端凭据以及其他必要的认证步骤(例如,如果是密码授权类型,还需要验证用户的凭据)。
- OAuth2Authentication
OAuth2Authentication 对象将 OAuth2Request 与认证的主体(用户详细信息或客户端详细信息)结合起来。这个对象用于创建访问令牌。
- AuthorizationServerTokenServices
AuthorizationServerTokenServices 接口定义了发放令牌的操作。DefaultTokenServices 是一种典型的实现,处理令牌的创建和持久化。
- TokenStore 和 TokenEnhancer
TokenStore 接口定义了如何存储和检索令牌(例如,内存、数据库、JWT等)。TokenEnhancer 允许在令牌发放之前添加额外的信息。
- OAuth2AccessToken
这个令牌包含访问令牌本身,以及其他信息如过期时间、刷新令牌、范围等。
4.2 微信小程序自定义登录
OAuth2默认授权方式为5种(授权码模式、简化模式、密码模式、客户端凭据模式和刷新令牌模式),不包含小程序登录,需要编写自定义授权代码,拓展AbstractTokenGranter,步骤如下:
- 在原有的五种授权模式上新增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 方法来处理微信特有的认证逻辑。该类确保了在微信小程序登录过程中,能够正确处理 loginCode 和 phoneCode,并通过 authenticationManager 进行认证,最终返回 OAuth2 的认证结果。
- 新增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;
}
}
代码讲解:
-
根据前端传入的code,结合appid和secret,远程调用api.weixin.qq.com/sns/jscode2…获取openId;
-
根据获取的openId查看user_auth表中identifier是否有对应openId,有直接登录返回token,没有则调用获取手机号接口;
-
根据appid和secret调用api.weixin.qq.com/cgi-bin/tok…获取accessToken;
-
根据前端传来的code(与获取openId的code不同)结合accessToken调用api.weixin.qq.com/wxa/busines…获取手机号;
-
将获取的手机号与user表中手机号进行对比,存在则在user_auth表中新增一条数据,并返回token;
-
不存在手机号则在user表中新增一条数据,用户名为手机号,权限为普通用户,同时在user_auth,user_role中新增一条记录,并登录返回token;
5. 结语
通过本文的介绍,我们成功地在 Spring Security OAuth 2.0 中实现了微信小程序的登录扩展。通过自定义授权器和验证器,我们能够处理微信小程序特有的登录流程,确保用户能够安全、便捷地通过小程序登录到我们的系统中。这不仅提升了用户体验,也增强了系统的安全性和灵活性。如果你在实际操作中遇到任何问题或有更好的建议,欢迎交流与探讨。