feat: allow users to logout
This commit is contained in:
@@ -9,6 +9,7 @@ internal static class AuthExtensions
|
||||
var authGroup = app.MapGroup(RouteGroupPrefix);
|
||||
|
||||
authGroup.MapLoginEndpoint();
|
||||
authGroup.MapLogoutEndpoint();
|
||||
|
||||
authGroup.MapRefreshEndpoint()
|
||||
.RequireAuthorization(Schemes.AllowExpiredTokens);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,106 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { computed } from 'vue';
|
||||
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 userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
const authService = useAuthService(userStore);
|
||||
|
||||
const asideClasses = computed(() => ({
|
||||
collapsed: userStore.user?.sidebarCollapsed,
|
||||
}));
|
||||
const asideClasses = computed(() => ({
|
||||
collapsed: userStore.user?.sidebarCollapsed,
|
||||
}));
|
||||
|
||||
function handleToggleButtonClick() {
|
||||
userStore.toggleSidebar();
|
||||
function handleToggleButtonClick() {
|
||||
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() {
|
||||
// TODO: Also need to log user
|
||||
// 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' });
|
||||
}
|
||||
userStore.logUserOut();
|
||||
router.push({ path: '/public/login' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
--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;
|
||||
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);
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
aside.collapsed {
|
||||
width: calc(0.125rem + var(--button-size) / 2);
|
||||
}
|
||||
.logout-button {
|
||||
background: var(--bg-element);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
aside.collapsed .toggle-button {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.toggle-button {
|
||||
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 {
|
||||
--size: 1rem;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
}
|
||||
aside.collapsed {
|
||||
width: calc(0.125rem + var(--button-size) / 2);
|
||||
}
|
||||
|
||||
aside.collapsed .toggle-button {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.toggle-button svg {
|
||||
--size: 1rem;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user