使用OpenSSL發送IOS推送通知 Apple Push Notification


蘋果的推送服務的設計非常優秀和巧妙,開發者將消息發送到蘋果的APN服務器,APN服務器將消息轉發到設備上,設備與APN保持一個長連接即可,即保證了消息的實時性,又節省了系統資源,更省電。相比之下,Android這個粗放管理的,耗電大戶平台,直到2.2后才添加了類似的推送服務,而且還被牆了。

蘋果的推送模式如下圖所示:

iOS應用首先要請求用戶允許推送通知,用戶允許后,應用會獲得一個32字節的token。

應用開發者要推送通知到用戶的設備時,把消息和token一起發送給APN服務器,APN服務器根據token來將消息發送到用戶的設備上。

本文主要介紹如何通過Openssl來將推送消息發送到APN服務器,有關Apple Push Notification的更多內容可以參考官方文檔。 

1. 准備工作

需要支持推送的應用必須要有一個獨立的App ID(不帶*號的)。並且要在配置里打開Push Notification這一項,配置成功后會有兩個用於發送推送通知時使用的Certificate,分別在Development和Production環境下使用,開發階段使用Development的證書,連接測試的APN服務器,兩者之間不能混用。

將兩個證書都導入鑰匙串,然后從鑰匙串中連Key和證書一起導出到p12文件。再次p12文件轉換成PEM文件。

# 將p12證書轉換為pem
> openssl pkcs12 -in development.p12 -out development.pem -nodes

Xcode中應用的Bundle Identifier必須與App ID相符,並且,還需要創建一個新的與App ID相符的Provisioning Profile,應用的Code Signing要選擇這個Profile才行。

2. 獲取設備token

// 修改AppDelegate.m

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{  
    // 在這里添加以下幾行,請求允許通知
    [[UIApplication sharedApplication]
     registerForRemoteNotificationTypes:(UIRemoteNotificationTypeAlert |
                                         UIRemoteNotificationTypeBadge |
                                         UIRemoteNotificationTypeSound)];

    return YES;
}

// 成功的話會在這里返回得到的token
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
{
    
    NSString *str = [[[NSString stringWithFormat:@"%@", deviceToken]
                      stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"<>"]]
                     stringByReplacingOccurrencesOfString:@" " withString:@""];
    NSLog(@"token: %@", str,);
}

// 失敗時會調用這里
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
{
    NSLog(@"register for remote notification Error: %@", error);
}

// 當收到推送消息時會調用這里。
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo
{
    
}

3. 建立SSL連接,發送推送消息

3.1 初始化SSL

// 初始化ssl庫,Windows下初始化WinSock
void init_openssl()
{
#ifdef _WIN32
    WSADATA wsaData;
    WSAStartup(MAKEWORD(2, 2), &wsaData);
#endif

    SSL_library_init();
    ERR_load_BIO_strings();
    SSL_load_error_strings();
    OpenSSL_add_all_algorithms();
}

3.2 連接服務器

蘋果提供了兩個服務器gateway.push.apple.com:2195,用於正式服務,gateway.sandbox.push.apple.com:2195用於測試服務。我們在測試的時候使用測試服務器,應用正式發布后使用正式服務器。

首先,建立TCP連接。

int tcp_connect(const char* host, int port)
{
    struct hostent *hp;
    struct sockaddr_in addr;
    int sock = -1;

    // 解析域名 
    if (!(hp = gethostbyname(host))) {
        return -1;
    }

    memset(&addr, 0, sizeof(addr));
    addr.sin_addr = *(struct in_addr*)hp->h_addr_list[0];
    addr.sin_family = AF_INET;
    addr.sin_port = htons(port);

    if ((sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0){
        return -1;
    }

    if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
        return -1;
    }

    return sock;
}

第二步,要用我們的證書和Key創建SSL Context,並用SSL_connect實現與服務器的握手

