JWT Authentication
ავთენტიფიკაცია გულისხმობს არა მხოლოდ ანგარიშში შესასვლელი მოთხოვნის გაგზავნას, არამედ შედეგად დაბრუნებული საავთენტიფიკაციო ინფორმაციის შენახვას და მის გამოყენებას ისეთი ენფოინთებზე, რომელიც სათანადო პრივილეგიებს საჭიროებს, ამ ავთენტიფიკაციის ინფორმაციის ჰედერებში მიწოდებით. ჩვეულებრივ ეს ინფორმაცია გულისხმობს ტოკენებს, კერძოდ JSON Web Token-ებს (JWT). ჩვენ სწორედ ასეთ ტოკენებთან ვიმუშავებთ.
ამ ნიმუშში ვიხელმძღვანელებთ ბიბლიოთეკით @auth0/angular-jwt
, რომელიც JWT ტოკენებთან მუშაობას უფრო ამარტივებს.
npm install @auth0/angular-jwt
ჩვენი ანგულარის კონფიგურაცია შემდეგნაირად გამოიყურება. app.config.ts
-ში პროვაიდერებში გვაქვს შემოტანილი
provideHttpClient
და JwtModule
(რომელიც npm-ით დავაინსტალირეთ). provideHttpClient
-ს უნდა მივაწოდოთ
პარამეტრი withInterceptorsFromDi()
, რადგან JwtModule
ინტერსეპტორებს იყენებს ტოკენის დასამატებლად.
ვინაიდან JwtModule
მოდულზე დაფუძნებული ბიბლიოთეკაა, მისი დარეგისტრირება საჭიროა importProvidersFrom
ფუნქციაში:
import { ApplicationConfig, importProvidersFrom } from "@angular/core";
import {
provideHttpClient,
withInterceptorsFromDi,
} from "@angular/common/http";
import { JwtModule } from "@auth0/angular-jwt";
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptorsFromDi()),
importProvidersFrom(
JwtModule.forRoot({
config: {
tokenGetter: () => localStorage.getItem("access_token"),
allowedDomains: ["dummyjson.com"],
},
})
),
],
};
ამ მოდულს forRoot
ში უნდა მივაწოდოთ კონფიგურაცია. ერთი მხრივ, ტოკენის
გეთერი ფუნქცია - ჩვენ მას შევინახავთ და ავიღებთ ლოკალური მეხსიერებიდან, ამიტომ ანონიმურ
ფუნქციაში დავაბრუნოთ localStorage.getItem
და ჩვენი ტოკენის key.
allowdDomains
არასავალდებულო კონფიგურაციაა, სადაც შეგვიძლია განვსაზღვროთ,
მოდულმა რომელ დომეინებზე უნდა იმუშაოს. შესაძლოა გვაქვს რომელიმე დომეინი,
სადაც ტოკენის მიწოდება არ გვჭირდება. ჩვენ აქ უბრალოდ ჩვენი ბექენდის დომეინს
ჩავწერთ.
კონკრეტულად რას აკეთებს ეს ბიბლიოთეკა? მარტივად რომ ვთქვათ, ის მეხსიერებიდან
იღებს ჩვენ ტოკენს და მას Http ჰედერებს ავტომატურად აბამს, რომ ჩვენ ამის
გაკეთება არ მოგვიწიოს ყოველ მოთხოვნაზე. ამას ეს ბიბლიოთეკა HttpInterceptor
-ის
საშუალებით აკეთებს.
ჩვებულებრივ ამას ასე გავაკეთებდით:
import { Injectable } from "@angular/core";
import { ShoppingCart } from "../types/cart.model";
import { AuthService } from "./auth.service";
@Injectable({ providedIn: "root" })
export class CartService {
constructor(private http: HttpClient, private authService: AuthService) {}
getCartsForUser() {
return this.http.get<{ carts: ShoppingCart[] }>(
`https://dummyjson.com/auth/carts/user/`,
{
headers: {
Authentication: `Bearer ${authService.getToken()}`,
},
}
);
}
}
ეს უბრალოდ ნიმუშია და არ წარმოადგენს ჩვენი აპლიკაციის ნაწილს.
ჩვეულებრივ ვაგზავნით მოთხოვნას რაღაც ენდფოინთზე, და დამატებით ვაწვდით
მეორე არგუმენტად კონფიგურაციის ობიექტს, სადაც ჰედერებს ვაყენებთ,
კერძოდ Authentication
ჰედერს, რომელიც არის Bearer
და ამას
მოყვება მეხსიერებაში შენახული ტოკენი, რომელიც შეიძლება ავტენტიფიკაციის
სერვისით ავიღოთ. JwtModule
-ის დახმარებით ამის გაკეთება არ გვჭირდება.
მეტიც, ამ ბიბლიოთეკით ტოკენის დეკოდირება და მისი ვადის შემოწმებაც შეგვიძლია.
შევხედოთ ჩვენი როუთინგის კონფიგურაციას app.routes.ts
-ში:
import { Routes } from "@angular/router";
import { AuthComponent } from "./auth/auth.component";
import { LogoutComponent } from "./logout/logout.component";
import { ShoppingCartComponent } from "./shopping-cart/shopping-cart.component";
export const routes: Routes = [
{ path: "auth", component: AuthComponent },
{ path: "logout", component: LogoutComponent },
{
path: "cart",
component: ShoppingCartComponent,
},
{ path: "", redirectTo: "cart", pathMatch: "full" },
];
ჩვენ გვაქვს სამი ძირითადი მისამართი და სამი შესაბამისი კომპონენტი.
auth
მისამართზე ჩვენ გვექნება კომპონენტი სადაც მომხმარებელი
თავის ინფორმაციას შეიყვანს და საპასუხოდ მიიღებს ტოკენს.
logout
მისამართზე მომხმარებელი ანგარიშიდან გავა.
cart
მისამართზე მომხმარებელმა უნდა შეძლოს თავის ექაუნთზე
არსებული საშოპინგო კალათის ნახვა. თავიდანვე გადამისამართება
მოხდება ამ კალათის გვერდზე, თუმცა მომხმარებელი რადგან
ავთენტიფიცირებული არ არის ის შედეგს ვერ დაინახავს.
ასე გამოიყურება ჩვენი AppComponent
-ის თემფლეითი:
<header>
<nav>
<ul>
<li>
<a routerLink="/auth">Login/Register</a>
</li>
<li>
<a routerLink="/logout">logout</a>
</li>
</ul>
</nav>
</header>
<router-outlet></router-outlet>
გვაქვს ლინკები auth
და logout
გვერდებზე, და, რა თქმა უნდა, აუთლეტი.
types
ფოლდერში გვაქვს შექმნილი პროდუქტის და კალათის მოდელი.
product.model.ts
export interface Product {
id: number;
title: string;
description: string;
price: number;
discountPercentage: number;
rating: number;
stock: number;
brand: string;
category: string;
thumbnail: string;
images: string[];
}
types/cart.model.ts
import { Product } from "./product.model";
export interface ShoppingCart {
id: number;
products: Product[];
}
ცალკე სერვისების ფოლდერში მოვათავსებთ სერვისებს. ჯერ მივხედოთ ავთენტიფიკაციის ლოგიკას.
types/services/auth.service.ts
import { HttpClient } from "@angular/common/http";
import { inject, Injectable } from "@angular/core";
import {
ActivatedRouteSnapshot,
CanActivateFn,
Router,
RouterStateSnapshot,
} from "@angular/router";
import { tap } from "rxjs";
interface LoginResponse {
id: number;
username: string;
email: string;
firstName: string;
lastName: string;
gender: string;
image: string;
token: string;
}
@Injectable({ providedIn: "root" })
export class AuthService {
constructor(private http: HttpClient, private router: Router) {}
login(credentials: { username: string; password: string }) {
return this.http
.post<LoginResponse>(
"https://dummyjson.com/auth/login",
JSON.stringify(credentials),
{ headers: { "Content-Type": "application/json" } }
)
.pipe(
tap((response) => {
localStorage.setItem("access_token", response.token);
localStorage.setItem("user", JSON.stringify(response));
this.router.navigate(["/"]);
})
);
}
logout() {
localStorage.removeItem("access_token");
localStorage.removeItem("user");
this.router.navigate(["/"]);
}
getUserId() {
const user = localStorage.getItem("user");
if (user) {
return JSON.parse(user).id;
} else {
return null;
}
}
}
ჩვენ ამ სერვისში არა მხოლოდ HTTP მოთხოვნებს გავგზავნით, არამედ რაუთინგსაც ვაწარმოებთ და ტოკენებსაც ვმართავთ.
login
მეთოდით მომხმარებელს შევიყვანთ ექაუნთში. ავიღებთ
პაროლსა და მეილს და მას სათანადო ენდფოინთზე გავგზავნით
post მოთხოვნით. აქ მესამე არგუმენტად ჩვენ HTTP ჰედერებს
ვაკონკრეტებთ. ანუ მოთხოვნის შესახებ დამატებით ინფორმაციას.
ამ სპეციფიკური ბექენდისთვის საჭიროა, რომ მივუთითოთ
Content-Type
რომელიც იქნება application/json
.
ამ მოთხოვნაზე ვიყენებთ pipe
მეთოდს და მასში ვუძახებთ
tap
ოპერატორს (rxjs
-დან). ეს ოპერატორი საშუალებას
გვაძლევს რომ შედეგს ჩავწვდეთ სტრიმის მოდიფიკაციის გარეშე
და რაიმე გვერდითი მოვლენის მსგავსი ოპერაციები ჩავატაროთ.
ჩვენ LoginResponse
ტიპის პასუხს ვიღებთ და აქედან ტოკენსა და
მომხმარებლის ინფორმაციას ვინახავთ ლოკალურ მეხსიერებაში access_token
-ისა
და user
-ის სახელების ქვეშ.
იდეაში მარტო ტოკენიც საკმარისია, რადგან მისი დეკოდირებული ვერსია
შეიცავს მოხმარებლის მონაცემებს, თუმცა ზედმეტი დეკოდირებისგან თავი
რომ ავირიდოთ, პირდაპირ მომხმარებელიც შევინახოთ.
შემდგომ ჩვენ ნავიგაციას ვაკეთებთ მთავარ გვერდზე, რომელიც მოხმარებელს
საშოპინგო კალათაზე გადაიყვანს, ახლა უკვე ავთენტიფიცირებულს.
აქვე ვქმნით logout
მეთოდს, რომელიც მეხსიერებიდან ავთენტიფიკაციის
ინფორმაციას წაშლის და მომხმარებელს მთავარ გვერდზე გადაიყვანს.
რაუტინგმა უნდა მოაგვაროს ის, თუ ავთენტიფიკაციის მიხედვით რომელ
გვერდზე შეუძლია მოხმარებელს გადასვლა. ამას სხვა თავში მივხედავთ
(სახელდობრ CanActivate თავში).
ბოლოს ვქმნით მეთოდს getUserId
, რომლითაც შეგვიძლია მოხმარებლის
აიდის აღება ლოკალური მეხსიერებიდან. ეს უკანასკნელი დაგვჭირდება
სწორი საშოპინგო კალათის მისაღებად.
ახლა AuthComponent
-ში ეს სერვისი გამოვიყენოთ:
import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { ReactiveFormsModule, FormBuilder, Validators } from "@angular/forms";
import { AuthService } from "../services/auth.service";
@Component({
selector: "app-auth",
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: "./auth.component.html",
styleUrls: ["./auth.component.css"],
})
export class AuthComponent {
loginForm = this.fb.nonNullable.group({
username: ["", Validators.required],
password: ["", Validators.required],
});
constructor(private fb: FormBuilder, private authService: AuthService) {}
login() {
if (this.loginForm.valid) {
this.authService
.login(this.loginForm.getRawValue())
.subscribe((response) => {
console.log(response);
});
}
}
}
ჩვენ უბრალოდ ფორმას ვქმნით FormBuilder
-ით და მის მნიშვნელობას
ვაწვდით AuthService
-ზე login
მეთოდს. შედეგს სანიმუშოდ კონსოლში ვლოგავთ.
ფორმის ჯგუფს თემლფეითთან ვაკავშირებთ:
<form [formGroup]="loginForm" (ngSubmit)="login()">
<h1>Log In</h1>
<div>
<label for="username">Username</label>
<input type="text" id="username" formControlName="username" />
</div>
<div>
<label for="password">Password</label>
<input type="password" id="password" formControlName="password" />
</div>
<button type="submit" [disabled]="loginForm.invalid">log in</button>
</form>
ასე API-ის დოკუმენტაციაში არსებული მოხმარებლების მონაცემებს თუ შევიყვანთ
ველში და დავასაბმითებთ, ჩვენ პასუხად უნდა მივიღოთ მოხმარებლის მონაცემები
და ტოკენი, რომელიც ლოკალურ მეხსიერებაშიც უნდა განთავსდეს. ჩვენ ასევე
გადამისამართებულები ვიქნებით მთავარ გვერდზე, რომელიც თავის მხრივ cart
მისამართზე გადაგვიყვანს.
LogoutComponent
-ს მივხედოთ, რომელზეც მაშინ გადავალთ, როცა logout
სანავიგაციო ღილაკს დავაჭერთ.
import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { AuthService } from "../services/auth.service";
@Component({
selector: "app-logout",
standalone: true,
imports: [CommonModule],
templateUrl: "./logout.component.html",
styleUrls: ["./logout.component.css"],
})
export class LogoutComponent implements OnInit {
constructor(private authService: AuthService) {}
ngOnInit(): void {
this.authService.logout();
}
}
ინიციალიზაციის დროს ჩვენ უბრალოდ სერვისზე დავუძახებთ logout
-ს,
რათა ინფორმაცია წაიშალოს და ჩვენ მთავარ გვერდზე გადავიდეთ.
აქ შეიძლება უკუთვლა გამოვაჩინოთ და მომხმარებელს ვანიშნოთ,
რომ შეუძლია ამ მოქმედების გაუქმება. ეს სურვილისამებრ თქვენით დაამატეთ.
ახლა მოხმარებლის კალათა ავამუშავოთ. ჯერ შევქმნათ სერვისი, საიდანაც მონაცემებს მივიღებთ:
import { HttpClient } from "@angular/common/http";
import { Injectable } from "@angular/core";
import { ShoppingCart } from "../types/cart.model";
import { AuthService } from "./auth.service";
@Injectable({ providedIn: "root" })
export class CartService {
constructor(private http: HttpClient, private authService: AuthService) {}
getCartsForUser() {
return this.http.get<{ carts: ShoppingCart[] }>(
`https://dummyjson.com/auth/carts/user/${this.authService.getUserId()}`
);
}
}
აქ ჩვენ ასევე ვაინჯექთებთ AuthService
-ს და მისი საშუალებით მოხმარებლის
აიდის ვიღებთ, რომელსაც ენდფოინთის ბოლოში ვამატებთ. /auth
მისამართზე
არსებული მონაცემები ხელმისაწვდომია მხოლოდ ტოკენის საშუალებით. ამ ტოკენს
JwtModule
ჩვენ მაგივრად მისცემს ამ მოთხოვნას. ამ მოდულმა უკვე იცის,
საიდან უნდა აიღოს ტოკენი. საბოლოოდ getCartsForUser
მეთოდი მოგვცემს Obsevable
-ს რომელიც დააბრუნებს ობიექტს. ამ ობექტის ერთ-ერთი
თვისებაა cart
სადაც ჩვენთვის საჭირო მონაცემებია.
ამ მეთოდს დავუძახებთ ShoppingCart
კომპონენტში:
import { Component, OnInit } from "@angular/core";
import { CommonModule } from "@angular/common";
import { CartService } from "../services/cart.service";
import { ShoppingCart } from "../types/cart.model";
@Component({
selector: "app-shopping-cart",
standalone: true,
imports: [CommonModule],
templateUrl: "./shopping-cart.component.html",
styleUrls: ["./shopping-cart.component.css"],
})
export class ShoppingCartComponent implements OnInit {
carts: ShoppingCart[] = [];
constructor(private cartService: CartService) {}
ngOnInit(): void {
this.cartService.getCartsForUser().subscribe((response) => {
this.carts = response.carts;
});
}
}
ჩვენ უბრალოდ ვაინჯექთებთ CartService
-ს და მისი საშუალებით კალათის ინფორმაციას
ვიღებთ. ამ ინფორმაციას კლასის თვისებაში ვინახავთ. შემდგომ ამ ყველაფერს თემფლეითში
განვათავსებთ:
<div *ngFor="let cart of carts">
<h1>Shopping Cart:</h1>
<div *ngFor="let product of cart.products">
<h2>{{ product.title }}</h2>
<h3>{{ product.price | currency }}</h3>
</div>
</div>
დავლუპავთ კალათებზე (რადგან ის მასივშია, ანუ ერთზე მეტი შეიძლება იყოს) და მის შიგნით არსებულ პროდუქტებზეც. აქ მარტივად სათაურს და ფასს გამოვსახავთ.
ასე აპლიკაცია სანახევროდ მუშაობს. პრობლემა ის არის, რომ ჩვენ თუ ანგარიშიდან
გავედით, მაინც შევძლებთ /cart
მისამართზე გადასვლას, მოთხოვნა ტყუილად და
წარუმატებლად გაიგზავნება. როგორმე მოხმარებელს არ უნდა მივცეთ საშუალება, რომ
ამ გვერდზე გადავიდეს, თუ ის ავთენტიფიცირებული არ არის. ამაზე ვისაუბრებთ შემდეგ
თავში.
შეჯამება
ჩვენ ამ თავში ანგულარში ვისწავლეთ ავთენტიფიკაცია JWT-ის საშუალებით. ჩვენ გამოვიეყნეთ
@auth0/angular-jwt
რომელიც ჩვენ მაგივრად ამატებს ტოკენს მოთხოვნების ჰედერებში.
ჩვენ შევქმენით ავთენტიფიკაციის სერვისი, სადაც ვმართავთ ანგარიშში შესვლას, ანუ
ტოკენისა და მომხმარებლის მონაცემების მიღებასადა შენახვას, და ანგარიშიდან გასვლას,
ანუ მოხმარებლის მონაცემებისა და ტოკენის მეხსიერებიდან წაშლას. ხშირად ამ დროს
შეიძლება დაგვჭირდეს მოხმარებლის სხვადასხვა გვერდზე გადამისამართება. ამ სერვისს
ვიყენებთ არა მხოლოდ კომპონენტებში, არამედ იმ სერვისებშიც, სადაც ავთენტიფიკაციის
შესახებ ინფორმაცია გვჭირდება.