RDWeb: Nutzung RDP Datei ohne RDWeb Auth einschränken

Weil Remote Desktop Web Access (RDWeb) ja nur mit IE wirklich so funktioniert wie Microsoft sich das vorstellt (weil ActiveX zum Zug kommen kann) und bei allen anderen Browsern in Wahrheit nur ein RDP File erzeugt/heruntergeladen/gestartet wird kann man dieses RDP File dann leider für immer benutzen. Zwar muss man sich am RDG und RDSH natürlich anmelden aber eine etwaig im Web Access eingebaute 2FA (siehe hier) geht damit flöten.

Man kann dann den Weg gehen und die Nutzung von RDP Files via ADFS Pre-Authentication grundsätzlich zu verhindern – nur ist das halt ein bisserl weltfremd im Jahre 2017 den IE zu erzwingen (vor allem wenn man auch iOS/Android/macOS bedienen will oder muss), daher habe ich folgenden “Workaround” ersonnen (und auch erfolgreich implementiert):

Am RDWeb Server ein Verzeichnis erstellen und für die relevanten Benutzer lesend freigeben.

AM RDWeb Server in die login.aspx Seiten nach der OTP Authentication (hier beschrieben) folgendes einbauen (ich habe den Pfad für das Timestampdirectory in einen Parameter in der web.config hinterlegt):

if(bOTPAuthenticated)
{
  // NEU: RDP File Start Handling 
  StreamWriter oWriter=new StreamWriter(ConfigurationManager.AppSettings["OTPAuthenticatedPath"]+"\\"+strOTPUsername.ToUpper());
  oWriter.WriteLine(DateTime.Now.AddHours(8).ToString(@"yyyy\/MM\/dd HH:mm:ss"));
  oWriter.Close();
  // NEU-Ende
  if (String.IsNullOrEmpty(strReturnUrlPage))
     Response.Redirect("default.aspx");
  else
     SafeRedirect(strReturnUrlPage);
}

Entweder über lokale Group Policy oder all Users Startup eine Prüfung der Datei (sofern vorhanden) gegen den aktuellen Timestamp und Logoff (oder Meldung + Logoff oder was auch immer), ich habe dafür KIXTART genommen:

$SharePath="\\mein.server.com\MeinTimestampshare$"

