2015年9月11日 星期五

Token Authentication with ASP.NET OWIN Identity (3) – Email Authentication

 ASP.NET   IDENTITY   Token based authentication

背景

本篇重點在於加入Email認證, 只有通過Email認證的使用者才可以正常取得Identitytoken


相關文章


目標


l   在註冊(Register)時,寄發一封有認證URL (UserIdEmail token) Email到使用者信箱。
l   User點選Email上的連結時,Server side會認證,並告知是否認證成功。
l   調整登入(login)的原則,只有當帳號/密碼正確,且做過認證Email通過的使用者才可以正常登入並取得token


環境

l   Visual Studio 2015
l   WEB API 2.2
l   Entity Framework 6
l   SNMP Relay Server *
l   套件版本:
Microsoft.Owin.Host.SystemWeb 3.0.1
Microsoft ASP.NET Identity Owin 2.2.1
Microsoft ASP.NET Identity EntityFramework 2.2.1
Microsoft ASP.NET Web API 2.2 OWIN 5.2.3
Microsoft.Owin.Security.OAuth 3.0.1

PS.Email 發送的服務上,本篇是採用SNMP Relay來做轉送的動作,實際上可依您的開發環境做調整。

實作


新增EMAIL 發送服務類別

l   IdentityEmailService :

必須實作IIdentityMessageService 這個介面, 至於SendAsync方法則必須依照您使用的Email服務再自行實作。

public class IdentityEmailService : IIdentityMessageService
{

        /// <summary>
        /// Send Email (Async)
        /// </summary>
        /// <param name="msg"></param>
        /// <returns></returns>
        public async Task SendAsync(IdentityMessage msg)
        {
            /* Modify this fuction to meet your development environment */
        }
}


建立產生Microsoft.AspNet.Identity.UserManager<TUser>的工廠類別

我們在處理認證的Repository AuthRepository ,大量使用了 UserManager 的方法;
因為加入Email認證需要UserManager需協助產生Email Token,因此需設定其屬性。
我們另外產生一支UserManagerFactory 來建立並設定它


public static class UserManagerFactory
{
        private static IDataProtectionProvider provider =
            new Microsoft.Owin.Security.DataProtection.DpapiDataProtectionProvider("ASP.NET Identity");

        public static UserManager<EnhancedIdentityUser> Create(AuthContext context)
        {
            var userManager =
                new UserManager<EnhancedIdentityUser>(new UserStore<EnhancedIdentityUser>(context));

            //指定UserManager發送Email的方法為我們自行定義的IdentityEmailService
            userManager.EmailService = new JB.OWIN.Identity.Service.IdentityEmailService();

            userManager.UserTokenProvider = new DataProtectorTokenProvider<EnhancedIdentityUser>(provider.Create("ASP.NET Identity"))
            {
                TokenLifespan = TimeSpan.FromHours(24) //Pwd's valid time
            };

            return userManager;
        }
}

注意:宣告IDataProtectionProvider的物件必須是static變數,否則在上到IIS時,會出現run time error ,請參考這篇文章


有了工廠,當然接下來是調整Repository!


AuthRepository : 更新UserManager的建立方式,並加入SendConfirmEmail (寄送認證Email)ConfirmEmailAsync(驗證Email Token)方法

這邊只列出新增及更新的部分 ~

l   AuthRepository : Constructor

利用工廠模式建立UserManager物件。

public AuthRepository()
{
this._ctx = new AuthContext();
this._userManager = UserManagerFactory.Create(this._ctx);

//
設定可允許欄位UserName, Email包含特殊字元及空白
this._userManager.UserValidator =
                new UserValidator<EnhancedIdentityUser>(this._userManager)
                {
                    AllowOnlyAlphanumericUserNames = false
                };
}


l   AuthRepository : SendConfirmEmail

幾個重點:
n   IdentityUser.Id作為參數產生Email token

n   記得token要做Url編碼 (UrlEncode),因為驗證時會透過URL傳送。

n   callbackUrl User打開Email開啟的驗證連結,我們還沒實作這一支Web Api,所以Url可以等下再加上; 但可以肯定的,驗證Email token一定會夾帶User idEmail token!
所以在驗證Url後面加上這兩個Url參數吧~

ex. http://localhost/api/Email/Confirm?userId=XXX&token=XXXX

public async Task SendConfirmEmail(IdentityUser user)
        {
            try
            {
                //Get the email token
                string emailToken =
await this._userManager.GenerateEmailConfirmationTokenAsync(user.Id);
                emailToken = System.Web.HttpUtility.UrlEncode(emailToken);

                //Get the confirmation uri
                var callbackUrl = new Uri(
                    String.Format("{0}?userId={1}&token={2}",
                    AppConfigHelper.GetInstance.ConfirmEmailRoute,
                    user.Id, emailToken));

                await this._userManager.SendEmailAsync(user.Id, "Confirm your account", "請驗證您的Email信箱: <a href=\"" + callbackUrl + "\">=>>> Click Here</a>");
               
            }
            catch (Exception ex)
            {
                throw;
            }
           
        }


l   AuthRepository : SendConfirmEmail

這一支方法是給驗證EmailWeb Api呼叫的,先建起來放著。

public Task<IdentityResult> ConfirmEmailAsync(string userId, string emailToken)
{
    return this._userManager.ConfirmEmailAsync(userId, emailToken);
}



