diff --git a/SS14.Auth.Shared/Config/DiscordConfiguration.cs b/SS14.Auth.Shared/Config/DiscordConfiguration.cs new file mode 100644 index 0000000..06acea6 --- /dev/null +++ b/SS14.Auth.Shared/Config/DiscordConfiguration.cs @@ -0,0 +1,9 @@ +namespace SS14.Auth.Shared.Config +{ + public sealed class DiscordConfiguration + { + public string ClientId { get; set; } + public string ClientSecret { get; set; } + public string RedirectUri { get; set; } + } +} diff --git a/SS14.Auth.Shared/Data/ApplicationDbContext.cs b/SS14.Auth.Shared/Data/ApplicationDbContext.cs index a4521ac..f451317 100644 --- a/SS14.Auth.Shared/Data/ApplicationDbContext.cs +++ b/SS14.Auth.Shared/Data/ApplicationDbContext.cs @@ -113,6 +113,7 @@ protected override void OnModelCreating(ModelBuilder builder) public DbSet UserOAuthClients { get; set; } public DbSet PastAccountNames { get; set; } public DbSet AccountLogs { get; set; } + public DbSet DiscordLoginSessions { get; set; } // IS4 configuration. public DbSet Clients { get; set; } @@ -130,4 +131,4 @@ Task IPersistedGrantDbContext.SaveChangesAsync() { return base.SaveChangesAsync(); } -} \ No newline at end of file +} diff --git a/SS14.Auth.Shared/Data/DiscordDataManager.cs b/SS14.Auth.Shared/Data/DiscordDataManager.cs new file mode 100644 index 0000000..b81295c --- /dev/null +++ b/SS14.Auth.Shared/Data/DiscordDataManager.cs @@ -0,0 +1,19 @@ +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; + +namespace SS14.Auth.Shared.Data; + +public sealed class DiscordDataManager +{ + private readonly ApplicationDbContext _db; + + public DiscordDataManager(ApplicationDbContext db) + { + _db = db; + } + + public async Task GetUserByDiscordId(string discordId) + { + return (await _db.Users.SingleOrDefaultAsync(p => p.DiscordId == discordId)); + } +} diff --git a/SS14.Auth.Shared/Data/DiscordLoginSession.cs b/SS14.Auth.Shared/Data/DiscordLoginSession.cs new file mode 100644 index 0000000..3675b7b --- /dev/null +++ b/SS14.Auth.Shared/Data/DiscordLoginSession.cs @@ -0,0 +1,13 @@ +using System; + +namespace SS14.Auth.Shared.Data; + +public class DiscordLoginSession +{ + public Guid Id { get; set; } + + public Guid SpaceUserId { get; set; } + public SpaceUser SpaceUser { get; set; } + + public DateTimeOffset Expires { get; set; } +} diff --git a/SS14.Auth.Shared/Data/DiscordLoginSessionManager.cs b/SS14.Auth.Shared/Data/DiscordLoginSessionManager.cs new file mode 100644 index 0000000..581ad25 --- /dev/null +++ b/SS14.Auth.Shared/Data/DiscordLoginSessionManager.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using SS14.Auth.Shared.Config; + +namespace SS14.Auth.Shared.Data; + +public sealed class DiscordLoginSessionManager +{ + public static readonly TimeSpan DefaultExpireTime = TimeSpan.FromMinutes(5); + + private readonly ApplicationDbContext _db; + private readonly ISystemClock _clock; + private readonly HttpClient _httpClient; + private readonly IOptions _config; + private readonly ILogger _logger; + + public DiscordLoginSessionManager( + ApplicationDbContext db, + ISystemClock clock, + IHttpClientFactory httpClientFactory, + IOptions config, + ILogger logger) + { + _db = db; + _clock = clock; + _httpClient = httpClientFactory.CreateClient(nameof(DiscordLoginSessionManager)); + _config = config; + _logger = logger; + } + + public async Task RegisterNewSession(SpaceUser user, TimeSpan expireTime) + { + var expiresAt = _clock.UtcNow + expireTime; + var session = new DiscordLoginSession + { + SpaceUserId = user.Id, + Expires = expiresAt, + }; + _db.DiscordLoginSessions.Add(session); + await _db.SaveChangesAsync(); + return session; + } + + public async Task GetSessionById(Guid sessionId) + { + var session = await _db.DiscordLoginSessions + .Include(p => p.SpaceUser) + .SingleOrDefaultAsync(s => s.Id == sessionId); + + if (session == null) + { + // Session does not exist. + return null; + } + + if (session.Expires < _clock.UtcNow) + { + // Token expired. + return null; + } + + return session.SpaceUser; + } + + public async Task LinkDiscord(SpaceUser user, string discordCode) + { + var accessToken = await ExchangeDiscordCode(discordCode); + var discordId = await GetDiscordId(accessToken); + user.DiscordId = discordId; + await _db.SaveChangesAsync(); + _logger.LogInformation("User {UserId} linked to {DiscordId} Discord", user.Id, discordId); + } + + private async Task ExchangeDiscordCode(string discordCode) + { + var config = _config.Value; + + var exchangeParams = new List>(5) + { + new("client_id", config.ClientId), + new("client_secret", config.ClientSecret), + new("redirect_uri", config.RedirectUri), + new("grant_type", "authorization_code"), + new("code", discordCode), + }; + var form = new FormUrlEncodedContent(exchangeParams); + var resp = await _httpClient.PostAsync("https://discord.com/api/v10/oauth2/token", form); + resp.EnsureSuccessStatusCode(); + + var data = await resp.Content.ReadFromJsonAsync(); + if (data == null) + throw new InvalidDataException("Response data cannot be null"); + + return data.AccessToken; + } + + private async Task GetDiscordId(string accessToken) + { + var request = new HttpRequestMessage(HttpMethod.Get, "https://discord.com/api/v10/users/@me"); + request.Headers.Add("Authorization", $"Bearer {accessToken}"); + var resp = await _httpClient.SendAsync(request); + resp.EnsureSuccessStatusCode(); + + var data = await resp.Content.ReadFromJsonAsync(); + if (data == null) + throw new InvalidDataException("Response data cannot be null"); + + return data.Id; + } + + private sealed record DiscordExchangeResponse( + [property: JsonPropertyName("access_token")] string AccessToken + ); + + private sealed record DiscordMeResponse( + [property: JsonPropertyName("id")] string Id + ); +} + diff --git a/SS14.Auth.Shared/Data/Migrations/20230218205029_DiscordLinking.Designer.cs b/SS14.Auth.Shared/Data/Migrations/20230218205029_DiscordLinking.Designer.cs new file mode 100644 index 0000000..6074e6f --- /dev/null +++ b/SS14.Auth.Shared/Data/Migrations/20230218205029_DiscordLinking.Designer.cs @@ -0,0 +1,1689 @@ +// +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using SS14.Auth.Shared.Data; + +#nullable disable + +namespace SS14.Auth.Shared.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20230218205029_DiscordLinking")] + partial class DiscordLinking + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "6.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AllowedAccessTokenSigningAlgorithms") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("LastAccessed") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NonEditable") + .HasColumnType("boolean"); + + b.Property("ShowInDiscoveryDocument") + .HasColumnType("boolean"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiResources", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiResourceClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiResourceId") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ApiResourceId"); + + b.ToTable("ApiResourceClaims", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiResourceProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiResourceId") + .HasColumnType("integer"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("ApiResourceId"); + + b.ToTable("ApiResourceProperties", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiResourceScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiResourceId") + .HasColumnType("integer"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ApiResourceId"); + + b.ToTable("ApiResourceScopes", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiResourceSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ApiResourceId") + .HasColumnType("integer"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ApiResourceId"); + + b.ToTable("ApiResourceSecrets", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Emphasize") + .HasColumnType("boolean"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("ShowInDiscoveryDocument") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ApiScopes", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiScopeClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.ToTable("ApiScopeClaims", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiScopeProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Key") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("ScopeId") + .HasColumnType("integer"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("ScopeId"); + + b.ToTable("ApiScopeProperties", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.Client", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AbsoluteRefreshTokenLifetime") + .HasColumnType("integer"); + + b.Property("AccessTokenLifetime") + .HasColumnType("integer"); + + b.Property("AccessTokenType") + .HasColumnType("integer"); + + b.Property("AllowAccessTokensViaBrowser") + .HasColumnType("boolean"); + + b.Property("AllowOfflineAccess") + .HasColumnType("boolean"); + + b.Property("AllowPlainTextPkce") + .HasColumnType("boolean"); + + b.Property("AllowRememberConsent") + .HasColumnType("boolean"); + + b.Property("AllowedIdentityTokenSigningAlgorithms") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("AlwaysIncludeUserClaimsInIdToken") + .HasColumnType("boolean"); + + b.Property("AlwaysSendClientClaims") + .HasColumnType("boolean"); + + b.Property("AuthorizationCodeLifetime") + .HasColumnType("integer"); + + b.Property("BackChannelLogoutSessionRequired") + .HasColumnType("boolean"); + + b.Property("BackChannelLogoutUri") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ClientClaimsPrefix") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientUri") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("ConsentLifetime") + .HasColumnType("integer"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DeviceCodeLifetime") + .HasColumnType("integer"); + + b.Property("EnableLocalLogin") + .HasColumnType("boolean"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("FrontChannelLogoutSessionRequired") + .HasColumnType("boolean"); + + b.Property("FrontChannelLogoutUri") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("IdentityTokenLifetime") + .HasColumnType("integer"); + + b.Property("IncludeJwtId") + .HasColumnType("boolean"); + + b.Property("LastAccessed") + .HasColumnType("timestamp with time zone"); + + b.Property("LogoUri") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("NonEditable") + .HasColumnType("boolean"); + + b.Property("PairWiseSubjectSalt") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProtocolType") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RefreshTokenExpiration") + .HasColumnType("integer"); + + b.Property("RefreshTokenUsage") + .HasColumnType("integer"); + + b.Property("RequireClientSecret") + .HasColumnType("boolean"); + + b.Property("RequireConsent") + .HasColumnType("boolean"); + + b.Property("RequirePkce") + .HasColumnType("boolean"); + + b.Property("RequireRequestObject") + .HasColumnType("boolean"); + + b.Property("SlidingRefreshTokenLifetime") + .HasColumnType("integer"); + + b.Property("UpdateAccessTokenClaimsOnRefresh") + .HasColumnType("boolean"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone"); + + b.Property("UserCodeType") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UserSsoLifetime") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("Clients", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ClientClaims", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientCorsOrigin", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("Origin") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ClientCorsOrigins", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientGrantType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("GrantType") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ClientGrantTypes", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientIdPRestriction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ClientIdPRestrictions", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientPostLogoutRedirectUri", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("PostLogoutRedirectUri") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ClientPostLogoutRedirectUris", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ClientProperties", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientRedirectUri", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("RedirectUri") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ClientRedirectUris", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("Scope") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ClientScopes", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientSecret", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ClientSecrets", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.DeviceFlowCodes", b => + { + b.Property("UserCode") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasMaxLength(50000) + .HasColumnType("character varying(50000)"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DeviceCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Expiration") + .IsRequired() + .HasColumnType("timestamp with time zone"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("UserCode"); + + b.HasIndex("DeviceCode") + .IsUnique(); + + b.HasIndex("Expiration"); + + b.ToTable("DeviceCodes", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.IdentityResource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("DisplayName") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Emphasize") + .HasColumnType("boolean"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("NonEditable") + .HasColumnType("boolean"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("ShowInDiscoveryDocument") + .HasColumnType("boolean"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("IdentityResources", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.IdentityResourceClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IdentityResourceId") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("IdentityResourceId"); + + b.ToTable("IdentityResourceClaims", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.IdentityResourceProperty", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("IdentityResourceId") + .HasColumnType("integer"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.HasKey("Id"); + + b.HasIndex("IdentityResourceId"); + + b.ToTable("IdentityResourceProperties", "IS4"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.PersistedGrant", b => + { + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ConsumedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("CreationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .IsRequired() + .HasMaxLength(50000) + .HasColumnType("character varying(50000)"); + + b.Property("Description") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Expiration") + .HasColumnType("timestamp with time zone"); + + b.Property("SessionId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SubjectId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Key"); + + b.HasIndex("Expiration"); + + b.HasIndex("SubjectId", "ClientId", "Type"); + + b.HasIndex("SubjectId", "SessionId", "Type"); + + b.ToTable("PersistedGrants", "IS4"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.AccountLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Data") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SpaceUserId") + .HasColumnType("uuid"); + + b.Property("Time") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SpaceUserId"); + + b.ToTable("AccountLogs"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.AuthHash", b => + { + b.Property("AuthHashId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("AuthHashId")); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("SpaceUserId") + .HasColumnType("uuid"); + + b.HasKey("AuthHashId"); + + b.HasIndex("SpaceUserId"); + + b.HasIndex("Hash", "SpaceUserId") + .IsUnique(); + + b.ToTable("AuthHashes"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.BurnerEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Domain") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Domain") + .IsUnique(); + + b.ToTable("BurnerEmails"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.DiscordLoginSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("SpaceUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SpaceUserId"); + + b.ToTable("DiscordLoginSessions"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.LoginSession", b => + { + b.Property("LoginSessionId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("LoginSessionId")); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("SpaceUserId") + .HasColumnType("uuid"); + + b.Property("Token") + .IsRequired() + .HasColumnType("bytea"); + + b.HasKey("LoginSessionId"); + + b.HasIndex("SpaceUserId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("ActiveSessions"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.PastAccountName", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ChangeTime") + .HasColumnType("timestamp with time zone"); + + b.Property("PastName") + .IsRequired() + .HasColumnType("text"); + + b.Property("SpaceUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SpaceUserId"); + + b.ToTable("PastAccountNames"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.PatreonWebhookLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .HasColumnType("jsonb"); + + b.Property("Time") + .HasColumnType("timestamp with time zone"); + + b.Property("Trigger") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("PatreonWebhookLogs"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.Patron", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CurrentTier") + .HasColumnType("text"); + + b.Property("PatreonId") + .IsRequired() + .HasColumnType("text"); + + b.Property("SpaceUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PatreonId") + .IsUnique(); + + b.HasIndex("SpaceUserId") + .IsUnique(); + + b.ToTable("Patrons"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.SpaceRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.SpaceUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DiscordId") + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.UserOAuthClient", b => + { + b.Property("UserOAuthClientId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("UserOAuthClientId")); + + b.Property("ClientId") + .HasColumnType("integer"); + + b.Property("SpaceUserId") + .HasColumnType("uuid"); + + b.HasKey("UserOAuthClientId"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.HasIndex("SpaceUserId"); + + b.ToTable("UserOAuthClients"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiResourceClaim", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.ApiResource", "ApiResource") + .WithMany("UserClaims") + .HasForeignKey("ApiResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApiResource"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiResourceProperty", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.ApiResource", "ApiResource") + .WithMany("Properties") + .HasForeignKey("ApiResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApiResource"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiResourceScope", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.ApiResource", "ApiResource") + .WithMany("Scopes") + .HasForeignKey("ApiResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApiResource"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiResourceSecret", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.ApiResource", "ApiResource") + .WithMany("Secrets") + .HasForeignKey("ApiResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ApiResource"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiScopeClaim", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.ApiScope", "Scope") + .WithMany("UserClaims") + .HasForeignKey("ScopeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiScopeProperty", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.ApiScope", "Scope") + .WithMany("Properties") + .HasForeignKey("ScopeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Scope"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientClaim", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.Client", "Client") + .WithMany("Claims") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientCorsOrigin", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.Client", "Client") + .WithMany("AllowedCorsOrigins") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientGrantType", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.Client", "Client") + .WithMany("AllowedGrantTypes") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientIdPRestriction", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.Client", "Client") + .WithMany("IdentityProviderRestrictions") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientPostLogoutRedirectUri", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.Client", "Client") + .WithMany("PostLogoutRedirectUris") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientProperty", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.Client", "Client") + .WithMany("Properties") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientRedirectUri", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.Client", "Client") + .WithMany("RedirectUris") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientScope", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.Client", "Client") + .WithMany("AllowedScopes") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ClientSecret", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.Client", "Client") + .WithMany("ClientSecrets") + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.IdentityResourceClaim", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.IdentityResource", "IdentityResource") + .WithMany("UserClaims") + .HasForeignKey("IdentityResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("IdentityResource"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.IdentityResourceProperty", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.IdentityResource", "IdentityResource") + .WithMany("Properties") + .HasForeignKey("IdentityResourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("IdentityResource"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("SS14.Auth.Shared.Data.SpaceRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("SS14.Auth.Shared.Data.SpaceUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("SS14.Auth.Shared.Data.SpaceUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("SS14.Auth.Shared.Data.SpaceRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SS14.Auth.Shared.Data.SpaceUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("SS14.Auth.Shared.Data.SpaceUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.AccountLog", b => + { + b.HasOne("SS14.Auth.Shared.Data.SpaceUser", "SpaceUser") + .WithMany("AccountLogs") + .HasForeignKey("SpaceUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SpaceUser"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.AuthHash", b => + { + b.HasOne("SS14.Auth.Shared.Data.SpaceUser", "SpaceUser") + .WithMany("AuthHashes") + .HasForeignKey("SpaceUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SpaceUser"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.DiscordLoginSession", b => + { + b.HasOne("SS14.Auth.Shared.Data.SpaceUser", "SpaceUser") + .WithMany() + .HasForeignKey("SpaceUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SpaceUser"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.LoginSession", b => + { + b.HasOne("SS14.Auth.Shared.Data.SpaceUser", "SpaceUser") + .WithMany("LoginSessions") + .HasForeignKey("SpaceUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SpaceUser"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.PastAccountName", b => + { + b.HasOne("SS14.Auth.Shared.Data.SpaceUser", "SpaceUser") + .WithMany("PastAccountNames") + .HasForeignKey("SpaceUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SpaceUser"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.Patron", b => + { + b.HasOne("SS14.Auth.Shared.Data.SpaceUser", "SpaceUser") + .WithOne("Patron") + .HasForeignKey("SS14.Auth.Shared.Data.Patron", "SpaceUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SpaceUser"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.UserOAuthClient", b => + { + b.HasOne("IdentityServer4.EntityFramework.Entities.Client", "Client") + .WithMany() + .HasForeignKey("ClientId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("SS14.Auth.Shared.Data.SpaceUser", "SpaceUser") + .WithMany() + .HasForeignKey("SpaceUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Client"); + + b.Navigation("SpaceUser"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiResource", b => + { + b.Navigation("Properties"); + + b.Navigation("Scopes"); + + b.Navigation("Secrets"); + + b.Navigation("UserClaims"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.ApiScope", b => + { + b.Navigation("Properties"); + + b.Navigation("UserClaims"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.Client", b => + { + b.Navigation("AllowedCorsOrigins"); + + b.Navigation("AllowedGrantTypes"); + + b.Navigation("AllowedScopes"); + + b.Navigation("Claims"); + + b.Navigation("ClientSecrets"); + + b.Navigation("IdentityProviderRestrictions"); + + b.Navigation("PostLogoutRedirectUris"); + + b.Navigation("Properties"); + + b.Navigation("RedirectUris"); + }); + + modelBuilder.Entity("IdentityServer4.EntityFramework.Entities.IdentityResource", b => + { + b.Navigation("Properties"); + + b.Navigation("UserClaims"); + }); + + modelBuilder.Entity("SS14.Auth.Shared.Data.SpaceUser", b => + { + b.Navigation("AccountLogs"); + + b.Navigation("AuthHashes"); + + b.Navigation("LoginSessions"); + + b.Navigation("PastAccountNames"); + + b.Navigation("Patron"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/SS14.Auth.Shared/Data/Migrations/20230218205029_DiscordLinking.cs b/SS14.Auth.Shared/Data/Migrations/20230218205029_DiscordLinking.cs new file mode 100644 index 0000000..3b519ce --- /dev/null +++ b/SS14.Auth.Shared/Data/Migrations/20230218205029_DiscordLinking.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace SS14.Auth.Shared.Data.Migrations +{ + public partial class DiscordLinking : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DiscordId", + table: "AspNetUsers", + type: "text", + nullable: true); + + migrationBuilder.CreateTable( + name: "DiscordLoginSessions", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SpaceUserId = table.Column(type: "uuid", nullable: false), + Expires = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DiscordLoginSessions", x => x.Id); + table.ForeignKey( + name: "FK_DiscordLoginSessions_AspNetUsers_SpaceUserId", + column: x => x.SpaceUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_DiscordLoginSessions_SpaceUserId", + table: "DiscordLoginSessions", + column: "SpaceUserId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DiscordLoginSessions"); + + migrationBuilder.DropColumn( + name: "DiscordId", + table: "AspNetUsers"); + } + } +} diff --git a/SS14.Auth.Shared/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/SS14.Auth.Shared/Data/Migrations/ApplicationDbContextModelSnapshot.cs index b52576b..9e63f09 100644 --- a/SS14.Auth.Shared/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/SS14.Auth.Shared/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -1070,6 +1070,25 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("BurnerEmails"); }); + modelBuilder.Entity("SS14.Auth.Shared.Data.DiscordLoginSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("SpaceUserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SpaceUserId"); + + b.ToTable("DiscordLoginSessions"); + }); + modelBuilder.Entity("SS14.Auth.Shared.Data.LoginSession", b => { b.Property("LoginSessionId") @@ -1214,6 +1233,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CreatedTime") .HasColumnType("timestamp with time zone"); + b.Property("DiscordId") + .HasColumnType("text"); + b.Property("Email") .HasMaxLength(256) .HasColumnType("character varying(256)"); @@ -1538,6 +1560,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("SpaceUser"); }); + modelBuilder.Entity("SS14.Auth.Shared.Data.DiscordLoginSession", b => + { + b.HasOne("SS14.Auth.Shared.Data.SpaceUser", "SpaceUser") + .WithMany() + .HasForeignKey("SpaceUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SpaceUser"); + }); + modelBuilder.Entity("SS14.Auth.Shared.Data.LoginSession", b => { b.HasOne("SS14.Auth.Shared.Data.SpaceUser", "SpaceUser") diff --git a/SS14.Auth.Shared/Data/SpaceUser.cs b/SS14.Auth.Shared/Data/SpaceUser.cs index 44b032b..5692da4 100644 --- a/SS14.Auth.Shared/Data/SpaceUser.cs +++ b/SS14.Auth.Shared/Data/SpaceUser.cs @@ -14,6 +14,7 @@ public class SpaceUser : IdentityUser public List AuthHashes { get; set; } = new List(); public Patron Patron { get; set; } + public string DiscordId { get; set; } public List PastAccountNames { get; set; } = default!; public List AccountLogs { get; set; } = default!; @@ -98,4 +99,4 @@ public enum AccountLogType AuthenticatorEnabled, AuthenticatorDisabled, RecoveryCodesGenerated, -} \ No newline at end of file +} diff --git a/SS14.Auth.Shared/StartupHelpers.cs b/SS14.Auth.Shared/StartupHelpers.cs index 7f77131..215f551 100644 --- a/SS14.Auth.Shared/StartupHelpers.cs +++ b/SS14.Auth.Shared/StartupHelpers.cs @@ -27,6 +27,7 @@ public static void AddShared(IServiceCollection services, IConfiguration config) services.Configure(config.GetSection("Limits")); services.Configure(config.GetSection("Mutex")); services.Configure(config.GetSection("Patreon")); + services.Configure(config.GetSection("Discord")); services.Configure(options => { // The fact that this isn't default absolutely baffles me. @@ -75,6 +76,8 @@ public static void AddShared(IServiceCollection services, IConfiguration config) services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddTransient(_ => RandomNumberGenerator.Create()); @@ -119,4 +122,4 @@ private sealed class LokiConfigurationData public string Username { get; set; } public string Password { get; set; } } -} \ No newline at end of file +} diff --git a/SS14.Auth/Controllers/DiscordLinkController.cs b/SS14.Auth/Controllers/DiscordLinkController.cs new file mode 100644 index 0000000..213fc86 --- /dev/null +++ b/SS14.Auth/Controllers/DiscordLinkController.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using SS14.Auth.Shared.Data; + +namespace SS14.Auth.Controllers; + +[ApiController] +[Route("/api/discord")] +public class DiscordLinkController : ControllerBase +{ + private readonly SpaceUserManager _userManager; + private readonly DiscordLoginSessionManager _loginSessionManager; + + public DiscordLinkController( + SpaceUserManager userManager, + DiscordLoginSessionManager loginSessionManager) + { + _userManager = userManager; + _loginSessionManager = loginSessionManager; + } + + [Authorize(AuthenticationSchemes = "SS14Auth")] + [HttpPost("session")] + public async Task Generate() + { + var user = await _userManager.GetUserAsync(User); + var session = await _loginSessionManager.RegisterNewSession(user, DiscordLoginSessionManager.DefaultExpireTime); + return Ok(new DiscordSessionResponse(session.Id, session.Expires)); + } + + [HttpGet("callback")] + public async Task Callback(Guid state, string code) + { + var user = await _loginSessionManager.GetSessionById(state); + + if (user == null) + return NotFound("Session not exist or expired"); + + if (user.DiscordId != null) + return BadRequest("⚠️ You already linked Discord with you account.\nAccount can be unlinked in account settings."); + + await _loginSessionManager.LinkDiscord(user, code); + + return Ok("✅ Discord successfully linked to your account!\nYou can now close this page and return to launcher."); + } +} + +public sealed record DiscordSessionResponse(Guid SessionId, DateTimeOffset ExpireTime) +{ +} diff --git a/SS14.Auth/Controllers/QueryApiController.cs b/SS14.Auth/Controllers/QueryApiController.cs index 096b3b7..50a667f 100644 --- a/SS14.Auth/Controllers/QueryApiController.cs +++ b/SS14.Auth/Controllers/QueryApiController.cs @@ -13,11 +13,13 @@ public class QueryApiController : ControllerBase { private readonly UserManager _userManager; private readonly PatreonDataManager _patreonDataManager; + private readonly DiscordDataManager _discordDataManager; - public QueryApiController(UserManager userManager, PatreonDataManager patreonDataManager) + public QueryApiController(UserManager userManager, PatreonDataManager patreonDataManager, DiscordDataManager discordDataManager) { _userManager = userManager; _patreonDataManager = patreonDataManager; + _discordDataManager = discordDataManager; } [HttpGet("name")] @@ -36,13 +38,22 @@ public async Task QueryByUserId(Guid userId) return await DoResponse(user); } + [HttpGet("discord")] + [HttpHead("discord")] + public async Task QueryByDiscordId(string discordId) + { + var user = await _discordDataManager.GetUserByDiscordId(discordId); + return await DoResponse(user); + } + internal static async Task BuildUserResponse( PatreonDataManager patreonDataManager, + DiscordDataManager discordDataManager, SpaceUser user) { var patronTier = await patreonDataManager.GetPatreonTierAsync(user); - - return new QueryUserResponse(user.UserName!, user.Id, patronTier, user.CreatedTime); + + return new QueryUserResponse(user.UserName!, user.Id, patronTier, user.DiscordId, user.CreatedTime); } private async Task DoResponse(SpaceUser? user) @@ -50,6 +61,6 @@ private async Task DoResponse(SpaceUser? user) if (user == null) return NotFound(); - return Ok(await BuildUserResponse(_patreonDataManager, user)); + return Ok(await BuildUserResponse(_patreonDataManager, _discordDataManager, user)); } -} \ No newline at end of file +} diff --git a/SS14.Auth/Controllers/SessionApiController.cs b/SS14.Auth/Controllers/SessionApiController.cs index c1047ef..7cdfa69 100644 --- a/SS14.Auth/Controllers/SessionApiController.cs +++ b/SS14.Auth/Controllers/SessionApiController.cs @@ -22,6 +22,7 @@ public class SessionApiController : ControllerBase private readonly SpaceUserManager _userManager; private readonly ApplicationDbContext _dbContext; private readonly PatreonDataManager _patreonDataManager; + private readonly DiscordDataManager _discordDataManager; private readonly ISystemClock _clock; public SessionApiController( @@ -29,13 +30,15 @@ public SessionApiController( SpaceUserManager userManager, ApplicationDbContext dbContext, ISystemClock clock, - PatreonDataManager patreonDataManager) + PatreonDataManager patreonDataManager, + DiscordDataManager discordDataManager) { _configuration = configuration; _userManager = userManager; _dbContext = dbContext; _clock = clock; _patreonDataManager = patreonDataManager; + _discordDataManager = discordDataManager; } [Authorize(AuthenticationSchemes = "SS14Auth")] @@ -84,7 +87,7 @@ public async Task HasJoined(Guid userId, string hash) } var userResponse = await QueryApiController.BuildUserResponse( - _patreonDataManager, authHash.SpaceUser); + _patreonDataManager, _discordDataManager, authHash.SpaceUser); var resp = new HasJoinedResponse(true, userResponse); @@ -102,4 +105,4 @@ public sealed record JoinRequest(string Hash) public sealed record HasJoinedResponse(bool IsValid, QueryUserResponse? UserData) { } -} \ No newline at end of file +} diff --git a/SS14.Auth/Responses/QueryUserResponse.cs b/SS14.Auth/Responses/QueryUserResponse.cs index a1c35ed..ee88cc8 100644 --- a/SS14.Auth/Responses/QueryUserResponse.cs +++ b/SS14.Auth/Responses/QueryUserResponse.cs @@ -6,6 +6,7 @@ public sealed record QueryUserResponse( string UserName, Guid UserId, string? PatronTier, + string? DiscordId, DateTimeOffset CreatedTime) { -} \ No newline at end of file +} diff --git a/SS14.Auth/Startup.cs b/SS14.Auth/Startup.cs index 7c29f9a..f4f91a5 100644 --- a/SS14.Auth/Startup.cs +++ b/SS14.Auth/Startup.cs @@ -9,6 +9,7 @@ using SS14.Auth.Services; using SS14.Auth.Shared; using SS14.Auth.Shared.Auth; +using SS14.Auth.Shared.Data; namespace SS14.Auth; @@ -38,6 +39,7 @@ public void ConfigureServices(IServiceCollection services) .AddScheme("SS14Auth", _ => {}); services.AddHostedService(); + services.AddHttpClient(nameof(DiscordLoginSessionManager)); StartupHelpers.AddShared(services, Configuration); } @@ -69,4 +71,4 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) endpoints.MapMetrics(); }); } -} \ No newline at end of file +} diff --git a/SS14.Auth/appsettings.yml b/SS14.Auth/appsettings.yml index 6b5417a..b821f73 100644 --- a/SS14.Auth/appsettings.yml +++ b/SS14.Auth/appsettings.yml @@ -21,4 +21,10 @@ Serilog: AllowedHosts: "*" -WebBaseUrl: "https://localhost:5001/" \ No newline at end of file +WebBaseUrl: "https://localhost:5001/" + +Discord: + ClientId: "" + ClientSecret: "" + RedirectUri: "https://mysite.com/api/discord/callback" + diff --git a/SS14.Web/Areas/Identity/Pages/Account/Manage/ManageDiscord.cshtml b/SS14.Web/Areas/Identity/Pages/Account/Manage/ManageDiscord.cshtml new file mode 100644 index 0000000..f8e3741 --- /dev/null +++ b/SS14.Web/Areas/Identity/Pages/Account/Manage/ManageDiscord.cshtml @@ -0,0 +1,33 @@ +@page +@model ManageDiscord + +@{ + ViewData["Title"] = "Discord"; + ViewData["ActivePage"] = ManageNavPages.ManageDiscord; +} + +