IF EXIST($SharePath+"\"+@USERID) = 0
  ? @USERID+" existiert auf "+$SharePath+" nicht, logoff wird initiiert"
  LOGOFF 1
ELSE
  ? @USERID+" existiert auf "+$SharePath
  IF OPEN(3, $SharePath+"\"+@USERID) = 0
    $Date=READLINE(3)
    IF @ERROR <> 0 
      ? "Timestamp Datei kann nicht gelesen werden, logoff wird initiiert"
      LOGOFF 1
    ELSE
      ? "Ablauftimestamp aus Timestamp Datei: "+$Date
      $Diff=TimeDiff($Date,"now","m")
      ? "Zeitunterschied in Minuten: "+$Diff
      IF $Diff > 0
        ? "Ablauftimestamp liegt in der Vergangenheit, logoff wird initiiert"
        LOGOFF 1
      ELSE
        ? "Ablauftimestamp liegt in der Zukunft, logon ok"
      ENDIF
    ENDIF
    $dummy=ClOSE(3)
  ELSE
    ? "Timestamp Datei kann nicht geöffnet werden, logoff wird initiiert"
    LOGOFF 1
  ENDIF
ENDIF

Credits für TimeDiff: https://community.spiceworks.com/scripts/show/599-kixtart-function-timediff

Multilanguage Remote Desktop Web Access (RDWeb)

Es gibt zwar noch kein Multilanguage Pack für Windows Server 2016, im Fall von RDWeb lässt sich das aber leicht umgehen: Einfach einen Server in der gewünschten Sprache aufsetzen, Remote Desktop Web Access Rolle installieren und das Verzeichnis C:\Windows\Web\RDWeb\Pages\<Sprache> auf den Server mit der “echten” RDWeb Installation kopieren.

Mehr ist nicht zu tun – die Sprache des Browser wird automatisch aufgenommen und die Seiten im entsprechenden Sprachverzeichnis verwendet.

Remote Desktop Web Access (RDWeb) mit MultiOTP sichern

Der vorerst letzte Eintrag in der bliebten Reihe “wir sichern alles mögliche mit MultiOTP” 😀

RDWeb landet nach der Installation in C:\Windows\Web\RDWeb. Die Datei die uns interessiert befindet sich im Unterverzeichnis Pages und da im Verzeichnis der gewünschten Sprache: login.aspx.

Oben bei den @Import Zeilen fügen wir ein

<% @Import Namespace="System.Diagnostics" %>
<% @Import Namespace="System.Text.RegularExpressions" %>
<% @Import Namespace="System.Configuration" %>

ein.

Bei den Strings (unter Kommentar // Localizable Text) folgendes:

const string L_OTPLabel_Text = "OTP:";

In der Methode LoginPageLoadAsync() direkt vor dem Ende des if ( Request.QueryString != null ) Blocks:


            }
            // EINFÜGEN
            if( Request.Form["UserOTP"] != null ) Session["UserOTP"] = (string)Request.Form["UserOTP"];
            // EINFÜGEN
        }

        //
        // Special case to handle 'ServerConfigChanged' error from Response's Location header.
        //

Weiter unten in der gleichen Methode im zweiten if ( HttpContext.Current.User.Identity.IsAuthenticated == true ) Block ersetzen wir SafeRedirect(strReturnUrlPage); durch:

string strOTPUsername = HttpContext.Current.User.Identity.Name;
if (strOTPUsername.Split('\\').Length > 1) strOTPUsername = strOTPUsername.Split('\\')[1];
if (strOTPUsername.Split('@').Length > 0) strOTPUsername = strOTPUsername.Split('@')[0];
			
bool bOTPAuthenticated = false;

if (Session["UserOTP"] != null)
{
  string strOTP = (string)Session["UserOTP"];
  if (!string.IsNullOrEmpty(strOTP) && Regex.Match(strOTP,ConfigurationManager.AppSettings["OTPRegex"]).Success && !string.IsNullOrEmpty(strOTPUsername) && Regex.Match(strOTPUsername,ConfigurationManager.AppSettings["OTPUsernameRegex"]).Success)
  {
    ProcessStartInfo oOTPStartInfo = new ProcessStartInfo();
    oOTPStartInfo.FileName = ConfigurationManager.AppSettings["MultiOTPPathAndName"];
    oOTPStartInfo.Arguments = strOTPUsername + " " + strOTP;
    oOTPStartInfo.CreateNoWindow = true;
    oOTPStartInfo.UseShellExecute = true;
    Process oOTP = Process.Start(oOTPStartInfo);
    oOTP.WaitForExit();
    bOTPAuthenticated = (oOTP.ExitCode == 0);
  }
}

if(bOTPAuthenticated)
{
  if (String.IsNullOrEmpty(strReturnUrlPage))
    Response.Redirect("default.aspx");
  else
    SafeRedirect(strReturnUrlPage);
}
else
{
  FormsAuthentication.SignOut(); 
  bFailedLogon = true;
  if (bFailedAuthorization) bFailedAuthorization = false; 
}

Zuletzt fügen wir noch die notwendigen Parameter in C:\Windows\Web\Web.Config im appSettings Block ein:


<add key="MultiOTPPathAndName" value="C:\\Pfad\\zu\\multiotp\\multiotp.exe" />
<add key="OTPUsernameRegex" value="^[0-9a-zA-Z]*$" />
<add key="OTPRegex" value="^[0-9]{6}$" />

Das war der Code, nun müssen wir noch das OTP Inputfeld einfügen – das passiert nach dem Passwort-Feld (einfach nach “UserPass” suchen) nach dem abschließenden

folgendes einfügen:

<tr>
  <td>
    <table width="300" border="0" cellpadding="0" cellspacing="0">
      <tr>
        <td width="130" align="right"><%=L_OTPLabel_Text%></td>
        <td width="7"></td>
        <td align="right">
          <label><input id="UserOTP" name="UserOTP" type="password" class="textInputField" runat="server" size="23" autocomplete="off" /></label>
        </td>
      </tr>
    </table>
  </td>
</tr>

Ein auf den konfigurierten Pfad kopiertes MultiOTP samt passendem .INI vorausgesetzt (aufpassen: wenn Logging eingeschaltet ist muss RDWeb Application Pool Account Schreibrechte haben!) sollte das Ganze dann ungefähr so aussehen:

Wie üblich der Warnhinweis: Das alles ist natürlich weit jenseits jeglichen Supports und MS Support wird (zurecht) das Weite suchen wenn er/sie/es sowas sieht und ein Problem daraus entstanden ist….zusätzlich sollte man die geänderten Dateien auch sichern, obwohl die Seiten seit 2008R2 praktisch unverändert geblieben sind weiß man nie was der nächste CU so bringt 😀

MultiOTP Authentication Provider für ADFS 3.0

Hab gemäß der Anleitung des Cloud-PFEs Tino Donderwinkel (Teil 1, Teil 2) einen sehr rudimentären aber tatsächlich funktionierenden Authentication Provider für ADFS 3.0 (=2012 R2) erzeugt: MultiOTPAuthenticationProvider.dll (Source: MultiOTPAuthenticationProvider-Source)

Wie man das Ding am ADFS Server registriert ist in Teil 2 der Anleitung beschrieben, der Provider hat 4 Parameter die man in die Datei C:\Windows\ADFS\Microsoft.IdentityServer.Servicehost.exe.config einfügen muss (am besten direkt unter configSections):

<appSettings>
      <add key="MultiOTPPathAndName" value="C:\\Pfad\\zu\\multiotp\\multiotp.exe" />
      <add key="OTPUsernameRegex" value="^[a-zA-Z0-9*$"/>
      <add key="OTPRegex" value="^[0-9]{6}$"/>
  </appSettings>

Natürlich muss man MultiOTP mit passender .INI im konfigurierten Pfad deployed haben – dann noch ADFS restarten und es kann losgehen; die DLL erkennt Deutsch und Englisch und reagiert entsprechend.

Wichtig: Der Provider verwendet nur den Benutzernamen (sAMAccountName) OHNE Domäne für den Regex Check bzw. den MultiOTP Call!

PowerShell Web Access (PSWA) mit MultiOTP sichern

PSWA ist ein relativ mächtiges Werkzeug, da liegt es auf der Hand den Zugang dazu mit 2FA zu sichern – hier am Beispiel MultiOTP, eine Open Source Geschichte.

Voraussetzung: MultiOTP ist lokal am PSWA Server eingerichtet (was im Normalfall nur Copy/Paste der Windows Version – mit entsprechender .ini Datei – bedeutet).
Vorsicht: PSWA Application Pool Account muss Schreibrechte auf das MultiOTP Verzeichnis haben wenn Logging enabled ist!

Umsetzung: PSWA mißbraucht Forms based Authentication ein wenig, daher muss man ein wenig kreativ eingreifen (PSWA spielt sich in C:\Windows\Web\PowerShellWebAccess\wwwroot ab): FBA Login Url ist Default.aspx welche aber leer ist und nur dazu dient auf die richtige Sprache umzuleiten (en-US in unserem Fall) – dort wird dann je nachdem ob eine Session da ist console.aspx oder logon.aspx (mit User/PW/Zielgerät etc. aufgerufen).

Wir kopieren daher im en-US Verzeichnis das Original logon.aspx auf logonMS.aspx und erzeugen ein neues logon.aspx. Die logon.aspx ist durch die lokale web.config für alle Benutzer freigegeben, unsere logonMS.aspx nicht – daher kann man unsere neue logon.aspx nicht umgehen weil durch die FBA Mechanik sofort auf Default.aspx und damit auf logon.aspx umgeleitet wird. Unsere neue logon.aspx leitet bei erfolgreicher OTP Authentifizerung auf logonMS.aspx weiter (konfigurierbar, s.u.).

Den Code für die Seite (OTPAuth.dll, Source: OTPAuth-Source) laden wir im übergeordneten \bin ab.

Die neue logon.aspx Seite kann über vier Parameter gesteuert werden die in die web.config direkt in …\wwwroot im Abschnitt appSettings einzufügen sind:

<add key="MultiOTPPathAndName" value="C:\\Pfad\\zu\\multiotp\\multiotp.exe" />
<add key="OTPUsernameRegex" value="^[a-zA-Z0-9]*$"/>
<add key="OTPRegex" value="^[0-9]{6}$"/>
<add key="TargetPageOnSuccessfullAuthentication" value="logonMS.aspx"/>

Und das wars schon.

Ablauf:
1) User surft Seite an, egal wo. FBA leitet auf Default.aspx weiter.
2) Default.aspx leitet auf logon.aspx im Sprachverzeichnis weiter (unsere OTP Seite).
3) Bei erfolgreicher OTP Authentifizerung leitet unser logon.aspx gemäß Konfiguration auf logonMS.aspx weiter.
4) PSWA wie ohne OTP.

