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

@@ -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<IActionResult> 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<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.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<SocketController> 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<SocketController> 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<IActionResult> 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<IActionResult> 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));
}
/// <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")]
[AllowAnonymous]
public async Task<IActionResult> 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();
}
}
}

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>
<ItemGroup>
<PackageReference Include="FluentValidation" Version="10.3.0" />
<PackageReference Include="IdentityModel" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.AzureAD.UI" Version="5.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.2" />
<PackageReference Include="Microsoft.Identity.Web" Version="1.5.1" />
<PackageReference Include="Microsoft.Identity.Web.MicrosoftGraph" Version="1.5.1" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="FluentValidation" Version="10.3.3" />
<PackageReference Include="IdentityModel" Version="5.1.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.AzureAD.UI" Version="5.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="5.0.9" />
<PackageReference Include="Microsoft.Identity.Web" Version="1.16.0" />
<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>

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 System;
using System.Security.Claims;
using System.Threading.Tasks;
namespace Gameboard.ShogiUI.Sockets.Managers
{
public interface IGameboardManager
{
Task<string> CreateGuestUser(Guid webSessionId);
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> CreateBoardState(string sessionName, Shogi shogi);
Task<User?> ReadUser(string userName);
Task<User?> ReadUser(Guid webSessionId);
Task<User?> 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<string> CreateGuestUser(Guid webSessionId)
public Task<User?> 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<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)
{
//var session = await repository.GetGame(sessionName);
@@ -69,31 +55,6 @@ namespace Gameboard.ShogiUI.Sockets.Managers
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)
{
var isSuccess = false;

View File

@@ -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
{
/// <summary>
/// Key is userName or webSessionId
/// </summary>
private readonly ConcurrentDictionary<string, Guid> Tokens;
public SocketTokenManager()
public SocketTokenCache()
{
Tokens = new ConcurrentDictionary<string, Guid>();
}
@@ -41,7 +41,7 @@ namespace Gameboard.ShogiUI.Sockets.Managers
}
/// <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;
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.Net.WebSockets;
@@ -30,5 +31,7 @@ namespace Gameboard.ShogiUI.Sockets.Models
{
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 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;

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
{
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;
}
/// <summary>
/// Constructor for guest user.
/// </summary>
public User(string name, Guid webSessionId)
{
Name = name;
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": {
"commandName": "Project",
"launchUrl": "Socket/Token",
"launchBrowser": true,
"launchUrl": "/swagger",
"environmentVariables": {
"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
{
public class UserDocument : CouchDocument
{
public enum LoginPlatform
{
Microsoft,
Guest
}
public string Name { get; set; }
public LoginPlatform Platform { get; set; }
public WhichLoginPlatform Platform { get; set; }
/// <summary>
/// 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;
}
}
}

View File

@@ -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<bool> CreateBoardState(string sessionName, Models.Shogi shogi);
Task<bool> CreateBoardState(Models.Session session);
Task<bool> CreateSession(Models.SessionMetadata session);
Task<bool> CreateUser(Models.User user);
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 response = await client.PostAsync("_find", content);
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
.Select(s => new Models.SessionMetadata(s.Name, s.IsPrivate, s.Player1, s.Player2))
.ToList();
return new List<Models.SessionMetadata>(0);
}
public async Task<Models.Session?> ReadSession(string name)
@@ -113,9 +119,12 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
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 response = await client.PostAsync(string.Empty, content);
return response.IsSuccessStatusCode;
@@ -198,14 +207,23 @@ namespace Gameboard.ShogiUI.Sockets.Repositories
public async Task<Models.User?> 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<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;
}

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 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<CreateGameRequest> createGameRequestValidator;
private readonly IValidator<JoinByCodeRequest> joinByCodeRequestValidator;
private readonly IValidator<JoinGameRequest> joinGameRequestValidator;
private readonly IValidator<ListGamesRequest> listGamesRequestValidator;
private readonly IValidator<LoadGameRequest> loadGameRequestValidator;
private readonly IValidator<MoveRequest> moveRequestValidator;
public SocketService(
ILogger<SocketService> logger,
ISocketConnectionManager communicationManager,
IGameboardRepository gameboardRepository,
ISocketTokenManager tokenManager,
ICreateGameHandler createGameHandler,
IGameboardManager gameboardManager,
ISocketTokenCache tokenManager,
IJoinByCodeHandler joinByCodeHandler,
IJoinGameHandler joinGameHandler,
IListGamesHandler listGamesHandler,
ILoadGameHandler loadGameHandler,
IMoveHandler moveHandler,
IValidator<CreateGameRequest> createGameRequestValidator,
IValidator<JoinByCodeRequest> joinByCodeRequestValidator,
IValidator<JoinGameRequest> joinGameRequestValidator,
IValidator<ListGamesRequest> listGamesRequestValidator,
IValidator<LoadGameRequest> loadGameRequestValidator,
IValidator<MoveRequest> moveRequestValidator
IValidator<JoinGameRequest> 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<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:
{
var req = JsonConvert.DeserializeObject<JoinGameRequest>(message);
@@ -159,24 +120,6 @@ namespace Gameboard.ShogiUI.Sockets.Services
}
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)

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.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<ICreateGameHandler, CreateGameHandler>();
services.AddSingleton<IJoinByCodeHandler, JoinByCodeHandler>();
services.AddSingleton<IJoinGameHandler, JoinGameHandler>();
services.AddSingleton<IListGamesHandler, ListGamesHandler>();
services.AddSingleton<ILoadGameHandler, LoadGameHandler>();
services.AddSingleton<IMoveHandler, MoveHandler>();
// Managers
services.AddSingleton<ISocketConnectionManager, SocketConnectionManager>();
services.AddSingleton<ISocketTokenManager, SocketTokenManager>();
services.AddSingleton<ISocketTokenCache, SocketTokenCache>();
services.AddSingleton<IGameboardManager, GameboardManager>();
// Services
services.AddSingleton<IValidator<CreateGameRequest>, CreateGameRequestValidator>();
services.AddSingleton<IValidator<JoinByCodeRequest>, JoinByCodeRequestValidator>();
services.AddSingleton<IValidator<JoinGameRequest>, JoinGameRequestValidator>();
services.AddSingleton<IValidator<ListGamesRequest>, ListGamesRequestValidator>();
services.AddSingleton<IValidator<LoadGameRequest>, LoadGameRequestValidator>();
services.AddSingleton<IValidator<MoveRequest>, MoveRequestValidator>();
services.AddSingleton<ISocketService, SocketService>();
// Repositories
services.AddTransient<IGameboardRepository, GameboardRepository>();
services.AddSingleton<IClaimsTransformation, ShogiUserClaimsTransformer>();
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<IGameboardRepository, GameboardRepository>();
//services.AddSingleton<IAuthenticatedHttpClient, AuthenticatedHttpClient>();
//services.AddSingleton<ICouchClient>(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<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.
@@ -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)
{

View File

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

View File

@@ -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": "*"
}