OAuth2协议与微信扫码登录实战

1.OAuth2协议

1.1 协议介绍

OAuth协议为用户资源的授权提供了一个安全的、开放而又简易的标准。同时,任何第三方都可以使用OAUTH认证服务,任何服务提供商都可以实现自身的OAUTH认证服务,因而OAUTH是开放的。业界提供了OAUTH的多种实现如PHP、JavaScript,Java,Ruby等各种语言开发包,大大节约了程序员的时间,因而OAUTH是简易的。互联网很多服务如Open API,很多大公司如Google,Yahoo,Microsoft等都提供了OAUTH认证服务,这些都足以说明OAUTH标准逐渐成为开放资源授权的标准。如微信扫码认证等第三方认证的方式都是基于OAuth2协议实现。

Oauth协议目前发展到2.0版本,1.0版本过于复杂,2.0版本已得到广泛应用。

参考:https://baike.baidu.com/item/oAuth/7153134?fr=aladdin

Oauth协议:https://tools.ietf.org/html/rfc6749

1.2 OAuth2认证流程

下边分析一个Oauth2认证的例子,某网站使用微信认证扫码登录的过程:

几个概念:

资源:用户信息,在微信中存储。

资源拥有者:用户是用户信息资源的拥有者。

认证服务:微信负责认证当前用户的身份,负责为客户端颁发令牌。

客户端:客户端会携带令牌请求微信获取用户信息,网站即客户端,网站需要在浏览器打开。

具体流程认证流程如下:

1.用户扫码登录

用户进入网站的登录页面,点击微信图片打开微信扫码登录页面。微信扫码的目的是通过微信认证登录网站,网站需要从微信获取当前用户的身份信息才会让当前用户在网站登录成功。

2.用户授权网站访问用户信息

资源拥有者扫描二维码表示资源拥有者请求微信进行认证,微信认证通过向用户手机返回授权页面。询问用户是否授权网站访问自己在微信的用户信息,用户点击"确认登录"表示同意授权,微信认证服务器会颁发一个授权码给网站。只有资源拥有者同意微信才允许黑马网站访问资源。

3、网站获取到授权码

4、携带授权码请求微信认证服务器申请令牌

此交互过程用户看不到。

5、微信认证服务器向网站响应令牌

此交互过程用户看不到。

6、网站请求微信资源服务器获取资源即用户信息。

网站携带令牌请求访问微信服务器获取用户的基本信息。

7、资源服务器返回受保护资源即用户信息

8、网站接收到用户信息,此时用户在网站登录成功。

理解了微信扫码登录网站的流程,接下来认识Oauth2.0的认证流程,如下:

引自Oauth2.0协议rfc6749 https://tools.ietf.org/html/rfc6749

Oauth2包括以下角色:

1、客户端

本身不存储资源,需要通过资源拥有者的授权去请求资源服务器的资源,比如:手机客户端、浏览器等。

上边示例中网站即为客户端,它需要通过浏览器打开。

2、资源拥有者

通常为用户,也可以是应用程序,即该资源的拥有者。

A表示 客户端请求资源拥有者授权。

B表示 资源拥有者授权客户端即网站访问自己的用户信息。

3、授权服务器(也称认证服务器)

认证服务器对资源拥有者进行认证,还会对客户端进行认证并颁发令牌。

C 客户端即网站携带授权码请求认证。

D认证通过颁发令牌。

4、资源服务器

存储资源的服务器。

E表示客户端即网站携带令牌请求资源服务器获取资源。

F表示资源服务器校验令牌通过后提供受保护资源。

1.3 OAuth2授权认证

Spring Security支持OAuth2认证,OAuth2提供授权码模式、密码模式、简化模式、客户端模式等四种授权模式,前边举的微信扫码登录的例子就是基于授权码模式,这四种模式中授权码模式和密码模式应用较多。

1.3.1 授权码模式

OAuth2的几个授权模式是根据不同的应用场景以不同的方式去获取令牌,最终目的是要获取认证服务颁发的令牌,最终通过令牌去获取资源。

授权码模式简单理解是使用授权码去获取令牌,要想获取令牌先要获取授权码,授权码的获取需要资源拥有者亲自授权同意才可以获取。

下图是授权码模式的交互图:

还以网站微信扫码登录为例进行说明:

1、用户打开浏览器。

2、通过浏览器访问客户端即黑马网站。

3、用户通过浏览器向认证服务请求授权,请求授权时会携带客户端的URL,此URL为下发授权码的重定向地址。

