Skip to content
This repository was archived by the owner on Aug 24, 2025. It is now read-only.
Open
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
79 changes: 41 additions & 38 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -1,40 +1,43 @@
<Project>
<ItemGroup>
<PackageVersion Include="Meziantou.Analyzer" Version="2.0.212"/>
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15"/>
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0"/>
<PackageVersion Include="AsyncFixer" Version="1.6.0"/>
<PackageVersion Include="Asyncify" Version="0.9.7"/>
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.15.0.120848"/>
<PackageVersion Include="SecurityCodeScan.VS2019" Version="5.6.7"/>
<PackageVersion Include="Roslynator.Analyzers" Version="4.14.0"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Analyzers" Version="9.0.8"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.8"/>
<PackageVersion Include="DNTBreadCrumb.Core" Version="2.0.2"/>
<PackageVersion Include="DNTCaptcha.Core" Version="5.3.1"/>
<PackageVersion Include="DNTCommon.Web.Core" Version="11.8.3"/>
<PackageVersion Include="DNTPersianUtils.Core" Version="6.7.0"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.8"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.8"/>
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.8"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.8"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.8"/>
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.8"/>
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.8"/>
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.8"/>
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.8"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="9.0.8"/>
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="9.0.8"/>
<PackageVersion Include="System.ComponentModel.Annotations" Version="5.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.8"/>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageVersion Include="MSTest.TestAdapter" Version="3.10.2"/>
<PackageVersion Include="MSTest.TestFramework" Version="3.10.2"/>
<PackageVersion Include="Microsoft.Extensions.Caching.SqlServer" Version="9.0.8"/>
<PackageVersion Include="cloudscribe.Web.Pagination" Version="8.4.0"/>
<PackageVersion Include="LigerShark.WebOptimizer.Core" Version="3.0.433"/>
<PackageVersion Include="Microsoft.Web.LibraryManager.Build" Version="3.0.71"/>
</ItemGroup>
<ItemGroup>
<PackageVersion Include="Meziantou.Analyzer" Version="2.0.212"/>
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15"/>
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0"/>
<PackageVersion Include="AsyncFixer" Version="1.6.0"/>
<PackageVersion Include="Asyncify" Version="0.9.7"/>
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.15.0.120848"/>
<PackageVersion Include="SecurityCodeScan.VS2019" Version="5.6.7"/>
<PackageVersion Include="Roslynator.Analyzers" Version="4.14.0"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Analyzers" Version="9.0.8"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.8"/>
<PackageVersion Include="DNTBreadCrumb.Core" Version="2.0.2"/>
<PackageVersion Include="DNTCaptcha.Core" Version="5.3.1"/>
<PackageVersion Include="DNTCommon.Web.Core" Version="11.8.3"/>
<PackageVersion Include="DNTPersianUtils.Core" Version="6.7.0"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="9.0.8"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.8"/>
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.8"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.8"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.8"/>
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.8"/>
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="9.0.8"/>
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.8"/>
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.8"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8"/>
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="9.0.8"/>
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="9.0.8"/>
<PackageVersion Include="System.ComponentModel.Annotations" Version="5.0.0"/>
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.8"/>
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageVersion Include="MSTest.TestAdapter" Version="3.10.2"/>
<PackageVersion Include="MSTest.TestFramework" Version="3.10.2"/>
<PackageVersion Include="Microsoft.Extensions.Caching.SqlServer" Version="9.0.8"/>
<PackageVersion Include="cloudscribe.Web.Pagination" Version="8.4.0"/>
<PackageVersion Include="LigerShark.WebOptimizer.Core" Version="3.0.433"/>
<PackageVersion Include="Microsoft.Web.LibraryManager.Build" Version="3.0.71"/>
<PackageVersion Include="AspNet.Security.OAuth.GitHub" Version="9.4.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="9.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="9.0.8" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System.Security.Claims;
using System.Security.Principal;
using ASPNETCoreIdentitySample.DataLayer.Context;
using ASPNETCoreIdentitySample.DataLayer.Context;
using ASPNETCoreIdentitySample.Entities.Identity;
using ASPNETCoreIdentitySample.Services.Contracts.Identity;
using ASPNETCoreIdentitySample.Services.Identity;
Expand All @@ -9,6 +7,8 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using System.Security.Claims;
using System.Security.Principal;

namespace ASPNETCoreIdentitySample.IocConfig;

