肇鑫的技术博客

业精于勤,荒于嬉

Twitter首次授权,三步这么走

three steps

初步结论

发送部分

  • methodPOST时,postContent为nil或需要上传的数据。
    • 例外:申请用户授权的第一步时,oauth_callback是作为header的一部分,而不是postContent
  • methodGET时,会需要在baseURL后连接GET的信息urlGetParameter,形成新的fullURL
  • oauth_signature的计算,是将methodbaseURL和所有要发送的信息连起来,并计算校验值。其中,除methodbaseURL之外的所有的键值对,需要按照键的字母顺序进行排列。
  • oauth_signature的计算,signing_key由两部分构成,consumer_secretoauth_token_secret两部分合成,中间用&连接。
    • 例外:申请用户授权的第一步时,由于没有下发的oauth_token_secretsigning_keyconsumer_secret+&
  • header永远使用Authorization字段,该字段使用的键为七个:oauth_consumer_keyoauth_nonceoauth_signatureoauth_signature_methodoauth_timestampoauth_tokenoauth_version
    • 例外:申请用户授权的第一步时,此时还没有服务器下发的oauth_token。但这时要把oauth_callback加入其中。
  • 例外:虽然oauth_signature的计算方法写的是使用baseURL,但是我发现,当HttpMethodGET时,既可以使用baseURL进行计算,也可以使用fullURL进行计算。当使用fullURL时,需要把GET的参数项对应的键值对,从oauth_signature的计算中移除。

接收部分

  • 接收成功的标志是successStatusCode=200。
  • 接收的内容是一组由预定的键组成的键值对。可以认为,键值对与baseURL是对应的关系。
  • 例外:申请用户授权的第二步时,会用到第一步里的跳转链接oauth_callback
  • 例外:申请用户授权的第二步时,由于用户需要用户授权,然后跳转,这一步,是没有返回值的。跳转之后的连接需要开发者手动拦截。
  • 例外:类似发推功能的API这种,我们只需要确认成功发推,并不关注返回值。此时可以设定返回值的键为空,不对其进行处理。

具体实施

第一步:Obtaining a request token

  1. 采用URLSessionDataTask链接,并处理传来的数据
  2. 打开WebViewViewController

