Added google androidpublisher/v3 api to verify google purchase tokens

This commit is contained in:
Mike Schwörer 2022-11-25 22:42:21 +01:00
parent 6d80638cf8
commit 3a0c65a849
Signed by: Mikescher
GPG Key ID: D3C7172E0A70F8CF
18 changed files with 608 additions and 163 deletions

View File

@ -2,51 +2,19 @@
//TODO //TODO
- Return client on register - return subscribtions in list-channels (?)
- Register with noclient=true
- migration script for existing data - migration script for existing data
- ack/read deliveries && return ack-count (? or not, how to query?) - ack/read deliveries && return ack-count (? or not, how to query?)
- verify pro_token
- full-text-search: https://www.sqlite.org/fts5.html#contentless_tables - full-text-search: https://www.sqlite.org/fts5.html#contentless_tables
- dark mode toggle for html - dark mode toggle for html
- app-store link in HTML - app-store link in HTML
- route to re-check all pro-token
- tests - tests
- deploy - deploy
~~~~~~~~~~~~~~~~~~~~~~~~~~
https://developers.google.com/android-publisher/api-ref/purchases/products/get
https://console.cloud.google.com/apis/credentials?project=api-5982391131063970546-620227
https://pkg.go.dev/google.golang.org/api/androidpublisher/v2
https://github.com/googleapis/google-api-go-client
{
"web":
{
"client_id": "57770789756-7c29a3gif5346dkte194j1rb6u3mt5vs.apps.googleusercontent.com",
"project_id": "api-5982391131063970546-620227",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_secret": "wG22acxdYUfpi8RkSsjyu6Tw",
"redirect_uris":
[
"https://scn.blackforestbytes.com/index.php"
],
"javascript_origins":
[
"https://scn.blackforestbytes.com"
]
}
}
~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -82,7 +82,7 @@ func (h APIHandler) CreateUser(g *gin.Context) ginresp.HTTPResponse {
} }
if b.ProToken != nil { if b.ProToken != nil {
ptok, err := h.app.VerifyProToken(*b.ProToken) ptok, err := h.app.VerifyProToken(ctx, *b.ProToken)
if err != nil { if err != nil {
return ginresp.APIError(g, 500, apierr.FAILED_VERIFY_PRO_TOKEN, "Failed to query purchase status", err) return ginresp.APIError(g, 500, apierr.FAILED_VERIFY_PRO_TOKEN, "Failed to query purchase status", err)
} }
@ -232,7 +232,7 @@ func (h APIHandler) UpdateUser(g *gin.Context) ginresp.HTTPResponse {
} }
if b.ProToken != nil { if b.ProToken != nil {
ptok, err := h.app.VerifyProToken(*b.ProToken) ptok, err := h.app.VerifyProToken(ctx, *b.ProToken)
if err != nil { if err != nil {
return ginresp.APIError(g, 500, apierr.FAILED_VERIFY_PRO_TOKEN, "Failed to query purchase status", err) return ginresp.APIError(g, 500, apierr.FAILED_VERIFY_PRO_TOKEN, "Failed to query purchase status", err)
} }
@ -697,8 +697,8 @@ func (h APIHandler) UpdateChannel(g *gin.Context) ginresp.HTTPResponse {
// @Tags API-v2 // @Tags API-v2
// //
// @Param query_data query handler.ListChannelMessages.query false " " // @Param query_data query handler.ListChannelMessages.query false " "
// @Param uid path int true "UserID" // @Param uid path int true "UserID"
// @Param cid path int true "ChannelID" // @Param cid path int true "ChannelID"
// //
// @Success 200 {object} handler.ListChannelMessages.response // @Success 200 {object} handler.ListChannelMessages.response
// @Failure 400 {object} ginresp.apiError // @Failure 400 {object} ginresp.apiError

View File

@ -162,9 +162,9 @@ func (h CommonHandler) Health(g *gin.Context) ginresp.HTTPResponse {
// //
// @Param secs path number true "sleep delay (in seconds)" // @Param secs path number true "sleep delay (in seconds)"
// //
// @Success 200 {object} handler.Sleep.response // @Success 200 {object} handler.Sleep.response
// @Failure 400 {object} ginresp.apiError // @Failure 400 {object} ginresp.apiError
// @Failure 500 {object} ginresp.apiError // @Failure 500 {object} ginresp.apiError
// //
// @Router /api/sleep/{secs} [post] // @Router /api/sleep/{secs} [post]
func (h CommonHandler) Sleep(g *gin.Context) ginresp.HTTPResponse { func (h CommonHandler) Sleep(g *gin.Context) ginresp.HTTPResponse {

View File

@ -85,7 +85,7 @@ func (h CompatHandler) Register(g *gin.Context) ginresp.HTTPResponse {
} }
if data.ProToken != nil { if data.ProToken != nil {
ptok, err := h.app.VerifyProToken(*data.ProToken) ptok, err := h.app.VerifyProToken(ctx, "ANDROID|v2|"+*data.ProToken)
if err != nil { if err != nil {
return ginresp.CompatAPIError(0, "Failed to query purchase status") return ginresp.CompatAPIError(0, "Failed to query purchase status")
} }
@ -634,7 +634,7 @@ func (h CompatHandler) Upgrade(g *gin.Context) ginresp.HTTPResponse {
} }
if data.ProToken != nil { if data.ProToken != nil {
ptok, err := h.app.VerifyProToken(*data.ProToken) ptok, err := h.app.VerifyProToken(ctx, "ANDROID|v2|"+*data.ProToken)
if err != nil { if err != nil {
return ginresp.CompatAPIError(0, "Failed to query purchase status") return ginresp.CompatAPIError(0, "Failed to query purchase status")
} }

View File

@ -14,12 +14,14 @@ import (
type WebsiteHandler struct { type WebsiteHandler struct {
app *logic.Application app *logic.Application
rexTemplate *regexp.Regexp rexTemplate *regexp.Regexp
rexConfig *regexp.Regexp
} }
func NewWebsiteHandler(app *logic.Application) WebsiteHandler { func NewWebsiteHandler(app *logic.Application) WebsiteHandler {
return WebsiteHandler{ return WebsiteHandler{
app: app, app: app,
rexTemplate: regexp.MustCompile("{{template\\|[A-Za-z0-9_\\-.]+}}"), rexTemplate: regexp.MustCompile("{{template\\|[A-Za-z0-9_\\-.]+}}"),
rexConfig: regexp.MustCompile("{{config\\|[A-Za-z0-9_\\-.]+}}"),
} }
} }
@ -94,6 +96,21 @@ func (h WebsiteHandler) serveAsset(g *gin.Context, fn string, repl bool) ginresp
if failed { if failed {
return ginresp.InternalError(errors.New("template replacement failed")) return ginresp.InternalError(errors.New("template replacement failed"))
} }
data = h.rexConfig.ReplaceAllFunc(data, func(match []byte) []byte {
prefix := len("{{config|")
suffix := len("}}")
cfgKey := match[prefix : len(match)-suffix]
cval, ok := h.getReplConfig(string(cfgKey))
if !ok {
failed = true
}
return []byte(cval)
})
if failed {
return ginresp.InternalError(errors.New("config replacement failed"))
}
} }
mime := "text/plain" mime := "text/plain"
@ -117,3 +134,23 @@ func (h WebsiteHandler) serveAsset(g *gin.Context, fn string, repl bool) ginresp
return ginresp.Data(http.StatusOK, mime, data) return ginresp.Data(http.StatusOK, mime, data)
} }
func (h WebsiteHandler) getReplConfig(key string) (string, bool) {
key = strings.TrimSpace(strings.ToLower(key))
if key == "baseurl" {
return h.app.Config.BaseURL, true
}
if key == "ip" {
return h.app.Config.ServerIP, true
}
if key == "port" {
return h.app.Config.ServerPort, true
}
if key == "namespace" {
return h.app.Config.Namespace, true
}
return "", false
}

View File

@ -6,6 +6,7 @@ import (
"blackforestbytes.com/simplecloudnotifier/common" "blackforestbytes.com/simplecloudnotifier/common"
"blackforestbytes.com/simplecloudnotifier/common/ginext" "blackforestbytes.com/simplecloudnotifier/common/ginext"
"blackforestbytes.com/simplecloudnotifier/db" "blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/google"
"blackforestbytes.com/simplecloudnotifier/jobs" "blackforestbytes.com/simplecloudnotifier/jobs"
"blackforestbytes.com/simplecloudnotifier/logic" "blackforestbytes.com/simplecloudnotifier/logic"
"blackforestbytes.com/simplecloudnotifier/push" "blackforestbytes.com/simplecloudnotifier/push"
@ -38,7 +39,7 @@ func main() {
var nc push.NotificationClient var nc push.NotificationClient
if conf.DummyFirebase { if conf.DummyFirebase {
nc, err = push.NewDummy() nc = push.NewDummy()
} else { } else {
nc, err = push.NewFirebaseConn(conf) nc, err = push.NewFirebaseConn(conf)
if err != nil { if err != nil {
@ -47,9 +48,20 @@ func main() {
} }
} }
var apc google.AndroidPublisherClient
if conf.DummyGoogleAPI {
apc = google.NewDummy()
} else {
apc, err = google.NewAndroidPublisherAPI(conf)
if err != nil {
log.Fatal().Err(err).Msg("failed to init google-api")
return
}
}
jobRetry := jobs.NewDeliveryRetryJob(app) jobRetry := jobs.NewDeliveryRetryJob(app)
app.Init(conf, ginengine, nc, []logic.Job{jobRetry}) app.Init(conf, ginengine, nc, apc, []logic.Job{jobRetry})
router.Init(ginengine) router.Init(ginengine)

View File

@ -8,110 +8,158 @@ import (
) )
type Config struct { type Config struct {
Namespace string Namespace string
GinDebug bool BaseURL string
ServerIP string GinDebug bool
ServerPort string ServerIP string
DBFile string ServerPort string
RequestTimeout time.Duration DBFile string
ReturnRawErrors bool RequestTimeout time.Duration
DummyFirebase bool ReturnRawErrors bool
FirebaseTokenURI string DummyFirebase bool
FirebaseProjectID string FirebaseTokenURI string
FirebasePrivKeyID string FirebaseProjectID string
FirebaseClientMail string FirebasePrivKeyID string
FirebasePrivateKey string FirebaseClientMail string
FirebasePrivateKey string
DummyGoogleAPI bool
GoogleAPITokenURI string
GoogleAPIPrivKeyID string
GoogleAPIClientMail string
GoogleAPIPrivateKey string
GooglePackageName string
GoogleProProductID string
} }
var Conf Config var Conf Config
var configLocHost = func() Config { var configLocHost = func() Config {
return Config{ return Config{
Namespace: "local-host", Namespace: "local-host",
GinDebug: true, BaseURL: "http://localhost:8080",
ServerIP: "0.0.0.0", GinDebug: true,
ServerPort: "8080", ServerIP: "0.0.0.0",
DBFile: ".run-data/db.sqlite3", ServerPort: "8080",
RequestTimeout: 16 * time.Second, DBFile: ".run-data/db.sqlite3",
ReturnRawErrors: true, RequestTimeout: 16 * time.Second,
DummyFirebase: true, ReturnRawErrors: true,
FirebaseTokenURI: "", DummyFirebase: true,
FirebaseProjectID: "", FirebaseTokenURI: "",
FirebasePrivKeyID: "", FirebaseProjectID: "",
FirebaseClientMail: "", FirebasePrivKeyID: "",
FirebasePrivateKey: "", FirebaseClientMail: "",
FirebasePrivateKey: "",
DummyGoogleAPI: true,
GoogleAPITokenURI: "",
GoogleAPIPrivKeyID: "",
GoogleAPIClientMail: "",
GoogleAPIPrivateKey: "",
GooglePackageName: "",
GoogleProProductID: "",
} }
} }
var configLocDocker = func() Config { var configLocDocker = func() Config {
return Config{ return Config{
Namespace: "local-docker", Namespace: "local-docker",
GinDebug: true, BaseURL: "http://localhost:8080",
ServerIP: "0.0.0.0", GinDebug: true,
ServerPort: "80", ServerIP: "0.0.0.0",
DBFile: "/data/scn_docker.sqlite3", ServerPort: "80",
RequestTimeout: 16 * time.Second, DBFile: "/data/scn_docker.sqlite3",
ReturnRawErrors: true, RequestTimeout: 16 * time.Second,
DummyFirebase: true, ReturnRawErrors: true,
FirebaseTokenURI: "", DummyFirebase: true,
FirebaseProjectID: "", FirebaseTokenURI: "",
FirebasePrivKeyID: "", FirebaseProjectID: "",
FirebaseClientMail: "", FirebasePrivKeyID: "",
FirebasePrivateKey: "", FirebaseClientMail: "",
FirebasePrivateKey: "",
DummyGoogleAPI: true,
GoogleAPITokenURI: "",
GoogleAPIPrivKeyID: "",
GoogleAPIClientMail: "",
GoogleAPIPrivateKey: "",
GooglePackageName: "",
GoogleProProductID: "",
} }
} }
var configDev = func() Config { var configDev = func() Config {
return Config{ return Config{
Namespace: "develop", Namespace: "develop",
GinDebug: true, BaseURL: confEnv("BASE_URL"),
ServerIP: "0.0.0.0", GinDebug: true,
ServerPort: "80", ServerIP: "0.0.0.0",
DBFile: "/data/scn.sqlite3", ServerPort: "80",
RequestTimeout: 16 * time.Second, DBFile: "/data/scn.sqlite3",
ReturnRawErrors: true, RequestTimeout: 16 * time.Second,
DummyFirebase: false, ReturnRawErrors: true,
FirebaseTokenURI: "https://oauth2.googleapis.com/token", DummyFirebase: false,
FirebaseProjectID: confEnv("FB_PROJECTID"), FirebaseTokenURI: "https://oauth2.googleapis.com/token",
FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"), FirebaseProjectID: confEnv("FB_PROJECTID"),
FirebaseClientMail: confEnv("FB_CLIENTEMAIL"), FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"),
FirebasePrivateKey: confEnv("FB_PRIVATEKEY"), FirebaseClientMail: confEnv("FB_CLIENTEMAIL"),
FirebasePrivateKey: confEnv("FB_PRIVATEKEY"),
DummyGoogleAPI: false,
GoogleAPITokenURI: "https://oauth2.googleapis.com/token",
GoogleAPIPrivKeyID: confEnv("GOOG_PRIVATEKEYID"),
GoogleAPIClientMail: confEnv("GOOG_CLIENTEMAIL"),
GoogleAPIPrivateKey: confEnv("GOOG_PRIVATEKEY"),
GooglePackageName: confEnv("GOOG_PACKAGENAME"),
GoogleProProductID: confEnv("GOOG_PROPRODUCTID"),
} }
} }
var configStag = func() Config { var configStag = func() Config {
return Config{ return Config{
Namespace: "staging", Namespace: "staging",
GinDebug: true, BaseURL: confEnv("BASE_URL"),
ServerIP: "0.0.0.0", GinDebug: true,
ServerPort: "80", ServerIP: "0.0.0.0",
DBFile: "/data/scn.sqlite3", ServerPort: "80",
RequestTimeout: 16 * time.Second, DBFile: "/data/scn.sqlite3",
ReturnRawErrors: true, RequestTimeout: 16 * time.Second,
DummyFirebase: false, ReturnRawErrors: true,
FirebaseTokenURI: "https://oauth2.googleapis.com/token", DummyFirebase: false,
FirebaseProjectID: confEnv("FB_PROJECTID"), FirebaseTokenURI: "https://oauth2.googleapis.com/token",
FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"), FirebaseProjectID: confEnv("FB_PROJECTID"),
FirebaseClientMail: confEnv("FB_CLIENTEMAIL"), FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"),
FirebasePrivateKey: confEnv("FB_PRIVATEKEY"), FirebaseClientMail: confEnv("FB_CLIENTEMAIL"),
FirebasePrivateKey: confEnv("FB_PRIVATEKEY"),
DummyGoogleAPI: false,
GoogleAPITokenURI: "https://oauth2.googleapis.com/token",
GoogleAPIPrivKeyID: confEnv("GOOG_PRIVATEKEYID"),
GoogleAPIClientMail: confEnv("GOOG_CLIENTEMAIL"),
GoogleAPIPrivateKey: confEnv("GOOG_PRIVATEKEY"),
GooglePackageName: confEnv("GOOG_PACKAGENAME"),
GoogleProProductID: confEnv("GOOG_PROPRODUCTID"),
} }
} }
var configProd = func() Config { var configProd = func() Config {
return Config{ return Config{
Namespace: "production", Namespace: "production",
GinDebug: false, BaseURL: confEnv("BASE_URL"),
ServerIP: "0.0.0.0", GinDebug: false,
ServerPort: "80", ServerIP: "0.0.0.0",
DBFile: "/data/scn.sqlite3", ServerPort: "80",
RequestTimeout: 16 * time.Second, DBFile: "/data/scn.sqlite3",
ReturnRawErrors: false, RequestTimeout: 16 * time.Second,
DummyFirebase: false, ReturnRawErrors: false,
FirebaseTokenURI: "https://oauth2.googleapis.com/token", DummyFirebase: false,
FirebaseProjectID: confEnv("FB_PROJECTID"), FirebaseTokenURI: "https://oauth2.googleapis.com/token",
FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"), FirebaseProjectID: confEnv("FB_PROJECTID"),
FirebaseClientMail: confEnv("FB_CLIENTEMAIL"), FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"),
FirebasePrivateKey: confEnv("FB_PRIVATEKEY"), FirebaseClientMail: confEnv("FB_CLIENTEMAIL"),
FirebasePrivateKey: confEnv("FB_PRIVATEKEY"),
DummyGoogleAPI: false,
GoogleAPITokenURI: "https://oauth2.googleapis.com/token",
GoogleAPIPrivKeyID: confEnv("GOOG_PRIVATEKEYID"),
GoogleAPIClientMail: confEnv("GOOG_CLIENTEMAIL"),
GoogleAPIPrivateKey: confEnv("GOOG_PRIVATEKEY"),
GooglePackageName: confEnv("GOOG_PACKAGENAME"),
GoogleProProductID: confEnv("GOOG_PROPRODUCTID"),
} }
} }

View File

@ -0,0 +1,151 @@
package google
import (
scn "blackforestbytes.com/simplecloudnotifier"
"context"
"encoding/json"
"errors"
"fmt"
"github.com/rs/zerolog/log"
"io"
"net/http"
"time"
)
// https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products/get
// https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.products#ProductPurchase
type AndroidPublisher struct {
client http.Client
auth *GoogleOAuth2
baseURL string
}
func NewAndroidPublisherAPI(conf scn.Config) (AndroidPublisherClient, error) {
googauth, err := NewAuth(conf.GoogleAPITokenURI, conf.GoogleAPIPrivKeyID, conf.GoogleAPIClientMail, conf.GoogleAPIPrivateKey)
if err != nil {
return nil, err
}
return &AndroidPublisher{
client: http.Client{Timeout: 5 * time.Second},
auth: googauth,
baseURL: "https://androidpublisher.googleapis.com/androidpublisher",
}, nil
}
type PurchaseType int
const (
PurchaseTypeTest PurchaseType = 0 // i.e. purchased from a license testing account
PurchaseTypePromo PurchaseType = 1 // i.e. purchased using a promo code
PurchaseTypeRewarded PurchaseType = 2 // i.e. from watching a video ad instead of paying
)
type ConsumptionState int
const (
ConsumptionStateYetToBeConsumed ConsumptionState = 0
ConsumptionStateConsumed ConsumptionState = 1
)
type PurchaseState int
const (
PurchaseStatePurchased PurchaseState = 0
PurchaseStateCanceled PurchaseState = 1
PurchaseStatePending PurchaseState = 2
)
type AcknowledgementState int
const (
AcknowledgementStateYetToBeAcknowledged AcknowledgementState = 0
AcknowledgementStateAcknowledged AcknowledgementState = 1
)
type ProductPurchase struct {
Kind string `json:"kind"`
PurchaseTimeMillis string `json:"purchaseTimeMillis"`
PurchaseState *PurchaseState `json:"purchaseState"`
ConsumptionState ConsumptionState `json:"consumptionState"`
DeveloperPayload string `json:"developerPayload"`
OrderId string `json:"orderId"`
PurchaseType *PurchaseType `json:"purchaseType"`
AcknowledgementState AcknowledgementState `json:"acknowledgementState"`
PurchaseToken *string `json:"purchaseToken"`
ProductId *string `json:"productId"`
Quantity *int `json:"quantity"`
ObfuscatedExternalAccountId string `json:"obfuscatedExternalAccountId"`
ObfuscatedExternalProfileId string `json:"obfuscatedExternalProfileId"`
RegionCode string `json:"regionCode"`
}
type apiError struct {
Code int `json:"code"`
Message string `json:"message"`
}
func (ap AndroidPublisher) GetProductPurchase(ctx context.Context, packageName string, productId string, token string) (*ProductPurchase, error) {
uri := fmt.Sprintf("%s/v3/applications/%s/purchases/products/%s/tokens/%s", ap.baseURL, packageName, productId, token)
request, err := http.NewRequestWithContext(ctx, "GET", uri, nil)
if err != nil {
return nil, err
}
tok, err := ap.auth.Token(ctx)
if err != nil {
log.Err(err).Msg("Refreshing FB token failed")
return nil, err
}
request.Header.Set("Authorization", "Bearer "+tok)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json")
response, err := ap.client.Do(request)
if err != nil {
return nil, err
}
defer func() { _ = response.Body.Close() }()
respBodyBin, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
if response.StatusCode == 400 {
var errBody struct {
Error apiError `json:"error"`
}
if err := json.Unmarshal(respBodyBin, &errBody); err != nil {
return nil, err
}
if errBody.Error.Code == 400 {
return nil, nil // probably token not found
}
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
if bstr, err := io.ReadAll(response.Body); err == nil {
return nil, errors.New(fmt.Sprintf("GetProducts-Request returned %d: %s", response.StatusCode, string(bstr)))
} else {
return nil, errors.New(fmt.Sprintf("GetProducts-Request returned %d", response.StatusCode))
}
}
var respBody ProductPurchase
if err := json.Unmarshal(respBodyBin, &respBody); err != nil {
return nil, err
}
if respBody.Kind != "androidpublisher#productPurchase" {
return nil, errors.New(fmt.Sprintf("Invalid ProductPurchase.kind: '%s'", respBody.Kind))
}
return &respBody, nil
}

9
server/google/client.go Normal file
View File

@ -0,0 +1,9 @@
package google
import (
"context"
)
type AndroidPublisherClient interface {
GetProductPurchase(ctx context.Context, packageName string, productId string, token string) (*ProductPurchase, error)
}

16
server/google/dummy.go Normal file
View File

@ -0,0 +1,16 @@
package google
import (
"context"
_ "embed"
)
type DummyGoogleAPIClient struct{}
func NewDummy() AndroidPublisherClient {
return &DummyGoogleAPIClient{}
}
func (d DummyGoogleAPIClient) GetProductPurchase(ctx context.Context, packageName string, productId string, token string) (*ProductPurchase, error) {
return nil, nil // = purchase not found
}

174
server/google/oauth2.go Normal file
View File

@ -0,0 +1,174 @@
package google
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"gogs.mikescher.com/BlackForestBytes/goext/langext"
"gogs.mikescher.com/BlackForestBytes/goext/timeext"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type GoogleOAuth2 struct {
client *http.Client
scopes []string
tokenURL string
privateKeyID string
clientMail string
currToken *string
tokenExpiry *time.Time
privateKey *rsa.PrivateKey
}
func NewAuth(tokenURL string, privKeyID string, cmail string, pemstr string) (*GoogleOAuth2, error) {
pkey, err := decodePemKey(pemstr)
if err != nil {
return nil, err
}
return &GoogleOAuth2{
client: &http.Client{Timeout: 3 * time.Second},
tokenURL: tokenURL,
privateKey: pkey,
privateKeyID: privKeyID,
clientMail: cmail,
scopes: []string{
"https://www.googleapis.com/auth/androidpublisher",
},
}, nil
}
func decodePemKey(pemstr string) (*rsa.PrivateKey, error) {
var raw []byte
block, _ := pem.Decode([]byte(pemstr))
if block != nil {
raw = block.Bytes
} else {
raw = []byte(pemstr)
}
pkey8, err1 := x509.ParsePKCS8PrivateKey(raw)
if err1 == nil {
privkey, ok := pkey8.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("private key is invalid")
}
return privkey, nil
}
pkey1, err2 := x509.ParsePKCS1PrivateKey(raw)
if err2 == nil {
return pkey1, nil
}
return nil, errors.New(fmt.Sprintf("failed to parse private-key: [ %v | %v ]", err1, err2))
}
func (a *GoogleOAuth2) Token(ctx context.Context) (string, error) {
if a.currToken == nil || a.tokenExpiry == nil || a.tokenExpiry.Before(time.Now()) {
err := a.Refresh(ctx)
if err != nil {
return "", err
}
}
return *a.currToken, nil
}
func (a *GoogleOAuth2) Refresh(ctx context.Context) error {
assertion, err := a.encodeAssertion(a.privateKey)
if err != nil {
return err
}
body := url.Values{
"assertion": []string{assertion},
"grant_type": []string{"urn:ietf:params:oauth:grant-type:jwt-bearer"},
}.Encode()
req, err := http.NewRequestWithContext(ctx, "POST", a.tokenURL, strings.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
reqNow := time.Now()
resp, err := a.client.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if bstr, err := io.ReadAll(resp.Body); err == nil {
return errors.New(fmt.Sprintf("Auth-Request returned %d: %s", resp.StatusCode, string(bstr)))
} else {
return errors.New(fmt.Sprintf("Auth-Request returned %d", resp.StatusCode))
}
}
respBodyBin, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var respBody struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(respBodyBin, &respBody); err != nil {
return err
}
a.currToken = langext.Ptr(respBody.AccessToken)
a.tokenExpiry = langext.Ptr(reqNow.Add(timeext.FromSeconds(respBody.ExpiresIn)))
return nil
}
func (a *GoogleOAuth2) encodeAssertion(key *rsa.PrivateKey) (string, error) {
headBin, err := json.Marshal(gin.H{"alg": "RS256", "typ": "JWT", "kid": a.privateKeyID})
if err != nil {
return "", err
}
head := base64.RawURLEncoding.EncodeToString(headBin)
now := time.Now().Add(-10 * time.Second) // jwt hack against unsynced clocks
claimBin, err := json.Marshal(gin.H{"iss": a.clientMail, "scope": strings.Join(a.scopes, " "), "aud": a.tokenURL, "exp": now.Add(time.Hour).Unix(), "iat": now.Unix()})
if err != nil {
return "", err
}
claim := base64.RawURLEncoding.EncodeToString(claimBin)
checksum := sha256.New()
checksum.Write([]byte(head + "." + claim))
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, checksum.Sum(nil))
if err != nil {
return "", err
}
return head + "." + claim + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}

View File

@ -5,6 +5,7 @@ import (
"blackforestbytes.com/simplecloudnotifier/api/apierr" "blackforestbytes.com/simplecloudnotifier/api/apierr"
"blackforestbytes.com/simplecloudnotifier/common/ginresp" "blackforestbytes.com/simplecloudnotifier/common/ginresp"
"blackforestbytes.com/simplecloudnotifier/db" "blackforestbytes.com/simplecloudnotifier/db"
"blackforestbytes.com/simplecloudnotifier/google"
"blackforestbytes.com/simplecloudnotifier/models" "blackforestbytes.com/simplecloudnotifier/models"
"blackforestbytes.com/simplecloudnotifier/push" "blackforestbytes.com/simplecloudnotifier/push"
"context" "context"
@ -24,13 +25,14 @@ import (
) )
type Application struct { type Application struct {
Config scn.Config Config scn.Config
Gin *gin.Engine Gin *gin.Engine
Database *db.Database Database *db.Database
Firebase push.NotificationClient Firebase push.NotificationClient
Jobs []Job AndroidPublisher google.AndroidPublisherClient
stopChan chan bool Jobs []Job
Port string stopChan chan bool
Port string
} }
func NewApp(db *db.Database) *Application { func NewApp(db *db.Database) *Application {
@ -40,10 +42,11 @@ func NewApp(db *db.Database) *Application {
} }
} }
func (app *Application) Init(cfg scn.Config, g *gin.Engine, fb push.NotificationClient, jobs []Job) { func (app *Application) Init(cfg scn.Config, g *gin.Engine, fb push.NotificationClient, apc google.AndroidPublisherClient, jobs []Job) {
app.Config = cfg app.Config = cfg
app.Gin = g app.Gin = g
app.Firebase = fb app.Firebase = fb
app.AndroidPublisher = apc
app.Jobs = jobs app.Jobs = jobs
} }
@ -142,8 +145,41 @@ func (app *Application) QuotaMax(ispro bool) int {
} }
} }
func (app *Application) VerifyProToken(token string) (bool, error) { func (app *Application) VerifyProToken(ctx *AppContext, token string) (bool, error) {
return false, nil //TODO implement pro verification if strings.HasPrefix(token, "ANDROID|v2|") {
subToken := token[len("ANDROID|v2|"):]
return app.VerifyAndroidProToken(ctx, subToken)
}
if strings.HasPrefix(token, "IOS|v2|") {
subToken := token[len("IOS|v2|"):]
return app.VerifyIOSProToken(ctx, subToken)
}
return false, nil
}
func (app *Application) VerifyAndroidProToken(ctx *AppContext, token string) (bool, error) {
purchase, err := app.AndroidPublisher.GetProductPurchase(ctx, app.Config.GooglePackageName, app.Config.GoogleProProductID, token)
if err != nil {
return false, err
}
if purchase == nil {
return false, nil
}
if purchase.PurchaseState == nil {
return false, nil
}
if *purchase.PurchaseState != google.PurchaseStatePurchased {
return false, nil
}
return true, nil
}
func (app *Application) VerifyIOSProToken(ctx *AppContext, token string) (bool, error) {
return false, nil //TODO IOS
} }
func (app *Application) Migrate() error { func (app *Application) Migrate() error {

View File

@ -8,8 +8,8 @@ import (
type DummyConnector struct{} type DummyConnector struct{}
func NewDummy() (NotificationClient, error) { func NewDummy() NotificationClient {
return &DummyConnector{}, nil return &DummyConnector{}
} }
func (d DummyConnector) SendNotification(ctx context.Context, client models.Client, msg models.Message) (string, error) { func (d DummyConnector) SendNotification(ctx context.Context, client models.Client, msg models.Message) (string, error) {

View File

@ -213,7 +213,7 @@
} }
}, },
"/api/db-test": { "/api/db-test": {
"get": { "post": {
"tags": [ "tags": [
"Common" "Common"
], ],
@ -1013,7 +1013,7 @@
} }
} }
}, },
"/api/users/": { "/api/users": {
"post": { "post": {
"tags": [ "tags": [
"API-v2" "API-v2"
@ -2366,12 +2366,6 @@
}, },
"handler.CreateUser.body": { "handler.CreateUser.body": {
"type": "object", "type": "object",
"required": [
"agent_model",
"agent_version",
"client_type",
"fcm_token"
],
"properties": { "properties": {
"agent_model": { "agent_model": {
"type": "string" "type": "string"
@ -2385,6 +2379,9 @@
"fcm_token": { "fcm_token": {
"type": "string" "type": "string"
}, },
"no_client": {
"type": "boolean"
},
"pro_token": { "pro_token": {
"type": "string" "type": "string"
}, },

View File

@ -71,15 +71,12 @@ definitions:
type: string type: string
fcm_token: fcm_token:
type: string type: string
no_client:
type: boolean
pro_token: pro_token:
type: string type: string
username: username:
type: string type: string
required:
- agent_model
- agent_version
- client_type
- fcm_token
type: object type: object
handler.DatabaseTest.response: handler.DatabaseTest.response:
properties: properties:
@ -615,7 +612,7 @@ paths:
tags: tags:
- API-v1 - API-v1
/api/db-test: /api/db-test:
get: post:
operationId: api-common-dbtest operationId: api-common-dbtest
responses: responses:
"200": "200":
@ -1162,7 +1159,7 @@ paths:
summary: Upgrade a free account to a paid account summary: Upgrade a free account to a paid account
tags: tags:
- API-v1 - API-v1
/api/users/: /api/users:
post: post:
operationId: api-user-create operationId: api-user-create
parameters: parameters:

View File

@ -66,8 +66,6 @@ func NewSimpleWebserver() (*logic.Application, func()) {
panic(err) panic(err)
} }
//dbfile := "/home/mike/Code/private/SimpleCloudNotifier/server/.run-data/db_test.sqlite3"
fmt.Println("DatabaseFile: " + dbfile) fmt.Println("DatabaseFile: " + dbfile)
conf := scn.Config{ conf := scn.Config{
@ -98,8 +96,10 @@ func NewSimpleWebserver() (*logic.Application, func()) {
nc := push.NewTestSink() nc := push.NewTestSink()
apc := google.NewDummy()
jobRetry := jobs.NewDeliveryRetryJob(app) jobRetry := jobs.NewDeliveryRetryJob(app)
app.Init(conf, ginengine, nc, []logic.Job{jobRetry}) app.Init(conf, ginengine, nc, apc, []logic.Job{jobRetry})
router.Init(ginengine) router.Init(ginengine)

View File

@ -25,7 +25,7 @@
<a tabindex="-1" href="/" class="linkcaption"><h1>Simple Cloud Notifier</h1></a> <a tabindex="-1" href="/" class="linkcaption"><h1>Simple Cloud Notifier</h1></a>
<p>Get your user-id and user-key from the app and send notifications to your phone by performing a POST request against <code>https://simplecloudnotifier.blackforestbytes.com/</code></p> <p>Get your user-id and user-key from the android or iOS app.<br/>And send notifications to your phone by performing a POST request against <code>{{config|baseURL}}/</code> from anywhere</p>
<pre> <pre>
curl \ curl \
--data "user_id=${userid}" \ --data "user_id=${userid}" \
@ -36,14 +36,14 @@ curl \
--data "msg_id=$(uuidgen)" \ --data "msg_id=$(uuidgen)" \
--data "timestamp=$(date +%s)" \ --data "timestamp=$(date +%s)" \
--data "channel={channel_name}" \ --data "channel={channel_name}" \
https://scn.blackforestbytes.com/</pre> {{config|baseURL}}/</pre>
<p>Most parameters are optional, you can send a message with only a title (default priority and channel will be used)</p> <p>Most parameters are optional, you can send a message with only a title (default priority and channel will be used)</p>
<pre> <pre>
curl \ curl \
--data "user_id={userid}" \ --data "user_id={userid}" \
--data "user_key={userkey}" \ --data "user_key={userkey}" \
--data "title={message_title}" \ --data "title={message_title}" \
https://scn.blackforestbytes.com/</pre> {{config|baseURL}}/</pre>
<a href="/api_more" class="button bordered tertiary" style="float: right; min-width: 100px; text-align: center">More</a> <a href="/api_more" class="button bordered tertiary" style="float: right; min-width: 100px; text-align: center">More</a>

View File

@ -54,7 +54,7 @@
<h2>API Requests</h2> <h2>API Requests</h2>
<div class="section"> <div class="section">
<p> <p>
To send a new notification you send a <code>POST</code> request to the URL <code>https://scn.blackforestbytes.com/</code>. To send a new notification you send a <code>POST</code> request to the URL <code>{{config|baseURL}}/</code>.
All Parameters can either directly be submitted as URL parameters or they can be put into the POST body (either multipart/form-data or JSON). All Parameters can either directly be submitted as URL parameters or they can be put into the POST body (either multipart/form-data or JSON).
</p> </p>
<p> <p>
@ -141,7 +141,7 @@
--data "user_key={userkey}" \ --data "user_key={userkey}" \
--data "title={message_title}" \ --data "title={message_title}" \
--data "content={message_content}" \ --data "content={message_content}" \
https://scn.blackforestbytes.com/</pre> {{config|baseURL}}/</pre>
</div> </div>
<h2>Message Priority</h2> <h2>Message Priority</h2>
@ -159,7 +159,7 @@
--data "user_key={userkey}" \ --data "user_key={userkey}" \
--data "title={message_title}" \ --data "title={message_title}" \
--data "priority={0|1|2}" \ --data "priority={0|1|2}" \
https://scn.blackforestbytes.com/s</pre> {{config|baseURL}}/</pre>
</div> </div>
<h2>Channels</h2> <h2>Channels</h2>
@ -174,7 +174,7 @@
--data "user_key={userkey}" \ --data "user_key={userkey}" \
--data "title={message_title}" \ --data "title={message_title}" \
--data "channel={my_channel}" \ --data "channel={my_channel}" \
https://scn.blackforestbytes.com/s</pre> {{config|baseURL}}/</pre>
</div> </div>
<h2>Message Uniqueness</h2> <h2>Message Uniqueness</h2>
@ -194,7 +194,7 @@
--data "user_key={userkey}" \ --data "user_key={userkey}" \
--data "title={message_title}" \ --data "title={message_title}" \
--data "msg_id={message_id}" \ --data "msg_id={message_id}" \
https://scn.blackforestbytes.com/</pre> {{config|baseURL}}/</pre>
<p> <p>
Be aware that the server only saves send messages for a short amount of time. Because of that you can only use this to prevent duplicates in a short time-frame, older messages with the same ID are probably already deleted and the message will be send again. Be aware that the server only saves send messages for a short amount of time. Because of that you can only use this to prevent duplicates in a short time-frame, older messages with the same ID are probably already deleted and the message will be send again.
</p> </p>
@ -213,7 +213,7 @@
--data "user_key={userkey}" \ --data "user_key={userkey}" \
--data "title={message_title}" \ --data "title={message_title}" \
--data "timestamp={unix_timestamp}" \ --data "timestamp={unix_timestamp}" \
https://scn.blackforestbytes.com/</pre> {{config|baseURL}}/</pre>
</div> </div>
<h2>Bash script example</h2> <h2>Bash script example</h2>