آموزش ایجاد قابلیت ثبت‌نام و ورود کاربر با Angular

آفلاین
user-avatar
عرفان حشمتی
12 مهر 1400, خواندن در 16 دقیقه

در این آموزش قصد داریم یک راه حل کامل برای احراز هویت کاربر شامل ثبت نام، ورود و تأیید حساب با فریمورک محبوب انگولار طراحی و پیاده سازی کنیم. در ادامه یاد خواهید گرفت که چگونه برنامه را با یک ماژول جداگانه که مدیریت قسمت‌های گرافیکی و منطقی احراز هویت کاربر را بر عهده دارد، بسازید. رویکرد پیشنهادی قوی و انعطاف پذیر خواهد بود تا به سخت‌ترین نیاز برنامه‌های وب مدرن پاسخ دهد.

جدا از پیاده سازی موارد پیش فرض، روش‌های مختلف برای احراز هویت کاربر در وب را نیز مقایسه خواهیم کرد. همچنین در مورد سناریوهای متمایز برای استقرار برنامه بحث خواهیم کرد و یک رویکرد مناسب و مطمئن برای نیازهای شما پیشنهاد می‌کنیم. در پایان این آموزش، یک مثال ساده و در عین حال سازگار برای ورود به سیستم خواهید داشت که می‌توانید نیازهای خاص خود را متناسب با آن تغییر دهید. کد با استفاده از +Angular 2 نوشته می‌شود و مربوط به همه نسخه‌های جدیدتر (از جمله Angular 11) هم هست، اما مفاهیم مورد بحث نیز برای اعتبارسنجی به کار گرفته می‌شوند‌.

ساختار برنامه و ارائه راه‌حل

برای یافتن مکان مناسب در برنامه به منظور اجرای ویژگی‌های احراز هویت، باید یک قدم به عقب برگردیم و در مورد معماری انگولار و طراحی ماژولار فکر کنیم. برنامه ما به ماژول‌هایی تقسیم می‌شود که هر یک ویژگی‌های خاص خود را دارد. بیشتر کدهایی که برای این آموزش خواهیم داشت متعلق به AuthModule است. همچنین این ماژول موارد زیر را در بر می‌گیرد:

  • کامپوننت‌های کانتینر قابل مسیریابی برای ورود، ثبت نام و صفحه تأیید
  • دو روتر گارد
  • چند سرویس fine-grained
  • پیکربندی مسیریابی
  • رهگیری http

بخش بعدی برنامه، مسیریابی سطح بالا است. ما می‌خواهیم برنامه را به دو قسمت authentication و application تقسیم کنیم. این کار درخت مسیرها را ساده کرده و بعدا به ما امکان می‌دهد دو روتر گارد متمایز برای اعمال سیاست‌های فعال سازی مسیر مناسب ایجاد کنیم.

const routes: Routes = [

  { path: '', redirectTo: '/login', pathMatch: 'full' },

  {

    path: 'app',

    canActivate: [AppGuard],

    component: LayoutComponent,

    children: [

      { path: 'dashboard', component: DashboardComponent },

      { path: 'expenses', component: ExpensesComponent },

      { path: 'settings', component: SettingsComponent) }

    ]

  }

];


@NgModule({

  imports: [RouterModule.forRoot(routes)],

  exports: [RouterModule]

})

export class AppRoutingModule { }

قبل از شروع به اجرا، باید به آخرین سوال بسیار مهم پاسخ دهیم. از آنجا که پروتکل HTTP یک پروتکل درخواست و پاسخ stateless است، بنابراین باید راهی برای حفظ و نگهداری اطلاعات کاربر پس از ورود موفق داشته باشیم. در این مقاله دو رویکرد پرکاربرد را توضیح می‌دهیم: سِشِن‌های مبتنی بر کوکی و توکن‌های مستقل.

یک سشن مبتنی بر کوکی بر اساس کانتکست کاربر عمل می‌کند که در سمت سرور نگهداری می‌شود. هر کانتکست را می‌توان با شناسه سشن مشخص کرد که به طور تصادفی برای هر مرورگر ایجاد شده و در یک کوکی قرار می‌گیرد. هنگامی که از فلگ HttpOnly در آن کوکی استفاده کنیم، مانع از حملات XSS می‌شویم. اما هنوز هم باید به درخواست جعلی cross-site فکر کنیم. رویکرد مبتنی بر کوکی زمانی بسیار کاربردی است که برنامه داخلی و API پشتیبان ما از یک منبع (دامنه و پورت یکسان) میزبانی شوند. که این به دلیل سیاست مدل امنیت وب به ما اجازه نمی‌دهد کوکی‌های یکسانی را در چندین بک-اند به اشتراک بگذاریم. به عبارت دیگر، کوکی‌ها در هر دامنه محدود شده‌اند.

