Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b93b29e51e | ||
|
|
868039d102 |
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj"
|
"appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj",
|
||||||
|
"defaultWatchEnabled": "true"
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user