From 05c5298d2c693f6fc11a0b280bef522dcb0ad04f Mon Sep 17 00:00:00 2001 From: Leonid Tsarev Date: Tue, 13 Aug 2024 23:05:03 +0300 Subject: [PATCH] Linking account with telegram (#2744) * add fork of Telegram.Bot.Extensions.LoginWidget * modernize Telegram.Bot.Extensions.LoginWidget * Implement linking with telegram --- Joinrpg.sln | 2 + manifests/dev/.env | 2 + manifests/prod/.env | 2 + .../20240806190058_CreateDailyJob.cs | 63 ++++++----- .../UserProfile/ManageController.cs | 37 ++++++- .../AuthenticationConfigurator.cs | 5 + .../ExternalLoginProfileExtractor.cs | 20 ++++ .../Telegram/TelegramAuthorizationResult.cs | 10 ++ .../Telegram/TelegramLoginOptions.cs | 12 ++ .../Telegram/TelegramLoginValidator.cs | 102 +++++++++++++++++ src/JoinRpg.Portal/Startup.cs | 4 +- .../Views/Manage/SetupProfile.cshtml | 45 ++++---- .../Views/Manage/TelegramLoginButton.cshtml | 12 ++ src/JoinRpg.Portal/appsettings.json | 5 + src/JoinRpg.PrimitiveTypes/TelegramId.cs | 5 + src/JoinRpg.Services.Impl/UserServiceImpl.cs | 32 +++++- .../IUserService.cs | 5 +- .../UserProfile/EditUserProfileViewModel.cs | 14 +-- .../UserProfile/UserLoginInfoViewModel.cs | 54 +++++---- .../MyUserStore.IUserLoginStore .cs | 17 ++- .../Authorization.cs | 10 ++ .../ButtonStyle.cs | 8 ++ ...Telegram.Bot.Extensions.LoginWidget.csproj | 12 ++ .../TelegramLoginOptions.cs | 12 ++ .../TelegramLoginValidator.cs | 104 ++++++++++++++++++ .../WidgetEmbedCodeGenerator.cs | 60 ++++++++++ 26 files changed, 552 insertions(+), 102 deletions(-) create mode 100644 src/JoinRpg.Portal/Infrastructure/Authentication/Telegram/TelegramAuthorizationResult.cs create mode 100644 src/JoinRpg.Portal/Infrastructure/Authentication/Telegram/TelegramLoginOptions.cs create mode 100644 src/JoinRpg.Portal/Infrastructure/Authentication/Telegram/TelegramLoginValidator.cs create mode 100644 src/JoinRpg.Portal/Views/Manage/TelegramLoginButton.cshtml create mode 100644 src/JoinRpg.PrimitiveTypes/TelegramId.cs create mode 100644 src/Telegram.Bot.Extensions.LoginWidget/Authorization.cs create mode 100644 src/Telegram.Bot.Extensions.LoginWidget/ButtonStyle.cs create mode 100644 src/Telegram.Bot.Extensions.LoginWidget/Telegram.Bot.Extensions.LoginWidget.csproj create mode 100644 src/Telegram.Bot.Extensions.LoginWidget/TelegramLoginOptions.cs create mode 100644 src/Telegram.Bot.Extensions.LoginWidget/TelegramLoginValidator.cs create mode 100644 src/Telegram.Bot.Extensions.LoginWidget/WidgetEmbedCodeGenerator.cs diff --git a/Joinrpg.sln b/Joinrpg.sln index d0605f037..a69ce8d7e 100644 --- a/Joinrpg.sln +++ b/Joinrpg.sln @@ -30,6 +30,8 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{18541948-5966-481B-B755-833AF5A7BC17}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + manifests\dev\.env = manifests\dev\.env + manifests\prod\.env = manifests\prod\.env code_placement.txt = code_placement.txt CONTRIBUTING.MD = CONTRIBUTING.MD .github\dependabot.yml = .github\dependabot.yml diff --git a/manifests/dev/.env b/manifests/dev/.env index 9469ef223..0d7838052 100644 --- a/manifests/dev/.env +++ b/manifests/dev/.env @@ -1,3 +1,5 @@ Notifications__ServiceAccountEmail=support@dev.joinrpg.ru MailGun__ApiDomain=dev.joinrpg.ru MailGun__Enabled=true +Telegram__BotName=joinrpg_bot +Telegram__BotId=6987034096 diff --git a/manifests/prod/.env b/manifests/prod/.env index b696da8ce..9ccf08820 100644 --- a/manifests/prod/.env +++ b/manifests/prod/.env @@ -1,3 +1,5 @@ Notifications__ServiceAccountEmail=support@joinrpg.ru MailGun__ApiDomain=joinrpg.ru MailGun__Enabled=true +Telegram__BotName=joinrpg_dev_bot +Telegram__BotId=7431625317 diff --git a/src/JoinRpg.Dal.JobService/Migrations/20240806190058_CreateDailyJob.cs b/src/JoinRpg.Dal.JobService/Migrations/20240806190058_CreateDailyJob.cs index 4e31d3a11..5cb3c6e35 100644 --- a/src/JoinRpg.Dal.JobService/Migrations/20240806190058_CreateDailyJob.cs +++ b/src/JoinRpg.Dal.JobService/Migrations/20240806190058_CreateDailyJob.cs @@ -3,42 +3,41 @@ #nullable disable -namespace JoinRpg.Dal.JobService.Migrations +namespace JoinRpg.Dal.JobService.Migrations; + +/// +public partial class CreateDailyJob : Migration { /// - public partial class CreateDailyJob : Migration + protected override void Up(MigrationBuilder migrationBuilder) { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "DailyJobRuns", - columns: table => new - { - DailyJobRunId = table.Column(type: "integer", nullable: false) - .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), - JobName = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), - DayOfRun = table.Column(type: "date", maxLength: 1024, nullable: false), - JobStatus = table.Column(type: "integer", nullable: false), - MachineName = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_DailyJobRuns", x => x.DailyJobRunId); - }); + migrationBuilder.CreateTable( + name: "DailyJobRuns", + columns: table => new + { + DailyJobRunId = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + JobName = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false), + DayOfRun = table.Column(type: "date", maxLength: 1024, nullable: false), + JobStatus = table.Column(type: "integer", nullable: false), + MachineName = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DailyJobRuns", x => x.DailyJobRunId); + }); - migrationBuilder.CreateIndex( - name: "IX_DailyJobRuns_JobName_DayOfRun", - table: "DailyJobRuns", - columns: new[] { "JobName", "DayOfRun" }, - unique: true); - } + migrationBuilder.CreateIndex( + name: "IX_DailyJobRuns_JobName_DayOfRun", + table: "DailyJobRuns", + columns: new[] { "JobName", "DayOfRun" }, + unique: true); + } - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "DailyJobRuns"); - } + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DailyJobRuns"); } } diff --git a/src/JoinRpg.Portal/Controllers/UserProfile/ManageController.cs b/src/JoinRpg.Portal/Controllers/UserProfile/ManageController.cs index eaf06d8f4..381eecf0b 100644 --- a/src/JoinRpg.Portal/Controllers/UserProfile/ManageController.cs +++ b/src/JoinRpg.Portal/Controllers/UserProfile/ManageController.cs @@ -1,3 +1,4 @@ +using Joinrpg.Web.Identity; using JoinRpg.Data.Interfaces; using JoinRpg.DataModel; using JoinRpg.Domain; @@ -5,6 +6,7 @@ using JoinRpg.Portal.Identity; using JoinRpg.Portal.Infrastructure; using JoinRpg.Portal.Infrastructure.Authentication; +using JoinRpg.Portal.Infrastructure.Authentication.Telegram; using JoinRpg.PrimitiveTypes; using JoinRpg.Services.Interfaces; using JoinRpg.Web.Helpers; @@ -12,6 +14,7 @@ using JoinRpg.Web.Models.UserProfile; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; namespace JoinRpg.Portal.Controllers; @@ -174,8 +177,35 @@ public async Task LinkLoginCallback() return RedirectToAction("SetupProfile"); } + public async Task LinkTelegramLoginCallback([FromServices] ICustomLoginStore loginStore, [FromServices] TelegramLoginValidator loginValidator) + { + + var dictionary = Request.Query.Select(x => x).ToDictionary(x => x.Key, x => x.Value.First() ?? ""); + var value = loginValidator.CheckAuthorization(dictionary); + + var principal = new System.Security.Claims.ClaimsPrincipal(); + + + var userId = CurrentUserAccessor.UserId; + var user = (await UserManager.FindByIdAsync(userId.ToString()))!; + + + var telegramUserId = dictionary["id"]; + + var u = await loginStore.FindByLoginAsync("telegram", telegramUserId, CancellationToken.None); + + if (u is not null) + { + return RedirectToAction("SetupProfile", new { Message = ManageMessageId.SocialLoginAlreadyLinked }); + } + await loginStore.AddCustomLoginAsync(user, telegramUserId, "telegram", CancellationToken.None); + + await externalLoginProfileExtractor.TryExtractTelegramProfile(user, dictionary); + return RedirectToAction("SetupProfile"); + } + [HttpGet] - public async Task SetupProfile(bool checkContactsMessage = false, ManageMessageId? message = null) + public async Task SetupProfile([FromServices] IOptions options, bool checkContactsMessage = false, ManageMessageId? message = null) { await avatarService.AddGrAvatarIfRequired(CurrentUserAccessor.UserId); @@ -200,20 +230,18 @@ public async Task SetupProfile(bool checkContactsMessage = false, PhoneNumber = user.Extra?.PhoneNumber ?? "", Nicknames = user.Extra?.Nicknames ?? "", GroupNames = user.Extra?.GroupNames ?? "", - Vk = user.Extra?.Vk, Livejournal = user.Extra?.Livejournal ?? "", - Telegram = user.Extra?.Telegram ?? "", Skype = user.Extra?.Skype ?? "", LastClaimId = lastClaim?.ClaimId, LastClaimProjectId = lastClaim?.ProjectId, IsVerifiedFlag = user.VerifiedProfileFlag, - IsVkVerifiedFlag = user.Extra?.VkVerified ?? false, SocialNetworkAccess = (ContactsAccessTypeView)user.GetSocialNetworkAccess(), SocialLoginStatus = user.GetSocialLogins().ToList(), Email = user.Email, HasPassword = user.PasswordHash != null, Avatars = new UserAvatarListViewModel(user), Message = message, + TelegramBotName = options.Value.BotName, }; return base.View(model); @@ -233,7 +261,6 @@ public async Task SetupProfile(EditUserProfileViewModel viewModel) new FatherName(viewModel.FatherName)), viewModel.Gender, viewModel.PhoneNumber, viewModel.Nicknames, viewModel.GroupNames, viewModel.Skype, viewModel.Livejournal, - viewModel.Telegram, (ContactsAccessType)viewModel.SocialNetworkAccess ); var userId = CurrentUserAccessor.UserId; diff --git a/src/JoinRpg.Portal/Infrastructure/Authentication/AuthenticationConfigurator.cs b/src/JoinRpg.Portal/Infrastructure/Authentication/AuthenticationConfigurator.cs index 59c28f856..aa0c63bf0 100644 --- a/src/JoinRpg.Portal/Infrastructure/Authentication/AuthenticationConfigurator.cs +++ b/src/JoinRpg.Portal/Infrastructure/Authentication/AuthenticationConfigurator.cs @@ -1,6 +1,7 @@ using System.Text; using Joinrpg.Web.Identity; using JoinRpg.Portal.Infrastructure.Authentication; +using JoinRpg.Portal.Infrastructure.Authentication.Telegram; using JoinRpg.Portal.Infrastructure.Authorization; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; @@ -26,6 +27,10 @@ public static IServiceCollection AddJoinAuth(this IServiceCollection services, .AddUserStore() .AddRoleStore(); + _ = services + .AddTransient() + .AddTransient(); + _ = services.AddAntiforgery(options => options.HeaderName = "X-CSRF-TOKEN"); _ = services.ConfigureApplicationCookie(SetCookieOptions()); diff --git a/src/JoinRpg.Portal/Infrastructure/Authentication/ExternalLoginProfileExtractor.cs b/src/JoinRpg.Portal/Infrastructure/Authentication/ExternalLoginProfileExtractor.cs index 3d0f5de93..d11e90fa9 100644 --- a/src/JoinRpg.Portal/Infrastructure/Authentication/ExternalLoginProfileExtractor.cs +++ b/src/JoinRpg.Portal/Infrastructure/Authentication/ExternalLoginProfileExtractor.cs @@ -34,6 +34,22 @@ await userService.SetVkIfNotSetWithoutAccessChecks(user.Id, vkId, } } + public async Task TryExtractTelegramProfile(JoinIdentityUser user, Dictionary loginInfo) + { + var bornName = BornName.FromOptional(loginInfo.GetValueOrDefault("first_name")); + var surName = SurName.FromOptional(loginInfo.GetValueOrDefault("last_name")); + var prefferedName = PrefferedName.FromOptional(loginInfo.GetValueOrDefault("username")); + + var userFullName = new UserFullName(prefferedName, bornName, surName, FatherName: null); + + + await userService.SetNameIfNotSetWithoutAccessChecks(user.Id, userFullName); + + var avatar = loginInfo["photo_url"]; + + await userService.SetTelegramIfNotSetWithoutAccessChecks(user.Id, new TelegramId(int.Parse(loginInfo["id"]), prefferedName), new AvatarInfo(new Uri(avatar), 50, 50)); + } + private static UserFullName TryGetUserName(ExternalLoginInfo loginInfo) { var bornName = BornName.FromOptional(loginInfo.Principal.FindFirstValue(ClaimTypes.GivenName)); @@ -57,5 +73,9 @@ internal async Task CleanAfterLogin(JoinIdentityUser user, string loginProvider) { await userService.RemoveVkFromProfile(user.Id); } + else if (loginProvider == "telegram") + { + await userService.RemoveTelegramFromProfile(user.Id); + } } } diff --git a/src/JoinRpg.Portal/Infrastructure/Authentication/Telegram/TelegramAuthorizationResult.cs b/src/JoinRpg.Portal/Infrastructure/Authentication/Telegram/TelegramAuthorizationResult.cs new file mode 100644 index 000000000..b70b5f221 --- /dev/null +++ b/src/JoinRpg.Portal/Infrastructure/Authentication/Telegram/TelegramAuthorizationResult.cs @@ -0,0 +1,10 @@ +namespace JoinRpg.Portal.Infrastructure.Authentication.Telegram; + +public enum TelegramAuthorizationResult +{ + InvalidHash, + MissingFields, + InvalidAuthDateFormat, + TooOld, + Valid +} diff --git a/src/JoinRpg.Portal/Infrastructure/Authentication/Telegram/TelegramLoginOptions.cs b/src/JoinRpg.Portal/Infrastructure/Authentication/Telegram/TelegramLoginOptions.cs new file mode 100644 index 000000000..45cf15b28 --- /dev/null +++ b/src/JoinRpg.Portal/Infrastructure/Authentication/Telegram/TelegramLoginOptions.cs @@ -0,0 +1,12 @@ +namespace JoinRpg.Portal.Infrastructure.Authentication.Telegram; +public class TelegramLoginOptions +{ + public required string BotName { get; set; } + public int BotId { get; set; } + public required string BotSecret { get; set; } + + /// + /// How old (in seconds) can authorization attempts be to be considered valid (compared to the auth_date field) + /// + public TimeSpan AllowedTimeOffset { get; set; } = TimeSpan.FromSeconds(30); +} diff --git a/src/JoinRpg.Portal/Infrastructure/Authentication/Telegram/TelegramLoginValidator.cs b/src/JoinRpg.Portal/Infrastructure/Authentication/Telegram/TelegramLoginValidator.cs new file mode 100644 index 000000000..c381f079c --- /dev/null +++ b/src/JoinRpg.Portal/Infrastructure/Authentication/Telegram/TelegramLoginValidator.cs @@ -0,0 +1,102 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; + +namespace JoinRpg.Portal.Infrastructure.Authentication.Telegram; + +/// +/// A helper class used to verify authorization data. +/// Based on https://github.com/TelegramBots/Telegram.Bot.Extensions.LoginWidget/ +/// +/// +/// Construct a new instance +/// +public class TelegramLoginValidator(IOptions options) +{ + private static readonly DateTime _unixStart = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// + /// Checks whether the authorization data received from the user is valid + /// + /// A collection containing query string fields as sorted key-value pairs + /// + public TelegramAuthorizationResult CheckAuthorization(SortedDictionary fields) + { + ArgumentNullException.ThrowIfNull(fields); + + var _hmac = new HMACSHA256(SHA256.HashData(Encoding.ASCII.GetBytes(options.Value.BotId + ":" + options.Value.BotSecret))); + + if (!fields.ContainsKey(Field.Id) || + !fields.TryGetValue(Field.AuthDate, out var authDate) || + !fields.TryGetValue(Field.Hash, out var hash) + ) + { + return TelegramAuthorizationResult.MissingFields; + } + + if (hash.Length != 64) + { + return TelegramAuthorizationResult.InvalidHash; + } + + if (!long.TryParse(authDate, out var timestamp)) + { + return TelegramAuthorizationResult.InvalidAuthDateFormat; + } + + if (TimeSpan.FromSeconds(Math.Abs(DateTime.UtcNow.Subtract(_unixStart).TotalSeconds - timestamp)) > options.Value.AllowedTimeOffset) + { + return TelegramAuthorizationResult.TooOld; + } + + fields.Remove(Field.Hash); + var 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 + + var signature = _hmac.ComputeHash(Encoding.UTF8.GetBytes(dataStringBuilder.ToString())); + + // Adapted from: https://stackoverflow.com/a/14333437/6845657 + for (var i = 0; i < signature.Length; i++) + { + if (hash[i * 2] != 87 + (signature[i] >> 4) + ((signature[i] >> 4) - 10 >> 31 & -39)) + { + return TelegramAuthorizationResult.InvalidHash; + } + + if (hash[i * 2 + 1] != 87 + (signature[i] & 0xF) + ((signature[i] & 0xF) - 10 >> 31 & -39)) + { + return TelegramAuthorizationResult.InvalidHash; + } + } + + return TelegramAuthorizationResult.Valid; + } + + /// + /// Checks whether the authorization data received from the user is valid + /// + /// A collection containing query string fields as key-value pairs + /// + public TelegramAuthorizationResult CheckAuthorization(Dictionary fields) + { + ArgumentNullException.ThrowIfNull(fields); + return CheckAuthorization(new SortedDictionary(fields, StringComparer.Ordinal)); + } + + private static class Field + { + public const string AuthDate = "auth_date"; + public const string Id = "id"; + public const string Hash = "hash"; + } +} diff --git a/src/JoinRpg.Portal/Startup.cs b/src/JoinRpg.Portal/Startup.cs index 2bae899e1..8a198b39d 100644 --- a/src/JoinRpg.Portal/Startup.cs +++ b/src/JoinRpg.Portal/Startup.cs @@ -7,6 +7,7 @@ using JoinRpg.Interfaces; using JoinRpg.Portal.Infrastructure; using JoinRpg.Portal.Infrastructure.Authentication; +using JoinRpg.Portal.Infrastructure.Authentication.Telegram; using JoinRpg.Portal.Infrastructure.DailyJobs; using JoinRpg.Portal.Infrastructure.DiscoverFilters; using JoinRpg.Portal.Infrastructure.HealthChecks; @@ -43,7 +44,8 @@ public void ConfigureServices(IServiceCollection services) .Configure(Configuration.GetSection("Jwt")) .Configure(Configuration.GetSection("Notifications")) .Configure(Configuration.GetSection("MailGun")) - .Configure(Configuration.GetSection("DailyJob")); + .Configure(Configuration.GetSection("DailyJob")) + .Configure(Configuration.GetSection("Telegram")); s3StorageOptions = Configuration.GetSection("S3BlobStorage").Get()!; diff --git a/src/JoinRpg.Portal/Views/Manage/SetupProfile.cshtml b/src/JoinRpg.Portal/Views/Manage/SetupProfile.cshtml index e6a708862..9e0540f7c 100644 --- a/src/JoinRpg.Portal/Views/Manage/SetupProfile.cshtml +++ b/src/JoinRpg.Portal/Views/Manage/SetupProfile.cshtml @@ -18,6 +18,7 @@ ManageMessageId.ChangePasswordSuccess => "Пароль изменен", ManageMessageId.SetPasswordSuccess => "Пароль установлен", ManageMessageId.RemoveLoginSuccess => "Соцсеть отвязана", + _ => "Неизвестная ошибка", }) } @@ -146,19 +147,6 @@ -
- @Html.LabelFor(model => model.Telegram, htmlAttributes: new { @class = "control-label col-md-2" }) -
-
- https://t.me/ - @Html.EditorFor(model => model.Telegram, new { htmlAttributes = new { @class = "form-control" } }) - -
- @Html.DescriptionFor(model => model.Telegram) - @Html.ValidationMessageFor(model => model.Telegram, "", new { @class = "text-danger" }) -
-
-
@Html.LabelFor(model => model.Nicknames, htmlAttributes: new { @class = "control-label col-md-2" })
@@ -203,24 +191,37 @@

@social.LoginProvider.FriendlyName

@if (social.ProviderLink != null) { - (@social.ProviderKey) + (@social.ProviderLink.AbsoluteUri) } @if (social.AllowLink) { -
- - -
+ @if (social.LoginProvider == ProviderDescViewModel.Telegram) + { + + } + else { +
+ + +
+ } } @if (social.NeedToReLink) {
Необходимо подтвердить аккаунт!
-
- - -
+ @if (social.LoginProvider == ProviderDescViewModel.Telegram) + { + + } + else + { +
+ + +
+ } } @if (social.AllowUnlink) { diff --git a/src/JoinRpg.Portal/Views/Manage/TelegramLoginButton.cshtml b/src/JoinRpg.Portal/Views/Manage/TelegramLoginButton.cshtml new file mode 100644 index 000000000..529e67af1 --- /dev/null +++ b/src/JoinRpg.Portal/Views/Manage/TelegramLoginButton.cshtml @@ -0,0 +1,12 @@ +@model string +@{ + int LoginWidgetJsVersion = 22; +} + + + diff --git a/src/JoinRpg.Portal/appsettings.json b/src/JoinRpg.Portal/appsettings.json index c307275aa..e021d51b1 100644 --- a/src/JoinRpg.Portal/appsettings.json +++ b/src/JoinRpg.Portal/appsettings.json @@ -50,5 +50,10 @@ }, "DailyJob": { "DebugDailyJobMode": "false", // If true, daily jobs will start first time directly after start of application + }, + "Telegram": { + "BotName": "", + "BotId": "", + "BotSecret": "" } } diff --git a/src/JoinRpg.PrimitiveTypes/TelegramId.cs b/src/JoinRpg.PrimitiveTypes/TelegramId.cs new file mode 100644 index 000000000..dd732ee60 --- /dev/null +++ b/src/JoinRpg.PrimitiveTypes/TelegramId.cs @@ -0,0 +1,5 @@ +namespace JoinRpg.PrimitiveTypes; + +public record TelegramId(int Id, PrefferedName? UserName) +{ +} diff --git a/src/JoinRpg.Services.Impl/UserServiceImpl.cs b/src/JoinRpg.Services.Impl/UserServiceImpl.cs index ea7a55eff..afe9e0057 100644 --- a/src/JoinRpg.Services.Impl/UserServiceImpl.cs +++ b/src/JoinRpg.Services.Impl/UserServiceImpl.cs @@ -43,7 +43,6 @@ public async Task UpdateProfile(int userId, string groupNames, string skype, string livejournal, - string telegram, ContactsAccessType socialAccessType) { if (CurrentUserId != userId) @@ -70,10 +69,8 @@ public async Task UpdateProfile(int userId, user.Extra.Nicknames = nicknames; user.Extra.GroupNames = groupNames; user.Extra.Skype = skype; - var tokensToRemove = new[] - {"http://", "https://", "vk.com", "vkontakte.ru", ".livejournal.com", ".lj.ru", "t.me", "/",}; + string[] tokensToRemove = ["http://", "https://", "vk.com", "vkontakte.ru", ".livejournal.com", ".lj.ru", "t.me", "/",]; user.Extra.Livejournal = livejournal?.RemoveFromString(tokensToRemove); - user.Extra.Telegram = telegram?.RemoveFromString(tokensToRemove).TrimStart('@'); user.Extra.SocialNetworksAccess = socialAccessType; @@ -147,6 +144,19 @@ async Task IUserService.SetVkIfNotSetWithoutAccessChecks(int userId, VkId vkId, await UnitOfWork.SaveChangesAsync(); } + async Task IUserService.SetTelegramIfNotSetWithoutAccessChecks(int userId, TelegramId telegramId, AvatarInfo avatarInfo) + { + logger.LogInformation("About to link user: {userId} to {telegramId}", userId, telegramId); + var user = await UserRepository.WithProfile(userId); + + user.Extra ??= new UserExtra(); + user.Extra.Telegram = string.IsNullOrWhiteSpace(telegramId.UserName?.Value) ? user.Extra.Telegram : telegramId.UserName; + + await AddSocialAvatarImplAsync(avatarInfo, user, "telegram"); + + await UnitOfWork.SaveChangesAsync(); + } + private async Task AddSocialAvatarImplAsync(AvatarInfo avatarInfo, User user, string providerId) { if ( @@ -202,6 +212,20 @@ public async Task RemoveVkFromProfile(int userId) await UnitOfWork.SaveChangesAsync(); } + public async Task RemoveTelegramFromProfile(int userId) + { + logger.LogInformation("About to remove Telegram link from user: {userId}", userId); + if (CurrentUserId != userId) + { + throw new JoinRpgInvalidUserException(); + } + var user = await UserRepository.WithProfile(userId); + user.Extra ??= new UserExtra(); + user.Extra.Telegram = null; + + await UnitOfWork.SaveChangesAsync(); + } + /// async Task IAvatarService.AddGrAvatarIfRequired(int userId) { diff --git a/src/JoinRpg.Services.Interfaces/IUserService.cs b/src/JoinRpg.Services.Interfaces/IUserService.cs index 35128fb29..e294fd372 100644 --- a/src/JoinRpg.Services.Interfaces/IUserService.cs +++ b/src/JoinRpg.Services.Interfaces/IUserService.cs @@ -5,7 +5,7 @@ namespace JoinRpg.Services.Interfaces; public interface IUserService { - Task UpdateProfile(int userId, UserFullName userFullName, Gender gender, string phoneNumber, string nicknames, string groupNames, string skype, string livejournal, string telegram, ContactsAccessType socialNetworkAccess); + Task UpdateProfile(int userId, UserFullName userFullName, Gender gender, string phoneNumber, string nicknames, string groupNames, string skype, string livejournal, ContactsAccessType socialNetworkAccess); Task SetAdminFlag(int userId, bool administratorFlag); Task SetVerificationFlag(int userId, bool verificationFlag); /// @@ -21,5 +21,8 @@ public interface IUserService /// Task SetVkIfNotSetWithoutAccessChecks(int id, VkId vkId, AvatarInfo avatarInfo); + + Task SetTelegramIfNotSetWithoutAccessChecks(int id, TelegramId telegramId, AvatarInfo avatarInfo); Task RemoveVkFromProfile(int id); + Task RemoveTelegramFromProfile(int id); } diff --git a/src/JoinRpg.WebPortal.Models/UserProfile/EditUserProfileViewModel.cs b/src/JoinRpg.WebPortal.Models/UserProfile/EditUserProfileViewModel.cs index c335dc6a9..3ab751d94 100644 --- a/src/JoinRpg.WebPortal.Models/UserProfile/EditUserProfileViewModel.cs +++ b/src/JoinRpg.WebPortal.Models/UserProfile/EditUserProfileViewModel.cs @@ -36,14 +36,6 @@ public class EditUserProfileViewModel [Display(Name = "ЖЖ")] public string Livejournal { get; set; } - [Display(Name = "VK")] - [UIHint("Vkontakte")] - [ReadOnly(true)] - public string Vk { get; set; } - - [Display(Name = "Telegram")] - public string Telegram { get; set; } - [Display(Name = "Все ник(и)", Description = "Все ники, через запятую, под которыми вас могут знать. Это позволит находить вас поиском даже тем, кто использует ваш старый или по другому написанный ник")] @@ -60,9 +52,6 @@ public class EditUserProfileViewModel [ReadOnly(true)] public bool IsVerifiedFlag { get; set; } - [ReadOnly(true)] - public bool IsVkVerifiedFlag { get; set; } - [Display(Name = "Публичность соцсетей")] public ContactsAccessTypeView SocialNetworkAccess { get; set; } @@ -76,4 +65,7 @@ public class EditUserProfileViewModel public UserAvatarListViewModel Avatars { get; set; } public ManageMessageId? Message { get; set; } + + [ReadOnly(true)] + public string TelegramBotName { get; set; } = null!; } diff --git a/src/JoinRpg.WebPortal.Models/UserProfile/UserLoginInfoViewModel.cs b/src/JoinRpg.WebPortal.Models/UserProfile/UserLoginInfoViewModel.cs index b27d8f6b3..0a419447f 100644 --- a/src/JoinRpg.WebPortal.Models/UserProfile/UserLoginInfoViewModel.cs +++ b/src/JoinRpg.WebPortal.Models/UserProfile/UserLoginInfoViewModel.cs @@ -1,18 +1,36 @@ +using System.Diagnostics.CodeAnalysis; using JoinRpg.DataModel; namespace JoinRpg.Web.Models; -public record ProviderDescViewModel(string ProviderId, string FriendlyName) +public abstract record ProviderDescViewModel(string ProviderId, string FriendlyName) { - public static readonly ProviderDescViewModel Vk = new("Vkontakte", "ВК"); + public static readonly ProviderDescViewModel Vk = new VkDescViewModel(); + + public static readonly ProviderDescViewModel Telegram = new TelegramDescViewModel(); + + [return: NotNullIfNotNull(nameof(providerKey))] + public abstract Uri? GetProfileUri(string? providerKey); +} + +public record VkDescViewModel() : ProviderDescViewModel("Vkontakte", "ВК") +{ + [return: NotNullIfNotNull(nameof(providerKey))] + public override Uri? GetProfileUri(string? providerKey) => providerKey is null ? null : new Uri($"https://vk.com/id{providerKey}"); +} + +public record TelegramDescViewModel() : ProviderDescViewModel(ProviderId: "telegram", "Телеграм") +{ + [return: NotNullIfNotNull(nameof(providerKey))] + public override Uri? GetProfileUri(string? providerKey) => providerKey is null ? null : new Uri($"https://t.me/{providerKey}"); } public record UserLoginInfoViewModel { - public ProviderDescViewModel LoginProvider { get; init; } = null!; + public required ProviderDescViewModel LoginProvider { get; init; } - public string? ProviderLink { get; set; } + public Uri? ProviderLink { get; set; } public string? ProviderKey { get; set; } @@ -26,23 +44,11 @@ public static class UserLoginInfoViewModelBuilder { public static IEnumerable GetSocialLogins(this User user) { - var canRemoveLogins = user.PasswordHash != null || user.ExternalLogins.Count > 1; - var vk = GetModel(ProviderDescViewModel.Vk); - if (vk.ProviderKey is not null) - { - vk.ProviderLink = $"https://vk.com/id{vk.ProviderKey}"; - } + yield return GetModel(ProviderDescViewModel.Vk, user.Extra?.Vk); - if (vk.ProviderKey is null && user.Extra?.Vk is not null) - { - vk.NeedToReLink = true; - vk.AllowLink = false; - vk.ProviderLink = $"https://vk.com/id{user.Extra?.Vk}"; - } - - yield return vk; + yield return GetModel(ProviderDescViewModel.Telegram, user.Extra?.Telegram); - UserLoginInfoViewModel GetModel(ProviderDescViewModel provider) + UserLoginInfoViewModel GetModel(ProviderDescViewModel provider, string? idFromProfile) { if (user.ExternalLogins.SingleOrDefault(l => l.Provider.Equals(provider.ProviderId, StringComparison.InvariantCultureIgnoreCase) @@ -51,23 +57,23 @@ UserLoginInfoViewModel GetModel(ProviderDescViewModel provider) return new UserLoginInfoViewModel() { AllowLink = false, - AllowUnlink = canRemoveLogins, + AllowUnlink = user.PasswordHash != null || user.ExternalLogins.Count > 1, LoginProvider = provider, ProviderKey = login.Key, NeedToReLink = false, - ProviderLink = null, + ProviderLink = provider.GetProfileUri(idFromProfile), }; } else { return new UserLoginInfoViewModel() { - AllowLink = true, + AllowLink = idFromProfile is null, AllowUnlink = false, LoginProvider = provider, ProviderKey = null, - NeedToReLink = false, - ProviderLink = null, + NeedToReLink = idFromProfile is not null, + ProviderLink = provider.GetProfileUri(idFromProfile), }; } } diff --git a/src/Joinrpg.Web.Identity/AspNetCore/MyUserStore.IUserLoginStore .cs b/src/Joinrpg.Web.Identity/AspNetCore/MyUserStore.IUserLoginStore .cs index f8266875d..a307789eb 100644 --- a/src/Joinrpg.Web.Identity/AspNetCore/MyUserStore.IUserLoginStore .cs +++ b/src/Joinrpg.Web.Identity/AspNetCore/MyUserStore.IUserLoginStore .cs @@ -5,8 +5,15 @@ namespace Joinrpg.Web.Identity; -public partial class MyUserStore : IUserLoginStore +public partial class MyUserStore : IUserLoginStore, ICustomLoginStore { + async Task ICustomLoginStore.AddCustomLoginAsync(JoinIdentityUser user, string key, string provider, CancellationToken ct) + { + var dbUser = await LoadUser(user, ct); + dbUser.ExternalLogins.Add(new UserExternalLogin() { Key = key, Provider = provider }); + _ = await _ctx.SaveChangesAsync(ct); + } + async Task IUserLoginStore.AddLoginAsync(JoinIdentityUser user, UserLoginInfo login, CancellationToken cancellationToken) { var dbUser = await LoadUser(user, cancellationToken); @@ -14,7 +21,7 @@ async Task IUserLoginStore.AddLoginAsync(JoinIdentityUser user _ = await _ctx.SaveChangesAsync(cancellationToken); } - async Task IUserLoginStore.FindByLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken) + public async Task FindByLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken) { var uel = await _ctx.Set().SingleOrDefaultAsync(u => u.Key == providerKey && u.Provider == loginProvider); @@ -38,3 +45,9 @@ async Task IUserLoginStore.RemoveLoginAsync(JoinIdentityUser u _ = await _ctx.SaveChangesAsync(cancellationToken); } } + +public interface ICustomLoginStore +{ + Task AddCustomLoginAsync(JoinIdentityUser user, string key, string provider, CancellationToken ct); + Task FindByLoginAsync(string loginProvider, string providerKey, CancellationToken cancellationToken); +} diff --git a/src/Telegram.Bot.Extensions.LoginWidget/Authorization.cs b/src/Telegram.Bot.Extensions.LoginWidget/Authorization.cs new file mode 100644 index 000000000..3e75114c4 --- /dev/null +++ b/src/Telegram.Bot.Extensions.LoginWidget/Authorization.cs @@ -0,0 +1,10 @@ +namespace Telegram.Bot.Extensions.LoginWidget; + +public enum Authorization +{ + InvalidHash, + MissingFields, + InvalidAuthDateFormat, + TooOld, + Valid +} diff --git a/src/Telegram.Bot.Extensions.LoginWidget/ButtonStyle.cs b/src/Telegram.Bot.Extensions.LoginWidget/ButtonStyle.cs new file mode 100644 index 000000000..b7bba4107 --- /dev/null +++ b/src/Telegram.Bot.Extensions.LoginWidget/ButtonStyle.cs @@ -0,0 +1,8 @@ +namespace Telegram.Bot.Extensions.LoginWidget; + +public enum ButtonStyle +{ + Large, + Medium, + Small +} \ No newline at end of file diff --git a/src/Telegram.Bot.Extensions.LoginWidget/Telegram.Bot.Extensions.LoginWidget.csproj b/src/Telegram.Bot.Extensions.LoginWidget/Telegram.Bot.Extensions.LoginWidget.csproj new file mode 100644 index 000000000..662787a8c --- /dev/null +++ b/src/Telegram.Bot.Extensions.LoginWidget/Telegram.Bot.Extensions.LoginWidget.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + Allows you to generate embed JavaScript for the Telegram login widget and verify the hashes received. + + + + + + + diff --git a/src/Telegram.Bot.Extensions.LoginWidget/TelegramLoginOptions.cs b/src/Telegram.Bot.Extensions.LoginWidget/TelegramLoginOptions.cs new file mode 100644 index 000000000..7ca00fd9a --- /dev/null +++ b/src/Telegram.Bot.Extensions.LoginWidget/TelegramLoginOptions.cs @@ -0,0 +1,12 @@ +namespace Telegram.Bot.Extensions.LoginWidget; +public class TelegramLoginOptions +{ + public required string BotName { get; set; } + public int BotId { get; set; } + public required string BotSecret { get; set; } + + /// + /// How old (in seconds) can authorization attempts be to be considered valid (compared to the auth_date field) + /// + public TimeSpan AllowedTimeOffset { get; set; } = TimeSpan.FromSeconds(30); +} diff --git a/src/Telegram.Bot.Extensions.LoginWidget/TelegramLoginValidator.cs b/src/Telegram.Bot.Extensions.LoginWidget/TelegramLoginValidator.cs new file mode 100644 index 000000000..86742858e --- /dev/null +++ b/src/Telegram.Bot.Extensions.LoginWidget/TelegramLoginValidator.cs @@ -0,0 +1,104 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; + +namespace Telegram.Bot.Extensions.LoginWidget; + +/// +/// A helper class used to verify authorization data +/// +/// +/// Construct a new instance +/// +public class TelegramLoginValidator(IOptions options) +{ + private static readonly DateTime _unixStart = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + /// + /// 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) + { + ArgumentNullException.ThrowIfNull(fields); + using SHA256 sha256 = SHA256.Create(); + var _hmac = new HMACSHA256(sha256.ComputeHash(Encoding.ASCII.GetBytes(options.Value.BotId + ":" + options.Value.BotSecret))); + + 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 (hash.Length != 64) return Authorization.InvalidHash; + + if (!long.TryParse(authDate, out long timestamp)) + return Authorization.InvalidAuthDateFormat; + + if (TimeSpan.FromSeconds(Math.Abs(DateTime.UtcNow.Subtract(_unixStart).TotalSeconds - timestamp)) > options.Value.AllowedTimeOffset) + { + return Authorization.TooOld; + } + + 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 + + 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 (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; + } + + 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(Dictionary 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)); + + 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/WidgetEmbedCodeGenerator.cs b/src/Telegram.Bot.Extensions.LoginWidget/WidgetEmbedCodeGenerator.cs new file mode 100644 index 000000000..7b243861e --- /dev/null +++ b/src/Telegram.Bot.Extensions.LoginWidget/WidgetEmbedCodeGenerator.cs @@ -0,0 +1,60 @@ +namespace Telegram.Bot.Extensions.LoginWidget; + +/// +/// Generates JavaScript embed code matching the one found on https://core.telegram.org/widgets/login +/// +public static class WidgetEmbedCodeGenerator +{ + public static int LoginWidgetJsVersion = 22; + + /// + /// 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, buttonStyle, showUserPhoto, requestAccess, string.Concat("data-onauth=\"", callbackFunctionName, "(", callbackParameterName, ")\"")); + } + + /// + /// Generate the embed code that redirects you to the url you specify with parameters in the query string + /// + /// 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, buttonStyle, showUserPhoto, requestAccess, $"data-auth-url=\"{redirectUrl}\""); + } + + private static string GenerateBaseEmbedCode(string botName, ButtonStyle buttonStyle, bool showUserPhoto, bool requestAccess, string data_auth) + { + return string.Concat( + ""); + } +}