From 3a0c65a849f9069095e93deaa7f13889a29c5815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Schw=C3=B6rer?= Date: Fri, 25 Nov 2022 22:42:21 +0100 Subject: [PATCH] Added google androidpublisher/v3 api to verify google purchase tokens --- server/README.md | 38 +----- server/api/handler/api.go | 8 +- server/api/handler/common.go | 6 +- server/api/handler/compat.go | 4 +- server/api/handler/website.go | 37 ++++++ server/cmd/scnserver/main.go | 16 ++- server/config.go | 204 ++++++++++++++++++------------ server/google/androidPublisher.go | 151 ++++++++++++++++++++++ server/google/client.go | 9 ++ server/google/dummy.go | 16 +++ server/google/oauth2.go | 174 +++++++++++++++++++++++++ server/logic/application.go | 56 ++++++-- server/push/dummy.go | 4 +- server/swagger/swagger.json | 13 +- server/swagger/swagger.yaml | 11 +- server/test/common_test.go | 6 +- server/website/api.html | 6 +- server/website/api_more.html | 12 +- 18 files changed, 608 insertions(+), 163 deletions(-) create mode 100644 server/google/androidPublisher.go create mode 100644 server/google/client.go create mode 100644 server/google/dummy.go create mode 100644 server/google/oauth2.go diff --git a/server/README.md b/server/README.md index a017fb9..28e93b0 100644 --- a/server/README.md +++ b/server/README.md @@ -2,51 +2,19 @@ //TODO - - Return client on register - - Register with noclient=true + - return subscribtions in list-channels (?) - migration script for existing data - 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 - dark mode toggle for html - app-store link in HTML + - route to re-check all pro-token + - tests - 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" - ] - } -} - -~~~~~~~~~~~~~~~~~~~~~~~~~~ \ No newline at end of file diff --git a/server/api/handler/api.go b/server/api/handler/api.go index 501ac49..b9a20f8 100644 --- a/server/api/handler/api.go +++ b/server/api/handler/api.go @@ -82,7 +82,7 @@ func (h APIHandler) CreateUser(g *gin.Context) ginresp.HTTPResponse { } if b.ProToken != nil { - ptok, err := h.app.VerifyProToken(*b.ProToken) + ptok, err := h.app.VerifyProToken(ctx, *b.ProToken) if err != nil { 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 { - ptok, err := h.app.VerifyProToken(*b.ProToken) + ptok, err := h.app.VerifyProToken(ctx, *b.ProToken) if err != nil { 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 // // @Param query_data query handler.ListChannelMessages.query false " " -// @Param uid path int true "UserID" -// @Param cid path int true "ChannelID" +// @Param uid path int true "UserID" +// @Param cid path int true "ChannelID" // // @Success 200 {object} handler.ListChannelMessages.response // @Failure 400 {object} ginresp.apiError diff --git a/server/api/handler/common.go b/server/api/handler/common.go index 20b8296..9ae0bf6 100644 --- a/server/api/handler/common.go +++ b/server/api/handler/common.go @@ -162,9 +162,9 @@ func (h CommonHandler) Health(g *gin.Context) ginresp.HTTPResponse { // // @Param secs path number true "sleep delay (in seconds)" // -// @Success 200 {object} handler.Sleep.response -// @Failure 400 {object} ginresp.apiError -// @Failure 500 {object} ginresp.apiError +// @Success 200 {object} handler.Sleep.response +// @Failure 400 {object} ginresp.apiError +// @Failure 500 {object} ginresp.apiError // // @Router /api/sleep/{secs} [post] func (h CommonHandler) Sleep(g *gin.Context) ginresp.HTTPResponse { diff --git a/server/api/handler/compat.go b/server/api/handler/compat.go index 6b654bf..1bf5b60 100644 --- a/server/api/handler/compat.go +++ b/server/api/handler/compat.go @@ -85,7 +85,7 @@ func (h CompatHandler) Register(g *gin.Context) ginresp.HTTPResponse { } if data.ProToken != nil { - ptok, err := h.app.VerifyProToken(*data.ProToken) + ptok, err := h.app.VerifyProToken(ctx, "ANDROID|v2|"+*data.ProToken) if err != nil { 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 { - ptok, err := h.app.VerifyProToken(*data.ProToken) + ptok, err := h.app.VerifyProToken(ctx, "ANDROID|v2|"+*data.ProToken) if err != nil { return ginresp.CompatAPIError(0, "Failed to query purchase status") } diff --git a/server/api/handler/website.go b/server/api/handler/website.go index 0df6148..a9923db 100644 --- a/server/api/handler/website.go +++ b/server/api/handler/website.go @@ -14,12 +14,14 @@ import ( type WebsiteHandler struct { app *logic.Application rexTemplate *regexp.Regexp + rexConfig *regexp.Regexp } func NewWebsiteHandler(app *logic.Application) WebsiteHandler { return WebsiteHandler{ app: app, 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 { 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" @@ -117,3 +134,23 @@ func (h WebsiteHandler) serveAsset(g *gin.Context, fn string, repl bool) ginresp 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 + +} diff --git a/server/cmd/scnserver/main.go b/server/cmd/scnserver/main.go index 4dbc5f7..7f52c51 100644 --- a/server/cmd/scnserver/main.go +++ b/server/cmd/scnserver/main.go @@ -6,6 +6,7 @@ import ( "blackforestbytes.com/simplecloudnotifier/common" "blackforestbytes.com/simplecloudnotifier/common/ginext" "blackforestbytes.com/simplecloudnotifier/db" + "blackforestbytes.com/simplecloudnotifier/google" "blackforestbytes.com/simplecloudnotifier/jobs" "blackforestbytes.com/simplecloudnotifier/logic" "blackforestbytes.com/simplecloudnotifier/push" @@ -38,7 +39,7 @@ func main() { var nc push.NotificationClient if conf.DummyFirebase { - nc, err = push.NewDummy() + nc = push.NewDummy() } else { nc, err = push.NewFirebaseConn(conf) 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) - app.Init(conf, ginengine, nc, []logic.Job{jobRetry}) + app.Init(conf, ginengine, nc, apc, []logic.Job{jobRetry}) router.Init(ginengine) diff --git a/server/config.go b/server/config.go index 9424749..9fcdfa3 100644 --- a/server/config.go +++ b/server/config.go @@ -8,110 +8,158 @@ import ( ) type Config struct { - Namespace string - GinDebug bool - ServerIP string - ServerPort string - DBFile string - RequestTimeout time.Duration - ReturnRawErrors bool - DummyFirebase bool - FirebaseTokenURI string - FirebaseProjectID string - FirebasePrivKeyID string - FirebaseClientMail string - FirebasePrivateKey string + Namespace string + BaseURL string + GinDebug bool + ServerIP string + ServerPort string + DBFile string + RequestTimeout time.Duration + ReturnRawErrors bool + DummyFirebase bool + FirebaseTokenURI string + FirebaseProjectID string + FirebasePrivKeyID string + FirebaseClientMail string + FirebasePrivateKey string + DummyGoogleAPI bool + GoogleAPITokenURI string + GoogleAPIPrivKeyID string + GoogleAPIClientMail string + GoogleAPIPrivateKey string + GooglePackageName string + GoogleProProductID string } var Conf Config var configLocHost = func() Config { return Config{ - Namespace: "local-host", - GinDebug: true, - ServerIP: "0.0.0.0", - ServerPort: "8080", - DBFile: ".run-data/db.sqlite3", - RequestTimeout: 16 * time.Second, - ReturnRawErrors: true, - DummyFirebase: true, - FirebaseTokenURI: "", - FirebaseProjectID: "", - FirebasePrivKeyID: "", - FirebaseClientMail: "", - FirebasePrivateKey: "", + Namespace: "local-host", + BaseURL: "http://localhost:8080", + GinDebug: true, + ServerIP: "0.0.0.0", + ServerPort: "8080", + DBFile: ".run-data/db.sqlite3", + RequestTimeout: 16 * time.Second, + ReturnRawErrors: true, + DummyFirebase: true, + FirebaseTokenURI: "", + FirebaseProjectID: "", + FirebasePrivKeyID: "", + FirebaseClientMail: "", + FirebasePrivateKey: "", + DummyGoogleAPI: true, + GoogleAPITokenURI: "", + GoogleAPIPrivKeyID: "", + GoogleAPIClientMail: "", + GoogleAPIPrivateKey: "", + GooglePackageName: "", + GoogleProProductID: "", } } var configLocDocker = func() Config { return Config{ - Namespace: "local-docker", - GinDebug: true, - ServerIP: "0.0.0.0", - ServerPort: "80", - DBFile: "/data/scn_docker.sqlite3", - RequestTimeout: 16 * time.Second, - ReturnRawErrors: true, - DummyFirebase: true, - FirebaseTokenURI: "", - FirebaseProjectID: "", - FirebasePrivKeyID: "", - FirebaseClientMail: "", - FirebasePrivateKey: "", + Namespace: "local-docker", + BaseURL: "http://localhost:8080", + GinDebug: true, + ServerIP: "0.0.0.0", + ServerPort: "80", + DBFile: "/data/scn_docker.sqlite3", + RequestTimeout: 16 * time.Second, + ReturnRawErrors: true, + DummyFirebase: true, + FirebaseTokenURI: "", + FirebaseProjectID: "", + FirebasePrivKeyID: "", + FirebaseClientMail: "", + FirebasePrivateKey: "", + DummyGoogleAPI: true, + GoogleAPITokenURI: "", + GoogleAPIPrivKeyID: "", + GoogleAPIClientMail: "", + GoogleAPIPrivateKey: "", + GooglePackageName: "", + GoogleProProductID: "", } } var configDev = func() Config { return Config{ - Namespace: "develop", - GinDebug: true, - ServerIP: "0.0.0.0", - ServerPort: "80", - DBFile: "/data/scn.sqlite3", - RequestTimeout: 16 * time.Second, - ReturnRawErrors: true, - DummyFirebase: false, - FirebaseTokenURI: "https://oauth2.googleapis.com/token", - FirebaseProjectID: confEnv("FB_PROJECTID"), - FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"), - FirebaseClientMail: confEnv("FB_CLIENTEMAIL"), - FirebasePrivateKey: confEnv("FB_PRIVATEKEY"), + Namespace: "develop", + BaseURL: confEnv("BASE_URL"), + GinDebug: true, + ServerIP: "0.0.0.0", + ServerPort: "80", + DBFile: "/data/scn.sqlite3", + RequestTimeout: 16 * time.Second, + ReturnRawErrors: true, + DummyFirebase: false, + FirebaseTokenURI: "https://oauth2.googleapis.com/token", + FirebaseProjectID: confEnv("FB_PROJECTID"), + FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"), + 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 { return Config{ - Namespace: "staging", - GinDebug: true, - ServerIP: "0.0.0.0", - ServerPort: "80", - DBFile: "/data/scn.sqlite3", - RequestTimeout: 16 * time.Second, - ReturnRawErrors: true, - DummyFirebase: false, - FirebaseTokenURI: "https://oauth2.googleapis.com/token", - FirebaseProjectID: confEnv("FB_PROJECTID"), - FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"), - FirebaseClientMail: confEnv("FB_CLIENTEMAIL"), - FirebasePrivateKey: confEnv("FB_PRIVATEKEY"), + Namespace: "staging", + BaseURL: confEnv("BASE_URL"), + GinDebug: true, + ServerIP: "0.0.0.0", + ServerPort: "80", + DBFile: "/data/scn.sqlite3", + RequestTimeout: 16 * time.Second, + ReturnRawErrors: true, + DummyFirebase: false, + FirebaseTokenURI: "https://oauth2.googleapis.com/token", + FirebaseProjectID: confEnv("FB_PROJECTID"), + FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"), + 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 { return Config{ - Namespace: "production", - GinDebug: false, - ServerIP: "0.0.0.0", - ServerPort: "80", - DBFile: "/data/scn.sqlite3", - RequestTimeout: 16 * time.Second, - ReturnRawErrors: false, - DummyFirebase: false, - FirebaseTokenURI: "https://oauth2.googleapis.com/token", - FirebaseProjectID: confEnv("FB_PROJECTID"), - FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"), - FirebaseClientMail: confEnv("FB_CLIENTEMAIL"), - FirebasePrivateKey: confEnv("FB_PRIVATEKEY"), + Namespace: "production", + BaseURL: confEnv("BASE_URL"), + GinDebug: false, + ServerIP: "0.0.0.0", + ServerPort: "80", + DBFile: "/data/scn.sqlite3", + RequestTimeout: 16 * time.Second, + ReturnRawErrors: false, + DummyFirebase: false, + FirebaseTokenURI: "https://oauth2.googleapis.com/token", + FirebaseProjectID: confEnv("FB_PROJECTID"), + FirebasePrivKeyID: confEnv("FB_PRIVATEKEYID"), + 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"), } } diff --git a/server/google/androidPublisher.go b/server/google/androidPublisher.go new file mode 100644 index 0000000..c983221 --- /dev/null +++ b/server/google/androidPublisher.go @@ -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 +} diff --git a/server/google/client.go b/server/google/client.go new file mode 100644 index 0000000..433cac0 --- /dev/null +++ b/server/google/client.go @@ -0,0 +1,9 @@ +package google + +import ( + "context" +) + +type AndroidPublisherClient interface { + GetProductPurchase(ctx context.Context, packageName string, productId string, token string) (*ProductPurchase, error) +} diff --git a/server/google/dummy.go b/server/google/dummy.go new file mode 100644 index 0000000..dde38ce --- /dev/null +++ b/server/google/dummy.go @@ -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 +} diff --git a/server/google/oauth2.go b/server/google/oauth2.go new file mode 100644 index 0000000..bd21e72 --- /dev/null +++ b/server/google/oauth2.go @@ -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 +} diff --git a/server/logic/application.go b/server/logic/application.go index 2d6d0ab..1fd9c02 100644 --- a/server/logic/application.go +++ b/server/logic/application.go @@ -5,6 +5,7 @@ import ( "blackforestbytes.com/simplecloudnotifier/api/apierr" "blackforestbytes.com/simplecloudnotifier/common/ginresp" "blackforestbytes.com/simplecloudnotifier/db" + "blackforestbytes.com/simplecloudnotifier/google" "blackforestbytes.com/simplecloudnotifier/models" "blackforestbytes.com/simplecloudnotifier/push" "context" @@ -24,13 +25,14 @@ import ( ) type Application struct { - Config scn.Config - Gin *gin.Engine - Database *db.Database - Firebase push.NotificationClient - Jobs []Job - stopChan chan bool - Port string + Config scn.Config + Gin *gin.Engine + Database *db.Database + Firebase push.NotificationClient + AndroidPublisher google.AndroidPublisherClient + Jobs []Job + stopChan chan bool + Port string } 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.Gin = g app.Firebase = fb + app.AndroidPublisher = apc app.Jobs = jobs } @@ -142,8 +145,41 @@ func (app *Application) QuotaMax(ispro bool) int { } } -func (app *Application) VerifyProToken(token string) (bool, error) { - return false, nil //TODO implement pro verification +func (app *Application) VerifyProToken(ctx *AppContext, token string) (bool, error) { + 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 { diff --git a/server/push/dummy.go b/server/push/dummy.go index 71afbe7..0d56216 100644 --- a/server/push/dummy.go +++ b/server/push/dummy.go @@ -8,8 +8,8 @@ import ( type DummyConnector struct{} -func NewDummy() (NotificationClient, error) { - return &DummyConnector{}, nil +func NewDummy() NotificationClient { + return &DummyConnector{} } func (d DummyConnector) SendNotification(ctx context.Context, client models.Client, msg models.Message) (string, error) { diff --git a/server/swagger/swagger.json b/server/swagger/swagger.json index aa003ff..782062d 100644 --- a/server/swagger/swagger.json +++ b/server/swagger/swagger.json @@ -213,7 +213,7 @@ } }, "/api/db-test": { - "get": { + "post": { "tags": [ "Common" ], @@ -1013,7 +1013,7 @@ } } }, - "/api/users/": { + "/api/users": { "post": { "tags": [ "API-v2" @@ -2366,12 +2366,6 @@ }, "handler.CreateUser.body": { "type": "object", - "required": [ - "agent_model", - "agent_version", - "client_type", - "fcm_token" - ], "properties": { "agent_model": { "type": "string" @@ -2385,6 +2379,9 @@ "fcm_token": { "type": "string" }, + "no_client": { + "type": "boolean" + }, "pro_token": { "type": "string" }, diff --git a/server/swagger/swagger.yaml b/server/swagger/swagger.yaml index 587b0bd..970758b 100644 --- a/server/swagger/swagger.yaml +++ b/server/swagger/swagger.yaml @@ -71,15 +71,12 @@ definitions: type: string fcm_token: type: string + no_client: + type: boolean pro_token: type: string username: type: string - required: - - agent_model - - agent_version - - client_type - - fcm_token type: object handler.DatabaseTest.response: properties: @@ -615,7 +612,7 @@ paths: tags: - API-v1 /api/db-test: - get: + post: operationId: api-common-dbtest responses: "200": @@ -1162,7 +1159,7 @@ paths: summary: Upgrade a free account to a paid account tags: - API-v1 - /api/users/: + /api/users: post: operationId: api-user-create parameters: diff --git a/server/test/common_test.go b/server/test/common_test.go index 3e80ece..051f9cf 100644 --- a/server/test/common_test.go +++ b/server/test/common_test.go @@ -66,8 +66,6 @@ func NewSimpleWebserver() (*logic.Application, func()) { panic(err) } - //dbfile := "/home/mike/Code/private/SimpleCloudNotifier/server/.run-data/db_test.sqlite3" - fmt.Println("DatabaseFile: " + dbfile) conf := scn.Config{ @@ -98,8 +96,10 @@ func NewSimpleWebserver() (*logic.Application, func()) { nc := push.NewTestSink() + apc := google.NewDummy() + jobRetry := jobs.NewDeliveryRetryJob(app) - app.Init(conf, ginengine, nc, []logic.Job{jobRetry}) + app.Init(conf, ginengine, nc, apc, []logic.Job{jobRetry}) router.Init(ginengine) diff --git a/server/website/api.html b/server/website/api.html index 77d42b5..366afe3 100644 --- a/server/website/api.html +++ b/server/website/api.html @@ -25,7 +25,7 @@

