massive checkpoint

This commit is contained in:
2021-09-03 22:43:06 -05:00
parent bb1d2c491c
commit 2a3b7b32b4
40 changed files with 456 additions and 738 deletions

View File

@@ -7,7 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.12.1" /> <PackageReference Include="BenchmarkDotNet" Version="0.13.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -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<Move> MoveHistory { get; set; }
}
}

View File

@@ -5,9 +5,6 @@ namespace Gameboard.ShogiUI.Sockets.ServiceModels.Api
{ {
public class PostMove public class PostMove
{ {
[Required]
public string GameName { get; set; }
[Required] [Required]
public Move Move { get; set; } public Move Move { get; set; }
} }

View File

@@ -3,8 +3,6 @@
public class PostSession public class PostSession
{ {
public string Name { get; set; } public string Name { get; set; }
public string Player1 { get; set; }
public string Player2 { get; set; }
public bool IsPrivate { get; set; } public bool IsPrivate { get; set; }
} }
} }

View File

@@ -1,27 +1,21 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Types; using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
using System;
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket 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 class CreateGameResponse : IResponse
{ {
public string Action { get; } public string Action { get; }
public string Error { get; set; }
public Game Game { get; set; } public Game Game { get; set; }
/// <summary>
/// The player who created the game.
/// </summary>
public string PlayerName { get; set; } public string PlayerName { get; set; }
public CreateGameResponse() public CreateGameResponse()
{ {
Action = ClientAction.CreateGame.ToString(); Action = ClientAction.CreateGame.ToString();
Error = string.Empty;
Game = new Game();
PlayerName = string.Empty;
} }
} }
} }

View File

@@ -3,6 +3,5 @@
public interface IResponse public interface IResponse
{ {
string Action { get; } string Action { get; }
string Error { get; set; }
} }
} }

View File

@@ -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<Game> Games { get; set; }
public ListGamesResponse()
{
Action = ClientAction.ListGames.ToString();
Error = "";
Games = new Collection<Game>();
}
}
}

View File

@@ -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<Move> MoveHistory { get; set; }
public string Error { get; set; }
public LoadGameResponse()
{
Action = ClientAction.LoadGame.ToString();
}
}
}

View File

@@ -1,29 +1,19 @@
using Gameboard.ShogiUI.Sockets.ServiceModels.Types; using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
using System.Collections.Generic;
namespace Gameboard.ShogiUI.Sockets.ServiceModels.Socket 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 class MoveResponse : IResponse
{ {
public string Action { get; } public string Action { get; protected set; }
public string Error { get; set; } public Game Game { get; set; }
public string GameName { get; set; } public WhichPlayer PlayerPerspective { get; set; }
public string PlayerName { get; set; } public BoardState BoardState { get; set; }
public Move Move { get; set; } public IList<Move> MoveHistory { get; set; }
public MoveResponse() public MoveResponse()
{ {
Action = ClientAction.Move.ToString(); Action = ClientAction.Move.ToString();
Error = string.Empty;
GameName = string.Empty;
PlayerName = string.Empty;
Move = new Move();
} }
} }
} }

View File

@@ -2,11 +2,9 @@
{ {
public enum ClientAction public enum ClientAction
{ {
ListGames,
CreateGame, CreateGame,
JoinGame, JoinGame,
JoinByCode, JoinByCode,
LoadGame,
Move Move
} }
} }

View File

