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/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"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..708cfcb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,112 @@ +# 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: Run CI + +on: + push: + branches-ignore: [ "master" ] + pull_request: + branches-ignore: [ "master" ] + +env: + versionPrefix: 2.0.0 + versionSuffix: 'preview.1' + 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 + +jobs: + build: + + 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 }}" + 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: ${{ 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 }} + + - name: Test + run: | + 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 0000000..5eef67f Binary files /dev/null and b/package-icon.png differ 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); + } +}