工作笔记 - 一次微信认证集成迁移

概述

最近笔者参与维护的一个应用系统,由于客户的问题,域名备案出了问题,无法使用了。相关的手机客户端系统受到了影响,使用的微信认证方式也失效了。

这个系统原来的实现方式比较奇特,微信认证这个模块,是依靠在另一个应用上实现的,这个应用也没有再继续了,现在只是作为这个系统的认证模块使用。这样,所有的相关认证的方式,都需要做调整。简单而言,就是需要在本系统复现微信认证流程。笔者实现了这个调整,觉得这个过程中有一些比较有趣的地方,可以值得分享一下,同时也作为一个工作笔记进行要点的记录。

原理

笔者原来对微信认证相关的实现,只是有一个理论和初步上的认知,并没有实际完整的实现和操作过,这次有了实现的机会,觉得其实原理并不复杂,而且如果能够充分理解,再配合一下网络安全和编程方面的知识,实现起来也比较简单顺畅。

先说原理。

笔者总结,从应用的流程来看,大致如下:

1 用户需要先关注一个微信公众号,关注后,关于此公众号,用户会得到一个标识,就是openid。当然,这个标识并不是显式的,用户无法看到,但程序会使用它来标识当前微信用户在当前公众号上的身份

2 随后,用户微信公众号界面中,看到一个菜单项目,它实际上会连接到一个页面,即应用入口和授权页面,从这里可以发起应用的用户授权和应用,这时候,逻辑上而言openid也是用户在应用中的账号(或者映射)

3 用户点击菜单项目,会在微信(浏览器)中打开这个页面,如果当前用户尚未在应用中登录(可以是session判断),则会启动(微信)认证过程,这个过程大致分为两个阶段

4 第一阶段,入口页面将按照预先的设定,重定向到一个微信服务提供的地址,这个重定向的参数包括了公众号的标识(APPID),和应用设定的返回地址

5 微信服务在进行相关处理后,会再次重定向浏览器返回到返回地址,但这个过程是由微信服务处理的,它会对应用进行检查,如应用公众号是否有效,当前微信用户是否关注等等,由于这个环境是微信服务的环境,它会知晓当前关联的微信账号,然后生成一个相关的authcode(认证代码),并附加到返回地址中

6 浏览器将回到返回地址,但带有authcode,由于返回地址是由应用系统提供和处理的,它会在URL中,获得这个authcode,这时第一阶段就算是基本完成了。简单而言第一阶段的目的就是将用户导引到微信服务,并获取一个authcode

7 第二阶段开始,应用服务获取authcode之后,可以在后台发起一个认证请求(HTTP GET),请求地址同样由微信服务提供,请求信息主要就是预先设置好的APPID、APPSEC(公众号密钥)和AuthCode。这个操作在应用服务后端执行,从用户看起来就是重定向请求挂起和等待处理

8 微信认证服务系统收到这个请求,对APPID和authcode进行验证,确认此请求确实是关联的公众号发起的,然后就可以基于authcode内容响应当前微信用户在公众号的openid

9 应用服务获得微信服务的响应,其中带有当前微信用户在公众号的openid,就可以以此作为用户标识进行后续应用了。至此第二阶段结束。简单而言就是应用服务使用authcode在应用后台对微信服务进行请求,微信服务对应用服务进行验证后(APPID/APPSEC),基于authcode响应用户openid。

10 应用服务获取当前用户的openid之后,要如何操作,就是应用实现自己的问题了,其实和微信本身是没有关系的。比如,具体的操作可以是基于一个映射关系,关联实际的应用的用户;或者再次使用openid进行应用中进行二次认证;或者再次作为参数重定向到另一个应用中(笔者的应用设定就是如此);这些都取决于业务需求和场景。

至此,微信认证的大致原理就是这样。其实,这个认证过程基本上也遵循常用的认证技术就是OAuth2。其原理框架如下图:

可能读者会觉得这个图画的实在不怎么样?!那就对了,因为这是claude生成的,虽然确实不怎么样,但大体的关系和意思确实表达出来了。读者也可以试试,chatGTP、Grok和元宝应该更加惨不忍睹。

为什么要这样设计和实现?因为OAuth2的设计目标为了解决不同应用之间认证和互相的问题的,要考虑到相关的互操作和安全问题,这个认证机制的特性包括:

  • 用户无需向第三方应用提供密码,从而保护账户安全
  • 授权码具有时效性和一次性,可以防止重放攻击
  • 访问令牌可限制权限范围,实现了最小权限原则
  • 支持令牌刷新和撤销,便于权限管理