4、认证服务向资源拥有者返回授权页面。

5、资源拥有者亲自授权同意。

6、通过浏览器向认证服务发送授权同意。

7、认证服务向客户端地址重定向并携带授权码。

8、客户端即黑马网站收到授权码。

9、客户端携带授权码向认证服务申请令牌。

10、认证服务向客户端颁发令牌。

1.3.2 密码模式

密码模式相对授权码模式简单,授权码模式需要借助浏览器供用户亲自授权,密码模式不用借助浏览器,如下图:

1、资源拥有者提供账号和密码

2、客户端向认证服务申请令牌,请求中携带账号和密码

3、认证服务校验账号和密码正确颁发令牌。

1.4 认证令牌JWT

1.4.1 问题背景

客户端申请到令牌,接下来客户端携带令牌去访问资源,到资源服务器将会校验令牌的合法性。

资源服务器如何校验令牌的合法性?

我们以OAuth2的密码模式为例进行说明:

从第4步开始说明:

1、客户端携带令牌访问资源服务获取资源。

2、资源服务远程请求认证服务校验令牌的合法性

3、如果令牌合法资源服务向客户端返回资源。

这里存在一个问题:

就是校验令牌需要远程请求认证服务,客户端的每次访问都会远程校验,执行性能低。

如果能够让资源服务自己校验令牌的合法性将省去远程请求认证服务的成本,提高了性能。如下图:

如何解决上边的问题,实现资源服务自行校验令牌。

令牌采用JWT格式即可解决上边的问题,用户认证通过后会得到一个JWT令牌,JWT令牌中已经包括了用户相关的信息,客户端只需要携带JWT访问资源服务,资源服务根据事先约定的算法自行完成令牌校验,无需每次都请求认证服务完成授权。

1.4.2 JWT介绍

SON Web Token(JWT)是一种使用JSON格式传递数据的网络令牌技术,它是一个开放的行业标准(RFC 7519),它定义了一种简洁的、自包含的协议格式,用于在通信双方传递json对象,传递的信息经过数字签名可以被验证和信任,它可以使用HMAC算法或使用RSA的公钥/私钥对来签名,防止内容篡改。官网:https://jwt.io/

使用JWT可以实现无状态认证,什么是无状态认证?

传统的基于session的方式是有状态认证,用户登录成功将用户的身份信息存储在服务端,这样加大了服务端的存储压力,并且这种方式不适合在分布式系统中应用。

如下图,当用户访问应用服务,每个应用服务都会去服务器查看session信息,如果session中没有该用户则说明用户没有登录,此时就会重新认证,而解决这个问题的方法是Session复制、Session黏贴。

如果是基于令牌技术在分布式系统中实现认证则服务端不用存储session,可以将用户身份信息存储在令牌中,用户认证通过后认证服务颁发令牌给用户,用户将令牌存储在客户端,去访问应用服务时携带令牌去访问,服务端从jwt解析出用户信息。这个过程就是无状态认证。

JWT令牌的优点:

1、jwt基于json,非常方便解析。

2、可以在令牌中自定义丰富的内容,易扩展。

3、通过非对称加密算法及数字签名技术,JWT防止篡改,安全性高。

4、资源服务使用JWT可不依赖认证服务即可完成授权。

缺点:JWT令牌较长,占存储空间比较大。

JWT令牌由三部分组成,每部分中间使用点(.)分隔,比如:xxxxx.yyyyy.zzzzz

1.Header

头部包括令牌的类型(即JWT)及使用的哈希算法(如HMAC SHA256或RSA)

一个例子如下:

下边是Header部分的内容

复制代码
{ "alg": "HS256", "typ": "JWT" }

将上边的内容使用Base64Url编码,得到一个字符串就是JWT令牌的第一部分。

2.Payload

第二部分是负载,内容也是一个json对象,它是存放有效信息的地方,它可以存放jwt提供的信息字段,比如:iss(签发者),exp(过期时间戳), sub(面向的用户)等,也可自定义字段。

此部分不建议存放敏感信息,因为此部分可以解码还原原始内容。

最后将第二部分负载使用Base64Url编码,得到一个字符串就是JWT令牌的第二部分。

一个例子:

复制代码
{ "sub": "1234567890", "name": "456", "admin": true }

3.Signature

第三部分是签名,此部分用于防止jwt内容被篡改。

这个部分使用base64url将前两部分进行编码,编码后使用点(.)连接组成字符串,最后使用header中声明的签名算法进行签名。

