Replace custom socket implementation with SignalR.

Replace MSAL and custom cookie auth with Microsoft.Identity.EntityFramework
Also some UI redesign to accommodate different login experience.
This commit is contained in:
2024-08-25 03:46:44 +00:00
parent d688afaeae
commit 51d234d871
172 changed files with 3857 additions and 4045 deletions

6
Shogi.Api/ApiKeys.cs Normal file
View File

@@ -0,0 +1,6 @@
namespace Shogi.Api;
public class ApiKeys
{
public string BrevoEmailService { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,19 @@
using Microsoft.AspNetCore.SignalR;
namespace Shogi.Api.Application;
/// <summary>
/// Used to receive signals from connected clients.
/// </summary>
public class GameHub : Hub
{
public Task Subscribe(string sessionId)
{
return this.Groups.AddToGroupAsync(this.Context.ConnectionId, sessionId);
}
public Task Unsubscribe(string sessionId)
{
return this.Groups.RemoveFromGroupAsync(this.Context.ConnectionId, sessionId);
}
}

View File

@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.SignalR;
namespace Shogi.Api.Application;
/// <summary>
/// Used to send signals to connected clients.
/// </summary>
public class GameHubContext(IHubContext<GameHub> context)
{
public async Task Emit_SessionJoined(string sessionId)
{
var clients = context.Clients.Group(sessionId);
await clients.SendAsync("SessionJoined");
}
public async Task Emit_PieceMoved(string sessionId)
{
var clients = context.Clients.Group(sessionId);
await clients.SendAsync("PieceMoved");
}
}

View File

@@ -0,0 +1,143 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Shogi.Api.Controllers;
using Shogi.Api.Extensions;
using Shogi.Api.Identity;
using Shogi.Api.Repositories;
using Shogi.Api.Repositories.Dto;
using Shogi.Contracts.Api;
using Shogi.Domain.Aggregates;
using System.Data.SqlClient;
namespace Shogi.Api.Application;
public class ShogiApplication(
QueryRepository queryRepository,
SessionRepository sessionRepository,
UserManager<ShogiUser> userManager,
GameHubContext gameHubContext)
{
public async Task<IActionResult> CreateSession(string playerId)
{
var session = new Session(Guid.NewGuid(), playerId);
try
{
await sessionRepository.CreateSession(session);
return new CreatedAtActionResult(
nameof(SessionsController.GetSession),
null,
new { sessionId = session.Id.ToString() },
session.Id.ToString());
}
catch (SqlException)
{
return new ConflictResult();
}
}
public async Task<IEnumerable<SessionDto>> ReadAllSessionMetadatas(string playerId)
{
return await queryRepository.ReadSessionsMetadata(playerId);
}
public async Task<Session?> ReadSession(string id)
{
var (sessionDto, moveDtos) = await sessionRepository.ReadSessionAndMoves(id);
if (!sessionDto.HasValue)
{
return null;
}
var session = new Session(Guid.Parse(sessionDto.Value.Id), sessionDto.Value.Player1Id);
if (!string.IsNullOrWhiteSpace(sessionDto.Value.Player2Id)) session.AddPlayer2(sessionDto.Value.Player2Id);
foreach (var move in moveDtos)
{
if (move.PieceFromHand.HasValue)
{
session.Board.Move(move.PieceFromHand.Value, move.To);
}
else if (move.From != null)
{
session.Board.Move(move.From, move.To, false);
}
else
{
throw new InvalidOperationException($"Corrupt data during {nameof(ReadSession)}");
}
}
return session;
}
public async Task<IActionResult> MovePiece(string playerId, string sessionId, MovePieceCommand command)
{
var session = await this.ReadSession(sessionId);
if (session == null)
{
return new NotFoundResult();
}
if (!session.IsSeated(playerId))
{
return new ForbidResult();
}
try
{
if (command.PieceFromHand.HasValue)
{
session.Board.Move(command.PieceFromHand.Value.ToDomain(), command.To);
}
else
{
session.Board.Move(command.From!, command.To, command.IsPromotion ?? false);
}
}
catch (InvalidOperationException e)
{
return new ConflictObjectResult(e.Message);
}
await sessionRepository.CreateMove(sessionId, command);
await gameHubContext.Emit_PieceMoved(sessionId);
return new NoContentResult();
}
public async Task<IActionResult> JoinSession(string sessionId, string player2Id)
{
var session = await this.ReadSession(sessionId);
if (session == null) return new NotFoundResult();
if (string.IsNullOrEmpty(session.Player2))
{
session.AddPlayer2(player2Id);
await sessionRepository.SetPlayer2(sessionId, player2Id);
var player2Email = this.GetUsername(player2Id);
await gameHubContext.Emit_SessionJoined(sessionId);
return new OkResult();
}
return new ConflictObjectResult("This game already has two players.");
}
public string GetUsername(string? userId)
{
if (string.IsNullOrEmpty(userId))
{
return string.Empty;
}
return userManager.Users.FirstOrDefault(u => u.Id == userId)?.UserName!;
}
}

View File

@@ -0,0 +1,74 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Shogi.Api.Identity;
using System.Security.Claims;
namespace Shogi.Api.Controllers;
[Authorize]
[Route("[controller]")]
[ApiController]
public class AccountController(
SignInManager<ShogiUser> signInManager,
UserManager<ShogiUser> UserManager,
IConfiguration configuration) : ControllerBase
{
[Authorize("Admin")]
[HttpPost("TestAccount")]
public async Task<IActionResult> CreateTestAccounts()
{
var newUser = new ShogiUser { UserName = "aat-account", Email = "test-account@lucaserver.space", EmailConfirmed = true };
var newUser2 = new ShogiUser { UserName = "aat-account-2", Email = "test-account2@lucaserver.space", EmailConfirmed = true };
var pass = configuration["TestUserPassword"] ?? throw new InvalidOperationException("TestUserPassword not configured.");
var result = await UserManager.CreateAsync(newUser, pass);
if (result != null && !result.Succeeded)
{
return this.Problem(string.Join(",", result.Errors.Select(e => e.Description)));
}
result = await UserManager.CreateAsync(newUser2, pass);
if(result != null && !result.Succeeded)
{
return this.Problem(string.Join(",", result.Errors.Select(e => e.Description)));
}
return this.Created();
}
[HttpPost("/logout")]
public async Task<IActionResult> Logout([FromBody] object empty)
{
// https://learn.microsoft.com/aspnet/core/blazor/security/webassembly/standalone-with-identity#antiforgery-support
if (empty is not null)
{
await signInManager.SignOutAsync();
return this.Ok();
}
return this.Unauthorized();
}
[HttpGet("/roles")]
public IActionResult GetRoles()
{
if (this.User.Identity is not null && this.User.Identity.IsAuthenticated)
{
var identity = (ClaimsIdentity)this.User.Identity;
var roles = identity.FindAll(identity.RoleClaimType)
.Select(c => new
{
c.Issuer,
c.OriginalIssuer,
c.Type,
c.Value,
c.ValueType
});
return this.Ok(roles);
}
return this.Unauthorized();
}
}

View File

@@ -0,0 +1,11 @@
using System.Security.Claims;
namespace Shogi.Api.Controllers;
public static class Extentions
{
public static string? GetId(this ClaimsPrincipal self)
{
return self.Claims.FirstOrDefault(claim => claim.Type == ClaimTypes.NameIdentifier)?.Value;
}
}

View File

@@ -1,169 +1,123 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Shogi.Api.Application;
using Shogi.Api.Extensions;
using Shogi.Api.Managers;
using Shogi.Api.Identity;
using Shogi.Api.Repositories;
using Shogi.Contracts.Api;
using Shogi.Contracts.Socket;
using Shogi.Contracts.Types;
using System.Data.SqlClient;
using System.Security.Claims;
namespace Shogi.Api.Controllers;
[Authorize]
[ApiController]
[Route("[controller]")]
[Authorize]
public class SessionsController : ControllerBase
public class SessionsController(
SessionRepository sessionRepository,
ShogiApplication application,
SignInManager<ShogiUser> signInManager,
UserManager<ShogiUser> userManager) : ControllerBase
{
private readonly ISocketConnectionManager communicationManager;
private readonly IModelMapper mapper;
private readonly ISessionRepository sessionRepository;
private readonly IQueryRespository queryRespository;
private readonly ILogger<SessionsController> logger;
public SessionsController(
ISocketConnectionManager communicationManager,
IModelMapper mapper,
ISessionRepository sessionRepository,
IQueryRespository queryRespository,
ILogger<SessionsController> logger)
{
this.communicationManager = communicationManager;
this.mapper = mapper;
this.sessionRepository = sessionRepository;
this.queryRespository = queryRespository;
this.logger = logger;
}
[HttpPost]
public async Task<IActionResult> CreateSession()
{
var id = this.User.GetId();
if (string.IsNullOrEmpty(id))
{
return this.Unauthorized();
}
return await application.CreateSession(id);
}
[HttpPost]
public async Task<IActionResult> CreateSession([FromBody] CreateSessionCommand request)
{
var userId = User.GetShogiUserId();
var session = new Domain.Session(request.Name, userId);
try
{
await sessionRepository.CreateSession(session);
}
catch (SqlException e)
{
logger.LogError(exception: e, message: "Uh oh");
return this.Conflict();
}
[HttpDelete("{sessionId}")]
public async Task<IActionResult> DeleteSession(string sessionId)
{
var id = this.User.GetId();
if (id == null)
{
return this.Unauthorized();
}
await communicationManager.BroadcastToAll(new SessionCreatedSocketMessage());
return CreatedAtAction(nameof(CreateSession), new { sessionName = request.Name }, null);
}
var (session, _) = await sessionRepository.ReadSessionAndMoves(sessionId);
if (!session.HasValue) return this.NoContent();
[HttpDelete("{name}")]
public async Task<IActionResult> DeleteSession(string name)
{
var userId = User.GetShogiUserId();
var session = await sessionRepository.ReadSession(name);
if (session.Value.Player1Id == id)
{
await sessionRepository.DeleteSession(sessionId);
return this.NoContent();
}
if (session == null) return this.NoContent();
return this.StatusCode(StatusCodes.Status403Forbidden, "Cannot delete sessions created by others.");
}
if (session.Player1 == userId)
{
await sessionRepository.DeleteSession(name);
return this.NoContent();
}
/// <summary>
/// Fetch the session and latest board state. Also subscribe the user to socket events for this session.
/// </summary>
/// <param name="sessionId"></param>
/// <returns></returns>
[HttpGet("{sessionId}")]
public async Task<ActionResult<Session>> GetSession(Guid sessionId)
{
var session = await application.ReadSession(sessionId.ToString());
if (session == null) return this.NotFound();
return this.StatusCode(StatusCodes.Status403Forbidden, "Cannot delete sessions created by others.");
}
return new Session
{
BoardState = new BoardState
{
Board = session.Board.BoardState.State.ToContract(),
Player1Hand = session.Board.BoardState.Player1Hand.ToContract(),
Player2Hand = session.Board.BoardState.Player2Hand.ToContract(),
PlayerInCheck = session.Board.BoardState.InCheck?.ToContract(),
WhoseTurn = session.Board.BoardState.WhoseTurn.ToContract()
},
Player1 = application.GetUsername(session.Player1),
Player2 = application.GetUsername(session.Player2),
SessionId = session.Id
};
}
[HttpGet("PlayerCount")]
public async Task<ActionResult<ReadSessionsPlayerCountResponse>> GetSessionsPlayerCount()
{
return Ok(await this.queryRespository.ReadSessionPlayerCount(this.User.GetShogiUserId()));
}
[HttpGet()]
public async Task<ActionResult<SessionMetadata[]>> ReadAllSessionsMetadata()
{
var id = this.User.GetId();
if (id == null) return this.Unauthorized();
/// <summary>
/// Fetch the session and latest board state. Also subscribe the user to socket events for this session.
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
[HttpGet("{name}")]
public async Task<ActionResult<ReadSessionResponse>> GetSession(string name)
{
var session = await sessionRepository.ReadSession(name);
if (session == null) return this.NotFound();
var dtos = await application.ReadAllSessionMetadatas(id);
return dtos
.Select(dto => new SessionMetadata
{
Player1 = application.GetUsername(dto.Player1Id),
Player2 = application.GetUsername(dto.Player2Id),
SessionId = Guid.Parse(dto.Id),
})
.ToArray();
}
var players = await queryRespository.GetUsersForSession(session.Name);
if (players == null) return this.NotFound();
[HttpPatch("{sessionId}/Join")]
public async Task<IActionResult> JoinSession(string sessionId)
{
var id = this.User.GetId();
if (id == null)
{
return this.Unauthorized();
}
return new ReadSessionResponse
{
Session = new Session
{
BoardState = new BoardState
{
Board = session.Board.BoardState.State.ToContract(),
Player1Hand = session.Board.BoardState.Player1Hand.ToContract(),
Player2Hand = session.Board.BoardState.Player2Hand.ToContract(),
PlayerInCheck = session.Board.BoardState.InCheck?.ToContract(),
WhoseTurn = session.Board.BoardState.WhoseTurn.ToContract()
},
Player1 = players.Value.Player1,
Player2 = players.Value.Player2,
SessionName = session.Name
}
};
}
return await application.JoinSession(sessionId, id);
}
[HttpPatch("{name}/Join")]
public async Task<IActionResult> JoinSession(string name)
{
var session = await sessionRepository.ReadSession(name);
if (session == null) return this.NotFound();
[HttpPatch("{sessionId}/Move")]
public async Task<IActionResult> Move([FromRoute] string sessionId, [FromBody] MovePieceCommand command)
{
var id = this.User.GetId();
if (id == null)
{
return this.Unauthorized();
}
if (string.IsNullOrEmpty(session.Player2))
{
session.AddPlayer2(User.GetShogiUserId());
await sessionRepository.SetPlayer2(name, User.GetShogiUserId());
await communicationManager.BroadcastToAll(new SessionJoinedByPlayerSocketMessage(session.Name));
return this.Ok();
}
return this.Conflict("This game already has two players.");
}
[HttpPatch("{sessionName}/Move")]
public async Task<IActionResult> Move([FromRoute] string sessionName, [FromBody] MovePieceCommand command)
{
var userId = User.GetShogiUserId();
var session = await sessionRepository.ReadSession(sessionName);
if (session == null) return this.NotFound("Shogi session does not exist.");
if (!session.IsSeated(userId)) return this.StatusCode(StatusCodes.Status403Forbidden, "Player is not a member of the Shogi session.");
try
{
if (command.PieceFromHand.HasValue)
{
session.Board.Move(command.PieceFromHand.Value.ToDomain(), command.To);
}
else
{
session.Board.Move(command.From!, command.To, command.IsPromotion ?? false);
}
}
catch (InvalidOperationException e)
{
return this.Conflict(e.Message);
}
await sessionRepository.CreateMove(sessionName, command);
// Send socket message to both players so their clients know that new board state is available.
await communicationManager.BroadcastToPlayers(
new PlayerHasMovedMessage
{
PlayerName = userId,
SessionName = session.Name,
},
session.Player1,
session.Player2);
return this.NoContent();
}
return await application.MovePiece(id, sessionId, command);
}
}

View File

@@ -1,94 +0,0 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Shogi.Api.Extensions;
using Shogi.Api.Managers;
using Shogi.Api.Repositories;
using Shogi.Contracts.Api;
namespace Shogi.Api.Controllers;
[ApiController]
[Route("[controller]")]
[Authorize]
public class UserController : ControllerBase
{
private readonly ISocketTokenCache tokenCache;
private readonly ISocketConnectionManager connectionManager;
private readonly IUserRepository userRepository;
private readonly IShogiUserClaimsTransformer claimsTransformation;
private readonly AuthenticationProperties authenticationProps;
public UserController(
ILogger<UserController> logger,
ISocketTokenCache tokenCache,
ISocketConnectionManager connectionManager,
IUserRepository userRepository,
IShogiUserClaimsTransformer claimsTransformation)
{
this.tokenCache = tokenCache;
this.connectionManager = connectionManager;
this.userRepository = userRepository;
this.claimsTransformation = claimsTransformation;
authenticationProps = new AuthenticationProperties
{
AllowRefresh = true,
IsPersistent = true
};
}
[HttpGet("Token")]
public ActionResult<CreateTokenResponse> GetWebSocketToken()
{
var userId = User.GetShogiUserId();
var displayName = User.GetShogiUserDisplayname();
var token = tokenCache.GenerateToken(userId);
return new CreateTokenResponse
{
DisplayName = displayName,
OneTimeToken = token,
UserId = userId
};
}
/// <summary>
/// </summary>
/// <param name="returnUrl">Used by cookie authentication.</param>
/// <returns></returns>
[AllowAnonymous]
[HttpGet("LoginAsGuest")]
public async Task<IActionResult> GuestLogin([FromQuery] string? returnUrl)
{
var principal = await this.claimsTransformation.CreateClaimsFromGuestPrincipal(User);
if (principal != null)
{
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
authenticationProps
);
}
if (!string.IsNullOrWhiteSpace(returnUrl))
{
return Redirect(returnUrl);
}
return Ok();
}
[HttpPut("GuestLogout")]
public async Task<IActionResult> GuestLogout()
{
var signOutTask = HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
var userId = User?.GetShogiUserId();
if (!string.IsNullOrEmpty(userId))
{
connectionManager.Unsubscribe(userId);
}
await signOutTask;
return Ok();
}
}

View File

@@ -1,39 +0,0 @@
namespace Shogi.Api
{
namespace anonymous_session.Middlewares
{
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Authentication;
using System.Security.Claims;
/// <summary>
/// TODO: Use this example in the guest session logic instead of custom claims.
/// </summary>
public class ExampleAnonymousSessionMiddleware
{
private readonly RequestDelegate _next;
public ExampleAnonymousSessionMiddleware(RequestDelegate next)
{
_next = next;
}
public async System.Threading.Tasks.Task InvokeAsync(HttpContext context)
{
if (!context.User.Identity.IsAuthenticated)
{
if (string.IsNullOrEmpty(context.User.FindFirstValue(ClaimTypes.Anonymous)))
{
var claim = new Claim(ClaimTypes.Anonymous, System.Guid.NewGuid().ToString());
context.User.AddIdentity(new ClaimsIdentity(new[] { claim }));
string scheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme;
await context.SignInAsync(scheme, context.User, new AuthenticationProperties { IsPersistent = false });
}
}
await _next(context);
}
}
}
}

View File

@@ -1,30 +0,0 @@
using Microsoft.Identity.Web;
using System.Security.Claims;
namespace Shogi.Api.Extensions;
public static class ClaimsExtensions
{
// https://learn.microsoft.com/en-us/azure/active-directory/develop/id-tokens#payload-claims
/// <summary>
/// Get Id from claims after applying shogi-specific claims transformations.
/// </summary>
public static string GetShogiUserId(this ClaimsPrincipal self)
{
var id = self.GetNameIdentifierId();
if (string.IsNullOrEmpty(id)) throw new InvalidOperationException("Shogi UserId not found in claims.");
return id;
}
/// <summary>
/// Get display name from claims after applying shogi-specific claims transformations.
/// </summary>
public static string GetShogiUserDisplayname(this ClaimsPrincipal self)
{
var displayName = self.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Name)?.Value;
if (string.IsNullOrEmpty(displayName)) throw new InvalidOperationException("Shogi Display name not found in claims.");
return displayName;
}
}

