Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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
```
1 change: 1 addition & 0 deletions src/sn-auth/Models/Options/JwtSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
116 changes: 68 additions & 48 deletions src/sn-auth/Services/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -68,7 +71,7 @@ public async Task<LoginResponse> 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
};
Expand All @@ -77,15 +80,15 @@ public async Task<LoginResponse> 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)
{
Expand All @@ -99,58 +102,76 @@ public async Task<LoginResponse> LoginAsync(LoginRequest loginRequest, Cancellat

public async Task<LoginResponse> 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<RegistrationResponse> RegisterAsync(RegistrationRequest registrationRequest, CancellationToken cancel)
Expand Down Expand Up @@ -239,17 +260,14 @@ public async Task<LoginResponse> 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
};
Expand All @@ -258,30 +276,32 @@ public async Task<LoginResponse> 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)
{
response.QrCodeSetupImageUrl = multiFactorInfo.QrCodeSetupImageUrl;
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)
Expand Down
3 changes: 1 addition & 2 deletions src/sn-auth/TokenProviders/InMemory/AuthTokenProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
47 changes: 43 additions & 4 deletions src/sn-auth/TokenProviders/InMemory/InMemoryTokenProvider.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
using System.Collections.Concurrent;
using System.Text;
using System.Security.Cryptography;

namespace SenseNetAuth.TokenProviders.InMemory;

public abstract class InMemoryTokenProvider : ITokenProvider
{
protected readonly ConcurrentDictionary<string, UserInfo> 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);

Expand Down Expand Up @@ -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())
Expand All @@ -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();
}
}
}
Loading
Loading