رویکرد دوم ممکن است زمانی مفید واقع شود که برنامه ما در دو سرور جداگانه مستقر شده باشد؛ یعنی فرانت-اند در مکانی متفاوت از API پشتیبان میزبانی می‌شود. در این مورد، درخواست‌های پیش روی به عنوان پشتیبان، درخواست‌های اصلی نامیده می‌شوند و کوکی‌هایی که بر روی منبع پشتیبان قرار می‌گیرند، کوکی‌های شخص ثالث نام می‌گیرند. کوکی شخص ثالث همان مکانیزمی است که توسط سیستم‌های تجزیه و تحلیل و ردیابی استفاده می‌شود و می‌تواند به راحتی در مرورگرهای مدرن غیرفعال شود. بسیاری از کاربران از به کارگیری کوکی‌های شخص ثالث خودداری می‌کنند، زیرا نگران حریم خصوصی خود در اینترنت هستند. همچنین برخی از توسعه‌دهندگان مرورگر تلاش زیادی برای از بین بردن کامل کوکی‌های شخص ثالث انجام می‌دهند.

بنابراین در چنین شرایطی چه کاری باید انجام دهیم؟ در این صورت می‌توانیم از روش دیگری برای ارائه کانتکست کاربر بین درخواست‌ها استفاده کنیم، یعنی HTTP Authorization Header. این امر مستلزم خواندن، ذخیره سازی و متصل کردن یک توکن است که از طریق هدر (برخلاف کوکی‌ها) منتقل می‌شود. همچنین به یاد داشته باشید که شناسه سشنی که در کوکی‌ها استفاده می‌گردد نیز یک توکن است و هیچ اطلاعاتی را منتقل نمی‌کند، چرا که فقط یک کلید برای بازیابی سشن در سرور به حساب می‌آید. نوع دیگری از توکن‌ها، توکن مستقل نامیده می‌شود که می‌توانیم کانتکست کاربر را درون آن قرار دهیم. در سال 2015 گروه جهانی مهندسی اینترنت، استاندارد JSON Web Token (JWT) را معرفی کرد که می‌تواند اطلاعات را به طور ایمن بین طرفین منتقل کند. همچنین به لطف مجوز cryptographic می‌توان محتوای JWT را معتبر و یکپارچه در نظر گرفت. ماهیت JWT به ما اجازه می‌دهد تا کانتکست کاربر را مانند مجوزها و اعتبارنامه‌ها بدون نیاز به حفظ سشن روی سرور بازیابی کنیم (به serverless و Function-as-a-service فکر کنید). به علاوه می‌توانیم با سرویس‌های شخص ثالث بدون محدودیت در محیطی یکسان (مانند Firebase یا AWSAmplify) ادغام شویم. در زیر توضیحات بیشتری راجع به JSON Web Tokens ارائه کرده‌ایم.

درک تفاوت‌های اساسی بین این دو مکانیزم، قبل از پیاده سازی احراز هویت کاربر در برنامه بسیار مهم است. ما دموی خود را ایجاد خواهیم کرد که می‌تواند از کوکی‌های سشن و احراز هویت توکن با JSON Web Tokens استفاده کند. این به شما نشان می‌دهد که چقدر انعطاف‌پذیر است.

توجه: هر زمان از هر نوع توکن که در LocalStorage یا IndexedDB ذخیره می‌کنید استفاده نمایید (با کد جاوااسکریپت قابل دسترسی است)، توکن مربوطه را در معرض ربوده شدن از طریق حمله XSS قرار می‌دهید. با این حال تکنیک‌هایی مانند سیاست امنیت محتوا، یکپارچگی زیرساخت و مکانیزم‌های پاکسازی وجود دارد (البته هنگامی که به درستی اعمال شوند) که این خطر را تا حد ناچیزی کاهش می‌دهد. گفته می‌شود که خطر واقعا جدی است، بنابراین باید به مسائل امنیتی توجه کافی داشته باشید.

پیاده‌سازی دقیق