从这些角度来看,OAuth2的原理开放清晰,安全性高,完全基于HTTP协议,实现简单方便,应用广泛。已经基本上已经成为互联网行业应用认证集成的事实标准。所以,类似的其他各种平台系统的认证,基础思想基本上也是一样的。

理解了原理之后,下面就可以笔者的实践过程为例,来具体了解一下,在真实环境中,是如何进行实现的。

准备工作

如果是从完全零开始微信认证集成的开发工作的话,在开始之前,需要一些前期的准备。

应用服务

在笔者的应用环境中,和微信认证有关的应用方面的调整和开发主要包括:

  • 认证入口地址

就是在服务号应用菜单项目,对应的那个地址。用户可以在关注公众号后,方便的通过公众号菜单点击,进行微信认证后,进入业务应用程序。

  • 业务应用和地址

笔者的应用程序本身是一个中立的MobileWeb应用,并不依赖微信的环境,只是为了方便用户使用,借用微信的自动认证过程,简化用户登录过程。用户本身是有账号的,但可选在微信认证后进行关联,以后都可以通过微信环境来开展应用。

所以,应用的地址也是一个独立的地址。但需要有一个机制,在用户完成微信认证后,来接收这个认证信息(这里是就是简单的openid)。现在的实现方式,就是在一个应用URL的路径上,简单的加入一个openid的参数,然后在应用端的Web应用中,增加相关的参数解析、再验证和账号关联等方面的操作。

但对于认证过程而言,就是简单的在获取Openid后,将此作为参数加入到业务应用提供的地址,并且引导用户浏览器进行重定向操作。

由OAuth的原理可知,在逻辑上,认证入口和业务应用确实是可以分离并且应该分离的。但在实际操作中,基于应用部署方便的考虑,笔者实际上是将认证入口作为业务应用的一个模块进行部署的,表现为这个入口就是应用程序下面的一个路径。

公众号/服务号

显然,要开展微信应用,需要去微信公众平台去申请公众号或者服务号。具体过程和详细内容不在这里讨论。我们只讨论,在笔者的应用中,和微信认证集成相关的设置主要有下面几个地方:

  • 服务号菜单项目

服务号菜单项目,让用户可以方便的通过菜单来访问应用系统,而非像普通应用Web那样手动输入地址。管理员可以配置这些菜单和应用地址的关联关系。具体位置和设置是:

互动管理-自定义菜单-跳转网页-网页链接(图)

在这里配置认证和程序的入口,名称就是出现在菜单上的标题;类型是"跳转网页";并且提供入口地址。

  • 网页授权

网页授权其实是一个安全控制策略。它可以控制认证过程中,相关操作,应当被限制在这些域名空间当中。在应用认证集成中的很多错误,比如"Request_URI"等,都和这个设置是否正确相关。

网页授权设置的具体位置是:

设置与开发-账号设置-功能设置-网页授权域名(图)

点击网络域名栏中的"设置",可以打开具体设置页面(图):

这里要注意,设置这个授权域名时,并不是简单的编辑域名信息就可以了,实际上微信服务是要对这个域名的有效性和安全性进行验证的。所以在正式编辑时,需要进行一个额外的操作。就是需要用户下载一个验证文件(xxx.txt)。然后将这个文件,放置在域名应用的服务器上,让微信服务可以以"域名+验证文件名"的方式,来访问这个文件中的内容(纯文本,其实就是签名或者随机信息)。这个操作可以证明用户确实拥有该域名,因为可以修改域名下的内容。一般情况下,用户也可以修改DNS记录来实现这个,但这种方式显然更有可操作性。

微信网页授权的内容可以是一个域名,也可以是域名+路径。使用路径是为了更加灵活。笔者的环境中就是这样。笔者的业务应用没有拥有一个完整域名,而是一个域名下的路径,通过网关进行代理和转换。

为了避免反复,用户有必要在保存前,先自己验证一下,就是使用浏览器来访问这个文件,看是否正常访问和获取其中的内容。

在笔者的实际环境中,由于是使用一个nginx作为前端代理,同时可以提供Web文件服务,这方面并没有遇到太大的问题。但如果是一个纯的后端程序的话(比如一个nodejs开发的API接口),稍微麻烦一点,可能需要做一些调整,来模拟访问Web文件的方式。

最后,按照中国互联网管理的规定,这些域名应当先进行备案,才能作为有效域名。

  • APP参数

应用的微信认证程序,需要使用APP参数来作为请求信息,让微信认证服务知晓当前的请求标识。APP参数的获取和设置位置在:

设置与开发-开发接口管理-基本配置-开发者ID/开发者密码(图):

APP参数用于微信服务对业务应用进行验证。主要包括APPID和APPSEC。APPID是一个固定的信息;基于安全的考虑,微信平台不会保存开发者密码的原始内容,只有在生成的时候,展示给开发者进行记录。

获取这些信息之后,开发者应当将其配置在其认证集成的程序代码当中。

在做好这些准备工作之后,开发者就可以进入实际的编码和配置阶段了。由于笔者的业务系统是原始基于PHP的,所以这里先讨论一下如何在PHP应用中来实现。

PHP版本实现

笔者的PHP应用,并不是纯粹的PHP代码,而是基于CodeIgnite框架。所以,实现方式基本上是扩展了一个标准的CI控制器,来处理一个路径请求(Wechat)。先看一下相关的参考实现代码:

wechat.php 复制代码
<?php
/**
 * WeChat login and direct controller
*/

const WX_APPID  = 'xxx';
const WX_APPSEC = 'yyy';
const ENTRY_URL    = 'http://entry.url'; // 认证入口地址
const APP_URL    = 'http://app.url'; // 业务应用地址

class Wechat extends CI_Controller {
    public function __construct() {
        parent::__construct(false);
        $this->load->library('curl');
    }

    public function index(){
        $code=$this->input->get('code');

        if (empty($code)) { // get code 
            $url="https://open.weixin.qq.com/connect/oauth2/authorize?appid=".WX_APPID."&redirect_uri=".urlencode(ENTRY_URL."/wechat/")."&response_type=code&scope=snsapi_userinfo&state=ENTRY#wechat_redirect";
            
            //如果用户同意授权,页面将跳转至 redirect_uri/?code=CODE&state=STATE
            redirect($url);
            exit;
        } else {
            //获取openid
            $openid  = $this->getOpenId($code);

            //携带openid跳转到第三方应用系统
            redirect(APP_URL.'?openid='.$openid);        
        }
    }

    //作用:获取用户OPENID和网页access_token
    private function getOpenId($code=''){
        ////获取OPENID和网页授权access_token凭证
        $url = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=".WX_APPID."&secret=".WX_APPSEC."&code=".$code.'&grant_type=authorization_code';
            
        $output = $this->curl->_simple_call('get', $url); 
        $b = json_decode($output);
        $openid=$b->openid;

        return $openid;
    }
}

稍微解释一下:

  • 此文件直接放置在CI项目的Controllers文件夹中,即可生效
  • 基于控制器和类名称,外部访问路径为 http://entry_url/wechat (在授权页面名字空间之下)
  • 简单起见,除框架(CI)和curl之外,没有外部依赖和代码
  • 简单起见,也没有其他的方法和路径,而是直接使用默认方法(index)
  • 使用是否包括code参数,来区分认证阶段
  • 在笔者的环境中,使用认证结果的方式是,获取openid之后,重定向到目标应用的URL
  • 认证应用和目标应用可以不是同一个域名空间或者应用环境
  • 移植时,如果环境相同,只需要修改基础参数即可

至此,笔者应用场景中的微信认证集成就已经完成了。但基于这个原理,如果考虑到,如果将微信认证这个功能抽象出来,作为一个标准服务的话,应当部署成为一个独立的应用。

笔者在这方面也做了简单的探索,就是使用nodejs实现了另外一个js的版本,而且是可以独立工作的。

JS版本实现

下面就是依照相同的原理和流程,实现的JS代码版本,而且在另一个环境中进行了部署,达到了相同的效果:

wechat.js 复制代码
// 引用
const
http  = require('http'),
https = require('https'),
{ URL } = require('url');

// 配置
const CONF = {
    port    : 8011,
    host    : "127.0.0.1",
    appid   : "XXX",
    appsec  : 'YYY',
    entry_url : 'https://service.url',
    app_url : 'http://app.url', // final app page
    auth_url1 : "https://open.weixin.qq.com/connect/oauth2/authorize?appid=__APPID__&redirect_uri=__URL__&response_type=code&scope=sns$    
    auth_url2 : "https://api.weixin.qq.com/sns/oauth2/access_token?appid=__APPID__&secret=__APPSEC__&code=__CODE__&grant_type=authoriz$
};

// 封装http get promise
const simpleGet = (url)=> new Promise((r,v)=>{
    https.get(url, (res) => {
        let data = ''; // 接收数据
        res
        .on('data', (chunk) => { data += chunk; })
        .on('end', () => {
            let or = JSON.parse(data);
            r(or?.openid);
        })
        .on('error', (err) => {
            console.log("Error:", err.message);
            r(null);
        });
    });
});

