diff --git a/Seasoned.Backend/Program.cs b/Seasoned.Backend/Program.cs index 5797f6e..7aa540b 100644 --- a/Seasoned.Backend/Program.cs +++ b/Seasoned.Backend/Program.cs @@ -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(); @@ -21,22 +48,8 @@ builder.Services.AddIdentityApiEndpoints( options => { options.Password.RequireLowercase = false; options.User.RequireUniqueEmail = true; }) - .AddEntityFrameworkStores(); - -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() +.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"); diff --git a/Seasoned.Backend/Seasoned.Backend.csproj b/Seasoned.Backend/Seasoned.Backend.csproj index 69a34e1..56ee40b 100644 --- a/Seasoned.Backend/Seasoned.Backend.csproj +++ b/Seasoned.Backend/Seasoned.Backend.csproj @@ -10,8 +10,10 @@ + + diff --git a/Seasoned.Frontend/app/app.vue b/Seasoned.Frontend/app/app.vue index 1332a1f..23beaf7 100644 --- a/Seasoned.Frontend/app/app.vue +++ b/Seasoned.Frontend/app/app.vue @@ -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') } \ No newline at end of file diff --git a/Seasoned.Frontend/app/pages/login.vue b/Seasoned.Frontend/app/pages/login.vue index d7e28da..377d252 100644 --- a/Seasoned.Frontend/app/pages/login.vue +++ b/Seasoned.Frontend/app/pages/login.vue @@ -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 diff --git a/Seasoned.Frontend/app/plugins/auth-watch.ts b/Seasoned.Frontend/app/plugins/auth-watch.ts index 6ce3521..e368303 100644 --- a/Seasoned.Frontend/app/plugins/auth-watch.ts +++ b/Seasoned.Frontend/app/plugins/auth-watch.ts @@ -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; } } diff --git a/Seasoned.Frontend/test/LoginPage.spec.ts b/Seasoned.Frontend/test/LoginPage.spec.ts index 7290886..477cc5e 100644 --- a/Seasoned.Frontend/test/LoginPage.spec.ts +++ b/Seasoned.Frontend/test/LoginPage.spec.ts @@ -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 diff --git a/compose.yaml b/compose.yaml index 71dce96..8bcb6b0 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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