一个例子:

复制代码
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

base64UrlEncode(header):jwt令牌的第一部分。

base64UrlEncode(payload):jwt令牌的第二部分。

secret:签名所使用的密钥。

为什么JWT可以防止篡改?

第三部分使用签名算法对第一部分和第二部分的内容进行签名,常用的签名算法是 HS256,常见的还有md5,sha 等,签名算法需要使用密钥进行签名,密钥不对外公开,并且签名是不可逆的,如果第三方更改了内容那么服务器验证签名就会失败,要想保证验证签名正确必须保证内容、密钥与签名前一致。

从上图可以看出认证服务和资源服务使用相同的密钥,这叫对称加密,对称加密效率高,如果一旦密钥泄露可以伪造jwt令牌。

JWT还可以使用非对称加密,认证服务自己保留私钥,将公钥下发给受信任的客户端、资源服务,公钥和私钥是配对的,成对的公钥和私钥才可以正常加密和解密,非对称加密效率低但相比对称加密非对称加密更安全一些。

拿到了jwt令牌下一步就要携带令牌去访问资源服务中的资源,本项目各个微服务就是资源服务,比如:内容管理服务,客户端申请到jwt令牌,携带jwt去内容管理服务查询课程信息,此时内容管理服务要对jwt进行校验,只有jwt合法才可以继续访问。如下图:

加上网关并完善后如下图所示:

所有访问微服务的请求都要经过网关,在网关进行用户身份的认证可以将很多非法的请求拦截到微服务以外,这叫做网关认证。

下边需要明确网关的职责:

1、网站白名单维护

针对不用认证的URL全部放行。

2、校验jwt的合法性。

除了白名单剩下的就是需要认证的请求,网关需要验证jwt的合法性,jwt合法则说明用户身份合法,否则说明身份不合法则拒绝继续访问。

网关负责授权吗?

网关不负责授权,对请求的授权操作在各个微服务进行,因为微服务最清楚用户有哪些权限访问哪些接口。

2.微信扫码登录

2.1 接入规范

2.1.1 接入流程

微信扫码登录基于OAuth2协议的授权码模式,接口文档:

https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Wechat_Login.html

流程如下:

第三方应用获取access_token令牌后即可请求微信获取用户的信息,成功获取到用户的信息表示用户在第三方应用认证成功。

2.1.2 请求获取授权码

第三方使用网站应用授权登录前请注意已获取相应网页授权作用域(scope=snsapi_login),则可以通过在 PC 端打开以下链接:http:// https://open.weixin.qq.com/connect/qrconnect?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect 若提示"该链接无法访问",请检查参数是否填写错误,如redirect_uri的域名与审核时填写的授权域名不一致或 scope 不为snsapi_login。

参数说明

返回说明

用户允许授权后,将会重定向到redirect_uri的网址上,并且带上 code 和state参数

复制代码
redirect_uri?code=CODE&state=STATE

若用户禁止授权,则不会发生重定向。

登录一号店网站应用 https://test.yhd.com/wechat/login.do 打开后,一号店会生成 state 参数,跳转到 https://open.weixin.qq.com/connect/qrconnect?appid=wxbdc5610cc59c1631&redirect_uri=https%3A%2F%2Fpassport.yhd.com%2Fwechat%2Fcallback.do&response_type=code&scope=snsapi_login&state=3d6be0a4035d839573b04816624a415e#wechat_redirect微信用户使用微信扫描二维码并且确认登录后,PC端会跳转到 https://test.yhd.com/wechat/callback.do?code=CODE&state=3d6be0a40sssssxxxxx6624a415e

为了满足网站更定制化的需求,我们还提供了第二种获取 code 的方式,支持网站将微信登录二维码内嵌到自己页面中,用户使用微信扫码授权后通过 JS 将code返回给网站。 JS微信登录主要用途:网站希望用户在网站内就能完成登录,无需跳转到微信域下登录后再返回,提升微信登录的流畅性与成功率。 网站内嵌二维码微信登录 JS 实现办法:

步骤1:在页面中先引入如下 JS 文件(支持https):

bash 复制代码
http://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js

步骤2:在需要使用微信登录的地方实例以下 JS 对象:

bash 复制代码
 var obj = new WxLogin({
  self_redirect:true,
  id:"login_container", 
  appid: "", 
  scope: "", 
  redirect_uri: "",
  state: "",
  style: "",
  href: ""
 });

