HowTo: LDAP Login mit Zend Framework 2 - Teil2

Kategorie:
Entwicklung
Zend Framework 2

Dieses HowTo ist die Fortsetzung des Artikels vom 26. März .

Im ersten Teil habe ich beschrieben wie der Login mit LDAP realisierbar ist. In zweiten Teil geht es um die Umsetzung einer Zugriffsbeschränkung/-erweiterung aufgrund einer LDAP-Anmeldung.

Bevor es losgeht möchte ich meinen Ansatz und meine Vorgehensweise beschreiben:

Wenn der User sich erfolgreich über LDAP authentifiziert hat, wird geprüft ob der User in der User-Tabelle der Anwendung vorhanden ist. Wenn nicht, wird dieser hinzugefügt und der Administrator erhält eine Email um dem Benutzer eine zuvor angelegte Rolle aus der Datenbank zuzuweisen. Ist der User in der User-Tabelle vorhanden und besitzt eine Rolle, ist die Anmeldung erfolgreich. In der Applikation wird unter anderem in der Navigation sowie in diversen Actions das Recht der Rolle abgefragt und der User erhält Zugriff oder nicht.

Datenbank

Falls nicht schon vorhanden solltet ihr jetzt eine Datenbank für eure Applikation erstellen und in der config definieren:

return array(
'service_manager' => array(
'factories' => array(
'navigation' => 'Zend\Navigation\Service\DefaultNavigationFactory',
'Zend\Db\Adapter\Adapter' => 'Zend\Db\Adapter\AdapterServiceFactory',
),
),
);

Hier seht ihr, dass ich eine Zend Navigation und und einen Zend DB Adapter zu meinem Service-Manager hinzugefügt habe. Für den Login ist aber nur der Adapter erforderlich.

Man könnte jetzt die Zugangsdaten auch in der global.php definieren, jedoch ist der Denkansatz hier, dass die local.php bei einem Commit oder Ähnlichem ignoriert wird. Damit ist gewährleistet, dass bei einem Update die Datenbank-Zugangsdaten nicht überschrieben werden. Eine Beispielkonfiguration der local.php könnte so aussehen:

return array(
'db' => array(
'driver' => 'pdo',
'dsn' => 'mysql:dbname=mein_tool;host=localhost',
'username' => 'userxxx',
'password' => 'xxx',
),
);
CREATE TABLE IF NOT EXISTS `option` (
`name` varchar(20) NOT NULL,
`value` text,
`description` varchar(255) DEFAULT NULL,
UNIQUE KEY `name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1;

CREATE TABLE IF NOT EXISTS `role` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(30) NOT NULL,
`default` tinyint(1) NOT NULL DEFAULT '0',
`admin` tinyint(1) NOT NULL DEFAULT '0',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1 AUTO_INCREMENT=1 ;

CREATE TABLE IF NOT EXISTS `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(30) NOT NULL,
`type` varchar(7) NOT NULL,
`password` varchar(255) DEFAULT NULL,
`firstname` varchar(30) DEFAULT NULL,
`lastname` varchar(50) DEFAULT NULL,
`role_id` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=latin1 AUTO_INCREMENT=1 ;

INSERT INTO `role` (`id`, `name`, `default`, `admin`) VALUES
(1, 'guest', 1, 0),
(2, 'admin', 0, 1);

In diesem Beispiel gibt es 2 vordefinierte Rollen "guest" und "admin" und ein Recht "admin".

Model-Klassen

Um Zugriff auf die Einträge in der Datenbank zu erhalten, erstellen wir je eine Model-Klasse für die Tabelle und eine für einen Datensatz.

User

<?php

namespace Application\Model;

class User
{
public $id;
public $username;
public $type;
public $password;
public $firstname;
public $lastname;
public $role_id;
protected $adapter;

public function __construct(Adapter $adapter=null)
{
if(!empty($adapter)) {
$this->adapter = $adapter;
}
}

public function exchangeArray($data)
{
$vars = $this->getArrayCopy();
foreach($vars as $name => $value) {
$this->$name = (isset($data[$name])) ? $data[$name] : null;
}
}

public function getArrayCopy()
{
$vars = get_object_vars($this);
unset($vars["adapter"]);
return $vars;
}

public function save(Adapter $adapter=null)
{
if(!empty($adapter)) {
$this->adapter = $adapter;
}
if(!empty($this->adapter)) {
$user_table = new UserTable($this->adapter);
$user_table->save($this);
return true;
}
return false;

}
}

UserTable

<?php

namespace Application\Model;

use Zend\Db\Adapter\Adapter;
use Zend\Db\ResultSet\ResultSet;
use Zend\Db\TableGateway\AbstractTableGateway;

class UserTable extends AbstractTableGateway
{
protected $table ='user';

public function __construct(Adapter $adapter)
{
$this->adapter = $adapter;
$this->resultSetPrototype = new ResultSet();
$this->resultSetPrototype->setArrayObjectPrototype(new User());

$this->initialize();
}

public function fetchAll()
{
$resultSet = $this->select();
return $resultSet;
}

public function getById($id)
{
$id = (int) $id;
$rowset = $this->select(array(
'id' => $id,
));
$row = $rowset->current();
if (!$row) {
throw new \Exception("Could not find row $id");
}

return $row;
}

public function getByUsername($username)
{
$rowset = $this->select(array(
"username" => $username
));
$row = $rowset->current();
if (!$row) {
throw new \Exception("Could not find row $id");
}

return $row;
}

public function save(User $object)
{
$data = array(
'username' => $object->username,
'type' => $object->type,
'password' => $object->password,
'firstname' => $object->firstname,
'lastname' => $object->lastname,
'role_id' => $object->role_id,
);

$id = (int) $object->id;

if ($id == 0) {
$this->insert($data);
} elseif ($this->getById($id)) {
$this->update(
$data,
array(
'id' => $id,
)
);
} else {
throw new \Exception('Form id does not exist');
}
}

public function deleteById($id)
{
$this->delete(array(
'id' => $id,
));
}

public function existsByUsername($username) {
$row = $this->getByUsername($username);
if (!$row) {
return false;
}
return true;
}

public function hasRoleByUsername($username) {
$rowset = $this->select(array(
"username" => $username
));

$row = $rowset->current()->getArrayCopy();
if(!$row) {
return false;
}
if(!empty($row["role_id"])) {
return true;
}
return false;
}
}

Role

<?php
namespace Application\Model;

class Role
{
public $id;
public $name;
public $default;
public $admin;
protected $adapter;
protected $table;

public function __construct(Adapter $adapter=null)
{
if(!empty($adapter)) {
$this->setAdapter($adapter);
}
}

public function setAdapter(Adapter $adapter) {
if(!empty($adapter)) {
$this->adapter = $adapter;
$this->table = new RoleTable($this->adapter);
}
}

public function exchangeArray($data)
{
$vars = $this->getArrayCopy();
foreach($vars as $name => $value) {
$this->$name = (isset($data[$name])) ? $data[$name] : null;
}
}

public function getArrayCopy()
{
$vars = get_object_vars($this);
unset($vars["adapter"]);
unset($vars["table"]);
return $vars;
}

public function save(Adapter $adapter=null)
{
$this->setAdapter($adapter);
if(!empty($this->table)) {
$this->table->save($this);
return true;
}
return false;

}

public function setAsDefault(Adapter $adapter=null)
{
$this->setAdapter($adapter);
if(!empty($this->table)) {
$this->table->setDefault($this->id);
return true;
}
return false;
}

public function IsAllowed(Adapter $adapter, $role_id, $resource=null, $privilege=null)
{
$this->setAdapter($adapter);
if(!empty($this->table)) {
return $this->table->IsAllowed($role_id,$resource,$privilege);
}
}
}

RoleTable

<?php

namespace Application\Model;

use Zend\Db\Adapter\Adapter;
use Zend\Db\ResultSet\ResultSet;
use Zend\Db\TableGateway\AbstractTableGateway;
use Zend\Permissions\Acl\Acl;
use Zend\Permissions\Acl\Role\GenericRole;

class RoleTable extends AbstractTableGateway
{
protected $table ='role';
protected $acl;
protected $resources = array();

public function __construct(Adapter $adapter)
{
$this->adapter = $adapter;

$this->resultSetPrototype = new ResultSet();
$this->resultSetPrototype->setArrayObjectPrototype(new Role());

$this->initialize();
$this->initAcl();
}

public function initAcl()
{
$this->acl = new Acl();
//set properties of Role as resources
$role = new Role();
$resources = array_keys($role->getArrayCopy());
$not_resources = array("id", "name", "default");
$this->resources = array_diff($resources, $not_resources);
$i = 0;
$roles = $this->fetchAll();//get all roles from db
foreach($roles as $role) {
$this->acl->addRole(new GenericRole($role->id));//add role to acl
foreach($this->resources as $resource) {
if($I == 0) {//add resource on first run
$this->acl->addResource('mvc:'.$resource);
$I++;
}

if($role->$resource == 1) {
$this->acl->allow((string)$role->id, 'mvc:'.$resource, null);//allow
}else {
$this->acl->deny((string)$role->id, 'mvc:'.$resource, null);//deny
}

}
}
}

public function fetchAll()
{
$resultSet = $this->select();
return $resultSet;
}

public function getById($id)
{
$id = (int) $id;
$rowset = $this->select(array(
'id' => $id,
));
$row = $rowset->current();
if (!$row) {
throw new \Exception("Could not find row $id");
}

return $row;
}

public function save(Role $object)
{
$data = array();
foreach($this->resources as $resource) {
$data[$resource] = $object->$resource;
}
$data['name'] = $object->name;

$id = (int) $object->id;

if ($id == 0) {
$insert = $this->insert($data);
$id = $this->getLastInsertValue();
} elseif ($this->getById($id)) {
$this->update(
$data,
array(
'id' => $id,
)
);
} else {
throw new \Exception('Form id does not exist');
}
if(!empty($object->default) && $object->default == 1) {
$this->setDefault($id);
}
}

public function deleteById($id)
{
$this->delete(array(
'id' => $id,
));
}

public function setDefault($role_id)
{
if($this->getById($role_id)) {
$this->update(array("default" => 0));
$this->update(array("default" => 1), array("id" => $role_id));
return true;
}
return false;
}

public function getDefault()
{
$result = $this->select(array("default" => 1));
$row = $result->current();
if (!$row) {
throw new \Exception("No default role set");
}

return $row;

}

public function IsAllowed($role_id, $resource=null, $privilege=null)
{
return $this->acl->isAllowed((string)$role_id, $resource, $privilege);
}

public function getAcl()
{
return $this->acl;
}
}

Die initAcl-Methode ist eine der interessantesten Teile dieses Beispiels. In ihr werden die Rollen aus der Datenbank ausgelesen und in eine Zend ACL übertragen. Mit der isAllowed-Methode wird einfach ohne Umwege abgefragt, ob die Rolle befugt ist, auf eine gewisse Resource(=Datenbankspalte) zuzugreifen. Die Bezeichnung der Resource setzt sich aus "mvc:" plus dem Namen der Spalte aus der Datenbank zusammen. In der Konfiguration von Zend Navigation kann man diese Resource bei einer Page aus Zend Navigation angeben. Eine Erweiterung mittels eines Privilegs(zB. lesen, schreiben) ist mit dieser Datenbankstruktur nicht vorgesehen. Sollten komplexere Rechte-Systeme erforderlich sein, ist es sehr sinnvoll die Datenbankstruktur abzuändern und die Methode anzupassen!

Option

<?php

namespace Application\Model;

class Option
{
public $name;
public $value;
public $description;

public function exchangeArray($data)
{
$vars = get_object_vars($this);
foreach($vars as $name => $value) {
$this->$name = (isset($data[$name])) ? $data[$name] : null;
}
}

public function getArrayCopy()
{
return get_object_vars($this);
}
}

OptionTable

<?php

namespace Application\Model;

use Zend\Db\Adapter\Adapter;
use Zend\Db\ResultSet\ResultSet;
use Zend\Db\TableGateway\AbstractTableGateway;

class OptionTable extends AbstractTableGateway
{
protected $table ='option';
protected $options = array(
array(
"name" => "admin_notify_email",
"value" => "admin@example.com",
"description" => "Admin-Email-Adresse: erhält alle Admin-Nachrichten",
),
array(
"name" => "system_email",
"value" => "system@example.com",
"description" => "Absender-Email-Adresse für System-Nachrichten",
),
array(
"name" => "app_name",
"value" => "Mein System",
"description" => "Name der Applikation",
),
array(
"name" => "footer_text",
"value" => "Das ist der Footer von meiner Applikation",
"description" => "Text der ganz unten auf der Seite angezeigt wird",
),
);

public function __construct(Adapter $adapter)
{
$this->adapter = $adapter;
$this->resultSetPrototype = new ResultSet();
$this->resultSetPrototype->setArrayObjectPrototype(new Option());

$this->registerOptions();
$this->initialize();
}

public function fetchAll()
{
$resultSet = $this->select();
return $resultSet;
}

public function getByName($name)
{
$rowset = $this->select(array("name" => $name));
$row = $rowset->current();
if (!$row) {
return false;
}

return $row;
}

public function getValue($name) {
$row = $this->getByName($name);
$row = $row->getArrayCopy();
return $row["value"];
}

public function save(Option $object)
{
$data = array(
'name' => $object->name,
'value' => $object->value,
'description' => $object->description,
);

try {
$this->update($data, array('name' => $object->name));
} catch (\Exception $e) {
throw new \Exception("Could not find row $object->name");
}
}

protected function registerOptions()
{
foreach($this->options as $option) {
$row = $this->getByName($option["name"]);
if(empty($row)) {
$this->insert($option);
}
}
}

}

In der Eigenschaft "options" werden alle Einträge inklusiver vordefinierter Werte eingetragen. Die registerOptions-Methode überprüft beim Aufruf ob alle Einträge in der Datenbank enthalten sind, und fügt eventuell neue Einträge hinzu. Mit der getValue-Methode bekommt man den den Wert einer gewünschten Option aus der Datenbank.

AuthController

Nachdem alle Model-Klassen erstellt wurden können wir nun die neuen Methoden nutzen um die Login-Action zu erweitern. Der erweiterte AuthController:

<?php

namespace Application\Controller;

use Application\Model\OptionTable;

use Zend\Mvc\Controller\AbstractActionController;
use Zend\View\Model\ViewModel;
use Zend\View\Model\JsonModel;
use Application\Model\UserTable;
use Application\Model\User;
use Zend\Mail\Message;
use Zend\Mail\Transport\Sendmail as SendmailTransport;
#auth
use Zend\Authentication\AuthenticationService;
use Zend\Authentication\Adapter\Ldap as AuthAdapter;
use Zend\Config\Reader\Ini as ConfigReader;
use Zend\Config\Config;
use Zend\Log\Logger;
use Zend\Log\Writer\Stream as LogWriter;
use Zend\Log\Filter\Priority as LogFilter;

class AuthController extends AbstractActionController
{
protected $auth;

public function __construct()
{
if (! $this->auth) {
$this->auth = new AuthenticationService();
}
}

public function indexAction()
{
$this->adapter = $this->getServiceLocator()->get('Zend\Db\Adapter\Adapter');
$user = $this->auth->getIdentity();
if(empty($user)) {
return new ViewModel();
}else {
return $this->redirect()->toRoute("application/auth", array("action" => "profile"));
}
}

public function loginAction()
{
$username = $this->getRequest()->getPost('username');
$password = $this->getRequest()->getPost('password');

$configReader = new ConfigReader();
$configData = $configReader->fromFile('./config/ldap-config.ini');
$config = new Config($configData, true);

$log_path = $config->production->ldap->log_path;
$options = $config->production->ldap->toArray();
unset($options['log_path']);

$adapter = new AuthAdapter($options,
$username,
$password);

$result = $this->auth->authenticate($adapter);
$messages = $result->getMessages();

if ($log_path) {
$logger = new Logger;
$writer = new LogWriter($log_path);

$logger->addWriter($writer);

$filter = new LogFilter(Logger::DEBUG);
$writer->addFilter($filter);

foreach ($messages as $I => $message) {
if ($I> 1) { // $messages[2] and up are log messages
$message = str_replace("\n", "\n ", $message);
$logger->debug("Ldap: $I: $message");
}
}
}
if(empty($messages[0])) {//successfull ldap login
$adapter = $this->getServiceLocator()->get('Zend\Db\Adapter\Adapter');
$user_table = new UserTable($adapter);

if(!$user_table->existsByUsername($username)) {//if no user exists in db

$user = new User();
$user->exchangeArray(array(
"username" => $username,
"type" => "ldap"
));
$user->save($adapter);//save new user to db
$this->auth->clearIdentity();//delete session
$return = "Der Admin muss noch eine Rolle zuweisen, damit Sie sich anmelden k&ouml;nnen!";

//get options from database
$option_table = new OptionTable($adapter);
$admin = $option_table->getValue("admin_notify_email");
$system = $option_table->getValue("system_email");
$app_name = $option_table->getValue("app_name");

//send message to admin
$message = new Message();
$message->addTo($admin)
->addFrom($system)
->setSubject($app_name.': User "'.$username.'" möchte sich anmelden')
->setBody('Der User "'.$username.'" hat versucht sich bei '.$app_name.' anzumelden. Momentan hat er keine erweiterten Rechte.
Um ihm mehr Rechte zu geben, weisen Sie ihm eine Rolle zu!'
);

$transport = new SendmailTransport();
$transport->send($message);

}elseif(!$user_table->hasRoleByUsername($username)) {//if user got no role
$this->auth->clearIdentity();
$return = "Der Admin muss noch eine Rolle zuweisen, damit Sie sich anmelden k&ouml;nnen!";
}else {//if everything ok, write user information in storage
$user = $user_table->getByUsername($username);
$this->auth->getStorage()->write($user);
}
} else {
switch($messages[0]) {
case "A password is required": $return = "Bitte geben Sie ein Passwort ein!";
break;
case "A username is required": $return = "Bitte geben Sie einen Benutzernamen ein!";
break;
case "An unexpected failure occurred": $return = "Ein unerwarteter Fehler ist aufgetreten!";
break;
default: $return = $messages[0];
}
}
$json = new JsonModel(array("message" => $return));
return $json;
}

public function logoutAction()
{
$this->auth->clearIdentity();
return $this->redirect()->toRoute("application", array("action" => "index"));
}

public function profileAction()
{
return new ViewModel(array(
"user" => $this->auth->getIdentity(),
));
}

}

Routing

Damit der Redirect korrekt funktioniert musste ich das Routing um "auth" erweitern:

                'child_routes' => array(
'default' => array(

),
'auth' => array(//new route
'type' => 'Segment',
'options' => array(
'route' => '/auth[/:action]',
'defaults' => array(
'controller' => 'Application\Controller\Auth',
'action' => 'index',
),
),
), //end new route

Fertig :-)

Damit ist die LDAP-Anmeldung fertig umgesetzt. Ihr könnt jetzt mit Hilfe der isAllowed-Methode in euren Actions abfragen ob der User befugt ist, diese Action auszuführen. Was jetzt noch fehlt ist ein Frontend für die Verwaltung der Rollen, User und Optionen. Das schafft ihr aber schon selbst ;-) Ich hoffe ich konnte euch mit meinem Tutorial/HowTo helfen!!!

5. April 2013
Christoph Müller