View File

@@ -1,50 +0,0 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.IO;
using System.Text;
using System.Threading.Tasks;
namespace Shogi.Api.Extensions
{
public class LogMiddleware
{
private readonly RequestDelegate next;
private readonly ILogger logger;
public LogMiddleware(RequestDelegate next, ILoggerFactory factory)
{
this.next = next;
logger = factory.CreateLogger<LogMiddleware>();
}
public async Task Invoke(HttpContext context)
{
try
{
await next(context);
}
finally
{
using var stream = new MemoryStream();
context.Request?.Body.CopyToAsync(stream);
logger.LogInformation("Request {method} {url} => {statusCode} \n Body: {body}",
context.Request?.Method,
context.Request?.Path.Value,
context.Response?.StatusCode,
Encoding.UTF8.GetString(stream.ToArray()));
}
}
}
public static class IApplicationBuilderExtensions
{
public static IApplicationBuilder UseRequestResponseLogging(this IApplicationBuilder builder)
{
builder.UseMiddleware<LogMiddleware>();
return builder;
}
}
}

View File

@@ -0,0 +1,8 @@
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace Shogi.Api.Identity;
public class ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : IdentityDbContext<ShogiUser>(options)
{
}

View File

@@ -0,0 +1,7 @@
using Microsoft.AspNetCore.Identity;
namespace Shogi.Api.Identity;
public class ShogiUser : IdentityUser
{
}