@@ -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.Repositories;
using Gameboard.ShogiUI.Sockets.ServiceModels.Api; using Gameboard.ShogiUI.Sockets.ServiceModels.Api;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; using Gameboard.ShogiUI.Sockets.ServiceModels.Socket;
using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System; using System;
@@ -10,16 +12,15 @@ using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Controllers namespace Gameboard.ShogiUI.Sockets.Controllers
{ {
[Authorize]
[ApiController] [ApiController]
[Route("[controller]")] [Route("[controller]")]
[Authorize(Roles = "Shogi")]
public class GameController : ControllerBase public class GameController : ControllerBase
{ {
private static readonly string UsernameClaim = "preferred_username";
private readonly IGameboardManager gameboardManager; private readonly IGameboardManager gameboardManager;
private readonly IGameboardRepository gameboardRepository; private readonly IGameboardRepository gameboardRepository;
private readonly ISocketConnectionManager communicationManager; private readonly ISocketConnectionManager communicationManager;
private string? JwtUserName => HttpContext.User.Claims.FirstOrDefault(c => c.Type == UsernameClaim)?.Value;
public GameController( public GameController(
IGameboardRepository repository, IGameboardRepository repository,
@@ -68,22 +69,12 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
[HttpPost("{gameName}/Move")] [HttpPost("{gameName}/Move")]
public async Task<IActionResult> PostMove([FromRoute] string gameName, [FromBody] PostMove request) public async Task<IActionResult> PostMove([FromRoute] string gameName, [FromBody] PostMove request)
{ {
Models.User? user = null; var user = await gameboardManager.ReadUser(User);
if (Request.Cookies.ContainsKey(SocketController.WebSessionKey)) var session = await gameboardRepository.ReadSession(gameName);
{
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);
if (session == null || user == null || (session.Player1 != user.Name && session.Player2 != user.Name)) 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; var move = request.Move;
@@ -94,13 +85,19 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
if (moveSuccess) if (moveSuccess)
{ {
var createSuccess = await gameboardRepository.CreateBoardState(session);
if (!createSuccess)
{
throw new ApplicationException("Unable to persist board state.");
}
await communicationManager.BroadcastToPlayers(new MoveResponse await communicationManager.BroadcastToPlayers(new MoveResponse
{ {
GameName = session.Name, BoardState = session.Shogi.ToServiceModel(),
PlayerName = user.Name, Game = session.ToServiceModel(),
Move = moveModel.ToServiceModel() MoveHistory = session.Shogi.MoveHistory.Select(h => h.ToServiceModel()).ToList(),
PlayerPerspective = user.Name == session.Player1 ? WhichPlayer.Player1 : WhichPlayer.Player2
}, session.Player1, session.Player2); }, session.Player1, session.Player2);
return Created(string.Empty, null); return Ok();
} }
throw new InvalidOperationException("Illegal move."); throw new InvalidOperationException("Illegal move.");
} }
@@ -124,5 +121,56 @@ namespace Gameboard.ShogiUI.Sockets.Controllers
// } // }
// return new ConflictResult(); // return new ConflictResult();
//} //}
[HttpPost]
public async Task<IActionResult> 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();
}
/// <summary>
/// Reads the board session and subscribes the caller to socket events for that session.
/// </summary>
[HttpGet("{gameName}")]
public async Task<IActionResult> 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<IActionResult> GetSessions()
{
var sessions = await gameboardRepository.ReadSessionMetadatas();
return new JsonResult(sessions.Select(s => s.ToServiceModel()).ToList());
}
} }
} }

View File

@@ -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.Repositories;
using Gameboard.ShogiUI.Sockets.ServiceModels.Api; using Gameboard.ShogiUI.Sockets.ServiceModels.Api;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System; using System;
using System.Linq; using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Controllers namespace Gameboard.ShogiUI.Sockets.Controllers
{ {
[Authorize]
[Route("[controller]")]
[ApiController] [ApiController]
[Route("[controller]")]
[Authorize(Roles = "Shogi")]
public class SocketController : ControllerBase public class SocketController : ControllerBase
{ {
public static readonly string WebSessionKey = "session-id";
private readonly ILogger<SocketController> logger; private readonly ILogger<SocketController> logger;
private readonly ISocketTokenManager tokenManager; private readonly ISocketTokenCache tokenCache;
private readonly IGameboardManager gameboardManager; private readonly IGameboardManager gameboardManager;
private readonly IGameboardRepository gameboardRepository; private readonly IGameboardRepository gameboardRepository;
private readonly CookieOptions createSessionOptions; private readonly AuthenticationProperties authenticationProps;
private readonly CookieOptions deleteSessionOptions;
public SocketController( public SocketController(
ILogger<SocketController> logger, ILogger<SocketController> logger,
ISocketTokenManager tokenManager, ISocketTokenCache tokenCache,
IGameboardManager gameboardManager, IGameboardManager gameboardManager,
IGameboardRepository gameboardRepository) IGameboardRepository gameboardRepository)
{ {
this.logger = logger; this.logger = logger;
this.tokenManager = tokenManager; this.tokenCache = tokenCache;
this.gameboardManager = gameboardManager; this.gameboardManager = gameboardManager;
this.gameboardRepository = gameboardRepository; this.gameboardRepository = gameboardRepository;
createSessionOptions = new CookieOptions authenticationProps = new AuthenticationProperties
{ {
Secure = true, AllowRefresh = true,
HttpOnly = true, IsPersistent = true
SameSite = SameSiteMode.None,
Expires = DateTimeOffset.Now.AddYears(5)
}; };
deleteSessionOptions = new CookieOptions();
} }
[HttpGet("Yep")] [HttpGet("GuestLogout")]
[AllowAnonymous] [AllowAnonymous]
public IActionResult Yep() public async Task<IActionResult> GuestLogout()
{ {
deleteSessionOptions.Expires = DateTimeOffset.Now.AddDays(-1); await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
Response.Cookies.Append(WebSessionKey, "", deleteSessionOptions);
return Ok(); return Ok();
} }
[HttpGet("Token")] [HttpGet("Token")]
public IActionResult GetToken() public async Task<IActionResult> GetToken()
{ {
var userName = HttpContext.User.Claims.First(c => c.Type == "preferred_username").Value; var identityId = User.UserId();
var token = tokenManager.GenerateToken(userName); 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)); return new JsonResult(new GetTokenResponse(token));
} }
/// <summary>
/// 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.
/// </summary>
/// <param name="request"></param>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("GuestToken")] [HttpGet("GuestToken")]
[AllowAnonymous]
public async Task<IActionResult> GetGuestToken() public async Task<IActionResult> GetGuestToken()
{ {
var cookies = Request.Cookies; if (Guid.TryParse(User.UserId(), out Guid webSessionId))
var webSessionId = cookies.ContainsKey(WebSessionKey)
? Guid.Parse(cookies[WebSessionKey]!)
: Guid.NewGuid();
var webSessionIdAsString = webSessionId.ToString();
var user = await gameboardRepository.ReadGuestUser(webSessionId);
if (user == null)
{ {
var userName = await gameboardManager.CreateGuestUser(webSessionId); var user = await gameboardRepository.ReadGuestUser(webSessionId);
var token = tokenManager.GenerateToken(webSessionIdAsString); if (user != null)
Response.Cookies.Append(WebSessionKey, webSessionIdAsString, createSessionOptions); {
return new JsonResult(new GetGuestTokenResponse(userName, token)); var token = tokenCache.GenerateToken(webSessionId.ToString());
return new JsonResult(new GetGuestTokenResponse(user.Name, token));
}
} }
else else
{ {
var token = tokenManager.GenerateToken(webSessionIdAsString); // Setup a guest user.
Response.Cookies.Append(WebSessionKey, webSessionIdAsString, createSessionOptions); var newSessionId = Guid.NewGuid();
return new JsonResult(new GetGuestTokenResponse(user.Name, token)); 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();
} }
} }
} }

View File

@@ -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");
}
}
}

