Derinlemesine yazılım eğitimleri için kanalımı takip edebilirsiniz...

IdentityServer4 Yazı Serisi #21 – Angular İle IdentityServer4 Kullanımı ve Refresh Token(Silent Refresh)

Merhaba,

IdentityServer4 Yazı Serisinin bu yirmibirinci makalesinde artık IdentityServer4 framework’ü ile bir SPA(Single Page Application) uygulamasının nasıl kullanıldığına sıra geldiğini düşünerek Angular ile örneklendirmeye karar verdim. SPA teknolojilerinden Angular mimarisinde ele alacağımız bu içeriğimizde kullanacağımız kütüphane ve yaklaşımları React, Vue.js vs. gibi UI teknolojilerinde de birebir kullanabileceğinizi ve modelleyebileceğinizi ifade ederek esasında teknik açıdan Angular kullansakta, teoride herhangi bir UI teknolojisine bağımlı bir makale olmayacağını bildirdikten sonra hiç vakit kaybetmeden konuya giriş yapalım.

Başlarken

Angular ile IdentityServer4 kullanımını inceleyebilmek için ilk olarak bir Angular uygulaması oluşturalım ve içerisine oidc-client paketini yükleyelim.

oidc-client paketi; UI teknolojilerinde OAuth 2.0 ve OpenId Connect protokollerini destekleyen bir pakettir.

Ardından ilgili uygulamanın ‘angular.json’ isimli dosyasında aşağıdaki konfigürasyonu gerçekleştirelim.
IdentityServer4 Yazı Serisi #21 - Angular İle IdentityServer4 Kullanımı
Evet… Böylece IdentityServer4 işlemleri için geliştirmeye hazır bir Angular uygulaması oluşturmuş olduk.

Makale sürecinde bir diğer ihtiyacımız ise IdentityServer4 framework’ü ile geliştirilmiş bir Auth Server uygulaması ve yetkilendirme örneklendirmesi için ise bir API tasarımıdır. Sizler isterseniz bu uygulamaları kendiniz geliştirebilir yahut şuradaki tarafımca hazırlanmış hali hazır uygulamayı kullanabilirsiniz. Not: Bu uygulamanın içeriği, makalenin seyriyle birebir uyumludur.

IdentityServer4(Auth Server) Tasarımı

İlk olarak Auth Server’ın tasarımsal altyapısından bahsetmeli ve bir UI teknolojisine uygun nasıl olması gerektiği üzerine hasbihalde bulunmalıyız.

    static public class Config
    {
        #region Scoped
        public static IEnumerable<ApiScope> GetApiScopes() =>
            new List<ApiScope>
            {
                new ApiScope("Garanti.Write","Garanti bankası yazma izni."),
                new ApiScope("Garanti.Read","Garanti bankası okuma izni.")
            };
        #endregion
        #region Resources
        public static IEnumerable<ApiResource> GetApiResources() =>
            new List<ApiResource>
            {
                new ApiResource("Garanti"){ Scopes = {"Garanti.Write", "Garanti.Read" } }
            };
        #endregion
        #region Clients
        public static IEnumerable<Client> GetClients() =>
            new List<Client>
            {
                new Client
                {
                    ClientId = "AngularClient",
                    ClientName = "Angular Client",
                    RequireClientSecret = false,
                    AllowedScopes = {
                        "Garanti.Write",
                        "Garanti.Read",
                        IdentityServerConstants.StandardScopes.Profile,
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Email,
                        "Roles"
                    },
                    RedirectUris = {"http://localhost:4200/callback"},
                    AllowedCorsOrigins = {"http://localhost:4200"},
                    PostLogoutRedirectUris = {"http://localhost:4200"},
                    AllowedGrantTypes = GrantTypes.Code,
                    RequirePkce = true,
                }
            };
        #endregion
        #region Users
        public static IEnumerable<TestUser> GetTestUsers() =>
            new List<TestUser>
            {
                new TestUser
                {
                    SubjectId = "test-user1",
                    Username = "test-user1",
                    Password = "12345",
                    Claims =
                    {
                        new Claim(JwtRegisteredClaimNames.Email, "test-user@gmail.com"),
                        new Claim("role", "admin"),
                        new Claim("authority","true")
                    }
                }
            };
        #endregion
        #region Identity Resources
        public static IEnumerable<IdentityResource> GetIdentityResources() =>
            new List<IdentityResource>
            {
                new IdentityResources.OpenId(),
                new IdentityResources.Profile(),
                new IdentityResources.Email(),
                new IdentityResource
                {
                    Name = "Roles",
                    DisplayName = "Roles",
                    Description = "User roles",
                    UserClaims = {"role", "authority"}
                }
            };
        #endregion
    }