Login

بیایید ابتدا با قسمت رابط کاربری (UI) کامپوننت لاگین شروع کنیم. رویکرد ما برای احراز هویت کاربر بر اساس ایمیل و رمز عبور است، بنابراین به دو ورودی نیاز داریم. توجه داشته باشید که ورودی دوم دارای یک خصوصیت "type = "password است که به مرورگر دستور می‌دهد یک عنصر ورودی با کاراکترهای غیرقابل مشاهده نمایش دهد. همچنین از Angular Material برای طراحی ظاهر زیبا و رابط کاربری استفاده می‌کنیم. در زیر می‌توانید نمونه فرم ورود به سیستم را ببینید.

<form [formGroup]="loginForm">
  <div class="header">Login to your account</div>
  <mat-form-field>
    <input matInput type="email" id="email" placeholder="Email" autocomplete="off" formControlName="email" required>
  </mat-form-field>
  <mat-form-field>
    <input matInput type="password" id="password" placeholder="Password" autocomplete="off" formControlName="password" required>
  </mat-form-field>

  <div class="actions">
    <button mat-flat-button color="primary" type="submit" (click)="login()" [disabled]="!loginForm.valid">Login</button>
    <div class="separator">
      <span>OR</span>
    </div>
    <button mat-stroked-button type="button" routerLink="/signup">Sign up</button>
  </div>
</form>

حال سوال این است: چگونه می‌توان مقادیر ورودی را از کاربر برای اجرای کامپوننت لاگین دریافت کرد؟ به منظور لینک کردن فرم HTML و عناصر ورودی در کد می‌توانیم از دستورات ماژول Reactive Forms استفاده کنیم.
با استفاده از FormGroupDirective در " [formGroup] = "loginForm، به انگولار می‌گوییم یک ویژگی loginForm در کامپوننت وجود دارد که باید نمونه‌ای از آن فرم را داشته باشد. سپس از FormBuilder برای ایجاد نمونه‌های FormControl ایمیل و رمز عبور استفاده می‌کنیم. همچنین کنترل ایمیل مجهز به اعتبارسنجی داخلی ایمیل است.

@Component({
  selector: 'app-login',
  templateUrl: './login.component.html'
})

export class LoginComponent implements OnInit {
  loginForm: FormGroup;

  constructor(private authService: AuthService,
    private formBuilder: FormBuilder,
    private router: Router) { }

  ngOnInit() {
    this.loginForm = this.formBuilder.group({
      email: ['', Validators.email],
      password: ['']
    });

  }

  get f() { return this.loginForm.controls; }

  login() {
    const loginRequest: LoginRequest = {
      email: this.f.email.value,
      password: this.f.password.value

    };

    this.authService.login(loginRequest)
      .subscribe((user) => this.router.navigate([this.authService.INITIAL_PATH]));

  }
}

گام بعدی این است که درخواست‌های اصلی را برای ورود به سیستم پس از کلیک روی دکمه مربوطه اجرا کنیم. از آنجا که می‌خواهیم هم سشن‌های مبتنی بر کوکی و هم توکن‌های JWT را مدیریت کنیم، بنابراین درخواست‌های HTTP را از مدیریت منطق با رابط AuthStrategy جدا می‌نماییم.

بسته به نوع مکانیزم انتخابی، اجرای AuthStrategy در AuthService تزریق می‌شود. این امر به لطف تنظیمات پیکربندی که تعیین می‌کند از اجرای AuthStrategy استفاده کند، امکان پذیر است. در زیر می‌توانید رابط کاربری واقعی برای کوکی‌ها و JWT را مشاهده کنید. توجه داشته باشید که از متد authStrategyProvider برای ثبت اطلاعات در AuthModule استفاده می‌شود.

auth.strategy.ts

export interface AuthStrategy<T> {

  doLoginUser(data: T): void;

  doLogoutUser(): void;

  getCurrentUser(): Observable<User>;

}




export const AUTH_STRATEGY = new InjectionToken<AuthStrategy<any>>('AuthStrategy');




export const authStrategyProvider = {

  provide: AUTH_STRATEGY,

  deps: [HttpClient],

  useFactory: (http: HttpClient) => {

    switch (config.auth) {

        case 'session':

          return new SessionAuthStrategy(http);

        case 'token':

          return new JwtAuthStrategy();

      }

  }

};

session-auth.strategy.ts

