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)