View File

@@ -1,86 +0,0 @@
using Shogi.Contracts.Types;
using DomainWhichPiece = Shogi.Domain.ValueObjects.WhichPiece;
using DomainWhichPlayer = Shogi.Domain.ValueObjects.WhichPlayer;
using Piece = Shogi.Contracts.Types.Piece;
namespace Shogi.Api.Managers
{
public class ModelMapper : IModelMapper
{
public WhichPlayer Map(DomainWhichPlayer whichPlayer)
{
return whichPlayer switch
{
DomainWhichPlayer.Player1 => WhichPlayer.Player1,
DomainWhichPlayer.Player2 => WhichPlayer.Player2,
_ => throw new ArgumentException("Unrecognized value for WhichPlayer", nameof(whichPlayer))
};
}
public WhichPlayer? Map(DomainWhichPlayer? whichPlayer)
{
return whichPlayer.HasValue
? Map(whichPlayer.Value)
: null;
}
public WhichPiece Map(DomainWhichPiece whichPiece)
{
return whichPiece switch
{
DomainWhichPiece.King => WhichPiece.King,
DomainWhichPiece.GoldGeneral => WhichPiece.GoldGeneral,
DomainWhichPiece.SilverGeneral => WhichPiece.SilverGeneral,
DomainWhichPiece.Bishop => WhichPiece.Bishop,
DomainWhichPiece.Rook => WhichPiece.Rook,
DomainWhichPiece.Knight => WhichPiece.Knight,
DomainWhichPiece.Lance => WhichPiece.Lance,
DomainWhichPiece.Pawn => WhichPiece.Pawn,
_ => throw new ArgumentException("Unrecognized value", nameof(whichPiece)),
};
}
public DomainWhichPiece Map(WhichPiece whichPiece)
{
return whichPiece switch
{
WhichPiece.King => DomainWhichPiece.King,
WhichPiece.GoldGeneral => DomainWhichPiece.GoldGeneral,
WhichPiece.SilverGeneral => DomainWhichPiece.SilverGeneral,
WhichPiece.Bishop => DomainWhichPiece.Bishop,
WhichPiece.Rook => DomainWhichPiece.Rook,
WhichPiece.Knight => DomainWhichPiece.Knight,
WhichPiece.Lance => DomainWhichPiece.Lance,
WhichPiece.Pawn => DomainWhichPiece.Pawn,
_ => throw new ArgumentException("Unrecognized value", nameof(whichPiece)),
};
}
public Piece Map(Domain.ValueObjects.Piece piece)
{
return new Piece { IsPromoted = piece.IsPromoted, Owner = Map(piece.Owner), WhichPiece = Map(piece.WhichPiece) };
}
public Dictionary<string, Piece?> Map(IDictionary<string, Domain.ValueObjects.Piece?> boardState)
{
return boardState.ToDictionary(kvp => kvp.Key.ToUpper(), kvp => MapNullable(kvp.Value));
}
public Piece? MapNullable(Domain.ValueObjects.Piece? piece)
{
if (piece == null) return null;
return Map(piece);
}
}
public interface IModelMapper
{
WhichPlayer Map(DomainWhichPlayer whichPlayer);
WhichPlayer? Map(DomainWhichPlayer? whichPlayer);
WhichPiece Map(DomainWhichPiece whichPiece);
DomainWhichPiece Map(WhichPiece value);
Piece Map(Domain.ValueObjects.Piece p);
Piece? MapNullable(Domain.ValueObjects.Piece? p);
Dictionary<string, Piece?> Map(IDictionary<string, Domain.ValueObjects.Piece?> boardState);
}
}

