xi_wechat_corp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: a668aecce2c27bf2ecf30019fe33a0a7e03778a9
4
+ data.tar.gz: 99ea5c52fe837b0e3dc317de78325a6e01566ba6
5
+ SHA512:
6
+ metadata.gz: 3257dd7d6467d516ba196d8be07f635bf826d6dacfd6d77c17ad21c038b67fdc12c9804ac4d53cd9323f23eb57108d0406788151ea23cc0cd815d43c7f46d059
7
+ data.tar.gz: 4496688d3f9aa96234d5f538e1d6b69013e44995a924bb8eb1d037109b77292900822f9d04b5da4ec885376d8a27dc92c73a1ce540549c1f9de5a27153c32f64
data/.gitignore ADDED
@@ -0,0 +1,14 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
10
+ *.bundle
11
+ *.so
12
+ *.o
13
+ *.a
14
+ mkmf.log
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in xi_wechat_corp.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2015 Ian Yang
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # XiWechatCorp
2
+
3
+ Toolkits for Tencent Wechat Corp development
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ gem 'xi_wechat_corp'
10
+
11
+ And then execute:
12
+
13
+ $ bundle
14
+
15
+ Or install it yourself as:
16
+
17
+ $ gem install xi_wechat_corp
18
+
19
+ ## Usage
20
+
21
+ ### Rack Middleware
22
+
23
+ use XiWechatCorp::Callback::Rack do |request|
24
+ corp_id 'wx1234'
25
+ if request.path_info == '/wechat'
26
+ token 'secret'
27
+ aes_key 'secret'
28
+ elsif request.path_info == '/another'
29
+ token 'secret'
30
+ aes_key 'secret'
31
+ end
32
+ end
33
+
34
+ The middleware is enabled for requests when `corp_id`, `token` and `aes_key`
35
+ are all configured.
36
+
37
+ The `GET` request is handled by the middleware directly. Just implement the
38
+ logic for `POST` requests. All post params can be get through rack env
39
+ `xi_wechat_corp.params`, including decrypted params. The handler just return
40
+ the plain XML, The middleware will encrypt response and sign it.
41
+
42
+ ### Handle Callback Manually
43
+
44
+ Verify and decrypt Request
45
+
46
+ cryptor = XiWechatCorp::AesCrypt.new(aes_key, corp_id)
47
+ signer = XiWechatCorp::SHA1Signer.new(token)
48
+ request = XiWechatCorp::Callback::Request.new(cryptor, signer, query_params, xml_body)
49
+ request.verify!
50
+
51
+ # Get decrypted echostr for GET
52
+ request.challenge
53
+
54
+ # Decrypt Encrypt field for POST
55
+ request.decrypt
56
+
57
+ # Pack response
58
+ response = request.build_response
59
+ response.assign(xml_response)
60
+ response.to_s
61
+
62
+ ### API
63
+
64
+ conn = XiWechatCorp::Client::Connection.new
65
+ conn.get_access_token(corp_id, secret)
66
+ conn.post(
67
+ 'message/send',
68
+ touser: 'UserID1|UserID2|UserID3',
69
+ toparty: ' PartyID1 | PartyID2 ',
70
+ totag: ' TagID1 | TagID2 ',
71
+ touser: 'user1|user2',
72
+ msgtype: 'text',
73
+ agentid: '1',
74
+ text: {
75
+ content: 'Holiday Request for Pony'
76
+ },
77
+ safe: 0
78
+ )
79
+
80
+ ## Contributing
81
+
82
+ 1. Fork it ( https://github.com/3pjgames/xi_wechat_corp/fork )
83
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
84
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
85
+ 4. Push to the branch (`git push origin my-new-feature`)
86
+ 5. Create a new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ require "bundler/gem_tasks"
2
+ require 'rake/testtask'
3
+
4
+ Rake::TestTask.new do |t|
5
+ t.libs << 'test'
6
+ t.pattern = 'test/**/*_test.rb'
7
+ end
8
+
9
+ task :default => [:test]
@@ -0,0 +1,77 @@
1
+ require 'securerandom'
2
+ require 'base64'
3
+ require_relative 'error'
4
+ require_relative 'pkcs7_encoder'
5
+
6
+ module XiWechatCorp
7
+ class AesCrypt
8
+ PKCS7 = PKCS7Encoder.new
9
+
10
+ def initialize(aes_key, corp_id)
11
+ @aes_key = decode64(aes_key + '=')
12
+ raise ArgumentError if @aes_key.size != 32
13
+ @corp_id = corp_id
14
+ end
15
+
16
+ def encrypt(text)
17
+ encode64 cipher_encrypt pad pack text
18
+ end
19
+
20
+ def decrypt(text)
21
+ unpack unpad cipher_decrypt decode64 text
22
+ end
23
+
24
+ def pack(text)
25
+ [
26
+ SecureRandom.random_bytes(16),
27
+ [text.bytesize].pack('N'),
28
+ text,
29
+ @corp_id
30
+ ].join('')
31
+ end
32
+
33
+ def unpack(text)
34
+ size, corp_id = text.unpack('@16Na*')
35
+ output = corp_id.slice!(0, size)
36
+
37
+ raise InvalidCorpIDError.new(corp_id) if corp_id != @corp_id
38
+ output
39
+ end
40
+
41
+ def pad(text)
42
+ PKCS7.encode(text)
43
+ end
44
+
45
+ def unpad(text)
46
+ PKCS7.decode(text)
47
+ end
48
+
49
+ def encode64(text)
50
+ Base64.strict_encode64(text)
51
+ end
52
+
53
+ def decode64(text)
54
+ Base64.decode64(text)
55
+ end
56
+
57
+ def cipher_encrypt(text)
58
+ cipher(:encrypt, text)
59
+ end
60
+
61
+ def cipher_decrypt(text)
62
+ cipher(:decrypt, text)
63
+ end
64
+
65
+ private
66
+
67
+ def cipher(action, text)
68
+ cipher = OpenSSL::Cipher::AES.new(256, :CBC)
69
+ cipher.send action
70
+ cipher.padding = 0
71
+ cipher.key = @aes_key
72
+ cipher.iv = @aes_key[0...16]
73
+ cipher.update(text) + cipher.final
74
+ end
75
+ end
76
+ end
77
+
@@ -0,0 +1,42 @@
1
+ module XiWechatCorp
2
+ module API
3
+ class AccessToken
4
+ # Leave 5 minutes buffer
5
+ EXPIRE_SECONDS = 7200 - 300
6
+
7
+ attr_reader :token
8
+ attr_reader :expired_at
9
+
10
+ def initialize(token = nil, expired_at = nil)
11
+ assign(token, expired_at)
12
+ end
13
+
14
+ def assign(token, expired_at = nil)
15
+ @token = token.to_s
16
+ if !@token.empty?
17
+ @expired_at = expired_at || (token.respond_to?(:expired_at) ? token.expired_at : Time.now.to_i + EXPIRE_SECONDS)
18
+ else
19
+ @expired_at = 0
20
+ end
21
+ end
22
+
23
+ alias_method :refresh, :assign
24
+
25
+ def present?
26
+ @token.size > 0
27
+ end
28
+
29
+ def expired?
30
+ @token.empty? || @expired_at < Time.now.to_i
31
+ end
32
+
33
+ def valid?
34
+ !expired?
35
+ end
36
+
37
+ def to_s
38
+ @token
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,99 @@
1
+ require 'faraday'
2
+ require 'faraday_middleware'
3
+ require_relative 'access_token'
4
+
5
+ module XiWechatCorp
6
+ module API
7
+ Error = Faraday::Error
8
+ class ClientError < Faraday::ClientError
9
+ def initialize(response)
10
+ @response = response
11
+ message = response ? response.values_at('errcode', 'errmsg').compact.join(' ') : nil
12
+ super(message)
13
+ end
14
+ end
15
+
16
+ class RaiseClientError < Faraday::Response::Middleware
17
+ def parse(body)
18
+ raise ClientError.new(body) if body.nil? || body.key?('errcode')
19
+ body
20
+ end
21
+ end
22
+
23
+ class Connection < Faraday::Connection
24
+ ENDPOINT = 'https://qyapi.weixin.qq.com/cgi-bin'
25
+
26
+ BUILDER = Faraday::RackBuilder.new do |builder|
27
+ builder.request :json
28
+ builder.use RaiseClientError
29
+ builder.response :json
30
+ builder.response :raise_error
31
+ builder.adapter Faraday.default_adapter
32
+ end
33
+
34
+ ACCESS_TOKEN_MISSING = [41001, 'Access token is missing']
35
+ ACCESS_TOKEN_EXPIRED = [40014, 'Access token is expired']
36
+
37
+ # Accepts all options supported by Faraday::Connection, as well as:
38
+ #
39
+ # - access_token [AccessToken|String] already acquired access token
40
+ # - corp_id
41
+ # - secret
42
+ #
43
+ # When both corp_id and secret are specified, access token is fetched and
44
+ # refreshed automatically.
45
+ def initialize(options = {}, &block)
46
+ token = options.delete(:access_token)
47
+ token = options[:params][:access_token] if token.nil? && options.key?(:params)
48
+ @access_token = AccessToken.new(options.delete(:access_token))
49
+
50
+ @corp_id = options.delete(:corp_id)
51
+ @secret = options.delete(:secret)
52
+
53
+ super(ENDPOINT, { builder: BUILDER }.merge(options), &block)
54
+ end
55
+
56
+ extend Forwardable
57
+ def_delegator :@access_token, :assign, :access_token=
58
+
59
+ def get_access_token(corp_id = nil, secret = nil)
60
+ @corp_id = corp_id unless corp_id.nil?
61
+ @secret = secret unless secret.nil?
62
+ resp = get('gettoken', corpid: @corp_id, corpsecret: @secret)
63
+ @access_token.assign(resp.body['access_token'])
64
+ @access_token
65
+ end
66
+
67
+ alias_method :auth, :get_access_token
68
+
69
+ def run_request(method, url, body, headers, &block)
70
+ set_access_token_in_params unless url == 'gettoken'
71
+ super(method, url, body, headers, &block)
72
+ end
73
+
74
+ private
75
+ def can_get_access_token?
76
+ !@corp_id.nil? && !@secret.nil?
77
+ end
78
+
79
+ def error!(code, msg)
80
+ raise ClientError.new('errcode' => code, 'errmsg' => msg)
81
+ end
82
+
83
+ def set_access_token_in_params
84
+ if @access_token.expired?
85
+ if can_get_access_token?
86
+ get_access_token
87
+ elsif @access_token.present?
88
+ error!(*ACCESS_TOKEN_EXPIRED)
89
+ else
90
+ error!(*ACCESS_TOKEN_MISSING)
91
+ end
92
+ end
93
+
94
+ params['access_token'] = @access_token
95
+ end
96
+ end
97
+ end
98
+ end
99
+
@@ -0,0 +1,2 @@
1
+ require 'xi_wechat_corp/api/access_token'
2
+ require 'xi_wechat_corp/api/connection'
@@ -0,0 +1,45 @@
1
+ require 'rack/utils'
2
+ require 'xi_wechat_corp/sha1_signer'
3
+ require 'xi_wechat_corp/aes_crypt'
4
+ require_relative 'request'
5
+
6
+ module XiWechatCorp
7
+ module Callback
8
+ class Config
9
+ include Rack::Utils
10
+
11
+ class Credentials
12
+ def corp_id(v = nil)
13
+ v.nil? ? @corp_id : (@corp_id = v)
14
+ end
15
+ def token(v = nil)
16
+ v.nil? ? @token : (@token = v)
17
+ end
18
+ def aes_key(v = nil)
19
+ v.nil? ? @aes_key : (@aes_key = v)
20
+ end
21
+
22
+ def configured?
23
+ @corp_id && @token && @aes_key
24
+ end
25
+ end
26
+
27
+ def configure(&block)
28
+ @block = block
29
+ self
30
+ end
31
+
32
+ def build_request(rack_request)
33
+ return if @block.nil?
34
+ credentials = Credentials.new
35
+ credentials.instance_exec(rack_request, &@block)
36
+ if credentials.configured?
37
+ cryptor = AesCrypt.new(credentials.aes_key, credentials.corp_id)
38
+ signer = SHA1Signer.new(credentials.token)
39
+ query_params = parse_query(rack_request.query_string)
40
+ Request.new(cryptor, signer, query_params, rack_request.body)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,63 @@
1
+ require 'rack/utils'
2
+ require 'xi_wechat_corp'
3
+ require 'xi_wechat_corp/error'
4
+ require_relative 'request'
5
+ require_relative 'response'
6
+ require_relative 'config'
7
+
8
+ module XiWechatCorp
9
+ module Callback
10
+ class Rack
11
+ include ::Rack::Utils
12
+ CONTENT_TYPE = 'text/html; charset=utf-8'
13
+
14
+ def initialize(app, &block)
15
+ @app = app
16
+ @config = Config.new.configure(&block)
17
+ end
18
+
19
+ def call(env)
20
+ rack_request = ::Rack::Request.new(env)
21
+ if (rack_request.get? || rack_request.post?) &&
22
+ (request = @config.build_request(rack_request))
23
+ request.verify!
24
+ if rack_request.get?
25
+ ::Rack::Response.new([request.challenge]).finish
26
+ else
27
+ request.decrypt
28
+ env['xi_wechat_corp.params'] = request.params
29
+ status, headers, body = @app.call(env)
30
+ rack_response = ::Rack::Response.new([], status, headers)
31
+ if rack_response.successful?
32
+ response = request.build_response(read_body(body))
33
+ xml = response.to_s
34
+ if !xml.empty?
35
+ rack_response['Content-Type'] = CONTENT_TYPE
36
+ rack_response.write xml
37
+ end
38
+ rack_response.finish
39
+ else
40
+ [status, headers, body]
41
+ end
42
+ end
43
+ else
44
+ @app.call(env)
45
+ end
46
+ rescue XiWechatCorp::Error => e
47
+ if XiWechatCorp.logger
48
+ XiWechatCorp.logger.error(e.message)
49
+ end
50
+ [403, {}, []]
51
+ end
52
+
53
+ def read_body(body)
54
+ ret = ''
55
+ body.each do |str|
56
+ ret << str
57
+ end
58
+ body.close if body.respond_to?(:close)
59
+ ret
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,82 @@
1
+ require 'multi_xml'
2
+ require_relative 'response'
3
+
4
+ module XiWechatCorp
5
+ module Callback
6
+ class Request
7
+ SIGN_PARAMS = %w(timestamp nonce echostr Encrypt)
8
+
9
+ attr_reader :params
10
+
11
+ def initialize(cryptor, signer, query_params = {}, xml = nil)
12
+ @cryptor = cryptor
13
+ @signer = signer
14
+ assign(query_params, xml)
15
+ end
16
+
17
+ def build_response(xml = nil)
18
+ Response.new(@cryptor, @signer, xml)
19
+ end
20
+
21
+ def assign(query_params, xml = nil)
22
+ body_params = xml ? parse_xml(xml) : {}
23
+ @params = body_params.merge(query_params)
24
+ end
25
+
26
+ def [](key)
27
+ @params[key.to_s]
28
+ end
29
+
30
+ def key?(key)
31
+ @params.key?(key.to_s)
32
+ end
33
+
34
+ def verify
35
+ signature = @params['msg_signature']
36
+ signature && signature == @signer.sign(*@params.values_at(*SIGN_PARAMS))
37
+ end
38
+
39
+ def verify!
40
+ raise SignatureVerificationError unless verify
41
+ end
42
+
43
+ def challenge?
44
+ key?('echostr')
45
+ end
46
+
47
+ def challenge
48
+ @cryptor.decrypt(@params['echostr'])
49
+ end
50
+
51
+ def decryptable?
52
+ key?('Encrypt')
53
+ end
54
+
55
+ def decrypt
56
+ if decryptable?
57
+ @params.merge!(parse_xml(@cryptor.decrypt(@params['Encrypt'])))
58
+ end
59
+ @params
60
+ end
61
+
62
+ def corp_id
63
+ @params['ToUserName']
64
+ end
65
+
66
+ def agent_id
67
+ @params['AgentID']
68
+ end
69
+
70
+ private
71
+
72
+ def parse_xml(xml)
73
+ node = MultiXml.parse(xml)
74
+ while node.key?('xml')
75
+ node = node['xml']
76
+ end
77
+ node
78
+ end
79
+ end
80
+ end
81
+ end
82
+
@@ -0,0 +1,31 @@
1
+ require 'securerandom'
2
+
3
+ module XiWechatCorp
4
+ module Callback
5
+ class Response
6
+ TEMPLATE = '<xml><Encrypt>%s</Encrypt><MsgSignature>%s</MsgSignature><TimeStamp>%d</TimeStamp><Nonce>%d</Nonce></xml>'
7
+ NONCE_UPPER = 1 << 32
8
+
9
+ def initialize(cryptor, signer, xml = nil)
10
+ @cryptor = cryptor
11
+ @signer = signer
12
+ assign(xml)
13
+ end
14
+
15
+ def assign(xml)
16
+ @encrypt = xml ? @cryptor.encrypt(xml) : nil
17
+ end
18
+
19
+ def to_s
20
+ return '' if @encrypt.nil?
21
+ timestamp = Time.now.to_i
22
+ nonce = SecureRandom.random_number(NONCE_UPPER)
23
+ signature = @signer.sign(@encrypt, timestamp, nonce)
24
+ sprintf(TEMPLATE, @encrypt, signature, timestamp, nonce)
25
+ end
26
+
27
+ alias_method :to_xml, :to_s
28
+ end
29
+ end
30
+ end
31
+
@@ -0,0 +1,7 @@
1
+ module XiWechatCorp
2
+ module Callback
3
+ end
4
+ end
5
+
6
+ require 'xi_wechat_corp/callback/request'
7
+ require 'xi_wechat_corp/callback/response'
@@ -0,0 +1,13 @@
1
+ module XiWechatCorp
2
+ class Error < StandardError; end
3
+ class DecodeError < Error; end
4
+ class PKCS7DecodeError < DecodeError; end
5
+ class InvalidCorpIDError < Error
6
+ attr_reader :corpid
7
+ def initialize(corpid)
8
+ @corpid = corpid
9
+ super "Invalid corpid: #{@corpid}"
10
+ end
11
+ end
12
+ class SignatureVerificationError < Error; end
13
+ end
@@ -0,0 +1,28 @@
1
+ require_relative 'error'
2
+
3
+ module XiWechatCorp
4
+ # This file is modified from offical python example
5
+ #
6
+ # http://qydev.weixin.qq.com/python.zip
7
+ class PKCS7Encoder
8
+ BLOCK_SIZE = 32
9
+
10
+ # Pad the text so the length is divisable by BLOCK_SIZE. Save the padding length as padding chracter itself.
11
+ def encode(text)
12
+ return 32.chr * BLOCK_SIZE if text.nil? || text.empty?
13
+
14
+ text_length = text.bytesize
15
+ amount_to_pad = BLOCK_SIZE - (text_length % BLOCK_SIZE)
16
+ pad = amount_to_pad.chr
17
+ text + pad * amount_to_pad
18
+ end
19
+
20
+ def decode(decrypted)
21
+ return decrypted if decrypted.nil? || decrypted.empty?
22
+
23
+ pad = decrypted[-1].ord
24
+ raise PKCS7DecodeError if pad < 1 or pad > BLOCK_SIZE
25
+ decrypted.byteslice(0...-pad)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,14 @@
1
+ module XiWechatCorp
2
+ # Sign and verify the signature
3
+ class SHA1Signer
4
+ def initialize(token)
5
+ @token = token
6
+ end
7
+
8
+ def sign(*args)
9
+ sha1 = Digest::SHA1.new
10
+ [@token, *(args.compact)].map(&:to_s).sort.each {|s| sha1 << s }
11
+ sha1.hexdigest
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,3 @@
1
+ module XiWechatCorp
2
+ VERSION = '1.0.0'
3
+ end
@@ -0,0 +1,12 @@
1
+ require "xi_wechat_corp/version"
2
+
3
+ module XiWechatCorp
4
+ autoload :API, 'xi_wechat_corp/api'
5
+ autoload :Callback, 'xi_wechat_corp/callback'
6
+ autoload :SHA1Signer, 'xi_wechat_corp/sha1_signer'
7
+ autoload :AesCrypt, 'xi_wechat_corp/aes_crypt'
8
+
9
+ class << self
10
+ attr_accessor :logger
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ require 'xi_wechat_corp'
2
+ require 'minitest'
3
+ require 'minitest/unit'
4
+ require 'minitest/autorun'
5
+ require 'minitest/pride'
6
+ require 'webmock/minitest'
7
+
8
+ class << Minitest::Test
9
+ def test(name, &block)
10
+ define_method("test_#{name.gsub(/[^_a-zA-Z0-9]/, '_')}", &block)
11
+ end
12
+ end
@@ -0,0 +1,24 @@
1
+ require 'test_helper'
2
+ require 'xi_wechat_corp/aes_crypt'
3
+
4
+ module XiWechatCorp
5
+ class AesCryptTest < Minitest::Test
6
+ TOKEN = "QDG6eK"
7
+ AES_KEY = "jWmYm7qr5nMoAUwZRjGtBxmz3KA1tkAj3ykkR6q2B2C"
8
+ CORP_ID = "wx5823bf96d3bd56c7"
9
+ ENCRYPTED = 'RypEvHKD8QQKFhvQ6QleEB4J58tiPdvo+rtK1I9qca6aM/wvqnLSV5zEPeusUiX5L5X/0lWfrf0QADHHhGd3QczcdCUpj911L3vg3W/sYYvuJTs3TUUkSUXxaccAS0qhxchrRYt66wiSpGLYL42aM6A8dTT+6k4aSknmPj48kzJs8qLjvd4Xgpue06DOdnLxAUHzM6+kDZ+HMZfJYuR+LtwGc2hgf5gsijff0ekUNXZiqATP7PF5mZxZ3Izoun1s4zG4LUMnvw2r+KqCKIw+3IQH03v+BCA9nMELNqbSf6tiWSrXJB3LAVGUcallcrw8V2t9EL4EhzJWrQUax5wLVMNS0+rUPA3k22Ncx4XXZS9o0MBH27Bo6BpNelZpS+/uh9KsNlY6bHCmJU9p8g7m3fVKn28H3KDYA5Pl/T8Z1ptDAVe0lXdQ2YoyyH2uyPIGHBZZIs2pDBS8R07+qN+E7Q=='
10
+ EXPECT = "<xml><ToUserName><![CDATA[wx5823bf96d3bd56c7]]></ToUserName>\n<FromUserName><![CDATA[mycreate]]></FromUserName>\n<CreateTime>1409659813</CreateTime>\n<MsgType><![CDATA[text]]></MsgType>\n<Content><![CDATA[hello]]></Content>\n<MsgId>4561255354251345929</MsgId>\n<AgentID>218</AgentID>\n</xml>"
11
+
12
+ def test_decrypt_sample_request
13
+ crypt = AesCrypt.new(AES_KEY, CORP_ID)
14
+ text = crypt.decrypt(ENCRYPTED)
15
+ assert_equal EXPECT, text
16
+ end
17
+
18
+ def test_encrypt_and_decrypt
19
+ crypt = AesCrypt.new(AES_KEY, CORP_ID)
20
+ text = crypt.decrypt(crypt.encrypt(EXPECT))
21
+ assert_equal EXPECT, text
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,22 @@
1
+ require 'test_helper'
2
+ require 'xi_wechat_corp/api/access_token'
3
+
4
+ module XiWechatCorp
5
+ module API
6
+ class AccessTokenTest < Minitest::Test
7
+ def test_assign_access_token_to_another
8
+ @src = AccessToken.new('test', 123)
9
+ @dest = AccessToken.new
10
+ @dest.assign @src
11
+ assert_equal 'test', @dest.token
12
+ assert_equal 123, @dest.expired_at
13
+ end
14
+ def test_assign_nil
15
+ @token = AccessToken.new('test', 123)
16
+ @token.assign nil
17
+ assert_equal '', @token.token
18
+ assert_equal 0, @token.expired_at
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,36 @@
1
+ require 'test_helper'
2
+ require 'xi_wechat_corp/api/access_token'
3
+
4
+ module XiWechatCorp
5
+ module API
6
+ class ConnectionTest < Minitest::Test
7
+ def setup
8
+ stub_request(:any, %r{https://qyapi.weixin.qq.com/cgi-bin/.*}).to_return(body: '{"access_token":"at"}')
9
+ end
10
+
11
+ def conn
12
+ @conn ||= Connection.new
13
+ end
14
+
15
+ def test_that_access_token_is_set
16
+ conn.access_token = '123'
17
+ conn.get 'test'
18
+ assert_requested :get, 'https://qyapi.weixin.qq.com/cgi-bin/test',
19
+ query: { 'access_token' => '123' }
20
+ end
21
+
22
+ test 'raise when access token is not available' do
23
+ assert_raises ClientError do
24
+ conn.get 'test'
25
+ end
26
+ end
27
+
28
+ test 'auto get access token' do
29
+ conn = Connection.new corp_id: 'corptest', secret: 'secret'
30
+ conn.get 'test'
31
+ assert_requested :get, 'https://qyapi.weixin.qq.com/cgi-bin/test',
32
+ query: { 'access_token' => 'at' }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,73 @@
1
+ require 'test_helper'
2
+ require 'xi_wechat_corp/callback/rack'
3
+ require 'rack'
4
+ require 'rack/test'
5
+ require 'multi_xml'
6
+
7
+ module XiWechatCorp
8
+ module Callback
9
+ class RackTest < Minitest::Test
10
+ include ::Rack::Test::Methods
11
+ class App
12
+ def call(env)
13
+ end
14
+ end
15
+
16
+ RESPONSE_XML = '<xml><test>1</test></xml>'
17
+ CORP_ID = 'corp_id'
18
+ AES_KEY = Base64.strict_encode64('a' * 32).chop
19
+ TOKEN = 'token'
20
+
21
+ def app
22
+ ::Rack::Builder.app do
23
+ use Rack do |req|
24
+ if req.path_info == '/wechat'
25
+ corp_id CORP_ID
26
+ aes_key AES_KEY
27
+ token TOKEN
28
+ end
29
+ end
30
+
31
+ run lambda { |env|
32
+ response = if env['xi_wechat_corp.params']
33
+ env['xi_wechat_corp.params']['response']
34
+ else
35
+ RESPONSE_XML
36
+ end
37
+ [200, {'Content-Type' => 'text/xml'}, [response]]
38
+ }
39
+ end
40
+ end
41
+
42
+ def cryptor
43
+ @cryptor ||= AesCrypt.new(AES_KEY, CORP_ID)
44
+ end
45
+
46
+ def signer
47
+ @signer ||= SHA1Signer.new(TOKEN)
48
+ end
49
+
50
+ test 'GET not configured path' do
51
+ get '/test'
52
+ assert_equal RESPONSE_XML, last_response.body
53
+ end
54
+
55
+ test 'GET configured path' do
56
+ echostr = cryptor.encrypt('echostr')
57
+ signature = signer.sign(1, 2, echostr)
58
+ get '/wechat', timestamp: 1, nonce: 2, msg_signature: signature, echostr: echostr
59
+ assert_equal 'echostr', last_response.body
60
+ end
61
+
62
+ test 'POST configured path' do
63
+ encrypt = cryptor.encrypt('<response>secret</response>')
64
+ signature = signer.sign(1, 2, encrypt)
65
+ post "/wechat?timestamp=1&nonce=2&msg_signature=#{signature}", "<xml><Encrypt>#{encrypt}</Encrypt></xml>"
66
+ response = MultiXml.parse(last_response.body).fetch('xml')
67
+ assert_equal 'secret', cryptor.decrypt(response['Encrypt'])
68
+ assert_equal response['MsgSignature'], signer.sign(response['Encrypt'], response['Nonce'], response['TimeStamp'])
69
+ end
70
+ end
71
+ end
72
+ end
73
+
@@ -0,0 +1,29 @@
1
+ require 'test_helper'
2
+ require 'xi_wechat_corp/pkcs7_encoder'
3
+
4
+ module XiWechatCorp
5
+ class PKCS7EncoderTest < Minitest::Test
6
+ def encoder
7
+ @encoder ||= XiWechatCorp::PKCS7Encoder.new
8
+ end
9
+
10
+ CASES = {
11
+ '' => 32.chr * 32,
12
+ 'a' => 'a' + 31.chr * 31,
13
+ 'a' * 31 => 'a' * 31 + 1.chr,
14
+ 'a' * 32 => 'a' * 32 + 32.chr * 32,
15
+ }
16
+
17
+ def test_encode
18
+ CASES.each_pair do |raw, encoded|
19
+ assert_equal encoded, encoder.encode(raw)
20
+ end
21
+ end
22
+
23
+ def test_decode
24
+ CASES.each_pair do |raw, encoded|
25
+ assert_equal raw, encoder.decode(encoded)
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,19 @@
1
+ require 'test_helper'
2
+ require 'xi_wechat_corp/sha1_signer'
3
+
4
+ module XiWechatCorp
5
+ class SHA1SignerTest < Minitest::Test
6
+ TEST_TOKEN = 'QDG6eK'
7
+ TEST_TIME_STAMP = '1409659589'
8
+ TEST_NONCE = '263014780'
9
+ TEST_ECHO_STR = 'P9nAzCzyDtyTWESHep1vC5X9xho/qYX3Zpb4yKa9SKld1DsH3Iyt3tP3zNdtp+4RPcs8TgAE7OaBO+FZXvnaqQ=='
10
+ TEST_SIGNATURE = '5c45ff5e21c57e6ad56bac8758b79b1d9ac89fd3'
11
+ def signer
12
+ SHA1Signer.new(TEST_TOKEN)
13
+ end
14
+
15
+ def test_sign_example
16
+ assert_equal TEST_SIGNATURE, signer.sign(TEST_TIME_STAMP, TEST_NONCE, TEST_ECHO_STR)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,31 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'xi_wechat_corp/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'xi_wechat_corp'
8
+ spec.version = XiWechatCorp::VERSION
9
+ spec.authors = ['Ian Yang']
10
+ spec.email = ['ian@3pjgames.com']
11
+ spec.summary = %q{Wechat Corp development toolkits}
12
+ spec.description = %q{Wechat Corp development toolkits}
13
+ spec.homepage = 'https://github.com/3pjgames/xi_wechat_corp'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_dependency 'faraday', '~> 0.9.1'
22
+ spec.add_dependency 'faraday_middleware', '~> 0.9.1'
23
+ spec.add_dependency 'multi_xml', '~> 0.5.5'
24
+ spec.add_dependency 'multi_json', '~> 1.10.1'
25
+ spec.add_dependency 'rack', '~> 1.6.0'
26
+ spec.add_development_dependency 'bundler', '~> 1.7'
27
+ spec.add_development_dependency 'rake', '~> 10.0'
28
+ spec.add_development_dependency 'minitest', '~> 5.5.1'
29
+ spec.add_development_dependency 'webmock'
30
+ spec.add_development_dependency 'rack-test'
31
+ end
metadata ADDED
@@ -0,0 +1,218 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: xi_wechat_corp
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Ian Yang
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-02-08 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 0.9.1
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 0.9.1
27
+ - !ruby/object:Gem::Dependency
28
+ name: faraday_middleware
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.9.1
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.9.1
41
+ - !ruby/object:Gem::Dependency
42
+ name: multi_xml
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: 0.5.5
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: 0.5.5
55
+ - !ruby/object:Gem::Dependency
56
+ name: multi_json
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: 1.10.1
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: 1.10.1
69
+ - !ruby/object:Gem::Dependency
70
+ name: rack
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: 1.6.0
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: 1.6.0
83
+ - !ruby/object:Gem::Dependency
84
+ name: bundler
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '1.7'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '1.7'
97
+ - !ruby/object:Gem::Dependency
98
+ name: rake
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '10.0'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '10.0'
111
+ - !ruby/object:Gem::Dependency
112
+ name: minitest
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: 5.5.1
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: 5.5.1
125
+ - !ruby/object:Gem::Dependency
126
+ name: webmock
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: rack-test
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ description: Wechat Corp development toolkits
154
+ email:
155
+ - ian@3pjgames.com
156
+ executables: []
157
+ extensions: []
158
+ extra_rdoc_files: []
159
+ files:
160
+ - ".gitignore"
161
+ - Gemfile
162
+ - LICENSE.txt
163
+ - README.md
164
+ - Rakefile
165
+ - lib/xi_wechat_corp.rb
166
+ - lib/xi_wechat_corp/aes_crypt.rb
167
+ - lib/xi_wechat_corp/api.rb
168
+ - lib/xi_wechat_corp/api/access_token.rb
169
+ - lib/xi_wechat_corp/api/connection.rb
170
+ - lib/xi_wechat_corp/callback.rb
171
+ - lib/xi_wechat_corp/callback/config.rb
172
+ - lib/xi_wechat_corp/callback/rack.rb
173
+ - lib/xi_wechat_corp/callback/request.rb
174
+ - lib/xi_wechat_corp/callback/response.rb
175
+ - lib/xi_wechat_corp/error.rb
176
+ - lib/xi_wechat_corp/pkcs7_encoder.rb
177
+ - lib/xi_wechat_corp/sha1_signer.rb
178
+ - lib/xi_wechat_corp/version.rb
179
+ - test/test_helper.rb
180
+ - test/xi_wechat_corp/aes_crypt_test.rb
181
+ - test/xi_wechat_corp/api/access_token_test.rb
182
+ - test/xi_wechat_corp/api/connection_test.rb
183
+ - test/xi_wechat_corp/callback/rack_test.rb
184
+ - test/xi_wechat_corp/pkcs7_encoder_test.rb
185
+ - test/xi_wechat_corp/sha1_signer_test.rb
186
+ - xi_wechat_corp.gemspec
187
+ homepage: https://github.com/3pjgames/xi_wechat_corp
188
+ licenses:
189
+ - MIT
190
+ metadata: {}
191
+ post_install_message:
192
+ rdoc_options: []
193
+ require_paths:
194
+ - lib
195
+ required_ruby_version: !ruby/object:Gem::Requirement
196
+ requirements:
197
+ - - ">="
198
+ - !ruby/object:Gem::Version
199
+ version: '0'
200
+ required_rubygems_version: !ruby/object:Gem::Requirement
201
+ requirements:
202
+ - - ">="
203
+ - !ruby/object:Gem::Version
204
+ version: '0'
205
+ requirements: []
206
+ rubyforge_project:
207
+ rubygems_version: 2.2.2
208
+ signing_key:
209
+ specification_version: 4
210
+ summary: Wechat Corp development toolkits
211
+ test_files:
212
+ - test/test_helper.rb
213
+ - test/xi_wechat_corp/aes_crypt_test.rb
214
+ - test/xi_wechat_corp/api/access_token_test.rb
215
+ - test/xi_wechat_corp/api/connection_test.rb
216
+ - test/xi_wechat_corp/callback/rack_test.rb
217
+ - test/xi_wechat_corp/pkcs7_encoder_test.rb
218
+ - test/xi_wechat_corp/sha1_signer_test.rb