// 創建SSL Context
SSL_CTX* init_ssl_context(
        const char* clientcert,                 /* 客戶端的證書 */
        const char* clientkey,                  /* 客戶端的Key */
        const char* keypwd,                     /* 客戶端Key的密碼, 如果有的話 */
        const char* cacert)                     /* 服務器CA證書 如果有的話 */
{
    // set up the ssl context
    SSL_CTX *ctx = SSL_CTX_new(SSLv23_client_method());
    if (!ctx) {
        return NULL;
    }

    // certificate
    if (SSL_CTX_use_certificate_file(ctx, clientcert, SSL_FILETYPE_PEM) <= 0) {
        return NULL;
    }

    // key
    if (SSL_CTX_use_PrivateKey_file(ctx, clientkey, SSL_FILETYPE_PEM) <= 0) {
        return NULL;
    }

    // make sure the key and certificate file match
    if (SSL_CTX_check_private_key(ctx) == 0) {
        return NULL;
    }

    // load ca if exist
    if (cacert) {
        if (!SSL_CTX_load_verify_locations(ctx, cacert, NULL)) {
            return NULL;
        }
    }

    return ctx;
}

// 實現SSL握手,建立SSL連接
SSL* ssl_connect(SSL_CTX* ctx, int socket)
{
    SSL *ssl = SSL_new(ctx);
    BIO *bio = BIO_new_socket(socket, BIO_NOCLOSE);
    SSL_set_bio(ssl, bio, bio);

    if (SSL_connect(ssl) <= 0) {
        return NULL;
    }

    return ssl;
}

第三步,建立SSL連接成功后,說明至少我們的證書服務器認可了,我們還需要驗證一下服務器的證書是不是正確

// 驗證服務器證書
// 首先要驗證服務器的證書有效,其次要驗證服務器證書的CommonName(CN)與我們
// 實際要連接的服務器域名一致
int verify_connection(SSL* ssl, const char* peername)
{
    int result = SSL_get_verify_result(ssl);
    if (result != X509_V_OK) {
        fprintf(stderr, "WARNING! ssl verify failed: %d", result);
        return -1;
    }

    X509 *peer;
    char peer_CN[256] = {0};

    peer = SSL_get_peer_certificate(ssl);
    X509_NAME_get_text_by_NID(X509_get_subject_name(peer), NID_commonName, peer_CN, 255);
    if (strcmp(peer_CN, peername) != 0) {
        fprintf(stderr, "WARNING! Server Name Doesn't match, got: %s, required: %s", peer_CN,
            peername);
    }
    return 0;
}

3.3 打包要發送的消息

發送的推送消息有兩種格式,這里做簡單介紹,具體的可見蘋果的文檔

第一種形式,比較簡單,Command占一個字節長度,必須是0,Token length是設備號的長度,現在是32,Device Token是二進制的,需要把我們前面獲得的字符串轉換成二進制,Payload length是Payload的長度,根據Payload的長度變化,Payload部分,最多256個字節,是JSON格式的內容,不能以'\0'結尾,如果有中文的話,需要是UTF-8編碼,(關於Payload可以看這里, “The Notification Payload”).

第二種形式比第一種形式增加了,Identifier和Expiry, Identifier是我們自定義的消息編號,如果我們發送的消息有錯誤,比如token無效,或者格式錯誤等,服務器會把這個Identifier返回給我們並返回錯誤碼,需要注意的是如果發送成功,服務器不會給任何回應。Expiry是一個UNIX格式的時間,用來表示消息過期的時間,比如一天的過期的時間可以寫成(time(NULL) + 24 * 3600)。

如果發送失敗,服務器會給我們回應上面格式的數據包,Identifier是我們發送的指定的編號,也就是說只有第二種形式發送的時間服務器才會給回應。

由於涉及Payload的編碼,這部分代碼較長,這里只給出部分片斷,詳細的可參閱完整代碼。

