diff --git a/MareSynchronosServer/MareSynchronosServer/Data/MareDbContext.cs b/MareSynchronosServer/MareSynchronosServer/Data/MareDbContext.cs index f455ce3..a6975d2 100644 --- a/MareSynchronosServer/MareSynchronosServer/Data/MareDbContext.cs +++ b/MareSynchronosServer/MareSynchronosServer/Data/MareDbContext.cs @@ -15,6 +15,7 @@ namespace MareSynchronosServer.Data public DbSet ForbiddenUploadEntries { get; set; } public DbSet BannedUsers { get; set; } public DbSet Auth { get; set; } + public DbSet LodeStoneAuth { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -30,6 +31,7 @@ namespace MareSynchronosServer.Data modelBuilder.Entity().HasIndex(c => c.OtherUserUID); modelBuilder.Entity().ToTable("forbidden_upload_entries"); modelBuilder.Entity().ToTable("banned_users"); + modelBuilder.Entity().ToTable("lodestone_auth"); } } } diff --git a/MareSynchronosServer/MareSynchronosServer/Discord/DiscordBot.cs b/MareSynchronosServer/MareSynchronosServer/Discord/DiscordBot.cs new file mode 100644 index 0000000..522f4ef --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServer/Discord/DiscordBot.cs @@ -0,0 +1,331 @@ +using Discord; +using Discord.WebSocket; +using MareSynchronosServer.Data; +using MareSynchronosServer.Hubs; +using MareSynchronosServer.Metrics; +using MareSynchronosServer.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Concurrent; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace MareSynchronosServer.Discord +{ + public class DiscordBot : IHostedService + { + private readonly IServiceProvider services; + private readonly IConfiguration configuration; + private readonly ILogger logger; + private string authToken = string.Empty; + DiscordSocketClient discordClient; + ConcurrentDictionary DiscordLodestoneMapping = new(); + private Timer _timer; + private readonly string[] LodestoneServers = new[] { "eu", "na", "jp", "fr", "na" }; + + public DiscordBot(IServiceProvider services, IConfiguration configuration, ILogger logger) + { + this.services = services; + this.configuration = configuration; + this.logger = logger; + + discordClient = new(new DiscordSocketConfig() + { + DefaultRetryMode = RetryMode.AlwaysRetry + }); + + discordClient.Log += Log; + } + + private async Task DiscordClient_SlashCommandExecuted(SocketSlashCommand arg) + { + if (arg.Data.Name == "register") + { + var modal = new ModalBuilder(); + modal.WithTitle("Verify with Lodestone"); + modal.WithCustomId("register_modal"); + modal.AddTextInput("Enter the Lodestone URL of your Character", "lodestoneurl", TextInputStyle.Short, "https://*.finalfantasyxiv.com/lodestone/character//", required: true); + await arg.RespondWithModalAsync(modal.Build()); + } + else if (arg.Data.Name == "verify") + { + await arg.DeferAsync(true); + Embed response = await HandleVerifyAsync(arg.User.Id); + await arg.FollowupAsync(embeds: new[] { response }); + } + } + + private async Task DiscordClient_ModalSubmitted(SocketModal arg) + { + if (arg.Data.CustomId == "register_modal") + { + await arg.DeferAsync(true); + var embed = await HandleRegisterModalAsync(arg); + await arg.FollowupAsync(embeds: new Embed[] { embed }, ephemeral: true); + } + } + + private async Task HandleVerifyAsync(ulong id) + { + var embedBuilder = new EmbedBuilder(); + if (!DiscordLodestoneMapping.ContainsKey(id)) + { + embedBuilder.WithTitle("Cannot verify registration"); + embedBuilder.WithDescription("You need to **/register** first before you can **/verify**"); + } + else + { + using var scope = services.CreateScope(); + var req = new HttpClient(); + using var db = scope.ServiceProvider.GetService(); + + var lodestoneAuth = db.LodeStoneAuth.SingleOrDefault(u => u.DiscordId == id); + if (lodestoneAuth != null) + { + Random rand = new(); + var randomServer = LodestoneServers[rand.Next(LodestoneServers.Length)]; + var response = await req.GetAsync($"https://{randomServer}.finalfantasyxiv.com/lodestone/character/{DiscordLodestoneMapping[id]}"); + if (response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(); + if (content.Contains(lodestoneAuth.LodestoneAuthString)) + { + DiscordLodestoneMapping.TryRemove(id, out _); + + using var sha256 = SHA256.Create(); + var user = new User(); + + var hasValidUid = false; + while (!hasValidUid) + { + var uid = MareHub.GenerateRandomString(10); + if (db.Users.Any(u => u.UID == uid)) continue; + user.UID = uid; + hasValidUid = true; + } + + // make the first registered user on the service to admin + if (!await db.Users.AnyAsync()) + { + user.IsAdmin = true; + } + + if (configuration.GetValue("PurgeUnusedAccounts")) + { + var purgedDays = configuration.GetValue("PurgeUnusedAccountsPeriodInDays"); + user.LastLoggedIn = DateTime.UtcNow - TimeSpan.FromDays(purgedDays) + TimeSpan.FromHours(1); + } + + var computedHash = BitConverter.ToString(sha256.ComputeHash(Encoding.UTF8.GetBytes(MareHub.GenerateRandomString(64)))).Replace("-", ""); + var auth = new Auth() + { + HashedKey = BitConverter.ToString(sha256.ComputeHash(Encoding.UTF8.GetBytes(computedHash))) + .Replace("-", ""), + User = user, + }; + + db.Users.Add(user); + db.Auth.Add(auth); + + logger.LogInformation("User registered: " + user.UID); + + MareMetrics.UsersRegistered.Inc(); + + lodestoneAuth.StartedAt = null; + lodestoneAuth.User = user; + lodestoneAuth.LodestoneAuthString = null; + + embedBuilder.WithTitle("Registration successful"); + embedBuilder.WithDescription("This is your private secret key. Do not share this private secret key with anyone. If you lose it, it is irrevocably lost." + + Environment.NewLine + Environment.NewLine + + $"**{computedHash}**" + + Environment.NewLine + Environment.NewLine + + "Enter this key in Mare Synchronos and hit save to connect to the service." + + Environment.NewLine + + "You should connect as soon as possible to not get caught by the automatic cleanup process." + + Environment.NewLine + + "Have fun."); + } + else + { + embedBuilder.WithTitle("Failed to verify your character"); + embedBuilder.WithDescription("Did not find requested authentication key on your profile. Start over with **/register**"); + } + } + else + { + embedBuilder.WithTitle("Failed to get response from Lodestone"); + embedBuilder.WithDescription("Try again later"); + lodestoneAuth.StartedAt = DateTime.UtcNow; + } + + await db.SaveChangesAsync(); + } + else + { + embedBuilder.WithTitle("Your auth has expired"); + embedBuilder.WithDescription("Start again with **/register**"); + DiscordLodestoneMapping.TryRemove(id, out _); + } + } + + return embedBuilder.Build(); + } + + private async Task HandleRegisterModalAsync(SocketModal arg) + { + var embed = new EmbedBuilder(); + + var lodestoneId = ParseCharacterIdFromLodestoneUrl(arg.Data.Components.Single(c => c.CustomId == "lodestoneurl").Value); + if (lodestoneId == null) + { + embed.WithTitle("Invalid Lodestone URL"); + embed.WithDescription("The lodestone URL was not valid. It should have following format:" + Environment.NewLine + + "https://eu.finalfantasyxiv.com/lodestone/character/YOUR_LODESTONE_ID/"); + } + else + { + // check if userid is already in db + using var scope = services.CreateScope(); + using var sha256 = SHA256.Create(); + + var hashedLodestoneId = BitConverter.ToString(sha256.ComputeHash(Encoding.UTF8.GetBytes(lodestoneId.ToString()))).Replace("-", ""); + + var db = scope.ServiceProvider.GetService(); + + if (db.LodeStoneAuth.Any(a => a.DiscordId == arg.User.Id)) + { + // user already in db + embed.WithTitle("Registration failed"); + embed.WithDescription("You cannot register more than one lodestone character to your discord account."); + } + else if (db.LodeStoneAuth.Any(a => a.HashedLodestoneId == hashedLodestoneId)) + { + // character already in db + embed.WithTitle("Registration failed"); + embed.WithDescription("This lodestone character already exists in the Database. If you are the rightful owner for this character and lost your secret key generated with it, contact the developer."); + } + else + { + string lodestoneAuth = await GenerateLodestoneAuth(arg.User.Id, hashedLodestoneId, db); + // check if lodestone id is already in db + embed.WithTitle("Authorize your character"); + embed.WithDescription("Add following key to your character profile at https://na.finalfantasyxiv.com/lodestone/my/setting/profile/" + + Environment.NewLine + Environment.NewLine + + $"**{lodestoneAuth}**" + + Environment.NewLine + Environment.NewLine + + "Once added and saved, use command **/verify** to finish registration and receive a secret key to use for Mare Synchronos." + + Environment.NewLine + + "You can delete the entry from your profile after verification." + + Environment.NewLine + Environment.NewLine + + "The verification will expire in approximately 15 minutes. If you fail to **/verify** the registration will be invalidated and you have to **/register** again."); + DiscordLodestoneMapping[arg.User.Id] = lodestoneId.ToString(); + } + } + + return embed.Build(); + } + + private async Task GenerateLodestoneAuth(ulong discordid, string hashedLodestoneId, MareDbContext dbContext) + { + var auth = MareHub.GenerateRandomString(64); + LodeStoneAuth lsAuth = new LodeStoneAuth() + { + DiscordId = discordid, + HashedLodestoneId = hashedLodestoneId, + LodestoneAuthString = auth, + StartedAt = DateTime.UtcNow + }; + + dbContext.Add(lsAuth); + await dbContext.SaveChangesAsync(); + + return auth; + } + + private int? ParseCharacterIdFromLodestoneUrl(string lodestoneUrl) + { + var isLodestoneUrl = Regex.Match(lodestoneUrl, @"https:\/\/(na|eu|de|fr|jp)\.finalfantasyxiv\.com\/lodestone\/character\/\d+").Success; + if (!isLodestoneUrl) return null; + + lodestoneUrl = lodestoneUrl.Split('/', StringSplitOptions.RemoveEmptyEntries).Last(); + if (!int.TryParse(lodestoneUrl, out int lodestoneId)) + { + return null; + } + + return lodestoneId; + } + + private async Task DiscordClient_Ready() + { + var cb = new SlashCommandBuilder(); + cb.WithName("register"); + cb.WithDescription("Starts the registration process for the Mare Synchronos server of this Discord"); + + var cb2 = new SlashCommandBuilder(); + cb2.WithName("verify"); + cb2.WithDescription("Finishes the registration process for the Mare Synchronos server of this Discord"); + + try + { + await discordClient.CreateGlobalApplicationCommandAsync(cb.Build()); + await discordClient.CreateGlobalApplicationCommandAsync(cb2.Build()); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to create command"); + } + } + + private Task Log(LogMessage msg) + { + logger.LogInformation(msg.ToString()); + + return Task.CompletedTask; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + authToken = configuration.GetValue("DiscordBotToken"); + if (!string.IsNullOrEmpty(authToken)) + { + await discordClient.LoginAsync(TokenType.Bot, authToken); + await discordClient.StartAsync(); + + discordClient.Ready += DiscordClient_Ready; + discordClient.SlashCommandExecuted += DiscordClient_SlashCommandExecuted; + discordClient.ModalSubmitted += DiscordClient_ModalSubmitted; + + _timer = new Timer(UpdateStatus, null, TimeSpan.Zero, TimeSpan.FromSeconds(15)); + } + } + + private void UpdateStatus(object state) + { + using var scope = services.CreateScope(); + var db = scope.ServiceProvider.GetService(); + + var users = db.Users.Count(c => c.CharacterIdentification != null); + + discordClient.SetActivityAsync(new Game("Mare for " + users + " Users")); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _timer?.Change(Timeout.Infinite, 0); + + await discordClient.LogoutAsync(); + await discordClient.StopAsync(); + } + } +} diff --git a/MareSynchronosServer/MareSynchronosServer/FileCleanupService.cs b/MareSynchronosServer/MareSynchronosServer/FileCleanupService.cs index 5feecf6..877394a 100644 --- a/MareSynchronosServer/MareSynchronosServer/FileCleanupService.cs +++ b/MareSynchronosServer/MareSynchronosServer/FileCleanupService.cs @@ -73,6 +73,20 @@ namespace MareSynchronosServer } } + var lodestoneAuths = dbContext.LodeStoneAuth.Include(u => u.User).Where(a => a.StartedAt != null).ToList(); + List expiredAuths = new List(); + foreach (var auth in lodestoneAuths) + { + if (auth.StartedAt < DateTime.UtcNow - TimeSpan.FromMinutes(15)) + { + expiredAuths.Add(auth); + } + } + + dbContext.RemoveRange(expiredAuths); + dbContext.RemoveRange(expiredAuths.Select(a => a.User)); + + if (!bool.TryParse(_configuration["PurgeUnusedAccounts"], out var purgeUnusedAccounts)) { purgeUnusedAccounts = false; @@ -91,7 +105,7 @@ namespace MareSynchronosServer List usersToRemove = new(); foreach (var user in allUsers) { - if (user.LastLoggedIn < (DateTime.Now - TimeSpan.FromDays(usersOlderThanDays))) + if (user.LastLoggedIn < (DateTime.UtcNow - TimeSpan.FromDays(usersOlderThanDays))) { _logger.LogInformation("User outdated: " + user.UID); usersToRemove.Add(user); @@ -100,6 +114,13 @@ namespace MareSynchronosServer foreach (var user in usersToRemove) { + var lodestone = dbContext.LodeStoneAuth.SingleOrDefault(a => a.User.UID == user.UID); + + if (lodestone != null) + { + dbContext.Remove(lodestone); + } + var auth = dbContext.Auth.Single(a => a.UserUID == user.UID); var userFiles = dbContext.Files.Where(f => f.Uploaded && f.Uploader.UID == user.UID).ToList(); diff --git a/MareSynchronosServer/MareSynchronosServer/Hubs/MareHub.User.cs b/MareSynchronosServer/MareSynchronosServer/Hubs/MareHub.User.cs index 9f194b2..50088b6 100644 --- a/MareSynchronosServer/MareSynchronosServer/Hubs/MareHub.User.cs +++ b/MareSynchronosServer/MareSynchronosServer/Hubs/MareHub.User.cs @@ -25,6 +25,13 @@ namespace MareSynchronosServer.Hubs var ownPairData = await _dbContext.ClientPairs.Where(u => u.User.UID == userid).ToListAsync(); var auth = await _dbContext.Auth.SingleAsync(u => u.UserUID == userid); + var lodestone = await _dbContext.LodeStoneAuth.SingleOrDefaultAsync(a => a.User.UID == userid); + + if (lodestone != null) + { + _dbContext.Remove(lodestone); + } + while (_dbContext.Files.Any(f => f.Uploader == userEntry)) { await Task.Delay(1000); @@ -130,47 +137,6 @@ namespace MareSynchronosServer.Hubs MareMetrics.UserPushDataTo.Inc(visibleCharacterIds.Count); } - /*[HubMethodName(Api.InvokeUserRegister)] - public async Task Register() - { - using var sha256 = SHA256.Create(); - var user = new User(); - - var hasValidUid = false; - while (!hasValidUid) - { - var uid = GenerateRandomString(10); - if (_dbContext.Users.Any(u => u.UID == uid)) continue; - user.UID = uid; - hasValidUid = true; - } - - // make the first registered user on the service to admin - if (!await _dbContext.Users.AnyAsync()) - { - user.IsAdmin = true; - } - - var computedHash = BitConverter.ToString(sha256.ComputeHash(Encoding.UTF8.GetBytes(GenerateRandomString(64)))).Replace("-", ""); - var auth = new Auth() - { - HashedKey = BitConverter.ToString(sha256.ComputeHash(Encoding.UTF8.GetBytes(computedHash))) - .Replace("-", ""), - User = user - }; - - _dbContext.Users.Add(user); - _dbContext.Auth.Add(auth); - - _logger.LogInformation("User registered: " + user.UID); - - MareMetrics.UsersRegistered.Inc(); - - await _dbContext.SaveChangesAsync(); - return computedHash; - }*/ - - [Authorize(AuthenticationSchemes = SecretKeyAuthenticationHandler.AuthScheme)] [HubMethodName(Api.SendUserPairedClientAddition)] public async Task SendPairedClientAddition(string uid) diff --git a/MareSynchronosServer/MareSynchronosServer/MareSynchronosServer.csproj b/MareSynchronosServer/MareSynchronosServer/MareSynchronosServer.csproj index c1c417d..9e45326 100644 --- a/MareSynchronosServer/MareSynchronosServer/MareSynchronosServer.csproj +++ b/MareSynchronosServer/MareSynchronosServer/MareSynchronosServer.csproj @@ -7,7 +7,9 @@ + + diff --git a/MareSynchronosServer/MareSynchronosServer/Migrations/20220801121419_AddLodestoneAuth.Designer.cs b/MareSynchronosServer/MareSynchronosServer/Migrations/20220801121419_AddLodestoneAuth.Designer.cs new file mode 100644 index 0000000..0476cdc --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServer/Migrations/20220801121419_AddLodestoneAuth.Designer.cs @@ -0,0 +1,283 @@ +// +using System; +using MareSynchronosServer.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MareSynchronosServer.Migrations +{ + [DbContext(typeof(MareDbContext))] + [Migration("20220801121419_AddLodestoneAuth")] + partial class AddLodestoneAuth + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MareSynchronosServer.Models.Auth", b => + { + b.Property("HashedKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("hashed_key"); + + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.HasKey("HashedKey") + .HasName("pk_auth"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_auth_user_uid"); + + b.ToTable("auth", (string)null); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.Banned", b => + { + b.Property("CharacterIdentification") + .HasColumnType("text") + .HasColumnName("character_identification"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("CharacterIdentification") + .HasName("pk_banned_users"); + + b.ToTable("banned_users", (string)null); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.ClientPair", b => + { + b.Property("UserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("OtherUserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("other_user_uid"); + + b.Property("AllowReceivingMessages") + .HasColumnType("boolean") + .HasColumnName("allow_receiving_messages"); + + b.Property("IsPaused") + .HasColumnType("boolean") + .HasColumnName("is_paused"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("UserUID", "OtherUserUID") + .HasName("pk_client_pairs"); + + b.HasIndex("OtherUserUID") + .HasDatabaseName("ix_client_pairs_other_user_uid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_client_pairs_user_uid"); + + b.ToTable("client_pairs", (string)null); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.FileCache", b => + { + b.Property("Hash") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("hash"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.Property("Uploaded") + .HasColumnType("boolean") + .HasColumnName("uploaded"); + + b.Property("UploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("uploader_uid"); + + b.HasKey("Hash") + .HasName("pk_file_caches"); + + b.HasIndex("UploaderUID") + .HasDatabaseName("ix_file_caches_uploader_uid"); + + b.ToTable("file_caches", (string)null); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.ForbiddenUploadEntry", b => + { + b.Property("Hash") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("hash"); + + b.Property("ForbiddenBy") + .HasColumnType("text") + .HasColumnName("forbidden_by"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("Hash") + .HasName("pk_forbidden_upload_entries"); + + b.ToTable("forbidden_upload_entries", (string)null); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.LodeStoneAuth", b => + { + b.Property("DiscordId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("discord_id"); + + b.Property("HashedLodestoneId") + .HasColumnType("text") + .HasColumnName("hashed_lodestone_id"); + + b.Property("LodestoneAuthString") + .HasColumnType("text") + .HasColumnName("lodestone_auth_string"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.HasKey("DiscordId") + .HasName("pk_lodestone_auth"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_lodestone_auth_user_uid"); + + b.ToTable("lodestone_auth", (string)null); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.User", b => + { + b.Property("UID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("uid"); + + b.Property("CharacterIdentification") + .HasColumnType("text") + .HasColumnName("character_identification"); + + b.Property("IsAdmin") + .HasColumnType("boolean") + .HasColumnName("is_admin"); + + b.Property("IsModerator") + .HasColumnType("boolean") + .HasColumnName("is_moderator"); + + b.Property("LastLoggedIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_logged_in"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("UID") + .HasName("pk_users"); + + b.HasIndex("CharacterIdentification") + .HasDatabaseName("ix_users_character_identification"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.Auth", b => + { + b.HasOne("MareSynchronosServer.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .HasConstraintName("fk_auth_users_user_temp_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.ClientPair", b => + { + b.HasOne("MareSynchronosServer.Models.User", "OtherUser") + .WithMany() + .HasForeignKey("OtherUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_pairs_users_other_user_temp_id1"); + + b.HasOne("MareSynchronosServer.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_pairs_users_user_temp_id2"); + + b.Navigation("OtherUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.FileCache", b => + { + b.HasOne("MareSynchronosServer.Models.User", "Uploader") + .WithMany() + .HasForeignKey("UploaderUID") + .HasConstraintName("fk_file_caches_users_uploader_uid"); + + b.Navigation("Uploader"); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.LodeStoneAuth", b => + { + b.HasOne("MareSynchronosServer.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .HasConstraintName("fk_lodestone_auth_users_user_uid"); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MareSynchronosServer/MareSynchronosServer/Migrations/20220801121419_AddLodestoneAuth.cs b/MareSynchronosServer/MareSynchronosServer/Migrations/20220801121419_AddLodestoneAuth.cs new file mode 100644 index 0000000..be72371 --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServer/Migrations/20220801121419_AddLodestoneAuth.cs @@ -0,0 +1,44 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MareSynchronosServer.Migrations +{ + public partial class AddLodestoneAuth : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "lodestone_auth", + columns: table => new + { + discord_id = table.Column(type: "numeric(20,0)", nullable: false), + hashed_lodestone_id = table.Column(type: "text", nullable: true), + lodestone_auth_string = table.Column(type: "text", nullable: true), + user_uid = table.Column(type: "character varying(10)", nullable: true), + started_at = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_lodestone_auth", x => x.discord_id); + table.ForeignKey( + name: "fk_lodestone_auth_users_user_uid", + column: x => x.user_uid, + principalTable: "users", + principalColumn: "uid"); + }); + + migrationBuilder.CreateIndex( + name: "ix_lodestone_auth_user_uid", + table: "lodestone_auth", + column: "user_uid"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "lodestone_auth"); + } + } +} diff --git a/MareSynchronosServer/MareSynchronosServer/Migrations/20220801122103_AddNullableLodestoneAuthProperties.Designer.cs b/MareSynchronosServer/MareSynchronosServer/Migrations/20220801122103_AddNullableLodestoneAuthProperties.Designer.cs new file mode 100644 index 0000000..54c6aa5 --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServer/Migrations/20220801122103_AddNullableLodestoneAuthProperties.Designer.cs @@ -0,0 +1,283 @@ +// +using System; +using MareSynchronosServer.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MareSynchronosServer.Migrations +{ + [DbContext(typeof(MareDbContext))] + [Migration("20220801122103_AddNullableLodestoneAuthProperties")] + partial class AddNullableLodestoneAuthProperties + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MareSynchronosServer.Models.Auth", b => + { + b.Property("HashedKey") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("hashed_key"); + + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.HasKey("HashedKey") + .HasName("pk_auth"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_auth_user_uid"); + + b.ToTable("auth", (string)null); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.Banned", b => + { + b.Property("CharacterIdentification") + .HasColumnType("text") + .HasColumnName("character_identification"); + + b.Property("Reason") + .HasColumnType("text") + .HasColumnName("reason"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("CharacterIdentification") + .HasName("pk_banned_users"); + + b.ToTable("banned_users", (string)null); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.ClientPair", b => + { + b.Property("UserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.Property("OtherUserUID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("other_user_uid"); + + b.Property("AllowReceivingMessages") + .HasColumnType("boolean") + .HasColumnName("allow_receiving_messages"); + + b.Property("IsPaused") + .HasColumnType("boolean") + .HasColumnName("is_paused"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("UserUID", "OtherUserUID") + .HasName("pk_client_pairs"); + + b.HasIndex("OtherUserUID") + .HasDatabaseName("ix_client_pairs_other_user_uid"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_client_pairs_user_uid"); + + b.ToTable("client_pairs", (string)null); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.FileCache", b => + { + b.Property("Hash") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("hash"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.Property("Uploaded") + .HasColumnType("boolean") + .HasColumnName("uploaded"); + + b.Property("UploaderUID") + .HasColumnType("character varying(10)") + .HasColumnName("uploader_uid"); + + b.HasKey("Hash") + .HasName("pk_file_caches"); + + b.HasIndex("UploaderUID") + .HasDatabaseName("ix_file_caches_uploader_uid"); + + b.ToTable("file_caches", (string)null); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.ForbiddenUploadEntry", b => + { + b.Property("Hash") + .HasMaxLength(40) + .HasColumnType("character varying(40)") + .HasColumnName("hash"); + + b.Property("ForbiddenBy") + .HasColumnType("text") + .HasColumnName("forbidden_by"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("Hash") + .HasName("pk_forbidden_upload_entries"); + + b.ToTable("forbidden_upload_entries", (string)null); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.LodeStoneAuth", b => + { + b.Property("DiscordId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("discord_id"); + + b.Property("HashedLodestoneId") + .HasColumnType("text") + .HasColumnName("hashed_lodestone_id"); + + b.Property("LodestoneAuthString") + .HasColumnType("text") + .HasColumnName("lodestone_auth_string"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.HasKey("DiscordId") + .HasName("pk_lodestone_auth"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_lodestone_auth_user_uid"); + + b.ToTable("lodestone_auth", (string)null); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.User", b => + { + b.Property("UID") + .HasMaxLength(10) + .HasColumnType("character varying(10)") + .HasColumnName("uid"); + + b.Property("CharacterIdentification") + .HasColumnType("text") + .HasColumnName("character_identification"); + + b.Property("IsAdmin") + .HasColumnType("boolean") + .HasColumnName("is_admin"); + + b.Property("IsModerator") + .HasColumnType("boolean") + .HasColumnName("is_moderator"); + + b.Property("LastLoggedIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_logged_in"); + + b.Property("Timestamp") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea") + .HasColumnName("timestamp"); + + b.HasKey("UID") + .HasName("pk_users"); + + b.HasIndex("CharacterIdentification") + .HasDatabaseName("ix_users_character_identification"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.Auth", b => + { + b.HasOne("MareSynchronosServer.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .HasConstraintName("fk_auth_users_user_temp_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.ClientPair", b => + { + b.HasOne("MareSynchronosServer.Models.User", "OtherUser") + .WithMany() + .HasForeignKey("OtherUserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_pairs_users_other_user_temp_id1"); + + b.HasOne("MareSynchronosServer.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_client_pairs_users_user_temp_id2"); + + b.Navigation("OtherUser"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.FileCache", b => + { + b.HasOne("MareSynchronosServer.Models.User", "Uploader") + .WithMany() + .HasForeignKey("UploaderUID") + .HasConstraintName("fk_file_caches_users_uploader_uid"); + + b.Navigation("Uploader"); + }); + + modelBuilder.Entity("MareSynchronosServer.Models.LodeStoneAuth", b => + { + b.HasOne("MareSynchronosServer.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .HasConstraintName("fk_lodestone_auth_users_user_uid"); + + b.Navigation("User"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/MareSynchronosServer/MareSynchronosServer/Migrations/20220801122103_AddNullableLodestoneAuthProperties.cs b/MareSynchronosServer/MareSynchronosServer/Migrations/20220801122103_AddNullableLodestoneAuthProperties.cs new file mode 100644 index 0000000..6a79460 --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServer/Migrations/20220801122103_AddNullableLodestoneAuthProperties.cs @@ -0,0 +1,34 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MareSynchronosServer.Migrations +{ + public partial class AddNullableLodestoneAuthProperties : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "started_at", + table: "lodestone_auth", + type: "timestamp with time zone", + nullable: true, + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "started_at", + table: "lodestone_auth", + type: "timestamp with time zone", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + oldClrType: typeof(DateTime), + oldType: "timestamp with time zone", + oldNullable: true); + } + } +} diff --git a/MareSynchronosServer/MareSynchronosServer/Migrations/MareDbContextModelSnapshot.cs b/MareSynchronosServer/MareSynchronosServer/Migrations/MareDbContextModelSnapshot.cs index a9c98bc..610fa11 100644 --- a/MareSynchronosServer/MareSynchronosServer/Migrations/MareDbContextModelSnapshot.cs +++ b/MareSynchronosServer/MareSynchronosServer/Migrations/MareDbContextModelSnapshot.cs @@ -155,6 +155,38 @@ namespace MareSynchronosServer.Migrations b.ToTable("forbidden_upload_entries", (string)null); }); + modelBuilder.Entity("MareSynchronosServer.Models.LodeStoneAuth", b => + { + b.Property("DiscordId") + .ValueGeneratedOnAdd() + .HasColumnType("numeric(20,0)") + .HasColumnName("discord_id"); + + b.Property("HashedLodestoneId") + .HasColumnType("text") + .HasColumnName("hashed_lodestone_id"); + + b.Property("LodestoneAuthString") + .HasColumnType("text") + .HasColumnName("lodestone_auth_string"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("started_at"); + + b.Property("UserUID") + .HasColumnType("character varying(10)") + .HasColumnName("user_uid"); + + b.HasKey("DiscordId") + .HasName("pk_lodestone_auth"); + + b.HasIndex("UserUID") + .HasDatabaseName("ix_lodestone_auth_user_uid"); + + b.ToTable("lodestone_auth", (string)null); + }); + modelBuilder.Entity("MareSynchronosServer.Models.User", b => { b.Property("UID") @@ -233,6 +265,16 @@ namespace MareSynchronosServer.Migrations b.Navigation("Uploader"); }); + + modelBuilder.Entity("MareSynchronosServer.Models.LodeStoneAuth", b => + { + b.HasOne("MareSynchronosServer.Models.User", "User") + .WithMany() + .HasForeignKey("UserUID") + .HasConstraintName("fk_lodestone_auth_users_user_uid"); + + b.Navigation("User"); + }); #pragma warning restore 612, 618 } } diff --git a/MareSynchronosServer/MareSynchronosServer/Models/LodeStoneAuth.cs b/MareSynchronosServer/MareSynchronosServer/Models/LodeStoneAuth.cs new file mode 100644 index 0000000..af2c1ba --- /dev/null +++ b/MareSynchronosServer/MareSynchronosServer/Models/LodeStoneAuth.cs @@ -0,0 +1,15 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace MareSynchronosServer.Models +{ + public class LodeStoneAuth + { + [Key] + public ulong DiscordId { get; set; } + public string HashedLodestoneId { get; set; } + public string? LodestoneAuthString { get; set; } + public User? User { get; set; } + public DateTime? StartedAt { get; set; } + } +} diff --git a/MareSynchronosServer/MareSynchronosServer/Program.cs b/MareSynchronosServer/MareSynchronosServer/Program.cs index dae9fb5..904dc78 100644 --- a/MareSynchronosServer/MareSynchronosServer/Program.cs +++ b/MareSynchronosServer/MareSynchronosServer/Program.cs @@ -37,6 +37,8 @@ namespace MareSynchronosServer user.CharacterIdentification = null; } var looseFiles = context.Files.Where(f => f.Uploaded == false); + var unfinishedRegistrations = context.LodeStoneAuth.Where(c => c.StartedAt != null); + context.RemoveRange(unfinishedRegistrations); context.RemoveRange(looseFiles); context.SaveChanges(); diff --git a/MareSynchronosServer/MareSynchronosServer/Startup.cs b/MareSynchronosServer/MareSynchronosServer/Startup.cs index 24b92d1..419b429 100644 --- a/MareSynchronosServer/MareSynchronosServer/Startup.cs +++ b/MareSynchronosServer/MareSynchronosServer/Startup.cs @@ -16,6 +16,8 @@ using Prometheus; using WebSocketOptions = Microsoft.AspNetCore.Builder.WebSocketOptions; using Microsoft.Extensions.FileProviders; using Microsoft.AspNetCore.Authorization; +using MareSynchronosServer.Discord; +using AspNetCoreRateLimit; namespace MareSynchronosServer { @@ -41,6 +43,13 @@ namespace MareSynchronosServer hubOptions.StreamBufferCapacity = 200; }); + services.AddMemoryCache(); + + services.Configure(Configuration.GetSection("IpRateLimiting")); + services.Configure(Configuration.GetSection("IpRateLimitPolicies")); + + services.AddInMemoryRateLimiting(); + services.AddSingleton(); services.AddSingleton(); services.AddTransient(_ => Configuration); @@ -55,6 +64,7 @@ namespace MareSynchronosServer services.AddHostedService(); services.AddHostedService(provider => provider.GetService()); + services.AddHostedService(); services.AddDatabaseDeveloperPageExceptionFilter(); services.AddAuthentication(options => @@ -63,6 +73,8 @@ namespace MareSynchronosServer }) .AddScheme(SecretKeyAuthenticationHandler.AuthScheme, options => { }); services.AddAuthorization(options => options.FallbackPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build()); + + services.AddSingleton(); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -81,6 +93,8 @@ namespace MareSynchronosServer app.UseHsts(); } + app.UseIpRateLimiting(); + app.UseStaticFiles(); app.UseHttpLogging(); diff --git a/MareSynchronosServer/MareSynchronosServer/appsettings.json b/MareSynchronosServer/MareSynchronosServer/appsettings.json index 2015dd1..fc54e92 100644 --- a/MareSynchronosServer/MareSynchronosServer/appsettings.json +++ b/MareSynchronosServer/MareSynchronosServer/appsettings.json @@ -23,6 +23,8 @@ ] } }, + "DiscordServerUrl": "", + "DiscordBotToken": "", "UnusedFileRetentionPeriodInDays": 7, "PurgeUnusedAccounts": true, "PurgeUnusedAccountsPeriodInDays": 14, @@ -35,7 +37,7 @@ "Certificate": { "Subject": "darkarchon.internet-box.ch", "Store": "My", - "Location": "LocalMachine", + "Location": "LocalMachine" //"AllowInvalid": false // "Path": "", //use path, keypath and password to provide a valid certificate if not using windows key store // "KeyPath": "" @@ -43,5 +45,23 @@ } } } + }, + "IpRateLimiting": { + "EnableEndpointRateLimiting": false, + "StackBlockedRequests": false, + "RealIpHeader": "X-Real-IP", + "ClientIdHeader": "X-ClientId", + "HttpStatusCode": 429, + "IpWhitelist": [ "127.0.0.1", "::1/10", "192.168.0.0/24" ], + "GeneralRules": [ + { + "Endpoint": "*", + "Period": "1s", + "Limit": 2 + } + ] + }, + "IPRateLimitPolicies": { + "IpRules": [] } }