在最近的项目中,需要开发一个能支撑大型 SaaS 生态链的平台,其中涉及到账户多设备管理、自有业务单点登录(SSO)、多主体入驻、开放平台下的第三方应用入驻及审批(OAuth2)、物联网平台下的项目及其设备接入、可插拔模块化应用开发的支持等等。
在之前,我也独立开发过类似的平台,但当时没有对大体架构及技术细节做深入了解就急着去敲代码实现,导致后期频频停下来陷入碎片化的思考。所以,在这次开发之前,我做足了功课,先撰写了流程文档,理顺后开始编码,一切水到渠成。现在该平台除了物联网和可插拔模块化应用开发,其他皆由我开发完毕,并写下本文章作为复盘。
前端:Typescript 5、Sveltekit 1.5、TailwindCSS 3.3、DaisyUI 2.51
后端:PHP 8.2、Webman 1.5
数据库:Redis 6、MySQL 8
微信扫码关注公众号登录,Odoo OAuth Provider
首先,是账户管理系统中最基础的注册和登录,这两者司空见惯不再多提。但在状态管理中,登录态的实现需做下强调,为了简化我们只思考登录流程,以及鉴权逻辑。
在早期的互联网应用中,最基本的身份验证方法是使用账号密码。用户只需要提供用户名和密码,系统就会核对数据库中的记录以验证用户身份。这种方法简单易用,但安全性较低,容易受到暴力破解、字典攻击等风险。[基于账号密码的认证]
为了提高安全性,HTTP 基本认证(Basic Authentication)和摘要认证(Digest Authentication)应运而生。HTTP 基本认证通过将用户名和密码以 Base64 编码的形式传输,而摘要认证使用特定的哈希算法对密码进行加密,然后将加密后的摘要传输。这两种方法虽然在一定程度上提高了安全性,但仍然存在一些缺陷,例如信息在传输过程中容易被拦截。[HTTP 基本认证和摘要认证]
随着 Web 应用的普及,开发者需要一种在用户多次请求之间保持登录状态的方法。这时,Session 和 Cookie 便出现了。Session 是服务器端存储的一种数据结构,用于保存用户的登录信息;Cookie 是一种客户端技术,将登录凭证存储在用户的浏览器中。这种方法在当时已经足够满足大部分应用场景,但随着网络攻击手段的不断演变,这种方法的安全性逐渐受到挑战。[Session 和 Cookie]
同时为了解决信息在传输过程中的安全问题,SSL(Secure Sockets Layer)和后来的 TLS(Transport Layer Security)诞生了。这些协议为客户端和服务器之间的通信提供了一种加密机制,保证了传输过程中的数据安全。随后,基于 SSL/TLS 的 HTTPS(Hyper Text Transfer Protocol Secure)成为了 Web 应用的标准传输协议。[SSL/TLS 加密传输]
再往后,随着社交网络的兴起,OAuth 协议应运而生,允许用户使用第三方平台(如 QQ、GitHub、Google 等)的账号登录其他应用。这样,用户无需为每个应用创建单独的账号密码,降低了记忆负担,同时提高了安全性。OAuth 同时也促进了应用间的数据共享和协作。[OAuth 和第三方登录]
为了进一步提高账户安全性,多因素认证(MFA)开始出现。MFA 结合了多种验证方式,如密码、硬件令牌、生物特征等,以确保只有经过多重验证的用户才能访问受保护的资源。MFA 提高了安全性,使得攻击者在窃取某一验证因素时,仍无法轻易地获取完整访问权限。[MFA]
为了更好地管理登录态并解决跨域问题,JSON Web Token(JWT)诞生了。JWT 是一种紧凑、自包含的令牌,可用于在客户端和服务器之间安全地传输信息。与 session 和 cookie 相比,JWT 可以在无状态的场景下维护用户登录态,提高了应用的可扩展性。[JWT]
OpenID Connect(OIDC) 是一种基于 OAuth 2.0 的身份验证协议,提供了一种简单、标准化的方法来验证用户身份。通过 OIDC,用户可以使用一个统一的身份提供者(如 Google、Facebook 等)登录多个应用,实现单点登录(SSO)。OIDC 进一步简化了身份验证流程,提高了用户体验。[OIDC]
这时,移动和 Web 应用开始大面积普及,登录态管理逐渐从 session 和 cookie 转向 access_token 和 refresh_token。access_token 是一种短期的、有时限的令牌,用于访问受保护的资源。refresh_token 则是一种较长期的令牌,用于在 access_token 过期后请求新的 access_token,以保持用户登录态。这种方法的优点在于,access_token 的短时效性可以降低令牌被盗用的风险,而 refresh_token 的存在又能确保用户无需频繁地重新登录。[Access & Refresh Token]
最后,截止 2019 年,为了进一步提高安全性和降低对密码的依赖,WebAuthn(Web Authentication)和 FIDO2(Fast Identity Online 2)标准应运而生。这些标准支持基于公钥密码学的身份验证方法,如生物特征、安全密钥等。用户无需记住复杂的密码,同时避免了密码泄露的风险。[WebAuthn 和 FIDO2]
在本平台中,我采取 Access & Refresh Token 来实现,为提高安全性,access_token 的有效期只有 15 分钟,refresh_token 有效期为 7 天。在浏览器中,refresh_token 不作为 JSON 对象返回,而是通过 httpOnly 直接写入浏览器 Cookie,同时推荐在支持 SSL 下同时加上 Secure 字段。具体流程如下:
在第三步需要注意,在某些条件的后端签发中,只对 refresh_token 做 Cookie 存储,access_token 则需在前端本地存储。也就是说,过期令牌的更换逻辑需要由前端完成。同时这里也会有个坑,例如我们通过单例模式 Api 示例类进行请求调用:类中有 request 和 getToken 这两个函数方法,例如const response = Api.request(url, data); 其中 getToken 是用于在 request 方法的请求发起之前,从 localStorage 获取用户的 access_token,然后自动将 token 写入请求头中。当通过 payload 检测到 access_token 过期时,会访问后端通过 refresh_token 去刷新令牌,再返回新的有效令牌。但这里有个问题:如果在 access_token 过期时有多个请求,由于网络传输需要较长时间,会导致令牌被重复刷新。
为了解决这一个问题,在前端实现中,我们还需要为 Api 做一个异步请求列队:
interface QueuedRequest {
(): Promise<void>;
}
interface TokenInfo {
head: { alg: string, typ: string };
payload: any;
signature: string;
}
class ApiSingleton {
private queue: QueuedRequest[];
private isRefreshing: boolean;
constructor() {
this.queue = [];
this.isRefreshing = false;
}
async request(url:string, data:any = {}, opts:any = {}): Promise<any> {
let token = opts.accessToken || '';
const sendRequest = async () => {
// 自动获取 token
if(!token) {token = await this.getToken();}
if(token) {data.headers['Authorization'] = `Bearer ${token}`;}
return this.requestSend(url, data, opts);
};
if (!token && this.isRefreshing) {
return new Promise((resolve) => {
this.queue.push(() => {
return sendRequest().then(resolve);
});
});
} else {
return sendRequest();
}
}
private requestSend(url:URL, data:any = {}, opts:any = {}): Promise<any>{
// 发送请求
}
async getToken(): Promise<string> {
const token = localStorage.getItem('__access_token')||'';
if(!token) {return '';}
const tokenInfo = this.parseToken(token);
if(!tokenInfo) {
localStorage.removeItem('__access_token');
return '';
}
// 检查 access_token 是否有效
let nowTime = Math.floor(Date.now() / 1000);
if (nowTime < tokenInfo.payload.exp-5) {return token;}
if (!this.isRefreshing) {
this.isRefreshing = true;
const newAccessToken = await this.refreshToken(token);
if(newAccessToken){
localStorage.setItem('__access_token', newAccessToken);
} else {localStorage.removeItem('__access_token');}
this.isRefreshing = false;
this.queue.forEach((queuedRequest) => queuedRequest());
this.queue = [];
}
return localStorage.getItem('__access_token') || '';
}
private parseToken(token: string): TokenInfo|null {
const [head64, body64, signature] = token.split('.');
let head, payload;
try{
head = JSON.parse(atob(head64));
payload = JSON.parse(atob(body64));
} catch (err) {return null;}
return { head, payload, signature };
}
async refreshToken(access_token: string): Promise<string> {
let data = await Api.request('/token/refresh', {
body: { access_token }
}, { errorHandle: false, accessToken: access_token });
if(data.code!==200) {return '';}
return data.access_token;
}
}
const Api = new ApiSingleton();
export { Api };
以上步骤高度抽象来说,无非就四步:账密登录(C) > 签发令牌(S) > 储存令牌以供请求(C) > 校验令牌与权限管理(S)。
其次,我们再处理多设备管理,同时具体化上述流程逻辑:
按照这个逻辑,多设备管理便非常明了:只需列出 key 为 tokens_{UID} 的哈希表,通过 hashKey 指定某一设备,遂进行令牌注销。
另外,这里引入随机值作为 hashKey 的目的只有一个:保护令牌信息不泄露给客户端。
这方面也就是稍复杂一些的多租户系统,所以只阐述概念上的规则内容。
首先是统一账户原则,个人账户一次注册,全平台通用。基于个人账户申请主体,后者分类如政府机关、监管机构、行业协会、事业单位、团体组织、基金会、个体户、企业等。主体信息填报通过资质审核后,方可进行实体及架构管理。
同时,个人账户与组织实体的从属关系是基于单独的业务系统存在的,是相互独立且隔离的,以此来提高平台的灵活性和扩展性。
该平台是用于使个人开发者或团体组织能便捷地接入统一平台,在许多企业级应用中都能见到类似功能的影子。从安全角度来说,第三方应用皆是不安全的,故采用 OAuth 2.0 的授权码模式来实现。
从最小可行性方案来说,第三方应用申请只需简单填写一个表单内容,即应用名称,后端为其生成客户端ID(client_id)及客户端秘钥(client_secret)。要提升安全性可以添加回调地址来强制匹配,提高自定义度可以添加图标、授权页样式等。
对于通过审核的第三方应用,授权流程如下:
为了更加直观易懂,另附 URL 流程如下:
第三方平台用户访问
https://solitude.land/user/edit
发现未登录,跳转至
https://solitude.land/signin?next=/user/edit
本地存储 next 地址后,访问统一登录系统授权页面
https://gxzv.com/oauth/?client_id=6445253fe2a00&scope=identify email phone&redirect_uri=https://solitude.land/callback/oauth
确认授权后,携授权码跳转回
https://solitude.land/callback/oauth?authorization_code=8661a04840fefd55e3504089d57ef580
第三方后端通过授权码获得身份令牌后,跳转至 next 地址
https://solitude.land/user/edit
这部分是为使平台用户登录认证更便捷,以及使用户可借助自身平台完成其他平台应用的免登录认证,提高用户体验及留存。
微信的公众平台和开放平台间的账户完全独立,由于目前只在公众平台缴纳了认证费,索性就通过公众号进行扫码登录,其原理如下:
需要注意的是,只凭公众平台只能获取到用户基于公众号的唯一 ID,也就是 openid。无法获取到用户在微信的唯一 ID,也就是 unionid。这会导致公众号发生更换后,先前的绑定信息将失效。
为了解决这个可能发生的问题,可以在微信开放平台进行认证,并将两个平台关联。关联后微信服务器将同时返回 openid 与 unionid。
Odoo 是一款集成化的企业管理软件,它为中小型企业提供了一站式解决方案,包括销售、采购、库存、生产、财务管理等多个业务领域。作为一款开源软件,Odoo 拥有丰富的第三方插件和高度的定制性,可满足企业不同需求。Odoo 16 版本在原有基础上进行了持续优化与创新,强化了易用性、性能和扩展性,使企业能够高效地进行业务管理和协同,提升整体竞争力。
为了兼顾平台内用户的内部系统,如 OA、CRM、ERP、BMS 等,还需要使这类系统能通过 OAuth 2 调用我们的 Api 以获取需要的信息。例如在 Odoo 内,我们需要先添加 OAuth 服务商:
服务商名称: 甘小蔗的窝
Auth Flow: OAuth2
Token Map: 空
客户ID: 6445253fe2a00
Client Secret: 空
允许: 允许
登录按钮标签: 通过甘小蔗免登录
授权网址: https://gxzv.com/oauth
作用范围: identify email
UserInfo URL: https://api.gxzv.com/oauth/v1/user/info
这样,在 Odoo 登录就会走以下流程:
在第二步中,跳转后的网址如下:
https://gxzv.com/oauth
?response_type=token
&client_id=6445253fe2a00
&redirect_uri=http%3A%2F%2Foa.gxzv.cn%3A58068%2Fauth_oauth%2Fsignin
&scope=identify+email
&state=%7B%22d%22%3A+%22odoo16ent%22%2C+%22p%22%3A+5%2C+%22r%22%3A+%22http%253A%252F%252Foa.gxzv.cn%252Fweb%22%7D
然而在实际调试中,Odoo 成功通过 UserInfo URL 获取到用户信息后确报错:您无权访问此数据库或者您的邀请已经过期,请申请一个新的邀请并在您的邀请邮件中确认。
由于 Odoo 官方文档和网上都没提到其 OAuth 认证提供商部分的资料,所以我去翻了下它的源码。其中,用户信息这部分请求在 addons/auth_oauth/models/res_users.py 文件中:
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import requests
import werkzeug.http
from odoo import api, fields, models
from odoo.exceptions import AccessDenied, UserError
from odoo.addons.auth_signup.models.res_users import SignupError
from odoo.addons import base
base.models.res_users.USER_PRIVATE_FIELDS.append('oauth_access_token')
class ResUsers(models.Model):
_inherit = 'res.users'
oauth_provider_id = fields.Many2one('auth.oauth.provider', string='OAuth Provider')
oauth_uid = fields.Char(string='OAuth User ID', help="Oauth Provider user_id", copy=False)
oauth_access_token = fields.Char(string='OAuth Access Token', readonly=True, copy=False)
_sql_constraints = [
('uniq_users_oauth_provider_oauth_uid', 'unique(oauth_provider_id, oauth_uid)', 'OAuth UID must be unique per provider'),
]
def _auth_oauth_rpc(self, endpoint, access_token):
if self.env['ir.config_parameter'].sudo().get_param('auth_oauth.authorization_header'):
response = requests.get(endpoint, headers={'Authorization': 'Bearer %s' % access_token}, timeout=10)
else:
response = requests.get(endpoint, params={'access_token': access_token}, timeout=10)
if response.ok: # nb: could be a successful failure
return response.json()
auth_challenge = werkzeug.http.parse_www_authenticate_header(
response.headers.get('WWW-Authenticate'))
if auth_challenge.type == 'bearer' and 'error' in auth_challenge:
return dict(auth_challenge)
return {'error': 'invalid_request'}
@api.model
def _auth_oauth_validate(self, provider, access_token):
""" return the validation data corresponding to the access token """
oauth_provider = self.env['auth.oauth.provider'].browse(provider)
validation = self._auth_oauth_rpc(oauth_provider.validation_endpoint, access_token)
if validation.get("error"):
raise Exception(validation['error'])
if oauth_provider.data_endpoint:
data = self._auth_oauth_rpc(oauth_provider.data_endpoint, access_token)
validation.update(data)
# unify subject key, pop all possible and get most sensible. When this
# is reworked, BC should be dropped and only the `sub` key should be
# used (here, in _generate_signup_values, and in _auth_oauth_signin)
subject = next(filter(None, [
validation.pop(key, None)
for key in [
'sub', # standard
'id', # google v1 userinfo, facebook opengraph
'user_id', # google tokeninfo, odoo (tokeninfo)
]
]), None)
if not subject:
raise AccessDenied('Missing subject identity')
validation['user_id'] = subject
return validation
@api.model
def _generate_signup_values(self, provider, validation, params):
oauth_uid = validation['user_id']
email = validation.get('email', 'provider_%s_user_%s' % (provider, oauth_uid))
name = validation.get('name', email)
return {
'name': name,
'login': email,
'email': email,
'oauth_provider_id': provider,
'oauth_uid': oauth_uid,
'oauth_access_token': params['access_token'],
'active': True,
}
@api.model
def _auth_oauth_signin(self, provider, validation, params):
""" retrieve and sign in the user corresponding to provider and validated access token
:param provider: oauth provider id (int)
:param validation: result of validation of access token (dict)
:param params: oauth parameters (dict)
:return: user login (str)
:raise: AccessDenied if signin failed
This method can be overridden to add alternative signin methods.
"""
oauth_uid = validation['user_id']
try:
oauth_user = self.search([("oauth_uid", "=", oauth_uid), ('oauth_provider_id', '=', provider)])
if not oauth_user:
raise AccessDenied()
assert len(oauth_user) == 1
oauth_user.write({'oauth_access_token': params['access_token']})
return oauth_user.login
except AccessDenied as access_denied_exception:
if self.env.context.get('no_user_creation'):
return None
state = json.loads(params['state'])
token = state.get('t')
values = self._generate_signup_values(provider, validation, params)
try:
login, _ = self.signup(values, token)
return login
except (SignupError, UserError):
raise access_denied_exception
@api.model
def auth_oauth(self, provider, params):
# Advice by Google (to avoid Confused Deputy Problem)
# if validation.audience != OUR_CLIENT_ID:
# abort()
# else:
# continue with the process
access_token = params.get('access_token')
validation = self._auth_oauth_validate(provider, access_token)
# retrieve and sign in user
login = self._auth_oauth_signin(provider, validation, params)
if not login:
raise AccessDenied()
# return user credentials
return (self.env.cr.dbname, login, access_token)
def _check_credentials(self, password, env):
try:
return super(ResUsers, self)._check_credentials(password, env)
except AccessDenied:
passwd_allowed = env['interactive'] or not self.env.user._rpc_api_keys_only()
if passwd_allowed and self.env.user.active:
res = self.sudo().search([('id', '=', self.env.uid), ('oauth_access_token', '=', password)])
if res:
return
raise
def _get_session_token_fields(self):
return super(ResUsers, self)._get_session_token_fields() | {'oauth_access_token'}
而最相关的,便是 auth_oauth 函数方法。
在其流程中,_auth_oauth_rpc 方法使用 GET 请求从 UserInfo URL 获取数据,以 access_token 作为查询参数或在请求头部作为授权头发送,并返回 UserInfo URL 提供的 JSON 数据。_auth_oauth_validate 方法则负责调用 _auth_oauth_rpc 方法来获取 UserInfo URL 的数据。接着,它将检查返回的数据是否有错误。如果没有错误,该方法将更新 validation 字典,并将其返回给调用者。
然后,将 validation 传入 _auth_oauth_signin 方法中,执行后续的登录或注册流程。而先前的报错也于此:在没有匹配到本地用户尝试注册账户时,由于 no_user_creation 参数没有设置为关闭,导致 Odoo 不会通过注册创建新用户,遂报错。
在自己搭建 Odoo 的过程中,需要注意 Nginx 反向代理的问题。
upstream odoo {
server 127.0.0.1:8069;
}
upstream odoochat {
server 127.0.0.1:8072;
}
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server
{
listen 80;
listen 443 ssl http2;
server_name odoo.gxzv.com;
index index.php index.html index.htm default.php default.htm default.html;
root /www/wwwroot/odoo;
#REWRITE-START URL重写规则引用,修改后将导致面板设置的伪静态规则失效
proxy_read_timeout 720s;
proxy_connect_timeout 720s;
proxy_send_timeout 720s;
# Add Headers for odoo proxy mode
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
location @odoo {
proxy_pass http://odoo;
proxy_redirect off;
}
location / {
proxy_pass http://odoo;
proxy_redirect off;
}
location /websocket {
proxy_pass http://odoochat;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
location ~ ^/[^/]+/static/.+$ {
root /opt/odoo;
try_files /community/odoo/addons$uri @odoo;
expires 24h;
}
#REWRITE-END
#禁止访问的文件或目录
location ~ ^/(\.user.ini|\.htaccess|\.git|\.svn|\.project|LICENSE|README.md)
{
return 404;
}
access_log /www/wwwlogs/odoo.gxzv.com.log;
error_log /www/wwwlogs/odoo.gxzv.com.error.log;
}
在平台收到收付款项请求时,将创建订单后统一通过收银台进行处理。同时,为了方便境内用户付款,我们还需对接微信与支付宝。
首先,需要使用主体资质注册微信公众平台和微信支付商户平台,并缴纳认证年费完成资质认证,然后将公众号与商户号进行关联。
完成上诉步骤后,你将获得来自公众号平台的 AppId,商户平台的 MchId 以及 SecretKey。但为了调用微信支付的 v3 接口,还需借助工具在商户后台生成商户 API 证书,具体流程可见腾讯客服 - API 证书及密钥。
至此已经可以顺利发起接口调用,但若要应答和对回调进行签名验证,还需要获取微信支付的平台证书,具体可以见:微信支付文档 - 获取平台证书。
对于微信开发,我这里的相应后端语言为 PHP,故选择了 EasyWeChat 作为微信 SDK。需要注意的是,其 v6 的改动和之前版本截然不同,需要结合微信官方开发文档来进行具体的业务开发,甚至当前无法在网上找到相关实例。
这里我基于 Webman 提供一份实例示例,首先是配置文件:
<?php
return [
'official_account' => [
'app_id' => 'wx1127814e7843e7e7',
'secret' => 'gxzv.com',
'token' => 'gxzv_api_token',
'aes_key' => 'gxzv.com'
],
'merchant_account' => [
'mch_id' => '1643647873',
'secret_key' => 'gxzv.com',
'private_key' => base_path() . '/config/wechat/apiclient_1643647873_key.pem',
'certificate' => base_path() . '/config/wechat/apiclient_1643647873_cert.pem',
'platform_certs' => [
base_path() . '/config/wechat/wechatpay.pem'
]
]
];
其次是发起接口调用的代码段,这里是请求付款二维码:
// ...省略
$oaConfig = config('wechatv6.official_account');
$app = new Application(config('wechatv6.merchant_account'));
$api = $app->getClient();
$amount = intval($payment->amount*100);
$response = $api->postJson('/v3/pay/transactions/native', [
'mchid' => (string)$app->getMerchant()->getMerchantId(),
'out_trade_no' => $tradeNo,
'appid' => $oaConfig['app_id'],
'description' => $payment->desc,
'notify_url' => config('app.host').'/callback/wechat/pay',
'amount' => [
'total' => $amount,
'currency' => 'CNY',
],
'time_expire' => $expires_at
]);
$respArray = $response->toArray(false);
if(isset($respArray['code_url'])){
Redis::set($rKey, $respArray['code_url']);
$payment->expires_at && Redis::expire($rKey, $expires_in);
}
// ...省略
同时,为了在 Webman 中处理微信服务器发起的回调请求,处理时需要稍作修改:
<?php
namespace app\controller\callback\wechat;
use EasyWeChat\Pay\Application;
use EasyWeChat\Kernel\Exceptions;
use EasyWeChat\Pay\Message;
use Psr\Http\Message\ResponseInterface;
use support\Request;
use Symfony\Component\HttpFoundation\HeaderBag;
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
class PayController
{
/**
* @throws Exceptions\InvalidArgumentException
* @throws \Throwable
* @throws \ReflectionException
* @throws Exceptions\RuntimeException
*/
public function index(Request $request): ResponseInterface
{
$app = new Application(config('wechatv6.merchant_account'));
$symfony_request = new SymfonyRequest($request->get(), $request->post(), [], $request->cookie(), [], [], $request->rawBody());
$symfony_request->headers = new HeaderBag($request->header());
$app->setRequestFromSymfonyRequest($symfony_request);
$server = $app->getServer();
$server->handlePaid(function (Message $message) {
$tradeNo = $message['out_trade_no'];
$tid = $message['transaction_id'];
// 业务逻辑...
});
// 默认返回 ['code' => 'SUCCESS', 'message' => '成功']
return $server->serve();
}
}
至此,即可简易实现完整的微信支付流程。