准备工作

  1. 本文以nestjs框架为例子,完成shopify oauth认证的流程
  2. 使用ngrok来完成远程调试工作
  3. 浏览一遍shopify关于OAuth的文档OAuth · Shopify Help Center

开始认证

第一步: 获取客户端密钥

在开发应用的界面找到API key 和 secret key

第二步: 请求授权

用户点击安装链接之后,你的应用将会收到一个GET请求,该请求URL是在合伙人后台指定的。

登陆过 Shopify App Store的用户请求这个路由会自动带上shop, timestamphmac请求参数。如果你的安装链接不是源于 Shopify App Store, 那么你需要自己提供shop请求参数或者实现其他可以获取用户店铺名称的方法。

Shopify会展示一个对话框来获取用户的授权:

在服务器端返回如下的URL重定向来展现该对话框:

https://example.org/some/redirect/uri?code={authorization_code}&hmac=da9d83c171400a41f8db91a950508985&timestamp=1409617544&state={nonce}&shop={hostname}
  • {shop} : 商户的店铺名称(全局唯一)
  • {api_key} : 应用的 API Key
  • {scopes} : 一个用 , (逗号)分割的scope(授权范围)列表。例如,应用需要修改订单和读取消费者的权限,则应写成scope=write_orders,read_customers 任何对资源修改的权限都包含了对该资源的读权限。
  • {redirect_uri} : 当应用被授权之后用户被重定向到页面的URL. 完整的重定向URL必须添加在应用后台的 whitelisted redirection URL(重定向白名单)中。 API access scopes · Shopify Help Center
  • {nonce} : 你的应用提供的随机数应该是每一个授权请求都唯一的。在OAuth回调中, 你的应用必须校验回调的随机数和提供授权请求的随机数的一致性。这是对于你的应用非常重要的安全机制RFC 6819 - OAuth 2.0 Threat Model and Security Considerations
  • {access_mode} : 设置访问模式 access mode ,如果该参数设置为空或者省略了,那么默认值为 offline access mode 。如果设置为per-user则为 online access mode

第三部分: 确认安装

当用户在弹出对话框中点击Install按钮后,用户的会被重定向到上文提到的redirect_uri对应的页面。authorization_code将会被作为请求参数传给该URL地址。

https://example.org/some/redirect/uri?code={authorization_code}&hmac=da9d83c171400a41f8db91a950508985&timestamp=1409617544&state={nonce}&shop={hostname}

在你继续之前,请确认你的应用会进行如下的安全校验。如果任何一项校验失败了,你必须拒绝该请求并返回错误,并且不能继续下去。

  • nonce必须和第二步提供给Shopify的nonce保持一致
  • hmac必须是合法的。HMAC是Shopify签发的,详见下文验证
  • hostname参数必须是一个合法的hostname,即以myshopify.com结尾,不能包含英文字母(a-z)、数字(0-9)、点(.)、连字符(-)之外的任何符号。
    参考restjs代码:
    checkHostname(hostname: string) : boolean {
        if (!hostname.endsWith('myshopify.com')) {
            return false
        }

        if (!RegExp('^[-.a-z0-9]*$').test(hostname)) {
            return false
        }

        return true
    }

当所有安全校验通过之后,你便可以通过店铺的access_token节点将authorization_code换取永久access token:

POST https://{shop}.myshopify.com/admin/oauth/access_token

在这个请求当中,{shop}是商户店铺的名称,以下参数都需要在request body中提供:

  • client_id : 在 Partner Dashboard中定义的API Key
  • client_secret : 在 Partner Dashboard中定义的API Secret
  • code : 重定向请求中的 authorization code
    Shopify服务器返回的数据如下所示:
{
  "access_token": "f85632530bf277ec9ac6f649fc327f17",
  "scope": "write_orders,read_customers"
}

还有文档中没提到的是,如果同一个authorization code被使用两次会返回HTTP STATUS CODE为400的response,所以注意做容错处理
示例代码如下(参考了这篇文章Using Providers and HTTP Requests in a NestJS Backend | joshmorony - Learn Ionic & Build Mobile Apps with Web Tech):
参考restjs代码:

import { Controller, Get, Query, Res, UseFilters, HttpException } from '@nestjs/common';
import { AccessTokensService } from 'src/services/access-tokens/access-tokens.service';
import { Logger } from '@nestjs/common';
import {HttpExceptionFilter} from 'src/filters/api-exception.filter';