Yukarıda makele serimiz boyunca şimdiye kadar incelediğimiz tüm yapıları barındıran ‘Config.cs’ dosyasının içeriğini görmekteyiz. İlgili yapılanmada bu makale konusuyla alakadar noktalara özel bir şekilde temas etmemiz gerekirse eğer;

  • 19 – 41. satır aralığı
    Sistemde var olan client’ların tanımlandığı alandır.

        .
        .
        .
        public static IEnumerable<Client> GetClients() =>
            new List<Client>
            {
                new Client
                {
                    ClientId = "AngularClient",
                    ClientName = "Angular Client",
                    RequireClientSecret = false,
                    AllowedScopes = {
                        "Garanti.Write",
                        "Garanti.Read",
                        IdentityServerConstants.StandardScopes.Profile,
                        IdentityServerConstants.StandardScopes.OpenId,
                        IdentityServerConstants.StandardScopes.Email,
                        "Roles"
                    },
                    RedirectUris = {"http://localhost:4200/callback"},
                    AllowedCorsOrigins = {"http://localhost:4200"},
                    PostLogoutRedirectUris = {"http://localhost:4200"},
                    AllowedGrantTypes = GrantTypes.Code,
                    RequirePkce = true,
                }
            };
        .
        .
        .
    

    Tanımlanan ‘AngularClient’ isimli client’ın özelliklerine göz atarsak eğer ilk olarak client secret değerinin olmadığını görürüz. Bunun sebebi PKCE(Proof Key for Code Exchange) başlıklı makalede ele aldığımız güvenlik açığına sebep olabilecek bir problem durumundan kaynaklanmaktadır. Bu sebepten dolayı ‘RequireClientSecret’ özelliğine ‘false’ ve ‘RequirePkce’ özelliğine ise ‘true’ değeri set edilmektedir..

    SPA uygulamalarında güvenlik açığı oluşmaması için client secret değeri kullanmamalıyız!

    ‘AllowedCorsOrigins ‘ özelliğinde ise ilgili kullanıcının profil bilgileri(Profile), subject id değeri(OpenId), email gibi bilgileri ile birlikte API resource’e erişim sağlayabilmesi için ‘Garanti.Write’ ve ‘Garanti.Read’ scope’ları talep edilmektedir. Ayriyetten ‘Roles’ isimli custom geliştirilmiş identity resource’de talep edilmektedir.

    Client uygulaması tarayıcıda çalışan UI teknolojisi olacağından dolayı Auth Server’a erişimin sağlanabilmesi için Cross-Origin-Resource-Sharing politikasının ayarlanması gerekmektedir. Bunun için ‘AllowedCorsOrigins’ özelliği kullanılmaktadır.

  • 62 – 75. satır aralığı
    Client’ların bilgileri olan identity resource’lerin tanımlandığı alandır.

            .
            .
            .
            public static IEnumerable<IdentityResource> GetIdentityResources() =>
                new List<IdentityResource>
                {
                    new IdentityResources.OpenId(),
                    new IdentityResources.Profile(),
                    new IdentityResources.Email(),
                    new IdentityResource
                    {
                        Name = "Roles",
                        DisplayName = "Roles",
                        Description = "User roles",
                        UserClaims = {"role", "authority"}
                    }
                };
            .
            .
            .
    

    Görüldüğü üzere öntanımlı olan ‘OpenId’, ‘Profile’ ve ‘Email’ bilgilerinin yanında ‘role’ ve ‘authority’ claim’lerini tutan ‘Roles’ isimli custom identity resource’de tanımlanmaktadır. Zaten hatırlarsanız eğer bu değer yukarıdaki client tanımlamasında scope olarak çağrılmaktadır.

  • 44 – 59. satır aralığı
    Test user’ların tanımlandığı alandır.

            .
            .
            .
            public static IEnumerable<TestUser> GetTestUsers() =>
                new List<TestUser>
                {
                    new TestUser
                    {
                        SubjectId = "test-user1",
                        Username = "test-user1",
                        Password = "12345",
                        Claims =
                        {
                            new Claim(JwtRegisteredClaimNames.Email, "test-user@gmail.com"),
                            new Claim("role", "admin"),
                            new Claim("authority","true")
                        }
                    }
                };
            .
            .
            .
    

    Tek dikkat etmenizi istediğim custom eklenen identity resource’un claim değerlerinin kullanıcıya eklenmiş olmasıdır. Böylece hem ilgili identity resource değerlerini kullanıcı taşıyacak hem de client tarafından talep edilmiş olacaktır.

