JWT (JSON Web Tokens), bir RFC7519 endüstri standartıdır. JWT, kullanıcının doğrulanması, web servis güvenliği, bilgi güvenliği gibi birçok konuda kullanılabilir. JWT oldukça popüler ve tercih edilen bir yöntemdir.
Bir JWT kimlik doğrulama yöntemi Session Stateless çalışır. Bu durum bilgisi olmadığı ve sunucunun, sunucu tarafında istemci oturumu hakkında herhangi bir durumu saklamadığı anlamına gelir. Yani kullanıcı bilgileri ve oturum son geçerlilik tarihi ne sunucuda, ne de istemci tarafında tutulur. Tüm bilgiler jeton içerisindedir.
Jwt dezavantajları için takip eden bağlantıyı tıklayın.
Yukarıda anlatılan dezavantajları ortadan kaldırmak için Olobase'da kullanılan bir dizi önlemden bahsedebiliriz.
JWT ile imzalanmış bir jeton Base64 ile kodlanmış 3 ana kısımdan oluşmaktadır. Bunlar Header(Başlık), Payload(Veri), Signature(İmza) kısımlarıdır. Aşağıdaki jeton örneğinde dikkat edecek olursak aaa.bbb.ccc şeklinde noktalarla ayrılmış 3 alan bulunmaktadır.
Örnek Jeton: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdGF0dXMiOiJ0ZWJyaWtsZXIhIDopIn0.sTLXY5iAs1IzJJ-8GVP_pMR65qqgCUpbMl-aSPcrQHc
JWT'de kullanılacak bu kısım JSON formatında yazılmakta ve 2 alandan oluşmaktadır. Bunlar jeton tipi ve imzalama için kullanılacak algoritmanın adı.
Örnek olarak:
{
"typ": "JWT",
"alg": "EdDSA"
}
Algoritma kısmında EdDSA, HS256, HMAC SHA256 ya da RSA gibi birçok farklı algoritma kullanılabilir. Type kısmında ise JWT yazmakta. Bu kısım Base64 ile encode edilir ve oluşturulacak jetonun ilk parçasını oluşturur.
Bu kısım claim'leri içerir. Bu kısımda tutulan veriler ile jeton istemci ve sunucu arasında eşsiz olur. Bu tutulan claim bilgileri de bu eşsizliği sağlar. Bu kısımda 3 tip claim bulunmaktadır.
JWT tarafından önceden rezerve edilmiş 3 harf uzunluğunda claim 'lerdir. Yani bu ayarlanmış belli claim isimlerini diğer claim lerde kullanamazsınız. Bu bilgilerin kullanılması zorunlu değildir ama önerilmektedir. Bu claimlerden bazıları iss (issuer), exp (expiration time), sub (subject), aud(audience) ve diğerleri. Bunlardan en çok kullanılanı expiration time yani son geçerlilik tarihidir. Örneğin jeton bilginizin 3 saat sonra geçersiz olmasını isterseniz bu bilgiyi exp alanında gönderirsiniz. 3 saat ardından aynı jeton ile gelen isteklerde jeton geçersiz olarak değerlendirilir.
İsteğe bağlı, açık yayınlanan claimlerdir.
Tarafların kendi aralarında bilgi taşımak için kullandığı gizli claim lerdir.
Örnek bir payload alanı:
{
"sub": "1234567890",
"name": "John Doe",
"email": "[email protected]",
"iat": 1516239022
}
Bu kısım Base64 ile encode edilir ve oluşturulacak jetonun ikinci parçasını oluşturur.
Bu kısım jetonun son kısmıdır. Bu kısmın oluşturulabilmesi için header, payload ve gizli anahtar(gizli) gereklidir. İmza kısmı ile veri bütünlüğü garanti altına alınır. Burada kullandığımız gizli anahtar Header kısmında belirttiğimiz algoritma için kullanılır. Header ve Payload kısımları bu gizli anahtar ile imzalanır
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
your-256-bit-secret
)
Doğrulama işlemi üstteki senaryoda da belirttiğimiz gibi istemci tarafından jeton geldikten sonra kullanıcının yetkisini kontrol etmek için kullanılır. jetonun geçerli olup olmadığı JWT ile doğrulanır. JWT doğrulama işlemi oldukça basittir. Gelen jetonda Header ve Payload sunucumuzda bulunan gizli anahtar ile imzalanır ve 3. kısım hesaplanır. Daha sonra bu oluşturulan imza istemci tarafından gelen imza ile karşılaştırılır. Eğer imzalar aynı ise jeton geçerli sayılır ve kullanıcıya erişim verilir.
Bu bölümde uygulamanızda oturum açmadan önce yapmanız gereken aşağıdaki adımlara değilecek:
echo base64_encode(openssl_random_pseudo_bytes(32)); // ewQrCBs/3Mp7RKgtbjd4jjdOJLY8uyENcmKcssQnvWE=
$keyPair = sodium_crypto_sign_keypair();
$publicKey = base64_encode(sodium_crypto_sign_publickey($keyPair));
$privateKey = base64_encode(sodium_crypto_sign_secretkey($keyPair));
echo $publicKey."\n"; // W9JHddARm1iwrIV+DhlQ1t0vGxWwgwVTHyHpjq6n4L8=
echo $privateKey."\n"; // KXgCiGnLLkYI/j/uGOgmSn5P9lATSZcd/p86azEgwW1b0kd10BGbWLCshX4OGVDW3S8bFbCDBVMfIemOrqfgvw==
Oluşturduğunuz anahtarı aşağıdaki gibi public_key ve private_key alanlarına tanımlamalısınız.
config/autoload/local.php
// local.php
//
'token' => [
// Cookie encryption
'encryption' => [
'iv' => '', // generate random 16 chars
'enabled' => false, // it should be true in production environment
'secret_key' => '',
],
// Public and private keys are expected to be Base64 encoded.
'public_key' => '',
// The secret keys generated by other tools may
// need to be adjusted to match the input expected by libsodium.
'private_key' => '',
//
// for strong security reason it should be less
'session_ttl' => 15, // in minutes (TTL cannot be less then 10 minute)
// you can reduce the time for higher security
// for how long the token will be valid in the app.
// in every "x" time the token will be refresh.
'token_validity' => 5, // in minutes
// whether to check the IP and User Agent when the token is resolved.
//
'validation' => [
'user_ip' => true,
'user_agent' => true,
],
],
Anahtar | Açıklama |
---|---|
encryption.iv | Rastgele 16 karakterlik bir dizilim oluşturun. Çevrimiçi random.org adresini kullanabilirsiniz. |
encryption.enabled | Kullanıcıya gönderilmeden önce belirtecin şifreleme özelliğini açar/kapatır. Üretim ortamında şifrelemenin açılması önemle tavsiye edilir. |
encryption.secret_key | Bu yöntemi kullanarak rastgele bir gizli şifre oluşturun. base64_encode(openssl_random_pseudo_bytes(32)); Bu şifreyi hiç kimseyle paylaşmamalısınız. |
public_key | Genel ve özel anahtarların Base64 kodlu olması bekleniyor. Genel anahtarlar oluşturmak için yukarıdaki örneğe bakın. |
private_key | Diğer araçlar tarafından oluşturulan gizli anahtarların, libsodium tarafından beklenen girdiyle eşleşecek şekilde ayarlanması gerekebilir. Özel anahtarlar oluşturmak için yukarıdaki örneğe bakın. |
session_ttl | Oturumun yaşam süresini belirler. Yani jeton imzalandıktan sonra kullanıcının ne kadar süre sistemde kalacağının süresi önbelleğe kayıt edilir. Kullanıcının tarayıcısı açık oluduğu sürece bu süre, her 5 dakika da bir atılan otomatik http istekleri ile her seferinde başa döndürülmüş olur. Böylece tarayıcısı açık olan kullanıcıların sistemde kalması sağlanır. Tarayıcının kapatıldığı durumda ise önbellekteki bu süre sona ereceğinden kullanıcı oturumu otomatik olarak sonlanır. Oturumun yaşam süresi 10 dakika dan az olmamalıdır. Aksi durumda kullanıcılarınızın oturumları beklenmeyen zamanlarda sonlanabilir. Yine de bu süreyi düşürmek istiyorsanız önyüz uygulamanızın .env.* dosyasında tanımlı olan VITE_SESSION_UPDATE_TIME süresini düşürmelisiniz. |
token_validity | Kullanıcıya verilen jetonların ne kadar süre içinde yenileceğini belirler. Bu süre ne kadar az olursa uygulama güvenliğiniz o kadar artmış olur fakat sürenin 5 dakika dan az olması sunucu ve istemci kaynaklarını yorabilir. |
validation.user_ip | Kullanıcının mevcut IP adresi jeton içinde saklanan IP adresiyle eşleşmiyorsa kullanıcının oturumu kapatılır. |
validation.user_agent | Kullanıcının tarayıcı adı, jeton içine kaydedilen değer ile eşleşmiyorsa kullanıcının oturumu kapatılır. |
App\Authentication\AuthenticationAdapter yetkilendirme bağdaştırıcısı oturum açma sırasında , yapılandırmada tanımlanan tablename, username ve password sütunlarına göre SQL sorgusunu gerçekleştirir.
config/autoload/mezzio.global.php
// mezzio.global.php
//
'authentication' => [
'tablename' => 'users',
'username' => 'email', // identity table column
'password' => 'password', // password table column
'form' => [
'username' => 'username', // username form input name
'password' => 'password', // password form input name
]
],
users isimli tablonuzun veritabanında mevcut olduğundan emin olun.
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
`userId` varchar(36) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NOT NULL,
`email` varchar(160) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`password` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`firstname` varchar(120) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`lastname` varchar(120) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`createdAt` datetime DEFAULT NULL,
`active` tinyint(1) DEFAULT '0',
`themeColor` char(7) DEFAULT NULL,
PRIMARY KEY (`userId`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 ROW_FORMAT=DYNAMIC;
/*Data for the table `users` */
INSERT INTO `users`(`userId`,`email`,`password`,`firstname`,`lastname`,`createdAt`,`active`,`themeColor`) VALUES
(
'21615870-4f89-4ab8-b91e-af6370a3089e',
'[email protected]',
'$2y$10$sXQiNPPK5TQFIORtQ4fxKex4GJkHMa7h5loGHB0Ea.fj4dQWlKZn.',
'Demo',
'Login',
'2021-12-22 12:32:17',
1,
'#0a7248'
),
Veritabanı ve Jwt konfigürasyon ayarlarınızı yaptıktan sonra Postman uygulaması ile oturum açma testi gerçekleştirin. Yeni bir sekme açarak aşağıdaki gibi url kısmına http://demo.local/api/auth/token auth url adresinizi girin.
http://demo.local/api/auth/token
Http POST yöntemiyle json raw gövdesine kullanıcı tablonuzdan seçtiğiniz bir kullanıcı ile giriş yapmayı deneyin.
{
"username": "[email protected]",
"password": "12345678"
}
Giriş testi başarılı ise aşağıdaki gibi bir yanıt alacaksınız.
Örnek bir jeton yanıtı,
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE2OTk1MjAwNDUsImp0aSI6IjE1ZjhiNjY1MGE3ZTM3ODc2NDRhY2Y3Y2ZiMTIzNDQ2IiwiaXNzIjoiaHR0cDovL3ZhLWRlbW8tcGhwIiwibmJmIjoxNjk5NTIwMDQ1LCJleHAiOjE2OTk1MjAzNDUsImRhdGEiOnsidXNlcklkIjoiYzEzZTU1MGEtNjBlZS00OGQ1LWJmNmUtZWQyOTMxMDY0MGIyIiwiaWRlbnRpdHkiOiJkZW1vQGV4YW1wbGUuY29tIiwicm9sZXMiOlsiYWRtaW4iLCJzYWxlcyJdLCJkZXRhaWxzIjp7ImVtYWlsIjoiZGVtb0BleGFtcGxlLmNvbSIsImZpcnN0bmFtZSI6IkRlbW8iLCJsYXN0bmFtZSI6IkFkbWluIiwiaXAiOiIxOTIuMTY4LjIzMS4xIiwiZGV2aWNlS2V5IjoiMWQyMGZlMTExODBiODIxN2FkNmE0NTZlNjc0NWQ0OTkifX19._iUg9YK9DqOPEooacCTKvhzPew_vzWEplpj5Z-Sfw1Y
Elde ettiğiniz her jetonu base64_decode komutu ile çözümleyerek görüntüleyebilirsiniz. Yukarıdaki jeton yanıtını jwt.io adresinde çözümlediğinizde takip eden örnekte olduğu gibi çıktı alacaksınız.
Rotalarınıza kimlik doğrulama eklemek için her rotaya App\Middleware\JwtAuthenticationMiddleware sınıfı, takip eden örnekte olduğu gibi dahil edilmedilir.
$auth = [
JwtAuthenticationMiddleware::class,
Mezzio\Authorization\AuthorizationMiddleware::class,
];
// Companies
//
$app->route('/api/companies/create', [...$auth, [App\Handler\Companies\CreateHandler::class]], ['POST']);
$app->route('/api/companies/update/:companyId', [...$auth, [App\Handler\Companies\UpdateHandler::class]], ['PUT']);
$app->route('/api/companies/delete/:companyId', [...$auth, [App\Handler\Companies\DeleteHandler::class]], ['DELETE']);
$app->route('/api/companies/findAll', [JwtAuthenticationMiddleware::class, App\Handler\Companies\FindAllHandler::class], ['GET']);
$app->route('/api/companies/findAllByPaging', [...$auth, [App\Handler\Companies\FindAllByPagingHandler::class]], ['GET']);
$app->route('/api/companies/findOneById/:companyId', [...$auth, [App\Handler\Companies\FindOneByIdHandler::class]], ['GET']);
Eğer bir rota kimlik doğrulama gerektirmiyorsa $auth değişkenini ilgili diziden kaldırıp sadece handler ismini girmeniz yeterli olacaktır.
// Common (public) resources
//
$app->route('/api/stream/events', App\Handler\Common\Stream\EventsHandler::class, ['GET']);
$app->route('/api/years/findAll', App\Handler\Common\Years\FindAllHandler::class, ['GET']);
Kulanıcı girişinin öncesinde veya sonrasında gelişen olayları kontrol etmek için App\Authentication\JwtAuthentication sınıfı herhangi bir event sınıfını gerektirmeden basitçe kullanıcı için oluşmuşsa aşağıdaki ilgili hataları işler.
Anahtar | Açıklama |
---|---|
AUTHENTICATION_REQUIRED | Kimlik doğrulama gerekli. Lütfen hesabınızda oturum açın. |
USERNAME_OR_PASSWORD_FIELDS_NOT_GIVEN | Kullanıcı adı ve şifre alanları girilmelidir. |
USERNAME_OR_PASSWORD_INCORRECT | Kullanıcı adı veya şifre yanlış. |
ACCOUNT_IS_INACTIVE_OR_SUSPENDED | Bu hesap onay bekliyor veya askıya alındı. |
NO_ROLE_DEFINED_ON_THE_ACCOUNT | Bu kullanıcı için tanımlanmış bir rol yok. |
IP_VALIDATION_FAILED | IP adresiniz doğrulanamadı ve güvenlik nedeniyle çıkış yapıldı. |
USER_AGENT_VALIDATION_FAILED | Tarayıcınız doğrulanamadı ve güvenlik nedeniyle çıkış yapıldı. |
Eğer kimlik doğrulama işleminden sonra bir geliştirme yapmayı düşünüyorsanız initAuthentication metodunu gözatabilirsiniz.
public function initAuthentication(ServerRequestInterface $request) : ?UserInterface
{
$post = $request->getParsedBody();
$usernameField = $this->config['authentication']['form']['username'];
$passwordField = $this->config['authentication']['form']['password'];
// credentials are given ?
//
if (! isset($post[$usernameField]) || ! isset($post[$passwordField])) {
$this->error(Self::USERNAME_OR_PASSWORD_FIELDS_NOT_GIVEN);
return null;
}
$this->authAdapter->setIdentity($post[$usernameField]);
$this->authAdapter->setCredential($post[$passwordField]);
$eventParams = [
'request' => $request,
'username' => $post[$usernameField],
];
// credentials are correct ?
//
$result = $this->authAdapter->authenticate();
if (! $result->isValid()) {
//
// failed attempts event start
//
$results = $this->events->trigger(LoginListener::onFailedLogin, null, $eventParams);
$failedResponse = $results->last();
if ($failedResponse['banned']) {
$this->error($failedResponse['message']);
return null;
}
//
// default behaviour
//
$this->error(Self::USERNAME_OR_PASSWORD_INCORRECT);
return null;
}
$rowObject = $this->authAdapter->getResultRowObject();
//
// successful login event
//
$this->events->trigger(LoginListener::onSuccessfullLogin, null, $eventParams);
//
// user is active ?
//
if (empty($rowObject->active)) {
$this->error(Self::ACCOUNT_IS_INACTIVE_OR_SUSPENDED);
return null;
}
//
// is the role exists ?
//
$roles = $this->authModel->findRolesById($rowObject->userId);
if (empty($roles)) {
$this->error(Self::NO_ROLE_DEFINED_ON_THE_ACCOUNT);
return null;
}
$details = [
'email' => $rowObject->email,
'fullname' => $rowObject->fullname,
'ip' => $this->getIpAddress(),
'deviceKey' => $this->getDeviceKey($request),
];
return ($this->userFactory)(
$rowObject->userId,
$result->getIdentity(),
$roles,
$details
);
}
Yukarıdaki anlatılanlar veya burada anlatılmayan durumlar için daha geniş kapsamlı düzenlemeler yapmayı planlıyorsanız Laminas EventManager sınıfı ile daha ileri düzeyde bir olay yönetimi gerçekleştirebilirsiniz.
Her kimlik doğrulama sonrasında kullanıcı başarılı bir şekilde yaratıldıysa Olobase\Mezzio\Authentication\DefaultUser isimli sınıf oluşturulur ve bu sınıf değiştirelemez niteliktedir.
<?php
declare(strict_types=1);
namespace Olobase\Mezzio\Authentication;
use Mezzio\Authentication\UserInterface;
/**
* Default implementation of UserInterface.
*
* This implementation is modeled as immutable, to prevent propagation of
* user state changes.
*
* We recommend that any details injected are serializable.
*/
final class DefaultUser implements UserInterface
{
/**
* User id
* @var string
*/
private $id;
/**
* User email
* @var string
*/
private $identity;
/**
* User roles
* @var string[]
*/
private $roles;
/**
* User details
* @var array
*/
private $details;
/**
* Constuctor
*
* @param string $id user_id
* @param string $identity user email
* @param array $roles user roles for frontend
* @param array $details extra details
*/
public function __construct(
string $id,
string $identity,
array $roles = [],
array $details = []
)
{
$this->id = $id;
$this->identity = $identity;
$this->roles = $roles;
$this->details= $details;
}
public function getId() : string
{
return $this->id;
}
public function getIdentity() : string
{
return $this->identity;
}
public function getRoles() : array
{
return $this->roles;
}
public function getDetails() : array
{
return $this->details;
}
public function getDetail(string $name, $default = null)
{
return isset($this->details[$name]) ? $this->details[$name] : $default;
}
}
Takip eden örnekte App\Middleware\JwtAuthenticationMiddleware sınıfının içeriği gösteriliyor.
declare(strict_types=1);
namespace App\Middleware;
use Mezzio\Authentication\UserInterface;
use Mezzio\Authentication\AuthenticationInterface;
use Firebase\JWT\ExpiredException;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Laminas\I18n\Translator\TranslatorInterface as Translator;
class JwtAuthenticationMiddleware implements MiddlewareInterface
{
/**
* @var AuthenticationInterface
*/
protected $auth;
public function __construct(AuthenticationInterface $auth, Translator $translator)
{
$this->auth = $auth;
$this->translator = $translator;
}
/**
* {@inheritDoc}
*/
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
{
try {
$user = $this->auth->authenticate($request);
if (null !== $user) {
return $handler->handle($request->withAttribute(UserInterface::class, $user));
}
} catch (ExpiredException $e) {
// 401 Unauthorized response
// Response Header = 'Token-Expired: true'
return new JsonResponse(['data' => ['error' => $this->translator->translate('Token Expired')]], 401, ['Token-Expired' => 1]);
}
return $this->auth->unauthorizedResponse($request);
}
}
Kimlik doğrulama başarılı ise $this->auth->authenticate() metodu ile DefaultUser sınıfı elde ediliyor; ve elde edilen nesne http ara katmanı ile $request sınıfına Mezzio\Authentication\UserInterface nitelik olarak kayıt edilerek kullanıcı nesnesinin tüm katmanlarda evrensel olarak elde edilebilmesi sağlanıyor.
$request->withAttribute(UserInterface::class, $user); // Set user
Request sınıfına atanan doğrulanmış kullanıcının bilgilerine bir handler sınıfı içerisinden aşağıdaki gibi ulaşabilirsiniz.
$user = $request->getAttribute(UserInterface::class); // get DefaultUser Class
$userId = $user->getId(); // get id from current user
Takip eden örnekte FindMeHandler sınıfı içerisinde kullanıcının id değeri elde ediliyor.
src/App/Handler/Account/FindMeHandler.php
<?php
declare(strict_types=1);
namespace App\Handler\Account;
use App\Model\AccountModel;
use Olobase\Mezzio\DataManagerInterface;
use App\Schema\Account\AccountFindMe;
use Mezzio\Authentication\UserInterface;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
class FindMeHandler implements RequestHandlerInterface
{
public function __construct(
private AccountModel $accountModel,
private DataManagerInterface $dataManager
)
{
$this->dataManager = $dataManager;
$this->accountModel = $accountModel;
}
/**
* @OA\Get(
* path="/account/findMe",
* tags={"Account"},
* summary="Find my account data",
* operationId="account_findOneById",
*
* @OA\Response(
* response=200,
* description="Successful operation",
* @OA\JsonContent(ref="#/components/schemas/AccountFindMe"),
* )
*)
**/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$user = $request->getAttribute(UserInterface::class); // get user from current token
$userId = $user->getId();
$row = $this->accountModel->findMe($userId);
if ($row) {
$data = $this->dataManager->getViewData(
AccountFindMe::class,
$row
);
return new JsonResponse($data);
}
return new JsonResponse([], 404);
}
}