Skip to content

Commit

Permalink
[410] support Garmin MFA login (philosowaffle#432)
Browse files Browse the repository at this point in the history
* [410] support Garmin MFA login

* refactor and cleaning up poc code

* some cleanup

* settings controller - add 2fa setting

* webui - manage 2fa setting

* webui - blocks sync if mfa enabled and no auth initialized yet

* background sync - wont run when MFA enabled

* garmin auth service stuff

* webui - mfa flows are working

* webui - polish

* console - polish

* docs updated

* cleanup
  • Loading branch information
philosowaffle authored Feb 12, 2023
1 parent 8b8160e commit c4096ee
Show file tree
Hide file tree
Showing 43 changed files with 1,183 additions and 433 deletions.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@

**Peloton Tag:** _#PelotonToGarmin_

Convert workout data from Peloton into a format that can be uploaded to Garmin.
Sync workouts from Peloton to Garmin.

* Fetch latest workouts from Peloton
* Bike, Tread, Rower, Meditation, Strength, Outdoor, and more
* Convert Peloton workout to a variety of formats
* Upload TCX or FIT workout to Garmin
* Avoid duplicates in Garmin
* Backup your downloaded data and converted files
* Automatically upload TCX or FIT workout to Garmin
* Convert Peloton workouts to a variety of formats for offline backup
* Earn Badges and credit for Garmin Challenges
* Counts towards VO2 Max [1]({{ site.baseurl }}{% link faq.md %}) and Training Stress Scores
* Supports Garmin accounts protected by Two Step Verification

Head on over to the [Wiki](https://philosowaffle.github.io/peloton-to-garmin) to get started!

Expand Down
1 change: 1 addition & 0 deletions configuration.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"Garmin": {
"Email": "",
"Password": "",
"TwoStepVerificationEnabled": false,
"Upload": false,
"FormatToUpload": "fit",
"UploadStrategy": 2
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration/json.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ This section provides settings related to uploading workouts to Garmin.
"Garmin": {
"Email": "[email protected]",
"Password": "garmin",
"TwoStepVerificationEnabled": false,
"Upload": false,
"FormatToUpload": "fit",
"UploadStrategy": 2
Expand All @@ -250,6 +251,7 @@ This section provides settings related to uploading workouts to Garmin.
|:-----------|:---------|:--------|:--------------------|:------------|
| Email | **yes - if Upload=true** | `null` | `Garmin Tab` | Your Garmin email used to sign in |
| Password | **yes - if Upload=true** | `null` | `Garmin Tab` | Your Garmin password used to sign in |
| TwoStepVerificationEnabled | no | `false` | `Garmin Tab` | Whether or not your Garmin account is protected by Two Step Verification |
| Upload | no | `false` | `Garmin Tab` | `true` indicates you wish downloaded workouts to be automatically uploaded to Garmin for you. |
| FormatToUpload | no | `fit` | `Garmin Tab > Advanced` | Valid values are `fit` or `tcx`. Ensure the format you specify here is also enabled in your [Format config](#format-config) |
| UploadStrategy | **yes if Upload=true** | `null` | `Garmin Tab > Advanced` | Allows configuring different upload strategies for syncing with Garmin. Valid values are `[0 - PythonAndGuploadInstalledLocally, 1 - WindowsExeBundledPython, 2 - NativeImplV1]`. See [upload strategies](#upload-strategies) for more info. |
Expand Down
8 changes: 6 additions & 2 deletions docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,18 @@ Below are a list of commonly asked questions. For even more help head on over to
1. TOC
{:toc}

## VO2 Max is not displaying
## VO2 Max and TSS

Garmin will only generate a VO2 max for your workouts if all of the following criteria are met:

1. Your personal Garmin device already supports VO2 Max Calculations
1. You have not configured a [custom device info file]({{ site.baseurl }}{% link configuration/providing-device-info.md %}) (i.e. you are using the defaults)
1. You have met all of [Garmin's VO2 requirements](https://support.garmin.com/en-SG/?faq=MyIZ05OMpu6wSl95UVUjp7) for your workout type

## Garmin Two Step Verification

Only some [install options have support]({{ site.baseurl }}{% link install/index.md %}) for Garmin Two Step Verification. In all cases, automatic-syncing is never supported when your Garmin account is protected by two step verification.

## Garmin Upload Not Working

Sometimes, auth failures with Garmin are only temporary. For this reason, a failure to upload will not kill your sync job. Instead, P2G will stage the files and try to upload them again on the next sync interval. You can also always manually upload your files, you do not need to worry about duplicates.
Expand All @@ -35,4 +39,4 @@ If the problem persists head on over to the [discussion forum](https://github.co

## My Zones are missing the range values in Garmin Connect

See [Understanding custom zones]({{ site.baseurl }}{% link configuration/json.md %}#understanding-custom-zones).
See [Understanding custom zones]({{ site.baseurl }}{% link configuration/json.md %}#understanding-custom-zones).
5 changes: 3 additions & 2 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ Convert, Backup, and Sync.

1. Syncs workout data from Peloton to Garmin Connect
1. Supports all Peloton workout types (Biking, Tread, Core, Meditation, Rower etc.)
1. Supports Garmin accounts protected by Two Step Verification
1. Syncs all available metric data from Peloton over to Garmin Connect
1. Syncs laps and target cadence
1. Synced workouts count towards Garmin Badges and Challenges
1. Synced workouts will count towards VO2 max calculations [1]({{ site.baseurl }}{% link configuration/providing-device-info.md %})
1. Synced workouts count towards VO2 Max [1]({{ site.baseurl }}{% link faq.md %}) and Training Stress Scores
1. Syncs on demand or on a schedule
1. Highly Configurable
1. Docker-ized
Expand All @@ -35,7 +36,7 @@ Convert, Backup, and Sync.
1. If you use your Garmin device to send HR data to the Peloton Bike or Tread then at the end of your workout **do not save the workout on your watch, discard it.**
1. Sync your workout with P2G
1. You can go to your computer and manually run P2G to sync recent workouts
1. **OR** You can configure P2G to run in the background on your computer, syncing workouts every hour
1. **OR** You can configure P2G to run in the background on your computer, syncing workouts once a day
1. P2G can be configured to download your workout data and save it to your computer **AND** it can automatically upload those workouts to Garmin Connect

## Screenshots
Expand Down
17 changes: 13 additions & 4 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,23 @@ nav_order: 0
Sync workouts from Peloton to Garmin.

* Fetch latest workouts from Peloton
* Convert Peloton workouts to a variety of formats
* Automatically upload workouts to Garmin Connect
* Backup your downloaded data and converted files
* Bike, Tread, Rower, Meditation, Strength, Outdoor, and more
* Automatically upload TCX or FIT workout to Garmin
* Convert Peloton workouts to a variety of formats for offline backup
* Earn Badges and credit for Garmin Challenges
* Update VO2 Max [1]({{ site.baseurl }}{% link configuration/providing-device-info.md %}) and Training Stress Scores
* Counts towards VO2 Max [1]({{ site.baseurl }}{% link faq.md %}) and Training Stress Scores
* Supports Garmin accounts protected by Two Step Verification

Head on over to the [Install]({{ site.baseurl }}{% link install/index.md %}) page to get started!

## Example Usage

1. Do a Peloton Workout!
1. If you use your Garmin device to send HR data to the Peloton Bike or Tread then at the end of your workout **do not save the workout on your watch, discard it.**
1. Sync your workout using P2G
1. You can go to your computer and manually run P2G to sync recent workouts
1. **OR** You can configure P2G to run in the background on your computer, syncing workouts once a day

![Example Cycling Workout](https://github.com/philosowaffle/peloton-to-garmin/blob/master/images/example_cycle.png?raw=true "Example Cycling Workout")

## Supported Platforms
Expand Down
2 changes: 1 addition & 1 deletion docs/install/docker-headless.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ nav_order: 2

# Docker Headless

This the original flavor of P2G. It runs without any user interface and relies on configuration from `configuration.local.json` file.
This the original flavor of P2G. It runs without any user interface and relies on configuration from `configuration.local.json` file. This method does not support Garmin accounts with Two-Step Verification enabled.

### [DockerHub](https://hub.docker.com/r/philosowaffle/peloton-to-garmin)

Expand Down
6 changes: 4 additions & 2 deletions docs/install/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ The recommended installation method is with Docker. If you're not familiar with

P2G offers two main flavors of docker images:

1. [Docker Web UI]({{ site.baseurl }}{% link install/docker-webui.md %})
1. [Docker Headless]({{ site.baseurl }}{% link install/docker-headless.md %})
| Flavor | Features | Support Garmin 2-Step Verification | Support Automatic Syncing |
|:------------------|:-----------------------------------|:--------------------------|
| [Web UI]({{ site.baseurl }}{% link install/docker-webui.md %}) | yes | only when Garmin 2fa is disabled |
| [Docker Headless]({{ site.baseurl }}{% link install/docker-headless.md %}) | no | yes |

## Image Repositories

Expand Down
2 changes: 1 addition & 1 deletion docs/install/github-action.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ has_children: false

# Github Actions

A Github Actions workflow exists that can be used to automatically sync your rides on a schedule (by default once a day)
A Github Actions workflow exists that can be used to automatically sync your rides on a schedule (by default once a day). This option does not support Garmin accounts protected by Two Step Verification.

## Getting started

Expand Down
14 changes: 7 additions & 7 deletions docs/install/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ has_children: true

P2G can be run on all major operating systems.

| Choose your system |
|:-------------------|
| [Windows](#windows-quick-start) |
| [Mac](#mac-quick-start) |
| [Linux](#linux-quick-start) |
| [I don't want to install anything](#github-actions)|
| Choose your system | Support Garmin 2-Step Verification |
|:-------------------|:-----------------------------------|
| [Windows](#windows-quick-start) | yes |
| [Mac](#mac-quick-start) | yes |
| [Linux](#linux-quick-start) | yes |
| [I don't want to install anything](#github-actions)| no |

## Windows Quick Start

Expand All @@ -34,6 +34,6 @@ P2G can be run on all major operating systems.

## GitHub Actions

If you would rather not install anything on your computer, you can run P2G using a feature called `GitHub Actions`, this will require you to create an account on `GitHub`.
If you would rather not install anything on your computer, you can run P2G using a feature called `GitHub Actions`, this will require you to create an account on `GitHub`. Additionally, your Garmin account must *not* have Two Step Verification enabled.

1. [GitHub Actions]({{ site.baseurl }}{% link install/github-action.md %})
2 changes: 1 addition & 1 deletion docs/install/windows.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ P2G provides two different versions of the executable you can choose between:
## Limitations

1. Does not truly run in the background, the program must be minimized to the the task bar if using it to automatically sync, and you must manually restart it if your computer reboots
1. No GUI - yet ;)
1. No GUI....yet ;)
105 changes: 105 additions & 0 deletions src/Api/Controllers/GarminAuthenticationController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
using Common.Dto.Api;
using Common.Helpers;
using Common.Service;
using Garmin.Auth;
using Microsoft.AspNetCore.Mvc;

namespace Api.Controllers
{
[ApiController]
[Produces("application/json")]
[Consumes("application/json")]
public class GarminAuthenticationController : Controller
{
private readonly IGarminAuthenticationService _garminAuthService;
private readonly ISettingsService _settingsService;

public GarminAuthenticationController(IGarminAuthenticationService garminAuthService, ISettingsService settingsService)
{
_garminAuthService = garminAuthService;
_settingsService = settingsService;
}

[HttpGet]
[Route("api/garminauthentication")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<GarminAuthenticationGetResponse>> GetAsync()
{
var settings = await _settingsService.GetSettingsAsync();
var auth = _settingsService.GetGarminAuthentication(settings.Garmin.Email);

var result = new GarminAuthenticationGetResponse() { IsAuthenticated = auth?.IsValid(settings) ?? false };
return Ok(result);
}

/// <summary>
/// Initializes Garmin authentication. When TwoStep verification is enabled, will perform part one of the auth
/// flow, triggering and Email or Text to the user with an MFA code. This flow will be completed by calling
/// POST api/garminauthentication/mfaToken
/// </summary>
/// <returns>201 - Garmin Authentication was successfully initialized.</returns>
/// <returns>202 - Garmin Authentication was successfully started but needs MFA token to be completed.</returns>
[HttpPost]
[Route("api/garminauthentication/signin")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status202Accepted)]
public async Task<ActionResult> SignInAsync()
{
var settings = await _settingsService.GetSettingsAsync();

if (settings.Garmin.Password.CheckIsNullOrEmpty("Garmin Password", out var result)) return result;
if (settings.Garmin.Email.CheckIsNullOrEmpty("Garmin Email", out result)) return result;

try
{
if (!settings.Garmin.TwoStepVerificationEnabled)
{
await _garminAuthService.RefreshGarminAuthenticationAsync();
return Created("api/garminauthentication", new GarminAuthenticationGetResponse() { IsAuthenticated = true });
}
else
{
var auth = await _garminAuthService.RefreshGarminAuthenticationAsync();

if (auth.AuthStage == Common.Stateful.AuthStage.NeedMfaToken)
return Accepted();

return Created("api/garminauthentication", new GarminAuthenticationGetResponse() { IsAuthenticated = true });
}
}
catch (GarminAuthenticationError gae) when (gae.Code == Code.UnexpectedMfa)
{
return BadRequest(new ErrorResponse("It looks like your account is protected by two step verification. Please enable the Two Step verification setting."));
}
catch (GarminAuthenticationError gae) when (gae.Code == Code.InvalidCredentials)
{
return Unauthorized(new ErrorResponse("Garmin authentication failed. Invalid Garmin credentials."));
}
catch (Exception e)
{
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse($"Unexpected error occurred: {e.Message}"));
}
}

[HttpPost]
[Route("api/garminauthentication/mfaToken")]
[ProducesResponseType(StatusCodes.Status201Created)]
public async Task<ActionResult> PostMfaTokenAsync([FromBody] GarminAuthenticationMfaTokenPostRequest request)
{
var settings = await _settingsService.GetSettingsAsync();

if (!settings.Garmin.TwoStepVerificationEnabled)
return BadRequest(new ErrorResponse("Garmin two step verification is not enabled in Settings."));

try
{
await _garminAuthService.CompleteMFAAuthAsync(request.MfaToken);
return Created("api/garminauthentication", new GarminAuthenticationGetResponse() { IsAuthenticated = true });
}
catch (Exception e)
{
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse($"Unexpected error occurred: {e.Message}"));
}
}
}
}
6 changes: 6 additions & 0 deletions src/Api/Controllers/SettingsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ public async Task<ActionResult<App>> AppPost([FromBody] App updatedAppSettings)
var settings = await _settingsService.GetSettingsAsync();
settings.App = updatedAppSettings;

if (settings.Garmin.TwoStepVerificationEnabled && settings.App.EnablePolling)
return new BadRequestObjectResult(new ErrorResponse($"Automatic Syncing cannot be enabled when Garmin TwoStepVerification is enabled."));

await _settingsService.UpdateSettingsAsync(settings);
var updatedSettings = await _settingsService.GetSettingsAsync();

Expand Down Expand Up @@ -177,6 +180,9 @@ public async Task<ActionResult<SettingsGarminGetResponse>> GarminPost([FromBody]
var settings = await _settingsService.GetSettingsAsync();
settings.Garmin = updatedGarminSettings.Map();

if (settings.Garmin.Upload && settings.Garmin.TwoStepVerificationEnabled && settings.App.EnablePolling)
return new BadRequestObjectResult(new ErrorResponse($"Garmin TwoStepVerification cannot be enabled while Automatic Syncing is enabled. Please disable Automatic Syncing first."));

await _settingsService.UpdateSettingsAsync(settings);
var updatedSettings = await _settingsService.GetSettingsAsync();

Expand Down
33 changes: 24 additions & 9 deletions src/Api/Controllers/SyncController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,21 @@ public SyncController(ISettingsService settingsService, ISyncService syncService
/// </summary>
/// <response code="201">The sync was successful. Returns the sync status information.</response>
/// <response code="200">This request completed, but the Sync may not have been successful. Returns the sync status information.</response>
/// <response code="400">If the request fields are invalid.</response>
/// <response code="400">If the request fields are invalid.</response>
/// <response code="401">ErrorCode.NeedToInitGarminMFAAuth - Garmin Two Factor is enabled, you must manually initialize Garmin auth prior to syncing.</response>
/// <response code="500">Unhandled exception.</response>
[HttpPost]
[ProducesResponseType(typeof(SyncPostResponse), StatusCodes.Status201Created)]
[ProducesResponseType(typeof(SyncPostResponse), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status401Unauthorized)]
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
public async Task<ActionResult<SyncPostResponse>> SyncAsync([FromBody] SyncPostRequest request)
{
if (!IsValid(request, out var result))
return result;
{
var settings = await _settingsService.GetSettingsAsync();

var (isValid, result) = await IsValidAsync(request);
if (!isValid) return result;

SyncResult syncResult = new();
try
Expand Down Expand Up @@ -96,16 +100,27 @@ public async Task<ActionResult<SyncGetResponse>> GetAsync()
return response;
}

bool IsValid(SyncPostRequest request, out ActionResult result)
async Task<(bool, ActionResult)> IsValidAsync(SyncPostRequest request)
{
result = new OkResult();
ActionResult result = new OkResult();

if (request.CheckIsNull("PostRequest", out result))
return false;
return (false, result);

var settings = await _settingsService.GetSettingsAsync();
if (settings.Garmin.Upload && settings.Garmin.TwoStepVerificationEnabled)
{
var auth = _settingsService.GetGarminAuthentication(settings.Garmin.Email);
if (auth is null || !auth.IsValid(settings))
{
result = new UnauthorizedObjectResult(new ErrorResponse("Must initialize Garmin two factor auth token before sync can be preformed.", ErrorCode.NeedToInitGarminMFAAuth));
return (false, result);
}
}

if (request.WorkoutIds.CheckDoesNotHaveAny(nameof(request.WorkoutIds), out result))
return false;
return (false, result);

return true;
return (true, result);
}
}
2 changes: 2 additions & 0 deletions src/Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Common.Stateful;
using Conversion;
using Garmin;
using Garmin.Auth;
using Microsoft.Extensions.Caching.Memory;
using Peloton;
using Peloton.AnnualChallenge;
Expand Down Expand Up @@ -78,6 +79,7 @@
builder.Services.AddSingleton<IConverter, JsonConverter>();

// GARMIN
builder.Services.AddSingleton<IGarminAuthenticationService, GarminAuthenticationService>();
builder.Services.AddSingleton<IGarminUploader, GarminUploader>();
builder.Services.AddSingleton<IGarminApiClient, Garmin.ApiClient>();

Expand Down
Loading

0 comments on commit c4096ee

Please sign in to comment.