diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 86a241b..024c59c 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -3,6 +3,9 @@ true + + + @@ -37,4 +40,4 @@ - \ No newline at end of file + diff --git a/backend/Money.Api/Controllers/AuthController.cs b/backend/Money.Api/Controllers/AuthController.cs index 74b93c7..0cf2635 100644 --- a/backend/Money.Api/Controllers/AuthController.cs +++ b/backend/Money.Api/Controllers/AuthController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; @@ -43,6 +44,11 @@ public async Task Exchange() var result = await HttpContext.AuthenticateAsync(AuthenticationScheme); identity = await authService.HandleRefreshTokenGrantAsync(result); } + else if (string.Equals(request.GrantType, "external", StringComparison.Ordinal)) + { + var result = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + identity = await authService.HandleExternalGrantAsync(result); + } else { var grantType = request.GrantType ?? "неизвестно"; diff --git a/backend/Money.Api/Controllers/ExternalAuthController.cs b/backend/Money.Api/Controllers/ExternalAuthController.cs new file mode 100644 index 0000000..8658e3a --- /dev/null +++ b/backend/Money.Api/Controllers/ExternalAuthController.cs @@ -0,0 +1,168 @@ +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Money.Data.Entities; +using OpenIddict.Abstractions; +using OpenIddict.Client.AspNetCore; +using System.Security.Claims; +using System.Text; +using System.Text.RegularExpressions; + +namespace Money.Api.Controllers; + +[ApiController] +[Route("external")] +public class ExternalAuthController( + SignInManager signInManager, + UserManager userManager, + AccountsService accountsService) : ControllerBase +{ + [HttpGet("login/github")] + public IActionResult LoginWithGitHub([FromQuery] string? returnUrl = null) + { + var properties = new AuthenticationProperties { RedirectUri = Url.Content("~/connect/callback") }; + properties.SetString(OpenIddictClientAspNetCoreConstants.Properties.ProviderName, "GitHub"); + + if (string.IsNullOrWhiteSpace(returnUrl) == false) + { + properties.Items["returnUrl"] = returnUrl; + } + + return Challenge(properties, OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); + } + + [HttpGet("~/connect/callback")] + public async Task Callback() + { + var result = await HttpContext.AuthenticateAsync(OpenIddictClientAspNetCoreDefaults.AuthenticationScheme); + var principal = result.Principal; + + if (principal == null) + { + return BadRequest("Не удалось аутентифицировать внешний провайдер."); + } + + var userId = principal.FindFirst(OpenIddictConstants.Claims.Subject)?.Value + ?? principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + // TODO: Не работает + var email = principal.FindFirst(ClaimTypes.Email)?.Value; + var userNameCandidate = BuildValidUserName(email, userId); + + var providerName = result.Properties?.GetString(OpenIddictClientAspNetCoreConstants.Properties.ProviderName) ?? "GitHub"; + + var user = userId != null ? await userManager.FindByLoginAsync(providerName, userId) : null; + + if (user == null && email != null) + { + user = await userManager.FindByEmailAsync(email); + } + + if (user == null) + { + var uniqueUserName = userNameCandidate; + var existing = await userManager.FindByNameAsync(uniqueUserName); + + if (existing != null) + { + uniqueUserName = $"{uniqueUserName}-{Guid.NewGuid().ToString("N")[..6]}"; + } + + user = new() + { + UserName = uniqueUserName, + Email = email, + EmailConfirmed = email != null + }; + + var createResult = await userManager.CreateAsync(user); + + if (createResult.Succeeded == false) + { + return BadRequest(string.Join("; ", createResult.Errors.Select(e => e.Description))); + } + + await accountsService.EnsureUserIdAsync(user.Id); + } + + if (userId != null) + { + var logins = await userManager.GetLoginsAsync(user); + + if (logins.Any(x => x.LoginProvider == providerName && x.ProviderKey == userId) == false) + { + var addLoginResult = await userManager.AddLoginAsync(user, new(providerName, userId, providerName)); + + if (addLoginResult.Succeeded == false) + { + return BadRequest(string.Join("; ", addLoginResult.Errors.Select(e => e.Description))); + } + } + } + + await signInManager.SignInAsync(user, true); + + var props = result.Properties ?? throw new InvalidOperationException("ReturnUrl отсутствует в контексте авторизации."); + + if (props.Items.TryGetValue("returnUrl", out var redirectUri) == false || string.IsNullOrWhiteSpace(redirectUri)) + { + throw new InvalidOperationException("ReturnUrl отсутствует в контексте авторизации."); + } + + return Redirect(redirectUri); + } + + [HttpGet("denied")] + public IActionResult AccessDenied() + { + return Forbid(CookieAuthenticationDefaults.AuthenticationScheme); + } + + // TODO: Переделать на нормальный запрос у пользователя + private static string BuildValidUserName(string? email, string? subject) + { + string baseName; + + if (string.IsNullOrWhiteSpace(email) == false) + { + var at = email.IndexOf('@'); + baseName = at > 0 ? email[..at] : email; + } + else if (string.IsNullOrWhiteSpace(subject) == false) + { + baseName = $"gh_{subject}"; + } + else + { + baseName = $"gh_{Guid.NewGuid().ToString("N")[..8]}"; + } + + var builder = new StringBuilder(baseName.Length); + + foreach (var ch in baseName) + { + if (char.IsLetterOrDigit(ch)) + { + builder.Append(char.ToLowerInvariant(ch)); + } + else if (ch == '-' || ch == '_') + { + builder.Append(ch); + } + else if (char.IsWhiteSpace(ch)) + { + builder.Append('-'); + } + } + + var cleaned = Regex.Replace(builder.ToString(), "[-_]{2,}", "-").Trim('-', '_'); + + if (string.IsNullOrEmpty(cleaned)) + { + cleaned = $"gh_{Guid.NewGuid().ToString("N")[..8]}"; + } + + return cleaned; + } +} diff --git a/backend/Money.Api/Definitions/AuthorizationDefinition.cs b/backend/Money.Api/Definitions/AuthorizationDefinition.cs index b1a17c2..3d8a884 100644 --- a/backend/Money.Api/Definitions/AuthorizationDefinition.cs +++ b/backend/Money.Api/Definitions/AuthorizationDefinition.cs @@ -10,7 +10,8 @@ public override void ConfigureServices(WebApplicationBuilder builder) policyBuilder => policyBuilder .WithOrigins(builder.Configuration["CORS_ORIGIN"] ?? "https://localhost:7168") .AllowAnyMethod() - .AllowAnyHeader()); + .AllowAnyHeader() + .AllowCredentials()); }); } diff --git a/backend/Money.Api/Definitions/IdentityDefinition.cs b/backend/Money.Api/Definitions/IdentityDefinition.cs index 0b716e9..1ff4204 100644 --- a/backend/Money.Api/Definitions/IdentityDefinition.cs +++ b/backend/Money.Api/Definitions/IdentityDefinition.cs @@ -27,5 +27,19 @@ public override void ConfigureServices(WebApplicationBuilder builder) }) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); + + builder.Services.ConfigureApplicationCookie(options => + { + options.LoginPath = "/external/login"; + options.AccessDeniedPath = "/external/denied"; + options.Cookie.SameSite = SameSiteMode.None; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + }); + + builder.Services.ConfigureExternalCookie(options => + { + options.Cookie.SameSite = SameSiteMode.None; + options.Cookie.SecurePolicy = CookieSecurePolicy.Always; + }); } } diff --git a/backend/Money.Api/Definitions/OpenIddictDefinition.cs b/backend/Money.Api/Definitions/OpenIddictDefinition.cs index d044b19..73a948c 100644 --- a/backend/Money.Api/Definitions/OpenIddictDefinition.cs +++ b/backend/Money.Api/Definitions/OpenIddictDefinition.cs @@ -12,7 +12,7 @@ public override void ConfigureServices(WebApplicationBuilder builder) var tokenSettings = builder.Configuration.GetSection(nameof(TokenSettings)).Get() ?? new TokenSettings(); - builder.Services.AddOpenIddict() + var openIddictBuilder = builder.Services.AddOpenIddict() .AddCore(options => { options.UseEntityFrameworkCore() @@ -29,7 +29,10 @@ public override void ConfigureServices(WebApplicationBuilder builder) .SetCryptographyEndpointUris("connect/jwks"); options.AllowPasswordFlow() - .AllowRefreshTokenFlow(); + .AllowRefreshTokenFlow() + .AllowAuthorizationCodeFlow() + .AllowCustomFlow("external") + .RequireProofKeyForCodeExchange(); options.SetAccessTokenLifetime(tokenSettings.AccessTokenLifetime); options.SetRefreshTokenLifetime(tokenSettings.RefreshTokenLifetime); @@ -46,11 +49,36 @@ public override void ConfigureServices(WebApplicationBuilder builder) .EnableUserinfoEndpointPassthrough() .EnableStatusCodePagesIntegration() .DisableTransportSecurityRequirement(); - }) - .AddValidation(options => - { - options.UseLocalServer(); - options.UseAspNetCore(); }); + + if (builder.Configuration["GITHUB_CLIENT_ID"] is not null && builder.Configuration["GITHUB_CLIENT_SECRET"] is not null) + { + openIddictBuilder + .AddClient(options => + { + options.AllowAuthorizationCodeFlow(); + + options.UseAspNetCore() + .EnableRedirectionEndpointPassthrough(); + + options.AddDevelopmentEncryptionCertificate() + .AddDevelopmentSigningCertificate(); + + options.UseWebProviders() + .AddGitHub(github => + { + github.SetClientId(builder.Configuration["GITHUB_CLIENT_ID"] ?? string.Empty); + github.SetClientSecret(builder.Configuration["GITHUB_CLIENT_SECRET"] ?? string.Empty); + github.SetRedirectUri(new Uri("/connect/callback", UriKind.Relative)); + github.AddScopes("read:user", "user:email"); + }); + }); + } + + openIddictBuilder.AddValidation(options => + { + options.UseLocalServer(); + options.UseAspNetCore(); + }); } } diff --git a/backend/Money.Api/Money.Api.csproj b/backend/Money.Api/Money.Api.csproj index a43121e..657bc71 100644 --- a/backend/Money.Api/Money.Api.csproj +++ b/backend/Money.Api/Money.Api.csproj @@ -10,6 +10,9 @@ + + + diff --git a/backend/Money.Business/Services/AuthService.cs b/backend/Money.Business/Services/AuthService.cs index c17d157..805f474 100644 --- a/backend/Money.Business/Services/AuthService.cs +++ b/backend/Money.Business/Services/AuthService.cs @@ -88,6 +88,41 @@ public async Task HandleRefreshTokenGrantAsync(AuthenticateResul return await CreateClaimsIdentityAsync(user, scopes); } + public async Task HandleExternalGrantAsync(AuthenticateResult result) + { + var nameId = result.Principal?.FindFirstValue(ClaimTypes.NameIdentifier) + ?? result.Principal?.FindFirstValue(OpenIddictConstants.Claims.Subject); + + if (nameId == null) + { + throw new PermissionException("Не удалось получить идентификатор пользователя."); + } + + var user = await userManager.GetUserAsync(result.Principal!) + ?? await userManager.FindByNameAsync(result.Principal!.Identity?.Name ?? string.Empty) + ?? await userManager.FindByEmailAsync(result.Principal!.FindFirstValue(ClaimTypes.Email) ?? string.Empty); + + if (user == null) + { + throw new PermissionException("Извините, но учетная запись пользователя не найдена."); + } + + if (await signInManager.CanSignInAsync(user) == false) + { + throw new PermissionException("Вам больше не разрешено входить в систему."); + } + + var scopes = new[] + { + OpenIddictConstants.Scopes.OfflineAccess, + OpenIddictConstants.Permissions.Scopes.Email, + OpenIddictConstants.Permissions.Scopes.Profile, + OpenIddictConstants.Permissions.Scopes.Roles, + }; + + return await CreateClaimsIdentityAsync(user, scopes); + } + public async Task> GetUserInfoAsync(ClaimsPrincipal principal) { var userId = principal.GetClaim(OpenIddictConstants.Claims.Subject) @@ -100,7 +135,19 @@ public async Task> GetUserInfoAsync(ClaimsPrincipal p throw new PermissionException("Извините, но учетная запись, связанная с этим токеном доступа, больше не существует."); } - return principal.Claims.ToDictionary(claim => claim.Type, claim => claim.Value, StringComparer.Ordinal); + var result = new Dictionary(StringComparer.Ordinal); + + foreach (var group in principal.Claims.GroupBy(x => x.Type, StringComparer.Ordinal)) + { + var uniqueValues = group.Select(x => x.Value) + .Where(x => x != null) + .Distinct(StringComparer.Ordinal); + + var joined = string.Join(' ', uniqueValues); + result[group.Key] = joined; + } + + return result; } private static IEnumerable GetDestinations(Claim claim) diff --git a/frontend/Directory.Packages.props b/frontend/Directory.Packages.props index ba30055..c159980 100644 --- a/frontend/Directory.Packages.props +++ b/frontend/Directory.Packages.props @@ -1,20 +1,20 @@  - - true - - - - - - - - - - - - - - - - - \ No newline at end of file + + true + + + + + + + + + + + + + + + + + diff --git a/frontend/Money.Web/Pages/Account/Callback.razor b/frontend/Money.Web/Pages/Account/Callback.razor new file mode 100644 index 0000000..124a96e --- /dev/null +++ b/frontend/Money.Web/Pages/Account/Callback.razor @@ -0,0 +1,5 @@ +@page "/Account/Callback" +@attribute [AllowAnonymous] + + diff --git a/frontend/Money.Web/Pages/Account/Callback.razor.cs b/frontend/Money.Web/Pages/Account/Callback.razor.cs new file mode 100644 index 0000000..3b868ff --- /dev/null +++ b/frontend/Money.Web/Pages/Account/Callback.razor.cs @@ -0,0 +1,28 @@ +using CSharpFunctionalExtensions; +using Microsoft.AspNetCore.Components; +using Money.Web.Services.Authentication; + +namespace Money.Web.Pages.Account; + +public partial class Callback +{ + [SupplyParameterFromQuery] + private string? ReturnUrl { get; set; } + + [Inject] + private AuthenticationService AuthenticationService { get; set; } = null!; + + [Inject] + private NavigationManager NavigationManager { get; set; } = null!; + + [Inject] + private ISnackbar Snackbar { get; set; } = null!; + + protected override Task OnInitializedAsync() + { + return AuthenticationService.ExchangeExternalAsync() + .TapError(message => Snackbar.Add($"Ошибка во время входа {message}", Severity.Error)) + .Map(() => ReturnUrl ?? "operations") + .Tap(x => NavigationManager.ReturnTo(x)); + } +} diff --git a/frontend/Money.Web/Pages/Account/Login.razor b/frontend/Money.Web/Pages/Account/Login.razor index 281e264..29bdc40 100644 --- a/frontend/Money.Web/Pages/Account/Login.razor +++ b/frontend/Money.Web/Pages/Account/Login.razor @@ -56,6 +56,14 @@ + + + Войти через GitHub + + diff --git a/frontend/Money.Web/Pages/Account/Login.razor.cs b/frontend/Money.Web/Pages/Account/Login.razor.cs index a4b31b9..f1fdb5c 100644 --- a/frontend/Money.Web/Pages/Account/Login.razor.cs +++ b/frontend/Money.Web/Pages/Account/Login.razor.cs @@ -38,6 +38,12 @@ protected override void OnParametersSet() Input = new(); } + private void OnGitHubLogin() + { + var url = AuthenticationService.GetExternalAuthUrl("github", NavigationManager.BaseUri + "Account/Callback"); + NavigationManager.NavigateTo(url, true); + } + private sealed class InputModel { [Required(ErrorMessage = "Login обязателен.")] diff --git a/frontend/Money.Web/Program.cs b/frontend/Money.Web/Program.cs index a282513..5afd689 100644 --- a/frontend/Money.Web/Program.cs +++ b/frontend/Money.Web/Program.cs @@ -10,7 +10,7 @@ using System.Globalization; var builder = WebAssemblyHostBuilder.CreateDefault(args); -var apiUri = new Uri("https+http://api/"); +var apiUri = new Uri($"https://{builder.Configuration["Services:api:https:0"]}"); builder.AddServiceDefaults(); builder.RootComponents.Add("#app"); @@ -21,6 +21,7 @@ configuration.SnackbarConfiguration.PositionClass = Defaults.Classes.Position.BottomRight; configuration.SnackbarConfiguration.PreventDuplicates = false; }); + builder.Services.AddMudTranslations(); builder.Services.AddMemoryCache(); diff --git a/frontend/Money.Web/Services/Authentication/AuthenticationService.cs b/frontend/Money.Web/Services/Authentication/AuthenticationService.cs index f7183d0..05b2215 100644 --- a/frontend/Money.Web/Services/Authentication/AuthenticationService.cs +++ b/frontend/Money.Web/Services/Authentication/AuthenticationService.cs @@ -1,5 +1,6 @@ using Blazored.LocalStorage; using CSharpFunctionalExtensions; +using Microsoft.AspNetCore.Components.WebAssembly.Http; using Money.ApiClient; using System.Net.Http.Json; using System.Text.Json; @@ -45,6 +46,22 @@ public async Task LogoutAsync() return Result.Success(); } + public string GetExternalAuthUrl(string provider, string returnUrl) + { + var encoded = Uri.EscapeDataString(returnUrl); + return client.BaseAddress + $"external/login/{provider}?returnUrl={encoded}"; + } + + public async Task ExchangeExternalAsync() + { + using var requestContent = new FormUrlEncodedContent([new("grant_type", "external")]); + var result = await AuthenticateAsync(requestContent); + + return await result.Tap(() => ((AuthStateProvider)authStateProvider).NotifyUserAuthentication()) + .Map(_ => Result.Success()) + .OnFailureCompensate(_ => Result.Failure("Не удалось завершить внешний вход.")); + } + public async Task> RefreshTokenAsync() { var token = await localStorage.GetItemAsync(AccessTokenKey); @@ -64,7 +81,12 @@ public async Task> RefreshTokenAsync() private async Task> AuthenticateAsync(FormUrlEncodedContent requestContent) { - var response = await client.PostAsync(new Uri("connect/token", UriKind.Relative), requestContent); + using var request = new HttpRequestMessage(HttpMethod.Post, new Uri("connect/token", UriKind.Relative)); + + request.Content = requestContent; + request.SetBrowserRequestCredentials(BrowserRequestCredentials.Include); + + var response = await client.SendAsync(request); client.DefaultRequestHeaders.Clear(); if (response.IsSuccessStatusCode == false)