// 主处理方法
const handleWeb = async(req,res)=>{
    console.log(req.url);

    const code = new URL(req.url,"http://localhost").searchParams.get("code");

    if (!code) { // get code
        const url1 = CONF.auth_url1
            .replace("__APPID__", CONF.appid)
            .replace("__URL__", encodeURIComponent(CONF.entry_url));

        res.writeHead(301, { Location: url1 }).end();
    } else { // get openid
        const url2 = CONF.auth_url2
            .replace("__APPID__", CONF.appid)
            .replace("__APPSEC__", CONF.appsec)
            .replace("__CODE__", code);


        const openid = await simpleGet(url2);

        if (openid) { // everything ok !
            res.writeHead(302, { Location: CONF.app_url+"?openid="+openid }).end();
        } else {
            res
            .writeHead(200, {'Content-Type': 'text/plain; charset=utf-8' })
            .end("OpenID Error");
        }
    }
}

// 创建并启动 HTTP 服务器
http.createServer(handleWeb)
    .listen(CONF.port,CONF.host, () => {
        console.log(`服务器已启动:http://${CONF.host}:${CONF.port}`);
    }
);

简单解释一下:

  • 为了方便修改和移植,使用单一的js文件,也无项目配置和任何外部依赖。
  • 此文件可以直接由nodejs程序,文件可以独立执行
  • 程序执行后,创建一个Web应用并侦听配置好的地址和端口
  • 笔者的环境是通过nginx进行发布的,所以侦听本地地址和特定端口(和网关部署在一起),提高安全性
  • 实际外部访问路径,由网关控制和映射,而且在程序中不做处理
  • 基于兼容性的考虑,没有使用fetch方法,而是简单封装了simpleGet和JSON解析

一些细节和遗留问题

虽然在实际工作中的问题算是解决了,但笔者认为还是有一些其他值得探讨的问题的:

  • 认证后安全

笔者的场景中的处理方式是,认证完成之后,使用openid作为参数,跳转到另一个应用系统。这里面稍微有点安全的问题。首先就是openid是暴露在URL当中的(特别是笔者的应用由于条件限制,还没有使用https协议)。所以,这部分应该对openid参数进行加密,使用验证系统的公钥进行签名,并且设置超时控制,来确保这个认证信息传递过程和后续认证信息使用的安全。

  • 认证阶段分离

笔者的示例当中,两个认证阶段其实是写在一起的,这当然很方便。但实际上这两个部分逻辑上可以分离。入口在一个应用地址,微信认证完成后,可以返回另一个地址来处理openid的问题。

  • 认证服务化

基于认证阶段的分离,就更容易实现微信认证的服务化。就是同样的认证方式,为不同的业务应用提供服务。入口可以配置在不同的业务应用当中,但进行微信验证时,统一使用相同的方式开展第二阶段的认证。

这种部署方式的好处是,各个业务系统不用自己单独开发微信认证的实现,只需要简单的配置和处理返回的openid信息即可。从而脱离和公众号管理和配置具体相关的工作,也不用记录公众号应用的敏感信息。从而提高系统安全和集成效率。

小结

本文基于笔者在实际工作中遇到的一次微信认证迁移的问题,探讨了在Web应用中,如何和微信认证集成的相关问题,包括基础原理和流程,微信公众号的设置和配置,并提供了参考PHP和JS代码。

相关推荐
_風箏23 分钟前
Zabbix【问题 01】安装问题 (比 zabbix-release-5.0-1.el7.noarch 还要新) 问题处理
后端
卓码软件测评29 分钟前
网站测评-利用缓存机制实现XSS的分步测试方法
java·后端·安全·spring·可用性测试·软件需求
星星电灯猴30 分钟前
一次真实的 TF 上架协作案例,从证书到分发的多工具配合流程
后端
Cosolar1 小时前
玩转 WSL:Windows 一键开启 Linux 子系统,轻松实现 SSH 远程连接
后端
rannn_1111 小时前
【Linux学习|黑马笔记|Day4】IP地址、主机名、网络请求、下载、端口、进程管理、主机状态监控、环境变量、文件的上传和下载、压缩和解压
linux·笔记·后端·学习
惜鸟1 小时前
如何让大模型输出结构化数据
后端
ApeAssistant1 小时前
windows 端口占用解决方案
服务器·后端
阿湯哥1 小时前
SkyPilot 的产生背景
后端·python·flask
吴佳浩2 小时前
Python 环境管理工具完全指南
后端·python