對於在網路上開放的Api來說,擁有一套完善的安全機制是很重要的,但在網路上永遠不存在絕對安全的程式碼,不管是從很常見的Sql Injection,到系統層級的漏洞,我們都很難知道自己的系統是否安全,只能盡可能的做好預防措施,以確保提供服務對於使用者的可靠度。
今天我將向大家介紹,如何依照自己的需求,透過Asp.Net MVC所提供的豐富彈性來完成Api的擴充。
大家可以從Github ApiSample - Tag Day13取得程式碼開始練習
※Api的安全需求
由於我們提供的是一個商店管理系統,所以我們希望Api在安全性上可以滿足下列幾個需求,來讓我們的系統擁有一個基本的安全機制。
※如何確保資料在傳輸過程中不會被竄改
基本上我們在正式環境所提供的Api,都將會限定以Https協定來連接,它保障了我們在傳輸過程的隱密性,但就前面所提到的,在世界上各種意外狀況都有可能發生,我們還是必須為自己的系統多增加一層保險,來確保資料在傳輸過程中是安全的,而我將介紹的方法也是簡單的針對傳輸訊息透過約定好的金鑰作加密得到一組Hash(Signature),而Server端會在收到資訊後也做一次同樣的加密來判別資料在傳輸過程中是否有被竄改。
首先我們將會提供使用者三個資訊,Token、EncryptKey和SaltKey。
* Token的功用是用來當作識別碼,透過使用者傳過來的Token,我們可以辨別使用者是誰,以及它擁有的EncryptKey和SaltKey是什麼。
* EncryptKey的功用如同字面上的意思,將會當作我們對傳輸資料做Hash的金鑰
* SaltKey的功用是我們會依照約定好的方式,將它加入傳輸資料中,增加資料的變化性
除此之外,我們通常都還會要求發送請求時要同時送出一組TimeStamp,與SaltKey一起在加入傳輸資料中,作為產生Hash的演算邏輯之一。
※針對傳輸資料做Hash
在我們和使用者都擁有了Key等資料後,接下來將介紹如何驗證傳輸資料的部分,假設我們擁有以下資料,Token: 123456,EncryptKey: 1111,SaltKey: 2222
產生TimeStamp,由於Unix系統中的時間戳記是從1970年1月1日 00:00:00起算,因此此處TimeStamp也是由1970年起算。
1381601491
使用者準備傳輸資料,我們預先約定好傳輸格式如下,為了方便應用到所有Api,因此Data欄位內容為實際想送給Api的內容,在這邊將其轉換為JSON格式字串,方便進行Hash。此時Signature應還為空的
{
"token":"123456",
"timestamp":"1381601491",
"signature":"<尚未計算的Signature>",
"data":"<JSON格式傳輸資料>"
}
以之前的新增商品為例的話就會像這樣
{
"token":"123456",
"timestamp":"1381601491",
"signature":"<尚未計算的Signature>",
"data":"{'Name':'Test','Price':300,'Cost':200,'Introduction':'Test Description','StartListingAt':'2013-10-09T10:00:00','FinishListingAt':'2014-10-09T10:00:00','StartSellAt':'2013-10-09T10:00:00','FinishSellAt':'2014-10-09T10:00:00','CategoryId':1,'Gifts':'Gift1:Gift1Desc;Gift2:Gift2Desc'}"
}
這邊比較需要注意的是外層的JSON如果使用雙引號的話,裡面就必須使用單引號,反之亦然,這是因為系統無法自動判斷文字的結束區塊,必須以此來區隔。
將傳輸資料與TimeStampe和SaltKey組合,再經過SHA256進行Hash,得到Signature。
組合格式:
string.Format("ts={0};d={1};sk={2}", timestamp, content, saltKey);
本例就是:
ts=1381601491;d={'Name':'Test','Price':300,'Cost':200,'Introduction':'Test Description','StartListingAt':'2013-10-09T10:00:00','FinishListingAt':'2014-10-09T10:00:00','StartSellAt':'2013-10-09T10:00:00','FinishSellAt':'2014-10-09T10:00:00','CategoryId':1,'Gifts':'Gift1:Gift1Desc;Gift2:Gift2Desc'};sk=2222
經過SHA256計算Hash得到Signature
ead7ac46340b31c0d799518c6b0c12f9f50ddc83fec1d5a4a3df8abbd241555e
因此我們可以,得到最終的傳輸資料為
{
"token":"123456",
"timestamp":"1381601491",
"signature":"ead7ac46340b31c0d799518c6b0c12f9f50ddc83fec1d5a4a3df8abbd241555e",
"data":"{'Name':'Test','Price':300,'Cost':200,'Introduction':'Test Description','StartListingAt':'2013-10-09T10:00:00','FinishListingAt':'2014-10-09T10:00:00','StartSellAt':'2013-10-09T10:00:00','FinishSellAt':'2014-10-09T10:00:00','CategoryId':1,'Gifts':'Gift1:Gift1Desc;Gift2:Gift2Desc'}"
}
而伺服器接收到傳輸資料,會根據token取得未在傳輸過程中提供的EncryptKey和SaltKey,並且重新計算出Hash,來檢查是否一致。
在了解如何針對傳輸資料進行加密之後,接下來我們就開始實作Api的安全性機制囉!
※建立使用者和群組資料
首先我們將進行在資料庫存放使用者資料(包含金鑰)和使用者群組等資訊,提供Api驗證使用
我們的使用者和群組為多對多關聯
在Tables建立User和Group
public class User : EntityBase
{
[Key]
public int Id { get; set; }
[Required]
[StringLength(100)]
public string Name { get; set; }
[Required]
[StringLength(300)]
public string Email { get; set; }
public string Token { get; set; }
public string EncryptKey { get; set; }
public string SaltKey { get; set; }
public ICollection<Group> Groups { get; set; }
}
public class Group : EntityBase
{
[Key]
public int Id { get; set; }
public string Name { get; set; }
public ICollection<User> Users { get; set; }
}
註: 記得Update-Database
3. 建立Repository和Service,提供由Token取得User資料
public interface IUserRepository
{
UserModel GetUserByToken(string token);
}
public class UserRepository : IUserRepository
{
public ShopContext ShopContext { get; set; }
public UserRepository(ShopContext context)
{
this.ShopContext = context;
}
public UserModel GetUserByToken(string token)
{
var user = this.ShopContext.Users
.Valids()
.Where(i => i.Token == token)
.Project()
.To<UserModel>()
.First();
return user;
}
}
public interface IUserService
{
UserModel GetUserByToken(string token);
}
public class UserService : IUserService
{
public IUserRepository UserRepository { get; set; }
public UserService(IUserRepository userRepository)
{
this.UserRepository = userRepository;
}
public UserModel GetUserByToken(string token)
{
return this.UserRepository.GetUserByToken(token);
}
}
建立User的MappingProfile
public class UserMappingProfile : Profile
{
public override string ProfileName
{
get
{
return "UserMappingProfile";
}
}
protected override void Configure()
{
Mapper.CreateMap<User, UserModel>()
.ForMember(i => i.Groups,
s => s.MapFrom(i => i.Groups.Select(j => j.Name)));
}
}
if (isValid)
{
result = true;
}
return result;
}
public string GetTimeStamp()
{
DateTime gtm = new DateTime(1970, 1, 1);//宣告一個GTM時間出來
DateTime utc = DateTime.UtcNow.AddHours(8);//宣告一個目前的時間
//我們把現在時間減掉GTM時間得到的秒數就是timpStamp,因為我不要小數點後面的所以我把它轉成int
int timeStamp = Convert.ToInt32(((TimeSpan)utc.Subtract(gtm)).TotalSeconds);
return timeStamp.ToString();
}
public string GetSignature(string encryptKey, string saltKey, string timestamp, string content)
{
var encryptSource = string.Format("ts={0};d={1};sk={2}", timestamp, content, saltKey);
var encryptContent = this.EncryptHelper.Encrypt(encryptKey, content);
return encryptContent;
}
}
※實作加密計算邏輯
實作用SHA256透過金鑰計算Hash的邏輯,在Utility的Extensions專案中新增
IEncryptHelper.cs
public interface IEncryptHelper
{
string Encrypt(string key, string source);
}
SHA256EncryptHelper.cs
public class SHA256EncryptHelper : IEncryptHelper
{
public string Encrypt(string key, string source)
{
if (string.IsNullOrWhiteSpace(source))
{
return source;
}
var encoding = Encoding.UTF8;
byte[] keyByte = encoding.GetBytes(key);
byte[] sourceByte = encoding.GetBytes(source);
using (HMACSHA256 sha256 = new HMACSHA256(keyByte))
{
var encryptContentByte = sha256.ComputeHash(sourceByte);
StringBuilder encryptContent = new StringBuilder();
foreach (byte item in encryptContentByte)
{
encryptContent.Append(item.ToString("x2")); // hex format
}
return encryptContent.ToString();
}
}
}
實作取得時間戳記,驗證時間戳記是否過期的邏輯,以及取得Signature的邏輯
IChiperTextHelper.cs
public interface IChiperTextHelper
{
bool CheckTimestampInRange(string TimeStamp, int inspectionSecond);
string GetTimeStamp();
string GetSignature(string encryptKey, string saltKey, string timestamp, string content);
}
ChiperTextHelper.cs
public class ChiperTextHelper : IChiperTextHelper
{
public IEncryptHelper EncryptHelper { get; set; }
public ChiperTextHelper(IEncryptHelper encryptHelper)
{
this.EncryptHelper = encryptHelper;
}
public bool CheckTimestampInRange(string TimeStamp, int inspectionSecond)
{
bool result = false;
int apiTimeStamp;
apiTimeStamp = Convert.ToInt32(TimeStamp);
////取得現在的TimeStamp
int nowTimeStamp = Convert.ToInt32(this.GetTimeStamp());
////判斷Timestamp是否過期
var isValid = (nowTimeStamp - apiTimeStamp) <= inspectionSecond;
※擴充Asp.Net整合驗證
若要在Asp.Net MVC中限定特定使用者存取Controller或Action,最常使用的就是Authorize屬性,他可以直接設定允許存取的使用者和群組,而我們將要以此為基礎作擴充,額外的驗證上述的Token,驗證通過的話自動將使用者和所屬群組代入系統中,接下來再由Authorize屬性判斷是否允許該使用者執行Action
擴充AuthorizeAttribute,額外增加驗證Token功能
public class AuthorizeByTokenAttribute : AuthorizeAttribute
{
public IUserService UserService { get; set; }
public IChiperTextHelper ChiperTextHelper { get; set; }
public override void OnAuthorization(AuthorizationContext filterContext)
{
//// Get request data
var requestData = this.GetApiRequestEntity(filterContext);
//// Check TimeStamp
this.CheckIsTimeStampValid(requestData);
//// Check Signature
var userData = this.UserService.GetUserByToken(requestData.Token);
this.CheckIsSignatureValid(requestData,userData);
//// Assign User Identity
this.AssignUserIdentity(filterContext, userData);
//// Chekc user and group
base.OnAuthorization(filterContext);
//// Override unauthorized behavior
if (filterContext.Result is HttpUnauthorizedResult)
{
throw new ApplicationException("Unauthorized!");
}
}
private void AssignUserIdentity(AuthorizationContext filterContext, UserModel userData)
{
var identity = new GenericIdentity(userData.Name, "Basic");
var principal = new GenericPrincipal(identity, userData.Groups.ToArray());
filterContext.HttpContext.User = principal;
Thread.CurrentPrincipal = principal;
}
private void CheckIsSignatureValid(ApiRequestEntity requestData, UserModel userData)
{
var expectSignature = this.ChiperTextHelper
.GetSignature(userData.EncryptKey, userData.SaltKey, requestData.TimeStamp, requestData.Data);
if (requestData.Signature != expectSignature)
{
throw new ApplicationException("Signature not valid!");
}
}
private void CheckIsTimeStampValid(ApiRequestEntity requestData)
{
//// Check is timestamp valid
if (!this.ChiperTextHelper.CheckTimestampInRange(requestData.TimeStamp, 86400))
{
throw new ApplicationException("Timestamp not valid!");
}
}
private ApiRequestEntity GetApiRequestEntity(AuthorizationContext filterContext)
{
ApiRequestEntity entity = new ApiRequestEntity();
entity.Token = this.GetDataFromValueProvider(filterContext, "token");
entity.TimeStamp = this.GetDataFromValueProvider(filterContext, "timestamp");
entity.Signature = this.GetDataFromValueProvider(filterContext, "signature");
entity.Data = this.GetDataFromValueProvider(filterContext, "data");
return entity;
}
private string GetDataFromValueProvider(AuthorizationContext filterContext, string key)
{
if (filterContext.Controller.ValueProvider.GetValue(key) != null)
{
return filterContext.Controller.ValueProvider.GetValue(key).AttemptedValue;
}
else
{
throw new ApplicationException(string.Format("{0} can't not be null!", key));
}
}
}
為了注入Attribute所需的Service,我們必須修改AutofacConfig,讓DI Framework可以自動幫我們注入Attribute所需要的實體。
builder.RegisterFilterProvider();
由於為了驗證方便,我們不像平常是直接將JSON資料直接Post給Api,而是轉換成字串格式放在Data欄位,和Token等資訊一起Post過來,但Asp.Net MVC不會知道應該從Data取得資料,因此我們需要客製化ModelBinder,但處理資料綁定的工作。
public class ApiModelBinder : DefaultModelBinder
{
public override object BindModel(ControllerContext controllerContext, ModelBindingContext bindingContext)
{
var valueResult = bindingContext.ValueProvider.GetValue("data");
if (valueResult != null &&
!string.IsNullOrWhiteSpace(valueResult.AttemptedValue))
{
try
{
var data = valueResult.AttemptedValue;
var modelType = bindingContext.ModelType;
var model = JsonConvert.DeserializeObject(data, modelType);
ModelBindingContext newBindingContext = new ModelBindingContext()
{
ModelMetadata = ModelMetadataProviders.Current.GetMetadataForType(
() => model,
modelType
),
ModelState = bindingContext.ModelState,
ValueProvider = bindingContext.ValueProvider
};
return base.BindModel(controllerContext, newBindingContext);
}
catch
{
//// Skip json.net deserialize error
}
}
return base.BindModel(controllerContext, bindingContext);
}
}
接下來改用這個ModelBinder來處理資料綁定,在App_Start新增ModelBinderConfig.cs,替換原本的ModelBinder
public class ModelBinderConfig
{
public static void Initialize()
{
ModelBinders.Binders.DefaultBinder = new ApiModelBinder();
}
}
在Global.asax加入啟動
ModelBinderConfig.Initialize();
※測試使用Api Token驗證
首先在資料庫中建立一筆User,並屬於Administrator群組
在Product的Create Action加上屬性,限制只能Administrator存取
[HttpPost]
[AuthorizeByToken(Roles = "Administrator")]
[ValidateRequestEntity]
public ActionResult Create(InsertProductModel product)
{
this.ProductService.InsertProduct(product);
return Json(ApiStatusEnum.Success.ToString());
}
取得TimeStamp
1381601491
計算Signature
974707d23123b30528f98677c75090d1d0a57d199570f626f8ace33da1cb5500
帶入以上資料進行存取,發現執行成功!
測試群組功能,若將允許群組改為其他,重新建置網站,因為原使用者屬於Administrator,不屬於User,所以應該會失敗!
[HttpPost]
[AuthorizeByToken(Roles = "User")]
[ValidateRequestEntity]
public ActionResult Create(InsertProductModel product)
{
this.ProductService.InsertProduct(product);
return Json(ApiStatusEnum.Success.ToString());
}
可以發現執行失敗,不允許的群組有確實被檔下來
※本日小結
透過Asp.Net MVC提供的豐富擴充性,我們可以很輕鬆的實現我們的功能,並盡可能的讓程式撰寫方法與以往使用Asp.Net MVC相似(例如 AuthorizeAttribute的使用),現在我們可以加深對傳輸資料的信心,也可以在程式碼中進行基本的使用者群組判斷了!關於今天的內容歡迎大家一起討論喔^_^
10/15 Update - 修正筆誤! 應該都是使用SHA256