export class SessionAuthStrategy implements AuthStrategy<User> {
  private loggedUser: User;
  constructor(private http: HttpClient) {}
  doLoginUser(user: User): void {
    this.loggedUser = user;
  }
  doLogoutUser(): void {
    this.loggedUser = undefined;
  }
  getCurrentUser(): Observable<User> {
    if (this.loggedUser) {
      return of(this.loggedUser);
    } else {
      return this.http.get<User>(`${config.authUrl}/user`)
        .pipe(tap(user => this.loggedUser = user));
    }
  }
}

jwt-auth.strategy.ts

export class JwtAuthStrategy implements AuthStrategy<Token> {
  private readonly JWT_TOKEN = 'JWT_TOKEN';
  doLoginUser(token: Token): void {
    localStorage.setItem(this.JWT_TOKEN, token.jwt);
  }

  doLogoutUser(): void {
    localStorage.removeItem(this.JWT_TOKEN);
  }

  getCurrentUser(): Observable<User> {
    const token = this.getToken();

    if (token) {
      const encodedPayload = token.split('.')[1];
      const payload = window.atob(encodedPayload);
      return of(JSON.parse(payload));

    } else {
      return of(undefined);
    }
  }

  getToken() {
    return localStorage.getItem(this.JWT_TOKEN);
  }
}

همانطور که در بالا هنگام استفاده از کوکی‌ها می‌بینید، نیازی به شناسه سشن نداریم. زیرا به طور خودکار توسط مرورگر در کوکی قرار می‌گیرد. علاوه بر این برای احراز هویت توکن JWT باید آن را در جایی ذخیره کنیم. روشی که ما پیاده سازی می‌کنیم، قرار دادن آن در LocalStorage است.

در نهایت برای ادغام همه چیز به یکدیگر، AuthService پس از اجرای درخواست HTTP ، doLoginMethod را در AuthStrategy فراخوانی می‌کند. توجه داشته باشید که عضویت نهایی در استریم قابل مشاهده در LoginComponent متصل شده و آخرین مرحله را برای هدایت به صفحه اولیه پس از ورود انجام می‌دهد.

@Injectable({
  providedIn: 'root'
})

export class AuthService {
  public readonly LOGIN_PATH = '/login';
  public readonly CONFIRM_PATH = '/confirm';
  public readonly INITIAL_PATH = '/app/dashboard';

  constructor(
    private router: Router,
    private http: HttpClient,
    @Inject(AUTH_STRATEGY) private auth: AuthStrategy<any>

  ) { }

  signup(user: User): Observable<void> {
    return this.http.post<any>(`${config.authUrl}/signup`, user);
  }

  confirm(email: string, code: string): Observable<void> {
    return this.http.post<any>(`${config.authUrl}/confirm?`, {email, code});
  }

  login(loginRequest: LoginRequest): Observable<User> {
    return this.http.post<any>(`${config.authUrl}/login`, loginRequest)
      .pipe(tap(data => this.auth.doLoginUser(data)));
  }

  logout() {
    return this.http.get<any>(`${config.authUrl}/logout`)
      .pipe(tap(() => this.doLogoutUser()));

  }

  isLoggedIn$(): Observable<boolean> {
    return this.auth.getCurrentUser().pipe(
      map(user => !!user),
      catchError(() => of(false))

    );

  }
  getCurrentUser$(): Observable<User> {
    return this.auth.getCurrentUser();
  }
  private doLogoutUser() {
    this.auth.doLogoutUser();
  }
}

رویکرد AuthStrategy باعث می‌شود برنامه AuthService بسیار انعطاف پذیر باشد، اما اگر نیازی به آن ندارید خوب است که بدون آن کار کنید. تصویر زیر ترکیب عناصر ارائه شده را نشان می‌دهد.

 

Sign-up

کامپوننت ثبت نام بسیار شبیه به کامپوننت ورود است. ما یک کد الگو مشابه با فرم و ورودی داریم. تفاوت اصلی در چیزی است که پس از یک درخواست HTTP موفق اتفاق می‌افتد. در اینجا فقط به صفحه تأیید از ConfirmComponent هدایت می‌شویم.

signup.component.html