View File

@@ -1,89 +0,0 @@
using Shogi.Contracts.Socket;
using Shogi.Api.Extensions;
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text.Json;
namespace Shogi.Api.Managers;
public interface ISocketConnectionManager
{
Task BroadcastToAll(ISocketMessage response);
void Subscribe(WebSocket socket, string playerName);
void Unsubscribe(string playerName);
Task BroadcastToPlayers(ISocketMessage response, params string?[] playerNames);
}
/// <summary>
/// Retains all active socket connections and provides convenient methods for sending messages to clients.
/// </summary>
public class SocketConnectionManager : ISocketConnectionManager
{
/// <summary>Dictionary key is player name.</summary>
private readonly ConcurrentDictionary<string, WebSocket> connections;
private readonly JsonSerializerOptions serializeOptions;
/// <summary>Dictionary key is game name.</summary>
private readonly ILogger<SocketConnectionManager> logger;
public SocketConnectionManager(ILogger<SocketConnectionManager> logger)
{
this.logger = logger;
this.connections = new ConcurrentDictionary<string, WebSocket>();
this.serializeOptions = new JsonSerializerOptions(JsonSerializerDefaults.General);
}
public void Subscribe(WebSocket socket, string playerName)
{
connections.TryRemove(playerName, out var _);
connections.TryAdd(playerName, socket);
}
public void Unsubscribe(string playerName)
{
connections.TryRemove(playerName, out _);
}
public async Task BroadcastToPlayers(ISocketMessage response, params string?[] playerNames)
{
var tasks = new List<Task>(playerNames.Length);
foreach (var name in playerNames)
{
if (!string.IsNullOrEmpty(name) && connections.TryGetValue(name, out var socket))
{
var serialized = Serialize(response);
logger.LogInformation("Response to {0} \n{1}\n", name, serialized);
tasks.Add(socket.SendTextAsync(serialized));
}
}
await Task.WhenAll(tasks);
}
public Task BroadcastToAll(ISocketMessage response)
{
var message = Serialize(response);
logger.LogInformation("Broadcasting:\n{0}\nDone Broadcasting.", message);
var tasks = new List<Task>(connections.Count);
foreach (var kvp in connections)
{
var socket = kvp.Value;
try
{
tasks.Add(socket.SendTextAsync(message));
}
catch (WebSocketException)
{
logger.LogInformation("Tried sending a message to socket connection for user [{user}], but found the connection has closed.", kvp.Key);
Unsubscribe(kvp.Key);
}
catch
{
logger.LogInformation("Tried sending a message to socket connection for user [{user}], but found the connection has closed.", kvp.Key);
Unsubscribe(kvp.Key);
}
}
return Task.WhenAll(tasks);
}
private string Serialize(object o) => JsonSerializer.Serialize(o, this.serializeOptions);
}

View File

@@ -1,54 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Shogi.Api.Managers
{
public interface ISocketTokenCache
{
Guid GenerateToken(string s);
string? GetUsername(Guid g);
}
public class SocketTokenCache : ISocketTokenCache
{
/// <summary>
/// Key is userName or webSessionId
/// </summary>
private readonly ConcurrentDictionary<string, Guid> Tokens;
public SocketTokenCache()
{
Tokens = new ConcurrentDictionary<string, Guid>();
}
public Guid GenerateToken(string userName)
{
Tokens.Remove(userName, out _);
var guid = Guid.NewGuid();
Tokens.TryAdd(userName, guid);
_ = Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromMinutes(1));
Tokens.Remove(userName, out _);
}).ConfigureAwait(false);
return guid;
}
/// <returns>User name associated to the guid or null.</returns>
public string? GetUsername(Guid guid)
{
var userName = Tokens.FirstOrDefault(kvp => kvp.Value == guid).Key;
if (userName != null)
{
Tokens.Remove(userName, out _);
}
return userName;
}
}
}

View File

