Skip to content

Commit

Permalink
Gets user session management tracking via the database including dete…
Browse files Browse the repository at this point in the history
…cting stale sessions, generating and removing them along with cleaning them up. This takes into account legacy code too. The session is revalidated on a one minute threshold per user so that it's not hammering the databse on every request.
  • Loading branch information
Shazwazza committed Nov 3, 2017
1 parent 5b19d5e commit 782d610
Show file tree
Hide file tree
Showing 31 changed files with 665 additions and 276 deletions.
29 changes: 16 additions & 13 deletions src/Umbraco.Core/Models/Identity/IdentityModelMappings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,22 @@ public override void ConfigureMappings(IConfiguration config, ApplicationContext
identityUser.EnableChangeTracking();
});

config.CreateMap<BackOfficeIdentityUser, UserData>()
.ConstructUsing((BackOfficeIdentityUser user) => new UserData(Guid.NewGuid().ToString("N"))) //this is the 'session id'
.ForMember(detail => detail.Id, opt => opt.MapFrom(user => user.Id))
.ForMember(detail => detail.AllowedApplications, opt => opt.MapFrom(user => user.AllowedSections))
.ForMember(detail => detail.Roles, opt => opt.MapFrom(user => user.Roles.Select(x => x.RoleId).ToArray()))
.ForMember(detail => detail.RealName, opt => opt.MapFrom(user => user.Name))
//When mapping to UserData which is used in the authcookie we want ALL start nodes including ones defined on the groups
.ForMember(detail => detail.StartContentNodes, opt => opt.MapFrom(user => user.CalculatedContentStartNodeIds))
//When mapping to UserData which is used in the authcookie we want ALL start nodes including ones defined on the groups
.ForMember(detail => detail.StartMediaNodes, opt => opt.MapFrom(user => user.CalculatedMediaStartNodeIds))
.ForMember(detail => detail.Username, opt => opt.MapFrom(user => user.UserName))
.ForMember(detail => detail.Culture, opt => opt.MapFrom(user => user.Culture))
.ForMember(detail => detail.SessionId, opt => opt.MapFrom(user => user.SecurityStamp.IsNullOrWhiteSpace() ? Guid.NewGuid().ToString("N") : user.SecurityStamp));
//config.CreateMap<BackOfficeIdentityUser, UserData>()
// //TODO: SessionId is set at the DB level! change this OR , maybe it's generated here and we persist it to the db from here but i don't think so
// .ConstructUsing((BackOfficeIdentityUser user) => new UserData(Guid.NewGuid().ToString("N"))) //this is the 'session id'
// .ForMember(detail => detail.Id, opt => opt.MapFrom(user => user.Id))
// .ForMember(detail => detail.AllowedApplications, opt => opt.MapFrom(user => user.AllowedSections))
// .ForMember(detail => detail.Roles, opt => opt.MapFrom(user => user.Roles.Select(x => x.RoleId).ToArray()))
// .ForMember(detail => detail.RealName, opt => opt.MapFrom(user => user.Name))
// //When mapping to UserData which is used in the authcookie we want ALL start nodes including ones defined on the groups
// .ForMember(detail => detail.StartContentNodes, opt => opt.MapFrom(user => user.CalculatedContentStartNodeIds))
// //When mapping to UserData which is used in the authcookie we want ALL start nodes including ones defined on the groups
// .ForMember(detail => detail.StartMediaNodes, opt => opt.MapFrom(user => user.CalculatedMediaStartNodeIds))
// .ForMember(detail => detail.Username, opt => opt.MapFrom(user => user.UserName))
// .ForMember(detail => detail.Culture, opt => opt.MapFrom(user => user.Culture))

// //TODO: This is not the sessionId!
// .ForMember(detail => detail.SessionId, opt => opt.MapFrom(user => user.SecurityStamp.IsNullOrWhiteSpace() ? Guid.NewGuid().ToString("N") : user.SecurityStamp));
}

private string GetPasswordHash(string storedPass)
Expand Down
52 changes: 52 additions & 0 deletions src/Umbraco.Core/Models/Rdbms/UserLoginDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System;
using Umbraco.Core.Persistence;
using Umbraco.Core.Persistence.DatabaseAnnotations;

namespace Umbraco.Core.Models.Rdbms
{
[TableName("umbracoUserLogin")]
[PrimaryKey("sessionId", autoIncrement = false)]
[ExplicitColumns]
internal class UserLoginDto
{
[Column("sessionId")]
[PrimaryKeyColumn(AutoIncrement = false)]
public Guid SessionId { get; set; }

[Column("userId")]
[ForeignKey(typeof(UserDto), Name = "FK_umbracoUserLogin_umbracoUser_id")]
public int UserId { get; set; }

/// <summary>
/// Tracks when the session is created
/// </summary>
[Column("loggedInUtc")]
[NullSetting(NullSetting = NullSettings.NotNull)]
public DateTime LoggedInUtc { get; set; }

/// <summary>
/// Updated every time a user's session is validated
/// </summary>
/// <remarks>
/// This allows us to guess if a session is timed out if a user doesn't actively log out
/// and also allows us to trim the data in the table
/// </remarks>
[Column("lastValidatedUtc")]
[NullSetting(NullSetting = NullSettings.NotNull)]
public DateTime LastValidatedUtc { get; set; }

/// <summary>
/// Tracks when the session is removed when the user's account is logged out
/// </summary>
[Column("loggedOutUtc")]
[NullSetting(NullSetting = NullSettings.Null)]
public DateTime? LoggedOutUtc { get; set; }

/// <summary>
/// Logs the IP address of the session if available
/// </summary>
[Column("ipAddress")]
[NullSetting(NullSetting = NullSettings.Null)]
public string IpAddress { get; set; }
}
}
26 changes: 0 additions & 26 deletions src/Umbraco.Core/Models/Rdbms/UserLoginsDto.cs

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using System.Linq;
using Umbraco.Core.Logging;
using Umbraco.Core.Models.Rdbms;
using Umbraco.Core.Persistence.SqlSyntax;

namespace Umbraco.Core.Persistence.Migrations.Upgrades.TargetVersionSevenEightZero
{
[Migration("7.8.0", 4, Constants.System.UmbracoMigrationName)]
public class AddUserLoginTable : MigrationBase
{
public AddUserLoginTable(ISqlSyntaxProvider sqlSyntax, ILogger logger) : base(sqlSyntax, logger)
{
}

public override void Up()
{
var tables = SqlSyntax.GetTablesInSchema(Context.Database).ToArray();

if (tables.InvariantContains("umbracoUserLogin") == false)
{
Create.Table<UserLoginDto>();
}
}

public override void Down()
{
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,12 @@ IEnumerable<IUser> GetPagedResultsByQuery(IQuery<IUser> query, long pageIndex, i

IProfile GetProfile(string username);
IProfile GetProfile(int id);
IDictionary<UserState, int> GetUserStates();

IDictionary<UserState, int> GetUserStates();

Guid CreateLoginSession(int userId, string requestingIpAddress, bool cleanStaleSessions = true);
bool ValidateLoginSession(int userId, Guid sessionId);
int ClearLoginSessions(int userId);
int ClearLoginSessions(TimeSpan timespan);
void ClearLoginSession(Guid sessionId);
}
}
92 changes: 81 additions & 11 deletions src/Umbraco.Core/Persistence/Repositories/UserRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System.Text;
using System.Web.Security;
using Newtonsoft.Json;
using Umbraco.Core.Configuration;
using Umbraco.Core.Logging;
using Umbraco.Core.Models.EntityBase;
using Umbraco.Core.Models.Membership;
Expand Down Expand Up @@ -187,8 +188,8 @@ public IDictionary<UserState, int> GetUserStates()
SELECT '5CountOfInvited' AS colName, COUNT(id) AS num FROM umbracoUser WHERE lastLoginDate IS NULL AND userDisabled = 1 AND invitedDate IS NOT NULL
ORDER BY colName";

var result = Database.Fetch<dynamic>(sql);

var result = Database.Fetch<dynamic>(sql);

return new Dictionary<UserState, int>
{
{UserState.All, result[0].num},
Expand All @@ -199,6 +200,83 @@ public IDictionary<UserState, int> GetUserStates()
};
}

public Guid CreateLoginSession(int userId, string requestingIpAddress, bool cleanStaleSessions = true)
{
//TODO: I know this doesn't follow the normal repository conventions which would require us to crete a UserSessionRepository
//and also business logic models for these objects but that's just so overkill for what we are doing
//and now that everything is properly in a transaction (Scope) there doesn't seem to be much reason for using that anymore
var now = DateTime.UtcNow;
var dto = new UserLoginDto
{
UserId = userId,
IpAddress = requestingIpAddress,
LoggedInUtc = now,
LastValidatedUtc = now,
LoggedOutUtc = null,
SessionId = Guid.NewGuid()
};
Database.Insert(dto);

if (cleanStaleSessions)
{
ClearLoginSessions(TimeSpan.FromDays(15));
}

return dto.SessionId;
}

public bool ValidateLoginSession(int userId, Guid sessionId)
{
var found = Database.FirstOrDefault<UserLoginDto>("WHERE sessionId=@sessionId", new {sessionId = sessionId});
if (found == null || found.UserId != userId || found.LoggedOutUtc.HasValue)
return false;

//now detect if there's been a timeout
if (DateTime.UtcNow - found.LastValidatedUtc > TimeSpan.FromMinutes(GlobalSettings.TimeOutInMinutes))
{
//timeout detected, update the record
ClearLoginSession(sessionId);
return false;
}

//update the validate date
found.LastValidatedUtc = DateTime.UtcNow;
Database.Update(found);
return true;
}

public int ClearLoginSessions(int userId)
{
//TODO: I know this doesn't follow the normal repository conventions which would require us to crete a UserSessionRepository
//and also business logic models for these objects but that's just so overkill for what we are doing
//and now that everything is properly in a transaction (Scope) there doesn't seem to be much reason for using that anymore
var count = Database.ExecuteScalar<int>("SELECT COUNT(*) FROM umbracoUserLogin WHERE userId=@userId", new { userId = userId });
Database.Execute("DELETE FROM umbracoUserLogin WHERE userId=@userId", new {userId = userId});
return count;
}

public int ClearLoginSessions(TimeSpan timespan)
{
//TODO: I know this doesn't follow the normal repository conventions which would require us to crete a UserSessionRepository
//and also business logic models for these objects but that's just so overkill for what we are doing
//and now that everything is properly in a transaction (Scope) there doesn't seem to be much reason for using that anymore

var fromDate = DateTime.UtcNow - timespan;

var count = Database.ExecuteScalar<int>("SELECT COUNT(*) FROM umbracoUserLogin WHERE lastValidatedUtc=@fromDate", new { fromDate = fromDate });
Database.Execute("DELETE FROM umbracoUserLogin WHERE lastValidatedUtc=@fromDate", new { fromDate = fromDate });
return count;
}

public void ClearLoginSession(Guid sessionId)
{
//TODO: I know this doesn't follow the normal repository conventions which would require us to crete a UserSessionRepository
//and also business logic models for these objects but that's just so overkill for what we are doing
//and now that everything is properly in a transaction (Scope) there doesn't seem to be much reason for using that anymore
Database.Execute("UPDATE umbracoUserLogin SET loggedOutUtc=@now WHERE sessionId=@sessionId",
new { now = DateTime.UtcNow, sessionId = sessionId });
}

protected override IEnumerable<IUser> PerformGetAll(params int[] ids)
{
var sql = GetQueryWithGroups();
Expand Down Expand Up @@ -834,14 +912,6 @@ private IEnumerable<IUser> ConvertFromDtos(IEnumerable<UserDto> dtos)
{
return dtos.Select(UserFactory.BuildEntity);
}

//private IEnumerable<IUserGroup> ConvertFromDtos(IEnumerable<UserGroupDto> dtos)
//{
// return dtos.Select(dto =>
// {
// var userGroupFactory = new UserGroupFactory();
// return userGroupFactory.BuildEntity(dto);
// });
//}

}
}
34 changes: 24 additions & 10 deletions src/Umbraco.Core/Security/AuthenticationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@
using System.Threading;
using System.Web;
using System.Web.Security;
using Microsoft.AspNet.Identity;
using AutoMapper;
using Microsoft.Owin;
using Newtonsoft.Json;
using Umbraco.Core.Configuration;
using Umbraco.Core.Models.Membership;
using Umbraco.Core.Logging;
using IUser = Umbraco.Core.Models.Membership.IUser;

namespace Umbraco.Core.Security
{
Expand Down Expand Up @@ -241,8 +243,11 @@ internal static bool RenewUmbracoAuthTicket(this HttpContext http)
/// <param name="userdata"></param>
public static FormsAuthenticationTicket CreateUmbracoAuthTicket(this HttpContextBase http, UserData userdata)
{
//ONLY used by BasePage.doLogin!

if (http == null) throw new ArgumentNullException("http");
if (userdata == null) throw new ArgumentNullException("userdata");
if (userdata == null) throw new ArgumentNullException("userdata");

var userDataString = JsonConvert.SerializeObject(userdata);
return CreateAuthTicketAndCookie(
http,
Expand All @@ -254,14 +259,7 @@ public static FormsAuthenticationTicket CreateUmbracoAuthTicket(this HttpContext
1440,
UmbracoConfig.For.UmbracoSettings().Security.AuthCookieName,
UmbracoConfig.For.UmbracoSettings().Security.AuthCookieDomain);
}

internal static FormsAuthenticationTicket CreateUmbracoAuthTicket(this HttpContext http, UserData userdata)
{
if (http == null) throw new ArgumentNullException("http");
if (userdata == null) throw new ArgumentNullException("userdata");
return new HttpContextWrapper(http).CreateUmbracoAuthTicket(userdata);
}
}

/// <summary>
/// returns the number of seconds the user has until their auth session times out
Expand Down Expand Up @@ -331,7 +329,23 @@ internal static FormsAuthenticationTicket GetUmbracoAuthTicket(this IOwinContext
/// <param name="http"></param>
/// <param name="cookieName"></param>
private static void Logout(this HttpContextBase http, string cookieName)
{
{
//We need to clear the sessionId from the database. This is legacy code to do any logging out and shouldn't really be used at all but in any case
//we need to make sure the session is cleared. Due to the legacy nature of this it means we need to use singletons
if (http.User != null)
{
var claimsIdentity = http.User.Identity as ClaimsIdentity;
if (claimsIdentity != null)
{
var sessionId = claimsIdentity.FindFirstValue(Constants.Security.SessionIdClaimType);
Guid guidSession;
if (sessionId.IsNullOrWhiteSpace() == false && Guid.TryParse(sessionId, out guidSession))
{
ApplicationContext.Current.Services.UserService.ClearLoginSession(guidSession);
}
}
}

if (http == null) throw new ArgumentNullException("http");
//clear the preview cookie and external login
var cookies = new[] { cookieName, Constants.Web.PreviewCookieName, Constants.Security.BackOfficeExternalCookieName };
Expand Down
Loading

0 comments on commit 782d610

Please sign in to comment.