// 第一種形式的包
int build_output_packet(char* buf, int buflen,  /* 輸出的緩沖區及長度 */                                                           
        const char* tokenbinary,                /* 二進制的Token */                                                                
        const char* msg,                        /* 要發送的消息 */                                                                 
        int badage,                             /* 應用圖標上顯示的數字 */                                                         
        const char * sound)                     /* 設備收到時播放的聲音,可以為空 */                                               
{                                                                                                                                  
    assert(buflen >= 1 + 2 + TOKEN_SIZE + 2 + MAX_PAYLOAD_SIZE);                                                                   
                                                                                                                                   
    char * pdata = buf;                                                                                                            
    // command                                                                                                                     
    *pdata = 0;                                                                                                                    
                                                                                                                                   
    // token length                                                                                                                
    pdata++;                                                                                                                       
    *(uint16_t*)pdata = htons(TOKEN_SIZE);                                                                                         
                                                                                                                                   
    // token binary                                                                                                                
    pdata += 2;                                                                                                                    
    memcpy(pdata, tokenbinary, TOKEN_SIZE);                                                                                        
                                                                                                                                   
    pdata += TOKEN_SIZE;                                                                                                           
                                                                                                                                   
    int payloadlen = MAX_PAYLOAD_SIZE;                                                                                             
    if (build_payload(pdata + 2, payloadlen, msg, badage, sound) < 0) {                                                            
        std::string strmsg(msg);                                                                                                   
        strmsg.erase(strmsg.length() - (payloadlen - MAX_PAYLOAD_SIZE));                                                           
        payloadlen = MAX_PAYLOAD_SIZE;                                                                                             
        if (build_payload(pdata + 2, payloadlen, msg, badage, sound) <= 0) {                                                       
            return -1;                                                                                                             
        }                                                                                                                          
    }                                                                                                                              
    *(uint16_t*)pdata = htons(payloadlen);                                                                                         
                                                                                                                                   
    return 1 + 2 + TOKEN_SIZE + 2 + payloadlen;                                                                                    
}  

// 第二種形式的包
int build_output_packet_2(char* buf, int buflen, /* 緩沖區及長度 */
        uint32_t messageid,                     /* 消息編號 */
        uint32_t expiry,                        /* 過期時間 */
        const char* tokenbinary,                /* 二進制Token */
        const char* msg,                        /* message */
        int badage,                             /* badage */
        const char * sound)                     /* sound */
{
    assert(buflen >= 1 + 2 + 4 + 4 + TOKEN_SIZE + 2 + MAX_PAYLOAD_SIZE);

    char * pdata = buf;
    // command
    *pdata = 1;

    // messageid
    pdata++;
    *(uint32_t*)pdata = messageid;

    // expiry time
    pdata += 4;
    *(uint32_t*)pdata = htonl(expiry);

    // token length
    pdata += 4;
    *(uint16_t*)pdata = htons(TOKEN_SIZE);

    // token binary
    pdata += 2;
    memcpy(pdata, tokenbinary, TOKEN_SIZE);

    pdata += TOKEN_SIZE;

    int payloadlen = MAX_PAYLOAD_SIZE;
    if (build_payload(pdata + 2, payloadlen, msg, badage, sound) < 0) {
        std::string strmsg(msg);
        strmsg.erase(strmsg.length() - (payloadlen - MAX_PAYLOAD_SIZE));
        payloadlen = MAX_PAYLOAD_SIZE;
        if (build_payload(pdata + 2, payloadlen, msg, badage, sound) <= 0) {
            return -1;
        }
    }
    *(uint16_t*)pdata = htons(payloadlen);

    return 1 + 4 + 4 + 2 + TOKEN_SIZE + 2 + payloadlen;
}

發送消息

