Jwt rough setup

This commit is contained in:
2026-03-20 18:54:27 +00:00
parent 5271343a25
commit 2374574220
7 changed files with 81 additions and 36 deletions

View File

@@ -1,15 +1,42 @@
using Seasoned.Backend.Services;
using Microsoft.AspNetCore.HttpOverrides;
using System.Text.Json;
using System.Text;
using Microsoft.EntityFrameworkCore;
using Seasoned.Backend.Data;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using DotNetEnv;
Env.Load("../.env");
var builder = WebApplication.CreateBuilder(args);
builder.Configuration.AddEnvironmentVariables();
var jwtKey = builder.Configuration["Jwt:Key"]
?? throw new InvalidOperationException("JWT Key is missing from configuration!");
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "SeasonedAPI";
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "SeasonedFrontend";
builder.Services.AddAuthentication(options => {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options => {
options.TokenValidationParameters = new TokenValidationParameters {
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwtIssuer,
ValidAudience = jwtAudience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
};
});
builder.Services.AddScoped<IRecipeService, RecipeService>();
@@ -21,22 +48,8 @@ builder.Services.AddIdentityApiEndpoints<IdentityUser>( options => {
options.Password.RequireLowercase = false;
options.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.ConfigureApplicationCookie(options =>
{
options.Cookie.Name = "Seasoned.Session";
options.Cookie.HttpOnly = true;
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.ExpireTimeSpan = TimeSpan.FromMinutes(30);
options.SlidingExpiration = true;
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
});
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddDefaultTokenProviders();
builder.Services.AddAuthorization();
@@ -93,6 +106,11 @@ using (var scope = app.Services.CreateScope())
}
}
app.UseForwardedHeaders(new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto
});
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseCors("SeasonedOriginPolicy");

View File

@@ -10,8 +10,10 @@
<ItemGroup>
<PackageReference Include="dotenv.net" Version="4.0.1" />
<PackageReference Include="DotNetEnv" Version="3.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.2" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.13" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
<PackageReference Include="Mscc.GenerativeAI" Version="2.2.8" />
<PackageReference Include="pgvector" Version="0.3.2" />
<PackageReference Include="Pgvector.EntityFrameworkCore" Version="0.3.0" />

View File

@@ -50,17 +50,23 @@
import { onMounted } from 'vue'
import '@/assets/css/app-theme.css'
import SessionTimeout from './components/SessionTimeout.vue'
const authCookie = useCookie('.AspNetCore.Identity.Application')
const isLoggedIn = useState('isLoggedIn', () => false)
onMounted(() => {
if (authCookie.value) isLoggedIn.value = true
if (import.meta.client) {
const token = localStorage.getItem('auth_token')
if (token) {
isLoggedIn.value = true
}
}
})
const logout = () => {
authCookie.value = null
isLoggedIn.value = false
if (import.meta.client) localStorage.removeItem('token')
if (import.meta.client) {
localStorage.removeItem('auth_token')
localStorage.removeItem('token')
}
navigateTo('/login')
}
</script>

View File

@@ -145,23 +145,27 @@ const handleAuth = async () => {
authLoading.value = true
const endpoint = isLogin.value ? 'api/auth/login' : 'api/auth/register'
const url = isLogin.value
? `${config.public.apiBase}${endpoint}?useCookies=true&useSessionCookies=false`
: `${config.public.apiBase}${endpoint}`
const url = `${config.public.apiBase}${endpoint}`
try {
const response = await $fetch(url, {
method: 'POST',
body: {
email: email.value,
password: password.value
},
credentials: 'include'
password: password.value,
useCookies: false,
useSessionCookies: false
}
})
if (isLogin.value) {
isLoggedIn.value = true
navigateTo('/')
if (response.accessToken) {
localStorage.setItem('auth_token', response.accessToken)
isLoggedIn.value = true
navigateTo('/')
} else {
throw new Error('Token not received')
}
} else {
isLogin.value = true
authLoading.value = false

View File

@@ -3,18 +3,33 @@ export default defineNuxtPlugin((nuxtApp) => {
const isLoggedIn = useState('isLoggedIn');
nuxtApp.hook('app:created', () => {
const originalFetch = globalThis.$fetch;
globalThis.$fetch = originalFetch.create({
onRequest({ options }) {
if (import.meta.client) {
const token = localStorage.getItem('auth_token');
if (token) {
const headers = new Headers(options.headers);
headers.set('Authorization', `Bearer ${token}`);
options.headers = headers;
}
}
},
onResponseError({ response }) {
if (response.status === 401) {
console.warn("Session Interceptor: Caught 401 Unauthorized.");
const route = useRoute();
if (route.path !== '/login') {
isLoggedIn.value = false;
if (import.meta.client) {
localStorage.removeItem('auth_token');
}
showTimeout.value = true;
}
}

View File

@@ -21,8 +21,8 @@ vi.stubGlobal('$fetch', mockFetch)
const mockNavigate = vi.fn()
vi.stubGlobal('navigateTo', mockNavigate)
vi.stubGlobal('localStorage', { setItem: vi.fn(), getItem: vi.fn(), removeItem: vi.fn() })
// Mock Nuxt's useState
vi.stubGlobal('useState', () => ({ value: false }))
describe('LoginPage.vue', () => {
@@ -37,15 +37,12 @@ describe('LoginPage.vue', () => {
it('switches between Login and Register modes', async () => {
const wrapper = mount(LoginPage, mountOptions)
// Default is Login
expect(wrapper.find('h1').text()).toBe('Sign In')
expect(wrapper.find('input[label="Confirm Password"]').exists()).toBe(false)
// Click toggle
await wrapper.find('.auth-toggle-btn').trigger('click')
expect(wrapper.find('h1').text()).toBe('Join Us')
// V-expand-transition might need a tick or we check the v-if logic
expect(wrapper.vm.isLogin).toBe(false)
})
@@ -65,7 +62,7 @@ describe('LoginPage.vue', () => {
})
it('calls login API and redirects on success', async () => {
mockFetch.mockResolvedValueOnce({ token: 'fake-token' })
mockFetch.mockResolvedValueOnce({ accessToken: 'fake-token' })
const wrapper = mount(LoginPage, mountOptions)
const vm = wrapper.vm as any

View File

@@ -25,6 +25,9 @@ services:
environment:
- GEMINI_API_KEY=${GEMINI_API_KEY}
- NUXT_PUBLIC_API_BASE=${NUXT_PUBLIC_API_BASE}
- Jwt__Key=${JWT__KEY}
- Jwt__Issuer=${JWT__ISSUER}
- Jwt__Audience=${JWT__AUDIENCE}
- ConnectionStrings__DefaultConnection=${ConnectionStrings__DefaultConnection}
- ASPNETCORE_ENVIRONMENT=Production
- ASPNETCORE_HTTP_PORTS=8080