<form [formGroup]="signupForm">
  <div class="header">Create your account</div>

  <mat-form-field>
    <input matInput type="email" id="signup_email" placeholder="Email" autocomplete="new-password" formControlName="email" required>
  </mat-form-field>

  <mat-form-field>
    <input matInput type="password" id="signup_password" placeholder="Password" autocomplete="new-password" formControlName="password" required>
  </mat-form-field>

  <div class="actions">
    <button mat-flat-button color="accent" type="submit" (click)="signup()" [disabled]="!signupForm.valid">Sign up</button>
    <div class="separator">
      <span>OR</span>
    </div>
    <button mat-stroked-button routerLink="/login">Login</button>
  </div>

</form>

signup.component.ts

@Component({
  selector: 'signup',
  templateUrl: './signup.component.html',
  styleUrls: ['./../auth.scss']
})

export class SignupComponent implements OnInit {
  signupForm: FormGroup;
  constructor(private authService: AuthService,
    private formBuilder: FormBuilder,
    private router: Router) { }

  ngOnInit() {
    this.signupForm = this.formBuilder.group({
      email: ['', Validators.email],
      password: ['']
    });

  }
  get f() { return this.signupForm.controls; }
  signup() {
    this.authService.signup(
      {
        email: this.f.email.value,
        password: this.f.password.value

      }

    ).subscribe(() => this.router.navigate([this.authService.CONFIRM_PATH]));
  }
}

همچنین توجه داشته باشید که در اینجا از AuthStrategy استفاده نمی‌کنیم. مرحله ثبت نام فقط شامل ارسال لاگین و پسورد جدید به پشتیبان و اطلاع از نیاز به تأیید حساب است.

تایید حساب کاربری

پس از ثبت نام موفقیت آمیز، به کاربر ایمیلی ارسال می‌شود. ایمیل حاوی لینک خاصی با کد تأیید است. این لینک به صفحه کامپوننت تأیید در فرانت-اند برنامه اشاره می‌کند. ConfirmComponent برای کار در 2 حالت طراحی شده است: قبل از تأیید و پس از تأیید موفقیت آمیز. به الگوی زیر نگاه کنید و در عبارت شرطی متوجه فلگ isConfified خواهید شد.

confirm.component.html

<ng-container *ngIf="!isConfirmed; else confirmed">
  <div class="header">We've sent you a confirmation link via email!</div>
  <div>Please confirm your profile.</div>
</ng-container>

<ng-template #confirmed>
  <div class="header">Your profile is confirmed!</div>
  <button mat-flat-button color="primary" routerLink="/login">Login</button>
</ng-template>

چیزی که محتوای نمایش داده شده کامپوننت را تعیین می‌کند ، مقدار بولی است که در ngOnInit تنظیم شده است.

confirm.component.ts

@Component({
  selector: 'confirm',
  templateUrl: './confirm.component.html',
  styleUrls: ['./confirm.component.scss']
})

export class ConfirmComponent implements OnInit {
  isConfirmed = false;
  constructor(private activeRoute: ActivatedRoute, private authService: AuthService) { }

  ngOnInit(): void {
    const email = this.activeRoute.snapshot.queryParams.email;
    const code = this.activeRoute.snapshot.queryParams.code;

    if (email && code) {
      this.authService.confirm(email, code)
        .subscribe(() => this.isConfirmed = true);
    }

  }

}

آخرین بخش هم فقط یک درخواست HTTP برای ارسال ایمیل و کد تأیید مربوطه به پشتیبان در AuthService است.

Auth.service.ts - confirm()

  confirm(email: string, code: string): Observable<void> {
    return this.http.post<any>(`${config.authUrl}/confirm?`, {email, code});
  }

پس از تأیید موفقیت آمیز، صفحه‌ای مانند زیر برای ورود به سیستم نشان داده می‌شود.

توجه: هر زمان که از برخی پارامترهای ارسال شده از طریق لینک (مانند کد تأیید یا بازیابی رمز عبور) استفاده می‌کنید، باید هدرهای Referer را به خاطر بسپارید. چرا که در سال 2020 کروم تنظیمات پیش فرض Referrer-Policy را به strict-origin-when-cross-origin تغییر داد. و بدون آن تنظیمات (که می‌توانید با نیازهای خود تنظیم کنید) همه درخواست‌های منابع اضافی (مانند تجزیه و تحلیل، ویجت، تصاویر و ...) ارسال شده از آن صفحه حاوی URL کامل در هدر Referer بود. به عنوان مثال هنگامی که لینک تأیید را باز می‌کنید و صفحه حاوی منابع شخص ثالث است، درخواست‌های واکشی آنها با هدری با URL کامل از جمله ایمیل و کد ارسال می‌شود. بنابراین به عنوان پیشگیری همیشه مهم است که در برنامه خود یک Referrer-Policy مناسب تنظیم کنید.

