Skip to content

Commit c56e61e

Browse files
committed
2 parents d4f98cc + 6cd683b commit c56e61e

7 files changed

Lines changed: 105 additions & 16 deletions

File tree

Sources/EasyExtensions.AspNetCore.Authorization/Controllers/BaseAuthController.cs

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public abstract class BaseAuthController(
2424
IPasswordHashService _passwordHasher,
2525
ITokenProvider _tokenProvider) : ControllerBase
2626
{
27+
private const string CookieRefreshTokenName = "ee_refresh_token";
28+
2729
/// <summary>
2830
/// Gets the IP address from which the current request originated.
2931
/// </summary>
@@ -71,8 +73,24 @@ public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest
7173
/// <returns>An <see cref="IActionResult"/> containing the new access token if the refresh token is valid; otherwise, a
7274
/// 404 Not Found result if the token is invalid or revoked.</returns>
7375
[HttpPost("refresh")]
74-
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequestDto request)
76+
public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequestDto? request)
7577
{
78+
bool useCookie = string.IsNullOrWhiteSpace(request?.RefreshToken);
79+
if (useCookie)
80+
{
81+
if (Request.Cookies.TryGetValue(CookieRefreshTokenName, out string? cookieRefreshToken))
82+
{
83+
request = request is not null
84+
? request with { RefreshToken = cookieRefreshToken }
85+
: new RefreshTokenRequestDto { RefreshToken = cookieRefreshToken };
86+
}
87+
}
88+
89+
if (string.IsNullOrWhiteSpace(request?.RefreshToken))
90+
{
91+
return this.ApiNotFound("Refresh token was not found");
92+
}
93+
7694
Guid? userId = await FindUserByRefreshTokenAsync(request.RefreshToken);
7795
if (!userId.HasValue || userId == Guid.Empty)
7896
{
@@ -83,10 +101,17 @@ public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequestDto
83101
var roles = await GetUserRolesAsync(userId.Value);
84102
string accessToken = CreateAccessToken(userId.Value, roles);
85103
await SaveAndRevokeRefreshTokenAsync(userId.Value, request.RefreshToken, newRefreshToken, AuthType.Unknown);
86-
return Ok(new TokenPairDto
104+
Response.Cookies.Append(CookieRefreshTokenName, newRefreshToken, new()
105+
{
106+
Secure = true,
107+
HttpOnly = true,
108+
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict,
109+
Expires = DateTimeOffset.UtcNow.Add(GetCookieExpirationTime()),
110+
});
111+
return Ok(new TokenPairResponseDto
87112
{
88113
AccessToken = accessToken,
89-
RefreshToken = newRefreshToken
114+
RefreshToken = useCookie ? StringHelpers.CreateRandomString(64) : newRefreshToken
90115
});
91116
}
92117

@@ -98,7 +123,7 @@ public async Task<IActionResult> RefreshToken([FromBody] RefreshTokenRequestDto
98123
/// re-entering credentials. Repeated failed login attempts may be subject to rate limiting or account lockout
99124
/// policies, depending on system configuration.</remarks>
100125
/// <param name="request">The login request containing the user's username and password. Cannot be null.</param>
101-
/// <returns>An <see cref="IActionResult"/> containing a <see cref="TokenPairDto"/> with access and refresh tokens if
126+
/// <returns>An <see cref="IActionResult"/> containing a <see cref="TokenPairResponseDto"/> with access and refresh tokens if
102127
/// authentication is successful; otherwise, an unauthorized response if the credentials are invalid.</returns>
103128
[HttpPost("login")]
104129
public async Task<IActionResult> Login([FromBody] LoginRequestDto request)
@@ -132,7 +157,14 @@ public async Task<IActionResult> Login([FromBody] LoginRequestDto request)
132157
string refreshToken = StringHelpers.CreateRandomString(64);
133158
await SaveAndRevokeRefreshTokenAsync(userId.Value, string.Empty, refreshToken, AuthType.Credentials);
134159
await OnUserLoggingInAsync(userId.Value, AuthType.Credentials, AuthRejectionType.None);
135-
return Ok(new TokenPairDto
160+
Response.Cookies.Append(CookieRefreshTokenName, refreshToken, new()
161+
{
162+
Secure = true,
163+
HttpOnly = true,
164+
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict,
165+
Expires = DateTimeOffset.UtcNow.Add(GetCookieExpirationTime()),
166+
});
167+
return Ok(new TokenPairResponseDto
136168
{
137169
AccessToken = accessToken,
138170
RefreshToken = refreshToken
@@ -148,7 +180,7 @@ public async Task<IActionResult> Login([FromBody] LoginRequestDto request)
148180
/// The method issues new tokens and revokes any previous refresh tokens for the user.</remarks>
149181
/// <param name="token">The Google OAuth access token to use for retrieving user information. Must be a valid token issued by
150182
/// Google.</param>
151-
/// <returns>An <see cref="IActionResult"/> containing a <see cref="TokenPairDto"/> with access and refresh tokens if
183+
/// <returns>An <see cref="IActionResult"/> containing a <see cref="TokenPairResponseDto"/> with access and refresh tokens if
152184
/// authentication is successful; otherwise, an unauthorized response if authentication fails or the user's
153185
/// email is not verified.</returns>
154186
/// <exception cref="InvalidOperationException">Thrown if user information cannot be retrieved from Google using the provided token.</exception>
@@ -181,7 +213,14 @@ public async Task<IActionResult> LoginWithGoogle([FromQuery] string token)
181213
string refreshToken = StringHelpers.CreateRandomString(64);
182214
await SaveAndRevokeRefreshTokenAsync(userId.Value, string.Empty, refreshToken, AuthType.Google);
183215
await OnUserLoggingInAsync(userId.Value, AuthType.Google, AuthRejectionType.None);
184-
return Ok(new TokenPairDto
216+
Response.Cookies.Append(CookieRefreshTokenName, refreshToken, new()
217+
{
218+
Secure = true,
219+
HttpOnly = true,
220+
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Strict,
221+
Expires = DateTimeOffset.UtcNow.Add(GetCookieExpirationTime()),
222+
});
223+
return Ok(new TokenPairResponseDto
185224
{
186225
AccessToken = accessToken,
187226
RefreshToken = refreshToken
@@ -294,6 +333,15 @@ public virtual bool IsEmailVerificationRequired()
294333
return Task.FromResult<Guid?>(null);
295334
}
296335

336+
/// <summary>
337+
/// Returns the default expiration time span for cookies issued by the application.
338+
/// </summary>
339+
/// <returns>A <see cref="TimeSpan"/> representing the duration after which a cookie expires. The default is 30 days.</returns>
340+
public virtual TimeSpan GetCookieExpirationTime()
341+
{
342+
return TimeSpan.FromDays(30);
343+
}
344+
297345
private string CreateAccessToken(Guid userId, IEnumerable<string> roles)
298346
{
299347
return _tokenProvider.CreateToken(cb =>
Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
using System.ComponentModel.DataAnnotations;
2-
3-
namespace EasyExtensions.AspNetCore.Authorization.Models.Dto
1+
namespace EasyExtensions.AspNetCore.Authorization.Models.Dto
42
{
53
/// <summary>
64
/// Represents a request to refresh an authentication token using the current password.
@@ -10,7 +8,6 @@ public record RefreshTokenRequestDto
108
/// <summary>
119
/// Gets or sets the refresh token used to obtain a new access token when the current one expires.
1210
/// </summary>
13-
[Required(AllowEmptyStrings = false)]
14-
public string RefreshToken { get; set; } = null!;
11+
public string? RefreshToken { get; set; }
1512
}
1613
}

Sources/EasyExtensions.AspNetCore.Authorization/Models/Dto/TokenPairDto.cs renamed to Sources/EasyExtensions.AspNetCore.Authorization/Models/Dto/TokenPairResponseDto.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
/// <remarks>A token pair typically consists of an access token, which is used to authorize API requests,
77
/// and a refresh token, which can be used to obtain a new access token when the current one expires. This class is
88
/// commonly used in authentication flows that require token management.</remarks>
9-
public class TokenPairDto
9+
public class TokenPairResponseDto
1010
{
1111
/// <summary>
1212
/// Gets or sets the OAuth 2.0 access token used for authenticating API requests.
@@ -16,6 +16,6 @@ public class TokenPairDto
1616
/// <summary>
1717
/// Gets or sets the refresh token used to obtain a new access token when the current one expires.
1818
/// </summary>
19-
public string RefreshToken { get; set; } = null!;
19+
public string? RefreshToken { get; set; } = null!;
2020
}
2121
}

Sources/EasyExtensions.AspNetCore.Stack/EasyExtensions.AspNetCore.Stack.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
<ProjectReference Include="..\EasyExtensions.EntityFrameworkCore.Npgsql\EasyExtensions.EntityFrameworkCore.Npgsql.csproj" />
3535
<ProjectReference Include="..\EasyExtensions.EntityFrameworkCore\EasyExtensions.EntityFrameworkCore.csproj" />
3636
<ProjectReference Include="..\EasyExtensions.Quartz\EasyExtensions.Quartz.csproj" />
37-
<PackageReference Include="EasyVault" Version="1.0.9" />
37+
<PackageReference Include="EasyVault" Version="1.0.10" />
3838
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="All" />
3939
</ItemGroup>
4040

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// This file is used by Code Analysis to maintain SuppressMessage
2+
// attributes that are applied to this project.
3+
// Project-level suppressions either have no target or are given
4+
// a specific target and scoped to a namespace, type, member, etc.
5+
6+
using System.Diagnostics.CodeAnalysis;
7+
8+
[assembly: SuppressMessage("Performance", "CA1873:Avoid potentially expensive logging", Justification = "<Pending>", Scope = "member", Target = "~M:EasyExtensions.AspNetCore.Stack.Extensions.HostApplicationBuilderExtensions.AddEasyStack(Microsoft.Extensions.Hosting.IHostApplicationBuilder,System.Action{EasyExtensions.AspNetCore.Stack.Builders.EasyStackOptions})~Microsoft.Extensions.Hosting.IHostApplicationBuilder")]
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using Microsoft.EntityFrameworkCore;
2+
using System.ComponentModel.DataAnnotations.Schema;
3+
using EasyExtensions.EntityFrameworkCore.Abstractions;
4+
5+
namespace EasyExtensions.EntityFrameworkCore.Database
6+
{
7+
/// <summary>
8+
/// Represents a refresh token used to obtain new access tokens for a user in authentication workflows.
9+
/// </summary>
10+
/// <remarks>A refresh token is typically issued alongside an access token and allows clients to request
11+
/// new access tokens without requiring the user to re-authenticate. Each refresh token is associated with a
12+
/// specific user and can be revoked to prevent further use.</remarks>
13+
[Table("refresh_tokens")]
14+
[Index(nameof(Token), IsUnique = true)]
15+
public class RefreshToken : BaseEntity<Guid>
16+
{
17+
/// <summary>
18+
/// Gets or sets the unique identifier for the user.
19+
/// </summary>
20+
[Column("user_id")]
21+
public Guid UserId { get; set; }
22+
23+
/// <summary>
24+
/// Gets or sets the authentication token associated with the entity.
25+
/// </summary>
26+
[Column("token")]
27+
public string Token { get; set; } = null!;
28+
29+
/// <summary>
30+
/// Gets or sets the date and time when the entity was revoked. Stored in UTC.
31+
/// </summary>
32+
[Column("revoked_at")]
33+
public DateTime? RevokedAt { get; set; }
34+
}
35+
}

Sources/EasyExtensions/Streams/ChunkedStream.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public class ChunkedStream : IDisposable
1616
{
1717
private readonly Stream _baseStream;
1818
private readonly int _chunkSize;
19-
private readonly bool _disposed;
19+
private bool _disposed;
2020

2121
/// <summary>
2222
/// Initializes a new instance of the ChunkedStream class that reads from or writes to the specified base stream
@@ -101,6 +101,7 @@ public void Dispose()
101101
if (!_disposed)
102102
{
103103
_baseStream.Dispose();
104+
_disposed = true;
104105
}
105106
}
106107
}

0 commit comments

Comments
 (0)