در انتهای «
قسمت دوازدهم- یکپارچه سازی با اکانت گوگل»، کار «اتصال کاربر وارد شدهی از طریق یک IDP خارجی به اکانتی که هم اکنون در سطح IDP ما موجود است» انجام شد. اما این مورد یک مشکل امنیتی را هم ممکن است ایجاد کند. اگر IDP ثالث، ایمیل اشخاص را تعیین اعتبار نکند، هر شخصی میتواند ایمیل دیگری را بجای ایمیل اصلی خودش در آنجا ثبت کند. به این ترتیب یک مهاجم میتواند به سادگی تنها با تنظیم ایمیل کاربری مشخص و مورد استفادهی در برنامهی ما در آن IDP ثالث، با سطح دسترسی او فقط با دو کلیک ساده به سایت وارد شود. کلیک اول، کلیک بر روی دکمهی external login در برنامهی ما است و کلیک دوم، کلیک بر روی دکمهی انتخاب اکانت، در آن اکانت لینک شدهی خارجی است.
برای بهبود این وضعیت میتوان مرحلهی دومی را نیز به این فرآیند لاگین افزود؛ پس از اینکه مشخص شد کاربر وارد شدهی به سایت، دارای اکانتی در IDP ما است، کدی را به آدرس ایمیل او ارسال میکنیم. اگر این ایمیل واقعا متعلق به این شخص است، بنابراین قادر به دسترسی به آن، خواندن و ورود آن به برنامهی ما نیز میباشد. این اعتبارسنجی دو مرحلهای را میتوان به عملیات لاگین متداول از طریق ورود نام کاربری و کلمهی عبور در IDP ما نیز اضافه کرد.
تنظیم میانافزار Cookie Authentication
مرحلهی اول ایجاد گردش کاری اعتبارسنجی دو مرحلهای، فعالسازی میانافزار Cookie Authentication در برنامهی IDP است. برای این منظور به کلاس Startup آن مراجعه کرده و AddCookie را اضافه میکنیم:
namespace DNT.IDP
{
public class Startup
{
public const string TwoFactorAuthenticationScheme = "idsrv.2FA";
public void ConfigureServices(IServiceCollection services)
{
// ...
services.AddAuthentication()
.AddCookie(authenticationScheme: TwoFactorAuthenticationScheme)
.AddGoogle(authenticationScheme: "Google", configureOptions: options =>
{
options.SignInScheme = IdentityServerConstants.ExternalCookieAuthenticationScheme;
options.ClientId = Configuration["Authentication:Google:ClientId"];
options.ClientSecret = Configuration["Authentication:Google:ClientSecret"];
});
}
اصلاح اکشن متد Login برای هدایت کاربر به صفحهی ورود اطلاعات کد موقتی
تا این مرحله، در اکشن متد Login کنترلر Account، اگر کاربر، اطلاعات هویتی خود را صحیح وارد کند، به سیستم وارد خواهد شد. برای لغو این عملکرد پیشفرض، کدهای HttpContext.SignInAsync آنرا حذف کرده و با Redirect به اکشن متد نمایش صفحهی ورود کد موقتی ارسال شدهی به آدرس ایمیل کاربر، جایگزین میکنیم.
namespace DNT.IDP.Controllers.Account
{
[SecurityHeaders]
[AllowAnonymous]
public class AccountController : Controller
{
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Login(LoginInputModel model, string button)
{
// ...
if (ModelState.IsValid)
{
if (await _usersService.AreUserCredentialsValidAsync(model.Username, model.Password))
{
var user = await _usersService.GetUserByUsernameAsync(model.Username);
var id = new ClaimsIdentity();
id.AddClaim(new Claim(JwtClaimTypes.Subject, user.SubjectId));
await HttpContext.SignInAsync(scheme: Startup.TwoFactorAuthenticationScheme, principal: new ClaimsPrincipal(id));
await _twoFactorAuthenticationService.SendTemporaryCodeAsync(user.SubjectId);
var redirectToAdditionalFactorUrl =
Url.Action("AdditionalAuthenticationFactor",
new
{
returnUrl = model.ReturnUrl,
rememberLogin = model.RememberLogin
});
// request for a local page
if (Url.IsLocalUrl(model.ReturnUrl))
{
return Redirect(redirectToAdditionalFactorUrl);
}
if (string.IsNullOrEmpty(model.ReturnUrl))
{
return Redirect("~/");
}
// user might have clicked on a malicious link - should be logged
throw new Exception("invalid return URL");
}
await _events.RaiseAsync(new UserLoginFailureEvent(model.Username, "invalid credentials"));
ModelState.AddModelError("", AccountOptions.InvalidCredentialsErrorMessage);
}
// something went wrong, show form with error
var vm = await BuildLoginViewModelAsync(model);
return View(vm);
}
- در این اکشن متد، ابتدا مشخصات کاربر، از بانک اطلاعاتی بر اساس نام کاربری او، دریافت میشود.
- سپس بر اساس Id این کاربر، یک ClaimsIdentity تشکیل میشود.
- در ادامه با فراخوانی متد SignInAsync بر روی این ClaimsIdentity، یک کوکی رمزنگاری شده را با scheme تعیین شده که با authenticationScheme تنظیم شدهی در کلاس آغازین برنامه تطابق دارد، ایجاد میکنیم.
await HttpContext.SignInAsync(scheme: Startup.TwoFactorAuthenticationScheme, principal: new ClaimsPrincipal(id));
سپس کد موقتی به آدرس ایمیل کاربر ارسال میشود. برای این منظور
سرویس جدید زیر را به برنامه اضافه کردهایم:
public interface ITwoFactorAuthenticationService
{
Task SendTemporaryCodeAsync(string subjectId);
Task<bool> IsValidTemporaryCodeAsync(string subjectId, string code);
}
- کار متد SendTemporaryCodeAsync، ایجاد و ذخیرهی یک کد موقتی در بانک اطلاعاتی و سپس ارسال آن به کاربر است. البته در اینجا، این کد در صفحهی Console برنامه لاگ میشود (یا هر نوع Log provider دیگری که برای برنامه تعریف کردهاید) که میتوان بعدها آنرا با کدهای ارسال ایمیل
جایگزین کرد.
- متد IsValidTemporaryCodeAsync، کد دریافت شدهی از کاربر را با نمونهی موجود در بانک اطلاعاتی مقایسه و اعتبار آنرا اعلام میکند.
ایجاد اکشن متد AdditionalAuthenticationFactor و View مرتبط با آن
پس از ارسال کد موقتی به کاربر، کاربر را به صورت خودکار به اکشن متد جدید AdditionalAuthenticationFactor هدایت میکنیم تا این کد موقتی را که به صورت ایمیل (و یا در اینجا با مشاهدهی لاگ برنامه)، دریافت کردهاست، وارد کند. همچنین returnUrl را نیز به این اکشن متد جدید ارسال میکنیم تا بدانیم پس از ورود موفق کد موقتی توسط کاربر، او را باید در ادامهی این گردش کاری به کجا هدایت کنیم. بنابراین قسمت بعدی کار، ایجاد این اکشن متد و تکمیل View آن است.
ViewModel ای که بیانگر ساختار View مرتبط است، چنین تعریفی را دارد:
using System.ComponentModel.DataAnnotations;
namespace DNT.IDP.Controllers.Account
{
public class AdditionalAuthenticationFactorViewModel
{
[Required]
public string Code { get; set; }
public string ReturnUrl { get; set; }
public bool RememberLogin { get; set; }
}
}
که در آن، Code توسط کاربر تکمیل میشود و دو گزینهی دیگر را از طریق مسیریابی و هدایت به این View دریافت خواهد کرد.
سپس اکشن متد AdditionalAuthenticationFactor در حالت Get، این View را نمایش میدهد و در حالت Post، اطلاعات آنرا از کاربر دریافت خواهد کرد:
namespace DNT.IDP.Controllers.Account
{
public class AccountController : Controller
{
[HttpGet]
public IActionResult AdditionalAuthenticationFactor(string returnUrl, bool rememberLogin)
{
// create VM
var vm = new AdditionalAuthenticationFactorViewModel
{
RememberLogin = rememberLogin,
ReturnUrl = returnUrl
};
return View(vm);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AdditionalAuthenticationFactor(
AdditionalAuthenticationFactorViewModel model)
{
if (!ModelState.IsValid)
{
return View(model);
}
// read identity from the temporary cookie
var info = await HttpContext.AuthenticateAsync(Startup.TwoFactorAuthenticationScheme);
var tempUser = info?.Principal;
if (tempUser == null)
{
throw new Exception("2FA error");
}
// ... check code for user
if (!await _twoFactorAuthenticationService.IsValidTemporaryCodeAsync(tempUser.GetSubjectId(), model.Code))
{
ModelState.AddModelError("code", "2FA code is invalid.");
return View(model);
}
// login the user
AuthenticationProperties props = null;
if (AccountOptions.AllowRememberLogin && model.RememberLogin)
{
props = new AuthenticationProperties
{
IsPersistent = true,
ExpiresUtc = DateTimeOffset.UtcNow.Add(AccountOptions.RememberMeLoginDuration)
};
}
// issue authentication cookie for user
var user = await _usersService.GetUserBySubjectIdAsync(tempUser.GetSubjectId());
await _events.RaiseAsync(new UserLoginSuccessEvent(user.Username, user.SubjectId, user.Username));
await HttpContext.SignInAsync(user.SubjectId, user.Username, props);
// delete temporary cookie used for 2FA
await HttpContext.SignOutAsync(Startup.TwoFactorAuthenticationScheme);
if (_interaction.IsValidReturnUrl(model.ReturnUrl) || Url.IsLocalUrl(model.ReturnUrl))
{
return Redirect(model.ReturnUrl);
}
return Redirect("~/");
}
توضیحات:
- فراخوانی HttpContext.SignInAsync با اسکیمای مشخص شده، یک کوکی رمزنگاری شده را در اکشن متد Login ایجاد میکند. اکنون در اینجا با استفاده از متد HttpContext.AuthenticateAsync و ذکر همان اسکیما، میتوانیم به محتوای این کوکی رمزنگاری شده دسترسی داشته باشیم و از طریق آن، Id کاربر را استخراج کنیم.
- اکنون که این Id را داریم و همچنین Code موقتی نیز از طرف کاربر ارسال شدهاست، آنرا به متد IsValidTemporaryCodeAsync که پیشتر در مورد آن توضیح دادیم، ارسال کرده و اعتبارسنجی میکنیم.
- در آخر این کوکی رمزنگاری شده را با فراخوانی متد HttpContext.SignOutAsync، حذف و سپس یک کوکی جدید را بر اساس اطلاعات هویت کاربر، توسط متد HttpContext.SignInAsync ایجاد و ثبت میکنیم تا کاربر بتواند بدون مشکل وارد سیستم شود.
View متناظر با آن نیز در فایل src\IDP\DNT.IDP\Views\Account\AdditionalAuthenticationFactor.cshtml، به صورت زیر تعریف شدهاست تا کد موقتی را به همراه آدرس بازگشت پس از ورود آن، به سمت سرور ارسال کند:
@model AdditionalAuthenticationFactorViewModel
<div>
<div class="page-header">
<h1>2-Factor Authentication</h1>
</div>
@Html.Partial("_ValidationSummary")
<div class="row">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">Input your 2FA code</h3>
</div>
<div class="panel-body">
<form asp-route="Login">
<input type="hidden" asp-for="ReturnUrl" />
<input type="hidden" asp-for="RememberLogin" />
<fieldset>
<div class="form-group">
<label asp-for="Code"></label>
<input class="form-control" placeholder="Code" asp-for="Code" autofocus>
</div>
<div class="form-group">
<button class="btn btn-primary">Submit code</button>
</div>
</fieldset>
</form>
</div>
</div>
</div>
</div>
آزمایش برنامه جهت بررسی اعتبارسنجی دو مرحلهای
پس از طی این مراحل، اعتبارسنجی دو مرحلهای در برنامه فعال شدهاست. اکنون برای آزمایش آن، برنامهها را اجرا میکنیم. پس از لاگین، صفحهی زیر نمایش داده میشود:
همچنین کد موقتی این مرحله را نیز در لاگهای برنامه مشاهده میکنید:
پس از ورود آن، کار اعتبارسنجی نهایی آن انجام شده و سپس بلافاصله به برنامهی MVC Client هدایت میشویم.
اضافه کردن اعتبارسنجی دو مرحلهای به قسمت ورود از طریق تامین کنندههای هویت خارجی
دقیقا همین مراحل را نیز به اکشن متد Callback کنترلر ExternalController اضافه میکنیم. در این اکشن متد، تا قسمت کدهای مشخص شدن user آن که از اکانت خارجی وارد شدهاست، با قبل یکی است. پس از آن تمام کدهای لاگین شخص به برنامه را از اینجا حذف و به اکشن متد جدید AdditionalAuthenticationFactor در همین کنترلر منتقل میکنیم.
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید.
برای اجرای برنامه:
- ابتدا به پوشهی src\WebApi\ImageGallery.WebApi.WebApp وارد شده و dotnet_run.bat آنرا اجرا کنید تا WebAPI برنامه راه اندازی شود.
- سپس به پوشهی src\IDP\DNT.IDP مراجعه کرده و و dotnet_run.bat آنرا اجرا کنید تا برنامهی IDP راه اندازی شود.
- در آخر به پوشهی src\MvcClient\ImageGallery.MvcClient.WebApp وارد شده و dotnet_run.bat آنرا اجرا کنید تا MVC Client راه اندازی شود.
اکنون که هر سه برنامه در حال اجرا هستند، مرورگر را گشوده و مسیر https://localhost:5001 را درخواست کنید. در صفحهی login نام کاربری را User 1 و کلمهی عبور آنرا password وارد کنید.