diff --git a/README.md b/README.md index 49e18ce..7e43609 100644 --- a/README.md +++ b/README.md @@ -6,4 +6,43 @@ The authentication server consists of two parts: - An API through which the client can log in/register etc. (recommended for custom UI) - A built-in UI that you do not need to implement in your application. -The description of the API endpoints can be found in the swagger documentation. \ No newline at end of file +The description of the API endpoints can be found in the swagger documentation. + +## Concurrent browser sessions + +By default, the same user can sign in from multiple browsers or devices using the +same application URL. Each completed login creates an independent session. +Refreshing or logging out of one session does not revoke another session. + +To require a single active session per user and application, configure the +authentication server with this environment variable: + +```text +JwtSettings__SingleSessionPerUser=true +``` + +The equivalent JSON setting is `JwtSettings.SingleSessionPerUser`. Its default is +`false`; omit the environment variable or set it to `false` to allow concurrent +sessions. Restart the authentication server after changing the configuration. +The single-session scope remains **user ID + application SiteUrl**, not a global +limit across all applications. In the redirect login flow, SiteUrl is the client +application's RedirectUrl (for example, `https://adminui.test.sensenet.com`). + +Access/refresh response fields and endpoints are unchanged. Refresh tokens and +callback auth codes are single-use; successful MFA also consumes its challenge. +Earlier access tokens from the same session remain valid until their original +expiry or session logout, so logout racing with refresh still revokes that session. +New logins in single-session mode revoke the previous session's access and refresh +tokens. Consumers must save the replacement refresh token after every refresh. + +Token state remains in memory in one SNAuth process, and expired tokens are removed +when new tokens are issued. Restarting loses all sessions; this change does not add +shared persistence for replicas. Revocation is enforced by SNAuth's validation and +refresh endpoints. A repository that validates JWT signatures locally can continue +to accept an issued access token until its expiry unless it also checks revocation. + +Run the authentication regression tests with: + +```text +dotnet test src/SenseNetAuth.sln +``` diff --git a/src/sn-auth/Models/Options/JwtSettings.cs b/src/sn-auth/Models/Options/JwtSettings.cs index 3b31358..fa4b55a 100644 --- a/src/sn-auth/Models/Options/JwtSettings.cs +++ b/src/sn-auth/Models/Options/JwtSettings.cs @@ -2,6 +2,7 @@ public class JwtSettings { + public bool SingleSessionPerUser { get; set; } public string Issuer { get; set; } = string.Empty; public string Audience { get; set; } = string.Empty; public string SecretKey { get; set; } = string.Empty; diff --git a/src/sn-auth/Services/AuthService.cs b/src/sn-auth/Services/AuthService.cs index c8cf714..96ae1f5 100644 --- a/src/sn-auth/Services/AuthService.cs +++ b/src/sn-auth/Services/AuthService.cs @@ -14,15 +14,18 @@ public class AuthService : IAuthService private const string EMAIL_REGEX = @"^[^@\s]+@[^@\s]+\.[^@\s]+$"; private readonly IUserService _userService; - private readonly ITokenProvider _accessTokenProvider; - private readonly ITokenProvider _refreshTokenProvider; - private readonly ITokenProvider _authTokenProvider; + private readonly JwtTokenProvider _accessTokenProvider; + private readonly RefreshTokenProvider _refreshTokenProvider; + private readonly AuthTokenProvider _authTokenProvider; private readonly ITokenProvider _passwordRecoveryTokenProvider; - private readonly ITokenProvider _multiFactorAuthTokenProvider; + private readonly MultiFactorAuthTokenProvider _multiFactorAuthTokenProvider; private readonly ITokenProvider _rememberMeTokenProvider; private readonly RegistrationSettings _registrationSettings; private readonly ApplicationSettings _applicationSettings; private readonly IEmailService _emailService; + // Token-pair issuance, rotation and logout must be one transaction across providers. + // No repository I/O or await is performed while holding this lock. + private readonly object _tokenLock = new(); public AuthService( IUserService userService, @@ -38,7 +41,7 @@ public AuthService( _authTokenProvider = new AuthTokenProvider(jwtOptions); _multiFactorAuthTokenProvider = new MultiFactorAuthTokenProvider(jwtOptions); _passwordRecoveryTokenProvider = new PasswordRecoveryTokenProvider(recoveryOptions); - _rememberMeTokenProvider = new RememberMeTokenProvider(); + _rememberMeTokenProvider = new RememberMeTokenProvider(jwtOptions.Value.SingleSessionPerUser); _applicationSettings = applicationOptions.Value; _registrationSettings = registrationOptions.Value; @@ -68,7 +71,7 @@ public async Task LoginAsync(LoginRequest loginRequest, Cancellat var user = await _userService.GetUserByUserIdAsync(userId, cancel).ConfigureAwait(false); response.RememberMeDetails = new RememberMeDetails { - RememberMeToken = _rememberMeTokenProvider.CreateToken(userId, loginRequest.SiteUrl), + RememberMeToken = CreateRememberMeToken(userId, loginRequest.SiteUrl), FullName = user.FullName, LoginName = user.LoginName }; @@ -77,15 +80,15 @@ public async Task LoginAsync(LoginRequest loginRequest, Cancellat var multiFactorInfo = await _userService.GetMultiFactorAuthenticationInfoAsync(userId, cancel); if (multiFactorInfo == null || !multiFactorInfo.MultiFactorEnabled) { - response.AccessToken = _accessTokenProvider.CreateToken(userId, loginRequest.SiteUrl); - response.RefreshToken = _refreshTokenProvider.CreateToken(userId, loginRequest.SiteUrl); - - return response; + var tokens = CreateTokenPair(new UserInfo(userId, default, loginRequest.SiteUrl)); + tokens.RememberMeDetails = response.RememberMeDetails; + return tokens; } else { response.MultiFactorRequired = true; - response.MultiFactorAuthToken = _multiFactorAuthTokenProvider.CreateToken(userId, loginRequest.SiteUrl); + lock (_tokenLock) + response.MultiFactorAuthToken = _multiFactorAuthTokenProvider.CreateToken(userId, loginRequest.SiteUrl); if (!multiFactorInfo.MultiFactorRegistered) { @@ -99,58 +102,76 @@ public async Task LoginAsync(LoginRequest loginRequest, Cancellat public async Task MultiFactorLoginAsync(MultiFactorLoginRequest loginRequest, CancellationToken cancel, bool directLogin = true) { - var userInfo = _multiFactorAuthTokenProvider.GetUserInfoByToken(loginRequest.MultiFactorAuthToken); - if (!userInfo.HasValue || !_multiFactorAuthTokenProvider.IsTokenValid(loginRequest.MultiFactorAuthToken)) + var userInfo = _multiFactorAuthTokenProvider.GetValidUserInfo(loginRequest.MultiFactorAuthToken); + if (!userInfo.HasValue || userInfo.Value.SiteUrl != loginRequest.SiteUrl) throw new BadRequestException(ResponseMessages.InvalidMultiFactorToken); if (!await _userService.ValidateTwoFactorCodeAsync(userInfo.Value.UserId, loginRequest.MultiFactorCode, cancel)) throw new BadRequestException(ResponseMessages.InvalidMultiFactorCode); - if (directLogin) - { - return new LoginResponse - { - AccessToken = _accessTokenProvider.CreateToken(userInfo.Value.UserId, loginRequest.SiteUrl), - RefreshToken = _refreshTokenProvider.CreateToken(userInfo.Value.UserId, loginRequest.SiteUrl) - }; - } - else + lock (_tokenLock) { + // Consume only after a valid MFA code, but atomically before issuing a session. + var consumed = _multiFactorAuthTokenProvider.ConsumeToken(loginRequest.MultiFactorAuthToken) + ?? throw new BadRequestException(ResponseMessages.InvalidMultiFactorToken); + if (directLogin) + return CreateTokenPair(consumed); + return new LoginResponse { - AuthToken = _authTokenProvider.CreateToken(userInfo.Value.UserId, loginRequest.SiteUrl) + AuthToken = _authTokenProvider.CreateToken(consumed.UserId, consumed.SiteUrl) }; } } public void Logout(string token) { - var userInfo = _accessTokenProvider.GetUserInfoByToken(token); - if (userInfo.HasValue) + lock (_tokenLock) { - _accessTokenProvider.InvalidateToken(userInfo.Value); - _refreshTokenProvider.InvalidateToken(userInfo.Value); + var userInfo = _accessTokenProvider.GetUserInfoByToken(token); + if (userInfo.HasValue) + { + _accessTokenProvider.InvalidateSession(userInfo.Value); + _refreshTokenProvider.InvalidateSession(userInfo.Value); + } } } public bool ValidateToken(string token) { - return _accessTokenProvider.IsTokenValid(token); + lock (_tokenLock) + return _accessTokenProvider.IsTokenValid(token); } public LoginResponse RefreshToken(string token) { - var userInfo = _refreshTokenProvider.GetValidUserInfo(token); - if (userInfo.HasValue) + lock (_tokenLock) + { + var userInfo = _refreshTokenProvider.ConsumeToken(token) + ?? throw new UnauthorizedException(ResponseMessages.Unauthorized); + return CreateTokenPair(userInfo); + } + } + + private LoginResponse CreateTokenPair(UserInfo userInfo) + { + lock (_tokenLock) { + if (string.IsNullOrEmpty(userInfo.SessionId)) + userInfo = userInfo with { SessionId = Guid.NewGuid().ToString("N") }; + return new LoginResponse { - AccessToken = _accessTokenProvider.CreateToken(userInfo.Value), - RefreshToken = _refreshTokenProvider.CreateToken(userInfo.Value) + AccessToken = _accessTokenProvider.CreateToken(userInfo), + RefreshToken = _refreshTokenProvider.CreateToken(userInfo) }; } + } - throw new UnauthorizedException(ResponseMessages.Unauthorized); + private string CreateRememberMeToken(int userId, string siteUrl) + { + lock (_tokenLock) + return _rememberMeTokenProvider.CreateToken(userId, siteUrl); } public async Task RegisterAsync(RegistrationRequest registrationRequest, CancellationToken cancel) @@ -239,17 +260,14 @@ public async Task AuthenticateAsync(LoginRequest loginRequest, Ca var multiFactorInfo = await _userService.GetMultiFactorAuthenticationInfoAsync(userId, cancel); - var response = new LoginResponse - { - AuthToken = _authTokenProvider.CreateToken(userId, loginRequest.SiteUrl), - }; + var response = new LoginResponse(); if (loginRequest.RememberMeRequested && string.IsNullOrEmpty(loginRequest.RememberMeToken)) { var user = await _userService.GetUserByUserIdAsync(userId, cancel).ConfigureAwait(false); response.RememberMeDetails = new RememberMeDetails { - RememberMeToken = _rememberMeTokenProvider.CreateToken(userId, loginRequest.SiteUrl), + RememberMeToken = CreateRememberMeToken(userId, loginRequest.SiteUrl), FullName = user.FullName, LoginName = user.LoginName }; @@ -258,7 +276,8 @@ public async Task AuthenticateAsync(LoginRequest loginRequest, Ca if (multiFactorInfo != null && multiFactorInfo.MultiFactorEnabled) { response.MultiFactorRequired = true; - response.MultiFactorAuthToken = _multiFactorAuthTokenProvider.CreateToken(userId, loginRequest.SiteUrl); + lock (_tokenLock) + response.MultiFactorAuthToken = _multiFactorAuthTokenProvider.CreateToken(userId, loginRequest.SiteUrl); if (!multiFactorInfo.MultiFactorRegistered) { @@ -266,22 +285,23 @@ public async Task AuthenticateAsync(LoginRequest loginRequest, Ca response.ManualEntryKey = multiFactorInfo.ManualEntryKey; } } + else + { + lock (_tokenLock) + response.AuthToken = _authTokenProvider.CreateToken(userId, loginRequest.SiteUrl); + } return response; } public LoginResponse ConvertAuthToken(TokenRequest tokenRequest) { - if (!_authTokenProvider.IsTokenValid(tokenRequest.Token)) - throw new UnauthorizedException(ResponseMessages.InvalidAuthToken); - - var userInfo = _authTokenProvider.GetUserInfoByToken(tokenRequest.Token); - - return new LoginResponse + lock (_tokenLock) { - AccessToken = _accessTokenProvider.CreateToken(userInfo.Value.UserId, userInfo.Value.SiteUrl), - RefreshToken = _refreshTokenProvider.CreateToken(userInfo.Value.UserId, userInfo.Value.SiteUrl) - }; + var userInfo = _authTokenProvider.ConsumeToken(tokenRequest.Token) + ?? throw new UnauthorizedException(ResponseMessages.InvalidAuthToken); + return CreateTokenPair(userInfo); + } } public async Task ChangePasswordAsync(string bearerToken, ChangePasswordRequest changePasswordRequest, CancellationToken cancel) diff --git a/src/sn-auth/TokenProviders/InMemory/AuthTokenProvider.cs b/src/sn-auth/TokenProviders/InMemory/AuthTokenProvider.cs index 291e8e1..e517436 100644 --- a/src/sn-auth/TokenProviders/InMemory/AuthTokenProvider.cs +++ b/src/sn-auth/TokenProviders/InMemory/AuthTokenProvider.cs @@ -17,8 +17,7 @@ public override string CreateToken(int userId, string siteUrl) var expiration = new DateTimeOffset(DateTime.UtcNow.AddDays(_jwtSettings.AuthTokenExpiryMinutes)); var token = GenerateUniqueToken(128); - InvalidateToken(userId, siteUrl); - tokens.TryAdd(token, (userId, expiration, siteUrl)); + StoreToken(token, new UserInfo(userId, expiration, siteUrl), _jwtSettings.SingleSessionPerUser); return token; } diff --git a/src/sn-auth/TokenProviders/InMemory/InMemoryTokenProvider.cs b/src/sn-auth/TokenProviders/InMemory/InMemoryTokenProvider.cs index cbe475b..d5f20a7 100644 --- a/src/sn-auth/TokenProviders/InMemory/InMemoryTokenProvider.cs +++ b/src/sn-auth/TokenProviders/InMemory/InMemoryTokenProvider.cs @@ -1,5 +1,6 @@ using System.Collections.Concurrent; using System.Text; +using System.Security.Cryptography; namespace SenseNetAuth.TokenProviders.InMemory; @@ -7,10 +8,9 @@ public abstract class InMemoryTokenProvider : ITokenProvider { protected readonly ConcurrentDictionary tokens = []; - private static readonly Random _random = new(); private const int DEFAULT_TOKEN_SIZE = 128; - public string CreateToken(UserInfo userInfo) => CreateToken(userInfo.UserId, userInfo.SiteUrl); + public virtual string CreateToken(UserInfo userInfo) => CreateToken(userInfo.UserId, userInfo.SiteUrl); public abstract string CreateToken(int userId, string siteUrl); @@ -44,6 +44,45 @@ public bool IsTokenValid(string token) public void InvalidateToken(UserInfo userInfo) => InvalidateToken(userInfo.UserId, userInfo.SiteUrl); + public void InvalidateSession(UserInfo userInfo) + { + foreach (var entry in tokens.Where(kv => kv.Value.UserId == userInfo.UserId + && kv.Value.SiteUrl == userInfo.SiteUrl + && kv.Value.SessionId == userInfo.SessionId).ToList()) + tokens.TryRemove(entry.Key, out _); + } + + public UserInfo? ConsumeToken(string token) + { + return tokens.TryRemove(token, out var value) && value.Expiry >= DateTimeOffset.UtcNow + ? value + : null; + } + + protected void StoreToken(string token, UserInfo userInfo, bool singleSessionPerUser, + bool replaceSessionTokens = true) + { + // Opportunistic cleanup keeps expired sessions from accumulating between logins. + foreach (var entry in tokens.Where(kv => kv.Value.Expiry < DateTimeOffset.UtcNow).ToList()) + tokens.TryRemove(entry.Key, out _); + + if (string.IsNullOrEmpty(userInfo.SessionId)) + userInfo = userInfo with { SessionId = Guid.NewGuid().ToString("N") }; + + if (singleSessionPerUser) + { + foreach (var entry in tokens.Where(kv => kv.Value.UserId == userInfo.UserId + && kv.Value.SiteUrl == userInfo.SiteUrl + && (replaceSessionTokens || kv.Value.SessionId != userInfo.SessionId)).ToList()) + tokens.TryRemove(entry.Key, out _); + } + else if (replaceSessionTokens) + InvalidateSession(userInfo); + + if (!tokens.TryAdd(token, userInfo)) + throw new InvalidOperationException("A token with this identifier already exists."); + } + public void InvalidateToken(int userId, string siteUrl) { foreach (var s in tokens.Where(kv => kv.Value.UserId == userId && kv.Value.SiteUrl == siteUrl).ToList()) @@ -66,9 +105,9 @@ protected static string GenerateUniqueToken(int length = DEFAULT_TOKEN_SIZE) for (int i = 0; i < length; i++) { - token.Append(chars[_random.Next(chars.Length)]); + token.Append(chars[RandomNumberGenerator.GetInt32(chars.Length)]); } return token.ToString(); } -} \ No newline at end of file +} diff --git a/src/sn-auth/TokenProviders/InMemory/JwtTokenProvider.cs b/src/sn-auth/TokenProviders/InMemory/JwtTokenProvider.cs index 56590ad..476b07a 100644 --- a/src/sn-auth/TokenProviders/InMemory/JwtTokenProvider.cs +++ b/src/sn-auth/TokenProviders/InMemory/JwtTokenProvider.cs @@ -1,6 +1,5 @@ using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; -using SenseNet.Client; using SenseNetAuth.Models.Options; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; @@ -18,7 +17,13 @@ public JwtTokenProvider(IOptions jwtSettings) } public override string CreateToken(int userId, string siteUrl) + => CreateToken(new UserInfo(userId, default, siteUrl)); + + public override string CreateToken(UserInfo userInfo) { + if (string.IsNullOrEmpty(userInfo.SessionId)) + userInfo = userInfo with { SessionId = Guid.NewGuid().ToString("N") }; + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtSettings.SecretKey)); var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); @@ -26,7 +31,8 @@ public override string CreateToken(int userId, string siteUrl) var claims = new[] { new Claim(JwtRegisteredClaimNames.Iss, _jwtSettings.Issuer), - new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()), + new Claim(JwtRegisteredClaimNames.Sub, userInfo.UserId.ToString()), + new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")), new Claim(JwtRegisteredClaimNames.Exp, expiration.ToUnixTimeSeconds().ToString()), new Claim(JwtRegisteredClaimNames.AuthTime, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()) }; @@ -41,8 +47,10 @@ public override string CreateToken(int userId, string siteUrl) var tokenString = new JwtSecurityTokenHandler().WriteToken(token); - InvalidateToken(userId, siteUrl); - tokens.TryAdd(tokenString, (userId, expiration, siteUrl)); + // Retain this session's earlier access tokens until expiry so a concurrent logout + // using the previous token still revokes the session after a refresh. + StoreToken(tokenString, userInfo with { Expiry = expiration }, _jwtSettings.SingleSessionPerUser, + replaceSessionTokens: false); return tokenString; } diff --git a/src/sn-auth/TokenProviders/InMemory/MultiFactorAuthTokenProvider.cs b/src/sn-auth/TokenProviders/InMemory/MultiFactorAuthTokenProvider.cs index 755c514..c220267 100644 --- a/src/sn-auth/TokenProviders/InMemory/MultiFactorAuthTokenProvider.cs +++ b/src/sn-auth/TokenProviders/InMemory/MultiFactorAuthTokenProvider.cs @@ -19,8 +19,7 @@ public override string CreateToken(int userId, string siteUrl) var expiration = new DateTimeOffset(DateTime.UtcNow.AddDays(_jwtSettings.MultiFactorAuthExpiryMinutes)); var token = GenerateUniqueToken(128); - InvalidateToken(userId, siteUrl); - tokens.TryAdd(token, (userId, expiration, siteUrl)); + StoreToken(token, new UserInfo(userId, expiration, siteUrl), _jwtSettings.SingleSessionPerUser); return token; } diff --git a/src/sn-auth/TokenProviders/InMemory/RefreshTokenProvider.cs b/src/sn-auth/TokenProviders/InMemory/RefreshTokenProvider.cs index 20c1236..98ddb9b 100644 --- a/src/sn-auth/TokenProviders/InMemory/RefreshTokenProvider.cs +++ b/src/sn-auth/TokenProviders/InMemory/RefreshTokenProvider.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.Options; -using SenseNet.Client; using SenseNetAuth.Models.Options; namespace SenseNetAuth.TokenProviders.InMemory; @@ -14,12 +13,14 @@ public RefreshTokenProvider(IOptions options) } public override string CreateToken(int userId, string siteUrl) + => CreateToken(new UserInfo(userId, default, siteUrl)); + + public override string CreateToken(UserInfo userInfo) { var expiration = new DateTimeOffset(DateTime.UtcNow.AddDays(_jwtSettings.RefreshTokenExpiryDays)); var token = GenerateUniqueToken(); - InvalidateToken(userId, siteUrl); - tokens.TryAdd(token, (userId, expiration, siteUrl)); + StoreToken(token, userInfo with { Expiry = expiration }, _jwtSettings.SingleSessionPerUser); return token; } diff --git a/src/sn-auth/TokenProviders/InMemory/RememberMeTokenProvider.cs b/src/sn-auth/TokenProviders/InMemory/RememberMeTokenProvider.cs index 438329f..04a6d54 100644 --- a/src/sn-auth/TokenProviders/InMemory/RememberMeTokenProvider.cs +++ b/src/sn-auth/TokenProviders/InMemory/RememberMeTokenProvider.cs @@ -2,13 +2,21 @@ public class RememberMeTokenProvider : InMemoryTokenProvider { + private readonly bool _singleSessionPerUser; + + public RememberMeTokenProvider() : this(false) { } + + public RememberMeTokenProvider(bool singleSessionPerUser) + { + _singleSessionPerUser = singleSessionPerUser; + } + public override string CreateToken(int userId, string siteUrl) { var expiration = new DateTimeOffset(DateTime.UtcNow.AddYears(100)); var token = GenerateUniqueToken(128); - InvalidateToken(userId, siteUrl); - tokens.TryAdd(token, (userId, expiration, siteUrl)); + StoreToken(token, new UserInfo(userId, expiration, siteUrl), _singleSessionPerUser); return token; } diff --git a/src/sn-auth/TokenProviders/UserInfo.cs b/src/sn-auth/TokenProviders/UserInfo.cs index 4eb25d8..18374d3 100644 --- a/src/sn-auth/TokenProviders/UserInfo.cs +++ b/src/sn-auth/TokenProviders/UserInfo.cs @@ -2,8 +2,10 @@ public record struct UserInfo(int UserId, DateTimeOffset Expiry, string SiteUrl) { + public string SessionId { get; init; } = string.Empty; + public static implicit operator UserInfo((int UserId, DateTimeOffset Expiry, string siteUrl) value) { return new UserInfo(value.UserId, value.Expiry, value.siteUrl); } -} \ No newline at end of file +} diff --git a/src/sn-auth/appsettings.json b/src/sn-auth/appsettings.json index c2503be..a40fd96 100644 --- a/src/sn-auth/appsettings.json +++ b/src/sn-auth/appsettings.json @@ -12,6 +12,7 @@ ] }, "JwtSettings": { + "SingleSessionPerUser": false, "Issuer": "", "Audience": "sensenet", "SecretKey": "", @@ -52,4 +53,4 @@ "Enabled": false, "Domain": "sn" } -} \ No newline at end of file +} diff --git a/src/tests/IntegrationTests/AuthenticationTests.cs b/src/tests/IntegrationTests/AuthenticationTests.cs index e71f680..b967cca 100644 --- a/src/tests/IntegrationTests/AuthenticationTests.cs +++ b/src/tests/IntegrationTests/AuthenticationTests.cs @@ -89,11 +89,10 @@ public async Task LoginAndConvertAuthToken_ShouldReturnValidAccessAndRefreshToke } [Fact] - public async Task MultipleLoginsInSameApplication_ShouldInvalidateFirstToken_AndSecondTokenShouldBeValid() + public async Task MultipleLoginsInSameApplication_ShouldKeepBothTokensValidByDefault() { // Arrange var getLoginResponse = await GetLoginResponseAsync("https://localhost", "/home"); - await Task.Delay(1000); // Ensure there's a slight delay to differentiate the tokens var getLoginResponseSecond = await GetLoginResponseAsync("https://localhost", "/home"); TestOutputHelper.WriteLine("First login and second access token equals: " + (getLoginResponse.AccessToken == getLoginResponseSecond.AccessToken).ToString()); @@ -108,7 +107,8 @@ public async Task MultipleLoginsInSameApplication_ShouldInvalidateFirstToken_And TestOutputHelper.WriteLine("Second login access token validation response: " + secondLoginAccessTokenValidationResponse.StatusCode); // Assert - Assert.Equal(HttpStatusCode.Unauthorized, firstLoginAccessTokenValidationResponse.StatusCode); + Assert.NotEqual(getLoginResponse.AccessToken, getLoginResponseSecond.AccessToken); + Assert.Equal(HttpStatusCode.OK, firstLoginAccessTokenValidationResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, secondLoginAccessTokenValidationResponse.StatusCode); } @@ -117,7 +117,6 @@ public async Task LoginWithDifferentApplications_ShouldReturnValidTokensForBothS { // Arrange var loginResponse = await GetLoginResponseAsync("https://localhost", "/home"); - await Task.Delay(1000); // Ensure there's a slight delay to differentiate the tokens var secondLoginResponse = await GetLoginResponseAsync("https://localhost:8080", "/home"); // Act @@ -128,7 +127,7 @@ public async Task LoginWithDifferentApplications_ShouldReturnValidTokensForBothS var secondLoginAccessTokenValidationResponse = await Client.GetAsync("api/auth/validate-token"); // Assert - loginResponse.AuthToken.ShouldNotBe(secondLoginResponse.AccessToken); + loginResponse.AccessToken.ShouldNotBe(secondLoginResponse.AccessToken); Assert.Equal(HttpStatusCode.OK, firstLoginAccessTokenValidationResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, secondLoginAccessTokenValidationResponse.StatusCode); } @@ -208,4 +207,4 @@ public DelegatingHandlerAdapter(HttpMessageHandler innerHandler) InnerHandler = innerHandler; } } -} \ No newline at end of file +} diff --git a/src/tests/IntegrationTests/SessionEndpointTests.cs b/src/tests/IntegrationTests/SessionEndpointTests.cs new file mode 100644 index 0000000..1813a24 --- /dev/null +++ b/src/tests/IntegrationTests/SessionEndpointTests.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using SenseNetAuth.Models; +using SenseNetAuth.Models.Options; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Xunit; + +namespace IntegrationTests; + +public class SessionEndpointTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SeparateBrowserClientsCanRefreshAndLogoutAccordingToTheConfiguredPolicy(bool singleSession) + { + using var factory = new SensenetAuthApplicationFactory(); + using var configured = factory.WithWebHostBuilder(builder => builder.ConfigureServices(services => + services.PostConfigure(options => options.SingleSessionPerUser = singleSession))); + using var firstBrowser = configured.CreateClient(); + using var secondBrowser = configured.CreateClient(); + var first = await Login(firstBrowser); + var second = await Login(secondBrowser); + + Assert.NotEqual(first.AccessToken, second.AccessToken); + Assert.Equal(singleSession ? HttpStatusCode.Unauthorized : HttpStatusCode.OK, + await Validate(firstBrowser, first.AccessToken)); + Assert.Equal(HttpStatusCode.OK, await Validate(secondBrowser, second.AccessToken)); + + using var refreshFirst = await firstBrowser.PostAsJsonAsync("/api/auth/refresh-token", + new TokenRequest { Token = first.RefreshToken }); + if (singleSession) + Assert.Equal(HttpStatusCode.Unauthorized, refreshFirst.StatusCode); + else + { + Assert.Equal(HttpStatusCode.OK, refreshFirst.StatusCode); + var rotated = (await refreshFirst.Content.ReadFromJsonAsync())!; + using var replay = await firstBrowser.PostAsJsonAsync("/api/auth/refresh-token", + new TokenRequest { Token = first.RefreshToken }); + Assert.Equal(HttpStatusCode.Unauthorized, replay.StatusCode); + await Logout(firstBrowser, first.AccessToken); + Assert.Equal(HttpStatusCode.Unauthorized, await Validate(firstBrowser, rotated.AccessToken)); + using var afterLogout = await firstBrowser.PostAsJsonAsync("/api/auth/refresh-token", + new TokenRequest { Token = rotated.RefreshToken }); + Assert.Equal(HttpStatusCode.Unauthorized, afterLogout.StatusCode); + } + + // Even a stale first-browser logout must not revoke the second browser. + await Logout(firstBrowser, first.AccessToken); + using var refreshSecond = await secondBrowser.PostAsJsonAsync("/api/auth/refresh-token", + new TokenRequest { Token = second.RefreshToken }); + Assert.Equal(HttpStatusCode.OK, refreshSecond.StatusCode); + var secondRotated = (await refreshSecond.Content.ReadFromJsonAsync())!; + Assert.Equal(HttpStatusCode.OK, await Validate(secondBrowser, secondRotated.AccessToken)); + } + + private static async Task Login(HttpClient client) + { + using var response = await client.PostAsJsonAsync("/api/auth/login", new LoginRequest + { + LoginName = "test", Password = "test", SiteUrl = "https://adminui.test.sensenet.com" + }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + return (await response.Content.ReadFromJsonAsync())!; + } + + private static async Task Validate(HttpClient client, string token) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/auth/validate-token"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var response = await client.SendAsync(request); + return response.StatusCode; + } + + private static async Task Logout(HttpClient client, string token) + { + using var request = new HttpRequestMessage(HttpMethod.Post, "/api/auth/logout"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } +} diff --git a/src/tests/IntegrationTests/SessionTests.cs b/src/tests/IntegrationTests/SessionTests.cs new file mode 100644 index 0000000..aaf986c --- /dev/null +++ b/src/tests/IntegrationTests/SessionTests.cs @@ -0,0 +1,296 @@ +using IntegrationTests.Fakes; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; +using Moq; +using SenseNetAuth.Infrastructure.Exceptions; +using SenseNetAuth.Models; +using SenseNetAuth.Models.Options; +using SenseNetAuth.Services; +using Xunit; + +namespace IntegrationTests; + +public class SessionTests +{ + private const string Site = "https://adminui.test.sensenet.com"; + + [Fact] + public async Task DefaultAllowsManyIndependentLoginsForTheSameUserAndApplication() + { + var service = CreateService(); + var sessions = new List(); + for (var i = 0; i < 8; i++) + sessions.Add(await Login(service)); + + Assert.Equal(8, sessions.Select(s => s.AccessToken).Distinct().Count()); + Assert.All(sessions, s => Assert.True(service.ValidateToken(s.AccessToken))); + foreach (var session in sessions) + Assert.True(service.ValidateToken(service.RefreshToken(session.RefreshToken).AccessToken)); + } + + [Fact] + public async Task SingleSessionOptInInvalidatesPreviousAccessAndRefreshTokens() + { + var service = CreateService(singleSession: true); + var first = await Login(service); + var second = await Login(service); + + Assert.False(service.ValidateToken(first.AccessToken)); + Assert.Throws(() => service.RefreshToken(first.RefreshToken)); + Assert.True(service.ValidateToken(second.AccessToken)); + service.Logout(first.AccessToken); + Assert.True(service.ValidateToken(service.RefreshToken(second.RefreshToken).AccessToken)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task DifferentApplicationsAndUsersRemainIndependent(bool singleSession) + { + var service = CreateService(singleSession); + var first = await Login(service); + var anotherSite = await Login(service, "https://another-admin.example"); + var anotherUser = await Login(service, username: "other-user"); + + service.Logout(first.AccessToken); + Assert.True(service.ValidateToken(anotherSite.AccessToken)); + Assert.True(service.ValidateToken(anotherUser.AccessToken)); + service.RefreshToken(anotherSite.RefreshToken); + service.RefreshToken(anotherUser.RefreshToken); + } + + [Fact] + public async Task RefreshAndLogoutAffectOnlyTheirOwnBrowserSession() + { + var service = CreateService(); + var first = await Login(service); + var second = await Login(service); + var refreshed = service.RefreshToken(first.RefreshToken); + + Assert.True(service.ValidateToken(second.AccessToken)); + Assert.Throws(() => service.RefreshToken(first.RefreshToken)); + Assert.True(service.ValidateToken(refreshed.AccessToken)); + service.Logout(refreshed.AccessToken); + Assert.False(service.ValidateToken(first.AccessToken)); + Assert.False(service.ValidateToken(refreshed.AccessToken)); + Assert.Throws(() => service.RefreshToken(refreshed.RefreshToken)); + Assert.True(service.ValidateToken(service.RefreshToken(second.RefreshToken).AccessToken)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task LogoutWithThePreviousAccessTokenAlsoRevokesTheRefreshedSession(bool singleSession) + { + var service = CreateService(singleSession); + var session = await Login(service); + var refreshed = service.RefreshToken(session.RefreshToken); + service.Logout(session.AccessToken); + + Assert.False(service.ValidateToken(refreshed.AccessToken)); + Assert.Throws(() => service.RefreshToken(refreshed.RefreshToken)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ARefreshTokenCanBeConsumedByOnlyOneConcurrentRequest(bool singleSession) + { + var service = CreateService(singleSession); + var session = await Login(service); + var results = await Task.WhenAll(Enumerable.Range(0, 16).Select(_ => Task.Run(() => + { + try { return service.RefreshToken(session.RefreshToken); } + catch (UnauthorizedException) { return null; } + }))); + + var winner = Assert.Single(results.Where(r => r != null))!; + Assert.True(service.ValidateToken(winner.AccessToken)); + Assert.True(service.ValidateToken(service.RefreshToken(winner.RefreshToken).AccessToken)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ConcurrentRefreshAndLogoutCannotResurrectASession(bool singleSession) + { + var service = CreateService(singleSession); + for (var i = 0; i < 16; i++) + { + var session = await Login(service); + var refresh = Task.Run(() => + { + try { return service.RefreshToken(session.RefreshToken); } + catch (UnauthorizedException) { return null; } + }); + await Task.WhenAll(refresh, Task.Run(() => service.Logout(session.AccessToken))); + Assert.False(service.ValidateToken(session.AccessToken)); + Assert.Throws(() => service.RefreshToken(session.RefreshToken)); + if (refresh.Result is { } rotated) + { + Assert.False(service.ValidateToken(rotated.AccessToken)); + Assert.Throws(() => service.RefreshToken(rotated.RefreshToken)); + } + } + } + + [Fact] + public async Task ConcurrentLoginsInSingleSessionModeLeaveOnlyOneUsableTokenPair() + { + var service = CreateService(singleSession: true); + var sessions = await Task.WhenAll(Enumerable.Range(0, 16).Select(_ => Task.Run(() => Login(service)))); + var winner = Assert.Single(sessions.Where(s => service.ValidateToken(s.AccessToken))); + foreach (var session in sessions.Where(s => s != winner)) + Assert.Throws(() => service.RefreshToken(session.RefreshToken)); + Assert.True(service.ValidateToken(service.RefreshToken(winner.RefreshToken).AccessToken)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PendingCallbackCodesRespectThePolicyAndAreSingleUse(bool singleSession) + { + var service = CreateService(singleSession); + var first = await service.AuthenticateAsync(Request(), default); + var second = await service.AuthenticateAsync(Request(), default); + if (singleSession) + Assert.Throws(() => service.ConvertAuthToken(new() { Token = first.AuthToken })); + else + { + var converted = service.ConvertAuthToken(new() { Token = first.AuthToken }); + Assert.True(service.ValidateToken(converted.AccessToken)); + } + + var latest = service.ConvertAuthToken(new() { Token = second.AuthToken }); + Assert.Throws(() => service.ConvertAuthToken(new() { Token = second.AuthToken })); + Assert.True(service.ValidateToken(latest.AccessToken)); + } + + [Fact] + public async Task ConcurrentCallbackConversionCreatesOnlyOneSession() + { + var service = CreateService(); + var auth = await service.AuthenticateAsync(Request(), default); + var results = await Task.WhenAll(Enumerable.Range(0, 16).Select(_ => Task.Run(() => + { + try { return service.ConvertAuthToken(new() { Token = auth.AuthToken }); } + catch (UnauthorizedException) { return null; } + }))); + Assert.Single(results.Where(r => r != null)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task MfaChallengesRespectTheSessionPolicyAndCannotBeReplayed(bool singleSession, bool directLogin) + { + var service = CreateService(singleSession, mfa: true); + var first = directLogin ? await Login(service) : await service.AuthenticateAsync(Request(), default); + var second = directLogin ? await Login(service) : await service.AuthenticateAsync(Request(), default); + Assert.True(first.MultiFactorRequired); + Assert.Empty(first.AccessToken); + Assert.Empty(first.AuthToken); + if (singleSession) + await Assert.ThrowsAsync(() => CompleteMfa(service, first, directLogin)); + else + { + var firstSession = await CompleteMfa(service, first, directLogin); + Assert.True(service.ValidateToken(firstSession.AccessToken)); + } + + var session = await CompleteMfa(service, second, directLogin); + await Assert.ThrowsAsync(() => CompleteMfa(service, second, directLogin)); + Assert.True(service.ValidateToken(service.RefreshToken(session.RefreshToken).AccessToken)); + } + + [Fact] + public async Task InvalidMfaCodeOrChangedApplicationDoesNotConsumeTheChallenge() + { + var service = CreateService(mfa: true); + var challenge = await Login(service); + await Assert.ThrowsAsync(() => service.MultiFactorLoginAsync(new() + { + SiteUrl = Site, MultiFactorAuthToken = challenge.MultiFactorAuthToken, MultiFactorCode = "invalid" + }, default)); + await Assert.ThrowsAsync(() => service.MultiFactorLoginAsync(new() + { + SiteUrl = "https://another-admin.example", MultiFactorAuthToken = challenge.MultiFactorAuthToken, + MultiFactorCode = "valid" + }, default)); + Assert.True(service.ValidateToken((await CompleteMfa(service, challenge, true)).AccessToken)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task RememberMeTokensRespectTheSessionPolicy(bool singleSession) + { + var service = CreateService(singleSession); + var request = Request(); + request.RememberMeRequested = true; + var first = await service.AuthenticateAsync(request, default); + var second = await service.AuthenticateAsync(request, default); + var remembered = Request(); + remembered.RememberMeToken = first.RememberMeDetails!.RememberMeToken; + if (singleSession) + await Assert.ThrowsAsync(() => service.AuthenticateAsync(remembered, default)); + else + Assert.NotEmpty((await service.AuthenticateAsync(remembered, default)).AuthToken); + remembered.RememberMeToken = second.RememberMeDetails!.RememberMeToken; + Assert.NotEmpty((await service.AuthenticateAsync(remembered, default)).AuthToken); + } + + [Fact] + public void SingleSessionSettingDefaultsToFalseAndBindsFromEnvironment() + { + Assert.False(new JwtSettings().SingleSessionPerUser); + var prefix = "SNAUTH_SESSION_TEST_" + Guid.NewGuid().ToString("N") + "_"; + var key = prefix + "JwtSettings__SingleSessionPerUser"; + try + { + Environment.SetEnvironmentVariable(key, "true"); + var configuration = new ConfigurationBuilder().AddEnvironmentVariables(prefix).Build(); + var settings = configuration.GetSection("JwtSettings").Get(); + Assert.True(settings!.SingleSessionPerUser); + } + finally { Environment.SetEnvironmentVariable(key, null); } + } + + private static LoginRequest Request(string site = Site, string username = "test-user") + => new() { SiteUrl = site, LoginName = username, Password = "test-password" }; + + private static Task Login(AuthService service, string site = Site, string username = "test-user") + => service.LoginAsync(Request(site, username), default); + + private static async Task CompleteMfa(AuthService service, LoginResponse challenge, bool directLogin) + { + var result = await service.MultiFactorLoginAsync(new() + { + SiteUrl = Site, MultiFactorAuthToken = challenge.MultiFactorAuthToken, MultiFactorCode = "valid" + }, default, directLogin); + return directLogin ? result : service.ConvertAuthToken(new() { Token = result.AuthToken }); + } + + private static AuthService CreateService(bool singleSession = false, bool mfa = false) + { + var users = new Mock(); + users.Setup(u => u.ValidateCredentialsAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string username, string password, CancellationToken cancel) => username == "other-user" ? 2 : 1); + users.Setup(u => u.GetMultiFactorAuthenticationInfoAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MultiFactorInfoResponse { MultiFactorEnabled = mfa, MultiFactorRegistered = true }); + users.Setup(u => u.ValidateTwoFactorCodeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int userId, string code, CancellationToken cancel) => code == "valid"); + var fake = new FakeUserService(); + users.Setup(u => u.GetUserByUserIdAsync(It.IsAny(), It.IsAny())) + .Returns((int userId, CancellationToken cancel) => fake.GetUserByUserIdAsync(userId, cancel)); + return new AuthService(users.Object, Options.Create(new JwtSettings + { + SingleSessionPerUser = singleSession, Issuer = "session-tests", Audience = "sensenet", + SecretKey = "test-only-signing-key-with-at-least-32-bytes", TokenExpiryMinutes = 60, + RefreshTokenExpiryDays = 15, AuthTokenExpiryMinutes = 5, MultiFactorAuthExpiryMinutes = 5 + }), Options.Create(new RegistrationSettings()), Options.Create(new PasswordRecoverySettings()), + Options.Create(new ApplicationSettings()), Mock.Of()); + } +} diff --git a/src/tests/UnitTests/SessionTokenProviderTests.cs b/src/tests/UnitTests/SessionTokenProviderTests.cs new file mode 100644 index 0000000..1887e77 --- /dev/null +++ b/src/tests/UnitTests/SessionTokenProviderTests.cs @@ -0,0 +1,45 @@ +using Microsoft.Extensions.Options; +using SenseNetAuth.Models.Options; +using SenseNetAuth.TokenProviders.InMemory; +using System.IdentityModel.Tokens.Jwt; + +namespace TestProject1; + +public class SessionTokenProviderTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void JwtIdentifiersAreUniqueWithoutWaitingBetweenLogins(bool singleSession) + { + var provider = new JwtTokenProvider(Options.Create(new JwtSettings + { + SingleSessionPerUser = singleSession, SecretKey = "test-only-signing-key-with-at-least-32-bytes", + Issuer = "tests", Audience = "sensenet", TokenExpiryMinutes = 60 + })); + var tokens = Enumerable.Range(0, 32).Select(_ => provider.CreateToken(1, "https://admin.example")).ToArray(); + var handler = new JwtSecurityTokenHandler(); + Assert.Equal(32, tokens.Distinct().Count()); + Assert.Equal(32, tokens.Select(token => handler.ReadJwtToken(token).Id).Distinct().Count()); + Assert.Equal(singleSession ? 1 : 32, tokens.Count(provider.IsTokenValid)); + } + + [Fact] + public void IssuingANewRefreshTokenRemovesExpiredEntriesAndExpiredTokensCannotBeConsumed() + { + var settings = new JwtSettings { RefreshTokenExpiryDays = -1 }; + var provider = new InspectableRefreshProvider(Options.Create(settings)); + var expired = provider.CreateToken(1, "https://admin.example"); + settings.RefreshTokenExpiryDays = 1; + var valid = provider.CreateToken(1, "https://admin.example"); + Assert.Equal(1, provider.Count); + Assert.Null(provider.ConsumeToken(expired)); + Assert.NotNull(provider.ConsumeToken(valid)); + Assert.Null(provider.ConsumeToken(valid)); + } + + private sealed class InspectableRefreshProvider(IOptions options) : RefreshTokenProvider(options) + { + public int Count => tokens.Count; + } +}