Web Api

最後,該把我們的Email認證服務加入到Authentication Web Api了。

首先在註冊使用者的方法上, 除了驗證註冊Model註冊 現在多了一個寄送認證Email的動作。

l   AccountController : Register

[HttpPost]
public async Task<HttpResponseMessage> Register(UserModel userModel)
{
#region Model Validation
        var isModelValidationOk = this.validateUserModel();
if (!isModelValidationOk){
             return new HttpResponseMessage(HttpStatusCode.BadRequest);
}
        #endregion

#region Start Register Identity User
        var identityUser = await this.registerUser(userModel);
if (identityUser == null) //Fail
return new HttpResponseMessage(HttpStatusCode.InternalServerError);
}
        #endregion

        #region Send a confirmation email
        await this.sendConfirmEmail(identityUser);
        #endregion

return
new HttpResponseMessage(HttpStatusCode.Created);
}

private async Task sendConfirmEmail(IdentityUser user)
{
await this._authRepo.SendConfirmEmail(user);
}


加入新的ApiController (或使用AccountController) 加上驗證Email tokenHttp Method.

l   EmailController : Confirm

[AllowAnonymous]
[HttpGet]
public async Task<HttpResponseMessage> Confirm()
{
            NameValueCollection nvc =
HttpUtility.ParseQueryString(Request.RequestUri.Query);
            var userId = nvc["userId"] ?? String.Empty;
            var token = nvc["token"] ?? String.Empty;
 
            if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(token))
            {
                return new HttpResponseMessage(HttpStatusCode.BadRequest);
            }

            IdentityResult result = null;
            try
            {
                result  = await this._authRepo.ConfirmEmailAsync(userId, token);
            }
            catch (Exception)
            {
                //Continue the following check
            }

            var response = new HttpResponseMessage(HttpStatusCode.Moved) {
Content = new StringContent("") };
            response.Content.Headers.ContentType = new
System.Net.Http.Headers.MediaTypeHeaderValue("text/html");

            if (result!=null && result.Succeeded)
            {
                response.Headers.Location = new
Uri(AppConfigHelper.GetInstance.ConfirmEmailOkPage);
            }
            else
            {
                response.Headers.Location = new
Uri(AppConfigHelper.GetInstance.ConfirmEmailNgPage);
            }

            logger.Info(response.Headers.Location.ToString());
            return response;
}

上面這一支驗證Email token的重點:

n   User Id Email token做驗證; 驗證時(呼叫AuthRepository.ConfirmEmailAsync),如果是不正確的資訊會導致Exception,所以catch & continue 因為不論驗證成功與否,我們都要將Url導到對應的網頁來顯示訊息。

n   因為要驗證後做轉址,所以將responseContent-type設定為”text/html” 並指定Headerlocation

最後! 以上的程式碼有指定了一些Url (例如驗證Email toekn驗證成功redirect 驗證失敗redirect) 請將這些Url加入到Config~~~ (我這邊是用AppConfigHelper另外處理)

當然也別忘了分別建立驗證成功(失敗)Redirect 網頁~~

For example …


 



測試


1.  Register a user with email

可以看到註冊成功後,User會收到Email


先查看一下Database 這時候尚未Confirm email,其欄位為false.



點選Email 的超連結~








PS. 這時候,我們也可以複製Email上的認證連結,然後隨意修改email token;這時候使用此網址便會認證失敗,轉入到認證失敗的Html

當認證成功後,再回到Database …



2.  測試Login

使用先前建立的登入介面,可以使用註冊且驗證Email的使用者登入了~~

!! 你有發現了嗎,即時我們不驗證Email,也是可以順利登入的唷!!
因為登入(取得Token)的過程中,我們並沒有加上判斷AspNetUsers.EmailConfirmed 的條件~~
如果不加上這個條件,也失去了Email驗證的意義了~~

所以請繼續往下面看下去唄!


彩蛋


Verify UserName/Password and Email-confirmed flag before sending a token

更新BearerAuthorizationServerProvider使其在驗證User時,一併判斷是否已做過Email認證。

l   BearerAuthorizationServerProvider : GrantResourceOwnerCredentials

public class BearerAuthorizationServerProvider : OAuthAuthorizationServerProvider
{
       //Other codes

public override async Task GrantResourceOwnerCredentials(
OAuthGrantResourceOwnerCredentialsContext context)
        {

            context.OwinContext.Response.Headers.Add("Access-Control-Allow-Origin", new[] { "*" });

            using (AuthRepository _repo = new AuthRepository())
            {
                IdentityUser user = await _repo.FindUser(context.UserName, context.Password);

                if (user == null)
                {
                    context.SetError("invalid_grant", "The user name or password is incorrect.");
                    return;
                }
                else if(!user.EmailConfirmed)
                {
                    context.SetError("invalid_grant", "Denied because the email hadn't been confirmed.");
                    return;
                }
            }

            var identity = new ClaimsIdentity(context.Options.AuthenticationType);
            identity.AddClaim(new Claim("sub", context.UserName));
            identity.AddClaim(new Claim("role", "user"));

            context.Validated(identity);

        }

}

加上黃色那一段程式碼之後,就只有UserName/Pwd正確且做過Email驗證的使用者,才可以登入 J

完成~~!


Reference












沒有留言:

張貼留言