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:
6
Shogi.Api/ApiKeys.cs
Normal file
6
Shogi.Api/ApiKeys.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace Shogi.Api;
|
||||
|
||||
public class ApiKeys
|
||||
{
|
||||
public string BrevoEmailService { get; set; } = string.Empty;
|
||||
}
|
||||
19
Shogi.Api/Application/GameHub.cs
Normal file
19
Shogi.Api/Application/GameHub.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
21
Shogi.Api/Application/GameHubContext.cs
Normal file
21
Shogi.Api/Application/GameHubContext.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
143
Shogi.Api/Application/ShogiApplication.cs
Normal file
143
Shogi.Api/Application/ShogiApplication.cs
Normal 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!;
|
||||
}
|
||||
}
|
||||
74
Shogi.Api/Controllers/AccountController.cs
Normal file
74
Shogi.Api/Controllers/AccountController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
11
Shogi.Api/Controllers/Extentions.cs
Normal file
11
Shogi.Api/Controllers/Extentions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
Shogi.Api/Identity/ApplicationDbContext.cs
Normal file
8
Shogi.Api/Identity/ApplicationDbContext.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
7
Shogi.Api/Identity/ShogiUser.cs
Normal file
7
Shogi.Api/Identity/ShogiUser.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace Shogi.Api.Identity;
|
||||
|
||||
public class ShogiUser : IdentityUser
|
||||
{
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
279
Shogi.Api/Migrations/20240816002834_InitialCreate.Designer.cs
generated
Normal file
279
Shogi.Api/Migrations/20240816002834_InitialCreate.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
224
Shogi.Api/Migrations/20240816002834_InitialCreate.cs
Normal file
224
Shogi.Api/Migrations/20240816002834_InitialCreate.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
276
Shogi.Api/Migrations/ApplicationDbContextModelSnapshot.cs
Normal file
276
Shogi.Api/Migrations/ApplicationDbContextModelSnapshot.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
namespace Shogi.Api.Models
|
||||
{
|
||||
public enum WhichLoginPlatform
|
||||
{
|
||||
Unknown,
|
||||
Microsoft,
|
||||
Guest
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
|
||||
54
Shogi.Api/Repositories/EmailSender.cs
Normal file
54
Shogi.Api/Repositories/EmailSender.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
Reference in New Issue
Block a user