در برنامهی backend این سری (که از انتهای مطلب قابل دریافت است)، به Controllers\MoviesController.cs مراجعه کرده و متدهای Get/Delete/Create آنرا با فیلتر [Authorize] مزین میکنیم تا دسترسی به آنها، تنها به کاربران لاگین شدهی در سیستم، محدود شود. در این حالت اگر به برنامهی React مراجعه کرده و برای مثال سعی در ویرایش رکوردی کنیم، اتفاقی رخ نخواهد داد:
علت را نیز در برگهی network کنسول توسعه دهندگان مرورگر، میتوان مشاهده کرد. این درخواست از سمت سرور با Status Code: 401، برگشت خوردهاست. برای رفع این مشکل باید JSON web token ای را که در حین لاگین، از سمت سرور دریافت کرده بودیم، به همراه درخواست خود، مجددا به سمت سرور ارسال کنیم. این ارسال نیز باید به صورت یک هدر مخصوص با کلید Authorization و مقدار "Bearer jwt" باشد.
به همین جهت ابتدا به src\services\authService.js مراجعه کرده و متدی را برای بازگشت JWT ذخیره شدهی در local storage به آن اضافه میکنیم:
export function getLocalJwt(){ return localStorage.getItem(tokenKey); }
import * as auth from "./authService"; axios.defaults.headers.common["Authorization"] = "Bearer " + auth.getLocalJwt();
مشکل! اگر برنامه را در این حالت اجرا کنید، یک چنین خطایی را مشاهده خواهید کرد:
Uncaught ReferenceError: Cannot access 'tokenKey' before initialization
برای رفع این خطا باید ابتدا مشخص کنیم که کدامیک از این ماژولها، اصلی است و کدامیک باید وابستهی به دیگری باشد. در این حالت httpService، ماژول اصلی است و بدون آن و با نبود امکان اتصال به backend، دیگر authService قابل استفاده نخواهد بود.
به همین جهت به httpService مراجعه کرده و import مربوط به authService را از آن حذف میکنیم. سپس در همینجا متدی را برای تنظیم هدر Authorizationاضافه کرده و آنرا به لیست default exports این ماژول نیز اضافه میکنیم:
function setJwt(jwt) { axios.defaults.headers.common["Authorization"] = "Bearer " + jwt; } //... export default { // ... setJwt };
http.setJwt(getLocalJwt());
تا اینجا اگر تغییرات را ذخیره کرده و سعی در ویرایش یکی از رکوردهای فیلمهای نمایش داده شده کنیم، اینکار با موفقیت انجام میشود؛ چون اینبار درخواست ارسالی، دارای هدر ویژهی authorization است:
روش بررسی انقضای توکنها در سمت کلاینت
اگر JWT قدیمی و منقضی شدهی از روز گذشته را آزمایش کنید، باز هم از سمت سرور، Status Code: 401 دریافت خواهد شد. اما اینبار در لاگهای برنامهی سمت سرور، OnChallenge error مشخص است. در این حالت باید یکبار logout کرد تا JWT قدیمی حذف شود. سپس نیاز به لاگین مجدد است تا یک JWT جدید دریافت گردد. میتوان اینکار را پیش از ارسال اطلاعات به سمت سرور، در سمت کلاینت نیز بررسی کرد:
function checkExpirationDate(user) { if (!user || !user.exp) { throw new Error("This access token doesn't have an expiration date!"); } user.expirationDateUtc = new Date(0); // The 0 sets the date to the epoch user.expirationDateUtc.setUTCSeconds(user.exp); const isAccessTokenTokenExpired = user.expirationDateUtc.valueOf() < new Date().valueOf(); if (isAccessTokenTokenExpired) { throw new Error("This access token is expired!"); } }
محدود کردن حذف رکوردهای فیلمها به نقش Admin در Backend
تا اینجا تمام کاربران وارد شدهی به سیستم، میتوانند علاوه بر ویرایش فیلمها، آنها را نیز حذف کنند. به همین جهت میخواهیم دسترسی حذف را از کاربرانی که ادمین نیستند، بگیریم. برای این منظور، در سمت سرور کافی است در کنترلر MoviesController، ویژگی [Authorize(Policy = CustomRoles.Admin)] را به اکشن متد Delete، اضافه کنیم. به این ترتیب اگر کاربری در سیستم ادمین نبود و درخواست حذف رکوردی را صادر کرد، خطای 403 را از سمت سرور دریافت میکند:
در برنامهی مثال backend این سری، در فایل Services\UsersDataSource.cs، یک کاربر ادمین پیشفرض ثبت شدهاست. مابقی کاربرانی که به صورت معمولی در سایت ثبت نام میکنند، ادمین نیستند.
در این حالت اگر کاربری ادمین بود، چون در توکن او که در فایل Services\TokenFactoryService.cs صادر میشود، یک User Claim ویژهی از نوع Role و با مقدار Admin وجود دارد:
if (user.IsAdmin) { claims.Add(new Claim(ClaimTypes.Role, CustomRoles.Admin, ClaimValueTypes.String, _configuration.Value.Issuer)); }
{ // ... "http://schemas.microsoft.com/ws/2008/06/identity/claims/role": "Admin", // ... }
نکته 1: اگر در اینجا چندین بار یک User Claim را با مقادیر متفاوتی، به لیست claims اضافه کنیم، مقادیر آن در خروجی نهایی، به شکل یک آرایه ظاهر میگردند.
نکته 2: پیاده سازی سمت سرور backend این سری، یک باگ امنیتی مهم را دارد! در حین ثبت نام، کاربران میتوانند مقدار خاصیت isAdmin شیء User را:
public class User : BaseModel { [Required, MinLength(2), MaxLength(50)] public string Name { set; get; } [Required, MinLength(5), MaxLength(255)] public string Email { set; get; } [Required, MinLength(5), MaxLength(1024)] public string Password { set; get; } public bool IsAdmin { set; get; } }
راه حل اصولی مقابلهی با آن، داشتن یک DTO و یا ViewModel خاص قسمت ثبت نام و جدا کردن مدل متناظر با موجودیت User، از شیءای است که اطلاعات نهایی را از کاربر، دریافت میکند. شیءای که اطلاعات را از کاربر دریافت میکند، نباید دارای خاصیت isAdmin قابل تنظیم در حین ثبت نام معمولی کاربران سایت باشد. یک روش دیگر حل این مشکل، استفاده از ویژگی Bind و ذکر صریح نام خواصی است که قرار است bind شوند و نه هیچ خاصیت دیگری از شیء User:
[HttpPost] public ActionResult<User> Create( [FromBody] [Bind(nameof(Models.User.Name), nameof(Models.User.Email), nameof(Models.User.Password))] User data) {
نکته 3: اگر میخواهید در برنامهی React، با مواجه شدن با خطای 403 از سمت سرور، کاربر را به یک صفحهی عمومی «دسترسی ندارید» هدایت کنید، میتوانید از interceptor سراسری که در قسمت 24 تعریف کردیم، استفاده کنید. در اینجا status code = 403 را جهت history.push به یک آدرس access-denied سفارشی و جدید، پردازش کنید.
نمایش یا مخفی کردن المانها بر اساس سطوح دسترسی کاربر وارد شدهی به سیستم
میخواهیم در صفحهی نمایش لیست فیلمها، دکمهی new movie را که بالای صفحه قرار دارد، به کاربرانی که لاگین نکردهاند، نمایش ندهیم. همچنین نمیخواهیم اینگونه کاربران، بتوانند فیلمی را ویرایش و یا حذف کنند؛ یعنی لینک به صفحهی جزئیات ویرایشی فیلمها و ستونی که دکمههای حذف هر ردیف را نمایش میدهد، به کاربران وارد نشدهی به سیستم نمایش داده نشوند.
در قسمت قبل، در فایل app.js، شیء currentUser را به state اضافه کردیم و با استفاده از ارسال آن به کامپوننت NavBar:
<NavBar user={this.state.currentUser} />
<Route path="/movies" render={props => <Movies {...props} user={this.state.currentUser} />} />
پس از این تغییر به فایل src\components\movies.jsx مراجعه کرده و شیء user را در متد رندر، دریافت میکنیم:
class Movies extends Component { // ... render() { const { user } = this.props; // ...
{user && ( <Link to="/movies/new" className="btn btn-primary" style={{ marginBottom: 20 }} > New Movie </Link> )}
در این تصویر همانطور که مشخص است، کاربر هنوز به سیستم وارد نشدهاست؛ بنابراین به علت null بودن شیء user، دکمهی New Movie را مشاهده نمیکند.
روش دریافت نقشهای کاربر وارد شدهی به سیستم در سمت کلاینت
همانطور که پیشتر در مطلب جاری عنوان شد، نقشهای دریافتی از سرور، یک چنین شکلی را در jwtDecode نهایی (یا user در اینجا) دارند:
{ // ... "http://schemas.microsoft.com/ws/2008/06/identity/claims/role": "Admin", // ... }
function addRoles(user) { const roles = user["http://schemas.microsoft.com/ws/2008/06/identity/claims/role"]; if (roles) { if (Array.isArray(roles)) { user.roles = roles.map(role => role.toLowerCase()); } else { user.roles = [roles.toLowerCase()]; } } }
export function isAuthUserInRoles(user, requiredRoles) { if (!user || !user.roles) { return false; } if (user.roles.indexOf(adminRoleName.toLowerCase()) >= 0) { return true; // The `Admin` role has full access to every pages. } return requiredRoles.some(requiredRole => { if (user.roles) { return user.roles.indexOf(requiredRole.toLowerCase()) >= 0; } else { return false; } }); } export function isAuthUserInRole(user, requiredRole) { return isAuthUserInRoles(user, [requiredRole]); }
در این کدها، adminRoleName به صورت زیر تامین شدهاست:
import { adminRoleName, apiUrl } from "../config.json";
{ "apiUrl": "https://localhost:5001/api", "adminRoleName": "Admin" }
اکنون که امکان بررسی نقشهای کاربر لاگین شدهی به سیستم را داریم، میخواهیم ستون Delete ردیفهای لیست فیلمها را فقط به کاربری که دارای نقش Admin است، نمایش دهیم. برای اینکار نیاز به دریافت شیء user، در src\components\moviesTable.jsx وجود دارد. یک روش دریافت کاربر جاری وارد شدهی به سیستم، همانی است که تا به اینجا بررسی کردیم: شیء currentUser را به صورت props، از بالاترین کامپوننت، به پایینتر کامپوننت موجود در component tree ارسال میکنیم. روش دیگر اینکار، دریافت مستقیم کاربر جاری از خود src\services\authService.js است و ... اینکار سادهتر است! به علاوه اینکه همیشه بررسی تاریخ انقضای توکن را نیز به صورت خودکار انجام میدهد و در صورت انقضای توکن، کاربر را در قسمت catch متد getCurrentUser، از سیستم خارج خواهد کرد.
بنابراین در src\components\moviesTable.jsx، ابتدا authService را import میکنیم:
import * as auth from "../services/authService";
class MoviesTable extends Component { columns = [ ... ]; // ... deleteColumn = { key: "delete", content: movie => ( <button onClick={() => this.props.onDelete(movie)} className="btn btn-danger btn-sm" > Delete </button> ) };
constructor() { super(); const user = auth.getCurrentUser(); if (user && auth.isAuthUserInRole(user, "Admin")) { this.columns.push(this.deleteColumn); } }
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید: sample-28-backend.zip و sample-28-frontend.zip