Expand Down Expand Up @@ -65,6 +65,7 @@ public static IServiceCollection AddCustomServices(this IServiceCollection servi
services.AddScoped<ISecurityTrimmingService, SecurityTrimmingService>();
services.AddScoped<IAppLogItemsService, AppLogItemsService>();

services.AddScoped<IExternalLoginService, ExternalLoginService>();
return services;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using ASPNETCoreIdentitySample.Entities.Identity;
using Microsoft.AspNetCore.Identity;

namespace ASPNETCoreIdentitySample.Services.Contracts.Identity;
public interface IExternalLoginService
{
Task<IdentityResult> AddExternalLoginAsync(User user, ExternalLoginInfo info);
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ public override async Task<IdentityResult> CreateAsync(User user)

if (result.Succeeded)
{
await _usedPasswordsService.AddToUsedPasswordsListAsync(user);
// Skip adding to used passwords when there is no local password (external login scenario)
if (!string.IsNullOrWhiteSpace(user?.PasswordHash))
{
await _usedPasswordsService.AddToUsedPasswordsListAsync(user);
}
}

return result;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using ASPNETCoreIdentitySample.Entities.Identity;
using ASPNETCoreIdentitySample.Services.Contracts.Identity;
using Microsoft.AspNetCore.Identity;

namespace ASPNETCoreIdentitySample.Services.Identity;
public sealed class ExternalLoginService(IApplicationUserManager userManager) : IExternalLoginService
{
public async Task<IdentityResult> AddExternalLoginAsync(User user, ExternalLoginInfo info)
{
ArgumentNullException.ThrowIfNull(user);
ArgumentNullException.ThrowIfNull(info);
return await userManager.AddLoginAsync(user, info);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
namespace ASPNETCoreIdentitySample.ViewModels.Identity;
public sealed class ExternalLoginConfirmationViewModel
{
[Required(ErrorMessage = "ایمیل لازم است.")]
[EmailAddress(ErrorMessage = "ایمیل نامعتبر است.")]
[Display(Name = "ایمیل")]
public required string Email { get; set; }

[Display(Name = "نام")] public string FirstName { get; set; }
[Display(Name = "نام خانوادگی")] public string LastName { get; set; }

public string ReturnUrl { get; set; }
public string ProviderDisplayName { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace ASPNETCoreIdentitySample.ViewModels.Identity.Settings;
public sealed class GitHubOptions
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public bool Enabled { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace ASPNETCoreIdentitySample.ViewModels.Identity.Settings;
public sealed class GoogleOptions
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public bool Enabled { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace ASPNETCoreIdentitySample.ViewModels.Identity.Settings;
public sealed class MicrosoftOptions
{
public string ClientId { get; set; }
public string ClientSecret { get; set; }
public bool Enabled { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace ASPNETCoreIdentitySample.ViewModels.Identity.Settings;
public sealed class OAuthOptions
{
public GoogleOptions Google { get; set; } = new();
public MicrosoftOptions Microsoft { get; set; } = new();
public GitHubOptions GitHub { get; set; } = new();
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ public class SiteSettings
{
public AdminUserSeed AdminUserSeed { get; set; }
public Logging Logging { get; set; }
public OAuthOptions Authentication { get; set; }
public SmtpConfig Smtp { get; set; }
public Connectionstrings ConnectionStrings { get; set; }
public bool EnableEmailConfirmation { get; set; }
Expand Down
3 changes: 3 additions & 0 deletions src/ASPNETCoreIdentitySample/ASPNETCoreIdentitySample.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@
<ProjectReference Include="..\ASPNETCoreIdentitySample.IocConfig\ASPNETCoreIdentitySample.IocConfig.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.GitHub" />
<PackageReference Include="DNTBreadCrumb.Core" />
<PackageReference Include="DNTCaptcha.Core" />
<PackageReference Include="DNTCommon.Web.Core" />
<PackageReference Include="LigerShark.WebOptimizer.Core" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" />
<PackageReference Include="Microsoft.Web.LibraryManager.Build" />
</ItemGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
using ASPNETCoreIdentitySample.Services.Contracts.Identity;
using ASPNETCoreIdentitySample.ViewModels.Identity;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;

namespace ASPNETCoreIdentitySample.Areas.Identity.Controllers;

[Area(AreaConstants.IdentityArea)]
[Authorize]
public class ExternalLoginController(
IApplicationSignInManager signInManager,
IApplicationUserManager userManager,
IExternalLoginService externalLoginService) : Controller
{
private IActionResult LocalRedirectSafely(string returnUrl)
{
if (!string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl))
{
return LocalRedirect(returnUrl);
}
return RedirectToAction("Index", "Home", new { area = "" });
}

[AllowAnonymous]
public Task<IActionResult> Challenge(string provider, string returnUrl = null)
{
var redirectUrl = Url.Action(nameof(Callback), "ExternalLogin", new { returnUrl });
var properties = signInManager.ConfigureExternalAuthenticationProperties(provider, redirectUrl);
return Task.FromResult<IActionResult>(Challenge(properties, provider));
}

[AllowAnonymous]
public async Task<IActionResult> Callback(string returnUrl = null)
{
var info = await signInManager.GetExternalLoginInfoAsync();
if (info == null) return RedirectToAction(nameof(LoginController.Index), "Login");

var result = await signInManager.ExternalLoginSignInAsync(info.LoginProvider, info.ProviderKey, isPersistent: false);

if (result.Succeeded)
{
return LocalRedirectSafely(returnUrl);
}

if (result.IsNotAllowed)
{
var existingLoginUser = await userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey);
if (existingLoginUser != null)
{
if (!existingLoginUser.EmailConfirmed)
{
existingLoginUser.EmailConfirmed = true;
await userManager.UpdateAsync(existingLoginUser);
}
await signInManager.SignInAsync(existingLoginUser, isPersistent: false);
return LocalRedirectSafely(returnUrl);
}
}

var email = info.Principal.FindFirstValue(ClaimTypes.Email) ?? string.Empty;

if (!string.IsNullOrEmpty(email))
{
var existingUser = await userManager.FindByEmailAsync(email);
if (existingUser is { EmailConfirmed: true })
{
var addLoginResult = await externalLoginService.AddExternalLoginAsync(existingUser, info);
if (addLoginResult.Succeeded)
{
await signInManager.SignInAsync(existingUser, isPersistent: false);
return LocalRedirectSafely(returnUrl);
}
}
}

return View("ExternalLoginConfirmation", new ExternalLoginConfirmationViewModel
{
Email = email,
ReturnUrl = returnUrl,
ProviderDisplayName = info.ProviderDisplayName ?? info.LoginProvider
});
}

[HttpPost]
[AllowAnonymous]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ExternalLoginConfirmation(ExternalLoginConfirmationViewModel model)
{
if (model == null)
{
return View("ExternalLoginConfirmation", new ExternalLoginConfirmationViewModel { Email = string.Empty });
}
if (!ModelState.IsValid)
{
return View(model);
}

var info = await signInManager.GetExternalLoginInfoAsync();
if (info == null)
{
TempData["Error"] = "اطلاعات ورود خارجی یافت نشد.";
return RedirectToAction(nameof(LoginController.Index), "Login");
}

var user = await userManager.FindByEmailAsync(model.Email);
if (user == null)
{
user = new Entities.Identity.User
{
UserName = model.Email,
Email = model.Email,
FirstName = model.FirstName,
LastName = model.LastName,
IsActive = true,
EmailConfirmed = true
};
var createResult = await userManager.CreateAsync(user);
if (!createResult.Succeeded)
{
foreach (var err in createResult.Errors) ModelState.AddModelError(string.Empty, err.Description);
return View(model);
}
}
else if (!user.EmailConfirmed)
{
user.EmailConfirmed = true;
await userManager.UpdateAsync(user);
}

var addLoginResult = await externalLoginService.AddExternalLoginAsync(user, info);
if (!addLoginResult.Succeeded)
{
foreach (var err in addLoginResult.Errors) ModelState.AddModelError(string.Empty, err.Description);
return View(model);
}

await signInManager.SignInAsync(user, isPersistent: false);
return LocalRedirectSafely(model.ReturnUrl);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
@model ExternalLoginConfirmationViewModel
@{
ViewData["Title"] = "تکمیل ثبت‌نام با ورود خارجی";
}
<div class="row mt-4">
<div class="col-md-6">
<h4>@ViewData["Title"]</h4>
<p class="text-muted">لطفاً برای ایجاد حساب داخلی، اطلاعات زیر را تکمیل کنید.</p>
<form asp-action="ExternalLoginConfirmation" method="post">
<input type="hidden" asp-for="ReturnUrl" />
<input type="hidden" asp-for="ProviderDisplayName" />
<div class="form-group mb-3">
<label asp-for="Email" class="form-label"></label>
<input asp-for="Email" class="form-control" />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group mb-3">
<label asp-for="FirstName" class="form-label"></label>
<input asp-for="FirstName" class="form-control" />
</div>
<div class="form-group mb-3">
<label asp-for="LastName" class="form-label"></label>
<input asp-for="LastName" class="form-control" />
</div>
<button type="submit" class="btn btn-info col-md-2">ثبت نام</button>
</form>
</div>
</div>
Loading