From ec287ab9825983a74b6089d3e85a58783bfbea05 Mon Sep 17 00:00:00 2001 From: karb0f0s <17474471+karb0f0s@users.noreply.github.com> Date: Sat, 17 Dec 2022 19:54:18 +0300 Subject: [PATCH 1/3] Add: Dependabot --- .github/dependabot.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..d64b554 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + target-branch: "develop" + + - package-ecosystem: "nuget" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "daily" + target-branch: "develop" + ignore: + - dependency-name: "Microsoft.*" + update-types: ["version-update:semver-major"] From 8e5fd1e4fc5233c0862b7cc1626a6d66117b3518 Mon Sep 17 00:00:00 2001 From: karb0f0s <17474471+karb0f0s@users.noreply.github.com> Date: Sat, 17 Dec 2022 20:10:05 +0300 Subject: [PATCH 2/3] test ci --- .github/workflows/ci.yml | 62 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4bbc2a7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,62 @@ +# This workflow will build a .NET project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net + +name: .NET + +on: + push: + branches-ignore: [ "master" ] + pull_request: + branches-ignore: [ "master" ] + +env: + versionPrefix: 2.0.0 + versionSuffix: 'preview.1' + ciVersionSuffix: ci.${{ env.GITHUB_RUN_ID }}+git.commit.${{ env.GITHUB_SHA }} + isPreRelease: ${{ env.versionSuffix != '' }} + releaseVersion: + ciVersion: + buildConfiguration: Release + projectPath: src/Telegram.Bot.Extensions.LoginWidget/Telegram.Bot.Extensions.LoginWidget.csproj + testsProject: test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/Telegram.Bot.Extensions.LoginWidget.Tests.Unit.csproj + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - name: Test env + run: | + echo "versionPrefix: ${{ env.versionPrefix }}" + echo "versionSuffix: ${{ env.versionSuffix }}" + echo "ciVersionSuffix: ${{ env.ciVersionSuffix }}" + echo "isPreRelease: ${{ env.isPreRelease }}" + echo "releaseVersion: ${{ env.releaseVersion }}" + echo "ciVersion: ${{ env.ciVersion }}" + echo "buildConfiguration: ${{ env.buildConfiguration }}" + echo "projectPath: ${{ env.projectPath }}" + echo "testsProject: ${{ env.testsProject }}" + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7.0.x + - name: Restore dependencies + run: dotnet restore + - name: Build + run: | + dotnet build + --no-restore + --configuration ${{ env.buildConfiguration }} + -p:Version=${{ env.ciVersion }} + -p:CI_EMBED_SYMBOLS=true + ${{ env.projectPath }} + - name: Test + run: | + dotnet test + --no-restore + --verbosity normal + --configuration ${{ env.buildConfiguration }} + --logger "trx;LogFileName=testresults.trx" + ${{ env.testsProject }} From 95b0f64f5af699dd86dc0ebf97c1951adfe93eb7 Mon Sep 17 00:00:00 2001 From: karb0f0s <17474471+karb0f0s@users.noreply.github.com> Date: Sun, 18 Dec 2022 00:16:30 +0300 Subject: [PATCH 3/3] Modernize projects (#4) * Modernize projects * Add package-icon * Fix namespace conflict * Add NRT annotations * Improve Widget Generator * Fix Tests * Fix ci --- .appveyor.yml | 16 -- .editorconfig | 10 + .github/workflows/ci.yml | 86 +++++-- .gitignore | 70 +----- .travis.yml | 14 -- README.md | 5 +- Telegram.Bot.Extensions.LoginWidget.sln | 6 +- global.json | 6 + package-icon.png | Bin 0 -> 18504 bytes .../Authorization.cs | 39 ++- .../ButtonStyle.cs | 27 ++- .../LoginWidget.cs | 229 ++++++++++-------- ...Telegram.Bot.Extensions.LoginWidget.csproj | 82 +++++-- .../WidgetEmbedCodeGenerator.cs | 140 ++++++----- .../LoginWidgetTests.cs | 207 ++++++++-------- .../LoginWidgetTestsFixture.cs | 202 +++++++-------- ...t.Extensions.LoginWidget.Tests.Unit.csproj | 15 +- .../WidgetTests.cs | 30 +++ 18 files changed, 671 insertions(+), 513 deletions(-) delete mode 100644 .appveyor.yml delete mode 100644 .travis.yml create mode 100644 global.json create mode 100644 package-icon.png create mode 100644 test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/WidgetTests.cs diff --git a/.appveyor.yml b/.appveyor.yml deleted file mode 100644 index de2a6c4..0000000 --- a/.appveyor.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: '{build}' -max_jobs: 1 - -image: Visual Studio 2017 -configuration: Release - -before_build: - - nuget restore -verbosity quiet - -build: - project: Telegram.Bot.Extensions.LoginWidget.sln - verbosity: minimal - -test_script: - - cd test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit - - dotnet test -c Release --no-build diff --git a/.editorconfig b/.editorconfig index 379933f..b69f6a0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,6 +11,15 @@ indent_style = space indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true +max_line_length = 120 + +# C# Files +[*.cs] +csharp_align_multiline_parameter = true +csharp_align_multiline_extends_list = true +csharp_align_linq_query = true +csharp_place_attribute_on_same_line = false +csharp_empty_block_style = together # Solution Files [*.sln] @@ -27,3 +36,4 @@ indent_size = 2 # Markdown Files [*.md] trim_trailing_whitespace = false +indent_size = 2 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bbc2a7..708cfcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ # This workflow will build a .NET project # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net -name: .NET +name: Run CI on: push: @@ -12,11 +12,9 @@ on: env: versionPrefix: 2.0.0 versionSuffix: 'preview.1' - ciVersionSuffix: ci.${{ env.GITHUB_RUN_ID }}+git.commit.${{ env.GITHUB_SHA }} - isPreRelease: ${{ env.versionSuffix != '' }} - releaseVersion: - ciVersion: buildConfiguration: Release + netSdkVersion: 7.0.x + ciVersionSuffix: ci.$GITHUB_RUN_ID+git.commit.$GITHUB_SHA projectPath: src/Telegram.Bot.Extensions.LoginWidget/Telegram.Bot.Extensions.LoginWidget.csproj testsProject: test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/Telegram.Bot.Extensions.LoginWidget.Tests.Unit.csproj @@ -26,6 +24,20 @@ jobs: runs-on: ubuntu-latest steps: + - name: Set env + run: | + echo "isPreRelease=${{ env.versionSuffix != '' }}" >> $GITHUB_ENV + if [ isPreRelease ]; then + echo "releaseVersion='${{ env.versionPrefix }}-${{ env.versionSuffix }}'" >> $GITHUB_ENV + else + echo "releaseVersion='${{ env.versionPrefix }}'" >> $GITHUB_ENV + fi + if [ isPreRelease ]; then + echo "ciVersion='${{ env.versionPrefix }}-${{ env.versionSuffix }}.${{ env.ciVersionSuffix }}'" >> $GITHUB_ENV + else + echo "ciVersion='${{ env.versionPrefix }}-${{ env.ciVersionSuffix }}'" >> $GITHUB_ENV + fi + - name: Test env run: | echo "versionPrefix: ${{ env.versionPrefix }}" @@ -37,26 +49,64 @@ jobs: echo "buildConfiguration: ${{ env.buildConfiguration }}" echo "projectPath: ${{ env.projectPath }}" echo "testsProject: ${{ env.testsProject }}" + - uses: actions/checkout@v3 + - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: 7.0.x + dotnet-version: ${{ env.netSdkVersion }} + + - uses: actions/cache@v3 + with: + path: ~/.nuget/packages + # Look to see if there is a cache hit for the corresponding requirements file + key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }} + restore-keys: | + ${{ runner.os }}-nuget + - name: Restore dependencies run: dotnet restore + - name: Build run: | - dotnet build - --no-restore - --configuration ${{ env.buildConfiguration }} - -p:Version=${{ env.ciVersion }} - -p:CI_EMBED_SYMBOLS=true - ${{ env.projectPath }} + dotnet build \ + --no-restore \ + --configuration ${{ env.buildConfiguration }} \ + -p:Version=${{ env.ciVersion }} \ + -p:CI_EMBED_SYMBOLS=true \ + ${{ env.projectPath }} + - name: Test run: | - dotnet test - --no-restore - --verbosity normal - --configuration ${{ env.buildConfiguration }} - --logger "trx;LogFileName=testresults.trx" - ${{ env.testsProject }} + dotnet test \ + --no-restore \ + --verbosity normal \ + --configuration ${{ env.buildConfiguration }} \ + --logger "trx;LogFileName=testresults.trx" \ + ${{ env.testsProject }} + + - name: Add GitHub Repo + run: | + dotnet nuget add source \ + --username USERNAME \ + --password ${{ secrets.GITHUB_TOKEN }} \ + --store-password-in-clear-text \ + --name github "https://nuget.pkg.github.com/karb0f0s/index.json" + + - name: Pack nuget + run: | + dotnet pack \ + --no-build \ + --output "packages/" \ + --configuration ${{ env.buildConfiguration }} \ + -p:Version=${{ env.ciVersion }} \ + -p:CI_EMBED_SYMBOLS=true \ + ${{ env.projectPath }} + + - name: Pack nuget + run: | + dotnet nuget push \ + packages/*.nupkg \ + --api-key ${{ secrets.PUBLISH_TOKEN }} \ + --source "github" diff --git a/.gitignore b/.gitignore index 3c4efe2..a04f350 100644 --- a/.gitignore +++ b/.gitignore @@ -17,15 +17,13 @@ [Rr]eleases/ x64/ x86/ +build/ bld/ [Bb]in/ [Oo]bj/ -[Ll]og/ # Visual Studio 2015 cache/options directory .vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ # MSTest test Results [Tt]est[Rr]esult*/ @@ -42,7 +40,6 @@ dlldata.c # DNX project.lock.json -project.fragment.lock.json artifacts/ *_i.c @@ -77,18 +74,14 @@ _Chutzpah* ipch/ *.aps *.ncb -*.opendb *.opensdf *.sdf *.cachefile -*.VC.db -*.VC.VC.opendb # Visual Studio profiler *.psess *.vsp *.vspx -*.sap # TFS 2012 Local Workspace $tf/ @@ -113,7 +106,6 @@ _TeamCity* # NCrunch _NCrunch_* .*crunch*.local.xml -nCrunchTemp_* # MightyMoose *.mm.* @@ -143,14 +135,9 @@ publish/ *.azurePubxml # TODO: Comment the next line if you want to checkin your web deploy settings # but database connection strings (with potential passwords) will be unencrypted -#*.pubxml +*.pubxml *.publishproj -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - # NuGet Packages *.nupkg # The packages folder can be ignored because of Package Restore @@ -159,23 +146,13 @@ PublishScripts/ !**/packages/build/ # Uncomment if necessary however generally it will be regenerated when needed #!**/packages/repositories.config -# NuGet v3's project.json files produces more ignoreable files -*.nuget.props -*.nuget.targets -# Microsoft Azure Build Output +# Windows Azure Build Output csx/ *.build.csdef -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files +# Windows Store app package directory AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt # Visual Studio cache files # files ending in .cache can be ignored @@ -185,19 +162,16 @@ _pkginfo.txt # Others ClientBin/ +[Ss]tyle[Cc]op.* ~$* *~ *.dbmdl *.dbproj.schemaview -*.jfm *.pfx *.publishsettings node_modules/ orleans.codegen.cs - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ +.DS_Store # RIA/Silverlight projects Generated_Code/ @@ -222,9 +196,6 @@ UpgradeLog*.htm # Microsoft Fakes FakesAssemblies/ -# GhostDoc plugin setting file -*.GhostDoc.xml - # Node.js Tools for Visual Studio .ntvs_analysis.dat @@ -234,28 +205,11 @@ FakesAssemblies/ # Visual Studio 6 workspace options file *.opt -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# JetBrains Rider -.idea/ -*.sln.iml +# Settings for different environments +appsettings.*.json -# CodeRush -.cr/ +# Documentation project +src/Telegram.Bot.Documentation/ -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc \ No newline at end of file +# Rider IDE +.idea diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 2b7b07c..0000000 --- a/.travis.yml +++ /dev/null @@ -1,14 +0,0 @@ -dist: trusty -sudo: false - -language: csharp -mono: none -dotnet: 2.1.403 - -script: > - dotnet build -c Release && - cd test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit && - dotnet test -c Release --no-build - -notifications: - email: false diff --git a/README.md b/README.md index e6935e3..5b89b54 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # Telegram bots Login Widget -[![Build Status](https://travis-ci.org/MihaZupan/Telegram.Bot.Extensions.LoginWidget.svg?branch=master)](https://travis-ci.org/MihaZupan/Telegram.Bot.Extensions.LoginWidget) -[![Build status](https://ci.appveyor.com/api/projects/status/720b19vgdhro14o5/branch/master?svg=true)](https://ci.appveyor.com/project/MihaZupan/telegram-bot-extensions-loginwidget/branch/master) +![Build Status](https://github.com/karb0f0s/Telegram.Bot.Extensions.LoginWidget/actions/workflows/ci.yml/badge.svg) + Makes it simple to validate login widget authorization hashes Built according to specifications published on [Telegram's website](https://core.telegram.org/widgets/login) ## Usage + ```c# // Parsed from the query string / from the callback object Dictionary fields = QueryStringFields; diff --git a/Telegram.Bot.Extensions.LoginWidget.sln b/Telegram.Bot.Extensions.LoginWidget.sln index 6331f3c..bf2ad1a 100644 --- a/Telegram.Bot.Extensions.LoginWidget.sln +++ b/Telegram.Bot.Extensions.LoginWidget.sln @@ -1,15 +1,13 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.27428.2043 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33209.295 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Telegram.Bot.Extensions.LoginWidget", "src\Telegram.Bot.Extensions.LoginWidget\Telegram.Bot.Extensions.LoginWidget.csproj", "{8B0BB536-17B1-43F1-A8B6-123100B8835E}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{81E021D2-D465-454A-AA5A-DA4023606926}" ProjectSection(SolutionItems) = preProject - .appveyor.yml = .appveyor.yml .editorconfig = .editorconfig - .travis.yml = .travis.yml CHANGELOG.md = CHANGELOG.md README.md = README.md EndProjectSection diff --git a/global.json b/global.json new file mode 100644 index 0000000..77c776f --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "7.0.100", + "rollForward": "latestFeature" + } +} diff --git a/package-icon.png b/package-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5eef67f6833e0094cdd1eef4a841e2ae4c74e778 GIT binary patch literal 18504 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 literal 0 HcmV?d00001 diff --git a/src/Telegram.Bot.Extensions.LoginWidget/Authorization.cs b/src/Telegram.Bot.Extensions.LoginWidget/Authorization.cs index 87299a1..a3ac6ee 100644 --- a/src/Telegram.Bot.Extensions.LoginWidget/Authorization.cs +++ b/src/Telegram.Bot.Extensions.LoginWidget/Authorization.cs @@ -1,11 +1,32 @@ -namespace Telegram.Bot.Extensions.LoginWidget +namespace Telegram.Bot.Extensions.TelegramLogin; + +/// +/// Authorization result +/// +public enum Authorization { - public enum Authorization - { - InvalidHash, - MissingFields, - InvalidAuthDateFormat, - TooOld, - Valid - } + /// + /// Error: Invalid hash + /// + InvalidHash, + + /// + /// Error: Missing fields + /// + MissingFields, + + /// + /// Error: Invalid date format + /// + InvalidAuthDateFormat, + + /// + /// Error: Too old + /// + TooOld, + + /// + /// Valid + /// + Valid, } diff --git a/src/Telegram.Bot.Extensions.LoginWidget/ButtonStyle.cs b/src/Telegram.Bot.Extensions.LoginWidget/ButtonStyle.cs index eb4b731..1b95043 100644 --- a/src/Telegram.Bot.Extensions.LoginWidget/ButtonStyle.cs +++ b/src/Telegram.Bot.Extensions.LoginWidget/ButtonStyle.cs @@ -1,9 +1,20 @@ -namespace Telegram.Bot.Extensions.LoginWidget +namespace Telegram.Bot.Extensions.TelegramLogin; + +/// +/// Widget button style +/// +public enum ButtonStyle { - public enum ButtonStyle - { - Large, - Medium, - Small - } -} \ No newline at end of file + /// + /// Large button + /// + Large, + /// + /// Medium button + /// + Medium, + /// + /// Small button + /// + Small, +} diff --git a/src/Telegram.Bot.Extensions.LoginWidget/LoginWidget.cs b/src/Telegram.Bot.Extensions.LoginWidget/LoginWidget.cs index 2068b05..8f8749e 100644 --- a/src/Telegram.Bot.Extensions.LoginWidget/LoginWidget.cs +++ b/src/Telegram.Bot.Extensions.LoginWidget/LoginWidget.cs @@ -1,130 +1,159 @@ using System; -using System.Text; using System.Collections.Generic; -using System.Security.Cryptography; +using System.Globalization; using System.Linq; +using System.Security.Cryptography; +using System.Text; -namespace Telegram.Bot.Extensions.LoginWidget +namespace Telegram.Bot.Extensions.TelegramLogin; + +/// +/// A helper class used to verify authorization data +/// +public class LoginWidget : IDisposable { /// - /// A helper class used to verify authorization data + /// How old (in seconds) can authorization attempts be to be considered valid (compared to the auth_date field) /// - public class LoginWidget : IDisposable - { - /// - /// How old (in seconds) can authorization attempts be to be considered valid (compared to the auth_date field) - /// - public long AllowedTimeOffset = 30; - - private bool _disposed = false; - private readonly HMACSHA256 _hmac; - private static readonly DateTime _unixStart = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); - - /// - /// Construct a new instance - /// - /// The bot API token used as a secret parameter when checking authorization - public LoginWidget(string token) - { - if (token == null) throw new ArgumentNullException(nameof(token)); + public long AllowedTimeOffset { get; set; } = 30; - using (SHA256 sha256 = SHA256.Create()) - { - _hmac = new HMACSHA256(sha256.ComputeHash(Encoding.ASCII.GetBytes(token))); - } - } + private bool _disposed = false; - /// - /// Checks whether the authorization data received from the user is valid - /// - /// A collection containing query string fields as sorted key-value pairs - /// - public Authorization CheckAuthorization(SortedDictionary fields) - { - if (_disposed) throw new ObjectDisposedException(nameof(LoginWidget)); - if (fields == null) throw new ArgumentNullException(nameof(fields)); - if (fields.Count < 3) return Authorization.MissingFields; - - if (!fields.ContainsKey(Field.Id) || - !fields.TryGetValue(Field.AuthDate, out string authDate) || - !fields.TryGetValue(Field.Hash, out string hash) - ) return Authorization.MissingFields; + private readonly HMACSHA256 _hmac; +#if NET6_0_OR_GREATER + private static readonly DateTime _unixStart = DateTime.UnixEpoch; +#else + private static readonly DateTime _unixStart = new(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); +#endif - if (hash.Length != 64) return Authorization.InvalidHash; + /// + /// Construct a new instance + /// + /// The bot API token used as a secret parameter when checking authorization + public LoginWidget(string token) + { + if (token == null) throw new ArgumentNullException(nameof(token)); + +#if NET6_0_OR_GREATER + _hmac = new HMACSHA256(SHA256.HashData(Encoding.ASCII.GetBytes(token))); +#else + using SHA256 sha256 = SHA256.Create(); + _hmac = new HMACSHA256(sha256.ComputeHash(Encoding.ASCII.GetBytes(token))); +#endif + } - if (!long.TryParse(authDate, out long timestamp)) - return Authorization.InvalidAuthDateFormat; + /// + /// Checks whether the authorization data received from the user is valid + /// + /// A collection containing query string fields as sorted key-value pairs + /// + public Authorization CheckAuthorization(SortedDictionary fields) + { + if (_disposed) throw new ObjectDisposedException(nameof(LoginWidget)); + if (fields == null) throw new ArgumentNullException(nameof(fields)); + if (fields.Count < 3) return Authorization.MissingFields; + + if (!fields.ContainsKey(Field.Id) || + !fields.TryGetValue(Field.AuthDate, out string? authDate) || + !fields.TryGetValue(Field.Hash, out string? hash) + ) + { + return Authorization.MissingFields; + } - if (Math.Abs(DateTime.UtcNow.Subtract(_unixStart).TotalSeconds - timestamp) > AllowedTimeOffset) - return Authorization.TooOld; + if (hash?.Length != 64) return Authorization.InvalidHash; - fields.Remove(Field.Hash); - StringBuilder dataStringBuilder = new StringBuilder(256); - foreach (var field in fields) - { - if (!string.IsNullOrEmpty(field.Value)) - { - dataStringBuilder.Append(field.Key); - dataStringBuilder.Append('='); - dataStringBuilder.Append(field.Value); - dataStringBuilder.Append('\n'); - } - } - dataStringBuilder.Length -= 1; // Remove the last \n + if (!long.TryParse( + s: authDate, + style: NumberStyles.Integer, + provider: CultureInfo.InvariantCulture, + result: out long timestamp)) + { + return Authorization.InvalidAuthDateFormat; + } - byte[] signature = _hmac.ComputeHash(Encoding.UTF8.GetBytes(dataStringBuilder.ToString())); + if (Math.Abs(DateTime.UtcNow.Subtract(_unixStart).TotalSeconds - timestamp) > AllowedTimeOffset) + return Authorization.TooOld; - // Adapted from: https://stackoverflow.com/a/14333437/6845657 - for (int i = 0; i < signature.Length; i++) + fields.Remove(Field.Hash); + StringBuilder dataStringBuilder = new(256); + foreach (var field in fields) + { + if (!string.IsNullOrEmpty(field.Value)) { - if (hash[i * 2] != 87 + (signature[i] >> 4) + ((((signature[i] >> 4) - 10) >> 31) & -39)) return Authorization.InvalidHash; - if (hash[i * 2 + 1] != 87 + (signature[i] & 0xF) + ((((signature[i] & 0xF) - 10) >> 31) & -39)) return Authorization.InvalidHash; + dataStringBuilder.Append(field.Key); + dataStringBuilder.Append('='); + dataStringBuilder.Append(field.Value); + dataStringBuilder.Append('\n'); } - - return Authorization.Valid; } + --dataStringBuilder.Length; // Remove the last \n - /// - /// Checks whether the authorization data received from the user is valid - /// - /// A collection containing query string fields as key-value pairs - /// - public Authorization CheckAuthorization(Dictionary fields) + byte[] signature = _hmac.ComputeHash(Encoding.UTF8.GetBytes(dataStringBuilder.ToString())); + + // Adapted from: https://stackoverflow.com/a/14333437/6845657 + for (int i = 0; i < signature.Length; i++) { - if (fields == null) throw new ArgumentNullException(nameof(fields)); - return CheckAuthorization(new SortedDictionary(fields, StringComparer.Ordinal)); + if (hash[i * 2] != 87 + (signature[i] >> 4) + ((((signature[i] >> 4) - 10) >> 31) & -39)) return Authorization.InvalidHash; + if (hash[(i * 2) + 1] != 87 + (signature[i] & 0xF) + ((((signature[i] & 0xF) - 10) >> 31) & -39)) return Authorization.InvalidHash; } - /// - /// Checks whether the authorization data received from the user is valid - /// - /// A collection containing query string fields as key-value pairs - /// - public Authorization CheckAuthorization(IEnumerable> fields) => - CheckAuthorization(fields?.ToDictionary(f => f.Key, f => f.Value, StringComparer.Ordinal)); - - /// - /// Checks whether the authorization data received from the user is valid - /// - /// A collection containing query string fields as key-value pairs - /// - public Authorization CheckAuthorization(IEnumerable> fields) => - CheckAuthorization(fields?.ToDictionary(f => f.Item1, f => f.Item2, StringComparer.Ordinal)); - - public void Dispose() + return Authorization.Valid; + } + + /// + /// Checks whether the authorization data received from the user is valid + /// + /// A collection containing query string fields as key-value pairs + /// + public Authorization CheckAuthorization(IDictionary fields) + { + if (fields == null) throw new ArgumentNullException(nameof(fields)); + return CheckAuthorization(new SortedDictionary(fields, StringComparer.Ordinal)); + } + + /// + /// Checks whether the authorization data received from the user is valid + /// + /// A collection containing query string fields as key-value pairs + /// + public Authorization CheckAuthorization(IEnumerable> fields) => + CheckAuthorization(fields.ToDictionary(f => f.Key, f => f.Value, StringComparer.Ordinal)); + + /// + /// Checks whether the authorization data received from the user is valid + /// + /// A collection containing query string fields as key-value pairs + /// + public Authorization CheckAuthorization(IEnumerable> fields) => + CheckAuthorization(fields.ToDictionary(f => f.Item1, f => f.Item2, StringComparer.Ordinal)); + + /// + protected virtual void Dispose(bool disposing) + { + if (!_disposed) { - if (!_disposed) + if (disposing) { - _disposed = true; _hmac?.Dispose(); } - } - private static class Field - { - public const string AuthDate = "auth_date"; - public const string Id = "id"; - public const string Hash = "hash"; + _disposed = true; } } + + /// + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + private static class Field + { + public const string AuthDate = "auth_date"; + public const string Id = "id"; + public const string Hash = "hash"; + } } diff --git a/src/Telegram.Bot.Extensions.LoginWidget/Telegram.Bot.Extensions.LoginWidget.csproj b/src/Telegram.Bot.Extensions.LoginWidget/Telegram.Bot.Extensions.LoginWidget.csproj index 8a3db79..be28dc5 100644 --- a/src/Telegram.Bot.Extensions.LoginWidget/Telegram.Bot.Extensions.LoginWidget.csproj +++ b/src/Telegram.Bot.Extensions.LoginWidget/Telegram.Bot.Extensions.LoginWidget.csproj @@ -1,25 +1,69 @@ - netstandard2.0 - Telegram.Bot.Extensions.LoginWidget - Telegram.Bot.Extensions.LoginWidget - latest - Copyright © github.com/TelegramBots team 2018 - true - Allows you to generate embed JavaScript for the Telegram login widget and verify the hashes received. - https://raw.githubusercontent.com/TelegramBots/Telegram.Bot.Extensions.LoginWidget/master/LICENSE - github.com/TelegramBots - MihaZupan,TelegramBots - https://github.com/TelegramBots/Telegram.Bot.Extensions.LoginWidget - https://github.com/TelegramBots/Telegram.Bot.Extensions.LoginWidget.git - Telegram;Bot;Api;Telegram-login;Login-widget; - https://telegram.org/img/t_logo.png - Telegram.Bot.Extensions.LoginWidget - Telegram.Bot.Extensions.LoginWidget - 1.2.0 - 1.2.0.0 - 1.2.0.0 + netstandard2.0;net6.0 + 11 + enable + 7 + True + AllEnabledByDefault + latest-recommended + True + + True + True + true + true + Telegram Bot Login Widget + + Allows you to generate embed JavaScript for the Telegram login widget and verify the hashes received. + + Telegram.Bot.Extensions.LoginWidget + MihaZupan,TelegramBots + Copyright © github.com/TelegramBots team 2018 + package-icon.png + https://github.com/TelegramBots/Telegram.Bot.Extensions.LoginWidget + MIT + https://github.com/TelegramBots/Telegram.Bot.Extensions.LoginWidget.git + Telegram;Bot;Api;Telegram-login;Login-widget; + + $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb + + + + + + + true + / + + + + + $(NoWarn);MA0046;MA0048 + + + + + + true + true + + + + + + + + diff --git a/src/Telegram.Bot.Extensions.LoginWidget/WidgetEmbedCodeGenerator.cs b/src/Telegram.Bot.Extensions.LoginWidget/WidgetEmbedCodeGenerator.cs index 28303f1..35ebc2f 100644 --- a/src/Telegram.Bot.Extensions.LoginWidget/WidgetEmbedCodeGenerator.cs +++ b/src/Telegram.Bot.Extensions.LoginWidget/WidgetEmbedCodeGenerator.cs @@ -1,66 +1,94 @@ -namespace Telegram.Bot.Extensions.LoginWidget +using System.Text; + +namespace Telegram.Bot.Extensions.TelegramLogin; + +/// +/// Generates JavaScript embed code matching the one found on https://core.telegram.org/widgets/login +/// +public sealed class WidgetEmbedCodeGenerator { /// - /// Generates JavaScript embed code matching the one found on https://core.telegram.org/widgets/login + /// Defaults to 5 + /// + public static int LoginWidgetJsVersion { get; set; } = 5; + + private WidgetEmbedCodeGenerator() { } + + /// + /// Generate the embed code that uses a callback function to signal user login + /// + /// Name of your Telegram bot + /// Name of the callback function (ex. onUserLogin) + /// Name of the parameter in the callback function (ex. user -> onUserLogin(user)) + /// Size of the login button + /// Show to user photo next to the login button + /// Request access for your bot to message the user + /// + public static string GenerateCallbackEmbedCode( + string botName, + string callbackFunctionName, + string callbackParameterName, + ButtonStyle buttonStyle = ButtonStyle.Large, + bool showUserPhoto = true, + bool requestAccess = true) + { + return GenerateBaseEmbedCode( + botName: botName, + buttonStyle: buttonStyle, + showUserPhoto: showUserPhoto, + requestAccess: requestAccess, + data_auth: $""" + data-onauth="{callbackFunctionName}({callbackParameterName})" + """); + } + + /// + /// Generate the embed code that redirects you to the url you specify with parameters in the query string /// - public class WidgetEmbedCodeGenerator + /// Name of your Telegram bot + /// The url to redirect the user to on login + /// Size of the login button + /// Show to user photo next to the login button + /// Request access for your bot to message the user + /// + public static string GenerateRedirectEmbedCode( + string botName, + string redirectUrl, + ButtonStyle buttonStyle = ButtonStyle.Large, + bool showUserPhoto = true, + bool requestAccess = true) + { + return GenerateBaseEmbedCode( + botName: botName, + buttonStyle: buttonStyle, + showUserPhoto: showUserPhoto, + requestAccess: requestAccess, + data_auth: $""" + data-auth-url="{redirectUrl}" + """); + } + + private static string GenerateBaseEmbedCode( + string botName, + ButtonStyle buttonStyle, + bool showUserPhoto, + bool requestAccess, + string data_auth) { - /// - /// Defaults to 5 - /// - public static int LoginWidgetJsVersion = 5; + StringBuilder sb = new StringBuilder() + .Append(""); - private static string GenerateBaseEmbedCode(string botName, ButtonStyle buttonStyle, bool showUserPhoto, bool requestAccess, string data_auth) - { - return string.Concat( - ""); - } + return sb.ToString(); } } diff --git a/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/LoginWidgetTests.cs b/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/LoginWidgetTests.cs index 4a7acae..8cc8ed9 100644 --- a/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/LoginWidgetTests.cs +++ b/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/LoginWidgetTests.cs @@ -1,150 +1,149 @@ using System.Collections.Generic; using Xunit; -namespace Telegram.Bot.Extensions.LoginWidget.Tests.Unit +namespace Telegram.Bot.Extensions.TelegramLogin.Tests.Unit; + +public class LoginWidgetTests : IClassFixture { - public class LoginWidgetTests : IClassFixture - { - private readonly LoginWidgetTestsFixture _fixture; + private readonly LoginWidgetTestsFixture _fixture; - private readonly LoginWidget _loginWidget; + private readonly LoginWidget _loginWidget; - public LoginWidgetTests(LoginWidgetTestsFixture fixture) - { - _fixture = fixture; + public LoginWidgetTests(LoginWidgetTestsFixture fixture) + { + _fixture = fixture; - _loginWidget = new LoginWidget(_fixture.Token) - { - AllowedTimeOffset = 60 - }; - } + _loginWidget = new LoginWidget(_fixture.Token) + { + AllowedTimeOffset = 60 + }; + } - [Fact] - public void Detect_MissingField_AuthDate() + [Fact] + public void Detect_MissingField_AuthDate() + { + Dictionary fields = new() { - Dictionary fields = new Dictionary() - { - { "id", string.Empty }, - { "hash", string.Empty } - }; + { "id", string.Empty }, + { "hash", string.Empty } + }; - Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); + Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); - Assert.Equal(Authorization.MissingFields, authorizationResult); - } + Assert.Equal(Authorization.MissingFields, authorizationResult); + } - [Fact] - public void Detect_MissingField_Id() + [Fact] + public void Detect_MissingField_Id() + { + Dictionary fields = new() { - Dictionary fields = new Dictionary() - { - { "auth_date", string.Empty }, - { "hash", string.Empty } - }; + { "auth_date", string.Empty }, + { "hash", string.Empty } + }; - Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); + Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); - Assert.Equal(Authorization.MissingFields, authorizationResult); - } + Assert.Equal(Authorization.MissingFields, authorizationResult); + } - [Fact] - public void Detect_MissingField_Hash() + [Fact] + public void Detect_MissingField_Hash() + { + Dictionary fields = new() { - Dictionary fields = new Dictionary() - { - { "auth_date", string.Empty }, - { "id", string.Empty }, - }; + { "auth_date", string.Empty }, + { "id", string.Empty }, + }; - Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); + Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); - Assert.Equal(Authorization.MissingFields, authorizationResult); - } + Assert.Equal(Authorization.MissingFields, authorizationResult); + } - [Fact] - public void Detect_InvalidFormat_AuthDate() + [Fact] + public void Detect_InvalidFormat_AuthDate() + { + Dictionary fields = new() { - Dictionary fields = new Dictionary() - { - { "auth_date", "Not a number" }, - { "id", string.Empty }, - { "hash", "d5e0dfc1d85d8e0488647a8e62adc55bcf49a8ef598a446f42186b646f35728e" } - }; + { "auth_date", "Not a number" }, + { "id", string.Empty }, + { "hash", "d5e0dfc1d85d8e0488647a8e62adc55bcf49a8ef598a446f42186b646f35728e" } + }; - Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); + Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); - Assert.Equal(Authorization.InvalidAuthDateFormat, authorizationResult); - } + Assert.Equal(Authorization.InvalidAuthDateFormat, authorizationResult); + } - [Fact] - public void Detect_TooOldAuthorization() + [Fact] + public void Detect_TooOldAuthorization() + { + Dictionary fields = new() { - Dictionary fields = new Dictionary() - { - // Test with January 1st 1970 - { "auth_date", "0" }, - { "id", string.Empty }, - { "hash", "d5e0dfc1d85d8e0488647a8e62adc55bcf49a8ef598a446f42186b646f35728e" } - }; + // Test with January 1st 1970 + { "auth_date", "0" }, + { "id", string.Empty }, + { "hash", "d5e0dfc1d85d8e0488647a8e62adc55bcf49a8ef598a446f42186b646f35728e" } + }; - Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); + Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); - Assert.Equal(Authorization.TooOld, authorizationResult); - } + Assert.Equal(Authorization.TooOld, authorizationResult); + } - [Fact] - public void AllowedTimeOffset_Respected() + [Fact] + public void AllowedTimeOffset_Respected() + { + Dictionary fields = new() { - Dictionary fields = new Dictionary() - { - { "auth_date", _fixture.CurrentTimestamp }, - { "id", string.Empty }, - { "hash", "d5e0dfc1d85d8e0488647a8e62adc55bcf49a8ef598a446f42186b646f35728e" } - }; + { "auth_date", _fixture.CurrentTimestamp }, + { "id", string.Empty }, + { "hash", "d5e0dfc1d85d8e0488647a8e62adc55bcf49a8ef598a446f42186b646f35728e" } + }; - Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); + Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); - Assert.NotEqual(Authorization.TooOld, authorizationResult); - } + Assert.NotEqual(Authorization.TooOld, authorizationResult); + } - [Fact] - public void Recognises_Valid_Authorization() + [Fact] + public void Recognises_Valid_Authorization() + { + // ValidTests contains valid test data generated using the TestBotToken + foreach (SortedDictionary fields in _fixture.ValidTests) { - // ValidTests contains valid test data generated using the TestBotToken - foreach (SortedDictionary fields in _fixture.ValidTests) - { - Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); + Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); - Assert.Equal(Authorization.Valid, authorizationResult); - } + Assert.Equal(Authorization.Valid, authorizationResult); } + } - [Fact] - public void Recognises_Invalid_Authorization() + [Fact] + public void Recognises_Invalid_Authorization() + { + // InvalidTests contains invalid test data generated using the TestBotToken + foreach (SortedDictionary fields in _fixture.InvalidTests) { - // InvalidTests contains invalid test data generated using the TestBotToken - foreach (SortedDictionary fields in _fixture.InvalidTests) - { - Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); + Authorization authorizationResult = _loginWidget.CheckAuthorization(fields); - Assert.Equal(Authorization.InvalidHash, authorizationResult); - } + Assert.Equal(Authorization.InvalidHash, authorizationResult); } + } - [Fact] - public void Real_Data_Valid() + [Fact] + public void Real_Data_Valid() + { + LoginWidget loginWidget = new(LoginWidgetTestsFixture.RealLifeDataTests_Token) { - LoginWidget loginWidget = new LoginWidget(LoginWidgetTestsFixture.RealLifeDataTests_Token) - { - AllowedTimeOffset = int.MaxValue - }; + AllowedTimeOffset = int.MaxValue + }; - foreach (SortedDictionary testData in LoginWidgetTestsFixture.RealLifeDataTests) - { - Authorization authorizationResult = loginWidget.CheckAuthorization(testData); + foreach (SortedDictionary testData in LoginWidgetTestsFixture.RealLifeDataTests) + { + Authorization authorizationResult = loginWidget.CheckAuthorization(testData); - Assert.Equal(Authorization.Valid, authorizationResult); - } + Assert.Equal(Authorization.Valid, authorizationResult); } } } diff --git a/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/LoginWidgetTestsFixture.cs b/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/LoginWidgetTestsFixture.cs index 987c8e4..6ede762 100644 --- a/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/LoginWidgetTestsFixture.cs +++ b/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/LoginWidgetTestsFixture.cs @@ -1,132 +1,136 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Security.Cryptography; using System.Text; -namespace Telegram.Bot.Extensions.LoginWidget.Tests.Unit -{ - public class LoginWidgetTestsFixture - { - private const int _testCount = 10; +namespace Telegram.Bot.Extensions.TelegramLogin.Tests.Unit; - private static readonly Random _random = new Random(); +public class LoginWidgetTestsFixture +{ + private const int _testCount = 10; - public readonly string Token = RandomString(); + public readonly string Token = RandomString(); - public readonly string CurrentTimestamp; + public readonly string CurrentTimestamp; - public const string RealLifeDataTests_Token = "324335643:AAHdDjFRqowmRegO7AHW4PzayNFzkIoMOww"; - public static readonly SortedDictionary[] RealLifeDataTests = new SortedDictionary[] + public const string RealLifeDataTests_Token = "324335643:AAHdDjFRqowmRegO7AHW4PzayNFzkIoMOww"; + public static readonly SortedDictionary[] RealLifeDataTests = + { + new() { - new SortedDictionary() - { - { "id", "168175103" }, - { "first_name", "Miha" }, - { "last_name", "Zupan" }, - { "username", "MihaZupan" }, - { "photo_url", "https://t.me/i/userpic/320/MihaZupan.jpg" }, - { "auth_date", "1540852587" }, - { "hash", "5b108abf4749846e96c4aa449eb65246c500d29cd6711463166bd2ffcf87285f" } - }, - new SortedDictionary() - { - { "id", "168175103" }, - { "first_name", "Miha" }, - { "last_name", "Zupan" }, - { "username", "MihaZupan" }, - { "photo_url", "https://t.me/i/userpic/320/MihaZupan.jpg" }, - { "auth_date", "1540852662" }, - { "hash", "2f917a0cbd0779cc1f06bc089ebc9079dc946818117d5e2e1ebfdcaa9c60d797" } - }, - new SortedDictionary() - { - { "id", "168175103" }, - { "first_name", "Miha" }, - { "last_name", "Zupan" }, - { "username", "MihaZupan" }, - { "photo_url", "https://t.me/i/userpic/320/MihaZupan.jpg" }, - { "auth_date", "1540852698" }, - { "hash", "7855000860fb319cf98c9f26456fd5b9d078d0cfef88997392334be0c1c6b10c" } - } - }; - - public readonly SortedDictionary[] ValidTests = new SortedDictionary[_testCount]; - public readonly SortedDictionary[] InvalidTests = new SortedDictionary[_testCount]; - - public LoginWidgetTestsFixture() + { "id", "168175103" }, + { "first_name", "Miha" }, + { "last_name", "Zupan" }, + { "username", "MihaZupan" }, + { "photo_url", "https://t.me/i/userpic/320/MihaZupan.jpg" }, + { "auth_date", "1540852587" }, + { "hash", "5b108abf4749846e96c4aa449eb65246c500d29cd6711463166bd2ffcf87285f" } + }, + new() + { + { "id", "168175103" }, + { "first_name", "Miha" }, + { "last_name", "Zupan" }, + { "username", "MihaZupan" }, + { "photo_url", "https://t.me/i/userpic/320/MihaZupan.jpg" }, + { "auth_date", "1540852662" }, + { "hash", "2f917a0cbd0779cc1f06bc089ebc9079dc946818117d5e2e1ebfdcaa9c60d797" } + }, + new() + { + { "id", "168175103" }, + { "first_name", "Miha" }, + { "last_name", "Zupan" }, + { "username", "MihaZupan" }, + { "photo_url", "https://t.me/i/userpic/320/MihaZupan.jpg" }, + { "auth_date", "1540852698" }, + { "hash", "7855000860fb319cf98c9f26456fd5b9d078d0cfef88997392334be0c1c6b10c" } + }, + new() { - CurrentTimestamp = ((long)DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds).ToString(); + { "id", "168175103" }, + { "first_name", "Miha" }, + { "last_name", null }, + { "username", "MihaZupan" }, + { "photo_url", "https://t.me/i/userpic/320/MihaZupan.jpg" }, + { "auth_date", "1540852698" }, + { "hash", "2093184fdda5535316db9b4d77422f8afd21023b3212d58f12c5da5b84b85fa2" } + }, + }; + + public readonly SortedDictionary[] ValidTests = new SortedDictionary[_testCount]; + public readonly SortedDictionary[] InvalidTests = new SortedDictionary[_testCount]; + + public LoginWidgetTestsFixture() + { + CurrentTimestamp = ((long)DateTime.UtcNow.Subtract(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds).ToString(CultureInfo.InvariantCulture); - using (HMACSHA256 hmac = new HMACSHA256()) - { - using (SHA256 sha256 = SHA256.Create()) - { - hmac.Key = sha256.ComputeHash(Encoding.ASCII.GetBytes(Token)); - } + using HMACSHA256 hmac = new(); + hmac.Key = SHA256.HashData(Encoding.ASCII.GetBytes(Token)); - FillValidData(hmac); - FillInvalidData(); - } - } + FillValidData(hmac); + FillInvalidData(); + } - private void FillValidData(HMACSHA256 hmac) + private void FillValidData(HMACSHA256 hmac) + { + for (int i = 0; i < _testCount; i++) { - for (int i = 0; i < _testCount; i++) + SortedDictionary fields = new() { - SortedDictionary fields = new SortedDictionary - { - { "auth_date", CurrentTimestamp }, - { "id", RandomString() }, - }; - fields.Add("hash", ComputeHash(fields, hmac)); - - ValidTests[i] = fields; - } - } + { "auth_date", CurrentTimestamp }, + { "id", RandomString() }, + }; + fields.Add("hash", ComputeHash(fields, hmac)); - private void FillInvalidData() - { - for (int i = 0; i < _testCount; i++) - { - // replace field with random data - SortedDictionary fields = new SortedDictionary - { - { "auth_date", CurrentTimestamp }, - { "id", (i % 2) == 0 ? RandomString() : ValidTests[i]["id"] }, - { "hash", (i % 2) == 1 ? RandomString(64) : ValidTests[i]["hash"] }, - { RandomString(), RandomString() } - }; - - InvalidTests[i] = fields; - } + ValidTests[i] = fields; } + } - private static string RandomString(int length = 10) + private void FillInvalidData() + { + for (int i = 0; i < _testCount; i++) { - using (RandomNumberGenerator rng = RandomNumberGenerator.Create()) + // replace field with random data + SortedDictionary fields = new() { - byte[] random = new byte[length]; - rng.GetBytes(random); - return Convert.ToBase64String(random).Substring(0, length); - } + { "auth_date", CurrentTimestamp }, + { "id", (i % 2) == 0 ? RandomString() : ValidTests[i]["id"] }, + { "hash", (i % 2) == 1 ? RandomString(64) : ValidTests[i]["hash"] }, + { RandomString(), RandomString() } + }; + + InvalidTests[i] = fields; } - - private static string ComputeHash(SortedDictionary fields, HMACSHA256 hmac) + } + + private static string RandomString(int length = 10) + { + using RandomNumberGenerator rng = RandomNumberGenerator.Create(); + byte[] random = new byte[length]; + rng.GetBytes(random); + return Convert.ToBase64String(random)[..length]; + } + + private static string ComputeHash(SortedDictionary fields, HMACSHA256 hmac) + { + fields.Remove("hash"); + StringBuilder dataStringBuilder = new(256); + foreach (KeyValuePair field in fields) { - fields.Remove("hash"); - StringBuilder dataStringBuilder = new StringBuilder(256); - foreach (KeyValuePair field in fields) + if (!string.IsNullOrEmpty(field.Value)) { dataStringBuilder.Append(field.Key); dataStringBuilder.Append('='); dataStringBuilder.Append(field.Value); dataStringBuilder.Append('\n'); } - dataStringBuilder.Length -= 1; // Remove the last \n + } + --dataStringBuilder.Length; // Remove the last \n - byte[] signature = hmac.ComputeHash(Encoding.UTF8.GetBytes(dataStringBuilder.ToString())); + byte[] signature = hmac.ComputeHash(Encoding.UTF8.GetBytes(dataStringBuilder.ToString())); - return BitConverter.ToString(signature).Replace("-", "").ToLower(); - } + return BitConverter.ToString(signature).Replace("-", "").ToLower(); } } diff --git a/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/Telegram.Bot.Extensions.LoginWidget.Tests.Unit.csproj b/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/Telegram.Bot.Extensions.LoginWidget.Tests.Unit.csproj index d43ea87..35831a6 100644 --- a/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/Telegram.Bot.Extensions.LoginWidget.Tests.Unit.csproj +++ b/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/Telegram.Bot.Extensions.LoginWidget.Tests.Unit.csproj @@ -1,16 +1,19 @@ - netcoreapp2.0 - + net6.0 + 11 + enable false - - - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/WidgetTests.cs b/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/WidgetTests.cs new file mode 100644 index 0000000..ef8cda3 --- /dev/null +++ b/test/Telegram.Bot.Extensions.LoginWidget.Tests.Unit/WidgetTests.cs @@ -0,0 +1,30 @@ +using System.Xml.Linq; +using Xunit; + +namespace Telegram.Bot.Extensions.TelegramLogin.Tests.Unit; + +public class WidgetTests +{ + [Fact] + public void Test_Generate_Callback_Embed() + { + string result = WidgetEmbedCodeGenerator.GenerateCallbackEmbedCode( + botName: "samplebot", + callbackFunctionName: "onTelegramAuth", + callbackParameterName: "user"); + + Assert.Contains("data-telegram-login=\"samplebot\"", result); + Assert.Contains("data-onauth=\"onTelegramAuth(user)\"", result); + } + + [Fact] + public void Test_Generate_Redirect_Embed() + { + string result = WidgetEmbedCodeGenerator.GenerateRedirectEmbedCode( + botName: "samplebot", + redirectUrl: "http://example.com/callback"); + + Assert.Contains("data-telegram-login=\"samplebot\"", result); + Assert.Contains("data-auth-url=\"http://example.com/callback\"", result); + } +}