diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index e7806fe..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,73 +0,0 @@ -# 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] - -### Changed -- License changed from MIT to Apache 2.0 for better patent protection and enterprise adoption - -### 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. diff --git a/config.example.yaml b/config.example.yaml index 6b3deb7..c18db6a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -53,6 +53,10 @@ auth: # - "user1@yourcompany.com" # - "user2@yourcompany.com" + # Admin email - user with this email will be auto-approved and can access admin panel + # Can also be set via ADMIN_EMAIL environment variable + admin_email: "" # Set this to your admin email address + # Environment-specific overrides can be set via environment variables: # - PORT: Server port # - HOST: Server host @@ -60,3 +64,4 @@ auth: # - DATABASE_PATH: Database file path # - SESSION_SECRET: Session encryption secret # - ALLOWED_EMAILS: Comma-separated list of allowed emails +# - ADMIN_EMAIL: Admin email address diff --git a/config.yaml b/config.yaml index dc57e2e..8732e15 100644 --- a/config.yaml +++ b/config.yaml @@ -22,3 +22,5 @@ auth: allowed_emails: # - "admin@example.com" # - "user@example.com" + # Admin email - user with this email will be auto-approved and can access admin panel + admin_email: "" # Set this to your admin email or use ADMIN_EMAIL environment variable diff --git a/internal/config/config.go b/internal/config/config.go index ebc922f..6cce6d5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,6 +40,7 @@ type AuthConfig struct { SessionSecret string `yaml:"session_secret"` RequireApproval bool `yaml:"require_approval"` AllowedEmails []string `yaml:"allowed_emails"` + AdminEmail string `yaml:"admin_email"` } func Load() (*Config, error) { @@ -113,6 +114,9 @@ func Load() (*Config, error) { config.Auth.AllowedEmails[i] = strings.TrimSpace(email) } } + if adminEmail := os.Getenv("ADMIN_EMAIL"); adminEmail != "" { + config.Auth.AdminEmail = adminEmail + } return config, nil } @@ -135,6 +139,11 @@ func (c *Config) IsEmailAllowed(email string) bool { return false } +// IsAdmin checks if an email address is the admin email +func (c *Config) IsAdmin(email string) bool { + return c.Auth.AdminEmail != "" && email == c.Auth.AdminEmail +} + // validateConfigPath ensures the config path is safe and doesn't allow path traversal func validateConfigPath(path string) error { // Clean the path and check for path traversal attempts diff --git a/internal/database/database.go b/internal/database/database.go index 6299e2f..e3eea0f 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -98,6 +98,23 @@ func (db *DB) CreateUser(email, displayName string) (*User, error) { return db.GetUser(int(id)) } +func (db *DB) CreateUserWithApproval(email, displayName string, approved bool) (*User, error) { + result, err := db.conn.Exec( + "INSERT INTO users (email, display_name, approved) VALUES (?, ?, ?)", + email, displayName, approved, + ) + 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( diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 6571180..ccec451 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -94,13 +94,18 @@ func (h *Handlers) BeginRegistration(w http.ResponseWriter, r *http.Request) { } // Create new user - user, err := h.db.CreateUser(req.Email, req.DisplayName) + isAdmin := h.config.IsAdmin(req.Email) + user, err := h.db.CreateUserWithApproval(req.Email, req.DisplayName, isAdmin) if err != nil { logrus.Errorf("Failed to create user: %v", err) h.writeError(w, "Failed to create user", http.StatusInternalServerError) return } + if isAdmin { + logrus.Infof("Admin user auto-approved: %s", req.Email) + } + webAuthnUser := &auth.WebAuthnUser{} webAuthnUser.SetUser(user) @@ -190,7 +195,17 @@ func (h *Handlers) FinishRegistration(w http.ResponseWriter, r *http.Request) { return } - // Clear session + // Set authenticated session after successful registration + authSession, _ := h.store.Get(r, "auth-session") + authSession.Values["authenticated"] = true + authSession.Values["user_id"] = user.ID + authSession.Values["user_email"] = user.Email + if err := authSession.Save(r, w); err != nil { + h.writeError(w, "Failed to save auth session", http.StatusInternalServerError) + return + } + + // Clear webauthn session session.Values["challenge"] = nil session.Values["user_id"] = nil if err := session.Save(r, w); err != nil { @@ -364,11 +379,55 @@ func (h *Handlers) AuthCheck(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } +// GetAuthStatus returns the current authentication status +func (h *Handlers) GetAuthStatus(w http.ResponseWriter, r *http.Request) { + session, err := h.store.Get(r, "auth-session") + if err != nil { + h.writeError(w, "Failed to get session", http.StatusInternalServerError) + return + } + + userEmail, ok := session.Values["user_email"].(string) + if !ok || userEmail == "" { + h.writeError(w, "Not authenticated", http.StatusUnauthorized) + return + } + + // Get user details from database + user, err := h.db.GetUserByEmail(userEmail) + if err != nil { + h.writeError(w, "User not found", http.StatusNotFound) + return + } + + // Check if user is approved + if !user.Approved { + h.writeError(w, "User not approved", http.StatusForbidden) + return + } + + response := map[string]interface{}{ + "authenticated": true, + "user": map[string]interface{}{ + "id": user.ID, + "email": user.Email, + "display_name": user.DisplayName, + "approved": user.Approved, + "is_admin": h.config.IsAdmin(user.Email), + }, + } + + h.writeJSON(w, response) +} + // Admin endpoints // ListUsers returns all users (admin endpoint) func (h *Handlers) ListUsers(w http.ResponseWriter, r *http.Request) { - // TODO: Add admin authentication check + if !h.requireAdmin(w, r) { + return + } + users, err := h.db.ListUsers() if err != nil { logrus.Errorf("Failed to list users: %v", err) @@ -381,7 +440,10 @@ func (h *Handlers) ListUsers(w http.ResponseWriter, r *http.Request) { // CreateUser creates a new user (admin endpoint) func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) { - // TODO: Add admin authentication check + if !h.requireAdmin(w, r) { + return + } + var req struct { Email string `json:"email"` DisplayName string `json:"display_name"` @@ -399,7 +461,7 @@ func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) { return } - user, err := h.db.CreateUser(req.Email, req.DisplayName) + user, err := h.db.CreateUserWithApproval(req.Email, req.DisplayName, req.Approved) if err != nil { if strings.Contains(err.Error(), "UNIQUE constraint failed") { h.writeError(w, "User already exists", http.StatusConflict) @@ -410,18 +472,15 @@ func (h *Handlers) CreateUser(w http.ResponseWriter, r *http.Request) { 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 + if !h.requireAdmin(w, r) { + return + } + vars := mux.Vars(r) idStr, ok := vars["id"] if !ok { @@ -467,7 +526,10 @@ func (h *Handlers) UpdateUser(w http.ResponseWriter, r *http.Request) { // DeleteUser deletes a user (admin endpoint) func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) { - // TODO: Add admin authentication check + if !h.requireAdmin(w, r) { + return + } + vars := mux.Vars(r) idStr, ok := vars["id"] if !ok { @@ -489,3 +551,25 @@ func (h *Handlers) DeleteUser(w http.ResponseWriter, r *http.Request) { h.writeJSON(w, map[string]string{"status": "success"}) } + +func (h *Handlers) isAdmin(r *http.Request) bool { + session, err := h.store.Get(r, "auth-session") + if err != nil { + return false + } + + userEmail, ok := session.Values["user_email"].(string) + if !ok { + return false + } + + return h.config.IsAdmin(userEmail) +} + +func (h *Handlers) requireAdmin(w http.ResponseWriter, r *http.Request) bool { + if !h.isAdmin(r) { + h.writeError(w, "Admin access required", http.StatusForbidden) + return false + } + return true +} diff --git a/main.go b/main.go index 8622c65..e6a8d7a 100644 --- a/main.go +++ b/main.go @@ -54,6 +54,7 @@ func main() { 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("/auth/status", h.GetAuthStatus).Methods("GET") api.HandleFunc("/users", h.ListUsers).Methods("GET") api.HandleFunc("/users", h.CreateUser).Methods("POST") api.HandleFunc("/users/{id}", h.UpdateUser).Methods("PUT") diff --git a/web/index.html b/web/index.html index b94bf65..aae9eff 100644 --- a/web/index.html +++ b/web/index.html @@ -76,6 +76,41 @@ display: block; } + .auth-panel, .welcome-panel, .admin-panel { + margin-bottom: 2rem; + } + + .auth-toggle { + text-align: center; + margin-top: 1rem; + } + + .link-btn { + background: none; + border: none; + color: #667eea; + cursor: pointer; + text-decoration: underline; + font-size: 0.9rem; + } + + .link-btn:hover { + color: #764ba2; + } + + .welcome-panel { + text-align: center; + } + + .welcome-actions { + margin-top: 1.5rem; + } + + .admin-panel { + border-top: 1px solid #eee; + padding-top: 2rem; + } + .form-group { margin-bottom: 1.5rem; } @@ -207,46 +242,35 @@
Admin Dashboard
+Secure Authentication