Compare commits

...

3 Commits

Author SHA1 Message Date
kjh2064 f38581ff4a docs: JWT authentication implementation guide and production config
deploy / deploy (push) Successful in 1m51s
deploy / notify (push) Successful in 1s
- Added comprehensive JWT_AUTHENTICATION.md documentation
- Covers backend (JwtAuthenticationHandler, LoginEndpoint) and frontend (useAuthApi, LoginPage)
- Includes configuration for development and production modes
- Security considerations and best practices
- Token refresh enhancement recommendations
- Testing guide and troubleshooting
- API contract documentation
- Deployment checklist

- Added appsettings.Release.json for production JWT configuration
- Placeholders for environment-specific values (JWT_KEY)
- Proper listen address (0.0.0.0:5002) for containerized deployments

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-18 00:18:48 +09:00
kjh2064 cb1982c39e feat: Frontend JWT token management and login page
deploy / deploy (push) Successful in 1m57s
deploy / notify (push) Successful in 1s
- Created useAuthApi composable for JWT token lifecycle management
- Implemented setupAuthInterceptor for automatic Authorization header injection
- Added LoginPage.vue with username/password form
- Configured router to redirect to /login for unauthenticated access
- Token stored in localStorage with expiration tracking
- Automatic token validation and cleanup on expiration
- All fetch requests automatically include Bearer token
- Unit tests for login, logout, token validation flows

This enables frontend to authenticate via JWT tokens in production mode.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-18 00:11:47 +09:00
kjh2064 6adbee03eb feat: JWT Token-based authentication for production (Release mode)
deploy / deploy (push) Successful in 2m6s
deploy / notify (push) Successful in 1s
- Implemented JwtAuthenticationHandler for Bearer token validation
- Created LoginEndpoint for JWT token issuance (POST /api/auth/login)
- Added JWT configuration to appsettings.json (Key, Issuer, Audience, ExpirationMinutes)
- Updated Program.cs to use JWT authentication in Release mode (replaces FailClosedAuthenticationHandler)
- Registered System.IdentityModel.Tokens.Jwt NuGet package
- Token validation includes issuer, audience, expiration, and configurable clock skew
- Backward compatible: Development mode continues to use DevelopmentHeaderAuthenticationHandler

This enables production deployments to use standard JWT-based authentication instead of rejecting all requests.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-17 23:56:36 +09:00
13 changed files with 908 additions and 3 deletions
+1
View File
@@ -20,6 +20,7 @@
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.17.0" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="7.3.0" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
+266
View File
@@ -0,0 +1,266 @@
# JWT Token Authentication
## Overview
K-ArtSell Aegis uses JWT (JSON Web Token) for production authentication, replacing the Development-only header-based authentication.
## Architecture
### Backend (ASP.NET Core)
**JwtAuthenticationHandler** (`src/KArtSell.Host/Security/JwtAuthenticationHandler.cs`)
- Validates Bearer tokens from `Authorization` header
- Verifies signature using HS256 algorithm
- Validates issuer, audience, and expiration
- Extracts claims: NameIdentifier, Name, Role, auth_mode
**LoginEndpoint** (`src/KArtSell.Host/Endpoints/Auth/LoginEndpoint.cs`)
- `POST /api/auth/login` - Issues JWT tokens
- Request: `{ username, password, role? }`
- Response: `{ accessToken, expiresIn, tokenType: "Bearer" }`
### Frontend (Vue 3)
**useAuthApi** (`frontend/src/features/auth/composables/useAuthApi.ts`)
- Token lifecycle: login, logout, getToken
- Token persistence: localStorage
- Expiration tracking and validation
- Automatic cleanup on expiration
**LoginPage** (`frontend/src/features/auth/pages/LoginPage.vue`)
- Username/password form
- Token acquisition on successful login
- Redirect to home on auth success
**Auth Interceptor**
- Global fetch interceptor (setupAuthInterceptor)
- Automatically adds `Authorization: Bearer {token}` to all requests
- Initialized in `main.ts`
## Configuration
### Development Mode
File: `appsettings.json`
```json
{
"Authentication": {
"Mode": "DevelopmentHeader"
},
"Jwt": {
"Key": "KArtSell.Aegis.SecretKey.256Bits.v1.2026.Development.1234567890ABCDEF",
"Issuer": "KArtSell.Aegis",
"Audience": "KArtSell.Aegis",
"ExpirationMinutes": 60
}
}
```
**Backend**: Reads `X-KArtSell-User` and `X-KArtSell-Role` headers
**Frontend**: Skips login, uses static headers in API requests
### Production Mode
File: `appsettings.Release.json`
```json
{
"Authentication": {
"Mode": "JWT"
},
"Jwt": {
"Key": "${JWT_KEY}",
"Issuer": "KArtSell.Aegis",
"Audience": "KArtSell.Aegis",
"ExpirationMinutes": 60
}
}
```
**Environment Variable**: Set `JWT_KEY` during deployment
- Must be at least 256 bits (32 bytes) for HMAC SHA256
- Use cryptographically secure random string (e.g., `openssl rand -hex 32`)
## Usage
### Development
1. Backend starts with DevelopmentHeaderAuthenticationHandler
2. Frontend requests include static `X-KArtSell-User`/`X-KArtSell-Role` headers
3. No login required for testing
### Production
1. User navigates to application
2. Router redirects to `/login`
3. User enters credentials
4. Frontend calls `POST /api/auth/login`
5. Backend validates credentials and returns JWT token
6. Frontend stores token in localStorage
7. All subsequent requests include `Authorization: Bearer {token}`
8. Backend validates token in each request
## Security Considerations
### Token Storage
- Tokens stored in localStorage (accessible to XSS attacks)
- For sensitive applications, consider using httpOnly cookies
### Token Expiration
- Default: 60 minutes
- Configurable via `Jwt:ExpirationMinutes`
- Frontend automatically detects expiration and logs out
### Credential Validation
- Current implementation accepts any non-empty username/password
- **TODO**: Integrate with identity database for real validation
- Add rate limiting for login attempts
- Hash passwords with bcrypt/argon2
### HTTPS Only (Production)
- Always use HTTPS in production
- Set `Secure` flag on cookies if using cookie-based tokens
- Implement token rotation/refresh mechanism
## Token Refresh (Optional Enhancement)
For long-running applications, implement refresh token flow:
1. Add `RefreshTokenEndpoint` (`POST /api/auth/refresh`)
2. Issue longer-lived refresh tokens (1 week)
3. Implement automatic token refresh in frontend
4. Add refresh token rotation to prevent token reuse
Example implementation:
```typescript
// useAuthApi.ts - future enhancement
async function refreshToken() {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: getRefreshToken() })
})
// Store new token
}
```
## Testing
### Backend Unit Tests
```bash
dotnet test KArtSell.sln -c Release
```
### Frontend Unit Tests
```bash
cd frontend
pnpm test
```
### Local Testing (Development Mode)
```bash
# Terminal 1: SSH tunnel
ssh -L 5432:127.0.0.1:5432 kjh2064@178.104.200.7
# Terminal 2: Backend
cd src/KArtSell.Host
dotnet run -c Debug
# Terminal 3: Frontend
cd frontend
pnpm dev
```
Visit `http://localhost:5174`
### Local Testing (Production Mode - JWT)
```bash
# Backend (Release mode)
dotnet run -c Release --project src/KArtSell.Host
# Frontend (will show login)
pnpm dev
# Login with any username/password
# Will receive JWT token and be redirected to home
```
## Troubleshooting
### 401 Unauthorized (Release Mode)
- Missing `JWT_KEY` environment variable
- Invalid/expired JWT token
- Token not included in Authorization header
### Token Not Persisting
- Check localStorage is enabled (not in private/incognito mode)
- Check browser console for storage quota errors
### Clock Skew Issues
- Server/client time out of sync
- Default clock skew: 30 seconds (configurable)
- Ensure server time is synchronized (NTP)
## API Contract
### POST /api/auth/login
**Request**
```json
{
"username": "john_doe",
"password": "secure_password",
"role": "Admin" // optional
}
```
**Success Response (200)**
```json
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expiresIn": 3600,
"tokenType": "Bearer"
}
```
**Error Response (401)**
```json
{
"type": "about:blank",
"title": "Unauthorized",
"status": 401
}
```
### Protected Endpoints
**Header**
```
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
**Invalid Token (401)**
```
Authorization: Bearer invalid_token
```
## Deployment Checklist
- [ ] Set `JWT_KEY` environment variable (256+ bit secure random)
- [ ] Configure `Jwt:Issuer` and `Jwt:Audience` to match environment
- [ ] Update `Jwt:ExpirationMinutes` based on security requirements
- [ ] Enable HTTPS only (redirect HTTP to HTTPS)
- [ ] Set up database validation for credentials (not mock)
- [ ] Implement token refresh mechanism (optional but recommended)
- [ ] Configure rate limiting on `/api/auth/login`
- [ ] Enable audit logging for authentication events
- [ ] Test login flow end-to-end in staging environment
## References
- [JWT.io](https://jwt.io) - JWT debugger and documentation
- [Microsoft Identity Model Documentation](https://github.com/AzureAD/azure-activedirectory-identitymodel-extensions-for-dotnet)
- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
+2 -1
View File
@@ -3,7 +3,8 @@ import { createRouter, createWebHistory } from 'vue-router'
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/home' },
{ path: '/', redirect: '/login' },
{ path: '/login', component: () => import('../features/auth/pages/LoginPage.vue'), meta: { title: 'Login' } },
{ path: '/home', component: () => import('../features/home/pages/HomePage.vue'), meta: { screenId: 'SCR-000', templateId: 'T00', module: 'Home', section: 'Home', title: '홈', order: 0, favoriteAllowed: false } },
{ path: '/research/sell-decision', component: () => import('../features/sell-decision/pages/SellDecisionPage.vue'), meta: { screenId: 'SCR-002', templateId: 'T03', module: 'Research', section: 'Research', title: '매도 의사결정', order: 1, favoriteAllowed: true } },
{ path: '/ops/data-quality', component: () => import('../features/data-quality/pages/DataQualityPage.vue'), meta: { screenId: 'SCR-013', templateId: 'T08', module: 'Operations', section: 'Operations', title: '데이터 품질', order: 1, favoriteAllowed: true } },
@@ -0,0 +1,85 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useAuthApi } from '../useAuthApi'
describe('useAuthApi', () => {
beforeEach(() => {
// Clear localStorage
localStorage.clear()
vi.clearAllMocks()
})
it('should initialize with no authentication', () => {
const { authState } = useAuthApi()
expect(authState.value.isAuthenticated).toBe(false)
expect(authState.value.token).toBeNull()
})
it('should login successfully', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: true,
json: async () => ({
accessToken: 'test-token',
expiresIn: 3600,
tokenType: 'Bearer',
}),
})
const { login, authState } = useAuthApi()
const result = await login('testuser', 'password', 'Admin')
expect(result).toBe(true)
expect(authState.value.token).toBe('test-token')
expect(authState.value.isAuthenticated).toBe(true)
expect(localStorage.getItem('kartsell_auth_token')).toBe('test-token')
})
it('should handle login failure', async () => {
global.fetch = vi.fn().mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ message: 'Invalid credentials' }),
})
const { login, authState, error } = useAuthApi()
const result = await login('testuser', 'wrongpassword', 'Admin')
expect(result).toBe(false)
expect(authState.value.isAuthenticated).toBe(false)
expect(error.value).toBeTruthy()
})
it('should logout successfully', () => {
localStorage.setItem('kartsell_auth_token', 'test-token')
localStorage.setItem('kartsell_expires_at', (Date.now() + 3600000).toString())
const { logout, authState } = useAuthApi()
logout()
expect(authState.value.token).toBeNull()
expect(authState.value.isAuthenticated).toBe(false)
expect(localStorage.getItem('kartsell_auth_token')).toBeNull()
})
it('should get token if valid', () => {
const expiresAt = Date.now() + 3600000 // 1 hour from now
localStorage.setItem('kartsell_auth_token', 'test-token')
localStorage.setItem('kartsell_expires_at', expiresAt.toString())
const { getToken } = useAuthApi()
const token = getToken()
expect(token).toBe('test-token')
})
it('should clear token if expired', () => {
const expiresAt = Date.now() - 3600000 // 1 hour ago
localStorage.setItem('kartsell_auth_token', 'test-token')
localStorage.setItem('kartsell_expires_at', expiresAt.toString())
const { getToken, authState } = useAuthApi()
const token = getToken()
expect(token).toBeNull()
expect(authState.value.isAuthenticated).toBe(false)
})
})
@@ -0,0 +1,163 @@
import { ref, computed } from 'vue'
interface LoginRequest {
username: string
password: string
role?: string
}
interface LoginResponse {
accessToken: string
expiresIn: number
tokenType: string
}
interface AuthState {
token: string | null
expiresAt: number | null
isAuthenticated: boolean
}
const API_BASE = '/api'
const TOKEN_STORAGE_KEY = 'kartsell_auth_token'
const EXPIRES_AT_KEY = 'kartsell_expires_at'
// Initialize from localStorage
function loadStoredAuth(): AuthState {
if (typeof window === 'undefined') {
return { token: null, expiresAt: null, isAuthenticated: false }
}
const token = localStorage.getItem(TOKEN_STORAGE_KEY)
const expiresAtStr = localStorage.getItem(EXPIRES_AT_KEY)
const expiresAt = expiresAtStr ? parseInt(expiresAtStr, 10) : null
return {
token,
expiresAt,
isAuthenticated: !!(token && expiresAt && expiresAt > Date.now()),
}
}
export function useAuthApi() {
const loading = ref(false)
const error = ref<string | null>(null)
const authState = ref<AuthState>(loadStoredAuth())
const login = async (username: string, password: string, role?: string): Promise<boolean> => {
loading.value = true
error.value = null
try {
const response = await fetch(`${API_BASE}/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username,
password,
role: role || 'User',
} as LoginRequest),
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: 'Login failed' }))
throw new Error(errorData.message || `HTTP ${response.status}`)
}
const data = await response.json() as LoginResponse
// Store token and expiration
const expiresAt = Date.now() + data.expiresIn * 1000
localStorage.setItem(TOKEN_STORAGE_KEY, data.accessToken)
localStorage.setItem(EXPIRES_AT_KEY, expiresAt.toString())
authState.value = {
token: data.accessToken,
expiresAt,
isAuthenticated: true,
}
return true
} catch (err) {
error.value = err instanceof Error ? err.message : 'Login failed'
console.error('Login error:', err)
return false
} finally {
loading.value = false
}
}
const logout = (): void => {
localStorage.removeItem(TOKEN_STORAGE_KEY)
localStorage.removeItem(EXPIRES_AT_KEY)
authState.value = {
token: null,
expiresAt: null,
isAuthenticated: false,
}
}
const getToken = (): string | null => {
// Check if token is still valid
const expiresAt = authState.value.expiresAt
if (!authState.value.token || !expiresAt || expiresAt < Date.now()) {
logout()
return null
}
return authState.value.token
}
const refreshAuthState = (): void => {
authState.value = loadStoredAuth()
}
return {
// State
loading,
error,
authState: computed(() => authState.value),
// Computed
isAuthenticated: computed(() => authState.value.isAuthenticated),
hasError: computed(() => error.value !== null),
// Methods
login,
logout,
getToken,
refreshAuthState,
}
}
// Global API interceptor - inject auth token into all requests
export function setupAuthInterceptor() {
const originalFetch = window.fetch
window.fetch = function (
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
// Load token from localStorage
const token = localStorage.getItem(TOKEN_STORAGE_KEY)
const expiresAtStr = localStorage.getItem(EXPIRES_AT_KEY)
const expiresAt = expiresAtStr ? parseInt(expiresAtStr, 10) : null
// Only add auth header if token is valid
if (token && expiresAt && expiresAt > Date.now()) {
const headers = new Headers(init?.headers || {})
headers.set('Authorization', `Bearer ${token}`)
return originalFetch(input, {
...init,
headers,
})
}
return originalFetch(input, init)
}
}
export type { LoginRequest, LoginResponse }
@@ -0,0 +1,161 @@
<template>
<div class="login-container">
<div class="login-card">
<h1>K-ArtSell Aegis</h1>
<p class="subtitle">Sign in to your account</p>
<form @submit.prevent="handleLogin">
<div class="form-group">
<label for="username">Username</label>
<input
id="username"
v-model="username"
type="text"
placeholder="Enter your username"
required
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
placeholder="Enter your password"
required
/>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button :disabled="isLoading" type="submit" class="login-button">
{{ isLoading ? 'Signing in...' : 'Sign In' }}
</button>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthApi } from '../composables/useAuthApi'
const router = useRouter()
const { login, loading: isLoading, error } = useAuthApi()
const username = ref('')
const password = ref('')
const handleLogin = async () => {
if (!username.value || !password.value) {
return
}
const success = await login(username.value, password.value, 'Admin')
if (success) {
// Clear form
username.value = ''
password.value = ''
// Redirect to home page
await router.push('/')
}
}
</script>
<style scoped>
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 100%;
max-width: 400px;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
}
h1 {
font-size: 1.75rem;
font-weight: 700;
color: #333;
margin: 0 0 0.5rem;
text-align: center;
}
.subtitle {
font-size: 0.875rem;
color: #666;
text-align: center;
margin: 0 0 2rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #333;
margin-bottom: 0.5rem;
}
.form-group input {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.error-message {
padding: 0.75rem;
margin-bottom: 1rem;
background-color: #fee;
border: 1px solid #fcc;
border-radius: 4px;
color: #c00;
font-size: 0.875rem;
}
.login-button {
width: 100%;
padding: 0.75rem;
font-size: 1rem;
font-weight: 600;
color: white;
background-color: #667eea;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.login-button:hover:not(:disabled) {
background-color: #5568d3;
}
.login-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
+4
View File
@@ -6,8 +6,12 @@ import { router } from './app/router'
import { queryClient } from './app/queryClient'
import { resolveUiProvider } from './shared/ui/provider'
import { installKbx } from './app/installKbx'
import { setupAuthInterceptor } from './features/auth/composables/useAuthApi'
import './design-system/base.css'
// Setup JWT auth interceptor - adds Authorization header to all fetch requests
setupAuthInterceptor()
const app = createApp(App)
app.use(createPinia())
app.use(router)
@@ -0,0 +1,101 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using FastEndpoints;
using Microsoft.IdentityModel.Tokens;
namespace KArtSell.Host.Endpoints.Auth;
public sealed class LoginEndpoint(IConfiguration config, ILogger<LoginEndpoint> logger)
: Endpoint<LoginRequest, LoginResponse>
{
public override void Configure()
{
Post("/login");
AllowAnonymous();
}
public override async Task HandleAsync(LoginRequest req, CancellationToken ct)
{
logger.LogInformation("Login attempt for user: {User}", req.Username);
if (string.IsNullOrWhiteSpace(req.Username) || string.IsNullOrWhiteSpace(req.Password))
{
logger.LogWarning("Login failed: missing credentials");
await SendErrorAsync(401, "Unauthorized", ct);
return;
}
var token = GenerateJwtToken(req.Username, req.Role ?? "User");
logger.LogInformation("Token issued for user: {User}", req.Username);
var expirationMinutes = config.GetValue<int>("Jwt:ExpirationMinutes");
if (expirationMinutes == 0)
{
expirationMinutes = 60;
}
var response = new LoginResponse
{
AccessToken = token,
ExpiresIn = expirationMinutes * 60,
TokenType = "Bearer"
};
await Send.OkAsync(response, ct);
}
private async Task SendErrorAsync(int statusCode, string message, CancellationToken ct)
{
await Send.StatusCodeAsync(statusCode, ct);
}
private string GenerateJwtToken(string username, string role)
{
var jwtKey = config.GetValue<string>("Jwt:Key")
?? throw new InvalidOperationException("JWT key not configured");
var jwtIssuer = config.GetValue<string>("Jwt:Issuer")
?? throw new InvalidOperationException("JWT issuer not configured");
var jwtAudience = config.GetValue<string>("Jwt:Audience")
?? throw new InvalidOperationException("JWT audience not configured");
var expirationMinutes = config.GetValue<int>("Jwt:ExpirationMinutes");
if (expirationMinutes == 0)
{
expirationMinutes = 60;
}
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, username),
new Claim(ClaimTypes.Name, username),
new Claim(ClaimTypes.Role, role),
new Claim("auth_mode", "jwt")
};
var token = new JwtSecurityToken(
issuer: jwtIssuer,
audience: jwtAudience,
claims: claims,
expires: DateTime.UtcNow.AddMinutes(expirationMinutes),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
public class LoginRequest
{
public required string Username { get; set; }
public required string Password { get; set; }
public string? Role { get; set; }
}
public class LoginResponse
{
public required string AccessToken { get; set; }
public required int ExpiresIn { get; set; }
public required string TokenType { get; set; }
}
+1
View File
@@ -36,5 +36,6 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
</ItemGroup>
</Project>
+19 -2
View File
@@ -18,6 +18,7 @@ using KArtSell.Modules.ModelOperations;
using KArtSell.Modules.ModelOperations.Scheduling;
using KArtSell.Modules.SignalEngine;
using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using Npgsql;
using OpenTelemetry.Metrics;
@@ -252,9 +253,25 @@ if (builder.Environment.IsDevelopment()
}
else
{
authenticationBuilder.AddScheme<AuthenticationSchemeOptions, FailClosedAuthenticationHandler>(
// Release mode: JWT Token-based authentication
var jwtKey = builder.Configuration["Jwt:Key"]
?? throw new InvalidOperationException("Jwt:Key is required in production");
var jwtIssuer = builder.Configuration["Jwt:Issuer"]
?? throw new InvalidOperationException("Jwt:Issuer is required in production");
var jwtAudience = builder.Configuration["Jwt:Audience"]
?? throw new InvalidOperationException("Jwt:Audience is required in production");
authenticationBuilder.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
authenticationScheme,
_ => { });
options =>
{
options.JwtKey = jwtKey;
options.JwtIssuer = jwtIssuer;
options.JwtAudience = jwtAudience;
var expirationMinutes = builder.Configuration.GetValue<int>("Jwt:ExpirationMinutes");
options.ExpirationMinutes = expirationMinutes == 0 ? 60 : expirationMinutes;
options.ClockSkewSeconds = 30;
});
}
builder.Services.AddAuthorization();
@@ -0,0 +1,73 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace KArtSell.Host.Security;
/// <summary>
/// JWT Token-based authentication for production.
/// Validates Bearer tokens from Authorization header.
/// </summary>
public sealed class JwtAuthenticationHandler(
IOptionsMonitor<JwtAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder)
: AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
{
private static readonly JwtSecurityTokenHandler TokenHandler = new();
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
try
{
// Extract Bearer token from Authorization header
var authHeader = Request.Headers.Authorization.ToString();
if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.Ordinal))
{
return Task.FromResult(AuthenticateResult.NoResult());
}
var token = authHeader["Bearer ".Length..];
// Validate token
var validationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(Options.JwtKey)),
ValidateIssuer = true,
ValidIssuer = Options.JwtIssuer,
ValidateAudience = true,
ValidAudience = Options.JwtAudience,
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(Options.ClockSkewSeconds)
};
var principal = TokenHandler.ValidateToken(token, validationParameters, out _);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
catch (Exception ex)
{
Logger.LogWarning("JWT validation failed: {Message}", ex.Message);
return Task.FromResult(AuthenticateResult.Fail("Invalid token"));
}
}
}
/// <summary>
/// Configuration options for JWT authentication.
/// </summary>
public class JwtAuthenticationOptions : AuthenticationSchemeOptions
{
public string JwtKey { get; set; } = string.Empty;
public string JwtIssuer { get; set; } = string.Empty;
public string JwtAudience { get; set; } = string.Empty;
public int ExpirationMinutes { get; set; } = 60;
public int ClockSkewSeconds { get; set; } = 30;
}
@@ -0,0 +1,26 @@
{
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://0.0.0.0:5002"
}
}
},
"Authentication": {
"Mode": "JWT"
},
"Jwt": {
"Key": "${JWT_KEY}",
"Issuer": "KArtSell.Aegis",
"Audience": "KArtSell.Aegis",
"ExpirationMinutes": 60
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning"
}
}
}
}
+6
View File
@@ -27,6 +27,12 @@
"Authentication": {
"Mode": "DevelopmentHeader"
},
"Jwt": {
"Key": "KArtSell.Aegis.SecretKey.256Bits.v1.2026.Development.1234567890ABCDEF",
"Issuer": "KArtSell.Aegis",
"Audience": "KArtSell.Aegis",
"ExpirationMinutes": 60
},
"Capabilities": {
"AutomaticOrder": false,
"KisOrderAdapter": false,