Commit 8a46c7ff authored by Mo Firouz's avatar Mo Firouz
Browse files

Add sample IAP verification modules. (#317)

parent b17aa8ce
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -4,6 +4,10 @@ All notable changes to this project are documented below.
The format is based on [keep a changelog](http://keepachangelog.com) and this project uses [semantic versioning](http://semver.org).

## [Unreleased]
### Added
- New Lua runtime functions to generate JWT tokens.
- New Lua runtime functions to hash data using RSA SHA256.

### Changed
- Log more information when authoritative match handlers receive too many data messages.
- Ensure storage writes and deletes are performed in a consistent order within each batch.
+166 −0
Original line number Diff line number Diff line
--[[
 Copyright 2019 The Nakama Authors

 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at

 http://www.apache.org/licenses/LICENSE-2.0

 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
--]]

--[[
In-App Purchase Verification module.

Using this module, you can check the validity of an Apple or Google IAP receipt.
--]]

local nk = require("nakama")

local M = {}

--[[
Sends a request to the Apple IAP Verification service.

It will first try to validate against Production servers, and if code 21007 is returned, it will retry it with Sandbox servers.

Request object match the following format:
{
  receipt = "", -- base64 encoded receipt data received from client/iOS
  password = "", -- optional. Used to verify auto-renewable subscriptions.
  exclude_old_transactions = true -- optional. Return only the most recent transaction for auto-renewable subscriptions.
}

This function will return a Lua table that represents the data in this page:
https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html

This function can also raise an error in case of bad network, or invalid receipt data.
--]]
function M.verify_payment_apple(request)
  local url_sandbox = "https://sandbox.itunes.apple.com/verifyReceipt"
  local url_production = "https://buy.itunes.apple.com/verifyReceipt"

  local http_body = nk.json_encode({
    ["receipt-data"] = request.receipt,
    ["password"] = request.password,
    ["exclude-old-transactions"] = request.exclude_old_transactions
  })

  local http_headers = {
    ["Content-Type"] = "application/json",
    ["Accept"] = "application/json"
  }

  local success, code, _, body = pcall(nk.http_request, url_production, "POST", http_headers, http_body)
  if (not success) then
    nk.logger_warn(("Network error occurred: %q"):format(code))
    error(code)
  elseif (code == 200) then
    return nk.json_decode(body)
  elseif (code == 400) then
    local response = nk.json_decode(body)
    if (response.status == 21007) then  -- was supposed to be sent to sandbox
      local success, code, _, body = pcall(nk.http_request, url_sandbox, "POST", http_headers, http_body)
      if (not success) then
        nk.logger_warn(("Network error occurred: %q"):format(code))
        error(code)
      elseif (code == 200) then
        return nk.json_decode(body)
      end
    end
  end
  error(body)
end

function M.google_obtain_access_token(client_email, private_key)
  local auth_url = "https://accounts.google.com/o/oauth2/auth"
  local scope = "https://www.googleapis.com/auth/androidpublisher"
  local exp = nk.time() + 3600000 -- current time + 1hr added in ms
  local iat = nk.time()

  local algo_type = "RS256"

  local jwt_claimset = nk.base64url_encode({
    ["iss"] = client_email,
    ["scope"] = scope,
    ["aud"] = auth_url,
    ["exp"] = exp,
    ["iat"] = iat
  })

  local jwt_token = nk.jwt_generate(algo_type, private_key, jwt_claimset)

  local grant_type = "urn%3ietf%3params%3oauth%3grant-type%3jwt-bearer"
  local form_data = "grant_type=" .. grant_type .. "&assertion=" .. jwt_token
  local http_headers = {["Content-Type"] = "application/x-www-form-urlencoded"}

  local success, code, _, body = pcall(nk.http_request, auth_url, "POST", http_headers, form_data)
  if (not success) then
    nk.logger_warn(("Network error occurred: %q"):format(code))
    error(code)
  elseif (code == 200) then
    return nk.json_decode(body)["access_token"]
  end

  error(body)
end

--[[
Sends a request to the Google IAP Verification service.

It will first try to obtain an access token using the service account provided.

Request object match the following format:
{
  is_subscription = false, -- set to true if it is subscription, otherwise product.
  product_id = "" -- Product ID,
  package_name = "" -- Product Name,
  receipt = "" -- Payment receipt in string format,
  client_email = "", -- Service account client email address. Retrieve this from Service account in JSON format.
  private_key = "", -- Service account private key. Retrieve this from Service account in JSON format.
}

For Products, this function will return a Lua table that represents the data in this page:
https://developers.google.com/android-publisher/api-ref/purchases/products#resource

For Subscritions, this function will return a Lua table that represents the data in this page:
https://developers.google.com/android-publisher/api-ref/purchases/subscriptions

This function can also raise an error in case of bad network, bad authentication or invalid receipt data.
--]]

function M.verify_payment_google(request)
  local success, access_token = pcall(M.google_obtain_access_token, request.client_email, request.private_key)
  if (not success) then
    nk.logger_warn(("Failed to obtain access token: %q"):format(access_token))
    error(access_token)
  end

  local url_template = "https://www.googleapis.com/androidpublisher/v2/applications/%q/purchases/subscriptions/%q/tokens/%q?access_token=%q"
  if (not request.is_subscription) then
    url_template = "https://www.googleapis.com/androidpublisher/v2/applications/%q/purchases/products/%q/tokens/%q?access_token=%q"
  end

  local url = url_template:format(request.package_name, request.product_id, request.receipt, access_token)

  local http_headers = {
    ["Content-Type"] = "application/json",
    ["Accept"] = "application/json"
  }
  local success, code, _, body = pcall(nk.http_request, url, "GET", http_headers, nil)
  if (not success) then
    nk.logger_warn(("Network error occurred: %q"):format(code))
    error(code)
  elseif (code == 200) then
    return nk.json_decode(body)
  end

  error(body)
end

return M
+142 −0
Original line number Diff line number Diff line
--[[
 Copyright 2019 The Nakama Authors

 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at

 http://www.apache.org/licenses/LICENSE-2.0

 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
--]]

--[[
Client RPC calls to validate receipt with Apple or Google.
--]]

local nk = require("nakama")
local iap = require("iap_verifier")

--[[
This function expects the following information to come from Runtime environment variables:
"password" -- Shares secret password obtained from Apple.

Client must send through the following information:
{
  receipt = "" -- base64 encoded receipt information
}

The response object will be:
{
  "success": true
  "result": {}
}

or in case of an error:
{
  "success": false
  "error": ""
}

This function will return result that represents the data in this page:
https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html

--]]
local function apple_verify_payment(context, payload)
  -- In-App Purchase Shared Secret required to verify auto-renewable subscriptions.
  local password = context.env["iap_apple_password"]

  local json_payload = nk.json_decode(payload)
  local receipt = json_payload.receipt

  local success, result = pcall(iap.verify_payment_apple, {
    receipt = receipt,
    password = password,
    exclude_old_transactions = true
  })

  if (not success) then
    nk.logger_warn(("Apple IAP verification failed - request: %q - response: %q"):format(payload, result))
    return nk.json_encode({
      ["success"] = false,
      ["error"] = result
    })
  else
    nk.logger_info(("Apple IAP verification completed - request: %q - response: %q"):format(payload, result))
    return nk.json_encode({
      ["success"] = true,
      ["result"] = result
    })
  end

end
nk.register_rpc(apple_verify_payment, "iap.apple_verify_payment")

--[[
This function expects the following information to come from Runtime environment variables:
"iap_google_service_account" -- Base64 encoded JSON file.

Client must send through the following information:
{
  product_id = "",
  package_name = "",
  receipt = "",
  is_subscription = false
}

The response object will be:
{
  "success": true
  "result": {}
}

or in case of an error:
{
  "success": false
  "error": ""
}

For Products, this function will return result that represents the data in this page:
https://developers.google.com/android-publisher/api-ref/purchases/products#resource

For Subscritions, this function will return result that represents the data in this page:
https://developers.google.com/android-publisher/api-ref/purchases/subscriptions
--]]

local function google_verify_payment(context, payload)
  -- Google API Service Account JSON key file in base64.
  local service_account = nk.base64_decode(context.env["iap_google_service_account"])

  local json_payload = nk.json_decode(payload)
  local product_id = json_payload.product_id
  local package_name = json_payload.package_name
  local receipt = json_payload.receipt

  local success, result = pcall(iap.verify_payment_google, {
    is_subscription = false,
    product_id = product_id,
    package_name = package_name,
    receipt = receipt,
    client_email = service_account["client_email"],
    private_key = service_account["private_key"],
  })

  if (not success) then
    nk.logger_warn(("Google IAP verification failed - request: %q - response: %q"):format(payload, result))
    return nk.json_encode({
      ["success"] = false,
      ["error"] = result
    })
  else
    nk.logger_info(("Google IAP verification completed - request: %q - response: %q"):format(payload, result))
    return nk.json_encode({
      ["success"] = true,
      ["result"] = result
    })
  end
end
nk.register_rpc(google_verify_payment, "iap.google_verify_payment")
+76 −0
Original line number Diff line number Diff line
@@ -16,12 +16,15 @@ package server

import (
	"bytes"
	"crypto"
	"crypto/aes"
	"crypto/cipher"
	"crypto/hmac"
	"crypto/md5"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"database/sql"
	"encoding/base64"
	"encoding/gob"
@@ -35,6 +38,7 @@ import (
	"sync"
	"time"

	"github.com/dgrijalva/jwt-go"
	"github.com/golang/protobuf/jsonpb"

	"github.com/gofrs/uuid"
@@ -125,6 +129,7 @@ func (n *RuntimeLuaNakamaModule) Loader(l *lua.LState) int {
		"uuid_bytes_to_string":        n.uuidBytesToString,
		"uuid_string_to_bytes":        n.uuidStringToBytes,
		"http_request":                n.httpRequest,
		"jwt_generate":                n.jwtGenerate,
		"json_encode":                 n.jsonEncode,
		"json_decode":                 n.jsonDecode,
		"base64_encode":               n.base64Encode,
@@ -140,6 +145,7 @@ func (n *RuntimeLuaNakamaModule) Loader(l *lua.LState) int {
		"md5_hash":                    n.md5Hash,
		"sha256_hash":                 n.sha256Hash,
		"hmac_sha256_hash":            n.hmacSHA256Hash,
		"rsa_sha256_hash":             n.rsaSHA256Hash,
		"bcrypt_hash":                 n.bcryptHash,
		"bcrypt_compare":              n.bcryptCompare,
		"authenticate_custom":         n.authenticateCustom,
@@ -699,6 +705,47 @@ func (n *RuntimeLuaNakamaModule) httpRequest(l *lua.LState) int {
	return 3
}

func (n *RuntimeLuaNakamaModule) jwtGenerate(l *lua.LState) int {
	algoType := l.CheckString(1)
	if algoType == "" {
		l.ArgError(1, "expects string")
		return 0
	}

	var signingMethod jwt.SigningMethod
	switch algoType {
	case "HS256":
		signingMethod = jwt.SigningMethodHS256
	case "RS256":
		signingMethod = jwt.SigningMethodRS256
	default:
		l.ArgError(3, "unsupported algo type - only allowed 'HS256', 'RS256'.")
	}

	signingKey := l.CheckString(2)
	if signingKey == "" {
		l.ArgError(2, "expects string")
		return 0
	}

	claimsetTable := l.CheckTable(3)
	if claimsetTable == nil {
		l.ArgError(3, "expects nil")
		return 0
	}

	claimset := RuntimeLuaConvertLuaValue(claimsetTable).(map[string]interface{})
	jwtClaims := jwt.MapClaims{}
	for k, v := range claimset {
		jwtClaims[k] = v
	}

	token := jwt.NewWithClaims(signingMethod, jwtClaims)
	signedToken, _ := token.SignedString([]byte(signingKey))
	l.Push(lua.LString(signedToken))
	return 1
}

func (n *RuntimeLuaNakamaModule) jsonEncode(l *lua.LState) int {
	value := l.Get(1)
	if value == nil {
@@ -964,6 +1011,35 @@ func (n *RuntimeLuaNakamaModule) sha256Hash(l *lua.LState) int {
	return 1
}

func (n *RuntimeLuaNakamaModule) rsaSHA256Hash(l *lua.LState) int {
	input := l.CheckString(1)
	if input == "" {
		l.ArgError(1, "expects input string")
		return 0
	}
	key := l.CheckString(2)
	if key == "" {
		l.ArgError(2, "expects key string")
		return 0
	}

	rsaPrivateKey, err := x509.ParsePKCS1PrivateKey([]byte(key))
	if err != nil {
		l.RaiseError("error parsing key: %v", err.Error())
		return 0
	}

	hashed := sha256.Sum256([]byte(input))
	signature, err := rsa.SignPKCS1v15(rand.Reader, rsaPrivateKey, crypto.SHA256, hashed[:])
	if err != nil {
		l.RaiseError("error parsing key: %v", err.Error())
		return 0
	}

	l.Push(lua.LString(signature))
	return 1
}

func (n *RuntimeLuaNakamaModule) hmacSHA256Hash(l *lua.LState) int {
	input := l.CheckString(1)
	if input == "" {