NodeJS ve Angular Üzerinde Authentication ve Security
Selamlar,
https://youtu.be/41DTY1tXIi4
Bu makale, bundan önce yazılan NodeJs&MongoDb ve NodeJs&Swagger makalelerinin devamıdır. Burada amaç, tüm projeye kapsamlı bir security module’ünü adapte etmektir. Gelin işe önce Login ekranını, Angular projeye eklemek ile başlıyalım. Nede olsa güvenlik denince, ilk akla gelen Login ekranıdır :)
Login.html: Aşağıda görüldüğü gibi, [(ngModel)]=”userName” ve [(ngModel)]=”password” değişkenlere atanmıştır. Giriş button’u tıklandığında, “Redirect()” methoduna gidilmektedir. Login.scss dosyasına, sayfanın sonundaki proje url’inden erişilebilir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
<div id="form-wrapper"> <div class="container"> <div class="row"> <div class="col-md-offset-5 col-md-3" class="form-signin"> <div class="form-login"> <h3>Token Login Page</h3> <input type="text" [(ngModel)]="userName" id="userName" class="form-control input-sm chat-input" placeholder="Kullanıcı Adı Giriniz" /> <br /> <input type="password" [(ngModel)]="password" id="password" class="form-control input-sm chat-input" placeholder="Şifrenizi Giriniz" /> <br /> <div class="wrapper"> <span class="group-btn"> <input type="button" class="btn btn-primary btn-md" (click)="Redirect()" value="Giriş Yapınız"/> </span> </div> </div> </div> </div> </div> </div> |
Login.ts: LoginComponent ==> AfterViewInit sınıfından inherit olmuştur. Böylece sayfa yüklendiği zaman, ngAfterViewInit() methodu devreye girmektedir.
- “selector: ‘app-root'” : Artık default olarak açılacak sayfa “app.component” yerine “login” sayfasıdır.
- “this.service.checkToken().subscribe((data: any) => {” : Makalenin devamında anlatılacak olan “checkToken” servisine gidilerek, önceden login olunmuş ise Local Storage’da bulunan JWT token, Nodejs servisine gönderilip, geçerli olup olunmadığına bakılır.
- “this.router.navigateByUrl(‘person’)” : Eğer gönderilen token son 1 saatden önce girilmiş ise, geçerli olur ve yine makalenin devamında anlatılacak olan app-routing.module‘de tanımlı olan “person” keyword’üne karşılık gelen “AppComponent” sayfasına yönlenilir. Kısaca, sayfa ilk yüklendiğinde 1 saat içinde Login olunmuş ise AppComponent sayfasına yönlenilir. Değil ise, Login sayfasında kalınılır.
- Redirect() : Login button’una basılmış ise, “this.service.login” servisine Username ve password parametre olarak gönderilir.
- “window.localStorage.setItem(“token”, data.token)” :Username ve Password doğru ise, NodeJs’den dönen “token” LocakStorage’a yazılır.
- “this.router.navigateByUrl(‘person’)” : Daha sonra da AppComponent sayfasına yönlenilir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
import { Component, AfterViewInit } from '@angular/core'; import { PersonService } from 'src/Service/personService'; import { Router } from '@angular/router'; @Component({ selector: 'app-root', templateUrl: './login.html', styleUrls: ['./login.scss'] }) export class LoginComponent implements AfterViewInit { userName: string; password: string; constructor(public service: PersonService, private router: Router) { } ngAfterViewInit(): void { this.service.checkToken().subscribe((data: any) => { if (data.success != false) { this.router.navigateByUrl('person'); } }); //throw new Error("Method not implemented."); } Redirect() { this.service.login(this.userName, this.password).subscribe((data: any) => { window.localStorage.setItem("token", data.token); this.router.navigateByUrl('person'); }); } } |
Gelin isterseniz servisden önce, Angular üzerinde routing nasıl yapılıyor onu inceleyelim.
app-routing.module.ts: İlgili sayfa projeye eklenir. Aşağıda görüldüğü gibi pathler ‘ ‘ ve ‘person’ olarak tanımlanmıştır.
- ‘ ‘=> LoginComponent sayfasını yani ilk açılış sayfasını tanımlamaktadır.
- ‘person’=> ise, AppComponent sayfasını tanımlamaktadır.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { LoginComponent } from './login'; import { AppComponent } from './app.component'; const routes: Routes = [ { path: '', component: LoginComponent, pathMatch: 'full' }, { path: 'person', component: AppComponent, pathMatch: 'full' } ]; @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule] }) export class AppRoutingModule { } |
Not: Routing amaçlı yeni eklenen app-routing.module sayfasının ayrıca login amaçlı Login ve layout amaçlı Main sayfalarının, app.module.ts‘e aşağıdaki gibi eklenmesi gerekmektedir.
app.module.ts:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import { BrowserModule } from '@angular/platform-browser'; import { NgModule } from '@angular/core'; import { AppComponent } from './app.component'; import { PersonService } from 'src/Service/personService'; import { HttpClientModule } from '@angular/common/http'; import { FormsModule } from '@angular/forms'; import { LoginComponent } from './login'; import { AppRoutingModule } from './app-routing.module'; import { MainComponent } from './main'; @NgModule({ declarations: [ AppComponent, LoginComponent, MainComponent ], imports: [ BrowserModule, FormsModule, HttpClientModule, AppRoutingModule ], providers: [PersonService], bootstrap: [MainComponent] }) export class AppModule { } |
Angular Routing Ağacı:
1-) İlk açılan sayfa Index.html: “<app-root></app-root>” body tagları arasında tanımlanan component’dır.
1 2 3 |
<body> <app-root></app-root> </body> |
2- ) Yeni eklenen Main.ts sayfasının “selector”‘ü “app-root”‘dır. Tüm sayfaların içinde açılacağı ana bir sayfanın olması gerekmektedir.
1 2 3 4 5 6 7 8 9 |
import { Component } from '@angular/core'; @Component({ selector: 'app-root', templateUrl: './main.html' }) export class MainComponent { constructor() { } } |
3-) Main.html: Yeni sayfanın, yönlendirme sayfası olduğunu eskilerin tabiri ile Main Layout :) olduğunu “router-outlet” tanımlaması ile belirtilir. Tüm açılan sayfalar, bu sayfa içinde açılır. “Login” ve “app.component” sayfaları gibi..
1 2 3 |
<body> <router-outlet></router-outlet> </body> |
4-) app-routing.module.ts‘de, Url’de hiçbir tanımlama yapılmaz ise kısaca path ‘ ‘ olur ise, Login sayfasına yönlenilir. Yani main.html içinde root page olarak, Login.html sayfası açılır.
1 2 3 4 |
const routes: Routes = [ { path: '', component: LoginComponent, pathMatch: 'full' }, . ]; |
Şimdi sıra geldi personSerivce.
Service/personService.ts: Sadece yeni eklenen functionlar, aşağıda tanımlanmıştır.
- login(): NodeJs tarafında login() methoduna, username ve encrypt‘li password parametreleri body içerisinde post edilir. Password’ün şifrelenmesi için, aşağıda tanımlanan el yapımı :) “Encrypt()” methodu kullanılmıştır. Amaç, güvenlik amacı ile giden network trafiğinde şifrenin gözükmemesidir. NodeJs tarafında bu methodun bir karşılığı olan, Decrypt() methodu kullanılmaktadır. Yukarıdaki resimde, post edilen dataya bakıldığında görülen password, şifreli olduğundan dolayı gerçeği ile bir alakası yoktur :)
- checkToken() : Login sayfasına gelinildiğinde, “ngAfterViewInit()” methodunda yani sayfa yüklenmesi bittiği zaman, ilgili method çağrılarak LocalStorage’da var olan token “GetToken()” methodu ile alınıp, header’a başına “Bearer ” konmak sureti ile yukarıda görüldüğü gibi NodeJs’e post edilir. Eğer “true” değeri dönülür ise, Login sayfasından AppComponent sayfasına yönlenilir. Eğer false dönülür ise login sayfasında kalınır.
- GetToken() : Bu method ile, eğer localStorage’da “token” değeri var ise alınıp geri dönülür.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
loginUrl: string = "http://localhost:9480/login"; checkTokenUrl: string = "http://localhost:9480/checkToken"; public login(userName: string, password: string): Observable<Person> { let httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json' }) } return this.httpClient.post<Person>(this.loginUrl, { username: userName, password: this.Encrypt(password) }, httpOptions) .pipe( retry(1), catchError(this.errorHandel) ) } public checkToken(): Observable<Person> { let httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + this.GetToken(), }) } return this.httpClient.get<Person>(this.checkTokenUrl, httpOptions) .pipe( retry(1), catchError(this.errorHandel) ) } GetToken(): string { if (window.localStorage.getItem("token") != null) { return window.localStorage.getItem("token"); } } public Encrypt(password: string) { let keyStr: string = "ABCDEFGHIJKLMNOP" + "QRSTUVWXYZabcdef" + "ghijklmnopqrstuv" + "wxyz0123456789+/" + "="; password = password.split('+').join('|'); //let input = escape(password); /* let input = password; */ let input = encodeURI(password); let output = ""; let chr1, chr2, chr3; let enc1, enc2, enc3, enc4; let i = 0; do { chr1 = input.charCodeAt(i++); chr2 = input.charCodeAt(i++); chr3 = input.charCodeAt(i++); enc1 = chr1 >> 2; enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); enc4 = chr3 & 63; if (isNaN(chr2)) { enc3 = enc4 = 64; } else if (isNaN(chr3)) { enc4 = 64; } output = output + keyStr.charAt(enc1) + keyStr.charAt(enc2) + keyStr.charAt(enc3) + keyStr.charAt(enc4); chr1 = chr2 = chr3 = ""; enc1 = enc2 = enc3 = enc4 = ""; } while (i < input.length); //console.log("Password :" + output); return output; } |
Örnek Service/personService.ts(getPeopleList): Son olarak personService’inde yazılan tüm methodların Header’ına, aynı checkToken() methodunda olduğu gibi, güvenlik amaçlı “‘Authorization’: ‘Bearer ‘ + this.GetToken()” konulması gerekmektedir. Bu şekilde makalenin devamında NodeJs tarafında inceleyeceğimiz, “JWT Token” kontrolünden geçinilmiş olunur. Bu token konmasa idi, ilgili servis Url’ine erişen herkes, ilgili dataları kolayca çekebilirdi. İlgili Token’ın manuel olarak ayarlanan expire süresi, 1 saattir. Bu makalede localStorage tarafından bir expire time yazılmamıştır. Expire işlemi, NodeJs tarafında JWT Token alınır iken yapılmıştır. Eğer süresi geçmiş bir token, localStorage’dan alınıp header’da post edilir ise herhangi bir sonuç alınamıyacaktır.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
public getPeopleList(desc: boolean = false): Observable<Person> { let url: string = desc ? this.baseUrlDesc : this.baseUrl; let httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + this.GetToken(), }) } return this.httpClient.get<Person>(url, httpOptions) .pipe( retry(1), catchError(this.errorHandel) ) } |
Sıra geldi AppComponent sayfasında yapılan değişikliklere.
app.component.ts/getPeople(): Aşağıda görüldüğü gibi sayfa ilk yüklendiğinde “ngAfterViewInit()” methodunda, “getPeople()” methodu çağrılır. ==>”data.success” değeri kontrol edilir. Sadece Token hatası durumunda, data.success değeri “false” olarak NodeJs’den dönülür. Bu durumda Login sayfasına yönlendirilir.
- “this.router.navigateByUrl(”)” : Hata durumunda Login sayfasına yönlenilir. ‘ ‘ anlamı, “app-routing.module” sayfasına bakılır ise ==> “{ path: ‘ ‘ ”, component: LoginComponent, pathMatch: ‘full’ }” yani, root sayfanın Login sayfası olduğu görülür.
-
“constructor(public service:PersonService, private router:Router) { }” : Routing işlemini yapılabilmesi için Router’ın constructor’da eklenmesi gerekmektedir.
-
“import { Router } from ‘@angular/router'” : Sayfanın başına, ilgili Router kütüphanesinin eklenmesi gerekmektedir.
1 2 3 4 5 6 7 8 9 10 11 12 |
public getPeople(desc: boolean = false) { return this.service.getPeopleList(desc).subscribe((data: any = []) => { if (data.success == false) { this.router.navigateByUrl(''); } else { this.isGetPeople = true; this.peopleList = data.slice(0, 5); console.log(this.peopleList); } }); } |
- Next(), Preview(), Save(), Delete() methodlarının tamamında, NodeJs servisine gidildikten sonra eğer “data.success == false” hatası alınır ise ==> “this.router.navigateByUrl(”)” login sayfasına yönlenilir. Aşağıda örnek amaçlı sadece Next() methodu gösterilmiştir. Tüm methodlarda, aynı işlemin yapılması gerekmektedir. Amaç eğer gidilip LocalStorage’dan token silinir ise, ya da Token’ın 1 saatlik süresi geçer ise tekrar login olunmasıdır.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
public Next() { this.currentPagecount = this.currentPagecount + 1 >= 0 ? this.currentPagecount + 1 : this.currentPagecount return this.service.getPeopleListByPaging(this.currentPagecount).subscribe((data: any = []) => { if (data.success == false) { this.router.navigateByUrl(''); } else { if (data.length == 0) { this.currentPagecount = this.currentPagecount - 1; this.isNextActive = false; } else { this.isNextActive = true; this.peopleList = data; this.isPreviewActive = true; } console.log(this.peopleList); } }); } |
Şimdi sıra geldi Backend NodeJs tarafına.
Image Source: https://t1.daumcdn.net/cfile/tistory/99A2204B5C983E070A
Amaç: Esas amaç veri güvenliğinin sağlanmasıdır. Sadece Login sayfası olan, o kadar çok proje gördüm ki beni hayrete düşürdü. Esas güvenliğin, Client ile erişilen servisler arasında olması gerekmektedir. Aksi takdirde, servis yollarına erişen art niyetli kişiler, datayı elegeçriebilir, silebilir ya da bozabilir.
- Client taraftan servise, username – password gönderilir. Doğru ise, geriye belli bir zaman geçerli olan yeni bir token oluşturulup dönülür.
- Nodejs tarafında gönderilen password, hashlenerek MongoDB tarafında saklanır. Doğru olup olmadığı, belli bir key’e göre gene hashlenip 2 encrypted şifre karşılaştırılarak belirlenir. Burada amaç clientların passwordlerinin MongoDB’de görülmemesidir.
- Client, makalenin başında gördüğümüz gibi kendisine gelen bu token’ı, localStorage veya Cookiede saklar.
- Artık clientside’dan webservisine bir istekte bulunulacağı zaman, “Authorization Header“‘a kendisine gelen bu token’ın konulması gerekmektedir. Süresi geçmiş, yanlış ya da token’sız tüm istekler, “Token is not valid” hata mesajı ile karşılaşır.
- Eğer Nodejs tarafında istekler, en başta token kontrolünden başarılı olarak geçer ise, istenen data Client’a geri dönülür.
Bu makalede token üretimi ve kontrolü için JWT kütüphanesi kullanılmıştır.
1 |
npm i jsonwebtoken |
Yukarıdaki komut ile, jwt kütüphanesi projeye eklenir. Projenin başına “jsonwebtoken” ve gelen password’ü hashlenmesi için “crypto” kütüphaneleri, sayfanın başına aşağıdaki gibi eklenir. crypto kütüphanesi, NodeJs ile default gelen bir kütüphanedir. Ayrıca yüklenmesine gerek yoktur. Son olarak config.js dosyasından, ilgili key’e göre value’nun okunabilmesi için, “config” kütüphanesinin aşağıdaki gibi tanımlanması gerekmektedir. Config.json dosyası, makalenin devamında anlatılacaktır.
1 2 3 |
let jwt = require('jsonwebtoken'); let config = require('./config'); var crypto = require('crypto'); //Default Geliyor |
MongoDB tarafında yukarıda propertyleri gözüken client collection’ının mongoose şeması, aşağıdaki gibi tanımlanmıştır. Client değişkenine, ilgili döküman, aşağıdaki şeme ile atanır.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
var ClientSchema = new mongoose.Schema({ username: { type: String, required: true, minlength: 3, maxlength: 50 }, email: { type: String, required: true, minlength: 5, maxlength: 255, unique: true }, password: { type: String, required: true, minlength: 3, maxlength: 255 } }); var Client = mongoose.model('Client', ClientSchema, 'clients'); |
Image Source: https://fullstackmark.com/img/posts/19/jwt-flow-using-authentication-server-with-access-token-and-resource-server.png
Sıra geldi NodeJs tarafında Login methoduna:
service.js/login: İlgili kişi doğru username ve password’ü girdi mi diye kontrol edilir. Doğru ise, geriye yeni oluşturulan JWT token dönülür.
- “var password = Decrypt(req.body.password)” : Encrypt’li gönderilen password, makalenin devamında anlatılacak olan Decrypt() function ile çözülür. Amaç, client side tarafdan gönderilen password’ün NodeJs’e gelene kadar okunamamasıdır.
- “this.salt = config.salt” : Password’un hashlene bilmesi için gerekli salt key, config dosyasından okunur.
- “this.hash = crypto.pbkdf2Sync(password, this.salt, 1000, 64, ‘sha512’).toString(‘hex’)” : Gönderilen password, decrypt edildikten sonra mongodb’den kontrol edilebilmesi için hashlenir. Çünkü password mongoDB’de güvenlik amaçlı hash’li olarak tutulmaktadır. Hashleme hakkında daha detaylı bilgiye, bu makaleden erişebilirsiniz.
- “var query = { username: req.body.username, password: this.hash }” : Body içerisinde gönderilen username ve password’ün, mongoDB’de aranması amacı ile query oluşturulur.
- “Client.find(query, function (err, doc) {” : Username ve hash’li password’ün, mongodb’de bir karşılığı olup olmadığına bakılır.
- “if (doc.length > 0) {” : Geçerli bir kaydın bulunması durumunda işlemlere devam edilir.
- “let token = jwt.sign({ username: req.body.username }, config.secret, { expiresIn: ‘1h’ // expires in 1 hour } )” : Login olan kişinin username’ine karşılık, JWT kütüphanesi yardımı ile 1 saatlik token oluşturulur.
- “config.secret” : JWT için gerekli secret key config.js dosyasından okunur.
- “expiresIn: ‘1h'” : 1 saat sonra, ilgili token’ın geçersiz olamsı sağlanır.
- “return res.status(200).json({ status: “succesfully login”, token: token, success: true })” : Geriye 200 kodu ve oluşturulan token dönülür.
- “return res.status(401).send(“Username or Password Wrong!”)” : Username veya şifrenin yanlış olması durumunda, 401 hata kodu dönülür.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
app.post('/login', async (req, res) => { try { console.log("req.username : " + req.body.username); console.log("req.password : " + req.body.password); var password = Decrypt(req.body.password); //Encrypt this.salt = config.salt; //this.salt = crypto.randomBytes(16).toString('hex'); this.hash = crypto.pbkdf2Sync(password, this.salt, 1000, 64, 'sha512').toString('hex'); //-------------------------- var query = { username: req.body.username, password: this.hash }; Client.find(query, function (err, doc) { if (doc.length > 0) { let token = jwt.sign({ username: req.body.username }, config.secret, { expiresIn: '1h' // expires in 1 hour } ); return res.status(200).json({ status: "succesfully login", token: token, success: true }); } else { return res.status(401).send("Username or Password Wrong!"); } }) } catch (error) { res.status(500).send(error); } }) |
service.js/Decrypt(): Client Side tarafta, güvenlik amaçlı şifrelenerek gönderilen password, server side tarafta kontrol edilmek amacı ile decrypt edilir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
function Decrypt(password) { let keyStr = "ABCDEFGHIJKLMNOP" + "QRSTUVWXYZabcdef" + "ghijklmnopqrstuv" + "wxyz0123456789+/" + "="; var output = ""; var i = 0; password = password.replace("/[^ A - Za - z0 - 9\+\/\=] / g", ""); do { var enc1 = keyStr.indexOf(password[i++]); var enc2 = keyStr.indexOf(password[i++]); var enc3 = keyStr.indexOf(password[i++]); var enc4 = keyStr.indexOf(password[i++]); var chr1 = (enc1 << 2) | (enc2 >> 4); var chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); var chr3 = ((enc3 & 3) << 6) | enc4; output = output + String.fromCharCode(chr1); if (enc3 != 64) { output = output + String.fromCharCode(chr2); } if (enc4 != 64) { output = output + String.fromCharCode(chr3); } chr1 = chr2 = chr3 = null; enc1 = enc2 = enc3 = enc4 = null; } while (i < password.length); output = unescape(output); var pattern = new RegExp("[|]"); output = output.replace(pattern, "+"); return output; } |
config.js: Config.js’in kullanılabilmesi için, aşağıdaki komut ile proje eklenmesi gerekmektedir.
-
1npm i config
- salt, gönderilen password’ün hashlenmesi için kullanılır.
- secret ise, Jwt kütüphanesinde ilgili username için oluşturulacak token için kullanılır.
1 2 3 4 |
module.exports = { secret: 'cutthenightwiththelight', salt: 'c3d3b76f0d085898f6c5a3738ac9a167' }; |
Şimdi sıra geldi service methodları üzerine bir istek geldiği zaman, header’dan gönderilen token’ın kontrol edilmesine.
token.js: Servislerin güvenliği tek bir yerden yönetilmelidir. Bunun için aşağıdaki token.js yazılmıştır.
- let jwt = require(‘jsonwebtoken’)” : JWT Token kütüphanesi kullanılmaktadır.
- “const config = require(‘./config.js’)” : config.js dosyasından, istenen keylerin okunması için kullanılır.
- “let token = req.headers[‘x-access-token’] || req.headers[‘authorization’]” : Gelen requestin Header’ında, “authorization” key var mı diye bakılır? Kısaca token header’a konmuş mu diye kontrol edilir.
- “if (token !=undefined && token.startsWith(‘Bearer ‘)) {” : Gelen Token’ın “Bearer” kelimesi ile başlaması gerekmektedir. Varsa, “Bearer” kelimesi kaldırılıp, sadece token alınır.
- “jwt.verify(token, config.secret, (err, decoded) => {” : JWT kütüphanesi kullanılarak, gelen token’ın config dosyasındaki secret keyi ile decod edilip geçerli olup olunmadığına bakılır :)
- “return res.json({ success: false, message: ‘Token is not valid’ })” : Geçerli değil ise “success: false” ve mesaj olarak da “Token is not valid” mesajı dönülür.Bu cevap dönüldüğünde, client side tarafında Login ekranına yönlenilir.
- “req.decoded = decoded; next()” : Geçerli ise “next()“, NodeJs kütüphanesine özel bir methoddur ve kendi üzerinde çağrılan servisin çalıştırılmasını sağlanır. Not: Bu token.js, tüm servis istekleri üzerinde çalıştırılacaktır. Dolayısı ile next() methodu, hangi servis üzerinde çalıştırılır ise o servis çağrılacaktır.
- “return res.json({ success: false, message: ‘Auth token is not supplied’ })” : Header üzerinde token bulunmaması durumunda, “success: false” ve mesaj olarak da “Auth token is not supplied” mesajı dönülür. Bu cevap dönüldüğünde, client side tarafında Login ekranına yönlenilir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
let jwt = require('jsonwebtoken'); //npm install jsonwebtoken const config = require('./config.js'); module.exports = (req, res, next) => { let token = req.headers['x-access-token'] || req.headers['authorization']; // Express headers are auto converted to lowercase if (token !=undefined && token.startsWith('Bearer ')) { // Remove Bearer from string token = token.slice(7, token.length); } if (token && token != "undefined" && token != undefined) { jwt.verify(token, config.secret, (err, decoded) => { if (err) { return res.json({ success: false, message: 'Token is not valid' }); } else { req.decoded = decoded; next(); } }); } else { return res.json({ success: false, message: 'Auth token is not supplied' }); } }; |
Şimdi sıra geldi, service.js üzerinde tüm functionlara bu token.js’i, implemente etmeye. Sayfanın başına, aşağıdaki şekilde token.json dosyası tanımlanır.
1 |
let token = require('./token'); |
Aşağıda tüm servis methodlarından sadece 2 tanesi için örnek verilmiştir. Geri kalan tüm methodlarda, token kontrolü aynı şekilde yapılmaktadır.
“app.get(“/people”, token, function (req, res) {” : Yeni eklenen tek parametre “token“‘dır. Burada, ilgili function’a gelinildiğinde ilk olarak token.json sayfasına gidilir ve hemen yukarıda anlatılan “module.exports = (req, res, next) =>” function’ı çalıştırılır. Dönen sonuca göre yani Header’da gönderilen token’ın geçerli olması durumunda, yukarıda anlatılan “Next()” methodu çağrılarak, ilgili app.get() method’u çalıştırılır. Ya da token’ın geçersiz olması durumunda, geriye “return res.json({ success: false, message: ‘Token is not valid’ })” satırı dönülür ve client side tarafda “Login” sayfasına yönlenilir.
Aynı işlem “app.post(‘/insertPeople’, token, async (req, res) => {” function’ı için de geçerlidir. Header’da gönderilen token geçersiz ise insert işlemi yapılmaz ve Login ekranına dönülür.Token geçerli ise, yeni client mongoDB’deki users document’ine kaydedilir. Yukarıdaki örnekde, test amaçlı var olan token bozulmuştur. Böylece Header ile gönderilen token, NodeJs tarafında insertPeople() methodu çalıştırılmadan önce token.js tarafından karşılanmış ve token invalid olduğu için “return res.json({ success: false, message: ‘Token is not valid’ })” değeri dönülmüştür. Bu cevabı alan client side da, user’ın geçerli bir token alması için Login sayfasına yönlendirmiştir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
app.get("/people", token, function (req, res) { //res.send("Günaydın Millet"); Human.find(function (err, doc) { doc.forEach(function (item) { item.fullName = item._doc.name.first + ' ' + item._doc.name.last; }); res.send(doc); }) }) . . app.post('/insertPeople', token, async (req, res) => { console.log("req.body : " + req.body.username); console.log("req.body.last : " + req.body.name.last); try { var person = new Human(req.body); var result = await person.save(); res.send(result); } catch (error) { res.status(500).send(error); } }) . . |
Swagger Token Entegrasyonu: Güvenlik amaçlı Header’a eklenen “Authorization: Bearer Token“‘ın, Swagger dökümanı tarafında da, bir karşılığı olması gerekir. Aksi takdirde, test amaçlı çalıştırılan methodlar token.js‘den geçemezler ve ‘Auth token is not supplied‘ hatasını alırlar. Öncelikle uygulamaya Login olunduktan sonra, browser’daki Local Storage’dan token alınarak, yukarıdaki Value kutucuğuna yazılır.
1. Bunun olmaması için, swagger.json dosyasının başına aşağıdaki “securityDefinitions” sekmesinin eklenmesi gerekmektedir.
swagger.json:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
{ "swagger": "2.0", "securityDefinitions": { "Bearer": { "type": "apiKey", "name": "Authorization", "in": "header", "description": "JWT Authorization header using the Bearer scheme. Example: \"Bearer {token}\"" } }, . . . |
2. Her bir method’a, “security”: [ { “Bearer”: [] } ]” sekmesinin eklenmesi gerekmektedir. Aşağıda örnek amaçlı eklenen “people()” methodu görülmektedir. Diğer methodlara da, aynı şekilde “security” sekmesinin eklenmesi gerekmektedir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
. . "/people": { "get": { "tags": [ "Users" ], "security": [ { "Bearer": [] } ], "summary": "Get all users in system", "responses": { "200": { "description": "OK", "schema": { "$ref": "#/definitions/User" } } } } } . . |
Refresh Token
Öncelikle gelin Refresh Token nedir onu inceleyelim. Örnek amaçlı bir senerya belirleyelim. Ali ilgili form üzerinde 1 saatten fazla çalışması durumunda, Token’ın süresi biteceğinden NodeJs’e yapılan ilk istekte, Ali Login sayfasına yönlenecektir. İşte bu durumun olmaması için, kesin bir kural olamasa da bu örnekde, süresi “45< T <60” dakikaları arasında olan tokenların yenilenmesi gerekmektedir. Bunun için Client Side taraftan NodeJs’e, Login olunulduğu zama, “Token“‘ın haricinde yine Login zamanı alınacak olan “Refresh Token“‘ın Header üzerinden gönderilmesi gerekmektedir. Bu, NodeJs tarafında yakalanıldığı zaman, güvenlik amaçlı Token’ın yaşı JWT kütüphanesi yardımı ile tekrardan kontrol edilir ve gerçekten 45 – 60 dakikaları arasında ise, ayrıca gönderilen “Token” ile “Refresh Token” geçerli ise yeni “Token” ve “Refresh Token” oluşturularak geri dönülür. Böylece yeni 1 saatlik token, Ali için kullanıma hazır olur. Sonuç olarak sürekli çalışan Login olmuş bir client’ın, tekrardan Login sayfasına düşmesi engellenir. Ancak T<60 durumunda, yani hiçbir işlem yapılmadan Token’ın expire süresi geçer ise, Login sayfasına yönlenilir.
NodeJs/service.js/Login : Öncelikle gelin, NodeJs tarafında Login() methodunu Refresh Token için güncelleyelim.
Login olunulması durumunda, ilgili user için token üretilmesi yanında, bir de Refresh Token oluşturulmakta ve geri dönülmektedir. Normal Token’ın süresi 1 saat iken, Refresh Token’ın süresi 2 saattir. Biraz daha uzun olmasının tek nedeni güvenliktir. Sonuçta Refresh Token, var olan Token’ın yenilenmesi için kullanılan 2.cil bir anahtardır. Bundan dolayı expire süresinin, Token’ın süresinden biraz daha fazla olması gayet normaldir.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
app.post('/login', async (req, res) => { try { console.log("req.username : " + req.body.username); console.log("req.password : " + req.body.password); var password = Decrypt(req.body.password); //Encrypt this.salt = config.salt; //this.salt = crypto.randomBytes(16).toString('hex'); this.hash = crypto.pbkdf2Sync(password, this.salt, 1000, 64, 'sha512').toString('hex'); //-------------------------- var query = { username: req.body.username, password: this.hash }; Client.find(query, function (err, doc) { if (doc.length > 0) { let token = jwt.sign({ username: req.body.username }, config.secret, { expiresIn: '1h' // expires in 1 hour /* expiresIn: 15 // expires in 15 minutes */ } ); var refreshToken = jwt.sign({ username: req.body.username }, config.secret, { expiresIn: '2h' // expires in 2 hour } ); return res.status(200).json({ status: "succesfully login", token: token, refreshToken: refreshToken, success: true }); } else { return res.status(401).send("Username or Password Wrong!"); } }) } catch (error) { res.status(500).send(error); } }) |
Şimdi sıra geldi ,ClientSide tarafta bu servisi karşılayan Redirect() function’a.
app/login.ts/Redirect(): Aşağıda görüldüğü gibi login işlemi başarı ile sağlandıktan sonra, token ve refreshToken Local Storage’a kaydedilir. Ayrıca oluşturma tarihi olan CreateDate de, LocalStorage’a kaydedilir.
1 2 3 4 5 6 7 8 9 |
Redirect() { this.service.login(this.userName, this.password).subscribe((data: any) => { debugger; window.localStorage.setItem("token", data.token); window.localStorage.setItem("refreshToken", data.refreshToken); window.localStorage.setItem("createdDate", new Date().toString()); this.router.navigateByUrl('person'); }); } |
Şimdi sıra geldi her bir request işleminden, önce token süresini client side tarafta kontrol etmeye.
Service/personService.ts/checkTokenTime(): Burada amaç, kaydedilen token süresi ile şimdiki zaman arasında 45 dakikalık farkın olup olmadığına bakınılmasıdır.
1 2 3 4 5 6 7 8 9 10 11 12 |
public checkTokenTime() { if (window.localStorage.getItem("createdDate") != null) { var createdDate = new Date(window.localStorage.getItem("createdDate")); var now = new Date(); var difference = now.getTime() - createdDate.getTime(); var resultInMinutes = Math.round(difference / 60000); return resultInMinutes > 45; } else { return true; } } |
Service/personService.ts/getPeopleList(): İlgili functionlardan, örnek amaçlı getPeopleList() seçilmiştir. Tüm functionlar için aynı işlemin yapılması gerekir. Refresh Token için yapılan değişiklikler, aşağıda maddeler halinde sıralanmıştır. Esas amaç eğer Token’ın süresi 45 dakikayı geçmiş ise, yenilenmesi amacı ile NodeJs tarafına, Header’da RefreshToken’ın gönderilmesidir.
- var refreshToken = (window.localStorage.getItem(“refreshToken”) != null && this.checkTokenTime()) ? window.localStorage.getItem(“refreshToken”) : “” : İlgili refresToken LocalStorage’da var ise ve checkTokenTime() functionından true değeri dönmüş ise yani Token süresi 45 dakikayı geçmiş, ise işleme devam edilir. Ve LocalStoragedaki refreshToken değeri atanır. Değil ise, ” “ yani boş değeri atanır.
- let httpOptions = { headers: new HttpHeaders({ .., ‘RefreshToken’: refreshToken, }), observe: ‘response’ as ‘body’,}: Get işlemi yapılacak servisin header’ına RefreshToken eklenir.
Buradaki en önemli konu httpOptions’a observe: ‘response’ as ‘body’ tagının eklenmesidir. Bu eklenmez ise, geriye dönüş tipi olarak HttpResponse alınamaz. Bu da makalenin devamında anlatılacak olan, gelen cevabın Header’ından yeni Token ve RefreshToken‘ın alınamamasına neden olacaktır. Bu soruna çözümü, gerçekten uzun bir süre aradım :) Özellikle “as ‘body’” kısmını.
- “return this.httpClient.get<any>(url, httpOptions)” : Dönüş tipi httpClient.get<Person>’dan httpClient.get<any>’e çevrilmiştir. Çünkü dönen değer artık bir HttpResponse’dur.
- “map(response => {” : İle araya girilmiş ve gelen kaydın header’ından, datalar çekilmiştir.
- “var token = response.headers.get(‘token’);var refreshToken = response.headers.get(‘refreshToken’)” : Yeni oluşturulan Token ve RefreshToken, header’dan çekilir.
- “if (token && refreshToken) {” : Gerçekten yeni tokenların oluşup oluşmadığına bakılır. Çünkü gönderilen refreshToken’ın geçerli olup olmadığı ve Token süresinin 45> dakikadan büyük olup olmadığı, NodeJs tarafında da kontrol edilmektedir. Bu koşullar sağlanmıyor, ise yeni Token ve RefreshToken dönülmez.
- “window.localStorage.setItem(“token”, token)”; window.localStorage.setItem(“refreshToken”, refreshToken); window.localStorage.setItem(“createdDate”, new Date().toString()) : Yeni oluşturulmuş Token, RefreshToken ve oluşturulma zamanı olan CreatedDate, LocalStorage’da güncellenir.
- “return response.body”: Servis sonucunu bekleyen functionlarda herhangi bir değişikliğe gidilmemesi için, geriye onaların beklediği HttpResponse’un body’sinde taşınan ==> Person[] dönülür.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
public getPeopleList(desc: boolean = false): Observable<Person[]> { let url: string = desc ? this.baseUrlDesc : this.baseUrl; var refreshToken = (window.localStorage.getItem("refreshToken") != null && this.checkTokenTime()) ? window.localStorage.getItem("refreshToken") : ""; let httpOptions = { headers: new HttpHeaders({ 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + this.GetToken(), 'RefreshToken': refreshToken, }), observe: 'response' as 'body', } debugger; return this.httpClient.get<any>(url, httpOptions) .pipe( map(response => { var token = response.headers.get('token'); var refreshToken = response.headers.get('refreshToken'); if (token && refreshToken) { console.log("Token :" + token); console.log("RefreshToken :" + refreshToken); window.localStorage.setItem("token", token); window.localStorage.setItem("refreshToken", refreshToken); window.localStorage.setItem("createdDate", new Date().toString()); } return response.body; }), retry(1), catchError(this.errorHandel), ) } |
Şimdi sıra geldi NodeJs tarafında, tüm servislerde middleware’e girerek token kontrolü yapan token.js’e.
NodeJs/token.js : RefreshToken için yapılan değişiklikler kısaca, client’a ait token’in süresinin 45 dakikadan büyük olup olmadığının araştırılması, büyük olması durumunda client’dan gelen refreshTone’ın geçerlilik durumuna bakılıp, yeni refreshToken ve Token’ın yaratılıp, headar’da geri dönülmesidir.
- “var exp = new Date(decoded.exp * 1000)” : Token’ın expire olacağı zaman, JWT kütüphanesi üzerinden alınır.
- “var now = new Date(); var difference = exp.getTime() – now.getTime()” : Şimdiki zaman ve token’ın süresini hesaplamak için, aradaki fark alınır.
- “var resultInMinutes = Math.round(difference / 60000)” : Token’ın süresi, dakika cinsinden resultMinutes’e atanır.
- “if (resultInMinutes < 15) {” : Token’ın expire süresi 15 dakikadan küçük ise, işleme devam edilir.
- “var refreshToken = req.headers[‘refreshtoken’]” : Client side tarafdan gönderilen refresh token, header’dan alınır.
- “if (refreshToken && refreshToken!=”” && refreshToken != “undefined” && refreshToken != undefined) {” : Eğer refreshToken var ise işleme devam edilir.
- “jwt.verify(refreshToken, config.secret, (err, decoded) => {” : Gönderilen refresh token, doğrulanır.
- “var newToken = jwt.sign({ username: decoded.username }” : Yeni bir token oluşturulur.
- “var newRefreshToken = jwt.sign({ username: decoded.username }” : Aynı user için yeni bir refreshToken oluşturulur.
- “res.setHeader(‘Token’, newToken); res.setHeader(‘RefreshToken’, newRefreshToken)” : Var olan yapının bozulmaması adına, token.js’in üzerinde çalıştığı servislerin dönüş tipinin değiştirilmemesi için, en kısa ve en elverişli yol olarak, yaratılan yeni Token’ların HttpResponse.header‘da, ilgili client’a gönderilmesidir. Uzun yol olarak, tüm servilerin geri dönüş tipleri bir base model’den türetilip, bu base modelin propertylerine ilgili token ve refreshTokenlar property olarak atanabilirdi. Ama en iyi ol en kısa olandır. Hem hata riskini azaltır, hem de testi kolaylaştırır.
Not: JWT Token’da, delete ya da remove gibi işlemler yoktur. Yani Logout olma durumunda, user’a ait kayıtlı Token JWT’den silinemez. Ancak Expire olmasının beklenmesi gerekmektedir. Bizim bu örneğimizde de aslında, request çeken client’a geçerli 2 token verilmektedir. Dikkat edilmesi gerekene konu 45<T<60 dakikaları arasında ilgili client’a ait, aslında 4 token bulunmasıdır. Çünkü diğerleri henüz expire olmamıştır. 60. dakikadan sonra eski Token, 120. dakikadan sonra da eski RefreshToken expire olacaktır. Ben JWT Token’ın bu özelliğini, hiç beğenmedim. Çözüm olarak, Logout durumunda LocalStorage’ın temizlenmesi gerektiği söylense de, eski token’ı alan kişi, o token Expire olana kadar, ilgili servisden request çekmeye devam edebilecektir. Bu da bence önemli bir güvenlik açığıdır.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
let jwt = require('jsonwebtoken'); //npm install jsonwebtoken const config = require('./config.js'); module.exports = (req, res, next) => { let token = req.headers['x-access-token'] || req.headers['authorization']; // Express headers are auto converted to lowercase if (token != undefined && token.startsWith('Bearer ')) { // Remove Bearer from string token = token.slice(7, token.length); } if (token && token != "undefined" && token != undefined) { jwt.verify(token, config.secret, (err, decoded) => { if (err) { return res.json({ success: false, message: 'Token is not valid' }); } else { req.decoded = decoded; var exp = new Date(decoded.exp * 1000); //var iat = new Date(decoded.iat * 1000); var now = new Date(); var difference = exp.getTime() - now.getTime(); var resultInMinutes = Math.round(difference / 60000); //Refresh Token Time if (resultInMinutes < 15) { var refreshToken = req.headers['refreshtoken'] if (refreshToken && refreshToken!="" && refreshToken != "undefined" && refreshToken != undefined) { jwt.verify(refreshToken, config.secret, (err, decoded) => { if (err) { console.log(err); } else { var newToken = jwt.sign({ username: decoded.username }, config.secret, { expiresIn: '1h' // expires in 1 hour } ); var newRefreshToken = jwt.sign({ username: decoded.username }, config.secret, { expiresIn: '2h' // expires in 2 hour } ); res.setHeader('Token', newToken); res.setHeader('RefreshToken', newRefreshToken); } }) } } // End Refresh Token ----------- next(); } }); } else { return res.json({ success: false, message: 'Auth token is not supplied' }); } }; |
* NodeJs/ service.js: Karşılaştığım bir diğer zorluk, NodeJs’den Client Side’a Header’da istenen propertylerin taşınamaması idi. NodeJs’den gönderilen Token ve RefreshToken, Service/personService.ts‘de HttpRespone.Header’dan ==> “response.headers.get(‘refreshToken’)”‘dan alınamıyordu. Bunun çözümü için, aşağıda görüldüğü gibi NodeJs tarafında cors kütüphanesine, Header’da taşınacak propertylere ayrıca izin verilmesi gerekmektedir. Bunu gerçekten çok üzün süre aradım :)
1 2 3 |
app.use(cors({ exposedHeaders: ['Content-Length', 'Content-Type', 'Authorization', 'RefreshToken', 'Token'], })); |
Geldik bir makalenin daha sonuna. Bu makalede Angular 8, NodeJs ve MongoDB bir projede, sıfırdan Authenticationın nasıl yapılacağı incelenmiştir. Login sayfasının yaratılması, Login sayfasından password gibi kritik verilerin nasıl şifreli gönderilebileceği, Client Side taraftan – Server Side tarafındaki servislere Token ile nasıl erişilebileceği, Token’ın süresinin bitmesine yakın nasıl Refresh Token ile yenilenebileceği ve son olarak ilgili passwordlerin MongoDB’de nasıl Hashli olarak saklanabileceği detaylıca anlatılan konulardandır. Bunun yanında, Angular 8 tarafında routing, NodeJs tarafından dönen datanın RXJS kütüphaneleri kullanılarak, header’dan okunması ve kritik verilerin nasıl şifreleneceği konularına detaylıca değenilmiştir. Bütün bu işlemlerin yanında, var olan kodu değiştirmeden en az test ve refactoring işlemi ile Authentication ve security’nin, projeye implementasyonu, dikkat edilen en önemli unsurlardan biri olmuştur.
Yeni bir makalede görüşmek üzere hepinizi hoşçakalın.
Source Code : https://github.com/borakasmer/NodeJs-Angular8-Security-Authentication
Kaynaklar :
- https://jwt.io/introduction/
- https://medium.com/dev-bits/a-guide-for-adding-jwt-token-based-authentication-to-your-single-page-nodejs-applications-c403f7cf04f4
- https://www.freecodecamp.org/news/securing-node-js-restful-apis-with-json-web-tokens-9f811a92bb52/
- http://www.borakasmer.com/manuel-tokenization-ile-authentication-webservisleri-guvenligi-ve-otomatik-yenileme-part-1/
- https://fullstackmark.com/post/19/jwt-authentication-flow-with-refresh-tokens-in-aspnet-core-web-api
Ah birde şu hub sınıfı içerisinde kullanıcı bilgisine erişmeyi anlatsan:)))
giriş yapmaya kalkıştığımda herhangi bir geri dönüş olmuyor clients tablosunu paylaşabilir misiniz?
Öncelikle Selamlar,
Aldığınız hatayı paylaşırsanız size daha çok yardımcı olabilirim.
Bu makalede DB MongoDB’dir. MongoDB’de tablo yoktur. Collection vardır.Yani Clients tablosu değil, Collection’ı aşağıdaki gibidir.
_id : ObjectId
password : String
email : String
username : String
İyi çalışmalar.