JWT Authentication on Angular
Web, mobil, masaüstü hangi platform için olursa olsun uygulama geliştirirken çok yüksek ihtimal bir kaynağa erişmek için apilerden faydalanırız. Ya bir sağlayıcının sunduğu hazır apileri kullanırız ya da kendi geliştirdiğimiz apiyi kullanırız. Her iki durumda da apiler ile çalışırken bir güvenlik mekanizması olduğunu biliriz.
Sadece bu güvenlik mekanizması hakkında bir yazım bulunduğu için bu yazımda bahsetmeyeceğim. Eğer okumadıysanız bu yazımdan önce onu okumanız bu yazıyı daha iyi anlamanıza yardımcı olacaktır.
Uygulama Hakkında
Bir kullanıcı girişinin olduğu (LoginComponent) bir de kullanıcı giriş yaptıktan sonra apiden gelen post yazıların listelendiği (HomeComponent) toplam 2 sayfa olacak.
Projenin tamamına github hesabımdan erişebilirsiniz.
- Kullanıcı Adı ve Parola, apinin Authorization rotasında doğrulanacak. (Kullanıcı adı ve parola, geçerli ve gerçekten bir kullanıcıya ait ise Authorization Server bizim için bir Access Token ve Refresh Token üretecek.)
- Kullanıcı giriş yapmadan Home ekranını göremeyecek.
- Access Token bilgisi alındıktan sonra yazıların listeleneceği sayfamıza gideceğiz ve artık yazıları almak için Resource Server’ a bu token ile istekte bulunacağız.
- Logout linkine tıkladığımızda sistem bizi yeniden Login sayfasına yönlendirecek.
- Biz sistemden çıkmadan Access Token’ ımızın süresi dolduğu takdirde Refresh Token ile Access Token yenilenecek ve kullanıcı deneyimimiz aksamadan sayfa içerisinde gezinmeye devam edebileceğiz.
Tahmin edersiniz ki bu uygulama için öncelikle bir apimizin olması gerekiyor. Ben kendi yazdığım apiyi kullanacağım. Aşağıdaki paylaşımdan Api hazılığını okuyabilirsiniz github hesabımdan da apiye erişebilirsiniz.
Adım 1
Angular CLI yardımıyla projemi oluşturuyorum.
// cli
ng new JWT-Authentication-Angular --routing
Adım 2
Projemin dizinine gelip bootstrap kullanabilmek için paketleri indirip ekliyorum.
// cli
npm i bootstrap jquery// angular.json
...
...
...
"assets": [
"src/favicon.ico",
"src/assets"
],
"styles": [
"src/styles.css",
"node_modules/bootstrap/dist/css/bootstrap.min.css"
],
"scripts": [
"node_modules/jquery/dist/jquery.min.js",
"node_modules/bootstrap/dist/js/bootstrap.min.js"
]
...
...
...
Adım 3
Environments’ ta api url’lerimi tanımlıyorum.
// environment.prod.ts
export const environment = {
production: true,
apiUrl: 'http://localhost:8081/api/'
};// environment.ts
export const environment = {
production: false,
apiUrl: 'http://localhost:8080/api/'
};
Test ortamı için 8080, Production ortamı için 8081 portundaki apiyi kullanması gerektiğini belirttim.
Adım 4
Apiye yapılacak ilk istek Login Sayfasından olacağı için önce kullanıcı girişi ile ilgili servisi yazıyorum.
// cli
ng g s _services/authentication/authentication
Angular CLI ile AuthorizationService oluşturuyorum.
// AuthenticationService
export class AuthenticationService {
public userSubject: BehaviorSubject<User>;
constructor(private http: HttpClient) {
this.userSubject = new BehaviorSubject<User>(null);
} public get userValue(): User {
return this.userSubject.value;
}
....
Önce boş bir UserSubject verisi oluşturdum. UserValue ile de user bilgisini alıyorum. Login işleminden sonra UserSubject.next ile bu veriyi dolduracağız.
export class User {
uid: string;
email: string;
displayName: string;
access_token: string;
refresh_token: string;
}
…gibi bir User sınıfı oluşturuyorum.
// AuthenticationService
logIn(email: string, password: string) {
const url: string = `${environment.apiUrl}auth/login`;
const body: any = { email: email, password: password };
return this.http.post<any>(url, body)
.pipe(map(res => {
const user: User = {
uid: res.userId,
email: res.email,
displayName: res.displayName,
access_token: res.access_token,
refresh_token: res.refresh_token
};
this.userSubject.next(user);
localStorage.setItem('user', JSON.stringify(user));
return user;
}));
}
Login metotu ile apinin login end point’ ine email ve password bilgisini gönderiyoruz. Api de bize kullanıcı bilgileri, Access Token ve Refresh Token bilgileri döndürüyor.
// AuthenticationService
refreshToken() {
const url: string = `${environment.apiUrl}auth/token`;
const body: any = {
token : this.userValue.refresh_token
};
return this.http.post<any>(url, body)
.pipe(map((token) => {
const user: User = {
uid: this.userValue.uid,
displayName: this.userValue.displayName,
email: this.userValue.email,
access_token: token.access_token,
refresh_token: this.userValue.refresh_token
}
this.userSubject.next(user);
localStorage.setItem('user', JSON.stringify(user));
return user;
}));
}
refreshToken metotunu Access Token’ ı yenilemek için kullanıyoruz. Apinin token endpoint’ ine Refresh Token’ ı gönderiyoruz ve bize kullanıcı bilgisi, Access Token ve Refresh Token datası döndürüyor.
// AuthenticationService
logout() {
const url: string = `${environment.apiUrl}auth/token/${this.userValue.refresh_token}`;
let options = {
headers: new HttpHeaders()
.set('Content-Type', 'application/json');
}
this.http.delete<any>(url, options).subscribe();
localStorage.removeItem('user');
this.userSubject.next(null);
}
logout metotu ile refresh token’ ı apiye gönderiyoruz. Gelen cevaba göre de eğer çıkış işlemi başarılıysa kullanıcı bilgilerini temizliyoruz.
Adım 5
Kullanıcının Giriş yapmadan Home sayfasını görememesi için bir Guard oluturuyorum.
// cli
ng g g _guards/auth/auth
Seçeneklerden CanActivate seçiyorum.
// AuthGuard
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot) {
const user = this.authenticationService.userValue;
if (user) {
return true;
} else {
this.router.navigate(['/login'],
{
queryParams: { returnUrl: state.url }
});
return false;
}
}
AuthenticationService’ te userValue eğer dolu ise true değeri dönüyoruz ve kullanıcı Home sayfasını görebiliyor. Ancak kullanıcı datası boş ise demek ki giriş yapmış bir kullanıcı yok bu nedenle false dönüyoruz.
queryParams olarak döndüğümüz returnUrl isteğe bağlı. Peki ne zaman kullanırız? Mesela kullanıcı hiç giriş yapmadı kendisine bir yazı linki geldi ve açmak istedi. Tıkladı ama açamadı çünkü giriş yapması lazım. Giriş yaptıktan sonra klasik ana sayfaya yönlendirmek yerine direkt o yazıya yönlendirebilmek için kullanıyoruz.
Oluşturduğumuz Guard’ ı routing ayarlarında kullanacağız.
Adım 6
// cli
ng g c _components/login
Authentication Servisini kullanacak Login sayfasını hazırlayalım.
// LoginComponent
<div class="row">
<form [formGroup]="loginForm" (ngSubmit)="submit()">
<div class="form-group">
<label for="exampleInputEmail1">Email address</label>
<input formControlName="email" type="email" class="form-control" id="exampleInputEmail1" aria-describedby="emailHelp">
</div>
<div class="form-group">
<label for="exampleInputPassword1">Password</label>
<input formControlName="password" type="password" class="form-control" id="exampleInputPassword1">
</div>
<button type="submit" class="btn btn-primary">login</button>
</form>
</div>
Email ve parola alanı için iki input ve bir butonu olan bir reactive form ekledim.
// LoginComponent
loginForm : FormGroup;
returnUrl : string;constructor(
private fb : FormBuilder,
private route: ActivatedRoute,
private router : Router,
private _auth : AuthenticationService
) {}ngOnInit(): void {
this.createForm();
this.returnUrl = this.route.snapshot.queryParams['returnUrl'] || '/';
}get f(){
return this.loginForm.value;
}createForm(){
this.loginForm = this.fb.group({
email : ['test@test.com',[Validators.required]],
password : ['123456',[Validators.required]]
});
}async submit(){
const {email,password} = this.f;
const user : User = await this._auth.logIn(email,password).toPromise();
this.router.navigate(['/home']);
}
- loginForm adında bir reactive form oluşturdum.
- AuthGuard’ dan gelen return url’ i yakalamak için bir returnUrl değişkeni oluşturup ona atadım.
- loginForm’ da dolan email ve password bilgilerini AuthenticationService’ e gönderdim.
Adım 7
JwtInterceptor oluşturma
app klasörü altında bir _helpers klasörü oluşturup içinde jwt.interceptor.ts adında bir boş dosya oluştuyorum.
// JwtInterceptor
import { Injectable } from '@angular/core';
import {
HttpRequest,
HttpHandler,
HttpEvent,
HttpInterceptor
} from '@angular/common/http';
İlk başta lazım olacakları import ediyorum. Bir servis gibi Injectable olacak ve Http kütüphanesinin elemanlarını kullanacak.
// JwtInterceptor
@Injectable()export class JwtInterceptor implements HttpInterceptor {
...
}
HttpInterceptor interface’ inden implemente ediyorum.
// JwtInterceptor
intercept(
request: HttpRequest<any>,
next: HttpHandler): Observable<HttpEvent<any>> {
const user = this._auth.userValue;
const isLoggedIn = user && user.access_token;
const isApiUrl = request.url.startsWith(environment.apiUrl);
if (isLoggedIn && isApiUrl) {
request = request.clone({
setHeaders: {
'Authorization': `Bearer ${user.access_token}`
}
})
}
return next.handle(request);
}
interceptor kelime anlamı kesicidir. Bu kesici ne yapar açıklamaya çalışayım.
const user = this._auth.userValue;
const isLoggedIn = user && user.access_token;
const isApiUrl = request.url.startsWith(environment.apiUrl);
if (isLoggedIn && isApiUrl) {
request = request.clone({
setHeaders: {
'Authorization': `Bearer ${user.access_token}`
}
});
}
return next.handle(request)
- Kullanıcı datası boş değilse, Access Token varsa ve istek yapılan endpoint bizim kullanmakta olduğumuz apinin bir end pointi ise bizim isteğimizin Header bilgisine Token’ ı ekle yoksa devam et.
App Module sağlayıcılara ekliyoruz.
// app.imports.ts
export const PROVIDERS : any[] = [
{
provide: HTTP_INTERCEPTORS,
useClass: JwtInterceptor,
multi: true
}
];// app.module.ts
providers: [
PROVIDERS
]
Adım 8
PostServisi oluşturuyorum.
// cli
ng g s _services/post/post
Bu servis apiden yazıları alıp getirecek.
export class Post{
id : number;
userId : number;
title : string;
body : string;
}
… gibi bir Post sınıfı oluşturuyorum.
// PostService
constructor(private http : HttpClient) { }getAllPosts(){
const url : string = `${environment.apiUrl}posts`;
let options = {
headers : new HttpHeaders()
.set('Content-Type', 'application/json')
}
return this.http.get<Post[]>(url,options).toPromise();
}
‘…api/posts’ endpointine bir get isteği yaptık ve gelen cevabı return ettik. Burada önemli olan nokta şudur. Bu endpoint Resource Server’ da, yani bizim doğrulama için token göndermemiz gerekiyordu ama header bilgisinde yok.
İşte bir önceki adımda yazdığımız JwtInterceptor bu görevi yapıyor. Bizim apimize giden isteğe bakıp token bilgisini otomatik olarak ekliyor.
Adım 9
Yazıları listeleyeceğimiz bir HomeComponent oluşturuyorum.
// cli
ng g c _components/home
Home component’ te yazılar listelenecek ve çıkış yapmak için bir logout butonu olacak
// HomeComponent
<br><h2 style="cursor: pointer;" (click)="logout()">Logout</h2>
<hr><div class="row" style="padding-right: 10%;padding-left: 10%;">
<div class="col-4" style="margin-top: 10px;" *ngFor="let post of posts">
<div class="card" style="min-height: 280px; ">
<div class="card-header" [innerText]="[post?.title]"></div>
<div class="card-body">
<blockquote class="blockquote mb-0">
<footer class="blockquote-footer" [innerText]="[post?.body]"></footer>
</blockquote> </div>
</div>
</div>
</div>// HomeComponent
posts : Post[];
constructor(
private router : Router,
private _auth : AuthenticationService,
private _post : PostService
) { }ngOnInit(): void {
this.getAllPosts();
}async getAllPosts(){
this.posts = await this._post.getAllPosts();
console.log(this.posts);
}logout(){
this._auth.logout();
this.router.navigate(['/login']);
}
Adım 10
Routing ayarlıyorum.
// app.routing.ts
const routes: Routes = [
{
path : '',
redirectTo : 'home',
pathMatch : 'full'
},
{
path : 'login',
component : LoginComponent
},
{
path : 'home',
component : HomeComponent,
canActivate : [AuthGuard]
}
];
Adım 11
Şimdi de Hata yakalama Interceptor’ unu hazırlayacağım. Bu benim http requestlerinden dönen hataları yakalamama yardımcı olacak.
@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
...
}
JwtInterceptor’ da olduğu gibi bunu da HttpInterceptor’ dan implemente ediyorum.
// ErrorInterceptor
intercept(
request: HttpRequest<any>,
next: HttpHandler): Observable<HttpEvent<any>> {
return next.handle(request).pipe(catchError(err => {
if ([401, 403].includes(err.status) && this._auth.userValue) {
return next.handle(request).pipe(
catchError(error => {
if (error instanceof HttpErrorResponse && error.status === 401) {
...............
} else {
return throwError(error);
}
}));
}const error = (err && err.error && err.error.message) || err.statusText;return throwError(error);}))
}
Genele bakarsak istekte oluşan hataları fırlattığımız bir işleyiş mevcut. Ama şu satırları inceleyelim.
if (error instanceof HttpErrorResponse && error.status === 401) {
...............
} else {
return throwError(error);
}
401 dışındaki hataları bir şekilde dönderdim ama 401 için ayrıca bir durum yazacağım.
401 Access Token invalid uyarısıdır. Yani eğer benim daha önceden bir erişim jetonum varsa süresi bitti anlamına gelir. O halde ben burada jetonumu yenileme metotumu yazmalıyım.
// ErrorInterceptor
private isRefreshing = false;private refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);
isRefreshing ve refreshTokenSubject datalarını tanımladım.
// ErrorInterceptor
private handle401Error(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (!this.isRefreshing) {
this.isRefreshing = true;
this.refreshTokenSubject.next(null);
return this._auth.refreshToken().pipe(
switchMap((user: User) => {
this.isRefreshing = false;
this.refreshTokenSubject.next(user.access_token);
return next.handle(
request = request.clone({
setHeaders: {
'Authorization': `Bearer ${user.access_token}`
}
});
);
}));
} else {
return this.refreshTokenSubject.pipe(
filter(token => token != null),
take(1),
switchMap(jwt => {
return next.handle(
request = request.clone({
setHeaders: {
'Authorization': `Bearer ${jwt}`
}
});
);
})
);
}
}
Yani son durumda blok şu şekilde olacak.
if (error instanceof HttpErrorResponse && error.status === 401) {
return this.handle401Error(request, next);
} else {
return throwError(error);
}
App Module sağlayıcılarına bu Interceptor’ u da eklememiz gerekecek.
// app.imports.ts
export const PROVIDERS : any[] = [
{
provide: HTTP_INTERCEPTORS,
useClass: JwtInterceptor,
multi: true
},
{
provide: HTTP_INTERCEPTORS,
useClass: ErrorInterceptor,
multi: true
}
];
Adım 12
Eğer bir kullanıcı giriş yapmışsa (localStorage’ da bir user bilgisi varsa) kullanıcının yeniden giriş yapmamasını sağlamak için app dosyasında geliştirme yapıyorum.
// AppComponent
title = 'JWT-Authentication-Angular';
constructor(private _auth: AuthenticationService) {
let user = localStorage.getItem('user')
if (typeof user !== 'undefined' && user !== null && _auth.userValue == null) {
_auth.userSubject.next(JSON.parse(user));
}
}
Özet
JWT-Authentication-Angular uygulaması ile
- Authorization Server’ a kullanıcı adı ve parola bilgisi gönderip Access Token ve Refresh Token bilgisi aldık.
- HttpInterceptor kullanarak Resource Server’ a istekte bulunduk.
- HttpInterceptor yardımıyla Access Token süresi dolduğunda Refresh Token bilgisi ile hızlıca yenildik.