View File

@@ -8,13 +8,15 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentValidation" Version="10.3.0" /> <PackageReference Include="FluentValidation" Version="10.3.3" />
<PackageReference Include="IdentityModel" Version="5.0.0" /> <PackageReference Include="IdentityModel" Version="5.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.AzureAD.UI" Version="5.0.2" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.AzureAD.UI" Version="5.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.2" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.9" />
<PackageReference Include="Microsoft.Identity.Web" Version="1.5.1" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.9" />
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="1.5.1" /> <PackageReference Include="Microsoft.Identity.Web" Version="1.16.0" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="1.16.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="NSwag.AspNetCore" Version="13.13.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
/// <summary>
/// Subscribes a user to messages for a session and loads that session into the BoardManager for playing.
/// </summary>
public class LoadGameHandler : ILoadGameHandler
{
private readonly ILogger<LoadGameHandler> logger;
private readonly IGameboardRepository gameboardRepository;
private readonly ISocketConnectionManager communicationManager;
public LoadGameHandler(
ILogger<LoadGameHandler> 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);
}
}
}

View File

@@ -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);
}
}
}
}
}

View File

@@ -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 Gameboard.ShogiUI.Sockets.Repositories;
using System; using System;
using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Managers namespace Gameboard.ShogiUI.Sockets.Managers
{ {
public interface IGameboardManager public interface IGameboardManager
{ {
Task<string> CreateGuestUser(Guid webSessionId);
Task<bool> IsPlayer1(string sessionName, string playerName); Task<bool> IsPlayer1(string sessionName, string playerName);
Task<bool> CreateSession(SessionMetadata session);
Task<Session?> ReadSession(string gameName);
Task<bool> UpdateSession(SessionMetadata session);
Task<bool> AssignPlayer2ToSession(string sessionName, string userName); Task<bool> AssignPlayer2ToSession(string sessionName, string userName);
Task<bool> CreateBoardState(string sessionName, Shogi shogi); Task<User?> ReadUser(ClaimsPrincipal user);
Task<User?> ReadUser(string userName);
Task<User?> ReadUser(Guid webSessionId);
} }
public class GameboardManager : IGameboardManager public class GameboardManager : IGameboardManager
{ {
private const int MaxTries = 3;
private readonly IGameboardRepository repository; private readonly IGameboardRepository repository;
public GameboardManager(IGameboardRepository repository) public GameboardManager(IGameboardRepository repository)
@@ -28,30 +23,21 @@ namespace Gameboard.ShogiUI.Sockets.Managers
this.repository = repository; this.repository = repository;
} }
public async Task<string> CreateGuestUser(Guid webSessionId) public Task<User?> ReadUser(ClaimsPrincipal user)
{ {
var count = 0; var userId = user.UserId();
while (count < MaxTries) if (user.IsGuest() && Guid.TryParse(userId, out var webSessionId))
{ {
count++; return repository.ReadGuestUser(webSessionId);
var userName = $"Guest-{Guid.NewGuid()}";
var isCreated = await repository.CreateUser(new User(userName, webSessionId));
if (isCreated)
{
return userName;
}
} }
throw new OperationCanceledException($"Failed to create guest user after {count} tries."); else if (!string.IsNullOrEmpty(userId))
{
return repository.ReadUser(userId);
}
return Task.FromResult<User?>(null);
} }
public Task<User?> ReadUser(Guid webSessionId)
{
return repository.ReadGuestUser(webSessionId);
}
public Task<User?> ReadUser(string userName)
{
return repository.ReadUser(userName);
}
public async Task<bool> IsPlayer1(string sessionName, string playerName) public async Task<bool> IsPlayer1(string sessionName, string playerName)
{ {
//var session = await repository.GetGame(sessionName); //var session = await repository.GetGame(sessionName);
@@ -69,31 +55,6 @@ namespace Gameboard.ShogiUI.Sockets.Managers
return string.Empty; return string.Empty;
} }
public Task<bool> CreateSession(SessionMetadata session)
{
return repository.CreateSession(session);
}
public Task<Session?> ReadSession(string sessionName)
{
return repository.ReadSession(sessionName);
}
/// <summary>
/// Saves the session to storage.
/// </summary>
/// <param name="session">The session to save.</param>
/// <returns>True if the session was saved successfully.</returns>
public Task<bool> UpdateSession(SessionMetadata session)
{
return repository.UpdateSession(session);
}
public Task<bool> CreateBoardState(string sessionName, Shogi shogi)
{
return repository.CreateBoardState(sessionName, shogi);
}
public async Task<bool> AssignPlayer2ToSession(string sessionName, string userName) public async Task<bool> AssignPlayer2ToSession(string sessionName, string userName)
{ {
var isSuccess = false; var isSuccess = false;

View File

@@ -6,20 +6,20 @@ using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Managers namespace Gameboard.ShogiUI.Sockets.Managers
{ {
public interface ISocketTokenManager public interface ISocketTokenCache
{ {
Guid GenerateToken(string s); Guid GenerateToken(string s);
string GetUsername(Guid g); string? GetUsername(Guid g);
} }
public class SocketTokenManager : ISocketTokenManager public class SocketTokenCache : ISocketTokenCache
{ {
/// <summary> /// <summary>
/// Key is userName or webSessionId /// Key is userName or webSessionId
/// </summary> /// </summary>
private readonly ConcurrentDictionary<string, Guid> Tokens; private readonly ConcurrentDictionary<string, Guid> Tokens;
public SocketTokenManager() public SocketTokenCache()
{ {
Tokens = new ConcurrentDictionary<string, Guid>(); Tokens = new ConcurrentDictionary<string, Guid>();
} }
@@ -41,7 +41,7 @@ namespace Gameboard.ShogiUI.Sockets.Managers
} }
/// <returns>User name associated to the guid or null.</returns> /// <returns>User name associated to the guid or null.</returns>
public string GetUsername(Guid guid) public string? GetUsername(Guid guid)
{ {
var userName = Tokens.FirstOrDefault(kvp => kvp.Value == guid).Key; var userName = Tokens.FirstOrDefault(kvp => kvp.Value == guid).Key;
if (userName != null) if (userName != null)

View File

@@ -1,4 +1,5 @@
using Newtonsoft.Json; using Gameboard.ShogiUI.Sockets.ServiceModels.Types;
using Newtonsoft.Json;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Net.WebSockets; using System.Net.WebSockets;
@@ -30,5 +31,7 @@ namespace Gameboard.ShogiUI.Sockets.Models
{ {
Player2 = userName; Player2 = userName;
} }
public Game ToServiceModel() => new() { GameName = Name, Player1 = Player1, Player2 = Player2 };
} }
} }

View File

@@ -10,7 +10,7 @@
public string? Player2 { get; private set; } public string? Player2 { get; private set; }
public bool IsPrivate { get; } 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; Name = name;
IsPrivate = isPrivate; IsPrivate = isPrivate;

View File

@@ -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 namespace Gameboard.ShogiUI.Sockets.Models
{ {
public class User public class User
{ {
public static readonly string GuestPrefix = "Guest-";
public string Name { get; } public string Name { get; }
public Guid? WebSessionId { 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;
}
/// <summary>
/// Constructor for guest user.
/// </summary>
public User(string name, Guid webSessionId)
{ {
Name = name; Name = name;
WebSessionId = webSessionId; WebSessionId = webSessionId;
} }
public ClaimsIdentity CreateMsalUserIdentity()
{
var claims = new List<Claim>()
{
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<Claim>()
{
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);
}
} }
} }

View File

@@ -0,0 +1,8 @@
namespace Gameboard.ShogiUI.Sockets.Models
{
public enum WhichLoginPlatform
{
Microsoft,
Guest
}
}

View File

@@ -18,11 +18,12 @@
}, },
"Kestrel": { "Kestrel": {
"commandName": "Project", "commandName": "Project",
"launchUrl": "Socket/Token", "launchBrowser": true,
"launchUrl": "/swagger",
"environmentVariables": { "environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development" "ASPNETCORE_ENVIRONMENT": "Development"
}, },
"applicationUrl": "http://127.0.0.1:5100" "applicationUrl": "http://localhost:5100"
} }
} }
} }

