The article discusses implementation of a web service that handles various AD routines, including authentication, user management, and encryption. It demonstrates use of WCF web services, session handling, encryption, and Active Directory APIs in C#. The web service aims to simplify automation of tasks for sysadmins and provide single sign-on functionality across multiple applications.
Introduction
I found it interesting to do a web services that handles most of the day to day jobs when it comes to active directory, such as ADD/REMOVE Users/Groups/Computers Enable/Disable Users/Computers, etc. The idea behind was instead of implementing those micro routines every time you need them, you just consume the web services. But the real reason I implemented this thing is because of authentication and to be able to provide SSO across all applications that wish to consume this web service. This implementation is not final, but it gives the idea how to extend to include all active directory related routines, which makes life easier for Sysadmins when they want to automate tasks such as create AD user along with Mailbox and enable Lync. Those previously mentioned tasks could be done using PowerShell, most of them if not all of them, but to be able to provide such functionalities to applications, it is convenient enough to do it through web service.
Background
What I’m doing here is implementing WCF Web services for Active Directory routines. Most of the routines are wrappers around ComputerPrincipal
, UserPrincipal
, GroupPrincipal
, and DirectoryEntry
which is available under the following namespaces:
using System.DirectoryServices;
using System.DirectoryServices.AccountManagement;
using System.DirectoryServices.ActiveDirectory;
Aside from that, implementing AES Encryption for the session which contains an XML Serialized object for Username
, Password
and SessionStart
Date.
Using the Code
The code is quite long, so I will try to keep it going through the main things.
The code is being split in two categories:
- Authentication Related (which include session handling and encryption)
- Active Directory Routines
So we will start with Authentication related, and to begin with Session Handling, which contains a Serializable Struct
contains the SessionData
and then the handler handles Serialization and Deserialization of the SessionData Struct
, I will not include the encryption and decryption here as it could be replaced with what every Symmetrical Encryption Algorithm you wish to implement. The Resulting Encrypted Session will be sent with every single Request for every Active Directory routine.
[Serializable]
public struct SessionData
{
public string Username { get; set; }
public string Password { get; set; }
public DateTime SessionStart { get; set; }
}
public class SessionHandler
{
private static string SerializeObject<t>(SessionData sessionData)
{
string serialized = string.Empty;
XmlSerializer serializer = new XmlSerializer( typeof( T ) );
using ( StringWriter writer = new StringWriter() )
{
serializer.Serialize( writer , sessionData );
byte[] data = Encoding.UTF8.GetBytes( writer.ToString() );
return Convert.ToBase64String( data );
}
}
private static T DesrializeObject<t>( string data )
{
return (T)DesrializeObject( data , typeof( T ) );
}
private static object DesrializeObject( string objectData , Type type )
{
var serializer = new XmlSerializer( type );
object result;
using ( TextReader reader = new StringReader( objectData ) )
{
result = serializer.Deserialize( reader );
}
return result;
}
private static string Encrypt(string session)
{
string sessionCypher = Cryptor.Encrypt( session );
return sessionCypher;
}
private static string Decrypt( string session )
{
string decrptedSession = Cryptor.Decrypt( session );
return decrptedSession;
}
public static string EncryptSession(SessionData session)
{
string serializedSession = SerializeObject<sessiondata>( session );
string encryptedSession = Encrypt( serializedSession );
return encryptedSession;
}
public static SessionData DecryptSession( string session )
{
string decryptedSession = Decrypt( session );
SessionData desrialized = DesrializeObject<sessiondata>( decryptedSession );
return desrialized;
}
}
Everything starts when user authenticates using his active directory credentials which returns an encrypted session as a JSON Object which contains IsAuthenticated
, Message
, SessionKey
, the AuthData
class contains Username
, Password
.
public Session AuthenticateUserUsingCredentials( AuthDataRequest authData )
{
UserInfoResponse userInfo = new UserInfoResponse();
string emailAddress = authData.username;
string password = authData.password;
Session stat = new Session();
string msg = string.Empty;
if ( string.IsNullOrEmpty( emailAddress ) || string.IsNullOrEmpty( password ) )
{
stat.Message = "Email and/or password can't be empty!";
stat.IsAuthenticated = false;
return stat;
}
try
{
userInfo = GetUserAttributes( emailAddress );
if ( userInfo == null )
{
stat.Message = "Error: Couldn't fetch user information!";
stat.IsAuthenticated = false;
return stat;
}
var directoryEntry = new DirectoryEntry( LocalGcUri , userInfo.Upn , password );
directoryEntry.AuthenticationType = AuthenticationTypes.None;
var localFilter = string.Format( AdSearchFilter , emailAddress );
var localSearcher = new DirectorySearcher( directoryEntry );
localSearcher.PropertiesToLoad.Add( "mail" );
localSearcher.Filter = localFilter;
var result = localSearcher.FindOne();
if ( result != null )
{
stat.Message = "You have logged in successfully!";
stat.IsAuthenticated = true;
SessionData session = new SessionData();
session.Username = userInfo.EmailAddress;
session.Password = password;
session.SessionStart = DateTime.Now;
stat.SessionKey = SessionHandler.EncryptSession( session );
return stat;
}
stat.Message = "Login failed, please try again.";
stat.IsAuthenticated = false;
return stat;
}
catch ( Exception ex )
{
stat.Message = "Wrong Email and/or Password " + ex;
stat.IsAuthenticated = false;
return stat;
}
}
And the Web service interface for Authentication defined to serialize the input and output as JSON:
[OperationContract]
[WebInvoke(
UriTemplate = "auth/user" ,
RequestFormat= WebMessageFormat.Json,
ResponseFormat = WebMessageFormat.Json,
BodyStyle = WebMessageBodyStyle.Bare,
Method = "POST" )]
Session AuthenticateUserUsingCredentials
( [MessageParameter( Name = "authdata" )] AuthDataRequest authData );
Kindly note that after successfully authenticating yourself, you will be able to use the session key to authenticate to different applications which use the same web services for authentication, and this could be done as follows:
Web service OperationContract
definition:
[OperationContract]
[WebInvoke(
UriTemplate = "auth/session" ,
ResponseFormat = WebMessageFormat.Json ,
RequestFormat = WebMessageFormat.Json ,
BodyStyle = WebMessageBodyStyle.Bare,
Method="POST")]
Session AuthenticateUserUsingSession
( [MessageParameter( Name = "sessionkey" )] string sessionKey );
As for the implementation, it is described as below:
public Session AuthenticateUserUsingSession( string sessionKey )
{
return ValidateSession( sessionKey );
}
This function is only a wrapper for SessionValidation
, the session will be validated based on two factors:
- Successful decryption for the
SessionKey
- The validity of the session doesn't exceed configurable TTL, in my case, is two hours.
public Session ValidateSession( string sessionKey )
{
Session stat = new Session();
if ( string.IsNullOrWhiteSpace( sessionKey ) )
{
stat.Message = "No Session key has been provide";
stat.IsAuthenticated = false;
return stat;
}
else
{
try
{
SessionData sessionData = SessionHandler.DecryptSession( sessionKey );
if ( sessionKey != null && ( ( DateTime.Now.Subtract
( sessionData.SessionStart ) ).TotalHours < SessionTTL ) )
{
stat.Message = "You have logged in successfully!";
stat.IsAuthenticated = true;
stat.SessionKey = sessionKey;
return stat;
}
else
{
AuthDataRequest authData = new AuthDataRequest();
authData.username = sessionData.Username;
authData.password = sessionData.Password;
stat = AuthenticateUserUsingCredentials( authData );
stat.Message = "You have logged in successfully!,
and Session key has been renewed";
return stat;
}
}
catch ( Exception ex )
{
stat.Message = "Couldn't validate Session key,
kindly authenticate first " + ex;
stat.IsAuthenticated = false;
return stat;
}
}
}
As far as authentication is concerned, I think we can stop here and move to Active Directory routines, kindly note that I am not going to list all the routines because they are quite a lot, so I will stick with couple of examples.
AddUser
First, we define a DataContract
with DataMembers
:
[DataContract]
public class RequestUserCreate
{
[DataMember]
public string FirstName { get; set; }
[DataMember]
public string LastName { get; set; }
[DataMember]
public string UserLogonName { get; set; }
[DataMember]
public string EmployeeID { get; set; }
[DataMember]
public string EmailAddress { get; set; }
[DataMember]
public string Telephone { get; set; }
[DataMember]
public string Address { get; set; }
[DataMember]
public string PostalCode { get; set; }
[DataMember]
public string PostOfficeBox { get; set; }
[DataMember]
public string PhysicalDeliveryOffice { get; set; }
[DataMember]
public string Country { get; set; }
[DataMember]
public string City { get; set; }
[DataMember]
public string Title { get; set; }
[DataMember]
public string Department { get; set; }
[DataMember]
public string Company { get; set; }
[DataMember]
public string Description { get; set; }
[DataMember]
public string PhoneExtention { get; set; }
[DataMember]
public string PhoneIpAccessCode { get; set; }
[DataMember]
public string Password { get; set; }
[DataMember]
public DomainRequest DomainInfo { get; set; }
}
I think that you will note that there is an Object
called DomainRequest
, which includes all the information needed to connect to a domain controller or to connect to Global Catalog aside from holding the session key after successful authentication.
[DataContract]
public class DomainRequest
{
[DataMember]
public string ADHost { get; set; }
[DataMember]
public string DomainName { get; set; }
[DataMember]
public string ContainerPath { get; set; }
[DataMember]
public string BindingUserName { get; set; }
[DataMember]
public string BindingUserPassword { get; set; }
[DataMember]
public string SessionKey { get; set; }
}
but why would we add username and password as long as we have the session key and we are already authenticated, and the answer will be simple, because you may not have a write or read permission on that specific domain container, so you run these commands with a different username, but it is a must that you are already authenticated and the session key is valid. Another thing to mention in the could it is always checking if you have a write access to that specific container.
Then, we define the OperationContract
:
[OperationContract]
[WebInvoke(
UriTemplate = "ad/account/add" ,
RequestFormat = WebMessageFormat.Json ,
ResponseFormat = WebMessageFormat.Json ,
BodyStyle = WebMessageBodyStyle.Bare ,
Method = "POST" )]
ResponseMessage AddADUser( [MessageParameter( Name = "userinfo" )]
RequestUserCreate userinfo );
And the implementation will look like below:
public ResponseMessage AddADUser( RequestUserCreate userinfo )
{
ResponseMessage status = new ResponseMessage();
status.IsSuccessful = false;
status.Message = string.Empty;
Session stat = ValidateSession( userinfo.DomainInfo.SessionKey );
if ( stat.IsAuthenticated == true )
{
PrincipalContext principalContext = null;
string uri = FixADURI( userinfo.DomainInfo.ADHost ,
userinfo.DomainInfo.ContainerPath );
if ( string.IsNullOrWhiteSpace( uri ) )
{
status.Message = status.Message =
"AD Host is not allowed to be empty, kindly provide the AD Host";
return status;
}
bool isAllowWite = CheckWriteOermission( uri ,
userinfo.DomainInfo.BindingUserName , userinfo.DomainInfo.BindingUserPassword );
try
{
UserPrincipal usr =
FindADUser( userinfo.UserLogonName , userinfo.DomainInfo );
if ( usr != null )
{
status.Message = " user already exists.
Please use a different User Logon Name";
return status;
}
else
{
principalContext = new PrincipalContext
( ContextType.Domain , userinfo.DomainInfo.DomainName ,
userinfo.DomainInfo.ContainerPath ,
userinfo.DomainInfo.BindingUserName ,
userinfo.DomainInfo.BindingUserPassword );
}
}
catch ( Exception ex )
{
status.Message = @"Failed to create PrincipalContext: " + ex;
return status;
}
UserPrincipal userPrincipal = new UserPrincipal( principalContext );
if ( !string.IsNullOrWhiteSpace( userinfo.LastName ) )
userPrincipal.Surname = userinfo.LastName;
if ( !string.IsNullOrWhiteSpace( userinfo.FirstName ) )
userPrincipal.GivenName = userinfo.FirstName;
if ( !string.IsNullOrWhiteSpace( userinfo.LastName ) &&
!string.IsNullOrWhiteSpace( userinfo.FirstName ) )
userPrincipal.DisplayName = userinfo.FirstName + " " + userinfo.LastName;
if ( !string.IsNullOrWhiteSpace( userinfo.Description ) )
userPrincipal.Description = userinfo.Description;
if ( !string.IsNullOrWhiteSpace( userinfo.EmployeeID ) )
userPrincipal.EmployeeId = userinfo.EmployeeID;
if ( !string.IsNullOrWhiteSpace( userinfo.EmailAddress ) )
userPrincipal.EmailAddress = userinfo.EmailAddress;
if ( !string.IsNullOrWhiteSpace( userinfo.Telephone ) )
userPrincipal.VoiceTelephoneNumber = userinfo.Telephone;
if ( !string.IsNullOrWhiteSpace( userinfo.UserLogonName ) )
userPrincipal.SamAccountName = userinfo.UserLogonName;
if ( !string.IsNullOrWhiteSpace( userinfo.Password ) )
userPrincipal.SetPassword( userinfo.Password );
userPrincipal.Enabled = true;
userPrincipal.ExpirePasswordNow();
try
{
userPrincipal.Save();
DirectoryEntry de = (DirectoryEntry)userPrincipal.GetUnderlyingObject();
FillUserExtraAttributes( ref de , userinfo );
de.CommitChanges();
status.Message = "Account has been created successfully";
status.IsSuccessful = true;
}
catch ( Exception ex )
{
status.Message = "Exception creating user object. " + ex;
status.IsSuccessful = false;
return status;
}
return status;
}
else
{
status.Message = "Kindly authenticate first";
return status;
}
}
Here, I'm using UserPrincipal
to populate the user object with the basic information, save it and return the UnderlyingObject
which is of type DirectoryEntry
to populate the rest of the Information, the extra attributes function is listed below:
private void FillUserExtraAttributes
( ref DirectoryEntry de , RequestUserCreate userinfo )
{
try
{
if ( !string.IsNullOrWhiteSpace( userinfo.Title ) )
de.Properties[ "title" ].Value = userinfo.Title;
if ( !string.IsNullOrWhiteSpace( userinfo.City ) )
de.Properties[ "l" ].Value = userinfo.City;
if ( !string.IsNullOrWhiteSpace( userinfo.Country ) )
de.Properties[ "c" ].Value = userinfo.Country;
if ( !string.IsNullOrWhiteSpace( userinfo.PostalCode ) )
de.Properties[ "postalCode" ].Value = userinfo.PostalCode;
if ( !string.IsNullOrWhiteSpace( userinfo.PostOfficeBox ) )
de.Properties[ "postOfficeBox" ].Value = userinfo.PostOfficeBox;
if ( !string.IsNullOrWhiteSpace( userinfo.Address ) )
de.Properties[ "streetAddress" ].Value = userinfo.Address;
if ( !string.IsNullOrWhiteSpace( userinfo.Department ) )
de.Properties[ "department" ].Value = userinfo.Department;
if ( !string.IsNullOrWhiteSpace( userinfo.PhysicalDeliveryOffice ) )
de.Properties[ "physicalDeliveryOfficeName" ].Value =
userinfo.PhysicalDeliveryOffice;
if ( !string.IsNullOrWhiteSpace( userinfo.Company ) )
de.Properties[ "company" ].Value = userinfo.Company;
if ( !string.IsNullOrWhiteSpace( userinfo.PhoneExtention ) )
de.Properties[ "extensionAttribute1" ].Value = userinfo.PhoneExtention;
if ( !string.IsNullOrWhiteSpace( userinfo.PhoneIpAccessCode ) )
de.Properties[ "extensionAttribute2" ].Value = userinfo.PhoneIpAccessCode;
}
catch ( Exception ex )
{
throw ex;
}
}
Web Services Definition
The WCF Web services has been exposed through REST and SOAP to provide the flexibility for developers to choose their way of consumption, as for the web.conf, the configuration is listed below:
<configuration>
<appSettings>
<add key="aspnet:UseTaskFriendlySynchronizationContext" value="true" />
<add key="LocalDomainURI" value="GC://x.x.x.x" />
<add key="LocalDomainUser" value="bind-user" />
<add key="LocalDomainPassword" value="bind-password" />
<add key="ADSearchFilter"
value="(&(objectClass=user)(objectCategory=person)(mail={0}))" />
<add key="MailHost" value="X.X.X.X />
<add key="ReplyTo" value="xxx@cexample.com" />
<add key="NotificationsEmail" value="xxx@example.com" />
<add key="AesKey" value="AES KEY"/>
<add key="AesIV" value="AES IV"/>
<add key="SessionTTL" value="2"/>
</appSettings>
<system.web>
<compilation debug="true" targetFramework="4.5" />
<httpRuntime targetFramework="4.5"/>
</system.web>
<system.serviceModel>
<services>
<service name="ADWS.Adws">
<endpoint address="rest" behaviorConfiguration="webBehaviour"
binding="webHttpBinding" name="RESTEndPoint" contract="ADWS.IAdws" />
<endpoint address="soap" binding="basicHttpBinding" name="SOAPEndPoint"
contract="ADWS.IAdws" />
<endpoint address="mex" binding="mexHttpBinding" contract="IMetadataExchange" />
</service>
</services>
<behaviors>
<serviceBehaviors>
<behavior>
<serviceMetadata httpGetEnabled="true" httpsGetEnabled="true"/>
<serviceDebug includeExceptionDetailInFaults="true"/>
</behavior>
</serviceBehaviors>
<endpointBehaviors>
<behavior name="webBehaviour">
<webHttp helpEnabled="true"/>
</behavior>
</endpointBehaviors>
</behaviors>
<protocolMapping>
<add binding="webHttpBinding" scheme="http" />
</protocolMapping>
<serviceHostingEnvironment aspNetCompatibilityEnabled="true"
multipleSiteBindingsEnabled="true" />
</system.serviceModel>
<system.webServer>
<security>
<requestFiltering allowDoubleEscaping="true"/>
</security>
<modules runAllManagedModulesForAllRequests="true"/>
<httpProtocol>
<customHeaders>
<add name="Access-Control-Allow-Origin" value="*" />
<add name="Access-Control-Allow-Headers" value="Content-Type, Accept" />
</customHeaders>
</httpProtocol>
<directoryBrowse enabled="true"/>
</system.webServer>
</configuration>
The AES KEY and IV should be provided here as for their security if somebody accessed the web.conf, you can encrypt the web.conf, kindly refer to this article for more information about the topic.
History