Initial commit: Custom Start Page application with authentication and DynamoDB storage
This commit is contained in:
170
internal/handlers/auth_handler.go
Normal file
170
internal/handlers/auth_handler.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"custom-start-page/internal/auth"
|
||||
)
|
||||
|
||||
// AuthHandler handles authentication-related HTTP requests
|
||||
type AuthHandler struct {
|
||||
oauthService *auth.OAuthService
|
||||
userService *auth.UserService
|
||||
sessionStore SessionStore
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
// SessionStore manages user sessions
|
||||
type SessionStore interface {
|
||||
CreateSession(w http.ResponseWriter, r *http.Request, userID string) error
|
||||
GetUserID(r *http.Request) (string, error)
|
||||
DestroySession(w http.ResponseWriter, r *http.Request) error
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
func NewAuthHandler(oauthService *auth.OAuthService, userService *auth.UserService, sessionStore SessionStore) *AuthHandler {
|
||||
return NewAuthHandlerWithTemplates(oauthService, userService, sessionStore, nil)
|
||||
}
|
||||
|
||||
// NewAuthHandlerWithTemplates creates a new auth handler with custom templates
|
||||
func NewAuthHandlerWithTemplates(oauthService *auth.OAuthService, userService *auth.UserService, sessionStore SessionStore, templates *template.Template) *AuthHandler {
|
||||
if templates == nil {
|
||||
// Parse templates
|
||||
templates = template.Must(template.ParseGlob(filepath.Join("templates", "*.html")))
|
||||
template.Must(templates.ParseGlob(filepath.Join("templates", "layouts", "*.html")))
|
||||
}
|
||||
|
||||
return &AuthHandler{
|
||||
oauthService: oauthService,
|
||||
userService: userService,
|
||||
sessionStore: sessionStore,
|
||||
templates: templates,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleOAuthInitiate initiates the OAuth flow
|
||||
// GET /auth/oauth/:provider
|
||||
func (h *AuthHandler) HandleOAuthInitiate(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract provider from URL path
|
||||
provider := r.PathValue("provider")
|
||||
if provider == "" {
|
||||
http.Error(w, "Provider not specified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate OAuth redirect URL
|
||||
redirectURL, err := h.oauthService.InitiateOAuth(provider)
|
||||
if err != nil {
|
||||
log.Printf("Failed to initiate OAuth: %v", err)
|
||||
http.Error(w, "Failed to initiate OAuth", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to OAuth provider
|
||||
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// HandleOAuthCallback handles the OAuth callback
|
||||
// GET /auth/callback/:provider
|
||||
func (h *AuthHandler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
|
||||
// Extract provider from URL path
|
||||
provider := r.PathValue("provider")
|
||||
if provider == "" {
|
||||
http.Error(w, "Provider not specified", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get code and state from query parameters
|
||||
code := r.URL.Query().Get("code")
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
if code == "" {
|
||||
// Check for error from OAuth provider
|
||||
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
|
||||
log.Printf("OAuth error: %s", errMsg)
|
||||
http.Redirect(w, r, "/login?error=oauth_failed", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Authorization code not provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if state == "" {
|
||||
http.Error(w, "State parameter not provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Exchange code for token
|
||||
token, err := h.oauthService.HandleOAuthCallback(r.Context(), provider, code, state)
|
||||
if err != nil {
|
||||
log.Printf("Failed to handle OAuth callback: %v", err)
|
||||
http.Redirect(w, r, "/login?error=oauth_failed", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Get or create user from OAuth provider
|
||||
user, err := h.userService.GetOrCreateUserFromGoogle(r.Context(), token, h.oauthService.GetGoogleConfig())
|
||||
if err != nil {
|
||||
log.Printf("Failed to get or create user: %v", err)
|
||||
http.Redirect(w, r, "/login?error=user_creation_failed", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Create session
|
||||
if err := h.sessionStore.CreateSession(w, r, user.ID); err != nil {
|
||||
log.Printf("Failed to create session: %v", err)
|
||||
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to dashboard
|
||||
http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// HandleLogout logs out the user
|
||||
// POST /logout
|
||||
func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.sessionStore.DestroySession(w, r); err != nil {
|
||||
log.Printf("Failed to destroy session: %v", err)
|
||||
}
|
||||
|
||||
// Redirect to login page
|
||||
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
// HandleLogin displays the login page
|
||||
// GET /login
|
||||
func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if user is already logged in
|
||||
if userID, err := h.sessionStore.GetUserID(r); err == nil && userID != "" {
|
||||
http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect)
|
||||
return
|
||||
}
|
||||
|
||||
// Get error message if any
|
||||
errorMsg := ""
|
||||
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
||||
switch errParam {
|
||||
case "oauth_failed":
|
||||
errorMsg = "Authentication failed. Please try again."
|
||||
case "user_creation_failed":
|
||||
errorMsg = "Failed to create user account. Please try again."
|
||||
default:
|
||||
errorMsg = "An error occurred. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
// Render login template
|
||||
data := map[string]interface{}{
|
||||
"Error": errorMsg,
|
||||
"OAuthProviders": []map[string]string{}, // Empty for now, can be extended
|
||||
}
|
||||
|
||||
if err := h.templates.ExecuteTemplate(w, "login.html", data); err != nil {
|
||||
log.Printf("Failed to render login template: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
159
internal/handlers/auth_handler_test.go
Normal file
159
internal/handlers/auth_handler_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"custom-start-page/internal/auth"
|
||||
)
|
||||
|
||||
// MockSessionStore is a mock implementation of SessionStore for testing
|
||||
type MockSessionStore struct {
|
||||
userID string
|
||||
shouldError bool
|
||||
}
|
||||
|
||||
func (m *MockSessionStore) CreateSession(w http.ResponseWriter, r *http.Request, userID string) error {
|
||||
m.userID = userID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockSessionStore) GetUserID(r *http.Request) (string, error) {
|
||||
if m.shouldError {
|
||||
return "", http.ErrNoCookie
|
||||
}
|
||||
return m.userID, nil
|
||||
}
|
||||
|
||||
func (m *MockSessionStore) DestroySession(w http.ResponseWriter, r *http.Request) error {
|
||||
m.userID = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockSessionStore) ValidateSession(r *http.Request) bool {
|
||||
return m.userID != ""
|
||||
}
|
||||
|
||||
// createMockTemplate creates a simple mock template for testing
|
||||
func createMockTemplate() *template.Template {
|
||||
tmpl := template.New("login.html")
|
||||
template.Must(tmpl.Parse(`<!DOCTYPE html><html><body>{{if .Error}}<div>{{.Error}}</div>{{end}}<a href="/auth/oauth/google">Login</a></body></html>`))
|
||||
return tmpl
|
||||
}
|
||||
|
||||
// TestHandleLogin_UnauthenticatedUser tests that unauthenticated users see the login page
|
||||
func TestHandleLogin_UnauthenticatedUser(t *testing.T) {
|
||||
// Setup
|
||||
mockSessionStore := &MockSessionStore{shouldError: true}
|
||||
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
|
||||
userService := auth.NewUserService(nil) // nil repo for this test
|
||||
mockTemplate := createMockTemplate()
|
||||
handler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
|
||||
|
||||
// Create request
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Execute
|
||||
handler.HandleLogin(w, req)
|
||||
|
||||
// Assert
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Check that response contains login page elements
|
||||
body := w.Body.String()
|
||||
if body == "" {
|
||||
t.Error("Expected non-empty response body")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleLogin_AuthenticatedUser tests that authenticated users are redirected to dashboard
|
||||
func TestHandleLogin_AuthenticatedUser(t *testing.T) {
|
||||
// Setup
|
||||
mockSessionStore := &MockSessionStore{userID: "test-user-123"}
|
||||
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
|
||||
userService := auth.NewUserService(nil)
|
||||
mockTemplate := createMockTemplate()
|
||||
handler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
|
||||
|
||||
// Create request
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Execute
|
||||
handler.HandleLogin(w, req)
|
||||
|
||||
// Assert
|
||||
if w.Code != http.StatusTemporaryRedirect {
|
||||
t.Errorf("Expected status 307, got %d", w.Code)
|
||||
}
|
||||
|
||||
location := w.Header().Get("Location")
|
||||
if location != "/dashboard" {
|
||||
t.Errorf("Expected redirect to /dashboard, got %s", location)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleLogin_WithError tests that error messages are displayed
|
||||
func TestHandleLogin_WithError(t *testing.T) {
|
||||
// Setup
|
||||
mockSessionStore := &MockSessionStore{shouldError: true}
|
||||
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
|
||||
userService := auth.NewUserService(nil)
|
||||
mockTemplate := createMockTemplate()
|
||||
handler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
|
||||
|
||||
// Create request with error parameter
|
||||
req := httptest.NewRequest(http.MethodGet, "/login?error=oauth_failed", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Execute
|
||||
handler.HandleLogin(w, req)
|
||||
|
||||
// Assert
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Check that response contains error message
|
||||
body := w.Body.String()
|
||||
if body == "" {
|
||||
t.Error("Expected non-empty response body")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleLogout tests that logout destroys session and redirects to login
|
||||
func TestHandleLogout(t *testing.T) {
|
||||
// Setup
|
||||
mockSessionStore := &MockSessionStore{userID: "test-user-123"}
|
||||
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
|
||||
userService := auth.NewUserService(nil)
|
||||
mockTemplate := createMockTemplate()
|
||||
handler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
|
||||
|
||||
// Create request
|
||||
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Execute
|
||||
handler.HandleLogout(w, req)
|
||||
|
||||
// Assert
|
||||
if w.Code != http.StatusTemporaryRedirect {
|
||||
t.Errorf("Expected status 307, got %d", w.Code)
|
||||
}
|
||||
|
||||
location := w.Header().Get("Location")
|
||||
if location != "/login" {
|
||||
t.Errorf("Expected redirect to /login, got %s", location)
|
||||
}
|
||||
|
||||
// Verify session was destroyed
|
||||
if mockSessionStore.userID != "" {
|
||||
t.Error("Expected session to be destroyed")
|
||||
}
|
||||
}
|
||||
58
internal/handlers/dashboard_handler.go
Normal file
58
internal/handlers/dashboard_handler.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"custom-start-page/internal/middleware"
|
||||
)
|
||||
|
||||
// DashboardHandler handles dashboard-related HTTP requests
|
||||
type DashboardHandler struct {
|
||||
templates *template.Template
|
||||
}
|
||||
|
||||
// NewDashboardHandler creates a new dashboard handler
|
||||
func NewDashboardHandler() *DashboardHandler {
|
||||
// Parse templates
|
||||
templates := template.Must(template.ParseGlob(filepath.Join("templates", "*.html")))
|
||||
template.Must(templates.ParseGlob(filepath.Join("templates", "layouts", "*.html")))
|
||||
|
||||
return &DashboardHandler{
|
||||
templates: templates,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleDashboard displays the dashboard page
|
||||
// GET /dashboard
|
||||
func (h *DashboardHandler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
// Get user ID from context (set by auth middleware)
|
||||
userID, ok := middleware.GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Fetch user's pages from database
|
||||
// For now, we'll use mock data
|
||||
pages := []map[string]interface{}{
|
||||
{
|
||||
"ID": "default-page",
|
||||
"Name": "Home",
|
||||
"Active": true,
|
||||
},
|
||||
}
|
||||
|
||||
// Render dashboard template
|
||||
data := map[string]interface{}{
|
||||
"UserID": userID,
|
||||
"Pages": pages,
|
||||
}
|
||||
|
||||
if err := h.templates.ExecuteTemplate(w, "dashboard.html", data); err != nil {
|
||||
log.Printf("Failed to render dashboard template: %v", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
68
internal/handlers/dashboard_handler_test.go
Normal file
68
internal/handlers/dashboard_handler_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"custom-start-page/internal/middleware"
|
||||
)
|
||||
|
||||
// createMockDashboardTemplate creates a simple mock template for testing
|
||||
func createMockDashboardTemplate() *template.Template {
|
||||
tmpl := template.New("dashboard.html")
|
||||
template.Must(tmpl.Parse(`<!DOCTYPE html><html><body><h1>Dashboard</h1><div>User: {{.UserID}}</div></body></html>`))
|
||||
return tmpl
|
||||
}
|
||||
|
||||
// TestHandleDashboard_WithAuthenticatedUser tests that authenticated users see the dashboard
|
||||
func TestHandleDashboard_WithAuthenticatedUser(t *testing.T) {
|
||||
// Setup
|
||||
mockTemplate := createMockDashboardTemplate()
|
||||
handler := &DashboardHandler{
|
||||
templates: mockTemplate,
|
||||
}
|
||||
|
||||
// Create request with user ID in context
|
||||
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
|
||||
ctx := context.WithValue(req.Context(), middleware.GetUserIDContextKey(), "test-user-123")
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Execute
|
||||
handler.HandleDashboard(w, req)
|
||||
|
||||
// Assert
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Check that response contains dashboard content
|
||||
body := w.Body.String()
|
||||
if body == "" {
|
||||
t.Error("Expected non-empty response body")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleDashboard_WithoutUserID tests that requests without user ID fail
|
||||
func TestHandleDashboard_WithoutUserID(t *testing.T) {
|
||||
// Setup
|
||||
mockTemplate := createMockDashboardTemplate()
|
||||
handler := &DashboardHandler{
|
||||
templates: mockTemplate,
|
||||
}
|
||||
|
||||
// Create request without user ID in context
|
||||
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Execute
|
||||
handler.HandleDashboard(w, req)
|
||||
|
||||
// Assert
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("Expected status 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
102
internal/handlers/integration_test.go
Normal file
102
internal/handlers/integration_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"custom-start-page/internal/auth"
|
||||
"custom-start-page/internal/middleware"
|
||||
)
|
||||
|
||||
// TestRedirectFlow_UnauthenticatedToLogin tests that unauthenticated users are redirected to login
|
||||
func TestRedirectFlow_UnauthenticatedToLogin(t *testing.T) {
|
||||
// Setup
|
||||
mockSessionStore := &MockSessionStore{shouldError: true}
|
||||
|
||||
// Create middleware
|
||||
requireAuth := middleware.RequireAuth(mockSessionStore)
|
||||
|
||||
// Create dashboard handler
|
||||
mockDashboardTemplate := createMockDashboardTemplate()
|
||||
dashboardHandler := &DashboardHandler{templates: mockDashboardTemplate}
|
||||
|
||||
// Wrap dashboard handler with auth middleware
|
||||
protectedHandler := requireAuth(http.HandlerFunc(dashboardHandler.HandleDashboard))
|
||||
|
||||
// Create request to dashboard
|
||||
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Execute
|
||||
protectedHandler.ServeHTTP(w, req)
|
||||
|
||||
// Assert - should redirect to login
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Errorf("Expected status 303, got %d", w.Code)
|
||||
}
|
||||
|
||||
location := w.Header().Get("Location")
|
||||
if location != "/login" {
|
||||
t.Errorf("Expected redirect to /login, got %s", location)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedirectFlow_AuthenticatedToDashboard tests that authenticated users accessing login are redirected to dashboard
|
||||
func TestRedirectFlow_AuthenticatedToDashboard(t *testing.T) {
|
||||
// Setup
|
||||
mockSessionStore := &MockSessionStore{userID: "test-user-123"}
|
||||
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
|
||||
userService := auth.NewUserService(nil)
|
||||
mockTemplate := createMockTemplate()
|
||||
authHandler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
|
||||
|
||||
// Create request to login page
|
||||
req := httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Execute
|
||||
authHandler.HandleLogin(w, req)
|
||||
|
||||
// Assert - should redirect to dashboard
|
||||
if w.Code != http.StatusTemporaryRedirect {
|
||||
t.Errorf("Expected status 307, got %d", w.Code)
|
||||
}
|
||||
|
||||
location := w.Header().Get("Location")
|
||||
if location != "/dashboard" {
|
||||
t.Errorf("Expected redirect to /dashboard, got %s", location)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedirectFlow_LogoutToLogin tests that logout redirects to login
|
||||
func TestRedirectFlow_LogoutToLogin(t *testing.T) {
|
||||
// Setup
|
||||
mockSessionStore := &MockSessionStore{userID: "test-user-123"}
|
||||
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
|
||||
userService := auth.NewUserService(nil)
|
||||
mockTemplate := createMockTemplate()
|
||||
authHandler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
|
||||
|
||||
// Create logout request
|
||||
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
// Execute
|
||||
authHandler.HandleLogout(w, req)
|
||||
|
||||
// Assert - should redirect to login
|
||||
if w.Code != http.StatusTemporaryRedirect {
|
||||
t.Errorf("Expected status 307, got %d", w.Code)
|
||||
}
|
||||
|
||||
location := w.Header().Get("Location")
|
||||
if location != "/login" {
|
||||
t.Errorf("Expected redirect to /login, got %s", location)
|
||||
}
|
||||
|
||||
// Verify session was destroyed
|
||||
if mockSessionStore.userID != "" {
|
||||
t.Error("Expected session to be destroyed after logout")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user