Skip to content

Commit

Permalink
Linking account with telegram (#2744)
Browse files Browse the repository at this point in the history
* add fork of Telegram.Bot.Extensions.LoginWidget

* modernize Telegram.Bot.Extensions.LoginWidget

* Implement linking with telegram
  • Loading branch information
leotsarev authored Aug 13, 2024
1 parent 1a376a9 commit 05c5298
Show file tree
Hide file tree
Showing 26 changed files with 552 additions and 102 deletions.
2 changes: 2 additions & 0 deletions Joinrpg.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions manifests/dev/.env
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions manifests/prod/.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Notifications__ServiceAccountEmail=support@joinrpg.ru
MailGun__ApiDomain=joinrpg.ru
MailGun__Enabled=true
Telegram__BotName=joinrpg_dev_bot
Telegram__BotId=7431625317
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,41 @@

#nullable disable

namespace JoinRpg.Dal.JobService.Migrations
namespace JoinRpg.Dal.JobService.Migrations;

/// <inheritdoc />
public partial class CreateDailyJob : Migration
{
/// <inheritdoc />
public partial class CreateDailyJob : Migration
protected override void Up(MigrationBuilder migrationBuilder)
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DailyJobRuns",
columns: table => new
{
DailyJobRunId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
JobName = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
DayOfRun = table.Column<DateOnly>(type: "date", maxLength: 1024, nullable: false),
JobStatus = table.Column<int>(type: "integer", nullable: false),
MachineName = table.Column<string>(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<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
JobName = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
DayOfRun = table.Column<DateOnly>(type: "date", maxLength: 1024, nullable: false),
JobStatus = table.Column<int>(type: "integer", nullable: false),
MachineName = table.Column<string>(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);
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DailyJobRuns");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DailyJobRuns");
}
}
37 changes: 32 additions & 5 deletions src/JoinRpg.Portal/Controllers/UserProfile/ManageController.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
using Joinrpg.Web.Identity;
using JoinRpg.Data.Interfaces;
using JoinRpg.DataModel;
using JoinRpg.Domain;
using JoinRpg.Interfaces;
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;
using JoinRpg.Web.Models;
using JoinRpg.Web.Models.UserProfile;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;

namespace JoinRpg.Portal.Controllers;

Expand Down Expand Up @@ -174,8 +177,35 @@ public async Task<ActionResult> LinkLoginCallback()
return RedirectToAction("SetupProfile");
}

public async Task<ActionResult> 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<ActionResult> SetupProfile(bool checkContactsMessage = false, ManageMessageId? message = null)
public async Task<ActionResult> SetupProfile([FromServices] IOptions<TelegramLoginOptions> options, bool checkContactsMessage = false, ManageMessageId? message = null)
{
await avatarService.AddGrAvatarIfRequired(CurrentUserAccessor.UserId);

Expand All @@ -200,20 +230,18 @@ public async Task<ActionResult> 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);
Expand All @@ -233,7 +261,6 @@ public async Task<ActionResult> 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -26,6 +27,10 @@ public static IServiceCollection AddJoinAuth(this IServiceCollection services,
.AddUserStore<MyUserStore>()
.AddRoleStore<MyUserStore>();

_ = services
.AddTransient<ICustomLoginStore, MyUserStore>()
.AddTransient<TelegramLoginValidator>();

_ = services.AddAntiforgery(options => options.HeaderName = "X-CSRF-TOKEN");

_ = services.ConfigureApplicationCookie(SetCookieOptions());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,22 @@ await userService.SetVkIfNotSetWithoutAccessChecks(user.Id, vkId,
}
}

public async Task TryExtractTelegramProfile(JoinIdentityUser user, Dictionary<string, string> 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));
Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace JoinRpg.Portal.Infrastructure.Authentication.Telegram;

public enum TelegramAuthorizationResult
{
InvalidHash,
MissingFields,
InvalidAuthDateFormat,
TooOld,
Valid
}
Original file line number Diff line number Diff line change
@@ -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; }

/// <summary>
/// How old (in seconds) can authorization attempts be to be considered valid (compared to the auth_date field)
/// </summary>
public TimeSpan AllowedTimeOffset { get; set; } = TimeSpan.FromSeconds(30);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;

namespace JoinRpg.Portal.Infrastructure.Authentication.Telegram;

/// <summary>
/// A helper class used to verify authorization data.
/// Based on https://github.com/TelegramBots/Telegram.Bot.Extensions.LoginWidget/
/// </summary>
/// <remarks>
/// Construct a new <see cref="TelegramLoginValidator"/> instance
/// </remarks>
public class TelegramLoginValidator(IOptions<TelegramLoginOptions> options)
{
private static readonly DateTime _unixStart = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);

/// <summary>
/// Checks whether the authorization data received from the user is valid
/// </summary>
/// <param name="fields">A collection containing query string fields as sorted key-value pairs</param>
/// <returns></returns>
public TelegramAuthorizationResult CheckAuthorization(SortedDictionary<string, string> 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;
}

/// <summary>
/// Checks whether the authorization data received from the user is valid
/// </summary>
/// <param name="fields">A collection containing query string fields as key-value pairs</param>
/// <returns></returns>
public TelegramAuthorizationResult CheckAuthorization(Dictionary<string, string> fields)
{
ArgumentNullException.ThrowIfNull(fields);
return CheckAuthorization(new SortedDictionary<string, string>(fields, StringComparer.Ordinal));
}

private static class Field
{
public const string AuthDate = "auth_date";
public const string Id = "id";
public const string Hash = "hash";
}
}
4 changes: 3 additions & 1 deletion src/JoinRpg.Portal/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,7 +44,8 @@ public void ConfigureServices(IServiceCollection services)
.Configure<JwtBearerOptions>(Configuration.GetSection("Jwt"))
.Configure<NotificationsOptions>(Configuration.GetSection("Notifications"))
.Configure<MailGunOptions>(Configuration.GetSection("MailGun"))
.Configure<DailyJobOptions>(Configuration.GetSection("DailyJob"));
.Configure<DailyJobOptions>(Configuration.GetSection("DailyJob"))
.Configure<TelegramLoginOptions>(Configuration.GetSection("Telegram"));

s3StorageOptions = Configuration.GetSection("S3BlobStorage").Get<S3StorageOptions>()!;

Expand Down
Loading

0 comments on commit 05c5298

Please sign in to comment.