Bu nazar-ı dikkat edilen noktalar dışında Auth Server ile birlikte API’ların ‘Startup.cs’ konfigürasyonu vs. gibi geri kalan işlemlerde ki tüm süreç yazı serimiz boyunca ele alındığı gibidir. Dolayısıyla ilgili aşamalara temas etmeksizin bu andan itibaren client uygulaması(Angular) boyutundan makalemize devam edebiliriz…

Angular Tasarımı

İlk olarak Auth Server ile iletişim kuracak olan aşağıdaki gibi bir servis oluşturulmalıdır.

import { Injectable } from '@angular/core';
import * as oidc from "oidc-client";

@Injectable({
  providedIn: 'root'
})
export class AuthService {

  config: oidc.UserManagerSettings = {
    authority: "https://localhost:1000",
    client_id: "AngularClient",
    redirect_uri: "http://localhost:4200/callback",
    post_logout_redirect_uri: "http://localhost:4200",
    response_type: "code",
    scope: "Garanti.Write Garanti.Read profile openid email Roles"
  };

  userManager: oidc.UserManager;
  constructor() {
    this.userManager = new oidc.UserManager(this.config)
  }
}

Oluşturulan bu serviste ‘config’ değişkeni bu client’ın yetkiyi alacağı Auth Server’a dair tüm konfigürasyonları barındırmaktadır. Detaylara bakarsak eğer;

  • authority
    Bu uygulamanın yetkiyi alacağı sunucunun yani Auth Server’ın adresini tutar.
  • client_id
    Client’ın Auth Server’da var olan tanımlamasındaki client id değerini tutar.
  • redirect_uri
    Client doğrulandığı zaman hangi adrese yönlendirme yapılacağını tutar. Burada dikkat ederseniz ‘/callback’ adresine yönlendirme gerçekleştirilmektedir. Birazdan bu adresi karşılayacak olan component oluşturulacaktır.
  • post_logout_redirect_uri
    Çıkış yapıldığında kullanıcının hangi adrese yönlendirileceği tutulur.
  • response_type
    Akış türünü belirtir. ‘code’, authorization code’a karşılık gelir.
  • scope
    Client’ın talep ettiği scope’lardır. Bir MVC uygulamasına nazaran scope’lar oldukça kolay talep edilebilmektedir. Scope’lar arasında virgül vs. gibi bir ayraç olmaksızın direkt boşluk kullanılmalıdır.

Ardından client’ın giriş talebi neticesinde Auth Server’dan gelen sonucu karşılayacak olan ‘callback’ component’ı oluşturulmalı ve içeriği aşağıdaki gibi geliştirilmelidir.

