mirror of
https://github.com/wahyd4/passkey-auth.git
synced 2026-08-08 20:15:44 +10:00
feat: initial commit - WebAuthn passkey authentication service
- Complete WebAuthn/FIDO2 authentication implementation - SQLite database with user and credential management - Email-based user identification with allowlist support - Admin approval workflow for new users - Session management with secure cookies - Docker containerization with Debian base for SQLite compatibility - Kubernetes deployment manifests with nginx ingress support - Web-based admin interface for user management - Comprehensive documentation and deployment guides - Standard open source project structure with CI/CD
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Create a report to help us improve
|
||||
title: '[BUG] '
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Environment (please complete the following information):**
|
||||
- OS: [e.g. macOS, Linux, Windows]
|
||||
- Go Version: [e.g. 1.21.0]
|
||||
- Browser: [e.g. Chrome, Firefox, Safari]
|
||||
- Docker Version: [if using Docker]
|
||||
- Kubernetes Version: [if using Kubernetes]
|
||||
|
||||
**Configuration**
|
||||
- Are you using email allowlist? [yes/no]
|
||||
- Is admin approval enabled? [yes/no]
|
||||
- Deployment method: [Docker, Kubernetes, direct binary]
|
||||
|
||||
**Logs**
|
||||
If applicable, add logs to help diagnose the problem.
|
||||
|
||||
```
|
||||
Paste relevant logs here
|
||||
```
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest an idea for this project
|
||||
title: '[FEATURE] '
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Use case**
|
||||
Describe the specific use case this feature would address.
|
||||
|
||||
**Implementation considerations**
|
||||
- Would this be backwards compatible?
|
||||
- Any security implications?
|
||||
- Database schema changes needed?
|
||||
- Configuration changes required?
|
||||
|
||||
**Additional context**
|
||||
Add any other context, mockups, or examples about the feature request here.
|
||||
@@ -0,0 +1,38 @@
|
||||
## Description
|
||||
Brief description of what this PR does.
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Documentation update
|
||||
- [ ] Performance improvement
|
||||
- [ ] Code refactoring
|
||||
|
||||
## Testing
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] I have tested the changes manually
|
||||
|
||||
## Checklist
|
||||
- [ ] My code follows the project's style guidelines
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have updated the CHANGELOG.md if needed
|
||||
|
||||
## Security Considerations
|
||||
- [ ] This change does not introduce any security vulnerabilities
|
||||
- [ ] I have considered the security implications of this change
|
||||
- [ ] Authentication/authorization is properly handled
|
||||
|
||||
## Breaking Changes
|
||||
If this PR introduces breaking changes, please describe them here and update the major version.
|
||||
|
||||
## Screenshots/Demos
|
||||
If applicable, add screenshots or demo videos.
|
||||
|
||||
## Related Issues
|
||||
Closes #(issue_number)
|
||||
Related to #(issue_number)
|
||||
@@ -0,0 +1,140 @@
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
release:
|
||||
types: [ published ]
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.21'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc libsqlite3-dev
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Verify dependencies
|
||||
run: go mod verify
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v -race -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.out
|
||||
flags: unittests
|
||||
name: codecov-umbrella
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
CGO_ENABLED=1 go build -v -o passkey-auth .
|
||||
|
||||
- name: Test binary
|
||||
run: |
|
||||
./passkey-auth --help || true # Some apps don't have --help
|
||||
echo "Binary built successfully"
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
if: github.event_name == 'push' || github.event_name == 'release'
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'release'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
passkey-auth
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha,prefix={{branch}}-
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name == 'release' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ env.GO_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc libsqlite3-dev
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3
|
||||
with:
|
||||
version: latest
|
||||
args: --timeout=5m
|
||||
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run Gosec Security Scanner
|
||||
uses: securecodewarrior/github-action-gosec@master
|
||||
with:
|
||||
args: './...'
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
bin/
|
||||
passkey-auth
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Configuration files with secrets
|
||||
config-prod.yaml
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Data directory
|
||||
data/
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Docker
|
||||
.docker/
|
||||
|
||||
# Kubernetes secrets
|
||||
k8s/*-secret.yaml
|
||||
|
||||
# Development files
|
||||
dev-*
|
||||
@@ -0,0 +1,70 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Initial release of Passkey Auth
|
||||
- WebAuthn (FIDO2) authentication support
|
||||
- SQLite database with user management
|
||||
- Email-based user identification
|
||||
- Admin approval workflow for new users
|
||||
- Email allowlist support
|
||||
- Session management with secure cookies
|
||||
- Kubernetes deployment configuration
|
||||
- nginx ingress auth backend support
|
||||
- Docker containerization with Debian base
|
||||
- Web-based admin interface
|
||||
- User registration and login flows
|
||||
- Real-time WebAuthn credential management
|
||||
|
||||
### Features
|
||||
- **Authentication**: WebAuthn/FIDO2 passkey authentication
|
||||
- **Database**: SQLite with user and credential storage
|
||||
- **Admin Interface**: Web UI for user management and approval
|
||||
- **Email Allowlist**: Restrict registration to specific email addresses
|
||||
- **Session Management**: Secure session handling with configurable secrets
|
||||
- **Docker Support**: Multi-stage builds with Debian base for SQLite compatibility
|
||||
- **Kubernetes Ready**: Complete deployment manifests and ingress configuration
|
||||
- **nginx Integration**: Auth backend for protecting upstream services
|
||||
|
||||
### Security
|
||||
- WebAuthn challenge/response authentication
|
||||
- Secure session cookie handling
|
||||
- Admin approval workflow
|
||||
- Email-based access control
|
||||
- HTTPS-ready configuration
|
||||
|
||||
### Documentation
|
||||
- Comprehensive README with setup instructions
|
||||
- API endpoint documentation
|
||||
- Kubernetes deployment guide
|
||||
- Docker usage examples
|
||||
- Troubleshooting guide
|
||||
- Contributing guidelines
|
||||
|
||||
### Technical Details
|
||||
- Go 1.21+ with CGO for SQLite
|
||||
- Base64URL encoding/decoding for WebAuthn data
|
||||
- CORS support for cross-origin requests
|
||||
- Environment variable configuration
|
||||
- Health check endpoints
|
||||
- Structured logging
|
||||
|
||||
## [0.1.0] - 2025-08-04
|
||||
|
||||
### Added
|
||||
- Initial project structure
|
||||
- Core WebAuthn implementation
|
||||
- SQLite database integration
|
||||
- Basic web interface
|
||||
- Docker containerization
|
||||
- Kubernetes manifests
|
||||
|
||||
---
|
||||
|
||||
**Note**: This project follows semantic versioning. For upgrade instructions and breaking changes, see the README.md file.
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
# Contributing to Passkey Auth
|
||||
|
||||
Thank you for your interest in contributing to Passkey Auth! This document provides guidelines for contributing to the project.
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
2. Clone your fork: `git clone https://github.com/yourusername/passkey-auth.git`
|
||||
3. Create a feature branch: `git checkout -b feature/your-feature-name`
|
||||
4. Make your changes
|
||||
5. Test your changes
|
||||
6. Commit and push to your fork
|
||||
7. Create a Pull Request
|
||||
|
||||
## 🛠️ Development Setup
|
||||
|
||||
### Prerequisites
|
||||
- Go 1.21 or later
|
||||
- Docker (for containerized testing)
|
||||
- Make (optional, for build scripts)
|
||||
|
||||
### Local Development
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/yourusername/passkey-auth.git
|
||||
cd passkey-auth
|
||||
|
||||
# Install dependencies
|
||||
go mod download
|
||||
|
||||
# Run the application
|
||||
go run .
|
||||
|
||||
# Run tests
|
||||
go test ./...
|
||||
```
|
||||
|
||||
### Docker Development
|
||||
```bash
|
||||
# Build Docker image
|
||||
./scripts/build.sh
|
||||
|
||||
# Run with Docker
|
||||
docker run -p 8080:8080 passkey-auth
|
||||
```
|
||||
|
||||
## 📋 Guidelines
|
||||
|
||||
### Code Style
|
||||
- Follow standard Go formatting (`gofmt`)
|
||||
- Use meaningful variable and function names
|
||||
- Add comments for exported functions and complex logic
|
||||
- Keep functions focused and small
|
||||
|
||||
### Commit Messages
|
||||
- Use clear, descriptive commit messages
|
||||
- Start with a verb in present tense ("Add", "Fix", "Update")
|
||||
- Include context for why the change was made
|
||||
|
||||
Example:
|
||||
```
|
||||
Add email allowlist support for user registration
|
||||
|
||||
- Allows administrators to restrict registration to specific email domains
|
||||
- Configurable via config.yaml or environment variables
|
||||
- Backwards compatible (empty list allows all emails)
|
||||
```
|
||||
|
||||
### Testing
|
||||
- Write tests for new features
|
||||
- Ensure existing tests pass
|
||||
- Test both success and error cases
|
||||
- Include integration tests for new endpoints
|
||||
|
||||
### Documentation
|
||||
- Update README.md for new features
|
||||
- Add inline code comments
|
||||
- Update API documentation if endpoints change
|
||||
- Include examples in documentation
|
||||
|
||||
## 🐛 Bug Reports
|
||||
|
||||
When reporting bugs, please include:
|
||||
- Go version
|
||||
- Operating system
|
||||
- Steps to reproduce
|
||||
- Expected vs actual behavior
|
||||
- Relevant logs or error messages
|
||||
|
||||
## 💡 Feature Requests
|
||||
|
||||
When requesting features:
|
||||
- Describe the use case
|
||||
- Explain why it would be valuable
|
||||
- Consider backwards compatibility
|
||||
- Provide examples if possible
|
||||
|
||||
## 🔐 Security
|
||||
|
||||
For security vulnerabilities:
|
||||
- Do not open public issues
|
||||
- Contact maintainers directly
|
||||
- Provide detailed reproduction steps
|
||||
- Allow time for fixes before disclosure
|
||||
|
||||
## 📜 Code of Conduct
|
||||
|
||||
- Be respectful and inclusive
|
||||
- Welcome newcomers and help them learn
|
||||
- Focus on constructive feedback
|
||||
- Respect different viewpoints and experiences
|
||||
|
||||
## 🏷️ Release Process
|
||||
|
||||
1. Update version in relevant files
|
||||
2. Update CHANGELOG.md
|
||||
3. Create release PR
|
||||
4. Tag release after merge
|
||||
5. Build and publish Docker images
|
||||
6. Create GitHub release with notes
|
||||
|
||||
## 📞 Getting Help
|
||||
|
||||
- Check existing issues and documentation first
|
||||
- Open an issue for bugs or feature requests
|
||||
- Join discussions in existing issues
|
||||
- Ask questions in issues (we're happy to help!)
|
||||
|
||||
Thank you for contributing! 🎉
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
# Build stage
|
||||
FROM golang:1.21-bullseye AS builder
|
||||
|
||||
# Install build dependencies including sqlite
|
||||
RUN apt-get update && apt-get install -y gcc libsqlite3-dev && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application (remove static linking for SQLite compatibility)
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -a -o passkey-auth .
|
||||
|
||||
# Final stage
|
||||
FROM debian:bullseye-slim
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y ca-certificates libsqlite3-0 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /root/
|
||||
|
||||
# Copy the binary from builder stage
|
||||
COPY --from=builder /app/passkey-auth .
|
||||
|
||||
# Copy web files
|
||||
COPY --from=builder /app/web ./web/
|
||||
|
||||
# Copy default config
|
||||
COPY --from=builder /app/config.yaml .
|
||||
|
||||
# Create directory for database
|
||||
RUN mkdir -p /data
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run the application
|
||||
CMD ["./passkey-auth"]
|
||||
@@ -0,0 +1,122 @@
|
||||
# 📋 Implementation Summary
|
||||
|
||||
## What We've Built
|
||||
|
||||
✅ **Complete Passkey Authentication System** with email-based access control
|
||||
✅ **SQLite Database** for persistent user storage
|
||||
✅ **Email Allowlist System** for controlling who can register
|
||||
✅ **Kubernetes Integration** with nginx ingress auth backend
|
||||
✅ **Modern Web UI** for user registration and management
|
||||
✅ **Production-Ready Deployment** with Docker and Kubernetes manifests
|
||||
|
||||
## Key Features Implemented
|
||||
|
||||
### 🔐 Email-Based Authentication
|
||||
- Users are identified by email addresses (not usernames)
|
||||
- Configurable email allowlist for access control
|
||||
- Environment variable support for email configuration
|
||||
|
||||
### 📧 Email Access Control Options
|
||||
|
||||
1. **Open Mode**: Empty allowlist allows any email
|
||||
2. **Restricted Mode**: Only allowlisted emails can register
|
||||
3. **Combined Security**: Allowlist + manual approval
|
||||
|
||||
### 🗄️ Database Architecture (SQLite)
|
||||
```sql
|
||||
-- Users table
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL, -- Email as primary identifier
|
||||
display_name TEXT NOT NULL,
|
||||
approved BOOLEAN DEFAULT FALSE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Credentials table (WebAuthn keys)
|
||||
CREATE TABLE credentials (
|
||||
id BLOB PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id),
|
||||
public_key BLOB NOT NULL,
|
||||
attestation_type TEXT NOT NULL,
|
||||
aaguid BLOB,
|
||||
sign_count INTEGER DEFAULT 0,
|
||||
clone_warning BOOLEAN DEFAULT FALSE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### ⚙️ Configuration Options
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
session_secret: "your-secret-key"
|
||||
require_approval: true # Admin approval required
|
||||
allowed_emails: # Email allowlist
|
||||
- "admin@company.com"
|
||||
- "engineering@company.com"
|
||||
```
|
||||
|
||||
Or via environment variables:
|
||||
```bash
|
||||
export ALLOWED_EMAILS="admin@company.com,user1@company.com,user2@company.com"
|
||||
export SESSION_SECRET="your-secure-session-secret"
|
||||
```
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
### Core Application
|
||||
- `main.go` - Application entry point
|
||||
- `internal/config/` - Configuration management with email allowlist
|
||||
- `internal/database/` - SQLite database layer with email-based users
|
||||
- `internal/auth/` - WebAuthn implementation
|
||||
- `internal/handlers/` - HTTP handlers with email validation
|
||||
|
||||
### Web Interface
|
||||
- `web/index.html` - Admin UI updated for email addresses
|
||||
|
||||
### Deployment
|
||||
- `Dockerfile` - Container build
|
||||
- `k8s/` - Kubernetes manifests
|
||||
- `docker-compose.yml` - Local development
|
||||
- `scripts/` - Build and deployment scripts
|
||||
|
||||
### Documentation
|
||||
- `README.md` - Complete usage guide
|
||||
- `PRODUCTION.md` - Production deployment guide
|
||||
- `config.example.yaml` - Example configuration
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Email Validation**: When a user tries to register, the system checks if their email is in the allowlist (if configured)
|
||||
2. **Database Storage**: User data is stored in SQLite with email as the unique identifier
|
||||
3. **WebAuthn Integration**: Passkey credentials are linked to the user record
|
||||
4. **Session Management**: Authentication sessions use email-based identification
|
||||
5. **Nginx Integration**: Auth headers include user email for downstream applications
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Configure email allowlist
|
||||
vim config.yaml # Add your allowed emails
|
||||
|
||||
# 2. Start the service
|
||||
./scripts/dev.sh
|
||||
|
||||
# 3. Register users at http://localhost:8080
|
||||
# Only emails in the allowlist can register
|
||||
|
||||
# 4. Deploy to Kubernetes
|
||||
./scripts/build.sh
|
||||
./scripts/deploy.sh
|
||||
```
|
||||
|
||||
## Security Benefits
|
||||
|
||||
- **No passwords stored** - Only WebAuthn public keys
|
||||
- **Email-based access control** - Restrict registration to specific domains/emails
|
||||
- **Phishing resistant** - WebAuthn is tied to the domain
|
||||
- **MFA built-in** - Passkeys require user presence and verification
|
||||
- **Session security** - Secure cookie-based sessions
|
||||
|
||||
This implementation provides a complete, production-ready passkey authentication system with fine-grained email-based access control, perfect for enterprise environments where you need to restrict access to specific users.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Passkey Auth Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,67 @@
|
||||
.PHONY: help build run dev test clean docker-build docker-run k8s-deploy k8s-undeploy
|
||||
|
||||
help: ## Show this help message
|
||||
@echo 'Usage: make [target]'
|
||||
@echo ''
|
||||
@echo 'Targets:'
|
||||
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
|
||||
|
||||
build: ## Build the Go binary
|
||||
@echo "🔨 Building..."
|
||||
@go build -o bin/passkey-auth .
|
||||
|
||||
run: build ## Run the application locally
|
||||
@echo "🚀 Running..."
|
||||
@./bin/passkey-auth
|
||||
|
||||
dev: ## Run in development mode with hot reload
|
||||
@echo "🔧 Starting development server..."
|
||||
@./scripts/dev.sh
|
||||
|
||||
test: ## Run tests
|
||||
@echo "🧪 Running tests..."
|
||||
@go test -v ./...
|
||||
|
||||
clean: ## Clean build artifacts
|
||||
@echo "🧹 Cleaning..."
|
||||
@rm -rf bin/
|
||||
@rm -f *.db
|
||||
|
||||
docker-build: ## Build Docker image
|
||||
@echo "🐳 Building Docker image..."
|
||||
@docker build -t passkey-auth:latest .
|
||||
|
||||
docker-run: docker-build ## Run with Docker Compose
|
||||
@echo "🐳 Starting with Docker Compose..."
|
||||
@mkdir -p data
|
||||
@docker-compose up -d
|
||||
@echo "Access the application at http://localhost:8080"
|
||||
|
||||
docker-stop: ## Stop Docker Compose
|
||||
@echo "🛑 Stopping Docker Compose..."
|
||||
@docker-compose down
|
||||
|
||||
k8s-deploy: docker-build ## Deploy to Kubernetes
|
||||
@echo "☸️ Deploying to Kubernetes..."
|
||||
@./scripts/deploy.sh
|
||||
|
||||
k8s-undeploy: ## Remove from Kubernetes
|
||||
@echo "☸️ Removing from Kubernetes..."
|
||||
@./scripts/undeploy.sh
|
||||
|
||||
deps: ## Download dependencies
|
||||
@echo "📦 Downloading dependencies..."
|
||||
@go mod download
|
||||
@go mod tidy
|
||||
|
||||
fmt: ## Format code
|
||||
@echo "🎨 Formatting code..."
|
||||
@go fmt ./...
|
||||
|
||||
lint: ## Run linter
|
||||
@echo "🔍 Running linter..."
|
||||
@golangci-lint run
|
||||
|
||||
security: ## Run security scan
|
||||
@echo "🔒 Running security scan..."
|
||||
@gosec ./...
|
||||
+287
@@ -0,0 +1,287 @@
|
||||
# Production Deployment Guide
|
||||
|
||||
This guide covers deploying Passkey Auth in a production Kubernetes environment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Kubernetes cluster with nginx ingress controller
|
||||
- Domain name with SSL certificate
|
||||
- kubectl access to the cluster
|
||||
- Docker registry access (optional, for custom builds)
|
||||
|
||||
## Step 1: Prepare Configuration
|
||||
|
||||
1. **Generate Session Secret**:
|
||||
```bash
|
||||
openssl rand -base64 32
|
||||
```
|
||||
|
||||
2. **Update Kubernetes Configuration**:
|
||||
|
||||
Edit `k8s/deployment.yaml` and update the ConfigMap:
|
||||
|
||||
```yaml
|
||||
data:
|
||||
config.yaml: |
|
||||
server:
|
||||
port: "8080"
|
||||
host: "0.0.0.0"
|
||||
|
||||
webauthn:
|
||||
rp_display_name: "Your Company Auth"
|
||||
rp_id: "auth.yourcompany.com" # Your auth domain
|
||||
rp_origins:
|
||||
- "https://auth.yourcompany.com" # Your auth URL
|
||||
- "https://app.yourcompany.com" # Your app URLs
|
||||
|
||||
database:
|
||||
path: "/data/passkey-auth.db"
|
||||
|
||||
cors:
|
||||
allowed_origins:
|
||||
- "https://auth.yourcompany.com"
|
||||
- "https://app.yourcompany.com"
|
||||
|
||||
auth:
|
||||
session_secret: "YOUR_GENERATED_SECRET_HERE"
|
||||
require_approval: true
|
||||
```
|
||||
|
||||
## Step 2: Deploy to Kubernetes
|
||||
|
||||
1. **Create Namespace**:
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
```
|
||||
|
||||
2. **Deploy Application**:
|
||||
```bash
|
||||
kubectl apply -f k8s/deployment.yaml
|
||||
```
|
||||
|
||||
3. **Verify Deployment**:
|
||||
```bash
|
||||
kubectl get pods -n passkey-auth
|
||||
kubectl logs -f deployment/passkey-auth -n passkey-auth
|
||||
```
|
||||
|
||||
## Step 3: Configure SSL/TLS
|
||||
|
||||
Create an ingress with SSL termination:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: passkey-auth-ingress
|
||||
namespace: passkey-auth
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod" # If using cert-manager
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- auth.yourcompany.com
|
||||
secretName: passkey-auth-tls
|
||||
rules:
|
||||
- host: auth.yourcompany.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: passkey-auth-service
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
## Step 4: Configure Your Application Ingress
|
||||
|
||||
Update your application's ingress to use passkey auth:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: your-app-ingress
|
||||
annotations:
|
||||
# Auth configuration
|
||||
nginx.ingress.kubernetes.io/auth-url: "http://passkey-auth-service.passkey-auth.svc.cluster.local/auth"
|
||||
nginx.ingress.kubernetes.io/auth-signin: "https://auth.yourcompany.com"
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-User,X-Auth-User-ID"
|
||||
|
||||
# SSL configuration
|
||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
spec:
|
||||
tls:
|
||||
- hosts:
|
||||
- app.yourcompany.com
|
||||
secretName: your-app-tls
|
||||
rules:
|
||||
- host: app.yourcompany.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: your-app-service
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
## Step 5: Set Up Monitoring (Optional)
|
||||
|
||||
### Health Checks
|
||||
|
||||
The service includes health checks at `/health`. Configure monitoring:
|
||||
|
||||
```yaml
|
||||
# Add to your monitoring stack
|
||||
- job_name: 'passkey-auth'
|
||||
static_configs:
|
||||
- targets: ['passkey-auth-service.passkey-auth.svc.cluster.local:80']
|
||||
metrics_path: '/health'
|
||||
```
|
||||
|
||||
### Log Aggregation
|
||||
|
||||
Configure log forwarding to your logging system:
|
||||
|
||||
```bash
|
||||
kubectl logs -f deployment/passkey-auth -n passkey-auth | your-log-forwarder
|
||||
```
|
||||
|
||||
## Step 6: Backup Strategy
|
||||
|
||||
### Database Backup
|
||||
|
||||
Set up regular backups of the SQLite database:
|
||||
|
||||
```bash
|
||||
# Create a backup cronjob
|
||||
apiVersion: batch/v1
|
||||
kind: CronJob
|
||||
metadata:
|
||||
name: passkey-auth-backup
|
||||
namespace: passkey-auth
|
||||
spec:
|
||||
schedule: "0 2 * * *" # Daily at 2 AM
|
||||
jobTemplate:
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: backup
|
||||
image: alpine
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
cp /data/passkey-auth.db /backup/passkey-auth-$(date +%Y%m%d).db
|
||||
# Upload to your backup storage
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
- name: backup
|
||||
mountPath: /backup
|
||||
restartPolicy: OnFailure
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: passkey-auth-storage
|
||||
- name: backup
|
||||
# Configure your backup storage volume
|
||||
```
|
||||
|
||||
## Security Checklist
|
||||
|
||||
- [ ] HTTPS enforced for all endpoints
|
||||
- [ ] Session secret is randomly generated and secure
|
||||
- [ ] CORS origins are specifically configured (not "*")
|
||||
- [ ] WebAuthn RP ID matches your domain exactly
|
||||
- [ ] Network policies restrict pod-to-pod communication
|
||||
- [ ] Regular security updates applied
|
||||
- [ ] Database backups are encrypted and tested
|
||||
- [ ] Access logs are monitored
|
||||
- [ ] Kubernetes RBAC is properly configured
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Production Issues
|
||||
|
||||
1. **WebAuthn Registration Fails**:
|
||||
- Verify RP ID matches domain exactly
|
||||
- Ensure HTTPS is properly configured
|
||||
- Check browser developer console for errors
|
||||
|
||||
2. **Auth Backend Returns 502**:
|
||||
- Verify service is running: `kubectl get pods -n passkey-auth`
|
||||
- Check service connectivity: `kubectl get svc -n passkey-auth`
|
||||
- Review nginx ingress logs
|
||||
|
||||
3. **Session Issues**:
|
||||
- Verify session secret is consistent across restarts
|
||||
- Check cookie domain settings
|
||||
- Ensure persistent storage is working
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check service status
|
||||
kubectl get all -n passkey-auth
|
||||
|
||||
# View logs
|
||||
kubectl logs -f deployment/passkey-auth -n passkey-auth
|
||||
|
||||
# Test auth endpoint
|
||||
kubectl exec -it deployment/passkey-auth -n passkey-auth -- wget -O- http://localhost:8080/health
|
||||
|
||||
# Check ingress
|
||||
kubectl describe ingress -n passkey-auth
|
||||
|
||||
# Port forward for testing
|
||||
kubectl port-forward svc/passkey-auth-service 8080:80 -n passkey-auth
|
||||
```
|
||||
|
||||
## Scaling Considerations
|
||||
|
||||
### High Availability
|
||||
|
||||
For high availability, consider:
|
||||
|
||||
1. **Multiple Replicas**: Increase replica count in deployment
|
||||
2. **Session Storage**: Use Redis for shared session storage
|
||||
3. **Database**: Consider PostgreSQL for better concurrent access
|
||||
4. **Load Balancing**: Ensure proper session affinity
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
1. **Resource Limits**: Adjust based on usage patterns
|
||||
2. **Database Optimization**: Regular VACUUM for SQLite
|
||||
3. **Caching**: Add caching layer for frequently accessed data
|
||||
|
||||
## Updates and Maintenance
|
||||
|
||||
### Rolling Updates
|
||||
|
||||
```bash
|
||||
# Update the image
|
||||
kubectl set image deployment/passkey-auth passkey-auth=passkey-auth:v1.1.0 -n passkey-auth
|
||||
|
||||
# Monitor rollout
|
||||
kubectl rollout status deployment/passkey-auth -n passkey-auth
|
||||
|
||||
# Rollback if needed
|
||||
kubectl rollout undo deployment/passkey-auth -n passkey-auth
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
For schema changes, implement migration scripts and run them during maintenance windows.
|
||||
|
||||
---
|
||||
|
||||
This production guide ensures a secure, reliable deployment of Passkey Auth in your Kubernetes environment.
|
||||
@@ -0,0 +1,416 @@
|
||||
# 🔐 Passkey Auth for Kubernetes Nginx Ingress
|
||||
|
||||
A WebAuthn-based passkey authentication provider that integrates seamlessly with Kubernetes nginx ingress controllers. This service provides secure, passwordless authentication using passkeys (FIDO2/WebAuthn) and acts as an auth backend for nginx ingress.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Pa2. **"User not found" during login**:
|
||||
- Ensure user is registered and approved (if required)
|
||||
- Check that email address matches exactly
|
||||
|
||||
3. **WebAuthn errors**:dless Authentication**: Uses WebAuthn/FIDO2 passkeys for secure authentication
|
||||
- **Email-Based Access Control**: Users are identified by email addresses with configurable allowlists
|
||||
- **Nginx Ingress Integration**: Works as an auth backend using nginx `auth_request` directive
|
||||
- **User Management**: Admin interface for managing users and their approval status
|
||||
- **Access Control**: Configure allowed email addresses and approval requirements
|
||||
- **Kubernetes Native**: Designed specifically for Kubernetes deployment
|
||||
- **Persistent Storage**: Uses SQLite with persistent volumes for data storage
|
||||
- **Modern UI**: Clean, responsive web interface for user registration and management
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
||||
│ User Browser │ │ Nginx Ingress │ │ Your App │
|
||||
│ │ │ │ │ │
|
||||
│ 1. Request │───▶│ 2. Auth Check │───▶│ 4. Serve App │
|
||||
│ 4. Redirect │◀───│ 3. 401/302 │ │ │
|
||||
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
||||
│
|
||||
│ auth_request
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ Passkey Auth │
|
||||
│ │
|
||||
│ - WebAuthn │
|
||||
│ - User Mgmt │
|
||||
│ - Session Mgmt │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Kubernetes cluster with nginx ingress controller
|
||||
- Docker
|
||||
- kubectl configured to access your cluster
|
||||
|
||||
### 1. Clone and Build
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd passkey-auth
|
||||
|
||||
# Build the Docker image
|
||||
./scripts/build.sh
|
||||
```
|
||||
|
||||
### 2. Configure
|
||||
|
||||
Edit `k8s/deployment.yaml` to update:
|
||||
|
||||
```yaml
|
||||
# Update these values in the ConfigMap
|
||||
webauthn:
|
||||
rp_id: "your-domain.com" # Your domain
|
||||
rp_origins:
|
||||
- "https://your-domain.com" # Your domain with protocol
|
||||
|
||||
cors:
|
||||
allowed_origins:
|
||||
- "https://your-domain.com" # Your domain with protocol
|
||||
|
||||
auth:
|
||||
session_secret: "your-secure-secret-key" # Generate a secure random string
|
||||
```
|
||||
|
||||
### 3. Deploy to Kubernetes
|
||||
|
||||
```bash
|
||||
# Deploy the passkey auth service
|
||||
./scripts/deploy.sh
|
||||
|
||||
# Verify deployment
|
||||
kubectl get pods -n passkey-auth
|
||||
kubectl logs -f deployment/passkey-auth -n passkey-auth
|
||||
```
|
||||
|
||||
### 4. Configure Your App's Ingress
|
||||
|
||||
Update your application's ingress to use passkey auth:
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: your-app-ingress
|
||||
annotations:
|
||||
# Auth backend - points to passkey auth service
|
||||
nginx.ingress.kubernetes.io/auth-url: "http://passkey-auth-service.passkey-auth.svc.cluster.local/auth"
|
||||
|
||||
# Redirect unauthorized users to login page
|
||||
nginx.ingress.kubernetes.io/auth-signin: "https://your-domain.com/auth"
|
||||
|
||||
# Pass user info to your app
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-User,X-Auth-User-ID"
|
||||
spec:
|
||||
rules:
|
||||
- host: your-app.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: your-app-service
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
### 5. Create Ingress for Passkey Auth
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: passkey-auth-ingress
|
||||
namespace: passkey-auth
|
||||
spec:
|
||||
rules:
|
||||
- host: your-domain.com
|
||||
http:
|
||||
paths:
|
||||
- path: /auth
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: passkey-auth-service
|
||||
port:
|
||||
number: 80
|
||||
```
|
||||
|
||||
Apply the ingress:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/ingress-example.yaml
|
||||
```
|
||||
|
||||
## 👥 User Management
|
||||
|
||||
### Access the Admin Interface
|
||||
|
||||
1. Navigate to `https://your-domain.com/auth` in your browser
|
||||
2. You'll see the admin dashboard with three tabs:
|
||||
- **Register User**: Register new users with passkeys
|
||||
- **Test Login**: Test authentication
|
||||
- **Manage Users**: View and manage all users
|
||||
|
||||
### User Registration Flow
|
||||
|
||||
1. **Register**: Admin enters email address and display name
|
||||
2. **Email Validation**: System checks if email is in the allowlist (if configured)
|
||||
3. **Passkey Creation**: Browser prompts for passkey creation (TouchID, Windows Hello, etc.)
|
||||
4. **Approval**: If `require_approval` is enabled, admin must approve users manually
|
||||
5. **Authentication**: Users can now authenticate with their passkeys
|
||||
|
||||
### User Approval Process
|
||||
|
||||
When `require_approval` is enabled in the configuration:
|
||||
|
||||
1. **New users register** but cannot authenticate until approved
|
||||
2. **Admin reviews pending users** in the "Manage Users" tab
|
||||
3. **Admin clicks "Approve"** next to pending users
|
||||
4. **Users can now authenticate** with their passkeys
|
||||
|
||||
**To approve a user:**
|
||||
1. Navigate to the admin interface at `https://your-domain.com/auth`
|
||||
2. Click the "Manage Users" tab
|
||||
3. Find users with "Pending" status
|
||||
4. Click the "Approve" button next to the user
|
||||
5. Confirm the approval in the dialog
|
||||
|
||||
**User Status Indicators:**
|
||||
- 🟢 **Approved**: User can authenticate
|
||||
- 🟡 **Pending**: User registered but needs approval
|
||||
|
||||
### Configuration Options
|
||||
|
||||
In `config.yaml` or environment variables:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
require_approval: true # Require admin approval for new users
|
||||
session_secret: "secret" # Session encryption key
|
||||
allowed_emails: # Email allowlist (empty = allow all)
|
||||
- "admin@company.com"
|
||||
- "user@company.com"
|
||||
```
|
||||
|
||||
Environment variable overrides:
|
||||
- `WEBAUTHN_RP_ID`: WebAuthn Relying Party ID (your domain)
|
||||
- `SESSION_SECRET`: Session encryption secret
|
||||
- `DATABASE_PATH`: SQLite database file path
|
||||
- `PORT`: Server port (default: 8080)
|
||||
- `ALLOWED_EMAILS`: Comma-separated list of allowed emails
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Local Development
|
||||
|
||||
1. **Install Go dependencies**:
|
||||
```bash
|
||||
go mod download
|
||||
```
|
||||
|
||||
2. **Run locally**:
|
||||
```bash
|
||||
# Update config.yaml for local development
|
||||
go run main.go
|
||||
```
|
||||
|
||||
3. **Access the interface**:
|
||||
```
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
passkey-auth/
|
||||
├── main.go # Application entry point
|
||||
├── internal/
|
||||
│ ├── auth/ # WebAuthn implementation
|
||||
│ ├── config/ # Configuration management
|
||||
│ ├── database/ # SQLite database layer
|
||||
│ └── handlers/ # HTTP handlers
|
||||
├── web/ # Static web files
|
||||
├── k8s/ # Kubernetes manifests
|
||||
├── scripts/ # Deployment scripts
|
||||
└── config.yaml # Configuration file
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/register/begin` | POST | Start passkey registration |
|
||||
| `/api/register/finish` | POST | Complete passkey registration |
|
||||
| `/api/login/begin` | POST | Start passkey authentication |
|
||||
| `/api/login/finish` | POST | Complete passkey authentication |
|
||||
| `/api/logout` | POST | Logout user |
|
||||
| `/auth` | GET | Nginx auth check endpoint |
|
||||
| `/api/users` | GET | List all users (admin) |
|
||||
| `/api/users` | POST | Create new user (admin) |
|
||||
| `/api/users/{id}` | PUT | Update user (approve/admin) |
|
||||
| `/api/users/{id}` | DELETE | Delete user (admin) |
|
||||
| `/health` | GET | Health check |
|
||||
|
||||
## 🔒 Security Considerations
|
||||
|
||||
### Production Deployment
|
||||
|
||||
1. **HTTPS Only**: Always use HTTPS in production
|
||||
2. **Secure Session Secret**: Use a strong, random session secret
|
||||
3. **Domain Configuration**: Ensure `rp_id` matches your domain exactly
|
||||
4. **Network Security**: Use Kubernetes network policies to restrict access
|
||||
5. **Regular Backups**: Backup the SQLite database regularly
|
||||
|
||||
### Session Configuration
|
||||
|
||||
```yaml
|
||||
# Secure session configuration for production
|
||||
auth:
|
||||
session_secret: "your-256-bit-random-key" # Use a proper secret manager
|
||||
```
|
||||
|
||||
### Database Security
|
||||
|
||||
The SQLite database contains:
|
||||
- User information (email addresses, display names)
|
||||
- WebAuthn credentials (public keys, metadata)
|
||||
- No passwords or private keys are stored
|
||||
|
||||
## 🔐 Email Access Control
|
||||
|
||||
### Allowlist Configuration
|
||||
|
||||
You can control who can register by configuring an email allowlist:
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
auth:
|
||||
allowed_emails:
|
||||
- "admin@yourcompany.com"
|
||||
- "engineering@yourcompany.com"
|
||||
- "support@yourcompany.com"
|
||||
```
|
||||
|
||||
Or via environment variable:
|
||||
```bash
|
||||
export ALLOWED_EMAILS="admin@company.com,user1@company.com,user2@company.com"
|
||||
```
|
||||
|
||||
### Access Control Options
|
||||
|
||||
1. **Open Registration** (allowed_emails is empty or not set):
|
||||
- Any email address can register
|
||||
- Suitable for internal/trusted environments
|
||||
|
||||
2. **Allowlist Mode** (allowed_emails configured):
|
||||
- Only specified email addresses can register
|
||||
- Recommended for production environments
|
||||
|
||||
3. **Combined with Approval**:
|
||||
- Users must be in allowlist AND get admin approval
|
||||
- Maximum security for sensitive applications
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
### Health Checks
|
||||
|
||||
The service exposes a health endpoint at `/health`:
|
||||
|
||||
```bash
|
||||
curl http://passkey-auth-service.passkey-auth.svc.cluster.local/health
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
View application logs:
|
||||
|
||||
```bash
|
||||
kubectl logs -f deployment/passkey-auth -n passkey-auth
|
||||
```
|
||||
|
||||
### Metrics
|
||||
|
||||
For production, consider adding metrics collection:
|
||||
- Authentication success/failure rates
|
||||
- User registration rates
|
||||
- Session duration statistics
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Docker build failures with SQLite CGO errors**:
|
||||
- The Dockerfile uses Debian-based images (golang:1.21-bullseye) instead of Alpine
|
||||
- This resolves musl vs glibc compatibility issues with go-sqlite3
|
||||
- If you encounter `pread64` or `pwrite64` errors, ensure you're using a glibc-based image
|
||||
|
||||
2. **WebAuthn encoding errors** (challenge not ArrayBuffer):
|
||||
- The web interface includes base64url conversion functions
|
||||
- Ensure you're using the included HTML file, not a custom frontend
|
||||
- Binary WebAuthn data must be converted between base64url and ArrayBuffer
|
||||
|
||||
3. **"User not found" during login**:
|
||||
- Ensure user is registered and approved (if required)
|
||||
- Check that username matches exactly
|
||||
|
||||
4. **WebAuthn errors**:
|
||||
- Verify `rp_id` matches your domain
|
||||
- Ensure HTTPS is used (required for WebAuthn)
|
||||
- Check browser support for WebAuthn
|
||||
|
||||
5. **Auth backend not working**:
|
||||
- Verify ingress annotations are correct
|
||||
- Check that the auth service is accessible from nginx
|
||||
- Review nginx ingress controller logs
|
||||
|
||||
6. **Session issues**:
|
||||
- Ensure session secret is consistent
|
||||
- Check cookie settings (secure flag for HTTPS)
|
||||
- Verify session storage is persistent
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check pod status
|
||||
kubectl get pods -n passkey-auth
|
||||
|
||||
# View logs
|
||||
kubectl logs deployment/passkey-auth -n passkey-auth
|
||||
|
||||
# Check service
|
||||
kubectl get svc -n passkey-auth
|
||||
|
||||
# Test auth endpoint
|
||||
kubectl exec -it deployment/passkey-auth -n passkey-auth -- wget -O- http://localhost:8080/health
|
||||
|
||||
# Port forward for local testing
|
||||
kubectl port-forward svc/passkey-auth-service 8080:80 -n passkey-auth
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests if applicable
|
||||
5. Submit a pull request
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
- [go-webauthn](https://github.com/go-webauthn/webauthn) - WebAuthn library for Go
|
||||
- [Gorilla](https://github.com/gorilla) - HTTP utilities for Go
|
||||
- WebAuthn/FIDO2 specifications
|
||||
- Kubernetes and nginx ingress controller teams
|
||||
|
||||
---
|
||||
|
||||
For support or questions, please create an issue in the repository.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Development Configuration
|
||||
# Copy this file to config.yaml and modify for your environment
|
||||
|
||||
server:
|
||||
port: "8080"
|
||||
host: "0.0.0.0"
|
||||
|
||||
webauthn:
|
||||
# Display name shown during passkey registration
|
||||
rp_display_name: "Passkey Auth - Development"
|
||||
|
||||
# Must match your domain exactly (without protocol)
|
||||
# For local development, use "localhost"
|
||||
# For production, use your actual domain like "auth.example.com"
|
||||
rp_id: "localhost"
|
||||
|
||||
# List of allowed origins (with protocol)
|
||||
# Must include all URLs where users will access the auth interface
|
||||
rp_origins:
|
||||
- "http://localhost:8080"
|
||||
# Add your production URLs:
|
||||
# - "https://auth.example.com"
|
||||
# - "https://example.com"
|
||||
|
||||
database:
|
||||
# SQLite database file path
|
||||
# In Kubernetes, this should be in a persistent volume
|
||||
path: "passkey-auth.db"
|
||||
|
||||
cors:
|
||||
# Allowed origins for CORS
|
||||
# In production, be specific about allowed origins for security
|
||||
allowed_origins:
|
||||
- "*" # Only use "*" for development
|
||||
# Production example:
|
||||
# - "https://auth.example.com"
|
||||
# - "https://admin.example.com"
|
||||
|
||||
auth:
|
||||
# Session encryption secret - MUST be changed in production!
|
||||
# Generate with: openssl rand -base64 32
|
||||
session_secret: "change-me-in-production"
|
||||
|
||||
# Whether new users need admin approval before they can authenticate
|
||||
# Set to false to allow automatic approval for trusted environments
|
||||
require_approval: true
|
||||
|
||||
# Email allowlist - list of email addresses allowed to register
|
||||
# Leave empty to allow any email address (not recommended for production)
|
||||
allowed_emails:
|
||||
# Example emails (uncomment and modify as needed):
|
||||
# - "admin@yourcompany.com"
|
||||
# - "user1@yourcompany.com"
|
||||
# - "user2@yourcompany.com"
|
||||
|
||||
# Environment-specific overrides can be set via environment variables:
|
||||
# - PORT: Server port
|
||||
# - HOST: Server host
|
||||
# - WEBAUTHN_RP_ID: WebAuthn Relying Party ID
|
||||
# - DATABASE_PATH: Database file path
|
||||
# - SESSION_SECRET: Session encryption secret
|
||||
# - ALLOWED_EMAILS: Comma-separated list of allowed emails
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
server:
|
||||
port: "8080"
|
||||
host: "0.0.0.0"
|
||||
|
||||
webauthn:
|
||||
rp_display_name: "Passkey Auth"
|
||||
rp_id: "localhost"
|
||||
rp_origins:
|
||||
- "http://localhost:8080"
|
||||
|
||||
database:
|
||||
path: "passkey-auth.db"
|
||||
|
||||
cors:
|
||||
allowed_origins:
|
||||
- "*"
|
||||
|
||||
auth:
|
||||
session_secret: "change-me-in-production"
|
||||
require_approval: true
|
||||
# Email allowlist - leave empty to allow any email
|
||||
allowed_emails:
|
||||
# - "admin@example.com"
|
||||
# - "user@example.com"
|
||||
@@ -0,0 +1,20 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
passkey-auth:
|
||||
build: .
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- WEBAUTHN_RP_ID=localhost
|
||||
- DATABASE_PATH=/data/passkey-auth.db
|
||||
- SESSION_SECRET=dev-secret-change-in-production
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./config.yaml:/root/config.yaml
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
@@ -0,0 +1,26 @@
|
||||
module passkey-auth
|
||||
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/go-webauthn/webauthn v0.10.2
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/sessions v1.2.2
|
||||
github.com/mattn/go-sqlite3 v1.14.18
|
||||
github.com/rs/cors v1.10.1
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/fxamacker/cbor/v2 v2.6.0 // indirect
|
||||
github.com/go-webauthn/x v0.1.9 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
||||
github.com/google/go-tpm v0.9.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
golang.org/x/crypto v0.21.0 // indirect
|
||||
golang.org/x/sys v0.18.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/fxamacker/cbor/v2 v2.6.0 h1:sU6J2usfADwWlYDAFhZBQ6TnLFBHxgesMrQfQgk1tWA=
|
||||
github.com/fxamacker/cbor/v2 v2.6.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/go-webauthn/webauthn v0.10.2 h1:OG7B+DyuTytrEPFmTX503K77fqs3HDK/0Iv+z8UYbq4=
|
||||
github.com/go-webauthn/webauthn v0.10.2/go.mod h1:Gd1IDsGAybuvK1NkwUTLbGmeksxuRJjVN2PE/xsPxHs=
|
||||
github.com/go-webauthn/x v0.1.9 h1:v1oeLmoaa+gPOaZqUdDentu6Rl7HkSSsmOT6gxEQHhE=
|
||||
github.com/go-webauthn/x v0.1.9/go.mod h1:pJNMlIMP1SU7cN8HNlKJpLEnFHCygLCvaLZ8a1xeoQA=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/google/go-tpm v0.9.0 h1:sQF6YqWMi+SCXpsmS3fd21oPy/vSddwZry4JnmltHVk=
|
||||
github.com/google/go-tpm v0.9.0/go.mod h1:FkNVkc6C+IsvDI9Jw1OveJmxGZUUaKxtrpOS47QWKfU=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
|
||||
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
|
||||
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
||||
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo=
|
||||
github.com/rs/cors v1.10.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,139 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"passkey-auth/internal/config"
|
||||
"passkey-auth/internal/database"
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
)
|
||||
|
||||
type WebAuthn struct {
|
||||
web *webauthn.WebAuthn
|
||||
db *database.DB
|
||||
}
|
||||
|
||||
// WebAuthnUser implements the webauthn.User interface
|
||||
type WebAuthnUser struct {
|
||||
user *database.User
|
||||
credentials []*database.Credential
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) SetUser(user *database.User) {
|
||||
u.user = user
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) GetUser() *database.User {
|
||||
return u.user
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) WebAuthnID() []byte {
|
||||
return []byte(u.user.Email)
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) WebAuthnName() string {
|
||||
return u.user.Email
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) WebAuthnDisplayName() string {
|
||||
return u.user.DisplayName
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) WebAuthnIcon() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (u *WebAuthnUser) WebAuthnCredentials() []webauthn.Credential {
|
||||
var creds []webauthn.Credential
|
||||
for _, dbCred := range u.credentials {
|
||||
creds = append(creds, webauthn.Credential{
|
||||
ID: dbCred.ID,
|
||||
PublicKey: dbCred.PublicKey,
|
||||
AttestationType: dbCred.AttestationType,
|
||||
Authenticator: webauthn.Authenticator{
|
||||
AAGUID: dbCred.AAGUID,
|
||||
SignCount: dbCred.SignCount,
|
||||
CloneWarning: dbCred.CloneWarning,
|
||||
},
|
||||
})
|
||||
}
|
||||
return creds
|
||||
}
|
||||
|
||||
func NewWebAuthn(cfg *config.Config) (*WebAuthn, error) {
|
||||
wconfig := &webauthn.Config{
|
||||
RPDisplayName: cfg.WebAuthn.RPDisplayName,
|
||||
RPID: cfg.WebAuthn.RPID,
|
||||
RPOrigins: cfg.WebAuthn.RPOrigins,
|
||||
}
|
||||
|
||||
web, err := webauthn.New(wconfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &WebAuthn{
|
||||
web: web,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (wa *WebAuthn) SetDB(db *database.DB) {
|
||||
wa.db = db
|
||||
}
|
||||
|
||||
func (wa *WebAuthn) GetUserByEmail(email string) (*WebAuthnUser, error) {
|
||||
user, err := wa.db.GetUserByEmail(email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
credentials, err := wa.db.GetCredentialsByUserID(user.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &WebAuthnUser{
|
||||
user: user,
|
||||
credentials: credentials,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (wa *WebAuthn) BeginRegistration(user *WebAuthnUser) (*protocol.CredentialCreation, *webauthn.SessionData, error) {
|
||||
creation, sessionData, err := wa.web.BeginRegistration(user)
|
||||
return creation, sessionData, err
|
||||
}
|
||||
|
||||
func (wa *WebAuthn) FinishRegistration(user *WebAuthnUser, sessionData webauthn.SessionData, response *http.Request) (*webauthn.Credential, error) {
|
||||
credential, err := wa.web.FinishRegistration(user, sessionData, response)
|
||||
return credential, err
|
||||
}
|
||||
|
||||
func (wa *WebAuthn) BeginLogin(user *WebAuthnUser) (*protocol.CredentialAssertion, *webauthn.SessionData, error) {
|
||||
assertion, sessionData, err := wa.web.BeginLogin(user)
|
||||
return assertion, sessionData, err
|
||||
}
|
||||
|
||||
func (wa *WebAuthn) FinishLogin(user *WebAuthnUser, sessionData webauthn.SessionData, response *http.Request) (*webauthn.Credential, error) {
|
||||
credential, err := wa.web.FinishLogin(user, sessionData, response)
|
||||
return credential, err
|
||||
}
|
||||
|
||||
func (wa *WebAuthn) SaveCredential(userID int, cred *webauthn.Credential) error {
|
||||
dbCred := &database.Credential{
|
||||
ID: cred.ID,
|
||||
UserID: userID,
|
||||
PublicKey: cred.PublicKey,
|
||||
AttestationType: cred.AttestationType,
|
||||
AAGUID: cred.Authenticator.AAGUID,
|
||||
SignCount: cred.Authenticator.SignCount,
|
||||
CloneWarning: cred.Authenticator.CloneWarning,
|
||||
}
|
||||
|
||||
return wa.db.SaveCredential(dbCred)
|
||||
}
|
||||
|
||||
func (wa *WebAuthn) UpdateCredentialSignCount(credID []byte, signCount uint32) error {
|
||||
return wa.db.UpdateCredentialSignCount(credID, signCount)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
WebAuthn WebAuthnConfig `yaml:"webauthn"`
|
||||
Database DatabaseConfig `yaml:"database"`
|
||||
CORS CORSConfig `yaml:"cors"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Port string `yaml:"port"`
|
||||
Host string `yaml:"host"`
|
||||
}
|
||||
|
||||
type WebAuthnConfig struct {
|
||||
RPDisplayName string `yaml:"rp_display_name"`
|
||||
RPID string `yaml:"rp_id"`
|
||||
RPOrigins []string `yaml:"rp_origins"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
Path string `yaml:"path"`
|
||||
}
|
||||
|
||||
type CORSConfig struct {
|
||||
AllowedOrigins []string `yaml:"allowed_origins"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
SessionSecret string `yaml:"session_secret"`
|
||||
RequireApproval bool `yaml:"require_approval"`
|
||||
AllowedEmails []string `yaml:"allowed_emails"`
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
configPath := os.Getenv("CONFIG_PATH")
|
||||
if configPath == "" {
|
||||
configPath = "config.yaml"
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
config := &Config{
|
||||
Server: ServerConfig{
|
||||
Port: "8080",
|
||||
Host: "0.0.0.0",
|
||||
},
|
||||
WebAuthn: WebAuthnConfig{
|
||||
RPDisplayName: "Passkey Auth",
|
||||
RPID: "localhost",
|
||||
RPOrigins: []string{"http://localhost:8080"},
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
Path: "passkey-auth.db",
|
||||
},
|
||||
CORS: CORSConfig{
|
||||
AllowedOrigins: []string{"*"},
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
SessionSecret: "change-me-in-production",
|
||||
RequireApproval: true,
|
||||
AllowedEmails: []string{}, // Empty means no email restrictions
|
||||
},
|
||||
}
|
||||
|
||||
// Load from file if it exists
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
data, err := ioutil.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Override with environment variables
|
||||
if port := os.Getenv("PORT"); port != "" {
|
||||
config.Server.Port = port
|
||||
}
|
||||
if host := os.Getenv("HOST"); host != "" {
|
||||
config.Server.Host = host
|
||||
}
|
||||
if rpid := os.Getenv("WEBAUTHN_RP_ID"); rpid != "" {
|
||||
config.WebAuthn.RPID = rpid
|
||||
}
|
||||
if dbPath := os.Getenv("DATABASE_PATH"); dbPath != "" {
|
||||
config.Database.Path = dbPath
|
||||
}
|
||||
if secret := os.Getenv("SESSION_SECRET"); secret != "" {
|
||||
config.Auth.SessionSecret = secret
|
||||
}
|
||||
if allowedEmails := os.Getenv("ALLOWED_EMAILS"); allowedEmails != "" {
|
||||
config.Auth.AllowedEmails = strings.Split(allowedEmails, ",")
|
||||
// Trim whitespace from emails
|
||||
for i, email := range config.Auth.AllowedEmails {
|
||||
config.Auth.AllowedEmails[i] = strings.TrimSpace(email)
|
||||
}
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// IsEmailAllowed checks if an email address is in the allowed list
|
||||
// Returns true if the allowlist is empty (no restrictions) or if the email is in the list
|
||||
func (c *Config) IsEmailAllowed(email string) bool {
|
||||
// If no allowed emails specified, allow all
|
||||
if len(c.Auth.AllowedEmails) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if email is in the allowed list
|
||||
for _, allowedEmail := range c.Auth.AllowedEmails {
|
||||
if email == allowedEmail {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Approved bool `json:"approved"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type Credential struct {
|
||||
ID []byte `json:"id"`
|
||||
UserID int `json:"user_id"`
|
||||
PublicKey []byte `json:"public_key"`
|
||||
AttestationType string `json:"attestation_type"`
|
||||
AAGUID []byte `json:"aaguid"`
|
||||
SignCount uint32 `json:"sign_count"`
|
||||
CloneWarning bool `json:"clone_warning"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func New(dbPath string) (*DB, error) {
|
||||
conn, err := sql.Open("sqlite3", dbPath+"?_fk=1")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db := &DB{conn: conn}
|
||||
if err := db.migrate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (db *DB) Close() error {
|
||||
return db.conn.Close()
|
||||
}
|
||||
|
||||
func (db *DB) migrate() error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
approved BOOLEAN DEFAULT FALSE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS credentials (
|
||||
id BLOB PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
public_key BLOB NOT NULL,
|
||||
attestation_type TEXT NOT NULL,
|
||||
aaguid BLOB,
|
||||
sign_count INTEGER DEFAULT 0,
|
||||
clone_warning BOOLEAN DEFAULT FALSE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_credentials_user_id ON credentials(user_id)`,
|
||||
}
|
||||
|
||||
for _, query := range queries {
|
||||
if _, err := db.conn.Exec(query); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateUser(email, displayName string) (*User, error) {
|
||||
result, err := db.conn.Exec(
|
||||
"INSERT INTO users (email, display_name) VALUES (?, ?)",
|
||||
email, displayName,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return db.GetUser(int(id))
|
||||
}
|
||||
|
||||
func (db *DB) GetUser(id int) (*User, error) {
|
||||
var user User
|
||||
err := db.conn.QueryRow(
|
||||
"SELECT id, email, display_name, approved, created_at FROM users WHERE id = ?",
|
||||
id,
|
||||
).Scan(&user.ID, &user.Email, &user.DisplayName, &user.Approved, &user.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetUserByEmail(email string) (*User, error) {
|
||||
var user User
|
||||
err := db.conn.QueryRow(
|
||||
"SELECT id, email, display_name, approved, created_at FROM users WHERE email = ?",
|
||||
email,
|
||||
).Scan(&user.ID, &user.Email, &user.DisplayName, &user.Approved, &user.CreatedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (db *DB) ListUsers() ([]*User, error) {
|
||||
rows, err := db.conn.Query(
|
||||
"SELECT id, email, display_name, approved, created_at FROM users ORDER BY created_at DESC",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []*User
|
||||
for rows.Next() {
|
||||
var user User
|
||||
if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.Approved, &user.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, &user)
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (db *DB) ApproveUser(id int) error {
|
||||
_, err := db.conn.Exec("UPDATE users SET approved = TRUE WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) DeleteUser(id int) error {
|
||||
_, err := db.conn.Exec("DELETE FROM users WHERE id = ?", id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) SaveCredential(cred *Credential) error {
|
||||
_, err := db.conn.Exec(
|
||||
`INSERT INTO credentials (id, user_id, public_key, attestation_type, aaguid, sign_count, clone_warning)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
cred.ID, cred.UserID, cred.PublicKey, cred.AttestationType,
|
||||
cred.AAGUID, cred.SignCount, cred.CloneWarning,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetCredentialsByUserID(userID int) ([]*Credential, error) {
|
||||
rows, err := db.conn.Query(
|
||||
`SELECT id, user_id, public_key, attestation_type, aaguid, sign_count, clone_warning, created_at
|
||||
FROM credentials WHERE user_id = ?`,
|
||||
userID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var credentials []*Credential
|
||||
for rows.Next() {
|
||||
var cred Credential
|
||||
if err := rows.Scan(
|
||||
&cred.ID, &cred.UserID, &cred.PublicKey, &cred.AttestationType,
|
||||
&cred.AAGUID, &cred.SignCount, &cred.CloneWarning, &cred.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
credentials = append(credentials, &cred)
|
||||
}
|
||||
|
||||
return credentials, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpdateCredentialSignCount(credID []byte, signCount uint32) error {
|
||||
_, err := db.conn.Exec(
|
||||
"UPDATE credentials SET sign_count = ? WHERE id = ?",
|
||||
signCount, credID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"passkey-auth/internal/auth"
|
||||
"passkey-auth/internal/config"
|
||||
"passkey-auth/internal/database"
|
||||
)
|
||||
|
||||
type Handlers struct {
|
||||
db *database.DB
|
||||
webAuthn *auth.WebAuthn
|
||||
config *config.Config
|
||||
store *sessions.CookieStore
|
||||
}
|
||||
|
||||
func New(db *database.DB, webAuthn *auth.WebAuthn, config *config.Config) *Handlers {
|
||||
webAuthn.SetDB(db)
|
||||
|
||||
store := sessions.NewCookieStore([]byte(config.Auth.SessionSecret))
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 7, // 7 days
|
||||
HttpOnly: true,
|
||||
Secure: false, // Set to true in production with HTTPS
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
}
|
||||
|
||||
return &Handlers{
|
||||
db: db,
|
||||
webAuthn: webAuthn,
|
||||
config: config,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handlers) writeError(w http.ResponseWriter, message string, code int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
|
||||
func (h *Handlers) writeJSON(w http.ResponseWriter, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// BeginRegistration starts the passkey registration process
|
||||
func (h *Handlers) BeginRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Email == "" || req.DisplayName == "" {
|
||||
h.writeError(w, "Email and display name are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if email is allowed
|
||||
if !h.config.IsEmailAllowed(req.Email) {
|
||||
h.writeError(w, "Email address not allowed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
existingUser, err := h.db.GetUserByEmail(req.Email)
|
||||
if err == nil && existingUser != nil {
|
||||
h.writeError(w, "User already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new user
|
||||
user, err := h.db.CreateUser(req.Email, req.DisplayName)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to create user: %v", err)
|
||||
h.writeError(w, "Failed to create user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
webAuthnUser := &auth.WebAuthnUser{}
|
||||
webAuthnUser.SetUser(user)
|
||||
|
||||
options, sessionData, err := h.webAuthn.BeginRegistration(webAuthnUser)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to begin registration: %v", err)
|
||||
h.writeError(w, "Failed to begin registration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Store session data
|
||||
session, _ := h.store.Get(r, "webauthn-session")
|
||||
session.Values["challenge"] = sessionData.Challenge
|
||||
session.Values["user_id"] = user.ID
|
||||
session.Save(r, w)
|
||||
|
||||
// Debug: log the options structure
|
||||
logrus.Debugf("WebAuthn options: %+v", options)
|
||||
logrus.Debugf("Challenge type: %T", options.Response.Challenge)
|
||||
logrus.Debugf("Challenge value: %v", options.Response.Challenge)
|
||||
logrus.Debugf("User ID type: %T", options.Response.User.ID)
|
||||
logrus.Debugf("User ID value: %v", options.Response.User.ID)
|
||||
|
||||
h.writeJSON(w, options)
|
||||
}
|
||||
|
||||
// FinishRegistration completes the passkey registration process
|
||||
func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := h.store.Get(r, "webauthn-session")
|
||||
|
||||
userID, ok := session.Values["user_id"].(int)
|
||||
if !ok {
|
||||
h.writeError(w, "Invalid session", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
challenge, ok := session.Values["challenge"].(string)
|
||||
if !ok {
|
||||
h.writeError(w, "Invalid session", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Read and log the request body for debugging
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
h.writeError(w, "Failed to read request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
logrus.Debugf("Received credential response body: %s", string(body))
|
||||
|
||||
// Create a new reader from the body for the WebAuthn library
|
||||
r.Body = io.NopCloser(strings.NewReader(string(body)))
|
||||
|
||||
user, err := h.db.GetUser(userID)
|
||||
if err != nil {
|
||||
h.writeError(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
webAuthnUser := &auth.WebAuthnUser{}
|
||||
webAuthnUser.SetUser(user)
|
||||
|
||||
sessionData := webauthn.SessionData{
|
||||
Challenge: challenge,
|
||||
UserID: webAuthnUser.WebAuthnID(),
|
||||
}
|
||||
|
||||
// Log the request details for debugging
|
||||
logrus.Debugf("Finishing registration for user: %s", user.Email)
|
||||
logrus.Debugf("Session challenge: %s", challenge)
|
||||
logrus.Debugf("Session user ID: %v", webAuthnUser.WebAuthnID())
|
||||
|
||||
credential, err := h.webAuthn.FinishRegistration(webAuthnUser, sessionData, r)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to finish registration: %v", err)
|
||||
h.writeError(w, "Failed to finish registration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Save credential to database
|
||||
if err := h.webAuthn.SaveCredential(user.ID, credential); err != nil {
|
||||
logrus.Errorf("Failed to save credential: %v", err)
|
||||
h.writeError(w, "Failed to save credential", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear session
|
||||
session.Values["challenge"] = nil
|
||||
session.Values["user_id"] = nil
|
||||
session.Save(r, w)
|
||||
|
||||
h.writeJSON(w, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
// BeginLogin starts the passkey authentication process
|
||||
func (h *Handlers) BeginLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
webAuthnUser, err := h.webAuthn.GetUserByEmail(req.Email)
|
||||
if err != nil {
|
||||
h.writeError(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if user is approved (if required)
|
||||
if h.config.Auth.RequireApproval && !webAuthnUser.GetUser().Approved {
|
||||
h.writeError(w, "User not approved", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
options, sessionData, err := h.webAuthn.BeginLogin(webAuthnUser)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to begin login: %v", err)
|
||||
h.writeError(w, "Failed to begin login", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Debug: log the login options structure
|
||||
logrus.Debugf("Login options: %+v", options)
|
||||
logrus.Debugf("Login challenge type: %T", options.Response.Challenge)
|
||||
logrus.Debugf("Login challenge value: %v", options.Response.Challenge)
|
||||
if len(options.Response.AllowedCredentials) > 0 {
|
||||
logrus.Debugf("AllowedCredentials count: %d", len(options.Response.AllowedCredentials))
|
||||
for i, cred := range options.Response.AllowedCredentials {
|
||||
logrus.Debugf("Credential %d ID type: %T", i, cred.CredentialID)
|
||||
logrus.Debugf("Credential %d ID value: %v", i, cred.CredentialID)
|
||||
}
|
||||
}
|
||||
|
||||
// Store session data
|
||||
session, _ := h.store.Get(r, "webauthn-session")
|
||||
session.Values["challenge"] = sessionData.Challenge
|
||||
session.Values["user_id"] = webAuthnUser.GetUser().ID
|
||||
session.Save(r, w)
|
||||
|
||||
h.writeJSON(w, options)
|
||||
}
|
||||
|
||||
// FinishLogin completes the passkey authentication process
|
||||
func (h *Handlers) FinishLogin(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := h.store.Get(r, "webauthn-session")
|
||||
|
||||
userID, ok := session.Values["user_id"].(int)
|
||||
if !ok {
|
||||
h.writeError(w, "Invalid session", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
challenge, ok := session.Values["challenge"].(string)
|
||||
if !ok {
|
||||
h.writeError(w, "Invalid session", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.db.GetUser(userID)
|
||||
if err != nil {
|
||||
h.writeError(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
webAuthnUser, err := h.webAuthn.GetUserByEmail(user.Email)
|
||||
if err != nil {
|
||||
h.writeError(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
sessionData := webauthn.SessionData{
|
||||
Challenge: challenge,
|
||||
UserID: webAuthnUser.WebAuthnID(),
|
||||
}
|
||||
|
||||
credential, err := h.webAuthn.FinishLogin(webAuthnUser, sessionData, r)
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to finish login: %v", err)
|
||||
h.writeError(w, "Authentication failed", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Update credential sign count
|
||||
if err := h.webAuthn.UpdateCredentialSignCount(credential.ID, credential.Authenticator.SignCount); err != nil {
|
||||
logrus.Errorf("Failed to update sign count: %v", err)
|
||||
}
|
||||
|
||||
// Set authenticated session
|
||||
authSession, _ := h.store.Get(r, "auth-session")
|
||||
authSession.Values["authenticated"] = true
|
||||
authSession.Values["user_id"] = user.ID
|
||||
authSession.Values["user_email"] = user.Email
|
||||
authSession.Save(r, w)
|
||||
|
||||
// Clear webauthn session
|
||||
session.Values["challenge"] = nil
|
||||
session.Values["user_id"] = nil
|
||||
session.Save(r, w)
|
||||
|
||||
h.writeJSON(w, map[string]interface{}{
|
||||
"status": "success",
|
||||
"user": map[string]interface{}{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"display_name": user.DisplayName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Logout clears the authentication session
|
||||
func (h *Handlers) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := h.store.Get(r, "auth-session")
|
||||
session.Values["authenticated"] = false
|
||||
session.Values["user_id"] = nil
|
||||
session.Values["user_email"] = nil
|
||||
session.Options.MaxAge = -1
|
||||
session.Save(r, w)
|
||||
|
||||
h.writeJSON(w, map[string]string{"status": "success"})
|
||||
}
|
||||
|
||||
// AuthCheck implements the nginx auth_request protocol
|
||||
func (h *Handlers) AuthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := h.store.Get(r, "auth-session")
|
||||
|
||||
authenticated, ok := session.Values["authenticated"].(bool)
|
||||
if !ok || !authenticated {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Optional: Add user info to response headers
|
||||
if userID, ok := session.Values["user_id"].(int); ok {
|
||||
w.Header().Set("X-Auth-User-ID", strconv.Itoa(userID))
|
||||
}
|
||||
if userEmail, ok := session.Values["user_email"].(string); ok {
|
||||
w.Header().Set("X-Auth-User", userEmail)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// Admin endpoints
|
||||
|
||||
// ListUsers returns all users (admin endpoint)
|
||||
func (h *Handlers) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Add admin authentication check
|
||||
users, err := h.db.ListUsers()
|
||||
if err != nil {
|
||||
logrus.Errorf("Failed to list users: %v", err)
|
||||
h.writeError(w, "Failed to list users", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeJSON(w, users)
|
||||
}
|
||||
|
||||
// CreateUser creates a new user (admin endpoint)
|
||||
func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Add admin authentication check
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Approved bool `json:"approved"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if email is allowed
|
||||
if !h.config.IsEmailAllowed(req.Email) {
|
||||
h.writeError(w, "Email address not allowed", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.db.CreateUser(req.Email, req.DisplayName)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
h.writeError(w, "User already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
logrus.Errorf("Failed to create user: %v", err)
|
||||
h.writeError(w, "Failed to create user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Approved {
|
||||
if err := h.db.ApproveUser(user.ID); err != nil {
|
||||
logrus.Errorf("Failed to approve user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
h.writeJSON(w, user)
|
||||
}
|
||||
|
||||
// UpdateUser updates a user (admin endpoint)
|
||||
func (h *Handlers) UpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Add admin authentication check
|
||||
vars := mux.Vars(r)
|
||||
idStr, ok := vars["id"]
|
||||
if !ok {
|
||||
h.writeError(w, "User ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
h.writeError(w, "Invalid user ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Approved *bool `json:"approved"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
h.writeError(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get existing user
|
||||
user, err := h.db.GetUser(id)
|
||||
if err != nil {
|
||||
h.writeError(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Update approval status if provided
|
||||
if req.Approved != nil && *req.Approved {
|
||||
if err := h.db.ApproveUser(id); err != nil {
|
||||
logrus.Errorf("Failed to approve user: %v", err)
|
||||
h.writeError(w, "Failed to approve user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
user.Approved = true
|
||||
logrus.Infof("User approved: %s", user.Email)
|
||||
}
|
||||
|
||||
h.writeJSON(w, user)
|
||||
}
|
||||
|
||||
// DeleteUser deletes a user (admin endpoint)
|
||||
func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
// TODO: Add admin authentication check
|
||||
vars := mux.Vars(r)
|
||||
idStr, ok := vars["id"]
|
||||
if !ok {
|
||||
h.writeError(w, "User ID required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
h.writeError(w, "Invalid user ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.DeleteUser(id); err != nil {
|
||||
logrus.Errorf("Failed to delete user: %v", err)
|
||||
h.writeError(w, "Failed to delete user", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeJSON(w, map[string]string{"status": "success"})
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: passkey-auth-config
|
||||
namespace: passkey-auth
|
||||
data:
|
||||
config.yaml: |
|
||||
server:
|
||||
port: "8080"
|
||||
host: "0.0.0.0"
|
||||
|
||||
webauthn:
|
||||
rp_display_name: "Passkey Auth"
|
||||
rp_id: "your-domain.com"
|
||||
rp_origins:
|
||||
- "https://your-domain.com"
|
||||
|
||||
database:
|
||||
path: "/data/passkey-auth.db"
|
||||
|
||||
cors:
|
||||
allowed_origins:
|
||||
- "https://your-domain.com"
|
||||
|
||||
auth:
|
||||
session_secret: "your-session-secret-change-me"
|
||||
require_approval: true
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: passkey-auth-storage
|
||||
namespace: passkey-auth
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: passkey-auth
|
||||
namespace: passkey-auth
|
||||
labels:
|
||||
app: passkey-auth
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: passkey-auth
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: passkey-auth
|
||||
spec:
|
||||
containers:
|
||||
- name: passkey-auth
|
||||
image: passkey-auth:latest
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
env:
|
||||
- name: CONFIG_PATH
|
||||
value: "/config/config.yaml"
|
||||
- name: DATABASE_PATH
|
||||
value: "/data/passkey-auth.db"
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /config
|
||||
readOnly: true
|
||||
- name: data
|
||||
mountPath: /data
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 30
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8080
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 5
|
||||
resources:
|
||||
requests:
|
||||
memory: "64Mi"
|
||||
cpu: "50m"
|
||||
limits:
|
||||
memory: "128Mi"
|
||||
cpu: "100m"
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: passkey-auth-config
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: passkey-auth-storage
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: passkey-auth-service
|
||||
namespace: passkey-auth
|
||||
labels:
|
||||
app: passkey-auth
|
||||
spec:
|
||||
selector:
|
||||
app: passkey-auth
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
protocol: TCP
|
||||
type: ClusterIP
|
||||
@@ -0,0 +1,44 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: example-app-ingress
|
||||
namespace: default
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: "http://passkey-auth-service.passkey-auth.svc.cluster.local/auth"
|
||||
nginx.ingress.kubernetes.io/auth-signin: "https://your-domain.com/auth"
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "X-Auth-User,X-Auth-User-ID"
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: your-app.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: your-app-service
|
||||
port:
|
||||
number: 80
|
||||
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: passkey-auth-ingress
|
||||
namespace: passkey-auth
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
spec:
|
||||
ingressClassName: nginx
|
||||
rules:
|
||||
- host: your-domain.com
|
||||
http:
|
||||
paths:
|
||||
- path: /auth
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: passkey-auth-service
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: passkey-auth
|
||||
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rs/cors"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"passkey-auth/internal/auth"
|
||||
"passkey-auth/internal/config"
|
||||
"passkey-auth/internal/database"
|
||||
"passkey-auth/internal/handlers"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Setup logging
|
||||
logrus.SetFormatter(&logrus.JSONFormatter{})
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
db, err := database.New(cfg.Database.Path)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
// Initialize WebAuthn
|
||||
webAuthn, err := auth.NewWebAuthn(cfg)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize WebAuthn: %v", err)
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
h := handlers.New(db, webAuthn, cfg)
|
||||
|
||||
// Setup routes
|
||||
router := mux.NewRouter()
|
||||
|
||||
// API routes
|
||||
api := router.PathPrefix("/api").Subrouter()
|
||||
api.HandleFunc("/register/begin", h.BeginRegistration).Methods("POST")
|
||||
api.HandleFunc("/register/finish", h.FinishRegistration).Methods("POST")
|
||||
api.HandleFunc("/login/begin", h.BeginLogin).Methods("POST")
|
||||
api.HandleFunc("/login/finish", h.FinishLogin).Methods("POST")
|
||||
api.HandleFunc("/logout", h.Logout).Methods("POST")
|
||||
api.HandleFunc("/users", h.ListUsers).Methods("GET")
|
||||
api.HandleFunc("/users", h.CreateUser).Methods("POST")
|
||||
api.HandleFunc("/users/{id}", h.UpdateUser).Methods("PUT")
|
||||
api.HandleFunc("/users/{id}", h.DeleteUser).Methods("DELETE")
|
||||
|
||||
// Nginx auth backend endpoint
|
||||
router.HandleFunc("/auth", h.AuthCheck).Methods("GET", "HEAD")
|
||||
|
||||
// Health check
|
||||
router.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
|
||||
}).Methods("GET")
|
||||
|
||||
// Static files for admin UI
|
||||
router.PathPrefix("/").Handler(http.FileServer(http.Dir("./web/"))).Methods("GET")
|
||||
|
||||
// Setup CORS
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
AllowCredentials: true,
|
||||
})
|
||||
|
||||
handler := c.Handler(router)
|
||||
|
||||
// Start server
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
logrus.Infof("Starting server on port %s", port)
|
||||
if err := http.ListenAndServe(":"+port, handler); err != nil {
|
||||
log.Fatalf("Server failed to start: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHealthEndpoint(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", "/health", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status": "healthy"}`))
|
||||
})
|
||||
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("handler returned wrong status code: got %v want %v",
|
||||
status, http.StatusOK)
|
||||
}
|
||||
|
||||
expected := `{"status": "healthy"}`
|
||||
if rr.Body.String() != expected {
|
||||
t.Errorf("handler returned unexpected body: got %v want %v",
|
||||
rr.Body.String(), expected)
|
||||
}
|
||||
}
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Building Passkey Auth..."
|
||||
|
||||
# Build Docker image
|
||||
echo "Building Docker image..."
|
||||
docker build -t passkey-auth:latest .
|
||||
|
||||
echo "✅ Build complete!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Update k8s/deployment.yaml with your domain and session secret"
|
||||
echo "2. Run ./scripts/deploy.sh to deploy to Kubernetes"
|
||||
echo "3. Configure your nginx ingress to use passkey auth"
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Deploying Passkey Auth to Kubernetes..."
|
||||
|
||||
# Check if kubectl is available
|
||||
if ! command -v kubectl &> /dev/null; then
|
||||
echo "❌ kubectl is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create namespace
|
||||
echo "Creating namespace..."
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
|
||||
# Deploy the application
|
||||
echo "Deploying application..."
|
||||
kubectl apply -f k8s/deployment.yaml
|
||||
|
||||
# Wait for deployment to be ready
|
||||
echo "Waiting for deployment to be ready..."
|
||||
kubectl wait --for=condition=available --timeout=300s deployment/passkey-auth -n passkey-auth
|
||||
|
||||
echo "✅ Deployment complete!"
|
||||
echo ""
|
||||
echo "To check the status:"
|
||||
echo " kubectl get pods -n passkey-auth"
|
||||
echo ""
|
||||
echo "To view logs:"
|
||||
echo " kubectl logs -f deployment/passkey-auth -n passkey-auth"
|
||||
echo ""
|
||||
echo "To configure nginx ingress, see k8s/ingress-example.yaml"
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔧 Starting Passkey Auth in development mode..."
|
||||
|
||||
# Check if Go is installed
|
||||
if ! command -v go &> /dev/null; then
|
||||
echo "❌ Go is not installed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install dependencies
|
||||
echo "Installing dependencies..."
|
||||
go mod download
|
||||
|
||||
# Set development environment variables
|
||||
export PORT=8080
|
||||
export WEBAUTHN_RP_ID=localhost
|
||||
export DATABASE_PATH=./dev-passkey-auth.db
|
||||
export SESSION_SECRET=dev-secret-not-for-production
|
||||
|
||||
echo "🚀 Starting server..."
|
||||
echo "Access the admin interface at: http://localhost:8080"
|
||||
echo "Press Ctrl+C to stop"
|
||||
|
||||
# Run the application
|
||||
go run main.go
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧪 Testing Passkey Auth Build..."
|
||||
|
||||
# Clean previous builds
|
||||
rm -f bin/passkey-auth
|
||||
|
||||
# Test build
|
||||
echo "Building application..."
|
||||
go build -o bin/passkey-auth .
|
||||
|
||||
if [ -f "bin/passkey-auth" ]; then
|
||||
echo "✅ Build successful!"
|
||||
echo "📦 Binary size: $(du -h bin/passkey-auth | cut -f1)"
|
||||
else
|
||||
echo "❌ Build failed!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test basic functionality
|
||||
echo ""
|
||||
echo "🔍 Testing basic configuration..."
|
||||
|
||||
# Create test config
|
||||
cat > test-config.yaml << EOF
|
||||
server:
|
||||
port: "8080"
|
||||
host: "localhost"
|
||||
|
||||
webauthn:
|
||||
rp_display_name: "Test Auth"
|
||||
rp_id: "localhost"
|
||||
rp_origins:
|
||||
- "http://localhost:8080"
|
||||
|
||||
database:
|
||||
path: "test.db"
|
||||
|
||||
cors:
|
||||
allowed_origins:
|
||||
- "*"
|
||||
|
||||
auth:
|
||||
session_secret: "test-secret"
|
||||
require_approval: false
|
||||
allowed_emails:
|
||||
- "test@example.com"
|
||||
- "admin@example.com"
|
||||
EOF
|
||||
|
||||
echo "✅ Test configuration created"
|
||||
|
||||
# Clean up
|
||||
rm -f test-config.yaml test.db
|
||||
|
||||
echo ""
|
||||
echo "🎉 All tests passed!"
|
||||
echo ""
|
||||
echo "To run the application:"
|
||||
echo " ./bin/passkey-auth"
|
||||
echo ""
|
||||
echo "To run in development mode:"
|
||||
echo " ./scripts/dev.sh"
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "🧹 Undeploying Passkey Auth from Kubernetes..."
|
||||
|
||||
# Delete the application
|
||||
echo "Deleting application..."
|
||||
kubectl delete -f k8s/deployment.yaml --ignore-not-found=true
|
||||
|
||||
# Delete namespace (this will also delete PVC - data will be lost!)
|
||||
read -p "⚠️ This will delete all data. Are you sure? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
kubectl delete -f k8s/namespace.yaml --ignore-not-found=true
|
||||
echo "✅ Undeployment complete!"
|
||||
else
|
||||
echo "❌ Cancelled"
|
||||
fi
|
||||
+701
@@ -0,0 +1,701 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Passkey Auth - Admin</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
color: #333;
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: #666;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
margin-bottom: 2rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.tab {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 1rem 2rem;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
color: #666;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: #667eea;
|
||||
border-bottom-color: #667eea;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"], input[type="email"] {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 2px solid #eee;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
input[type="text"]:focus, input[type="email"]:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a24 100%);
|
||||
}
|
||||
|
||||
.users-list {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.user-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.user-info h3 {
|
||||
color: #333;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.user-info p {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.status-approved {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🔐 Passkey Auth</h1>
|
||||
<p>Admin Dashboard</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="showTab('register')">Register User</button>
|
||||
<button class="tab" onclick="showTab('login')">Test Login</button>
|
||||
<button class="tab" onclick="showTab('users')">Manage Users</button>
|
||||
</div>
|
||||
|
||||
<!-- Register Tab -->
|
||||
<div id="register" class="tab-content active">
|
||||
<h2>Register New User</h2>
|
||||
<form id="registerForm">
|
||||
<div class="form-group">
|
||||
<label for="email">Email Address:</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="displayName">Display Name:</label>
|
||||
<input type="text" id="displayName" name="displayName" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">Register with Passkey</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Login Tab -->
|
||||
<div id="login" class="tab-content">
|
||||
<h2>Test Login</h2>
|
||||
<form id="loginForm">
|
||||
<div class="form-group">
|
||||
<label for="loginEmail">Email Address:</label>
|
||||
<input type="email" id="loginEmail" name="email" required>
|
||||
</div>
|
||||
<button type="submit" class="btn">Login with Passkey</button>
|
||||
</form>
|
||||
<div id="loginStatus"></div>
|
||||
</div>
|
||||
|
||||
<!-- Users Tab -->
|
||||
<div id="users" class="tab-content">
|
||||
<h2>Manage Users</h2>
|
||||
<button class="btn" onclick="loadUsers()">Refresh Users</button>
|
||||
<div id="usersList" class="users-list">
|
||||
<div class="loading">Loading users...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="alerts"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Base64/Base64URL decoding functions for WebAuthn
|
||||
function base64ToArrayBuffer(base64) {
|
||||
if (!base64 || typeof base64 !== 'string') {
|
||||
console.error('Invalid base64 input:', base64);
|
||||
throw new Error('Invalid base64 input');
|
||||
}
|
||||
|
||||
console.log('Converting base64 to ArrayBuffer:', base64);
|
||||
|
||||
try {
|
||||
// If it looks like base64url, convert to base64 first
|
||||
let base64String = base64;
|
||||
if (base64.includes('-') || base64.includes('_')) {
|
||||
// This is base64url, convert to base64
|
||||
base64String = base64.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Add padding if needed
|
||||
const padding = base64String.length % 4;
|
||||
if (padding) {
|
||||
base64String += '='.repeat(4 - padding);
|
||||
}
|
||||
} else {
|
||||
// This is standard base64, add padding if needed
|
||||
const padding = base64String.length % 4;
|
||||
if (padding) {
|
||||
base64String += '='.repeat(4 - padding);
|
||||
}
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
const binary = atob(base64String);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
console.log('Converted to ArrayBuffer, length:', bytes.buffer.byteLength);
|
||||
return bytes.buffer;
|
||||
} catch (error) {
|
||||
console.error('Error converting base64 to ArrayBuffer:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// For backwards compatibility, keep the old function name but make it handle both
|
||||
function base64urlToArrayBuffer(base64url) {
|
||||
return base64ToArrayBuffer(base64url);
|
||||
}
|
||||
|
||||
function arrayBufferToBase64url(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
} // Convert WebAuthn options from base64url to ArrayBuffer
|
||||
function prepareWebAuthnOptions(options) {
|
||||
console.log('prepareWebAuthnOptions called with:', options);
|
||||
|
||||
// Handle both registration and login options (both use publicKey wrapper)
|
||||
if (options.publicKey) {
|
||||
console.log('Processing WebAuthn options (has publicKey)');
|
||||
const publicKey = { ...options.publicKey };
|
||||
|
||||
// Convert challenge (common to both registration and login)
|
||||
if (publicKey.challenge) {
|
||||
console.log('Original challenge:', publicKey.challenge, 'type:', typeof publicKey.challenge);
|
||||
if (typeof publicKey.challenge === 'string') {
|
||||
publicKey.challenge = base64ToArrayBuffer(publicKey.challenge);
|
||||
console.log('Converted challenge to ArrayBuffer, byteLength:', publicKey.challenge.byteLength);
|
||||
} else {
|
||||
console.error('Challenge is not a string:', publicKey.challenge);
|
||||
throw new Error('Challenge must be a base64 or base64url string');
|
||||
}
|
||||
}
|
||||
|
||||
// Convert user ID (registration only)
|
||||
if (publicKey.user && publicKey.user.id) {
|
||||
console.log('Original user ID:', publicKey.user.id, 'type:', typeof publicKey.user.id);
|
||||
if (typeof publicKey.user.id === 'string') {
|
||||
publicKey.user.id = base64ToArrayBuffer(publicKey.user.id);
|
||||
console.log('Converted user ID to ArrayBuffer, byteLength:', publicKey.user.id.byteLength);
|
||||
} else {
|
||||
console.error('User ID is not a string:', publicKey.user.id);
|
||||
throw new Error('User ID must be a base64 or base64url string');
|
||||
}
|
||||
}
|
||||
|
||||
// Convert excludeCredentials (registration)
|
||||
if (publicKey.excludeCredentials) {
|
||||
console.log('Converting excludeCredentials for registration');
|
||||
publicKey.excludeCredentials = publicKey.excludeCredentials.map(cred => ({
|
||||
...cred,
|
||||
id: base64ToArrayBuffer(cred.id)
|
||||
}));
|
||||
}
|
||||
|
||||
// Convert allowCredentials (login/authentication)
|
||||
if (publicKey.allowCredentials) {
|
||||
console.log('Converting allowCredentials for login:', publicKey.allowCredentials);
|
||||
publicKey.allowCredentials = publicKey.allowCredentials.map(cred => {
|
||||
console.log('Converting credential ID:', cred.id, 'type:', typeof cred.id);
|
||||
return {
|
||||
...cred,
|
||||
id: base64ToArrayBuffer(cred.id)
|
||||
};
|
||||
});
|
||||
console.log('Converted allowCredentials:', publicKey.allowCredentials);
|
||||
}
|
||||
|
||||
const result = { publicKey };
|
||||
console.log('Returning prepared WebAuthn options:', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
console.log('No publicKey property found, returning as-is');
|
||||
return options;
|
||||
}
|
||||
|
||||
// Convert WebAuthn response from ArrayBuffer to base64url
|
||||
function prepareWebAuthnResponse(credential) {
|
||||
console.log('prepareWebAuthnResponse input:', credential);
|
||||
|
||||
const response = {
|
||||
id: '',
|
||||
rawId: '',
|
||||
type: credential.type || 'public-key',
|
||||
response: {}
|
||||
};
|
||||
|
||||
// Convert credential ID
|
||||
if (credential.rawId) {
|
||||
const credentialId = arrayBufferToBase64url(credential.rawId);
|
||||
response.id = credentialId;
|
||||
response.rawId = credentialId;
|
||||
console.log('Converted credential ID:', credentialId);
|
||||
} else if (credential.id) {
|
||||
// Some browsers might provide id as string already
|
||||
response.id = credential.id;
|
||||
response.rawId = credential.id;
|
||||
console.log('Using existing credential ID:', credential.id);
|
||||
}
|
||||
|
||||
// Convert response data
|
||||
if (credential.response) {
|
||||
console.log('Processing credential.response:', credential.response);
|
||||
|
||||
if (credential.response.clientDataJSON) {
|
||||
response.response.clientDataJSON = arrayBufferToBase64url(credential.response.clientDataJSON);
|
||||
console.log('Converted clientDataJSON');
|
||||
}
|
||||
|
||||
// For registration (attestationObject)
|
||||
if (credential.response.attestationObject) {
|
||||
response.response.attestationObject = arrayBufferToBase64url(credential.response.attestationObject);
|
||||
console.log('Converted attestationObject');
|
||||
}
|
||||
|
||||
// For authentication (authenticatorData, signature)
|
||||
if (credential.response.authenticatorData) {
|
||||
response.response.authenticatorData = arrayBufferToBase64url(credential.response.authenticatorData);
|
||||
console.log('Converted authenticatorData');
|
||||
}
|
||||
|
||||
if (credential.response.signature) {
|
||||
response.response.signature = arrayBufferToBase64url(credential.response.signature);
|
||||
console.log('Converted signature');
|
||||
}
|
||||
|
||||
if (credential.response.userHandle) {
|
||||
response.response.userHandle = arrayBufferToBase64url(credential.response.userHandle);
|
||||
console.log('Converted userHandle');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Final prepared response:', response);
|
||||
return response;
|
||||
}
|
||||
|
||||
// Tab functionality
|
||||
function showTab(tabName) {
|
||||
// Hide all tab contents
|
||||
document.querySelectorAll('.tab-content').forEach(content => {
|
||||
content.classList.remove('active');
|
||||
});
|
||||
|
||||
// Remove active class from all tabs
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show selected tab content
|
||||
document.getElementById(tabName).classList.add('active');
|
||||
|
||||
// Add active class to clicked tab
|
||||
event.target.classList.add('active');
|
||||
|
||||
// Load users when users tab is selected
|
||||
if (tabName === 'users') {
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
|
||||
// Alert functions
|
||||
function showAlert(message, type = 'success') {
|
||||
const alertsContainer = document.getElementById('alerts');
|
||||
const alert = document.createElement('div');
|
||||
alert.className = `alert alert-${type}`;
|
||||
alert.textContent = message;
|
||||
alertsContainer.appendChild(alert);
|
||||
|
||||
setTimeout(() => {
|
||||
alert.remove();
|
||||
}, 5000);
|
||||
} // Register functionality
|
||||
document.getElementById('registerForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const email = formData.get('email');
|
||||
const displayName = formData.get('displayName');
|
||||
|
||||
try {
|
||||
console.log('Starting registration for email:', email);
|
||||
|
||||
// Begin registration
|
||||
const beginResponse = await fetch('/api/register/begin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: email,
|
||||
display_name: displayName
|
||||
}),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!beginResponse.ok) {
|
||||
throw new Error(await beginResponse.text());
|
||||
}
|
||||
|
||||
const options = await beginResponse.json();
|
||||
console.log('Raw options from server:', JSON.stringify(options, null, 2));
|
||||
|
||||
// Convert base64url encoded fields to ArrayBuffers
|
||||
const webAuthnOptions = prepareWebAuthnOptions(options);
|
||||
console.log('Final webAuthnOptions for navigator.credentials.create:', webAuthnOptions);
|
||||
|
||||
// Create credential
|
||||
const credential = await navigator.credentials.create(webAuthnOptions);
|
||||
console.log('Raw credential from navigator.credentials.create:', credential);
|
||||
|
||||
// Convert ArrayBuffers back to base64url for JSON
|
||||
const credentialResponse = prepareWebAuthnResponse(credential);
|
||||
console.log('Prepared credential response to send to server:', JSON.stringify(credentialResponse, null, 2));
|
||||
|
||||
// Finish registration
|
||||
const finishResponse = await fetch('/api/register/finish', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentialResponse),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
console.log('Finish registration response status:', finishResponse.status);
|
||||
if (!finishResponse.ok) {
|
||||
const errorText = await finishResponse.text();
|
||||
console.error('Finish registration error:', errorText);
|
||||
throw new Error(errorText);
|
||||
}
|
||||
|
||||
showAlert('User registered successfully!');
|
||||
e.target.reset();
|
||||
} catch (error) {
|
||||
showAlert(`Registration failed: ${error.message}`, 'error');
|
||||
}
|
||||
}); // Login functionality
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
const email = formData.get('email');
|
||||
|
||||
try {
|
||||
// Begin login
|
||||
const beginResponse = await fetch('/api/login/begin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: email
|
||||
}),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!beginResponse.ok) {
|
||||
throw new Error(await beginResponse.text());
|
||||
}
|
||||
|
||||
const options = await beginResponse.json();
|
||||
console.log('Raw login options from server:', JSON.stringify(options, null, 2));
|
||||
|
||||
// Convert base64url encoded fields to ArrayBuffers
|
||||
const webAuthnOptions = prepareWebAuthnOptions(options);
|
||||
console.log('Final login webAuthnOptions for navigator.credentials.get:', webAuthnOptions);
|
||||
|
||||
// Get credential
|
||||
const credential = await navigator.credentials.get(webAuthnOptions);
|
||||
|
||||
// Convert ArrayBuffers back to base64url for JSON
|
||||
const credentialResponse = prepareWebAuthnResponse(credential);
|
||||
|
||||
// Finish login
|
||||
const finishResponse = await fetch('/api/login/finish', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(credentialResponse),
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!finishResponse.ok) {
|
||||
throw new Error(await finishResponse.text());
|
||||
}
|
||||
|
||||
const result = await finishResponse.json();
|
||||
showAlert(`Login successful! Welcome, ${result.user.display_name}`);
|
||||
|
||||
document.getElementById('loginStatus').innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
<strong>Logged in as:</strong> ${result.user.display_name} (${result.user.email})
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
showAlert(`Login failed: ${error.message}`, 'error');
|
||||
document.getElementById('loginStatus').innerHTML = `
|
||||
<div class="alert alert-error">
|
||||
Login failed: ${error.message}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// Users management
|
||||
async function loadUsers() {
|
||||
const usersList = document.getElementById('usersList');
|
||||
usersList.innerHTML = '<div class="loading">Loading users...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/users');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load users');
|
||||
}
|
||||
|
||||
const users = await response.json();
|
||||
|
||||
if (users.length === 0) {
|
||||
usersList.innerHTML = '<p>No users found.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
usersList.innerHTML = users.map(user => `
|
||||
<div class="user-item">
|
||||
<div class="user-info">
|
||||
<h3>${user.display_name}</h3>
|
||||
<p>${user.email} • Created: ${new Date(user.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<span class="status-badge ${user.approved ? 'status-approved' : 'status-pending'}">
|
||||
${user.approved ? 'Approved' : 'Pending'}
|
||||
</span>
|
||||
${!user.approved ? `<button class="btn" onclick="approveUser(${user.id})">Approve</button>` : ''}
|
||||
<button class="btn btn-danger" onclick="deleteUser(${user.id})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
usersList.innerHTML = `<div class="alert alert-error">Failed to load users: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function approveUser(userId) {
|
||||
if (!confirm('Are you sure you want to approve this user?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
approved: true
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to approve user');
|
||||
}
|
||||
|
||||
showAlert('User approved successfully', 'success');
|
||||
loadUsers(); // Refresh the users list
|
||||
} catch (error) {
|
||||
showAlert(`Failed to approve user: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userId) {
|
||||
if (!confirm('Are you sure you want to delete this user?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/users/${userId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete user');
|
||||
}
|
||||
|
||||
showAlert('User deleted successfully!');
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
showAlert(`Failed to delete user: ${error.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Load users when page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadUsers();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user