Ordinarily I'm fairly confident in my own code, but seeing as how this pertains to security and I don't want to have overlooked anything that might cause security problems, I figured I should have this checked.
A little background here is that I needed some way to authenticate users of my API against an Active Directory. I wanted to use a system that was compatible with Basic Authentication as far as protocol, but allows token based authentication with a username of "token" and a password that is the token.
If they log in with a token, I won't generate a token that they can use, but if they log in with a username I will.
My base code is from http://piotrwalat.net/basic-http-authentication-in-asp-net-web-api-using-message-handlers/ but it's mostly just the idea and small bits of code that have made it through my heavy modifications.
I mostly just want to make sure this is safe for handling passwords and authentication when done over https.
Feel free to use portions of this in your programs if you like it.
BasicAuthMessageHandler.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Principal;
using System.Threading;
using System.DirectoryServices.AccountManagement;
namespace MyApp.Security
{
public class BasicAuthMessageHandler : DelegatingHandler
{
protected override System.Threading.Tasks.Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
AuthenticationHeaderValue authValue = request.Headers.Authorization;
if (authValue != null && !String.IsNullOrWhiteSpace(authValue.Parameter))
{
string username = null;
string password = null;
string toSplit = Encoding.UTF8.GetString(Convert.FromBase64String(authValue.Parameter));
int index;
if ((index = toSplit.IndexOf(":")) != -1)//verifies that they included at least the : between the username and password
{
username = toSplit.Substring(0, index);
password = toSplit.Substring(index + 1);
}
//makes sure that there IS some semblance of a username and password. Blanks aren't allowed.
if ( !string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(password))
{
IPrincipal currentPrincipal = CreatePrincipal(username, password);//Validates the credentials and creates a principal containing their domain groups
Thread.CurrentPrincipal = currentPrincipal;//sets the principal for the thread
HttpContext.Current.User = currentPrincipal;//sets the principal for the HttpContext
}
}
return base.SendAsync(request, cancellationToken);
}
private IPrincipal CreatePrincipal(String username, String password)
{
PrincipalContext ctx = new PrincipalContext(ContextType.Domain);//Sets up a context for the domain the app is running in
if (username.ToLower() == "token")//special case where the username is "token" means we're using token authentication instead of normal basic authentication
{
username = TokenCache.localTokenCache.validateToken(password);//checks for presence of the token in the cache and grabs the username associated with it if any.
if (username == null)
{
return null;//No token? No login!
}
else
{
UserPrincipal user = UserPrincipal.FindByIdentity(ctx, username);//Grab the necessary information about the user from the domain.
if (user.Enabled == true)//make sure their account isn't disabled.
{
//SIDs can be used to check NTFS file access permissions before doing an operation on behalf of the user.
List<string> groups = new List<string>(user.GetAuthorizationGroups().Select(i => i.Sid.Value));//grab the SIDs from any groups they're in
groups.Add(user.Sid.Value);//also add their own SID
groups.Add("Auth:Token");//Lets me check later from APIs if they authenticated with a token or username and password.
//Note that here I'm using the "Authentication Type" part of generic identity to store the token if I make one.
//This is a convienient place to put a string that can be used by API methods later in the pipeline.
return new GenericPrincipal(new GenericIdentity(user.SamAccountName, ""), groups.ToArray());//Creates and returns the Principal
}
else
{
return null;//disabled account? No login!
}
}
}
if (ctx.ValidateCredentials(username, password))//if they give an actual username and password, we authenticate against the domain!
{
UserPrincipal user = UserPrincipal.FindByIdentity(ctx, username);//Grab the necessary information about the user from the domain.
string token = TokenCache.localTokenCache.generateNewToken(user.SamAccountName);//generate a new token for the user
if (user.Enabled == true)//make sure their account isn't disabled.
{
//SIDs can be used to check NTFS file access permissions before doing an operation on behalf of the user.
List<string> groups = new List<string>(user.GetAuthorizationGroups().Select(i => i.Sid.Value));//grab the SIDs from any groups they're in
groups.Add(user.Sid.Value);//also add their own SID
groups.Add("Auth:Basic");//Lets me check later from APIs if they authenticated with a token or username and password.
//Note that here I'm using the "Authentication Type" part of generic identity to store the token if I make one.
//This is a convienient place to put a string that can be used by API methods later in the pipeline.
return new GenericPrincipal(new GenericIdentity(user.SamAccountName, token), groups.ToArray());//Creates and returns the Principal
}
else
{
return null;//disabled account? No login!
}
}
return null;//invalid username and password? No login!
}
}
}
TokenCache.cs
using System;
using System.Security.Cryptography;
using System.Collections.Generic;
namespace MyApp.Security
{
class TokenCache : IDisposable
{
public static readonly TokenCache localTokenCache = new TokenCache(new TimeSpan(20,0,0));//Singleton where tokens expire after 20 hours
private Dictionary<string, string> userToToken = new Dictionary<string, string>();
private Dictionary<string, string> tokenToUser = new Dictionary<string, string>();
private Dictionary<string, DateTime> tokenToDate = new Dictionary<string, DateTime>();
private RNGCryptoServiceProvider numgen;
private TimeSpan _maxValidity;
private TokenCache(TimeSpan maxValidity)
{
_maxValidity = maxValidity;
numgen = new RNGCryptoServiceProvider();
}
public string validateToken(string token)
{
if (tokenToUser.ContainsKey(token) && DateTime.Now - tokenToDate[token] < _maxValidity)//do we have the token, and is it unexpired?
{
return tokenToUser[token];//Yes? Return username.
}else
{
return null;//No? Then return null;
}
}
public string generateNewToken(string username)
{
byte[] tokenarr = new byte[33];
numgen.GetBytes(tokenarr);
string token = SimpleMethods.Base642URL(Convert.ToBase64String(tokenarr));//Makes a URL friendly token
DateTime createdon = DateTime.Now;
removePreviousTokens(username, service);
tokenToUser.Add(token, username);
userToToken.Add(username, token);
tokenToDate.Add(token, createdon);
return token;
}
private void removePreviousTokens(string username)
{
if(userToToken.ContainsKey(username))
{
string token = userToToken[username];
tokenToUser.Remove(token);
userToToken.Remove(username);
tokenToDate.Remove(token);
}
}
#region IDisposable Support
private bool disposedValue = false; // To detect redundant calls
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
numgen.Dispose();
}
tokenToDate = null;
tokenToUser = null;
userToToken = null;
disposedValue = true;
}
}
public void Dispose()
{
Dispose(true);
}
#endregion
}
}
SimpleMethods.Base642URL
public static string Base642URL(string base64String)
{
base64String = base64String.Replace("+", "-");
base64String = base64String.Replace("/", "_");
base64String = base64String.Replace("=", "");
return base64String;
}