2.1.3 通过code获取acess_token

通过 code 获取access_token

bash 复制代码
https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code

返回说明

正确的返回:

bash 复制代码
{ 
"access_token":"ACCESS_TOKEN", 
"expires_in":7200, 
"refresh_token":"REFRESH_TOKEN",
"openid":"OPENID", 
"scope":"SCOPE",
"unionid": "o6_bmasdasdsad6_2sgVt7hMZOPfL"
}

参数说明

错误返回样例:

bash 复制代码
{"errcode":40029,"errmsg":"invalid code"}

2.1.4 通过access_token调用接口

获取access_token后,进行接口调用,有以下前提:

bash 复制代码
access_token有效且未超时;
微信用户已授权给第三方应用帐号相应接口作用域(scope)。

对于接口作用域(scope),能调用的接口有以下:

中snsapi_base属于基础接口,若应用已拥有其它 scope 权限,则默认拥有snsapi_base的权限。使用snsapi_base可以让移动端网页授权绕过跳转授权登录页请求用户授权的动作,直接跳转第三方网页带上授权临时票据(code),但会使得用户已授权作用域(scope)仅为snsapi_base,从而导致无法获取到需要用户授权才允许获得的数据和基础功能。 接口调用方法可查阅《微信授权关系接口调用指南》

获取用户信息接口文档:https://developers.weixin.qq.com/doc/oplatform/Website_App/WeChat_Login/Authorized_Interface_Calling_UnionID.html

接口地址:

bash 复制代码
http请求方式: GET
https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID

如下:

响应:

bash 复制代码
{
"openid":"OPENID",
"nickname":"NICKNAME",
"sex":1,
"province":"PROVINCE",
"city":"CITY",
"country":"COUNTRY",
"headimgurl": "https://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0",
"privilege":[
"PRIVILEGE1",
"PRIVILEGE2"
],
"unionid": " o6_bmasdasdsad6_2sgVt7hMZOPfL"

}

说明如下:

bash 复制代码
参数            说明
openid        普通用户的标识,对当前开发者帐号唯一
nickname        普通用户昵称
sex            普通用户性别,1为男性,2为女性
province        普通用户个人资料填写的省份
city            普通用户个人资料填写的城市
country        国家,如中国为CN
headimgurl        用户头像,最后一个数值代表正方形头像大小(有0、46、64、96、132数值可选,0代表640*640正方形头像),用户没有头像时该项为空
privilege        用户特权信息,json数组,如微信沃卡用户为(chinaunicom)
unionid          用户统一标识。针对一个微信开放平台帐号下的应用,同一用户的 unionid 是唯一的。

4.2 开发环境准备

4.2.1 添加应用

1、注册微信开放平台

https://open.weixin.qq.com/

2、添加应用

进入网站应用,添加应用

3、添加应用需要指定一个外网域名作为微信回调域名

审核通过后,生成app密钥。

最终获取appID和AppSecret

4.2.2 内网穿透

我们的开发环境在局域网,微信回调指向一个公网域名。

如何让微信回调请求至我们的开发计算机上呢?

可以使用内网穿透技术,什么是内网穿透?

内网穿透简单来说就是将内网外网通过隧道打通,让内网的数据让外网可以获取。比如常用的办公室软件等,一般在办公室或家里,通过拨号上网,这样办公软件只有在本地的局域网之内才能访问,那么问题来了,如果是手机上,或者公司外地的办公人员,如何访问到办公软件呢?这就需要内网穿透工具了。开启隧道之后,网穿透工具会分配一个专属域名/端口,办公软件就已经在公网上了,在外地的办公人员可以在任何地方愉快的访问办公软件了~~

1、在内网穿透服务器上开通隧道,配置外网域名,配置穿透内网的端口即本地电脑上的端口。

这里我们配置认证服务端口,最终实现通过外网域名访问本地认证服务。

2、在本地电脑上安装内网穿透的工具,工具上配置内网穿透服务器隧道token。

4.3 接入微信登录

4.3.1 流程分析

根据OAuth2协议授权码流程,结合本项目自身特点,分析接入微信扫码登录的流程如下:

本项目认证服务需要做哪些事?

1、需要定义接口接收微信下发的授权码。

2、收到授权码调用微信接口申请令牌。

3、申请到令牌调用微信获取用户信息

4、获取用户信息成功将其写入本项目用户中心数据库。

5、最后重定向到浏览器自动登录。

4.3.2 接口设计

完整交互流程:

依赖配置:

bash 复制代码
# 微信配置
weixin:
  appid: 你的微信公众号appid # 替换为实际值
  secret: 你的微信公众号secret # 替换为实际值
java 复制代码
/**
 * 微信登录参数Properties
 * @Author GuihaoLv
 */
@Data
@ConfigurationProperties(prefix = "weixin")
@Configuration
public class WxProperties {

    private String appid;

    private String secret;
}

实体类补充:

java 复制代码
/**
 * 用户实体类
 * @Auther: GuihaoLv
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class User extends BaseEntity implements Serializable {
    private String username;
    private String password;
    private String email;
    private String phoneNumber;
    private String avatar;

    // 新增微信登录相关字段
    private String wxOpenid; // 微信openid
    private String wxUnionid; // 微信unionid
    private String nickname; // 微信昵称
}
java 复制代码
/**
 * 登录返回结果
 * @Auther GuihaoLv
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginVo {
    private String username;
    private String email;
    private String phoneNumber;
    private String avatar;
    private String token;

    // 新增微信登录相关字段
    private String wxOpenid; // 微信openid
    private String wxUnionid; // 微信unionid
    private String nickname; // 微信昵称
}

控制器类实现:

java 复制代码
/**
 * 微信扫码登录管理
 * @Author: GuihaoLv
 */
@RestController
@RequestMapping("/web/wx")
@Slf4j
@Tag(name = "微信扫码登录", description = "微信扫码登录")
public class WxLoginController {
    @Autowired
    private WxLoginService wxLoginService;
    @Autowired
    private JwtUtil jwtUtil;
    @Autowired
    private JwtProperties jwtProperties;
    @Autowired
    private StringRedisTemplate stringRedisTemplate;


   
     /**
     * 生成微信授权二维码的URL
     * 传入前端回调地址,拼接微信官方的授权二维码URL,
     * 返回给前端后,前端就能通过这个 URL 加载出微信登录的二维码
     * 用户扫码后微信会跳转到指定的回调地址并携带授权码code。
     * @param redirectUri
     * @return
     */
     @PostMapping("/getAuthUrl")
     @Operation(summary = "获取微信授权二维码URL")
     public Result<String> getAuthUrl(@RequestParam String redirectUri) {
         String finalRedirectUri = wxLoginService.getAuthUrl(redirectUri);
         return Result.success(finalRedirectUri);
     }


     /**
      * 微信扫码登录实现(前端回调后调用此接口完成登录)
      * @param code 微信回调的授权码
      * @param state 防跨域攻击的状态值(可根据业务验证)
      * @return 跳转地址或登录结果
      */
     @PostMapping("/login")
     @Operation(summary = "微信扫码登录")
     public Result<LoginVo> login(String code, String state) {
         log.debug("微信扫码回调,code:{},state:{}", code, state);

         // 1. 验证state(可选,根据业务场景开启)
         // if (!validateState(state)) {
         //     return Result.fail("非法的state参数");
         // }

         // 2. 调用微信登录核心逻辑,获取用户信息
         User user = wxLoginService.wxLogin(code);
         if (user == null) {
             return Result.fail("微信登录失败");
         }

         // 3. 生成UUID Token(和密码登录保持一致的前端token)
         String userUUID = UUID.randomUUID().toString();

         // 4. 封装UserInfo对象(和密码登录的UserInfo结构对齐)
         UserInfo userInfo = new UserInfo();
         BeanUtils.copyProperties(user, userInfo);
         userInfo.setToken(userUUID); // 把UUID Token存入UserInfo

         // 5. 构建JWT的claims参数,封装用户信息
         Map<String, Object> claims = new HashMap<>();
         String loginJson = JSONUtil.toJsonStr(userInfo);
         claims.put("currentUser", loginJson);

         // 6. 生成JWT Token(核心token,存储在Redis)
         String jwtToken = jwtUtil.generateToken(claims);

         // 7. 获取JWT过期时间(从配置文件读取,和密码登录一致)
         Long expireTime = jwtProperties.getExpireTime();

         // 8. 存储UUID Token到Redis(key: USER_TOKEN+用户名,value: UUID)
         String userUUIDKey = UserConstant.USER_TOKEN + user.getUsername();
         stringRedisTemplate.opsForValue().set(userUUIDKey, userUUID, expireTime, TimeUnit.SECONDS);

         // 9. 存储JWT Token到Redis(key: JWT_TOKEN+UUID,value: JWT)
         String userJWTKey = UserConstant.JWT_TOKEN + userUUID;
         stringRedisTemplate.opsForValue().set(userJWTKey, jwtToken, expireTime, TimeUnit.SECONDS);

         // 10. 构建返回给前端的LoginVo对象(和密码登录的返回结构完全一致)
         LoginVo loginVo = new LoginVo();
         BeanUtils.copyProperties(userInfo, loginVo);
         loginVo.setToken(userUUID); // 前端最终使用的是UUID Token

         // 11. 返回登录结果
         return Result.success(loginVo);
     }


}