View File

@@ -1,17 +1,13 @@
using System; using Gameboard.ShogiUI.Sockets.Models;
using System;
namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
{ {
public class UserDocument : CouchDocument public class UserDocument : CouchDocument
{ {
public enum LoginPlatform
{
Microsoft,
Guest
}
public string Name { get; set; } public string Name { get; set; }
public LoginPlatform Platform { get; set; } public WhichLoginPlatform Platform { get; set; }
/// <summary> /// <summary>
/// The browser session ID saved via Set-Cookie headers. /// The browser session ID saved via Set-Cookie headers.
/// Only used with guest accounts. /// Only used with guest accounts.
@@ -31,8 +27,8 @@ namespace Gameboard.ShogiUI.Sockets.Repositories.CouchModels
Name = name; Name = name;
WebSessionId = webSessionId; WebSessionId = webSessionId;
Platform = WebSessionId.HasValue Platform = WebSessionId.HasValue
? LoginPlatform.Guest ? WhichLoginPlatform.Guest
: LoginPlatform.Microsoft; : WhichLoginPlatform.Microsoft;
} }
} }
} }

View File

@@ -7,12 +7,13 @@ using System.Linq;
using System.Net.Http; using System.Net.Http;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web;
namespace Gameboard.ShogiUI.Sockets.Repositories namespace Gameboard.ShogiUI.Sockets.Repositories
{ {
public interface IGameboardRepository public interface IGameboardRepository
{ {
Task<bool> CreateBoardState(string sessionName, Models.Shogi shogi); Task<bool> CreateBoardState(Models.Session session);
Task<bool> CreateSession(Models.SessionMetadata session); Task<bool> CreateSession(Models.SessionMetadata session);
Task<bool> CreateUser(Models.User user); Task<bool> CreateUser(Models.User user);
Task<IList<Models.SessionMetadata>> ReadSessionMetadatas(); Task<IList<Models.SessionMetadata>> ReadSessionMetadatas();
@@ -46,11 +47,16 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
var content = new StringContent(JsonConvert.SerializeObject(q), Encoding.UTF8, ApplicationJson); var content = new StringContent(JsonConvert.SerializeObject(q), Encoding.UTF8, ApplicationJson);
var response = await client.PostAsync("_find", content); var response = await client.PostAsync("_find", content);
var responseContent = await response.Content.ReadAsStringAsync(); var responseContent = await response.Content.ReadAsStringAsync();
var sessions = JsonConvert.DeserializeObject<CouchFindResult<SessionDocument>>(responseContent).docs; var results = JsonConvert.DeserializeObject<CouchFindResult<SessionDocument>>(responseContent);
if (results != null)
{
return results
.docs
.Select(s => new Models.SessionMetadata(s.Name, s.IsPrivate, s.Player1, s.Player2))
.ToList();
}
return sessions return new List<Models.SessionMetadata>(0);
.Select(s => new Models.SessionMetadata(s.Name, s.IsPrivate, s.Player1, s.Player2))
.ToList();
} }
public async Task<Models.Session?> ReadSession(string name) public async Task<Models.Session?> ReadSession(string name)
@@ -113,9 +119,12 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
return new Models.Shogi(moves); return new Models.Shogi(moves);
} }
public async Task<bool> CreateBoardState(string sessionName, Models.Shogi shogi) /// <summary>
/// Saves a snapshot of board state and the most recent move.
/// </summary>
public async Task<bool> 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 content = new StringContent(JsonConvert.SerializeObject(boardStateDocument), Encoding.UTF8, ApplicationJson);
var response = await client.PostAsync(string.Empty, content); var response = await client.PostAsync(string.Empty, content);
return response.IsSuccessStatusCode; return response.IsSuccessStatusCode;
@@ -198,14 +207,23 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
public async Task<Models.User?> ReadUser(string userName) public async Task<Models.User?> ReadUser(string userName)
{ {
var document = new UserDocument(userName); try
var response = await client.GetAsync(document.Id);
var responseContent = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode)
{ {
var user = JsonConvert.DeserializeObject<UserDocument>(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<UserDocument>(responseContent);
return new Models.User(user.Name); return new Models.User(user.Name);
}
}
catch (Exception e)
{
Console.WriteLine(e);
} }
return null; return null;
} }