Wie üblich der Warnhinweis: Das alles ist natürlich weit jenseits jeglichen Supports und MS Support wird (zurecht) das Weite suchen wenn er/sie/es sowas sieht und ein Problem daraus entstanden ist….zusätzlich sollte man die geänderten Dateien auch sichern, obwohl die Seiten seit 2012 praktisch unverändert geblieben sind weiß man nie was der nächste CU so bringt 😀

IIS: Windows Auth aber keine Impersonation/Delegation

Szenario:
IIS >= 7.5, ASP oder ASP.NET Applikation, Windows Authentication (damit man in der Applikation weiß wer da daher kommt) aber hinten raus soll beim Zugriff auf weitere (Remote-)Resourcen die Identität des Application Pools und nicht der authentifizierte Benutzer verwendet werden.

Hintergrund:
a) Minimalrechte für Application Pool Identity und nicht die Notwendigkeit die authentifizierten Benutzer auf die Files und andere Resourcen die die WebApp braucht zu berechtigen.
b) Application Pool Identity hat Rechte auf Resourcen die die authentifizierten Benutzer nicht haben – die Applikation muss dann natürlich checken ob der aufrufende Benutzer überhaupt das darf was er da gerne hätte.

Problem:
IIS nutzt per Default den authentifizierten Benutzer für die Zugriffe (egal ob lokal oder remote) – was bei Remotezugriffen zum double-hop-Problem führt und damit auf dem direkten Weg in die Kerberos Hölle (oder der aufrufende Benutzer hat überhaupt nicht die notwendigen Rechte auf der Remoteresource, selbst wenn Kerberos Delegation mal ausnahmsweise funktionieren würde).