شی User

اکنون به جایی رسیدیم که ورود و ثبت نام ما با ویژگی‌های تأیید آماده شده است. حال باید چند بخش دیگر را به سیستم خود اضافه کنیم. سوال این است: چگونه کاربر می‌داند که وارد سیستم شده است یا اینکه چه نقشی دارد؟ بسته به مکانیزم احراز هویت (مبتنی بر کوکی یا مبتنی بر توکن)، روش بازیابی اطلاعات متفاوت است. از آنجا که ما در حال حاضر یک دید کلی در مورد این مکانیزم‌ها داریم، بنابراین می‌توانیم از رابط AuthStrategy استفاده کنیم. به این صورت که متد getCurrentUser با یک Observable از شی کاربر در اختیار ما قرار می‌گیرد.

user.ts

import { Account } from './account';

import { Role } from './types';

export class User {
  id?: string;
  accountId?: string;
  account?: Account;
  email?: string;
  password?: string;
  role?: Role;
  confirmed?: boolean;
  tfa?: boolean;
}

به اجرای هر دو روش توجه کنید. در مورد سشن‌های سمت سرور، اگر نسخه محلی کاربر ثبت شده وجود نداشته باشد، باید از پشتیبان آن بخواهیم و آن را به صورت محلی ذخیره کنیم. در مورد احراز هویت مبتنی بر توکن JWT، فقط باید اطلاعات را از داخل توکن باز کنیم. از آنجا که ما فقط payload را می‌خواهیم، باید رشته را با token.split('.')[1] و تابع window.atob تقسیم کنیم تا فرمت base64 توکن را رمزگشایی کند.

session-auth.strategy.ts - getCurrentUser()

  getCurrentUser(): Observable<User> {
    if (this.loggedUser) {
      return of(this.loggedUser);
    } else {
      return this.http.get<User>(`${config.authUrl}/user`)
        .pipe(tap(user => this.loggedUser = user));
    }
  }

jwt-auth.strategy.ts - getCurrentUser()

  getCurrentUser(): Observable<User> {
    const token = this.getToken();
    if (token) {
      const encodedPayload = token.split('.')[1];
      const payload = window.atob(encodedPayload);
      return of(JSON.parse(payload));
    } else {
      return of(undefined);
    }
  } 
 getToken() {
    return localStorage.getItem(this.JWT_TOKEN);
  }

تطبیق رابط کاربری

از آنجا که کاربر وارد شده ممکن است نقش خاصی داشته باشد، باید رابط کاربری را بر این اساس تطبیق دهیم. نه تنها مسیرهای خاص در دسترس یا خارج از دسترس قرار گیرند، بلکه برخی عناصر دیگر نیز باید نمایش داده شوند یا پنهان شوند. ممکن است هربار که نیاز است بدانیم آیا عنصر باید با ngIf رندر شود یا نه، نقش کاربر را درخواست کنیم، اما راه هوشمندانه‌تری هم وجود دارد. آنچه من پیشنهاد می‌کنم ایجاد یک دستورالعمل ساختاری سفارشی است که به فهرستی از نقش‌ها نیاز دارد و برای آن یک عنصر مشخص باید نمایش داده شود. این می‌تواند شیوه‌ای زیبا از ترکیب قالب را در اختیار ما قرار دهد. به مثال زیر توجه کنید. این دکمه تنها در صورتی نمایش داده می‌شود که کاربر ثبت شده در حال حاضر دارای نقش "owner" باشد.

  <div class="add">
    <button mat-fab color="primary" (click)="openExpenseDialog()" *forRoles="['owner']">+</button>
  </div>

این امر به لطف اجرای دستور ساختاری forRoles در زیر ارائه شده است.

import { Directive, Input, ViewContainerRef, TemplateRef } from '@angular/core';
import { AuthService } from '../services/auth.service';

@Directive({
  selector: '[forRoles]'
})
export class ForRolesDirective {
  roles: string[];
  @Input()
  set forRoles(roles: string[]|string) {
    if (roles != null) {
      this.roles = Array.isArray(roles) ? roles : [roles];
      this.roles = this.roles.map(r => r.toUpperCase());
    } else {
      this.roles = [];
    }
    this.authService.getUserRole$().subscribe(
      role => {
        if (role && !this.roles.includes(role.toUpperCase())) {
          this.viewContainer.clear();
        } else {
          this.viewContainer.createEmbeddedView(this.templateRef);
        }
     }
    );
  }

