2020-08-20 22:18:50 +01:00
using System ;
2020-06-22 10:08:08 +02:00
using System.Collections.Generic ;
2020-08-06 19:09:05 +10:00
using System.Linq ;
2020-05-25 23:15:32 +10:00
using System.Net ;
2020-08-06 19:09:05 +10:00
using System.Net.Mail ;
2020-05-25 23:15:32 +10:00
using System.Threading.Tasks ;
2020-08-31 13:56:25 +02:00
using Microsoft.AspNetCore.Routing ;
2020-08-20 22:18:50 +01:00
using Microsoft.AspNetCore.Authentication ;
using Microsoft.AspNetCore.Mvc ;
using Microsoft.Extensions.Options ;
2020-09-21 09:52:58 +02:00
using Microsoft.Extensions.Logging ;
2020-06-09 14:36:36 +10:00
using Umbraco.Core ;
2020-05-25 23:15:32 +10:00
using Umbraco.Core.BackOffice ;
2020-08-20 22:18:50 +01:00
using Umbraco.Core.Configuration.Models ;
2020-05-25 23:15:32 +10:00
using Umbraco.Core.Mapping ;
2020-08-06 19:09:05 +10:00
using Umbraco.Core.Models ;
2020-05-25 23:15:32 +10:00
using Umbraco.Core.Models.Membership ;
2020-09-22 10:01:00 +02:00
using Umbraco.Core.Security ;
2020-05-25 23:15:32 +10:00
using Umbraco.Core.Services ;
using Umbraco.Extensions ;
2020-06-03 17:47:32 +10:00
using Umbraco.Net ;
2020-06-03 12:47:40 +10:00
using Umbraco.Web.BackOffice.Filters ;
2020-08-06 19:09:05 +10:00
using Umbraco.Web.Common.ActionsResults ;
2020-05-13 16:09:54 +10:00
using Umbraco.Web.Common.Attributes ;
2020-05-19 09:52:58 +02:00
using Umbraco.Web.Common.Controllers ;
2020-05-25 23:15:32 +10:00
using Umbraco.Web.Common.Exceptions ;
2020-05-13 16:09:54 +10:00
using Umbraco.Web.Common.Filters ;
2020-06-02 13:28:30 +10:00
using Umbraco.Web.Common.Security ;
2020-05-25 23:15:32 +10:00
using Umbraco.Web.Models ;
using Umbraco.Web.Models.ContentEditing ;
2020-05-19 09:52:58 +02:00
using Umbraco.Web.Security ;
2020-05-14 17:04:16 +10:00
using Constants = Umbraco . Core . Constants ;
2020-05-13 16:09:54 +10:00
namespace Umbraco.Web.BackOffice.Controllers
{
2020-05-14 21:12:41 +10:00
[PluginController(Constants.Web.Mvc.BackOfficeApiArea)] // TODO: Maybe this could be applied with our Application Model conventions
2020-05-13 16:09:54 +10:00
//[ValidationFilter] // TODO: I don't actually think this is required with our custom Application Model conventions applied
2020-06-08 13:14:23 +02:00
[AngularJsonOnlyConfiguration] // TODO: This could be applied with our Application Model conventions
2020-05-14 17:04:16 +10:00
[IsBackOffice] // TODO: This could be applied with our Application Model conventions
2020-05-25 23:15:32 +10:00
public class AuthenticationController : UmbracoApiControllerBase
2020-05-13 16:09:54 +10:00
{
2020-09-22 10:01:00 +02:00
private readonly IBackofficeSecurityAccessor _backofficeSecurityAccessor ;
2020-09-22 14:44:41 +02:00
private readonly IBackOfficeUserManager _userManager ;
2020-05-25 23:15:32 +10:00
private readonly BackOfficeSignInManager _signInManager ;
private readonly IUserService _userService ;
2020-08-06 19:09:05 +10:00
private readonly ILocalizedTextService _textService ;
2020-05-25 23:15:32 +10:00
private readonly UmbracoMapper _umbracoMapper ;
2020-08-21 14:52:47 +01:00
private readonly GlobalSettings _globalSettings ;
2020-08-20 22:18:50 +01:00
private readonly SecuritySettings _securitySettings ;
2020-09-21 09:52:58 +02:00
private readonly ILogger < AuthenticationController > _logger ;
2020-06-03 17:47:32 +10:00
private readonly IIpResolver _ipResolver ;
2020-08-20 22:18:50 +01:00
private readonly UserPasswordConfigurationSettings _passwordConfiguration ;
2020-08-06 19:09:05 +10:00
private readonly IEmailSender _emailSender ;
private readonly Core . Hosting . IHostingEnvironment _hostingEnvironment ;
private readonly IRequestAccessor _requestAccessor ;
2020-08-31 13:39:29 +02:00
private readonly LinkGenerator _linkGenerator ;
2020-05-19 09:52:58 +02:00
2020-05-25 23:15:32 +10:00
// TODO: We need to import the logic from Umbraco.Web.Editors.AuthenticationController
2020-05-27 18:27:49 +10:00
// TODO: We need to review all _userManager.Raise calls since many/most should be on the usermanager or signinmanager, very few should be here
2020-05-25 23:15:32 +10:00
public AuthenticationController (
2020-09-22 10:01:00 +02:00
IBackofficeSecurityAccessor backofficeSecurityAccessor ,
2020-09-22 14:44:41 +02:00
IBackOfficeUserManager backOfficeUserManager ,
2020-05-25 23:15:32 +10:00
BackOfficeSignInManager signInManager ,
IUserService userService ,
2020-08-06 19:09:05 +10:00
ILocalizedTextService textService ,
2020-05-25 23:15:32 +10:00
UmbracoMapper umbracoMapper ,
2020-08-23 23:36:48 +02:00
IOptions < GlobalSettings > globalSettings ,
IOptions < SecuritySettings > securitySettings ,
2020-09-21 09:52:58 +02:00
ILogger < AuthenticationController > logger ,
2020-06-22 10:08:08 +02:00
IIpResolver ipResolver ,
2020-08-23 23:36:48 +02:00
IOptions < UserPasswordConfigurationSettings > passwordConfiguration ,
2020-08-06 19:09:05 +10:00
IEmailSender emailSender ,
Core . Hosting . IHostingEnvironment hostingEnvironment ,
2020-08-31 13:39:29 +02:00
IRequestAccessor requestAccessor ,
LinkGenerator linkGenerator )
2020-05-19 09:52:58 +02:00
{
2020-09-22 10:01:00 +02:00
_backofficeSecurityAccessor = backofficeSecurityAccessor ;
2020-05-25 23:15:32 +10:00
_userManager = backOfficeUserManager ;
_signInManager = signInManager ;
_userService = userService ;
2020-08-06 19:09:05 +10:00
_textService = textService ;
2020-05-25 23:15:32 +10:00
_umbracoMapper = umbracoMapper ;
2020-08-21 14:52:47 +01:00
_globalSettings = globalSettings . Value ;
2020-08-20 22:18:50 +01:00
_securitySettings = securitySettings . Value ;
2020-06-03 17:47:32 +10:00
_logger = logger ;
_ipResolver = ipResolver ;
2020-08-20 22:18:50 +01:00
_passwordConfiguration = passwordConfiguration . Value ;
2020-08-06 19:09:05 +10:00
_emailSender = emailSender ;
_hostingEnvironment = hostingEnvironment ;
_requestAccessor = requestAccessor ;
2020-08-31 13:39:29 +02:00
_linkGenerator = linkGenerator ;
2020-06-22 10:08:08 +02:00
}
/// <summary>
/// Returns the configuration for the backoffice user membership provider - used to configure the change password dialog
/// </summary>
/// <returns></returns>
[UmbracoAuthorize]
public IDictionary < string , object > GetPasswordConfig ( int userId )
{
2020-09-22 10:01:00 +02:00
return _passwordConfiguration . GetConfiguration ( userId ! = _backofficeSecurityAccessor . BackofficeSecurity . CurrentUser . Id ) ;
2020-06-03 17:47:32 +10:00
}
2020-08-06 19:09:05 +10:00
/// <summary>
/// Checks if a valid token is specified for an invited user and if so logs the user in and returns the user object
/// </summary>
/// <param name="id"></param>
/// <param name="token"></param>
/// <returns></returns>
/// <remarks>
/// This will also update the security stamp for the user so it can only be used once
/// </remarks>
[ValidateAngularAntiForgeryToken]
public async Task < ActionResult < UserDisplay > > PostVerifyInvite ( [ FromQuery ] int id , [ FromQuery ] string token )
{
if ( string . IsNullOrWhiteSpace ( token ) )
return NotFound ( ) ;
var decoded = token . FromUrlBase64 ( ) ;
if ( decoded . IsNullOrWhiteSpace ( ) )
return NotFound ( ) ;
var identityUser = await _userManager . FindByIdAsync ( id . ToString ( ) ) ;
if ( identityUser = = null )
return NotFound ( ) ;
var result = await _userManager . ConfirmEmailAsync ( identityUser , decoded ) ;
if ( result . Succeeded = = false )
{
throw HttpResponseException . CreateNotificationValidationErrorResponse ( result . Errors . ToErrorMessage ( ) ) ;
}
await _signInManager . SignOutAsync ( ) ;
await _signInManager . SignInAsync ( identityUser , false ) ;
var user = _userService . GetUserById ( id ) ;
return _umbracoMapper . Map < UserDisplay > ( user ) ;
}
2020-06-03 17:47:32 +10:00
[HttpGet]
public double GetRemainingTimeoutSeconds ( )
{
var backOfficeIdentity = HttpContext . User . GetUmbracoIdentity ( ) ;
var remainingSeconds = HttpContext . User . GetRemainingAuthSeconds ( ) ;
if ( remainingSeconds < = 30 & & backOfficeIdentity ! = null )
{
//NOTE: We are using 30 seconds because that is what is coded into angular to force logout to give some headway in
// the timeout process.
2020-09-15 08:45:40 +02:00
_logger . LogInformation (
2020-06-03 17:47:32 +10:00
"User logged will be logged out due to timeout: {Username}, IP Address: {IPAddress}" ,
backOfficeIdentity . Name ,
_ipResolver . GetCurrentRequestIpAddress ( ) ) ;
}
return remainingSeconds ;
2020-05-19 09:52:58 +02:00
}
/// <summary>
/// Checks if the current user's cookie is valid and if so returns OK or a 400 (BadRequest)
/// </summary>
/// <returns></returns>
[HttpGet]
public bool IsAuthenticated ( )
{
2020-09-22 10:01:00 +02:00
var attempt = _backofficeSecurityAccessor . BackofficeSecurity . AuthorizeRequest ( ) ;
2020-05-19 09:52:58 +02:00
if ( attempt = = ValidateRequestAttempt . Success )
{
return true ;
}
return false ;
}
2020-05-25 23:15:32 +10:00
2020-06-03 12:47:40 +10:00
/// <summary>
/// Returns the currently logged in Umbraco user
/// </summary>
/// <returns></returns>
/// <remarks>
/// We have the attribute [SetAngularAntiForgeryTokens] applied because this method is called initially to determine if the user
/// is valid before the login screen is displayed. The Auth cookie can be persisted for up to a day but the csrf cookies are only session
/// cookies which means that the auth cookie could be valid but the csrf cookies are no longer there, in that case we need to re-set the csrf cookies.
/// </remarks>
[UmbracoAuthorize]
2020-06-13 10:42:07 +02:00
[SetAngularAntiForgeryTokens]
2020-06-03 12:47:40 +10:00
//[CheckIfUserTicketDataIsStale] // TODO: Migrate this, though it will need to be done differently at the cookie auth level
public UserDetail GetCurrentUser ( )
{
2020-09-22 10:01:00 +02:00
var user = _backofficeSecurityAccessor . BackofficeSecurity . CurrentUser ;
2020-06-03 12:47:40 +10:00
var result = _umbracoMapper . Map < UserDetail > ( user ) ;
//set their remaining seconds
result . SecondsUntilTimeout = HttpContext . User . GetRemainingAuthSeconds ( ) ;
return result ;
}
2020-08-06 19:09:05 +10:00
/// <summary>
/// When a user is invited they are not approved but we need to resolve the partially logged on (non approved)
/// user.
/// </summary>
/// <returns></returns>
/// <remarks>
/// We cannot user GetCurrentUser since that requires they are approved, this is the same as GetCurrentUser but doesn't require them to be approved
/// </remarks>
[UmbracoAuthorize(redirectToUmbracoLogin: false, requireApproval: false)]
[SetAngularAntiForgeryTokens]
public ActionResult < UserDetail > GetCurrentInvitedUser ( )
{
2020-09-22 10:01:00 +02:00
var user = _backofficeSecurityAccessor . BackofficeSecurity . CurrentUser ;
2020-08-06 19:09:05 +10:00
if ( user . IsApproved )
{
// if they are approved, than they are no longer invited and we can return an error
return Forbid ( ) ;
}
var result = _umbracoMapper . Map < UserDetail > ( user ) ;
// set their remaining seconds
result . SecondsUntilTimeout = HttpContext . User . GetRemainingAuthSeconds ( ) ;
return result ;
}
2020-05-25 23:15:32 +10:00
/// <summary>
/// Logs a user in
/// </summary>
/// <returns></returns>
2020-06-13 10:42:07 +02:00
[SetAngularAntiForgeryTokens]
2020-05-25 23:15:32 +10:00
public async Task < UserDetail > PostLogin ( LoginModel loginModel )
{
// Sign the user in with username/password, this also gives a chance for developers to
// custom verify the credentials and auto-link user accounts with a custom IBackOfficePasswordChecker
var result = await _signInManager . PasswordSignInAsync (
loginModel . Username , loginModel . Password , isPersistent : true , lockoutOnFailure : true ) ;
if ( result . Succeeded )
{
2020-05-27 18:27:49 +10:00
// return the user detail
return GetUserDetail ( _userService . GetByUsername ( loginModel . Username ) ) ;
2020-05-25 23:15:32 +10:00
}
if ( result . RequiresTwoFactor )
{
throw new NotImplementedException ( "Implement MFA/2FA, we need to have some IOptions or similar to configure this" ) ;
//var twofactorOptions = UserManager as IUmbracoBackOfficeTwoFactorOptions;
//if (twofactorOptions == null)
//{
// throw new HttpResponseException(
// Request.CreateErrorResponse(
// HttpStatusCode.BadRequest,
// "UserManager does not implement " + typeof(IUmbracoBackOfficeTwoFactorOptions)));
//}
//var twofactorView = twofactorOptions.GetTwoFactorView(
// owinContext,
// UmbracoContext,
// loginModel.Username);
//if (twofactorView.IsNullOrWhiteSpace())
//{
// throw new HttpResponseException(
// Request.CreateErrorResponse(
// HttpStatusCode.BadRequest,
// typeof(IUmbracoBackOfficeTwoFactorOptions) + ".GetTwoFactorView returned an empty string"));
//}
//var attemptedUser = Services.UserService.GetByUsername(loginModel.Username);
//// create a with information to display a custom two factor send code view
//var verifyResponse = Request.CreateResponse(HttpStatusCode.PaymentRequired, new
//{
// twoFactorView = twofactorView,
// userId = attemptedUser.Id
//});
//_userManager.RaiseLoginRequiresVerificationEvent(User, attemptedUser.Id);
//return verifyResponse;
}
// return BadRequest (400), we don't want to return a 401 because that get's intercepted
// by our angular helper because it thinks that we need to re-perform the request once we are
// authorized and we don't want to return a 403 because angular will show a warning message indicating
// that the user doesn't have access to perform this function, we just want to return a normal invalid message.
throw new HttpResponseException ( HttpStatusCode . BadRequest ) ;
}
2020-08-06 19:09:05 +10:00
/// <summary>
/// Processes a password reset request. Looks for a match on the provided email address
/// and if found sends an email with a link to reset it
/// </summary>
/// <returns></returns>
[SetAngularAntiForgeryTokens]
public async Task < IActionResult > PostRequestPasswordReset ( RequestPasswordResetModel model )
{
// If this feature is switched off in configuration the UI will be amended to not make the request to reset password available.
// So this is just a server-side secondary check.
if ( _securitySettings . AllowPasswordReset = = false )
return BadRequest ( ) ;
var identityUser = await _userManager . FindByEmailAsync ( model . Email ) ;
if ( identityUser ! = null )
{
var user = _userService . GetByEmail ( model . Email ) ;
if ( user ! = null )
{
var code = await _userManager . GeneratePasswordResetTokenAsync ( identityUser ) ;
var callbackUrl = ConstructCallbackUrl ( identityUser . Id , code ) ;
var message = _textService . Localize ( "resetPasswordEmailCopyFormat" ,
// Ensure the culture of the found user is used for the email!
UmbracoUserExtensions . GetUserCulture ( identityUser . Culture , _textService , _globalSettings ) ,
new [ ] { identityUser . UserName , callbackUrl } ) ;
var subject = _textService . Localize ( "login/resetPasswordEmailCopySubject" ,
// Ensure the culture of the found user is used for the email!
UmbracoUserExtensions . GetUserCulture ( identityUser . Culture , _textService , _globalSettings ) ) ;
var mailMessage = new MailMessage ( )
{
Subject = subject ,
Body = message ,
IsBodyHtml = true ,
To = { user . Email }
} ;
await _emailSender . SendAsync ( mailMessage ) ;
_userManager . RaiseForgotPasswordRequestedEvent ( User , user . Id ) ;
}
}
return Ok ( ) ;
}
/// <summary>
/// Processes a set password request. Validates the request and sets a new password.
/// </summary>
/// <returns></returns>
[SetAngularAntiForgeryTokens]
public async Task < IActionResult > PostSetPassword ( SetPasswordModel model )
{
var identityUser = await _userManager . FindByIdAsync ( model . UserId . ToString ( ) ) ;
var result = await _userManager . ResetPasswordAsync ( identityUser , model . ResetCode , model . Password ) ;
if ( result . Succeeded )
{
var lockedOut = await _userManager . IsLockedOutAsync ( identityUser ) ;
if ( lockedOut )
{
2020-09-15 08:45:40 +02:00
_logger . LogInformation ( "User {UserId} is currently locked out, unlocking and resetting AccessFailedCount" , model . UserId ) ;
2020-08-06 19:09:05 +10:00
//// var user = await UserManager.FindByIdAsync(model.UserId);
var unlockResult = await _userManager . SetLockoutEndDateAsync ( identityUser , DateTimeOffset . Now ) ;
if ( unlockResult . Succeeded = = false )
{
2020-09-16 09:58:07 +02:00
_logger . LogWarning ( "Could not unlock for user {UserId} - error {UnlockError}" , model . UserId , unlockResult . Errors . First ( ) . Description ) ;
2020-08-06 19:09:05 +10:00
}
var resetAccessFailedCountResult = await _userManager . ResetAccessFailedCountAsync ( identityUser ) ;
if ( resetAccessFailedCountResult . Succeeded = = false )
{
2020-09-16 09:58:07 +02:00
_logger . LogWarning ( "Could not reset access failed count {UserId} - error {UnlockError}" , model . UserId , unlockResult . Errors . First ( ) . Description ) ;
2020-08-06 19:09:05 +10:00
}
}
// They've successfully set their password, we can now update their user account to be confirmed
// if user was only invited, then they have not been approved
// but a successful forgot password flow (e.g. if their token had expired and they did a forgot password instead of request new invite)
// means we have verified their email
if ( ! await _userManager . IsEmailConfirmedAsync ( identityUser ) )
{
await _userManager . ConfirmEmailAsync ( identityUser , model . ResetCode ) ;
}
// invited is not approved, never logged in, invited date present
/ *
if ( LastLoginDate = = default & & IsApproved = = false & & InvitedDate ! = null )
return UserState . Invited ;
* /
if ( identityUser ! = null & & ! identityUser . IsApproved )
{
var user = _userService . GetByUsername ( identityUser . UserName ) ;
// also check InvitedDate and never logged in, otherwise this would allow a disabled user to reactivate their account with a forgot password
if ( user . LastLoginDate = = default & & user . InvitedDate ! = null )
{
user . IsApproved = true ;
user . InvitedDate = null ;
_userService . Save ( user ) ;
}
}
_userManager . RaiseForgotPasswordChangedSuccessEvent ( User , model . UserId ) ;
return Ok ( ) ;
}
return new ValidationErrorResult ( result . Errors . Any ( ) ? result . Errors . First ( ) . Description : "Set password failed" ) ;
}
2020-06-09 14:36:36 +10:00
/// <summary>
/// Logs the current user out
/// </summary>
/// <returns></returns>
2020-06-13 10:42:07 +02:00
[ValidateAngularAntiForgeryToken]
2020-06-09 14:36:36 +10:00
public IActionResult PostLogout ( )
{
HttpContext . SignOutAsync ( Core . Constants . Security . BackOfficeAuthenticationType ) ;
2020-09-15 08:45:40 +02:00
_logger . LogInformation ( "User {UserName} from IP address {RemoteIpAddress} has logged out" , User . Identity = = null ? "UNKNOWN" : User . Identity . Name , HttpContext . Connection . RemoteIpAddress ) ;
2020-06-09 14:36:36 +10:00
_userManager . RaiseLogoutSuccessEvent ( User , int . Parse ( User . Identity . GetUserId ( ) ) ) ;
return Ok ( ) ;
}
2020-08-20 11:54:35 +02:00
2020-05-25 23:15:32 +10:00
/// <summary>
2020-05-27 18:27:49 +10:00
/// Return the <see cref="UserDetail"/> for the given <see cref="IUser"/>
2020-05-25 23:15:32 +10:00
/// </summary>
/// <param name="user"></param>
/// <param name="principal"></param>
/// <returns></returns>
2020-05-27 18:27:49 +10:00
private UserDetail GetUserDetail ( IUser user )
2020-05-25 23:15:32 +10:00
{
2020-05-27 18:27:49 +10:00
if ( user = = null ) throw new ArgumentNullException ( nameof ( user ) ) ;
2020-05-25 23:15:32 +10:00
var userDetail = _umbracoMapper . Map < UserDetail > ( user ) ;
// update the userDetail and set their remaining seconds
userDetail . SecondsUntilTimeout = TimeSpan . FromMinutes ( _globalSettings . TimeOutInMinutes ) . TotalSeconds ;
return userDetail ;
}
2020-08-06 19:09:05 +10:00
private string ConstructCallbackUrl ( int userId , string code )
{
// Get an mvc helper to get the url
2020-08-31 13:39:29 +02:00
var action = _linkGenerator . GetPathByAction ( nameof ( BackOfficeController . ValidatePasswordResetCode ) , ControllerExtensions . GetControllerName < BackOfficeController > ( ) ,
2020-08-06 19:09:05 +10:00
new
{
2020-08-31 13:39:29 +02:00
area = Constants . Web . Mvc . BackOfficeArea ,
2020-08-06 19:09:05 +10:00
u = userId ,
r = code
} ) ;
// Construct full URL using configured application URL (which will fall back to request)
var applicationUri = _requestAccessor . GetApplicationUrl ( ) ;
var callbackUri = new Uri ( applicationUri , action ) ;
return callbackUri . ToString ( ) ;
}
2020-05-13 16:09:54 +10:00
}
}