Thursday, August 24, 2017

Sharing cookies for HttpWebRequest from Sharepoint site with FBA claims authentication

If you need to make sub request from your Sharepoint site you can do it like this (in this post we will assume that we make sub requests to the same Sharepoint site):

   1: var request = (HttpWebRequest)WebRequest.Create(url);
   2: request.Credentials = CredentialCache.DefaultNetworkCredentials;
   3: var response = (HttpWebResponse)request.GetResponse();

This code will work for Windows authentication – on receiver’s side if you will check SPContext.Current.Web.CurrentUser it will be the same as on sender’s side. But if the same code will run under FBA zone SPContext.Current.Web.CurrentUser will be null on receiver’s side. In order to force Sharepoint to execute the code under the same user also in FBA zone we need to share cookies:

   1: var request = (HttpWebRequest)WebRequest.Create(url);
   2: request.Credentials = CredentialCache.DefaultNetworkCredentials;
   3:  
   4: if (HttpContext.Current != null && web.Site.Zone != SPUrlZone.Default)
   5: {
   6:     HttpCookie authCookie = HttpContext.Current.Request.Cookies["FedAuth"];
   7:     if (authCookie != null)
   8:     {
   9:         log("Before send request: set auth cookies");
  10:         request.CookieContainer = new CookieContainer();
  11:         request.CookieContainer.Add(new Cookie("FedAuth", authCookie.Value,
  12:             authCookie.Path, new Uri(url).Host));
  13:     }
  14: }
  15:  
  16: var response = (HttpWebResponse)request.GetResponse();

In this example we assume that site works both with Windows and FBA zones and that Windows authentication is used on Default zone. After that SPContext.Current.Web.CurrentUser will be also correct on receiver’s side for FBA zone.

Unspecified error when create Domain local groups via DirectoryServices programmatically in .Net

In order to create AD group programmatically we can use DirectoryServices .Net assembly. Here is the code which create domain global group:

   1: string groupName = "...";
   2: var de = new DirectoryEntry("LDAP://...");
   3: var group = de.Children.Add("CN=" + groupName, "group");
   4: group.Properties["samAccountName"].Value = groupName;
   5: group.CommitChanges();
   6: return true;

If we want to create Domain local group we need to set one more property for created group: groupType. Value of this property should be created as bitmask from the following values (see ADS_GROUP_TYPE_ENUM enumeration):

ADS_GROUP_TYPE_GLOBAL_GROUP

0x00000002

ADS_GROUP_TYPE_DOMAIN_LOCAL_GROUP

0x00000004

ADS_GROUP_TYPE_UNIVERSAL_GROUP

0x00000008

ADS_GROUP_TYPE_SECURITY_ENABLED

0x80000000

But if we use C# and try to create group like this:

   1: string groupName = "...";
   2: bool local = ...;
   3: var de = new DirectoryEntry("LDAP://...");
   4: DirectoryEntry group = de.Children.Add("CN=" + groupName, "group");
   5: group.Properties["samAccountName"].Value = groupName;
   6: group.Properties["groupType"].Value = (local ? 0x00000004 : 0x00000002) | 0x80000000;
   7: group.CommitChanges();

we will get exception with Unspecified error message:

Unspecified error
   at System.DirectoryServices.Interop.UnsafeNativeMethods.IAds.PutEx(Int32 lnControlCode, String bstrName, Object vProp)
   at System.DirectoryServices.PropertyValueCollection.set_Value(Object value)

The reason is that by default C# will cast value (local ? 0x00000004 : 0x00000002) | 0x80000000 as long and will pass 2147483652 to the groupType property which is incorrect value here. In order to avoid this error we need to pass int value to this property, i.e. in our code we should explicitly cast it to int – in this case it will pass negative value -2147483644 there:

   1: string groupName = "...";
   2: bool local = ...;
   3: var de = new DirectoryEntry("LDAP://...");
   4: DirectoryEntry group = de.Children.Add("CN=" + groupName, "group");
   5: group.Properties["samAccountName"].Value = groupName;
   6: group.Properties["groupType"].Value =
   7:     (int)((local ? 0x00000004 : 0x00000002) | 0x80000000);
   8: group.CommitChanges();

and group will be successfully created.

Thursday, August 17, 2017

List all UserCustomActions in Sharepoint site collections and sub sites via PowerShell

User custom actions (see SPSite.UserCustomActions and SPWeb.UserCustomActions) are powerful mechanism to add customizations on Sharepoint site (on-premise or online) via javascript. E.g. in one of the previous posts I showed how to add custom javascript file to all pages in your site collection without modifying master page: see Add custom javascript file to all pages in on-premise Sharepoint site collection without modifying masterpage and Add custom javascript file to all pages in Sharepoint Online site collection. Sometimes we need to perform inventory of all custom actions with script links. Here is the PowerShell script which iterates through all site collections in provided web application and all sub sites and outputs custom action’s ScriptSrc to the log file:

   1: param(
   2:     [string]$url
   3: )
   4:  
   5: if (-not $url)
   6: {
   7:     Write-Host "Specify web application url in url parameter"
   8: -foregroundcolor red
   9:     return
  10: }
  11:  
  12: function CheckWeb($web)
  13: {
  14:     Write-Host "Web:" $web.Url
  15:     foreach($ac in $web.UserCustomActions)
  16:     {
  17:         ("  " + $ac.ScriptSrc) | Out-File "log.txt" -Append
  18:     }
  19:     
  20:     $web.Webs | ForEach-Object { CheckWeb $_ }
  21: }
  22:  
  23: function CheckSite($site)
  24: {
  25:     Write-Host "Site collection:" $site.Url
  26:     ("Site collection: " +  $site.Url) | Out-File "log.txt" -Append
  27:  
  28:     foreach($ac in $site.UserCustomActions)
  29:     {
  30:         ("  " + $ac.ScriptSrc) | Out-File "log.txt" -Append
  31:     }
  32:     
  33:     CheckWeb $site.RootWeb
  34:     
  35:     ("---------------------------------------") | Out-File "log.txt" -Append
  36: }
  37:  
  38:  
  39: $wa = Get-SPWebApplication $url
  40: $wa.Sites | ForEach-Object { CheckSite $_ }

Here in order to write results to the log file I used approach described in the following post: Write output to the file in PowerShell. And it is quite straightforward to rewrite this script for Sharepoint Online (see article provided above for Sharepoint Online). Hope it will help someone.

Friday, August 11, 2017

Create AD groups programmatically via DirectoryServices without domain admin rights using OU control delegation

In .Net when we need to work with AD we in most cases will use System.DirectoryServices assembly. In order to perform various actions against AD our code should run under account of user which has necessary permissions. Other option is to use security binding – DirectoryEntry constructor which receives username and password as parameter. In this case specified user also should have necessary permissions. The question is which exact permissions are needed e.g. for creating AD groups. Of course if we use account of domain admin it will be enough – users of this group may perform almost all actions over their AD. But it would also open more vulnerabilities as application may harm whole AD if malicious users will hack it.

In Microsoft AD there is another feature called control delegation (Delegate Control) which allows you to worj with AD without having domain admin rights. The idea is that you delegate control over specific AD object (e.g. over Organizational Unit – OU) to the user or group and this user/group will be able to make changes only within this AD object. Let’s see the following test example.

1. At first for clarity we will create new AD account which is not member of any AD group except built-in Domain Users group:

2. After that create Test OU in AD where we will create AD groups:

3. Now let’s try to run the following code which uses DirectoryEntry with security binding with created account for creating new AD group. Current timestamp is used in the group name:

   1: DirectoryEntry dom = new DirectoryEntry("LDAP://SP.DEV",
   2:     "sp\\testuser1", "...", AuthenticationTypes.Secure);
   3:  
   4: DirectoryEntry ou = dom.Children.Find("OU=Test");
   5:  
   6: string name = "test_" + DateTime.Now.ToString("yyyyMMddHHmmss");
   7: DirectoryEntry group = ou.Children.Add("CN=" + name, "group");
   8:  
   9: group.Properties["samAccountName"].Value = name;
  10:  
  11: group.CommitChanges();