  constructor(
    private viewContainer: ViewContainerRef,
    private templateRef: TemplateRef<any>,
    private authService: AuthService) { }
}

به یاد داشته باشید که دستور مورد نظر باید در یک ماژول انگولار تعریف شود. در این مثال ما آن را در AuthModule تعریف کرده و اکسپورت می‌کنیم تا در اختیار دیگران قرار گیرد.

حفاظت از مسیرها

علاوه بر تعیین مجوز و نقش کاربران برای دیدن عناصر رابط کاربری، باید در سطح بالاتر دسترسی به مسیرهای برنامه را نیز محدود کرد. به لطف مسیریابی سطح بالا و تفکیک authentication و application، این کار بسیار آسان است. بدین ترتیب به روتر گاردها نیاز داریم که دسترسی به این دو قسمت را کنترل کند.

@Injectable({
  providedIn: 'root'
})

export class AppGuard implements CanActivate {
  constructor(private authService: AuthService, private router: Router) { }
  canActivate(): Observable<boolean> {
    return this.authService.isLoggedIn$().pipe(
      tap(isLoggedIn => {
        if (!isLoggedIn) { this.router.navigate(['/login']); }
      })
    );
  }
}

منطق موجود در AppGuard می‌گوید: اگر کاربر وارد سیستم نشده باشد، او را به صفحه ورود هدایت کنید و اجازه دسترسی به صفحه مورد نظر را ندهید.

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {

  constructor(private authService: AuthService, private router: Router) { }

  canActivate(): Observable<boolean> {
    return this.authService.isLoggedIn$().pipe(
      tap(isLoggedIn => {
        if (isLoggedIn) {
          this.router.navigate([this.authService.INITIAL_PATH]);
        }
      }),
      map(isLoggedIn => !isLoggedIn)
    );
  }
}

 

از طرف دیگر، دستور موجود در AuthGuard دقیقا برعکس است و می‌گوید: اگر کاربر وارد سیستم شده باشد، اجازه ندهید صفحه ورود به سیستم نشان داده شود و او را به صفحه پیش فرض هدایت کنید. ما نحوه ثبت AppGuard را در مسیریابی اصلی مشاهده کرده‌ایم. اکنون مرحله بعدی ثبت AuthGuard در AuthRoutingModule است.

const routes: Routes = [
  {
    path: 'login', component: LoginComponent,
    canActivate: [AuthGuard]
  },
  {
    path: 'signup', component: SignupComponent,
    canActivate: [AuthGuard]
  },
  {
    path: 'confirm', component: ConfirmComponent,
    canActivate: [AuthGuard]
  }
];

@NgModule({
  imports: [RouterModule.forChild(routes)],
  exports: [RouterModule]
})
export class AuthRoutingModule { }

 

درخواست احراز هویت توسط API

آخرین اِلمان در سیستم، احراز هویت درخواست‌های خروجی است. هنگام استفاده از کوکی‌ها نیازی به انجام هیچ کاری نداریم، به این دلیل که session-id در هر کوئری HTTP متصل شده است.

در مورد JSON Web Token هم باید یک کد اختصاصی برای اضافه کردن یک هدر Authentication با یک توکن به درخواست‌ها داشته باشیم. راحت ترین راه استفاده از HttpInterceptor است. به بررسی شرط احراز هویت توجه کنید. می‌خواهیم توکن را فقط در صورت لزوم متصل کنیم.

@Injectable()
export class AuthInterceptor implements HttpInterceptor {
  constructor(private authService: AuthService, @Inject(AUTH_STRATEGY) private jwt: JwtAuthStrategy) { }
  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    if (config.auth === 'token' && this.jwt && this.jwt.getToken()) {
      request = this.addToken(request, this.jwt.getToken());
    }
    return next.handle(request).pipe(catchError(error => {
      if (error.status === 401) {
        this.authService.doLogoutAndRedirectToLogin();
      }
      return throwError(error);
    }));

  }
  private addToken(request: HttpRequest<any>, token: string) {
    return request.clone({
      setHeaders: { 'Authorization': `Bearer ${token}` }
    });
  }
}

در نهایت interceptor باید در لیست providers در AuthModule به صورت زیر ثبت شود.

@NgModule({
  declarations: [ ... ],
  exports: [ ... ],
  imports: [ ... ],
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: AuthInterceptor,
      multi: true
    },
    ...
  ]
})
export class AuthModule { }

 

جمع‌بندی

با وجود اینکه ما یک راه حل کامل و قوی داریم، رویکردهای بسیار دیگری نیز وجود دارد که می‌توانیم در سیستم برای بهبود امنیت آن پیاده سازی کنیم.

اول از همه، احراز هویت دو مرحله‌ای (2FA) این روزها اهمیت بیشتری پیدا می‌کند. هکرها از استراتژی‌های مختلفی برای دسترسی غیرمجاز به اکانت مانند brute-force، credential stuffing،session hijacking و موارد دیگر استفاده می‌کنند. یکی از ساده‌ترین راه‌های پیاده سازی 2FA استفاده از Google Authenticator در نظر گرفته می‌شود، اما این موضوع خارج از محدوده مورد بحث در این مقاله است. یکی دیگر از راه‌های افزایش امنیت سیستم ورود، خنثی کردن تلاش‌های ناموفق برای ورود غیرمجاز است. پیاده سازی این کار بسیار دشوار است، زیرا اگر ورود برخی از کاربران را سهواً مسدود کنیم، هکرها می‌توانند به راحتی Denial-of-Service (DoS) را برای کاربران خاصی اجرا کنند (برای مثال دائماً از رمز عبور اشتباه به صورت خودکار استفاده می‌کنند). اما راه حل‌های هوشمندی هم برای جلوگیری از این اتفاق وجود دارد، مانند کوکی‌های دستگاه و کلاینت‌های قابل اعتماد.

در نهایت، پیاده سازی ما ویژگی بسیار مهمی برای بازیابی اکانت (بازیابی پسورد) ندارد. اما ممکن است در آموزش‌های آینده قرار گیرد.

همچنین باید به خاطر داشته باشیم که خطرات امنیتی زیادی در برنامه‌های وب وجود دارد. آسیب پذیری‌هایی مانند جعل درخواست cross-site هنگام استفاده از کوکی‌ها، XSS هنگام ذخیره توکن‌ها به صورت محلی و مواردی از این قبیل. ناگفته نماند که پیاده سازی JSON Web Tokens بر روی بک-اند هم برای امنیت سیستم بسیار مهم است.

به منظور ایجاد سیستم‌های وب ایمن باید اصول مدل امنیت وب، آسیب پذیری‌های رایج امنیتی و روش‌های پیشگیری را بشناسید. کارهای زیادی برای مراقبت در قسمت فرانت-اند برنامه وجود دارد، اما مهمترین کار از دید امنیت در بک-اند سیستم انجام می‌شود. در مقالات بعدی به این موضوع پرداخته خواهد شد.

در این آموزش یاد گرفتیم که چگونه یک برنامه وب را به همراه صفحه ورود و ثبت نام به صورت کاملا کاربردی با استفاده از فریمورک محبوب انگولار ایجاد کنیم. همچنین تفاوت‌های بین احراز هویت مبتنی بر کوکی و stateless را با JSON Web Tokens تجزیه و تحلیل کردیم و سناریوهای معتبر برای هر دو ارائه دادیم. به علاوه می‌توانید یک کد منبع کامل از مکانیزم‌های ارائه شده در این آموزش را در گیت هاب پیدا کنید.

امیدوارم این آموزش برایتان مفید واقع شود. در صورت تمایل می‌توانید سوالات خود را در بخش زیر مطرح نمایید.

منبع

چه امتیازی به این مقاله می دید؟
خیلی بد
بد
متوسط
خوب
عالی

دیدگاه‌ها و پرسش‌ها

برای ارسال دیدگاه لازم است، ابتدا وارد سایت شوید.

در حال دریافت نظرات از سرور، لطفا منتظر بمانید

در حال دریافت نظرات از سرور، لطفا منتظر بمانید

آفلاین
user-avatar
عرفان حشمتی @heshmati74
مهندس معماری سیستم های کامپیوتری، طراح و توسعه دهنده وب سایت
دنبال کردن

گفتگو‌ برنامه نویسان

بخشی برای حل مشکلات برنامه‌نویسی و مباحث پیرامون آن وارد شو