Autorisierung mit Apache Shiro
Mit Apache Shiro steht dem Java-Entwickler ein umfangreiches und ausgereiftes Security-Framework mit den Kern-Features Authentifizierung, Autorisierung, Session-Management und Kryptographie zur Verfügung. Dieser Artikel konzentriert sich auf das Thema Autorisierung und zeigt, wie Shiros Permission-Konzept genutzt werden kann, um komplexe Berechtigungen abzubilden. Eine allgemeine Einführung in Shiro steht unter http://shiro.apache.org/ bereit. Ein vollständiges Code-Beispiel der hier vorgestellten Lösung ist unter https://github.com/triologygmbh/complex-shiro-permissions einsehbar.
Shiro-Permissions
Shiro bietet zwei Konzepte für die Autorisierung an: Rollen und Permissions. Rollen-basierte Berechtigungsprüfungen eigenen sich für einfache, überschaubare Szenarien. Permissions hingegen bieten die Möglichkeit Berechtigungen flexibel und beliebig feingranular zu definieren und zu vergeben. Dabei gilt alles als verboten, was nicht explizit erlaubt ist.
Mit WildcardPermission
s liefert Shiro bereits eine mächtige Permission-Implementierung mit aus, die für die meisten Anwendungsfälle ausreicht. WildcardPermission
s können als Strings definiert werden. So könnte man z. B. über den String „product:update:123
“ abfragen, ob der aktuelle Benutzer berechtigt ist, das Produkt mit der ID 123 zu ändern. Andersherum können wir einem Benutzer die Berechtigung „product:update:*
“ zuordnen. Diese erlaubt es ihm, alle Produkte zu ändern. „product:*
“ würde es erlauben, sämtliche Aktionen auf allen Produkten durchzuführen, „product:*:123
“ würde alle Aktionen auf Produkt 123 zulassen usw. Die Anzahl der Teile einer WildcardPermission
ist dabei unbegrenzt. Um die Berechtigung auf eine bestimmte Produktlinie einzuschränken, wäre z. B. die Permission „productline:hardware:product:update:*
“ denkbar.
Shiro-Grundlagen
Schauen wir uns zunächst an, wie Shiro grundsätzlich bedient wird, bevor wir die Grenzen der String-basierten WildcardPermission
s betrachten. Die Schnittstelle für Client-Code ist denkbar einfach:
Subject user = SecurityUtils.getSubject();
user.login(new UsernamePasswordToken("user", "password"));
user.checkRole("admin");
user.checkPermission("product:update:123");
user.logout();
Die Brücke zur Domäne der eigenen Anwendung schlägt bei Shiro der sogenannte Realm
. Anwendungsspezifische Belange wie die Anbindung einer Benutzerverwaltung oder die Vergabe von Berechtigungen werden von Shiro an eine zuvor registrierte Implementierung dieses Interfaces delegiert. Versucht der Client-Code, das Subject
einzuloggen, wird das definierte AuthenticationToken
(im Beispiel ein UsernamePasswordToken
) zu Prüfung an den Realm
weitergereicht. Analog wird der Realm
nach Rollen und Permissions des aktuellen Benutzers gefragt, wenn dessen Berechtigungen geprüft werden.
Damit man sich als Entwickler einzig auf diese beiden Aspekte konzentrieren muss, liefert Shiro die Klasse AuthorizingRealm
mit aus, die hierfür zwei Template-Methoden definiert. Wir erweitern sie wie folgt:
public class ComplexPermissionRealm extends AuthorizingRealm {
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
Collection<Permission> permissions = loadPermissions(principalCollection);
authorizationInfo.addObjectPermissions(permissions);
return authorizationInfo;
}
private Collection<Permission> loadPermissions(PrincipalCollection principalCollection) {
// In the real world we would most likely load the user's permissions from a database.
// We keep it simple for demonstration purposes.
return Arrays.asList(
new WildcardPermission("user:create:*"),
new WildcardPermission("user:update:*"),
new WildcardPermission("user:delete:*"));
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken authenticationToken)
throws AuthenticationException {
checkCredentials(authenticationToken);
return new SimpleAuthenticationInfo(authenticationToken.getPrincipal(), authenticationToken.getCredentials(), getName());
}
private void checkCredentials(AuthenticationToken authenticationToken) {
// implement authentication mechanism here using
// authenticationToken.getPrincipal()
// authenticationToken.getCredentials()
}
}
Gibt die Methode doGetAuthenticationInfo
nach der Prüfung des übergebenen AuthenticationToken
eine AuthenticationInfo
-Instanz zurück, ohne eine Exception zu werfen, sieht Shiro den Benutzer als authentifiziert an.
Bei der Prüfung von Berechtigungen wird doGetAuthorizationInfo
aufgerufen. Die Methode gibt die Berechtigungen und Rollen eines Benutzers gebündelt in einer AuthorizationInfo
-Instanz zurück.
Im Beispiel definieren wir eine Reihe von WildcardPermission
s. Diese Klasse implementiert das Interface Permission
, das eine nur Methode definiert:
boolean implies(Permission permission);
Beim Aufruf von user.checkPermission("product:update:123")
wandelt Shrio zunächst den angefragten Permission
-String in eine WildcardPermission
um. Mit dieser Instanz werden dann solange die implies
-Methoden der dem Benutzer zugewiesenen Permission
s aufgerufen, bis eine von ihnen true
zurückgibt. In unseren Beispiel wäre das
new WildcardPermission("user:update:*")
. Impliziert keine der Permission
s des Benutzers die angefragte Permission
, ist der Benutzer nicht autorisiert.
Anforderungen komplexer Permission
s
Mit den String-basierten WildcardPermission
s hat der Entwickler ein einfaches Werkzeug an der Hand, mit dem sich schon recht komplexe Szenarien abbilden lassen. Ihnen ist sofern möglich der Vorzug zu geben.
Allerdings ist diese Art der Berechtigungsdefinition nicht in jedem Fall passend. Nehmen wir z. B. an, dass wir mit Shiro Berechtigungen auf einen Datei-Baum wie folgt abbilden wollen:
- Dateizugriff kann lesend oder schreibend berechtigt werden.
- Dateien die geschrieben werden können, können auch gelesen werden.
- Wenn der Zugriff auf eine Datei erlaubt ist, muss auch lesender Zugriff auf alle übergeordneten Verzeichnisse erlaubt werden, um zur Datei navigieren zu können.
- Ist ein Verzeichnis lesend oder schreibend berechtigt, wird diese Berechtigung auf alle untergeordneten Verzeichnisse und Dateien vererbt.
Nach dieser Definition führen drei Umstände dazu, dass ein Benutzer Zugriff auf eine Datei hat:
1. Ihm wurde die Berechtigung für die angefragte Datei direkt zugewiesen.
2. Es soll lesend auf die angefragte Datei zugegriffen werden und der Benutzer hat Zugriff auf eine im Baum untergeordnete Datei.
3. Der Benutzer hat Zugriff auf eine Datei, die der angefragten Datei im Baum übergeordnet ist.
Die Prüfung dieser drei Konstellationen werden wir jeweils in einer eigenen Implementierung des Permission
-Interfaces umsetzen.
Implementierung komplexer Permission
s
Da die zu implementierende implies
Methode als Input ebenfalls eine Permission
erwartet, benötigen wir noch eine weitere Implementierung, die den angefragten Zugriff auf eine Datei abbildet:
public class RequestedFileAccess implements Permission {
private final Path requestedFile;
private final FileOperation operation;
public RequestedFileAccess(Path file, FileOperation operation) {
this.requestedFile = file;
this.operation = operation;
}
public Path getRequestedFile() {
return requestedFile;
}
public FileOperation getOperation() {
return operation;
}
@Override
public boolean implies(Permission permission) {
return false;
}
}
Alle drei zu implementierenden Berechtigungsprüfungen haben gemeinsame Grundfunktionen: Sie implizieren nur RequestedFileAccess PermissionS
. Außerdem müssen sie sicherstellen, dass die angefragte FileOperation
(READ
oder WRITE
) zur konfigurierten Berechtigung passt. Zu diesem Zweck definieren wir die Basisklasse BaseFilePermission
, die den Typ der angefragten Permission
sowie die angefragte Operation prüft. Anschließend ruft sie eine Template-Methode auf, so dass spezifische Prüfungen in ihren Subklassen implementiert werden können.
abstract class BaseFilePermission implements Permission {
protected final Path ownFile;
protected final FileOperation operation;
public BaseFilePermission(Path file, FileOperation operation) {
this.ownFile = file;
this.operation = operation;
}
@Override
final public boolean implies(Permission permission) {
return isFileAccessRequest(permission) && fileAccessIsImplied((RequestedFileAccess) permission);
}
private boolean isFileAccessRequest(Permission permission) {
return permission instanceof RequestedFileAccess;
}
private boolean fileAccessIsImplied(RequestedFileAccess requestedFileAccess) {
return requestedOperationIsImplied(requestedFileAccess) &&
accessIsAllowed(requestedFileAccess.getRequestedFile());
}
private boolean requestedOperationIsImplied(RequestedFileAccess requestedFileAccess) {
return requestsRead(requestedFileAccess) || writeIsPermitted();
}
private boolean requestsRead(RequestedFileAccess requestedFileAccess) {
return requestedFileAccess.getOperation() == FileOperation.READ;
}
private boolean writeIsPermitted() {
return this.operation == FileOperation.WRITE;
}
/**
* Template method called during permission check that allows subclasses to check access permissions.
*
* @param requestedFile Path
* @return true if the RequestedFileAccess is granted
*/
protected abstract boolean accessIsAllowed(Path requestedFile);
}
Im Folgenden werden wir die definierten Prüfungen 1., 2. und 3. in eigenen Permission
s umsetzen. Dabei gehen wir beispielhaft von folgendem Dateibaum aus:
/departments
/development
/employee_789
/employee_987
/finance
/employee_123
/employee_456
Setzen wir nun also die erste Prüfung um – die direkte Berechtigung auf eine Datei. Da uns BaseFilePermission
die meiste Arbeit abnimmt, müssen wir nur noch sicherstellen, dass die angefragte Datei dieselbe ist, für die eine direkte Berechtigung vorliegt. Die Prüfung zweier Dateien auf Gleichheit und auf ihre zu einander relative Position im Dateibaum delegieren wir an die Hilfsklasse FileRelations
.
class FileRelations {
private final Path file;
private FileRelations(Path file) {
this.file = file;
}
static FileRelations is(Path file) {
return new FileRelations(file);
}
boolean descendantOf(Path potentialAncestor) {
try {
return isDescendantOf(potentialAncestor);
} catch (IOException e) {
// wrap checked exception to avoid cluttering up the example code
throw new RuntimeException(e);
}
}
private boolean isDescendantOf(Path potentialAncestor) throws IOException {
return Files.walk(potentialAncestor)
.anyMatch(descendant -> descendant.equals(this.file));
}
boolean theSameAs(Path otherFile) {
return this.file.equals(otherFile);
}
}
Über einen statischen Import der is
Methode, können wir die Prüfung, ob es sich bei der angefragten Datei um die in der Permission
konfigurierte handelt, ausdrucksstark formulieren. Dies geschieht in der Klasse PermissionForTheFileItself
:
class PermissionForTheFileItself extends BaseFilePermission {
public PermissionForTheFileItself(Path file, FileOperation operation) {
super(file, operation);
}
@Override
protected boolean accessIsAllowed(Path requestedFile) {
return is(requestedFile).theSameAs(ownFile);
}
}
Um das Ganze auszuprobieren, weisen wir dem Benutzer im Realm
die neue Permission
zu:
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principalCollection) {
SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();
authorizationInfo.addObjectPermissions(createFilePermissions());
return authorizationInfo;
}
private Collection<Permission> createFilePermissions() {
return Arrays.asList(
new PermissionForTheFileItself(filePath("departments/development/employee_987"), FileOperation.READ));
}
Der Benutzer hat damit lesenden Zugriff auf die Datei departments/development/employee_987
. Schreibender Zugriff sowie Zugriffe auf andere Dateien sind nicht möglich. Zur Demonstration sei auf die Testklasse Demo.java
verwiesen, die im eingangs erwähnten GitHub-Repository zu finden ist.
Die Leseberechtigung für alle im Baum übergeordneten Dateien ist ähnlich einfach implementiert:
public class ReadPermissionForAncestors extends BaseFilePermission {
public ReadPermissionForDirectAncestors(Path file) {
super(file, FileOperation.READ);
}
@Override
protected boolean accessIsAllowed(Path requestedFile) {
return is(ownFile).descendantOf(requestedFile);
}
}
Weisen wir sie dem Benutzer im Realm
mit derselben Datei zu,
private Collection<Permission> createFilePermissions() {
return Arrays.asList(
new PermissionForTheFileItself(filePath("departments/development/employee_987"), FileOperation.READ),
new ReadPermissionForDirectAncestors(filePath("departments/development/employee_987")));
}
ist lesender Zugriff auf Verzeichnisse oberhalb der Datei employee_987
möglich.
Analog können wir die dritte und letzte Permission
implementieren, die den Zugriff auf alle untergeordneten Dateien in einem Verzeichnis erlaubt:
class PermissionForAllDescendantFiles extends BaseFilePermission {
public PermissionForAllDescendantFiles(Path file, FileOperation operation) {
super(file, operation);
}
@Override
protected boolean accessIsAllowed(Path requestedFile) {
return is(requestedFile).descendantOf(ownFile);
}
}
Mit der Zuweisung im Realm
private Collection<Permission> createFilePermissions() {
return Arrays.asList(
new PermissionForTheFileItself(filePath("departments/development/employee_987"), FileOperation.READ),
new ReadPermissionForAncestors(filePath("departments/development/employee_987")),
new PermissionForTheFileItself(filePath("departments/finance"), FileOperation.WRITE),
new ReadPermissionForAncestors(filePath("departments/finance")),
new PermissionForAllDescendantFiles(filePath("departments/finance"), FileOperation.WRITE));
}
haben wir nun Zugriff auf alle Dateien unterhalb von departments/finance
.
Fazit
Der Artikel zeigt, wie sich mit Apache Shiro beliebige Berechtigungen durch die Implementierung des einfachen Permission-
Interfaces abbilden lassen. Statt eine komplexe Permission
zu implementieren, die alle Fälle abdeckt, hat es sich in der Praxis bewährt, die Prüfungen auf viele Permission
s aufzuteilen. So lassen sich Berechtigungen feingranular im Realm
zuweisen.
Dabei ist darauf zu achten, dass Shiro immer alle Permission
s eines Benutzers aufruft, bis eine gefunden wird, die den angefragten Zugriff erlaubt. Teure Operationen wie Datenbankzugriffe sollten daher an dieser Stelle wenn möglich vermieden oder durch Caching reduziert werden. Auch der hier demonstrierte Dateizugriff könnte sich negativ auf die Performance auswirken, wenn große Dateihierarchien durchsucht werden oder es sich um ein entferntes Dateisystem handelt. In solchen Fällen bietet es sich an, die aufwändigen Operationen nur einmal vorm Prüfen der Berechtigungen durchzuführen. Im Beispiel könnte die Struktur des Dateibaums z. B. vorab geladen und im Speicher gehalten werden, statt bei jeder Anfrage im Dateisystem zu navigieren.
Im Artikel findet die Frage keine Betrachtung, wie ein Benutzer an seine Berechtigungen kommt. In der Regel wird über ein Rollen- und Berechtigungskonzept in einer Datenbank hinterlegt, was ein Benutzer darf. Bei der Zuweisung der Permission
s im Realm
, werden diese Informationen dann genutzt, um die richtigen Permission
s zu erzeugen. Auch hier ist Vorsicht geboten: Unternimmt man nichts weiter, fragt Shiro die Permission
s eines Benutzers bei jeder Rechteprüfung neu ab. Das könnte unnötige Last bedeuten. Hier bietet sich die Verwendung des von Shiro bereitgestellten Caching API an.