Service层:

java 复制代码
public interface WxLoginService {
    /**
     * 微信扫码登录核心方法
     * @param code 微信回调的code
     * @return 登录用户信息
     */
    User wxLogin(String code);

    /**
     *  生成微信授权二维码的URL(前端需要先请求这个接口获取扫码地址)
     * @param redirectUri
     * @return
     */
    String getAuthUrl(String redirectUri);

}
java 复制代码
@Service
@Slf4j
@EnableConfigurationProperties(WxProperties.class)
public class WxLoginServiceImpl implements WxLoginService {
    @Autowired
    private UserMapper userMapper;
    @Autowired
    private WxProperties wxProperties;
    @Autowired
    private BCryptPasswordEncoder passwordEncoder;




      /**
     * 生成微信授权二维码的URL
     * 传入前端回调地址,拼接微信官方的授权二维码URL,
     * 返回给前端后,前端就能通过这个 URL 加载出微信登录的二维码
     * 用户扫码后微信会跳转到指定的回调地址并携带授权码code。
     * @param redirectUri
     * @return
     */
    public String getAuthUrl(String redirectUri) {
        // 微信授权基础URL(需替换为你的公众号appid)
        String wxAuthUrl = "https://open.weixin.qq.com/connect/qrconnect" +
                "?appid=%s" +
                "&redirect_uri=%s" +
                "&response_type=code" +
                "&scope=snsapi_login" +
                "&state=STATE#wechat_redirect";
        // 对回调地址进行URLEncode(必须!)
        String encodeRedirectUri = URLEncoder.encode(redirectUri, StandardCharsets.UTF_8);
        // 替换为你的wxProperties中的appid
        String finalAuthUrl = String.format(wxAuthUrl, wxProperties.getAppid(), encodeRedirectUri);
        return finalAuthUrl;
    }

    /**
     * 微信登录核心逻辑
     */
    @Override
    public User wxLogin(String code) {
        // 1. 根据code获取微信access_token和openid
        Map<String, String> tokenMap = getAccessToken(code);
        if (tokenMap == null || tokenMap.get("openid") == null || tokenMap.get("access_token") == null) {
            log.error("获取微信access_token失败,返回数据:{}", tokenMap);
            return null;
        }

        String openid = tokenMap.get("openid");
        String accessToken = tokenMap.get("access_token");
        String unionid = tokenMap.get("unionid");

        // 2. 根据access_token和openid获取微信用户信息
        Map<String, String> userInfoMap = getUserInfo(accessToken, openid);
        if (userInfoMap == null) {
            log.error("获取微信用户信息失败");
            return null;
        }

        // 3. 保存/查询用户信息到本地数据库
        User user = saveOrUpdateWxUser(userInfoMap, openid, unionid);

        return user;
    }



    /**
     * 调用微信接口获取access_token(使用HttpUtil工具类)
     * 接口文档:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html#1
     */
    private Map<String, String> getAccessToken(String code) {
        // 拼接微信access_token请求URL
        String wxUrl = String.format(
                "https://api.weixin.qq.com/sns/oauth2/access_token?appid=%s&secret=%s&code=%s&grant_type=authorization_code",
                wxProperties.getAppid(), wxProperties.getSecret(), code
        );
        log.info("调用微信获取access_token接口,URL:{}", wxUrl);

        try {
            // 使用项目自定义的HttpUtil发送GET请求
            String result = HttpUtil.sendGet(wxUrl);
            log.info("获取access_token返回结果:{}", result);

            // 解析JSON为Map
            Map<String, String> resultMap = JSON.parseObject(result, Map.class);

            // 检查是否有错误码(微信接口返回错误时会包含errcode)
            if (resultMap.containsKey("errcode") && !"0".equals(resultMap.get("errcode"))) {
                log.error("获取access_token失败,错误码:{},错误信息:{}",
                        resultMap.get("errcode"), resultMap.get("errmsg"));
                return null;
            }
            return resultMap;
        } catch (Exception e) {
            log.error("调用微信access_token接口异常", e);
            return null;
        }
    }