import { Component, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import * as oidc from 'oidc-client'

@Component({
  selector: 'app-callback',
  templateUrl: './callback.component.html',
  styleUrls: ['./callback.component.css']
})
export class CallbackComponent implements OnInit {

  constructor(private router: Router) { }

  ngOnInit(): void {
    new oidc.UserManager({ response_mode: "query" })
      .signinRedirectCallback().then(() => {
        this.router.navigateByUrl("/");
      });
  }
}

Burada dikkat ederseniz, ‘UserManager’ nesnesinin constructor’ı üzerinde ‘response_mode’ alanının ‘query’ olarak işaretlendiğini görüyorsunuz. Bu, client’ın talebi neticesinde authorization endpoint’e gönderilecek isteğe karşılık gelecek olan dataların nereden taşınacağını belirtmektedir. ‘query’, Query String’e karşılık gelmektedir. Velhasıl bu component Auth Server’dan gelen sonucu karşıladıktan sonra artık kullanıcıyı bir sayfaya yönlendirmelidir. Misal bizler bu örneğimizde ana sayfaya(/) yönlendirmekteyiz. Lakin bu yönlendirmenin yapılacağı adresin ihtiyaca binaen özel olarak belirlenmesi en doğrusu olacaktır.

Ve son olarak giriş yapma işlemleri için oluşturulan bir component(home) içerisinde aşağıdaki çalışmalar yapılmalıdır.
home.component.html

<h4>{{message}}</h4>
<button (click)="login()">Login</button> | <button (click)="logout()">Logout</button>
<br>
<h3 style="color:red;">{{bankaData}}</h3>

home.component.js

import { Component, OnInit } from '@angular/core';
import { AuthService } from 'src/app/services/auth.service';
import { HttpClient } from '@angular/common/http';

@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.css']
})
export class HomeComponent implements OnInit {

  constructor(private authService: AuthService, private httpClient: HttpClient) { }

  message: string;
  bankaData: any = "Bağlantı sağlanamadı...";
  ngOnInit(): void {
    this.authService.userManager.getUser().then(user => {
      //Kullanıcı login olduysa burası tetiklenecek.
      if (user) {
        console.log(user);
        localStorage.setItem("accessToken", user.access_token)
        this.message = "Giriş başarılı...";
      }
      else
        this.message = "Giriş başarısız...";
    }).then(() => this.httpClient.get("https://localhost:2000/api/banka", {
      headers:{"Authorization":"Bearer " + localStorage.getItem("accessToken")}
    }).subscribe(data => this.bankaData = data));
  }

  login() {
    this.authService.userManager.signinRedirect();
  }

  logout() {
    this.authService.userManager.signoutRedirect();
  }
}

Görüldüğü üzere kullanıcı ilgili component’a istek gönderdiği taktirde biraz önce oluşturulan ‘AuthService’ üzerinden Auth Server’a bir istek gönderilecek ve ‘getUser()’ fonksiyonu ile istek neticesinde giriş yapan kullanıcı elde edilecektir. Bu örnekte gelen kullanıcının access token değeri local storage’a yazdırılmakta ve ilgili API’a istek yapılırken tekrar local storage’dan elde edilerek header’a yerleştirilip istek gönderilmektedir.

Ayrıca ‘login’ fonksiyonunda Auth Server üzerinden ‘signinRedirect()’ fonksiyonu ile giriş talebi yapılmakta ve ilgili serviste yapılan konfigürasyonlara uygun Auth Server’a gerekli yönlendirmeler sağlanmaktadır. Tabi giriş işlemi başarıyla sağlandığı taktirde kullanıcı konfigürasyonlarda belirtilen geri dönüş adresine yönlendirilmektedir. Benzer şekilde işlevsellik ‘logout’ fonksiyonu içinde geçerlidir ve çıkış işlemi sağlandıktan sonra belirtilen adrese yönlendirme yapılmaktadır.