Simple Cloud Notifier

-

Get your user-id and user-key from the app and send notifications to your phone by performing a POST request against https://simplecloudnotifier.blackforestbytes.com/

+

Get your user-id and user-key from the android or iOS app.
And send notifications to your phone by performing a POST request against {{config|baseURL}}/ from anywhere

 curl                                           \
     --data "user_id=${userid}"                 \
@@ -36,14 +36,14 @@ curl                                           \
     --data "msg_id=$(uuidgen)"                 \
     --data "timestamp=$(date +%s)"             \
     --data "channel={channel_name}"            \
-    https://scn.blackforestbytes.com/
+ {{config|baseURL}}/

Most parameters are optional, you can send a message with only a title (default priority and channel will be used)

 curl                                          \
     --data "user_id={userid}"                 \
     --data "user_key={userkey}"               \
     --data "title={message_title}"            \
-    https://scn.blackforestbytes.com/
+ {{config|baseURL}}/ More diff --git a/server/website/api_more.html b/server/website/api_more.html index 0dfde2a..9add34a 100644 --- a/server/website/api_more.html +++ b/server/website/api_more.html @@ -54,7 +54,7 @@

API Requests

- To send a new notification you send a POST request to the URL https://scn.blackforestbytes.com/. + To send a new notification you send a POST request to the URL {{config|baseURL}}/. 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).

@@ -141,7 +141,7 @@ --data "user_key={userkey}" \ --data "title={message_title}" \ --data "content={message_content}" \ - https://scn.blackforestbytes.com/ + {{config|baseURL}}/

Message Priority

@@ -159,7 +159,7 @@ --data "user_key={userkey}" \ --data "title={message_title}" \ --data "priority={0|1|2}" \ - https://scn.blackforestbytes.com/s + {{config|baseURL}}/

Channels

@@ -174,7 +174,7 @@ --data "user_key={userkey}" \ --data "title={message_title}" \ --data "channel={my_channel}" \ - https://scn.blackforestbytes.com/s + {{config|baseURL}}/

Message Uniqueness

@@ -194,7 +194,7 @@ --data "user_key={userkey}" \ --data "title={message_title}" \ --data "msg_id={message_id}" \ - https://scn.blackforestbytes.com/ + {{config|baseURL}}/

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.

@@ -213,7 +213,7 @@ --data "user_key={userkey}" \ --data "title={message_title}" \ --data "timestamp={unix_timestamp}" \ - https://scn.blackforestbytes.com/ + {{config|baseURL}}/

Bash script example