    /**
     * 调用微信接口获取用户信息(使用HttpUtil工具类)
     * 接口文档:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/Wechat_webpage_authorization.html#3
     */
    private Map<String, String> getUserInfo(String accessToken, String openid) {
        // 拼接微信用户信息请求URL
        String wxUrl = String.format(
                "https://api.weixin.qq.com/sns/userinfo?access_token=%s&openid=%s&lang=zh_CN",
                accessToken, openid
        );
        log.info("调用微信获取用户信息接口,URL:{}", wxUrl);

        try {
            // 使用项目自定义的HttpUtil发送GET请求(指定UTF-8编码,避免中文乱码)
            String result = HttpUtil.sendGet(wxUrl, "", StandardCharsets.UTF_8.name());
            log.info("获取用户信息返回结果:{}", result);

            // 解析JSON为Map
            Map<String, String> resultMap = JSON.parseObject(result, Map.class);

            // 检查是否有错误码
            if (resultMap.containsKey("errcode") && !"0".equals(resultMap.get("errcode"))) {
                log.error("获取用户信息失败,错误码:{},错误信息:{}",
                        resultMap.get("errcode"), resultMap.get("errmsg"));
                return null;
            }
            return resultMap;
        } catch (Exception e) {
            log.error("调用微信用户信息接口异常", e);
            return null;
        }
    }

    /**
     * 保存或更新微信用户到本地数据库(事务保证)
     */
    @Transactional(rollbackFor = Exception.class)
    public User saveOrUpdateWxUser(Map<String, String> userInfoMap, String openid, String unionid) {
        User user = null;
        // 1. 优先根据unionid查询(unionid跨公众号/小程序唯一)
        if (unionid != null && !unionid.isEmpty()) {
            user = userMapper.getUserByWxUnionid(unionid);
        }
        // 2. 无unionid则根据openid查询
        if (user == null) {
            user = userMapper.getUserByWxOpenid(openid);
        }

        // 3. 用户已存在,直接返回
        if (user != null) {
            log.info("微信用户已存在,openid:{},unionid:{}", openid, unionid);
            return user;
        }

        // 4. 用户不存在,创建新用户
        log.info("创建新的微信用户,openid:{},unionid:{}", openid, unionid);
        User newUser = User.builder()
                .username(unionid != null ? unionid : openid) // 用unionid/openid作为唯一用户名
                .password(passwordEncoder.encode("123456")) // 初始化密码并加密
                .avatar(userInfoMap.get("headimgurl")) // 微信头像URL
                .wxOpenid(openid) // 微信openid
                .wxUnionid(unionid) // 微信unionid
                .nickname(userInfoMap.get("nickname")) // 微信昵称
                .build();
        // 保存用户到数据库
        userMapper.saveWxUser(newUser);
        return newUser;
    }




}

前端测试:

登录工具类:

TypeScript 复制代码
import axios from 'axios'

// 后端基础地址(替换为你的后端地址)
const BASE_URL = 'http://localhost:8080/web/wx'

/**
 * 微信登录工具类
 */
export const wxLoginUtil = {
  /**
   * 获取微信授权二维码URL
   * @param redirectUri 回调地址(如:http://localhost:8081/wx/callback)
   */
  async getWxAuthUrl(redirectUri) {
    try {
      const res = await axios.post(`${BASE_URL}/getAuthUrl`, {
        redirectUri
      }, {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded'
        }
      })
      return res.data.data
    } catch (error) {
      console.error('获取微信授权URL失败:', error)
      return null
    }
  },

  /**
   * 提交code完成登录
   * @param code 微信回调返回的code
   */
  async loginByCode(code) {
    try {
      const res = await axios.post(`${BASE_URL}/login`, {
        code
      }, {
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded'
        }
      })
      // 登录成功后存储token和用户信息到本地
      if (res.data.success) {
        localStorage.setItem('token', res.data.data.token)
        localStorage.setItem('userInfo', JSON.stringify(res.data.data.user))
      }
      return res.data
    } catch (error) {
      console.error('微信登录失败:', error)
      return { success: false, message: '登录失败' }
    }
  },

  /**
   * 退出登录(清空本地存储)
   */
  logout() {
    localStorage.removeItem('token')
    localStorage.removeItem('userInfo')
  },

  /**
   * 获取当前登录用户信息
   */
  getUserInfo() {
    const userInfo = localStorage.getItem('userInfo')
    return userInfo ? JSON.parse(userInfo) : null
  },

  /**
   * 获取token
   */
  getToken() {
    return localStorage.getItem('token')
  }
}

