[ASP.NET Identity] OAuth Server 鎖定(Lockout)登入失敗次數太多的帳號

這個功能看似很簡單,但我一直無法成功鎖定帳號,追根究柢就是不了解運作方式,以下就來分享實作心得

Lockout 相關成員

欄位

帳號失敗次數鎖定,在 AspNetUsers Table 用三個欄位控制

  • LockedEnable:是否啟用鎖定
  • AccessFailedCount:失敗次數
  • LockoutEndDateUtc:鎖定到期時間

行為

  • ApplicationUserManager.SetLockoutEnabledAsync(user.Id) 方法,控制 LockedEnable 欄位= true | false
  • ApplicationUserManager.AccessFailedAsync(user.Id) 方法,控制 LockoutEndDateUtc 和 AccessFailedCount 欄位。
當調用一次 AccessFailedAsync(),AccessFailedCount 累加一,超過定義次數,AccessFailedCount 歸零,寫入 LockoutEndDateUtc 時間
  • ApplicationUserManager.SetLockoutEndDateAsync() 方法,控制結束鎖定時間
  • ApplicationUserManager.IsLockedOutAsync(user.Id) ,以 LockoutEndDateUtc 和 LockedEnable 欄位決定是否為 Lockout

另外,ApplicationSignInManager 也能處理 Lockout

  • ApplicationSignInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, shouldLockout: true),

屬性

還有三個屬性,可以定義,分別是:

  • ApplicationUserManager.DefaultAccountLockoutTimeSpan 屬性,鎖定時間
  • ApplicationUserManager.MaxFailedAccessAttemptsBeforeLockout 屬性,最多失敗次數
  • ApplicationUserManager.UserLockoutEnabledByDefault 屬性,建立帳號時是否啟用鎖定

Step1.定義 Lockout 屬性

@Startup.cs

把控制 Lockout 的屬性放在 Startup.CreateUserManager 方法集中建立

 

@AppSetting.cs

這個類別,提供讀取 Web.Config 的屬性並 Cache 起來,以免一個 Request 請求就讀一次檔案

public class AppSetting
{
	private static TimeSpan? s_defaultAccountLockoutTimeSpan;
	private static int? s_maxFailedAccessAttemptsBeforeLockout;
	private static bool? s_userLockoutEnabledByDefault;

	public static TimeSpan DefaultAccountLockoutTimeSpan
	{
		get
		{
			if (!s_defaultAccountLockoutTimeSpan.HasValue)
			{
				double result;
				if (double.TryParse(ConfigurationManager.AppSettings["DefaultAccountLockoutTimeSpan"], out result))
				{
					s_defaultAccountLockoutTimeSpan = TimeSpan.FromMinutes(result);
				}
				else
				{
					s_defaultAccountLockoutTimeSpan = TimeSpan.FromMinutes(20);
				}
			}
			return s_defaultAccountLockoutTimeSpan.Value;
		}
		set { s_defaultAccountLockoutTimeSpan = value; }
	}

	public static int MaxFailedAccessAttemptsBeforeLockout
	{
		get
		{
			if (!s_maxFailedAccessAttemptsBeforeLockout.HasValue)
			{
				int result;

				if (int.TryParse(ConfigurationManager.AppSettings["MaxFailedAccessAttemptsBeforeLockout"],
								 out result))
				{
					s_maxFailedAccessAttemptsBeforeLockout = result;
				}
				else
				{
					s_maxFailedAccessAttemptsBeforeLockout = 5;
				}
				s_maxFailedAccessAttemptsBeforeLockout = result;
			}
			return s_maxFailedAccessAttemptsBeforeLockout.Value;
		}
		set { s_maxFailedAccessAttemptsBeforeLockout = value; }
	}

	public static bool UserLockoutEnabledByDefault
	{
		get
		{
			if (!s_userLockoutEnabledByDefault.HasValue)
			{
				bool result;
				if (bool.TryParse(ConfigurationManager.AppSettings["UserLockoutEnabledByDefault"], out result))
				{
					s_userLockoutEnabledByDefault = result;
				}
				else
				{
					s_userLockoutEnabledByDefault = true;
				}

				s_userLockoutEnabledByDefault = result;
			}
			return s_userLockoutEnabledByDefault.Value;
		}
		set { s_userLockoutEnabledByDefault = value; }
	}
}

 

Step2.撰寫鎖定邏輯

@AuthorizationServerProvider.cs

在取得 Token 的 GrantResourceOwnerCredentials 方法裡面,控制帳號鎖定邏輯

public override async Task GrantResourceOwnerCredentials(OAuthGrantResourceOwnerCredentialsContext context)
{
    var userManager = context.OwinContext.GetUserManager<ApplicationUserManager>();
 
    var user = await userManager.FindByNameAsync(context.UserName);
    if (user == null)
    {
        var message = "Invalid credentials. Please try again.";
        context.SetError("invalid_grant", message);
        return;
    }

    var validCredentials = await userManager.FindAsync(context.UserName, context.Password);
    var enableLockout = await userManager.GetLockoutEnabledAsync(user.Id);

    if (await userManager.IsLockedOutAsync(user.Id))
    {
        var message = string.Format(
            "Your account has been locked out for {0} minutes due to multiple failed login attempts.",
            AppSetting.DefaultAccountLockoutTimeSpan.TotalMinutes);
        ;
        context.SetError("invalid_grant", message);
        return;
    }

    if (enableLockout & validCredentials == null)
    {
        string message;
        await userManager.AccessFailedAsync(user.Id);

        if (await userManager.IsLockedOutAsync(user.Id))
        {
            message =
                string.Format(
                    "Your account has been locked out for {0} minutes due to multiple failed login attempts.",
                    AppSetting.DefaultAccountLockoutTimeSpan.TotalMinutes);
        }
        else
        {
            var accessFailedCount = await userManager.GetAccessFailedCountAsync(user.Id);
            var attemptsLeft = AppSetting.MaxFailedAccessAttemptsBeforeLockout -
                               accessFailedCount;
            message =
                string.Format(
                    "Invalid credentials. You have {0} more attempt(s) before your account gets locked out.",
                    attemptsLeft);
        }

        context.SetError("invalid_grant", message);
        return;
    }
    if (validCredentials == null)
    {

        var message = "Invalid credentials. Please try again.";
        context.SetError("invalid_grant", message);
        return;
    }
    await userManager.ResetAccessFailedCountAsync(user.Id);
    var oAuthIdentity = await userManager.CreateIdentityAsync(user, OAuthDefaults.AuthenticationType);
    var properties = CreateProperties(user.UserName);


    var oAuthTicket = new AuthenticationTicket(oAuthIdentity, properties);
    context.Validated(oAuthTicket);
}

 

Step3.撰寫測試程式碼

可以在測試程式碼裡直接注入 Lockout 屬性

[ClassInitialize]
public static void Initialize(TestContext testContext)
{
    Database.SetInitializer(new DropCreateDatabaseIfModelChanges<ApplicationDbContext>());
    AppSetting.UserLockoutEnabledByDefault = true;
    AppSetting.DefaultAccountLockoutTimeSpan = TimeSpan.FromMinutes(5);
    AppSetting.MaxFailedAccessAttemptsBeforeLockout = 3;
}

 

在測試程式碼,我模擬登入失敗超過三次

[TestMethod]
public async Task Login_Fail_3_Lockout_3Test()
{
    await RegisterAsync();

    Password = "Pass@w0rd2~";
    var token1 = await LoginAsync();
    token1.ErrorDescription.Should()
          .Be("Invalid credentials. You have 2 more attempt(s) before your account gets locked out.");
    Password = "Pass@w0rd2~";
    var token2 = await LoginAsync();
    token2.ErrorDescription.Should()
          .Be("Invalid credentials. You have 1 more attempt(s) before your account gets locked out.");

    Password = "Pass@w0rd2~";
    var token3 = await LoginAsync();
    token3.ErrorDescription.Should()
          .Be("Your account has been locked out for 5 minutes due to multiple failed login attempts.");
}
假如你一直無法在 Production 裡面正確的使用 Lockout 請查看資料庫確認該帳號的 LockoutEnabled 有被打開

參考資源

http://www.jlum.ws/post/2014/5/27/user-lockouts-in-aspnet-identity-2-with-aspnet-mvc-5

http://tech.trailmax.info/2014/06/asp-net-identity-user-lockout/

專案位置

https://dotblogsamples.codeplex.com/SourceControl/latest#Simple.OAuthServer/

 

若有謬誤,煩請告知,新手發帖請多包涵


Microsoft MVP Award 2010~2017 C# 第四季
Microsoft MVP Award 2018~2022 .NET

Image result for microsoft+mvp+logo