使用jwt和APNs通信

引言

最近在设计后台服务的时候引入了JWT,正好想到在iOS10中APNs服务添加了和JWT有关的更新,但还没有来得及实践,于是借此机会码一把。

JSON Web Token (JWT)

JWT是基于JSON、开放的工业标准RFC 7519,用于通信双方安全的互相声明。比如用户登录后,服务器可以生成一个jwt token,保存后返回给用户。该token中指明了用户id和用户角色,当用户需要请求某些操作或资源时,头部添加该jwt token,服务器可以快速的鉴别该请求是否有效合法。因为只是包含了用户id和角色这些非隐私的数据,因此不用担心泄露数据或其他风险。
从结构上看,jwt token包含3部分:

  • header: dictionary,包含签名算法alg和token类型typ
  • payload: dictionary,有效载荷,包含一些数据
  • signature: 对header和payload的签名,规则 sign(base64UrlEncode(header) + "." + base64UrlEncode(payload), private_key)

payload中包含若干声明claims,有3种类型:

  • Registered Claim: 可以理解为预置的,有iss/sub/aud/iat等
  • Public Claim: 为了防止名字冲突,使用"JSON Web Token Claims"确立的或是公有的
  • Private Claim Names: 私有的,自定义的;上面两种声明之外的

完整的jwt token为:base64UrlEncode(header) + "." + base64UrlEncode(payload) + "." + signature
当客户端发送请求的时候,需要在头部headers添加token:

Authorization: Bearer jwt_token

另外token可以添加过期时间等限制,避免token泄漏后被滥用。
推荐一个网站,可以encode/decode token:jwt debugger。同时,该网站也收集了各种语言的jwt实现,在下面我们会再提及。

配置

在开始之前,需要设置相关Apple ID的推送属性,这一步可以在开发者网站做,也可以在Xcode中操作。接着,配置推送证书,这一步只能在开发者网站上操作。因为我们是尝试使用jwt token的通信方式,那么我们需要生成一个key:在创建证书的功能模块下,找到 keys,进行添加。生成的文件是你的私钥,需要妥善保管;key ID需要记下,在后面需要使用。这些完成后,就可以进行客户端和Provider的开发了。

客户端准备

iOS客户端需要做的事情简要如下:

  1. 注册推送服务
  2. 实现UNUserNotificationCenterDelegate:当注册成功后把token发送到Provider;否则提示用户
  3. 注册成功后,添加推送的消息分类
  4. 实现UNUserNotificationCenterDelegate:收到消息后,App的响应

其中,token相当于设备的标识符,当Provider需要推送服务给某个设备的时候,会把该token发送到APNs。
同时要注意,在App每次启动的时候,可以检查用户是否同意了消息提示。如果没有同意的话,需要适时的再次注册推送服务。 做为示例,这里创建AppDelegate的extension,把相关操作聚合在一起:

//APNS
extension AppDelegate:UNUserNotificationCenterDelegate {
    func registerRemoteNotification() {
        UIApplication.shared.registerForRemoteNotifications()
    }

    func requestAuth()  {
        let center = UNUserNotificationCenter.current()
        center.requestAuthorization(options: [.badge,.alert]) { (granted, error) in
            if false == granted {
                //do sth
            }
        }
    }

    func registerNotificationCategory() {
        let center = UNUserNotificationCenter.current()
        let category = UNNotificationCategory(identifier: "general", actions: [], intentIdentifiers: [], options: .customDismissAction)

        // Create the custom actions for the TIMER_EXPIRED category.
        let snoozeAction = UNNotificationAction(identifier: "SNOOZE_ACTION",
                                                title: "Snooze",
                                                options: UNNotificationActionOptions(rawValue: 0))
        let stopAction = UNNotificationAction(identifier: "STOP_ACTION",
                                              title: "Stop",
                                              options: .foreground)

        let expiredCategory = UNNotificationCategory(identifier: "TIMER_EXPIRED",
                                                     actions: [snoozeAction, stopAction],
                                                     intentIdentifiers: [],
                                                     options: UNNotificationCategoryOptions(rawValue: 0))

        center.setNotificationCategories([category, expiredCategory])
    }
    //MARK: - UNUserNotificationCenterDelegate

    func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
        debugPrint("\(#function): \(#line)")
    }
    func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        debugPrint("\(#function): \(#line)")
    }

    //MARK: - Appdelegate
    func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        sendToken(deviceToken)
    }

    func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
        debugPrint(error)
    }
}

