From d790f0621660db94b7f01778b4c6ef361d19a616 Mon Sep 17 00:00:00 2001 From: Christian Beutel <> Date: Sat, 8 Feb 2025 11:48:21 +0100 Subject: [PATCH] encrypts integration secrets --- db/integrations/komoot/komoot.go | 25 +- db/integrations/strava/models.go | 7 + db/integrations/strava/strava.go | 42 ++- db/main.go | 269 +++++++++++++++++- .../integrations/komoot_settings_modal.svelte | 1 + .../integrations/strava_settings_modal.svelte | 9 +- web/src/lib/i18n/locales/de.json | 3 + web/src/lib/i18n/locales/en.json | 3 + web/src/lib/i18n/locales/es.json | 3 + web/src/lib/i18n/locales/fr.json | 3 + web/src/lib/i18n/locales/hu.json | 3 + web/src/lib/i18n/locales/it.json | 3 + web/src/lib/i18n/locales/nl.json | 3 + web/src/lib/i18n/locales/pl.json | 3 + web/src/lib/i18n/locales/pt.json | 3 + web/src/lib/i18n/locales/zh.json | 3 + web/src/lib/models/api/integration_schema.ts | 5 +- web/src/lib/models/integration.ts | 2 +- .../routes/settings/integrations/+page.svelte | 33 ++- .../callback/strava/+page.server.ts | 54 ++-- 20 files changed, 405 insertions(+), 72 deletions(-) diff --git a/db/integrations/komoot/komoot.go b/db/integrations/komoot/komoot.go index 01656a7b..d5f05979 100644 --- a/db/integrations/komoot/komoot.go +++ b/db/integrations/komoot/komoot.go @@ -4,9 +4,11 @@ import ( "bytes" "encoding/base64" "encoding/json" + "errors" "fmt" "io" "net/http" + "os" "strconv" "strings" "time" @@ -16,6 +18,7 @@ import ( "github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tools/filesystem" + "github.com/pocketbase/pocketbase/tools/security" "github.com/twpayne/go-gpx" ) @@ -26,6 +29,11 @@ func SyncKomoot(app *pocketbase.PocketBase) error { } for _, i := range integrations { + encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") + if len(encryptionKey) == 0 { + return errors.New("POCKETBASE_ENCRYPTION_KEY not set") + } + userId := i.GetString("user") komootString := i.GetString("komoot") var komootIntegration KomootIntegration @@ -35,7 +43,13 @@ func SyncKomoot(app *pocketbase.PocketBase) error { continue } k := &KomootApi{} - err = k.login(komootIntegration.Email, komootIntegration.Password) + + decryptedPassword, err := security.Decrypt(komootIntegration.Password, encryptionKey) + if err != nil { + return err + } + + err = k.Login(komootIntegration.Email, string(decryptedPassword)) if err != nil { warning := fmt.Sprintf("komoot login failed: %v\n", err) fmt.Print(warning) @@ -114,7 +128,7 @@ func sendRequest(url string, auth *BasicAuthToken) ([]byte, error) { return io.ReadAll(resp.Body) } -func (k *KomootApi) login(email, password string) error { +func (k *KomootApi) Login(email, password string) error { url := fmt.Sprintf("https://api.komoot.de/v006/account/email/%s/", email) body, err := sendRequest(url, &BasicAuthToken{email, password}) @@ -222,6 +236,11 @@ func createTrailFromTour(app *pocketbase.PocketBase, detailedTour *DetailedKomoo return err } + diffculty := detailedTour.Difficulty.Grade + if diffculty == "" { + diffculty = "easy" + } + form.LoadData(map[string]any{ "name": detailedTour.Name, "public": detailedTour.Status == "public", @@ -234,7 +253,7 @@ func createTrailFromTour(app *pocketbase.PocketBase, detailedTour *DetailedKomoo "external_id": strconv.Itoa(detailedTour.ID), "lat": detailedTour.StartPoint.Lat, "lon": detailedTour.StartPoint.Lng, - "difficulty": detailedTour.Difficulty.Grade, + "difficulty": diffculty, "category": categoryId, "waypoints": wpIds, "author": user, diff --git a/db/integrations/strava/models.go b/db/integrations/strava/models.go index df13f3b8..9c18b113 100644 --- a/db/integrations/strava/models.go +++ b/db/integrations/strava/models.go @@ -2,6 +2,13 @@ package strava import "time" +type TokenRequest struct { + ClientID int32 `json:"client_id"` + ClientSecret string `json:"client_secret"` + Code string `json:"code"` + GrantType string `json:"grant_type"` +} + type RefreshTokenRequest struct { ClientID int32 `json:"client_id"` ClientSecret string `json:"client_secret"` diff --git a/db/integrations/strava/strava.go b/db/integrations/strava/strava.go index a5e17941..82dd848f 100644 --- a/db/integrations/strava/strava.go +++ b/db/integrations/strava/strava.go @@ -3,9 +3,11 @@ package strava import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" + "os" "strconv" "time" @@ -14,6 +16,7 @@ import ( "github.com/pocketbase/pocketbase/forms" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tools/filesystem" + "github.com/pocketbase/pocketbase/tools/security" "github.com/twpayne/go-gpx" "github.com/twpayne/go-polyline" ) @@ -29,16 +32,35 @@ func SyncStrava(app *pocketbase.PocketBase) error { } for _, i := range integrations { + encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") + if len(encryptionKey) == 0 { + return errors.New("POCKETBASE_ENCRYPTION_KEY not set") + } + userId := i.GetString("user") stravaString := i.GetString("strava") var stravaIntegration StravaIntegration - json.Unmarshal([]byte(stravaString), &stravaIntegration) + err := json.Unmarshal([]byte(stravaString), &stravaIntegration) + if err != nil { + return err + } if !stravaIntegration.Active || stravaIntegration.RefreshToken == "" { continue } - r, err := refreshStravaToken(stravaIntegration.ClientID, stravaIntegration.ClientSecret, stravaIntegration.RefreshToken) + decryptedSecret, err := security.Decrypt(stravaIntegration.ClientSecret, encryptionKey) + if err != nil { + return err + } + + request := RefreshTokenRequest{ + ClientID: stravaIntegration.ClientID, + ClientSecret: string(decryptedSecret), + RefreshToken: stravaIntegration.RefreshToken, + GrantType: "refresh_token", + } + r, err := GetStravaToken(request) if err != nil { warning := fmt.Sprintf("error refreshing strava access token: %v\n", err) fmt.Print(warning) @@ -60,7 +82,10 @@ func SyncStrava(app *pocketbase.PocketBase) error { return err } i.Set("strava", string(b)) - app.Dao().SaveRecord(i) + err = app.Dao().SaveRecord(i) + if err != nil { + return err + } if stravaIntegration.Routes { page := 1 @@ -110,15 +135,10 @@ func SyncStrava(app *pocketbase.PocketBase) error { return nil } -func refreshStravaToken(clientID int32, clientSecret, refreshToken string) (*RefreshTokenResponse, error) { +func GetStravaToken(request any) (*RefreshTokenResponse, error) { const stravaTokenURL = "https://www.strava.com/oauth/token" - requestBody, err := json.Marshal(RefreshTokenRequest{ - ClientID: clientID, - ClientSecret: clientSecret, - RefreshToken: refreshToken, - GrantType: "refresh_token", - }) + requestBody, err := json.Marshal(request) if err != nil { return nil, err } @@ -137,7 +157,7 @@ func refreshStravaToken(clientID int32, clientSecret, refreshToken string) (*Ref defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to refresh token: received status %d", resp.StatusCode) + return nil, fmt.Errorf("failed to get token: received status %d", resp.StatusCode) } var tokenResponse RefreshTokenResponse diff --git a/db/main.go b/db/main.go index 2a01e9ef..2b42f05c 100644 --- a/db/main.go +++ b/db/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "log" "math/rand/v2" @@ -21,6 +22,7 @@ import ( "github.com/pocketbase/pocketbase/tools/cron" "github.com/pocketbase/pocketbase/tools/filesystem" "github.com/pocketbase/pocketbase/tools/hook" + "github.com/pocketbase/pocketbase/tools/security" "pocketbase/integrations/komoot" "pocketbase/integrations/strava" @@ -76,6 +78,12 @@ func setupEventHandlers(app *pocketbase.PocketBase, client meilisearch.ServiceMa app.OnRecordAfterCreateRequest("follows").Add(createFollowHandler(app)) app.OnRecordAfterCreateRequest("comments").Add(createCommentHandler(app)) + app.OnRecordsListRequest("integrations").Add(listIntegrationHandler()) + app.OnRecordAfterCreateRequest("integrations").Add(createIntegrationAfterHandler()) + app.OnRecordAfterUpdateRequest("integrations").Add(updateIntegrationAfterHandler()) + app.OnRecordBeforeCreateRequest("integrations").Add(createIntegrationBeforeHandler(app)) + app.OnRecordBeforeUpdateRequest("integrations").Add(updateIntegrationBeforeHandler(app)) + app.OnRecordBeforeRequestEmailChangeRequest("users").Add(changeUserEmailHandler(app)) app.OnBeforeServe().Add(onBeforeServeHandler(app, client)) } @@ -330,6 +338,139 @@ func createCommentHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateE } } +func listIntegrationHandler() func(e *core.RecordsListEvent) error { + return func(e *core.RecordsListEvent) error { + info := apis.RequestInfo(e.HttpContext) + if info.Admin != nil { + return nil + } + for _, r := range e.Records { + + err := censorIntegrationSecrets(r) + if err != nil { + return err + } + } + + return nil + } +} + +func createIntegrationBeforeHandler(app *pocketbase.PocketBase) func(e *core.RecordCreateEvent) error { + return func(e *core.RecordCreateEvent) error { + err := encryptIntegrationSecrets(app, e.Record) + if err != nil { + return err + } + return nil + } +} + +func createIntegrationAfterHandler() func(e *core.RecordCreateEvent) error { + return func(e *core.RecordCreateEvent) error { + err := censorIntegrationSecrets(e.Record) + if err != nil { + return err + } + return nil + } +} + +func updateIntegrationBeforeHandler(app *pocketbase.PocketBase) func(e *core.RecordUpdateEvent) error { + return func(e *core.RecordUpdateEvent) error { + err := encryptIntegrationSecrets(app, e.Record) + if err != nil { + return err + } + + return nil + } +} + +func updateIntegrationAfterHandler() func(e *core.RecordUpdateEvent) error { + return func(e *core.RecordUpdateEvent) error { + err := censorIntegrationSecrets(e.Record) + if err != nil { + return err + } + return nil + } +} + +func censorIntegrationSecrets(r *models.Record) error { + secrets := map[string][]string{ + "strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"}, + "komoot": {"password"}, + } + for key, secretKeys := range secrets { + if integrationString := r.GetString(key); integrationString != "" { + var integration map[string]interface{} + if err := json.Unmarshal([]byte(integrationString), &integration); err != nil { + return err + } + for _, secretKey := range secretKeys { + integration[secretKey] = "" + } + b, err := json.Marshal(integration) + if err != nil { + return err + } + r.Set(key, string(b)) + } + } + + return nil +} + +func encryptIntegrationSecrets(app *pocketbase.PocketBase, r *models.Record) error { + encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") + if len(encryptionKey) == 0 { + return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil) + } + + secrets := map[string][]string{ + "strava": {"clientSecret", "refreshToken", "accessToken", "expiresAt"}, + "komoot": {"password"}, + } + + original, _ := app.Dao().FindRecordById("integrations", r.Id) + + for key, secretKeys := range secrets { + if integrationString := r.GetString(key); integrationString != "" { + var integration map[string]interface{} + if err := json.Unmarshal([]byte(integrationString), &integration); err != nil { + return err + } + + for _, secretKey := range secretKeys { + if secret, ok := integration[secretKey].(string); ok && len(secret) > 0 { + encryptedSecret, err := security.Encrypt([]byte(secret), encryptionKey) + if err != nil { + return err + } + integration[secretKey] = encryptedSecret + } else if original != nil { + + originalString := original.GetString(key) + var originalIntegration map[string]interface{} + if err := json.Unmarshal([]byte(originalString), &originalIntegration); err != nil { + return err + } + integration[secretKey] = originalIntegration[secretKey] + } + } + + b, err := json.Marshal(integration) + if err != nil { + return err + } + r.Set(key, string(b)) + } + } + + return nil +} + func changeUserEmailHandler(app *pocketbase.PocketBase) func(e *core.RecordRequestEmailChangeEvent) error { return func(e *core.RecordRequestEmailChangeEvent) error { form := forms.NewRecordEmailChangeRequest(app, e.Record) @@ -402,22 +543,144 @@ func registerRoutes(e *core.ServeEvent, app *pocketbase.PocketBase, client meili return c.JSON(http.StatusOK, randomTrails) }) + + e.Router.POST("/integration/strava/token", func(c echo.Context) error { + encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") + if len(encryptionKey) == 0 { + return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil) + } + + var data strava.TokenRequest + if err := c.Bind(&data); err != nil { + return apis.NewBadRequestError("Failed to read request data", err) + } + + user, success := c.Get(apis.ContextAuthRecordKey).(*models.Record) + userId := "" + if success { + userId = user.Id + } + + integrations, err := app.Dao().FindRecordsByExpr("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId})) + if err != nil { + return err + } + if len(integrations) == 0 { + return apis.NewBadRequestError("user has no integration", nil) + } + integration := integrations[0] + stravaString := integration.GetString("strava") + if len(stravaString) == 0 { + return apis.NewBadRequestError("strava integration missing", nil) + } + var stravaIntegration strava.StravaIntegration + err = json.Unmarshal([]byte(stravaString), &stravaIntegration) + if err != nil { + return err + } + decryptedSecret, err := security.Decrypt(stravaIntegration.ClientSecret, encryptionKey) + if err != nil { + return err + } + + request := strava.TokenRequest{ + ClientID: stravaIntegration.ClientID, + ClientSecret: string(decryptedSecret), + Code: data.Code, + GrantType: "authorization_code", + } + r, err := strava.GetStravaToken(request) + if err != nil { + return err + } + if r.AccessToken != "" { + stravaIntegration.AccessToken = r.AccessToken + } + if r.RefreshToken != "" { + stravaIntegration.RefreshToken = r.RefreshToken + } + if r.AccessToken != "" { + stravaIntegration.ExpiresAt = r.ExpiresAt + } + + stravaIntegration.Active = true + + b, err := json.Marshal(stravaIntegration) + if err != nil { + return err + } + integration.Set("strava", string(b)) + err = app.Dao().SaveRecord(integration) + if err != nil { + return err + } + return c.JSON(http.StatusOK, nil) + }) + + e.Router.GET("/integration/komoot/login", func(c echo.Context) error { + encryptionKey := os.Getenv("POCKETBASE_ENCRYPTION_KEY") + if len(encryptionKey) == 0 { + return apis.NewBadRequestError("POCKETBASE_ENCRYPTION_KEY not set", nil) + } + + user, success := c.Get(apis.ContextAuthRecordKey).(*models.Record) + userId := "" + if success { + userId = user.Id + } + + integrations, err := app.Dao().FindRecordsByExpr("integrations", dbx.NewExp("user = {:id}", dbx.Params{"id": userId})) + if err != nil { + return err + } + if len(integrations) == 0 { + return apis.NewBadRequestError("user has no integration", nil) + } + integration := integrations[0] + komootString := integration.GetString("komoot") + if len(komootString) == 0 { + return apis.NewBadRequestError("komoot integration missing", nil) + } + var komootIntegration komoot.KomootIntegration + err = json.Unmarshal([]byte(komootString), &komootIntegration) + if err != nil { + return err + } + decryptedPassword, err := security.Decrypt(komootIntegration.Password, encryptionKey) + if err != nil { + return err + } + + k := &komoot.KomootApi{} + + err = k.Login(komootIntegration.Email, string(decryptedPassword)) + if err != nil { + return apis.NewUnauthorizedError("invalid credentials", nil) + } + + return c.JSON(http.StatusOK, nil) + }) } func registerCronJobs(app *pocketbase.PocketBase) { scheduler := cron.New() - scheduler.MustAdd("integrations", "*/5 * * * *", func() { + schedule := os.Getenv("POCKETBASE_CRON_SYNC_SCHEDULE") + if len(schedule) == 0 { + schedule = "0 2 * * *" + } + + scheduler.MustAdd("integrations", schedule, func() { err := strava.SyncStrava(app) if err != nil { warning := fmt.Sprintf("Error syncing with strava: %v", err) - fmt.Print(warning) + fmt.Println(warning) app.Logger().Error(warning) } err = komoot.SyncKomoot(app) if err != nil { warning := fmt.Sprintf("Error syncing with komoot: %v", err) - fmt.Print(warning) + fmt.Println(warning) app.Logger().Error(warning) } }) diff --git a/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte b/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte index 8bcb7b4a..6459174c 100644 --- a/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte +++ b/web/src/lib/components/settings/integrations/komoot_settings_modal.svelte @@ -60,6 +60,7 @@ > diff --git a/web/src/routes/settings/integrations/callback/strava/+page.server.ts b/web/src/routes/settings/integrations/callback/strava/+page.server.ts index 9387ccb0..ac241b16 100644 --- a/web/src/routes/settings/integrations/callback/strava/+page.server.ts +++ b/web/src/routes/settings/integrations/callback/strava/+page.server.ts @@ -1,11 +1,13 @@ +import { pb } from "$lib/pocketbase"; import { integrations_index, integrations_update } from "$lib/stores/integration_store"; import { error, redirect, type RequestEvent, type ServerLoad } from "@sveltejs/kit"; +import { ClientResponseError } from "pocketbase"; export const load: ServerLoad = async ({ url, fetch }) => { const oauthError = url.searchParams.get('error'); if (oauthError) { // user cancelled - if(oauthError == "access_denied") { + if (oauthError == "access_denied") { return redirect(302, '/settings/integrations') } return error(400, { @@ -19,41 +21,23 @@ export const load: ServerLoad = async ({ url, fetch }) => { }); } - const integrations = await integrations_index(fetch); - - if (!integrations.length || !integrations[0].strava) { - return error(400, { - message: "Missing integration record" + try { + await pb.send("/integration/strava/token", { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code, + grant_type: 'authorization_code' + }) }); + } catch (e) { + console.error(e) + + if (e instanceof ClientResponseError) { + return error(e.status, e.message); + + } + throw e } - - const integration = integrations[0] - - const tokenResponse = await fetch('https://www.strava.com/oauth/token', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - client_id: integration.strava?.clientId, - client_secret: integration.strava?.clientSecret, - code, - grant_type: 'authorization_code' - }) - }); - - if (!tokenResponse.ok) { - const r = await tokenResponse.json() - console.error(r) - return error(500, 'Failed to get access token'); - } - - const { access_token, refresh_token, expires_at } = await tokenResponse.json(); - - integration.strava!.accessToken = access_token - integration.strava!.refreshToken = refresh_token - integration.strava!.expiresAt = expires_at - integration.strava!.active = true - - await integrations_update(integration, fetch); - return redirect(302, '/settings/integrations') } \ No newline at end of file