From 2beeadd42c8812a67d6f5af6d44e55dd0fb92d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Veikko=20Lintuj=C3=A4rvi?= Date: Mon, 2 Mar 2026 22:26:50 +0200 Subject: [PATCH] Add CORS config and auth with JWT --- api/App.Tests/ApiEndpointsTests.cs | 38 +++++++++++++ api/App/App.csproj | 1 + api/App/Endpoints/AuthEndpoints.cs | 77 +++++++++++++++++++++++++++ api/App/Endpoints/LokEndpoints.cs | 9 ++++ api/App/Endpoints/SystemEndpoints.cs | 2 + api/App/Program.cs | 66 ++++++++++++++++++++--- api/App/appsettings.Development.json | 17 ++++++ api/App/appsettings.json | 17 ++++++ api/Database/klapi.db | Bin 16384 -> 16384 bytes api/Tests/Http/LokOpenHours.http | 13 +++++ ui/src/api/index.ts | 40 ++++++++++++++ ui/src/api/url.test.ts | 8 +-- ui/src/app.tsx | 8 ++- ui/src/components/Nav.tsx | 9 ++-- ui/src/i18n.ts | 2 + ui/src/routes/login.tsx | 22 ++++++-- ui/src/state/appState.ts | 1 + 17 files changed, 307 insertions(+), 23 deletions(-) create mode 100644 api/App/Endpoints/AuthEndpoints.cs diff --git a/api/App.Tests/ApiEndpointsTests.cs b/api/App.Tests/ApiEndpointsTests.cs index 9a99475..f6915df 100644 --- a/api/App.Tests/ApiEndpointsTests.cs +++ b/api/App.Tests/ApiEndpointsTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; using Microsoft.AspNetCore.Hosting; @@ -37,6 +38,32 @@ public class ApiEndpointsTests(ApiTestFactory factory) : IClassFixture(); + Assert.NotNull(auth); + Assert.False(string.IsNullOrWhiteSpace(auth.AccessToken)); + + _client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", auth.AccessToken); + var createPayload = new { id = 0, @@ -189,3 +216,14 @@ public class LokOpenHoursDto public string KitchenNotice { get; set; } = string.Empty; } + +public class AuthTokenDto +{ + public string AccessToken { get; set; } = string.Empty; + + public string Email { get; set; } = string.Empty; + + public string TokenType { get; set; } = string.Empty; + + public int ExpiresIn { get; set; } +} diff --git a/api/App/App.csproj b/api/App/App.csproj index 305ef0b..1bb0572 100644 --- a/api/App/App.csproj +++ b/api/App/App.csproj @@ -7,6 +7,7 @@ + diff --git a/api/App/Endpoints/AuthEndpoints.cs b/api/App/Endpoints/AuthEndpoints.cs new file mode 100644 index 0000000..15d4ee8 --- /dev/null +++ b/api/App/Endpoints/AuthEndpoints.cs @@ -0,0 +1,77 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; + +public record AuthTokenRequest(string Email, string Password); + +public class AuthUser +{ + public string Email { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} + +public class AuthOptions +{ + public string Issuer { get; set; } = "klapi-api"; + public string Audience { get; set; } = "klapi-ui"; + public string SigningKey { get; set; } = string.Empty; + public List AllowedOrigins { get; set; } = []; + public List Users { get; set; } = []; +} + +public static class AuthEndpoints +{ + public static void MapAuthEndpoints(WebApplication app) + { + app.MapPost("/auth/token", ( + HttpContext httpContext, + IOptions authOptions, + AuthTokenRequest request) => + { + if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password)) + { + return Results.BadRequest(new { Message = "Email and password are required." }); + } + + var options = authOptions.Value; + var user = options.Users.FirstOrDefault(item => + string.Equals(item.Email, request.Email.Trim(), StringComparison.OrdinalIgnoreCase)); + + if (user is null || !string.Equals(user.Password, request.Password, StringComparison.Ordinal)) + { + return Results.Unauthorized(); + } + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.SigningKey)); + var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, user.Email), + new(JwtRegisteredClaimNames.Email, user.Email), + new(ClaimTypes.Name, user.Email), + new("scope", "openhours:write") + }; + + var token = new JwtSecurityToken( + issuer: options.Issuer, + audience: options.Audience, + claims: claims, + expires: DateTime.UtcNow.AddHours(12), + signingCredentials: credentials); + + var tokenValue = new JwtSecurityTokenHandler().WriteToken(token); + + return Results.Ok(new + { + AccessToken = tokenValue, + Email = user.Email, + TokenType = "Bearer", + ExpiresIn = 43200 + }); + }) + .RequireCors("FrontendWriteCors") + .WithName("CreateAuthToken"); + } +} diff --git a/api/App/Endpoints/LokEndpoints.cs b/api/App/Endpoints/LokEndpoints.cs index 23c00b0..32a57a4 100644 --- a/api/App/Endpoints/LokEndpoints.cs +++ b/api/App/Endpoints/LokEndpoints.cs @@ -32,6 +32,8 @@ public static class LokEndpoints httpContext.Response.Headers.Location = "/lok/open-hours"; await httpContext.Response.WriteAsJsonAsync(createdOpenHours); }) + .RequireAuthorization("OpenHoursWrite") + .RequireCors("FrontendWriteCors") .WithName("CreateLokOpenHours"); app.MapGet("/lok/open-hours", async (HttpContext httpContext) => @@ -51,6 +53,7 @@ public static class LokEndpoints await httpContext.Response.WriteAsJsonAsync(openHours); }) + .RequireCors("PublicReadCors") .WithName("GetLokOpenHours"); app.MapDelete("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) => @@ -70,6 +73,8 @@ public static class LokEndpoints httpContext.Response.StatusCode = StatusCodes.Status204NoContent; }) + .RequireAuthorization("OpenHoursWrite") + .RequireCors("FrontendWriteCors") .WithName("DeleteLokOpenHours"); app.MapPut("/lok/open-hours/{id:long}", async (HttpContext httpContext, long id) => @@ -111,6 +116,8 @@ public static class LokEndpoints await httpContext.Response.WriteAsJsonAsync(updatedOpenHours); }) + .RequireAuthorization("OpenHoursWrite") + .RequireCors("FrontendWriteCors") .WithName("UpdateLokOpenHours"); app.MapPut("/lok/open-hours/{id:long}/active", async (HttpContext httpContext, long id) => @@ -134,6 +141,8 @@ public static class LokEndpoints IsActive = true }); }) + .RequireAuthorization("OpenHoursWrite") + .RequireCors("FrontendWriteCors") .WithName("SetActiveLokOpenHours"); } } \ No newline at end of file diff --git a/api/App/Endpoints/SystemEndpoints.cs b/api/App/Endpoints/SystemEndpoints.cs index 64b9042..7d6e026 100644 --- a/api/App/Endpoints/SystemEndpoints.cs +++ b/api/App/Endpoints/SystemEndpoints.cs @@ -9,6 +9,7 @@ public static class SystemEndpoints Version = "1.0.0" }; }) + .RequireCors("PublicReadCors") .WithName("GetVersion"); app.MapGet("/health/db", async (Microsoft.Data.Sqlite.SqliteConnection connection) => @@ -24,6 +25,7 @@ public static class SystemEndpoints Result = result }; }) + .RequireCors("PublicReadCors") .WithName("GetDatabaseHealth"); } } \ No newline at end of file diff --git a/api/App/Program.cs b/api/App/Program.cs index 98d989d..b21afd7 100644 --- a/api/App/Program.cs +++ b/api/App/Program.cs @@ -1,4 +1,7 @@ using Microsoft.Data.Sqlite; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; public class Program { @@ -19,21 +22,66 @@ public class Program sqliteConnectionStringBuilder.DataSource = databasePath; var resolvedConnectionString = sqliteConnectionStringBuilder.ToString(); + var authOptions = builder.Configuration.GetSection("Auth").Get() + ?? throw new InvalidOperationException("Auth configuration was not found."); + + if (string.IsNullOrWhiteSpace(authOptions.SigningKey) || authOptions.SigningKey.Length < 32) + { + throw new InvalidOperationException("Auth:SigningKey must be at least 32 characters long."); + } + + if (authOptions.Users.Count == 0) + { + throw new InvalidOperationException("At least one user must be configured under Auth:Users."); + } + + builder.Services.Configure(builder.Configuration.GetSection("Auth")); + builder.Services.AddScoped(_ => new SqliteConnection(resolvedConnectionString)); builder.Services.AddScoped(); builder.Services.AddCors(options => { - options.AddPolicy("UiCors", policy => + options.AddPolicy("PublicReadCors", policy => { policy - .WithOrigins( - "http://localhost:5173", - "http://127.0.0.1:5173", - "http://localhost:4173", - "http://127.0.0.1:4173") + .AllowAnyOrigin() .AllowAnyHeader() .AllowAnyMethod(); }); + + options.AddPolicy("FrontendWriteCors", policy => + { + policy + .WithOrigins(authOptions.AllowedOrigins.ToArray()) + .AllowAnyHeader() + .AllowAnyMethod(); + }); + }); + + builder.Services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateIssuerSigningKey = true, + ValidateLifetime = true, + ValidIssuer = authOptions.Issuer, + ValidAudience = authOptions.Audience, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authOptions.SigningKey)), + ClockSkew = TimeSpan.FromMinutes(1) + }; + }); + + builder.Services.AddAuthorization(options => + { + options.AddPolicy("OpenHoursWrite", policy => + { + policy.RequireAuthenticatedUser(); + policy.RequireClaim("scope", "openhours:write"); + }); }); builder.Services.AddOpenApi(); @@ -137,7 +185,10 @@ public class Program app.MapOpenApi(); } - app.UseCors("UiCors"); + app.UseCors(); + + app.UseAuthentication(); + app.UseAuthorization(); if (!app.Environment.IsDevelopment()) { @@ -145,6 +196,7 @@ public class Program } SystemEndpoints.MapSystemEndpoints(app); + AuthEndpoints.MapAuthEndpoints(app); LokEndpoints.MapLokEndpoints(app); app.Run(); diff --git a/api/App/appsettings.Development.json b/api/App/appsettings.Development.json index 14a99ec..66e5213 100644 --- a/api/App/appsettings.Development.json +++ b/api/App/appsettings.Development.json @@ -7,5 +7,22 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Auth": { + "Issuer": "klapi-api", + "Audience": "klapi-ui", + "SigningKey": "change-this-to-a-long-random-32-char-minimum-key", + "AllowedOrigins": [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:4173", + "http://127.0.0.1:4173" + ], + "Users": [ + { + "Email": "admin@klapi.local", + "Password": "changeme" + } + ] } } diff --git a/api/App/appsettings.json b/api/App/appsettings.json index 6d2b9db..c51cd8b 100644 --- a/api/App/appsettings.json +++ b/api/App/appsettings.json @@ -8,5 +8,22 @@ "Microsoft.AspNetCore": "Warning" } }, + "Auth": { + "Issuer": "klapi-api", + "Audience": "klapi-ui", + "SigningKey": "change-this-to-a-long-random-32-char-minimum-key", + "AllowedOrigins": [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:4173", + "http://127.0.0.1:4173" + ], + "Users": [ + { + "Email": "admin@klapi.local", + "Password": "changeme" + } + ] + }, "AllowedHosts": "*" } diff --git a/api/Database/klapi.db b/api/Database/klapi.db index 2de42416f4918a8241aedd650de5249120ad9567..515e77791e2d3ce34deb6a6d39ba10c3e9b6f4fc 100644 GIT binary patch delta 382 zcmZo@U~Fh$oFL7(WulBTDmE~l%q#QDicQtkOoNL-(p6FvXfQ`fNr|DMA*xEC nu}0>4#zw{#=7ttg<*7yGscFTjDVrT-_X#l?Y%=)I&u9Puj5A}< delta 177 zcmZo@U~Fh$oFL7(YNCuY?shK7bl21aJO2FAJuMj=K9Rt82^rj~k!29^fKMrKjv WsYT_fn;m8M2{Gz!GWgHWs0#q}lPoj< diff --git a/api/Tests/Http/LokOpenHours.http b/api/Tests/Http/LokOpenHours.http index e4c1b1b..1d7b934 100644 --- a/api/Tests/Http/LokOpenHours.http +++ b/api/Tests/Http/LokOpenHours.http @@ -1,5 +1,15 @@ @App_HostAddress = http://localhost:5013 +### Request token for protected open-hours endpoints +POST {{App_HostAddress}}/auth/token +Content-Type: application/json +Accept: application/json + +{ + "email": "admin@klapi.local", + "password": "changeme" +} + ### Get newest open hours (returns latest 5) GET {{App_HostAddress}}/lok/open-hours Accept: application/json @@ -10,6 +20,7 @@ Content-Type: application/json Accept: application/json { + "name": "Version 1", "paragraph1": "Version 1 paragraph 1", "paragraph2": "Version 1 paragraph 2", "paragraph3": "Version 1 paragraph 3", @@ -23,6 +34,7 @@ Content-Type: application/json Accept: application/json { + "name": "Version 2", "paragraph1": "Version 2 paragraph 1", "paragraph2": "Version 2 paragraph 2", "paragraph3": "Version 2 paragraph 3", @@ -36,6 +48,7 @@ Content-Type: application/json Accept: application/json { + "name": "Version 3", "paragraph1": "Version 3 paragraph 1", "paragraph2": "Version 3 paragraph 2", "paragraph3": "Version 3 paragraph 3", diff --git a/ui/src/api/index.ts b/ui/src/api/index.ts index 173643c..1cfaa14 100644 --- a/ui/src/api/index.ts +++ b/ui/src/api/index.ts @@ -1,5 +1,12 @@ import { buildApiUrl } from "./url"; +type AuthTokenResponse = { + accessToken: string; + email: string; + tokenType: string; + expiresIn: number; +}; + async function fetchApi(path: string, init?: RequestInit): Promise { const response = await fetch(buildApiUrl(path), { ...init, @@ -21,6 +28,14 @@ async function fetchApi(path: string, init?: RequestInit): Promise { return (await response.json()) as T; } +function getAccessToken() { + if (typeof window === "undefined") { + return ""; + } + + return localStorage.getItem("session-token") ?? ""; +} + export type LokOpenHours = { id: number; name: string; @@ -74,6 +89,9 @@ export async function createLokOpenHours( return await fetchApi("/lok/open-hours", { method: "POST", + headers: { + Authorization: `Bearer ${getAccessToken()}`, + }, body: JSON.stringify(payload), }); } @@ -106,6 +124,9 @@ export async function updateLokOpenHours( return await fetchApi(`/lok/open-hours/${id}`, { method: "PUT", + headers: { + Authorization: `Bearer ${getAccessToken()}`, + }, body: JSON.stringify(payload), }); } @@ -117,6 +138,9 @@ export async function deleteLokOpenHours(id: number): Promise { await fetchApi(`/lok/open-hours/${id}`, { method: "DELETE", + headers: { + Authorization: `Bearer ${getAccessToken()}`, + }, }); } @@ -131,6 +155,22 @@ export async function setActiveLokOpenHours( `/lok/open-hours/${id}/active`, { method: "PUT", + headers: { + Authorization: `Bearer ${getAccessToken()}`, + }, }, ); } + +export async function requestAuthToken( + email: string, + password: string, +): Promise { + return await fetchApi("/auth/token", { + method: "POST", + body: JSON.stringify({ + email, + password, + }), + }); +} diff --git a/ui/src/api/url.test.ts b/ui/src/api/url.test.ts index f10ae28..4ec9e54 100644 --- a/ui/src/api/url.test.ts +++ b/ui/src/api/url.test.ts @@ -3,14 +3,10 @@ import { buildApiUrl } from "./url"; describe("buildApiUrl", () => { it("joins base url and relative path without duplicate slashes", () => { - expect(buildApiUrl("/lok/open-hours")).toBe( - "http://localhost:5013/lok/open-hours", - ); + expect(buildApiUrl("/lok/open-hours")).toBe("/api/lok/open-hours"); }); it("accepts path without leading slash", () => { - expect(buildApiUrl("lok/open-hours/1")).toBe( - "http://localhost:5013/lok/open-hours/1", - ); + expect(buildApiUrl("lok/open-hours/1")).toBe("/api/lok/open-hours/1"); }); }); diff --git a/ui/src/app.tsx b/ui/src/app.tsx index dc80879..978c8ab 100644 --- a/ui/src/app.tsx +++ b/ui/src/app.tsx @@ -21,12 +21,16 @@ function AppShell() { useEffect(() => { const storedEmail = localStorage.getItem("session-email"); - if (!storedEmail) { + const storedToken = localStorage.getItem("session-token"); + if (!storedEmail || !storedToken) { setSession(null); return; } - setSession({ email: storedEmail }); + setSession({ + email: storedEmail, + token: storedToken, + }); }, [setSession]); useEffect(() => { diff --git a/ui/src/components/Nav.tsx b/ui/src/components/Nav.tsx index f8c75af..5e677cd 100644 --- a/ui/src/components/Nav.tsx +++ b/ui/src/components/Nav.tsx @@ -13,6 +13,7 @@ export default function Nav() { const signOut = () => { setSession(null); localStorage.removeItem("session-email"); + localStorage.removeItem("session-token"); navigate("/login"); }; @@ -34,8 +35,8 @@ export default function Nav() { type="button" onClick={() => setLanguage("fi")} className={`rounded px-2 py-1 text-xs ${language === "fi" - ? "bg-[#E3A977] text-[#4C250E]" - : "text-[#F5D1A9] hover:text-[#FFF7EE]" + ? "bg-[#E3A977] text-[#4C250E]" + : "text-[#F5D1A9] hover:text-[#FFF7EE]" }`} > {t("nav.language.fi")} @@ -44,8 +45,8 @@ export default function Nav() { type="button" onClick={() => setLanguage("en")} className={`rounded px-2 py-1 text-xs ${language === "en" - ? "bg-[#E3A977] text-[#4C250E]" - : "text-[#F5D1A9] hover:text-[#FFF7EE]" + ? "bg-[#E3A977] text-[#4C250E]" + : "text-[#F5D1A9] hover:text-[#FFF7EE]" }`} > {t("nav.language.en")} diff --git a/ui/src/i18n.ts b/ui/src/i18n.ts index fca8bd4..26bc973 100644 --- a/ui/src/i18n.ts +++ b/ui/src/i18n.ts @@ -55,6 +55,7 @@ const translations = { "notFound.goHome": "Go Home", "error.title": "Error", "errors.requiredEmailPassword": "Email and password are required", + "errors.invalidEmailOrPassword": "Invalid email or password", }, fi: { "nav.home": "Etusivu", @@ -106,6 +107,7 @@ const translations = { "notFound.goHome": "Takaisin etusivulle", "error.title": "Virhe", "errors.requiredEmailPassword": "Sähköposti ja salasana vaaditaan", + "errors.invalidEmailOrPassword": "Virheellinen sähköposti tai salasana", }, } as const; diff --git a/ui/src/routes/login.tsx b/ui/src/routes/login.tsx index ae618b0..ec7dd52 100644 --- a/ui/src/routes/login.tsx +++ b/ui/src/routes/login.tsx @@ -1,6 +1,7 @@ import { FormEvent, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useRecoilState } from "recoil"; +import { requestAuthToken } from "~/api"; import { useT } from "~/i18n"; import { sessionAtom } from "~/state/appState"; @@ -21,7 +22,7 @@ export default function Login() { navigate("/"); }, [session, navigate]); - const submit = (event: FormEvent) => { + const submit = async (event: FormEvent) => { event.preventDefault(); if (!email.trim() || !password.trim()) { @@ -30,9 +31,22 @@ export default function Login() { } const normalizedEmail = email.trim().toLowerCase(); - setSession({ email: normalizedEmail }); - localStorage.setItem("session-email", normalizedEmail); - navigate("/"); + + try { + const auth = await requestAuthToken(normalizedEmail, password); + + setSession({ + email: auth.email, + token: auth.accessToken, + }); + + localStorage.setItem("session-email", auth.email); + localStorage.setItem("session-token", auth.accessToken); + setError(""); + navigate("/"); + } catch { + setError(t("errors.invalidEmailOrPassword")); + } }; return ( diff --git a/ui/src/state/appState.ts b/ui/src/state/appState.ts index 3235e5d..f0c1b2d 100644 --- a/ui/src/state/appState.ts +++ b/ui/src/state/appState.ts @@ -5,6 +5,7 @@ export type Language = "fi" | "en"; export type Session = { email: string; + token: string; }; export type Toast = {