// 配置axios请求拦截器(自动携带token)
axios.interceptors.request.use(config => {
  const token = wxLoginUtil.getToken()
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }
  return config
}, error => {
  return Promise.reject(error)
})

测试页面:

html 复制代码
<template>
  <div class="wx-login-container">
    <h2>微信扫码登录</h2>
    
    <!-- 未登录状态:显示二维码 -->
    <div v-if="!isLogin">
      <!-- 微信二维码容器(通过iframe加载授权URL) -->
      <iframe 
        :src="wxAuthUrl" 
        width="300" 
        height="400" 
        frameborder="0"
        class="wx-qrcode"
      ></iframe>
      <p>请使用微信扫码授权登录</p>
    </div>

    <!-- 已登录状态:显示用户信息 -->
    <div v-else class="user-info">
      <img :src="userInfo.avatar" alt="头像" class="avatar">
      <p>昵称:{{ userInfo.nickname }}</p>
      <p>用户名:{{ userInfo.username }}</p>
      <button @click="logout">退出登录</button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onActivated } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { wxLoginUtil } from '@/utils/wxLogin'

// 响应式数据
const wxAuthUrl = ref('')
const isLogin = ref(false)
const userInfo = ref(null)
const route = useRoute()
const router = useRouter()

// 页面加载时处理
onMounted(async () => {
  // 1. 检查是否有回调的code(从路由参数中获取)
  const code = route.query.code
  if (code) {
    // 有code则提交登录
    const res = await wxLoginUtil.loginByCode(code)
    if (res.success) {
      userInfo.value = res.data.user
      isLogin.value = true
      // 清空路由参数中的code
      router.replace({ query: {} })
    } else {
      alert(res.message)
    }
  } else {
    // 无code则获取微信授权URL
    // 回调地址:当前页面的URL(需和后端配置的白名单一致)
    const redirectUri = window.location.href.split('?')[0]
    const authUrl = await wxLoginUtil.getWxAuthUrl(redirectUri)
    if (authUrl) {
      wxAuthUrl.value = authUrl
    }
  }
})

// 退出登录
const logout = () => {
  wxLoginUtil.logout()
  isLogin.value = false
  userInfo.value = null
  // 重新获取授权URL
  const redirectUri = window.location.href.split('?')[0]
  wxLoginUtil.getWxAuthUrl(redirectUri).then(url => {
    wxAuthUrl.value = url
  })
}
</script>

<style scoped>
.wx-login-container {
  width: 100%;
  max-width: 400px;
  margin: 50px auto;
  text-align: center;
}

.wx-qrcode {
  border: none;
  margin: 20px 0;
}

.avatar {
  width: 100px;
  height: 100px;
  border-radius: 50%;
  margin: 20px 0;
}

.user-info {
  padding: 20px;
  border: 1px solid #eee;
  border-radius: 8px;
}

button {
  padding: 8px 20px;
  background: #07c160;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  margin-top: 10px;
}
</style>
相关推荐
星光一影6 小时前
智慧停车与充电一体化管理平台:打造城市出行新生态
mysql·vue·能源·springboot·uniapp
Coder_Boy_20 小时前
基于SpringAI的智能平台基座开发-(三)
人工智能·springboot·aiops·langchain4j
Coder_Boy_1 天前
基于SpringAI的智能平台基座开发-(七)
人工智能·springboot·aiops·langchain4j
Coder_Boy_2 天前
SpringAI与LangChain4j的智能应用-(实践篇2)
人工智能·springboot·aiops·langchain4j
大学生资源网2 天前
基于Javaweb技术的宠物用品商城的设计与实现(源码+文档)
java·mysql·毕业设计·源码·springboot
九月生2 天前
基于 Sa-Token 实现 API 接口签名校验(Spring Boot 3 实战)
web安全·springboot
带刺的坐椅3 天前
超越 SpringBoot 4.0了吗?OpenSolon v3.8, v3.7.4, v3.6.7 发布
java·ai·springboot·web·solon·flow·mcp
hgz07103 天前
Spring Boot自动配置
java·springboot
TimberWill4 天前
MinIO整合SpringBoot实现获取文件夹目录结构及文件内容
java·linux·springboot