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

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