Lösung:
a) Application Pool Identity setzen.
b) Windows Auth einschalten (alle anderen ab)
c) URL Authorization Regeln für die Benutzer erstellen die dürfen erstellen
OPTIONAL: Über ServerVariables Collection kann mit Item “LOGON_USER” auf den authentifizierten Benutzer zugegriffen werden (nicht “REMOTE_USER” – der ist fix mit der AppPool Identity belegt) – und damit können in der Applikation dann Zugriffsentscheidungen getroffen werden. Der ASP.NET Role Provider funktioniert NICHT!
d) Im IIS Manager/Site/Configuration Editor unter system.webServer/serverRuntime den Eintrag authenticatedUserOverride auf "UseWorkerProcessUser" umstellen

Credits: Scott Forsyth’s Blog

VEDATAMODEL.EDB von App-Leichen befreien

Wenn man im Enterpriseumfeld nach einem Windows 10 Update wieder all die schönen Nicht-Enterprise Apps hat will man die gerne löschen (Remove-AppxPackage bzw. Remove-AppxProvisionedPackage) – kann man auch, nur bleiben dann bei allen Benutzern die auf der Kiste schon mal angemeldet waren Leichen im Startmenü. Warum? Das weiß offenbar nicht mal Microsoft wenn man die hilflosen Antworten in diversen Foren liest.

