Skip to content

Commit

Permalink
feat(templates): improve Boilerplate refresh token rotation #9528 (#9530
Browse files Browse the repository at this point in the history
)
  • Loading branch information
ysmoradi authored Dec 23, 2024
1 parent 44ab21b commit 50b292e
Show file tree
Hide file tree
Showing 10 changed files with 81 additions and 12 deletions.
5 changes: 5 additions & 0 deletions .github/workflows/bit.full.ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ jobs:
retention-days: 14

- name: Test PostgreSQL, MySql, Other database options
continue-on-error: true
run: |
dotnet new bit-bp --name TestPostgreSQL --database PostgreSQL --framework net8.0 --signalR
cd TestPostgreSQL/src/Server/TestPostgreSQL.Server.Api/
Expand All @@ -157,6 +158,7 @@ jobs:
dotnet build
- name: Test file storage options
continue-on-error: true
run: |
dotnet new bit-bp --name TestLocal --filesStorage Local --framework net8.0 --appInsights
cd TestLocal/src/Server/TestLocal.Server.Api/
Expand All @@ -167,6 +169,7 @@ jobs:
dotnet build
- name: Test backend setup options
continue-on-error: true
run: |
dotnet new bit-bp --name TestStandalone --api Standalone --framework net8.0
cd TestStandalone/src/Server/TestStandalone.Server.Api/
Expand All @@ -180,11 +183,13 @@ jobs:
dotnet build
- name: Test sample configuration 1
continue-on-error: true
run: |
dotnet new bit-bp --name TestProject --database SqlServer --filesStorage AzureBlobStorage --api Integrated --captcha reCaptcha --pipeline Azure --sample Admin --offlineDb --windows --appInsights --sentry --signalR --notification --framework net9.0
dotnet build TestProject/TestProject.sln -p:MultilingualEnabled=true -p:PwaEnabled=true -p:Environment=Staging
- name: Test sample configuration 2
continue-on-error: true
run: |
dotnet new bit-bp --name TestProject2 --database Other --filesStorage Other --api Standalone --captcha None --pipeline None --sample None --offlineDb false --windows false --appInsights false --sentry false --signalR false --notification false --framework net8.0
dotnet build TestProject2/TestProject2.sln -p:MultilingualEnabled=false -p:PwaEnabled=false -p:Environment=Development
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@
"path": "README.md"
},
{
"path": "Boilerplate.Web.slnf"
"path": "Boilerplate.sln"
}
],
"sources": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public partial class DashboardPage
protected async override Task OnInitAsync()
{
//#if (signalR == true)
unsubscribe = PubSubService.Subscribe(ClientPubSubMessages.DASHBOARD_DATA_CHANGED, async _ =>
unsubscribe = PubSubService.Subscribe(SharedPubSubMessages.DASHBOARD_DATA_CHANGED, async _ =>
{
NavigationManager.NavigateTo(Urls.DashboardPage, replace: true);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public partial class AuthManager : AuthenticationStateProvider, IAsyncDisposable
[AutoInject] private IUserController userController = default!;
[AutoInject] private ILogger<AuthManager> authLogger = default!;
[AutoInject] private IAuthTokenProvider tokenProvider = default!;
[AutoInject] private ITelemetryContext telemetryContext = default!;
[AutoInject] private IExceptionHandler exceptionHandler = default!;
[AutoInject] private IStringLocalizer<AppStrings> localizer = default!;
[AutoInject] private IIdentityController identityController = default!;
Expand Down Expand Up @@ -106,13 +107,18 @@ public async Task SignOut(CancellationToken cancellationToken)
async Task RefreshTokenImplementation()
{
authLogger.LogInformation("Refreshing access token requested by {RequestedBy}", requestedBy);
string? refreshToken = await storageService.GetItem("refresh_token");
try
{
string? refreshToken = await storageService.GetItem("refresh_token");
if (string.IsNullOrEmpty(refreshToken))
throw new UnauthorizedException(localizer[nameof(AppStrings.YouNeedToSignIn)]);

var refreshTokenResponse = await identityController.Refresh(new() { RefreshToken = refreshToken, ElevatedAccessToken = elevatedAccessToken }, default);
var refreshTokenResponse = await identityController.Refresh(new()
{
RefreshToken = refreshToken,
DeviceInfo = telemetryContext.Platform,
ElevatedAccessToken = elevatedAccessToken
}, default);
await StoreTokens(refreshTokenResponse);
accessTokenTsc.SetResult(refreshTokenResponse.AccessToken!);
}
Expand All @@ -122,9 +128,10 @@ async Task RefreshTokenImplementation()
{
{ "AdditionalData", "Refreshing access token failed." },
{ "RefreshTokenRequestedBy", requestedBy }
});
}, nonInterrupting: exp is ReusedRefreshTokenException);

if (exp is UnauthorizedException) // refresh token is also invalid.
if (exp is UnauthorizedException // refresh token is also invalid.
|| exp is ReusedRefreshTokenException && refreshToken == await storageService.GetItem("refresh_token"))
{
await ClearTokens();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@

namespace Boilerplate.Client.Core.Services;

public partial class ClientPubSubMessages : SharedPubSubMessages
public partial class ClientPubSubMessages
//#if (signalR == true)
: SharedPubSubMessages
//#endif
{
public const string SHOW_SNACK = nameof(SHOW_SNACK);
public const string SHOW_MODAL = nameof(SHOW_MODAL);
Expand All @@ -26,4 +29,8 @@ public partial class ClientPubSubMessages : SharedPubSubMessages
/// </summary>
public const string NAVIGATE_TO = nameof(NAVIGATE_TO);
public const string SHOW_DIAGNOSTIC_MODAL = nameof(SHOW_DIAGNOSTIC_MODAL);

//#if (signalR != true)
public const string PROFILE_UPDATED = nameof(PROFILE_UPDATED);
//#endif
}
Original file line number Diff line number Diff line change
Expand Up @@ -218,17 +218,17 @@ public async Task<ActionResult<TokenResponseDto>> Refresh(RefreshRequestDto requ

if (refreshTicket?.Principal.IsAuthenticated() is false
|| (refreshTicket!.Properties.ExpiresUtc ?? DateTimeOffset.MinValue) < DateTimeOffset.UtcNow)
throw new UnauthorizedException();
throw new UnauthorizedException(); // refresh token is expired.

var user = await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) ?? throw new UnauthorizedException();
var user = await signInManager.ValidateSecurityStampAsync(refreshTicket.Principal) ?? throw new UnauthorizedException(); // Security stamp has been updated (for example after 2fa configuration)
var userId = refreshTicket!.Principal.GetUserId().ToString();
var currentSessionId = refreshTicket.Principal.GetSessionId();

userSession = await DbContext.UserSessions
.FirstOrDefaultAsync(us => us.Id == currentSessionId, cancellationToken) ?? throw new UnauthorizedException(); // User session might have been deleted.
.FirstOrDefaultAsync(us => us.Id == currentSessionId, cancellationToken) ?? throw new UnauthorizedException(); // User session has been deleted.

if ((userSession.RenewedOn ?? userSession.StartedOn).ToUnixTimeSeconds() != long.Parse(refreshTicket.Principal.Claims.Single(c => c.Type == AppClaimTypes.SESSION_STAMP).Value))
throw new UnauthorizedException(nameof(AppStrings.ConcurrentUserSessionOnTheSameDevice)); // refresh token is being re-used.
throw new ReusedRefreshTokenException(); // refresh token is being re-used.

if (string.IsNullOrEmpty(request.ElevatedAccessToken) is false)
{
Expand All @@ -248,6 +248,10 @@ public async Task<ActionResult<TokenResponseDto>> Refresh(RefreshRequestDto requ
}

userSession.RenewedOn = DateTimeOffset.UtcNow;
// Relying on Cloudflare cdn to retrieve address.
// https://developers.cloudflare.com/rules/transform/managed-transforms/reference/#add-visitor-location-headers
(userSession.IP, userSession.Address) = (HttpContext.Connection.RemoteIpAddress?.ToString(), $"{Request.Headers["cf-ipcountry"]}, {Request.Headers["cf-ipcity"]}");
userSession.DeviceInfo = request.DeviceInfo;

userClaimsPrincipalFactory.SessionClaims.Add(new(AppClaimTypes.SESSION_ID, currentSessionId.ToString()));
userClaimsPrincipalFactory.SessionClaims.Add(new(AppClaimTypes.SESSION_STAMP, userSession.RenewedOn.Value.ToUnixTimeSeconds().ToString()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ public partial class RefreshRequestDto
/// <inheritdoc cref="AuthPolicies.ELEVATED_ACCESS" />
/// </summary>
public string? ElevatedAccessToken { get; set; }

public string? DeviceInfo { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using System.Net;

namespace Boilerplate.Shared.Exceptions;

/// <summary>
/// Represents an exception that is thrown when a refresh token has been reused and is no longer valid.
/// </summary>
/// <remarks>
/// Refresh token rotation ensures that each refresh token can only be used once.
/// This exception is typically thrown to indicate a potential security issue or misuse of the refresh token.
/// </remarks>
public class ReusedRefreshTokenException : RestException
{
public ReusedRefreshTokenException()
: base(nameof(AppStrings.ConcurrentUserSessionOnTheSameDevice))
{
}

public ReusedRefreshTokenException(string message)
: base(message)
{
}

public ReusedRefreshTokenException(string message, Exception? innerException)
: base(message, innerException)
{
}

public ReusedRefreshTokenException(LocalizedString message)
: base(message)
{
}

public ReusedRefreshTokenException(LocalizedString message, Exception? innerException)
: base(message, innerException)
{
}

public override HttpStatusCode StatusCode => HttpStatusCode.Unauthorized;
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public async Task SignIn_Should_WorkAsExpected()

await Page.GetByPlaceholder(AppStrings.EmailPlaceholder).FillAsync(email);
await Page.GetByPlaceholder(AppStrings.PasswordPlaceholder).FillAsync(password);
await Page.GetByRole(AriaRole.Button, new() { Name = AppStrings.SignIn }).ClickAsync();
await Page.GetByRole(AriaRole.Button, new() { Name = AppStrings.SignIn, Exact = true }).ClickAsync();

await Expect(Page).ToHaveURLAsync(server.WebAppServerAddress.ToString());
await Expect(Page.GetByRole(AriaRole.Button, new() { Name = userFullName })).ToBeVisibleAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,16 @@ private static async Task InitializeDatabase(AppTestServer testServer)
await using var scope = testServer.WebApp.Services.CreateAsyncScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
//#if (database == 'Sqlite')
//#if (IsInsideProjectTemplate == true)
if (dbContext.Database.ProviderName!.EndsWith("Sqlite", StringComparison.InvariantCulture))
{
//#endif
connection = new SqliteConnection(dbContext.Database.GetConnectionString());
await connection.OpenAsync();
//#if (IsInsideProjectTemplate == true)
}
//#endif
//#endif
await dbContext.Database.MigrateAsync();
}
}
Expand Down

0 comments on commit 50b292e

Please sign in to comment.