@ViewData["Title"]

+ +
+
+ @if (Model.DiscordLinked) + { +

+ Your Discord account is linked to your Space Station 14 account.
+

+ + + } + else + { +

+ You can link your Discord account with your Space Station 14 account here to allow servers verify you. +

+ + } +
+
\ No newline at end of file diff --git a/SS14.Web/Areas/Identity/Pages/Account/Manage/ManageDiscord.cshtml.cs b/SS14.Web/Areas/Identity/Pages/Account/Manage/ManageDiscord.cshtml.cs new file mode 100644 index 0000000..27ef094 --- /dev/null +++ b/SS14.Web/Areas/Identity/Pages/Account/Manage/ManageDiscord.cshtml.cs @@ -0,0 +1,76 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Logging; +using SS14.Auth.Shared.Data; + +namespace SS14.Web.Areas.Identity.Pages.Account.Manage +{ + public class ManageDiscord : PageModel + { + private readonly UserManager _userManager; + private readonly ILogger _logger; + private readonly ApplicationDbContext _db; + + public bool DiscordLinked { get; private set; } + + public ManageDiscord( + UserManager userManager, + ILogger logger, + ApplicationDbContext db) + { + _userManager = userManager; + _logger = logger; + _db = db; + } + + public async Task OnGet() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + DiscordLinked = user.DiscordId != null; + + return Page(); + } + + public async Task OnPostUnlinkDiscordAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + user.DiscordId = null; + await _db.SaveChangesAsync(); + + return RedirectToPage(); + } + + public async Task OnPostLinkDiscordAsync() + { + var user = await _userManager.GetUserAsync(User); + if (user == null) + { + return NotFound($"Unable to load user with ID '{_userManager.GetUserId(User)}'."); + } + + var redirect = Url.Page("./ManageDiscord"); + + return Challenge(new AuthenticationProperties + { + Items = + { + ["SS14UserId"] = user.Id.ToString(), + }, + RedirectUri = redirect + }, "Discord"); + } + } +} diff --git a/SS14.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs b/SS14.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs index 7114517..5f1c0b8 100644 --- a/SS14.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs +++ b/SS14.Web/Areas/Identity/Pages/Account/Manage/ManageNavPages.cs @@ -16,6 +16,7 @@ public static class ManageNavPages public const string ExternalLogins = "ExternalLogins"; public const string PersonalData = "PersonalData"; public const string TwoFactorAuthentication = "TwoFactorAuthentication"; + public const string ManageDiscord = "ManageDiscord"; public const string ManagePatreon = "ManagePatreon"; public const string Developer = "Developer"; @@ -43,6 +44,7 @@ public static string PersonalDataNavClass(ViewContext viewContext) public static string TwoFactorAuthenticationNavClass(ViewContext viewContext) => PageNavClass(viewContext, TwoFactorAuthentication); + public static string DiscordNavClass(ViewContext viewContext) => PageNavClass(viewContext, ManageDiscord); public static string PatreonNavClass(ViewContext viewContext) => PageNavClass(viewContext, ManagePatreon); public static string DeveloperNavClass(ViewContext viewContext) => PageNavClass(viewContext, Developer); @@ -52,4 +54,4 @@ private static string PageNavClass(ViewContext viewContext, string page) ?? System.IO.Path.GetFileNameWithoutExtension(viewContext.ActionDescriptor.DisplayName); return string.Equals(activePage, page, StringComparison.OrdinalIgnoreCase) ? "active" : null; } -} \ No newline at end of file +} diff --git a/SS14.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml b/SS14.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml index ff44670..b51fddc 100644 --- a/SS14.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml +++ b/SS14.Web/Areas/Identity/Pages/Account/Manage/_ManageNav.cshtml @@ -14,6 +14,7 @@ } + diff --git a/SS14.Web/DiscordConnectionHandler.cs b/SS14.Web/DiscordConnectionHandler.cs new file mode 100644 index 0000000..2439c4a --- /dev/null +++ b/SS14.Web/DiscordConnectionHandler.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OAuth; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using SS14.Auth.Shared.Data; + +namespace SS14.Web +{ + public sealed class DiscordConnectionHandler + { + private readonly UserManager _userManager; + private readonly ApplicationDbContext _db; + + public DiscordConnectionHandler( + UserManager userManager, + ApplicationDbContext db) + { + _userManager = userManager; + _db = db; + } + public async Task HookReceivedTicket(TicketReceivedContext context) + { + var guid = context.Properties.Items["SS14UserId"]!; + var user = await _userManager.FindByIdAsync(guid); + if (user == null) + { + throw new InvalidOperationException("Unable to find user on hook"); + } + + var discordId = context.Principal!.Claims.First(p => p.Type == ClaimTypes.NameIdentifier).Value; + + // Relinking + var currentOwner = await _db.Users.FirstOrDefaultAsync(a => a.DiscordId == discordId); + if (currentOwner != null) + currentOwner.DiscordId = null; + + user.DiscordId = discordId; + + await _db.SaveChangesAsync(); + + if (context.ReturnUri != null) + { + context.HttpContext.Response.Redirect(context.ReturnUri); + } + + context.HandleResponse(); + } + } +} diff --git a/SS14.Web/SS14.Web.csproj b/SS14.Web/SS14.Web.csproj index a70f1af..a7e98ff 100644 --- a/SS14.Web/SS14.Web.csproj +++ b/SS14.Web/SS14.Web.csproj @@ -9,6 +9,7 @@ + diff --git a/SS14.Web/Startup.cs b/SS14.Web/Startup.cs index 990e28f..038beae 100644 --- a/SS14.Web/Startup.cs +++ b/SS14.Web/Startup.cs @@ -65,6 +65,7 @@ public void ConfigureServices(IServiceCollection services) services.AddRazorPages(); services.AddScoped(); + services.AddScoped(); var patreonSection = Configuration.GetSection("Patreon"); var patreonCfg = patreonSection.Get(); @@ -98,6 +99,21 @@ public void ConfigureServices(IServiceCollection services) }); } + var discordCfg = Configuration.GetSection("Discord").Get(); + if (discordCfg?.ClientId != null && discordCfg?.ClientSecret != null) + { + services.AddAuthentication() + .AddDiscord("Discord", null!, options => + { + options.ClientId = discordCfg.ClientId; + options.ClientSecret = discordCfg.ClientSecret; + options.Events.OnTicketReceived += context => + { + var handler = context.HttpContext.RequestServices.GetService(); + return handler!.HookReceivedTicket(context); + }; + }); + } var builder = services.AddIdentityServer(options => { @@ -216,4 +232,4 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env) app.UseIdentityServer(); } -} \ No newline at end of file +} diff --git a/SS14.Web/appsettings.yml b/SS14.Web/appsettings.yml index 90b6cef..b848f7c 100644 --- a/SS14.Web/appsettings.yml +++ b/SS14.Web/appsettings.yml @@ -22,4 +22,9 @@ Serilog: ForwardProxies: - 127.0.0.1 -AllowedHosts: "*" \ No newline at end of file +AllowedHosts: "*" + +Discord: + ClientId: "" + ClientSecret: "" + RedirectUri: "https://mysite.com/signin-discord"