关于推送的客户端部分,因为相关的资料很多,这里就不赘述了。

使用token和APNs通信

这里我们使用Express进行Provider的开发,具体的开发环境: Node.js v8.x.x(LTS),Express 4.x.x。使用Express Generator 创建工程,然后在 index.js文件中添加接收客户端发送的token:

router.post('/sendToken', function (req, res, next) {
  console.log(req.body);
  var token = null, appName = null;
  if (req.body.token) token = req.body.token;
  if (req.body.appname) appName = req.body.appname;

  var result = {
    'retCode': 200,
    'msg': 'OK'
  };

  res.status(200).json(result);
});

这里为了简单,没有做authentication,POST参数校验,token的存储等工作。
接下来,我们在网页上添加一个按钮,当点击按钮后,Provider向APNs发送推送消息。

//index.jade
a(href='/apns') APNS Test

接着继续在index.js中添加方法:

const jwt = require('jsonwebtoken');
const fs = require('fs');
const http2 = require('http2');
router.get('/apns', function (req, res, next) {
  var cert = fs.readFileSync('./routes/auth.p8'); 
  var jwtToken = jwt.sign({ iss: 'YOUR_DEVELOPER_ID' }, cert, { algorithm: 'ES256', header: { 'kid': 'YOUR_KEY_ID' } }); //1
  console.log(jwtToken);
  // http2 client
  const client = http2.connect('https://api.development.push.apple.com:443');;
  client.on('error', (err) => {
    console.error(err);
  });
  const playload = {'aps': { 'badge': 2, 'alert': 'this is a test' }};//2
  var payloadData = JSON.stringify(playload);
  const apnReq = client.request({
    ':path': '/3/device/YOUR_RECEIVED_TOKEN',
    ':method': 'POST',
    'authorization': 'bearer ' + jwtToken,
    'apns-topic': 'YOUR_BUNDLE_ID'
  });//3

  apnReq.on('response', (headers, flags) => {
    console.log('http2 response:');
    for (const name in headers) {
      console.log(`${name}: ${headers[name]}`);
    }
  });//4

  apnReq.setEncoding('utf8');
  let data = '';
  apnReq.on('data', (chunk) => { data += chunk; });
  apnReq.on('end', () => {
    console.log('end:');
    console.log(`${data}`);
    client.destroy();
  });

  apnReq.write(payloadData); //5

  apnReq.end();

  res.render('index', { title: 'Express' });
});

使用 npm start后启动过程,控制台也许会提示 http/2 是 experimental API。不用担心,这不会影响项目运行。
//1中使用了 jsonwebtoken模块来创建jwt token,创建的规则来自Apple的文档。注意在签名的时候,模块会自动添加时间戳,因此可以不用显示的添加iat
//2中创建了一个payload,相关的键值见文档
//3表示创建一个http/2的请求,其中4个键值都是必须的。
//4表示对"response"的响应,如果jwt token或者某个参数有误,这里可以得到相应的提示,具体的错误代码可以参考这里。 //5就是把payload信息发送给APNs。
当项目启动后,点击网页中的超链接,会看到控制台显示APNs的response,紧接着App就收到一条推送消息。
以上是Provider和APNs通信的过程,那为什么Apple要引入使用token的基于http/2的方式呢?
考虑原先向APNs发送推送消息,无法得到反馈。对于需要知道消息送达率的情形,这是很头疼的事情。而且一旦因为某些原因,发送某一条消息失败,会导致接下来的消息都发送失败。现在和APNs的通信基于http/2,该协议最大的特点是多路复用,也就是Provider和APNs之间不会频繁的断开/连接:使用ping可以检测并维持当前的连接;而且有明确的response响应。
第二,原先使用证书的通信方式虽然安全,但是考虑到Provider和APNs之间仅仅是通信,并不(或是很少)涉及业务。使用token的话,就跨越了某个App的限制,对于有多个App的开发者就可以共享Provider。
第三,token虽然使用私钥加密,但是还是需要识别App和发送者,而jwt就可以添加一些非隐私的消息来识别不同的App。

补充

  1. payload的数据大小现在最大可以4kb。
  2. 当获得了一个token后,在有效期内(1个小时),使劲的用,不要再去请求新的token
  3. 如果需要发送的消息内容很多很大,可以创建多个连接分别发送,以提高性能
  4. Provider的实现可以参考 apns