Benutzer-Werkzeuge

Webseiten-Werkzeuge


prog:oracle_apex_active_directory_integration

Unterschiede

Hier werden die Unterschiede zwischen zwei Versionen angezeigt.

Link zu dieser Vergleichsansicht

Beide Seiten der vorigen RevisionVorhergehende Überarbeitung
prog:oracle_apex_active_directory_integration [2016/05/19 20:00] – [PWD des Domain Users verstecken] gpipperrprog:oracle_apex_active_directory_integration [2018/04/24 00:26] (aktuell) gpipperr
Zeile 1: Zeile 1:
 +===== Oracle Apex 5 Securtiy und Microsoft Active Directory Integration =====
  
 +**Erstellt 05/2016**
 +
 +Um Zugriffe auf eine Apex Applikation zu steuern, kann auch das Active Directory eingebunden werden.
 +
 +Zwei Aufgaben sind dabei zu lösen, den User zu authentisieren und den Zugriff auf die Pages der Applikation zu autorisieren.
 +
 +Im folgenden Beispiel erfolgt die Authentifizierung über den Apache Webserver mit NTLM und für die Autorisierung von Teilen der Applikation wird auf die Hinterlegten Gruppen im AD zurückgegriffen.
 +
 +Die AD Gruppen werden dynamisch in den Session Kontext des aktuellen Users übernommen und werden dann in der Apex Applikation über einen "Authorisation Scheme" referenziert.
 +
 +
 +Übersicht:
 +
 +{{ :prog:apex:active_directory_integration_apex_v5.png?direct | Apex und Microsoft AD Integration}}
 +
 +----
 +
 +==== Authentisieren über den Webserver ====
 +
 +Ob ein User sich in der Domaine angemeldet hat muss der eingesetzte Webserver prüfen.
 +
 +===Single Sign On unter Windows 2012 mit Apache HTTP und dem NTLM Module ===
 +
 +Unter Windows kann am einfachsten mit dem Apache HTTP Server NTLM mit **Mod Auth NTLM ** eingesetzt werden.
 +Siehe dazu => [[prog:oracle_rest_data_service_tomcat#single_sign_on_mit_dem_apache_einrichten|Oracle ORDS 3.0 (Oracle REST Data Services) mit dem Apache Application Server Tomcat für Single Sign On unter Windows 2012 betreiben]]
 +
 +Über das Modul wird geprüft, ob der User sich gültig in der Windows Domain angemeldet hat, ist das True wird auf die Apex Application weitergleitet.
 +
 +Die weitere Prüfung, ob der User fachlich die Anwendung bzw. einzelnen Seiten oder Menüpunkte sehen darf, muss in der Apex Applikation getestet werden.
 +
 +Der Domain Name des User wird in den HTTP Header geschrieben (siehe die NTLM Konfiguration des obigen Beispiels) und in eine von uns definiert Variable abgelegt, im diesem Beispiel in "WIN_USER".
 +
 +
 +----
 +
 +
 +==== Apex - Authentication Scheme einrichten - Wie meldet man sich in Apex an?====
 +
 +Die Windows Anmeldung bzw. in Prinzip nur der Windows User Name wird an die Single Sign On Konfiguration von Apex für die jeweilige Applikation weitergereicht.
 +
 +Damit sich nun der User nicht nicht nochmal anmelden muss, muss in APEX das "Authentification Scheme" auf Applikationsebene gesetzt werden.
 +
 +  * In der Applikation auf "Shared Components" klicken, "Authentication Schemes" auswählen {{ :prog:apex:apex_single_sign_on_01.png?direct&300 | Authentication Schemes anlegen }}
 +  * Create Button rechte wählen
 +  * Vorlage auswählen : Based on a pre-configured scheme from the gallery => Next{{ :prog:apex:apex_single_sign_on_02.png?direct&300 | Authentication Schemes anlegen}} 
 +  * Nun einen Namen vergeben und die Methode **HTTP HEADER Variable** auswählen {{ :prog:apex:apex_single_sign_on_03.png?direct&300 |Authentication Schemes anlegen}}
 +  * Hinterlegen in welchen Header Feld der Username steht {{ :prog:apex:apex_single_sign_on_04.png?direct&300 | Authentication Scheme Header Variable hinterlegen}}
 +  * Speichern
 +
 +
 +In den Application Settings prüfen, ob auch das richtige Schema gewählt wurde:
 +
 +{{ :prog:apex:apex_single_sign_on_05.png | Authentication Schemes überprüfen }}
 +
 +
 +Nun kann der Test beginnen ob das auch geklappt hat.
 +
 +
 +Außerhalb der Domain sollte jetzt eine Benutzerabfrage erfolgen, angemeldet an der Domain sollte sich die Applikation ohne Login starten lassen.
 +
 +==Debuggen==
 +
 +Klappt es nicht, die Header Variablen auf einer Seite mit Hilfe von "owa_util.print_cgi_env;" anzeigen lassen (Page mit einer Region auf PL/SQL Basis):
 +<code plsql>
 +begin
 + htp.p(owa_util.get_cgi_env('WIN_USER'));
 + htp.p('<p>');
 + owa_util.print_cgi_env
 +end;
 +</code>
 +
 +Den Variablen Namen haben wir zuvor in der Apache Konfiguration auf NTLM Ebene festgelegt!
 +
 +
 +----
 +
 +----
 +
 +==== Autorisieren - In Apex prüfen ob der jeweilige User die Seite auch wirklich sehen darf ====
 +
 +
 +In der Windows Welt ist das Gruppen Konzept selbstverständlich um Rechte auf Objekte im Betriebssystem zu definieren.
 +
 +Das gleiche kann auch in Apex erfolgen, über Gruppen kann der Zugriff auf Seiten, einzelne Elemente der Seite und Menüeinträge gesteuert werden.
 +
 +
 +In Apex 5 kann die Zugehörigkeit zu einer Gruppe für den aktuelle angemeldeten Anwender beim Login gesetzt werden.
 +
 +
 +Um das Verhalten einfach zu testen ein statisches Beispiel als Procedure zwei Gruppen zum Testen (angelegt im Parsing Schema der Apex Application) erstellen:
 +<code plsql>
 +create procedure setRole
 +is
 +begin
 +  apex_authorization.enable_dynamic_groups ( p_group_names => apex_t_varchar2('KOSTENSTELLE', 'EINKAUF') );
 +end;
 +/
 +</code>
 +=> https://docs.oracle.com/cd/E59726_01/doc.50/e39149/apex_authorization.htm#AEAPI29592
 +
 +Nun wird diese Procedure im **Authentication Scheme** als die "Post-Authentication Procedure" hinterlegt:
 +
 +{{ :prog:apex:apex_authentication_scheme_set_post_procedure_name_v01.png | 'Post-Authentication Procedure Name' hinterlegen}}
 +
 +
 +
 +Der Trick dahinter ist es nun über das Active Directory die Gruppen des Windows Users auszulesen und diese Windows Gruppen dynamisch in den Session Kontext von Apex zu schrieben.
 +
 +=== Apex - Authorization Schemes verwenden ===
 +
 +In Apex wird ein "Authorization Scheme" anlegt, mit dem geprüft wird ob der Apex User die notwendige Gruppe für das Apex Element besitzt.
 +
 +In der Apex Application wird dann auf Seiten oder Menü Ebene dieses "Authorization Scheme" referenziert, um zu prüfen ob die Seite/der Menüpunkt angezeigt werden kann.
 +
 +
 +  * In der Applikation auf „Shared Components“ klicken, „Authorization Schemes“ auswählen 
 +  * Mit den "Create Button" den Wizard für ein neues Scheme starten
 +  * "From Scratch" auswählen
 +    * Name vergeben
 +    * Scheme Type "Is in Group" wählen 
 +    * Eine Gruppe einfach eintragen (nicht über die Dialog suchen,werden ja dann später erst dynamisch hinterlegt!)
 +    * {{ :prog:apex:apex_create_authorization_scheme_v01.png | ein Apex Authorization Schemes anlegen}}
 +    * Mit "Create Authorization Scheme" anlegen
 +
 +
 +
 +In der Applikation nun auf der jeweiligen Seite das Authorization Scheme in den Security Settings hinterlegen:
 +  * {{ :prog:apex:apex_use_authorization_scheme_v01.png |Authorization Scheme in den Apex Seite hinterlegen}}
 +
 +
 +
 +
 +----
 +
 +==== Mit DBMS_LDAP das AD abfragen ====
 +
 +Nach dem nun in den obigen Schritte mit der statischen Methode alles geklappt hat, gilt es nun das ganze dynamisch auch aus dem AD zu füllen.
 +
 +
 +
 +Im ersten Schritt muss geprüft werden ob aus der Datenbank überhaupt auf das AD zugegriffen werden kann.
 +
 +Ein sehr gutes Beispiel um das zu testen findet sich hier => https://oracle-base.com/articles/9i/ldap-from-plsql-9i . 
 +
 +Den Beispiel Code aus diesem guten Beispiel in eine Datei kopieren und UNTER dem Parsing Schema der Apex Applikation den Zugriff auf das AD prüfen.  
 +
 +Meist muss unter 11g und 12c eine Netzwerk ACL hinterlegt werden, um das überhaupt verwenden zu können, siehe dazu mehr am Ende dieser Seite.
 +
 +Mit diesen Schritt werden dann die notwendigen Zugangsdaten (AD IP Adresse und Port) + Username und Passwort ermittelt und die richtige LDAP Abfrage Struktur wird geklärt.
 + 
 +
 +Mit diese Daten kann dann mein Code Beispiel ldap_ad_util ergänzt werden.
 +
 +=== LDAP_AD_UTIL ===
 +
 +Mit Hilfe des Windows User Name wird das AD abgefragt, die gefunden Gruppe werden in den Session Kontext von Apex geschrieben.
 +
 +Das Schreiben in den Session Kontext von APEX erfolgt durch das Hinterlegen einer Procedure in der current "Authentifcation Scheme" der Application im Bereich "post_authentication" mit dem 'Post-Authentication Procedure name' Attribut.
 +
 +== Der Code ==
 +
 +Für die dynamische Übernahme der Gruppen aus dem AD nach Apex wird der folgende PL/SQL verwendet (muss im Pasing Schema liegen oder von dort mit entsprechenden Rechten lesbar sein).
 +
 +Spezifikation:
 +
 +<code plsql ldap_ad_util_spc.sql>
 +create or replace package ldap_ad_util
 +is
 +  -- +============================================================================
 +  --   NAME:       ldap_ad_util
 +  --   PURPOSE:    Read User information from the active directory
 +  --                    
 +  -- +============================================================================
 +  
 +  
 +  -- exception handling
 +  g_pck       constant varchar2 (30) := 'ldap_util';
 +  
 +  ex_gperrors exception;
 +  pragma exception_init (ex_gperrors, -20100);
 +  g_emerrors varchar2 (100) := 'An error occured. Please view the ERRORS-table for more information.';
 + 
 +  -- global variables
 +  -- You have to edit carefully this section to set all the values of your enviroment!
 +
 +  -- IP or name of the AD and the port
 +  g_ldap_host    varchar2(256) := '10.10.10.180';
 +  g_ldap_port    varchar2(256) := '389';
 +
 +  -- user to read from the AD
 +  g_ldap_user    varchar2(256) := 'ORASYSTEM';
 +  g_ldap_passwd  varchar2(256) := 'secret_password';
 +  
 +  -- The entry to the AD tree
 +  -- check the cn ! and adjust to your needs!
 +  g_ldap_base    varchar2(256) := 'cn=Users,dc=pipperr,dc=local';
 +
 +  -- how to seach the user in the ad
 +  -- check how the loginname in your domain is defined!
 +  g_ad_user_type varchar2(255) := 'cn=';
 +  
 +  
 +  -- +===========================================================+
 +  --  function : connectAD
 +  -- +===========================================================+
 +  function connectad
 +    return dbms_ldap.session;
 +  -- +===========================================================+
 +  --  procedure : disconnectAD
 +  -- +===========================================================+
 +  procedure disconnectad(
 +      p_session in out dbms_ldap.session);
 +  -- +===========================================================+
 +  --  procedure : disconnectAD
 +  -- +===========================================================+
 +  function readgroups(
 +      p_session dbms_ldap.session ,
 +      p_username varchar2)
 +    return apex_t_varchar2;
 +  -- +===========================================================+
 +  --  procedure : setApexGroups
 +  --  set in the Apex Session dynamic groups
 +  -- +===========================================================+
 +  procedure setapexgroups(
 +      p_username varchar2 default sys_context(
 +        'APEX$SESSION',
 +        'APP_USER'));
 +end ldap_ad_util;
 +/
 +
 +</code>
 +
 +Body:
 +
 +<code plsql ldap_ad_util_spc.sql>
 +create or replace package body ldap_ad_util
 +is
 +  -- +============================================================================
 +  --   NAME:       ldap_ad_util
 +  --   PURPOSE:    Read User information from the active directory
 +  --   GPI 2016
 +  -- +============================================================================
 +  -- +===========================================================+
 +  --  function : getADPWD
 +  --   get the Password for the AD User
 +  --   later we will save the pwd encrypted in the database
 +  -- +===========================================================+
 +  function getadpwd
 +    return varchar2
 +  is
 +  begin
 +    return g_ldap_passwd;
 +  end;
 +-- +===========================================================+
 +--  function : connectAD
 +-- connect to the AD
 +-- +===========================================================+
 +  function connectad
 +    return dbms_ldap.session
 +  is
 +    v_session dbms_ldap.session;
 +    v_retval pls_integer;
 +  begin
 +    -- choose to raise exceptions.
 +    dbms_ldap.use_exception  := true;
 +    dbms_ldap.utf8_conversion:=false;
 +    -- connect to the ldap server.
 +    v_session := dbms_ldap.init(hostname => g_ldap_host, portnum => g_ldap_port);
 +    -- connect with this user to the AD
 +    v_retval := dbms_ldap.simple_bind_s(ld => v_session , dn => g_ldap_user , passwd => getadpwd );
 +     if v_retval != DBMS_LDAP.SUCCESS then
 +       dbms_output.put_line('-- Error at::'||$$plsql_unit||' :: dbms_ldap.simple_bind_s for User  ::'||  g_ldap_user );
 +       raise_application_error( -21001 , '-- Error at::'||$$plsql_unit||' ::  dbms_ldap.simple_bind_s for User  ::'||  g_ldap_user );  
 +  end if;
 +    return v_session;
 +  exception
 +  when others then
 +    dbms_output.put_line('-- Error at::'||$$plsql_unit||' :: '||sqlerrm);
 +    raise_application_error( sqlcode , '-- Error at::'||$$plsql_unit||' :: '||sqlerrm);
 +  end connectad;
 +-- +===========================================================+
 +--  procedure : disconnectAD
 +-- +===========================================================+
 +  procedure disconnectad(
 +      p_session in out dbms_ldap.session)
 +  is
 +    v_retval pls_integer;
 +  begin
 +    v_retval := dbms_ldap.unbind_s(ld => p_session);
 +    if v_retval != DBMS_LDAP.SUCCESS then
 +       dbms_output.put_line('-- Error at::'||$$plsql_unit||' :: Can not close connection to LDAP');
 +       raise_application_error( -21009 , '-- Error at::'||$$plsql_unit||' :: Can not close connection to LDAP');  
 +    end if;
 +  exception
 +  when others then
 +    dbms_output.put_line('-- Error at::'||$$plsql_unit||' :: '||sqlerrm);   
 +  end disconnectad;
 +  
 +-- +===========================================================+
 +--  procedure : disconnectAD
 +--  Code Logic copied from https://oracle-base.com/articles/9i/ldap-from-plsql-9i . 
 +--  Thanks to Tim Hall
 +-- +===========================================================+
 +  function readgroups(
 +      p_session dbms_ldap.session ,
 +      p_username varchar2)
 +    return apex_t_varchar2
 +  is
 +    v_retval pls_integer;
 +    v_attrs dbms_ldap.string_collection;
 +    v_message dbms_ldap.message;
 +    v_entry dbms_ldap.message;
 +    v_attr_name varchar2(256);
 +    v_ber_element dbms_ldap.ber_element;
 +    v_vals dbms_ldap.string_collection;
 +    -- empty collection 
 +    v_group_tab apex_t_varchar2:=apex_t_varchar2();
 +    
 +    v_apex_ary apex_application_global.vc_arr2;
 +begin
 +  v_attrs(1) := 'memberOf'; 
 +  -- retrieve all attributes
 +  v_retval := dbms_ldap.search_s(ld => p_session 
 +                                 , base => g_ldap_base 
 +                                 , scope => dbms_ldap.scope_subtree 
 +                                 , filter => g_ad_user_type||p_username 
 +                                 , attrs => v_attrs 
 +                                 , attronly => 0 
 +                                 , res => v_message
 +                                 );
 +  if v_retval != DBMS_LDAP.SUCCESS then
 +       dbms_output.put_line('-- Error at::'||$$plsql_unit||' :: dbms_ldap.search_s for filter ::'|| g_ad_user_type||p_username );
 +       raise_application_error( -21002 , '-- Error at::'||$$plsql_unit||' :: dbms_ldap.search_s for filter ::'|| g_ad_user_type||p_username);  
 +  end if;
 +   
 +  if dbms_ldap.count_entries(ld => p_session 
 +                            , msg => v_message) > 0 then
 +    
 +    -- Get all the entries returned by our search.
 +    v_entry := dbms_ldap.first_entry( ld => p_session ,msg => v_message);
 +    << entry_loop >>
 +    while v_entry is not null
 +    loop
 +      -- Get all the attributes for this entry.
 +      dbms_output.put_line('------------------------------------');
 +      v_attr_name := dbms_ldap.first_attribute(ld => p_session, ldapentry => v_entry, ber_elem => v_ber_element);
 +      << attributes_loop >>
 +      while v_attr_name is not null
 +      loop
 +        -- Get all the values for this attribute.
 +        v_vals := dbms_ldap.get_values (ld => p_session
 +                                     , ldapentry => v_entry
 +                                     , attr => v_attr_name);
 +        begin
 +          << values_loop >>
 +          for i in v_vals.first .. v_vals.last
 +          loop
 +            dbms_output.put_line('-- Info: Found: ' || v_attr_name || ' = ' || substr(v_vals(i),1,500));
 +            
 +            -- decode memberOf = CN=ORA_ASMDBA,CN=Users,DC=pipperr,DC=local
 +            -- to the the group name
 +            v_apex_ary:=apex_util.string_to_table(p_string=> v_vals(i),p_separator => ',' );
 +            for y in v_apex_ary.first .. v_apex_ary.last
 +            loop
 +              if v_apex_ary.exists(y) then
 +                dbms_output.put_line('-- Info:  Catch Group Details ' || v_apex_ary(y));
 +                if y=1 then
 +                  v_group_tab.extend;
 +                  v_group_tab( v_group_tab.last ) :=  (replace(v_apex_ary(y),'CN=',''));
 +                  dbms_output.put_line('-- Info:  Found Group ' || replace(v_apex_ary(y),'CN=',''));
 +                end if;
 +              end if;
 +            end loop;
 +          end loop values_loop;
 +        exception
 +        when others then
 +          dbms_output.put_line('-- Error read Attribute: ' || v_attr_name || ' :: Errror '||sqlerrm);
 +        end;
 +        v_attr_name := dbms_ldap.next_attribute(ld => p_session
 +                                               , ldapentry => v_entry
 +                                               , ber_elem => v_ber_element);
 +      end loop attibutes_loop;
 +      v_entry := dbms_ldap.next_entry(ld => p_session
 +                                    , msg => v_entry);
 +    end loop entry_loop;
 +  end if;
 +  return v_group_tab;
 +end readgroups;
 +
 +-- +===========================================================+
 +--  procedure : setApexGroups
 +--  set in the Apex Session dynamic groups
 +-- +===========================================================+
 +procedure setapexgroups(
 +    p_username varchar2 default sys_context(
 +      'APEX$SESSION',
 +      'APP_USER'))
 +is
 +  v_session dbms_ldap.session;
 +  v_group_tab apex_t_varchar2;
 +begin
 +  -- connect to LDAP
 +  v_session:=connectad;
 +  -- read the groups into
 +  dbms_output.put_line('-- Info:  Get AD Groups for ' || p_username);
 +  v_group_tab:=readgroups(p_session => v_session, p_username => p_username);
 +  -- disconnect the LDAP Session
 +  disconnectad(p_session => v_session);
 +  -- add this groups to the Apex Session
 +  if v_group_tab.count > 0 then
 +    for i in v_group_tab.first .. v_group_tab.last 
 +    loop
 +       if v_group_tab.exists(i) then
 +        dbms_output.put_line('-- Info:  set Group in Apex Session ' || v_group_tab(i));
 +       end if;
 +    end loop;
 +  else
 +    dbms_output.put_line('-- Info:  No Groups for this user found' ||p_username );
 +  end if;
 +  
 +  -- set the groups with the group collection
 +  -- apex_t_varchar2('KOSTENSTELLE','EINKAUF')
 +  apex_authorization.enable_dynamic_groups ( p_group_names => v_group_tab);
 +  --
 +   exception
 +  when others then
 +    dbms_output.put_line('-- Error at::'||$$plsql_unit||' :: '||sqlerrm);
 +    -- check that the connection to the ldap is closed!
 +    -- Check if connection ist still open is in the function!
 +    disconnectad(p_session => v_session);
 +end setapexgroups;
 +begin
 +  -- Initialization
 +  null;
 +end ldap_ad_util ;
 +/
 +
 +</code>
 +
 +
 +Ein noch zu lösendes Problem ist das Passwort im Package, in einer produktiven Umgebung sollte das entweder verschlüsselt hinterlegt werden oder anderweitig geschützt gespeichert sein!
 +
 +Um das ganze zu testen, das Package mit den richtigen Globalen Einstellungen einspielen und in SQL*Plus im APEX 
 +Parsing Schema mit einen gekannten AD User aufrufen:
 +<code sql>
 +
 +set serveroutput on 
 +
 +exec setApexGroups('ORACLE_ADMIN');
 +
 +</code>
 +
 +
 +Da der Default die Apex Session Info ist, braucht später keine User übergeben zu werden!
 +
 +=== PWD des Domain Users verstecken ===
 +
 +Wollte jetzt keine eigenen Tabelle für das eine Password anlegen,
 +
 +Idee:
 +  * Key setzt sich aus der DB Laufzeit Umgebung zusammen
 +  * Key wird ergänzt um einen privaten Key bei Aufruf der Entschlüsselung.
 +  * Funktion oder Object wird in der DB für das Speichern des verschlüsselten Wertes verwendet
 +
 +
 +Lösung:
 +
 +siehe  => [[dba:passwort_in_psql_schuetzen|Passwörter und ähnliche Schlüssel in PL/SQL Packages schützen]]
 +
 +
 +==== Fehler Suche - ORA-24247: network access denied by access control list (ACL) ====
 +
 +
 +**Oracle 12c** Netzwerk ACL setzen!
 +
 +<fc #800000>Fehler:</fc>
 +<code sql>
 +ERROR at line 1:
 +ORA-24247: network access denied by access control list (ACL)
 +ORA-06512: at "SYS.DBMS_LDAP_API_FFI", line 25
 +ORA-06512: at "SYS.DBMS_LDAP", line 48
 +ORA-06512: at "GPI.LDAP_UTIL", line 23
 +ORA-06512: at "GPI.LDAP_UTIL", line 148
 +ORA-06512: at line 1
 +</code>
 +
 +<code sql>
 +BEGIN
 + DBMS_NETWORK_ACL_ADMIN.APPEND_HOST_ACE
 + (
 +     host       => '10.10.10.180'
 +  ,  lower_port => 389
 +  ,  upper_port => 389
 +  ,   ace       => xs$ace_type( privilege_list => xs$name_list('connect')
 +                              , principal_name => 'GPI'
 +                              , principal_type => xs_acl.ptype_db)
 + );
 +END;
 +/
 +</code>
 +
 +Siehe dazu => http://oracle.informatik.haw-hamburg.de/network.121/e17607/fine_grained_access.htm#DBSEG114
 +
 +
 +ACL in 11g  hinterlegen:
 +  * https://matthiashoys.wordpress.com/2012/04/24/ora-24247-during-ldap-authentication-from-apex-4-1-1-on-oracle-11gr2/
 +
 +
 +==== Quellen ====
 +
 +LDAP
 +  * http://www.idevelopment.info/data/Oracle/DBA_tips/LDAP/LDAP_21.shtml
 +  * http://sql-plsql-de.blogspot.de/2007/09/ldap-server-abfragen-mit-sql.html
 +  * https://docs.oracle.com/cd/B10501_01/network.920/a96577/concepts.htm#725943
 +
 +
 +Verschlüsseln:
 +  * http://www.oracleflash.com/41/Encrypt-or-Decrypt-sensitive-data-using-PLSQL---DBMS_CRYPTO.html
prog/oracle_apex_active_directory_integration.txt · Zuletzt geändert: 2018/04/24 00:26 von gpipperr