int send_message(SSL *ssl, const char* token, const char* msg, int badage, const char* sound)
{
    char buf[1 + 2 + TOKEN_SIZE + 2 + MAX_PAYLOAD_SIZE];
    int buflen = sizeof(buf);

    buflen = build_output_packet(buf, buflen, 
            (const char*)DeviceToken2Binary(token).binary(), 
            msg, badage, sound);
    if (buflen <= 0) {
        return -1;
    }

    return SSL_write(ssl, buf, buflen);
}

int send_message_2(SSL *ssl, const char* token, uint32_t id, uint32_t expire, 
        const char* msg, int badage, const char* sound)
{
    char buf[1 + 4 + 4 + 2 + TOKEN_SIZE + 2 + MAX_PAYLOAD_SIZE];
    int buflen = sizeof(buf);

    buflen = build_output_packet_2(buf, buflen, id, expire, 
            (const char*)DeviceToken2Binary(token).binary(), msg, badage, sound);
    if (buflen <= 0) {
        return -1;
    }

    return SSL_write(ssl, buf, buflen);
}

3.4 完整使用

    init_openssl();

    // 初始化Context
    // develop.pem是我們的證書和Key,為了方便使用,我們把證書和Key寫到同一個文件中
    // 並取水了Key的密碼保護 
    // entrust_2048_ca.pem是蘋果證書的CA,它並不在Openssl的根證書中,所以需要我們手動指定,不然會無法驗證
    // 詳細:http://www.entrust.net/developer/index.cfm
    SSL_CTX *ctx = init_ssl_context("develop.pem", "develop.pem", NULL, "entrust_2048_ca.pem");
    if (!ctx) {
        fprintf(stderr, "init ssl context failed: %s\n",
                ERR_reason_error_string(ERR_get_error()));
        return -1;
    }

    // 連接到測試服務器
    const char* host = "gateway.sandbox.push.apple.com";
    const int port = 2195;
    int socket = tcp_connect(host, port);
    if (socket < 0) {
        fprintf(stderr, "failed to connect to host %s\n",
                strerror(errno));
        return -1;
    }

    // SSL連接
    SSL *ssl = ssl_connect(ctx, socket);
    if (!ssl) {
        fprintf(stderr, "ssl connect failed: %s\n",
                ERR_reason_error_string(ERR_get_error()));
        Closesocket(socket);
        return -1;
    }
    // 驗證服務器證書
    if (verify_connection(ssl, host) != 0) {
        fprintf(stderr, "verify failed\n");
        Closesocket(socket);
        return 1;
    }

    uint32_t msgid = 1;
    uint32_t expire = time(NULL) + 24 * 3600;   // expire 1 day

    // 發送一條消息
    const char* token = "0a8b9e7cbe68616cd5470e4c8abb4c1a3f4ba2bee4ca113ff02ae2c325948b8a";
    if (send_message_2(ssl, token, msgid++, expire,
                "Hello, This is a push message", 1, "default") <= 0) {
        fprintf(stderr, "send failed: %s\n",
            ERR_reason_error_string(ERR_get_error()));
    }

    // 關閉連接
    SSL_shutdown(ssl);
    Closesocket(socket);

4. 總結

本例中,只演示了發送消息,而沒有實現服務器返回數據的接收,實際應用中如果服務器返回錯誤后,連接會斷開,這時候需要重新連接來發送其它的消息,另外,還應該注意,盡量將多個消息放到一個連接里發送,與服務器保持長連接,不能發一個消息連接一次,連接過於頻繁,服務器可能會把你的IP暫時禁掉。

在設備上獲得Token,開發環境下和從App Store下載正式的應用,獲得的Token是一樣的,而且這個Token一旦在測試環境下使用了,就無法再從正式服務器上推送消息了,會返回Token無效,除非把手機還原了,不然無法從正式服務器上推送消息。

本例的代碼放在這里。https://github.com/e7868a/apple-push-notify  src/push.cpp

編譯:gcc src/push.cpp -o push -lssl -lcrypto -lstdc++


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM