Thursday, January 5, 2017

Use MasterPage, JavaScript, Custom Security Level to Protect Provider-Hosted App's SharePoint Site

When setting up a SharePoint Provider-Hosted app (or add-in), I experienced a dilemma as to
  1.  Allow non-admin users ReadWrite permission to all necessary Lists and Document Libraries for the Provider-Hosted app (app uses current user's credential when accessing Lists and Libraries).
  2. Block non-admin users from directly accessing the SharePoint subweb (site) that hosts the Provider-Hosted app and its related Lists and Libraries .
A technically savvy person can easily decode the long URL of RemoteWeb of the Provider-Hosted app. They can trace it back to the data-repository SharePoint site's URL.

The difficulty for SharePoint admin is to accomplish how to allow ReadWrite permission to users on Lists and Libraries and to block users from directly accessing the same Lists and Libraries. If we remove read/write permission on the list from the user, the Provider-Hosted app cannot do its IO, because the app uses the user's credential to perform read/write.

So I came up with two lines of defense.  to accomplish both "allow" and "restrict".

First, I created a new permission level at the site collection based on Contribute permission level.
After creating a copy of Contribute permission level, I named it Custom Contribute. Then I further removed the following granular permission attributes.  
  • Browse Directories  (to prevent users from UNC-path backdoor access through Windows Explorer)
  • Manage Personal Views 
  • Add/Remove Personal Web Parts  
  • Update Personal Web Parts 
Deselecting last three options would prevent users from creating their own views. By taking away this ability, users can only use the the default view customized by SharePoint admins.

When the Custom Contribute permission level was ready, I created a SharePoint group called Custom Contribute Members that uses the Custom Contribute permission level. Then I added two domain groups to Custom Contribute Members in order to make the Provider-Hosted app to work properly (The provider-hosted app needed to be available to all domain users in this case).
  • Domain Users
  • Authenticated Users
Without Authenticated Users in the Custom Contribute Members group, SharePoint workflow email notification did not work properly. 

The Provider-Hosted App and its related Lists and Libraries were all placed in one subweb under the same site collection. I only kept the Custom Contribute Members group among the subweb's member groups.  I added all admin users to the Site Collection Administrators for full control.

The first line of defense was to restrict end-users' privileges as much as possible. Obviously, at this point, a technically savvy user with ill-intention can trace back the subweb's URL, access the subweb and browse or tamper with Lists and Libraries.  

Now the second line of defense.....

At the moment, I trimmed down all domain users' permission as much as possible by using Custom Contribute Members as mentioned above. All domain users can run the Provider-Hosted app to open the RemoteWeb and perform their own read/write actions with no problem.

Now it's time to block all non-admin users from directly accessing the data-repository subweb by web browser.

One of the easiest ways to allow only the admin users to directly access the data-repository subweb via web browser is:
  • Custom MasterPage
  • New Custom List that holds admin users' names
  • jQuery and custom javascript to query admin users' names and redirect
By using a custom MasterPage, I was able to apply a custom JavaScript run executes at any page load of the entire data-repository via a web browser. The JavaScript queries a custom list called Administrators that contains admin users' names. If the current user is not found in the Administrators List, the user will be redirected to some other page immediately. If current user is found, then the script does nothing, allowing the user to have direct normal access to the SharePoint site via a web browser.

**Before trying to use a Custom MasterPage, don't forget to turn on Publishing Infrastructure at the site collection settings and Publishing at the site settings.

At the site collection level, I added the jQuery file and a blank custom javascript file in SiteAssets library as follows.

Then I used SharePoint Designer to open the site collection, created a copy of seattle.html MasterPage and named it ProviderHosted.html. I then checked it out and check it back with a major version.  

I edited the ProfiderHosted.html master page file by adding two <ScriptLink...> tags in the <head> section as follows.

I saved the new MasterPage file and made sure that it is beyond version 1.0 and confirmed that the new MasterPage ProviderHosted is available in the dropdown under MasterPage in the subweb's site settings.

Then I created a new custom List in the same SharePoint site that hosts the app. I named the new custom List "Administrators". It only needed one field: Title. 

Then I added names of admin users under Title field of the new Administrators list. Later on, RedirectNonAdmin.js will retrieve admin users' names from this list's Title field and compare them against current user name. If there is no match, the script redirects the user to Google.

Below is RedirectNonAdmin.js.

var my = my || {}; 

// Note that there are two $.ajax functions, 
// one nested inside another's done() function. 
// This was necessary as list of admin users were retrieved 
// asynchrounously first, and then compared against current user name, 
// which had to be retrieved from SharePoint api asynchronously, too.
my.redirectIfNotMemberOf = function( listName ){
 var adminListUrl = _spPageContextInfo.webAbsoluteUrl + 
    "/_api/web/lists/getbytitle('" + listName + "')/items";
  url: adminListUrl,
  method: "GET",
  headers: { "Accept": "application/json; odata=verbose" },
  // Get Administrators names from Administrators List.
  var listItems = data.d.results;
  var admins = new Array();
  $.each(listItems, function(i){
  // Get current user's name. 
  var currentUserID = _spPageContextInfo.userId;
  var currentUserUrl = _spPageContextInfo.webAbsoluteUrl + 
     "/_api/web/getuserbyid(" + currentUserID + ")";

   url: currentUserUrl,
   method: "GET",
   headers: { "accept": "application/json;odata=verbose" }, 
   var currentUserName = data.d.Title;
   // Redirect to access denied page if current user name is not found 
   // in admin users' names.
   var isMember = 0;
   $.each(admins, function(i){
    if(admins[i].toUpperCase() == currentUserName.toUpperCase()){     
     isMember = 1;
     return false; // get out of $.each()
   if( isMember == 0 ){
    // If not a member, redirect to some other page.
   console.log("Error at my.getCurrentUserName()\n" + data); 
  console.log("Error at my.getListTitleValue()\n" + data); 


 // Current web must contain a list named "Administrators" 
 // with admin users in the Title field.
 // In order to stop the redirect behavior, 
 // either change the master page 
 // or change the name of this script by Windows Explorer via UNC path.
 my.redirectIfNotMemberOf( "Administrators" );

Before using RedirectNonAdmin.js, make sure that you add yourself to the custom list  Admninistrators. Otherwise, you could get kicked out of the subweb as soon as you change the MasterPage of the subweb. The Administrators list must reside on the same subweb where the Provider-Hosted app and its related lists and libraries are hosted in order for the RedirectNonAdmin.js to work properly.

After RedirectNonAdmin.js file is filled in and ready, go to subweb's site settings --> Master page and change System Master Page to the new  ProviderHosted master page from the dropdown.

Now when non-admin users visit the subweb directly, they will be redirected to Google. Only users specified in the Administrators list can directly access the subweb.

If you mistakenly did not include yourself in the Administrators list and cannot access the SharePoint site, the easiest fix would be to use SharePoint Designer, open the SiteCollection-level site, open ProviderHosted.html master page, and comment out the <ScriptLink...> tag that includes the RedirectNonAdmin.js.

No comments: