Working on user timeouts - now have the user timeout time being nicely tracked in the back office with a bit of injector magic both on the client side and the server side with filters. Now to wire up the call to get remaining seconds if a request hasn't been made for a specified amount of time, then we can add UI notification about timeout period.

This commit is contained in:
Shannon
2013-10-15 18:46:44 +11:00
parent c42170cf6b
commit 8d9f741a6a
19 changed files with 288 additions and 85 deletions

View File

@@ -38,19 +38,17 @@ namespace Umbraco.Core.Security
/// Renews the Umbraco authentication ticket
/// </summary>
/// <param name="http"></param>
/// <param name="timeoutInMinutes"></param>
/// <returns></returns>
public static bool RenewUmbracoAuthTicket(this HttpContextBase http, int timeoutInMinutes = 60)
public static bool RenewUmbracoAuthTicket(this HttpContextBase http)
{
return RenewAuthTicket(http,
UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName,
UmbracoConfig.For.UmbracoSettings().Security.AuthCookieDomain,
timeoutInMinutes);
UmbracoConfig.For.UmbracoSettings().Security.AuthCookieDomain);
}
internal static bool RenewUmbracoAuthTicket(this HttpContext http, int timeoutInMinutes = 60)
internal static bool RenewUmbracoAuthTicket(this HttpContext http)
{
return new HttpContextWrapper(http).RenewUmbracoAuthTicket(timeoutInMinutes);
return new HttpContextWrapper(http).RenewUmbracoAuthTicket();
}
/// <summary>
@@ -65,6 +63,7 @@ namespace Umbraco.Core.Security
http,
userdata.Username,
userDataString,
//use the configuration timeout - this is the same timeout that will be used when renewing the ticket.
GlobalSettings.TimeOutInMinutes,
//Umbraco has always persisted it's original cookie for 1 day so we'll keep it that way
1440,
@@ -78,6 +77,23 @@ namespace Umbraco.Core.Security
new HttpContextWrapper(http).CreateUmbracoAuthTicket(userdata);
}
/// <summary>
/// returns the number of seconds the user has until their auth session times out
/// </summary>
/// <param name="http"></param>
/// <returns></returns>
public static double GetRemainingAuthSeconds(this HttpContextBase http)
{
var ticket = http.GetUmbracoAuthTicket();
if (ticket == null)
{
return 0;
}
var utcExpired = ticket.Expiration.ToUniversalTime();
var secondsRemaining = utcExpired.Subtract(DateTime.UtcNow).TotalSeconds;
return secondsRemaining;
}
/// <summary>
/// Gets the umbraco auth ticket
/// </summary>
@@ -143,9 +159,8 @@ namespace Umbraco.Core.Security
/// <param name="http"></param>
/// <param name="cookieName"></param>
/// <param name="cookieDomain"></param>
/// <param name="minutesPersisted"></param>
/// <returns></returns>
private static bool RenewAuthTicket(this HttpContextBase http, string cookieName, string cookieDomain, int minutesPersisted)
/// <returns>true if there was a ticket to renew otherwise false if there was no ticket</returns>
private static bool RenewAuthTicket(this HttpContextBase http, string cookieName, string cookieDomain)
{
//get the ticket
var ticket = GetAuthTicket(http, cookieName);

View File

@@ -1,6 +1,9 @@
using System;
using System.IO;
using System.Linq;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;
using Umbraco.Core.Configuration;
using Umbraco.Core.IO;

View File

@@ -55,6 +55,17 @@ function authResource($q, $http, umbRequestHelper, angularHelper) {
"IsAuthenticated")),
'Server call failed for checking authentication');
},
/** Gets the user's remaining seconds before their login times out */
getRemainingTimeoutSeconds: function () {
return umbRequestHelper.resourcePromise(
$http.get(
umbRequestHelper.getApiUrl(
"authenticationApiBaseUrl",
"GetRemainingTimeoutSeconds")),
'Server call failed for checking remaining seconds');
}
};
}

View File

@@ -1,45 +1,63 @@
angular.module('umbraco.security.interceptor', ['umbraco.security.retryQueue'])
// This http interceptor listens for authentication successes and failures
.factory('securityInterceptor', ['$injector', 'securityRetryQueue', 'notificationsService', function ($injector, queue, notifications) {
return function(promise) {
// This http interceptor listens for authentication failures
.factory('securityInterceptor', ['$injector', 'securityRetryQueue', 'notificationsService', function ($injector, queue, notifications) {
return function (promise) {
// Intercept failed requests
return promise.then(null, function (originalResponse) {
//A 401 means that the user is not logged in
if (originalResponse.status === 401) {
return promise.then(
function(originalResponse) {
// Intercept successful requests
//Here we'll check if our custom header is in the response which indicates how many seconds the user's session has before it
//expires. Then we'll update the user in the user service accordingly.
var headers = originalResponse.headers();
if (headers["x-umb-user-seconds"]) {
var asNumber = parseFloat(headers["x-umb-user-seconds"]);
if (!isNaN(asNumber)) {
// We must use $injector to get the $http service to prevent circular dependency
var userService = $injector.get('userService');
userService.setUserTimeout(asNumber);
}
}
return promise;
}, function(originalResponse) {
// Intercept failed requests
// The request bounced because it was not authorized - add a new request to the retry queue
promise = queue.pushRetryFn('unauthorized-server', function retryRequest() {
// We must use $injector to get the $http service to prevent circular dependency
return $injector.get('$http')(originalResponse.config);
//A 401 means that the user is not logged in
if (originalResponse.status === 401) {
// The request bounced because it was not authorized - add a new request to the retry queue
promise = queue.pushRetryFn('unauthorized-server', function retryRequest() {
// We must use $injector to get the $http service to prevent circular dependency
return $injector.get('$http')(originalResponse.config);
});
}
else if (originalResponse.status === 403) {
//if the status was a 403 it means the user didn't have permission to do what the request was trying to do.
//How do we deal with this now, need to tell the user somehow that they don't have permission to do the thing that was
//requested. We can either deal with this globally here, or we can deal with it globally for individual requests on the umbRequestHelper,
// or completely custom for services calling resources.
//http://issues.umbraco.org/issue/U4-2749
//It was decided to just put these messages into the normal status messages.
var msg = "Unauthorized access to URL: <br/><i>" + originalResponse.config.url.split('?')[0] + "</i>";
if (originalResponse.config.data) {
msg += "<br/> with data: <br/><i>" + angular.toJson(originalResponse.config.data) + "</i><br/>Contact your administrator for information.";
}
notifications.error(
"Authorization error",
msg);
}
return promise;
});
}
else if (originalResponse.status === 403) {
//if the status was a 403 it means the user didn't have permission to do what the request was trying to do.
//How do we deal with this now, need to tell the user somehow that they don't have permission to do the thing that was
//requested. We can either deal with this globally here, or we can deal with it globally for individual requests on the umbRequestHelper,
// or completely custom for services calling resources.
//http://issues.umbraco.org/issue/U4-2749
//It was decided to just put these messages into the normal status messages.
};
}])
var msg = "Unauthorized access to URL: <br/><i>" + originalResponse.config.url.split('?')[0] + "</i>";
if (originalResponse.config.data) {
msg += "<br/> with data: <br/><i>" + angular.toJson(originalResponse.config.data) + "</i><br/>Contact your administrator for information.";
}
notifications.error(
"Authorization error",
msg);
}
return promise;
});
};
}])
// We have to add the interceptor to the queue as a string because the interceptor depends upon service instances that are not available in the config block.
.config(['$httpProvider', function ($httpProvider) {
$httpProvider.responseInterceptors.push('securityInterceptor');
}]);
// We have to add the interceptor to the queue as a string because the interceptor depends upon service instances that are not available in the config block.
.config(['$httpProvider', function ($httpProvider) {
$httpProvider.responseInterceptors.push('securityInterceptor');
}]);

View File

@@ -1,5 +1,5 @@
angular.module('umbraco.services')
.factory('userService', function ($rootScope, $q, $location, $log, securityRetryQueue, authResource, dialogService) {
.factory('userService', function ($rootScope, $q, $location, $log, securityRetryQueue, authResource, dialogService, $timeout) {
var currentUser = null;
var lastUserId = null;
@@ -34,6 +34,31 @@ angular.module('umbraco.services')
}
}
/**
This methods will set the current user when it is resolved and
will then start the counter to count in-memory how many seconds they have
remaining on the auth session
*/
function setCurrentUser(usr) {
if (!usr.remainingAuthSeconds) {
throw "The user object is invalid, the remainingAuthSeconds is required.";
}
currentUser = usr;
//start the timer
setCurrentUserTimeout(usr);
}
function setCurrentUserTimeout() {
$timeout(function () {
if (currentUser) {
currentUser.remainingAuthSeconds -= 1;
if (currentUser.remainingAuthSeconds > 0) {
//recurse!
setCurrentUserTimeout();
}
}
}, 1000);//every second
}
// Register a handler for when an item is added to the retry queue
securityRetryQueue.onItemAddedCallbacks.push(function (retryItem) {
if (securityRetryQueue.hasMore()) {
@@ -76,7 +101,7 @@ angular.module('umbraco.services')
.then(function (data) {
//when it's successful, return the user data
currentUser = data;
setCurrentUser(data);
var result = { user: data, authenticated: true, lastUserId: lastUserId };
@@ -119,7 +144,7 @@ angular.module('umbraco.services')
$rootScope.$broadcast("authenticated", result);
}
currentUser = data;
setCurrentUser(data);
currentUser.avatar = 'http://www.gravatar.com/avatar/' + data.emailHash + '?s=40&d=404';
deferred.resolve(currentUser);
});
@@ -130,6 +155,12 @@ angular.module('umbraco.services')
}
return deferred.promise;
},
setUserTimeout: function(newTimeout) {
if (currentUser && angular.isNumber(newTimeout)) {
currentUser.remainingAuthSeconds = newTimeout;
}
}
};

View File

@@ -69,7 +69,9 @@ h1.headline{height: 20px; padding: 30px 0 0 20px;}
width: 100%;
margin: -2px 0 0 0;
}
.umb-panel-header p {
margin:0px 20px;
}
.umb-headline-editor-wrapper input {
background: none;
border: none;

View File

@@ -8,6 +8,10 @@
<h1 class="headline">{{user.name}}</h1>
<p class="muted">
<small>Session expires in {{user.remainingAuthSeconds | number:0}} seconds</small>
</p>
</div>
<div class="umb-panel-body umb-scrollable">

View File

@@ -1,12 +1,15 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Web;
using System.Web.Http;
using AutoMapper;
using Umbraco.Core;
using Umbraco.Web.Models.ContentEditing;
using Umbraco.Web.Models.Mapping;
using Umbraco.Web.Mvc;
using Umbraco.Core.Security;
using Umbraco.Web.Security;
using Umbraco.Web.WebApi;
using Umbraco.Web.WebApi.Filters;
@@ -31,6 +34,25 @@ namespace Umbraco.Web.Editors
base.Initialize(controllerContext);
controllerContext.Configuration.Formatters.Remove(controllerContext.Configuration.Formatters.XmlFormatter);
}
/// <summary>
/// This is a special method that will return the current users' remaining session seconds, the reason
/// it is special is because this route is ignored in the UmbracoModule so that the auth ticket doesn't get
/// updated with a new timeout.
/// </summary>
/// <returns></returns>
[WebApi.UmbracoAuthorize]
public double GetRemainingTimeoutSeconds()
{
var httpContextAttempt = TryGetHttpContext();
if (httpContextAttempt.Success)
{
return httpContextAttempt.Result.GetRemainingAuthSeconds();
}
//we need an http context
throw new NotSupportedException("An HttpContext is required for this request");
}
/// <summary>
/// Checks if the current user's cookie is valid and if so returns OK or a 400 (BadRequest)
@@ -53,23 +75,23 @@ namespace Umbraco.Web.Editors
/// <summary>
/// Checks if the current user's cookie is valid and if so returns the user object associated
/// Returns the currently logged in Umbraco user
/// </summary>
/// <returns></returns>
[WebApi.UmbracoAuthorize]
public UserDetail GetCurrentUser()
{
var attempt = UmbracoContext.Security.AuthorizeRequest();
if (attempt == ValidateRequestAttempt.Success)
var user = Services.UserService.GetUserById(UmbracoContext.Security.GetUserId());
var result = Mapper.Map<UserDetail>(user);
var httpContextAttempt = TryGetHttpContext();
if (httpContextAttempt.Success)
{
var user = Services.UserService.GetUserById(UmbracoContext.Security.GetUserId());
return Mapper.Map<UserDetail>(user);
//set their remaining seconds
result.SecondsUntilTimeout = httpContextAttempt.Result.GetRemainingAuthSeconds();
}
//return Unauthorized (401) because the user is not authorized right now
throw new HttpResponseException(HttpStatusCode.Unauthorized);
return result;
}
/// <summary>
/// Logs a user in
/// </summary>
@@ -83,7 +105,14 @@ namespace Umbraco.Web.Editors
var user = Services.UserService.GetUserByUserName(username);
//TODO: Clean up the int cast!
UmbracoContext.Security.PerformLogin((int)user.Id);
return Mapper.Map<UserDetail>(user);
var result = Mapper.Map<UserDetail>(user);
var httpContextAttempt = TryGetHttpContext();
if (httpContextAttempt.Success)
{
//set their remaining seconds
result.SecondsUntilTimeout = httpContextAttempt.Result.GetRemainingAuthSeconds();
}
return result;
}
//return BadRequest (400), we don't want to return a 401 because that get's intercepted

View File

@@ -19,5 +19,11 @@ namespace Umbraco.Web.Models.ContentEditing
/// </summary>
[DataMember(Name = "emailHash")]
public string EmailHash { get; set; }
/// <summary>
/// Gets/sets the number of seconds for the user's auth ticket to expire
/// </summary>
[DataMember(Name = "remainingAuthSeconds")]
public double SecondsUntilTimeout { get; set; }
}
}

View File

@@ -16,6 +16,7 @@ namespace Umbraco.Web.Models.Mapping
.ForMember(
detail => detail.EmailHash,
opt => opt.MapFrom(user => user.Email.ToLowerInvariant().Trim().ToMd5()));
config.CreateMap<IProfile, UserBasic>()
.ForMember(detail => detail.UserId, opt => opt.MapFrom(profile => GetIntId(profile.Id)));
}

View File

@@ -166,7 +166,7 @@ namespace Umbraco.Web.Security
public void RenewLoginTimeout()
{
_httpContext.RenewUmbracoAuthTicket(UmbracoTimeOutInMinutes);
_httpContext.RenewUmbracoAuthTicket();
}
/// <summary>
@@ -229,7 +229,7 @@ namespace Umbraco.Web.Security
internal void UpdateLogin()
{
_httpContext.RenewUmbracoAuthTicket(UmbracoTimeOutInMinutes);
_httpContext.RenewUmbracoAuthTicket();
}
internal long GetTimeout()

View File

@@ -839,6 +839,7 @@
<Compile Include="WebApi\UmbracoAuthorizeAttribute.cs" />
<Compile Include="WebApi\UmbracoAuthorizedApiController.cs" />
<Compile Include="WebApi\Filters\ValidationFilterAttribute.cs" />
<Compile Include="WebApi\Filters\UmbracoUserTimeoutFilterAttribute.cs" />
<Compile Include="WebApi\WebApiHelper.cs" />
<Compile Include="WebBootManager.cs" />
<Compile Include="Routing\LegacyRequestInitializer.cs" />

View File

@@ -6,6 +6,7 @@ using System.Linq;
using System.Security.Principal;
using System.Threading;
using System.Web;
using System.Web.Mvc;
using System.Web.Routing;
using Newtonsoft.Json;
using Umbraco.Core;
@@ -13,6 +14,7 @@ using Umbraco.Core.Configuration;
using Umbraco.Core.IO;
using Umbraco.Core.Logging;
using Umbraco.Core.Security;
using Umbraco.Web.Editors;
using Umbraco.Web.Routing;
using Umbraco.Web.Security;
using umbraco;
@@ -173,7 +175,9 @@ namespace Umbraco.Web
if (ShouldAuthenticateRequest(req, UmbracoContext.Current.OriginalRequestUrl))
{
var ticket = http.GetUmbracoAuthTicket();
if (ticket != null && !ticket.Expired && http.RenewUmbracoAuthTicket())
//if there was a ticket, it's not expired, its renewable - or it should not be renewed
if (ticket != null && ticket.Expired == false
&& (http.RenewUmbracoAuthTicket() || ShouldIgnoreTicketRenew(UmbracoContext.Current.OriginalRequestUrl, http)))
{
try
{
@@ -249,6 +253,35 @@ namespace Umbraco.Web
return false;
}
private static readonly ConcurrentHashSet<string> IgnoreTicketRenewUrls = new ConcurrentHashSet<string>();
/// <summary>
/// Determines if the authentication ticket should be renewed with a new timeout
/// </summary>
/// <param name="url"></param>
/// <param name="httpContext"></param>
/// <returns></returns>
/// <remarks>
/// We do not want to renew the ticket when we are checking for the user's remaining timeout.
/// </remarks>
internal static bool ShouldIgnoreTicketRenew(Uri url, HttpContextBase httpContext)
{
//initialize the ignore ticket urls - we don't need to lock this, it's concurrent and a hashset
// we don't want to have to gen the url each request so this will speed things up a teeny bit.
if (IgnoreTicketRenewUrls.Any() == false)
{
var urlHelper = new UrlHelper(new RequestContext(httpContext, new RouteData()));
var checkSessionUrl = urlHelper.GetUmbracoApiServiceBaseUrl<AuthenticationController>(controller => controller.GetRemainingTimeoutSeconds());
IgnoreTicketRenewUrls.Add(checkSessionUrl);
}
if (IgnoreTicketRenewUrls.Any(x => url.AbsolutePath.StartsWith(x)))
{
return true;
}
return false;
}
/// <summary>
/// Checks the current request and ensures that it is routable based on the structure of the request and URI
/// </summary>

View File

@@ -0,0 +1,30 @@
using System.Globalization;
using System.Web.Http.Filters;
using Umbraco.Core.Security;
namespace Umbraco.Web.WebApi.Filters
{
/// <summary>
/// This will check if the request is authenticated and if there's an auth ticket present we will
/// add a custom header to the response indicating how many seconds are remaining for the current
/// user's session. This allows us to keep track of a user's session effectively in the back office.
/// </summary>
public sealed class UmbracoUserTimeoutFilterAttribute : ActionFilterAttribute
{
public override void OnActionExecuted(HttpActionExecutedContext actionExecutedContext)
{
base.OnActionExecuted(actionExecutedContext);
var httpContextAttempt = actionExecutedContext.Request.TryGetHttpContext();
if (httpContextAttempt.Success)
{
var ticket = httpContextAttempt.Result.GetUmbracoAuthTicket();
if (ticket != null && ticket.Expired == false)
{
var remainingSeconds = httpContextAttempt.Result.GetRemainingAuthSeconds();
actionExecutedContext.Response.Headers.Add("X-Umb-User-Seconds", remainingSeconds.ToString(CultureInfo.InvariantCulture));
}
}
}
}
}

View File

@@ -5,14 +5,39 @@ using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using System.Web;
using System.Web.Http;
using System.Web.Http.ModelBinding;
using Umbraco.Core;
namespace Umbraco.Web.WebApi
{
public static class HttpRequestMessageExtensions
{
/// <summary>
/// Tries to retreive the current HttpContext if one exists.
/// </summary>
/// <returns></returns>
public static Attempt<HttpContextBase> TryGetHttpContext(this HttpRequestMessage request)
{
object context;
if (request.Properties.TryGetValue("MS_HttpContext", out context))
{
var httpContext = context as HttpContextBase;
if (httpContext != null)
{
return Attempt.Succeed(httpContext);
}
}
if (HttpContext.Current != null)
{
return Attempt<HttpContextBase>.Succeed(new HttpContextWrapper(HttpContext.Current));
}
return Attempt<HttpContextBase>.Fail();
}
/// <summary>
/// Create a 403 (Forbidden) response indicating that hte current user doesn't have access to the resource
/// requested or the action it needs to take.

View File

@@ -35,21 +35,7 @@ namespace Umbraco.Web.WebApi
/// <returns></returns>
protected Attempt<HttpContextBase> TryGetHttpContext()
{
object context;
if (Request.Properties.TryGetValue("MS_HttpContext", out context))
{
var httpContext = context as HttpContextBase;
if (httpContext != null)
{
return Attempt.Succeed(httpContext);
}
}
if (HttpContext.Current != null)
{
return Attempt<HttpContextBase>.Succeed(new HttpContextWrapper(HttpContext.Current));
}
return Attempt<HttpContextBase>.Fail();
return Request.TryGetHttpContext();
}
/// <summary>

View File

@@ -6,7 +6,7 @@ namespace Umbraco.Web.WebApi
{
/// <summary>
/// Ensures authorization is successful for a back office user
/// </summary>
/// </summary>
public sealed class UmbracoAuthorizeAttribute : AuthorizeAttribute
{
/// <summary>

View File

@@ -8,6 +8,14 @@ using umbraco.BusinessLogic;
namespace Umbraco.Web.WebApi
{
/// <summary>
/// A base controller that ensures all requests are authorized - the user is logged in.
/// </summary>
/// <remarks>
/// This controller will also append a custom header to the response if the user is logged in using forms authentication
/// which indicates the seconds remaining before their timeout expires.
/// </remarks>
[UmbracoUserTimeoutFilter]
[UmbracoAuthorize]
public abstract class UmbracoAuthorizedApiController : UmbracoApiController
{

View File

@@ -252,12 +252,12 @@ namespace umbraco.BasePages
private void UpdateLogin()
{
Context.RenewUmbracoAuthTicket(UmbracoTimeOutInMinutes);
Context.RenewUmbracoAuthTicket();
}
public static void RenewLoginTimeout()
{
HttpContext.Current.RenewUmbracoAuthTicket(UmbracoTimeOutInMinutes);
HttpContext.Current.RenewUmbracoAuthTicket();
}
/// <summary>