Compare commits

...
Author SHA1 Message Date
Stevan Freeborn b93b29e51e feat: allow users to logout 2026-03-09 05:57:27 -05:00
Stevan Freeborn 868039d102 chore: enable aspire to run with made mode 2026-03-09 04:33:24 -05:00
7 changed files with 227 additions and 81 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
{
"appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj"
"appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj",
"defaultWatchEnabled": "true"
}
+1
View File
@@ -9,6 +9,7 @@ internal static class AuthExtensions
var authGroup = app.MapGroup(RouteGroupPrefix);
authGroup.MapLoginEndpoint();
authGroup.MapLogoutEndpoint();
authGroup.MapRefreshEndpoint()
.RequireAuthorization(Schemes.AllowExpiredTokens);
+38
View File
@@ -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<IResult> 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();
}
}
@@ -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;
+12 -16
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
import { useAuthService } from '@/composables/useAuthService';
import { useUserStore } from '@/stores/userStore';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
const userStore = useUserStore();
const router = useRouter();
const authService = useAuthService(userStore);
const asideClasses = computed(() => ({
collapsed: userStore.user?.sidebarCollapsed,
@@ -15,12 +17,14 @@ import { useRouter } from 'vue-router';
userStore.toggleSidebar();
}
function handleLogout() {
// TODO: Also need to log user
// out on the server...which is
// basically just clearing
// refresh token cookie and revoking
// it in the database
async function handleLogout() {
const logoutResult = await authService.logout();
if (logoutResult.err) {
alert(logoutResult.val.map(e => e.message).join('\n'));
return;
}
userStore.logUserOut();
router.push({ path: '/public/login' });
}
@@ -28,18 +32,10 @@ import { useRouter } from 'vue-router';
<template>
<aside :class="asideClasses">
<button
@click="handleToggleButtonClick"
type="button"
class="toggle-button"
>
<button @click="handleToggleButtonClick" type="button" class="toggle-button">
<LeftArrowIcon />
</button>
<button
class="logout-button"
type="button"
@click="handleLogout"
>
<button class="logout-button" type="button" @click="handleLogout">
Logout
</button>
</aside>
@@ -19,6 +19,7 @@ export class AuthServiceFactory implements IAuthServiceFactory {
export interface IAuthService {
login: (username: string, password: string) => Promise<Result<LoginResponse, Error[]>>;
refreshToken: () => Promise<Result<LoginResponse, Error[]>>;
logout: () => Promise<Result<boolean, Error[]>>;
}
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,
@@ -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<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
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<Response>(HttpStatusCode.OK))
.Which
.AccessToken;
var userRefreshTokens = await Api.ExecuteAsync(async (context, ct) =>
{
return await context.Set<RefreshToken>()
.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<RefreshToken>()
.Where(r => r.UserId == user.Id)
.ToListAsync(ct);
}, TestContext.Current.CancellationToken);
updatedUserRefreshTokens.Should()
.ContainSingle(r => r.Revoked && r.IsExpired(DateTimeOffset.UtcNow) == false);
}
}