Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion backend/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="OpenIddict.Client" Version="5.8.0" />
<PackageVersion Include="OpenIddict.Client.AspNetCore" Version="5.8.0" />
<PackageVersion Include="OpenIddict.Client.WebIntegration" Version="5.8.0" />
<PackageVersion Include="EFCore.NamingConventions" Version="8.0.3" />
<PackageVersion Include="Microsoft.OpenApi" Version="1.6.22" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
Expand Down Expand Up @@ -37,4 +40,4 @@
<PackageVersion Include="NUnit3TestAdapter" Version="4.6.0" />
<PackageVersion Include="ClosedXML" Version="0.104.2" />
</ItemGroup>
</Project>
</Project>
6 changes: 6 additions & 0 deletions backend/Money.Api/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -43,6 +44,11 @@ public async Task<IActionResult> 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 ?? "неизвестно";
Expand Down
168 changes: 168 additions & 0 deletions backend/Money.Api/Controllers/ExternalAuthController.cs
Original file line number Diff line number Diff line change
@@ -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<ApplicationUser> signInManager,
UserManager<ApplicationUser> 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<IActionResult> 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;
}
}
3 changes: 2 additions & 1 deletion backend/Money.Api/Definitions/AuthorizationDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ public override void ConfigureServices(WebApplicationBuilder builder)
policyBuilder => policyBuilder
.WithOrigins(builder.Configuration["CORS_ORIGIN"] ?? "https://localhost:7168")
.AllowAnyMethod()
.AllowAnyHeader());
.AllowAnyHeader()
.AllowCredentials());
});
}

Expand Down
14 changes: 14 additions & 0 deletions backend/Money.Api/Definitions/IdentityDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,19 @@ public override void ConfigureServices(WebApplicationBuilder builder)
})
.AddEntityFrameworkStores<ApplicationDbContext>()
.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;
});
}
}
42 changes: 35 additions & 7 deletions backend/Money.Api/Definitions/OpenIddictDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public override void ConfigureServices(WebApplicationBuilder builder)
var tokenSettings = builder.Configuration.GetSection(nameof(TokenSettings)).Get<TokenSettings>()
?? new TokenSettings();

builder.Services.AddOpenIddict()
var openIddictBuilder = builder.Services.AddOpenIddict()
.AddCore(options =>
{
options.UseEntityFrameworkCore()
Expand All @@ -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);
Expand All @@ -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();
});
}
}
3 changes: 3 additions & 0 deletions backend/Money.Api/Money.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="OpenIddict.Client" />
<PackageReference Include="OpenIddict.Client.AspNetCore" />
<PackageReference Include="OpenIddict.Client.WebIntegration" />
<PackageReference Include="ClosedXML" />
<PackageReference Include="EFCore.NamingConventions" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" NoWarn="NU1605" />
Expand Down
49 changes: 48 additions & 1 deletion backend/Money.Business/Services/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,41 @@ public async Task<ClaimsIdentity> HandleRefreshTokenGrantAsync(AuthenticateResul
return await CreateClaimsIdentityAsync(user, scopes);
}

public async Task<ClaimsIdentity> 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<Dictionary<string, string>> GetUserInfoAsync(ClaimsPrincipal principal)
{
var userId = principal.GetClaim(OpenIddictConstants.Claims.Subject)
Expand All @@ -100,7 +135,19 @@ public async Task<Dictionary<string, string>> GetUserInfoAsync(ClaimsPrincipal p
throw new PermissionException("Извините, но учетная запись, связанная с этим токеном доступа, больше не существует.");
}

return principal.Claims.ToDictionary(claim => claim.Type, claim => claim.Value, StringComparer.Ordinal);
var result = new Dictionary<string, string>(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<string> GetDestinations(Claim claim)
Expand Down
Loading
Loading