Wers wirklich Hardcore will kann in die Standard ESENT DB die die Grundlage für das neue (tolle) Startmenü bildet reinfummeln und die nicht gewollten Apps rauslöschen (Api ist in Microsoft.Isam.Esent.Interop Namespace von ESENT Managed Interface):

JET_INSTANCE oInstance;
JET_SESID oSession;
JET_DBID oDB;
JET_TABLEID oTable;
JET_COLUMNDEF oColDefAppRef, oColDefID;
string sTable = "Tile";
string sAppsToDelete = "WindowsFeedbackHub|Messaging|WindowsReadingList|CommsPhone|Microsoft.SkypeApp|OneNote|ConnectivityStore|Microsoft.MicrosoftOfficeHub|Microsoft.Office.Sway|Microsoft.MicrosoftSolitaireCollection|Microsoft.XboxApp|Microsoft.Getstarted|Microsoft.BingSports|Microsoft.BingNews|Microsoft.BingFinance|Microsoft.3DBuilder|Microsoft.OneConnect|Microsoft.3DBuilder|microsoft.windowscommunicationsapps|Microsoft.BingFoodAndDrink|Microsoft.BingHealthAndFitness|Microsoft.BingTravel|Windows.ContactSupport"
string sIDstoDelete = "PreInstalled.DefaultStartLayout";
string sAppRef;
string sID;
string sDB=@"C:\Users\whatever\AppData\Local\TileDataLayer\Database\vedatamodel.edb";

Console.WriteLine($"Doing {sDB}");

Api.JetCreateInstance(out oInstance, "instance");
Api.JetSetSystemParameter(oInstance, JET_SESID.Nil, JET_param.CircularLog, 1, null);
Api.JetInit(ref oInstance);
Api.JetBeginSession(oInstance, out oSession, null, null);


Api.JetAttachDatabase(oSession, sDB, AttachDatabaseGrbit.None);
Api.JetOpenDatabase(oSession, sDB, null, out oDB, OpenDatabaseGrbit.None);

Api.JetOpenTable(oSession, oDB, sTable, null, 0, OpenTableGrbit.None, out oTable);
Api.JetGetColumnInfo(oSession, oDB, sTable, "APPREF", out oColDefAppRef);
Api.JetGetColumnInfo(oSession, oDB, sTable, "ID", out oColDefID);

if (!Api.TryMoveFirst(oSession, oTable))
{
   Console.WriteLine("   ===> Tile table is empty");
}
else
{
   do
   {
      sAppRef = Api.RetrieveColumnAsString(oSession, oTable, oColDefAppRef.columnid);
      sID = Api.RetrieveColumnAsString(oSession, oTable, oColDefID.columnid);
      if (Regex.Match(sAppRef, sAppsToDelete, RegexOptions.IgnoreCase).Success || Regex.Match(sID, sIDstoDelete, RegexOptions.IgnoreCase).Success)
      {
         Console.WriteLine($"    ===> DELETING {sID}/{sAppRef}");
         Api.JetDelete(oSession, oTable);
      }
   }
   while (Api.TryMoveNext(oSession, oTable));
}

Api.JetCloseTable(oSession, oTable);
Api.JetCloseDatabase(oSession, oDB, CloseDatabaseGrbit.None);
Api.JetDetachDatabase(oSession, sDB);
Api.JetEndSession(oSession, EndSessionGrbit.None);
Api.JetTerm(oInstance);

Btw: Mit ESEDatabaseViewer kann man sich die Datenbank auch ansehen, steht zwar nicht allzuviel Sinnvolles/Lesbares drin aber he, manchmal will man es genau wissen 😀

 

Update: Ab Windows 10 1703 speichert Microsoft die Sachen wo anders (%LOCALAPPDATA%\Microsoft\Windows\Caches, CloudStore und was weiß der Teufel sonst noch wo)….schaut fast wie eine SQLite3 DB aus….