const clientId = 'bd873**************7512f2d89'
const clientSecret = 'faae6**************342ceada1e1'
const scope = 'write_products,read_product_listings,write_orders,write_script_tags,write_fulfillments,write_shipping,write_checkouts,write_price_rules,read_shopify_payments_payouts'
const redirectUri = 'https://**************.ngrok.io/codes/redirect'
const accessMode = ''
const nonce = '1234567890' //这里是demo,正式环境应该每次独立生成

@Controller('codes')
export class CodesController {
    constructor(
        private accessTokenService: AccessTokensService
    ){

    }

    @Get('install')
    auth(
        @Query() query,
        @Res() res
    ) {
        Logger.log('收到install请求:' + query.hmac + '|' + query.shop + '|' + query.timestamp)
        let shopName = query.shop.split('.')[0]
        const installRedirectUrl = 
            'https://' + shopName +
            '.myshopify.com/admin/oauth/authorize?client_id=' + clientId + 
            '&scope=' + scope + 
            '&redirect_uri=' + redirectUri + 
            '&state' + nonce + 
            '&grant_options[]=' + accessMode

        res.redirect(installRedirectUrl);
    }

    @Get('redirect')
    @UseFilters(new HttpExceptionFilter())
    async redirectUri(
        @Query() query
    ) {
        Logger.log('收到redirect请求:' + JSON.stringify(query))
        let hostname = query.shop
        let authorizationCode = query.code

        // 1.校验nonce
        if (query.nonce === nonce) {
            Logger.log('nonce校验成功')
        } else {
            Logger.error('nonce校验失败')
            throw new HttpException('nonce校验失败', 100001)
        }

        // 2.校验hmac
        let hmacCheck = this.accessTokenService.checkHMACDigest(query, clientSecret)
        if (false === hmacCheck) {
            Logger.error('hmac校验失败')
            throw new HttpException('hmac校验失败', 100002)
        } else {
            Logger.log('hmac校验成功')
        }
        
        // 3.校验hostname
        let hostnameCheck = this.accessTokenService.checkHostname(hostname)
        if (false === hostnameCheck) {
            Logger.error('hostname校验失败')
            throw new HttpException('hostname校验失败', 100003)
        } else {
            Logger.log('hostname校验成功')
        }

        await this.accessTokenService.getAccessToken(hostname, clientId, clientSecret, authorizationCode).then((res) => {
            Logger.log('收到access token:' + JSON.stringify(res))
			  // 正式环境应该保存access token
        }, (err) => {
            Logger.error('error:' + err)
            throw new HttpException('获取access token失败', 100004)
        })

        return 'success'
    }

}

第四步:发起认证请求

当APP获取到API access token之后,APP就可以发起需要认证的请求了。这些请求需要在HTTP请求头中添加X-Shopify-Access-Token: {access_token}其中{access_token}替换为获取到的永久token。

验证

每一条从Shopify发送到client服务器的请求或者重定向页面都会包含hmac参数,该参数用于验证来源于Shopify的请求的真实性。对于每一个请求来说,你必须先将hmac参数从请求query中移除,然后再用 HMAC-SHA256哈希函数来处理该请求query。
例如下面的请求query:

"code=0907a61c0c8d55e99db179b68161bc00&hmac=700e2dadb827fcc8609e9d5ce208b2e9cdaab9df07390d2cbca10d7c328fc4bf&shop=some-shop.myshopify.com&state=0.6784241404160823&timestamp=1337178173"

移除HMAC

为了移除HMAC,你可以先将query字符串转换成map,移除hmac的键值对,然后再将map按照字典序转换成字符串。上面例子转换后的结果如下:

"code=0907a61c0c8d55e99db179b68161bc00&shop=some-shop.myshopify.com&state=0.6784241404160823&timestamp=1337178173"

哈希函数处理

如果要将字符串经过 HMAC-SHA256函数处理,还需要再传入上文提到的secret key。如果自己生成的散列值和服务器返回的hmac一致,则说明请求是真实的。
nestjs Example:

    checkHMACDigest(query: Object, secret: string) : boolean {
        let hmac = query['hmac']
        delete query['hmac']
        let queryString = querystring.stringify(query)
        return hmac == crypto.createHmac('SHA256', secret).update(queryString).digest('hex')
    }

小结

这里就完成了Shopify OAuth的完整流程