diff --git a/Benchmarking/Benchmarking.csproj b/Benchmarking/Benchmarking.csproj index 4e77937..98fe81e 100644 --- a/Benchmarking/Benchmarking.csproj +++ b/Benchmarking/Benchmarking.csproj @@ -7,7 +7,7 @@ - + diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetGame.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetGame.cs new file mode 100644 index 0000000..c15dae4 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/GetGame.cs @@ -0,0 +1,17 @@ +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api +{ + public class GetGameResponse + { + public Game Game { get; set; } + public WhichPlayer PlayerPerspective { get; set; } + public BoardState BoardState { get; set; } + public IList MoveHistory { get; set; } + } +} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostMove.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostMove.cs index 153b1d9..012c6ea 100644 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostMove.cs +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostMove.cs @@ -5,9 +5,6 @@ namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api { public class PostMove { - [Required] - public string GameName { get; set; } - [Required] public Move Move { get; set; } } diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostSession.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostSession.cs index de71f67..ebe9665 100644 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostSession.cs +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Api/PostSession.cs @@ -3,8 +3,6 @@ public class PostSession { public string Name { get; set; } - public string Player1 { get; set; } - public string Player2 { get; set; } public bool IsPrivate { get; set; } } } diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/CreateGame.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/CreateGame.cs index 6aa082d..e1b59bb 100644 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/CreateGame.cs +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/CreateGame.cs @@ -1,27 +1,21 @@ using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using System; namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket { - public class CreateGameRequest : IRequest - { - public ClientAction Action { get; set; } - public string GameName { get; set; } = string.Empty; - public bool IsPrivate { get; set; } - } - public class CreateGameResponse : IResponse { public string Action { get; } - public string Error { get; set; } public Game Game { get; set; } + + /// + /// The player who created the game. + /// public string PlayerName { get; set; } public CreateGameResponse() { Action = ClientAction.CreateGame.ToString(); - Error = string.Empty; - Game = new Game(); - PlayerName = string.Empty; } } } diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/IResponse.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/IResponse.cs index 77f0dc2..69d18c6 100644 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/IResponse.cs +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/IResponse.cs @@ -3,6 +3,5 @@ public interface IResponse { string Action { get; } - string Error { get; set; } } } diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/ListGames.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/ListGames.cs deleted file mode 100644 index d8e5556..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/ListGames.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Types; -using System.Collections.Generic; -using System.Collections.ObjectModel; - -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket -{ - public class ListGamesRequest : IRequest - { - public ClientAction Action { get; set; } - } - - public class ListGamesResponse : IResponse - { - public string Action { get; } - public string Error { get; set; } - public IReadOnlyList Games { get; set; } - - public ListGamesResponse() - { - Action = ClientAction.ListGames.ToString(); - Error = ""; - Games = new Collection(); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/LoadGame.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/LoadGame.cs deleted file mode 100644 index 09884cb..0000000 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/LoadGame.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Types; -using System.Collections.Generic; - -namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket -{ - public class LoadGameRequest : IRequest - { - public ClientAction Action { get; set; } - public string GameName { get; set; } = ""; - } - - public class LoadGameResponse : IResponse - { - public string Action { get; } - public Game Game { get; set; } - public WhichPlayer PlayerPerspective { get; set; } - public BoardState BoardState { get; set; } - public IList MoveHistory { get; set; } - public string Error { get; set; } - - public LoadGameResponse() - { - Action = ClientAction.LoadGame.ToString(); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Move.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Move.cs index d7b9e91..0853998 100644 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Move.cs +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Socket/Move.cs @@ -1,29 +1,19 @@ using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using System.Collections.Generic; namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket { - public class MoveRequest : IRequest - { - public ClientAction Action { get; set; } - public string GameName { get; set; } = string.Empty; - public Move Move { get; set; } = new Move(); - } - public class MoveResponse : IResponse { - public string Action { get; } - public string Error { get; set; } - public string GameName { get; set; } - public string PlayerName { get; set; } - public Move Move { get; set; } + public string Action { get; protected set; } + public Game Game { get; set; } + public WhichPlayer PlayerPerspective { get; set; } + public BoardState BoardState { get; set; } + public IList MoveHistory { get; set; } public MoveResponse() { Action = ClientAction.Move.ToString(); - Error = string.Empty; - GameName = string.Empty; - PlayerName = string.Empty; - Move = new Move(); } } } diff --git a/Gameboard.ShogiUI.Sockets.ServiceModels/Types/ClientActionEnum.cs b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/ClientActionEnum.cs index f3a655b..99b9446 100644 --- a/Gameboard.ShogiUI.Sockets.ServiceModels/Types/ClientActionEnum.cs +++ b/Gameboard.ShogiUI.Sockets.ServiceModels/Types/ClientActionEnum.cs @@ -2,11 +2,9 @@ { public enum ClientAction { - ListGames, CreateGame, JoinGame, JoinByCode, - LoadGame, Move } } diff --git a/Gameboard.ShogiUI.Sockets/Controllers/GameController.cs b/Gameboard.ShogiUI.Sockets/Controllers/GameController.cs index 57fb268..718573f 100644 --- a/Gameboard.ShogiUI.Sockets/Controllers/GameController.cs +++ b/Gameboard.ShogiUI.Sockets/Controllers/GameController.cs @@ -1,7 +1,9 @@ -using Gameboard.ShogiUI.Sockets.Managers; +using Gameboard.ShogiUI.Sockets.Extensions; +using Gameboard.ShogiUI.Sockets.Managers; using Gameboard.ShogiUI.Sockets.Repositories; using Gameboard.ShogiUI.Sockets.ServiceModels.Api; using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using System; @@ -10,16 +12,15 @@ using System.Threading.Tasks; namespace Gameboard.ShogiUI.Sockets.Controllers { - [Authorize] + [ApiController] [Route("[controller]")] + [Authorize(Roles = "Shogi")] public class GameController : ControllerBase { - private static readonly string UsernameClaim = "preferred_username"; private readonly IGameboardManager gameboardManager; private readonly IGameboardRepository gameboardRepository; private readonly ISocketConnectionManager communicationManager; - private string? JwtUserName => HttpContext.User.Claims.FirstOrDefault(c => c.Type == UsernameClaim)?.Value; public GameController( IGameboardRepository repository, @@ -68,22 +69,12 @@ namespace Gameboard.ShogiUI.Sockets.Controllers [HttpPost("{gameName}/Move")] public async Task PostMove([FromRoute] string gameName, [FromBody] PostMove request) { - Models.User? user = null; - if (Request.Cookies.ContainsKey(SocketController.WebSessionKey)) - { - var webSessionId = Guid.Parse(Request.Cookies[SocketController.WebSessionKey]!); - user = await gameboardManager.ReadUser(webSessionId); - } - else if (!string.IsNullOrEmpty(JwtUserName)) - { - user = await gameboardManager.ReadUser(JwtUserName); - } - - var session = await gameboardManager.ReadSession(gameName); + var user = await gameboardManager.ReadUser(User); + var session = await gameboardRepository.ReadSession(gameName); if (session == null || user == null || (session.Player1 != user.Name && session.Player2 != user.Name)) { - throw new UnauthorizedAccessException("You are not seated at this game."); + throw new UnauthorizedAccessException("User is not seated at this game."); } var move = request.Move; @@ -94,13 +85,19 @@ namespace Gameboard.ShogiUI.Sockets.Controllers if (moveSuccess) { + var createSuccess = await gameboardRepository.CreateBoardState(session); + if (!createSuccess) + { + throw new ApplicationException("Unable to persist board state."); + } await communicationManager.BroadcastToPlayers(new MoveResponse { - GameName = session.Name, - PlayerName = user.Name, - Move = moveModel.ToServiceModel() + BoardState = session.Shogi.ToServiceModel(), + Game = session.ToServiceModel(), + MoveHistory = session.Shogi.MoveHistory.Select(h => h.ToServiceModel()).ToList(), + PlayerPerspective = user.Name == session.Player1 ? WhichPlayer.Player1 : WhichPlayer.Player2 }, session.Player1, session.Player2); - return Created(string.Empty, null); + return Ok(); } throw new InvalidOperationException("Illegal move."); } @@ -124,5 +121,56 @@ namespace Gameboard.ShogiUI.Sockets.Controllers // } // return new ConflictResult(); //} + + [HttpPost] + public async Task PostSession([FromBody] PostSession request) + { + var user = await gameboardManager.ReadUser(User); + var session = new Models.SessionMetadata(request.Name, request.IsPrivate, user!.Name); + var success = await gameboardRepository.CreateSession(session); + + if (success) + { + await communicationManager.BroadcastToAll(new CreateGameResponse + { + Game = session.ToServiceModel(), + PlayerName = user.Name + }); + return Ok(); + } + return Conflict(); + + } + + /// + /// Reads the board session and subscribes the caller to socket events for that session. + /// + [HttpGet("{gameName}")] + public async Task GetSession([FromRoute] string gameName) + { + var user = await gameboardManager.ReadUser(User); + var session = await gameboardRepository.ReadSession(gameName); + if (session == null) + { + return NotFound(); + } + + communicationManager.SubscribeToGame(session, user!.Name); + var response = new GetGameResponse() + { + Game = new Models.SessionMetadata(session).ToServiceModel(), + BoardState = session.Shogi.ToServiceModel(), + MoveHistory = session.Shogi.MoveHistory.Select(_ => _.ToServiceModel()).ToList(), + PlayerPerspective = user.Name == session.Player1 ? WhichPlayer.Player1 : WhichPlayer.Player2 + }; + return new JsonResult(response); + } + + [HttpGet] + public async Task GetSessions() + { + var sessions = await gameboardRepository.ReadSessionMetadatas(); + return new JsonResult(sessions.Select(s => s.ToServiceModel()).ToList()); + } } } diff --git a/Gameboard.ShogiUI.Sockets/Controllers/SocketController.cs b/Gameboard.ShogiUI.Sockets/Controllers/SocketController.cs index e5ee6db..0b9e941 100644 --- a/Gameboard.ShogiUI.Sockets/Controllers/SocketController.cs +++ b/Gameboard.ShogiUI.Sockets/Controllers/SocketController.cs @@ -1,96 +1,113 @@ -using Gameboard.ShogiUI.Sockets.Managers; +using Gameboard.ShogiUI.Sockets.Extensions; +using Gameboard.ShogiUI.Sockets.Managers; +using Gameboard.ShogiUI.Sockets.Models; using Gameboard.ShogiUI.Sockets.Repositories; using Gameboard.ShogiUI.Sockets.ServiceModels.Api; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using System; -using System.Linq; +using System.Security.Claims; using System.Threading.Tasks; namespace Gameboard.ShogiUI.Sockets.Controllers { - [Authorize] - [Route("[controller]")] [ApiController] + [Route("[controller]")] + [Authorize(Roles = "Shogi")] public class SocketController : ControllerBase { - public static readonly string WebSessionKey = "session-id"; private readonly ILogger logger; - private readonly ISocketTokenManager tokenManager; + private readonly ISocketTokenCache tokenCache; private readonly IGameboardManager gameboardManager; private readonly IGameboardRepository gameboardRepository; - private readonly CookieOptions createSessionOptions; - private readonly CookieOptions deleteSessionOptions; + private readonly AuthenticationProperties authenticationProps; public SocketController( ILogger logger, - ISocketTokenManager tokenManager, + ISocketTokenCache tokenCache, IGameboardManager gameboardManager, IGameboardRepository gameboardRepository) { this.logger = logger; - this.tokenManager = tokenManager; + this.tokenCache = tokenCache; this.gameboardManager = gameboardManager; this.gameboardRepository = gameboardRepository; - createSessionOptions = new CookieOptions + authenticationProps = new AuthenticationProperties { - Secure = true, - HttpOnly = true, - SameSite = SameSiteMode.None, - Expires = DateTimeOffset.Now.AddYears(5) + AllowRefresh = true, + IsPersistent = true }; - deleteSessionOptions = new CookieOptions(); } - [HttpGet("Yep")] + [HttpGet("GuestLogout")] [AllowAnonymous] - public IActionResult Yep() + public async Task GuestLogout() { - deleteSessionOptions.Expires = DateTimeOffset.Now.AddDays(-1); - Response.Cookies.Append(WebSessionKey, "", deleteSessionOptions); + await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return Ok(); } [HttpGet("Token")] - public IActionResult GetToken() + public async Task GetToken() { - var userName = HttpContext.User.Claims.First(c => c.Type == "preferred_username").Value; - var token = tokenManager.GenerateToken(userName); + var identityId = User.UserId(); + if (string.IsNullOrWhiteSpace(identityId)) + { + return Unauthorized(); + } + + var user = await gameboardManager.ReadUser(User); + if (user == null) + { + user = new User(identityId); + var success = await gameboardRepository.CreateUser(user); + if (!success) + { + return Unauthorized(); + } + } + + var token = tokenCache.GenerateToken(user.Name); return new JsonResult(new GetTokenResponse(token)); } - /// - /// Builds a token for guest users to send when requesting a socket connection. - /// Sends a HttpOnly cookie to the client with which to identify guest users. - /// - /// - /// - [AllowAnonymous] [HttpGet("GuestToken")] + [AllowAnonymous] public async Task GetGuestToken() { - var cookies = Request.Cookies; - var webSessionId = cookies.ContainsKey(WebSessionKey) - ? Guid.Parse(cookies[WebSessionKey]!) - : Guid.NewGuid(); - var webSessionIdAsString = webSessionId.ToString(); - - var user = await gameboardRepository.ReadGuestUser(webSessionId); - if (user == null) + if (Guid.TryParse(User.UserId(), out Guid webSessionId)) { - var userName = await gameboardManager.CreateGuestUser(webSessionId); - var token = tokenManager.GenerateToken(webSessionIdAsString); - Response.Cookies.Append(WebSessionKey, webSessionIdAsString, createSessionOptions); - return new JsonResult(new GetGuestTokenResponse(userName, token)); + var user = await gameboardRepository.ReadGuestUser(webSessionId); + if (user != null) + { + var token = tokenCache.GenerateToken(webSessionId.ToString()); + return new JsonResult(new GetGuestTokenResponse(user.Name, token)); + } } else { - var token = tokenManager.GenerateToken(webSessionIdAsString); - Response.Cookies.Append(WebSessionKey, webSessionIdAsString, createSessionOptions); - return new JsonResult(new GetGuestTokenResponse(user.Name, token)); + // Setup a guest user. + var newSessionId = Guid.NewGuid(); + var user = new User(Guid.NewGuid().ToString(), newSessionId); + if (await gameboardRepository.CreateUser(user)) + { + var identity = user.CreateGuestUserIdentity(); + await this.HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity), + authenticationProps + ); + + var token = tokenCache.GenerateToken(newSessionId.ToString()); + return new JsonResult(new GetGuestTokenResponse(user.Name, token)); + } } + + return Unauthorized(); } } } diff --git a/Gameboard.ShogiUI.Sockets/Extensions/Extensions.cs b/Gameboard.ShogiUI.Sockets/Extensions/Extensions.cs new file mode 100644 index 0000000..30517ad --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Extensions/Extensions.cs @@ -0,0 +1,18 @@ +using System.Linq; +using System.Security.Claims; + +namespace Gameboard.ShogiUI.Sockets.Extensions +{ + public static class Extensions + { + public static string? UserId(this ClaimsPrincipal self) + { + return self.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value; + } + + public static bool IsGuest(this ClaimsPrincipal self) + { + return self.HasClaim(c => c.Type == ClaimTypes.Role && c.Value == "Guest"); + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Gameboard.ShogiUI.Sockets.csproj b/Gameboard.ShogiUI.Sockets/Gameboard.ShogiUI.Sockets.csproj index 241f8e8..bd64225 100644 --- a/Gameboard.ShogiUI.Sockets/Gameboard.ShogiUI.Sockets.csproj +++ b/Gameboard.ShogiUI.Sockets/Gameboard.ShogiUI.Sockets.csproj @@ -8,13 +8,15 @@ - - - - - - - + + + + + + + + + diff --git a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/CreateGameHandler.cs b/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/CreateGameHandler.cs deleted file mode 100644 index 2041ef8..0000000 --- a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/CreateGameHandler.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Gameboard.ShogiUI.Sockets.Models; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; -using System.Threading.Tasks; - -namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers -{ - public interface ICreateGameHandler - { - Task Handle(CreateGameRequest request, string userName); - } - - // TODO: This doesn't need to be a socket action. - // It can be an API route and still tell socket connections about the new session. - public class CreateGameHandler : ICreateGameHandler - { - private readonly IGameboardManager manager; - private readonly ISocketConnectionManager connectionManager; - - public CreateGameHandler( - ISocketConnectionManager communicationManager, - IGameboardManager manager) - { - this.manager = manager; - this.connectionManager = communicationManager; - } - - public async Task Handle(CreateGameRequest request, string userName) - { - var model = new SessionMetadata(request.GameName, request.IsPrivate, userName, null); - var success = await manager.CreateSession(model); - - if (!success) - { - var error = new CreateGameResponse() - { - Error = "Unable to create game with this name." - }; - await connectionManager.BroadcastToPlayers(error, userName); - } - - var response = new CreateGameResponse() - { - PlayerName = userName, - Game = model.ToServiceModel() - }; - - var task = request.IsPrivate - ? connectionManager.BroadcastToPlayers(response, userName) - : connectionManager.BroadcastToAll(response); - - await task; - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/ListGamesHandler.cs b/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/ListGamesHandler.cs deleted file mode 100644 index b92c8ca..0000000 --- a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/ListGamesHandler.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Gameboard.ShogiUI.Sockets.Repositories; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; -using Gameboard.ShogiUI.Sockets.ServiceModels.Types; -using System.Linq; -using System.Threading.Tasks; - -namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers -{ - public interface IListGamesHandler - { - Task Handle(ListGamesRequest request, string userName); - } - - public class ListGamesHandler : IListGamesHandler - { - private readonly ISocketConnectionManager communicationManager; - private readonly IGameboardRepository repository; - - public ListGamesHandler( - ISocketConnectionManager communicationManager, - IGameboardRepository repository) - { - this.communicationManager = communicationManager; - this.repository = repository; - } - - public async Task Handle(ListGamesRequest _, string userName) - { - var sessions = await repository.ReadSessionMetadatas(); - var games = sessions.Select(s => new Game(s.Name, s.Player1, s.Player2)).ToList(); - - var response = new ListGamesResponse() - { - Games = games - }; - - await communicationManager.BroadcastToPlayers(response, userName); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/LoadGameHandler.cs b/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/LoadGameHandler.cs deleted file mode 100644 index d82c880..0000000 --- a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/LoadGameHandler.cs +++ /dev/null @@ -1,57 +0,0 @@ -using Gameboard.ShogiUI.Sockets.Models; -using Gameboard.ShogiUI.Sockets.Repositories; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; -using Gameboard.ShogiUI.Sockets.ServiceModels.Types; -using Microsoft.Extensions.Logging; -using System.Linq; -using System.Threading.Tasks; - -namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers -{ - public interface ILoadGameHandler - { - Task Handle(LoadGameRequest request, string userName); - } - - /// - /// Subscribes a user to messages for a session and loads that session into the BoardManager for playing. - /// - public class LoadGameHandler : ILoadGameHandler - { - private readonly ILogger logger; - private readonly IGameboardRepository gameboardRepository; - private readonly ISocketConnectionManager communicationManager; - - public LoadGameHandler( - ILogger logger, - ISocketConnectionManager communicationManager, - IGameboardRepository gameboardRepository) - { - this.logger = logger; - this.gameboardRepository = gameboardRepository; - this.communicationManager = communicationManager; - } - - public async Task Handle(LoadGameRequest request, string userName) - { - var sessionModel = await gameboardRepository.ReadSession(request.GameName); - if (sessionModel == null) - { - logger.LogWarning("{action} - {user} was unable to load session named {session}.", ClientAction.LoadGame, userName, request.GameName); - var error = new LoadGameResponse() { Error = "Game not found." }; - await communicationManager.BroadcastToPlayers(error, userName); - return; - } - - communicationManager.SubscribeToGame(sessionModel, userName); - - var response = new LoadGameResponse() - { - Game = new SessionMetadata(sessionModel).ToServiceModel(), - BoardState = sessionModel.Shogi.ToServiceModel(), - MoveHistory = sessionModel.Shogi.MoveHistory.Select(_ => _.ToServiceModel()).ToList() - }; - await communicationManager.BroadcastToPlayers(response, userName); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/MoveHandler.cs b/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/MoveHandler.cs deleted file mode 100644 index 706e125..0000000 --- a/Gameboard.ShogiUI.Sockets/Managers/ClientActionHandlers/MoveHandler.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; -using System.Threading.Tasks; - -namespace Gameboard.ShogiUI.Sockets.Managers.ClientActionHandlers -{ - public interface IMoveHandler - { - Task Handle(MoveRequest request, string userName); - } - public class MoveHandler : IMoveHandler - { - private readonly IGameboardManager gameboardManager; - private readonly ISocketConnectionManager connectionManager; - public MoveHandler( - ISocketConnectionManager connectionManager, - IGameboardManager gameboardManager) - { - this.gameboardManager = gameboardManager; - this.connectionManager = connectionManager; - } - - public async Task Handle(MoveRequest request, string userName) - { - Models.Move moveModel; - if (request.Move.PieceFromCaptured.HasValue) - { - moveModel = new Models.Move(request.Move.PieceFromCaptured.Value, request.Move.To); - } - else - { - moveModel = new Models.Move(request.Move.From!, request.Move.To, request.Move.IsPromotion); - } - - var session = await gameboardManager.ReadSession(request.GameName); - if (session != null) - { - var shogi = session.Shogi; - var moveSuccess = shogi.Move(moveModel); - if (moveSuccess) - { - await gameboardManager.CreateBoardState(session.Name, shogi); - var response = new MoveResponse() - { - GameName = request.GameName, - PlayerName = userName, - Move = moveModel.ToServiceModel() - }; - await connectionManager.BroadcastToPlayers(response, session.Player1, session.Player2); - } - else - { - var response = new MoveResponse() - { - Error = "Invalid move." - }; - await connectionManager.BroadcastToPlayers(response, userName); - } - } - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Managers/GameboardManager.cs b/Gameboard.ShogiUI.Sockets/Managers/GameboardManager.cs index 365722f..e4c4aa0 100644 --- a/Gameboard.ShogiUI.Sockets/Managers/GameboardManager.cs +++ b/Gameboard.ShogiUI.Sockets/Managers/GameboardManager.cs @@ -1,26 +1,21 @@ -using Gameboard.ShogiUI.Sockets.Models; +using Gameboard.ShogiUI.Sockets.Extensions; +using Gameboard.ShogiUI.Sockets.Models; using Gameboard.ShogiUI.Sockets.Repositories; using System; +using System.Security.Claims; using System.Threading.Tasks; namespace Gameboard.ShogiUI.Sockets.Managers { public interface IGameboardManager { - Task CreateGuestUser(Guid webSessionId); Task IsPlayer1(string sessionName, string playerName); - Task CreateSession(SessionMetadata session); - Task ReadSession(string gameName); - Task UpdateSession(SessionMetadata session); Task AssignPlayer2ToSession(string sessionName, string userName); - Task CreateBoardState(string sessionName, Shogi shogi); - Task ReadUser(string userName); - Task ReadUser(Guid webSessionId); + Task ReadUser(ClaimsPrincipal user); } public class GameboardManager : IGameboardManager { - private const int MaxTries = 3; private readonly IGameboardRepository repository; public GameboardManager(IGameboardRepository repository) @@ -28,30 +23,21 @@ namespace Gameboard.ShogiUI.Sockets.Managers this.repository = repository; } - public async Task CreateGuestUser(Guid webSessionId) + public Task ReadUser(ClaimsPrincipal user) { - var count = 0; - while (count < MaxTries) + var userId = user.UserId(); + if (user.IsGuest() && Guid.TryParse(userId, out var webSessionId)) { - count++; - var userName = $"Guest-{Guid.NewGuid()}"; - var isCreated = await repository.CreateUser(new User(userName, webSessionId)); - if (isCreated) - { - return userName; - } + return repository.ReadGuestUser(webSessionId); } - throw new OperationCanceledException($"Failed to create guest user after {count} tries."); + else if (!string.IsNullOrEmpty(userId)) + { + return repository.ReadUser(userId); + } + + return Task.FromResult(null); } - public Task ReadUser(Guid webSessionId) - { - return repository.ReadGuestUser(webSessionId); - } - public Task ReadUser(string userName) - { - return repository.ReadUser(userName); - } public async Task IsPlayer1(string sessionName, string playerName) { //var session = await repository.GetGame(sessionName); @@ -69,31 +55,6 @@ namespace Gameboard.ShogiUI.Sockets.Managers return string.Empty; } - public Task CreateSession(SessionMetadata session) - { - return repository.CreateSession(session); - } - - public Task ReadSession(string sessionName) - { - return repository.ReadSession(sessionName); - } - - /// - /// Saves the session to storage. - /// - /// The session to save. - /// True if the session was saved successfully. - public Task UpdateSession(SessionMetadata session) - { - return repository.UpdateSession(session); - } - - public Task CreateBoardState(string sessionName, Shogi shogi) - { - return repository.CreateBoardState(sessionName, shogi); - } - public async Task AssignPlayer2ToSession(string sessionName, string userName) { var isSuccess = false; diff --git a/Gameboard.ShogiUI.Sockets/Managers/SocketTokenManager.cs b/Gameboard.ShogiUI.Sockets/Managers/SocketTokenCache.cs similarity index 83% rename from Gameboard.ShogiUI.Sockets/Managers/SocketTokenManager.cs rename to Gameboard.ShogiUI.Sockets/Managers/SocketTokenCache.cs index d8c46ab..722dc33 100644 --- a/Gameboard.ShogiUI.Sockets/Managers/SocketTokenManager.cs +++ b/Gameboard.ShogiUI.Sockets/Managers/SocketTokenCache.cs @@ -6,20 +6,20 @@ using System.Threading.Tasks; namespace Gameboard.ShogiUI.Sockets.Managers { - public interface ISocketTokenManager + public interface ISocketTokenCache { Guid GenerateToken(string s); - string GetUsername(Guid g); + string? GetUsername(Guid g); } - public class SocketTokenManager : ISocketTokenManager + public class SocketTokenCache : ISocketTokenCache { /// /// Key is userName or webSessionId /// private readonly ConcurrentDictionary Tokens; - public SocketTokenManager() + public SocketTokenCache() { Tokens = new ConcurrentDictionary(); } @@ -41,7 +41,7 @@ namespace Gameboard.ShogiUI.Sockets.Managers } /// User name associated to the guid or null. - public string GetUsername(Guid guid) + public string? GetUsername(Guid guid) { var userName = Tokens.FirstOrDefault(kvp => kvp.Value == guid).Key; if (userName != null) diff --git a/Gameboard.ShogiUI.Sockets/Models/Session.cs b/Gameboard.ShogiUI.Sockets/Models/Session.cs index 32bd545..67240e0 100644 --- a/Gameboard.ShogiUI.Sockets/Models/Session.cs +++ b/Gameboard.ShogiUI.Sockets/Models/Session.cs @@ -1,4 +1,5 @@ -using Newtonsoft.Json; +using Gameboard.ShogiUI.Sockets.ServiceModels.Types; +using Newtonsoft.Json; using System.Collections.Concurrent; using System.Net.WebSockets; @@ -30,5 +31,7 @@ namespace Gameboard.ShogiUI.Sockets.Models { Player2 = userName; } + + public Game ToServiceModel() => new() { GameName = Name, Player1 = Player1, Player2 = Player2 }; } } diff --git a/Gameboard.ShogiUI.Sockets/Models/SessionMetadata.cs b/Gameboard.ShogiUI.Sockets/Models/SessionMetadata.cs index f3cfade..d46b8af 100644 --- a/Gameboard.ShogiUI.Sockets/Models/SessionMetadata.cs +++ b/Gameboard.ShogiUI.Sockets/Models/SessionMetadata.cs @@ -10,7 +10,7 @@ public string? Player2 { get; private set; } public bool IsPrivate { get; } - public SessionMetadata(string name, bool isPrivate, string player1, string? player2) + public SessionMetadata(string name, bool isPrivate, string player1, string? player2 = null) { Name = name; IsPrivate = isPrivate; diff --git a/Gameboard.ShogiUI.Sockets/Models/User.cs b/Gameboard.ShogiUI.Sockets/Models/User.cs index ba6959d..acfd4a9 100644 --- a/Gameboard.ShogiUI.Sockets/Models/User.cs +++ b/Gameboard.ShogiUI.Sockets/Models/User.cs @@ -1,18 +1,57 @@ -using System; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using System; +using System.Collections.Generic; +using System.Security.Claims; namespace Gameboard.ShogiUI.Sockets.Models { public class User { - public static readonly string GuestPrefix = "Guest-"; public string Name { get; } public Guid? WebSessionId { get; } - public bool IsGuest => Name.StartsWith(GuestPrefix) && WebSessionId.HasValue; - public User(string name, Guid? webSessionId = null) + public bool IsGuest => WebSessionId.HasValue; + + public User(string name) + { + Name = name; + } + + /// + /// Constructor for guest user. + /// + public User(string name, Guid webSessionId) { Name = name; WebSessionId = webSessionId; } + + public ClaimsIdentity CreateMsalUserIdentity() + { + var claims = new List() + { + new Claim(ClaimTypes.NameIdentifier, Name), + new Claim(ClaimTypes.Role, "Shogi") // The Shogi role grants access to api controllers. + }; + return new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme); + } + + public ClaimsIdentity CreateGuestUserIdentity() + { + // TODO: Make this method static and factory-like. + if (!WebSessionId.HasValue) + { + throw new InvalidOperationException("Cannot create guest identity without a session identifier."); + } + + var claims = new List() + { + new Claim(ClaimTypes.NameIdentifier, WebSessionId.Value.ToString()), + new Claim(ClaimTypes.Role, "Guest"), + new Claim(ClaimTypes.Role, "Shogi") // The Shogi role grants access to api controllers. + }; + return new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); + } } } diff --git a/Gameboard.ShogiUI.Sockets/Models/WhichLoginPlatform.cs b/Gameboard.ShogiUI.Sockets/Models/WhichLoginPlatform.cs new file mode 100644 index 0000000..5923404 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/Models/WhichLoginPlatform.cs @@ -0,0 +1,8 @@ +namespace Gameboard.ShogiUI.Sockets.Models +{ + public enum WhichLoginPlatform + { + Microsoft, + Guest + } +} diff --git a/Gameboard.ShogiUI.Sockets/Properties/launchSettings.json b/Gameboard.ShogiUI.Sockets/Properties/launchSettings.json index 4a087fc..230be61 100644 --- a/Gameboard.ShogiUI.Sockets/Properties/launchSettings.json +++ b/Gameboard.ShogiUI.Sockets/Properties/launchSettings.json @@ -18,11 +18,12 @@ }, "Kestrel": { "commandName": "Project", - "launchUrl": "Socket/Token", + "launchBrowser": true, + "launchUrl": "/swagger", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, - "applicationUrl": "http://127.0.0.1:5100" + "applicationUrl": "http://localhost:5100" } } } \ No newline at end of file diff --git a/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/UserDocument.cs b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/UserDocument.cs index 843094e..c41b60b 100644 --- a/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/UserDocument.cs +++ b/Gameboard.ShogiUI.Sockets/Repositories/CouchModels/UserDocument.cs @@ -1,17 +1,13 @@ -using System; +using Gameboard.ShogiUI.Sockets.Models; +using System; namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels { public class UserDocument : CouchDocument { - public enum LoginPlatform - { - Microsoft, - Guest - } public string Name { get; set; } - public LoginPlatform Platform { get; set; } + public WhichLoginPlatform Platform { get; set; } /// /// The browser session ID saved via Set-Cookie headers. /// Only used with guest accounts. @@ -31,8 +27,8 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels Name = name; WebSessionId = webSessionId; Platform = WebSessionId.HasValue - ? LoginPlatform.Guest - : LoginPlatform.Microsoft; + ? WhichLoginPlatform.Guest + : WhichLoginPlatform.Microsoft; } } } diff --git a/Gameboard.ShogiUI.Sockets/Repositories/GameboardRepository.cs b/Gameboard.ShogiUI.Sockets/Repositories/GameboardRepository.cs index fce2aaa..89ee28c 100644 --- a/Gameboard.ShogiUI.Sockets/Repositories/GameboardRepository.cs +++ b/Gameboard.ShogiUI.Sockets/Repositories/GameboardRepository.cs @@ -7,12 +7,13 @@ using System.Linq; using System.Net.Http; using System.Text; using System.Threading.Tasks; +using System.Web; namespace Gameboard.ShogiUI.Sockets.Repositories { public interface IGameboardRepository { - Task CreateBoardState(string sessionName, Models.Shogi shogi); + Task CreateBoardState(Models.Session session); Task CreateSession(Models.SessionMetadata session); Task CreateUser(Models.User user); Task> ReadSessionMetadatas(); @@ -46,11 +47,16 @@ namespace Gameboard.ShogiUI.Sockets.Repositories var content = new StringContent(JsonConvert.SerializeObject(q), Encoding.UTF8, ApplicationJson); var response = await client.PostAsync("_find", content); var responseContent = await response.Content.ReadAsStringAsync(); - var sessions = JsonConvert.DeserializeObject>(responseContent).docs; + var results = JsonConvert.DeserializeObject>(responseContent); + if (results != null) + { + return results + .docs + .Select(s => new Models.SessionMetadata(s.Name, s.IsPrivate, s.Player1, s.Player2)) + .ToList(); + } - return sessions - .Select(s => new Models.SessionMetadata(s.Name, s.IsPrivate, s.Player1, s.Player2)) - .ToList(); + return new List(0); } public async Task ReadSession(string name) @@ -113,9 +119,12 @@ namespace Gameboard.ShogiUI.Sockets.Repositories return new Models.Shogi(moves); } - public async Task CreateBoardState(string sessionName, Models.Shogi shogi) + /// + /// Saves a snapshot of board state and the most recent move. + /// + public async Task CreateBoardState(Models.Session session) { - var boardStateDocument = new BoardStateDocument(sessionName, shogi); + var boardStateDocument = new BoardStateDocument(session.Name, session.Shogi); var content = new StringContent(JsonConvert.SerializeObject(boardStateDocument), Encoding.UTF8, ApplicationJson); var response = await client.PostAsync(string.Empty, content); return response.IsSuccessStatusCode; @@ -198,14 +207,23 @@ namespace Gameboard.ShogiUI.Sockets.Repositories public async Task ReadUser(string userName) { - var document = new UserDocument(userName); - var response = await client.GetAsync(document.Id); - var responseContent = await response.Content.ReadAsStringAsync(); - if (response.IsSuccessStatusCode) + try { - var user = JsonConvert.DeserializeObject(responseContent); + var document = new UserDocument(userName); + var uri = new Uri(client.BaseAddress!, HttpUtility.UrlEncode(document.Id)); + var response = await client.GetAsync(HttpUtility.UrlEncode(document.Id)); + var response2 = await client.GetAsync(uri); + var responseContent = await response.Content.ReadAsStringAsync(); + if (response.IsSuccessStatusCode) + { + var user = JsonConvert.DeserializeObject(responseContent); - return new Models.User(user.Name); + return new Models.User(user.Name); + } + } + catch (Exception e) + { + Console.WriteLine(e); } return null; } diff --git a/Gameboard.ShogiUI.Sockets/Services/RequestValidators/CreateGameRequestValidator.cs b/Gameboard.ShogiUI.Sockets/Services/RequestValidators/CreateGameRequestValidator.cs deleted file mode 100644 index 033b218..0000000 --- a/Gameboard.ShogiUI.Sockets/Services/RequestValidators/CreateGameRequestValidator.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FluentValidation; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; -using Gameboard.ShogiUI.Sockets.ServiceModels.Types; - -namespace Gameboard.ShogiUI.Sockets.Services.RequestValidators -{ - public class CreateGameRequestValidator : AbstractValidator - { - public CreateGameRequestValidator() - { - RuleFor(_ => _.Action).Equal(ClientAction.CreateGame); - RuleFor(_ => _.GameName).NotEmpty(); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Services/RequestValidators/ListGamesRequestValidator.cs b/Gameboard.ShogiUI.Sockets/Services/RequestValidators/ListGamesRequestValidator.cs deleted file mode 100644 index 38ecc66..0000000 --- a/Gameboard.ShogiUI.Sockets/Services/RequestValidators/ListGamesRequestValidator.cs +++ /dev/null @@ -1,14 +0,0 @@ -using FluentValidation; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; -using Gameboard.ShogiUI.Sockets.ServiceModels.Types; - -namespace Gameboard.ShogiUI.Sockets.Services.RequestValidators -{ - public class ListGamesRequestValidator : AbstractValidator - { - public ListGamesRequestValidator() - { - RuleFor(_ => _.Action).Equal(ClientAction.ListGames); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Services/RequestValidators/LoadGameRequestValidator.cs b/Gameboard.ShogiUI.Sockets/Services/RequestValidators/LoadGameRequestValidator.cs deleted file mode 100644 index 3fb477d..0000000 --- a/Gameboard.ShogiUI.Sockets/Services/RequestValidators/LoadGameRequestValidator.cs +++ /dev/null @@ -1,15 +0,0 @@ -using FluentValidation; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; -using Gameboard.ShogiUI.Sockets.ServiceModels.Types; - -namespace Gameboard.ShogiUI.Sockets.Services.RequestValidators -{ - public class LoadGameRequestValidator : AbstractValidator - { - public LoadGameRequestValidator() - { - RuleFor(_ => _.Action).Equal(ClientAction.LoadGame); - RuleFor(_ => _.GameName).NotEmpty(); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Services/RequestValidators/MoveRequestValidator.cs b/Gameboard.ShogiUI.Sockets/Services/RequestValidators/MoveRequestValidator.cs deleted file mode 100644 index 29a0cb9..0000000 --- a/Gameboard.ShogiUI.Sockets/Services/RequestValidators/MoveRequestValidator.cs +++ /dev/null @@ -1,23 +0,0 @@ -using FluentValidation; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; -using Gameboard.ShogiUI.Sockets.ServiceModels.Types; - -namespace Gameboard.ShogiUI.Sockets.Services.RequestValidators -{ - public class MoveRequestValidator : AbstractValidator - { - public MoveRequestValidator() - { - RuleFor(_ => _.Action).Equal(ClientAction.Move); - RuleFor(_ => _.GameName).NotEmpty(); - RuleFor(_ => _.Move.From) - .Null() - .When(_ => _.Move.PieceFromCaptured.HasValue) - .WithMessage("Move.From and Move.PieceFromCaptured are mutually exclusive properties."); - RuleFor(_ => _.Move.From) - .NotEmpty() - .When(_ => !_.Move.PieceFromCaptured.HasValue) - .WithMessage("Move.From and Move.PieceFromCaptured are mutually exclusive properties."); - } - } -} diff --git a/Gameboard.ShogiUI.Sockets/Services/SocketService.cs b/Gameboard.ShogiUI.Sockets/Services/SocketService.cs index 925c018..30fff7f 100644 --- a/Gameboard.ShogiUI.Sockets/Services/SocketService.cs +++ b/Gameboard.ShogiUI.Sockets/Services/SocketService.cs @@ -31,65 +31,44 @@ namespace Gameboard.ShogiUI.Sockets.Services private readonly ILogger logger; private readonly ISocketConnectionManager communicationManager; private readonly IGameboardRepository gameboardRepository; - private readonly ISocketTokenManager tokenManager; - private readonly ICreateGameHandler createGameHandler; + private readonly IGameboardManager gameboardManager; + private readonly ISocketTokenCache tokenManager; private readonly IJoinByCodeHandler joinByCodeHandler; private readonly IJoinGameHandler joinGameHandler; - private readonly IListGamesHandler listGamesHandler; - private readonly ILoadGameHandler loadGameHandler; - private readonly IMoveHandler moveHandler; - private readonly IValidator createGameRequestValidator; private readonly IValidator joinByCodeRequestValidator; private readonly IValidator joinGameRequestValidator; - private readonly IValidator listGamesRequestValidator; - private readonly IValidator loadGameRequestValidator; - private readonly IValidator moveRequestValidator; public SocketService( ILogger logger, ISocketConnectionManager communicationManager, IGameboardRepository gameboardRepository, - ISocketTokenManager tokenManager, - ICreateGameHandler createGameHandler, + IGameboardManager gameboardManager, + ISocketTokenCache tokenManager, IJoinByCodeHandler joinByCodeHandler, IJoinGameHandler joinGameHandler, - IListGamesHandler listGamesHandler, - ILoadGameHandler loadGameHandler, - IMoveHandler moveHandler, - IValidator createGameRequestValidator, IValidator joinByCodeRequestValidator, - IValidator joinGameRequestValidator, - IValidator listGamesRequestValidator, - IValidator loadGameRequestValidator, - IValidator moveRequestValidator + IValidator joinGameRequestValidator ) : base() { this.logger = logger; this.communicationManager = communicationManager; this.gameboardRepository = gameboardRepository; + this.gameboardManager = gameboardManager; this.tokenManager = tokenManager; - this.createGameHandler = createGameHandler; this.joinByCodeHandler = joinByCodeHandler; this.joinGameHandler = joinGameHandler; - this.listGamesHandler = listGamesHandler; - this.loadGameHandler = loadGameHandler; - this.moveHandler = moveHandler; - this.createGameRequestValidator = createGameRequestValidator; this.joinByCodeRequestValidator = joinByCodeRequestValidator; this.joinGameRequestValidator = joinGameRequestValidator; - this.listGamesRequestValidator = listGamesRequestValidator; - this.loadGameRequestValidator = loadGameRequestValidator; - this.moveRequestValidator = moveRequestValidator; } public async Task HandleSocketRequest(HttpContext context) { string? userName = null; - if (context.Request.Cookies.ContainsKey(SocketController.WebSessionKey)) + var user = await gameboardManager.ReadUser(context.User); + if (user?.WebSessionId != null) { // Guest account - var webSessionId = Guid.Parse(context.Request.Cookies[SocketController.WebSessionKey]!); - userName = (await gameboardRepository.ReadGuestUser(webSessionId))?.Name; + userName = tokenManager.GetUsername(user.WebSessionId.Value); } else if (context.Request.Query.Keys.Contains("token")) { @@ -123,24 +102,6 @@ namespace Gameboard.ShogiUI.Sockets.Services } switch (request.Action) { - case ClientAction.ListGames: - { - var req = JsonConvert.DeserializeObject(message); - if (await ValidateRequestAndReplyIfInvalid(socket, listGamesRequestValidator, req)) - { - await listGamesHandler.Handle(req, userName); - } - break; - } - case ClientAction.CreateGame: - { - var req = JsonConvert.DeserializeObject(message); - if (await ValidateRequestAndReplyIfInvalid(socket, createGameRequestValidator, req)) - { - await createGameHandler.Handle(req, userName); - } - break; - } case ClientAction.JoinGame: { var req = JsonConvert.DeserializeObject(message); @@ -159,24 +120,6 @@ namespace Gameboard.ShogiUI.Sockets.Services } break; } - case ClientAction.LoadGame: - { - var req = JsonConvert.DeserializeObject(message); - if (await ValidateRequestAndReplyIfInvalid(socket, loadGameRequestValidator, req)) - { - await loadGameHandler.Handle(req, userName); - } - break; - } - case ClientAction.Move: - { - var req = JsonConvert.DeserializeObject(message); - if (await ValidateRequestAndReplyIfInvalid(socket, moveRequestValidator, req)) - { - await moveHandler.Handle(req, userName); - } - break; - } } } catch (OperationCanceledException ex) diff --git a/Gameboard.ShogiUI.Sockets/ShogiUserClaimsTransformer.cs b/Gameboard.ShogiUI.Sockets/ShogiUserClaimsTransformer.cs new file mode 100644 index 0000000..1cbc725 --- /dev/null +++ b/Gameboard.ShogiUI.Sockets/ShogiUserClaimsTransformer.cs @@ -0,0 +1,46 @@ +using Gameboard.ShogiUI.Sockets.Repositories; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; + +namespace Gameboard.ShogiUI.Sockets +{ + /// + /// Standardizes the claims from third party issuers. Also registers new msal users in the database. + /// + public class ShogiUserClaimsTransformer : IClaimsTransformation + { + private static readonly string MsalUsernameClaim = "preferred_username"; + private readonly IGameboardRepository gameboardRepository; + + public ShogiUserClaimsTransformer(IGameboardRepository gameboardRepository) + { + this.gameboardRepository = gameboardRepository; + } + + public async Task TransformAsync(ClaimsPrincipal principal) + { + var nameClaim = principal.Claims.FirstOrDefault(c => c.Type == MsalUsernameClaim); + if (nameClaim != default) + { + var user = await gameboardRepository.ReadUser(nameClaim.Value); + if (user == null) + { + var newUser = new Models.User(nameClaim.Value); + var success = await gameboardRepository.CreateUser(newUser); + if (success) user = newUser; + } + + if (user != null) + { + return new ClaimsPrincipal(user.CreateMsalUserIdentity()); + } + } + return principal; + } + } +} diff --git a/Gameboard.ShogiUI.Sockets/Startup.cs b/Gameboard.ShogiUI.Sockets/Startup.cs index 32cd211..92f88e5 100644 --- a/Gameboard.ShogiUI.Sockets/Startup.cs +++ b/Gameboard.ShogiUI.Sockets/Startup.cs @@ -6,17 +6,23 @@ using Gameboard.ShogiUI.Sockets.Repositories; using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; using Gameboard.ShogiUI.Sockets.Services; using Gameboard.ShogiUI.Sockets.Services.RequestValidators; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using Microsoft.Identity.Client; +using Microsoft.Identity.Web; using Newtonsoft.Json; using Newtonsoft.Json.Converters; using Newtonsoft.Json.Serialization; using System; +using System.Collections.Generic; using System.Linq; +using System.Security.Claims; using System.Text; using System.Threading.Tasks; @@ -34,29 +40,16 @@ namespace Gameboard.ShogiUI.Sockets // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - // Socket ActionHandlers - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - - // Managers services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); - - // Services - services.AddSingleton, CreateGameRequestValidator>(); services.AddSingleton, JoinByCodeRequestValidator>(); services.AddSingleton, JoinGameRequestValidator>(); - services.AddSingleton, ListGamesRequestValidator>(); - services.AddSingleton, LoadGameRequestValidator>(); - services.AddSingleton, MoveRequestValidator>(); services.AddSingleton(); - - // Repositories + services.AddTransient(); + services.AddSingleton(); services.AddHttpClient("couchdb", c => { var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:admin")); @@ -66,37 +59,56 @@ namespace Gameboard.ShogiUI.Sockets var baseUrl = $"{Configuration["AppSettings:CouchDB:Url"]}/{Configuration["AppSettings:CouchDB:Database"]}/"; c.BaseAddress = new Uri(baseUrl); }); - services.AddTransient(); - //services.AddSingleton(); - //services.AddSingleton(provider => new CouchClient(databaseName, couchUrl)); - - services.AddControllers(); services - .AddAuthentication(options => + .AddControllers() + .AddNewtonsoftJson(options => + { + options.SerializerSettings.Formatting = Formatting.Indented; + options.SerializerSettings.ContractResolver = new DefaultContractResolver { - options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - }) - .AddJwtBearer(options => - { - options.Authority = "https://login.microsoftonline.com/9188040d-6c67-4c5b-b112-36a304b66dad/v2.0"; - options.Audience = "935df672-efa6-45fa-b2e8-b76dfd65a122"; - options.TokenValidationParameters.ValidateIssuer = true; - options.TokenValidationParameters.ValidateAudience = true; + NamingStrategy = new CamelCaseNamingStrategy { ProcessDictionaryKeys = true } + }; + options.SerializerSettings.Converters = new[] { new StringEnumConverter() }; + options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore; + }); - options.Events = new JwtBearerEvents - { - OnMessageReceived = (context) => - { - if (context.HttpContext.WebSockets.IsWebSocketRequest) - { - Console.WriteLine("Yep"); - } - return Task.FromResult(0); - } - }; - }); + services.AddAuthentication("CookieOrJwt") + .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; + }; + }) + .AddCookie(options => + { + options.Cookie.Name = "session-id"; + options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.None; + options.Cookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.Always; + options.SlidingExpiration = true; + }) + .AddMicrosoftIdentityWebApi(Configuration); + + services.AddSwaggerDocument(config => + { + config.AddSecurity("Bearer", new NSwag.OpenApiSecurityScheme + { + Type = NSwag.OpenApiSecuritySchemeType.OAuth2, + Flow = NSwag.OpenApiOAuth2Flow.AccessCode, + AuthorizationUrl = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + TokenUrl = "https://login.microsoftonline.com/common/oauth2/v2.0/token", + Scopes = new Dictionary { { "api://c1e94676-cab0-42ba-8b6c-9532b8486fff/access_as_user", "The scope" } }, + Scheme = "Bearer" + }); + config.PostProcess = document => + { + document.Info.Title = "Gameboard.ShogiUI.Sockets"; + }; + }); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -114,30 +126,45 @@ namespace Gameboard.ShogiUI.Sockets if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); + var client = PublicClientApplicationBuilder + .Create(Configuration["AzureAd:ClientId"]) + .WithLogging( + (level, message, pii) => + { + + }, + LogLevel.Verbose, + true, + true + ) + .Build(); } else { app.UseHsts(); } app - .UseRequestResponseLogging() - .UseCors( - opt => opt - .WithOrigins(origins) - .AllowAnyMethod() - .AllowAnyHeader() - .WithExposedHeaders("Set-Cookie") - .AllowCredentials() - ) - .UseRouting() - .UseAuthentication() - .UseAuthorization() - .UseWebSockets(socketOptions) - .UseEndpoints(endpoints => + .UseRequestResponseLogging() + .UseCors(opt => opt.WithOrigins(origins).AllowAnyMethod().AllowAnyHeader().WithExposedHeaders("Set-Cookie").AllowCredentials()) + .UseRouting() + .UseAuthentication() + .UseAuthorization() + .UseOpenApi() + .UseSwaggerUi3(config => + { + config.OAuth2Client = new NSwag.AspNetCore.OAuth2ClientSettings() { - endpoints.MapControllers(); - }) - .Use(async (context, next) => + ClientId = "c1e94676-cab0-42ba-8b6c-9532b8486fff", + UsePkceWithAuthorizationCodeGrant = true + }; + //config.WithCredentials = true; + }) + .UseWebSockets(socketOptions) + .UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }) + .Use(async (context, next) => { if (context.WebSockets.IsWebSocketRequest) { diff --git a/Gameboard.ShogiUI.Sockets/Utilities/NotationHelper.cs b/Gameboard.ShogiUI.Sockets/Utilities/NotationHelper.cs index 8294dd6..c6831db 100644 --- a/Gameboard.ShogiUI.Sockets/Utilities/NotationHelper.cs +++ b/Gameboard.ShogiUI.Sockets/Utilities/NotationHelper.cs @@ -17,7 +17,6 @@ namespace Gameboard.ShogiUI.Sockets.Utilities { var file = (char)(x + A); var rank = y + 1; - Console.WriteLine($"({x},{y}) - {file}{rank}"); return $"{file}{rank}"; } diff --git a/Gameboard.ShogiUI.Sockets/appsettings.json b/Gameboard.ShogiUI.Sockets/appsettings.json index 9d9f40f..060e229 100644 --- a/Gameboard.ShogiUI.Sockets/appsettings.json +++ b/Gameboard.ShogiUI.Sockets/appsettings.json @@ -12,5 +12,11 @@ "Microsoft.Hosting.Lifetime": "Information" } }, + "AzureAd": { + "Instance": "https://login.microsoftonline.com/", + "ClientId": "c1e94676-cab0-42ba-8b6c-9532b8486fff", + "TenantId": "common", + "Audience": "c1e94676-cab0-42ba-8b6c-9532b8486fff" + }, "AllowedHosts": "*" } \ No newline at end of file diff --git a/Gameboard.ShogiUI.UnitTests/Gameboard.ShogiUI.UnitTests.csproj b/Gameboard.ShogiUI.UnitTests/Gameboard.ShogiUI.UnitTests.csproj index a15e786..3848edc 100644 --- a/Gameboard.ShogiUI.UnitTests/Gameboard.ShogiUI.UnitTests.csproj +++ b/Gameboard.ShogiUI.UnitTests/Gameboard.ShogiUI.UnitTests.csproj @@ -6,10 +6,10 @@ - - - - + + + + diff --git a/Gameboard.ShogiUI.xUnitTests/Gameboard.ShogiUI.xUnitTests.csproj b/Gameboard.ShogiUI.xUnitTests/Gameboard.ShogiUI.xUnitTests.csproj index c86f2d1..d47800f 100644 --- a/Gameboard.ShogiUI.xUnitTests/Gameboard.ShogiUI.xUnitTests.csproj +++ b/Gameboard.ShogiUI.xUnitTests/Gameboard.ShogiUI.xUnitTests.csproj @@ -8,14 +8,14 @@ - - + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Gameboard.ShogiUI.xUnitTests/RequestValidators/MoveRequestValidatorShould.cs b/Gameboard.ShogiUI.xUnitTests/RequestValidators/MoveRequestValidatorShould.cs deleted file mode 100644 index 8541ea4..0000000 --- a/Gameboard.ShogiUI.xUnitTests/RequestValidators/MoveRequestValidatorShould.cs +++ /dev/null @@ -1,76 +0,0 @@ -using AutoFixture; -using FluentAssertions; -using FluentAssertions.Execution; -using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; -using Gameboard.ShogiUI.Sockets.ServiceModels.Types; -using Gameboard.ShogiUI.Sockets.Services.RequestValidators; -using Xunit; - -namespace Gameboard.ShogiUI.xUnitTests.RequestValidators -{ - public class MoveRequestValidatorShould - { - private readonly Fixture fixture; - private readonly MoveRequestValidator validator; - public MoveRequestValidatorShould() - { - fixture = new Fixture(); - validator = new MoveRequestValidator(); - } - - [Fact] - public void PreventInvalidPropertyCombinations() - { - // Arrange - var request = fixture.Create(); - - // Act - var results = validator.Validate(request); - - // Assert - using (new AssertionScope()) - { - results.IsValid.Should().BeFalse(); - } - } - - [Fact] - public void AllowValidPropertyCombinations() - { - // Arrange - var requestWithoutFrom = new MoveRequest() - { - Action = ClientAction.Move, - GameName = "Some game name", - Move = new Move() - { - IsPromotion = false, - PieceFromCaptured = WhichPiece.Bishop, - To = "A4" - } - }; - var requestWithoutPieceFromCaptured = new MoveRequest() - { - Action = ClientAction.Move, - GameName = "Some game name", - Move = new Move() - { - From = "A1", - IsPromotion = false, - To = "A4" - } - }; - - // Act - var results = validator.Validate(requestWithoutFrom); - var results2 = validator.Validate(requestWithoutPieceFromCaptured); - - // Assert - using (new AssertionScope()) - { - results.IsValid.Should().BeTrue(); - results2.IsValid.Should().BeTrue(); - } - } - } -} diff --git a/PathFinding/PathFinder2D.cs b/PathFinding/PathFinder2D.cs index e5ded9f..aa3f3b4 100644 --- a/PathFinding/PathFinder2D.cs +++ b/PathFinding/PathFinder2D.cs @@ -72,7 +72,6 @@ namespace PathFinding var element = collection[from]; if (element == null) { - Console.WriteLine("Null element in PathEvery"); return; } foreach (var path in element.MoveSet.GetMoves())