Skip to content

Commit

Permalink
Lock user account for a period of time after failed login attempt (#3261
Browse files Browse the repository at this point in the history
)

Lockout user account after multiple failed login attempts
  • Loading branch information
skofman1 committed Oct 5, 2016
1 parent 051f0a8 commit a83cc5d
Show file tree
Hide file tree
Showing 21 changed files with 695 additions and 131 deletions.
7 changes: 5 additions & 2 deletions src/NuGetGallery.Core/Entities/User.cs
Expand Up @@ -9,8 +9,7 @@

namespace NuGetGallery
{
public class User
: IEntity
public class User : IEntity
{
public User() : this(null)
{
Expand Down Expand Up @@ -57,6 +56,10 @@ public bool Confirmed

public DateTime? CreatedUtc { get; set; }

public DateTime? LastFailedLoginUtc { get; set; }

public int FailedLoginCount { get; set; }

public string LastSavedEmailAddress
{
get
Expand Down
2 changes: 2 additions & 0 deletions src/NuGetGallery/App_Start/DefaultDependenciesModule.cs
Expand Up @@ -78,6 +78,8 @@ protected override void Load(ContainerBuilder builder)
.SingleInstance();
}

builder.RegisterType<DateTimeProvider>().AsSelf().As<IDateTimeProvider>().SingleInstance();

builder.RegisterType<HttpContextCacheService>()
.AsSelf()
.As<ICacheService>()
Expand Down
16 changes: 16 additions & 0 deletions src/NuGetGallery/Authentication/AuthenticateExternalLoginResult.cs
@@ -0,0 +1,16 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Security.Claims;
using NuGetGallery.Authentication.Providers;

namespace NuGetGallery.Authentication
{
public class AuthenticateExternalLoginResult
{
public AuthenticatedUser Authentication { get; set; }
public ClaimsIdentity ExternalIdentity { get; set; }
public Authenticator Authenticator { get; set; }
public Credential Credential { get; set; }
}
}
104 changes: 83 additions & 21 deletions src/NuGetGallery/Authentication/AuthenticationService.cs
Expand Up @@ -16,6 +16,8 @@
using NuGetGallery.Diagnostics;
using NuGetGallery.Infrastructure.Authentication;

using static NuGetGallery.Constants;

namespace NuGetGallery.Authentication
{
public class AuthenticationService
Expand All @@ -25,6 +27,7 @@ public class AuthenticationService
private readonly IAppConfiguration _config;
private readonly ICredentialBuilder _credentialBuilder;
private readonly ICredentialValidator _credentialValidator;
private readonly IDateTimeProvider _dateTimeProvider;

/// <summary>
/// This ctor is used for test only.
Expand All @@ -39,7 +42,7 @@ protected AuthenticationService()
public AuthenticationService(
IEntitiesContext entities, IAppConfiguration config, IDiagnosticsService diagnostics,
AuditingService auditing, IEnumerable<Authenticator> providers, ICredentialBuilder credentialBuilder,
ICredentialValidator credentialValidator)
ICredentialValidator credentialValidator, IDateTimeProvider dateTimeProvider)
{
if (entities == null)
{
Expand Down Expand Up @@ -76,6 +79,11 @@ protected AuthenticationService()
throw new ArgumentNullException(nameof(credentialValidator));
}

if (dateTimeProvider == null)
{
throw new ArgumentNullException(nameof(dateTimeProvider));
}

InitCredentialFormatters();

Entities = entities;
Expand All @@ -85,6 +93,7 @@ protected AuthenticationService()
Authenticators = providers.ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase);
_credentialBuilder = credentialBuilder;
_credentialValidator = credentialValidator;
_dateTimeProvider = dateTimeProvider;
}

public IEntitiesContext Entities { get; private set; }
Expand All @@ -100,7 +109,7 @@ private void InitCredentialFormatters()
};
}

public virtual async Task<AuthenticatedUser> Authenticate(string userNameOrEmail, string password)
public virtual async Task<PasswordAuthenticationResult> Authenticate(string userNameOrEmail, string password)
{
using (_trace.Activity("Authenticate:" + userNameOrEmail))
{
Expand All @@ -115,20 +124,32 @@ public virtual async Task<AuthenticatedUser> Authenticate(string userNameOrEmail
new FailedAuthenticatedOperationAuditRecord(
userNameOrEmail, AuditedAuthenticatedOperationAction.FailedLoginNoSuchUser));

return null;
return new PasswordAuthenticationResult(PasswordAuthenticationResult.AuthenticationResult.BadCredentials);
}

int remainingMinutes;

if (IsAccountLocked(user, out remainingMinutes))
{
_trace.Information($"Login failed. User account {userNameOrEmail} is locked for the next {remainingMinutes} minutes.");

return new PasswordAuthenticationResult(PasswordAuthenticationResult.AuthenticationResult.AccountLocked,
authenticatedUser: null, lockTimeRemainingMinutes: remainingMinutes);
}

// Validate the password
Credential matched;
if (!ValidatePasswordCredential(user.Credentials, password, out matched))
{
_trace.Information("Password validation failed: " + userNameOrEmail);
_trace.Information($"Password validation failed: {userNameOrEmail}");

await UpdateFailedLoginAttempt(user);

await Auditing.SaveAuditRecord(
new FailedAuthenticatedOperationAuditRecord(
userNameOrEmail, AuditedAuthenticatedOperationAction.FailedLoginInvalidPassword));

return null;
return new PasswordAuthenticationResult(PasswordAuthenticationResult.AuthenticationResult.BadCredentials);
}

var passwordCredentials = user
Expand All @@ -142,9 +163,12 @@ public virtual async Task<AuthenticatedUser> Authenticate(string userNameOrEmail
await MigrateCredentials(user, passwordCredentials, password);
}

// Reset failed login count upon successful login
await UpdateSuccessfulLoginAttempt(user);

// Return the result
_trace.Verbose("Successfully authenticated '" + user.Username + "' with '" + matched.Type + "' credential");
return new AuthenticatedUser(user, matched);
return new PasswordAuthenticationResult(PasswordAuthenticationResult.AuthenticationResult.Success, new AuthenticatedUser(user, matched));
}
}

Expand Down Expand Up @@ -184,7 +208,7 @@ public virtual async Task<AuthenticatedUser> Authenticate(Credential credential)
await Auditing.SaveAuditRecord(
new UserAuditRecord(matched.User, AuditedUserAction.ExpireCredential, matched));

matched.Expires = DateTime.UtcNow;
matched.Expires = _dateTimeProvider.UtcNow;
await Entities.SaveChangesAsync();

_trace.Verbose(
Expand All @@ -197,7 +221,7 @@ public virtual async Task<AuthenticatedUser> Authenticate(Credential credential)
}

// update last used timestamp
matched.LastUsed = DateTime.UtcNow;
matched.LastUsed = _dateTimeProvider.UtcNow;
await Entities.SaveChangesAsync();

_trace.Verbose("Successfully authenticated '" + matched.User.Username + "' with '" + matched.Type + "' credential");
Expand Down Expand Up @@ -247,7 +271,7 @@ public virtual async Task<AuthenticatedUser> Register(string username, string em
UnconfirmedEmailAddress = emailAddress,
EmailConfirmationToken = CryptographyService.GenerateToken(),
NotifyPackagePushed = true,
CreatedUtc = DateTime.UtcNow
CreatedUtc = _dateTimeProvider.UtcNow
};

// Add a credential for the password and the API Key
Expand Down Expand Up @@ -289,7 +313,7 @@ public virtual async Task ReplaceCredential(User user, Credential credential)