The result will be System.UnauthorizedAccessException:

Unhandled Exception: System.UnauthorizedAccessException: Access is denied.

   at System.DirectoryServices.Interop.UnsafeNativeMethods.IAds.SetInfo()
   at System.DirectoryServices.DirectoryEntry.CommitChanges()
   …

4. Now let’s return to AD, right click on OU and select Delegate Control menu item. In the opened wizard we choose our account and “Create, delete and manage groups” tasks:

5. After that if we will run our code it will successfully create new AD group:

As you can see using control delegation we were able to create AD groups without having domain admin rights. Here is the list of all actions for which you can delegate control in AD:

  • Create, delete and manage user accounts
  • Reset user passwords and force password change at next logon
  • Read all user information
  • Create, delete and manage groups
  • Modify the membership of a group
  • Manage group policy links
  • Generate resultant set of policy (planning)
  • Generate resultant set of policy (logging)
  • Create, delete and manage inetOrgPerson accounts
  • Reset inetOrgPerson passwords and force password change at next logon
  • Read all inetOrgPerson information

This is quite powerful feature which you may use for making you code more secure.

Wednesday, August 2, 2017

PowerShell script for listing master pages in all sites of Sharepoint Online site collection via CSOM

If you maintain big site collection in Sharepoint Online it will be useful to know what master pages are used in sub sites. The following script recursively iterates through all sub sites in specific site collection and prints master pages. Also it prints whether or not site master page and custom master page are inherited from the parent web:

   1: param(
   2:     [string]$siteUrl,
   3:     [string]$login,
   4:     [string]$password
   5: )
   6:  
   7: $currentDir = Convert-Path(Get-Location)
   8: $dllsDir = resolve-path($currentDir +
   9:     "\Microsoft.SharePointOnline.CSOM.16.1.6420.1200\lib\net45")
  10:  
  11: [System.Reflection.Assembly]::LoadFile([System.IO.Path]::Combine($dllsDir,
  12:     "Microsoft.SharePoint.Client.dll"))
  13: [System.Reflection.Assembly]::LoadFile([System.IO.Path]::Combine($dllsDir,
  14:     "Microsoft.SharePoint.Client.Runtime.dll"))
  15:  
  16: if (-not $siteUrl)
  17: {
  18:     Write-Host "Specify site url in siteUrl parameter" -foregroundcolor red
  19:     return
  20: }
  21:  
  22: if (-not $login)
  23: {
  24:     Write-Host "Specify user name in login parameter" -foregroundcolor red
  25:     return
  26: }
  27:  
  28: if (-not $password)
  29: {
  30:     Write-Host "Specify user password in password parameter" -foregroundcolor red
  31:     return
  32: }
  33:  
  34: <#
  35: .Synopsis
  36:     Facilitates the loading of specific properties of a Microsoft.SharePoint.Client.ClientObject object or Microsoft.SharePoint.Client.ClientObjectCollection object.
  37: .DESCRIPTION
  38:     Replicates what you would do with a lambda expression in C#. 
  39:     For example, "ctx.Load(list, l => list.Title, l => list.Id)" becomes
  40:     "Load-CSOMProperties -object $list -propertyNames @('Title', 'Id')".
  41: .EXAMPLE
  42:     Load-CSOMProperties -parentObject $web -collectionObject $web.Fields -propertyNames @("InternalName", "Id") -parentPropertyName "Fields" -executeQuery
  43:     $web.Fields | select InternalName, Id
  44: .EXAMPLE
  45:    Load-CSOMProperties -object $web -propertyNames @("Title", "Url", "AllProperties") -executeQuery
  46:    $web | select Title, Url, AllProperties
  47: #>
  48: function Load-CSOMProperties {
  49:     [CmdletBinding(DefaultParameterSetName='ClientObject')]
  50:     param (
  51:         # The Microsoft.SharePoint.Client.ClientObject to populate.
  52:         [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, ParameterSetName = "ClientObject")]
  53:         [Microsoft.SharePoint.Client.ClientObject]
  54:         $object,
  55:  
  56:         # The Microsoft.SharePoint.Client.ClientObject that contains the collection object.
  57:         [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 0, ParameterSetName = "ClientObjectCollection")]
  58:         [Microsoft.SharePoint.Client.ClientObject]
  59:         $parentObject,
  60:  
  61:         # The Microsoft.SharePoint.Client.ClientObjectCollection to populate.
  62:         [Parameter(Mandatory = $true, ValueFromPipeline = $true, Position = 1, ParameterSetName = "ClientObjectCollection")]
  63:         [Microsoft.SharePoint.Client.ClientObjectCollection]
  64:         $collectionObject,
  65:  
  66:         # The object properties to populate
  67:         [Parameter(Mandatory = $true, Position = 1, ParameterSetName = "ClientObject")]
  68:         [Parameter(Mandatory = $true, Position = 2, ParameterSetName = "ClientObjectCollection")]
  69:         [string[]]
  70:         $propertyNames,
  71:  
  72:         # The parent object's property name corresponding to the collection object to retrieve (this is required to build the correct lamda expression).
  73:         [Parameter(Mandatory = $true, Position = 3, ParameterSetName = "ClientObjectCollection")]
  74:         [string]
  75:         $parentPropertyName,
  76:  
  77:         # If specified, execute the ClientContext.ExecuteQuery() method.
  78:         [Parameter(Mandatory = $false, Position = 4)]
  79:         [switch]
  80:         $executeQuery
  81:     )
  82:  
  83:     begin { }
  84:     process {
  85:         if ($PsCmdlet.ParameterSetName -eq "ClientObject") {
  86:             $type = $object.GetType()
  87:         } else {
  88:             $type = $collectionObject.GetType() 
  89:             if ($collectionObject -is [Microsoft.SharePoint.Client.ClientObjectCollection]) {
  90:                 $type = $collectionObject.GetType().BaseType.GenericTypeArguments[0]
  91:             }
  92:         }
  93:  
  94:         $exprType = [System.Linq.Expressions.Expression]
  95:         $parameterExprType = [System.Linq.Expressions.ParameterExpression].MakeArrayType()
  96:         $lambdaMethod = $exprType.GetMethods() | ? { $_.Name -eq "Lambda" -and $_.IsGenericMethod -and $_.GetParameters().Length -eq 2 -and $_.GetParameters()[1].ParameterType -eq $parameterExprType }
  97:         $lambdaMethodGeneric = Invoke-Expression "`$lambdaMethod.MakeGenericMethod([System.Func``2[$($type.FullName),System.Object]])"
  98:         $expressions = @()
  99:  
 100:         foreach ($propertyName in $propertyNames) {
 101:             $param1 = [System.Linq.Expressions.Expression]::Parameter($type, "p")
 102:             try {
 103:                 $name1 = [System.Linq.Expressions.Expression]::Property($param1, $propertyName)
 104:             } catch {
 105:                 Write-Error "Instance property '$propertyName' is not defined for type $type"
 106:                 return
 107:             }
 108:             $body1 = [System.Linq.Expressions.Expression]::Convert($name1, [System.Object])
 109:             $expression1 = $lambdaMethodGeneric.Invoke($null, [System.Object[]] @($body1, [System.Linq.Expressions.ParameterExpression[]] @($param1)))
 110:  
 111:             if ($collectionObject -ne $null) {
 112:                 $expression1 = [System.Linq.Expressions.Expression]::Quote($expression1)
 113:             }
 114:             $expressions += @($expression1)
 115:         }
 116:  
 117:  
 118:         if ($PsCmdlet.ParameterSetName -eq "ClientObject") {
 119:             $object.Context.Load($object, $expressions)
 120:             if ($executeQuery) { $object.Context.ExecuteQuery() }
 121:         } else {
 122:             $newArrayInitParam1 = Invoke-Expression "[System.Linq.Expressions.Expression``1[System.Func````2[$($type.FullName),System.Object]]]"
 123:             $newArrayInit = [System.Linq.Expressions.Expression]::NewArrayInit($newArrayInitParam1, $expressions)
 124:  
 125:             $collectionParam = [System.Linq.Expressions.Expression]::Parameter($parentObject.GetType(), "cp")
 126:             $collectionProperty = [System.Linq.Expressions.Expression]::Property($collectionParam, $parentPropertyName)
 127:  
 128:             $expressionArray = @($collectionProperty, $newArrayInit)
 129:             $includeMethod = [Microsoft.SharePoint.Client.ClientObjectQueryableExtension].GetMethod("Include")
 130:             $includeMethodGeneric = Invoke-Expression "`$includeMethod.MakeGenericMethod([$($type.FullName)])"
 131:  
 132:             $lambdaMethodGeneric2 = Invoke-Expression "`$lambdaMethod.MakeGenericMethod([System.Func``2[$($parentObject.GetType().FullName),System.Object]])"
 133:             $callMethod = [System.Linq.Expressions.Expression]::Call($null, $includeMethodGeneric, $expressionArray)
 134:             
 135:             $expression2 = $lambdaMethodGeneric2.Invoke($null, @($callMethod, [System.Linq.Expressions.ParameterExpression[]] @($collectionParam)))
 136:  
 137:             $parentObject.Context.Load($parentObject, $expression2)
 138:             if ($executeQuery) { $parentObject.Context.ExecuteQuery() }
 139:         }
 140:     }
 141:     end { }
 142: }
 143:  
 144: function CheckMasterPage($ctx, $web)
 145: {
 146:     Load-CSOMProperties -object $web -propertyNames @("MasterUrl", "CustomMasterUrl",
 147:         "Webs", "AllProperties")
 148:     $ctx.ExecuteQuery()
 149:     
 150:     ($web.Url) | Out-File "log.txt" -Append
 151:     ("    " + $web.MasterUrl) | Out-File "log.txt" -Append
 152:     ("    " + $web.AllProperties["__InheritsMasterUrl"]) | Out-File "log.txt" -Append
 153:     ("    " + $web.CustomMasterUrl) | Out-File "log.txt" -Append
 154:     ("    " + $web.AllProperties["__InheritsCustomMasterUrl"]) | Out-File "log.txt" -Append
 155:     
 156:     foreach ($w in $web.Webs)
 157:     {
 158:         CheckMasterPage $ctx $w
 159:     }
 160: }
 161:  
 162: $securePassword = ConvertTo-SecureString $password -AsPlainText -Force
 163: $credentials =
 164:     New-Object Microsoft.SharePoint.Client.SharePointOnlineCredentials($login,
 165:         $securePassword)    
 166: $ctx = New-Object Microsoft.SharePoint.Client.ClientContext($siteUrl)
 167: $ctx.AuthenticationMode =
 168:     [Microsoft.SharePoint.Client.ClientAuthenticationMode]::Default
 169: $ctx.Credentials = $credentials
 170: $ctx.Load($ctx.Site)
 171: $ctx.Load($ctx.Web)
 172: $ctx.ExecuteQuery()
 173:  
 174: CheckMasterPage $ctx $ctx.Web

Script uses CSOM v.16.1.6420.1200, but you may use other versions of course – download it and save in the script folder (you may need to change path from where assemblies are loaded – lines 8-9). Also it uses helper utility function Load-CSOMProperties (credits go to Gary Lapointe) which is analogue of ClientContext.Load function in C# which allows to specify which properties should be loaded via lambda expressions (in PowerShell there are no lambdas, so we have to use helper function). Script itself is quite simple – it recursively iterates through all sub sites and prints master pages (lines 144-160). Hope it will help someone.