第二步:Redirecting the user

  1. 通过内嵌的网页处理URLRequest
  2. 用户登录并授权后,Twitter会跳转到第一步中设定的会掉页面
  3. 通过WKNavigationDelegatefunc webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {,截获该跳转,获得数据。
  4. 此时需要验证本次收到的oauth_token与第一步收到的相同

第三步:Converting the request token to an access token

  1. 采用URLSessionDataTask链接,将第二步获得的oauth_verifier,发往服务器进行验证,获得最终的tokens,并保存。
  2. 授权完成。

Twitter REST API使用通用流程

步骤

  1. 申请建立新程序(账号需要绑定手机)
  2. 测试OAuth 1.0

各个变量的含义

原文地址:
POST oauth/request_token
Creating a signature

参数 含义
oauth_consumer_key 申请的程序的key
oauth_nonce base64编码的32字节的随机数据。Twitter使用它来查验信息是否属于重复提交
oauth_signature 所有提交的信息的校验码,用于检验内容是否被篡改1
oauth_signature_method 校验码的计算方法,固定为HMAC-SHA1
oauth_timestamp Unix相对于GMT 1970年1月1日的秒数。
oauth_token 代表当前用户的token,首次使用时需要向Twitter申请
oauth_version oauth的版本,目前总是1.0
Consumer secret 你申请App时获得的另外一段文本2
OAuth token secret 调用API后从Twitter网站获得的当前用户的标志
Signing key 计算校验码时的key,由Consumer secret和OAuth token secret加工而成
status 用户发送的tweet的正文

创建校验码的方法

原文地址:
Creating a signature

写在前面

**一定要记住:只有上面原文的例子是真正可用的。**不过这个例子也有一些问题,后面我会提到。

按照流程我们一般会最先到POST oauth/request_token页面去。因为我们不申请授权的话,什么事都做不了。但这里是Twitter官方文档的第一个坑。用来计算oauth_signature需要的key的一部分是Consumer secret,而这个页面根本没有提供它。因此,这个例子里的值是没法计算的。

更坑的是,由于Consumer secretConsumer Key非常像,而后者就是oauth_consumer_key,这个值在这个页面有提供。因此,初学者如我,就很容易把oauth_consumer_key作为Consumer secret来计算oauth_signature。得到的结果自然与例子不同。我在这里浪费了很多的时间。

另外一个页面Authorizing a request也是同样的问题,没有提供Consumer secret,因此也是无法计算的。

结论:强烈建议你在算法设计完成时,使用Creating a signature这个页面提供的例子来检验你的算法是否正确。如果正确了,那么再进行下一步。

需要计算的量

在调用一次API时,需要有很多参数,有一些参数的值是可以直接获得的,比如oauth的版本,signature的校验方式,都是固定的,每次都一样。但也有一些参数需要计算,简单的timestamp的就不提了,下面提几个相对复杂的。

oauth_nonce

oauth_nonce是一个应该每次都不一样的字符串,Twitter通过这个值来验证客户端是否发送了重复的请求。按照官方的说法,这个字符串的计算过程是取一段32个字节的随机数据,然后将这段数据采用base64编码的方式输出为字符串,之后过滤掉其中非词的字符。

and stripping out all non-word characters

这里的非词指的是大小写字母和数字之外的字符。^[a-z|A-Z|0-9]

实现这个的算法有很多,但是有一点我很疑惑。因为官方文档里的几个例子,字符都是42个,但是我按照算法说明生成的,一般都是43个。只有把32个字节,改成31个字节,得到的才是42个。

先说结论,这个字符串是42还是43其实无所谓。后来我的程序确定能通过之后,我发现,有时生成的字符串是45个。也就是说,其实这个字符串,本质上来讲,只要每次不一样就可以,长度其实没有限制。

附上我的算法:

private static func calculateOauthNonce() -> String {
    let uuidString = UUID().uuidString
    let randomStringIn32Bytes = uuidString.substring(to: uuidString.index(uuidString.startIndex, offsetBy: 32))
    let dataOfRandomStringIn32Bytes = randomStringIn32Bytes.data(using: .utf8)!
    let base64EncodedStringOfRandomStringIn32Bytes = dataOfRandomStringIn32Bytes.base64EncodedString()
    
    let modifiedBase64EncodedString = base64EncodedStringOfRandomStringIn32Bytes.unicodeScalars
        .filter {
            CharacterSet.alphanumerics.contains($0)
        }
        .reduce("", { (result, unicodeScalar) -> String in
            let character = Character(unicodeScalar)
            return result + String(character)
        })
    
    return modifiedBase64EncodedString
}

percent encode

在计算headersignature等处,需要使用到percent encode。百分比化,即把不在范围内的字符,转化为带%号的形式。按照官方的规则percent encode的计算方法如下:

let nonePercentedCharacterSet = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._~"))
private func percentedString(_ source:String) -> String {
    return source.addingPercentEncoding(withAllowedCharacters: nonePercentedCharacterSet)!
}

signature base string

signature base string是计算signature的中间步骤。这里有一点要注意,即键值对的key要按照字母顺序排列。这是为了计算校验值的唯一。

这里有一个坑,官方例子提供的值是
POST&https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&include_entities%3Dtrue%26oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1318622958%26oauth_token%3D370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26oauth_version%3D1.0%26status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520a%2520signed%2520OAuth%2520request%2521

它去掉百分号实际上是

let s2 = "POST&https%3A%2F%2Fapi.twitter.com%2F1%2Fstatuses%2Fupdate.json&include_entities%3Dtrue%26oauth_consumer_key%3Dxvz1evFS4wEEPTGEFPHBog%26oauth_nonce%3DkYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1318622958%26oauth_token%3D370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb%26oauth_version%3D1.0%26status%3DHello%2520Ladies%2520%252B%2520Gentlemen%252C%2520a%2520signed%2520OAuth%2520request%2521"

print(s2.removingPercentEncoding!)

// POST&https://api.twitter.com/1/statuses/update.json&include_entities=true&oauth_consumer_key=xvz1evFS4wEEPTGEFPHBog&oauth_nonce=kYjzVBB8Y0ZFabxSWbWovY3uYSQ2pTgmZeNu2VS4cg&oauth_signature_method=HMAC-SHA1&oauth_timestamp=1318622958&oauth_token=370773112-GmHxMAgYyLbNEtIKZeRNFsMKPR9EyMZeS9weJAEb&oauth_version=1.0&status=Hello%20Ladies%20%2B%20Gentlemen%2C%20a%20signed%20OAuth%20request%21

可以看到,url实际是https://api.twitter.com/1/statuses/update.json,但是官方文档中写url是https://api.twitter.com/1.1/statuses/update.json。这里一个是1,一个是1.1,你按照文档的url来算,那么signature肯定就和官方提供的tnnArxj06cWHq44gCs1OSKk/jLY=不一样。

signing key

标准的signing key由两部分组成,百分比化的Consumer secret与百分比化的OAuth token secret,二者按照先后顺序用&链接,需要注意两点:

  1. Consumer secret而不是Consumer Key
  2. &不需要百分比化

当首次申请时,由于没有OAuth token secret,此时的signing key为百分比化的Consumer secret之后加上&

signature

这里直接调用加密的函数就可以,值得说的部分是,官方这里要求使用HMAC-SHA1,但是输出值不是普通的字符串,而是要转成base64编码的字符串输出。网上能找到的例子,一般都只是普通的字符串输出,这里提供我的算法,主要是增加了base64编码的输出,以及一些重构。

import Foundation

enum CryptoAlgorithm {
    case MD5, SHA1, SHA224, SHA256, SHA384, SHA512
    
    var HMACAlgorithm: CCHmacAlgorithm {
        var result: Int = 0
        switch self {
        case .MD5:
            result = kCCHmacAlgMD5
        case .SHA1:
            result = kCCHmacAlgSHA1
        case .SHA224:
            result = kCCHmacAlgSHA224
        case .SHA256:
            result = kCCHmacAlgSHA256
        case .SHA384:
            result = kCCHmacAlgSHA384
        case .SHA512:
            result = kCCHmacAlgSHA512
        }
        return CCHmacAlgorithm(result)
    }
    
    var digestLength: Int {
        var result: Int32 = 0
        switch self {
        case .MD5:
            result = CC_MD5_DIGEST_LENGTH
        case .SHA1:
            result = CC_SHA1_DIGEST_LENGTH
        case .SHA224:
            result = CC_SHA224_DIGEST_LENGTH
        case .SHA256:
            result = CC_SHA256_DIGEST_LENGTH
        case .SHA384:
            result = CC_SHA384_DIGEST_LENGTH
        case .SHA512:
            result = CC_SHA512_DIGEST_LENGTH
        }
        return Int(result)
    }
}

enum HashStyle {
    case string, base64EncodedString
}

extension String {
    func hmac(algorithm: CryptoAlgorithm, key: String, hashStyle:HashStyle = .base64EncodedString) -> String {
        let str = self.cString(using:.utf8)
        let strLen = self.lengthOfBytes(using:.utf8)
        let digestLen = algorithm.digestLength
        let hashBytes = UnsafeMutablePointer<UInt8>.allocate(capacity: digestLen)
        let keyStr = key.cString(using:.utf8)
        let keyLen = Int(key.lengthOfBytes(using:.utf8))
        
        CCHmac(algorithm.HMACAlgorithm, keyStr!, keyLen, str!, strLen, hashBytes)
        
        let digest:String
        switch hashStyle {
        case .string:
            digest = calculateString(from: hashBytes, length: digestLen)
        case .base64EncodedString:
            digest = calculateBase64EncodedString(from: hashBytes, length: digestLen)
        }
        
        hashBytes.deallocate(capacity: digestLen)
        
        return digest
    }
    
    private func calculateString(from hashBytes: UnsafeMutablePointer<UInt8>, length: Int) -> String {
        let string = (0..<length).reduce("") { (result, index) -> String in
            return result + String(hashBytes[index])
        }
        
        return string
    }
    
    private func calculateBase64EncodedString(from hashBytes: UnsafeMutablePointer<UInt8>, length: Int) -> String {
        let data = Data(bytes:hashBytes, count:length)
        
        return data.base64EncodedString()
    }
}

计算header

原文:Authorizing a request
这个没啥好说的,按照说明就行了。header和计算signature不一样,header里的键值对的顺序可以是任意的。

调用API

全都计算好了,就可以申请调用API了。简单的步骤如下:

let url = URL(string: "https://api.twitter.com/oauth/request_token")!

var request = URLRequest(url: url)
request.httpMethod = "POST"

var tt = Twitter()

request.setValue(tt.postOauthRequestToken.header, forHTTPHeaderField: "Authorization")
let task = URLSession.shared.dataTask(with: request) {
    data, response, error in
    
    if error != nil {
        print("error=\(error!.localizedDescription))")
        return
    }
    
    print("response = \(response!.description)")
    
    let responseString = String(data: data!, encoding: .utf8)!
    print("responseString = \(responseString)")
}
task.resume()

如果看到输出的response里的代码是200,那么就证明你的程序是没有问题的了。

  1. 创建校验码的方法

  2. 需要注意的事,计算校验码时需要的key的一部分是Consumer Secret而不是Consumer Key,Twitter的部分文档的例子里没有强调这一点,并切没有提供例子对应的Consumer Secret,这导致了那些例子你是没法计算的,也就是说例子根本是无效的。研究它们只能浪费时间。这一点后面还会提到。

深入探讨NotificationCenter的addObserver(forName:object:queue:using:)中的queque

先看看下面的代码,这两段notiObserver有区别吗?

override func viewDidLoad() {
    super.viewDidLoad()
    
    let noti = Notification(name: MyNotificationName)
    var v = 0
    let center = NotificationCenter.default
    var notiObserver:NSObjectProtocol! = nil
    
    notiObserver = center.addObserver(forName: MyNotificationName, object: nil, queue: .main) {_ in 
        if v == 10 { center.removeObserver(notiObserver) }
        else {
            v += 1
            center.post(noti)
        }
        print(v)
    }
    
    notiObserver = center.addObserver(forName: MyNotificationName, object: nil, queue: nil) { _ in
        DispatchQueue.main.async {
            if v == 10 { center.removeObserver(notiObserver) }
            else {
                v += 1
                center.post(noti)
            }
            print(v)
        }
    }
    
    center.post(noti)
}

无论你觉得有区别,还是没区别,你可以分别注释掉其中一个然后运行。运行结果是这样的。

上面的那段会输出

10
10
10
10
10
10
10
10
10
10
10

下面那段会输出

1
2
3
4
5
6
7
8
9
10
10

这是为什么呢?我们来看文档。苹果的文档在提到queue是这么说的。

queue
The operation queue to which block should be added.
If you pass nil, the block is run synchronously on the posting thread.

也就是说,它默认是在队列中同步执行。即便我们换成了.main,它也不是我们以为的是异步执行的。实际上,我们可以测试出来,这段代码,在运行时,是堆栈式的。新到的Notification,会进行压栈,优先执行。然后才是旧的Notification没执行完的代码。

如图所示,从左到右是时间轴,依次运行的3段代码,绿色的center.post(noti)导致黄色被执行,黄色的center.post(noti)导致蓝色被执行……,一直到v==10,最后的一段代码print(v),才会从上到下执行下来。

stack
结论:如果我们想要代码符合我们的预期的顺序,应该使用第二种方式,通过GCD,将代码加入到顺序执行的queue中去。