From 51d234d87137b9eca07cf728749d108daedf432e Mon Sep 17 00:00:00 2001 From: Lucas Morgan Date: Sun, 25 Aug 2024 03:46:44 +0000 Subject: [PATCH] Replace custom socket implementation with SignalR. Replace MSAL and custom cookie auth with Microsoft.Identity.EntityFramework Also some UI redesign to accommodate different login experience. --- Shogi.Api/ApiKeys.cs | 6 + Shogi.Api/Application/GameHub.cs | 19 + Shogi.Api/Application/GameHubContext.cs | 21 + Shogi.Api/Application/ShogiApplication.cs | 143 +++++++ Shogi.Api/Controllers/AccountController.cs | 74 ++++ Shogi.Api/Controllers/Extentions.cs | 11 + Shogi.Api/Controllers/SessionsController.cs | 240 +++++------- Shogi.Api/Controllers/UserController.cs | 94 ----- .../ExampleAnonymousSessionMiddleware.cs | 39 -- Shogi.Api/Extensions/ClaimsExtensions.cs | 30 -- Shogi.Api/Extensions/LogMiddleware.cs | 50 --- Shogi.Api/Identity/ApplicationDbContext.cs | 8 + Shogi.Api/Identity/ShogiUser.cs | 7 + Shogi.Api/Managers/ModelMapper.cs | 86 ----- Shogi.Api/Managers/SocketConnectionManager.cs | 89 ----- Shogi.Api/Managers/SocketTokenCache.cs | 54 --- .../20240816002834_InitialCreate.Designer.cs | 279 ++++++++++++++ .../20240816002834_InitialCreate.cs | 224 +++++++++++ .../ApplicationDbContextModelSnapshot.cs | 276 +++++++++++++ Shogi.Api/Models/User.cs | 39 -- Shogi.Api/Models/WhichLoginPlatform.cs | 9 - Shogi.Api/Program.cs | 326 +++++----------- Shogi.Api/Repositories/Dto/SessionDto.cs | 2 +- Shogi.Api/Repositories/EmailSender.cs | 54 +++ Shogi.Api/Repositories/QueryRepository.cs | 67 +--- Shogi.Api/Repositories/SessionRepository.cs | 168 ++++---- Shogi.Api/Repositories/UserRepository.cs | 52 --- Shogi.Api/Services/SocketService.cs | 104 ----- Shogi.Api/Shogi.Api.csproj | 12 +- Shogi.Api/ShogiUserClaimsTransformer.cs | 104 ----- Shogi.Api/appsettings.Development.json | 18 +- Shogi.Api/appsettings.json | 53 ++- .../Api/Commands/CreateGuestTokenResponse.cs | 17 - .../Api/Commands/CreateSessionCommand.cs | 9 - .../Api/Commands/CreateTokenResponse.cs | 10 - .../Api/Commands/MovePieceCommand.cs | 28 +- .../Api/Queries/ReadAllSessionsResponse.cs | 10 - .../Api/Queries/ReadSessionResponse.cs | 8 - Shogi.Contracts/Shogi.Contracts.csproj | 4 + Shogi.Contracts/Socket/ISocketMessage.cs | 13 - .../Socket/PlayerHasMovedMessage.cs | 20 - .../Socket/SessionCreatedSocketMessage.cs | 8 - .../SessionJoinedByPlayerSocketMessage.cs | 15 - Shogi.Contracts/Types/Session.cs | 19 +- Shogi.Contracts/Types/SessionMetadata.cs | 15 +- Shogi.Contracts/Types/SocketAction.cs | 9 - Shogi.Contracts/Types/User.cs | 17 - Shogi.Database/AspNetUsersId.sql | 3 + Shogi.Database/FirstTimeSetup.sql | 9 +- .../Post Deployment/Script.PostDeployment.sql | 1 - .../Scripts/PopulateLoginPlatforms.sql | 16 - .../Functions/MaxNewSessionsPerUser.sql | 18 + .../Session/Stored Procedures/CreateMove.sql | 13 +- .../Stored Procedures/CreateSession.sql | 13 +- .../Stored Procedures/DeleteSession.sql | 4 +- .../Session/Stored Procedures/ReadSession.sql | 17 +- .../ReadSessionPlayerCount.sql | 31 -- .../ReadSessionsMetadata.sql | 21 + .../Stored Procedures/ReadUsersBySession.sql | 13 - .../Session/Stored Procedures/SetPlayer2.sql | 17 +- Shogi.Database/Session/Tables/Move.sql | 10 +- Shogi.Database/Session/Tables/Session.sql | 18 +- Shogi.Database/Session/Types/SessionName.sql | 2 - .../Session/Types/SessionSurrogateKey.sql | 2 + Shogi.Database/Shogi.Database.refactorlog | 17 + Shogi.Database/Shogi.Database.sqlproj | 22 +- .../User/StoredProcedures/CreateUser.sql | 14 - .../User/StoredProcedures/ReadUser.sql | 11 - Shogi.Database/User/Tables/LoginPlatform.sql | 4 - Shogi.Database/User/Tables/User.sql | 12 - Shogi.Database/User/Types/UserName.sql | 2 - Shogi.Database/User/User.sql | 1 - Shogi.Domain/Aggregates/Session.cs | 53 ++- Shogi.UI/App.razor | 2 +- .../CookieAuthenticationStateProvider.cs | 246 ++++++++++++ Shogi.UI/Identity/CookieMessageHandler.cs | 14 + Shogi.UI/Identity/FormResult.cs | 14 + Shogi.UI/Identity/IAccountManagement.cs | 31 ++ Shogi.UI/Identity/UserInfo.cs | 22 ++ Shogi.UI/Layout/MainLayout.razor | 7 + Shogi.UI/Layout/MainLayout.razor.css | 5 + Shogi.UI/Layout/NavMenu.razor | 52 +++ Shogi.UI/Layout/NavMenu.razor.css | 15 + Shogi.UI/Pages/Authentication.razor | 27 -- Shogi.UI/Pages/FancyErrorPage.razor | 2 +- Shogi.UI/Pages/Home/Account/AccountManager.cs | 136 ------- Shogi.UI/Pages/Home/Account/AccountState.cs | 27 -- .../Home/Account/LocalStorageExtensions.cs | 23 -- Shogi.UI/Pages/Home/Account/LoginEventArgs.cs | 6 - Shogi.UI/Pages/Home/Account/User.cs | 9 - .../Home/Account/WhichAccountPlatform.cs | 8 - .../Pages/Home/Api/CookieMessageHandler.cs | 26 -- Shogi.UI/Pages/Home/Api/IShogiApi.cs | 17 - Shogi.UI/Pages/Home/Api/MsalMessageHandler.cs | 23 -- Shogi.UI/Pages/Home/Api/ShogiApi.cs | 106 ----- Shogi.UI/Pages/Home/GameBoard/GameBoard.razor | 79 ---- .../GameBoard/GameBoardPresentation.razor | 196 ---------- .../GameBoard/GameboardPresentation.razor.css | 143 ------- .../Home/GameBoard/SeatedGameBoard.razor | 121 ------ .../Home/GameBoard/SpectatorGameBoard.razor | 27 -- Shogi.UI/Pages/Home/GameBrowser.razor | 160 -------- Shogi.UI/Pages/Home/GameBrowser.razor.css | 13 - Shogi.UI/Pages/Home/Home.razor | 61 --- Shogi.UI/Pages/Home/Home.razor.css | 23 -- Shogi.UI/Pages/Home/LoginModal.razor | 52 --- Shogi.UI/Pages/Home/LoginModal.razor.css | 21 - Shogi.UI/Pages/Home/PageHeader.razor | 35 -- Shogi.UI/Pages/Home/PageHeader.razor.css | 20 - Shogi.UI/Pages/Home/PromotePrompt.cs | 58 --- Shogi.UI/Pages/HomePage.razor | 26 ++ Shogi.UI/Pages/HomePage.razor.css | 4 + Shogi.UI/Pages/Identity/LoginPage.razor | 72 ++++ Shogi.UI/Pages/Identity/LoginPage.razor.css | 28 ++ Shogi.UI/Pages/Identity/LogoutPage.razor | 30 ++ Shogi.UI/Pages/Identity/RegisterPage.razor | 93 +++++ .../Pages/Identity/RegisterPage.razor.css | 15 + .../GameBoard/EmptyGameBoard.razor | 0 Shogi.UI/Pages/Play/GameBoard/GameBoard.razor | 76 ++++ .../GameBoard/GameBoardPresentation.razor | 183 +++++++++ .../GameBoard/GameboardPresentation.razor.css | 128 +++++++ .../Pages/Play/GameBoard/PlayerName.razor | 21 + .../Play/GameBoard/SeatedGameBoard.razor | 128 +++++++ .../Play/GameBoard/SpectatorGameBoard.razor | 29 ++ Shogi.UI/Pages/Play/GameBrowser.razor | 59 +++ Shogi.UI/Pages/Play/GameBrowser.razor.css | 5 + Shogi.UI/Pages/{Home => Play}/GamePiece.razor | 15 +- .../Pages/{Home => Play}/GamePiece.razor.css | 0 .../Pages/{Home => Play}/Pieces/Bishop.razor | 0 .../Pieces/ChallengingKing.razor | 0 .../{Home => Play}/Pieces/GoldGeneral.razor | 0 .../Pages/{Home => Play}/Pieces/Knight.razor | 0 .../Pages/{Home => Play}/Pieces/Lance.razor | 0 .../Pages/{Home => Play}/Pieces/Pawn.razor | 0 .../{Home => Play}/Pieces/ReigningKing.razor | 0 .../Pages/{Home => Play}/Pieces/Rook.razor | 0 .../{Home => Play}/Pieces/SilverGeneral.razor | 0 Shogi.UI/Pages/Play/PlayPage.razor | 28 ++ Shogi.UI/Pages/Play/PromotePrompt.cs | 58 +++ Shogi.UI/Pages/SearchPage.razor | 11 + Shogi.UI/Pages/SearchPage.razor.css | 4 + Shogi.UI/Pages/TestPage.razor | 7 - Shogi.UI/Program.cs | 92 ++--- Shogi.UI/Shared/Events.cs | 8 - Shogi.UI/Shared/GameHubNode.cs | 59 +++ Shogi.UI/Shared/Icons/ChevronDownIcon.razor | 7 + Shogi.UI/Shared/Icons/ChevronUpIcon.razor | 7 + Shogi.UI/Shared/LocalStorage.cs | 69 ++-- Shogi.UI/Shared/MainLayout.razor | 4 - Shogi.UI/Shared/MainLayout.razor.css | 3 - Shogi.UI/Shared/ShogiApi.cs | 74 ++++ Shogi.UI/Shared/ShogiSocket.cs | 131 ------- Shogi.UI/Shogi.UI.csproj | 12 + Shogi.UI/_Imports.razor | 10 +- Shogi.UI/wwwroot/appsettings.json | 16 +- Shogi.UI/wwwroot/css/app.css | 88 +---- .../wwwroot/css/bootstrap/bootstrap-icons.svg | 1 - .../wwwroot/css/bootstrap/bootstrap.min.css | 7 - .../css/bootstrap/bootstrap.min.css.map | 1 - .../wwwroot/css/bootstrap/bootstrap.min.js | 7 - .../css/bootstrap/bootstrap.min.js.map | 1 - Shogi.UI/wwwroot/css/themes.css | 68 ++++ Shogi.UI/wwwroot/index.html | 45 +-- Shogi.sln | 2 +- Tests/AcceptanceTests/ApiTests.cs | 346 +++++++++++++++++ Tests/AcceptanceTests/GuestSessionTests.cs | 362 ------------------ ...ts.csproj => Shogi.AcceptanceTests.csproj} | 5 +- .../TestSetup/AatTestFixture.cs | 114 ++++++ .../TestSetup/GuestTestFixture.cs | 74 ---- .../TestSetup/MsalTestFixture.cs | 100 ----- .../appsettings.Development.json | 3 + Tests/AcceptanceTests/appsettings.json | 10 +- .../ServiceModelsShouldSerialize.cs | 25 -- 172 files changed, 3857 insertions(+), 4045 deletions(-) create mode 100644 Shogi.Api/ApiKeys.cs create mode 100644 Shogi.Api/Application/GameHub.cs create mode 100644 Shogi.Api/Application/GameHubContext.cs create mode 100644 Shogi.Api/Application/ShogiApplication.cs create mode 100644 Shogi.Api/Controllers/AccountController.cs create mode 100644 Shogi.Api/Controllers/Extentions.cs delete mode 100644 Shogi.Api/Controllers/UserController.cs delete mode 100644 Shogi.Api/ExampleAnonymousSessionMiddleware.cs delete mode 100644 Shogi.Api/Extensions/ClaimsExtensions.cs delete mode 100644 Shogi.Api/Extensions/LogMiddleware.cs create mode 100644 Shogi.Api/Identity/ApplicationDbContext.cs create mode 100644 Shogi.Api/Identity/ShogiUser.cs delete mode 100644 Shogi.Api/Managers/ModelMapper.cs delete mode 100644 Shogi.Api/Managers/SocketConnectionManager.cs delete mode 100644 Shogi.Api/Managers/SocketTokenCache.cs create mode 100644 Shogi.Api/Migrations/20240816002834_InitialCreate.Designer.cs create mode 100644 Shogi.Api/Migrations/20240816002834_InitialCreate.cs create mode 100644 Shogi.Api/Migrations/ApplicationDbContextModelSnapshot.cs delete mode 100644 Shogi.Api/Models/User.cs delete mode 100644 Shogi.Api/Models/WhichLoginPlatform.cs create mode 100644 Shogi.Api/Repositories/EmailSender.cs delete mode 100644 Shogi.Api/Repositories/UserRepository.cs delete mode 100644 Shogi.Api/Services/SocketService.cs delete mode 100644 Shogi.Api/ShogiUserClaimsTransformer.cs delete mode 100644 Shogi.Contracts/Api/Commands/CreateGuestTokenResponse.cs delete mode 100644 Shogi.Contracts/Api/Commands/CreateSessionCommand.cs delete mode 100644 Shogi.Contracts/Api/Commands/CreateTokenResponse.cs delete mode 100644 Shogi.Contracts/Api/Queries/ReadAllSessionsResponse.cs delete mode 100644 Shogi.Contracts/Api/Queries/ReadSessionResponse.cs delete mode 100644 Shogi.Contracts/Socket/ISocketMessage.cs delete mode 100644 Shogi.Contracts/Socket/PlayerHasMovedMessage.cs delete mode 100644 Shogi.Contracts/Socket/SessionCreatedSocketMessage.cs delete mode 100644 Shogi.Contracts/Socket/SessionJoinedByPlayerSocketMessage.cs delete mode 100644 Shogi.Contracts/Types/SocketAction.cs delete mode 100644 Shogi.Contracts/Types/User.cs create mode 100644 Shogi.Database/AspNetUsersId.sql delete mode 100644 Shogi.Database/Post Deployment/Scripts/PopulateLoginPlatforms.sql create mode 100644 Shogi.Database/Session/Functions/MaxNewSessionsPerUser.sql delete mode 100644 Shogi.Database/Session/Stored Procedures/ReadSessionPlayerCount.sql create mode 100644 Shogi.Database/Session/Stored Procedures/ReadSessionsMetadata.sql delete mode 100644 Shogi.Database/Session/Stored Procedures/ReadUsersBySession.sql delete mode 100644 Shogi.Database/Session/Types/SessionName.sql create mode 100644 Shogi.Database/Session/Types/SessionSurrogateKey.sql create mode 100644 Shogi.Database/Shogi.Database.refactorlog delete mode 100644 Shogi.Database/User/StoredProcedures/CreateUser.sql delete mode 100644 Shogi.Database/User/StoredProcedures/ReadUser.sql delete mode 100644 Shogi.Database/User/Tables/LoginPlatform.sql delete mode 100644 Shogi.Database/User/Tables/User.sql delete mode 100644 Shogi.Database/User/Types/UserName.sql delete mode 100644 Shogi.Database/User/User.sql create mode 100644 Shogi.UI/Identity/CookieAuthenticationStateProvider.cs create mode 100644 Shogi.UI/Identity/CookieMessageHandler.cs create mode 100644 Shogi.UI/Identity/FormResult.cs create mode 100644 Shogi.UI/Identity/IAccountManagement.cs create mode 100644 Shogi.UI/Identity/UserInfo.cs create mode 100644 Shogi.UI/Layout/MainLayout.razor create mode 100644 Shogi.UI/Layout/MainLayout.razor.css create mode 100644 Shogi.UI/Layout/NavMenu.razor create mode 100644 Shogi.UI/Layout/NavMenu.razor.css delete mode 100644 Shogi.UI/Pages/Authentication.razor delete mode 100644 Shogi.UI/Pages/Home/Account/AccountManager.cs delete mode 100644 Shogi.UI/Pages/Home/Account/AccountState.cs delete mode 100644 Shogi.UI/Pages/Home/Account/LocalStorageExtensions.cs delete mode 100644 Shogi.UI/Pages/Home/Account/LoginEventArgs.cs delete mode 100644 Shogi.UI/Pages/Home/Account/User.cs delete mode 100644 Shogi.UI/Pages/Home/Account/WhichAccountPlatform.cs delete mode 100644 Shogi.UI/Pages/Home/Api/CookieMessageHandler.cs delete mode 100644 Shogi.UI/Pages/Home/Api/IShogiApi.cs delete mode 100644 Shogi.UI/Pages/Home/Api/MsalMessageHandler.cs delete mode 100644 Shogi.UI/Pages/Home/Api/ShogiApi.cs delete mode 100644 Shogi.UI/Pages/Home/GameBoard/GameBoard.razor delete mode 100644 Shogi.UI/Pages/Home/GameBoard/GameBoardPresentation.razor delete mode 100644 Shogi.UI/Pages/Home/GameBoard/GameboardPresentation.razor.css delete mode 100644 Shogi.UI/Pages/Home/GameBoard/SeatedGameBoard.razor delete mode 100644 Shogi.UI/Pages/Home/GameBoard/SpectatorGameBoard.razor delete mode 100644 Shogi.UI/Pages/Home/GameBrowser.razor delete mode 100644 Shogi.UI/Pages/Home/GameBrowser.razor.css delete mode 100644 Shogi.UI/Pages/Home/Home.razor delete mode 100644 Shogi.UI/Pages/Home/Home.razor.css delete mode 100644 Shogi.UI/Pages/Home/LoginModal.razor delete mode 100644 Shogi.UI/Pages/Home/LoginModal.razor.css delete mode 100644 Shogi.UI/Pages/Home/PageHeader.razor delete mode 100644 Shogi.UI/Pages/Home/PageHeader.razor.css delete mode 100644 Shogi.UI/Pages/Home/PromotePrompt.cs create mode 100644 Shogi.UI/Pages/HomePage.razor create mode 100644 Shogi.UI/Pages/HomePage.razor.css create mode 100644 Shogi.UI/Pages/Identity/LoginPage.razor create mode 100644 Shogi.UI/Pages/Identity/LoginPage.razor.css create mode 100644 Shogi.UI/Pages/Identity/LogoutPage.razor create mode 100644 Shogi.UI/Pages/Identity/RegisterPage.razor create mode 100644 Shogi.UI/Pages/Identity/RegisterPage.razor.css rename Shogi.UI/Pages/{Home => Play}/GameBoard/EmptyGameBoard.razor (100%) create mode 100644 Shogi.UI/Pages/Play/GameBoard/GameBoard.razor create mode 100644 Shogi.UI/Pages/Play/GameBoard/GameBoardPresentation.razor create mode 100644 Shogi.UI/Pages/Play/GameBoard/GameboardPresentation.razor.css create mode 100644 Shogi.UI/Pages/Play/GameBoard/PlayerName.razor create mode 100644 Shogi.UI/Pages/Play/GameBoard/SeatedGameBoard.razor create mode 100644 Shogi.UI/Pages/Play/GameBoard/SpectatorGameBoard.razor create mode 100644 Shogi.UI/Pages/Play/GameBrowser.razor create mode 100644 Shogi.UI/Pages/Play/GameBrowser.razor.css rename Shogi.UI/Pages/{Home => Play}/GamePiece.razor (64%) rename Shogi.UI/Pages/{Home => Play}/GamePiece.razor.css (100%) rename Shogi.UI/Pages/{Home => Play}/Pieces/Bishop.razor (100%) rename Shogi.UI/Pages/{Home => Play}/Pieces/ChallengingKing.razor (100%) rename Shogi.UI/Pages/{Home => Play}/Pieces/GoldGeneral.razor (100%) rename Shogi.UI/Pages/{Home => Play}/Pieces/Knight.razor (100%) rename Shogi.UI/Pages/{Home => Play}/Pieces/Lance.razor (100%) rename Shogi.UI/Pages/{Home => Play}/Pieces/Pawn.razor (100%) rename Shogi.UI/Pages/{Home => Play}/Pieces/ReigningKing.razor (100%) rename Shogi.UI/Pages/{Home => Play}/Pieces/Rook.razor (100%) rename Shogi.UI/Pages/{Home => Play}/Pieces/SilverGeneral.razor (100%) create mode 100644 Shogi.UI/Pages/Play/PlayPage.razor create mode 100644 Shogi.UI/Pages/Play/PromotePrompt.cs create mode 100644 Shogi.UI/Pages/SearchPage.razor create mode 100644 Shogi.UI/Pages/SearchPage.razor.css delete mode 100644 Shogi.UI/Pages/TestPage.razor delete mode 100644 Shogi.UI/Shared/Events.cs create mode 100644 Shogi.UI/Shared/GameHubNode.cs create mode 100644 Shogi.UI/Shared/Icons/ChevronDownIcon.razor create mode 100644 Shogi.UI/Shared/Icons/ChevronUpIcon.razor delete mode 100644 Shogi.UI/Shared/MainLayout.razor delete mode 100644 Shogi.UI/Shared/MainLayout.razor.css create mode 100644 Shogi.UI/Shared/ShogiApi.cs delete mode 100644 Shogi.UI/Shared/ShogiSocket.cs delete mode 100644 Shogi.UI/wwwroot/css/bootstrap/bootstrap-icons.svg delete mode 100644 Shogi.UI/wwwroot/css/bootstrap/bootstrap.min.css delete mode 100644 Shogi.UI/wwwroot/css/bootstrap/bootstrap.min.css.map delete mode 100644 Shogi.UI/wwwroot/css/bootstrap/bootstrap.min.js delete mode 100644 Shogi.UI/wwwroot/css/bootstrap/bootstrap.min.js.map create mode 100644 Shogi.UI/wwwroot/css/themes.css create mode 100644 Tests/AcceptanceTests/ApiTests.cs delete mode 100644 Tests/AcceptanceTests/GuestSessionTests.cs rename Tests/AcceptanceTests/{AcceptanceTests.csproj => Shogi.AcceptanceTests.csproj} (91%) create mode 100644 Tests/AcceptanceTests/TestSetup/AatTestFixture.cs delete mode 100644 Tests/AcceptanceTests/TestSetup/GuestTestFixture.cs delete mode 100644 Tests/AcceptanceTests/TestSetup/MsalTestFixture.cs create mode 100644 Tests/AcceptanceTests/appsettings.Development.json delete mode 100644 Tests/UnitTests/ServiceModels/ServiceModelsShouldSerialize.cs diff --git a/Shogi.Api/ApiKeys.cs b/Shogi.Api/ApiKeys.cs new file mode 100644 index 0000000..f19377d --- /dev/null +++ b/Shogi.Api/ApiKeys.cs @@ -0,0 +1,6 @@ +namespace Shogi.Api; + +public class ApiKeys +{ + public string BrevoEmailService { get; set; } = string.Empty; +} diff --git a/Shogi.Api/Application/GameHub.cs b/Shogi.Api/Application/GameHub.cs new file mode 100644 index 0000000..4eb4b2d --- /dev/null +++ b/Shogi.Api/Application/GameHub.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.SignalR; + +namespace Shogi.Api.Application; + +/// +/// Used to receive signals from connected clients. +/// +public class GameHub : Hub +{ + public Task Subscribe(string sessionId) + { + return this.Groups.AddToGroupAsync(this.Context.ConnectionId, sessionId); + } + + public Task Unsubscribe(string sessionId) + { + return this.Groups.RemoveFromGroupAsync(this.Context.ConnectionId, sessionId); + } +} diff --git a/Shogi.Api/Application/GameHubContext.cs b/Shogi.Api/Application/GameHubContext.cs new file mode 100644 index 0000000..cbcead0 --- /dev/null +++ b/Shogi.Api/Application/GameHubContext.cs @@ -0,0 +1,21 @@ +using Microsoft.AspNetCore.SignalR; + +namespace Shogi.Api.Application; + +/// +/// Used to send signals to connected clients. +/// +public class GameHubContext(IHubContext context) +{ + public async Task Emit_SessionJoined(string sessionId) + { + var clients = context.Clients.Group(sessionId); + await clients.SendAsync("SessionJoined"); + } + + public async Task Emit_PieceMoved(string sessionId) + { + var clients = context.Clients.Group(sessionId); + await clients.SendAsync("PieceMoved"); + } +} diff --git a/Shogi.Api/Application/ShogiApplication.cs b/Shogi.Api/Application/ShogiApplication.cs new file mode 100644 index 0000000..7e6096e --- /dev/null +++ b/Shogi.Api/Application/ShogiApplication.cs @@ -0,0 +1,143 @@ +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Shogi.Api.Controllers; +using Shogi.Api.Extensions; +using Shogi.Api.Identity; +using Shogi.Api.Repositories; +using Shogi.Api.Repositories.Dto; +using Shogi.Contracts.Api; +using Shogi.Domain.Aggregates; +using System.Data.SqlClient; + +namespace Shogi.Api.Application; + +public class ShogiApplication( + QueryRepository queryRepository, + SessionRepository sessionRepository, + UserManager userManager, + GameHubContext gameHubContext) +{ + + public async Task CreateSession(string playerId) + { + var session = new Session(Guid.NewGuid(), playerId); + + try + { + await sessionRepository.CreateSession(session); + return new CreatedAtActionResult( + nameof(SessionsController.GetSession), + null, + new { sessionId = session.Id.ToString() }, + session.Id.ToString()); + } + catch (SqlException) + { + return new ConflictResult(); + } + } + + public async Task> ReadAllSessionMetadatas(string playerId) + { + return await queryRepository.ReadSessionsMetadata(playerId); + } + + public async Task ReadSession(string id) + { + var (sessionDto, moveDtos) = await sessionRepository.ReadSessionAndMoves(id); + if (!sessionDto.HasValue) + { + return null; + } + + var session = new Session(Guid.Parse(sessionDto.Value.Id), sessionDto.Value.Player1Id); + if (!string.IsNullOrWhiteSpace(sessionDto.Value.Player2Id)) session.AddPlayer2(sessionDto.Value.Player2Id); + + foreach (var move in moveDtos) + { + if (move.PieceFromHand.HasValue) + { + session.Board.Move(move.PieceFromHand.Value, move.To); + } + else if (move.From != null) + { + session.Board.Move(move.From, move.To, false); + } + else + { + throw new InvalidOperationException($"Corrupt data during {nameof(ReadSession)}"); + } + } + + + + return session; + } + + public async Task MovePiece(string playerId, string sessionId, MovePieceCommand command) + { + var session = await this.ReadSession(sessionId); + if (session == null) + { + return new NotFoundResult(); + } + + if (!session.IsSeated(playerId)) + { + return new ForbidResult(); + } + + try + { + if (command.PieceFromHand.HasValue) + { + session.Board.Move(command.PieceFromHand.Value.ToDomain(), command.To); + } + else + { + session.Board.Move(command.From!, command.To, command.IsPromotion ?? false); + } + } + catch (InvalidOperationException e) + { + return new ConflictObjectResult(e.Message); + } + + await sessionRepository.CreateMove(sessionId, command); + + await gameHubContext.Emit_PieceMoved(sessionId); + + return new NoContentResult(); + + } + + public async Task JoinSession(string sessionId, string player2Id) + { + var session = await this.ReadSession(sessionId); + if (session == null) return new NotFoundResult(); + + if (string.IsNullOrEmpty(session.Player2)) + { + session.AddPlayer2(player2Id); + + await sessionRepository.SetPlayer2(sessionId, player2Id); + + var player2Email = this.GetUsername(player2Id); + await gameHubContext.Emit_SessionJoined(sessionId); + + return new OkResult(); + } + + return new ConflictObjectResult("This game already has two players."); + } + + public string GetUsername(string? userId) + { + if (string.IsNullOrEmpty(userId)) + { + return string.Empty; + } + + return userManager.Users.FirstOrDefault(u => u.Id == userId)?.UserName!; + } +} diff --git a/Shogi.Api/Controllers/AccountController.cs b/Shogi.Api/Controllers/AccountController.cs new file mode 100644 index 0000000..4f89005 --- /dev/null +++ b/Shogi.Api/Controllers/AccountController.cs @@ -0,0 +1,74 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Shogi.Api.Identity; +using System.Security.Claims; + +namespace Shogi.Api.Controllers; + +[Authorize] +[Route("[controller]")] +[ApiController] +public class AccountController( + SignInManager signInManager, + UserManager UserManager, + IConfiguration configuration) : ControllerBase +{ + [Authorize("Admin")] + [HttpPost("TestAccount")] + public async Task CreateTestAccounts() + { + var newUser = new ShogiUser { UserName = "aat-account", Email = "test-account@lucaserver.space", EmailConfirmed = true }; + var newUser2 = new ShogiUser { UserName = "aat-account-2", Email = "test-account2@lucaserver.space", EmailConfirmed = true }; + var pass = configuration["TestUserPassword"] ?? throw new InvalidOperationException("TestUserPassword not configured."); + var result = await UserManager.CreateAsync(newUser, pass); + if (result != null && !result.Succeeded) + { + return this.Problem(string.Join(",", result.Errors.Select(e => e.Description))); + } + + result = await UserManager.CreateAsync(newUser2, pass); + if(result != null && !result.Succeeded) + { + return this.Problem(string.Join(",", result.Errors.Select(e => e.Description))); + } + + return this.Created(); + } + + [HttpPost("/logout")] + public async Task Logout([FromBody] object empty) + { + // https://learn.microsoft.com/aspnet/core/blazor/security/webassembly/standalone-with-identity#antiforgery-support + if (empty is not null) + { + await signInManager.SignOutAsync(); + + return this.Ok(); + } + + return this.Unauthorized(); + } + + [HttpGet("/roles")] + public IActionResult GetRoles() + { + if (this.User.Identity is not null && this.User.Identity.IsAuthenticated) + { + var identity = (ClaimsIdentity)this.User.Identity; + var roles = identity.FindAll(identity.RoleClaimType) + .Select(c => new + { + c.Issuer, + c.OriginalIssuer, + c.Type, + c.Value, + c.ValueType + }); + + return this.Ok(roles); + } + + return this.Unauthorized(); + } +} diff --git a/Shogi.Api/Controllers/Extentions.cs b/Shogi.Api/Controllers/Extentions.cs new file mode 100644 index 0000000..eb828cb --- /dev/null +++ b/Shogi.Api/Controllers/Extentions.cs @@ -0,0 +1,11 @@ +using System.Security.Claims; + +namespace Shogi.Api.Controllers; + +public static class Extentions +{ + public static string? GetId(this ClaimsPrincipal self) + { + return self.Claims.FirstOrDefault(claim => claim.Type == ClaimTypes.NameIdentifier)?.Value; + } +} diff --git a/Shogi.Api/Controllers/SessionsController.cs b/Shogi.Api/Controllers/SessionsController.cs index 38842a7..0f929f5 100644 --- a/Shogi.Api/Controllers/SessionsController.cs +++ b/Shogi.Api/Controllers/SessionsController.cs @@ -1,169 +1,123 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Shogi.Api.Application; using Shogi.Api.Extensions; -using Shogi.Api.Managers; +using Shogi.Api.Identity; using Shogi.Api.Repositories; using Shogi.Contracts.Api; -using Shogi.Contracts.Socket; using Shogi.Contracts.Types; -using System.Data.SqlClient; +using System.Security.Claims; namespace Shogi.Api.Controllers; +[Authorize] [ApiController] [Route("[controller]")] -[Authorize] -public class SessionsController : ControllerBase +public class SessionsController( + SessionRepository sessionRepository, + ShogiApplication application, + SignInManager signInManager, + UserManager userManager) : ControllerBase { - private readonly ISocketConnectionManager communicationManager; - private readonly IModelMapper mapper; - private readonly ISessionRepository sessionRepository; - private readonly IQueryRespository queryRespository; - private readonly ILogger logger; - public SessionsController( - ISocketConnectionManager communicationManager, - IModelMapper mapper, - ISessionRepository sessionRepository, - IQueryRespository queryRespository, - ILogger logger) - { - this.communicationManager = communicationManager; - this.mapper = mapper; - this.sessionRepository = sessionRepository; - this.queryRespository = queryRespository; - this.logger = logger; - } + [HttpPost] + public async Task CreateSession() + { + var id = this.User.GetId(); + if (string.IsNullOrEmpty(id)) + { + return this.Unauthorized(); + } + return await application.CreateSession(id); + } - [HttpPost] - public async Task CreateSession([FromBody] CreateSessionCommand request) - { - var userId = User.GetShogiUserId(); - var session = new Domain.Session(request.Name, userId); - try - { - await sessionRepository.CreateSession(session); - } - catch (SqlException e) - { - logger.LogError(exception: e, message: "Uh oh"); - return this.Conflict(); - } + [HttpDelete("{sessionId}")] + public async Task DeleteSession(string sessionId) + { + var id = this.User.GetId(); + if (id == null) + { + return this.Unauthorized(); + } - await communicationManager.BroadcastToAll(new SessionCreatedSocketMessage()); - return CreatedAtAction(nameof(CreateSession), new { sessionName = request.Name }, null); - } + var (session, _) = await sessionRepository.ReadSessionAndMoves(sessionId); + if (!session.HasValue) return this.NoContent(); - [HttpDelete("{name}")] - public async Task DeleteSession(string name) - { - var userId = User.GetShogiUserId(); - var session = await sessionRepository.ReadSession(name); + if (session.Value.Player1Id == id) + { + await sessionRepository.DeleteSession(sessionId); + return this.NoContent(); + } - if (session == null) return this.NoContent(); + return this.StatusCode(StatusCodes.Status403Forbidden, "Cannot delete sessions created by others."); + } - if (session.Player1 == userId) - { - await sessionRepository.DeleteSession(name); - return this.NoContent(); - } + /// + /// Fetch the session and latest board state. Also subscribe the user to socket events for this session. + /// + /// + /// + [HttpGet("{sessionId}")] + public async Task> GetSession(Guid sessionId) + { + var session = await application.ReadSession(sessionId.ToString()); + if (session == null) return this.NotFound(); - return this.StatusCode(StatusCodes.Status403Forbidden, "Cannot delete sessions created by others."); - } + return new Session + { + BoardState = new BoardState + { + Board = session.Board.BoardState.State.ToContract(), + Player1Hand = session.Board.BoardState.Player1Hand.ToContract(), + Player2Hand = session.Board.BoardState.Player2Hand.ToContract(), + PlayerInCheck = session.Board.BoardState.InCheck?.ToContract(), + WhoseTurn = session.Board.BoardState.WhoseTurn.ToContract() + }, + Player1 = application.GetUsername(session.Player1), + Player2 = application.GetUsername(session.Player2), + SessionId = session.Id + }; + } - [HttpGet("PlayerCount")] - public async Task> GetSessionsPlayerCount() - { - return Ok(await this.queryRespository.ReadSessionPlayerCount(this.User.GetShogiUserId())); - } + [HttpGet()] + public async Task> ReadAllSessionsMetadata() + { + var id = this.User.GetId(); + if (id == null) return this.Unauthorized(); - /// - /// Fetch the session and latest board state. Also subscribe the user to socket events for this session. - /// - /// - /// - [HttpGet("{name}")] - public async Task> GetSession(string name) - { - var session = await sessionRepository.ReadSession(name); - if (session == null) return this.NotFound(); + var dtos = await application.ReadAllSessionMetadatas(id); + return dtos + .Select(dto => new SessionMetadata + { + Player1 = application.GetUsername(dto.Player1Id), + Player2 = application.GetUsername(dto.Player2Id), + SessionId = Guid.Parse(dto.Id), + }) + .ToArray(); + } - var players = await queryRespository.GetUsersForSession(session.Name); - if (players == null) return this.NotFound(); + [HttpPatch("{sessionId}/Join")] + public async Task JoinSession(string sessionId) + { + var id = this.User.GetId(); + if (id == null) + { + return this.Unauthorized(); + } - return new ReadSessionResponse - { - Session = new Session - { - BoardState = new BoardState - { - Board = session.Board.BoardState.State.ToContract(), - Player1Hand = session.Board.BoardState.Player1Hand.ToContract(), - Player2Hand = session.Board.BoardState.Player2Hand.ToContract(), - PlayerInCheck = session.Board.BoardState.InCheck?.ToContract(), - WhoseTurn = session.Board.BoardState.WhoseTurn.ToContract() - }, - Player1 = players.Value.Player1, - Player2 = players.Value.Player2, - SessionName = session.Name - } - }; - } + return await application.JoinSession(sessionId, id); + } - [HttpPatch("{name}/Join")] - public async Task JoinSession(string name) - { - var session = await sessionRepository.ReadSession(name); - if (session == null) return this.NotFound(); + [HttpPatch("{sessionId}/Move")] + public async Task Move([FromRoute] string sessionId, [FromBody] MovePieceCommand command) + { + var id = this.User.GetId(); + if (id == null) + { + return this.Unauthorized(); + } - if (string.IsNullOrEmpty(session.Player2)) - { - session.AddPlayer2(User.GetShogiUserId()); - - await sessionRepository.SetPlayer2(name, User.GetShogiUserId()); - await communicationManager.BroadcastToAll(new SessionJoinedByPlayerSocketMessage(session.Name)); - return this.Ok(); - } - return this.Conflict("This game already has two players."); - } - - [HttpPatch("{sessionName}/Move")] - public async Task Move([FromRoute] string sessionName, [FromBody] MovePieceCommand command) - { - var userId = User.GetShogiUserId(); - var session = await sessionRepository.ReadSession(sessionName); - - if (session == null) return this.NotFound("Shogi session does not exist."); - - if (!session.IsSeated(userId)) return this.StatusCode(StatusCodes.Status403Forbidden, "Player is not a member of the Shogi session."); - - try - { - if (command.PieceFromHand.HasValue) - { - session.Board.Move(command.PieceFromHand.Value.ToDomain(), command.To); - } - else - { - session.Board.Move(command.From!, command.To, command.IsPromotion ?? false); - } - } - catch (InvalidOperationException e) - { - return this.Conflict(e.Message); - } - await sessionRepository.CreateMove(sessionName, command); - - // Send socket message to both players so their clients know that new board state is available. - await communicationManager.BroadcastToPlayers( - new PlayerHasMovedMessage - { - PlayerName = userId, - SessionName = session.Name, - }, - session.Player1, - session.Player2); - - return this.NoContent(); - } + return await application.MovePiece(id, sessionId, command); + } } diff --git a/Shogi.Api/Controllers/UserController.cs b/Shogi.Api/Controllers/UserController.cs deleted file mode 100644 index 85973da..0000000 --- a/Shogi.Api/Controllers/UserController.cs +++ /dev/null @@ -1,94 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; -using Shogi.Api.Extensions; -using Shogi.Api.Managers; -using Shogi.Api.Repositories; -using Shogi.Contracts.Api; - -namespace Shogi.Api.Controllers; - -[ApiController] -[Route("[controller]")] -[Authorize] -public class UserController : ControllerBase -{ - private readonly ISocketTokenCache tokenCache; - private readonly ISocketConnectionManager connectionManager; - private readonly IUserRepository userRepository; - private readonly IShogiUserClaimsTransformer claimsTransformation; - private readonly AuthenticationProperties authenticationProps; - - public UserController( - ILogger logger, - ISocketTokenCache tokenCache, - ISocketConnectionManager connectionManager, - IUserRepository userRepository, - IShogiUserClaimsTransformer claimsTransformation) - { - this.tokenCache = tokenCache; - this.connectionManager = connectionManager; - this.userRepository = userRepository; - this.claimsTransformation = claimsTransformation; - authenticationProps = new AuthenticationProperties - { - AllowRefresh = true, - IsPersistent = true - }; - } - - [HttpGet("Token")] - public ActionResult GetWebSocketToken() - { - var userId = User.GetShogiUserId(); - var displayName = User.GetShogiUserDisplayname(); - - var token = tokenCache.GenerateToken(userId); - return new CreateTokenResponse - { - DisplayName = displayName, - OneTimeToken = token, - UserId = userId - }; - } - - /// - /// - /// Used by cookie authentication. - /// - [AllowAnonymous] - [HttpGet("LoginAsGuest")] - public async Task GuestLogin([FromQuery] string? returnUrl) - { - var principal = await this.claimsTransformation.CreateClaimsFromGuestPrincipal(User); - if (principal != null) - { - await HttpContext.SignInAsync( - CookieAuthenticationDefaults.AuthenticationScheme, - principal, - authenticationProps - ); - } - if (!string.IsNullOrWhiteSpace(returnUrl)) - { - return Redirect(returnUrl); - } - return Ok(); - } - - [HttpPut("GuestLogout")] - public async Task GuestLogout() - { - var signOutTask = HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); - - var userId = User?.GetShogiUserId(); - if (!string.IsNullOrEmpty(userId)) - { - connectionManager.Unsubscribe(userId); - } - - await signOutTask; - return Ok(); - } -} diff --git a/Shogi.Api/ExampleAnonymousSessionMiddleware.cs b/Shogi.Api/ExampleAnonymousSessionMiddleware.cs deleted file mode 100644 index 9565c0a..0000000 --- a/Shogi.Api/ExampleAnonymousSessionMiddleware.cs +++ /dev/null @@ -1,39 +0,0 @@ -namespace Shogi.Api -{ - namespace anonymous_session.Middlewares - { - using Microsoft.AspNetCore.Http; - using Microsoft.AspNetCore.Authentication; - using System.Security.Claims; - - /// - /// TODO: Use this example in the guest session logic instead of custom claims. - /// - public class ExampleAnonymousSessionMiddleware - { - private readonly RequestDelegate _next; - - public ExampleAnonymousSessionMiddleware(RequestDelegate next) - { - _next = next; - } - - public async System.Threading.Tasks.Task InvokeAsync(HttpContext context) - { - if (!context.User.Identity.IsAuthenticated) - { - if (string.IsNullOrEmpty(context.User.FindFirstValue(ClaimTypes.Anonymous))) - { - var claim = new Claim(ClaimTypes.Anonymous, System.Guid.NewGuid().ToString()); - context.User.AddIdentity(new ClaimsIdentity(new[] { claim })); - - string scheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme; - await context.SignInAsync(scheme, context.User, new AuthenticationProperties { IsPersistent = false }); - } - } - - await _next(context); - } - } - } -} diff --git a/Shogi.Api/Extensions/ClaimsExtensions.cs b/Shogi.Api/Extensions/ClaimsExtensions.cs deleted file mode 100644 index 76bb571..0000000 --- a/Shogi.Api/Extensions/ClaimsExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.Identity.Web; -using System.Security.Claims; - -namespace Shogi.Api.Extensions; - -public static class ClaimsExtensions -{ - // https://learn.microsoft.com/en-us/azure/active-directory/develop/id-tokens#payload-claims - - /// - /// Get Id from claims after applying shogi-specific claims transformations. - /// - public static string GetShogiUserId(this ClaimsPrincipal self) - { - var id = self.GetNameIdentifierId(); - if (string.IsNullOrEmpty(id)) throw new InvalidOperationException("Shogi UserId not found in claims."); - return id; - } - - /// - /// Get display name from claims after applying shogi-specific claims transformations. - /// - public static string GetShogiUserDisplayname(this ClaimsPrincipal self) - { - var displayName = self.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value; - if (string.IsNullOrEmpty(displayName)) throw new InvalidOperationException("Shogi Display name not found in claims."); - return displayName; - } - -} \ No newline at end of file diff --git a/Shogi.Api/Extensions/LogMiddleware.cs b/Shogi.Api/Extensions/LogMiddleware.cs deleted file mode 100644 index a3111ec..0000000 --- a/Shogi.Api/Extensions/LogMiddleware.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.Logging; -using System.IO; -using System.Text; -using System.Threading.Tasks; - -namespace Shogi.Api.Extensions -{ - public class LogMiddleware - { - private readonly RequestDelegate next; - private readonly ILogger logger; - - - public LogMiddleware(RequestDelegate next, ILoggerFactory factory) - { - this.next = next; - logger = factory.CreateLogger(); - } - - public async Task Invoke(HttpContext context) - { - try - { - await next(context); - } - finally - { - using var stream = new MemoryStream(); - context.Request?.Body.CopyToAsync(stream); - - logger.LogInformation("Request {method} {url} => {statusCode} \n Body: {body}", - context.Request?.Method, - context.Request?.Path.Value, - context.Response?.StatusCode, - Encoding.UTF8.GetString(stream.ToArray())); - } - } - } - - public static class IApplicationBuilderExtensions - { - public static IApplicationBuilder UseRequestResponseLogging(this IApplicationBuilder builder) - { - builder.UseMiddleware(); - return builder; - } - } -} diff --git a/Shogi.Api/Identity/ApplicationDbContext.cs b/Shogi.Api/Identity/ApplicationDbContext.cs new file mode 100644 index 0000000..13be2bb --- /dev/null +++ b/Shogi.Api/Identity/ApplicationDbContext.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace Shogi.Api.Identity; + +public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext(options) +{ +} diff --git a/Shogi.Api/Identity/ShogiUser.cs b/Shogi.Api/Identity/ShogiUser.cs new file mode 100644 index 0000000..22eb31d --- /dev/null +++ b/Shogi.Api/Identity/ShogiUser.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Identity; + +namespace Shogi.Api.Identity; + +public class ShogiUser : IdentityUser +{ +} diff --git a/Shogi.Api/Managers/ModelMapper.cs b/Shogi.Api/Managers/ModelMapper.cs deleted file mode 100644 index 2a31cca..0000000 --- a/Shogi.Api/Managers/ModelMapper.cs +++ /dev/null @@ -1,86 +0,0 @@ -using Shogi.Contracts.Types; -using DomainWhichPiece = Shogi.Domain.ValueObjects.WhichPiece; -using DomainWhichPlayer = Shogi.Domain.ValueObjects.WhichPlayer; -using Piece = Shogi.Contracts.Types.Piece; - -namespace Shogi.Api.Managers -{ - public class ModelMapper : IModelMapper - { - public WhichPlayer Map(DomainWhichPlayer whichPlayer) - { - return whichPlayer switch - { - DomainWhichPlayer.Player1 => WhichPlayer.Player1, - DomainWhichPlayer.Player2 => WhichPlayer.Player2, - _ => throw new ArgumentException("Unrecognized value for WhichPlayer", nameof(whichPlayer)) - }; - } - - public WhichPlayer? Map(DomainWhichPlayer? whichPlayer) - { - return whichPlayer.HasValue - ? Map(whichPlayer.Value) - : null; - } - - public WhichPiece Map(DomainWhichPiece whichPiece) - { - return whichPiece switch - { - DomainWhichPiece.King => WhichPiece.King, - DomainWhichPiece.GoldGeneral => WhichPiece.GoldGeneral, - DomainWhichPiece.SilverGeneral => WhichPiece.SilverGeneral, - DomainWhichPiece.Bishop => WhichPiece.Bishop, - DomainWhichPiece.Rook => WhichPiece.Rook, - DomainWhichPiece.Knight => WhichPiece.Knight, - DomainWhichPiece.Lance => WhichPiece.Lance, - DomainWhichPiece.Pawn => WhichPiece.Pawn, - _ => throw new ArgumentException("Unrecognized value", nameof(whichPiece)), - }; - } - - public DomainWhichPiece Map(WhichPiece whichPiece) - { - return whichPiece switch - { - WhichPiece.King => DomainWhichPiece.King, - WhichPiece.GoldGeneral => DomainWhichPiece.GoldGeneral, - WhichPiece.SilverGeneral => DomainWhichPiece.SilverGeneral, - WhichPiece.Bishop => DomainWhichPiece.Bishop, - WhichPiece.Rook => DomainWhichPiece.Rook, - WhichPiece.Knight => DomainWhichPiece.Knight, - WhichPiece.Lance => DomainWhichPiece.Lance, - WhichPiece.Pawn => DomainWhichPiece.Pawn, - _ => throw new ArgumentException("Unrecognized value", nameof(whichPiece)), - }; - } - - public Piece Map(Domain.ValueObjects.Piece piece) - { - return new Piece { IsPromoted = piece.IsPromoted, Owner = Map(piece.Owner), WhichPiece = Map(piece.WhichPiece) }; - } - - public Dictionary Map(IDictionary boardState) - { - return boardState.ToDictionary(kvp => kvp.Key.ToUpper(), kvp => MapNullable(kvp.Value)); - } - - public Piece? MapNullable(Domain.ValueObjects.Piece? piece) - { - if (piece == null) return null; - return Map(piece); - } - } - - public interface IModelMapper - { - WhichPlayer Map(DomainWhichPlayer whichPlayer); - WhichPlayer? Map(DomainWhichPlayer? whichPlayer); - WhichPiece Map(DomainWhichPiece whichPiece); - DomainWhichPiece Map(WhichPiece value); - Piece Map(Domain.ValueObjects.Piece p); - Piece? MapNullable(Domain.ValueObjects.Piece? p); - Dictionary Map(IDictionary boardState); - } -} diff --git a/Shogi.Api/Managers/SocketConnectionManager.cs b/Shogi.Api/Managers/SocketConnectionManager.cs deleted file mode 100644 index 6cde2e2..0000000 --- a/Shogi.Api/Managers/SocketConnectionManager.cs +++ /dev/null @@ -1,89 +0,0 @@ -using Shogi.Contracts.Socket; -using Shogi.Api.Extensions; -using System.Collections.Concurrent; -using System.Net.WebSockets; -using System.Text.Json; - -namespace Shogi.Api.Managers; - -public interface ISocketConnectionManager -{ - Task BroadcastToAll(ISocketMessage response); - void Subscribe(WebSocket socket, string playerName); - void Unsubscribe(string playerName); - Task BroadcastToPlayers(ISocketMessage response, params string?[] playerNames); -} - -/// -/// Retains all active socket connections and provides convenient methods for sending messages to clients. -/// -public class SocketConnectionManager : ISocketConnectionManager -{ - /// Dictionary key is player name. - private readonly ConcurrentDictionary connections; - private readonly JsonSerializerOptions serializeOptions; - - /// Dictionary key is game name. - private readonly ILogger logger; - - public SocketConnectionManager(ILogger logger) - { - this.logger = logger; - this.connections = new ConcurrentDictionary(); - this.serializeOptions = new JsonSerializerOptions(JsonSerializerDefaults.General); - - } - - public void Subscribe(WebSocket socket, string playerName) - { - connections.TryRemove(playerName, out var _); - connections.TryAdd(playerName, socket); - } - - public void Unsubscribe(string playerName) - { - connections.TryRemove(playerName, out _); - } - - public async Task BroadcastToPlayers(ISocketMessage response, params string?[] playerNames) - { - var tasks = new List(playerNames.Length); - foreach (var name in playerNames) - { - if (!string.IsNullOrEmpty(name) && connections.TryGetValue(name, out var socket)) - { - var serialized = Serialize(response); - logger.LogInformation("Response to {0} \n{1}\n", name, serialized); - tasks.Add(socket.SendTextAsync(serialized)); - } - } - await Task.WhenAll(tasks); - } - public Task BroadcastToAll(ISocketMessage response) - { - var message = Serialize(response); - logger.LogInformation("Broadcasting:\n{0}\nDone Broadcasting.", message); - var tasks = new List(connections.Count); - foreach (var kvp in connections) - { - var socket = kvp.Value; - try - { - tasks.Add(socket.SendTextAsync(message)); - } - catch (WebSocketException) - { - logger.LogInformation("Tried sending a message to socket connection for user [{user}], but found the connection has closed.", kvp.Key); - Unsubscribe(kvp.Key); - } - catch - { - logger.LogInformation("Tried sending a message to socket connection for user [{user}], but found the connection has closed.", kvp.Key); - Unsubscribe(kvp.Key); - } - } - return Task.WhenAll(tasks); - } - - private string Serialize(object o) => JsonSerializer.Serialize(o, this.serializeOptions); -} diff --git a/Shogi.Api/Managers/SocketTokenCache.cs b/Shogi.Api/Managers/SocketTokenCache.cs deleted file mode 100644 index 15c9291..0000000 --- a/Shogi.Api/Managers/SocketTokenCache.cs +++ /dev/null @@ -1,54 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; - -namespace Shogi.Api.Managers -{ - public interface ISocketTokenCache - { - Guid GenerateToken(string s); - string? GetUsername(Guid g); - } - - public class SocketTokenCache : ISocketTokenCache - { - /// - /// Key is userName or webSessionId - /// - private readonly ConcurrentDictionary Tokens; - - public SocketTokenCache() - { - Tokens = new ConcurrentDictionary(); - } - - public Guid GenerateToken(string userName) - { - Tokens.Remove(userName, out _); - - var guid = Guid.NewGuid(); - Tokens.TryAdd(userName, guid); - - _ = Task.Run(async () => - { - await Task.Delay(TimeSpan.FromMinutes(1)); - Tokens.Remove(userName, out _); - }).ConfigureAwait(false); - - return guid; - } - - /// User name associated to the guid or null. - public string? GetUsername(Guid guid) - { - var userName = Tokens.FirstOrDefault(kvp => kvp.Value == guid).Key; - if (userName != null) - { - Tokens.Remove(userName, out _); - } - return userName; - } - } -} diff --git a/Shogi.Api/Migrations/20240816002834_InitialCreate.Designer.cs b/Shogi.Api/Migrations/20240816002834_InitialCreate.Designer.cs new file mode 100644 index 0000000..24c2203 --- /dev/null +++ b/Shogi.Api/Migrations/20240816002834_InitialCreate.Designer.cs @@ -0,0 +1,279 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Shogi.Api.Identity; + +#nullable disable + +namespace Shogi.Api.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20240816002834_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Shogi.Api.Models.User", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Shogi.Api.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Shogi.Api.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Shogi.Api.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Shogi.Api.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Shogi.Api/Migrations/20240816002834_InitialCreate.cs b/Shogi.Api/Migrations/20240816002834_InitialCreate.cs new file mode 100644 index 0000000..9ddf303 --- /dev/null +++ b/Shogi.Api/Migrations/20240816002834_InitialCreate.cs @@ -0,0 +1,224 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Shogi.Api.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + RoleId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/Shogi.Api/Migrations/ApplicationDbContextModelSnapshot.cs b/Shogi.Api/Migrations/ApplicationDbContextModelSnapshot.cs new file mode 100644 index 0000000..bc2412f --- /dev/null +++ b/Shogi.Api/Migrations/ApplicationDbContextModelSnapshot.cs @@ -0,0 +1,276 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Shogi.Api.Identity; + +#nullable disable + +namespace Shogi.Api.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + partial class ApplicationDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Shogi.Api.Models.User", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Shogi.Api.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Shogi.Api.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Shogi.Api.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Shogi.Api.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Shogi.Api/Models/User.cs b/Shogi.Api/Models/User.cs deleted file mode 100644 index a0620b6..0000000 --- a/Shogi.Api/Models/User.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System.Collections.ObjectModel; - -namespace Shogi.Api.Models; - -public class User -{ - public static readonly ReadOnlyCollection Adjectives = new(new[] { - "Fortuitous", "Retractable", "Happy", "Habbitable", "Creative", "Fluffy", "Impervious", "Kingly", "Queenly", "Blushing", "Brave", - "Brainy", "Eager", "Itchy", "Fierce" - }); - public static readonly ReadOnlyCollection Subjects = new(new[] { - "Hippo", "Basil", "Mouse", "Walnut", "Minstrel", "Lima Bean", "Koala", "Potato", "Penguin", "Cola", "Banana", "Egg", "Fish", "Yak" - }); - public static User CreateMsalUser(string id, string displayName) => new(id, displayName, WhichLoginPlatform.Microsoft); - public static User CreateGuestUser(string id) - { - var random = new Random(); - // Adjective - var index = (int)Math.Floor(random.NextDouble() * Adjectives.Count); - var adj = Adjectives[index]; - // Subject - index = (int)Math.Floor(random.NextDouble() * Subjects.Count); - var subj = Subjects[index]; - - return new User(id, $"{adj} {subj}", WhichLoginPlatform.Guest); - } - - public string Id { get; } - public string DisplayName { get; } - - public WhichLoginPlatform LoginPlatform { get; } - - public User(string id, string displayName, WhichLoginPlatform platform) - { - Id = id; - DisplayName = displayName; - LoginPlatform = platform; - } -} diff --git a/Shogi.Api/Models/WhichLoginPlatform.cs b/Shogi.Api/Models/WhichLoginPlatform.cs deleted file mode 100644 index 5972475..0000000 --- a/Shogi.Api/Models/WhichLoginPlatform.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Shogi.Api.Models -{ - public enum WhichLoginPlatform - { - Unknown, - Microsoft, - Guest - } -} diff --git a/Shogi.Api/Program.cs b/Shogi.Api/Program.cs index b9debee..5fd1b28 100644 --- a/Shogi.Api/Program.cs +++ b/Shogi.Api/Program.cs @@ -1,232 +1,106 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Http.Extensions; -using Microsoft.AspNetCore.Http.Json; -using Microsoft.AspNetCore.HttpLogging; -using Microsoft.Identity.Web; -using Microsoft.OpenApi.Models; -using Shogi.Api.Managers; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.AspNetCore.ResponseCompression; +using Microsoft.EntityFrameworkCore; +using Shogi.Api; +using Shogi.Api.Application; +using Shogi.Api.Identity; using Shogi.Api.Repositories; -using Shogi.Api.Services; -namespace Shogi.Api +var builder = WebApplication.CreateBuilder(args); +var allowedOrigins = builder + .Configuration + .GetSection("Cors:AllowedOrigins") + .Get() ?? throw new InvalidOperationException("Configuration for allowed origins is missing."); + +builder.Services + .AddControllers() + .AddJsonOptions(options => + { + options.JsonSerializerOptions.WriteIndented = true; + }); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddHttpClient(); +builder.Services.Configure(builder.Configuration.GetSection("ApiKeys")); + +AddIdentity(builder, builder.Configuration); +builder.Services.AddSignalR(); +builder.Services.AddResponseCompression(opts => { - public class Program - { - public static void Main(string[] args) - { - var builder = WebApplication.CreateBuilder(args); + opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]); +}); +var app = builder.Build(); - var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? throw new InvalidOperationException("Configuration for allowed origins is missing."); - builder.Services.AddCors(options => - { - options.AddDefaultPolicy(policy => - { - policy - .WithOrigins(allowedOrigins) - .SetIsOriginAllowedToAllowWildcardSubdomains() - .WithExposedHeaders("Set-Cookie") - .AllowAnyHeader() - .AllowAnyMethod() - .AllowCredentials(); - }); - }); - ConfigureAuthentication(builder); - ConfigureControllers(builder); - ConfigureSwagger(builder); - ConfigureDependencyInjection(builder); - ConfigureLogging(builder); +app.MapIdentityApi(); - var app = builder.Build(); - - app.UseWhen( - // Log anything that isn't related to swagger. - context => IsNotSwaggerUI(context), - appBuilder => appBuilder.UseHttpLogging()); - - // Configure the HTTP request pipeline. - if (app.Environment.IsDevelopment()) - { - app.UseHttpsRedirection(); // Apache handles HTTPS in production. - } - - app.UseSwagger(); - app.UseSwaggerUI(options => - { - options.OAuthScopes("api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope"); - options.OAuthConfigObject.ClientId = builder.Configuration["AzureAd:SwaggerUIClientId"]; - options.OAuthConfigObject.UsePkceWithAuthorizationCodeGrant = true; - }); - - UseCorsAndWebSockets(app, allowedOrigins); - - app.UseAuthentication(); - app.UseAuthorization(); - - app.Map("/", () => "OK"); - app.MapControllers(); - - app.Run(); - - static bool IsNotSwaggerUI(HttpContext context) - { - var path = context.Request.GetEncodedPathAndQuery(); - - return !path.Contains("swagger") - && !path.Equals("/", StringComparison.Ordinal); - } - } - - private static void UseCorsAndWebSockets(WebApplication app, string[] allowedOrigins) - { - - // TODO: Figure out how to make a middleware for sockets? - var socketService = app.Services.GetRequiredService(); - var socketOptions = new WebSocketOptions(); - foreach (var origin in allowedOrigins) - socketOptions.AllowedOrigins.Add(origin); - - app.UseCors(); - app.UseWebSockets(socketOptions); - app.Use(async (context, next) => - { - if (context.WebSockets.IsWebSocketRequest) - { - await socketService.HandleSocketRequest(context); - } - else - { - await next(); - } - }); - } - - private static void ConfigureLogging(WebApplicationBuilder builder) - { - builder.Services.AddHttpLogging(options => - { - options.LoggingFields = HttpLoggingFields.RequestProperties - | HttpLoggingFields.RequestBody - | HttpLoggingFields.ResponseStatusCode - | HttpLoggingFields.ResponseBody; - }); - } - - private static void ConfigureAuthentication(WebApplicationBuilder builder) - { - AddJwtAuth(builder); - AddCookieAuth(builder); - SetupAuthSwitch(builder); - - static void AddJwtAuth(WebApplicationBuilder builder) - { - builder.Services - .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) - .AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd")); - } - - static void AddCookieAuth(WebApplicationBuilder builder) - { - builder.Services - .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) - .AddCookie(options => - { - options.Cookie.Name = "session-id"; - options.Cookie.SameSite = SameSiteMode.None; - options.Cookie.SecurePolicy = CookieSecurePolicy.Always; - options.SlidingExpiration = true; - options.LoginPath = new PathString("/User/LoginAsGuest"); - }); - } - - static void SetupAuthSwitch(WebApplicationBuilder builder) - { - var defaultScheme = "CookieOrJwt"; - builder.Services - .AddAuthentication(defaultScheme) - .AddPolicyScheme("CookieOrJwt", "Either cookie or jwt", options => - { - options.ForwardDefaultSelector = context => - { - var bearerAuth = context.Request.Headers["Authorization"].FirstOrDefault()?.StartsWith("Bearer ") ?? false; - return bearerAuth - ? JwtBearerDefaults.AuthenticationScheme - : CookieAuthenticationDefaults.AuthenticationScheme; - }; - }); - builder - .Services - .AddAuthentication(options => - { - options.DefaultAuthenticateScheme = defaultScheme; - }); - } - } - - private static void ConfigureControllers(WebApplicationBuilder builder) - { - builder.Services.AddControllers(); - builder.Services.Configure(options => - { - options.SerializerOptions.WriteIndented = true; - }); - } - - private static void ConfigureDependencyInjection(WebApplicationBuilder builder) - { - var services = builder.Services; - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - services.AddTransient(); - } - - private static void ConfigureSwagger(WebApplicationBuilder builder) - { - // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle - builder.Services.AddEndpointsApiExplorer(); - builder.Services.AddSwaggerGen(options => - { - var bearerKey = "Bearer"; - options.AddSecurityDefinition(bearerKey, new OpenApiSecurityScheme - { - Type = SecuritySchemeType.OAuth2, - Flows = new OpenApiOAuthFlows - { - Implicit = new OpenApiOAuthFlow - { - // These urls might be why only my email can login. - // TODO: Try testing with tenantId in the url instead of "common". - AuthorizationUrl = new Uri("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"), - TokenUrl = new Uri("https://login.microsoftonline.com/common/oauth2/v2.0/token"), - Scopes = new Dictionary - { - { "api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope", "Default Scope" }, - { "profile", "profile" }, - { "openid", "openid" } - } - } - }, - Scheme = "Bearer", - BearerFormat = "JWT", - In = ParameterLocation.Header, - }); - - // This adds the lock symbol next to every route in SwaggerUI. - options.AddSecurityRequirement(new OpenApiSecurityRequirement - { - { - new OpenApiSecurityScheme{ Reference = new OpenApiReference{ Type = ReferenceType.SecurityScheme, Id = bearerKey } }, - Array.Empty() - } - }); - }); - } - } +if (app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); // Apache handles HTTPS in production. } +else +{ + app.UseResponseCompression(); +} +app.UseSwagger(); +app.UseSwaggerUI(options => options.DocumentTitle = "Shogi.Api"); +app.UseAuthorization(); +app.Map("/", () => "OK"); +app.MapControllers(); +app.UseCors(policy => +{ + policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod().AllowCredentials(); +}); + +app.MapHub("/gamehub").RequireAuthorization(); + +app.Run(); + +static void AddIdentity(WebApplicationBuilder builder, ConfigurationManager configuration) +{ + builder.Services + .AddAuthorizationBuilder() + .AddPolicy("Admin", policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireAssertion(context => context.User?.Identity?.Name switch + { + "Hauth@live.com" => true, + "aat-account" => true, + _ => false + }); + }); + + builder.Services + .AddDbContext(options => + { + var cs = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("Database not configured."); + options.UseSqlServer(cs); + + // This is helpful to debug account issues without affecting the database. + //options.UseInMemoryDatabase("AppDb"); + }) + .AddIdentityApiEndpoints(options => + { + options.SignIn.RequireConfirmedEmail = true; + options.User.RequireUniqueEmail = true; + }) + .AddEntityFrameworkStores(); + + // I shouldn't this because I have it above, right? + //builder.Services.Configure(options => + //{ + // options.SignIn.RequireConfirmedEmail = true; + // options.User.RequireUniqueEmail = true; + //}); + + builder.Services.ConfigureApplicationCookie(options => + { + options.SlidingExpiration = true; + options.ExpireTimeSpan = TimeSpan.FromDays(3); + }); + +} \ No newline at end of file diff --git a/Shogi.Api/Repositories/Dto/SessionDto.cs b/Shogi.Api/Repositories/Dto/SessionDto.cs index 996b504..bec4145 100644 --- a/Shogi.Api/Repositories/Dto/SessionDto.cs +++ b/Shogi.Api/Repositories/Dto/SessionDto.cs @@ -1,5 +1,5 @@ namespace Shogi.Api.Repositories.Dto; -public readonly record struct SessionDto(string Name, string Player1, string Player2) +public readonly record struct SessionDto(string Id, string Player1Id, string Player2Id) { } diff --git a/Shogi.Api/Repositories/EmailSender.cs b/Shogi.Api/Repositories/EmailSender.cs new file mode 100644 index 0000000..d9e5c6d --- /dev/null +++ b/Shogi.Api/Repositories/EmailSender.cs @@ -0,0 +1,54 @@ +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.Extensions.Options; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Shogi.Api.Repositories; + +// https://app-smtp.brevo.com/real-time + +public class EmailSender : IEmailSender +{ + private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); + private readonly HttpClient client; + private string apiKey; + + + public EmailSender(HttpClient client, IOptionsMonitor apiKeys) + { + this.apiKey = apiKeys.CurrentValue.BrevoEmailService; + apiKeys.OnChange(keys => this.apiKey = keys.BrevoEmailService); + this.client = client; + } + + + public async Task SendEmailAsync(string email, string subject, string htmlMessage) + { + var body = new + { + Sender = new + { + Name = "Shogi Account Support", + Email = "shogi@lucaserver.space", + }, + To = new[] + { + new + { + Name = email, + Email = email, + } + }, + Subject = subject, + HtmlContent = htmlMessage, + }; + + var request = new HttpRequestMessage(HttpMethod.Post, new Uri("https://api.brevo.com/v3/smtp/email", UriKind.Absolute)); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + request.Headers.Add("api-key", apiKey); + request.Content = JsonContent.Create(body, options: Options); + + var response = await this.client.SendAsync(request); + response.EnsureSuccessStatusCode(); + } +} diff --git a/Shogi.Api/Repositories/QueryRepository.cs b/Shogi.Api/Repositories/QueryRepository.cs index 89cc942..6b489fb 100644 --- a/Shogi.Api/Repositories/QueryRepository.cs +++ b/Shogi.Api/Repositories/QueryRepository.cs @@ -1,65 +1,24 @@ using Dapper; -using Shogi.Contracts.Api; -using Shogi.Contracts.Types; +using Shogi.Api.Repositories.Dto; using System.Data; using System.Data.SqlClient; namespace Shogi.Api.Repositories; -public class QueryRepository : IQueryRespository +public class QueryRepository(IConfiguration configuration) { - private readonly string connectionString; + private readonly string connectionString = configuration.GetConnectionString("ShogiDatabase") + ?? throw new InvalidOperationException("No database configured for QueryRepository."); - public QueryRepository(IConfiguration configuration) - { - var connectionString = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("No database configured for QueryRepository."); - this.connectionString = connectionString; - } + public async Task> ReadSessionsMetadata(string playerId) + { + using var connection = new SqlConnection(this.connectionString); - public async Task ReadSessionPlayerCount(string playerName) - { - using var connection = new SqlConnection(connectionString); + var results = await connection.QueryMultipleAsync( + "session.ReadSessionsMetadata", + new { PlayerId = playerId }, + commandType: CommandType.StoredProcedure); - var results = await connection.QueryMultipleAsync( - "session.ReadSessionPlayerCount", - new { PlayerName = playerName }, - commandType: System.Data.CommandType.StoredProcedure); - - var joinedSessions = await results.ReadAsync(); - var otherSessions = await results.ReadAsync(); - return new ReadSessionsPlayerCountResponse - { - PlayerHasJoinedSessions = joinedSessions.ToList(), - AllOtherSessions = otherSessions.ToList() - }; - } - - /// - /// - /// A with Item1 as player 1 and Item2 as player 2. - public async Task<(User Player1, User? Player2)?> GetUsersForSession(string sessionName) - { - using var connection = new SqlConnection(connectionString); - var results = await connection.QueryAsync<(string Player1Name, string Player1DisplayName, string Player2Name, string Player2DisplayName)>( - "session.ReadUsersBySession", - new { SessionName = sessionName }, - commandType: CommandType.StoredProcedure); - - if (results.Any()) - { - var (Player1Name, Player1DisplayName, Player2Name, Player2DisplayName) = results.First(); - var p1 = new User(Player1Name, Player1DisplayName); - var p2 = Player2Name != null - ? new User(Player2Name, Player2DisplayName) - : null; - return (p1, p2); - } - return null; - } + return await results.ReadAsync(); + } } - -public interface IQueryRespository -{ - Task<(User Player1, User? Player2)?> GetUsersForSession(string sessionName); - Task ReadSessionPlayerCount(string playerName); -} \ No newline at end of file diff --git a/Shogi.Api/Repositories/SessionRepository.cs b/Shogi.Api/Repositories/SessionRepository.cs index 3bdc0ae..0c6eaa3 100644 --- a/Shogi.Api/Repositories/SessionRepository.cs +++ b/Shogi.Api/Repositories/SessionRepository.cs @@ -1,120 +1,84 @@ using Dapper; using Shogi.Api.Repositories.Dto; using Shogi.Contracts.Api; -using Shogi.Domain; +using Shogi.Domain.Aggregates; using System.Data; using System.Data.SqlClient; namespace Shogi.Api.Repositories; -public class SessionRepository : ISessionRepository +public class SessionRepository(IConfiguration configuration) { - private readonly string connectionString; + private readonly string connectionString = configuration.GetConnectionString("ShogiDatabase") + ?? throw new InvalidOperationException("Database connection string not configured."); - public SessionRepository(IConfiguration configuration) - { - connectionString = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("Database connection string not configured."); - } + public async Task CreateSession(Session session) + { + using var connection = new SqlConnection(this.connectionString); + await connection.ExecuteAsync( + "session.CreateSession", + new + { + session.Id, + Player1Id = session.Player1, + }, + commandType: CommandType.StoredProcedure); + } - public async Task CreateSession(Session session) - { - using var connection = new SqlConnection(connectionString); - await connection.ExecuteAsync( - "session.CreateSession", - new - { - session.Name, - Player1Name = session.Player1, - }, - commandType: CommandType.StoredProcedure); - } + public async Task DeleteSession(string id) + { + using var connection = new SqlConnection(this.connectionString); + await connection.ExecuteAsync( + "session.DeleteSession", + new { Id = id }, + commandType: CommandType.StoredProcedure); + } - public async Task DeleteSession(string name) - { - using var connection = new SqlConnection(connectionString); - await connection.ExecuteAsync( - "session.DeleteSession", - new { Name = name }, - commandType: CommandType.StoredProcedure); - } + public async Task<(SessionDto? Session, IEnumerable Moves)> ReadSessionAndMoves(string id) + { + using var connection = new SqlConnection(this.connectionString); + var results = await connection.QueryMultipleAsync( + "session.ReadSession", + new { Id = id }, + commandType: CommandType.StoredProcedure); - public async Task ReadSession(string name) - { - using var connection = new SqlConnection(connectionString); - var results = await connection.QueryMultipleAsync( - "session.ReadSession", - new { Name = name }, - commandType: CommandType.StoredProcedure); + var sessionDtos = await results.ReadAsync(); + if (!sessionDtos.Any()) + { + return (null, []); + } - var sessionDtos = await results.ReadAsync(); - if (!sessionDtos.Any()) return null; - var dto = sessionDtos.First(); - var session = new Session(dto.Name, dto.Player1); - if (!string.IsNullOrWhiteSpace(dto.Player2)) session.AddPlayer2(dto.Player2); + var moveDtos = await results.ReadAsync(); - var moveDtos = await results.ReadAsync(); - foreach (var move in moveDtos) - { - if (move.PieceFromHand.HasValue) - { - session.Board.Move(move.PieceFromHand.Value, move.To); - } - else if (move.From != null) - { - session.Board.Move(move.From, move.To, false); - } - else - { - throw new InvalidOperationException($"Corrupt data during {nameof(ReadSession)}"); - } - } - return session; - } + return new(sessionDtos.First(), moveDtos); + } - public async Task CreateMove(string sessionName, MovePieceCommand command) - { - var yep = new - { - command.To, - command.From, - command.IsPromotion, - command.PieceFromHand, - SessionName = sessionName - }; + public async Task CreateMove(string sessionId, MovePieceCommand command) + { + using var connection = new SqlConnection(this.connectionString); + await connection.ExecuteAsync( + "session.CreateMove", + new + { + command.To, + command.From, + command.IsPromotion, + PieceFromHand = command.PieceFromHand.ToString(), + SessionId = sessionId + }, + commandType: CommandType.StoredProcedure); + } - using var connection = new SqlConnection(connectionString); - await connection.ExecuteAsync( - "session.CreateMove", - new - { - command.To, - command.From, - command.IsPromotion, - PieceFromHand = command.PieceFromHand.ToString(), - SessionName = sessionName - }, - commandType: CommandType.StoredProcedure); - } - - public async Task SetPlayer2(string sessionName, string player2Name) - { - using var connection = new SqlConnection(connectionString); - await connection.ExecuteAsync( - "session.SetPlayer2", - new - { - SessionName = sessionName, - Player2Name = player2Name - }, - commandType: CommandType.StoredProcedure); - } -} - -public interface ISessionRepository -{ - Task CreateMove(string sessionName, MovePieceCommand command); - Task CreateSession(Session session); - Task DeleteSession(string name); - Task ReadSession(string name); - Task SetPlayer2(string sessionName, string player2Name); + public async Task SetPlayer2(string sessionId, string player2Id) + { + using var connection = new SqlConnection(this.connectionString); + await connection.ExecuteAsync( + "session.SetPlayer2", + new + { + SessionId = sessionId, + PlayerId = player2Id + }, + commandType: CommandType.StoredProcedure); + } } \ No newline at end of file diff --git a/Shogi.Api/Repositories/UserRepository.cs b/Shogi.Api/Repositories/UserRepository.cs deleted file mode 100644 index 9f54dbf..0000000 --- a/Shogi.Api/Repositories/UserRepository.cs +++ /dev/null @@ -1,52 +0,0 @@ -using Dapper; -using Shogi.Api.Models; -using System.Data; -using System.Data.SqlClient; - -namespace Shogi.Api.Repositories; - -public class UserRepository : IUserRepository -{ - private readonly string connectionString; - - public UserRepository(IConfiguration configuration) - { - var connectionString = configuration.GetConnectionString("ShogiDatabase"); - if (string.IsNullOrEmpty(connectionString)) - { - throw new InvalidOperationException("Connection string for database is empty."); - } - this.connectionString = connectionString; - } - - public async Task CreateUser(User user) - { - using var connection = new SqlConnection(connectionString); - await connection.ExecuteAsync( - "user.CreateUser", - new - { - Name = user.Id, - DisplayName = user.DisplayName, - Platform = user.LoginPlatform.ToString() - }, - commandType: CommandType.StoredProcedure); - } - - public async Task ReadUser(string id) - { - using var connection = new SqlConnection(connectionString); - var results = await connection.QueryAsync( - "user.ReadUser", - new { Name = id }, - commandType: CommandType.StoredProcedure); - - return results.FirstOrDefault(); - } -} - -public interface IUserRepository -{ - Task CreateUser(User user); - Task ReadUser(string id); -} \ No newline at end of file diff --git a/Shogi.Api/Services/SocketService.cs b/Shogi.Api/Services/SocketService.cs deleted file mode 100644 index 3921202..0000000 --- a/Shogi.Api/Services/SocketService.cs +++ /dev/null @@ -1,104 +0,0 @@ -using FluentValidation; -using Shogi.Contracts.Socket; -using Shogi.Contracts.Types; -using Shogi.Api.Extensions; -using Shogi.Api.Managers; -using System.Net; -using System.Net.WebSockets; -using System.Text.Json; - -namespace Shogi.Api.Services -{ - public interface ISocketService - { - Task HandleSocketRequest(HttpContext context); - } - - /// - /// Services a single websocket connection. Authenticates the socket connection, accepts messages, and sends messages. - /// - public class SocketService : ISocketService - { - private readonly ILogger logger; - private readonly ISocketConnectionManager communicationManager; - private readonly ISocketTokenCache tokenManager; - - public SocketService( - ILogger logger, - ISocketConnectionManager communicationManager, - ISocketTokenCache tokenManager) : base() - { - this.logger = logger; - this.communicationManager = communicationManager; - this.tokenManager = tokenManager; - } - - public async Task HandleSocketRequest(HttpContext context) - { - if (!context.Request.Query.Keys.Contains("token")) - { - context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - return; - } - var token = Guid.Parse(context.Request.Query["token"][0] ?? throw new InvalidOperationException("Token expected during socket connection request, but was not sent.")); - var userName = tokenManager.GetUsername(token); - - if (string.IsNullOrEmpty(userName)) - { - context.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - return; - } - var socket = await context.WebSockets.AcceptWebSocketAsync(); - - communicationManager.Subscribe(socket, userName); - // TODO: I probably don't need this while-loop anymore? Perhaps unsubscribe when a disconnect is detected instead. - while (socket.State.HasFlag(WebSocketState.Open)) - { - try - { - var message = await socket.ReceiveTextAsync(); - if (string.IsNullOrWhiteSpace(message)) continue; - logger.LogInformation("Request \n{0}\n", message); - var request = JsonSerializer.Deserialize(message); - if (request == null || !Enum.IsDefined(typeof(SocketAction), request.Action)) - { - await socket.SendTextAsync("Error: Action not recognized."); - continue; - } - switch (request.Action) - { - default: - await socket.SendTextAsync($"Received your message with action {request.Action}, but did no work."); - break; - } - } - catch (OperationCanceledException ex) - { - logger.LogError(ex.Message); - } - catch (WebSocketException ex) - { - logger.LogInformation($"{nameof(WebSocketException)} in {nameof(SocketConnectionManager)}."); - logger.LogInformation("Probably tried writing to a closed socket."); - logger.LogError(ex.Message); - } - communicationManager.Unsubscribe(userName); - - if (!socket.State.HasFlag(WebSocketState.Closed) && !socket.State.HasFlag(WebSocketState.Aborted)) - { - try - { - await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, - "Socket closed", - CancellationToken.None); - } - catch (Exception ex) - { - Console.WriteLine($"Ignored exception during socket closing. {ex.Message}"); - } - } - - } - } - } -} diff --git a/Shogi.Api/Shogi.Api.csproj b/Shogi.Api/Shogi.Api.csproj index e237ce2..2394d04 100644 --- a/Shogi.Api/Shogi.Api.csproj +++ b/Shogi.Api/Shogi.Api.csproj @@ -23,13 +23,15 @@ - - - - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/Shogi.Api/ShogiUserClaimsTransformer.cs b/Shogi.Api/ShogiUserClaimsTransformer.cs deleted file mode 100644 index e94f46a..0000000 --- a/Shogi.Api/ShogiUserClaimsTransformer.cs +++ /dev/null @@ -1,104 +0,0 @@ -using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.Cookies; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.Identity.Web; -using Shogi.Api.Models; -using Shogi.Api.Repositories; -using System.Security.Claims; - -namespace Shogi.Api; - -/// -/// Standardizes the claims from third party issuers. Also registers new msal users in the database. -/// -public class ShogiUserClaimsTransformer : IShogiUserClaimsTransformer -{ - private readonly IUserRepository userRepository; - - public ShogiUserClaimsTransformer(IUserRepository userRepository) - { - this.userRepository = userRepository; - } - - public async Task TransformAsync(ClaimsPrincipal principal) - { - var newPrincipal = IsMicrosoft(principal) - ? await CreateClaimsFromMicrosoftPrincipal(principal) - : await CreateClaimsFromGuestPrincipal(principal); - - return newPrincipal; - } - - public async Task CreateClaimsFromGuestPrincipal(ClaimsPrincipal principal) - { - var id = GetGuestUserId(principal); - if (string.IsNullOrWhiteSpace(id)) - { - var newUser = User.CreateGuestUser(Guid.NewGuid().ToString()); - await this.userRepository.CreateUser(newUser); - return new ClaimsPrincipal(CreateClaimsIdentity(newUser)); - } - - var user = await this.userRepository.ReadUser(id); - if (user == null) throw new UnauthorizedAccessException("Guest account does not exist."); - return new ClaimsPrincipal(CreateClaimsIdentity(user)); - } - - private async Task CreateClaimsFromMicrosoftPrincipal(ClaimsPrincipal principal) - { - var id = GetMicrosoftUserId(principal); - var displayname = principal.GetDisplayName(); - if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(displayname)) - { - throw new UnauthorizedAccessException("Unknown claim set."); - } - - var user = await this.userRepository.ReadUser(id); - if (user == null) - { - user = User.CreateMsalUser(id, displayname); - await this.userRepository.CreateUser(user); - } - return new ClaimsPrincipal(CreateClaimsIdentity(user)); - - } - - private static bool IsMicrosoft(ClaimsPrincipal self) - { - return self.GetObjectId() != null; - } - - private static string? GetMicrosoftUserId(ClaimsPrincipal self) - { - return self.GetObjectId(); - } - - private static string? GetGuestUserId(ClaimsPrincipal self) - { - return self.GetNameIdentifierId(); - } - - private static ClaimsIdentity CreateClaimsIdentity(User user) - { - var claims = new List(4) - { - new Claim(ClaimTypes.NameIdentifier, user.Id), - new Claim(ClaimTypes.Name, user.DisplayName), - }; - if (user.LoginPlatform == WhichLoginPlatform.Guest) - { - - claims.Add(new Claim(ClaimTypes.Role, "Guest")); - return new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); - } - else - { - return new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme); - } - } -} - -public interface IShogiUserClaimsTransformer : IClaimsTransformation -{ - Task CreateClaimsFromGuestPrincipal(ClaimsPrincipal principal); -} \ No newline at end of file diff --git a/Shogi.Api/appsettings.Development.json b/Shogi.Api/appsettings.Development.json index 8983e0f..47aef72 100644 --- a/Shogi.Api/appsettings.Development.json +++ b/Shogi.Api/appsettings.Development.json @@ -1,9 +1,13 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Information" - } - } + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ApiKeys": { + "BrevoEmailService": "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" + }, + "TestUserPassword": "I'mAToysRUsK1d" } diff --git a/Shogi.Api/appsettings.json b/Shogi.Api/appsettings.json index b4da000..cc40092 100644 --- a/Shogi.Api/appsettings.json +++ b/Shogi.Api/appsettings.json @@ -1,29 +1,28 @@ { - "ConnectionStrings": { - "ShogiDatabase": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Shogi;Integrated Security=True;Application Name=Shogi.Api" - }, - "Logging": { - "LogLevel": { - "Default": "Warning", - "Microsoft": "Warning", - "Microsoft.Hosting.Lifetime": "Error", - "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information", - "System.Net.Http.HttpClient": "Error" - } - }, - "AzureAd": { - "Instance": "https://login.microsoftonline.com/", - "TenantId": "common", - "ClientId": "c1e94676-cab0-42ba-8b6c-9532b8486fff", - "SwaggerUIClientId": "26bf69a4-2af8-4711-bf5b-79f75e20b082", - "Scope": "api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope" - }, - "Cors": { - "AllowedOrigins": [ - "http://localhost:3000", - "https://localhost:3000", - "https://api.lucaserver.space", - "https://lucaserver.space" - ] - } + "ConnectionStrings": { + "ShogiDatabase": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Shogi;Integrated Security=True;Application Name=Shogi.Api" + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft": "Warning", + "Microsoft.Hosting.Lifetime": "Error", + "Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information", + "System.Net.Http.HttpClient": "Error" + //"Microsoft.AspNetCore.SignalR": "Debug", + //"Microsoft.AspNetCore.Http.Connections": "Debug" + } + }, + "ApiKeys": { + "BrevoEmailService": "" + }, + "Cors": { + "AllowedOrigins": [ + "http://localhost:3000", + "https://localhost:3000", + "https://api.lucaserver.space", + "https://lucaserver.space" + ] + }, + "TestUserPassword": "" } \ No newline at end of file diff --git a/Shogi.Contracts/Api/Commands/CreateGuestTokenResponse.cs b/Shogi.Contracts/Api/Commands/CreateGuestTokenResponse.cs deleted file mode 100644 index c72d12a..0000000 --- a/Shogi.Contracts/Api/Commands/CreateGuestTokenResponse.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; - -namespace Shogi.Contracts.Api; - - public class CreateGuestTokenResponse - { - public string UserId { get; } - public string DisplayName { get; } - public Guid OneTimeToken { get; } - - public CreateGuestTokenResponse(string userId, string displayName, Guid oneTimeToken) - { - UserId = userId; - DisplayName = displayName; - OneTimeToken = oneTimeToken; - } - } diff --git a/Shogi.Contracts/Api/Commands/CreateSessionCommand.cs b/Shogi.Contracts/Api/Commands/CreateSessionCommand.cs deleted file mode 100644 index 98a33f6..0000000 --- a/Shogi.Contracts/Api/Commands/CreateSessionCommand.cs +++ /dev/null @@ -1,9 +0,0 @@ -using System.ComponentModel.DataAnnotations; - -namespace Shogi.Contracts.Api; - -public class CreateSessionCommand -{ - [Required] - public string Name { get; set; } -} diff --git a/Shogi.Contracts/Api/Commands/CreateTokenResponse.cs b/Shogi.Contracts/Api/Commands/CreateTokenResponse.cs deleted file mode 100644 index eb141dc..0000000 --- a/Shogi.Contracts/Api/Commands/CreateTokenResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System; - -namespace Shogi.Contracts.Api; - -public class CreateTokenResponse -{ - public string UserId { get; set; } - public string DisplayName { get; set; } - public Guid OneTimeToken { get; set; } -} diff --git a/Shogi.Contracts/Api/Commands/MovePieceCommand.cs b/Shogi.Contracts/Api/Commands/MovePieceCommand.cs index 3854113..7e77549 100644 --- a/Shogi.Contracts/Api/Commands/MovePieceCommand.cs +++ b/Shogi.Contracts/Api/Commands/MovePieceCommand.cs @@ -12,7 +12,7 @@ public class MovePieceCommand : IValidatableObject /// public MovePieceCommand() { - To = string.Empty; + this.To = string.Empty; } /// @@ -20,9 +20,9 @@ public class MovePieceCommand : IValidatableObject /// public MovePieceCommand(string from, string to, bool isPromotion) { - From = from; - To = to; - IsPromotion = isPromotion; + this.From = from; + this.To = to; + this.IsPromotion = isPromotion; } /// @@ -30,8 +30,8 @@ public class MovePieceCommand : IValidatableObject /// public MovePieceCommand(WhichPiece pieceFromHand, string to) { - PieceFromHand = pieceFromHand; - To = to; + this.PieceFromHand = pieceFromHand; + this.To = to; } /// @@ -57,21 +57,21 @@ public class MovePieceCommand : IValidatableObject public IEnumerable Validate(ValidationContext validationContext) { - if (PieceFromHand.HasValue && !string.IsNullOrWhiteSpace(From)) + if (this.PieceFromHand.HasValue && !string.IsNullOrWhiteSpace(this.From)) { - yield return new ValidationResult($"{nameof(PieceFromHand)} and {nameof(From)} are mutually exclusive properties."); + yield return new ValidationResult($"{nameof(this.PieceFromHand)} and {nameof(this.From)} are mutually exclusive properties."); } - if (PieceFromHand.HasValue && IsPromotion.HasValue) + if (this.PieceFromHand.HasValue && this.IsPromotion.HasValue) { - yield return new ValidationResult($"{nameof(PieceFromHand)} and {nameof(IsPromotion)} are mutually exclusive properties."); + yield return new ValidationResult($"{nameof(this.PieceFromHand)} and {nameof(this.IsPromotion)} are mutually exclusive properties."); } - if (!Regex.IsMatch(To, "[A-I][1-9]")) + if (!Regex.IsMatch(this.To, "[A-I][1-9]")) { - yield return new ValidationResult($"{nameof(To)} must be a valid board position, between A1 and I9"); + yield return new ValidationResult($"{nameof(this.To)} must be a valid board position, between A1 and I9"); } - if (!string.IsNullOrEmpty(From) && !Regex.IsMatch(From, "[A-I][1-9]")) + if (!string.IsNullOrEmpty(this.From) && !Regex.IsMatch(this.From, "[A-I][1-9]")) { - yield return new ValidationResult($"{nameof(From)} must be a valid board position, between A1 and I9"); + yield return new ValidationResult($"{nameof(this.From)} must be a valid board position, between A1 and I9"); } } } diff --git a/Shogi.Contracts/Api/Queries/ReadAllSessionsResponse.cs b/Shogi.Contracts/Api/Queries/ReadAllSessionsResponse.cs deleted file mode 100644 index 1e98968..0000000 --- a/Shogi.Contracts/Api/Queries/ReadAllSessionsResponse.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Shogi.Contracts.Types; -using System.Collections.Generic; - -namespace Shogi.Contracts.Api; - -public class ReadSessionsPlayerCountResponse - { - public IList PlayerHasJoinedSessions { get; set; } - public IList AllOtherSessions { get; set; } - } diff --git a/Shogi.Contracts/Api/Queries/ReadSessionResponse.cs b/Shogi.Contracts/Api/Queries/ReadSessionResponse.cs deleted file mode 100644 index e8300ab..0000000 --- a/Shogi.Contracts/Api/Queries/ReadSessionResponse.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Shogi.Contracts.Types; - -namespace Shogi.Contracts.Api; - -public class ReadSessionResponse -{ - public Session Session { get; set; } -} diff --git a/Shogi.Contracts/Shogi.Contracts.csproj b/Shogi.Contracts/Shogi.Contracts.csproj index 14dcce0..7b85818 100644 --- a/Shogi.Contracts/Shogi.Contracts.csproj +++ b/Shogi.Contracts/Shogi.Contracts.csproj @@ -10,4 +10,8 @@ Contains DTOs use for http requests to Shogi backend services. + + + + diff --git a/Shogi.Contracts/Socket/ISocketMessage.cs b/Shogi.Contracts/Socket/ISocketMessage.cs deleted file mode 100644 index 675d1fe..0000000 --- a/Shogi.Contracts/Socket/ISocketMessage.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Shogi.Contracts.Types; - -namespace Shogi.Contracts.Socket; - -public interface ISocketMessage -{ - SocketAction Action { get; } -} - -public class SocketResponse : ISocketMessage -{ - public SocketAction Action { get; set; } -} diff --git a/Shogi.Contracts/Socket/PlayerHasMovedMessage.cs b/Shogi.Contracts/Socket/PlayerHasMovedMessage.cs deleted file mode 100644 index 048ad9e..0000000 --- a/Shogi.Contracts/Socket/PlayerHasMovedMessage.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Shogi.Contracts.Types; - -namespace Shogi.Contracts.Socket; - -public class PlayerHasMovedMessage : ISocketMessage -{ - public SocketAction Action { get; } - public string SessionName { get; set; } - /// - /// The player that made the move. - /// - public string PlayerName { get; set; } - - public PlayerHasMovedMessage() - { - Action = SocketAction.PieceMoved; - SessionName = string.Empty; - PlayerName = string.Empty; - } -} diff --git a/Shogi.Contracts/Socket/SessionCreatedSocketMessage.cs b/Shogi.Contracts/Socket/SessionCreatedSocketMessage.cs deleted file mode 100644 index 4700a9d..0000000 --- a/Shogi.Contracts/Socket/SessionCreatedSocketMessage.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Shogi.Contracts.Types; - -namespace Shogi.Contracts.Socket; - -public class SessionCreatedSocketMessage : ISocketMessage -{ - public SocketAction Action => SocketAction.SessionCreated; -} diff --git a/Shogi.Contracts/Socket/SessionJoinedByPlayerSocketMessage.cs b/Shogi.Contracts/Socket/SessionJoinedByPlayerSocketMessage.cs deleted file mode 100644 index 0457226..0000000 --- a/Shogi.Contracts/Socket/SessionJoinedByPlayerSocketMessage.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Shogi.Contracts.Types; - -namespace Shogi.Contracts.Socket; - -public class SessionJoinedByPlayerSocketMessage : ISocketMessage -{ - public SocketAction Action => SocketAction.SessionJoined; - - public string SessionName { get; set; } - - public SessionJoinedByPlayerSocketMessage(string sessionName) - { - SessionName = sessionName; - } -} diff --git a/Shogi.Contracts/Types/Session.cs b/Shogi.Contracts/Types/Session.cs index a6676aa..8f83cd5 100644 --- a/Shogi.Contracts/Types/Session.cs +++ b/Shogi.Contracts/Types/Session.cs @@ -1,9 +1,20 @@ -namespace Shogi.Contracts.Types; +using System; + +namespace Shogi.Contracts.Types; public class Session { - public User Player1 { get; set; } - public User? Player2 { get; set; } - public string SessionName { get; set; } + /// + /// Email + /// + public string Player1 { get; set; } + + /// + /// Email. Null if no second player exists. + /// + public string? Player2 { get; set; } + + public Guid SessionId { get; set; } + public BoardState BoardState { get; set; } } diff --git a/Shogi.Contracts/Types/SessionMetadata.cs b/Shogi.Contracts/Types/SessionMetadata.cs index 0a00b2d..14563f9 100644 --- a/Shogi.Contracts/Types/SessionMetadata.cs +++ b/Shogi.Contracts/Types/SessionMetadata.cs @@ -1,8 +1,11 @@ -namespace Shogi.Contracts.Types +using System; + +namespace Shogi.Contracts.Types { - public class SessionMetadata - { - public string Name { get; set; } - public int PlayerCount { get; set; } - } + public class SessionMetadata + { + public Guid SessionId { get; set; } + public string Player1 { get; set; } = string.Empty; + public string Player2 { get; set; } = string.Empty; + } } diff --git a/Shogi.Contracts/Types/SocketAction.cs b/Shogi.Contracts/Types/SocketAction.cs deleted file mode 100644 index 52efc3a..0000000 --- a/Shogi.Contracts/Types/SocketAction.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Shogi.Contracts.Types -{ - public enum SocketAction - { - SessionCreated, - SessionJoined, - PieceMoved - } -} diff --git a/Shogi.Contracts/Types/User.cs b/Shogi.Contracts/Types/User.cs deleted file mode 100644 index 2cfc70b..0000000 --- a/Shogi.Contracts/Types/User.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace Shogi.Contracts.Types; - -public class User -{ - public string Id { get; set; } = string.Empty; - - /// - /// A display name for the user. - /// - public string Name { get; set; } = string.Empty; - - public User(string id, string name) - { - Id = id; - Name = name; - } -} diff --git a/Shogi.Database/AspNetUsersId.sql b/Shogi.Database/AspNetUsersId.sql new file mode 100644 index 0000000..65a7f13 --- /dev/null +++ b/Shogi.Database/AspNetUsersId.sql @@ -0,0 +1,3 @@ +-- This is so I don't have to remember the type used in the dbo.AspNetUsers table for the Id column. +CREATE TYPE [dbo].[AspNetUsersId] + FROM NVARCHAR(450) NOT NULL; diff --git a/Shogi.Database/FirstTimeSetup.sql b/Shogi.Database/FirstTimeSetup.sql index ed1a8d6..40422fe 100644 --- a/Shogi.Database/FirstTimeSetup.sql +++ b/Shogi.Database/FirstTimeSetup.sql @@ -4,4 +4,11 @@ --CREATE ROLE db_executor --GRANT EXECUTE To db_executor --- Give Shogi.Api user permission to db_executor, db_datareader, db_datawriter \ No newline at end of file +-- Give Shogi.Api user permission to db_executor, db_datareader, db_datawriter + + +/** +* Local setup instructions, in order: +* 1. To setup the Shogi database, use the dacpac process in visual studio with the Shogi.Database project. +* 2. To setup the Entity Framework users database, run this powershell command using Shogi.Api as the target project: dotnet ef database update +*/ \ No newline at end of file diff --git a/Shogi.Database/Post Deployment/Script.PostDeployment.sql b/Shogi.Database/Post Deployment/Script.PostDeployment.sql index dd53baf..bf58235 100644 --- a/Shogi.Database/Post Deployment/Script.PostDeployment.sql +++ b/Shogi.Database/Post Deployment/Script.PostDeployment.sql @@ -10,6 +10,5 @@ Post-Deployment Script Template -------------------------------------------------------------------------------------- */ -:r .\Scripts\PopulateLoginPlatforms.sql :r .\Scripts\PopulatePieces.sql :r .\Scripts\EnableSnapshotIsolationLevel.sql \ No newline at end of file diff --git a/Shogi.Database/Post Deployment/Scripts/PopulateLoginPlatforms.sql b/Shogi.Database/Post Deployment/Scripts/PopulateLoginPlatforms.sql deleted file mode 100644 index 044b8ba..0000000 --- a/Shogi.Database/Post Deployment/Scripts/PopulateLoginPlatforms.sql +++ /dev/null @@ -1,16 +0,0 @@ - -DECLARE @LoginPlatforms TABLE ( - [Platform] NVARCHAR(20) -) - -INSERT INTO @LoginPlatforms ([Platform]) -VALUES - ('Guest'), - ('Microsoft'); - -MERGE [user].[LoginPlatform] as t -USING @LoginPlatforms as s -ON t.[Platform] = s.[Platform] -WHEN NOT MATCHED THEN - INSERT ([Platform]) - VALUES (s.[Platform]); \ No newline at end of file diff --git a/Shogi.Database/Session/Functions/MaxNewSessionsPerUser.sql b/Shogi.Database/Session/Functions/MaxNewSessionsPerUser.sql new file mode 100644 index 0000000..9f8d0f2 --- /dev/null +++ b/Shogi.Database/Session/Functions/MaxNewSessionsPerUser.sql @@ -0,0 +1,18 @@ +CREATE FUNCTION [session].[MaxNewSessionsPerUser]() RETURNS INT +AS +BEGIN + + DECLARE @MaxNewSessionsCreatedByAnyOneUser INT; + + WITH CountOfNewSessionsPerPlayer AS + ( + SELECT COUNT(*) as TotalNewSessions + FROM [session].[Session] + WHERE Player2Id IS NULL + GROUP BY Player1Id + ) + SELECT @MaxNewSessionsCreatedByAnyOneUser = MAX(CountOfNewSessionsPerPlayer.TotalNewSessions) + FROM CountOfNewSessionsPerPlayer + + RETURN @MaxNewSessionsCreatedByAnyOneUser +END diff --git a/Shogi.Database/Session/Stored Procedures/CreateMove.sql b/Shogi.Database/Session/Stored Procedures/CreateMove.sql index 6125e59..946ab94 100644 --- a/Shogi.Database/Session/Stored Procedures/CreateMove.sql +++ b/Shogi.Database/Session/Stored Procedures/CreateMove.sql @@ -1,9 +1,9 @@ CREATE PROCEDURE [session].[CreateMove] - @To VARCHAR(2), - @From VARCHAR(2) = NULL, + @To VARCHAR(2), + @From VARCHAR(2) = NULL, @IsPromotion BIT = 0, - @PieceFromHand NVARCHAR(13) = NULL, - @SessionName [session].[SessionName] + @PieceFromHand NVARCHAR(13) = NULL, + @SessionId [session].[SessionSurrogateKey] AS BEGIN @@ -13,11 +13,6 @@ BEGIN BEGIN TRANSACTION - DECLARE @SessionId BIGINT = 0; - SELECT @SessionId = Id - FROM [session].[Session] - WHERE [Name] = @SessionName; - DECLARE @PieceIdFromhand INT = NULL; SELECT @PieceIdFromhand = Id FROM [session].[Piece] diff --git a/Shogi.Database/Session/Stored Procedures/CreateSession.sql b/Shogi.Database/Session/Stored Procedures/CreateSession.sql index 879f2ab..3331682 100644 --- a/Shogi.Database/Session/Stored Procedures/CreateSession.sql +++ b/Shogi.Database/Session/Stored Procedures/CreateSession.sql @@ -1,12 +1,13 @@ CREATE PROCEDURE [session].[CreateSession] - @Name [session].[SessionName], - @Player1Name [user].[UserName] + @Id [session].[SessionSurrogateKey], + @Player1Id [dbo].[AspNetUsersId] AS BEGIN SET NOCOUNT ON - INSERT INTO [session].[Session] ([Name], Player1Id) - SELECT @Name, Id - FROM [user].[User] - WHERE [Name] = @Player1Name + INSERT INTO [session].[Session] + ([Id], Player1Id) + VALUES + (@Id, @Player1Id) + END \ No newline at end of file diff --git a/Shogi.Database/Session/Stored Procedures/DeleteSession.sql b/Shogi.Database/Session/Stored Procedures/DeleteSession.sql index 38cace2..45dbb08 100644 --- a/Shogi.Database/Session/Stored Procedures/DeleteSession.sql +++ b/Shogi.Database/Session/Stored Procedures/DeleteSession.sql @@ -1,5 +1,5 @@ CREATE PROCEDURE [session].[DeleteSession] - @Name [session].[SessionName] + @Id [session].[SessionSurrogateKey] AS -DELETE FROM [session].[Session] WHERE [Name] = @Name; \ No newline at end of file +DELETE FROM [session].[Session] WHERE [Id] = @Id; \ No newline at end of file diff --git a/Shogi.Database/Session/Stored Procedures/ReadSession.sql b/Shogi.Database/Session/Stored Procedures/ReadSession.sql index f37abe8..c9670ec 100644 --- a/Shogi.Database/Session/Stored Procedures/ReadSession.sql +++ b/Shogi.Database/Session/Stored Procedures/ReadSession.sql @@ -1,5 +1,5 @@ CREATE PROCEDURE [session].[ReadSession] - @Name [session].[SessionName] + @Id [session].[SessionSurrogateKey] AS BEGIN SET NOCOUNT ON -- Performance boost @@ -10,13 +10,12 @@ BEGIN -- Session SELECT - sess.[Name], - p1.[Name] as Player1, - p2.[Name] as Player2 - FROM [session].[Session] sess - INNER JOIN [user].[User] p1 on sess.Player1Id = p1.Id - LEFT JOIN [user].[User] p2 on sess.Player2Id = p2.Id - WHERE sess.[Name] = @Name; + Id, + Player1Id, + Player2Id, + CreatedDate + FROM [session].[Session] + WHERE Id = @Id; -- Player moves SELECT @@ -27,7 +26,7 @@ BEGIN FROM [session].[Move] mv INNER JOIN [session].[Session] sess ON sess.Id = mv.SessionId LEFT JOIN [session].Piece piece on piece.Id = mv.PieceIdFromHand - WHERE sess.[Name] = @Name; + WHERE sess.[Id] = @Id; COMMIT END diff --git a/Shogi.Database/Session/Stored Procedures/ReadSessionPlayerCount.sql b/Shogi.Database/Session/Stored Procedures/ReadSessionPlayerCount.sql deleted file mode 100644 index c993332..0000000 --- a/Shogi.Database/Session/Stored Procedures/ReadSessionPlayerCount.sql +++ /dev/null @@ -1,31 +0,0 @@ -CREATE PROCEDURE [session].[ReadSessionPlayerCount] - @PlayerName [user].UserName -AS -BEGIN - SET NOCOUNT ON; - - DECLARE @PlayerId as BIGINT; - SELECT @PlayerId = Id - FROM [user].[User] - WHERE [Name] = @PlayerName; - - -- Result set of sessions which @PlayerName participates in. - SELECT - [Name], - CASE - WHEN Player2Id IS NULL THEN 1 - ELSE 2 - END AS PlayerCount - FROM [session].[Session] - WHERE Player1Id = @PlayerId OR Player2Id = @PlayerId; - - -- Result set of sessions which @PlayerName does not participate in. - SELECT - [Name], - CASE - WHEN Player2Id IS NULL THEN 1 - ELSE 2 - END AS PlayerCount - FROM [session].[Session] - WHERE Player1Id <> @PlayerId AND ISNULL(Player2Id, 0) <> @PlayerId; -END \ No newline at end of file diff --git a/Shogi.Database/Session/Stored Procedures/ReadSessionsMetadata.sql b/Shogi.Database/Session/Stored Procedures/ReadSessionsMetadata.sql new file mode 100644 index 0000000..bff7ae9 --- /dev/null +++ b/Shogi.Database/Session/Stored Procedures/ReadSessionsMetadata.sql @@ -0,0 +1,21 @@ +CREATE PROCEDURE [session].[ReadSessionsMetadata] + @PlayerId [dbo].[AspNetUsersId] +AS +BEGIN + SET NOCOUNT ON; + + -- Read all sessions, in this order: + -- 1. sessions created by the logged-in user + -- 2. any other sessions the logged-in user participates in + -- 3. all other sessions + SELECT + Id, Player1Id, Player2Id, [Session].CreatedDate, + case + when Player1Id = @PlayerId then 0 + when Player2Id = @PlayerId then 1 + else 2 + end as OrderBy + FROM [session].[Session] + Order By OrderBy ASC, CreatedDate DESC + +END \ No newline at end of file diff --git a/Shogi.Database/Session/Stored Procedures/ReadUsersBySession.sql b/Shogi.Database/Session/Stored Procedures/ReadUsersBySession.sql deleted file mode 100644 index d8c6ed7..0000000 --- a/Shogi.Database/Session/Stored Procedures/ReadUsersBySession.sql +++ /dev/null @@ -1,13 +0,0 @@ -CREATE PROCEDURE [session].[ReadUsersBySession] - @SessionName [session].[SessionName] -AS - -SELECT - p1.[Name] as Player1Name, - p1.DisplayName as Player1DisplayName, - p2.[Name] as Player2Name, - p2.DisplayName as Player2Displayname -FROM [session].[Session] sess - INNER JOIN [user].[User] p1 ON sess.Player1Id = p1.Id - LEFT JOIN [user].[User] p2 on sess.Player2Id = p2.Id -WHERE sess.[Name] = @SessionName; diff --git a/Shogi.Database/Session/Stored Procedures/SetPlayer2.sql b/Shogi.Database/Session/Stored Procedures/SetPlayer2.sql index afad1c2..050a415 100644 --- a/Shogi.Database/Session/Stored Procedures/SetPlayer2.sql +++ b/Shogi.Database/Session/Stored Procedures/SetPlayer2.sql @@ -1,16 +1,13 @@ CREATE PROCEDURE [session].[SetPlayer2] - @SessionName [session].[SessionName], - @Player2Name [user].[UserName] NULL + @SessionId [session].[SessionSurrogateKey], + @PlayerId [dbo].[AspNetUsersId] AS BEGIN SET NOCOUNT ON; - - DECLARE @player2Id BIGINT; - SELECT @player2Id = Id FROM [user].[User] WHERE [Name] = @Player2Name; - - UPDATE sess - SET Player2Id = @player2Id - FROM [session].[Session] sess - WHERE sess.[Name] = @SessionName; + + UPDATE [session].[Session] + SET Player2Id = @PlayerId + FROM [session].[Session] + WHERE Id = @SessionId; END diff --git a/Shogi.Database/Session/Tables/Move.sql b/Shogi.Database/Session/Tables/Move.sql index f5b00f6..549bf4a 100644 --- a/Shogi.Database/Session/Tables/Move.sql +++ b/Shogi.Database/Session/Tables/Move.sql @@ -1,11 +1,11 @@ CREATE TABLE [session].[Move] ( - [Id] INT NOT NULL PRIMARY KEY IDENTITY, - [SessionId] BIGINT NOT NULL, - [To] VARCHAR(2) NOT NULL, - [From] VARCHAR(2) NULL, + [Id] INT NOT NULL PRIMARY KEY IDENTITY, + [SessionId] [session].[SessionSurrogateKey] NOT NULL, + [To] VARCHAR(2) NOT NULL, + [From] VARCHAR(2) NULL, [PieceIdFromHand] INT NULL, - [IsPromotion] BIT DEFAULT 0 + [IsPromotion] BIT DEFAULT 0 CONSTRAINT [Cannot end where you start] CHECK ([From] <> [To]), diff --git a/Shogi.Database/Session/Tables/Session.sql b/Shogi.Database/Session/Tables/Session.sql index 889e61e..c35f1da 100644 --- a/Shogi.Database/Session/Tables/Session.sql +++ b/Shogi.Database/Session/Tables/Session.sql @@ -1,16 +1,8 @@ CREATE TABLE [session].[Session] ( - Id BIGINT NOT NULL PRIMARY KEY IDENTITY, - [Name] [session].[SessionName] UNIQUE, - Player1Id BIGINT NOT NULL, - Player2Id BIGINT NULL, - Created DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(), - - CONSTRAINT FK_Player1_User FOREIGN KEY (Player1Id) REFERENCES [user].[User] (Id) - ON DELETE CASCADE - ON UPDATE CASCADE, - - CONSTRAINT FK_Player2_User FOREIGN KEY (Player2Id) REFERENCES [user].[User] (Id) - ON DELETE NO ACTION - ON UPDATE NO ACTION + Id [session].[SessionSurrogateKey] PRIMARY KEY, + Player1Id [dbo].[AspNetUsersId] NOT NULL, + Player2Id [dbo].[AspNetUsersId] NULL, + [CreatedDate] DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET(), + CONSTRAINT [CK_Session_LimitedNewSessions] CHECK ([session].MaxNewSessionsPerUser() < 4), ) diff --git a/Shogi.Database/Session/Types/SessionName.sql b/Shogi.Database/Session/Types/SessionName.sql deleted file mode 100644 index 0e6f505..0000000 --- a/Shogi.Database/Session/Types/SessionName.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE TYPE [session].[SessionName] - FROM nvarchar(50) NOT NULL diff --git a/Shogi.Database/Session/Types/SessionSurrogateKey.sql b/Shogi.Database/Session/Types/SessionSurrogateKey.sql new file mode 100644 index 0000000..a10a0f4 --- /dev/null +++ b/Shogi.Database/Session/Types/SessionSurrogateKey.sql @@ -0,0 +1,2 @@ +CREATE TYPE [session].[SessionSurrogateKey] + FROM CHAR(36) NOT NULL diff --git a/Shogi.Database/Shogi.Database.refactorlog b/Shogi.Database/Shogi.Database.refactorlog new file mode 100644 index 0000000..63da8ad --- /dev/null +++ b/Shogi.Database/Shogi.Database.refactorlog @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Shogi.Database/Shogi.Database.sqlproj b/Shogi.Database/Shogi.Database.sqlproj index e2c8d6b..26024ad 100644 --- a/Shogi.Database/Shogi.Database.sqlproj +++ b/Shogi.Database/Shogi.Database.sqlproj @@ -58,36 +58,27 @@ - - - - + - - - - - - - - - + - + + + @@ -97,4 +88,7 @@ + + + \ No newline at end of file diff --git a/Shogi.Database/User/StoredProcedures/CreateUser.sql b/Shogi.Database/User/StoredProcedures/CreateUser.sql deleted file mode 100644 index d86ac22..0000000 --- a/Shogi.Database/User/StoredProcedures/CreateUser.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE PROCEDURE [user].[CreateUser] - @Name [user].[UserName], - @DisplayName NVARCHAR(100), - @Platform NVARCHAR(20) -AS -BEGIN - -SET NOCOUNT ON - -INSERT INTO [user].[User] ([Name], DisplayName, [Platform]) -VALUES - (@Name, @DisplayName, @Platform); - -END \ No newline at end of file diff --git a/Shogi.Database/User/StoredProcedures/ReadUser.sql b/Shogi.Database/User/StoredProcedures/ReadUser.sql deleted file mode 100644 index 13e0a10..0000000 --- a/Shogi.Database/User/StoredProcedures/ReadUser.sql +++ /dev/null @@ -1,11 +0,0 @@ -CREATE PROCEDURE [user].[ReadUser] - @Name [user].[UserName] -AS -BEGIN - SELECT - [Name] as Id, - DisplayName, - [Platform] - FROM [user].[User] - WHERE [Name] = @Name; -END \ No newline at end of file diff --git a/Shogi.Database/User/Tables/LoginPlatform.sql b/Shogi.Database/User/Tables/LoginPlatform.sql deleted file mode 100644 index e63d0ca..0000000 --- a/Shogi.Database/User/Tables/LoginPlatform.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE [user].[LoginPlatform] -( - [Platform] NVARCHAR(20) NOT NULL PRIMARY KEY -) diff --git a/Shogi.Database/User/Tables/User.sql b/Shogi.Database/User/Tables/User.sql deleted file mode 100644 index f0915f9..0000000 --- a/Shogi.Database/User/Tables/User.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE [user].[User] -( - [Id] BIGINT NOT NULL PRIMARY KEY IDENTITY, -- TODO: Consider using user.UserName as the PK to avoid confusing "Id" in the database vs "Id" in the domain model. - [Name] [user].[UserName] NOT NULL UNIQUE, - [DisplayName] NVARCHAR(100) NOT NULL, - [Platform] NVARCHAR(20) NOT NULL, - [CreatedDate] DATETIMEOFFSET DEFAULT SYSDATETIMEOFFSET() - - CONSTRAINT User_Platform FOREIGN KEY ([Platform]) References [user].[LoginPlatform] ([Platform]) - ON DELETE CASCADE - ON UPDATE CASCADE -) diff --git a/Shogi.Database/User/Types/UserName.sql b/Shogi.Database/User/Types/UserName.sql deleted file mode 100644 index 224d537..0000000 --- a/Shogi.Database/User/Types/UserName.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE TYPE [user].[UserName] - FROM nvarchar(100) NOT NULL diff --git a/Shogi.Database/User/User.sql b/Shogi.Database/User/User.sql deleted file mode 100644 index 08baf83..0000000 --- a/Shogi.Database/User/User.sql +++ /dev/null @@ -1 +0,0 @@ -CREATE SCHEMA [user] diff --git a/Shogi.Domain/Aggregates/Session.cs b/Shogi.Domain/Aggregates/Session.cs index 6ba0e35..4c76fcf 100644 --- a/Shogi.Domain/Aggregates/Session.cs +++ b/Shogi.Domain/Aggregates/Session.cs @@ -1,41 +1,32 @@ using Shogi.Domain.ValueObjects; -namespace Shogi.Domain; +namespace Shogi.Domain.Aggregates; -public class Session +public class Session(Guid id, string player1Name) { - public Session( - string name, - string player1Name) - { - Name = name; - Player1 = player1Name; - Board = new(BoardState.StandardStarting); - } + public Guid Id { get; } = id; - public string Name { get; } + public ShogiBoard Board { get; } = new(BoardState.StandardStarting); - public ShogiBoard Board { get; } + /// + /// The email of the player which created the session. + /// + public string Player1 { get; } = player1Name; - /// - /// The User.Id of the player which created the session. - /// - public string Player1 { get; } + /// + /// The email of the second player. + /// + public string? Player2 { get; private set; } - /// - /// The User.Id of the second player. - /// - public string? Player2 { get; private set; } + public void AddPlayer2(string player2Name) + { + if (this.Player2 != null) throw new InvalidOperationException("Player 2 already exists while trying to add a second player."); + if (this.Player1.Equals(player2Name, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Player 2 must be different from Player 1"); + this.Player2 = player2Name; + } - public void AddPlayer2(string player2Name) - { - if (Player2 != null) throw new InvalidOperationException("Player 2 already exists while trying to add a second player."); - if (Player1.Equals(player2Name, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Player 2 must be different from Player 1"); - Player2 = player2Name; - } - - public bool IsSeated(string playerName) - { - return Player1 == playerName || Player2 == playerName; - } + public bool IsSeated(string playerName) + { + return this.Player1 == playerName || this.Player2 == playerName; + } } diff --git a/Shogi.UI/App.razor b/Shogi.UI/App.razor index efa8cea..e7359b7 100644 --- a/Shogi.UI/App.razor +++ b/Shogi.UI/App.razor @@ -2,7 +2,7 @@ - + Not found diff --git a/Shogi.UI/Identity/CookieAuthenticationStateProvider.cs b/Shogi.UI/Identity/CookieAuthenticationStateProvider.cs new file mode 100644 index 0000000..4234d33 --- /dev/null +++ b/Shogi.UI/Identity/CookieAuthenticationStateProvider.cs @@ -0,0 +1,246 @@ +namespace Shogi.UI.Identity; + +using Microsoft.AspNetCore.Components.Authorization; +using System.Net.Http.Json; +using System.Security.Claims; +using System.Text; +using System.Text.Json; + +/// +/// Handles state for cookie-based auth. +/// +public class CookieAuthenticationStateProvider : AuthenticationStateProvider, IAccountManagement +{ + /// + /// Map the JavaScript-formatted properties to C#-formatted classes. + /// + private readonly JsonSerializerOptions jsonSerializerOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + /// + /// Special auth client. + /// + private readonly HttpClient _httpClient; + + /// + /// Authentication state. + /// + private bool _authenticated = false; + + /// + /// Default principal for anonymous (not authenticated) users. + /// + private readonly ClaimsPrincipal Unauthenticated = + new(new ClaimsIdentity()); + + /// + /// Create a new instance of the auth provider. + /// + /// Factory to retrieve auth client. + public CookieAuthenticationStateProvider(IHttpClientFactory httpClientFactory) + => _httpClient = httpClientFactory.CreateClient("Auth"); + + /// + /// Register a new user. + /// + /// The user's email address. + /// The user's password. + /// The result serialized to a . + /// + public async Task RegisterAsync(string email, string password) + { + string[] defaultDetail = ["An unknown error prevented registration from succeeding."]; + + try + { + // make the request + var result = await _httpClient.PostAsJsonAsync("register", new + { + email, + password + }); + + // successful? + if (result.IsSuccessStatusCode) + { + return new FormResult { Succeeded = true }; + } + + // body should contain details about why it failed + var details = await result.Content.ReadAsStringAsync(); + var problemDetails = JsonDocument.Parse(details); + var errors = new List(); + var errorList = problemDetails.RootElement.GetProperty("errors"); + + foreach (var errorEntry in errorList.EnumerateObject()) + { + if (errorEntry.Value.ValueKind == JsonValueKind.String) + { + errors.Add(errorEntry.Value.GetString()!); + } + else if (errorEntry.Value.ValueKind == JsonValueKind.Array) + { + errors.AddRange( + errorEntry.Value.EnumerateArray().Select( + e => e.GetString() ?? string.Empty) + .Where(e => !string.IsNullOrEmpty(e))); + } + } + + // return the error list + return new FormResult + { + Succeeded = false, + ErrorList = problemDetails == null ? defaultDetail : [.. errors] + }; + } + catch { } + + // unknown error + return new FormResult + { + Succeeded = false, + ErrorList = defaultDetail + }; + } + + /// + /// User login. + /// + /// The user's email address. + /// The user's password. + /// The result of the login request serialized to a . + public async Task LoginAsync(string email, string password) + { + try + { + // login with cookies + var result = await _httpClient.PostAsJsonAsync("login?useCookies=true", new + { + email, + password + }); + + // success? + if (result.IsSuccessStatusCode) + { + // need to refresh auth state + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + + // success! + return new FormResult { Succeeded = true }; + } + } + catch { } + + // unknown error + return new FormResult + { + Succeeded = false, + ErrorList = ["Invalid email and/or password."] + }; + } + + /// + /// Get authentication state. + /// + /// + /// Called by Blazor anytime and authentication-based decision needs to be made, then cached + /// until the changed state notification is raised. + /// + /// The authentication state asynchronous request. + public override async Task GetAuthenticationStateAsync() + { + _authenticated = false; + + // default to not authenticated + var user = Unauthenticated; + + try + { + // the user info endpoint is secured, so if the user isn't logged in this will fail + var userResponse = await _httpClient.GetAsync("manage/info"); + + // throw if user info wasn't retrieved + userResponse.EnsureSuccessStatusCode(); + + // user is authenticated,so let's build their authenticated identity + var userJson = await userResponse.Content.ReadAsStringAsync(); + var userInfo = JsonSerializer.Deserialize(userJson, jsonSerializerOptions); + + if (userInfo != null) + { + // in our system name and email are the same + var claims = new List + { + new(ClaimTypes.Name, userInfo.Email), + new(ClaimTypes.Email, userInfo.Email) + }; + + // add any additional claims + claims.AddRange( + userInfo.Claims + .Where(c => c.Key != ClaimTypes.Name && c.Key != ClaimTypes.Email) + .Select(c => new Claim(c.Key, c.Value))); + + // tap the roles endpoint for the user's roles + var rolesResponse = await _httpClient.GetAsync("roles"); + + // throw if request fails + rolesResponse.EnsureSuccessStatusCode(); + + // read the response into a string + var rolesJson = await rolesResponse.Content.ReadAsStringAsync(); + + // deserialize the roles string into an array + var roles = JsonSerializer.Deserialize(rolesJson, jsonSerializerOptions); + + // if there are roles, add them to the claims collection + if (roles?.Length > 0) + { + foreach (var role in roles) + { + if (!string.IsNullOrEmpty(role.Type) && !string.IsNullOrEmpty(role.Value)) + { + claims.Add(new Claim(role.Type, role.Value, role.ValueType, role.Issuer, role.OriginalIssuer)); + } + } + } + + // set the principal + var id = new ClaimsIdentity(claims, nameof(CookieAuthenticationStateProvider)); + user = new ClaimsPrincipal(id); + _authenticated = true; + } + } + catch { } + + // return the state + return new AuthenticationState(user); + } + + public async Task LogoutAsync() + { + const string Empty = "{}"; + var emptyContent = new StringContent(Empty, Encoding.UTF8, "application/json"); + await _httpClient.PostAsync("logout", emptyContent); + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + } + + public async Task CheckAuthenticatedAsync() + { + await GetAuthenticationStateAsync(); + return _authenticated; + } + + public class RoleClaim + { + public string? Issuer { get; set; } + public string? OriginalIssuer { get; set; } + public string? Type { get; set; } + public string? Value { get; set; } + public string? ValueType { get; set; } + } +} diff --git a/Shogi.UI/Identity/CookieMessageHandler.cs b/Shogi.UI/Identity/CookieMessageHandler.cs new file mode 100644 index 0000000..3d78081 --- /dev/null +++ b/Shogi.UI/Identity/CookieMessageHandler.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Components.WebAssembly.Http; + +namespace Shogi.UI.Identity; + +public class CookieCredentialsMessageHandler : DelegatingHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include); + request.Headers.Add("X-Requested-With", ["XMLHttpRequest"]); + + return base.SendAsync(request, cancellationToken); + } +} diff --git a/Shogi.UI/Identity/FormResult.cs b/Shogi.UI/Identity/FormResult.cs new file mode 100644 index 0000000..c2bf8ac --- /dev/null +++ b/Shogi.UI/Identity/FormResult.cs @@ -0,0 +1,14 @@ +namespace Shogi.UI.Identity; + +public class FormResult +{ + /// + /// Gets or sets a value indicating whether the action was successful. + /// + public bool Succeeded { get; set; } + + /// + /// On failure, the problem details are parsed and returned in this array. + /// + public string[] ErrorList { get; set; } = []; +} \ No newline at end of file diff --git a/Shogi.UI/Identity/IAccountManagement.cs b/Shogi.UI/Identity/IAccountManagement.cs new file mode 100644 index 0000000..6417a8e --- /dev/null +++ b/Shogi.UI/Identity/IAccountManagement.cs @@ -0,0 +1,31 @@ +namespace Shogi.UI.Identity; + +/// +/// Account management services. +/// +public interface IAccountManagement +{ + /// + /// Login service. + /// + /// User's email. + /// User's password. + /// The result of the request serialized to . + public Task LoginAsync(string email, string password); + + /// + /// Log out the logged in user. + /// + /// The asynchronous task. + public Task LogoutAsync(); + + /// + /// Registration service. + /// + /// User's email. + /// User's password. + /// The result of the request serialized to . + public Task RegisterAsync(string email, string password); + + public Task CheckAuthenticatedAsync(); +} diff --git a/Shogi.UI/Identity/UserInfo.cs b/Shogi.UI/Identity/UserInfo.cs new file mode 100644 index 0000000..01e7809 --- /dev/null +++ b/Shogi.UI/Identity/UserInfo.cs @@ -0,0 +1,22 @@ +namespace Shogi.UI.Identity; + +/// +/// User info from identity endpoint to establish claims. +/// +public class UserInfo +{ + /// + /// The email address. + /// + public string Email { get; set; } = string.Empty; + + /// + /// A value indicating whether the email has been confirmed yet. + /// + public bool IsEmailConfirmed { get; set; } + + /// + /// The list of claims for the user. + /// + public Dictionary Claims { get; set; } = []; +} \ No newline at end of file diff --git a/Shogi.UI/Layout/MainLayout.razor b/Shogi.UI/Layout/MainLayout.razor new file mode 100644 index 0000000..35169e1 --- /dev/null +++ b/Shogi.UI/Layout/MainLayout.razor @@ -0,0 +1,7 @@ +@inherits LayoutComponentBase + +
+ + @Body +
+ diff --git a/Shogi.UI/Layout/MainLayout.razor.css b/Shogi.UI/Layout/MainLayout.razor.css new file mode 100644 index 0000000..9cffbdb --- /dev/null +++ b/Shogi.UI/Layout/MainLayout.razor.css @@ -0,0 +1,5 @@ +.MainLayout { + display: grid; + grid-template-columns: auto 1fr; + place-items: stretch; +} \ No newline at end of file diff --git a/Shogi.UI/Layout/NavMenu.razor b/Shogi.UI/Layout/NavMenu.razor new file mode 100644 index 0000000..e5da672 --- /dev/null +++ b/Shogi.UI/Layout/NavMenu.razor @@ -0,0 +1,52 @@ +@inject NavigationManager navigator +@inject ShogiApi Api + +