diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 4f7ceeb..e14ffd4 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -23,18 +23,21 @@ The configuration for Gaurun has some sections. The example is [here](conf/gauru ## iOS Section -| name | type | description | default | note | -| ------------------ | ------ | ------------------------------------------------------ | ---------------- | ---- | -| enabled | bool | On/Off for push notication to APNs | true | | -| pem_cert_path | string | certification file path for APNs | | | -| pem_key_path | string | secret key file path for APNs | | | -| pem_key_passphrase | string | secret key file pass phrase for APNs | | | -| sandbox | bool | On/Off for sandbox environment | true | | -| retry_max | int | maximum retry count for push notication to APNs | 1 | | -| timeout | int | timeout for push notification to APNs | 5 | | -| keepalive_timeout | int | time for continuing keep-alive connection to APNs | 90 | | -| keepalive_conns | int | number of keep-alive connection to APNs | runtime.NumCPU() | | -| topic | string | the assigned value of `apns-topic` for Request headers | | | +| name | type | description | default | note | +| ------------------- | ------ | -------------------------------------------------------- | ---------------- | ---- | +| enabled | bool | On/Off for push notication to APNs | true | | +| pem_cert_path | string | certification file path for APNs | | | +| pem_key_path | string | secret key file path for APNs | | | +| pem_key_passphrase | string | secret key file pass phrase for APNs | | | +| token_auth_key_path | string | secret APNs auth key file (.p8) for token based provider | | | +| token_auth_key_id | string | APNs key id for token based provider | | | +| token_auth_team_id | string | APNs team id for token based provider | | | +| sandbox | bool | On/Off for sandbox environment | true | | +| retry_max | int | maximum retry count for push notication to APNs | 1 | | +| timeout | int | timeout for push notification to APNs | 5 | | +| keepalive_timeout | int | time for continuing keep-alive connection to APNs | 90 | | +| keepalive_conns | int | number of keep-alive connection to APNs | runtime.NumCPU() | | +| topic | string | the assigned value of `apns-topic` for Request headers | | | `topic` is mandatory when the client is connected using the certificate that supports multiple topics. diff --git a/buford/push/errors.go b/buford/push/errors.go index ed73613..3aa12fb 100644 --- a/buford/push/errors.go +++ b/buford/push/errors.go @@ -40,6 +40,12 @@ var ( ErrUnregistered = errors.New("Unregistered") ErrDeviceTokenNotForTopic = errors.New("DeviceTokenNotForTopic") + // Token authentication errors. + ErrMissingProviderToken = errors.New("MissingProviderToken") + ErrInvalidProviderToken = errors.New("InvalidProviderToken") + ErrExpiredProviderToken = errors.New("ExpiredProviderToken") + ErrTooManyProviderTokenUpdates = errors.New("TooManyProviderTokenUpdates") + // These errors should never happen when using Push. ErrDuplicateHeaders = errors.New("DuplicateHeaders") ErrBadPath = errors.New("BadPath") @@ -105,6 +111,14 @@ func mapErrorReason(reason string) error { e = ErrMissingTopic case "InvalidPushType": e = ErrInvalidPushType + case "MissingProviderToken": + e = ErrMissingProviderToken + case "InvalidProviderToken": + e = ErrInvalidProviderToken + case "ExpiredProviderToken": + e = ErrExpiredProviderToken + case "TooManyProviderTokenUpdates": + e = ErrTooManyProviderTokenUpdates default: e = errors.New(reason) } @@ -141,6 +155,14 @@ func (e *Error) Error() string { return "the Topic header of the request was not specified and was required" case ErrInvalidPushType: return "the apns-push-type value is invalid" + case ErrMissingProviderToken: + return "no provider certificate was used to connect to APNs and Authorization header was missing or no provider token was specified" + case ErrInvalidProviderToken: + return "the provider token is not valid or the token signature could not be verified" + case ErrExpiredProviderToken: + return "the provider token is stale and a new token should be generated" + case ErrTooManyProviderTokenUpdates: + return "the provider token is being updated too often" case ErrTopicDisallowed: return "pushing to this topic is not allowed" case ErrUnregistered: diff --git a/buford/push/header.go b/buford/push/header.go index d2e09f8..9c7ba40 100644 --- a/buford/push/header.go +++ b/buford/push/header.go @@ -1,9 +1,12 @@ package push import ( + "fmt" "net/http" "strconv" "time" + + "github.com/mercari/gaurun/buford/token" ) // Headers sent with a push to control the notification (optional) @@ -26,6 +29,8 @@ type Headers struct { // Topic for certificates with multiple topics. Topic string + AuthToken *token.Token + PushType PushType } @@ -66,4 +71,8 @@ func (h *Headers) set(reqHeader http.Header) { if h.PushType != "" { reqHeader.Set("apns-push-type", string(h.PushType)) } + + if h.AuthToken != nil { + reqHeader.Set("authorization", fmt.Sprintf("bearer %s", h.AuthToken.GenerateBearerIfExpired())) + } } diff --git a/buford/push/header_test.go b/buford/push/header_test.go index 59aa5fb..98f4284 100644 --- a/buford/push/header_test.go +++ b/buford/push/header_test.go @@ -2,8 +2,11 @@ package push import ( "net/http" + "strings" "testing" "time" + + "github.com/mercari/gaurun/buford/token" ) func TestHeaders(t *testing.T) { @@ -25,6 +28,43 @@ func TestHeaders(t *testing.T) { testHeader(t, reqHeader, "apns-priority", "5") testHeader(t, reqHeader, "apns-topic", "bundle-id") testHeader(t, reqHeader, "apns-push-type", "alert") + testHeader(t, reqHeader, "authorization", "") +} + +func TestHeadersAuthToken(t *testing.T) { + ak, err := token.AuthKeyFromFile("testdata/authkey-valid.p8") + if err != nil { + t.Fatal(err) + } + + headers := Headers{ + ID: "uuid", + CollapseID: "game1.score.identifier", + Expiration: time.Unix(12622780800, 0), + LowPriority: true, + Topic: "bundle-id", + PushType: PushTypeAlert, + AuthToken: &token.Token{ + AuthKey: ak, + KeyID: "key_id", + TeamID: "team_id", + }, + } + + reqHeader := http.Header{} + headers.set(reqHeader) + + testHeader(t, reqHeader, "apns-id", "uuid") + testHeader(t, reqHeader, "apns-collapse-id", "game1.score.identifier") + testHeader(t, reqHeader, "apns-expiration", "12622780800") + testHeader(t, reqHeader, "apns-priority", "5") + testHeader(t, reqHeader, "apns-topic", "bundle-id") + testHeader(t, reqHeader, "apns-push-type", "alert") + + actual := reqHeader.Get("authorization") + if !strings.HasPrefix(actual, "bearer ") { + t.Errorf("expected authorization header is the beginning of `beaer`, but got %s", actual) + } } func TestNilHeader(t *testing.T) { @@ -38,6 +78,7 @@ func TestNilHeader(t *testing.T) { testHeader(t, reqHeader, "apns-priority", "") testHeader(t, reqHeader, "apns-topic", "") testHeader(t, reqHeader, "apns-push-type", "") + testHeader(t, reqHeader, "authorization", "") } func TestEmptyHeaders(t *testing.T) { @@ -51,6 +92,7 @@ func TestEmptyHeaders(t *testing.T) { testHeader(t, reqHeader, "apns-priority", "") testHeader(t, reqHeader, "apns-topic", "") testHeader(t, reqHeader, "apns-push-type", "") + testHeader(t, reqHeader, "authorization", "") } func testHeader(t *testing.T, reqHeader http.Header, key, expected string) { diff --git a/buford/push/testdata/authkey-valid.p8 b/buford/push/testdata/authkey-valid.p8 new file mode 100644 index 0000000..62c30f5 --- /dev/null +++ b/buford/push/testdata/authkey-valid.p8 @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgEbVzfPnZPxfAyxqE +ZV05laAoJAl+/6Xt2O4mOB611sOhRANCAASgFTKjwJAAU95g++/vzKWHkzAVmNMI +tB5vTjZOOIwnEb70MsWZFIyUFD1P9Gwstz4+akHX7vI8BH6hHmBmfeQl +-----END PRIVATE KEY----- diff --git a/buford/token/testdata/authkey-invalid-ecdsa.p8 b/buford/token/testdata/authkey-invalid-ecdsa.p8 new file mode 100644 index 0000000..2027eda --- /dev/null +++ b/buford/token/testdata/authkey-invalid-ecdsa.p8 @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDfdOqotHd55SYO +0dLz2oXengw/tZ+q3ZmOPeVmMuOMIYO/Cv1wk2U0OK4pug4OBSJPhl09Zs6IwB8N +wPOU7EDTgMOcQUYB/6QNCI1J7Zm2oLtuchzz4pIb+o4ZAhVprLhRyvqi8OTKQ7kf +Gfs5Tuwmn1M/0fQkfzMxADpjOKNgf0uy6lN6utjdTrPKKFUQNdc6/Ty8EeTnQEwU +lsT2LAXCfEKxTn5RlRljDztS7Sfgs8VL0FPy1Qi8B+dFcgRYKFrcpsVaZ1lBmXKs +XDRu5QR/Rg3f9DRq4GR1sNH8RLY9uApMl2SNz+sR4zRPG85R/se5Q06Gu0BUQ3UP +m67ETVZLAgMBAAECggEADjU54mYvHpICXHjc5+JiFqiH8NkUgOG8LL4kwt3DeBp9 +bP0+5hSJH8vmzwJkeGG9L79EWG4b/bfxgYdeNX7cFFagmWPRFrlxbd64VRYFawZH +RJt+2cbzMVI6DL8EK4bu5Ux5qTiV44Jw19hoD9nDzCTfPzSTSGrKD3iLPdnREYaI +GDVxcjBv3Tx6rrv3Z2lhHHKhEHb0RRjATcjAVKV9NZhMajJ4l9pqJ3A4IQrCBl95 +ux6Xm1oXP0i6aR78cjchsCpcMXdP3WMsvHgTlsZT0RZLFHrvkiNHlPiil4G2/eHk +wvT//CrcbO6SmI/zCtMmypuHJqcr+Xb7GPJoa64WoQKBgQDwrfelf3Rdfo9kaK/b +rBmbu1++qWpYVPTedQy84DK2p3GE7YfKyI+fhbnw5ol3W1jjfvZCmK/p6eZR4jgy +J0KJ76z53T8HoDTF+FTkR55oM3TEM46XzI36RppWP1vgcNHdz3U4DAqkMlAh4lVm +3GiKPGX5JHHe7tWz/uZ55Kk58QKBgQDtrkqdSzWlOjvYD4mq4m8jPgS7v3hiHd+1 +OT8S37zdoT8VVzo2T4SF+fBhI2lWYzpQp2sCjLmCwK9k/Gur55H2kTBTwzlQ6WSL +Te9Zj+eoMGklIirA+8YdQHXrO+CCw9BTJAF+c3c3xeUOLXafzyW29bASGfUtA7Ax +QAsR+Rr3+wKBgAwfZxrh6ZWP+17+WuVArOWIMZFj7SRX2yGdWa/lxwgmNPSSFkXj +hkBttujoY8IsSrTivzqpgCrTCjPTpir4iURzWw4W08bpjd7u3C/HX7Y16Uq8ohEJ +T5lslveDJ3iNljSK74eMK7kLg7fBM7YDogxccHJ1IHsvInp3e1pmZxOxAoGAO+bS +TUQ4N/UuQezgkF3TDrnBraO67leDGwRbfiE/U0ghQvqh5DA0QSPVzlWDZc9KUitv +j8vxsR9o1PW9GS0an17GJEYuetLnkShKK3NWOhBBX6d1yP9rVdH6JhgIJEy/g0Su +z7TAFiFc8i7JF8u4QJ05C8bZAMhOLotqftQeVOMCgYAid8aaRvaM2Q8a42Jn6ZTT +5ms6AvNr98sv0StnfmNQ+EYXN0bEk2huSW+w2hN34TYYBTjViQmHbhudwwu8lVjE +ccDmIXsUFbHVK+kTIpWGGchy5cYPs3k9s1nMR2av0Lojtw9WRY76xRXvN8W6R7Eh +wA2ax3+gEEYpGhjM/lO2Lg== +-----END PRIVATE KEY----- diff --git a/buford/token/testdata/authkey-invalid-pkcs8.p8 b/buford/token/testdata/authkey-invalid-pkcs8.p8 new file mode 100644 index 0000000..64a124d --- /dev/null +++ b/buford/token/testdata/authkey-invalid-pkcs8.p8 @@ -0,0 +1,27 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEogIBAAKCAQEA33TqqLR3eeUmDtHS89qF3p4MP7Wfqt2Zjj3lZjLjjCGDvwr9 +cJNlNDiuKboODgUiT4ZdPWbOiMAfDcDzlOxA04DDnEFGAf+kDQiNSe2ZtqC7bnIc +8+KSG/qOGQIVaay4Ucr6ovDkykO5Hxn7OU7sJp9TP9H0JH8zMQA6YzijYH9LsupT +errY3U6zyihVEDXXOv08vBHk50BMFJbE9iwFwnxCsU5+UZUZYw87Uu0n4LPFS9BT +8tUIvAfnRXIEWCha3KbFWmdZQZlyrFw0buUEf0YN3/Q0auBkdbDR/ES2PbgKTJdk +jc/rEeM0TxvOUf7HuUNOhrtAVEN1D5uuxE1WSwIDAQABAoIBAA41OeJmLx6SAlx4 +3OfiYhaoh/DZFIDhvCy+JMLdw3gafWz9PuYUiR/L5s8CZHhhvS+/RFhuG/238YGH +XjV+3BRWoJlj0Ra5cW3euFUWBWsGR0SbftnG8zFSOgy/BCuG7uVMeak4leOCcNfY +aA/Zw8wk3z80k0hqyg94iz3Z0RGGiBg1cXIwb908eq6792dpYRxyoRB29EUYwE3I +wFSlfTWYTGoyeJfaaidwOCEKwgZfebsel5taFz9Iumke/HI3IbAqXDF3T91jLLx4 +E5bGU9EWSxR675IjR5T4opeBtv3h5ML0//wq3GzukpiP8wrTJsqbhyanK/l2+xjy +aGuuFqECgYEA8K33pX90XX6PZGiv26wZm7tfvqlqWFT03nUMvOAytqdxhO2HysiP +n4W58OaJd1tY4372Qpiv6enmUeI4MidCie+s+d0/B6A0xfhU5EeeaDN0xDOOl8yN ++kaaVj9b4HDR3c91OAwKpDJQIeJVZtxoijxl+SRx3u7Vs/7meeSpOfECgYEA7a5K +nUs1pTo72A+JquJvIz4Eu794Yh3ftTk/Et+83aE/FVc6Nk+EhfnwYSNpVmM6UKdr +Aoy5gsCvZPxrq+eR9pEwU8M5UOlki03vWY/nqDBpJSIqwPvGHUB16zvggsPQUyQB +fnN3N8XlDi12n88ltvWwEhn1LQOwMUALEfka9/sCgYAMH2ca4emVj/te/lrlQKzl +iDGRY+0kV9shnVmv5ccIJjT0khZF44ZAbbbo6GPCLEq04r86qYAq0woz06Yq+IlE +c1sOFtPG6Y3e7twvx1+2NelKvKIRCU+ZbJb3gyd4jZY0iu+HjCu5C4O3wTO2A6IM +XHBydSB7LyJ6d3taZmcTsQKBgDvm0k1EODf1LkHs4JBd0w65wa2juu5XgxsEW34h +P1NIIUL6oeQwNEEj1c5Vg2XPSlIrb4/L8bEfaNT1vRktGp9exiRGLnrS55EoSitz +VjoQQV+ndcj/a1XR+iYYCCRMv4NErs+0wBYhXPIuyRfLuECdOQvG2QDITi6Lan7U +HlTjAoGAInfGmkb2jNkPGuNiZ+mU0+ZrOgLza/fLL9ErZ35jUPhGFzdGxJNobklv +sNoTd+E2GAU41YkJh24bncMLvJVYxHHA5iF7FBWx1SvpEyKVhhnIcuXGD7N5PbNZ +zEdmr9C6I7cPVkWO+sUV7zfFukexIcANmsd/oBBGKRoYzP5Tti4= +-----END RSA PRIVATE KEY----- diff --git a/buford/token/testdata/authkey-invalid.p8 b/buford/token/testdata/authkey-invalid.p8 new file mode 100644 index 0000000..74d6f54 --- /dev/null +++ b/buford/token/testdata/authkey-invalid.p8 @@ -0,0 +1,3 @@ +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgEbVzfPnZPxfAyxqE +ZV05laAoJAl+/6Xt2O4mOB611sOhRANCAASgFTKjwJAAU95g++/vzKWHkzAVmNMI +tB5vTjZOOIwnEb70MsWZFIyUFD1P9Gwstz4+akHX7vI8BH6hHmBmfZZZ \ No newline at end of file diff --git a/buford/token/testdata/authkey-valid.p8 b/buford/token/testdata/authkey-valid.p8 new file mode 100644 index 0000000..62c30f5 --- /dev/null +++ b/buford/token/testdata/authkey-valid.p8 @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgEbVzfPnZPxfAyxqE +ZV05laAoJAl+/6Xt2O4mOB611sOhRANCAASgFTKjwJAAU95g++/vzKWHkzAVmNMI +tB5vTjZOOIwnEb70MsWZFIyUFD1P9Gwstz4+akHX7vI8BH6hHmBmfeQl +-----END PRIVATE KEY----- diff --git a/buford/token/token.go b/buford/token/token.go new file mode 100644 index 0000000..a1d7b70 --- /dev/null +++ b/buford/token/token.go @@ -0,0 +1,111 @@ +// Package token +// original: https://github.com/sideshow/apns2/blob/master/token/token.go +// Copyright (c) 2016 Adam Jones +package token + +import ( + "crypto/ecdsa" + "crypto/x509" + "encoding/pem" + "errors" + "io/ioutil" + "sync" + "time" + + jwt "github.com/dgrijalva/jwt-go" +) + +const ( + // TokenTimeout is the period of time in seconds that a token is valid for. + // If the timestamp for token issue is not within the last hour, APNs + // rejects subsequent push messages. This is set to under an hour so that + // we generate a new token before the existing one expires. + TokenTimeout = 3000 +) + +// Possible errors when parsing a .p8 file. +var ( + ErrAuthKeyNotPem = errors.New("token: AuthKey must be a valid .p8 PEM file") + ErrAuthKeyNotECDSA = errors.New("token: AuthKey must be of type ecdsa.PrivateKey") + ErrAuthKeyNil = errors.New("token: AuthKey was nil") +) + +// Token represents an Apple Provider Authentication Token (JSON Web Token). +type Token struct { + sync.Mutex + AuthKey *ecdsa.PrivateKey + KeyID string + TeamID string + IssuedAt int64 + Bearer string +} + +// AuthKeyFromFile loads a .p8 certificate from a local file and returns a +func AuthKeyFromFile(filename string) (*ecdsa.PrivateKey, error) { + bytes, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + return AuthKeyFromBytes(bytes) +} + +// AuthKeyFromBytes loads a .p8 certificate from an in memory byte array and +func AuthKeyFromBytes(bytes []byte) (*ecdsa.PrivateKey, error) { + block, _ := pem.Decode(bytes) + if block == nil { + return nil, ErrAuthKeyNotPem + } + key, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + switch pk := key.(type) { + case *ecdsa.PrivateKey: + return pk, nil + default: + return nil, ErrAuthKeyNotECDSA + } +} + +// GenerateBearerIfExpired checks to see if the token is about to expire and +// generates a new token. +func (t *Token) GenerateBearerIfExpired() (bearer string) { + t.Lock() + defer t.Unlock() + if t.Expired() { + // TODO: error handling + t.Generate() + } + return t.Bearer +} + +// Expired checks to see if the token has expired. +func (t *Token) Expired() bool { + return time.Now().Unix() >= (t.IssuedAt + TokenTimeout) +} + +// Generate creates a new token. +func (t *Token) Generate() (bool, error) { + if t.AuthKey == nil { + return false, ErrAuthKeyNil + } + issuedAt := time.Now().Unix() + jwtToken := &jwt.Token{ + Header: map[string]interface{}{ + "alg": "ES256", + "kid": t.KeyID, + }, + Claims: jwt.MapClaims{ + "iss": t.TeamID, + "iat": issuedAt, + }, + Method: jwt.SigningMethodES256, + } + bearer, err := jwtToken.SignedString(t.AuthKey) + if err != nil { + return false, err + } + t.IssuedAt = issuedAt + t.Bearer = bearer + return true, nil +} diff --git a/buford/token/token_test.go b/buford/token/token_test.go new file mode 100644 index 0000000..be968d1 --- /dev/null +++ b/buford/token/token_test.go @@ -0,0 +1,96 @@ +package token_test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "errors" + "io/ioutil" + "testing" + "time" + + "github.com/mercari/gaurun/buford/token" + "github.com/stretchr/testify/assert" +) + +// AuthToken + +func TestValidTokenFromP8File(t *testing.T) { + _, err := token.AuthKeyFromFile("testdata/authkey-valid.p8") + assert.NoError(t, err) +} + +func TestValidTokenFromP8Bytes(t *testing.T) { + bytes, _ := ioutil.ReadFile("testdata/authkey-valid.p8") + _, err := token.AuthKeyFromBytes(bytes) + assert.NoError(t, err) +} + +func TestNoSuchFileP8File(t *testing.T) { + token, err := token.AuthKeyFromFile("") + assert.Equal(t, errors.New("open : no such file or directory").Error(), err.Error()) + assert.Nil(t, token) +} + +func TestInvalidP8File(t *testing.T) { + _, err := token.AuthKeyFromFile("testdata/authkey-invalid.p8") + assert.Error(t, err) +} + +func TestInvalidPKCS8P8File(t *testing.T) { + _, err := token.AuthKeyFromFile("testdata/authkey-invalid-pkcs8.p8") + assert.Error(t, err) +} + +func TestInvalidECDSAP8File(t *testing.T) { + _, err := token.AuthKeyFromFile("testdata/authkey-invalid-ecdsa.p8") + assert.Error(t, err) +} + +// Expiry & Generation + +func TestExpired(t *testing.T) { + token := &token.Token{} + assert.True(t, token.Expired()) +} + +func TestNotExpired(t *testing.T) { + token := &token.Token{ + IssuedAt: time.Now().Unix(), + } + assert.False(t, token.Expired()) +} + +func TestExpiresBeforeAnHour(t *testing.T) { + token := &token.Token{ + IssuedAt: time.Now().Add(-50 * time.Minute).Unix(), + } + assert.True(t, token.Expired()) +} + +func TestGenerateBearerIfExpired(t *testing.T) { + authKey, _ := token.AuthKeyFromFile("testdata/authkey-valid.p8") + token := &token.Token{ + AuthKey: authKey, + } + token.GenerateBearerIfExpired() + assert.Equal(t, time.Now().Unix(), token.IssuedAt) +} + +func TestGenerateWithNoAuthKey(t *testing.T) { + token := &token.Token{} + bool, err := token.Generate() + assert.False(t, bool) + assert.Error(t, err) +} + +func TestGenerateWithInvalidAuthKey(t *testing.T) { + pubkeyCurve := elliptic.P521() + privatekey, _ := ecdsa.GenerateKey(pubkeyCurve, rand.Reader) + token := &token.Token{ + AuthKey: privatekey, + } + bool, err := token.Generate() + assert.False(t, bool) + assert.Error(t, err) +} diff --git a/cmd/gaurun/gaurun.go b/cmd/gaurun/gaurun.go index 54a4603..8b18429 100644 --- a/cmd/gaurun/gaurun.go +++ b/cmd/gaurun/gaurun.go @@ -13,6 +13,7 @@ import ( "syscall" "time" + "github.com/mercari/gaurun/buford/token" "github.com/mercari/gaurun/gaurun" ) @@ -76,16 +77,28 @@ func main() { } if gaurun.ConfGaurun.Ios.Enabled { - gaurun.CertificatePemIos.Cert, err = ioutil.ReadFile(gaurun.ConfGaurun.Ios.PemCertPath) - if err != nil { - gaurun.LogSetupFatal(fmt.Errorf("the certification file for iOS was not found")) + if gaurun.ConfGaurun.Ios.IsCertificateBasedProvider() && gaurun.ConfGaurun.Ios.IsTokenBasedProvider() { + gaurun.LogSetupFatal(fmt.Errorf("you can use only one of certificate-based provider or token-based provider connection trust")) } - gaurun.CertificatePemIos.Key, err = ioutil.ReadFile(gaurun.ConfGaurun.Ios.PemKeyPath) - if err != nil { - gaurun.LogSetupFatal(fmt.Errorf("the key file for iOS was not found")) - } + if gaurun.ConfGaurun.Ios.IsCertificateBasedProvider() { + _, err = ioutil.ReadFile(gaurun.ConfGaurun.Ios.PemCertPath) + if err != nil { + gaurun.LogSetupFatal(fmt.Errorf("the certification file for iOS was not found")) + } + _, err = ioutil.ReadFile(gaurun.ConfGaurun.Ios.PemKeyPath) + if err != nil { + gaurun.LogSetupFatal(fmt.Errorf("the key file for iOS was not found")) + } + } else if gaurun.ConfGaurun.Ios.IsTokenBasedProvider() { + _, err = token.AuthKeyFromFile(gaurun.ConfGaurun.Ios.TokenAuthKeyPath) + if err != nil { + gaurun.LogSetupFatal(fmt.Errorf("the auth key file for iOS was not loading: %v", err)) + } + } else { + gaurun.LogSetupFatal(fmt.Errorf("the key file or APNsAuthKey file for iOS was not found")) + } } if gaurun.ConfGaurun.Android.Enabled { @@ -127,6 +140,7 @@ func main() { gaurun.LogSetupFatal(fmt.Errorf("failed to init http client for APNs: %v", err)) } } + gaurun.InitStat() gaurun.StartPushWorkers(gaurun.ConfGaurun.Core.WorkerNum, gaurun.ConfGaurun.Core.QueueNum) diff --git a/cmd/gaurun_recover/gaurun_recover.go b/cmd/gaurun_recover/gaurun_recover.go index a1a3120..3cbc1c2 100644 --- a/cmd/gaurun_recover/gaurun_recover.go +++ b/cmd/gaurun_recover/gaurun_recover.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "crypto/ecdsa" "encoding/json" "flag" "fmt" @@ -12,12 +13,13 @@ import ( "sync" "time" + "github.com/mercari/gaurun/buford/token" "github.com/mercari/gaurun/gaurun" "github.com/mercari/gaurun/gcm" ) var ( - APNSClient *http.Client + APNSClient gaurun.APNsClient GCMClient *gcm.Client ) @@ -131,15 +133,30 @@ func main() { } } - APNSClient, err = gaurun.NewApnsClientHttp2( - gaurun.ConfGaurun.Ios.PemCertPath, - gaurun.ConfGaurun.Ios.PemKeyPath, - gaurun.ConfGaurun.Ios.PemKeyPassphrase, - ) + if gaurun.ConfGaurun.Ios.IsCertificateBasedProvider() { + APNSClient, err = gaurun.NewApnsClientHttp2( + gaurun.ConfGaurun.Ios.PemCertPath, + gaurun.ConfGaurun.Ios.PemKeyPath, + gaurun.ConfGaurun.Ios.PemKeyPassphrase, + ) + } else if gaurun.ConfGaurun.Ios.IsTokenBasedProvider() { + var authKey *ecdsa.PrivateKey + authKey, err = token.AuthKeyFromFile(gaurun.ConfGaurun.Ios.TokenAuthKeyPath) + if err != nil { + gaurun.LogSetupFatal(err) + } + APNSClient, err = gaurun.NewApnsClientHttp2ForToken( + authKey, + gaurun.ConfGaurun.Ios.TokenAuthKeyID, + gaurun.ConfGaurun.Ios.TokenAuthTeamID, + ) + } else { + gaurun.LogSetupFatal(fmt.Errorf("should be specify Token-based provider or Certificate-based provider")) + } if err != nil { gaurun.LogSetupFatal(err) } - APNSClient.Timeout = time.Duration(gaurun.ConfGaurun.Ios.Timeout) * time.Second + APNSClient.HTTPClient.Timeout = time.Duration(gaurun.ConfGaurun.Ios.Timeout) * time.Second GCMClient, err := gcm.NewClient(gcm.FCMSendEndpoint, gaurun.ConfGaurun.Android.ApiKey) if err != nil { diff --git a/gaurun/apns_http2.go b/gaurun/apns_http2.go index 094d83e..c60a9d7 100644 --- a/gaurun/apns_http2.go +++ b/gaurun/apns_http2.go @@ -1,6 +1,7 @@ package gaurun import ( + "crypto/ecdsa" "crypto/tls" "crypto/x509" "encoding/json" @@ -14,8 +15,15 @@ import ( "github.com/mercari/gaurun/buford/payload" "github.com/mercari/gaurun/buford/payload/badge" "github.com/mercari/gaurun/buford/push" + "github.com/mercari/gaurun/buford/token" ) +type APNsClient struct { + HTTPClient *http.Client + // Token is set only for token-based provider connection trust + Token *token.Token +} + func NewTransportHttp2(cert tls.Certificate) (*http.Transport, error) { config := &tls.Config{ Certificates: []tls.Certificate{cert}, @@ -36,20 +44,48 @@ func NewTransportHttp2(cert tls.Certificate) (*http.Transport, error) { return transport, nil } -func NewApnsClientHttp2(certPath, keyPath, keyPassphrase string) (*http.Client, error) { +func NewApnsClientHttp2(certPath, keyPath, keyPassphrase string) (APNsClient, error) { cert, err := loadX509KeyPairWithPassword(certPath, keyPath, keyPassphrase) if err != nil { - return nil, err + return APNsClient{}, err } transport, err := NewTransportHttp2(cert) if err != nil { - return nil, err + return APNsClient{}, err } - return &http.Client{ - Transport: transport, - Timeout: time.Duration(ConfGaurun.Ios.Timeout) * time.Second, + return APNsClient{ + HTTPClient: &http.Client{ + Transport: transport, + Timeout: time.Duration(ConfGaurun.Ios.Timeout) * time.Second, + }, + }, nil +} + +func NewApnsClientHttp2ForToken(authKey *ecdsa.PrivateKey, keyID, teamID string) (APNsClient, error) { + authToken := &token.Token{ + AuthKey: authKey, + KeyID: keyID, + TeamID: teamID, + } + + transport := &http.Transport{ + MaxIdleConnsPerHost: ConfGaurun.Ios.KeepAliveConns, + Dial: (&net.Dialer{ + Timeout: time.Duration(ConfGaurun.Ios.Timeout) * time.Second, + KeepAlive: time.Duration(keepAliveInterval(ConfGaurun.Ios.KeepAliveTimeout)) * time.Second, + }).Dial, + IdleConnTimeout: time.Duration(ConfGaurun.Ios.KeepAliveTimeout) * time.Second, + ForceAttemptHTTP2: true, + } + + return APNsClient{ + HTTPClient: &http.Client{ + Transport: transport, + Timeout: time.Duration(ConfGaurun.Ios.Timeout) * time.Second, + }, + Token: authToken, }, nil } @@ -81,7 +117,7 @@ func loadX509KeyPairWithPassword(certPath, keyPath, keyPassphrase string) (tls.C return cert, nil } -func NewApnsServiceHttp2(client *http.Client) *push.Service { +func NewApnsServiceHttp2(apnsClient APNsClient) *push.Service { var host string if ConfGaurun.Ios.Sandbox { host = push.Development @@ -89,7 +125,7 @@ func NewApnsServiceHttp2(client *http.Client) *push.Service { host = push.Production } return &push.Service{ - Client: client, + Client: apnsClient.HTTPClient, Host: host, } } @@ -138,6 +174,13 @@ func NewApnsHeadersHttp2(req *RequestGaurunNotification) *push.Headers { return headers } +func NewApnsHeadersHttp2WithToken(req *RequestGaurunNotification, t *token.Token) *push.Headers { + headers := NewApnsHeadersHttp2(req) + headers.AuthToken = t + + return headers +} + func ApnsPushHttp2(token string, service *push.Service, headers *push.Headers, payload map[string]interface{}) error { b, err := json.Marshal(payload) if err != nil { diff --git a/gaurun/client.go b/gaurun/client.go index 54ea13d..c378b18 100644 --- a/gaurun/client.go +++ b/gaurun/client.go @@ -1,10 +1,13 @@ package gaurun import ( + "crypto/ecdsa" + "fmt" "net" "net/http" "time" + "github.com/mercari/gaurun/buford/token" "github.com/mercari/gaurun/gcm" ) @@ -51,11 +54,26 @@ func InitGCMClient() error { func InitAPNSClient() error { var err error - APNSClient, err = NewApnsClientHttp2( - ConfGaurun.Ios.PemCertPath, - ConfGaurun.Ios.PemKeyPath, - ConfGaurun.Ios.PemKeyPassphrase, - ) + if ConfGaurun.Ios.IsCertificateBasedProvider() { + APNSClient, err = NewApnsClientHttp2( + ConfGaurun.Ios.PemCertPath, + ConfGaurun.Ios.PemKeyPath, + ConfGaurun.Ios.PemKeyPassphrase, + ) + } else if ConfGaurun.Ios.IsTokenBasedProvider() { + var authKey *ecdsa.PrivateKey + authKey, err = token.AuthKeyFromFile(ConfGaurun.Ios.TokenAuthKeyPath) + if err != nil { + return err + } + APNSClient, err = NewApnsClientHttp2ForToken( + authKey, + ConfGaurun.Ios.TokenAuthKeyID, + ConfGaurun.Ios.TokenAuthTeamID, + ) + } else { + return fmt.Errorf("should be specify Token-based provider or Certificate-based provider") + } if err != nil { return err } diff --git a/gaurun/conf.go b/gaurun/conf.go index 7b011c6..31281dd 100644 --- a/gaurun/conf.go +++ b/gaurun/conf.go @@ -42,6 +42,9 @@ type SectionIos struct { PemCertPath string `toml:"pem_cert_path"` PemKeyPath string `toml:"pem_key_path"` PemKeyPassphrase string `toml:"pem_key_passphrase"` + TokenAuthKeyPath string `toml:"token_auth_key_path"` + TokenAuthKeyID string `toml:"token_auth_key_id"` + TokenAuthTeamID string `toml:"token_auth_team_id"` Sandbox bool `toml:"sandbox"` RetryMax int `toml:"retry_max"` Timeout int `toml:"timeout"` @@ -80,6 +83,9 @@ func BuildDefaultConf() ConfToml { conf.Ios.Enabled = true conf.Ios.PemCertPath = "" conf.Ios.PemKeyPath = "" + conf.Ios.TokenAuthKeyPath = "" + conf.Ios.TokenAuthKeyID = "" + conf.Ios.TokenAuthTeamID = "" conf.Ios.Sandbox = true conf.Ios.RetryMax = 1 conf.Ios.Timeout = 5 @@ -143,3 +149,11 @@ func ConfigPushersHandler(w http.ResponseWriter, r *http.Request) { sendResponse(w, "ok", http.StatusOK) } + +func (s *SectionIos) IsTokenBasedProvider() bool { + return s.TokenAuthKeyPath != "" && s.TokenAuthKeyID != "" && s.TokenAuthTeamID != "" +} + +func (s *SectionIos) IsCertificateBasedProvider() bool { + return s.PemCertPath != "" && s.PemKeyPath != "" +} diff --git a/gaurun/global.go b/gaurun/global.go index 5ce37c2..ce3d18d 100644 --- a/gaurun/global.go +++ b/gaurun/global.go @@ -1,8 +1,6 @@ package gaurun import ( - "net/http" - "github.com/mercari/gaurun/gcm" "go.uber.org/zap" @@ -13,12 +11,10 @@ var ( ConfGaurun ConfToml // push notification Queue QueueNotification chan RequestGaurunNotification - // TLS certificate and key for APNs - CertificatePemIos CertificatePem // Stat for Gaurun StatGaurun StatApp // http client for APNs and GCM/FCM - APNSClient *http.Client + APNSClient APNsClient GCMClient *gcm.Client // access and error logger LogAccess *zap.Logger diff --git a/gaurun/notification.go b/gaurun/notification.go index 5369300..3d4e7ce 100644 --- a/gaurun/notification.go +++ b/gaurun/notification.go @@ -10,6 +10,7 @@ import ( "sync/atomic" "time" + "github.com/mercari/gaurun/buford/push" "github.com/mercari/gaurun/gcm" "go.uber.org/zap" @@ -95,7 +96,12 @@ func pushNotificationIos(req RequestGaurunNotification) error { token := req.Tokens[0] - headers := NewApnsHeadersHttp2(&req) + var headers *push.Headers + if APNSClient.Token != nil { + headers = NewApnsHeadersHttp2WithToken(&req, APNSClient.Token) + } else { + headers = NewApnsHeadersHttp2(&req) + } payload := NewApnsPayloadHttp2(&req) stime := time.Now() diff --git a/go.mod b/go.mod index 4091d96..2d13ad9 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.13 require ( github.com/BurntSushi/toml v0.3.1 github.com/client9/reopen v1.0.0 + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/fukata/golang-stats-api-handler v1.0.0 github.com/lestrrat/go-server-starter v0.0.0-20180220115249-6ac0b358431b github.com/pkg/errors v0.8.1 // indirect diff --git a/go.sum b/go.sum index 4fb98c0..cac7562 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/client9/reopen v1.0.0 h1:8tpLVR74DLpLObrn2KvsyxJY++2iORGR17WLUdSzUws= github.com/client9/reopen v1.0.0/go.mod h1:caXVCEr+lUtoN1FlsRiOWdfQtdRHIYfcb0ai8qKWtkQ= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/fukata/golang-stats-api-handler v1.0.0 h1:N6M25vhs1yAvwGBpFY6oBmMOZeJdcWnvA+wej8pKeko= github.com/fukata/golang-stats-api-handler v1.0.0/go.mod h1:1sIi4/rHq6s/ednWMZqTmRq3765qTUSs/c3xF6lj8J8= github.com/lestrrat/go-server-starter v0.0.0-20180220115249-6ac0b358431b h1:Ial71qYQufttbgLhmAAAgf4Y+p9KRKYklAoE9a8rXOA=