Outline
We were requested to incorporate functionality that ensure single user login (access). It means that if another client logs into the application, the first client (already logged in) will be logged out during his next request.
Because in the app we are using JWT token, as described in the previous post, we incorporated this functionality into this approach.
Workflow
The process workflow is as follows:
- User1 logs into the app. App generates a token1 and stores token id to particular user in DB.
- User2 logs into the app. App generates a token2 and stores token id to particular user in DB.
- User1 makes another request. App validates token including token id against token id stored in DB. If token ids are not same, forbidden response is returned.
IdentityService
IdentityService contains a new method ValidateToken that compares token ids. The result of the comparison is added as a Claim with name ValidTokenId into the TokenValidatedContext. Updated IndetityService looks as follows:
public class IdentityService { public static string ValidTokenId = "ValidTokenId"; private static readonly Random Random = new Random(); private readonly Configuration configuration; public IdentityService(Configuration configuration) { this.configuration = configuration; } public async Task ValidateToken(TokenValidatedContext context) { var claims = new List(); var sub = (context.SecurityToken as JwtSecurityToken)?.Claims .FirstOrDefault(e => e.Type == JwtRegisteredClaimNames.Sub)?.Value; if (sub != null) { Common.Db.Model.User user; using (var dbContext = new DatabaseContext(DatabaseContext.GetDbOptions(this.configuration.DbConnection))) { user = dbContext .Users .AsNoTracking() .FirstOrDefault(e => string.Equals(e.Email, sub, StringComparison.OrdinalIgnoreCase)); } if (user != null) { claims.Add(new Claim(ValidTokenId, user.UserTokenId == context.SecurityToken.Id ? "true" : "false", ClaimValueTypes.Boolean)); var claimsIdentity = context.Ticket.Principal.Identity as ClaimsIdentity; claimsIdentity.AddClaims(claims); } } await Task.CompletedTask; } private static string GetSalt() { var bytes = new byte[128 / 8]; using (var keyGenerator = RandomNumberGenerator.Create()) { keyGenerator.GetBytes(bytes); return BitConverter.ToString(bytes).Replace("-", string.Empty).ToLower(); } } private string GeneratePasswordHash(string password, string salt) { // NOTE: Here you should generate the password hash by your own // algorithm :). } public string GetPasswordHash(string password) { return this.GeneratePasswordHash(password, GetSalt()); } public bool IsPasswordValid(string password, string passwordHash) { var salt= passwordHash; // NOTE: Get salt frompasswordHash var hash = this.GeneratePasswordHash(password, salt); return passwordHash == hash; } public string GetRandomString(int length = 16) { const string Chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; return new string(Enumerable.Repeat(Chars, length) .Select(s => s[Random.Next(s.Length)]).ToArray()); } public JwtToken GenerateToken(JwtConfig jwtConfig, Credentials credentials, IEnumerable roles) { var now = DateTime.UtcNow; var tokenId = Guid.NewGuid().ToString(); // Specifically add the jti (random nonce), iat (issued timestamp), and sub (subject/user) claims. // You can add other claims here, if you want: var claims = new List; { new Claim(JwtRegisteredClaimNames.Sub, credentials.Email), new Claim(ClaimTypes.Name, credentials.Email), new Claim(JwtRegisteredClaimNames.Jti, tokenId), new Claim(JwtRegisteredClaimNames.Iat, DateTimeToUnixSeconds(now).ToString(), ClaimValueTypes.Integer64), new Claim(JwtRegisteredClaimNames.Iss, jwtConfig.Issuer) }; foreach (var role in roles) { claims.Add(new Claim(ClaimTypes.Role, role.Name)); } // Create the JWT and write it to a string var jwt = new JwtSecurityToken( jwtConfig.Issuer, jwtConfig.Audience, claims, now, now.Add(jwtConfig.Expiration), jwtConfig.SigningCredentials); var encodedJwt = new JwtSecurityTokenHandler().WriteToken(jwt); var response = new JwtToken { AccessToken = encodedJwt, ExpiresIn = (int)jwtConfig.Expiration.TotalSeconds, TokenId = tokenId }; return response; } private static long DateTimeToUnixSeconds(DateTime date) { return (long)date.Subtract(new DateTime(1970, 1, 1)).TotalSeconds; } }
Startup.cs
A JWT configuration must be updated to call IdentityService.ValidateToken. Property JwtBearerOptions.Events must be updated as follows:
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory, DatabaseContext databaseContext, Configuration configuration) { CompositionRoot.SetProvider(app.ApplicationServices); var jwtConfig = configuration.JwtConfig; var signingKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtConfig.SecretKey)); jwtConfig.SigningCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.HmacSha256); jwtConfig.SecretKey = null; var tokenValidationParameters = new TokenValidationParameters { // The signing key must match! ValidateIssuerSigningKey = true, IssuerSigningKey = signingKey, // Validate the JWT Issuer (iss) claim ValidateIssuer = true, ValidIssuer = jwtConfig.Issuer, // Validate the JWT Audience (aud) claim ValidateAudience = true, ValidAudience = jwtConfig.Audience, // Validate the token expiry ValidateLifetime = true, // If you want to allow a certain amount of clock drift, set that here: ClockSkew = TimeSpan.Zero }; var identityService = CompositionRoot.Resolve(); app.UseJwtBearerAuthentication(new JwtBearerOptions { Audience = jwtConfig.Audience, AutomaticAuthenticate = true, AutomaticChallenge = true, TokenValidationParameters = tokenValidationParameters, Events = new JwtBearerEvents { OnTokenValidated = identityService.ValidateToken, OnChallenge = context => { return Task.CompletedTask; } } });
Authorize Attribute
To validate particular claim ValidTokenId, authorize attribute must be extended with this parameter:
[Authorize(“ValidTokenId”)]
Conclusion
There are probably other possibilities how to ensure single user login in app depending on particular requirements. In our case, if another user logs in with the same account as another user did before, after the first user tries to request the app again, the request is revoked with HTTP error 401. This satisfies that only single user can be logged in via an account – the last one (wins).