diff --git a/src/FiscalOS.API/Auth/AuthExtensions.cs b/src/FiscalOS.API/Auth/AuthExtensions.cs index 2e55eaf..c22d045 100644 --- a/src/FiscalOS.API/Auth/AuthExtensions.cs +++ b/src/FiscalOS.API/Auth/AuthExtensions.cs @@ -9,6 +9,7 @@ internal static class AuthExtensions var authGroup = app.MapGroup(RouteGroupPrefix); authGroup.MapLoginEndpoint(); + authGroup.MapLogoutEndpoint(); authGroup.MapRefreshEndpoint() .RequireAuthorization(Schemes.AllowExpiredTokens); diff --git a/src/FiscalOS.API/Auth/Logout/Endpoint.cs b/src/FiscalOS.API/Auth/Logout/Endpoint.cs new file mode 100644 index 0000000..de46d7b --- /dev/null +++ b/src/FiscalOS.API/Auth/Logout/Endpoint.cs @@ -0,0 +1,38 @@ +internal static class Endpoint +{ + private const string Route = "/logout"; + + public static RouteHandlerBuilder MapLogoutEndpoint(this RouteGroupBuilder groupBuilder) + { + return groupBuilder.MapPost(Route, HandleAsync); + } + + private static async Task HandleAsync( + HttpContext httpContext, + [FromServices] AppDbContext appDbContext, + CancellationToken ct + ) + { + var userId = httpContext.GetUserId(); + var refreshToken = httpContext.GetRefreshTokenFromCookie(); + + var user = await appDbContext.Users + .Include(u => u.RefreshTokens + .Where(r => r.Token == refreshToken) + ) + .Where(u => u.Id == userId) + .SingleOrDefaultAsync(ct); + + if (user is null || user.RefreshTokens.Any() is false) + { + return Results.Unauthorized(); + } + + user.RefreshTokens.First().Revoke(); + await appDbContext.SaveChangesAsync(ct); + + httpContext.ExpireRefreshTokenCookie(); + + return Results.NoContent(); + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Http/HttpContextExtensions.cs b/src/FiscalOS.API/Http/HttpContextExtensions.cs index dd443f7..18b46b6 100644 --- a/src/FiscalOS.API/Http/HttpContextExtensions.cs +++ b/src/FiscalOS.API/Http/HttpContextExtensions.cs @@ -25,6 +25,21 @@ internal static class HttpContextExtensions ); } + public static void ExpireRefreshTokenCookie(this HttpContext context) + { + context.Response.Cookies.Append( + RefreshTokenCookieName, + string.Empty, + new CookieOptions + { + HttpOnly = true, + Expires = DateTimeOffset.MinValue, + SameSite = SameSiteMode.Strict, + Secure = true + } + ); + } + public static string GetRefreshTokenFromCookie(this HttpContext context) { return context.Request.Cookies[RefreshTokenCookieName] ?? string.Empty; diff --git a/src/FiscalOS.Web/src/components/NavSidebar.vue b/src/FiscalOS.Web/src/components/NavSidebar.vue index 8fe66e5..0ef6351 100644 --- a/src/FiscalOS.Web/src/components/NavSidebar.vue +++ b/src/FiscalOS.Web/src/components/NavSidebar.vue @@ -1,106 +1,102 @@ diff --git a/src/FiscalOS.Web/src/services/authService.ts b/src/FiscalOS.Web/src/services/authService.ts index b97c27a..9a33274 100644 --- a/src/FiscalOS.Web/src/services/authService.ts +++ b/src/FiscalOS.Web/src/services/authService.ts @@ -19,6 +19,7 @@ export class AuthServiceFactory implements IAuthServiceFactory { export interface IAuthService { login: (username: string, password: string) => Promise>; refreshToken: () => Promise>; + logout: () => Promise>; } export class AuthService implements IAuthService { @@ -26,12 +27,30 @@ export class AuthService implements IAuthService { private readonly endpoints = { login: '/api/auth/login', refreshToken: '/api/auth/refresh', + logout: '/api/auth/logout', }; constructor(client: IClient) { this.client = client; } + async logout() { + const request = new ClientRequestWithBody(this.endpoints.logout, undefined); + + try { + const res = await this.client.post(request); + + if (res.ok === false) { + return Err([new Error('Failed to logout. Please try again.')]); + } + + return Ok(true); + } catch (e) { + console.error(e); + return Err([new Error('Failed to logout. Please try again.')]); + } + } + async login(username: string, password: string) { const request = new ClientRequestWithBody( this.endpoints.login, diff --git a/tests/FiscalOS.API.Tests/Integration/Auth/LogoutTests.cs b/tests/FiscalOS.API.Tests/Integration/Auth/LogoutTests.cs new file mode 100644 index 0000000..6c3affe --- /dev/null +++ b/tests/FiscalOS.API.Tests/Integration/Auth/LogoutTests.cs @@ -0,0 +1,76 @@ +using FiscalOS.API.Auth.Login; + +namespace FiscalOS.API.Tests.Integration.Auth; + +public class LogoutTests(TestApi testApi) : IntegrationTest(testApi) +{ + private static readonly Uri LogoutUri = new("/auth/logout", UriKind.Relative); + private static readonly Uri LoginUri = new("/auth/login", UriKind.Relative); + + [Fact] + public async Task Logout_WhenCalled_ItShouldExpireRefreshTokenCookie() + { + var user = await Api.ExecuteAsync(static async (context, ct, sp) => + { + var passwordHasher = sp.GetRequiredService(); + var encryptor = sp.GetRequiredService(); + + var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct); + var user = User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey); + context.Add(user); + + await context.SaveChangesAsync(ct); + return user; + }, TestContext.Current.CancellationToken); + + using var loginRequest = HttpRequestBuilder.New() + .Post(LoginUri) + .WithBody(new + { + username = "Stevan", + password = "@Password1", + }) + .Build(); + + var loginResponse = await Client.SendAsync(loginRequest, TestContext.Current.CancellationToken); + var setCookieHeaders = loginResponse.Headers.GetValues("Set-Cookie"); + var refreshTokenCookieHeader = setCookieHeaders.First(h => h.StartsWith("fiscalos_refresh_cookie", StringComparison.OrdinalIgnoreCase)); + var refreshTokenCookieParts = refreshTokenCookieHeader.Trim().Split("="); + + var accessToken = (await loginResponse.Should().BeJsonContentOfType(HttpStatusCode.OK)) + .Which + .AccessToken; + + var userRefreshTokens = await Api.ExecuteAsync(async (context, ct) => + { + return await context.Set() + .Where(r => r.UserId == user.Id) + .ToListAsync(ct); + }, TestContext.Current.CancellationToken); + + userRefreshTokens.Should() + .ContainSingle(r => r.IsExpired(DateTimeOffset.UtcNow) == false && r.Revoked == false); + + using var logoutRequest = HttpRequestBuilder.New() + .Post(LogoutUri) + .WithBearerToken(accessToken) + .WithCookie(refreshTokenCookieParts[0], refreshTokenCookieParts[1]) + .Build(); + + var logoutResponse = await Client.SendAsync(logoutRequest, TestContext.Current.CancellationToken); + + logoutResponse.StatusCode.Should().Be(HttpStatusCode.NoContent); + + logoutResponse.Should().HaveSetCookieHeader("fiscalos_refresh_cookie"); + + var updatedUserRefreshTokens = await Api.ExecuteAsync(async (context, ct) => + { + return await context.Set() + .Where(r => r.UserId == user.Id) + .ToListAsync(ct); + }, TestContext.Current.CancellationToken); + + updatedUserRefreshTokens.Should() + .ContainSingle(r => r.Revoked && r.IsExpired(DateTimeOffset.UtcNow) == false); + } +} \ No newline at end of file