Compare commits
5 Commits
3fd531c9f0
...
main-2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd3c172e35 | ||
|
|
581863d69b | ||
|
|
9238036042 | ||
|
|
431e11e052 | ||
|
|
f1194d3da8 |
@@ -1,9 +1,19 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/csharpier.json",
|
||||||
|
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"tabWidth": 4,
|
"indentSize": 4,
|
||||||
"endOfLine": "auto",
|
"endOfLine": "lf",
|
||||||
"indentStyle": "space",
|
|
||||||
"lineEndings": "auto",
|
"overrides": [
|
||||||
"wrapLineLength": 80
|
{
|
||||||
|
"files": "*.xml",
|
||||||
|
"indentSize": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": "*.csx",
|
||||||
|
"printWidth": 80
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ DB_PASSWORD=YourStrong!Passw0rd
|
|||||||
# JWT Secret for signing tokens
|
# JWT Secret for signing tokens
|
||||||
# IMPORTANT: Generate a secure secret (minimum 32 characters)
|
# IMPORTANT: Generate a secure secret (minimum 32 characters)
|
||||||
# Command: openssl rand -base64 32
|
# Command: openssl rand -base64 32
|
||||||
JWT_SECRET=128490218jfklsdajfdsa90f8sd0fid0safasr31jl2k1j4AFSDR
|
ACCESS_TOKEN_SECRET=your-secure-jwt-secret-key
|
||||||
|
REFRESH_TOKEN_SECRET=your-secure-jwt-refresh-secret-key
|
||||||
|
CONFIRMATION_TOKEN_SECRET=your-secure-jwt-confirmation-secret-key
|
||||||
|
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
|
|||||||
14
.gitignore
vendored
@@ -15,6 +15,14 @@
|
|||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
# project-specific build artifacts
|
||||||
|
/src/Website/build/
|
||||||
|
/src/Website/storybook-static/
|
||||||
|
/src/Website/.react-router/
|
||||||
|
/src/Website/playwright-report/
|
||||||
|
/src/Website/test-results/
|
||||||
|
/test-results/
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.pem
|
*.pem
|
||||||
@@ -42,6 +50,9 @@ next-env.d.ts
|
|||||||
|
|
||||||
# vscode
|
# vscode
|
||||||
.vscode
|
.vscode
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
/cloudinary-images
|
/cloudinary-images
|
||||||
|
|
||||||
@@ -487,3 +498,6 @@ FodyWeavers.xsd
|
|||||||
.env.dev
|
.env.dev
|
||||||
.env.test
|
.env.test
|
||||||
.env.prod
|
.env.prod
|
||||||
|
|
||||||
|
*storybook.log
|
||||||
|
storybook-static
|
||||||
|
|||||||
959
LICENSE.md
285
README.md
@@ -1,261 +1,142 @@
|
|||||||
# The Biergarten App
|
# The Biergarten App
|
||||||
|
|
||||||
A social platform for craft beer enthusiasts to discover breweries, share reviews, and
|
The Biergarten App is a multi-project monorepo with a .NET backend and an active React
|
||||||
connect with fellow beer lovers.
|
Router frontend in `src/Website`. The current website focuses on account flows, theme
|
||||||
|
switching, shared UI components, Storybook coverage, and integration with the API.
|
||||||
|
|
||||||
**Documentation**
|
## Documentation
|
||||||
|
|
||||||
- [Getting Started](docs/getting-started.md) - Setup and installation
|
- [Getting Started](docs/getting-started.md) - Local setup for backend and active website
|
||||||
- [Architecture](docs/architecture.md) - System design and patterns
|
- [Architecture](docs/architecture.md) - Current backend and frontend architecture
|
||||||
- [Database](docs/database.md) - Schema and stored procedures
|
- [Docker Guide](docs/docker.md) - Container-based backend development and testing
|
||||||
- [Docker Guide](docs/docker.md) - Container deployment
|
- [Testing](docs/testing.md) - Backend and frontend test commands
|
||||||
- [Testing](docs/testing.md) - Test strategy and commands
|
- [Environment Variables](docs/environment-variables.md) - Active configuration reference
|
||||||
- [Environment Variables](docs/environment-variables.md) - Configuration reference
|
- [Token Validation](docs/token-validation.md) - JWT validation architecture
|
||||||
|
- [Legacy Website Archive](docs/archive/legacy-website-v1.md) - Archived notes for the old Next.js frontend
|
||||||
|
|
||||||
**Diagrams**
|
## Diagrams
|
||||||
|
|
||||||
- [Architecture](docs/diagrams/pdf/architecture.pdf) - Layered architecture
|
- [Architecture](docs/diagrams-out/architecture.svg) - Layered architecture
|
||||||
- [Deployment](docs/diagrams/pdf/deployment.pdf) - Docker topology
|
- [Deployment](docs/diagrams-out/deployment.svg) - Docker topology
|
||||||
- [Authentication Flow](docs/diagrams/pdf/authentication-flow.pdf) - Auth sequence
|
- [Authentication Flow](docs/diagrams-out/authentication-flow.svg) - Auth sequence
|
||||||
- [Database Schema](docs/diagrams/pdf/database-schema.pdf) - Entity relationships
|
- [Database Schema](docs/diagrams-out/database-schema.svg) - Entity relationships
|
||||||
|
|
||||||
## Project Status
|
## Current Status
|
||||||
|
|
||||||
**Active Development** - Transitioning from full-stack Next.js to multi-project monorepo
|
Active areas in the repository:
|
||||||
|
|
||||||
- Core authentication and user management APIs
|
- .NET 10 backend with layered architecture and SQL Server
|
||||||
- Database schema with migrations and seeding
|
- React Router 7 website in `src/Website`
|
||||||
- Layered architecture (Domain, Service, Infrastructure, Repository, API)
|
- Shared Biergarten theme system with a theme guide route
|
||||||
- Comprehensive test suite (unit + integration)
|
- Storybook stories and browser-based checks for shared UI
|
||||||
- Frontend integration with .NET API (in progress)
|
- Auth demo flows for home, login, register, dashboard, logout, and confirmation
|
||||||
- Migration from Next.js serverless functions
|
- Toast-based feedback for auth outcomes
|
||||||
|
|
||||||
---
|
Legacy area retained for reference:
|
||||||
|
|
||||||
|
- `src/Website-v1` contains the archived Next.js frontend and is no longer the active website
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
**Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp **Frontend**: Next.js 14+,
|
- **Backend**: .NET 10, ASP.NET Core, SQL Server 2022, DbUp
|
||||||
TypeScript, TailwindCSS **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
- **Frontend**: React 19, React Router 7, Vite 7, Tailwind CSS 4, DaisyUI 5
|
||||||
**Infrastructure**: Docker, Docker Compose **Security**: Argon2id password hashing, JWT
|
- **UI Documentation**: Storybook 10, Vitest browser mode, Playwright
|
||||||
(HS256)
|
- **Testing**: xUnit, Reqnroll (BDD), FluentAssertions, Moq
|
||||||
|
- **Infrastructure**: Docker, Docker Compose
|
||||||
---
|
- **Security**: Argon2id password hashing, JWT access/refresh/confirmation tokens
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Backend
|
||||||
|
|
||||||
- [.NET SDK 10+](https://dotnet.microsoft.com/download)
|
|
||||||
- [Docker Desktop](https://www.docker.com/products/docker-desktop)
|
|
||||||
- [Node.js 18+](https://nodejs.org/) (for frontend)
|
|
||||||
|
|
||||||
### Start Development Environment
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone repository
|
|
||||||
git clone https://github.com/aaronpo97/the-biergarten-app
|
git clone https://github.com/aaronpo97/the-biergarten-app
|
||||||
cd the-biergarten-app
|
cd the-biergarten-app
|
||||||
|
|
||||||
# Configure environment
|
|
||||||
cp .env.example .env.dev
|
cp .env.example .env.dev
|
||||||
|
|
||||||
# Start all services
|
|
||||||
docker compose -f docker-compose.dev.yaml up -d
|
docker compose -f docker-compose.dev.yaml up -d
|
||||||
|
|
||||||
# View logs
|
|
||||||
docker compose -f docker-compose.dev.yaml logs -f
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Access**:
|
Backend access:
|
||||||
|
|
||||||
- API: http://localhost:8080/swagger
|
- API Swagger: http://localhost:8080/swagger
|
||||||
- Health: http://localhost:8080/health
|
- Health Check: http://localhost:8080/health
|
||||||
|
|
||||||
### Run Tests
|
### Frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
cd src/Website
|
||||||
|
npm install
|
||||||
|
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Results are in `./test-results/`
|
Optional frontend tools:
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
cd src/Website
|
||||||
|
npm run storybook
|
||||||
|
npm run test:storybook
|
||||||
|
npm run test:storybook:playwright
|
||||||
|
```
|
||||||
|
|
||||||
## Repository Structure
|
## Repository Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/Core/ Backend projects (.NET)
|
||||||
|
src/Website/ Active React Router frontend
|
||||||
|
src/Website-v1/ Archived legacy Next.js frontend
|
||||||
|
docs/ Active project documentation
|
||||||
|
docs/archive/ Archived legacy documentation
|
||||||
```
|
```
|
||||||
src/Core/ # Backend (.NET)
|
|
||||||
├── API/
|
|
||||||
│ ├── API.Core/ # ASP.NET Core Web API
|
|
||||||
│ └── API.Specs/ # Integration tests (Reqnroll)
|
|
||||||
├── Database/
|
|
||||||
│ ├── Database.Migrations/ # DbUp migrations
|
|
||||||
│ └── Database.Seed/ # Data seeding
|
|
||||||
├── Domain.Entities/ # Domain models
|
|
||||||
├── Infrastructure/ # Cross-cutting concerns
|
|
||||||
│ ├── Infrastructure.Jwt/
|
|
||||||
│ ├── Infrastructure.PasswordHashing/
|
|
||||||
│ ├── Infrastructure.Email/
|
|
||||||
│ ├── Infrastructure.Repository/
|
|
||||||
│ └── Infrastructure.Repository.Tests/
|
|
||||||
└── Service/ # Business logic
|
|
||||||
├── Service.Auth/
|
|
||||||
├── Service.Auth.Tests/
|
|
||||||
└── Service.UserManagement/
|
|
||||||
|
|
||||||
Website/ # Frontend (Next.js)
|
|
||||||
docs/ # Documentation
|
|
||||||
docs/diagrams/ # PlantUML diagrams
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Features
|
## Key Features
|
||||||
|
|
||||||
### Implemented
|
Implemented today:
|
||||||
|
|
||||||
- User registration and authentication
|
- User registration and login against the API
|
||||||
- JWT token-based auth
|
- JWT-based auth with access, refresh, and confirmation flows
|
||||||
- Argon2id password hashing
|
- SQL Server migrations and seed projects
|
||||||
- SQL Server with stored procedures
|
- Shared form components and auth screens
|
||||||
- Database migrations (DbUp)
|
- Theme switching with Lager, Stout, Cassis, and Weizen variants
|
||||||
- Docker containerization
|
- Storybook documentation and automated story interaction tests
|
||||||
- Comprehensive test suite
|
- Toast feedback for auth-related outcomes
|
||||||
- Swagger/OpenAPI documentation
|
|
||||||
- Health checks
|
|
||||||
|
|
||||||
### Planned
|
Planned next:
|
||||||
|
|
||||||
- [ ] Brewery discovery and management
|
- Brewery discovery and management
|
||||||
- [ ] Beer reviews and ratings
|
- Beer reviews and ratings
|
||||||
- [ ] Social following/followers
|
- Social follow relationships
|
||||||
- [ ] Geospatial brewery search
|
- Geospatial brewery experiences
|
||||||
- [ ] Image upload (Cloudinary)
|
- Additional frontend routes beyond the auth demo
|
||||||
- [ ] Email notifications
|
|
||||||
- [ ] OAuth integration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture Highlights
|
|
||||||
|
|
||||||
### Layered Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
API Layer (Controllers)
|
|
||||||
│
|
|
||||||
Service Layer (Business Logic)
|
|
||||||
│
|
|
||||||
Infrastructure Layer (Repositories, JWT, Email)
|
|
||||||
│
|
|
||||||
Domain Layer (Entities)
|
|
||||||
│
|
|
||||||
Database (SQL Server + Stored Procedures)
|
|
||||||
```
|
|
||||||
|
|
||||||
### SQL-First Approach
|
|
||||||
|
|
||||||
- All queries via stored procedures
|
|
||||||
- No ORM (no Entity Framework)
|
|
||||||
- Version-controlled schema
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
|
||||||
- **Password Hashing**: Argon2id (64MB memory, 4 iterations)
|
|
||||||
- **JWT Tokens**: HS256 with configurable expiration
|
|
||||||
- **Credential Rotation**: Built-in password change support
|
|
||||||
|
|
||||||
See [Architecture Guide](docs/architecture.md) for details.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
The project includes three test suites:
|
Backend suites:
|
||||||
|
|
||||||
| Suite | Type | Framework | Purpose |
|
- `API.Specs` - integration tests
|
||||||
| ---------------------- | ----------- | -------------- | ---------------------- |
|
- `Infrastructure.Repository.Tests` - repository unit tests
|
||||||
| **API.Specs** | Integration | Reqnroll (BDD) | End-to-end API testing |
|
- `Service.Auth.Tests` - service unit tests
|
||||||
| **Repository.Tests** | Unit | xUnit | Data access layer |
|
|
||||||
| **Service.Auth.Tests** | Unit | xUnit + Moq | Business logic |
|
|
||||||
|
|
||||||
**Run All Tests**:
|
Frontend suites:
|
||||||
|
|
||||||
|
- Storybook interaction tests via Vitest
|
||||||
|
- Storybook browser regression checks via Playwright
|
||||||
|
|
||||||
|
Run all backend tests with Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
||||||
```
|
```
|
||||||
|
|
||||||
**Run Individual Test Suite**:
|
See [Testing](docs/testing.md) for the full command list.
|
||||||
|
|
||||||
```bash
|
|
||||||
cd src/Core
|
|
||||||
dotnet test API/API.Specs/API.Specs.csproj
|
|
||||||
dotnet test Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj
|
|
||||||
dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Testing Guide](docs/testing.md) for more information.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker Environments
|
|
||||||
|
|
||||||
The project uses three Docker Compose configurations:
|
|
||||||
|
|
||||||
| File | Purpose | Features |
|
|
||||||
| ---------------------------- | ------------- | ------------------------------------------------- |
|
|
||||||
| **docker-compose.dev.yaml** | Development | Persistent data, hot reload, Swagger UI |
|
|
||||||
| **docker-compose.test.yaml** | CI/CD Testing | Isolated DB, auto-exit, test results export |
|
|
||||||
| **docker-compose.prod.yaml** | Production | Optimized builds, health checks, restart policies |
|
|
||||||
|
|
||||||
**Common Commands**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
docker compose -f docker-compose.dev.yaml up -d
|
|
||||||
docker compose -f docker-compose.dev.yaml logs -f api.core
|
|
||||||
docker compose -f docker-compose.dev.yaml down -v
|
|
||||||
|
|
||||||
# Testing
|
|
||||||
docker compose -f docker-compose.test.yaml up --abort-on-container-exit
|
|
||||||
docker compose -f docker-compose.test.yaml down -v
|
|
||||||
|
|
||||||
# Build
|
|
||||||
docker compose -f docker-compose.dev.yaml build
|
|
||||||
docker compose -f docker-compose.dev.yaml build --no-cache
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Docker Guide](docs/docker.md) for troubleshooting and advanced usage.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### Required Environment Variables
|
Common active variables:
|
||||||
|
|
||||||
**Backend** (`.env.dev`):
|
- Backend: `DB_SERVER`, `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `ACCESS_TOKEN_SECRET`, `REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`
|
||||||
|
- Frontend: `API_BASE_URL`, `SESSION_SECRET`, `NODE_ENV`
|
||||||
|
|
||||||
```bash
|
See [Environment Variables](docs/environment-variables.md) for details.
|
||||||
DB_SERVER=sqlserver,1433
|
|
||||||
DB_NAME=Biergarten
|
|
||||||
DB_USER=sa
|
|
||||||
DB_PASSWORD=YourStrong!Passw0rd
|
|
||||||
JWT_SECRET=<min-32-chars>
|
|
||||||
```
|
|
||||||
|
|
||||||
**Frontend** (`.env.local`):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BASE_URL=http://localhost:3000
|
|
||||||
NODE_ENV=development
|
|
||||||
CONFIRMATION_TOKEN_SECRET=<generated>
|
|
||||||
RESET_PASSWORD_TOKEN_SECRET=<generated>
|
|
||||||
SESSION_SECRET=<generated>
|
|
||||||
# + External services (Cloudinary, Mapbox, SparkPost)
|
|
||||||
```
|
|
||||||
|
|
||||||
See [Environment Variables Guide](docs/environment-variables.md) for complete reference.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- sqlserverdata-dev:/var/opt/mssql
|
- sqlserverdata-dev:/var/opt/mssql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1"]
|
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 12
|
||||||
@@ -91,7 +91,10 @@ services:
|
|||||||
DB_NAME: "${DB_NAME}"
|
DB_NAME: "${DB_NAME}"
|
||||||
DB_USER: "${DB_USER}"
|
DB_USER: "${DB_USER}"
|
||||||
DB_PASSWORD: "${DB_PASSWORD}"
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
JWT_SECRET: "${JWT_SECRET}"
|
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
|
||||||
|
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
|
||||||
|
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||||
|
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- devnet
|
- devnet
|
||||||
@@ -99,18 +102,18 @@ services:
|
|||||||
- nuget-cache-dev:/root/.nuget/packages
|
- nuget-cache-dev:/root/.nuget/packages
|
||||||
|
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:latest
|
image: axllent/mailpit:latest
|
||||||
container_name: dev-env-mailpit
|
container_name: dev-env-mailpit
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025" # Web UI
|
- "8025:8025" # Web UI
|
||||||
- "1025:1025" # SMTP server
|
- "1025:1025" # SMTP server
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||||
networks:
|
networks:
|
||||||
- devnet
|
- devnet
|
||||||
volumes:
|
volumes:
|
||||||
sqlserverdata-dev:
|
sqlserverdata-dev:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
91
docker-compose.min.yaml
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
services:
|
||||||
|
sqlserver:
|
||||||
|
env_file: ".env.local"
|
||||||
|
image: mcr.microsoft.com/mssql/server:2022-latest
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: dev-env-sqlserver
|
||||||
|
environment:
|
||||||
|
ACCEPT_EULA: "Y"
|
||||||
|
SA_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
MSSQL_PID: "Express"
|
||||||
|
ports:
|
||||||
|
- "1433:1433"
|
||||||
|
volumes:
|
||||||
|
- sqlserverdata-dev:/var/opt/mssql
|
||||||
|
healthcheck:
|
||||||
|
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
database.migrations:
|
||||||
|
env_file: ".env.local"
|
||||||
|
image: database.migrations
|
||||||
|
container_name: dev-env-database-migrations
|
||||||
|
depends_on:
|
||||||
|
sqlserver:
|
||||||
|
condition: service_healthy
|
||||||
|
build:
|
||||||
|
context: ./src/Core/Database
|
||||||
|
dockerfile: Database.Migrations/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
CLEAR_DATABASE: "true"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:latest
|
||||||
|
container_name: dev-env-mailpit
|
||||||
|
ports:
|
||||||
|
- "8025:8025" # Web UI
|
||||||
|
- "1025:1025" # SMTP server
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||||
|
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
|
||||||
|
database.seed:
|
||||||
|
env_file: ".env.local"
|
||||||
|
image: database.seed
|
||||||
|
container_name: dev-env-database-seed
|
||||||
|
depends_on:
|
||||||
|
database.migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
build:
|
||||||
|
context: ./src/Core
|
||||||
|
dockerfile: Database/Database.Seed/Dockerfile
|
||||||
|
args:
|
||||||
|
BUILD_CONFIGURATION: Release
|
||||||
|
APP_UID: 1000
|
||||||
|
environment:
|
||||||
|
DOTNET_RUNNING_IN_CONTAINER: "true"
|
||||||
|
DB_SERVER: "${DB_SERVER}"
|
||||||
|
DB_NAME: "${DB_NAME}"
|
||||||
|
DB_USER: "${DB_USER}"
|
||||||
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
|
restart: "no"
|
||||||
|
networks:
|
||||||
|
- devnet
|
||||||
|
volumes:
|
||||||
|
sqlserverdata-dev:
|
||||||
|
driver: local
|
||||||
|
nuget-cache-dev:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
devnet:
|
||||||
|
driver: bridge
|
||||||
@@ -66,7 +66,10 @@ services:
|
|||||||
DB_NAME: "${DB_NAME}"
|
DB_NAME: "${DB_NAME}"
|
||||||
DB_USER: "${DB_USER}"
|
DB_USER: "${DB_USER}"
|
||||||
DB_PASSWORD: "${DB_PASSWORD}"
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
JWT_SECRET: "${JWT_SECRET}"
|
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
|
||||||
|
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
|
||||||
|
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||||
|
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- prodnet
|
- prodnet
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- sqlserverdata-test:/var/opt/mssql
|
- sqlserverdata-test:/var/opt/mssql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1"]
|
test: [ "CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1' || exit 1" ]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 12
|
||||||
@@ -85,7 +85,10 @@ services:
|
|||||||
DB_NAME: "${DB_NAME}"
|
DB_NAME: "${DB_NAME}"
|
||||||
DB_USER: "${DB_USER}"
|
DB_USER: "${DB_USER}"
|
||||||
DB_PASSWORD: "${DB_PASSWORD}"
|
DB_PASSWORD: "${DB_PASSWORD}"
|
||||||
JWT_SECRET: "${JWT_SECRET}"
|
ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
|
||||||
|
REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
|
||||||
|
CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
|
||||||
|
WEBSITE_BASE_URL: "${WEBSITE_BASE_URL}"
|
||||||
volumes:
|
volumes:
|
||||||
- ./test-results:/app/test-results
|
- ./test-results:/app/test-results
|
||||||
restart: "no"
|
restart: "no"
|
||||||
|
|||||||
@@ -1,28 +1,27 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
This document describes the architecture patterns and design decisions for The Biergarten
|
This document describes the active architecture of The Biergarten App.
|
||||||
App.
|
|
||||||
|
|
||||||
## High-Level Overview
|
## High-Level Overview
|
||||||
|
|
||||||
The Biergarten App follows a **multi-project monorepo** architecture with clear separation
|
The Biergarten App is a monorepo with a clear split between the backend and the active
|
||||||
between backend and frontend:
|
website:
|
||||||
|
|
||||||
- **Backend**: .NET 10 Web API with SQL Server
|
- **Backend**: .NET 10 Web API with SQL Server and a layered architecture
|
||||||
- **Frontend**: Next.js with TypeScript
|
- **Frontend**: React 19 + React Router 7 website in `src/Website`
|
||||||
- **Architecture Style**: Layered architecture with SQL-first approach
|
- **Architecture Style**: Layered backend plus server-rendered React frontend
|
||||||
|
|
||||||
|
The legacy Next.js frontend has been retained in `src/Website-v1` for reference only and is
|
||||||
|
documented in [archive/legacy-website-v1.md](archive/legacy-website-v1.md).
|
||||||
|
|
||||||
## Diagrams
|
## Diagrams
|
||||||
|
|
||||||
For visual representations, see:
|
For visual representations, see:
|
||||||
|
|
||||||
- [architecture.pdf](diagrams/pdf/architecture.pdf) - Layered architecture diagram
|
- [architecture.svg](diagrams-out/architecture.svg) - Layered architecture diagram
|
||||||
- [deployment.pdf](diagrams/pdf/deployment.pdf) - Docker deployment diagram
|
- [deployment.svg](diagrams-out/deployment.svg) - Docker deployment diagram
|
||||||
- [authentication-flow.pdf](diagrams/pdf/authentication-flow.pdf) - Authentication
|
- [authentication-flow.svg](diagrams-out/authentication-flow.svg) - Authentication workflow
|
||||||
workflow
|
- [database-schema.svg](diagrams-out/database-schema.svg) - Database relationships
|
||||||
- [database-schema.pdf](diagrams/pdf/database-schema.pdf) - Database relationships
|
|
||||||
|
|
||||||
Generate diagrams with: `make diagrams`
|
|
||||||
|
|
||||||
## Backend Architecture
|
## Backend Architecture
|
||||||
|
|
||||||
@@ -217,39 +216,49 @@ public interface IAuthRepository
|
|||||||
|
|
||||||
## Frontend Architecture
|
## Frontend Architecture
|
||||||
|
|
||||||
### Next.js Application Structure
|
### Active Website (`src/Website`)
|
||||||
|
|
||||||
```
|
The current website is a React Router 7 application with server-side rendering enabled.
|
||||||
Website/src/
|
|
||||||
├── components/ # React components
|
```text
|
||||||
├── pages/ # Next.js routes
|
src/Website/
|
||||||
├── contexts/ # React context providers
|
├── app/
|
||||||
├── hooks/ # Custom React hooks
|
│ ├── components/ Shared UI such as Navbar, FormField, SubmitButton, ToastProvider
|
||||||
├── controllers/ # Business logic layer
|
│ ├── lib/ Auth helpers, schemas, and theme metadata
|
||||||
├── services/ # API communication
|
│ ├── routes/ Route modules for home, login, register, dashboard, confirm, theme
|
||||||
├── requests/ # API request builders
|
│ ├── root.tsx App shell and global providers
|
||||||
├── validation/ # Form validation schemas
|
│ └── app.css Theme tokens and global styling
|
||||||
├── config/ # Configuration & env vars
|
├── .storybook/ Storybook config and preview setup
|
||||||
└── prisma/ # Database schema (current)
|
├── stories/ Storybook stories for shared UI and themes
|
||||||
|
├── tests/playwright/ Storybook Playwright coverage
|
||||||
|
└── package.json Frontend scripts and dependencies
|
||||||
```
|
```
|
||||||
|
|
||||||
### Migration Strategy
|
### Frontend Responsibilities
|
||||||
|
|
||||||
The frontend is **transitioning** from a standalone architecture to integrate with the
|
- Render the auth demo and theme guide routes
|
||||||
.NET API:
|
- Manage cookie-backed website session state
|
||||||
|
- Call the .NET API for login, registration, token refresh, and confirmation
|
||||||
|
- Provide shared UI building blocks for forms, navigation, themes, and toasts
|
||||||
|
- Supply Storybook documentation and browser-based component verification
|
||||||
|
|
||||||
**Current State**:
|
### Theme System
|
||||||
|
|
||||||
- Uses Prisma ORM with Postgres (Neon)
|
The active website uses semantic DaisyUI theme tokens backed by four Biergarten themes:
|
||||||
- Has its own server-side API routes
|
|
||||||
- Direct database access from Next.js
|
|
||||||
|
|
||||||
**Target State**:
|
- Biergarten Lager
|
||||||
|
- Biergarten Stout
|
||||||
|
- Biergarten Cassis
|
||||||
|
- Biergarten Weizen
|
||||||
|
|
||||||
- Pure client-side Next.js app
|
All component styling should prefer semantic tokens such as `primary`, `success`,
|
||||||
- All data via .NET API
|
`surface`, and `highlight` instead of hard-coded color values.
|
||||||
- No server-side database access
|
|
||||||
- JWT-based authentication
|
### Legacy Frontend
|
||||||
|
|
||||||
|
The previous Next.js frontend has been archived at `src/Website-v1`. Active product and
|
||||||
|
engineering documentation should point to `src/Website`, while legacy notes live in
|
||||||
|
[archive/legacy-website-v1.md](archive/legacy-website-v1.md).
|
||||||
|
|
||||||
## Security Architecture
|
## Security Architecture
|
||||||
|
|
||||||
@@ -385,7 +394,7 @@ dependencies
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ['CMD-SHELL', 'sqlcmd health check']
|
test: ["CMD-SHELL", "sqlcmd health check"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
retries: 12
|
retries: 12
|
||||||
start_period: 30s
|
start_period: 30s
|
||||||
|
|||||||
56
docs/archive/legacy-website-v1.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Legacy Website Archive (`src/Website-v1`)
|
||||||
|
|
||||||
|
This archive captures high-level notes about the previous Biergarten frontend so active
|
||||||
|
project documentation can focus on the current website in `src/Website`.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- `src/Website-v1` is retained for historical reference only
|
||||||
|
- It is not the active frontend used by current setup, docs, or testing guidance
|
||||||
|
- New product and engineering work should target `src/Website`
|
||||||
|
|
||||||
|
## Legacy Stack Summary
|
||||||
|
|
||||||
|
The archived frontend used a different application model from the current website:
|
||||||
|
|
||||||
|
- Next.js 14
|
||||||
|
- React 18
|
||||||
|
- Prisma
|
||||||
|
- Postgres / Neon-hosted database workflows
|
||||||
|
- Next.js API routes and server-side controllers
|
||||||
|
- Additional third-party integrations such as Cloudinary, Mapbox, and SparkPost
|
||||||
|
|
||||||
|
## Why It Was Archived
|
||||||
|
|
||||||
|
The active website moved to a React Router-based frontend that talks directly to the .NET
|
||||||
|
API. As part of that shift, the main docs were updated to describe:
|
||||||
|
|
||||||
|
- `src/Website` as the active frontend
|
||||||
|
- React Router route modules and server rendering
|
||||||
|
- Storybook-based component documentation and tests
|
||||||
|
- Current frontend runtime variables: `API_BASE_URL`, `SESSION_SECRET`, and `NODE_ENV`
|
||||||
|
|
||||||
|
## Legacy Documentation Topics Moved Out of Active Docs
|
||||||
|
|
||||||
|
The following categories were removed from active documentation and intentionally archived:
|
||||||
|
|
||||||
|
- Next.js application structure guidance
|
||||||
|
- Prisma and Postgres frontend setup
|
||||||
|
- Legacy frontend environment variables
|
||||||
|
- External service setup that only applied to `src/Website-v1`
|
||||||
|
- Old frontend local setup instructions
|
||||||
|
|
||||||
|
## When To Use This Archive
|
||||||
|
|
||||||
|
Use this file only if you need to:
|
||||||
|
|
||||||
|
- inspect the historical frontend implementation
|
||||||
|
- compare old flows against the current website
|
||||||
|
- migrate or recover legacy logic from `src/Website-v1`
|
||||||
|
|
||||||
|
For all active work, use:
|
||||||
|
|
||||||
|
- [Getting Started](../getting-started.md)
|
||||||
|
- [Architecture](../architecture.md)
|
||||||
|
- [Environment Variables](../environment-variables.md)
|
||||||
|
- [Testing](../testing.md)
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
# Environment Variables
|
# Environment Variables
|
||||||
|
|
||||||
Complete documentation for all environment variables used in The Biergarten App.
|
This document covers the active environment variables used by the current Biergarten
|
||||||
|
stack.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The application uses environment variables for configuration across:
|
The application uses environment variables for:
|
||||||
|
|
||||||
- **.NET API Backend** - Database connections, JWT secrets
|
- **.NET API backend** - database connections, token secrets, runtime settings
|
||||||
- **Next.js Frontend** - External services, authentication
|
- **React Router website** - API base URL and session signing
|
||||||
- **Docker Containers** - Runtime configuration
|
- **Docker containers** - environment-specific orchestration
|
||||||
|
|
||||||
## Configuration Patterns
|
## Configuration Patterns
|
||||||
|
|
||||||
@@ -16,10 +17,10 @@ The application uses environment variables for configuration across:
|
|||||||
|
|
||||||
Direct environment variable access via `Environment.GetEnvironmentVariable()`.
|
Direct environment variable access via `Environment.GetEnvironmentVariable()`.
|
||||||
|
|
||||||
### Frontend (Next.js)
|
### Frontend (`src/Website`)
|
||||||
|
|
||||||
Centralized configuration module at `src/Website/src/config/env/index.ts` with Zod
|
The active website reads runtime values from the server environment for its auth and API
|
||||||
validation.
|
integration.
|
||||||
|
|
||||||
### Docker
|
### Docker
|
||||||
|
|
||||||
@@ -58,38 +59,55 @@ built from components.
|
|||||||
|
|
||||||
**Implementation**: See `DefaultSqlConnectionFactory.cs`
|
**Implementation**: See `DefaultSqlConnectionFactory.cs`
|
||||||
|
|
||||||
### JWT Authentication
|
### JWT Authentication Secrets (Backend)
|
||||||
|
|
||||||
|
The backend uses separate secrets for different token types to enable independent key rotation and validation isolation.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
JWT_SECRET=your-secret-key-minimum-32-characters-required
|
# Access token secret (1-hour tokens)
|
||||||
|
ACCESS_TOKEN_SECRET=<generated-secret> # Signs short-lived access tokens
|
||||||
|
|
||||||
|
# Refresh token secret (21-day tokens)
|
||||||
|
REFRESH_TOKEN_SECRET=<generated-secret> # Signs long-lived refresh tokens
|
||||||
|
|
||||||
|
# Confirmation token secret (30-minute tokens)
|
||||||
|
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Signs email confirmation tokens
|
||||||
|
|
||||||
|
# Website base URL (used in confirmation emails)
|
||||||
|
WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Required**: Yes
|
**Security Requirements**:
|
||||||
- **Minimum Length**: 32 characters (enforced)
|
|
||||||
- **Purpose**: Signs JWT tokens for user authentication
|
|
||||||
- **Algorithm**: HS256 (HMAC-SHA256)
|
|
||||||
|
|
||||||
**Generate Secret**:
|
- Each secret should be minimum 32 characters
|
||||||
|
- Recommend 127+ characters for production
|
||||||
|
- Generate using cryptographically secure random functions
|
||||||
|
- Never reuse secrets across token types or environments
|
||||||
|
- Rotate secrets periodically in production
|
||||||
|
|
||||||
|
**Generate Secrets**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS/Linux
|
# macOS/Linux - Generate 127-character base64 secret
|
||||||
openssl rand -base64 127
|
openssl rand -base64 127
|
||||||
|
|
||||||
# Windows PowerShell
|
# Windows PowerShell
|
||||||
[Convert]::ToBase64String((1..127 | %{Get-Random -Max 256}))
|
[Convert]::ToBase64String((1..127 | %{Get-Random -Max 256}))
|
||||||
```
|
```
|
||||||
|
|
||||||
**Additional JWT Settings** (appsettings.json):
|
**Token Expiration**:
|
||||||
|
|
||||||
```json
|
- **Access tokens**: 1 hour
|
||||||
{
|
- **Refresh tokens**: 21 days
|
||||||
"Jwt": {
|
- **Confirmation tokens**: 30 minutes
|
||||||
"ExpirationMinutes": 60,
|
|
||||||
"Issuer": "biergarten-api",
|
(Defined in `TokenServiceExpirationHours` class)
|
||||||
"Audience": "biergarten-users"
|
|
||||||
}
|
**JWT Implementation**:
|
||||||
}
|
|
||||||
```
|
- **Algorithm**: HS256 (HMAC-SHA256)
|
||||||
|
- **Handler**: Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
|
||||||
|
- **Validation**: Token signature, expiration, and malformed token checks
|
||||||
|
|
||||||
### Migration Control
|
### Migration Control
|
||||||
|
|
||||||
@@ -111,91 +129,38 @@ ASPNETCORE_URLS=http://0.0.0.0:8080 # Binding address and port
|
|||||||
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
|
DOTNET_RUNNING_IN_CONTAINER=true # Flag for container execution
|
||||||
```
|
```
|
||||||
|
|
||||||
## Frontend Variables (Next.js)
|
## Frontend Variables (`src/Website`)
|
||||||
|
|
||||||
Create `.env.local` in the `Website/` directory.
|
The active website does not use the old Next.js/Prisma environment model. Its core runtime
|
||||||
|
variables are:
|
||||||
### Base Configuration
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BASE_URL=http://localhost:3000 # Application base URL
|
API_BASE_URL=http://localhost:8080 # Base URL for the .NET API
|
||||||
NODE_ENV=development # Environment: development, production, test
|
SESSION_SECRET=<generated-secret> # Cookie session signing secret
|
||||||
|
NODE_ENV=development # Standard Node runtime mode
|
||||||
```
|
```
|
||||||
|
|
||||||
### Authentication & Sessions
|
### Frontend Variable Details
|
||||||
|
|
||||||
```bash
|
#### `API_BASE_URL`
|
||||||
# Token signing secrets (use openssl rand -base64 127)
|
|
||||||
CONFIRMATION_TOKEN_SECRET=<generated-secret> # Email confirmation tokens
|
|
||||||
RESET_PASSWORD_TOKEN_SECRET=<generated-secret> # Password reset tokens
|
|
||||||
SESSION_SECRET=<generated-secret> # Session cookie signing
|
|
||||||
|
|
||||||
# Session configuration
|
- **Required**: Yes for local development
|
||||||
SESSION_TOKEN_NAME=biergarten # Cookie name (optional)
|
- **Default in code**: `http://localhost:8080`
|
||||||
SESSION_MAX_AGE=604800 # Cookie max age in seconds (optional, default: 1 week)
|
- **Used by**: `src/Website/app/lib/auth.server.ts`
|
||||||
```
|
- **Purpose**: Routes website auth actions to the .NET API
|
||||||
|
|
||||||
**Security Requirements**:
|
#### `SESSION_SECRET`
|
||||||
|
|
||||||
- All secrets should be 127+ characters
|
- **Required**: Strongly recommended in all environments
|
||||||
- Generate using cryptographically secure random functions
|
- **Default in local code path**: `dev-secret-change-me`
|
||||||
- Never reuse secrets across environments
|
- **Used by**: React Router cookie session storage in `auth.server.ts`
|
||||||
- Rotate secrets periodically in production
|
- **Purpose**: Signs and validates the website session cookie
|
||||||
|
|
||||||
### Database (Current - Prisma/Postgres)
|
#### `NODE_ENV`
|
||||||
|
|
||||||
**Note**: Frontend currently uses Neon Postgres. Will migrate to .NET API.
|
- **Required**: No
|
||||||
|
- **Typical values**: `development`, `production`, `test`
|
||||||
```bash
|
- **Purpose**: Controls secure cookie behavior and runtime mode
|
||||||
POSTGRES_PRISMA_URL=postgresql://user:pass@host/db?pgbouncer=true # Pooled connection
|
|
||||||
POSTGRES_URL_NON_POOLING=postgresql://user:pass@host/db # Direct connection (migrations)
|
|
||||||
SHADOW_DATABASE_URL=postgresql://user:pass@host/shadow_db # Prisma shadow DB (optional)
|
|
||||||
```
|
|
||||||
|
|
||||||
### External Services
|
|
||||||
|
|
||||||
#### Cloudinary (Image Hosting)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name # Public, client-accessible
|
|
||||||
CLOUDINARY_KEY=your-api-key # Server-side API key
|
|
||||||
CLOUDINARY_SECRET=your-api-secret # Server-side secret
|
|
||||||
```
|
|
||||||
|
|
||||||
**Setup Steps**:
|
|
||||||
|
|
||||||
1. Sign up at [cloudinary.com](https://cloudinary.com)
|
|
||||||
2. Navigate to Dashboard
|
|
||||||
3. Copy Cloud Name, API Key, and API Secret
|
|
||||||
|
|
||||||
**Note**: `NEXT_PUBLIC_` prefix makes variable accessible in client-side code.
|
|
||||||
|
|
||||||
#### Mapbox (Maps & Geocoding)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
MAPBOX_ACCESS_TOKEN=pk.your-public-token
|
|
||||||
```
|
|
||||||
|
|
||||||
**Setup Steps**:
|
|
||||||
|
|
||||||
1. Create account at [mapbox.com](https://mapbox.com)
|
|
||||||
2. Navigate to Account → Tokens
|
|
||||||
3. Create new token with public scopes
|
|
||||||
4. Copy access token
|
|
||||||
|
|
||||||
#### SparkPost (Email Service)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SPARKPOST_API_KEY=your-api-key
|
|
||||||
SPARKPOST_SENDER_ADDRESS=noreply@yourdomain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
**Setup Steps**:
|
|
||||||
|
|
||||||
1. Sign up at [sparkpost.com](https://sparkpost.com)
|
|
||||||
2. Verify sending domain or use sandbox
|
|
||||||
3. Create API key with "Send via SMTP" permission
|
|
||||||
4. Configure sender address (must match verified domain)
|
|
||||||
|
|
||||||
### Admin Account (Seeding)
|
### Admin Account (Seeding)
|
||||||
|
|
||||||
@@ -241,69 +206,42 @@ cp .env.example .env.dev
|
|||||||
# Edit .env.dev with your values
|
# Edit .env.dev with your values
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Legacy Frontend Variables
|
||||||
|
|
||||||
|
Variables for the archived Next.js frontend (`src/Website-v1`) have been removed from this
|
||||||
|
active reference. See [archive/legacy-website-v1.md](archive/legacy-website-v1.md) if you
|
||||||
|
need the legacy Prisma, Cloudinary, Mapbox, or SparkPost notes.
|
||||||
|
|
||||||
**Docker Compose Mapping**:
|
**Docker Compose Mapping**:
|
||||||
|
|
||||||
- `docker-compose.dev.yaml` → `.env.dev`
|
- `docker-compose.dev.yaml` → `.env.dev`
|
||||||
- `docker-compose.test.yaml` → `.env.test`
|
- `docker-compose.test.yaml` → `.env.test`
|
||||||
- `docker-compose.prod.yaml` → `.env.prod`
|
- `docker-compose.prod.yaml` → `.env.prod`
|
||||||
|
|
||||||
### Frontend (Website Directory)
|
|
||||||
|
|
||||||
```
|
|
||||||
.env.local # Local development (gitignored)
|
|
||||||
.env.production # Production (gitignored)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Setup**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd Website
|
|
||||||
touch .env.local
|
|
||||||
# Add frontend variables
|
|
||||||
```
|
|
||||||
|
|
||||||
## Variable Reference Table
|
## Variable Reference Table
|
||||||
|
|
||||||
| Variable | Backend | Frontend | Docker | Required | Notes |
|
| Variable | Backend | Frontend | Docker | Required | Notes |
|
||||||
| ----------------------------------- | :-----: | :------: | :----: | :------: | ------------------------- |
|
| ----------------------------- | :-----: | :------: | :----: | :------: | -------------------------- |
|
||||||
| **Database** |
|
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
|
||||||
| `DB_SERVER` | ✓ | | ✓ | Yes\* | SQL Server address |
|
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
|
||||||
| `DB_NAME` | ✓ | | ✓ | Yes\* | Database name |
|
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
|
||||||
| `DB_USER` | ✓ | | ✓ | Yes\* | SQL username |
|
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
|
||||||
| `DB_PASSWORD` | ✓ | | ✓ | Yes\* | SQL password |
|
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
|
||||||
| `DB_CONNECTION_STRING` | ✓ | | | Yes\* | Alternative to components |
|
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to `True` |
|
||||||
| `DB_TRUST_SERVER_CERTIFICATE` | ✓ | | ✓ | No | Defaults to True |
|
| `ACCESS_TOKEN_SECRET` | ✓ | | ✓ | Yes | Access token signing |
|
||||||
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
| `REFRESH_TOKEN_SECRET` | ✓ | | ✓ | Yes | Refresh token signing |
|
||||||
| **Authentication (Backend)** |
|
| `CONFIRMATION_TOKEN_SECRET` | ✓ | | ✓ | Yes | Confirmation token signing |
|
||||||
| `JWT_SECRET` | ✓ | | ✓ | Yes | Min 32 chars |
|
| `WEBSITE_BASE_URL` | ✓ | | | Yes | Website URL for emails |
|
||||||
| **Authentication (Frontend)** |
|
| `API_BASE_URL` | | ✓ | | Yes | Website-to-API base URL |
|
||||||
| `CONFIRMATION_TOKEN_SECRET` | | ✓ | | Yes | Email confirmation |
|
| `SESSION_SECRET` | | ✓ | | Yes | Website session signing |
|
||||||
| `RESET_PASSWORD_TOKEN_SECRET` | | ✓ | | Yes | Password reset |
|
| `NODE_ENV` | | ✓ | | No | Runtime mode |
|
||||||
| `SESSION_SECRET` | | ✓ | | Yes | Session signing |
|
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test reset flag |
|
||||||
| `SESSION_TOKEN_NAME` | | ✓ | | No | Default: "biergarten" |
|
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
||||||
| `SESSION_MAX_AGE` | | ✓ | | No | Default: 604800 |
|
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
||||||
| **Base Configuration** |
|
| `SA_PASSWORD` | | | ✓ | Yes | SQL Server container |
|
||||||
| `BASE_URL` | | ✓ | | Yes | App base URL |
|
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
||||||
| `NODE_ENV` | | ✓ | | Yes | Node environment |
|
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
||||||
| `ASPNETCORE_ENVIRONMENT` | ✓ | | ✓ | Yes | ASP.NET environment |
|
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
|
||||||
| `ASPNETCORE_URLS` | ✓ | | ✓ | Yes | API binding address |
|
|
||||||
| **Database (Frontend - Current)** |
|
|
||||||
| `POSTGRES_PRISMA_URL` | | ✓ | | Yes | Pooled connection |
|
|
||||||
| `POSTGRES_URL_NON_POOLING` | | ✓ | | Yes | Direct connection |
|
|
||||||
| `SHADOW_DATABASE_URL` | | ✓ | | No | Prisma shadow DB |
|
|
||||||
| **External Services** |
|
|
||||||
| `NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME` | | ✓ | | Yes | Public, client-side |
|
|
||||||
| `CLOUDINARY_KEY` | | ✓ | | Yes | Server-side |
|
|
||||||
| `CLOUDINARY_SECRET` | | ✓ | | Yes | Server-side |
|
|
||||||
| `MAPBOX_ACCESS_TOKEN` | | ✓ | | Yes | Maps/geocoding |
|
|
||||||
| `SPARKPOST_API_KEY` | | ✓ | | Yes | Email service |
|
|
||||||
| `SPARKPOST_SENDER_ADDRESS` | | ✓ | | Yes | From address |
|
|
||||||
| **Other** |
|
|
||||||
| `ADMIN_PASSWORD` | | ✓ | | No | Seeding only |
|
|
||||||
| `CLEAR_DATABASE` | ✓ | | ✓ | No | Dev/test only |
|
|
||||||
| `ACCEPT_EULA` | | | ✓ | Yes | SQL Server EULA |
|
|
||||||
| `MSSQL_PID` | | | ✓ | No | SQL Server edition |
|
|
||||||
| `DOTNET_RUNNING_IN_CONTAINER` | ✓ | | ✓ | No | Container flag |
|
|
||||||
|
|
||||||
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`, `DB_NAME`,
|
\* Either `DB_CONNECTION_STRING` OR the component variables (`DB_SERVER`, `DB_NAME`,
|
||||||
`DB_USER`, `DB_PASSWORD`) must be provided.
|
`DB_USER`, `DB_PASSWORD`) must be provided.
|
||||||
@@ -320,13 +258,12 @@ Variables are validated at startup:
|
|||||||
|
|
||||||
### Frontend Validation
|
### Frontend Validation
|
||||||
|
|
||||||
Zod schemas validate variables at runtime:
|
The active website relies on runtime defaults for local development and the surrounding
|
||||||
|
server environment in deployed environments.
|
||||||
|
|
||||||
- Type checking (string, number, URL, etc.)
|
- `API_BASE_URL` defaults to `http://localhost:8080`
|
||||||
- Format validation (email, URL patterns)
|
- `SESSION_SECRET` falls back to a development-only local secret
|
||||||
- Required vs optional enforcement
|
- `NODE_ENV` controls secure cookie behavior
|
||||||
|
|
||||||
**Location**: `src/Website/src/config/env/index.ts`
|
|
||||||
|
|
||||||
## Example Configuration Files
|
## Example Configuration Files
|
||||||
|
|
||||||
@@ -339,8 +276,11 @@ DB_NAME=Biergarten
|
|||||||
DB_USER=sa
|
DB_USER=sa
|
||||||
DB_PASSWORD=Dev_Password_123!
|
DB_PASSWORD=Dev_Password_123!
|
||||||
|
|
||||||
# JWT
|
# JWT Authentication Secrets
|
||||||
JWT_SECRET=development-secret-key-at-least-32-characters-long-recommended-longer
|
ACCESS_TOKEN_SECRET=<generated-with-openssl>
|
||||||
|
REFRESH_TOKEN_SECRET=<generated-with-openssl>
|
||||||
|
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
|
||||||
|
WEBSITE_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
# Migration
|
# Migration
|
||||||
CLEAR_DATABASE=true
|
CLEAR_DATABASE=true
|
||||||
@@ -355,30 +295,10 @@ ACCEPT_EULA=Y
|
|||||||
MSSQL_PID=Express
|
MSSQL_PID=Express
|
||||||
```
|
```
|
||||||
|
|
||||||
### `.env.local` (Frontend)
|
### Frontend local runtime example
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Base
|
API_BASE_URL=http://localhost:8080
|
||||||
BASE_URL=http://localhost:3000
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# Authentication
|
|
||||||
CONFIRMATION_TOKEN_SECRET=<generated-with-openssl>
|
|
||||||
RESET_PASSWORD_TOKEN_SECRET=<generated-with-openssl>
|
|
||||||
SESSION_SECRET=<generated-with-openssl>
|
SESSION_SECRET=<generated-with-openssl>
|
||||||
|
NODE_ENV=development
|
||||||
# Database (current Prisma setup)
|
|
||||||
POSTGRES_PRISMA_URL=postgresql://user:pass@db.neon.tech/biergarten?pgbouncer=true
|
|
||||||
POSTGRES_URL_NON_POOLING=postgresql://user:pass@db.neon.tech/biergarten
|
|
||||||
|
|
||||||
# External Services
|
|
||||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=my-cloud
|
|
||||||
CLOUDINARY_KEY=123456789012345
|
|
||||||
CLOUDINARY_SECRET=abcdefghijklmnopqrstuvwxyz
|
|
||||||
MAPBOX_ACCESS_TOKEN=pk.eyJ...
|
|
||||||
SPARKPOST_API_KEY=abc123...
|
|
||||||
SPARKPOST_SENDER_ADDRESS=noreply@biergarten.app
|
|
||||||
|
|
||||||
# Admin (for seeding)
|
|
||||||
ADMIN_PASSWORD=Admin_Dev_Password_123!
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
# Getting Started
|
# Getting Started
|
||||||
|
|
||||||
This guide will help you set up and run The Biergarten App in your development
|
This guide covers local setup for the current Biergarten stack: the .NET backend in
|
||||||
environment.
|
`src/Core` and the active React Router frontend in `src/Website`.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Before you begin, ensure you have the following installed:
|
- **.NET SDK 10+**
|
||||||
|
- **Node.js 18+**
|
||||||
|
- **Docker Desktop** or equivalent Docker Engine setup
|
||||||
|
- **Java 8+** if you want to regenerate PlantUML diagrams
|
||||||
|
|
||||||
- **.NET SDK 10+** - [Download](https://dotnet.microsoft.com/download)
|
## Recommended Path: Docker for Backend, Node for Frontend
|
||||||
- **Node.js 18+** - [Download](https://nodejs.org/)
|
|
||||||
- **Docker Desktop** - [Download](https://www.docker.com/products/docker-desktop)
|
|
||||||
(recommended)
|
|
||||||
- **Java 8+** - Required for generating diagrams from PlantUML (optional)
|
|
||||||
|
|
||||||
## Quick Start with Docker (Recommended)
|
|
||||||
|
|
||||||
### 1. Clone the Repository
|
### 1. Clone the Repository
|
||||||
|
|
||||||
@@ -22,174 +19,120 @@ git clone <repository-url>
|
|||||||
cd the-biergarten-app
|
cd the-biergarten-app
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Configure Environment Variables
|
### 2. Configure Backend Environment Variables
|
||||||
|
|
||||||
Copy the example environment file:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.dev
|
cp .env.example .env.dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Edit `.env.dev` with your configuration:
|
At minimum, ensure `.env.dev` includes valid database and token values:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Database (component-based for Docker)
|
|
||||||
DB_SERVER=sqlserver,1433
|
DB_SERVER=sqlserver,1433
|
||||||
DB_NAME=Biergarten
|
DB_NAME=Biergarten
|
||||||
DB_USER=sa
|
DB_USER=sa
|
||||||
DB_PASSWORD=YourStrong!Passw0rd
|
DB_PASSWORD=YourStrong!Passw0rd
|
||||||
|
ACCESS_TOKEN_SECRET=<generated>
|
||||||
# JWT Authentication
|
REFRESH_TOKEN_SECRET=<generated>
|
||||||
JWT_SECRET=your-secret-key-minimum-32-characters-required
|
CONFIRMATION_TOKEN_SECRET=<generated>
|
||||||
|
WEBSITE_BASE_URL=http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
> For a complete list of environment variables, see
|
See [Environment Variables](environment-variables.md) for the full list.
|
||||||
> [Environment Variables](environment-variables.md).
|
|
||||||
|
|
||||||
### 3. Start the Development Environment
|
### 3. Start the Backend Stack
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yaml up -d
|
docker compose -f docker-compose.dev.yaml up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
This command will:
|
This starts SQL Server, migrations, seeding, and the API.
|
||||||
|
|
||||||
- Start SQL Server container
|
Available endpoints:
|
||||||
- Run database migrations
|
|
||||||
- Seed initial data
|
|
||||||
- Start the API on http://localhost:8080
|
|
||||||
|
|
||||||
### 4. Access the API
|
- API Swagger: http://localhost:8080/swagger
|
||||||
|
- Health Check: http://localhost:8080/health
|
||||||
|
|
||||||
- **Swagger UI**: http://localhost:8080/swagger
|
### 4. Start the Active Frontend
|
||||||
- **Health Check**: http://localhost:8080/health
|
|
||||||
|
|
||||||
### 5. View Logs
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All services
|
cd src/Website
|
||||||
docker compose -f docker-compose.dev.yaml logs -f
|
npm install
|
||||||
|
API_BASE_URL=http://localhost:8080 SESSION_SECRET=dev-secret-change-me npm run dev
|
||||||
# Specific service
|
|
||||||
docker compose -f docker-compose.dev.yaml logs -f api.core
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Stop the Environment
|
The website will be available at the local address printed by React Router dev.
|
||||||
|
|
||||||
|
Required frontend runtime variables for local work:
|
||||||
|
|
||||||
|
- `API_BASE_URL` - Base URL for the .NET API
|
||||||
|
- `SESSION_SECRET` - Cookie session signing secret for the website server
|
||||||
|
|
||||||
|
### 5. Optional: Run Storybook
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.dev.yaml down
|
cd src/Website
|
||||||
|
npm run storybook
|
||||||
|
```
|
||||||
|
|
||||||
# Remove volumes (fresh start)
|
Storybook runs at http://localhost:6006 by default.
|
||||||
|
|
||||||
|
## Useful Commands
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose -f docker-compose.dev.yaml logs -f
|
||||||
|
docker compose -f docker-compose.dev.yaml down
|
||||||
docker compose -f docker-compose.dev.yaml down -v
|
docker compose -f docker-compose.dev.yaml down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
## Manual Setup (Without Docker)
|
### Frontend
|
||||||
|
|
||||||
If you prefer to run services locally without Docker:
|
|
||||||
|
|
||||||
### Backend Setup
|
|
||||||
|
|
||||||
#### 1. Start SQL Server
|
|
||||||
|
|
||||||
You can use a local SQL Server instance or a cloud-hosted one. Ensure it's accessible and
|
|
||||||
you have the connection details.
|
|
||||||
|
|
||||||
#### 2. Set Environment Variables
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS/Linux
|
cd src/Website
|
||||||
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
npm run lint
|
||||||
export JWT_SECRET="your-secret-key-minimum-32-characters-required"
|
npm run typecheck
|
||||||
|
npm run format:check
|
||||||
# Windows PowerShell
|
npm run test:storybook
|
||||||
$env:DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
npm run test:storybook:playwright
|
||||||
$env:JWT_SECRET="your-secret-key-minimum-32-characters-required"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. Run Database Migrations
|
## Manual Backend Setup
|
||||||
|
|
||||||
|
If you do not want to use Docker, you can run the backend locally.
|
||||||
|
|
||||||
|
### 1. Set Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DB_CONNECTION_STRING="Server=localhost,1433;Database=Biergarten;User Id=sa;Password=YourStrong!Passw0rd;TrustServerCertificate=True;"
|
||||||
|
export ACCESS_TOKEN_SECRET="<generated>"
|
||||||
|
export REFRESH_TOKEN_SECRET="<generated>"
|
||||||
|
export CONFIRMATION_TOKEN_SECRET="<generated>"
|
||||||
|
export WEBSITE_BASE_URL="http://localhost:3000"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Run Migrations and Seed
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd src/Core
|
cd src/Core
|
||||||
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
|
dotnet run --project Database/Database.Migrations/Database.Migrations.csproj
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Seed the Database
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet run --project Database/Database.Seed/Database.Seed.csproj
|
dotnet run --project Database/Database.Seed/Database.Seed.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 5. Start the API
|
### 3. Start the API
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dotnet run --project API/API.Core/API.Core.csproj
|
dotnet run --project API/API.Core/API.Core.csproj
|
||||||
```
|
```
|
||||||
|
|
||||||
The API will be available at http://localhost:5000 (or the port specified in
|
## Legacy Frontend Note
|
||||||
launchSettings.json).
|
|
||||||
|
|
||||||
### Frontend Setup
|
|
||||||
|
|
||||||
> **Note**: The frontend is currently transitioning from its standalone Prisma/Postgres
|
|
||||||
> backend to the .NET API. Some features may still use the old backend.
|
|
||||||
|
|
||||||
#### 1. Navigate to Website Directory
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd Website
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Create Environment File
|
|
||||||
|
|
||||||
Create `.env.local` with frontend variables. See
|
|
||||||
[Environment Variables - Frontend](environment-variables.md#frontend-variables) for the
|
|
||||||
complete list.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BASE_URL=http://localhost:3000
|
|
||||||
NODE_ENV=development
|
|
||||||
|
|
||||||
# Generate secrets
|
|
||||||
CONFIRMATION_TOKEN_SECRET=$(openssl rand -base64 127)
|
|
||||||
RESET_PASSWORD_TOKEN_SECRET=$(openssl rand -base64 127)
|
|
||||||
SESSION_SECRET=$(openssl rand -base64 127)
|
|
||||||
|
|
||||||
# External services (you'll need to register for these)
|
|
||||||
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
|
|
||||||
CLOUDINARY_KEY=your-api-key
|
|
||||||
CLOUDINARY_SECRET=your-api-secret
|
|
||||||
NEXT_PUBLIC_MAPBOX_KEY=your-mapbox-token
|
|
||||||
|
|
||||||
# Database URL (current Prisma setup)
|
|
||||||
DATABASE_URL=your-postgres-connection-string
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Run Prisma Migrations
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma generate
|
|
||||||
npx prisma migrate dev
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. Start Development Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
The frontend will be available at http://localhost:3000.
|
|
||||||
|
|
||||||
|
The previous Next.js frontend now lives in `src/Website-v1` and is not the active website.
|
||||||
|
Legacy setup details have been moved to [docs/archive/legacy-website-v1.md](archive/legacy-website-v1.md).
|
||||||
|
|
||||||
## Next Steps
|
## Next Steps
|
||||||
|
|
||||||
- **Test the API**: Visit http://localhost:8080/swagger and try the endpoints
|
- Review [Architecture](architecture.md)
|
||||||
- **Run Tests**: See [Testing Guide](testing.md)
|
- Run backend and frontend checks from [Testing](testing.md)
|
||||||
- **Learn the Architecture**: Read [Architecture Overview](architecture.md)
|
- Use [Docker Guide](docker.md) for container troubleshooting
|
||||||
- **Understand Docker Setup**: See [Docker Guide](docker.md)
|
|
||||||
- **Database Details**: Check [Database Schema](database.md)
|
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ This document describes the testing strategy and how to run tests for The Bierga
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The project uses a multi-layered testing approach:
|
The project uses a multi-layered testing approach across backend and frontend:
|
||||||
|
|
||||||
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin)
|
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin)
|
||||||
- **Infrastructure.Repository.Tests** - Unit tests for data access layer
|
- **Infrastructure.Repository.Tests** - Unit tests for data access layer
|
||||||
- **Service.Auth.Tests** - Unit tests for authentication business logic
|
- **Service.Auth.Tests** - Unit tests for authentication business logic
|
||||||
|
- **Storybook Vitest project** - Browser-based interaction tests for shared website stories
|
||||||
|
- **Storybook Playwright suite** - Browser checks against Storybook-rendered components
|
||||||
|
|
||||||
## Running Tests with Docker (Recommended)
|
## Running Tests with Docker (Recommended)
|
||||||
|
|
||||||
@@ -86,6 +88,33 @@ dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
|
|||||||
|
|
||||||
- No database required (uses Moq for mocking)
|
- No database required (uses Moq for mocking)
|
||||||
|
|
||||||
|
### Frontend Storybook Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Website
|
||||||
|
npm install
|
||||||
|
npm run test:storybook
|
||||||
|
```
|
||||||
|
|
||||||
|
**Purpose**:
|
||||||
|
|
||||||
|
- Verifies shared stories such as form fields, submit buttons, navbar states, toasts, and the theme gallery
|
||||||
|
- Runs in browser mode via Vitest and Storybook integration
|
||||||
|
|
||||||
|
### Frontend Playwright Storybook Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Website
|
||||||
|
npm install
|
||||||
|
npm run test:storybook:playwright
|
||||||
|
```
|
||||||
|
|
||||||
|
**Requirements**:
|
||||||
|
|
||||||
|
- Storybook dependencies installed
|
||||||
|
- Playwright browser dependencies installed
|
||||||
|
- The command will start or reuse the Storybook server defined in `playwright.storybook.config.ts`
|
||||||
|
|
||||||
## Test Coverage
|
## Test Coverage
|
||||||
|
|
||||||
### Current Coverage
|
### Current Coverage
|
||||||
@@ -112,6 +141,14 @@ dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
|
|||||||
- Register service with validation
|
- Register service with validation
|
||||||
- Business logic for authentication flow
|
- Business logic for authentication flow
|
||||||
|
|
||||||
|
**Frontend UI Coverage**:
|
||||||
|
|
||||||
|
- Shared submit button states
|
||||||
|
- Form field happy path and error presentation
|
||||||
|
- Navbar guest, authenticated, and mobile behavior
|
||||||
|
- Theme gallery rendering across Biergarten themes
|
||||||
|
- Toast interactions and themed notification display
|
||||||
|
|
||||||
### Planned Coverage
|
### Planned Coverage
|
||||||
|
|
||||||
- [ ] Email verification workflow
|
- [ ] Email verification workflow
|
||||||
@@ -121,6 +158,7 @@ dotnet test Service/Service.Auth.Tests/Service.Auth.Tests.csproj
|
|||||||
- [ ] Beer post operations
|
- [ ] Beer post operations
|
||||||
- [ ] User follow/unfollow
|
- [ ] User follow/unfollow
|
||||||
- [ ] Image upload service
|
- [ ] Image upload service
|
||||||
|
- [ ] Frontend route integration coverage beyond Storybook stories
|
||||||
|
|
||||||
## Testing Frameworks & Tools
|
## Testing Frameworks & Tools
|
||||||
|
|
||||||
@@ -254,6 +292,15 @@ Exit codes:
|
|||||||
- `0` - All tests passed
|
- `0` - All tests passed
|
||||||
- Non-zero - Test failures occurred
|
- Non-zero - Test failures occurred
|
||||||
|
|
||||||
|
Frontend UI checks should also be included in CI for the active website workspace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd src/Website
|
||||||
|
npm ci
|
||||||
|
npm run test:storybook
|
||||||
|
npm run test:storybook:playwright
|
||||||
|
```
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Tests Failing Due to Database Connection
|
### Tests Failing Due to Database Connection
|
||||||
|
|||||||
205
docs/token-validation.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# Token Validation Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The Core project implements comprehensive JWT token validation across three token types:
|
||||||
|
|
||||||
|
- **Access Tokens**: Short-lived (1 hour) tokens for API authentication
|
||||||
|
- **Refresh Tokens**: Long-lived (21 days) tokens for obtaining new access tokens
|
||||||
|
- **Confirmation Tokens**: Short-lived (30 minutes) tokens for email confirmation
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Infrastructure Layer
|
||||||
|
|
||||||
|
#### [ITokenInfrastructure](Infrastructure.Jwt/ITokenInfrastructure.cs)
|
||||||
|
|
||||||
|
Low-level JWT operations.
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `GenerateJwt()` - Creates signed JWT tokens
|
||||||
|
- `ValidateJwtAsync()` - Validates token signature, expiration, and format
|
||||||
|
|
||||||
|
**Implementation:** [JwtInfrastructure.cs](Infrastructure.Jwt/JwtInfrastructure.cs)
|
||||||
|
- Uses Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler
|
||||||
|
- Algorithm: HS256 (HMAC-SHA256)
|
||||||
|
- Validates token lifetime, signature, and well-formedness
|
||||||
|
|
||||||
|
### Service Layer
|
||||||
|
|
||||||
|
#### [ITokenValidationService](Service.Auth/ITokenValidationService.cs)
|
||||||
|
|
||||||
|
High-level token validation with context (token type, user extraction).
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `ValidateAccessTokenAsync(string token)` - Validates access tokens
|
||||||
|
- `ValidateRefreshTokenAsync(string token)` - Validates refresh tokens
|
||||||
|
- `ValidateConfirmationTokenAsync(string token)` - Validates confirmation tokens
|
||||||
|
|
||||||
|
**Returns:** `ValidatedToken` record containing:
|
||||||
|
- `UserId` (Guid)
|
||||||
|
- `Username` (string)
|
||||||
|
- `Principal` (ClaimsPrincipal) - Full JWT claims
|
||||||
|
|
||||||
|
**Implementation:** [TokenValidationService.cs](Service.Auth/TokenValidationService.cs)
|
||||||
|
- Reads token secrets from environment variables
|
||||||
|
- Extracts and validates claims (Sub, UniqueName)
|
||||||
|
- Throws `UnauthorizedException` on validation failure
|
||||||
|
|
||||||
|
#### [ITokenService](Service.Auth/ITokenService.cs)
|
||||||
|
|
||||||
|
Token generation (existing service extended).
|
||||||
|
|
||||||
|
**Methods:**
|
||||||
|
- `GenerateAccessToken(UserAccount)` - Creates 1-hour access token
|
||||||
|
- `GenerateRefreshToken(UserAccount)` - Creates 21-day refresh token
|
||||||
|
- `GenerateConfirmationToken(UserAccount)` - Creates 30-minute confirmation token
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
#### [ConfirmationService](Service.Auth/IConfirmationService.cs)
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Receives confirmation token from user
|
||||||
|
2. Calls `TokenValidationService.ValidateConfirmationTokenAsync()`
|
||||||
|
3. Extracts user ID from validated token
|
||||||
|
4. Calls `AuthRepository.ConfirmUserAccountAsync()` to update database
|
||||||
|
5. Returns confirmation result
|
||||||
|
|
||||||
|
#### [RefreshTokenService](Service.Auth/RefreshTokenService.cs)
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Receives refresh token from user
|
||||||
|
2. Calls `TokenValidationService.ValidateRefreshTokenAsync()`
|
||||||
|
3. Retrieves user account via `AuthRepository.GetUserByIdAsync()`
|
||||||
|
4. Issues new access and refresh tokens via `TokenService`
|
||||||
|
5. Returns new token pair
|
||||||
|
|
||||||
|
#### [AuthController](API.Core/Controllers/AuthController.cs)
|
||||||
|
|
||||||
|
**Endpoints:**
|
||||||
|
- `POST /api/auth/register` - Register new user
|
||||||
|
- `POST /api/auth/login` - Authenticate user
|
||||||
|
- `POST /api/auth/confirm?token=...` - Confirm email
|
||||||
|
- `POST /api/auth/refresh` - Refresh access token
|
||||||
|
|
||||||
|
## Validation Security
|
||||||
|
|
||||||
|
### Token Secrets
|
||||||
|
|
||||||
|
Three independent secrets enable:
|
||||||
|
- **Key rotation** - Rotate each secret type independently
|
||||||
|
- **Isolation** - Compromise of one secret doesn't affect others
|
||||||
|
- **Different expiration** - Different token types can expire at different rates
|
||||||
|
|
||||||
|
**Environment Variables:**
|
||||||
|
```bash
|
||||||
|
ACCESS_TOKEN_SECRET=... # Signs 1-hour access tokens
|
||||||
|
REFRESH_TOKEN_SECRET=... # Signs 21-day refresh tokens
|
||||||
|
CONFIRMATION_TOKEN_SECRET=... # Signs 30-minute confirmation tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
### Validation Checks
|
||||||
|
|
||||||
|
Each token is validated for:
|
||||||
|
|
||||||
|
1. **Signature Verification** - Token must be signed with correct secret
|
||||||
|
2. **Expiration** - Token must not be expired (checked against current time)
|
||||||
|
3. **Claims Presence** - Required claims (Sub, UniqueName) must be present
|
||||||
|
4. **Claims Format** - UserId claim must be a valid GUID
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Validation failures return HTTP 401 Unauthorized:
|
||||||
|
- Invalid signature → "Invalid token"
|
||||||
|
- Expired token → "Invalid token" (message doesn't reveal reason for security)
|
||||||
|
- Missing claims → "Invalid token"
|
||||||
|
- Malformed claims → "Invalid token"
|
||||||
|
|
||||||
|
## Token Lifecycle
|
||||||
|
|
||||||
|
### Access Token Lifecycle
|
||||||
|
|
||||||
|
1. **Generation**: During login (1-hour validity)
|
||||||
|
2. **Usage**: Included in Authorization header on API requests
|
||||||
|
3. **Validation**: Validated on protected endpoints
|
||||||
|
4. **Expiration**: Token becomes invalid after 1 hour
|
||||||
|
5. **Refresh**: Use refresh token to obtain new access token
|
||||||
|
|
||||||
|
### Refresh Token Lifecycle
|
||||||
|
|
||||||
|
1. **Generation**: During login (21-day validity)
|
||||||
|
2. **Storage**: Client-side (secure storage)
|
||||||
|
3. **Usage**: Posted to `/api/auth/refresh` endpoint
|
||||||
|
4. **Validation**: Validated by RefreshTokenService
|
||||||
|
5. **Rotation**: New refresh token issued on successful refresh
|
||||||
|
6. **Expiration**: Token becomes invalid after 21 days
|
||||||
|
|
||||||
|
### Confirmation Token Lifecycle
|
||||||
|
|
||||||
|
1. **Generation**: During user registration (30-minute validity)
|
||||||
|
2. **Delivery**: Emailed to user in confirmation link
|
||||||
|
3. **Usage**: User clicks link, token posted to `/api/auth/confirm`
|
||||||
|
4. **Validation**: Validated by ConfirmationService
|
||||||
|
5. **Completion**: User account marked as confirmed
|
||||||
|
6. **Expiration**: Token becomes invalid after 30 minutes
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
**TokenValidationService.test.cs**
|
||||||
|
- Happy path: Valid token extraction
|
||||||
|
- Error cases: Invalid, expired, malformed tokens
|
||||||
|
- Missing/invalid claims scenarios
|
||||||
|
|
||||||
|
**RefreshTokenService.test.cs**
|
||||||
|
- Successful refresh with valid token
|
||||||
|
- Invalid/expired refresh token rejection
|
||||||
|
- Non-existent user handling
|
||||||
|
|
||||||
|
**ConfirmationService.test.cs**
|
||||||
|
- Successful confirmation with valid token
|
||||||
|
- Token validation failures
|
||||||
|
- User not found scenarios
|
||||||
|
|
||||||
|
### BDD Tests (Reqnroll)
|
||||||
|
|
||||||
|
**TokenRefresh.feature**
|
||||||
|
- Successful token refresh
|
||||||
|
- Invalid/expired token rejection
|
||||||
|
- Missing token validation
|
||||||
|
|
||||||
|
**Confirmation.feature**
|
||||||
|
- Successful email confirmation
|
||||||
|
- Expired/tampered token rejection
|
||||||
|
- Missing token validation
|
||||||
|
|
||||||
|
**AccessTokenValidation.feature**
|
||||||
|
- Protected endpoint access token validation
|
||||||
|
- Invalid/expired access token rejection
|
||||||
|
- Token type mismatch (refresh used as access token)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Stretch Goals
|
||||||
|
|
||||||
|
1. **Middleware for Access Token Validation**
|
||||||
|
- Automatically validate access tokens on protected routes
|
||||||
|
- Populate HttpContext.User from token claims
|
||||||
|
- Return 401 for invalid/missing tokens
|
||||||
|
|
||||||
|
2. **Token Blacklisting**
|
||||||
|
- Implement token revocation (e.g., on logout)
|
||||||
|
- Store blacklisted tokens in cache/database
|
||||||
|
- Check blacklist during validation
|
||||||
|
|
||||||
|
3. **Refresh Token Rotation Strategy**
|
||||||
|
- Detect token reuse (replay attacks)
|
||||||
|
- Automatically invalidate entire token chain on reuse
|
||||||
|
- Log suspicious activity
|
||||||
|
|
||||||
|
4. **Structured Logging**
|
||||||
|
- Log token validation attempts
|
||||||
|
- Track failed validation reasons
|
||||||
|
- Alert on repeated validation failures (brute force detection)
|
||||||
@@ -1,36 +1,42 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RootNamespace>API.Core</RootNamespace>
|
<RootNamespace>API.Core</RootNamespace>
|
||||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.11" />
|
<PackageReference
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
Include="Microsoft.AspNetCore.OpenApi"
|
||||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
Version="9.0.11"
|
||||||
</ItemGroup>
|
/>
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
|
<PackageReference
|
||||||
|
Include="FluentValidation.AspNetCore"
|
||||||
|
Version="11.3.0"
|
||||||
|
/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Folder Include="Infrastructure\" />
|
<Folder Include="Infrastructure\" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||||
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||||
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
<ProjectReference Include="..\..\Service\Service.Auth\Service.Auth.csproj" />
|
||||||
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
<ProjectReference Include="..\..\Service\Service.UserManagement\Service.UserManagement.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="..\..\.dockerignore">
|
<Content Include="..\..\.dockerignore">
|
||||||
<Link>.dockerignore</Link>
|
<Link>.dockerignore</Link>
|
||||||
</Content>
|
</Content>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.Json;
|
||||||
|
using API.Core.Contracts.Common;
|
||||||
|
using Infrastructure.Jwt;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace API.Core.Authentication;
|
||||||
|
|
||||||
|
public class JwtAuthenticationHandler(
|
||||||
|
IOptionsMonitor<JwtAuthenticationOptions> options,
|
||||||
|
ILoggerFactory logger,
|
||||||
|
UrlEncoder encoder,
|
||||||
|
ITokenInfrastructure tokenInfrastructure,
|
||||||
|
IConfiguration configuration
|
||||||
|
) : AuthenticationHandler<JwtAuthenticationOptions>(options, logger, encoder)
|
||||||
|
{
|
||||||
|
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||||
|
{
|
||||||
|
// Use the same access-token secret source as TokenService to avoid mismatched validation.
|
||||||
|
var secret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET");
|
||||||
|
if (string.IsNullOrWhiteSpace(secret))
|
||||||
|
{
|
||||||
|
secret = configuration["Jwt:SecretKey"];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(secret))
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("JWT secret is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Authorization header exists
|
||||||
|
if (
|
||||||
|
!Request.Headers.TryGetValue(
|
||||||
|
"Authorization",
|
||||||
|
out var authHeaderValue
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail("Authorization header is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
var authHeader = authHeaderValue.ToString();
|
||||||
|
if (
|
||||||
|
!authHeader.StartsWith(
|
||||||
|
"Bearer ",
|
||||||
|
StringComparison.OrdinalIgnoreCase
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail(
|
||||||
|
"Invalid authorization header format"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = authHeader.Substring("Bearer ".Length).Trim();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var claimsPrincipal = await tokenInfrastructure.ValidateJwtAsync(
|
||||||
|
token,
|
||||||
|
secret
|
||||||
|
);
|
||||||
|
var ticket = new AuthenticationTicket(claimsPrincipal, Scheme.Name);
|
||||||
|
return AuthenticateResult.Success(ticket);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return AuthenticateResult.Fail(
|
||||||
|
$"Token validation failed: {ex.Message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task HandleChallengeAsync(AuthenticationProperties properties)
|
||||||
|
{
|
||||||
|
Response.ContentType = "application/json";
|
||||||
|
Response.StatusCode = 401;
|
||||||
|
|
||||||
|
var response = new ResponseBody { Message = "Unauthorized: Invalid or missing authentication token" };
|
||||||
|
await Response.WriteAsJsonAsync(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class JwtAuthenticationOptions : AuthenticationSchemeOptions { }
|
||||||
@@ -17,3 +17,5 @@ public record RegistrationPayload(
|
|||||||
string AccessToken,
|
string AccessToken,
|
||||||
bool ConfirmationEmailSent
|
bool ConfirmationEmailSent
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
|
||||||
|
|||||||
19
src/Core/API/API.Core/Contracts/Auth/RefreshToken.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace API.Core.Contracts.Auth;
|
||||||
|
|
||||||
|
public record RefreshTokenRequest
|
||||||
|
{
|
||||||
|
public string RefreshToken { get; init; } = default!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RefreshTokenRequestValidator
|
||||||
|
: AbstractValidator<RefreshTokenRequest>
|
||||||
|
{
|
||||||
|
public RefreshTokenRequestValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.RefreshToken)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Refresh token is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
using API.Core.Contracts.Auth;
|
using API.Core.Contracts.Auth;
|
||||||
using API.Core.Contracts.Common;
|
using API.Core.Contracts.Common;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Service.Auth;
|
using Service.Auth;
|
||||||
|
|
||||||
@@ -8,15 +9,21 @@ namespace API.Core.Controllers
|
|||||||
{
|
{
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class AuthController(IRegisterService register, ILoginService login)
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
: ControllerBase
|
public class AuthController(
|
||||||
|
IRegisterService registerService,
|
||||||
|
ILoginService loginService,
|
||||||
|
IConfirmationService confirmationService,
|
||||||
|
ITokenService tokenService
|
||||||
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[AllowAnonymous]
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<ActionResult<UserAccount>> Register(
|
public async Task<ActionResult<UserAccount>> Register(
|
||||||
[FromBody] RegisterRequest req
|
[FromBody] RegisterRequest req
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var rtn = await register.RegisterAsync(
|
var rtn = await registerService.RegisterAsync(
|
||||||
new UserAccount
|
new UserAccount
|
||||||
{
|
{
|
||||||
UserAccountId = Guid.Empty,
|
UserAccountId = Guid.Empty,
|
||||||
@@ -43,10 +50,11 @@ namespace API.Core.Controllers
|
|||||||
return Created("/", response);
|
return Created("/", response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
[HttpPost("login")]
|
[HttpPost("login")]
|
||||||
public async Task<ActionResult> Login([FromBody] LoginRequest req)
|
public async Task<ActionResult> Login([FromBody] LoginRequest req)
|
||||||
{
|
{
|
||||||
var rtn = await login.LoginAsync(req.Username, req.Password);
|
var rtn = await loginService.LoginAsync(req.Username, req.Password);
|
||||||
|
|
||||||
return Ok(
|
return Ok(
|
||||||
new ResponseBody<LoginPayload>
|
new ResponseBody<LoginPayload>
|
||||||
@@ -61,5 +69,43 @@ namespace API.Core.Controllers
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("confirm")]
|
||||||
|
public async Task<ActionResult> Confirm([FromQuery] string token)
|
||||||
|
{
|
||||||
|
var rtn = await confirmationService.ConfirmUserAsync(token);
|
||||||
|
return Ok(
|
||||||
|
new ResponseBody<ConfirmationPayload>
|
||||||
|
{
|
||||||
|
Message = "User with ID " + rtn.UserId + " is confirmed.",
|
||||||
|
Payload = new ConfirmationPayload(
|
||||||
|
rtn.UserId,
|
||||||
|
rtn.ConfirmedAt
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpPost("refresh")]
|
||||||
|
public async Task<ActionResult> Refresh(
|
||||||
|
[FromBody] RefreshTokenRequest req
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var rtn = await tokenService.RefreshTokenAsync(req.RefreshToken);
|
||||||
|
|
||||||
|
return Ok(
|
||||||
|
new ResponseBody<LoginPayload>
|
||||||
|
{
|
||||||
|
Message = "Token refreshed successfully.",
|
||||||
|
Payload = new LoginPayload(
|
||||||
|
rtn.UserAccount.UserAccountId,
|
||||||
|
rtn.UserAccount.Username,
|
||||||
|
rtn.RefreshToken,
|
||||||
|
rtn.AccessToken
|
||||||
|
),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
27
src/Core/API/API.Core/Controllers/ProtectedController.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using API.Core.Contracts.Common;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace API.Core.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[Authorize(AuthenticationSchemes = "JWT")]
|
||||||
|
public class ProtectedController : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public ActionResult<ResponseBody<object>> Get()
|
||||||
|
{
|
||||||
|
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
var username = User.FindFirst(ClaimTypes.Name)?.Value;
|
||||||
|
|
||||||
|
return Ok(
|
||||||
|
new ResponseBody<object>
|
||||||
|
{
|
||||||
|
Message = "Protected endpoint accessed successfully",
|
||||||
|
Payload = new { userId, username },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
|||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
||||||
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||||
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
using API.Core.Contracts.Common;
|
using API.Core.Contracts.Common;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|
||||||
@@ -71,6 +72,16 @@ public class GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
|||||||
context.ExceptionHandled = true;
|
context.ExceptionHandled = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case SqlException ex:
|
||||||
|
context.Result = new ObjectResult(
|
||||||
|
new ResponseBody { Message = "A database error occurred." }
|
||||||
|
)
|
||||||
|
{
|
||||||
|
StatusCode = 503,
|
||||||
|
};
|
||||||
|
context.ExceptionHandled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
case Domain.Exceptions.ValidationException ex:
|
case Domain.Exceptions.ValidationException ex:
|
||||||
context.Result = new ObjectResult(
|
context.Result = new ObjectResult(
|
||||||
new ResponseBody { Message = ex.Message }
|
new ResponseBody { Message = ex.Message }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using API.Core;
|
using API.Core;
|
||||||
|
using API.Core.Authentication;
|
||||||
using API.Core.Contracts.Common;
|
using API.Core.Contracts.Common;
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
@@ -11,11 +12,12 @@ using Infrastructure.PasswordHashing;
|
|||||||
using Infrastructure.Repository.Auth;
|
using Infrastructure.Repository.Auth;
|
||||||
using Infrastructure.Repository.Sql;
|
using Infrastructure.Repository.Sql;
|
||||||
using Infrastructure.Repository.UserAccount;
|
using Infrastructure.Repository.UserAccount;
|
||||||
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
using Service.Auth;
|
using Service.Auth;
|
||||||
using Service.UserManagement.User;
|
|
||||||
using Service.Emails;
|
using Service.Emails;
|
||||||
|
using Service.UserManagement.User;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -64,10 +66,21 @@ builder.Services.AddScoped<IPasswordInfrastructure, Argon2Infrastructure>();
|
|||||||
builder.Services.AddScoped<IEmailProvider, SmtpEmailProvider>();
|
builder.Services.AddScoped<IEmailProvider, SmtpEmailProvider>();
|
||||||
builder.Services.AddScoped<IEmailTemplateProvider, EmailTemplateProvider>();
|
builder.Services.AddScoped<IEmailTemplateProvider, EmailTemplateProvider>();
|
||||||
builder.Services.AddScoped<IEmailService, EmailService>();
|
builder.Services.AddScoped<IEmailService, EmailService>();
|
||||||
|
builder.Services.AddScoped<IConfirmationService, ConfirmationService>();
|
||||||
|
|
||||||
// Register the exception filter
|
// Register the exception filter
|
||||||
builder.Services.AddScoped<GlobalExceptionFilter>();
|
builder.Services.AddScoped<GlobalExceptionFilter>();
|
||||||
|
|
||||||
|
// Configure JWT Authentication
|
||||||
|
builder
|
||||||
|
.Services.AddAuthentication("JWT")
|
||||||
|
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>(
|
||||||
|
"JWT",
|
||||||
|
options => { }
|
||||||
|
);
|
||||||
|
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
@@ -76,6 +89,9 @@ app.MapOpenApi();
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
// Health check endpoint (used by Docker health checks and orchestrators)
|
// Health check endpoint (used by Docker health checks and orchestrators)
|
||||||
app.MapHealthChecks("/health");
|
app.MapHealthChecks("/health");
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +1,46 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<IsPackable>false</IsPackable>
|
<IsPackable>false</IsPackable>
|
||||||
<RootNamespace>API.Specs</RootNamespace>
|
<RootNamespace>API.Specs</RootNamespace>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
|
||||||
<PackageReference Include="xunit" Version="2.9.2" />
|
<PackageReference Include="xunit" Version="2.9.2" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
|
||||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||||
<PackageReference Include="dbup" Version="5.0.41" />
|
<PackageReference Include="dbup" Version="5.0.41" />
|
||||||
|
|
||||||
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
<!-- Reqnroll core, xUnit adapter and code-behind generator -->
|
||||||
<PackageReference Include="Reqnroll" Version="3.3.3" />
|
<PackageReference Include="Reqnroll" Version="3.3.3" />
|
||||||
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" />
|
<PackageReference Include="Reqnroll.xUnit" Version="3.3.3" />
|
||||||
<PackageReference
|
<PackageReference
|
||||||
Include="Reqnroll.Tools.MsBuild.Generation"
|
Include="Reqnroll.Tools.MsBuild.Generation"
|
||||||
Version="3.3.3"
|
Version="3.3.3"
|
||||||
PrivateAssets="all"
|
PrivateAssets="all"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- ASP.NET Core integration testing -->
|
<!-- ASP.NET Core integration testing -->
|
||||||
<PackageReference
|
<PackageReference
|
||||||
Include="Microsoft.AspNetCore.Mvc.Testing"
|
Include="Microsoft.AspNetCore.Mvc.Testing"
|
||||||
Version="9.0.1"
|
Version="9.0.1"
|
||||||
/>
|
/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- Ensure feature files are included in the project -->
|
<!-- Ensure feature files are included in the project -->
|
||||||
<None Include="Features\**\*.feature" />
|
<None Include="Features\**\*.feature" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
<ProjectReference Include="..\API.Core\API.Core.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ ARG BUILD_CONFIGURATION=Release
|
|||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
COPY ["API/API.Core/API.Core.csproj", "API/API.Core/"]
|
||||||
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
|
COPY ["API/API.Specs/API.Specs.csproj", "API/API.Specs/"]
|
||||||
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||||
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||||
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
COPY ["Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj", "Infrastructure/Infrastructure.PasswordHashing/"]
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
Feature: Protected Endpoint Access Token Validation
|
||||||
|
As a backend developer
|
||||||
|
I want protected endpoints to validate access tokens
|
||||||
|
So that unauthorized requests are rejected
|
||||||
|
|
||||||
|
Scenario: Protected endpoint accepts valid access token
|
||||||
|
Given the API is running
|
||||||
|
And I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
When I submit a request to a protected endpoint with a valid access token
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
|
||||||
|
Scenario: Protected endpoint rejects missing access token
|
||||||
|
Given the API is running
|
||||||
|
When I submit a request to a protected endpoint without an access token
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
|
||||||
|
Scenario: Protected endpoint rejects invalid access token
|
||||||
|
Given the API is running
|
||||||
|
When I submit a request to a protected endpoint with an invalid access token
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
And the response JSON should have "message" containing "Unauthorized"
|
||||||
|
|
||||||
|
Scenario: Protected endpoint rejects expired access token
|
||||||
|
Given the API is running
|
||||||
|
And I have an existing account
|
||||||
|
And I am logged in with an immediately-expiring access token
|
||||||
|
When I submit a request to a protected endpoint with the expired token
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
And the response JSON should have "message" containing "Unauthorized"
|
||||||
|
|
||||||
|
Scenario: Protected endpoint rejects token signed with wrong secret
|
||||||
|
Given the API is running
|
||||||
|
And I have an access token signed with the wrong secret
|
||||||
|
When I submit a request to a protected endpoint with the tampered token
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
And the response JSON should have "message" containing "Unauthorized"
|
||||||
|
|
||||||
|
Scenario: Protected endpoint rejects refresh token as access token
|
||||||
|
Given the API is running
|
||||||
|
And I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
When I submit a request to a protected endpoint with my refresh token instead of access token
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
|
||||||
|
Scenario: Protected endpoint rejects confirmation token as access token
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid confirmation token
|
||||||
|
When I submit a request to a protected endpoint with my confirmation token instead of access token
|
||||||
|
Then the response has HTTP status 401
|
||||||
76
src/Core/API/API.Specs/Features/Confirmation.feature
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
Feature: User Account Confirmation
|
||||||
|
As a newly registered user
|
||||||
|
I want to confirm my email address via a validation token
|
||||||
|
So that my account is fully activated
|
||||||
|
Scenario: Successful confirmation with valid token
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid confirmation token for my account
|
||||||
|
And I have a valid access token for my account
|
||||||
|
When I submit a confirmation request with the valid token
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "message" containing "is confirmed"
|
||||||
|
|
||||||
|
Scenario: Re-confirming an already verified account remains successful
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid confirmation token for my account
|
||||||
|
And I have a valid access token for my account
|
||||||
|
When I submit a confirmation request with the valid token
|
||||||
|
And I submit the same confirmation request again
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "message" containing "is confirmed"
|
||||||
|
|
||||||
|
Scenario: Confirmation fails with invalid token
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid access token for my account
|
||||||
|
When I submit a confirmation request with an invalid token
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
|
|
||||||
|
Scenario: Confirmation fails with expired token
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have an expired confirmation token for my account
|
||||||
|
And I have a valid access token for my account
|
||||||
|
When I submit a confirmation request with the expired token
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
|
|
||||||
|
Scenario: Confirmation fails with tampered token (wrong secret)
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a confirmation token signed with the wrong secret
|
||||||
|
And I have a valid access token for my account
|
||||||
|
When I submit a confirmation request with the tampered token
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
|
|
||||||
|
Scenario: Confirmation fails when token is missing
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid access token for my account
|
||||||
|
When I submit a confirmation request with a missing token
|
||||||
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
|
Scenario: Confirmation endpoint only accepts POST requests
|
||||||
|
Given the API is running
|
||||||
|
And I have a valid confirmation token
|
||||||
|
When I submit a confirmation request using an invalid HTTP method
|
||||||
|
Then the response has HTTP status 404
|
||||||
|
|
||||||
|
Scenario: Confirmation fails with malformed token
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid access token for my account
|
||||||
|
When I submit a confirmation request with a malformed token
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
|
|
||||||
|
Scenario: Confirmation fails without an access token
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid confirmation token for my account
|
||||||
|
When I submit a confirmation request with the valid token without an access token
|
||||||
|
Then the response has HTTP status 401
|
||||||
36
src/Core/API/API.Specs/Features/ResendConfirmation.feature
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
Feature: Resend Confirmation Email
|
||||||
|
As a user who did not receive the confirmation email
|
||||||
|
I want to request a resend of the confirmation email
|
||||||
|
So that I can obtain a working confirmation link while preventing abuse
|
||||||
|
|
||||||
|
Scenario: Legitimate resend for an unconfirmed user
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid access token for my account
|
||||||
|
When I submit a resend confirmation request for my account
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
|
Scenario: Resend is a no-op for an already confirmed user
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid confirmation token for my account
|
||||||
|
And I have a valid access token for my account
|
||||||
|
And I have confirmed my account
|
||||||
|
When I submit a resend confirmation request for my account
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
|
Scenario: Resend is a no-op for a non-existent user
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
And I have a valid access token for my account
|
||||||
|
When I submit a resend confirmation request for a non-existent user
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "message" containing "confirmation email has been resent"
|
||||||
|
|
||||||
|
Scenario: Resend requires authentication
|
||||||
|
Given the API is running
|
||||||
|
And I have registered a new account
|
||||||
|
When I submit a resend confirmation request without an access token
|
||||||
|
Then the response has HTTP status 401
|
||||||
39
src/Core/API/API.Specs/Features/TokenRefresh.feature
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
Feature: Token Refresh
|
||||||
|
As an authenticated user
|
||||||
|
I want to refresh my access token using my refresh token
|
||||||
|
So that I can maintain my session without logging in again
|
||||||
|
|
||||||
|
Scenario: Successful token refresh with valid refresh token
|
||||||
|
Given the API is running
|
||||||
|
And I have an existing account
|
||||||
|
And I am logged in
|
||||||
|
When I submit a refresh token request with a valid refresh token
|
||||||
|
Then the response has HTTP status 200
|
||||||
|
And the response JSON should have "message" equal "Token refreshed successfully."
|
||||||
|
And the response JSON should have a new access token
|
||||||
|
And the response JSON should have a new refresh token
|
||||||
|
|
||||||
|
Scenario: Token refresh fails with invalid refresh token
|
||||||
|
Given the API is running
|
||||||
|
When I submit a refresh token request with an invalid refresh token
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
And the response JSON should have "message" containing "Invalid"
|
||||||
|
|
||||||
|
Scenario: Token refresh fails with expired refresh token
|
||||||
|
Given the API is running
|
||||||
|
And I have an existing account
|
||||||
|
And I am logged in with an immediately-expiring refresh token
|
||||||
|
When I submit a refresh token request with the expired refresh token
|
||||||
|
Then the response has HTTP status 401
|
||||||
|
And the response JSON should have "message" containing "Invalid token"
|
||||||
|
|
||||||
|
Scenario: Token refresh fails when refresh token is missing
|
||||||
|
Given the API is running
|
||||||
|
When I submit a refresh token request with a missing refresh token
|
||||||
|
Then the response has HTTP status 400
|
||||||
|
|
||||||
|
Scenario: Token refresh endpoint only accepts POST requests
|
||||||
|
Given the API is running
|
||||||
|
And I have a valid refresh token
|
||||||
|
When I submit a refresh token request using a GET request
|
||||||
|
Then the response has HTTP status 404
|
||||||
@@ -7,6 +7,8 @@ public class MockEmailService : IEmailService
|
|||||||
{
|
{
|
||||||
public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
|
public List<RegistrationEmail> SentRegistrationEmails { get; } = new();
|
||||||
|
|
||||||
|
public List<ResendConfirmationEmail> SentResendConfirmationEmails { get; } = new();
|
||||||
|
|
||||||
public Task SendRegistrationEmailAsync(
|
public Task SendRegistrationEmailAsync(
|
||||||
UserAccount createdUser,
|
UserAccount createdUser,
|
||||||
string confirmationToken
|
string confirmationToken
|
||||||
@@ -24,9 +26,27 @@ public class MockEmailService : IEmailService
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task SendResendConfirmationEmailAsync(
|
||||||
|
UserAccount user,
|
||||||
|
string confirmationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
SentResendConfirmationEmails.Add(
|
||||||
|
new ResendConfirmationEmail
|
||||||
|
{
|
||||||
|
UserAccount = user,
|
||||||
|
ConfirmationToken = confirmationToken,
|
||||||
|
SentAt = DateTime.UtcNow,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
public void Clear()
|
public void Clear()
|
||||||
{
|
{
|
||||||
SentRegistrationEmails.Clear();
|
SentRegistrationEmails.Clear();
|
||||||
|
SentResendConfirmationEmails.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RegistrationEmail
|
public class RegistrationEmail
|
||||||
@@ -35,4 +55,11 @@ public class MockEmailService : IEmailService
|
|||||||
public string ConfirmationToken { get; init; } = string.Empty;
|
public string ConfirmationToken { get; init; } = string.Empty;
|
||||||
public DateTime SentAt { get; init; }
|
public DateTime SentAt { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ResendConfirmationEmail
|
||||||
|
{
|
||||||
|
public UserAccount UserAccount { get; init; } = null!;
|
||||||
|
public string ConfirmationToken { get; init; } = string.Empty;
|
||||||
|
public DateTime SentAt { get; init; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,4 +149,61 @@ public class ApiGeneralSteps(ScenarioContext scenario)
|
|||||||
);
|
);
|
||||||
value.GetString().Should().Be(expected);
|
value.GetString().Should().Be(expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Then("the response JSON should have {string} containing {string}")]
|
||||||
|
public void ThenTheResponseJsonShouldHaveStringContainingString(
|
||||||
|
string field,
|
||||||
|
string expectedSubstring
|
||||||
|
)
|
||||||
|
{
|
||||||
|
scenario
|
||||||
|
.TryGetValue<HttpResponseMessage>(ResponseKey, out var response)
|
||||||
|
.Should()
|
||||||
|
.BeTrue();
|
||||||
|
scenario
|
||||||
|
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
||||||
|
.Should()
|
||||||
|
.BeTrue();
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(responseBody!);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
if (!root.TryGetProperty(field, out var value))
|
||||||
|
{
|
||||||
|
root.TryGetProperty("payload", out var payloadElem)
|
||||||
|
.Should()
|
||||||
|
.BeTrue(
|
||||||
|
"Expected field '{0}' to be present either at the root or inside 'payload'",
|
||||||
|
field
|
||||||
|
);
|
||||||
|
payloadElem
|
||||||
|
.ValueKind.Should()
|
||||||
|
.Be(JsonValueKind.Object, "payload must be an object");
|
||||||
|
payloadElem
|
||||||
|
.TryGetProperty(field, out value)
|
||||||
|
.Should()
|
||||||
|
.BeTrue(
|
||||||
|
"Expected field '{0}' to be present inside 'payload'",
|
||||||
|
field
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
value
|
||||||
|
.ValueKind.Should()
|
||||||
|
.Be(
|
||||||
|
JsonValueKind.String,
|
||||||
|
"Expected field '{0}' to be a string",
|
||||||
|
field
|
||||||
|
);
|
||||||
|
var actualValue = value.GetString();
|
||||||
|
actualValue
|
||||||
|
.Should()
|
||||||
|
.Contain(
|
||||||
|
expectedSubstring,
|
||||||
|
"Expected field '{0}' to contain '{1}' but was '{2}'",
|
||||||
|
field,
|
||||||
|
expectedSubstring,
|
||||||
|
actualValue
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using API.Specs;
|
using API.Specs;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
|
using Infrastructure.Jwt;
|
||||||
using Reqnroll;
|
using Reqnroll;
|
||||||
|
|
||||||
namespace API.Specs.Steps;
|
namespace API.Specs.Steps;
|
||||||
@@ -13,6 +14,10 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
private const string ResponseKey = "response";
|
private const string ResponseKey = "response";
|
||||||
private const string ResponseBodyKey = "responseBody";
|
private const string ResponseBodyKey = "responseBody";
|
||||||
private const string TestUserKey = "testUser";
|
private const string TestUserKey = "testUser";
|
||||||
|
private const string RegisteredUserIdKey = "registeredUserId";
|
||||||
|
private const string RegisteredUsernameKey = "registeredUsername";
|
||||||
|
private const string PreviousAccessTokenKey = "previousAccessToken";
|
||||||
|
private const string PreviousRefreshTokenKey = "previousRefreshToken";
|
||||||
|
|
||||||
private HttpClient GetClient()
|
private HttpClient GetClient()
|
||||||
{
|
{
|
||||||
@@ -34,6 +39,66 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string GetRequiredEnvVar(string name)
|
||||||
|
{
|
||||||
|
return Environment.GetEnvironmentVariable(name)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"{name} environment variable is not set"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GenerateJwtToken(
|
||||||
|
Guid userId,
|
||||||
|
string username,
|
||||||
|
string secret,
|
||||||
|
DateTime expiry
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var infra = new JwtInfrastructure();
|
||||||
|
return infra.GenerateJwt(userId, username, expiry, secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid ParseRegisteredUserId(JsonElement root)
|
||||||
|
{
|
||||||
|
return root
|
||||||
|
.GetProperty("payload")
|
||||||
|
.GetProperty("userAccountId")
|
||||||
|
.GetGuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ParseRegisteredUsername(JsonElement root)
|
||||||
|
{
|
||||||
|
return root
|
||||||
|
.GetProperty("payload")
|
||||||
|
.GetProperty("username")
|
||||||
|
.GetString()
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"username missing from registration payload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ParseTokenFromPayload(
|
||||||
|
JsonElement payload,
|
||||||
|
string camelCaseName,
|
||||||
|
string pascalCaseName
|
||||||
|
)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
payload.TryGetProperty(camelCaseName, out var tokenElem)
|
||||||
|
|| payload.TryGetProperty(pascalCaseName, out tokenElem)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
return tokenElem.GetString()
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
$"{camelCaseName} is null"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Could not find token field '{camelCaseName}' in payload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
[Given("I have an existing account")]
|
[Given("I have an existing account")]
|
||||||
public void GivenIHaveAnExistingAccount()
|
public void GivenIHaveAnExistingAccount()
|
||||||
{
|
{
|
||||||
@@ -229,6 +294,18 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
dateOfBirth = DateTime.UtcNow.AddYears(-18).ToString("yyyy-MM-dd");
|
dateOfBirth = DateTime.UtcNow.AddYears(-18).ToString("yyyy-MM-dd");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep default registration fixture values unique across repeated runs.
|
||||||
|
if (email == "newuser@example.com")
|
||||||
|
{
|
||||||
|
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
email = $"newuser-{suffix}@example.com";
|
||||||
|
|
||||||
|
if (username == "newuser")
|
||||||
|
{
|
||||||
|
username = $"newuser-{suffix}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var password = row["Password"];
|
var password = row["Password"];
|
||||||
|
|
||||||
var registrationData = new
|
var registrationData = new
|
||||||
@@ -284,4 +361,854 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
scenario[ResponseKey] = response;
|
scenario[ResponseKey] = response;
|
||||||
scenario[ResponseBodyKey] = responseBody;
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Given("I have registered a new account")]
|
||||||
|
public async Task GivenIHaveRegisteredANewAccount()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var suffix = Guid.NewGuid().ToString("N")[..8];
|
||||||
|
var registrationData = new
|
||||||
|
{
|
||||||
|
username = $"newuser-{suffix}",
|
||||||
|
firstName = "New",
|
||||||
|
lastName = "User",
|
||||||
|
email = $"newuser-{suffix}@example.com",
|
||||||
|
dateOfBirth = "1990-01-01",
|
||||||
|
password = "Password1!",
|
||||||
|
};
|
||||||
|
|
||||||
|
var body = JsonSerializer.Serialize(registrationData);
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/api/auth/register"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
body,
|
||||||
|
System.Text.Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(responseBody);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
scenario[RegisteredUserIdKey] = ParseRegisteredUserId(root);
|
||||||
|
scenario[RegisteredUsernameKey] = ParseRegisteredUsername(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("I am logged in")]
|
||||||
|
public async Task GivenIAmLoggedIn()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var loginData = new { username = "test.user", password = "password" };
|
||||||
|
var body = JsonSerializer.Serialize(loginData);
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/api/auth/login"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
body,
|
||||||
|
System.Text.Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
var doc = JsonDocument.Parse(responseBody);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (root.TryGetProperty("payload", out var payloadElem))
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
payloadElem.TryGetProperty("accessToken", out var tokenElem)
|
||||||
|
|| payloadElem.TryGetProperty("AccessToken", out tokenElem)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
scenario["accessToken"] = tokenElem.GetString();
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
payloadElem.TryGetProperty("refreshToken", out var refreshElem)
|
||||||
|
|| payloadElem.TryGetProperty("RefreshToken", out refreshElem)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
scenario["refreshToken"] = refreshElem.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("I have a valid refresh token")]
|
||||||
|
public async Task GivenIHaveAValidRefreshToken()
|
||||||
|
{
|
||||||
|
await GivenIAmLoggedIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("I am logged in with an immediately-expiring refresh token")]
|
||||||
|
public async Task GivenIAmLoggedInWithAnImmediatelyExpiringRefreshToken()
|
||||||
|
{
|
||||||
|
// For now, create a normal login; in production this would generate an expiring token
|
||||||
|
await GivenIAmLoggedIn();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("I have a valid access token for my account")]
|
||||||
|
public void GivenIHaveAValidAccessTokenForMyAccount()
|
||||||
|
{
|
||||||
|
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
|
||||||
|
? id
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
"registered user ID not found in scenario"
|
||||||
|
);
|
||||||
|
var username = scenario.TryGetValue<string>(
|
||||||
|
RegisteredUsernameKey,
|
||||||
|
out var user
|
||||||
|
)
|
||||||
|
? user
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
"registered username not found in scenario"
|
||||||
|
);
|
||||||
|
|
||||||
|
var secret = GetRequiredEnvVar("ACCESS_TOKEN_SECRET");
|
||||||
|
scenario["accessToken"] = GenerateJwtToken(
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
secret,
|
||||||
|
DateTime.UtcNow.AddMinutes(60)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("I have a valid confirmation token for my account")]
|
||||||
|
public void GivenIHaveAValidConfirmationTokenForMyAccount()
|
||||||
|
{
|
||||||
|
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
|
||||||
|
? id
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
"registered user ID not found in scenario"
|
||||||
|
);
|
||||||
|
var username = scenario.TryGetValue<string>(
|
||||||
|
RegisteredUsernameKey,
|
||||||
|
out var user
|
||||||
|
)
|
||||||
|
? user
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
"registered username not found in scenario"
|
||||||
|
);
|
||||||
|
|
||||||
|
var secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET");
|
||||||
|
scenario["confirmationToken"] = GenerateJwtToken(
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
secret,
|
||||||
|
DateTime.UtcNow.AddMinutes(5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("I have an expired confirmation token for my account")]
|
||||||
|
public void GivenIHaveAnExpiredConfirmationTokenForMyAccount()
|
||||||
|
{
|
||||||
|
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
|
||||||
|
? id
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
"registered user ID not found in scenario"
|
||||||
|
);
|
||||||
|
var username = scenario.TryGetValue<string>(
|
||||||
|
RegisteredUsernameKey,
|
||||||
|
out var user
|
||||||
|
)
|
||||||
|
? user
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
"registered username not found in scenario"
|
||||||
|
);
|
||||||
|
|
||||||
|
var secret = GetRequiredEnvVar("CONFIRMATION_TOKEN_SECRET");
|
||||||
|
scenario["confirmationToken"] = GenerateJwtToken(
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
secret,
|
||||||
|
DateTime.UtcNow.AddMinutes(-5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("I have a confirmation token signed with the wrong secret")]
|
||||||
|
public void GivenIHaveAConfirmationTokenSignedWithTheWrongSecret()
|
||||||
|
{
|
||||||
|
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
|
||||||
|
? id
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
"registered user ID not found in scenario"
|
||||||
|
);
|
||||||
|
var username = scenario.TryGetValue<string>(
|
||||||
|
RegisteredUsernameKey,
|
||||||
|
out var user
|
||||||
|
)
|
||||||
|
? user
|
||||||
|
: throw new InvalidOperationException(
|
||||||
|
"registered username not found in scenario"
|
||||||
|
);
|
||||||
|
|
||||||
|
const string wrongSecret =
|
||||||
|
"wrong-confirmation-secret-that-is-very-long-1234567890";
|
||||||
|
scenario["confirmationToken"] = GenerateJwtToken(
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
wrongSecret,
|
||||||
|
DateTime.UtcNow.AddMinutes(5)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[When(
|
||||||
|
"I submit a request to a protected endpoint with a valid access token"
|
||||||
|
)]
|
||||||
|
public async Task WhenISubmitARequestToAProtectedEndpointWithAValidAccessToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var token = scenario.TryGetValue<string>("accessToken", out var t)
|
||||||
|
? t
|
||||||
|
: "invalid-token";
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
"/api/protected"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Headers = { { "Authorization", $"Bearer {token}" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When(
|
||||||
|
"I submit a request to a protected endpoint with an invalid access token"
|
||||||
|
)]
|
||||||
|
public async Task WhenISubmitARequestToAProtectedEndpointWithAnInvalidAccessToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
"/api/protected"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Headers = { { "Authorization", "Bearer invalid-token-format" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a confirmation request with the valid token")]
|
||||||
|
public async Task WhenISubmitAConfirmationRequestWithTheValidToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||||
|
? t
|
||||||
|
: "valid-token";
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||||
|
);
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit the same confirmation request again")]
|
||||||
|
public async Task WhenISubmitTheSameConfirmationRequestAgain()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||||
|
? t
|
||||||
|
: "valid-token";
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||||
|
);
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a confirmation request with a malformed token")]
|
||||||
|
public async Task WhenISubmitAConfirmationRequestWithAMalformedToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
const string token = "malformed-token-not-jwt";
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||||
|
);
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a refresh token request with a valid refresh token")]
|
||||||
|
public async Task WhenISubmitARefreshTokenRequestWithTheValidRefreshToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
if (scenario.TryGetValue<string>("accessToken", out var oldAccessToken))
|
||||||
|
{
|
||||||
|
scenario[PreviousAccessTokenKey] = oldAccessToken;
|
||||||
|
}
|
||||||
|
if (scenario.TryGetValue<string>("refreshToken", out var oldRefreshToken))
|
||||||
|
{
|
||||||
|
scenario[PreviousRefreshTokenKey] = oldRefreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = scenario.TryGetValue<string>("refreshToken", out var t)
|
||||||
|
? t
|
||||||
|
: "valid-refresh-token";
|
||||||
|
var body = JsonSerializer.Serialize(new { refreshToken = token });
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/api/auth/refresh"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
body,
|
||||||
|
System.Text.Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a refresh token request with an invalid refresh token")]
|
||||||
|
public async Task WhenISubmitARefreshTokenRequestWithAnInvalidRefreshToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var body = JsonSerializer.Serialize(
|
||||||
|
new { refreshToken = "invalid-refresh-token" }
|
||||||
|
);
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/api/auth/refresh"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
body,
|
||||||
|
System.Text.Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a refresh token request with the expired refresh token")]
|
||||||
|
public async Task WhenISubmitARefreshTokenRequestWithTheExpiredRefreshToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
// Use an expired token
|
||||||
|
var body = JsonSerializer.Serialize(
|
||||||
|
new { refreshToken = "expired-refresh-token" }
|
||||||
|
);
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/api/auth/refresh"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
body,
|
||||||
|
System.Text.Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a refresh token request with a missing refresh token")]
|
||||||
|
public async Task WhenISubmitARefreshTokenRequestWithAMissingRefreshToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var body = JsonSerializer.Serialize(new { });
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
"/api/auth/refresh"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
body,
|
||||||
|
System.Text.Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a refresh token request using a GET request")]
|
||||||
|
public async Task WhenISubmitARefreshTokenRequestUsingAGETRequest()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
"/api/auth/refresh"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
"{}",
|
||||||
|
System.Text.Encoding.UTF8,
|
||||||
|
"application/json"
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected Endpoint Steps
|
||||||
|
[When("I submit a request to a protected endpoint without an access token")]
|
||||||
|
public async Task WhenISubmitARequestToAProtectedEndpointWithoutAnAccessToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
"/api/protected"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("I am logged in with an immediately-expiring access token")]
|
||||||
|
public Task GivenIAmLoggedInWithAnImmediatelyExpiringAccessToken()
|
||||||
|
{
|
||||||
|
// Simulate an expired access token for auth rejection behavior.
|
||||||
|
scenario["accessToken"] = "expired-access-token";
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("I have an access token signed with the wrong secret")]
|
||||||
|
public void GivenIHaveAnAccessTokenSignedWithTheWrongSecret()
|
||||||
|
{
|
||||||
|
// Create a token with a different secret
|
||||||
|
scenario["accessToken"] =
|
||||||
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a request to a protected endpoint with the expired token")]
|
||||||
|
public async Task WhenISubmitARequestToAProtectedEndpointWithTheExpiredToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var token = scenario.TryGetValue<string>("accessToken", out var t)
|
||||||
|
? t
|
||||||
|
: "expired-token";
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
"/api/protected"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Headers = { { "Authorization", $"Bearer {token}" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a request to a protected endpoint with the tampered token")]
|
||||||
|
public async Task WhenISubmitARequestToAProtectedEndpointWithTheTamperedToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var token = scenario.TryGetValue<string>("accessToken", out var t)
|
||||||
|
? t
|
||||||
|
: "tampered-token";
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
"/api/protected"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Headers = { { "Authorization", $"Bearer {token}" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When(
|
||||||
|
"I submit a request to a protected endpoint with my refresh token instead of access token"
|
||||||
|
)]
|
||||||
|
public async Task WhenISubmitARequestToAProtectedEndpointWithMyRefreshTokenInsteadOfAccessToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var token = scenario.TryGetValue<string>("refreshToken", out var t)
|
||||||
|
? t
|
||||||
|
: "refresh-token";
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
"/api/protected"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Headers = { { "Authorization", $"Bearer {token}" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("I have a valid confirmation token")]
|
||||||
|
public void GivenIHaveAValidConfirmationToken()
|
||||||
|
{
|
||||||
|
scenario["confirmationToken"] = "valid-confirmation-token";
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a confirmation request with the expired token")]
|
||||||
|
public async Task WhenISubmitAConfirmationRequestWithTheExpiredToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||||
|
? t
|
||||||
|
: "expired-confirmation-token";
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||||
|
);
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a confirmation request with the tampered token")]
|
||||||
|
public async Task WhenISubmitAConfirmationRequestWithTheTamperedToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||||
|
? t
|
||||||
|
: "tampered-confirmation-token";
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||||
|
);
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a confirmation request with a missing token")]
|
||||||
|
public async Task WhenISubmitAConfirmationRequestWithAMissingToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/confirm");
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a confirmation request using an invalid HTTP method")]
|
||||||
|
public async Task WhenISubmitAConfirmationRequestUsingAnInvalidHttpMethod()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||||
|
? t
|
||||||
|
: "valid-confirmation-token";
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When(
|
||||||
|
"I submit a request to a protected endpoint with my confirmation token instead of access token"
|
||||||
|
)]
|
||||||
|
public async Task WhenISubmitARequestToAProtectedEndpointWithMyConfirmationTokenInsteadOfAccessToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||||
|
? t
|
||||||
|
: "confirmation-token";
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Get,
|
||||||
|
"/api/protected"
|
||||||
|
)
|
||||||
|
{
|
||||||
|
Headers = { { "Authorization", $"Bearer {token}" } },
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a confirmation request with an invalid token")]
|
||||||
|
public async Task WhenISubmitAConfirmationRequestWithAnInvalidToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
const string token = "invalid-confirmation-token";
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||||
|
);
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a confirmation request with the valid token without an access token")]
|
||||||
|
public async Task WhenISubmitAConfirmationRequestWithTheValidTokenWithoutAnAccessToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||||
|
? t
|
||||||
|
: "valid-token";
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Then("the response JSON should have a new access token")]
|
||||||
|
public void ThenTheResponseJsonShouldHaveANewAccessToken()
|
||||||
|
{
|
||||||
|
scenario
|
||||||
|
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
||||||
|
.Should()
|
||||||
|
.BeTrue();
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(responseBody!);
|
||||||
|
var payload = doc.RootElement.GetProperty("payload");
|
||||||
|
var accessToken = ParseTokenFromPayload(
|
||||||
|
payload,
|
||||||
|
"accessToken",
|
||||||
|
"AccessToken"
|
||||||
|
);
|
||||||
|
|
||||||
|
accessToken.Should().NotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
if (
|
||||||
|
scenario.TryGetValue<string>(
|
||||||
|
PreviousAccessTokenKey,
|
||||||
|
out var previousAccessToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
accessToken.Should().NotBe(previousAccessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Then("the response JSON should have a new refresh token")]
|
||||||
|
public void ThenTheResponseJsonShouldHaveANewRefreshToken()
|
||||||
|
{
|
||||||
|
scenario
|
||||||
|
.TryGetValue<string>(ResponseBodyKey, out var responseBody)
|
||||||
|
.Should()
|
||||||
|
.BeTrue();
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(responseBody!);
|
||||||
|
var payload = doc.RootElement.GetProperty("payload");
|
||||||
|
var refreshToken = ParseTokenFromPayload(
|
||||||
|
payload,
|
||||||
|
"refreshToken",
|
||||||
|
"RefreshToken"
|
||||||
|
);
|
||||||
|
|
||||||
|
refreshToken.Should().NotBeNullOrWhiteSpace();
|
||||||
|
|
||||||
|
if (
|
||||||
|
scenario.TryGetValue<string>(
|
||||||
|
PreviousRefreshTokenKey,
|
||||||
|
out var previousRefreshToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
{
|
||||||
|
refreshToken.Should().NotBe(previousRefreshToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Given("I have confirmed my account")]
|
||||||
|
public async Task GivenIHaveConfirmedMyAccount()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var token = scenario.TryGetValue<string>("confirmationToken", out var t)
|
||||||
|
? t
|
||||||
|
: throw new InvalidOperationException("confirmation token not found");
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm?token={Uri.EscapeDataString(token)}"
|
||||||
|
);
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a resend confirmation request for my account")]
|
||||||
|
public async Task WhenISubmitAResendConfirmationRequestForMyAccount()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
|
||||||
|
? id
|
||||||
|
: throw new InvalidOperationException("registered user ID not found");
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm/resend?userId={userId}"
|
||||||
|
);
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a resend confirmation request for a non-existent user")]
|
||||||
|
public async Task WhenISubmitAResendConfirmationRequestForANonExistentUser()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var fakeUserId = Guid.NewGuid();
|
||||||
|
var accessToken = scenario.TryGetValue<string>("accessToken", out var at)
|
||||||
|
? at
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm/resend?userId={fakeUserId}"
|
||||||
|
);
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
requestMessage.Headers.Add("Authorization", $"Bearer {accessToken}");
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
[When("I submit a resend confirmation request without an access token")]
|
||||||
|
public async Task WhenISubmitAResendConfirmationRequestWithoutAnAccessToken()
|
||||||
|
{
|
||||||
|
var client = GetClient();
|
||||||
|
var userId = scenario.TryGetValue<Guid>(RegisteredUserIdKey, out var id)
|
||||||
|
? id
|
||||||
|
: Guid.NewGuid();
|
||||||
|
|
||||||
|
var requestMessage = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
$"/api/auth/confirm/resend?userId={userId}"
|
||||||
|
);
|
||||||
|
|
||||||
|
var response = await client.SendAsync(requestMessage);
|
||||||
|
var responseBody = await response.Content.ReadAsStringAsync();
|
||||||
|
scenario[ResponseKey] = response;
|
||||||
|
scenario[ResponseBodyKey] = responseBody;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,19 @@
|
|||||||
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
|
<Project Path="Database/Database.Seed/Database.Seed.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Domain/">
|
<Folder Name="/Domain/">
|
||||||
<Project Path="Domain.Entities\Domain.Entities.csproj" />
|
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
|
||||||
<Project Path="Domain.Exceptions/Domain.Exceptions.csproj" />
|
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Infrastructure/">
|
<Folder Name="/Infrastructure/">
|
||||||
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
|
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
|
||||||
<Project Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
|
<Project
|
||||||
|
Path="Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj" />
|
||||||
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
|
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
|
||||||
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
|
<Project
|
||||||
|
Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
|
||||||
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" />
|
<Project Path="Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj" />
|
||||||
<Project Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
|
<Project
|
||||||
|
Path="Infrastructure\Infrastructure.Repository.Tests\Infrastructure.Repository.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/Service/">
|
<Folder Name="/Service/">
|
||||||
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
|
<Project Path="Service/Service.Auth.Tests/Service.Auth.Tests.csproj" />
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ USE Biergarten;
|
|||||||
CREATE TABLE dbo.UserAccount
|
CREATE TABLE dbo.UserAccount
|
||||||
(
|
(
|
||||||
UserAccountID UNIQUEIDENTIFIER
|
UserAccountID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
CONSTRAINT DF_UserAccountID DEFAULT NEWID(),
|
||||||
|
|
||||||
Username VARCHAR(64) NOT NULL,
|
Username VARCHAR(64) NOT NULL,
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ CREATE TABLE dbo.UserAccount
|
|||||||
|
|
||||||
UpdatedAt DATETIME,
|
UpdatedAt DATETIME,
|
||||||
|
|
||||||
DateOfBirth DATETIME NOT NULL,
|
DateOfBirth DATE NOT NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
@@ -49,7 +49,6 @@ CREATE TABLE dbo.UserAccount
|
|||||||
|
|
||||||
CONSTRAINT AK_Email
|
CONSTRAINT AK_Email
|
||||||
UNIQUE (Email)
|
UNIQUE (Email)
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -109,7 +108,7 @@ CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
|
|||||||
|
|
||||||
CONSTRAINT AK_UserAvatar_UserAccountID
|
CONSTRAINT AK_UserAvatar_UserAccountID
|
||||||
UNIQUE (UserAccountID)
|
UNIQUE (UserAccountID)
|
||||||
)
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
CREATE NONCLUSTERED INDEX IX_UserAvatar_UserAccount
|
||||||
ON UserAvatar(UserAccountID);
|
ON UserAvatar(UserAccountID);
|
||||||
@@ -125,8 +124,7 @@ CREATE TABLE UserVerification -- delete verification data when user account is d
|
|||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
VerificationDateTime DATETIME NOT NULL
|
VerificationDateTime DATETIME NOT NULL
|
||||||
CONSTRAINT DF_VerificationDateTime
|
CONSTRAINT DF_VerificationDateTime DEFAULT GETDATE(),
|
||||||
DEFAULT GETDATE(),
|
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
@@ -155,13 +153,13 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
|||||||
|
|
||||||
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
UserAccountID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE() NOT NULL,
|
CONSTRAINT DF_UserCredential_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Expiry DATETIME
|
Expiry DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()) NOT NULL,
|
CONSTRAINT DF_UserCredential_Expiry DEFAULT DATEADD(DAY, 90, GETDATE()),
|
||||||
|
|
||||||
Hash NVARCHAR(MAX) NOT NULL,
|
Hash NVARCHAR(256) NOT NULL,
|
||||||
-- uses argon2
|
-- uses argon2
|
||||||
|
|
||||||
IsRevoked BIT NOT NULL
|
IsRevoked BIT NOT NULL
|
||||||
@@ -177,12 +175,16 @@ CREATE TABLE UserCredential -- delete credentials when user account is deleted
|
|||||||
CONSTRAINT FK_UserCredential_UserAccount
|
CONSTRAINT FK_UserCredential_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount(UserAccountID)
|
||||||
ON DELETE CASCADE,
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
CREATE NONCLUSTERED INDEX IX_UserCredential_UserAccount
|
||||||
ON UserCredential(UserAccountID);
|
ON UserCredential(UserAccountID);
|
||||||
|
|
||||||
|
CREATE NONCLUSTERED INDEX IX_UserCredential_Account_Active
|
||||||
|
ON UserCredential(UserAccountID, IsRevoked, Expiry)
|
||||||
|
INCLUDE (Hash);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -195,8 +197,8 @@ CREATE TABLE UserFollow
|
|||||||
|
|
||||||
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
FollowingID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
CreatedAt DATETIME
|
CreatedAt DATETIME NOT NULL
|
||||||
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE() NOT NULL,
|
CONSTRAINT DF_UserFollow_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
@@ -205,11 +207,13 @@ CREATE TABLE UserFollow
|
|||||||
|
|
||||||
CONSTRAINT FK_UserFollow_UserAccount
|
CONSTRAINT FK_UserFollow_UserAccount
|
||||||
FOREIGN KEY (UserAccountID)
|
FOREIGN KEY (UserAccountID)
|
||||||
REFERENCES UserAccount(UserAccountID),
|
REFERENCES UserAccount(UserAccountID)
|
||||||
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT FK_UserFollow_UserAccountFollowing
|
CONSTRAINT FK_UserFollow_UserAccountFollowing
|
||||||
FOREIGN KEY (FollowingID)
|
FOREIGN KEY (FollowingID)
|
||||||
REFERENCES UserAccount(UserAccountID),
|
REFERENCES UserAccount(UserAccountID)
|
||||||
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT CK_CannotFollowOwnAccount
|
CONSTRAINT CK_CannotFollowOwnAccount
|
||||||
CHECK (UserAccountID != FollowingID)
|
CHECK (UserAccountID != FollowingID)
|
||||||
@@ -221,7 +225,6 @@ CREATE NONCLUSTERED INDEX IX_UserFollow_UserAccount_FollowingID
|
|||||||
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
CREATE NONCLUSTERED INDEX IX_UserFollow_FollowingID_UserAccount
|
||||||
ON UserFollow(FollowingID, UserAccountID);
|
ON UserFollow(FollowingID, UserAccountID);
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -240,7 +243,7 @@ CREATE TABLE Country
|
|||||||
PRIMARY KEY (CountryID),
|
PRIMARY KEY (CountryID),
|
||||||
|
|
||||||
CONSTRAINT AK_Country_ISO3166_1
|
CONSTRAINT AK_Country_ISO3166_1
|
||||||
UNIQUE (ISO3166_1)
|
UNIQUE (ISO3166_1)
|
||||||
);
|
);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -299,7 +302,6 @@ CREATE TABLE City
|
|||||||
CREATE NONCLUSTERED INDEX IX_City_StateProvince
|
CREATE NONCLUSTERED INDEX IX_City_StateProvince
|
||||||
ON City(StateProvinceID);
|
ON City(StateProvinceID);
|
||||||
|
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -308,6 +310,8 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
|||||||
BreweryPostID UNIQUEIDENTIFIER
|
BreweryPostID UNIQUEIDENTIFIER
|
||||||
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
CONSTRAINT DF_BreweryPostID DEFAULT NEWID(),
|
||||||
|
|
||||||
|
BreweryName NVARCHAR(256) NOT NULL,
|
||||||
|
|
||||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Description NVARCHAR(512) NOT NULL,
|
Description NVARCHAR(512) NOT NULL,
|
||||||
@@ -325,15 +329,15 @@ CREATE TABLE BreweryPost -- A user cannot be deleted if they have a post
|
|||||||
CONSTRAINT FK_BreweryPost_UserAccount
|
CONSTRAINT FK_BreweryPost_UserAccount
|
||||||
FOREIGN KEY (PostedByID)
|
FOREIGN KEY (PostedByID)
|
||||||
REFERENCES UserAccount(UserAccountID)
|
REFERENCES UserAccount(UserAccountID)
|
||||||
ON DELETE NO ACTION,
|
ON DELETE NO ACTION
|
||||||
|
);
|
||||||
)
|
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
CREATE NONCLUSTERED INDEX IX_BreweryPost_PostedByID
|
||||||
ON BreweryPost(PostedByID);
|
ON BreweryPost(PostedByID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
CREATE TABLE BreweryPostLocation
|
CREATE TABLE BreweryPostLocation
|
||||||
(
|
(
|
||||||
BreweryPostLocationID UNIQUEIDENTIFIER
|
BreweryPostLocationID UNIQUEIDENTIFIER
|
||||||
@@ -349,7 +353,7 @@ CREATE TABLE BreweryPostLocation
|
|||||||
|
|
||||||
CityID UNIQUEIDENTIFIER NOT NULL,
|
CityID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Coordinates GEOGRAPHY NOT NULL,
|
Coordinates GEOGRAPHY NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
@@ -362,7 +366,11 @@ CREATE TABLE BreweryPostLocation
|
|||||||
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
CONSTRAINT FK_BreweryPostLocation_BreweryPost
|
||||||
FOREIGN KEY (BreweryPostID)
|
FOREIGN KEY (BreweryPostID)
|
||||||
REFERENCES BreweryPost(BreweryPostID)
|
REFERENCES BreweryPost(BreweryPostID)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE,
|
||||||
|
|
||||||
|
CONSTRAINT FK_BreweryPostLocation_City
|
||||||
|
FOREIGN KEY (CityID)
|
||||||
|
REFERENCES City(CityID)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
||||||
@@ -371,6 +379,18 @@ CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_BreweryPost
|
|||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
CREATE NONCLUSTERED INDEX IX_BreweryPostLocation_City
|
||||||
ON BreweryPostLocation(CityID);
|
ON BreweryPostLocation(CityID);
|
||||||
|
|
||||||
|
-- To assess when the time comes:
|
||||||
|
|
||||||
|
-- This would allow for efficient spatial queries to find breweries within a certain distance of a location, but it adds overhead to insert/update operations.
|
||||||
|
|
||||||
|
-- CREATE SPATIAL INDEX SIDX_BreweryPostLocation_Coordinates
|
||||||
|
-- ON BreweryPostLocation(Coordinates)
|
||||||
|
-- USING GEOGRAPHY_GRID
|
||||||
|
-- WITH (
|
||||||
|
-- GRIDS = (LEVEL_1 = MEDIUM, LEVEL_2 = MEDIUM, LEVEL_3 = MEDIUM, LEVEL_4 = MEDIUM),
|
||||||
|
-- CELLS_PER_OBJECT = 16
|
||||||
|
-- );
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
@@ -403,13 +423,14 @@ CREATE TABLE BreweryPostPhoto -- All photos linked to a post are deleted if the
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_Photo_BreweryPost
|
||||||
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
ON BreweryPostPhoto(PhotoID, BreweryPostID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
CREATE NONCLUSTERED INDEX IX_BreweryPostPhoto_BreweryPost_Photo
|
||||||
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
ON BreweryPostPhoto(BreweryPostID, PhotoID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
|
|
||||||
CREATE TABLE BeerStyle
|
CREATE TABLE BeerStyle
|
||||||
(
|
(
|
||||||
BeerStyleID UNIQUEIDENTIFIER
|
BeerStyleID UNIQUEIDENTIFIER
|
||||||
@@ -444,7 +465,7 @@ CREATE TABLE BeerPost
|
|||||||
-- Alcohol By Volume (typically 0-67%)
|
-- Alcohol By Volume (typically 0-67%)
|
||||||
|
|
||||||
IBU INT NOT NULL,
|
IBU INT NOT NULL,
|
||||||
-- International Bitterness Units (typically 0-100)
|
-- International Bitterness Units (typically 0-120)
|
||||||
|
|
||||||
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
PostedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
@@ -464,7 +485,8 @@ CREATE TABLE BeerPost
|
|||||||
|
|
||||||
CONSTRAINT FK_BeerPost_PostedBy
|
CONSTRAINT FK_BeerPost_PostedBy
|
||||||
FOREIGN KEY (PostedByID)
|
FOREIGN KEY (PostedByID)
|
||||||
REFERENCES UserAccount(UserAccountID),
|
REFERENCES UserAccount(UserAccountID)
|
||||||
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPost_BeerStyle
|
CONSTRAINT FK_BeerPost_BeerStyle
|
||||||
FOREIGN KEY (BeerStyleID)
|
FOREIGN KEY (BeerStyleID)
|
||||||
@@ -522,10 +544,10 @@ CREATE TABLE BeerPostPhoto -- All photos linked to a beer post are deleted if th
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_Photo_BeerPost
|
||||||
ON BeerPostPhoto(PhotoID, BeerPostID);
|
ON BeerPostPhoto(PhotoID, BeerPostID);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
CREATE NONCLUSTERED INDEX IX_BeerPostPhoto_BeerPost_Photo
|
||||||
ON BeerPostPhoto(BeerPostID, PhotoID);
|
ON BeerPostPhoto(BeerPostID, PhotoID);
|
||||||
|
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
----------------------------------------------------------------------------
|
----------------------------------------------------------------------------
|
||||||
@@ -539,17 +561,35 @@ CREATE TABLE BeerPostComment
|
|||||||
|
|
||||||
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
BeerPostID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
|
CommentedByID UNIQUEIDENTIFIER NOT NULL,
|
||||||
|
|
||||||
Rating INT NOT NULL,
|
Rating INT NOT NULL,
|
||||||
|
|
||||||
|
CreatedAt DATETIME NOT NULL
|
||||||
|
CONSTRAINT DF_BeerPostComment_CreatedAt DEFAULT GETDATE(),
|
||||||
|
|
||||||
|
UpdatedAt DATETIME NULL,
|
||||||
|
|
||||||
Timer ROWVERSION,
|
Timer ROWVERSION,
|
||||||
|
|
||||||
CONSTRAINT PK_BeerPostComment
|
CONSTRAINT PK_BeerPostComment
|
||||||
PRIMARY KEY (BeerPostCommentID),
|
PRIMARY KEY (BeerPostCommentID),
|
||||||
|
|
||||||
CONSTRAINT FK_BeerPostComment_BeerPost
|
CONSTRAINT FK_BeerPostComment_BeerPost
|
||||||
FOREIGN KEY (BeerPostID) REFERENCES BeerPost(BeerPostID)
|
FOREIGN KEY (BeerPostID)
|
||||||
)
|
REFERENCES BeerPost(BeerPostID),
|
||||||
|
|
||||||
|
CONSTRAINT FK_BeerPostComment_UserAccount
|
||||||
|
FOREIGN KEY (CommentedByID)
|
||||||
|
REFERENCES UserAccount(UserAccountID)
|
||||||
|
ON DELETE NO ACTION,
|
||||||
|
|
||||||
|
CONSTRAINT CHK_BeerPostComment_Rating
|
||||||
|
CHECK (Rating BETWEEN 1 AND 5)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
CREATE NONCLUSTERED INDEX IX_BeerPostComment_BeerPost
|
||||||
ON BeerPostComment(BeerPostID)
|
ON BeerPostComment(BeerPostID);
|
||||||
|
|
||||||
|
CREATE NONCLUSTERED INDEX IX_BeerPostComment_CommentedBy
|
||||||
|
ON BeerPostComment(CommentedByID);
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
CREATE OR ALTER PROCEDURE dbo.USP_CreateBrewery(
|
||||||
|
@BreweryName NVARCHAR(256),
|
||||||
|
@Description NVARCHAR(512),
|
||||||
|
@PostedByID UNIQUEIDENTIFIER,
|
||||||
|
@CityID UNIQUEIDENTIFIER,
|
||||||
|
@AddressLine1 NVARCHAR(256),
|
||||||
|
@AddressLine2 NVARCHAR(256) = NULL,
|
||||||
|
@PostalCode NVARCHAR(20),
|
||||||
|
@Coordinates GEOGRAPHY = NULL
|
||||||
|
)
|
||||||
|
AS
|
||||||
|
BEGIN
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
SET XACT_ABORT ON;
|
||||||
|
|
||||||
|
IF @BreweryName IS NULL
|
||||||
|
THROW 50001, 'Brewery name cannot be null.', 1;
|
||||||
|
|
||||||
|
IF @Description IS NULL
|
||||||
|
THROW 50002, 'Brewery description cannot be null.', 1;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1
|
||||||
|
FROM dbo.UserAccount
|
||||||
|
WHERE UserAccountID = @PostedByID)
|
||||||
|
THROW 50404, 'User not found.', 1;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1
|
||||||
|
FROM dbo.City
|
||||||
|
WHERE CityID = @CityID)
|
||||||
|
THROW 50404, 'City not found.', 1;
|
||||||
|
|
||||||
|
DECLARE @NewBreweryID UNIQUEIDENTIFIER = NEWID();
|
||||||
|
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
INSERT INTO dbo.BreweryPost
|
||||||
|
(BreweryPostID, BreweryName, Description, PostedByID)
|
||||||
|
VALUES (@NewBreweryID, @BreweryName, @Description, @PostedByID);
|
||||||
|
|
||||||
|
INSERT INTO dbo.BreweryPostLocation
|
||||||
|
(@NewBreweryID, CityID, AddressLine1, AddressLine2, PostalCode, Coordinates)
|
||||||
|
VALUES (@NewBreweryID, @CityID, @AddressLine1, @AddressLine2, @PostalCode, @Coordinates);
|
||||||
|
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
END
|
||||||
@@ -18,7 +18,8 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
<ProjectReference
|
||||||
|
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
@using Infrastructure.Email.Templates.Components
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml"
|
||||||
|
xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<title>Resend Confirmation - The Biergarten App</title>
|
||||||
|
<!--[if mso]>
|
||||||
|
<style>
|
||||||
|
* { font-family: Arial, sans-serif !important; }
|
||||||
|
table { border-collapse: collapse; }
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<!--<![endif]-->
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="margin:0; padding:0; background-color:#f4f4f4; width:100%;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="background-color:#f4f4f4;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding:40px 10px;">
|
||||||
|
<!--[if mso]>
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="600" style="width:600px;">
|
||||||
|
<tr><td>
|
||||||
|
<![endif]-->
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%"
|
||||||
|
style="max-width:600px; background:#ffffff; border-radius:8px; box-shadow:0 2px 8px rgba(0,0,0,.08);">
|
||||||
|
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:40px 40px 16px 40px; text-align:center;">
|
||||||
|
<h1 style="margin:0; color:#333333; font-size:26px; font-weight:700;">
|
||||||
|
New Confirmation Link
|
||||||
|
</h1>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 40px 20px 40px; text-align:center;">
|
||||||
|
<p style="margin:0; color:#666666; font-size:16px; line-height:24px;">
|
||||||
|
Hi <strong style="color:#333333;">@Username</strong>, you requested another email confirmation
|
||||||
|
link.
|
||||||
|
Use the button below to verify your account.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:8px 40px;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<!--[if mso]>
|
||||||
|
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word"
|
||||||
|
href="@ConfirmationLink" style="height:50px;v-text-anchor:middle;width:260px;"
|
||||||
|
arcsize="10%" stroke="f" fillcolor="#f59e0b">
|
||||||
|
<w:anchorlock/>
|
||||||
|
<center style="color:#ffffff;font-family:Arial,sans-serif;font-size:16px;font-weight:700;">
|
||||||
|
Confirm Email Again
|
||||||
|
</center>
|
||||||
|
</v:roundrect>
|
||||||
|
<![endif]-->
|
||||||
|
<!--[if !mso]><!-->
|
||||||
|
<a href="@ConfirmationLink" target="_blank" rel="noopener noreferrer"
|
||||||
|
style="display:inline-block; padding:16px 40px; background:#d97706; color:#ffffff; text-decoration:none; border-radius:6px; font-size:16px; font-weight:700;">
|
||||||
|
Confirm Email Again
|
||||||
|
</a>
|
||||||
|
<!--<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:20px 40px 8px 40px; text-align:center;">
|
||||||
|
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
|
||||||
|
This replacement link expires in 24 hours.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td style="padding:0 40px 28px 40px; text-align:center;">
|
||||||
|
<p style="margin:0; color:#999999; font-size:13px; line-height:20px;">
|
||||||
|
If you did not request this, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<EmailFooter FooterText="Cheers, The Biergarten App Team" />
|
||||||
|
</table>
|
||||||
|
<!--[if mso]></td></tr></table><![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter]
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string ConfirmationLink { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -30,6 +30,23 @@ public class EmailTemplateProvider(
|
|||||||
return await RenderComponentAsync<UserRegistration>(parameters);
|
return await RenderComponentAsync<UserRegistration>(parameters);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the ResendConfirmation template with the specified parameters.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> RenderResendConfirmationEmailAsync(
|
||||||
|
string username,
|
||||||
|
string confirmationLink
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var parameters = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
{ nameof(ResendConfirmation.Username), username },
|
||||||
|
{ nameof(ResendConfirmation.ConfirmationLink), confirmationLink },
|
||||||
|
};
|
||||||
|
|
||||||
|
return await RenderComponentAsync<ResendConfirmation>(parameters);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generic method to render any Razor component to HTML.
|
/// Generic method to render any Razor component to HTML.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -15,4 +15,15 @@ public interface IEmailTemplateProvider
|
|||||||
string username,
|
string username,
|
||||||
string confirmationLink
|
string confirmationLink
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Renders the ResendConfirmation template with the specified parameters.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="username">The username to include in the email</param>
|
||||||
|
/// <param name="confirmationLink">The new confirmation link</param>
|
||||||
|
/// <returns>The rendered HTML string</returns>
|
||||||
|
Task<string> RenderResendConfirmationEmailAsync(
|
||||||
|
string username,
|
||||||
|
string confirmationLink
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace Infrastructure.Jwt;
|
namespace Infrastructure.Jwt;
|
||||||
|
|
||||||
public interface ITokenInfrastructure
|
public interface ITokenInfrastructure
|
||||||
{
|
{
|
||||||
string GenerateJwt(Guid userId, string username, DateTime expiry);
|
string GenerateJwt(
|
||||||
}
|
Guid userId,
|
||||||
|
string username,
|
||||||
|
DateTime expiry,
|
||||||
|
string secret
|
||||||
|
);
|
||||||
|
|
||||||
|
Task<ClaimsPrincipal> ValidateJwtAsync(string token, string secret);
|
||||||
|
}
|
||||||
@@ -16,4 +16,8 @@
|
|||||||
Version="8.2.1"
|
Version="8.2.1"
|
||||||
/>
|
/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -3,28 +3,33 @@ using System.Text;
|
|||||||
using Microsoft.IdentityModel.JsonWebTokens;
|
using Microsoft.IdentityModel.JsonWebTokens;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
|
using JwtRegisteredClaimNames = System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
|
||||||
namespace Infrastructure.Jwt;
|
namespace Infrastructure.Jwt;
|
||||||
|
|
||||||
public class JwtInfrastructure : ITokenInfrastructure
|
public class JwtInfrastructure : ITokenInfrastructure
|
||||||
{
|
{
|
||||||
private readonly string? _secret = Environment.GetEnvironmentVariable(
|
public string GenerateJwt(
|
||||||
"JWT_SECRET"
|
Guid userId,
|
||||||
);
|
string username,
|
||||||
|
DateTime expiry,
|
||||||
public string GenerateJwt(Guid userId, string username, DateTime expiry)
|
string secret
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var handler = new JsonWebTokenHandler();
|
var handler = new JsonWebTokenHandler();
|
||||||
|
var key = Encoding.UTF8.GetBytes(secret);
|
||||||
var key = Encoding.UTF8.GetBytes(
|
|
||||||
_secret ?? throw new InvalidOperationException("secret not set")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Base claims (always present)
|
|
||||||
var claims = new List<Claim>
|
var claims = new List<Claim>
|
||||||
{
|
{
|
||||||
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(
|
||||||
|
JwtRegisteredClaimNames.Iat,
|
||||||
|
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()
|
||||||
|
),
|
||||||
|
new(
|
||||||
|
JwtRegisteredClaimNames.Exp,
|
||||||
|
new DateTimeOffset(expiry).ToUnixTimeSeconds().ToString()
|
||||||
|
),
|
||||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,4 +45,36 @@ public class JwtInfrastructure : ITokenInfrastructure
|
|||||||
|
|
||||||
return handler.CreateToken(tokenDescriptor);
|
return handler.CreateToken(tokenDescriptor);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
public async Task<ClaimsPrincipal> ValidateJwtAsync(
|
||||||
|
string token,
|
||||||
|
string secret
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var handler = new JsonWebTokenHandler();
|
||||||
|
var keyBytes = Encoding.UTF8.GetBytes(
|
||||||
|
secret
|
||||||
|
);
|
||||||
|
var parameters = new TokenValidationParameters
|
||||||
|
{
|
||||||
|
ValidateIssuer = false,
|
||||||
|
ValidateAudience = false,
|
||||||
|
ValidateLifetime = true,
|
||||||
|
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
|
||||||
|
};
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await handler.ValidateTokenAsync(token, parameters);
|
||||||
|
if (!result.IsValid || result.ClaimsIdentity == null)
|
||||||
|
throw new UnauthorizedAccessException();
|
||||||
|
|
||||||
|
return new ClaimsPrincipal(result.ClaimsIdentity);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedException("Invalid token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||||
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
COPY ["Infrastructure/Infrastructure.Repository/Infrastructure.Repository.csproj", "Infrastructure/Infrastructure.Repository/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
|
COPY ["Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj", "Infrastructure/Infrastructure.Repository.Tests/"]
|
||||||
RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj"
|
RUN dotnet restore "Infrastructure/Infrastructure.Repository.Tests/Infrastructure.Repository.Tests.csproj"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Data;
|
|||||||
using System.Data.Common;
|
using System.Data.Common;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
using Infrastructure.Repository.Sql;
|
using Infrastructure.Repository.Sql;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
|
|
||||||
namespace Infrastructure.Repository.Auth;
|
namespace Infrastructure.Repository.Auth;
|
||||||
|
|
||||||
@@ -107,6 +108,78 @@ public class AuthRepository(ISqlConnectionFactory connectionFactory)
|
|||||||
await command.ExecuteNonQueryAsync();
|
await command.ExecuteNonQueryAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Domain.Entities.UserAccount?> GetUserByIdAsync(
|
||||||
|
Guid userAccountId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
await using var connection = await CreateConnection();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "usp_GetUserAccountById";
|
||||||
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
|
|
||||||
|
AddParameter(command, "@UserAccountId", userAccountId);
|
||||||
|
|
||||||
|
await using var reader = await command.ExecuteReaderAsync();
|
||||||
|
return await reader.ReadAsync() ? MapToEntity(reader) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(
|
||||||
|
Guid userAccountId
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var user = await GetUserByIdAsync(userAccountId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotency: if already verified, treat as successful confirmation.
|
||||||
|
if (await IsUserVerifiedAsync(userAccountId))
|
||||||
|
{
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var connection = await CreateConnection();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText = "USP_CreateUserVerification";
|
||||||
|
command.CommandType = CommandType.StoredProcedure;
|
||||||
|
|
||||||
|
AddParameter(command, "@UserAccountID_", userAccountId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await command.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
catch (SqlException ex) when (IsDuplicateVerificationViolation(ex))
|
||||||
|
{
|
||||||
|
// A concurrent request verified this user first. Keep behavior idempotent.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and return the updated user
|
||||||
|
return await GetUserByIdAsync(userAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IsUserVerifiedAsync(Guid userAccountId)
|
||||||
|
{
|
||||||
|
await using var connection = await CreateConnection();
|
||||||
|
await using var command = connection.CreateCommand();
|
||||||
|
command.CommandText =
|
||||||
|
"SELECT TOP 1 1 FROM dbo.UserVerification WHERE UserAccountID = @UserAccountID";
|
||||||
|
command.CommandType = CommandType.Text;
|
||||||
|
|
||||||
|
AddParameter(command, "@UserAccountID", userAccountId);
|
||||||
|
|
||||||
|
var result = await command.ExecuteScalarAsync();
|
||||||
|
return result != null && result != DBNull.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsDuplicateVerificationViolation(SqlException ex)
|
||||||
|
{
|
||||||
|
// 2601/2627 are duplicate key violations in SQL Server.
|
||||||
|
return ex.Number == 2601 || ex.Number == 2627;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Maps a data reader row to a UserAccount entity.
|
/// Maps a data reader row to a UserAccount entity.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -60,4 +60,26 @@ public interface IAuthRepository
|
|||||||
/// <param name="userAccountId">ID of the user account</param>
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
/// <param name="newPasswordHash">New hashed password</param>
|
/// <param name="newPasswordHash">New hashed password</param>
|
||||||
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
Task RotateCredentialAsync(Guid userAccountId, string newPasswordHash);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a user account as confirmed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccountId">ID of the user account to confirm</param>
|
||||||
|
/// <returns>The confirmed UserAccount entity</returns>
|
||||||
|
/// <exception cref="UnauthorizedException">If user account not found</exception>
|
||||||
|
Task<Domain.Entities.UserAccount?> ConfirmUserAccountAsync(Guid userAccountId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a user account by ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
|
/// <returns>UserAccount if found, null otherwise</returns>
|
||||||
|
Task<Domain.Entities.UserAccount?> GetUserByIdAsync(Guid userAccountId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks whether a user account has been verified.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="userAccountId">ID of the user account</param>
|
||||||
|
/// <returns>True if the user has a verification record, false otherwise</returns>
|
||||||
|
Task<bool> IsUserVerifiedAsync(Guid userAccountId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,6 @@
|
|||||||
/>
|
/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
159
src/Core/Service/Service.Auth.Tests/ConfirmationService.test.cs
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Infrastructure.Repository.Auth;
|
||||||
|
using Moq;
|
||||||
|
using Service.Emails;
|
||||||
|
|
||||||
|
namespace Service.Auth.Tests;
|
||||||
|
|
||||||
|
public class ConfirmationServiceTest
|
||||||
|
{
|
||||||
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
|
private readonly Mock<ITokenService> _tokenServiceMock;
|
||||||
|
private readonly Mock<IEmailService> _emailServiceMock;
|
||||||
|
private readonly ConfirmationService _confirmationService;
|
||||||
|
|
||||||
|
public ConfirmationServiceTest()
|
||||||
|
{
|
||||||
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
|
_tokenServiceMock = new Mock<ITokenService>();
|
||||||
|
_emailServiceMock = new Mock<IEmailService>();
|
||||||
|
|
||||||
|
_confirmationService = new ConfirmationService(
|
||||||
|
_authRepositoryMock.Object,
|
||||||
|
_tokenServiceMock.Object,
|
||||||
|
_emailServiceMock.Object
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConfirmUserAsync_WithValidConfirmationToken_ConfirmsUser()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string confirmationToken = "valid-confirmation-token";
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
var validatedToken = new ValidatedToken(userId, username, principal);
|
||||||
|
var userAccount = new UserAccount
|
||||||
|
{
|
||||||
|
UserAccountId = userId,
|
||||||
|
Username = username,
|
||||||
|
FirstName = "Test",
|
||||||
|
LastName = "User",
|
||||||
|
Email = "test@example.com",
|
||||||
|
DateOfBirth = new DateTime(1990, 1, 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||||
|
.ReturnsAsync(validatedToken);
|
||||||
|
|
||||||
|
_authRepositoryMock
|
||||||
|
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
||||||
|
.ReturnsAsync(userAccount);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result =
|
||||||
|
await _confirmationService.ConfirmUserAsync(confirmationToken);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserId.Should().Be(userId);
|
||||||
|
result.ConfirmedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
_tokenServiceMock.Verify(
|
||||||
|
x => x.ValidateConfirmationTokenAsync(confirmationToken),
|
||||||
|
Times.Once
|
||||||
|
);
|
||||||
|
|
||||||
|
_authRepositoryMock.Verify(
|
||||||
|
x => x.ConfirmUserAccountAsync(userId),
|
||||||
|
Times.Once
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConfirmUserAsync_WithInvalidConfirmationToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string invalidToken = "invalid-confirmation-token";
|
||||||
|
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.ValidateConfirmationTokenAsync(invalidToken))
|
||||||
|
.ThrowsAsync(new UnauthorizedException(
|
||||||
|
"Invalid confirmation token"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _confirmationService.ConfirmUserAsync(invalidToken)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConfirmUserAsync_WithExpiredConfirmationToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string expiredToken = "expired-confirmation-token";
|
||||||
|
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.ValidateConfirmationTokenAsync(expiredToken))
|
||||||
|
.ThrowsAsync(new UnauthorizedException(
|
||||||
|
"Confirmation token has expired"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _confirmationService.ConfirmUserAsync(expiredToken)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ConfirmUserAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string username = "nonexistent";
|
||||||
|
const string confirmationToken = "valid-token-for-nonexistent-user";
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
var validatedToken = new ValidatedToken(userId, username, principal);
|
||||||
|
|
||||||
|
_tokenServiceMock
|
||||||
|
.Setup(x => x.ValidateConfirmationTokenAsync(confirmationToken))
|
||||||
|
.ReturnsAsync(validatedToken);
|
||||||
|
|
||||||
|
_authRepositoryMock
|
||||||
|
.Setup(x => x.ConfirmUserAccountAsync(userId))
|
||||||
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _confirmationService.ConfirmUserAsync(confirmationToken)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*User account not found*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
ARG BUILD_CONFIGURATION=Release
|
ARG BUILD_CONFIGURATION=Release
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY ["Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain.Entities/"]
|
||||||
COPY ["Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain.Exceptions/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
|
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]
|
||||||
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
COPY ["Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj", "Infrastructure/Infrastructure.Jwt/"]
|
||||||
|
|||||||
162
src/Core/Service/Service.Auth.Tests/TokenServiceRefresh.test.cs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Infrastructure.Jwt;
|
||||||
|
using Infrastructure.Repository.Auth;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Service.Auth.Tests;
|
||||||
|
|
||||||
|
public class TokenServiceRefreshTest
|
||||||
|
{
|
||||||
|
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||||
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
|
private readonly TokenService _tokenService;
|
||||||
|
|
||||||
|
public TokenServiceRefreshTest()
|
||||||
|
{
|
||||||
|
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||||
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
|
|
||||||
|
// Set environment variables for tokens
|
||||||
|
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
||||||
|
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
||||||
|
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
|
||||||
|
|
||||||
|
_tokenService = new TokenService(
|
||||||
|
_tokenInfraMock.Object,
|
||||||
|
_authRepositoryMock.Object
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithValidRefreshToken_ReturnsNewTokens()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string refreshToken = "valid-refresh-token";
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
var userAccount = new UserAccount
|
||||||
|
{
|
||||||
|
UserAccountId = userId,
|
||||||
|
Username = username,
|
||||||
|
FirstName = "Test",
|
||||||
|
LastName = "User",
|
||||||
|
Email = "test@example.com",
|
||||||
|
DateOfBirth = new DateTime(1990, 1, 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock the validation of refresh token
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Mock the generation of new tokens
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
|
||||||
|
.Returns((Guid _, string _, DateTime _, string _) => $"generated-token-{Guid.NewGuid()}");
|
||||||
|
|
||||||
|
_authRepositoryMock
|
||||||
|
.Setup(x => x.GetUserByIdAsync(userId))
|
||||||
|
.ReturnsAsync(userAccount);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result = await _tokenService.RefreshTokenAsync(refreshToken);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserAccount.UserAccountId.Should().Be(userId);
|
||||||
|
result.UserAccount.Username.Should().Be(username);
|
||||||
|
result.AccessToken.Should().NotBeEmpty();
|
||||||
|
result.RefreshToken.Should().NotBeEmpty();
|
||||||
|
|
||||||
|
_authRepositoryMock.Verify(
|
||||||
|
x => x.GetUserByIdAsync(userId),
|
||||||
|
Times.Once
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify tokens were generated (called twice - once for access, once for refresh)
|
||||||
|
_tokenInfraMock.Verify(
|
||||||
|
x => x.GenerateJwt(It.IsAny<Guid>(), It.IsAny<string>(), It.IsAny<DateTime>(), It.IsAny<string>()),
|
||||||
|
Times.Exactly(2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithInvalidRefreshToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string invalidToken = "invalid-refresh-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(invalidToken, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Invalid refresh token"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.RefreshTokenAsync(invalidToken)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithExpiredRefreshToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string expiredToken = "expired-refresh-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(expiredToken, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Refresh token has expired"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.RefreshTokenAsync(expiredToken)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RefreshTokenAsync_WithNonExistentUser_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string refreshToken = "valid-refresh-token";
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
_authRepositoryMock
|
||||||
|
.Setup(x => x.GetUserByIdAsync(userId))
|
||||||
|
.ReturnsAsync((UserAccount?)null);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.RefreshTokenAsync(refreshToken)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*User account not found*");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
|
using System.Security.Claims;
|
||||||
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Infrastructure.Jwt;
|
||||||
|
using Infrastructure.Repository.Auth;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace Service.Auth.Tests;
|
||||||
|
|
||||||
|
public class TokenServiceValidationTest
|
||||||
|
{
|
||||||
|
private readonly Mock<ITokenInfrastructure> _tokenInfraMock;
|
||||||
|
private readonly Mock<IAuthRepository> _authRepositoryMock;
|
||||||
|
private readonly TokenService _tokenService;
|
||||||
|
|
||||||
|
public TokenServiceValidationTest()
|
||||||
|
{
|
||||||
|
_tokenInfraMock = new Mock<ITokenInfrastructure>();
|
||||||
|
_authRepositoryMock = new Mock<IAuthRepository>();
|
||||||
|
|
||||||
|
// Set environment variables for tokens
|
||||||
|
Environment.SetEnvironmentVariable("ACCESS_TOKEN_SECRET", "test-access-secret-that-is-very-long-1234567890");
|
||||||
|
Environment.SetEnvironmentVariable("REFRESH_TOKEN_SECRET", "test-refresh-secret-that-is-very-long-1234567890");
|
||||||
|
Environment.SetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET", "test-confirmation-secret-that-is-very-long-1234567890");
|
||||||
|
|
||||||
|
_tokenService = new TokenService(
|
||||||
|
_tokenInfraMock.Object,
|
||||||
|
_authRepositoryMock.Object
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "valid-access-token";
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result =
|
||||||
|
await _tokenService.ValidateAccessTokenAsync(token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserId.Should().Be(userId);
|
||||||
|
result.Username.Should().Be(username);
|
||||||
|
result.Principal.Should().NotBeNull();
|
||||||
|
result.Principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value.Should().Be(userId.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateRefreshTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "valid-refresh-token";
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result =
|
||||||
|
await _tokenService.ValidateRefreshTokenAsync(token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserId.Should().Be(userId);
|
||||||
|
result.Username.Should().Be(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateConfirmationTokenAsync_WithValidToken_ReturnsValidatedToken()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "valid-confirmation-token";
|
||||||
|
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var result =
|
||||||
|
await _tokenService.ValidateConfirmationTokenAsync(token);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
result.UserId.Should().Be(userId);
|
||||||
|
result.Username.Should().Be(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string token = "invalid-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithExpiredToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string token = "expired-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException(
|
||||||
|
"Token has expired"
|
||||||
|
));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithMissingUserIdClaim_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "token-without-user-id";
|
||||||
|
|
||||||
|
// Claims without Sub (user ID)
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*missing required claims*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithMissingUsernameClaim_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
const string token = "token-without-username";
|
||||||
|
|
||||||
|
// Claims without UniqueName (username)
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*missing required claims*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateAccessTokenAsync_WithMalformedUserId_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string username = "testuser";
|
||||||
|
const string token = "token-with-malformed-user-id";
|
||||||
|
|
||||||
|
// Claims with invalid GUID format
|
||||||
|
var claims = new List<Claim>
|
||||||
|
{
|
||||||
|
new(JwtRegisteredClaimNames.Sub, "not-a-valid-guid"),
|
||||||
|
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||||
|
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
|
};
|
||||||
|
|
||||||
|
var claimsIdentity = new ClaimsIdentity(claims);
|
||||||
|
var principal = new ClaimsPrincipal(claimsIdentity);
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.ValidateAccessTokenAsync(token)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>()
|
||||||
|
.WithMessage("*malformed user ID*");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateRefreshTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string token = "invalid-refresh-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.ValidateRefreshTokenAsync(token)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ValidateConfirmationTokenAsync_WithInvalidToken_ThrowsUnauthorizedException()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
const string token = "invalid-confirmation-token";
|
||||||
|
|
||||||
|
_tokenInfraMock
|
||||||
|
.Setup(x => x.ValidateJwtAsync(token, It.IsAny<string>()))
|
||||||
|
.ThrowsAsync(new UnauthorizedException("Invalid token"));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
await FluentActions.Invoking(async () =>
|
||||||
|
await _tokenService.ValidateConfirmationTokenAsync(token)
|
||||||
|
).Should().ThrowAsync<UnauthorizedException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/Core/Service/Service.Auth/ConfirmationService.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
using Domain.Exceptions;
|
||||||
|
using Infrastructure.Repository.Auth;
|
||||||
|
using Service.Emails;
|
||||||
|
|
||||||
|
namespace Service.Auth;
|
||||||
|
|
||||||
|
public class ConfirmationService(
|
||||||
|
IAuthRepository authRepository,
|
||||||
|
ITokenService tokenService,
|
||||||
|
IEmailService emailService
|
||||||
|
) : IConfirmationService
|
||||||
|
{
|
||||||
|
public async Task<ConfirmationServiceReturn> ConfirmUserAsync(
|
||||||
|
string confirmationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var validatedToken = await tokenService.ValidateConfirmationTokenAsync(
|
||||||
|
confirmationToken
|
||||||
|
);
|
||||||
|
|
||||||
|
var user = await authRepository.ConfirmUserAccountAsync(
|
||||||
|
validatedToken.UserId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedException("User account not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ConfirmationServiceReturn(
|
||||||
|
DateTime.UtcNow,
|
||||||
|
user.UserAccountId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ResendConfirmationEmailAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var user = await authRepository.GetUserByIdAsync(userId);
|
||||||
|
if (user == null)
|
||||||
|
{
|
||||||
|
return; // Silent return to prevent user enumeration
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await authRepository.IsUserVerifiedAsync(userId))
|
||||||
|
{
|
||||||
|
return; // Already confirmed, no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
var confirmationToken = tokenService.GenerateConfirmationToken(user);
|
||||||
|
await emailService.SendResendConfirmationEmailAsync(user, confirmationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/Core/Service/Service.Auth/IConfirmationService.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using Domain.Exceptions;
|
||||||
|
using Infrastructure.Repository.Auth;
|
||||||
|
|
||||||
|
namespace Service.Auth;
|
||||||
|
|
||||||
|
public record ConfirmationServiceReturn(DateTime ConfirmedAt, Guid UserId);
|
||||||
|
|
||||||
|
public interface IConfirmationService
|
||||||
|
{
|
||||||
|
Task<ConfirmationServiceReturn> ConfirmUserAsync(string confirmationToken);
|
||||||
|
Task ResendConfirmationEmailAsync(Guid userId);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@ using Domain.Entities;
|
|||||||
|
|
||||||
namespace Service.Auth;
|
namespace Service.Auth;
|
||||||
|
|
||||||
|
public record LoginServiceReturn(
|
||||||
|
UserAccount UserAccount,
|
||||||
|
string RefreshToken,
|
||||||
|
string AccessToken
|
||||||
|
);
|
||||||
public interface ILoginService
|
public interface ILoginService
|
||||||
{
|
{
|
||||||
Task<LoginServiceReturn> LoginAsync(string username, string password);
|
Task<LoginServiceReturn> LoginAsync(string username, string password);
|
||||||
|
|||||||
@@ -36,4 +36,4 @@ public interface IRegisterService
|
|||||||
UserAccount userAccount,
|
UserAccount userAccount,
|
||||||
string password
|
string password
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,34 +1,156 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.IdentityModel.Tokens.Jwt;
|
||||||
using Domain.Entities;
|
using Domain.Entities;
|
||||||
|
using Domain.Exceptions;
|
||||||
using Infrastructure.Jwt;
|
using Infrastructure.Jwt;
|
||||||
|
using Infrastructure.Repository.Auth;
|
||||||
|
|
||||||
namespace Service.Auth;
|
namespace Service.Auth;
|
||||||
|
|
||||||
|
public enum TokenType
|
||||||
|
{
|
||||||
|
AccessToken,
|
||||||
|
RefreshToken,
|
||||||
|
ConfirmationToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ValidatedToken(Guid UserId, string Username, ClaimsPrincipal Principal);
|
||||||
|
|
||||||
|
public record RefreshTokenResult(
|
||||||
|
UserAccount UserAccount,
|
||||||
|
string RefreshToken,
|
||||||
|
string AccessToken
|
||||||
|
);
|
||||||
|
|
||||||
|
public static class TokenServiceExpirationHours
|
||||||
|
{
|
||||||
|
public const double AccessTokenHours = 1;
|
||||||
|
public const double RefreshTokenHours = 504; // 21 days
|
||||||
|
public const double ConfirmationTokenHours = 0.5; // 30 minutes
|
||||||
|
}
|
||||||
|
|
||||||
public interface ITokenService
|
public interface ITokenService
|
||||||
{
|
{
|
||||||
public string GenerateAccessToken(UserAccount user);
|
string GenerateAccessToken(UserAccount user);
|
||||||
public string GenerateRefreshToken(UserAccount user);
|
string GenerateRefreshToken(UserAccount user);
|
||||||
|
string GenerateConfirmationToken(UserAccount user);
|
||||||
|
string GenerateToken<T>(UserAccount user) where T : struct, Enum;
|
||||||
|
Task<ValidatedToken> ValidateAccessTokenAsync(string token);
|
||||||
|
Task<ValidatedToken> ValidateRefreshTokenAsync(string token);
|
||||||
|
Task<ValidatedToken> ValidateConfirmationTokenAsync(string token);
|
||||||
|
Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TokenService(ITokenInfrastructure tokenInfrastructure)
|
public class TokenService : ITokenService
|
||||||
: ITokenService
|
|
||||||
{
|
{
|
||||||
public string GenerateAccessToken(UserAccount userAccount)
|
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||||
|
private readonly IAuthRepository _authRepository;
|
||||||
|
|
||||||
|
private readonly string _accessTokenSecret;
|
||||||
|
private readonly string _refreshTokenSecret;
|
||||||
|
private readonly string _confirmationTokenSecret;
|
||||||
|
|
||||||
|
public TokenService(
|
||||||
|
ITokenInfrastructure tokenInfrastructure,
|
||||||
|
IAuthRepository authRepository
|
||||||
|
)
|
||||||
{
|
{
|
||||||
var jwtExpiresAt = DateTime.UtcNow.AddHours(1);
|
_tokenInfrastructure = tokenInfrastructure;
|
||||||
return tokenInfrastructure.GenerateJwt(
|
_authRepository = authRepository;
|
||||||
userAccount.UserAccountId,
|
|
||||||
userAccount.Username,
|
_accessTokenSecret = Environment.GetEnvironmentVariable("ACCESS_TOKEN_SECRET")
|
||||||
jwtExpiresAt
|
?? throw new InvalidOperationException("ACCESS_TOKEN_SECRET environment variable is not set");
|
||||||
);
|
|
||||||
|
_refreshTokenSecret = Environment.GetEnvironmentVariable("REFRESH_TOKEN_SECRET")
|
||||||
|
?? throw new InvalidOperationException("REFRESH_TOKEN_SECRET environment variable is not set");
|
||||||
|
|
||||||
|
_confirmationTokenSecret = Environment.GetEnvironmentVariable("CONFIRMATION_TOKEN_SECRET")
|
||||||
|
?? throw new InvalidOperationException("CONFIRMATION_TOKEN_SECRET environment variable is not set");
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GenerateRefreshToken(UserAccount userAccount)
|
public string GenerateAccessToken(UserAccount user)
|
||||||
{
|
{
|
||||||
var jwtExpiresAt = DateTime.UtcNow.AddDays(21);
|
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.AccessTokenHours);
|
||||||
return tokenInfrastructure.GenerateJwt(
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _accessTokenSecret);
|
||||||
userAccount.UserAccountId,
|
}
|
||||||
userAccount.Username,
|
|
||||||
jwtExpiresAt
|
public string GenerateRefreshToken(UserAccount user)
|
||||||
);
|
{
|
||||||
|
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.RefreshTokenHours);
|
||||||
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _refreshTokenSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GenerateConfirmationToken(UserAccount user)
|
||||||
|
{
|
||||||
|
var expiresAt = DateTime.UtcNow.AddHours(TokenServiceExpirationHours.ConfirmationTokenHours);
|
||||||
|
return _tokenInfrastructure.GenerateJwt(user.UserAccountId, user.Username, expiresAt, _confirmationTokenSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GenerateToken<T>(UserAccount user) where T : struct, Enum
|
||||||
|
{
|
||||||
|
if (typeof(T) != typeof(TokenType))
|
||||||
|
throw new InvalidOperationException("Invalid token type");
|
||||||
|
|
||||||
|
var tokenTypeName = typeof(T).Name;
|
||||||
|
if (!Enum.TryParse(typeof(TokenType), tokenTypeName, out var parsed))
|
||||||
|
throw new InvalidOperationException("Invalid token type");
|
||||||
|
|
||||||
|
var tokenType = (TokenType)parsed;
|
||||||
|
return tokenType switch
|
||||||
|
{
|
||||||
|
TokenType.AccessToken => GenerateAccessToken(user),
|
||||||
|
TokenType.RefreshToken => GenerateRefreshToken(user),
|
||||||
|
TokenType.ConfirmationToken => GenerateConfirmationToken(user),
|
||||||
|
_ => throw new InvalidOperationException("Invalid token type"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ValidatedToken> ValidateAccessTokenAsync(string token)
|
||||||
|
=> await ValidateTokenInternalAsync(token, _accessTokenSecret, "access");
|
||||||
|
|
||||||
|
public async Task<ValidatedToken> ValidateRefreshTokenAsync(string token)
|
||||||
|
=> await ValidateTokenInternalAsync(token, _refreshTokenSecret, "refresh");
|
||||||
|
|
||||||
|
public async Task<ValidatedToken> ValidateConfirmationTokenAsync(string token)
|
||||||
|
=> await ValidateTokenInternalAsync(token, _confirmationTokenSecret, "confirmation");
|
||||||
|
|
||||||
|
private async Task<ValidatedToken> ValidateTokenInternalAsync(string token, string secret, string tokenType)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var principal = await _tokenInfrastructure.ValidateJwtAsync(token, secret);
|
||||||
|
|
||||||
|
var userIdClaim = principal.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
|
||||||
|
var usernameClaim = principal.FindFirst(JwtRegisteredClaimNames.UniqueName)?.Value;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(userIdClaim) || string.IsNullOrEmpty(usernameClaim))
|
||||||
|
throw new UnauthorizedException($"Invalid {tokenType} token: missing required claims");
|
||||||
|
|
||||||
|
if (!Guid.TryParse(userIdClaim, out var userId))
|
||||||
|
throw new UnauthorizedException($"Invalid {tokenType} token: malformed user ID");
|
||||||
|
|
||||||
|
return new ValidatedToken(userId, usernameClaim, principal);
|
||||||
|
}
|
||||||
|
catch (UnauthorizedException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
throw new UnauthorizedException($"Failed to validate {tokenType} token: {e.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString)
|
||||||
|
{
|
||||||
|
var validated = await ValidateRefreshTokenAsync(refreshTokenString);
|
||||||
|
var user = await _authRepository.GetUserByIdAsync(validated.UserId);
|
||||||
|
if (user == null)
|
||||||
|
throw new UnauthorizedException("User account not found");
|
||||||
|
|
||||||
|
var newAccess = GenerateAccessToken(user);
|
||||||
|
var newRefresh = GenerateRefreshToken(user);
|
||||||
|
|
||||||
|
return new RefreshTokenResult(user, newRefresh, newAccess);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ using Infrastructure.Repository.Auth;
|
|||||||
|
|
||||||
namespace Service.Auth;
|
namespace Service.Auth;
|
||||||
|
|
||||||
public record LoginServiceReturn(
|
|
||||||
UserAccount UserAccount,
|
|
||||||
string RefreshToken,
|
|
||||||
string AccessToken
|
|
||||||
);
|
|
||||||
|
|
||||||
public class LoginService(
|
public class LoginService(
|
||||||
IAuthRepository authRepo,
|
IAuthRepository authRepo,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ public class RegisterService(
|
|||||||
|
|
||||||
var accessToken = tokenService.GenerateAccessToken(createdUser);
|
var accessToken = tokenService.GenerateAccessToken(createdUser);
|
||||||
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
var refreshToken = tokenService.GenerateRefreshToken(createdUser);
|
||||||
|
var confirmationToken = tokenService.GenerateConfirmationToken(createdUser);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
string.IsNullOrEmpty(accessToken)
|
string.IsNullOrEmpty(accessToken)
|
||||||
@@ -67,14 +68,15 @@ public class RegisterService(
|
|||||||
{
|
{
|
||||||
// send confirmation email
|
// send confirmation email
|
||||||
await emailService.SendRegistrationEmailAsync(
|
await emailService.SendRegistrationEmailAsync(
|
||||||
createdUser,
|
createdUser, confirmationToken
|
||||||
"some-confirmation-token"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
emailSent = true;
|
emailSent = true;
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
await Console.Error.WriteLineAsync(ex.Message);
|
||||||
|
Console.WriteLine("Could not send email.");
|
||||||
// ignored
|
// ignored
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,4 +87,4 @@ public class RegisterService(
|
|||||||
emailSent
|
emailSent
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,13 +6,17 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||||
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
<ProjectReference
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||||
|
<ProjectReference
|
||||||
|
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
<ProjectReference
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
|
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||||
|
<ProjectReference
|
||||||
|
Include="..\..\Infrastructure\Infrastructure.PasswordHashing\Infrastructure.PasswordHashing.csproj" />
|
||||||
<ProjectReference Include="..\Service.Emails\Service.Emails.csproj" />
|
<ProjectReference Include="..\Service.Emails\Service.Emails.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ public interface IEmailService
|
|||||||
UserAccount createdUser,
|
UserAccount createdUser,
|
||||||
string confirmationToken
|
string confirmationToken
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public Task SendResendConfirmationEmailAsync(
|
||||||
|
UserAccount user,
|
||||||
|
string confirmationToken
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class EmailService(
|
public class EmailService(
|
||||||
@@ -17,13 +22,17 @@ public class EmailService(
|
|||||||
IEmailTemplateProvider emailTemplateProvider
|
IEmailTemplateProvider emailTemplateProvider
|
||||||
) : IEmailService
|
) : IEmailService
|
||||||
{
|
{
|
||||||
|
private static readonly string WebsiteBaseUrl =
|
||||||
|
Environment.GetEnvironmentVariable("WEBSITE_BASE_URL")
|
||||||
|
?? throw new InvalidOperationException("WEBSITE_BASE_URL environment variable is not set");
|
||||||
|
|
||||||
public async Task SendRegistrationEmailAsync(
|
public async Task SendRegistrationEmailAsync(
|
||||||
UserAccount createdUser,
|
UserAccount createdUser,
|
||||||
string confirmationToken
|
string confirmationToken
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
var confirmationLink =
|
var confirmationLink =
|
||||||
$"https://thebiergarten.app/confirm?token={confirmationToken}";
|
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||||
|
|
||||||
var emailHtml =
|
var emailHtml =
|
||||||
await emailTemplateProvider.RenderUserRegisteredEmailAsync(
|
await emailTemplateProvider.RenderUserRegisteredEmailAsync(
|
||||||
@@ -38,4 +47,26 @@ public class EmailService(
|
|||||||
isHtml: true
|
isHtml: true
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendResendConfirmationEmailAsync(
|
||||||
|
UserAccount user,
|
||||||
|
string confirmationToken
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var confirmationLink =
|
||||||
|
$"{WebsiteBaseUrl}/users/confirm?token={confirmationToken}";
|
||||||
|
|
||||||
|
var emailHtml =
|
||||||
|
await emailTemplateProvider.RenderResendConfirmationEmailAsync(
|
||||||
|
user.FirstName,
|
||||||
|
confirmationLink
|
||||||
|
);
|
||||||
|
|
||||||
|
await emailProvider.SendAsync(
|
||||||
|
user.Email,
|
||||||
|
"Confirm Your Email - The Biergarten App",
|
||||||
|
emailHtml,
|
||||||
|
isHtml: true
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain.Entities\Domain.Entities.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
<ProjectReference
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
Include="..\..\Infrastructure\Infrastructure.Email.Templates\Infrastructure.Email.Templates.csproj" />
|
||||||
|
<ProjectReference
|
||||||
|
Include="..\..\Infrastructure\Infrastructure.Email\Infrastructure.Email.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Domain.Exceptions\Domain.Exceptions.csproj" />
|
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
|
||||||
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
<ProjectReference
|
||||||
|
Include="..\..\Infrastructure\Infrastructure.Repository\Infrastructure.Repository.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
10881
src/Website-v1/package-lock.json
generated
Normal file
98
src/Website-v1/package.json
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
"name": "biergarten",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"prestart": "npm run build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"clear-db": "npx ts-node ./src/prisma/seed/clear/index.ts",
|
||||||
|
"format": "npx prettier . --write; npx prisma format;",
|
||||||
|
"format-watch": "npx onchange \"**/*\" -- prettier --write --ignore-unknown {{changed}}",
|
||||||
|
"seed": "npx --max-old-space-size=4096 ts-node ./src/prisma/seed/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hapi/iron": "^7.0.1",
|
||||||
|
"@headlessui/react": "^1.7.15",
|
||||||
|
"@headlessui/tailwindcss": "^0.2.0",
|
||||||
|
"@hookform/resolvers": "^3.3.1",
|
||||||
|
"@mapbox/mapbox-sdk": "^0.15.2",
|
||||||
|
"@mapbox/search-js-core": "^1.0.0-beta.17",
|
||||||
|
"@mapbox/search-js-react": "^1.0.0-beta.17",
|
||||||
|
"@next/bundle-analyzer": "^14.0.3",
|
||||||
|
"@prisma/client": "^5.7.0",
|
||||||
|
"@react-email/components": "^0.0.11",
|
||||||
|
"@react-email/render": "^0.0.9",
|
||||||
|
"@react-email/tailwind": "^0.0.12",
|
||||||
|
"@vercel/analytics": "^1.1.0",
|
||||||
|
"argon2": "^0.31.1",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"cloudinary": "^1.41.0",
|
||||||
|
"cookie": "^0.7.0",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"jsonwebtoken": "^9.0.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"mapbox-gl": "^3.4.0",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"next": "^14.2.22",
|
||||||
|
"next-cloudinary": "^5.10.0",
|
||||||
|
"next-connect": "^1.0.0-next.3",
|
||||||
|
"passport": "^0.6.0",
|
||||||
|
"passport-local": "^1.0.0",
|
||||||
|
"pino": "^10.0.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-daisyui": "^5.0.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-email": "^1.9.5",
|
||||||
|
"react-hook-form": "^7.45.2",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"react-icons": "^4.10.1",
|
||||||
|
"react-intersection-observer": "^9.5.2",
|
||||||
|
"react-map-gl": "^7.1.7",
|
||||||
|
"react-responsive-carousel": "^3.2.23",
|
||||||
|
"swr": "^2.2.0",
|
||||||
|
"theme-change": "^2.5.0",
|
||||||
|
"zod": "^3.21.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@faker-js/faker": "^8.3.1",
|
||||||
|
"@types/cookie": "^0.5.1",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.2",
|
||||||
|
"@types/lodash": "^4.14.195",
|
||||||
|
"@types/mapbox__mapbox-sdk": "^0.13.4",
|
||||||
|
"@types/multer": "^1.4.7",
|
||||||
|
"@types/node": "^20.4.2",
|
||||||
|
"@types/passport-local": "^1.0.35",
|
||||||
|
"@types/react": "^18.2.15",
|
||||||
|
"@types/react-dom": "^18.2.7",
|
||||||
|
"@vercel/fetch": "^7.0.0",
|
||||||
|
"autoprefixer": "^10.4.14",
|
||||||
|
"daisyui": "^4.7.2",
|
||||||
|
"dotenv-cli": "^7.2.1",
|
||||||
|
"eslint": "^8.51.0",
|
||||||
|
"eslint-config-airbnb-base": "15.0.0",
|
||||||
|
"eslint-config-airbnb-typescript": "17.1.0",
|
||||||
|
"eslint-config-next": "^13.5.4",
|
||||||
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
"eslint-plugin-react": "^7.33.2",
|
||||||
|
"generate-password": "^1.7.1",
|
||||||
|
"onchange": "^7.1.0",
|
||||||
|
"postcss": "^8.4.26",
|
||||||
|
"prettier": "^3.0.0",
|
||||||
|
"prettier-plugin-jsdoc": "^1.0.2",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.5.7",
|
||||||
|
"prisma": "^5.7.0",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"tailwindcss-animated": "^1.0.1",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"typescript": "^5.3.2"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"schema": "./src/prisma/schema.prisma",
|
||||||
|
"seed": "npm run seed"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/Website-v1/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 515 B After Width: | Height: | Size: 515 B |
|
Before Width: | Height: | Size: 961 B After Width: | Height: | Size: 961 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 256 KiB After Width: | Height: | Size: 256 KiB |