Bu geliştirmelerden sonra ilgili component’ların rotalarını da kodlarda belirtilenlere uygun olarak aşağıdaki gibi belirleyebiliriz;

import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { CallbackComponent } from './components/callback/callback.component';
import { HomeComponent } from './components/home/home.component';
import { SilentCallbackComponent } from './components/silent-callback/silent-callback.component';

const routes: Routes = [
  {
    path: '', component: HomeComponent
  },
  {
    path: "callback", component: CallbackComponent
  }
];

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

Nihai olarak uygulamayı derleyip, çalıştırırsak eğer aşağıdaki ekran görüntüsünde olduğu gibi sıkıntısız yetkilendirmenin yapıldığını görebilmekteyiz.
IdentityServer4 Yazı Serisi #21 - Angular İle IdentityServer4 Kullanımı
Ayriyetten Angular uygulamasının Auth Server’dan aldığı cevabı konsolda incelersek eğer;
IdentityServer4 Yazı Serisi #21 - Angular İle IdentityServer4 Kullanımı
görüldüğü üzere access token değerinden, profil bilgilerine, client’ın tüm scope’larından, token type’ına kadar tüm bilgiler gelmektedir.

Refresh Token(Silent Refresh) Kullanımı

UI tabanlı client’lar da yapılan kimlik doğrulama işlemleri server side client’lara nazaran daha yüksek güvenlik riski taşımakta olduklarından dolayı refresh token gibi hassas verilerin tarayıcıda tutulmasına pek güvenilememekte ve bundan dolayı OpenId Connect protokolüyle yapılan çalışmalarda client’ta SPA uygulaması varsa refresh token kullanılmamaktadır. Nihayetinde SPA uygulaması üzerinden kötü niyetli kullanıcılar, bir vatandaşın refresh token değerini elde ederek, Auth Server üzerinden sisteme bağlı tüm client’ları sömürebilme şansınız elde edebilecektir. Böyle bir ihtimal sonucunda doğabilecek zarureti varın siz düşünün. Haliyle tarafımızca göze alınamayacak derecede büyük bir risk arz eden bu durum için refresh token kullanmamayı tercih etmekyiz…

Peki hoca, refresh token kullanmayacaksak ne kullanacağız? şeklinde sorduğunuzu duyar gibiyim… Evet, ara başlık olarak refresh token değerini yazmış olabilirim lakin teknik boyutta kullanacağımız yapılanma refresh token yerine Silent Refresh(Sessiz Yenileme) yapılanmasıdır.

Silent Refresh, tıpkı refresh token gibi kullanıcı deneyimi açısından süreci efektif hale getirebilmek için geliştirilmiş bir stratejidir. Kullanıcıyı yeni bir yetkili access token edinebilmesi için istek/login sayfasına yönlendirmeye gerek kalmaksızın açık olan oturum üzerinden otomatik access token almasını sağlayan ve bunu arkaplanda, *sessizce* gerçekleştiren bir yapılanmadır.

Bu yapılanmayı aktifleştirebilmek için ilk olarak Auth Server’daki ‘Config.cs’ dosyasında tanımlı olan client üzerinde aşağıdaki konfigürasyon yapılmalıdır.

        #region Clients
        public static IEnumerable<Client> GetClients() =>
            new List<Client>
            {
                new Client
                {
                    ClientId = "AngularClient",
                    .
                    .
                    .
                    RedirectUris = {"http://localhost:4200/callback", "http://localhost:4200/silent-callback"},
                    AccessTokenLifetime = 70
                }
            };
        #endregion