public virtual async Task<Credential> ResetPasswordWithToken(string username, string token, string newPassword)
{
if (String.IsNullOrEmpty(newPassword))
if (string.IsNullOrEmpty(newPassword))
{
throw new ArgumentNullException(nameof(newPassword));
}
Expand All @@ -299,7 +323,7 @@ public virtual async Task<Credential> ResetPasswordWithToken(string username, st
.Include(u => u.Credentials)
.SingleOrDefault(u => u.Username == username);

if (user != null && String.Equals(user.PasswordResetToken, token, StringComparison.Ordinal) && !user.PasswordResetTokenExpirationDate.IsInThePast())
if (user != null && string.Equals(user.PasswordResetToken, token, StringComparison.Ordinal) && !user.PasswordResetTokenExpirationDate.IsInThePast())
{
if (!user.Confirmed)
{
Expand All @@ -310,6 +334,8 @@ public virtual async Task<Credential> ResetPasswordWithToken(string username, st
await ReplaceCredentialInternal(user, cred);
user.PasswordResetToken = null;
user.PasswordResetTokenExpirationDate = null;
user.FailedLoginCount = 0;
user.LastFailedLoginUtc = null;
await Entities.SaveChangesAsync();
return cred;
}
Expand Down Expand Up @@ -360,7 +386,7 @@ public virtual async Task GeneratePasswordResetToken(User user, int expirationIn
}

user.PasswordResetToken = CryptographyService.GenerateToken();
user.PasswordResetTokenExpirationDate = DateTime.UtcNow.AddMinutes(expirationInMinutes);
user.PasswordResetTokenExpirationDate = _dateTimeProvider.UtcNow.AddMinutes(expirationInMinutes);

await Auditing.SaveAuditRecord(new UserAuditRecord(user, AuditedUserAction.RequestPasswordReset));

Expand Down Expand Up @@ -672,7 +698,51 @@ private User FindByUserNameOrEmail(string userNameOrEmail)
return user;
}

public bool ValidatePasswordCredential(IEnumerable<Credential> creds, string password, out Credential matched)
private async Task UpdateFailedLoginAttempt(User user)
{
user.FailedLoginCount += 1;
user.LastFailedLoginUtc = _dateTimeProvider.UtcNow;

await Entities.SaveChangesAsync();
}

private async Task UpdateSuccessfulLoginAttempt(User user)
{
if (user.FailedLoginCount > 0)
{
user.FailedLoginCount = 0;
user.LastFailedLoginUtc = null;

await Entities.SaveChangesAsync();
}
}

private bool IsAccountLocked(User user, out int remainingMinutes)
{
if (user.FailedLoginCount > 0)
{
var currentTime = _dateTimeProvider.UtcNow;
var unlockTime = CalculateAccountUnlockTime(user.FailedLoginCount, user.LastFailedLoginUtc.Value);

if (unlockTime > currentTime)
{
remainingMinutes = (int)Math.Ceiling((unlockTime - currentTime).TotalMinutes);
return true;
}
}

remainingMinutes = 0;
return false;
}

private DateTime CalculateAccountUnlockTime(int failedLoginCount, DateTime lastFailedLogin)
{
int lockoutPeriodInMinutes = (int)Math.Pow(AccountLockoutMultiplierInMinutes, (int) ((double)failedLoginCount/AllowedLoginAttempts) - 1);

return lastFailedLogin + TimeSpan.FromMinutes(lockoutPeriodInMinutes);
}

public virtual bool ValidatePasswordCredential(IEnumerable<Credential> creds, string password, out Credential matched)
{
matched = creds.FirstOrDefault(c => _credentialValidator.ValidatePasswordCredential(c, password));
return matched != null;
Expand Down Expand Up @@ -708,12 +778,4 @@ private async Task MigrateCredentials(User user, List<Credential> creds, string
await Entities.SaveChangesAsync();
}
}

public class AuthenticateExternalLoginResult
{
public AuthenticatedUser Authentication { get; set; }
public ClaimsIdentity ExternalIdentity { get; set; }
public Authenticator Authenticator { get; set; }
public Credential Credential { get; set; }
}
}
37 changes: 37 additions & 0 deletions src/NuGetGallery/Authentication/PasswordAuthenticationResult.cs
@@ -0,0 +1,37 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

namespace NuGetGallery.Authentication
{
public class PasswordAuthenticationResult
{
public enum AuthenticationResult
{
AccountLocked, // The account is locked
BadCredentials, // Bad user name or password provided
Success // All good
}

/// <summary>
/// The authentication status
/// </summary>
public AuthenticationResult Result { get; }

/// <summary>
/// If the account is locked, this is the period of time until unlock.
/// </summary>
public int LockTimeRemainingMinutes { get; }

/// <summary>
/// Is authentication was successful, this is the user details.
/// </summary>
public AuthenticatedUser AuthenticatedUser { get; }

public PasswordAuthenticationResult(AuthenticationResult result, AuthenticatedUser authenticatedUser = null, int lockTimeRemainingMinutes = 0)
{
Result = result;
LockTimeRemainingMinutes = lockTimeRemainingMinutes;
AuthenticatedUser = authenticatedUser;
}
}
}
8 changes: 8 additions & 0 deletions src/NuGetGallery/Constants.cs
Expand Up @@ -12,6 +12,14 @@ public static class Constants
public const int DefaultPackageListPageSize = 20;
public const string DefaultPackageListSortOrder = "package-download-count";
public const int PasswordResetTokenExpirationHours = 1;

/// <summary>
/// Parameters for calculating account lockout period after
/// wrong password entry.
/// </summary>
public const double AccountLockoutMultiplierInMinutes = 10;
public const double AllowedLoginAttempts = 10;

public const int MaxEmailSubjectLength = 255;
internal static readonly NuGetVersion MaxSupportedMinClientVersion = new NuGetVersion("3.4.0.0");
public const string PackageContentType = "binary/octet-stream";
Expand Down
28 changes: 23 additions & 5 deletions src/NuGetGallery/Controllers/AuthenticationController.cs
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Mail;
using System.Security.Claims;
Expand Down Expand Up @@ -95,16 +96,33 @@ public virtual async Task<ActionResult> SignIn(LogOnViewModel model, string retu
return LogOnView(model);
}

var user = await _authService.Authenticate(model.SignIn.UserNameOrEmail, model.SignIn.Password);
var authenticationResult = await _authService.Authenticate(model.SignIn.UserNameOrEmail, model.SignIn.Password);

if (user == null)

if (authenticationResult.Result != PasswordAuthenticationResult.AuthenticationResult.Success)
{
ModelState.AddModelError(
"SignIn",
Strings.UsernameAndPasswordNotFound);
string modelErrorMessage = string.Empty;

if (authenticationResult.Result == PasswordAuthenticationResult.AuthenticationResult.BadCredentials)
{
modelErrorMessage = Strings.UsernameAndPasswordNotFound;
}
else if (authenticationResult.Result == PasswordAuthenticationResult.AuthenticationResult.AccountLocked)
{
string timeRemaining =
authenticationResult.LockTimeRemainingMinutes == 1
? Strings.AMinute
: string.Format(CultureInfo.CurrentCulture, Strings.Minutes,
authenticationResult.LockTimeRemainingMinutes);

modelErrorMessage = string.Format(CultureInfo.CurrentCulture, Strings.UserAccountLocked, timeRemaining);
}

ModelState.AddModelError("SignIn", modelErrorMessage);
return LogOnView(model);
}

var user = authenticationResult.AuthenticatedUser;

if (linkingAccount)
{
Expand Down

0 comments on commit a83cc5d

Please sign in to comment.