@@ -0,0 +1,279 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Shogi.Api.Identity;
#nullable disable
namespace Shogi.Api.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240816002834_InitialCreate")]
partial class InitialCreate
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("RoleId")
.HasColumnType("nvarchar(450)");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Shogi.Api.Models.User", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,224 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Shogi.Api.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AspNetRoles",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
Name = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetUsers",
columns: table => new
{
Id = table.Column<string>(type: "nvarchar(450)", nullable: false),
UserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedUserName = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
NormalizedEmail = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: true),
EmailConfirmed = table.Column<bool>(type: "bit", nullable: false),
PasswordHash = table.Column<string>(type: "nvarchar(max)", nullable: true),
SecurityStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
ConcurrencyStamp = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhoneNumber = table.Column<string>(type: "nvarchar(max)", nullable: true),
PhoneNumberConfirmed = table.Column<bool>(type: "bit", nullable: false),
TwoFactorEnabled = table.Column<bool>(type: "bit", nullable: false),
LockoutEnd = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
LockoutEnabled = table.Column<bool>(type: "bit", nullable: false),
AccessFailedCount = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AspNetRoleClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
RoleId = table.Column<string>(type: "nvarchar(450)", nullable: false),
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserClaims",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
ClaimType = table.Column<string>(type: "nvarchar(max)", nullable: true),
ClaimValue = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
table.ForeignKey(
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserLogins",
columns: table => new
{
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
ProviderKey = table.Column<string>(type: "nvarchar(450)", nullable: false),
ProviderDisplayName = table.Column<string>(type: "nvarchar(max)", nullable: true),
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
table.ForeignKey(
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserRoles",
columns: table => new
{
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
RoleId = table.Column<string>(type: "nvarchar(450)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
column: x => x.RoleId,
principalTable: "AspNetRoles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "AspNetUserTokens",
columns: table => new
{
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
LoginProvider = table.Column<string>(type: "nvarchar(450)", nullable: false),
Name = table.Column<string>(type: "nvarchar(450)", nullable: false),
Value = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
table.ForeignKey(
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AspNetRoleClaims_RoleId",
table: "AspNetRoleClaims",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "RoleNameIndex",
table: "AspNetRoles",
column: "NormalizedName",
unique: true,
filter: "[NormalizedName] IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserClaims_UserId",
table: "AspNetUserClaims",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserLogins_UserId",
table: "AspNetUserLogins",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AspNetUserRoles_RoleId",
table: "AspNetUserRoles",
column: "RoleId");
migrationBuilder.CreateIndex(
name: "EmailIndex",
table: "AspNetUsers",
column: "NormalizedEmail");
migrationBuilder.CreateIndex(
name: "UserNameIndex",
table: "AspNetUsers",
column: "NormalizedUserName",
unique: true,
filter: "[NormalizedUserName] IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AspNetRoleClaims");
migrationBuilder.DropTable(
name: "AspNetUserClaims");
migrationBuilder.DropTable(
name: "AspNetUserLogins");
migrationBuilder.DropTable(
name: "AspNetUserRoles");
migrationBuilder.DropTable(
name: "AspNetUserTokens");
migrationBuilder.DropTable(
name: "AspNetRoles");
migrationBuilder.DropTable(
name: "AspNetUsers");
}
}
}

View File

@@ -0,0 +1,276 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Shogi.Api.Identity;
#nullable disable
namespace Shogi.Api.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
partial class ApplicationDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex")
.HasFilter("[NormalizedName] IS NOT NULL");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("RoleId")
.HasColumnType("nvarchar(450)");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("nvarchar(450)");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("Shogi.Api.Models.User", b =>
{
b.Property<string>("Id")
.HasColumnType("nvarchar(450)");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex")
.HasFilter("[NormalizedUserName] IS NOT NULL");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("Shogi.Api.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,39 +0,0 @@
using System.Collections.ObjectModel;
namespace Shogi.Api.Models;
public class User
{
public static readonly ReadOnlyCollection<string> Adjectives = new(new[] {
"Fortuitous", "Retractable", "Happy", "Habbitable", "Creative", "Fluffy", "Impervious", "Kingly", "Queenly", "Blushing", "Brave",
"Brainy", "Eager", "Itchy", "Fierce"
});
public static readonly ReadOnlyCollection<string> Subjects = new(new[] {
"Hippo", "Basil", "Mouse", "Walnut", "Minstrel", "Lima Bean", "Koala", "Potato", "Penguin", "Cola", "Banana", "Egg", "Fish", "Yak"
});
public static User CreateMsalUser(string id, string displayName) => new(id, displayName, WhichLoginPlatform.Microsoft);
public static User CreateGuestUser(string id)
{
var random = new Random();
// Adjective
var index = (int)Math.Floor(random.NextDouble() * Adjectives.Count);
var adj = Adjectives[index];
// Subject
index = (int)Math.Floor(random.NextDouble() * Subjects.Count);
var subj = Subjects[index];
return new User(id, $"{adj} {subj}", WhichLoginPlatform.Guest);
}
public string Id { get; }
public string DisplayName { get; }
public WhichLoginPlatform LoginPlatform { get; }
public User(string id, string displayName, WhichLoginPlatform platform)
{
Id = id;
DisplayName = displayName;
LoginPlatform = platform;
}
}

View File

@@ -1,9 +0,0 @@
namespace Shogi.Api.Models
{
public enum WhichLoginPlatform
{
Unknown,
Microsoft,
Guest
}
}

View File

@@ -1,232 +1,106 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Http.Json;
using Microsoft.AspNetCore.HttpLogging;
using Microsoft.Identity.Web;
using Microsoft.OpenApi.Models;
using Shogi.Api.Managers;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.EntityFrameworkCore;
using Shogi.Api;
using Shogi.Api.Application;
using Shogi.Api.Identity;
using Shogi.Api.Repositories;
using Shogi.Api.Services;
namespace Shogi.Api
var builder = WebApplication.CreateBuilder(args);
var allowedOrigins = builder
.Configuration
.GetSection("Cors:AllowedOrigins")
.Get<string[]>() ?? throw new InvalidOperationException("Configuration for allowed origins is missing.");
builder.Services
.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.WriteIndented = true;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddTransient<SessionRepository>();
builder.Services.AddTransient<QueryRepository>();
builder.Services.AddTransient<ShogiApplication>();
builder.Services.AddTransient<GameHubContext>();
builder.Services.AddHttpClient<IEmailSender, EmailSender>();
builder.Services.Configure<ApiKeys>(builder.Configuration.GetSection("ApiKeys"));
AddIdentity(builder, builder.Configuration);
builder.Services.AddSignalR();
builder.Services.AddResponseCompression(opts =>
{
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/octet-stream"]);
});
var app = builder.Build();
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? throw new InvalidOperationException("Configuration for allowed origins is missing.");
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy
.WithOrigins(allowedOrigins)
.SetIsOriginAllowedToAllowWildcardSubdomains()
.WithExposedHeaders("Set-Cookie")
.AllowAnyHeader()
.AllowAnyMethod()
.AllowCredentials();
});
});
ConfigureAuthentication(builder);
ConfigureControllers(builder);
ConfigureSwagger(builder);
ConfigureDependencyInjection(builder);
ConfigureLogging(builder);
app.MapIdentityApi<ShogiUser>();
var app = builder.Build();
app.UseWhen(
// Log anything that isn't related to swagger.
context => IsNotSwaggerUI(context),
appBuilder => appBuilder.UseHttpLogging());
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseHttpsRedirection(); // Apache handles HTTPS in production.
}
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.OAuthScopes("api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope");
options.OAuthConfigObject.ClientId = builder.Configuration["AzureAd:SwaggerUIClientId"];
options.OAuthConfigObject.UsePkceWithAuthorizationCodeGrant = true;
});
UseCorsAndWebSockets(app, allowedOrigins);
app.UseAuthentication();
app.UseAuthorization();
app.Map("/", () => "OK");
app.MapControllers();
app.Run();
static bool IsNotSwaggerUI(HttpContext context)
{
var path = context.Request.GetEncodedPathAndQuery();
return !path.Contains("swagger")
&& !path.Equals("/", StringComparison.Ordinal);
}
}
private static void UseCorsAndWebSockets(WebApplication app, string[] allowedOrigins)
{
// TODO: Figure out how to make a middleware for sockets?
var socketService = app.Services.GetRequiredService<ISocketService>();
var socketOptions = new WebSocketOptions();
foreach (var origin in allowedOrigins)
socketOptions.AllowedOrigins.Add(origin);
app.UseCors();
app.UseWebSockets(socketOptions);
app.Use(async (context, next) =>
{
if (context.WebSockets.IsWebSocketRequest)
{
await socketService.HandleSocketRequest(context);
}
else
{
await next();
}
});
}
private static void ConfigureLogging(WebApplicationBuilder builder)
{
builder.Services.AddHttpLogging(options =>
{
options.LoggingFields = HttpLoggingFields.RequestProperties
| HttpLoggingFields.RequestBody
| HttpLoggingFields.ResponseStatusCode
| HttpLoggingFields.ResponseBody;
});
}
private static void ConfigureAuthentication(WebApplicationBuilder builder)
{
AddJwtAuth(builder);
AddCookieAuth(builder);
SetupAuthSwitch(builder);
static void AddJwtAuth(WebApplicationBuilder builder)
{
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApi(builder.Configuration.GetSection("AzureAd"));
}
static void AddCookieAuth(WebApplicationBuilder builder)
{
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = "session-id";
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.SlidingExpiration = true;
options.LoginPath = new PathString("/User/LoginAsGuest");
});
}
static void SetupAuthSwitch(WebApplicationBuilder builder)
{
var defaultScheme = "CookieOrJwt";
builder.Services
.AddAuthentication(defaultScheme)
.AddPolicyScheme("CookieOrJwt", "Either cookie or jwt", options =>
{
options.ForwardDefaultSelector = context =>
{
var bearerAuth = context.Request.Headers["Authorization"].FirstOrDefault()?.StartsWith("Bearer ") ?? false;
return bearerAuth
? JwtBearerDefaults.AuthenticationScheme
: CookieAuthenticationDefaults.AuthenticationScheme;
};
});
builder
.Services
.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = defaultScheme;
});
}
}
private static void ConfigureControllers(WebApplicationBuilder builder)
{
builder.Services.AddControllers();
builder.Services.Configure<JsonOptions>(options =>
{
options.SerializerOptions.WriteIndented = true;
});
}
private static void ConfigureDependencyInjection(WebApplicationBuilder builder)
{
var services = builder.Services;
services.AddSingleton<ISocketConnectionManager, SocketConnectionManager>();
services.AddSingleton<ISocketTokenCache, SocketTokenCache>();
services.AddSingleton<ISocketService, SocketService>();
services.AddTransient<IClaimsTransformation, ShogiUserClaimsTransformer>();
services.AddTransient<IShogiUserClaimsTransformer, ShogiUserClaimsTransformer>();
services.AddTransient<IUserRepository, UserRepository>();
services.AddTransient<ISessionRepository, SessionRepository>();
services.AddTransient<IQueryRespository, QueryRepository>();
services.AddTransient<IModelMapper, ModelMapper>();
}
private static void ConfigureSwagger(WebApplicationBuilder builder)
{
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
var bearerKey = "Bearer";
options.AddSecurityDefinition(bearerKey, new OpenApiSecurityScheme
{
Type = SecuritySchemeType.OAuth2,
Flows = new OpenApiOAuthFlows
{
Implicit = new OpenApiOAuthFlow
{
// These urls might be why only my email can login.
// TODO: Try testing with tenantId in the url instead of "common".
AuthorizationUrl = new Uri("https://login.microsoftonline.com/common/oauth2/v2.0/authorize"),
TokenUrl = new Uri("https://login.microsoftonline.com/common/oauth2/v2.0/token"),
Scopes = new Dictionary<string, string>
{
{ "api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope", "Default Scope" },
{ "profile", "profile" },
{ "openid", "openid" }
}
}
},
Scheme = "Bearer",
BearerFormat = "JWT",
In = ParameterLocation.Header,
});
// This adds the lock symbol next to every route in SwaggerUI.
options.AddSecurityRequirement(new OpenApiSecurityRequirement
{
{
new OpenApiSecurityScheme{ Reference = new OpenApiReference{ Type = ReferenceType.SecurityScheme, Id = bearerKey } },
Array.Empty<string>()
}
});
});
}
}
if (app.Environment.IsDevelopment())
{
app.UseHttpsRedirection(); // Apache handles HTTPS in production.
}
else
{
app.UseResponseCompression();
}
app.UseSwagger();
app.UseSwaggerUI(options => options.DocumentTitle = "Shogi.Api");
app.UseAuthorization();
app.Map("/", () => "OK");
app.MapControllers();
app.UseCors(policy =>
{
policy.WithOrigins(allowedOrigins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
});
app.MapHub<GameHub>("/gamehub").RequireAuthorization();
app.Run();
static void AddIdentity(WebApplicationBuilder builder, ConfigurationManager configuration)
{
builder.Services
.AddAuthorizationBuilder()
.AddPolicy("Admin", policy =>
{
policy.RequireAuthenticatedUser();
policy.RequireAssertion(context => context.User?.Identity?.Name switch
{
"Hauth@live.com" => true,
"aat-account" => true,
_ => false
});
});
builder.Services
.AddDbContext<ApplicationDbContext>(options =>
{
var cs = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("Database not configured.");
options.UseSqlServer(cs);
// This is helpful to debug account issues without affecting the database.
//options.UseInMemoryDatabase("AppDb");
})
.AddIdentityApiEndpoints<ShogiUser>(options =>
{
options.SignIn.RequireConfirmedEmail = true;
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>();
// I shouldn't this because I have it above, right?
//builder.Services.Configure<IdentityOptions>(options =>
//{
// options.SignIn.RequireConfirmedEmail = true;
// options.User.RequireUniqueEmail = true;
//});
builder.Services.ConfigureApplicationCookie(options =>
{
options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(3);
});
}

View File

@@ -1,5 +1,5 @@
namespace Shogi.Api.Repositories.Dto;
public readonly record struct SessionDto(string Name, string Player1, string Player2)
public readonly record struct SessionDto(string Id, string Player1Id, string Player2Id)
{
}

View File

@@ -0,0 +1,54 @@
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.Extensions.Options;
using System.Net.Http.Headers;
using System.Text.Json;
namespace Shogi.Api.Repositories;
// https://app-smtp.brevo.com/real-time
public class EmailSender : IEmailSender
{
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
private readonly HttpClient client;
private string apiKey;
public EmailSender(HttpClient client, IOptionsMonitor<ApiKeys> apiKeys)
{
this.apiKey = apiKeys.CurrentValue.BrevoEmailService;
apiKeys.OnChange(keys => this.apiKey = keys.BrevoEmailService);
this.client = client;
}
public async Task SendEmailAsync(string email, string subject, string htmlMessage)
{
var body = new
{
Sender = new
{
Name = "Shogi Account Support",
Email = "shogi@lucaserver.space",
},
To = new[]
{
new
{
Name = email,
Email = email,
}
},
Subject = subject,
HtmlContent = htmlMessage,
};
var request = new HttpRequestMessage(HttpMethod.Post, new Uri("https://api.brevo.com/v3/smtp/email", UriKind.Absolute));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Add("api-key", apiKey);
request.Content = JsonContent.Create(body, options: Options);
var response = await this.client.SendAsync(request);
response.EnsureSuccessStatusCode();
}
}

View File

@@ -1,65 +1,24 @@
using Dapper;
using Shogi.Contracts.Api;
using Shogi.Contracts.Types;
using Shogi.Api.Repositories.Dto;
using System.Data;
using System.Data.SqlClient;
namespace Shogi.Api.Repositories;
public class QueryRepository : IQueryRespository
public class QueryRepository(IConfiguration configuration)
{
private readonly string connectionString;
private readonly string connectionString = configuration.GetConnectionString("ShogiDatabase")
?? throw new InvalidOperationException("No database configured for QueryRepository.");
public QueryRepository(IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("No database configured for QueryRepository.");
this.connectionString = connectionString;
}
public async Task<IEnumerable<SessionDto>> ReadSessionsMetadata(string playerId)
{
using var connection = new SqlConnection(this.connectionString);
public async Task<ReadSessionsPlayerCountResponse> ReadSessionPlayerCount(string playerName)
{
using var connection = new SqlConnection(connectionString);
var results = await connection.QueryMultipleAsync(
"session.ReadSessionsMetadata",
new { PlayerId = playerId },
commandType: CommandType.StoredProcedure);
var results = await connection.QueryMultipleAsync(
"session.ReadSessionPlayerCount",
new { PlayerName = playerName },
commandType: System.Data.CommandType.StoredProcedure);
var joinedSessions = await results.ReadAsync<SessionMetadata>();
var otherSessions = await results.ReadAsync<SessionMetadata>();
return new ReadSessionsPlayerCountResponse
{
PlayerHasJoinedSessions = joinedSessions.ToList(),
AllOtherSessions = otherSessions.ToList()
};
}
/// <summary>
/// </summary>
/// <returns>A <see cref="ValueTuple"/> with Item1 as player 1 and Item2 as player 2.</returns>
public async Task<(User Player1, User? Player2)?> GetUsersForSession(string sessionName)
{
using var connection = new SqlConnection(connectionString);
var results = await connection.QueryAsync<(string Player1Name, string Player1DisplayName, string Player2Name, string Player2DisplayName)>(
"session.ReadUsersBySession",
new { SessionName = sessionName },
commandType: CommandType.StoredProcedure);
if (results.Any())
{
var (Player1Name, Player1DisplayName, Player2Name, Player2DisplayName) = results.First();
var p1 = new User(Player1Name, Player1DisplayName);
var p2 = Player2Name != null
? new User(Player2Name, Player2DisplayName)
: null;
return (p1, p2);
}
return null;
}
return await results.ReadAsync<SessionDto>();
}
}
public interface IQueryRespository
{
Task<(User Player1, User? Player2)?> GetUsersForSession(string sessionName);
Task<ReadSessionsPlayerCountResponse> ReadSessionPlayerCount(string playerName);
}

View File

@@ -1,120 +1,84 @@
using Dapper;
using Shogi.Api.Repositories.Dto;
using Shogi.Contracts.Api;
using Shogi.Domain;
using Shogi.Domain.Aggregates;
using System.Data;
using System.Data.SqlClient;
namespace Shogi.Api.Repositories;
public class SessionRepository : ISessionRepository
public class SessionRepository(IConfiguration configuration)
{
private readonly string connectionString;
private readonly string connectionString = configuration.GetConnectionString("ShogiDatabase")
?? throw new InvalidOperationException("Database connection string not configured.");
public SessionRepository(IConfiguration configuration)
{
connectionString = configuration.GetConnectionString("ShogiDatabase") ?? throw new InvalidOperationException("Database connection string not configured.");
}
public async Task CreateSession(Session session)
{
using var connection = new SqlConnection(this.connectionString);
await connection.ExecuteAsync(
"session.CreateSession",
new
{
session.Id,
Player1Id = session.Player1,
},
commandType: CommandType.StoredProcedure);
}
public async Task CreateSession(Session session)
{
using var connection = new SqlConnection(connectionString);
await connection.ExecuteAsync(
"session.CreateSession",
new
{
session.Name,
Player1Name = session.Player1,
},
commandType: CommandType.StoredProcedure);
}
public async Task DeleteSession(string id)
{
using var connection = new SqlConnection(this.connectionString);
await connection.ExecuteAsync(
"session.DeleteSession",
new { Id = id },
commandType: CommandType.StoredProcedure);
}
public async Task DeleteSession(string name)
{
using var connection = new SqlConnection(connectionString);
await connection.ExecuteAsync(
"session.DeleteSession",
new { Name = name },
commandType: CommandType.StoredProcedure);
}
public async Task<(SessionDto? Session, IEnumerable<MoveDto> Moves)> ReadSessionAndMoves(string id)
{
using var connection = new SqlConnection(this.connectionString);
var results = await connection.QueryMultipleAsync(
"session.ReadSession",
new { Id = id },
commandType: CommandType.StoredProcedure);
public async Task<Session?> ReadSession(string name)
{
using var connection = new SqlConnection(connectionString);
var results = await connection.QueryMultipleAsync(
"session.ReadSession",
new { Name = name },
commandType: CommandType.StoredProcedure);
var sessionDtos = await results.ReadAsync<SessionDto>();
if (!sessionDtos.Any())
{
return (null, []);
}
var sessionDtos = await results.ReadAsync<SessionDto>();
if (!sessionDtos.Any()) return null;
var dto = sessionDtos.First();
var session = new Session(dto.Name, dto.Player1);
if (!string.IsNullOrWhiteSpace(dto.Player2)) session.AddPlayer2(dto.Player2);
var moveDtos = await results.ReadAsync<MoveDto>();
var moveDtos = await results.ReadAsync<MoveDto>();
foreach (var move in moveDtos)
{
if (move.PieceFromHand.HasValue)
{
session.Board.Move(move.PieceFromHand.Value, move.To);
}
else if (move.From != null)
{
session.Board.Move(move.From, move.To, false);
}
else
{
throw new InvalidOperationException($"Corrupt data during {nameof(ReadSession)}");
}
}
return session;
}
return new(sessionDtos.First(), moveDtos);
}
public async Task CreateMove(string sessionName, MovePieceCommand command)
{
var yep = new
{
command.To,
command.From,
command.IsPromotion,
command.PieceFromHand,
SessionName = sessionName
};
public async Task CreateMove(string sessionId, MovePieceCommand command)
{
using var connection = new SqlConnection(this.connectionString);
await connection.ExecuteAsync(
"session.CreateMove",
new
{
command.To,
command.From,
command.IsPromotion,
PieceFromHand = command.PieceFromHand.ToString(),
SessionId = sessionId
},
commandType: CommandType.StoredProcedure);
}
using var connection = new SqlConnection(connectionString);
await connection.ExecuteAsync(
"session.CreateMove",
new
{
command.To,
command.From,
command.IsPromotion,
PieceFromHand = command.PieceFromHand.ToString(),
SessionName = sessionName
},
commandType: CommandType.StoredProcedure);
}
public async Task SetPlayer2(string sessionName, string player2Name)
{
using var connection = new SqlConnection(connectionString);
await connection.ExecuteAsync(
"session.SetPlayer2",
new
{
SessionName = sessionName,
Player2Name = player2Name
},
commandType: CommandType.StoredProcedure);
}
}
public interface ISessionRepository
{
Task CreateMove(string sessionName, MovePieceCommand command);
Task CreateSession(Session session);
Task DeleteSession(string name);
Task<Session?> ReadSession(string name);
Task SetPlayer2(string sessionName, string player2Name);
public async Task SetPlayer2(string sessionId, string player2Id)
{
using var connection = new SqlConnection(this.connectionString);
await connection.ExecuteAsync(
"session.SetPlayer2",
new
{
SessionId = sessionId,
PlayerId = player2Id
},
commandType: CommandType.StoredProcedure);
}
}

View File

@@ -1,52 +0,0 @@
using Dapper;
using Shogi.Api.Models;
using System.Data;
using System.Data.SqlClient;
namespace Shogi.Api.Repositories;
public class UserRepository : IUserRepository
{
private readonly string connectionString;
public UserRepository(IConfiguration configuration)
{
var connectionString = configuration.GetConnectionString("ShogiDatabase");
if (string.IsNullOrEmpty(connectionString))
{
throw new InvalidOperationException("Connection string for database is empty.");
}
this.connectionString = connectionString;
}
public async Task CreateUser(User user)
{
using var connection = new SqlConnection(connectionString);
await connection.ExecuteAsync(
"user.CreateUser",
new
{
Name = user.Id,
DisplayName = user.DisplayName,
Platform = user.LoginPlatform.ToString()
},
commandType: CommandType.StoredProcedure);
}
public async Task<User?> ReadUser(string id)
{
using var connection = new SqlConnection(connectionString);
var results = await connection.QueryAsync<User>(
"user.ReadUser",
new { Name = id },
commandType: CommandType.StoredProcedure);
return results.FirstOrDefault();
}
}
public interface IUserRepository
{
Task CreateUser(User user);
Task<User?> ReadUser(string id);
}

View File

@@ -1,104 +0,0 @@
using FluentValidation;
using Shogi.Contracts.Socket;
using Shogi.Contracts.Types;
using Shogi.Api.Extensions;
using Shogi.Api.Managers;
using System.Net;
using System.Net.WebSockets;
using System.Text.Json;
namespace Shogi.Api.Services
{
public interface ISocketService
{
Task HandleSocketRequest(HttpContext context);
}
/// <summary>
/// Services a single websocket connection. Authenticates the socket connection, accepts messages, and sends messages.
/// </summary>
public class SocketService : ISocketService
{
private readonly ILogger<SocketService> logger;
private readonly ISocketConnectionManager communicationManager;
private readonly ISocketTokenCache tokenManager;
public SocketService(
ILogger<SocketService> logger,
ISocketConnectionManager communicationManager,
ISocketTokenCache tokenManager) : base()
{
this.logger = logger;
this.communicationManager = communicationManager;
this.tokenManager = tokenManager;
}
public async Task HandleSocketRequest(HttpContext context)
{
if (!context.Request.Query.Keys.Contains("token"))
{
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return;
}
var token = Guid.Parse(context.Request.Query["token"][0] ?? throw new InvalidOperationException("Token expected during socket connection request, but was not sent."));
var userName = tokenManager.GetUsername(token);
if (string.IsNullOrEmpty(userName))
{
context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
return;
}
var socket = await context.WebSockets.AcceptWebSocketAsync();
communicationManager.Subscribe(socket, userName);
// TODO: I probably don't need this while-loop anymore? Perhaps unsubscribe when a disconnect is detected instead.
while (socket.State.HasFlag(WebSocketState.Open))
{
try
{
var message = await socket.ReceiveTextAsync();
if (string.IsNullOrWhiteSpace(message)) continue;
logger.LogInformation("Request \n{0}\n", message);
var request = JsonSerializer.Deserialize<ISocketMessage>(message);
if (request == null || !Enum.IsDefined(typeof(SocketAction), request.Action))
{
await socket.SendTextAsync("Error: Action not recognized.");
continue;
}
switch (request.Action)
{
default:
await socket.SendTextAsync($"Received your message with action {request.Action}, but did no work.");
break;
}
}
catch (OperationCanceledException ex)
{
logger.LogError(ex.Message);
}
catch (WebSocketException ex)
{
logger.LogInformation($"{nameof(WebSocketException)} in {nameof(SocketConnectionManager)}.");
logger.LogInformation("Probably tried writing to a closed socket.");
logger.LogError(ex.Message);
}
communicationManager.Unsubscribe(userName);
if (!socket.State.HasFlag(WebSocketState.Closed) && !socket.State.HasFlag(WebSocketState.Aborted))
{
try
{
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure,
"Socket closed",
CancellationToken.None);
}
catch (Exception ex)
{
Console.WriteLine($"Ignored exception during socket closing. {ex.Message}");
}
}
}
}
}
}

View File

@@ -23,13 +23,15 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.3.0" />
<PackageReference Include="Azure.Identity" Version="1.11.2" />
<PackageReference Include="Dapper" Version="2.1.28" />
<PackageReference Include="FluentValidation" Version="11.9.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.1" />
<PackageReference Include="Microsoft.Identity.Web" Version="2.17.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.8" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="System.Data.SqlClient" Version="4.8.6" />
</ItemGroup>

View File

@@ -1,104 +0,0 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Identity.Web;
using Shogi.Api.Models;
using Shogi.Api.Repositories;
using System.Security.Claims;
namespace Shogi.Api;
/// <summary>
/// Standardizes the claims from third party issuers. Also registers new msal users in the database.
/// </summary>
public class ShogiUserClaimsTransformer : IShogiUserClaimsTransformer
{
private readonly IUserRepository userRepository;
public ShogiUserClaimsTransformer(IUserRepository userRepository)
{
this.userRepository = userRepository;
}
public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
var newPrincipal = IsMicrosoft(principal)
? await CreateClaimsFromMicrosoftPrincipal(principal)
: await CreateClaimsFromGuestPrincipal(principal);
return newPrincipal;
}
public async Task<ClaimsPrincipal> CreateClaimsFromGuestPrincipal(ClaimsPrincipal principal)
{
var id = GetGuestUserId(principal);
if (string.IsNullOrWhiteSpace(id))
{
var newUser = User.CreateGuestUser(Guid.NewGuid().ToString());
await this.userRepository.CreateUser(newUser);
return new ClaimsPrincipal(CreateClaimsIdentity(newUser));
}
var user = await this.userRepository.ReadUser(id);
if (user == null) throw new UnauthorizedAccessException("Guest account does not exist.");
return new ClaimsPrincipal(CreateClaimsIdentity(user));
}
private async Task<ClaimsPrincipal> CreateClaimsFromMicrosoftPrincipal(ClaimsPrincipal principal)
{
var id = GetMicrosoftUserId(principal);
var displayname = principal.GetDisplayName();
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(displayname))
{
throw new UnauthorizedAccessException("Unknown claim set.");
}
var user = await this.userRepository.ReadUser(id);
if (user == null)
{
user = User.CreateMsalUser(id, displayname);
await this.userRepository.CreateUser(user);
}
return new ClaimsPrincipal(CreateClaimsIdentity(user));
}
private static bool IsMicrosoft(ClaimsPrincipal self)
{
return self.GetObjectId() != null;
}
private static string? GetMicrosoftUserId(ClaimsPrincipal self)
{
return self.GetObjectId();
}
private static string? GetGuestUserId(ClaimsPrincipal self)
{
return self.GetNameIdentifierId();
}
private static ClaimsIdentity CreateClaimsIdentity(User user)
{
var claims = new List<Claim>(4)
{
new Claim(ClaimTypes.NameIdentifier, user.Id),
new Claim(ClaimTypes.Name, user.DisplayName),
};
if (user.LoginPlatform == WhichLoginPlatform.Guest)
{
claims.Add(new Claim(ClaimTypes.Role, "Guest"));
return new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
}
else
{
return new ClaimsIdentity(claims, JwtBearerDefaults.AuthenticationScheme);
}
}
}
public interface IShogiUserClaimsTransformer : IClaimsTransformation
{
Task<ClaimsPrincipal> CreateClaimsFromGuestPrincipal(ClaimsPrincipal principal);
}