Yukarıdaki kod bloğunu incelerseniz eğer client’a http://localhost:4200/silent-callback dönüş adresi eklenmiştir. Silent refresh yapılanması ile yapılacak talebin bilgileri ve konfigürasyonları için kullanıcı bu adrese yönlendirilecek ve hangi server’a(auth server), hangi response_type türünde istek yapılacağını ve hangi scope’ların talep edileceğini bu adreste bildireceğiz. Yani birazdan Angular uygulamasında bu adrese karşılık gelen bir component oluşturacak ve bahsedilen tüm bu işlemleri icra edeceğiz. ‘AccessTokenLifetime’ özelliğine gelirsek eğer, silent refresh yapılanması kendisini access token ömrünün bitimine her 60 saniye kaldığında tetiklemektedir bundan dolayı oluşturulacak access token süresini 70 saniye olarak belirliyoruz.

Auth server’da ki konfigürasyondan sonra yönümüzü Angular’a çevirelim ve oluşturduğumuz ‘AuthService’ isimli servisimizde tanımlanan konfigürasyona auth server’da tanımlanan silent redirect url’i aşağıdaki gibi bildirelim.

import { Injectable } from '@angular/core';
import * as oidc from "oidc-client";

@Injectable({
  providedIn: 'root'
})
export class AuthService {

  config: oidc.UserManagerSettings = {
    .
    .
    .
    automaticSilentRenew: true,
    silent_redirect_uri: "http://localhost:4200/silent-callback"
  };

  userManager: oidc.UserManager;
  constructor() {
    this.userManager = new oidc.UserManager(this.config);
  }
}

Böylece bu serviste oluşturulan userManager nesnesi ‘automaticSilentRenew’ sayesinde silent refresh operasyonunu aktifleştireceğini ve gerekli konfigürasyonlar için kullanıcıyı ‘silent_redirect_uri’ adresine yönlendireceğini bilmektedir. Haliyle şimdi ‘silent-callback’ adresini karşılayacak olan component’ı geliştirelim.

import { Component, OnInit } from '@angular/core';
import * as oidc from 'oidc-client';

@Component({
  selector: 'app-silent-callback',
  templateUrl: './silent-callback.component.html',
  styleUrls: ['./silent-callback.component.css']
})
export class SilentCallbackComponent implements OnInit {

  constructor() { }

  config: oidc.UserManagerSettings = {
    authority: "https://localhost:1000",
    client_id: "AngularClient",
    response_type: "code",
    scope: "Garanti.Write Garanti.Read profile openid email Roles",
  }

  ngOnInit(): void {
    new oidc.UserManager(this.config).signinSilentCallback()
      .catch(error => console.log(error));
  }
}

Burada görüldüğü üzere ‘UserManager’ nesnesi yeni bir access token’a odaklı konfigürasyonla ‘signinSilentCallback’ fonksiyonunu tetiklemektedir ve böylece bu fonksiyon ile yeni bir access token talep edilmektedir.

Yapılan bu çalışma neticesinde uygulamayı derleyip, çalıştırdığımızda işleyişi aşağıdaki gibi gözlemleyebilmekteyiz.
IdentityServer4 Yazı Serisi #21 - Angular İle IdentityServer4 Kullanımı
Dikkat ederseniz eğer silent refresh ile yapılan istek neticesinde elde edilen token bir sonraki ...userManager.getUser()... talepte tarayıcıya fiziksel olarak gönderilmektedir. Client gelen bu yeni token’ı ilgili storage’lara depoladıktan sonra isteklerine yeni access token ile devam edebilmekte ve kesintiye uğramaksızın işlemlerini gerçekleştirebilmektedir.

Uzun ve zahmetli bir yazı oldu… Umarım konuya dair meramınızı giderecek bir içerik ortaya koyabilmişimdir 🙂

İlgilenenlerin faydalanması dileğiyle…
Sonraki yazılarımda görüşmek üzere…
İyi çalışmalar…

Not : Örnek uygulamaları indirmek için aşağıdaki linklere tıklayınız.
AngularClient
SPAClientIdentityServer4Example

Bunlar da hoşunuza gidebilir...

1 Cevap

  1. 29 Kasım 2020

    […] IdentityServer4 Yazı Serisi #21 – Angular İle IdentityServer4 Kullanımı ve Refresh Token(Silen…Merhaba, […]

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir