50
.dockerignore
Normal file
50
.dockerignore
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
ENV/
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Environment files (will be mounted separately)
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
|
||||||
|
# CI/CD
|
||||||
|
.github/
|
||||||
|
|
||||||
|
# Claude
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Instance data (will be mounted as volume)
|
||||||
|
src/app/instance/
|
||||||
|
src/app/static/images/
|
||||||
|
src/app/static/barcodes/
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -2,6 +2,10 @@
|
|||||||
SECRET_KEY=your-secret-key-change-this-in-production
|
SECRET_KEY=your-secret-key-change-this-in-production
|
||||||
FLASK_DEBUG=1
|
FLASK_DEBUG=1
|
||||||
|
|
||||||
|
# Authentication Mode
|
||||||
|
# Options: 'entra' (Entra ID/Azure AD) or 'none' (no authentication for local testing)
|
||||||
|
AUTH_MODE=none
|
||||||
|
|
||||||
# Entra ID (Azure AD) Configuration
|
# Entra ID (Azure AD) Configuration
|
||||||
# Get these values from your Azure Portal > App Registrations
|
# Get these values from your Azure Portal > App Registrations
|
||||||
AZURE_CLIENT_ID=your-client-id-here
|
AZURE_CLIENT_ID=your-client-id-here
|
||||||
|
|||||||
127
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
127
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
name: Bug Report
|
||||||
|
description: Report a bug or issue with the Flask AR API Demo Server
|
||||||
|
title: "[Bug]: "
|
||||||
|
labels: ["bug"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for taking the time to report a bug! Please fill out the information below to help us resolve the issue.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: component
|
||||||
|
attributes:
|
||||||
|
label: Component
|
||||||
|
description: Which component is affected?
|
||||||
|
options:
|
||||||
|
- API Endpoints (login, arcontentfields, arinfo)
|
||||||
|
- Admin Interface
|
||||||
|
- Barcode Generation
|
||||||
|
- Image Serving
|
||||||
|
- Authentication (Entra ID)
|
||||||
|
- Database/Models
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: description
|
||||||
|
attributes:
|
||||||
|
label: Bug Description
|
||||||
|
description: A clear and concise description of what the bug is.
|
||||||
|
placeholder: Describe the bug...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce
|
||||||
|
description: Steps to reproduce the behavior
|
||||||
|
placeholder: |
|
||||||
|
1. Navigate to '...'
|
||||||
|
2. Click on '...'
|
||||||
|
3. Make API call to '...'
|
||||||
|
4. See error
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: Expected Behavior
|
||||||
|
description: What did you expect to happen?
|
||||||
|
placeholder: Describe what should happen...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: Actual Behavior
|
||||||
|
description: What actually happened?
|
||||||
|
placeholder: Describe what actually happened...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: Error Logs/Screenshots
|
||||||
|
description: If applicable, add error logs, stack traces, or screenshots
|
||||||
|
placeholder: Paste logs or drag and drop screenshots here...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: Environment
|
||||||
|
description: Where are you running the application?
|
||||||
|
options:
|
||||||
|
- Local Development
|
||||||
|
- Docker
|
||||||
|
- Cloud Deployment
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: python-version
|
||||||
|
attributes:
|
||||||
|
label: Python Version
|
||||||
|
description: What version of Python are you using?
|
||||||
|
placeholder: e.g., 3.9.0, 3.10.2, 3.11.5
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: input
|
||||||
|
id: flask-version
|
||||||
|
attributes:
|
||||||
|
label: Flask Version
|
||||||
|
description: What version of Flask are you using?
|
||||||
|
placeholder: e.g., 2.2.5
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context about the problem here
|
||||||
|
placeholder: Any additional information...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Pre-submission Checklist
|
||||||
|
description: Please confirm the following
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues to ensure this bug hasn't been reported
|
||||||
|
required: true
|
||||||
|
- label: I have included all relevant information above
|
||||||
|
required: true
|
||||||
|
- label: I can reproduce this bug consistently
|
||||||
|
required: false
|
||||||
155
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
155
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
name: Feature Request
|
||||||
|
description: Suggest a new feature or enhancement for the Flask AR API Demo Server
|
||||||
|
title: "[Feature]: "
|
||||||
|
labels: ["enhancement"]
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Thanks for suggesting a new feature! Please provide as much detail as possible to help us understand your request.
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: component
|
||||||
|
attributes:
|
||||||
|
label: Component
|
||||||
|
description: Which component would this feature affect?
|
||||||
|
options:
|
||||||
|
- API Endpoints
|
||||||
|
- Admin Interface
|
||||||
|
- Barcode Generation
|
||||||
|
- Image Management
|
||||||
|
- Authentication/Authorization
|
||||||
|
- Database/Models
|
||||||
|
- Product Management
|
||||||
|
- Tenant Management
|
||||||
|
- Documentation
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: feature-type
|
||||||
|
attributes:
|
||||||
|
label: Feature Type
|
||||||
|
description: What type of feature is this?
|
||||||
|
options:
|
||||||
|
- New Functionality
|
||||||
|
- Enhancement to Existing Feature
|
||||||
|
- UI/UX Improvement
|
||||||
|
- Performance Improvement
|
||||||
|
- Developer Experience
|
||||||
|
- Documentation
|
||||||
|
- Testing
|
||||||
|
- Security
|
||||||
|
- Other
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: Problem Statement
|
||||||
|
description: Is your feature request related to a problem? Please describe.
|
||||||
|
placeholder: I'm frustrated when... / There's no way to... / It would be helpful if...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: solution
|
||||||
|
attributes:
|
||||||
|
label: Proposed Solution
|
||||||
|
description: Describe the solution you'd like to see
|
||||||
|
placeholder: I would like to be able to... / The system should... / Add a new endpoint that...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: Alternatives Considered
|
||||||
|
description: Have you considered any alternative solutions or workarounds?
|
||||||
|
placeholder: I've thought about... / Another approach could be... / Currently using...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: use-case
|
||||||
|
attributes:
|
||||||
|
label: Use Case
|
||||||
|
description: Describe your use case and how this feature would benefit you or others
|
||||||
|
placeholder: |
|
||||||
|
This feature would allow me to...
|
||||||
|
It would be useful for...
|
||||||
|
The expected workflow would be...
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: implementation
|
||||||
|
attributes:
|
||||||
|
label: Implementation Ideas
|
||||||
|
description: If you have ideas about how to implement this feature, please share them
|
||||||
|
placeholder: |
|
||||||
|
This could be implemented by...
|
||||||
|
Possible technical approach...
|
||||||
|
Required changes...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: priority
|
||||||
|
attributes:
|
||||||
|
label: Priority
|
||||||
|
description: How important is this feature to you?
|
||||||
|
options:
|
||||||
|
- Nice to have
|
||||||
|
- Would be useful
|
||||||
|
- Important
|
||||||
|
- Critical
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: dropdown
|
||||||
|
id: breaking
|
||||||
|
attributes:
|
||||||
|
label: Breaking Change
|
||||||
|
description: Would this feature require breaking changes to the API or existing functionality?
|
||||||
|
options:
|
||||||
|
- "No"
|
||||||
|
- "Yes"
|
||||||
|
- "Unsure"
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: mockups
|
||||||
|
attributes:
|
||||||
|
label: Mockups/Examples
|
||||||
|
description: If applicable, add mockups, screenshots, or examples from other systems
|
||||||
|
placeholder: Drag and drop images or paste links here...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: textarea
|
||||||
|
id: additional
|
||||||
|
attributes:
|
||||||
|
label: Additional Context
|
||||||
|
description: Add any other context, links, or references about the feature request
|
||||||
|
placeholder: Related to... / Similar to... / Reference documentation...
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: Pre-submission Checklist
|
||||||
|
description: Please confirm the following
|
||||||
|
options:
|
||||||
|
- label: I have searched existing issues to ensure this feature hasn't been requested
|
||||||
|
required: true
|
||||||
|
- label: I have clearly described the feature and its benefits
|
||||||
|
required: true
|
||||||
|
- label: I am willing to help test this feature if implemented
|
||||||
|
required: false
|
||||||
|
- label: I am willing to contribute to the implementation
|
||||||
|
required: false
|
||||||
35
.github/workflows/docker-publish.yml
vendored
Normal file
35
.github/workflows/docker-publish.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
name: Build and Push Docker Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*' # triggers only on version tags like v1.0.0, v25.04, etc.
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract version from tag
|
||||||
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build and push Docker image with two tags
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
mattintech/kcapdemoserver:latest
|
||||||
|
mattintech/kcapdemoserver:${{ env.VERSION }}
|
||||||
33
Dockerfile
Normal file
33
Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Use Python 3.11 slim image as base
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
FLASK_APP=src/run.py
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for better layer caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create necessary directories for runtime
|
||||||
|
RUN mkdir -p src/app/static/images src/app/static/barcodes src/app/instance
|
||||||
|
|
||||||
|
# Expose port 5555
|
||||||
|
EXPOSE 5555
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["python", "src/run.py"]
|
||||||
93
README.md
93
README.md
@@ -36,6 +36,70 @@ This project is a simple Flask API for demonstrating AR content retrieval for ba
|
|||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
|
### Option 1: Docker Hub (Recommended)
|
||||||
|
|
||||||
|
Pull and run the pre-built image from Docker Hub:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull the latest image
|
||||||
|
docker pull mattintech/kcapdemoserver:latest
|
||||||
|
|
||||||
|
# Run the container
|
||||||
|
docker run -p 5555:5000 \
|
||||||
|
-e SECRET_KEY=your-secret-key-here \
|
||||||
|
-e DATABASE_URL=sqlite:///data/kcap_demo.db \
|
||||||
|
-v $(pwd)/data:/app/data \
|
||||||
|
mattintech/kcapdemoserver:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use a specific version:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull mattintech/kcapdemoserver:v1.0.0
|
||||||
|
docker run -p 5555:5000 --env-file .env mattintech/kcapdemoserver:v1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Docker Compose
|
||||||
|
|
||||||
|
1. **Clone the Repository:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/mattintech/KCAPDemoServer.git
|
||||||
|
cd KCAPDemoServer
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Configure Environment:**
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and update with your Azure AD credentials:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` and set your Azure AD configuration values.
|
||||||
|
|
||||||
|
3. **Run with Docker Compose:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at `http://localhost:5555`
|
||||||
|
|
||||||
|
4. **View Logs:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Stop the Application:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Local Python Setup
|
||||||
|
|
||||||
1. **Clone the Repository:**
|
1. **Clone the Repository:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -49,23 +113,26 @@ cd <repository_folder>
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Run the Server:**
|
3. **Configure Environment:**
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and update with your configuration.
|
||||||
|
|
||||||
|
4. **Run the Server:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python app.py
|
python src/run.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The application will automatically create the necessary directories and initialize the products.json file if it doesn't exist.
|
The application will automatically create the necessary directories and initialize the database if it doesn't exist.
|
||||||
|
|
||||||
4. **Access the Admin Interface:**
|
### Accessing the Application
|
||||||
|
|
||||||
Open your browser and navigate to:
|
**Admin Interface:**
|
||||||
```
|
```
|
||||||
http://localhost:5555/admin
|
http://localhost:5555/admin
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Test the Endpoints:**
|
**API Endpoints:**
|
||||||
|
|
||||||
* Login: `GET http://localhost:5555/login`
|
* Login: `GET http://localhost:5555/login`
|
||||||
* Content Fields: `GET http://localhost:5555/arcontentfields`
|
* Content Fields: `GET http://localhost:5555/arcontentfields`
|
||||||
* AR Info (Example): `GET http://localhost:5555/arinfo?barcode=123456`
|
* AR Info (Example): `GET http://localhost:5555/arinfo?barcode=123456`
|
||||||
@@ -105,6 +172,18 @@ The following barcode types are available:
|
|||||||
└── generate_barcode.html # Barcode generation page
|
└── generate_barcode.html # Barcode generation page
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Docker Hub
|
||||||
|
|
||||||
|
Pre-built Docker images are automatically published to Docker Hub on each release:
|
||||||
|
|
||||||
|
**Repository**: [mattintech/kcapdemoserver](https://hub.docker.com/r/mattintech/kcapdemoserver)
|
||||||
|
|
||||||
|
**Available Tags**:
|
||||||
|
- `latest` - Most recent release
|
||||||
|
- `v1.0.0`, `v1.0.1`, etc. - Specific version releases
|
||||||
|
|
||||||
|
For maintainers: See [DOCKER_HUB_SETUP.md](DOCKER_HUB_SETUP.md) for information on configuring automated builds.
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
* The application is designed as a single server hosting both the API endpoints and admin interface.
|
* The application is designed as a single server hosting both the API endpoints and admin interface.
|
||||||
|
|||||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
container_name: kcap-demo-server
|
||||||
|
ports:
|
||||||
|
- "5555:5555"
|
||||||
|
environment:
|
||||||
|
- FLASK_DEBUG=1
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
# Mount static directories for persistent storage
|
||||||
|
- ./src/app/static/images:/app/src/app/static/images
|
||||||
|
- ./src/app/static/barcodes:/app/src/app/static/barcodes
|
||||||
|
# Mount instance directory for database persistence
|
||||||
|
- ./src/app/instance:/app/src/app/instance
|
||||||
|
restart: unless-stopped
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
"""Authentication routes for Entra ID login/logout"""
|
"""Authentication routes for Entra ID login/logout and no-auth mode"""
|
||||||
import secrets
|
import secrets
|
||||||
from flask import session, redirect, url_for, request, current_app, render_template, flash
|
from flask import session, redirect, url_for, request, current_app, render_template, flash, abort
|
||||||
from . import auth_bp
|
from . import auth_bp
|
||||||
from app.services.msal_service import MSALService
|
|
||||||
from app.models.user import UserModel
|
from app.models.user import UserModel
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/login')
|
@auth_bp.route('/login')
|
||||||
def login():
|
def login():
|
||||||
"""Initiate Entra ID login flow"""
|
"""Initiate Entra ID login flow or redirect to role selection in no-auth mode"""
|
||||||
|
# Check if we're in no-auth mode
|
||||||
|
if current_app.config.get('AUTH_MODE') == 'none':
|
||||||
|
return redirect(url_for('auth.select_role'))
|
||||||
|
|
||||||
|
# Entra ID mode
|
||||||
|
from app.services.msal_service import MSALService
|
||||||
|
|
||||||
# Generate state token for CSRF protection
|
# Generate state token for CSRF protection
|
||||||
state = secrets.token_urlsafe(16)
|
state = secrets.token_urlsafe(16)
|
||||||
session['auth_state'] = state
|
session['auth_state'] = state
|
||||||
@@ -23,9 +29,51 @@ def login():
|
|||||||
return redirect(auth_url)
|
return redirect(auth_url)
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/select-role', methods=['GET', 'POST'])
|
||||||
|
def select_role():
|
||||||
|
"""Role selection page for no-auth mode"""
|
||||||
|
# Only available in no-auth mode
|
||||||
|
if current_app.config.get('AUTH_MODE') != 'none':
|
||||||
|
current_app.logger.warning(f"Attempted to access role selection in {current_app.config.get('AUTH_MODE')} mode")
|
||||||
|
abort(403, description="Role selection is only available in no-auth mode")
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
role = request.form.get('role', 'user')
|
||||||
|
|
||||||
|
# Validate role
|
||||||
|
if role not in ['admin', 'user']:
|
||||||
|
role = 'user'
|
||||||
|
|
||||||
|
# Create a mock user for testing
|
||||||
|
# Use a consistent ID based on role for session persistence
|
||||||
|
user_id = f"test-{role}-user"
|
||||||
|
|
||||||
|
# Store user in session
|
||||||
|
session['user'] = {
|
||||||
|
'id': user_id,
|
||||||
|
'email': f'{role}@test.local',
|
||||||
|
'name': f'Test {role.capitalize()}',
|
||||||
|
'role': role
|
||||||
|
}
|
||||||
|
|
||||||
|
current_app.logger.info(f"No-auth mode: User selected {role} role")
|
||||||
|
|
||||||
|
# Redirect to the originally requested page or home
|
||||||
|
next_url = session.pop('next', None)
|
||||||
|
if next_url:
|
||||||
|
return redirect(next_url)
|
||||||
|
|
||||||
|
return redirect(url_for('main.index'))
|
||||||
|
|
||||||
|
return render_template('select_role.html')
|
||||||
|
|
||||||
|
|
||||||
@auth_bp.route('/callback')
|
@auth_bp.route('/callback')
|
||||||
def callback():
|
def callback():
|
||||||
"""Handle the callback from Entra ID after authentication"""
|
"""Handle the callback from Entra ID after authentication"""
|
||||||
|
# Import MSAL service only when needed (Entra ID mode)
|
||||||
|
from app.services.msal_service import MSALService
|
||||||
|
|
||||||
# Verify state to prevent CSRF
|
# Verify state to prevent CSRF
|
||||||
state = request.args.get('state')
|
state = request.args.get('state')
|
||||||
if state != session.get('auth_state'):
|
if state != session.get('auth_state'):
|
||||||
@@ -104,6 +152,14 @@ def logout():
|
|||||||
# Clear session
|
# Clear session
|
||||||
session.clear()
|
session.clear()
|
||||||
|
|
||||||
|
# Check if we're in no-auth mode
|
||||||
|
if current_app.config.get('AUTH_MODE') == 'none':
|
||||||
|
# Just redirect to home in no-auth mode
|
||||||
|
return redirect(url_for('main.index'))
|
||||||
|
|
||||||
|
# Entra ID mode - logout from Azure AD
|
||||||
|
from app.services.msal_service import MSALService
|
||||||
|
|
||||||
# Remove from MSAL cache
|
# Remove from MSAL cache
|
||||||
MSALService.remove_account()
|
MSALService.remove_account()
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ class Config:
|
|||||||
SESSION_USE_SIGNER = True
|
SESSION_USE_SIGNER = True
|
||||||
SESSION_FILE_DIR = os.path.join(DATA_FOLDER, 'flask_session')
|
SESSION_FILE_DIR = os.path.join(DATA_FOLDER, 'flask_session')
|
||||||
|
|
||||||
|
# Authentication Mode
|
||||||
|
# Options: 'entra' (Entra ID/Azure AD) or 'none' (no authentication)
|
||||||
|
AUTH_MODE = os.environ.get('AUTH_MODE', 'entra').lower()
|
||||||
|
|
||||||
# Entra ID (Azure AD) Configuration
|
# Entra ID (Azure AD) Configuration
|
||||||
AZURE_CLIENT_ID = os.environ.get('AZURE_CLIENT_ID')
|
AZURE_CLIENT_ID = os.environ.get('AZURE_CLIENT_ID')
|
||||||
AZURE_CLIENT_SECRET = os.environ.get('AZURE_CLIENT_SECRET')
|
AZURE_CLIENT_SECRET = os.environ.get('AZURE_CLIENT_SECRET')
|
||||||
|
|||||||
@@ -4,17 +4,36 @@ from flask import session, redirect, url_for, request, abort, current_app
|
|||||||
from app.models.user import UserModel
|
from app.models.user import UserModel
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_no_auth_user():
|
||||||
|
"""
|
||||||
|
Helper function to ensure a user exists in session when AUTH_MODE is 'none'.
|
||||||
|
If no user in session, redirect to role selection page.
|
||||||
|
"""
|
||||||
|
if current_app.config.get('AUTH_MODE') == 'none' and 'user' not in session:
|
||||||
|
# Redirect to role selection page
|
||||||
|
session['next'] = request.url
|
||||||
|
return redirect(url_for('auth.select_role'))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def login_required(f):
|
def login_required(f):
|
||||||
"""
|
"""
|
||||||
Decorator to require user authentication.
|
Decorator to require user authentication.
|
||||||
Redirects to login if user is not authenticated.
|
Redirects to login if user is not authenticated.
|
||||||
|
In 'none' auth mode, redirects to role selection if no session exists.
|
||||||
"""
|
"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if 'user' not in session:
|
# Check if we're in no-auth mode and need to set up a user
|
||||||
# Store the requested URL to redirect back after login
|
if current_app.config.get('AUTH_MODE') == 'none':
|
||||||
|
redirect_response = _ensure_no_auth_user()
|
||||||
|
if redirect_response:
|
||||||
|
return redirect_response
|
||||||
|
elif 'user' not in session:
|
||||||
|
# Entra ID mode - redirect to login
|
||||||
session['next'] = request.url
|
session['next'] = request.url
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
return f(*args, **kwargs)
|
return f(*args, **kwargs)
|
||||||
return decorated_function
|
return decorated_function
|
||||||
|
|
||||||
@@ -26,7 +45,13 @@ def admin_required(f):
|
|||||||
"""
|
"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if 'user' not in session:
|
# Check if we're in no-auth mode and need to set up a user
|
||||||
|
if current_app.config.get('AUTH_MODE') == 'none':
|
||||||
|
redirect_response = _ensure_no_auth_user()
|
||||||
|
if redirect_response:
|
||||||
|
return redirect_response
|
||||||
|
elif 'user' not in session:
|
||||||
|
# Entra ID mode - redirect to login
|
||||||
session['next'] = request.url
|
session['next'] = request.url
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
@@ -47,7 +72,13 @@ def tenant_access_required(f):
|
|||||||
"""
|
"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if 'user' not in session:
|
# Check if we're in no-auth mode and need to set up a user
|
||||||
|
if current_app.config.get('AUTH_MODE') == 'none':
|
||||||
|
redirect_response = _ensure_no_auth_user()
|
||||||
|
if redirect_response:
|
||||||
|
return redirect_response
|
||||||
|
elif 'user' not in session:
|
||||||
|
# Entra ID mode - redirect to login
|
||||||
session['next'] = request.url
|
session['next'] = request.url
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
@@ -76,7 +107,13 @@ def settings_access_required(f):
|
|||||||
"""
|
"""
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated_function(*args, **kwargs):
|
def decorated_function(*args, **kwargs):
|
||||||
if 'user' not in session:
|
# Check if we're in no-auth mode and need to set up a user
|
||||||
|
if current_app.config.get('AUTH_MODE') == 'none':
|
||||||
|
redirect_response = _ensure_no_auth_user()
|
||||||
|
if redirect_response:
|
||||||
|
return redirect_response
|
||||||
|
elif 'user' not in session:
|
||||||
|
# Entra ID mode - redirect to login
|
||||||
session['next'] = request.url
|
session['next'] = request.url
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
|
|||||||
@@ -67,8 +67,22 @@ class UserModel:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_by_id(user_id: int) -> Optional[Dict]:
|
def get_by_id(user_id) -> Optional[Dict]:
|
||||||
"""Get user by ID"""
|
"""Get user by ID (supports both integer IDs and string test IDs for no-auth mode)"""
|
||||||
|
# Handle test user IDs for no-auth mode
|
||||||
|
if isinstance(user_id, str) and user_id.startswith('test-'):
|
||||||
|
# Mock test user
|
||||||
|
role = 'admin' if 'admin' in user_id else 'user'
|
||||||
|
return {
|
||||||
|
'id': user_id,
|
||||||
|
'email': f'{role}@test.local',
|
||||||
|
'azure_oid': None,
|
||||||
|
'name': f'Test {role.capitalize()}',
|
||||||
|
'role': role,
|
||||||
|
'created_at': None,
|
||||||
|
'updated_at': None
|
||||||
|
}
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(
|
cursor.execute(
|
||||||
@@ -211,13 +225,17 @@ class UserModel:
|
|||||||
return [row[0] for row in cursor.fetchall()]
|
return [row[0] for row in cursor.fetchall()]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def has_access_to_tenant(user_id: int, tenant_id: str) -> bool:
|
def has_access_to_tenant(user_id, tenant_id: str) -> bool:
|
||||||
"""Check if user has access to a specific tenant"""
|
"""Check if user has access to a specific tenant"""
|
||||||
# Admin has access to everything
|
# Admin has access to everything
|
||||||
user = UserModel.get_by_id(user_id)
|
user = UserModel.get_by_id(user_id)
|
||||||
if user and user['role'] == UserModel.ROLE_ADMIN:
|
if user and user['role'] == UserModel.ROLE_ADMIN:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Test users in no-auth mode don't have tenant associations
|
||||||
|
if isinstance(user_id, str) and user_id.startswith('test-'):
|
||||||
|
return False
|
||||||
|
|
||||||
# Check if user owns the tenant
|
# Check if user owns the tenant
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
@@ -228,7 +246,7 @@ class UserModel:
|
|||||||
return cursor.fetchone() is not None
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def is_admin(user_id: int) -> bool:
|
def is_admin(user_id) -> bool:
|
||||||
"""Check if user has admin role"""
|
"""Check if user has admin role"""
|
||||||
user = UserModel.get_by_id(user_id)
|
user = UserModel.get_by_id(user_id)
|
||||||
return user and user['role'] == UserModel.ROLE_ADMIN
|
return user and user['role'] == UserModel.ROLE_ADMIN
|
||||||
|
|||||||
@@ -72,6 +72,15 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav ms-auto">
|
<ul class="navbar-nav ms-auto">
|
||||||
|
{% if config.AUTH_MODE == 'none' %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<span class="nav-link">
|
||||||
|
<span class="badge bg-warning text-dark">
|
||||||
|
<i class="bi bi-shield-lock"></i> No Auth Mode
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
{% if session.user %}
|
{% if session.user %}
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
|
<a class="nav-link dropdown-toggle" href="#" id="userDropdown" role="button" data-bs-toggle="dropdown">
|
||||||
|
|||||||
105
src/app/templates/select_role.html
Normal file
105
src/app/templates/select_role.html
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block title %}Select Role - KCAP Demo Server{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.role-selection-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 100px auto;
|
||||||
|
}
|
||||||
|
.role-card {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.role-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
border-color: #0d6efd;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.role-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.auth-mode-badge {
|
||||||
|
position: fixed;
|
||||||
|
top: 80px;
|
||||||
|
right: 20px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<span class="badge bg-warning text-dark auth-mode-badge">
|
||||||
|
<i class="bi bi-shield-lock"></i> No Auth Mode
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="role-selection-container">
|
||||||
|
<div class="text-center mb-4">
|
||||||
|
<h2>Welcome to KCAP Demo Server</h2>
|
||||||
|
<p class="text-muted">Select your role to continue</p>
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-info-circle"></i> Running in no-auth mode for local testing
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card role-card h-100 text-center" onclick="selectRole('admin')">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="role-icon text-danger">
|
||||||
|
<i class="bi bi-shield-fill-check"></i>
|
||||||
|
</div>
|
||||||
|
<h4>Admin</h4>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
Full access to all tenants, server settings, and user management
|
||||||
|
</p>
|
||||||
|
<ul class="list-unstyled text-start mt-3">
|
||||||
|
<li><i class="bi bi-check-circle-fill text-success"></i> Manage all tenants</li>
|
||||||
|
<li><i class="bi bi-check-circle-fill text-success"></i> Server settings</li>
|
||||||
|
<li><i class="bi bi-check-circle-fill text-success"></i> User management</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card role-card h-100 text-center" onclick="selectRole('user')">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="role-icon text-primary">
|
||||||
|
<i class="bi bi-person-circle"></i>
|
||||||
|
</div>
|
||||||
|
<h4>User</h4>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
Access to assigned tenants only
|
||||||
|
</p>
|
||||||
|
<ul class="list-unstyled text-start mt-3">
|
||||||
|
<li><i class="bi bi-check-circle-fill text-success"></i> Assigned tenants</li>
|
||||||
|
<li><i class="bi bi-x-circle-fill text-danger"></i> Server settings</li>
|
||||||
|
<li><i class="bi bi-x-circle-fill text-danger"></i> User management</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info mt-4" role="alert">
|
||||||
|
<i class="bi bi-lightbulb"></i>
|
||||||
|
<strong>Testing Mode:</strong> This selection is temporary and only for local development.
|
||||||
|
To use Entra ID authentication, set <code>AUTH_MODE=entra</code> in your .env file.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="roleForm" method="POST" action="{{ url_for('auth.select_role') }}" style="display: none;">
|
||||||
|
<input type="hidden" name="role" id="roleInput">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function selectRole(role) {
|
||||||
|
document.getElementById('roleInput').value = role;
|
||||||
|
document.getElementById('roleForm').submit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user