View File

@@ -1,9 +1,13 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"ApiKeys": {
"BrevoEmailService": "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"
},
"TestUserPassword": "I'mAToysRUsK1d"
}

View File

@@ -1,29 +1,28 @@
{
"ConnectionStrings": {
"ShogiDatabase": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Shogi;Integrated Security=True;Application Name=Shogi.Api"
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Error",
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information",
"System.Net.Http.HttpClient": "Error"
}
},
"AzureAd": {
"Instance": "https://login.microsoftonline.com/",
"TenantId": "common",
"ClientId": "c1e94676-cab0-42ba-8b6c-9532b8486fff",
"SwaggerUIClientId": "26bf69a4-2af8-4711-bf5b-79f75e20b082",
"Scope": "api://c1e94676-cab0-42ba-8b6c-9532b8486fff/DefaultScope"
},
"Cors": {
"AllowedOrigins": [
"http://localhost:3000",
"https://localhost:3000",
"https://api.lucaserver.space",
"https://lucaserver.space"
]
}
"ConnectionStrings": {
"ShogiDatabase": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=Shogi;Integrated Security=True;Application Name=Shogi.Api"
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Error",
"Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware": "Information",
"System.Net.Http.HttpClient": "Error"
//"Microsoft.AspNetCore.SignalR": "Debug",
//"Microsoft.AspNetCore.Http.Connections": "Debug"
}
},
"ApiKeys": {
"BrevoEmailService": ""
},
"Cors": {
"AllowedOrigins": [
"http://localhost:3000",
"https://localhost:3000",
"https://api.lucaserver.space",
"https://lucaserver.space"
]
},
"TestUserPassword": ""
}