feat: allow users to logout

This commit is contained in:
Stevan Freeborn
2026-03-09 05:58:04 -05:00
parent c8505081a5
commit 091fa6f01c
6 changed files with 225 additions and 80 deletions
+1
View File
@@ -9,6 +9,7 @@ internal static class AuthExtensions
var authGroup = app.MapGroup(RouteGroupPrefix); var authGroup = app.MapGroup(RouteGroupPrefix);
authGroup.MapLoginEndpoint(); authGroup.MapLoginEndpoint();
authGroup.MapLogoutEndpoint();
authGroup.MapRefreshEndpoint() authGroup.MapRefreshEndpoint()
.RequireAuthorization(Schemes.AllowExpiredTokens); .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) public static string GetRefreshTokenFromCookie(this HttpContext context)
{ {
return context.Request.Cookies[RefreshTokenCookieName] ?? string.Empty; return context.Request.Cookies[RefreshTokenCookieName] ?? string.Empty;
+76 -80
View File
@@ -1,106 +1,102 @@
<script setup lang="ts"> <script setup lang="ts">
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue'; import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
import { useUserStore } from '@/stores/userStore'; import { useAuthService } from '@/composables/useAuthService';
import { computed } from 'vue'; import { useUserStore } from '@/stores/userStore';
import { computed } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
const userStore = useUserStore(); const userStore = useUserStore();
const router = useRouter(); const router = useRouter();
const authService = useAuthService(userStore);
const asideClasses = computed(() => ({ const asideClasses = computed(() => ({
collapsed: userStore.user?.sidebarCollapsed, collapsed: userStore.user?.sidebarCollapsed,
})); }));
function handleToggleButtonClick() { function handleToggleButtonClick() {
userStore.toggleSidebar(); userStore.toggleSidebar();
}
async function handleLogout() {
const logoutResult = await authService.logout();
if (logoutResult.err) {
alert(logoutResult.val.map(e => e.message).join('\n'));
return;
} }
function handleLogout() { userStore.logUserOut();
// TODO: Also need to log user router.push({ path: '/public/login' });
// out on the server...which is }
// basically just clearing
// refresh token cookie and revoking
// it in the database
userStore.logUserOut();
router.push({ path: '/public/login' });
}
</script> </script>
<template> <template>
<aside :class="asideClasses"> <aside :class="asideClasses">
<button <button @click="handleToggleButtonClick" type="button" class="toggle-button">
@click="handleToggleButtonClick"
type="button"
class="toggle-button"
>
<LeftArrowIcon /> <LeftArrowIcon />
</button> </button>
<button <button class="logout-button" type="button" @click="handleLogout">
class="logout-button"
type="button"
@click="handleLogout"
>
Logout Logout
</button> </button>
</aside> </aside>
</template> </template>
<style scoped> <style scoped>
aside {
--sidebar-width: 15.625rem;
--button-size: 1.5rem;
--transition-duration: 0.5s;
--transition-function: ease-in-out;
position: relative;
width: var(--sidebar-width);
height: 100%;
z-index: 999;
background: var(--bg-surface);
transition-property: width, transform;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-function);
}
@media screen and (max-width: 48rem) {
aside { aside {
--sidebar-width: 15.625rem;
--button-size: 1.5rem;
--transition-duration: 0.5s;
--transition-function: ease-in-out;
position: relative;
width: var(--sidebar-width);
height: 100%;
z-index: 999;
background: var(--bg-surface);
transition-property: width, transform;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-function);
}
@media screen and (max-width: 48rem) {
aside {
position: absolute;
right: 0;
}
}
.logout-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
.toggle-button {
display: flex;
align-items: center;
justify-content: center;
position: absolute; position: absolute;
top: 3rem; right: 0;
left: calc(-1 * var(--button-size) / 2);
height: var(--button-size);
width: var(--button-size);
background: var(--bg-element);
border-radius: 50%;
transition-property: transform;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-function);
} }
}
aside.collapsed { .logout-button {
width: calc(0.125rem + var(--button-size) / 2); background: var(--bg-element);
} padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
aside.collapsed .toggle-button { .toggle-button {
transform: rotate(180deg); display: flex;
} align-items: center;
justify-content: center;
position: absolute;
top: 3rem;
left: calc(-1 * var(--button-size) / 2);
height: var(--button-size);
width: var(--button-size);
background: var(--bg-element);
border-radius: 50%;
transition-property: transform;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-function);
}
.toggle-button svg { aside.collapsed {
--size: 1rem; width: calc(0.125rem + var(--button-size) / 2);
width: var(--size); }
height: var(--size);
} aside.collapsed .toggle-button {
transform: rotate(180deg);
}
.toggle-button svg {
--size: 1rem;
width: var(--size);
height: var(--size);
}
</style> </style>
@@ -19,6 +19,7 @@ export class AuthServiceFactory implements IAuthServiceFactory {
export interface IAuthService { export interface IAuthService {
login: (username: string, password: string) => Promise<Result<LoginResponse, Error[]>>; login: (username: string, password: string) => Promise<Result<LoginResponse, Error[]>>;
refreshToken: () => Promise<Result<LoginResponse, Error[]>>; refreshToken: () => Promise<Result<LoginResponse, Error[]>>;
logout: () => Promise<Result<boolean, Error[]>>;
} }
export class AuthService implements IAuthService { export class AuthService implements IAuthService {
@@ -26,12 +27,30 @@ export class AuthService implements IAuthService {
private readonly endpoints = { private readonly endpoints = {
login: '/api/auth/login', login: '/api/auth/login',
refreshToken: '/api/auth/refresh', refreshToken: '/api/auth/refresh',
logout: '/api/auth/logout',
}; };
constructor(client: IClient) { constructor(client: IClient) {
this.client = client; 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) { async login(username: string, password: string) {
const request = new ClientRequestWithBody( const request = new ClientRequestWithBody(
this.endpoints.login, 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);
}
}