View File

@@ -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<CreateGameRequest>
{
public CreateGameRequestValidator()
{
RuleFor(_ => _.Action).Equal(ClientAction.CreateGame);
RuleFor(_ => _.GameName).NotEmpty();
}
}
}

View File

@@ -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<ListGamesRequest>
{
public ListGamesRequestValidator()
{
RuleFor(_ => _.Action).Equal(ClientAction.ListGames);
}
}
}

View File

@@ -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<LoadGameRequest>
{
public LoadGameRequestValidator()
{
RuleFor(_ => _.Action).Equal(ClientAction.LoadGame);
RuleFor(_ => _.GameName).NotEmpty();
}
}
}

View File

@@ -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<MoveRequest>
{
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.");
}
}
}

View File

@@ -31,65 +31,44 @@ namespace Gameboard.ShogiUI.Sockets.Services
private readonly ILogger<SocketService> logger; private readonly ILogger<SocketService> logger;
private readonly ISocketConnectionManager communicationManager; private readonly ISocketConnectionManager communicationManager;
private readonly IGameboardRepository gameboardRepository; private readonly IGameboardRepository gameboardRepository;
private readonly ISocketTokenManager tokenManager; private readonly IGameboardManager gameboardManager;
private readonly ICreateGameHandler createGameHandler; private readonly ISocketTokenCache tokenManager;
private readonly IJoinByCodeHandler joinByCodeHandler; private readonly IJoinByCodeHandler joinByCodeHandler;
private readonly IJoinGameHandler joinGameHandler; private readonly IJoinGameHandler joinGameHandler;
private readonly IListGamesHandler listGamesHandler;
private readonly ILoadGameHandler loadGameHandler;
private readonly IMoveHandler moveHandler;
private readonly IValidator<CreateGameRequest> createGameRequestValidator;
private readonly IValidator<JoinByCodeRequest> joinByCodeRequestValidator; private readonly IValidator<JoinByCodeRequest> joinByCodeRequestValidator;
private readonly IValidator<JoinGameRequest> joinGameRequestValidator; private readonly IValidator<JoinGameRequest> joinGameRequestValidator;
private readonly IValidator<ListGamesRequest> listGamesRequestValidator;
private readonly IValidator<LoadGameRequest> loadGameRequestValidator;
private readonly IValidator<MoveRequest> moveRequestValidator;
public SocketService( public SocketService(
ILogger<SocketService> logger, ILogger<SocketService> logger,
ISocketConnectionManager communicationManager, ISocketConnectionManager communicationManager,
IGameboardRepository gameboardRepository, IGameboardRepository gameboardRepository,
ISocketTokenManager tokenManager, IGameboardManager gameboardManager,
ICreateGameHandler createGameHandler, ISocketTokenCache tokenManager,
IJoinByCodeHandler joinByCodeHandler, IJoinByCodeHandler joinByCodeHandler,
IJoinGameHandler joinGameHandler, IJoinGameHandler joinGameHandler,
IListGamesHandler listGamesHandler,
ILoadGameHandler loadGameHandler,
IMoveHandler moveHandler,
IValidator<CreateGameRequest> createGameRequestValidator,
IValidator<JoinByCodeRequest> joinByCodeRequestValidator, IValidator<JoinByCodeRequest> joinByCodeRequestValidator,
IValidator<JoinGameRequest> joinGameRequestValidator, IValidator<JoinGameRequest> joinGameRequestValidator
IValidator<ListGamesRequest> listGamesRequestValidator,
IValidator<LoadGameRequest> loadGameRequestValidator,
IValidator<MoveRequest> moveRequestValidator
) : base() ) : base()
{ {
this.logger = logger; this.logger = logger;
this.communicationManager = communicationManager; this.communicationManager = communicationManager;
this.gameboardRepository = gameboardRepository; this.gameboardRepository = gameboardRepository;
this.gameboardManager = gameboardManager;
this.tokenManager = tokenManager; this.tokenManager = tokenManager;
this.createGameHandler = createGameHandler;
this.joinByCodeHandler = joinByCodeHandler; this.joinByCodeHandler = joinByCodeHandler;
this.joinGameHandler = joinGameHandler; this.joinGameHandler = joinGameHandler;
this.listGamesHandler = listGamesHandler;
this.loadGameHandler = loadGameHandler;
this.moveHandler = moveHandler;
this.createGameRequestValidator = createGameRequestValidator;
this.joinByCodeRequestValidator = joinByCodeRequestValidator; this.joinByCodeRequestValidator = joinByCodeRequestValidator;
this.joinGameRequestValidator = joinGameRequestValidator; this.joinGameRequestValidator = joinGameRequestValidator;
this.listGamesRequestValidator = listGamesRequestValidator;
this.loadGameRequestValidator = loadGameRequestValidator;
this.moveRequestValidator = moveRequestValidator;
} }
public async Task HandleSocketRequest(HttpContext context) public async Task HandleSocketRequest(HttpContext context)
{ {
string? userName = null; string? userName = null;
if (context.Request.Cookies.ContainsKey(SocketController.WebSessionKey)) var user = await gameboardManager.ReadUser(context.User);
if (user?.WebSessionId != null)
{ {
// Guest account // Guest account
var webSessionId = Guid.Parse(context.Request.Cookies[SocketController.WebSessionKey]!); userName = tokenManager.GetUsername(user.WebSessionId.Value);
userName = (await gameboardRepository.ReadGuestUser(webSessionId))?.Name;
} }
else if (context.Request.Query.Keys.Contains("token")) else if (context.Request.Query.Keys.Contains("token"))
{ {
@@ -123,24 +102,6 @@ namespace Gameboard.ShogiUI.Sockets.Services
} }
switch (request.Action) switch (request.Action)
{ {
case ClientAction.ListGames:
{
var req = JsonConvert.DeserializeObject<ListGamesRequest>(message);
if (await ValidateRequestAndReplyIfInvalid(socket, listGamesRequestValidator, req))
{
await listGamesHandler.Handle(req, userName);
}
break;
}
case ClientAction.CreateGame:
{
var req = JsonConvert.DeserializeObject<CreateGameRequest>(message);
if (await ValidateRequestAndReplyIfInvalid(socket, createGameRequestValidator, req))
{
await createGameHandler.Handle(req, userName);
}
break;
}
case ClientAction.JoinGame: case ClientAction.JoinGame:
{ {
var req = JsonConvert.DeserializeObject<JoinGameRequest>(message); var req = JsonConvert.DeserializeObject<JoinGameRequest>(message);
@@ -159,24 +120,6 @@ namespace Gameboard.ShogiUI.Sockets.Services
} }
break; break;
} }
case ClientAction.LoadGame:
{
var req = JsonConvert.DeserializeObject<LoadGameRequest>(message);
if (await ValidateRequestAndReplyIfInvalid(socket, loadGameRequestValidator, req))
{
await loadGameHandler.Handle(req, userName);
}
break;
}
case ClientAction.Move:
{
var req = JsonConvert.DeserializeObject<MoveRequest>(message);
if (await ValidateRequestAndReplyIfInvalid(socket, moveRequestValidator, req))
{
await moveHandler.Handle(req, userName);
}
break;
}
} }
} }
catch (OperationCanceledException ex) catch (OperationCanceledException ex)

View File

@@ -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
{
/// <summary>
/// Standardizes the claims from third party issuers. Also registers new msal users in the database.
/// </summary>
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<ClaimsPrincipal> 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;
}
}
}

View File

@@ -6,17 +6,23 @@ using Gameboard.ShogiUI.Sockets.Repositories;
using Gameboard.ShogiUI.Sockets.ServiceModels.Socket; using Gameboard.ShogiUI.Sockets.ServiceModels.Socket;
using Gameboard.ShogiUI.Sockets.Services; using Gameboard.ShogiUI.Sockets.Services;
using Gameboard.ShogiUI.Sockets.Services.RequestValidators; using Gameboard.ShogiUI.Sockets.Services.RequestValidators;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Identity.Client;
using Microsoft.Identity.Web;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization; using Newtonsoft.Json.Serialization;
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Security.Claims;
using System.Text; using System.Text;
using System.Threading.Tasks; 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. // This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services) public void ConfigureServices(IServiceCollection services)
{ {
// Socket ActionHandlers
services.AddSingleton<ICreateGameHandler, CreateGameHandler>();
services.AddSingleton<IJoinByCodeHandler, JoinByCodeHandler>(); services.AddSingleton<IJoinByCodeHandler, JoinByCodeHandler>();
services.AddSingleton<IJoinGameHandler, JoinGameHandler>(); services.AddSingleton<IJoinGameHandler, JoinGameHandler>();
services.AddSingleton<IListGamesHandler, ListGamesHandler>();
services.AddSingleton<ILoadGameHandler, LoadGameHandler>();
services.AddSingleton<IMoveHandler, MoveHandler>();
// Managers
services.AddSingleton<ISocketConnectionManager, SocketConnectionManager>(); services.AddSingleton<ISocketConnectionManager, SocketConnectionManager>();
services.AddSingleton<ISocketTokenManager, SocketTokenManager>(); services.AddSingleton<ISocketTokenCache, SocketTokenCache>();
services.AddSingleton<IGameboardManager, GameboardManager>(); services.AddSingleton<IGameboardManager, GameboardManager>();
// Services
services.AddSingleton<IValidator<CreateGameRequest>, CreateGameRequestValidator>();
services.AddSingleton<IValidator<JoinByCodeRequest>, JoinByCodeRequestValidator>(); services.AddSingleton<IValidator<JoinByCodeRequest>, JoinByCodeRequestValidator>();
services.AddSingleton<IValidator<JoinGameRequest>, JoinGameRequestValidator>(); services.AddSingleton<IValidator<JoinGameRequest>, JoinGameRequestValidator>();
services.AddSingleton<IValidator<ListGamesRequest>, ListGamesRequestValidator>();
services.AddSingleton<IValidator<LoadGameRequest>, LoadGameRequestValidator>();
services.AddSingleton<IValidator<MoveRequest>, MoveRequestValidator>();
services.AddSingleton<ISocketService, SocketService>(); services.AddSingleton<ISocketService, SocketService>();
services.AddTransient<IGameboardRepository, GameboardRepository>();
// Repositories services.AddSingleton<IClaimsTransformation, ShogiUserClaimsTransformer>();
services.AddHttpClient("couchdb", c => services.AddHttpClient("couchdb", c =>
{ {
var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("admin:admin")); 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"]}/"; var baseUrl = $"{Configuration["AppSettings:CouchDB:Url"]}/{Configuration["AppSettings:CouchDB:Database"]}/";
c.BaseAddress = new Uri(baseUrl); c.BaseAddress = new Uri(baseUrl);
}); });
services.AddTransient<IGameboardRepository, GameboardRepository>();
//services.AddSingleton<IAuthenticatedHttpClient, AuthenticatedHttpClient>();
//services.AddSingleton<ICouchClient>(provider => new CouchClient(databaseName, couchUrl));
services.AddControllers();
services services
.AddAuthentication(options => .AddControllers()
.AddNewtonsoftJson(options =>
{
options.SerializerSettings.Formatting = Formatting.Indented;
options.SerializerSettings.ContractResolver = new DefaultContractResolver
{ {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; NamingStrategy = new CamelCaseNamingStrategy { ProcessDictionaryKeys = true }
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; };
}) options.SerializerSettings.Converters = new[] { new StringEnumConverter() };
.AddJwtBearer(options => options.SerializerSettings.NullValueHandling = NullValueHandling.Ignore;
{ });
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;
options.Events = new JwtBearerEvents services.AddAuthentication("CookieOrJwt")
{ .AddPolicyScheme("CookieOrJwt", "Either cookie or jwt", options =>
OnMessageReceived = (context) => {
{ options.ForwardDefaultSelector = context =>
if (context.HttpContext.WebSockets.IsWebSocketRequest) {
{ var bearerAuth = context.Request.Headers["Authorization"].FirstOrDefault()?.StartsWith("Bearer ") ?? false;
Console.WriteLine("Yep"); return bearerAuth
} ? JwtBearerDefaults.AuthenticationScheme
return Task.FromResult(0); : 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<string, string> { { "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. // 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()) if (env.IsDevelopment())
{ {
app.UseDeveloperExceptionPage(); app.UseDeveloperExceptionPage();
var client = PublicClientApplicationBuilder
.Create(Configuration["AzureAd:ClientId"])
.WithLogging(
(level, message, pii) =>
{
},
LogLevel.Verbose,
true,
true
)
.Build();
} }
else else
{ {
app.UseHsts(); app.UseHsts();
} }
app app
.UseRequestResponseLogging() .UseRequestResponseLogging()
.UseCors( .UseCors(opt => opt.WithOrigins(origins).AllowAnyMethod().AllowAnyHeader().WithExposedHeaders("Set-Cookie").AllowCredentials())
opt => opt .UseRouting()
.WithOrigins(origins) .UseAuthentication()
.AllowAnyMethod() .UseAuthorization()
.AllowAnyHeader() .UseOpenApi()
.WithExposedHeaders("Set-Cookie") .UseSwaggerUi3(config =>
.AllowCredentials() {
) config.OAuth2Client = new NSwag.AspNetCore.OAuth2ClientSettings()
.UseRouting()
.UseAuthentication()
.UseAuthorization()
.UseWebSockets(socketOptions)
.UseEndpoints(endpoints =>
{ {
endpoints.MapControllers(); ClientId = "c1e94676-cab0-42ba-8b6c-9532b8486fff",
}) UsePkceWithAuthorizationCodeGrant = true
.Use(async (context, next) => };
//config.WithCredentials = true;
})
.UseWebSockets(socketOptions)
.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
})
.Use(async (context, next) =>
{ {
if (context.WebSockets.IsWebSocketRequest) if (context.WebSockets.IsWebSocketRequest)
{ {

View File

@@ -17,7 +17,6 @@ namespace Gameboard.ShogiUI.Sockets.Utilities
{ {
var file = (char)(x + A); var file = (char)(x + A);
var rank = y + 1; var rank = y + 1;
Console.WriteLine($"({x},{y}) - {file}{rank}");
return $"{file}{rank}"; return $"{file}{rank}";
} }

View File

@@ -12,5 +12,11 @@
"Microsoft.Hosting.Lifetime": "Information" "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": "*" "AllowedHosts": "*"
} }

View File

@@ -6,10 +6,10 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AutoFixture" Version="4.17.0" /> <PackageReference Include="AutoFixture" Version="4.17.0" />
<PackageReference Include="FluentAssertions" Version="5.10.3" /> <PackageReference Include="FluentAssertions" Version="6.1.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.8.3" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.1.2" /> <PackageReference Include="MSTest.TestAdapter" Version="2.2.7" />
<PackageReference Include="MSTest.TestFramework" Version="2.1.2" /> <PackageReference Include="MSTest.TestFramework" Version="2.2.7" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />
</ItemGroup> </ItemGroup>

View File

@@ -8,14 +8,14 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AutoFixture" Version="4.17.0" /> <PackageReference Include="AutoFixture" Version="4.17.0" />
<PackageReference Include="FluentAssertions" Version="5.10.3" /> <PackageReference Include="FluentAssertions" Version="6.1.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3"> <PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="coverlet.collector" Version="3.0.2"> <PackageReference Include="coverlet.collector" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>

View File

@@ -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<MoveRequest>();
// 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();
}
}
}
}

View File

@@ -72,7 +72,6 @@ namespace PathFinding
var element = collection[from]; var element = collection[from];
if (element == null) if (element == null)
{ {
Console.WriteLine("Null element in PathEvery");
return; return;
} }
foreach (var path in element.MoveSet.GetMoves()) foreach (var path in element.MoveSet.GetMoves())