Spaß mit Zertifikaten am TV

Also drehen sie DVB-T ab. Klar, war ja zu einfach mit Kabel rein und gut ists. Klar steht bei LG dass das TV Gerät DVB-T/T2 kann. Klar steht viel weiter unten ganz klein “nicht alle Modelle”. Klar war unseres so eines welches nur T und kein T2 kann.

Also ein SAT CI+ Irdeto CAM mit ORF Zertifizierung gekauft (um den halben Preis den das ORF-Teil kostet) und reingestopft – was kommt?

Irdeto Access
CI+ Error Host Zertifikat ungültig
abgelaufen, bitten wählen sie den DTV-
Service -16

Nach kurzer Recherche erkannt dass der das wörtlich meint – also auf Kanal 16 (egal was dort programmiert ist) und er beginnt mit der Authentifizierung. Bleibt bei 3/5 hängen.

Weiter recherchiert – und diese Seite gefunden (offensichtlich arbeiten da eh _alle_ CAMs mit dem Chipsatz oder wie man das immer nennt was da drinsteckt) – und was lesen wir da?

Q: If the authentication failed just after step 3/5 or pop up message of “CI+ ERROR: Host Certificate Invalid-Expired, Please tune to DTV service – 16”, what can be done?

A: Please check whether the signal connection is OK, and check whether the TV time setting is the current time. If the TV has set to current time, that means the CI+ certification has something wrong, please contact TV service center.

Holy hell – die Zertifikatshölle ist auf die TV Geräte (und vermutlich Receiver) gekommen. Datum/Uhrzeit manuell eingestellt und siehe da…..zum Glück hat das Teil keine Internetanbindung sonst könnts auch noch die CRL checken wollen.

 

SharePoint Subdirectory einer Document Library kopieren

Da man mit Export-SPWeb bzw. Import-SPWeb bzw. im GUI über granular Backup/Restore nur ganze Document Libraries kopieren kann hab ich mich auf die Suche gemacht und bin hierauf gestoßen. Da das dort aufgeführte “Programm” eher nicht so 1:1 zu verwenden war (und auch bei den Foldern beispielsweise Author/Timestamp nicht setzt) und ich die Geschichte mit Excel, Versionen und verschiedenen Modi etc. nicht benötigte habe ich es entsprechend umgeschrieben (mit Hilfe). Ohne wirklich Errorhandling und kaum Logging – aber es hat einwandfrei funktioniert (SharePoint ist offenbar relativ relaxed wenn man Dinge added die es schon gibt):

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Data;
using Microsoft.SharePoint;

namespace Gallauner
{
   class SharepointListCopy
   {
      static void Main(string[] args)
      {
         string sSourceSite = "https://oldsite.domain.com";
         string sSourceWebName = "SomeWeb";
         string sSourceListName = "SomeList";
         string sSourceFolderName = "SomePath/MorePath/LastPath";
         string sDestSiteName = "https://newsite.domain.com";
         string sDestWebName = "SomeNewWeb/SubWeb";
         string sDestListName = "SomeNewList";
         string sDestFolderName = "";
         SPSite oSourceSite;
         SPList oSourceList;
         SPWeb oSourceWeb;
         SPListItemCollection oFolders;
         Microsoft.SharePoint.Administration.SPWebApplication oWebApp;

         // open source site/web/list
         oSourceSite = new SPSite(sSourceSite);
         oWebApp = oSourceSite.WebApplication;
         oWebApp.FormDigestSettings.Enabled = false;

         if (sSourceWebName == "")
            oSourceWeb = oSourceSite.RootWeb;
         else
            oSourceWeb = oSourceSite.OpenWeb(sSourceWebName);

         oSourceList = oSourceWeb.Lists[sSourceListName];
         if (oSourceList != null)
         {
            // search source folder
            oFolders = oSourceList.Folders;
            foreach (SPListItem oFolder in oFolders)
            {
               if (oFolder.Folder.Url == sSourceFolderName)
               {
                  // copy folder 
                  CopyFilesAndSubFolders(oLog, oFolder.Folder, oSourceWeb, 
                     sDestSiteName, sDestWebName, sDestListName, sDestFolderName);
               }
            }
         }
         oWebApp.FormDigestSettings.Enabled = true;
      }

      private static void CopyFilesAndSubFolders(Logfile oLog, SPFolder oFolder,
                                                 SPWeb oSourceWeb,string sDestSiteName,
                                                 string sDestWebName,string sDestListName,
                                                 string sDestFolderName)
      {
         SPSite oDestSite;
         SPWeb oDestWeb;
         SPList oDestList;
         SPFolder oDestFolder, oNewDestFolder;
         SPFile oFileCopy;
         SPUser oUser;

         // open site/web/list
         oDestSite = new SPSite(sDestSiteName);
         if (sDestWebName == "")
            oDestWeb = oDestSite.RootWeb;
         else
            oDestWeb = oDestSite.OpenWeb(sDestWebName);
         oDestList = oDestWeb.Lists[sDestListName];

         // find folder
         oDestFolder = null;
         if (sDestFolderName == "")
            oDestFolder = oDestList.RootFolder;
         else
         {
            foreach (SPListItem oTmpFolder in oDestList.Folders)
            {
               if (oTmpFolder.Folder.Url == sDestFolderName)
               {
                  oDestFolder = oTmpFolder.Folder;
                  break;
               }
            }
         }
         if (oDestFolder == null)
         {
            Console.WriteLine($"Cannot find destination folder '{sDestFolderName}'");
            return;
         }

         // copy files
         foreach (SPFile oFile in oFolder.Files)
         {
            Console.WriteLine($"FILE       {oFile.Name}");
            try
            {
               // add modifying user to destination web
               oUser=oDestWeb.EnsureUser(
                      (string)oFile.Properties["vti_modifiedby"]
                     );
            }
            catch (Exception)
            {
               // add failed, use me as modifying user
               oUser = oDestWeb.CurrentUser;
            }


            oFileCopy = oDestFolder.Files.Add(
                            $"{oDestWeb.Url}/{oDestFolder.Url}/{oFile.Name}", 
                            oFile.OpenBinaryStream(), 
                            oFile.Properties, 
                            oUser, 
                            oUser, 
                            (DateTime)oFile.Properties["vti_timelastmodified"],
                            (DateTime)oFile.Properties["vti_timelastmodified"],
                            oFile.CheckInComment, 
                            true);
         }

         // copy folders
         foreach (SPFolder oSubFolder in oFolder.SubFolders)
         {
            Console.WriteLine($"FOLDER {oSubFolder.Url}");

            try
            {
               // add modifying user to destination web
               oUser = oDestWeb.EnsureUser(
                        (string)oSubFolder.Properties["vti_modifiedby"]
                      );
            }
            catch (Exception)
            {
               // add failed, use me as modifying user
               oUser = oDestWeb.CurrentUser;
            }

            // first create on destination list
            oNewDestFolder = oDestFolder.SubFolders.Add(oSubFolder.Name);

            // set author/editor and timestamps
            SPListItem oTmp = oDestList.GetItemByUniqueId(oNewDestFolder.UniqueId);
            oTmp["Author"] = oUser;
            oTmp["Editor"] = oUser;
            oTmp["Created"] = oSubFolder.Properties["vti_nexttolasttimemodified"];
            oTmp["Modified"] = oSubFolder.Properties["vti_nexttolasttimemodified"];
            oTmp.Update();

            // call ourself recursively
            CopyFilesAndSubFolders(oLog, oSubFolder, oSourceWeb, 
                                   sDestSiteName, sDestWebName, 
                                   sDestListName, oNewDestFolder.Url);
         }

         // cleanup objects
         oDestWeb.Dispose();
         oDestSite.Dispose();
      }
   }
}

Die Reference Assembly bekommt man über das Nuget Package “Microsoft.SharePoint”, laufen lassen kann man das Ding dann allerdings nur auf einem SharePoint Server – mit Remote Debugger lässts sich dann aber bequem testen/debuggen.