From 56dca44815e8e9c7a8810abf37fa79875f075cd7 Mon Sep 17 00:00:00 2001 From: Sky Johnson Date: Sat, 9 Aug 2025 11:27:26 -0500 Subject: [PATCH] round out authentication --- .gitignore | 4 +- internal/auth/auth.go | 6 +- internal/csrf/csrf.go | 108 ++++++---- internal/middleware/csrf.go | 6 +- internal/routes/auth.go | 376 +++++++++++++++++++++++++++++++++++ internal/routes/doc.go | 13 ++ internal/server/server.go | 68 +++++-- templates/auth/login.html | 19 +- templates/auth/register.html | 36 +--- 9 files changed, 533 insertions(+), 103 deletions(-) create mode 100644 internal/routes/auth.go create mode 100644 internal/routes/doc.go diff --git a/.gitignore b/.gitignore index 6e162f4..93fdc12 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ # Dragon Knight test/build files /dk -/dk.db \ No newline at end of file +/dk.db +/dk.db-* +/sessions.json diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 9281202..aa2c23a 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -39,7 +39,7 @@ func (am *AuthManager) Authenticate(usernameOrEmail, plainPassword string) (*Use } // Verify password - isValid, err := password.Verify(user.Password, plainPassword) + isValid, err := password.Verify(plainPassword, user.Password) if err != nil { return nil, err } @@ -74,6 +74,10 @@ func (am *AuthManager) SessionStats() (total, active int) { return am.sessionStore.Stats() } +func (am *AuthManager) DB() *database.DB { + return am.db +} + func (am *AuthManager) Close() error { return am.sessionStore.Close() } diff --git a/internal/csrf/csrf.go b/internal/csrf/csrf.go index 6a54ada..c24dfe7 100644 --- a/internal/csrf/csrf.go +++ b/internal/csrf/csrf.go @@ -5,9 +5,12 @@ import ( "crypto/subtle" "encoding/base64" "fmt" + "time" "dk/internal/auth" "dk/internal/router" + + "github.com/valyala/fasthttp" ) const ( @@ -15,6 +18,7 @@ const ( TokenFieldName = "_csrf_token" SessionKey = "csrf_token" SessionCtxKey = "session" // Same as middleware.SessionKey + CookieName = "_csrf" ) // GetCurrentSession retrieves the session from context (mirrors middleware function) @@ -25,7 +29,7 @@ func GetCurrentSession(ctx router.Ctx) *auth.Session { return nil } -// GenerateToken creates a new CSRF token and stores it in the session +// GenerateToken creates a new CSRF token and stores it in the session or cookie func GenerateToken(ctx router.Ctx, authManager *auth.AuthManager) string { // Generate cryptographically secure random bytes tokenBytes := make([]byte, TokenLength) @@ -33,49 +37,61 @@ func GenerateToken(ctx router.Ctx, authManager *auth.AuthManager) string { // Fallback - this should never happen in practice return "" } - + token := base64.URLEncoding.EncodeToString(tokenBytes) - - // Store token in session if user is authenticated + + // Store token in session if user is authenticated, otherwise use cookie if session := GetCurrentSession(ctx); session != nil { StoreToken(session, token) + } else { + // Store in cookie for guest users + StoreTokenInCookie(ctx, token) } - + return token } -// GetToken retrieves the current CSRF token from session, generating one if needed +// GetToken retrieves the current CSRF token from session or cookie, generating one if needed func GetToken(ctx router.Ctx, authManager *auth.AuthManager) string { session := GetCurrentSession(ctx) - if session == nil { - return "" // No session, no CSRF protection needed + + if session != nil { + // Authenticated user - check session first + if existingToken := GetStoredToken(session); existingToken != "" { + return existingToken + } + } else { + // Guest user - check cookie first + if existingToken := GetTokenFromCookie(ctx); existingToken != "" { + return existingToken + } } - - // Check if token already exists in session - if existingToken := GetStoredToken(session); existingToken != "" { - return existingToken - } - + // Generate new token if none exists return GenerateToken(ctx, authManager) } -// ValidateToken verifies a CSRF token against the stored session token +// ValidateToken verifies a CSRF token against the stored session or cookie token func ValidateToken(ctx router.Ctx, authManager *auth.AuthManager, submittedToken string) bool { if submittedToken == "" { return false } - + + var storedToken string session := GetCurrentSession(ctx) - if session == nil { - return false // No session means no CSRF protection + + if session != nil { + // Authenticated user - get token from session + storedToken = GetStoredToken(session) + } else { + // Guest user - get token from cookie + storedToken = GetTokenFromCookie(ctx) } - - storedToken := GetStoredToken(session) + if storedToken == "" { return false // No stored token } - + // Use constant-time comparison to prevent timing attacks return subtle.ConstantTimeCompare([]byte(submittedToken), []byte(storedToken)) == 1 } @@ -93,11 +109,11 @@ func GetStoredToken(session *auth.Session) string { if session.Data == nil { return "" } - + if token, ok := session.Data[SessionKey].(string); ok { return token } - + return "" } @@ -107,10 +123,10 @@ func RotateToken(ctx router.Ctx, authManager *auth.AuthManager) string { if session == nil { return "" } - + // Generate new token newToken := GenerateToken(ctx, authManager) - + return newToken } @@ -120,9 +136,9 @@ func HiddenField(ctx router.Ctx, authManager *auth.AuthManager) string { if token == "" { return "" // No token available } - - return fmt.Sprintf(``, - TokenFieldName, escapeHTML(token)) + + return fmt.Sprintf(``, + TokenFieldName, token) } // TokenMeta generates HTML meta tag for JavaScript access to CSRF token @@ -131,17 +147,8 @@ func TokenMeta(ctx router.Ctx, authManager *auth.AuthManager) string { if token == "" { return "" } - - return fmt.Sprintf(``, escapeHTML(token)) -} -// escapeHTML provides basic HTML escaping for token values -func escapeHTML(s string) string { - // Basic HTML escaping - base64 tokens shouldn't need much escaping - // but better safe than sorry - s = fmt.Sprintf("%s", s) // Ensure it's a string - // Base64 URL encoding uses only safe characters, but let's be thorough - return s + return fmt.Sprintf(``, token) } // ValidateFormToken is a convenience function to validate CSRF token from form data @@ -152,10 +159,29 @@ func ValidateFormToken(ctx router.Ctx, authManager *auth.AuthManager) bool { // Try from query parameters as fallback tokenBytes = ctx.QueryArgs().Peek(TokenFieldName) } - + if len(tokenBytes) == 0 { return false } - + return ValidateToken(ctx, authManager, string(tokenBytes)) -} \ No newline at end of file +} + +// StoreTokenInCookie stores a CSRF token in a cookie for guest users +func StoreTokenInCookie(ctx router.Ctx, token string) { + cookie := &fasthttp.Cookie{} + cookie.SetKey(CookieName) + cookie.SetValue(token) + cookie.SetHTTPOnly(true) + cookie.SetSameSite(fasthttp.CookieSameSiteStrictMode) + cookie.SetSecure(false) // Set to true in production with HTTPS + cookie.SetExpire(time.Now().Add(24 * time.Hour)) // Expire in 24 hours + cookie.SetPath("/") + + ctx.Response.Header.SetCookie(cookie) +} + +// GetTokenFromCookie retrieves a CSRF token from cookie for guest users +func GetTokenFromCookie(ctx router.Ctx) string { + return string(ctx.Request.Header.Cookie(CookieName)) +} diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go index f85ab9f..84fcaae 100644 --- a/internal/middleware/csrf.go +++ b/internal/middleware/csrf.go @@ -59,10 +59,8 @@ func CSRF(authManager *auth.AuthManager, config ...CSRFConfig) router.Middleware } } - // Skip CSRF for non-authenticated users (no session) - if !shouldSkip && !IsAuthenticated(ctx) { - shouldSkip = true - } + // CSRF protection now works for both authenticated and guest users + // Remove the skip for non-authenticated users if shouldSkip { next(ctx, params) diff --git a/internal/routes/auth.go b/internal/routes/auth.go new file mode 100644 index 0000000..8d48219 --- /dev/null +++ b/internal/routes/auth.go @@ -0,0 +1,376 @@ +package routes + +import ( + "fmt" + "strings" + + "dk/internal/auth" + "dk/internal/csrf" + "dk/internal/middleware" + "dk/internal/password" + "dk/internal/router" + "dk/internal/template" + "dk/internal/users" + + "github.com/valyala/fasthttp" +) + +// RegisterAuthRoutes sets up authentication routes +func RegisterAuthRoutes(r *router.Router, authManager *auth.AuthManager, templateCache *template.Cache) { + // Guest routes (redirect to dashboard if already authenticated) + guestGroup := r.Group("") + guestGroup.Use(middleware.RequireGuest("/")) + + guestGroup.Get("/login", showLogin(authManager, templateCache)) + guestGroup.Post("/login", processLogin(authManager, templateCache)) + guestGroup.Get("/register", showRegister(authManager, templateCache)) + guestGroup.Post("/register", processRegister(authManager, templateCache)) + + // Authenticated routes + authGroup := r.Group("") + authGroup.Use(middleware.RequireAuth("/login")) + + authGroup.Post("/logout", processLogout(authManager)) +} + +// showLogin displays the login form +func showLogin(authManager *auth.AuthManager, templateCache *template.Cache) router.Handler { + return func(ctx router.Ctx, params []string) { + layoutTmpl, err := templateCache.Load("layout.html") + if err != nil { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + fmt.Fprintf(ctx, "Template error: %v", err) + return + } + + loginTmpl, err := templateCache.Load("auth/login.html") + if err != nil { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + fmt.Fprintf(ctx, "Template error: %v", err) + return + } + + loginFormData := map[string]any{ + "csrf_token": csrf.GetToken(ctx, authManager), + "csrf_field": csrf.HiddenField(ctx, authManager), + "error_message": "", + } + + loginContent := loginTmpl.RenderNamed(loginFormData) + + data := map[string]any{ + "title": "Login - Dragon Knight", + "content": loginContent, + "topnav": "", + "leftside": "", + "rightside": "", + "totaltime": middleware.GetRequestTime(ctx), + "numqueries": "0", + "version": "1.0.0", + "build": "dev", + } + + layoutTmpl.WriteTo(ctx, data) + } +} + +// processLogin handles login form submission +func processLogin(authManager *auth.AuthManager, templateCache *template.Cache) router.Handler { + return func(ctx router.Ctx, params []string) { + // Validate CSRF token + if !csrf.ValidateFormToken(ctx, authManager) { + ctx.SetStatusCode(fasthttp.StatusForbidden) + ctx.WriteString("CSRF validation failed") + return + } + + // Get form values + email := strings.TrimSpace(string(ctx.PostArgs().Peek("email"))) + userPassword := string(ctx.PostArgs().Peek("password")) + + // Validate input + if email == "" || userPassword == "" { + showLoginError(ctx, authManager, templateCache, "Email and password are required") + return + } + + // Authenticate user + user, err := authManager.Authenticate(email, userPassword) + if err != nil { + showLoginError(ctx, authManager, templateCache, "Invalid email or password") + return + } + + // Create session and login + middleware.Login(ctx, authManager, user) + + // Redirect to dashboard + ctx.Redirect("/dashboard", fasthttp.StatusFound) + } +} + +// showRegister displays the registration form +func showRegister(authManager *auth.AuthManager, templateCache *template.Cache) router.Handler { + return func(ctx router.Ctx, params []string) { + layoutTmpl, err := templateCache.Load("layout.html") + if err != nil { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + fmt.Fprintf(ctx, "Template error: %v", err) + return + } + + registerTmpl, err := templateCache.Load("auth/register.html") + if err != nil { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + fmt.Fprintf(ctx, "Template error: %v", err) + return + } + + registerFormData := map[string]any{ + "csrf_token": csrf.GetToken(ctx, authManager), + "csrf_field": csrf.HiddenField(ctx, authManager), + "error_message": "", + "username": "", + "email": "", + } + + registerContent := registerTmpl.RenderNamed(registerFormData) + + data := map[string]any{ + "title": "Register - Dragon Knight", + "content": registerContent, + "topnav": "", + "leftside": "", + "rightside": "", + "totaltime": middleware.GetRequestTime(ctx), + "numqueries": "0", + "version": "1.0.0", + "build": "dev", + } + + layoutTmpl.WriteTo(ctx, data) + } +} + +// processRegister handles registration form submission +func processRegister(authManager *auth.AuthManager, templateCache *template.Cache) router.Handler { + return func(ctx router.Ctx, params []string) { + // Validate CSRF token + if !csrf.ValidateFormToken(ctx, authManager) { + ctx.SetStatusCode(fasthttp.StatusForbidden) + ctx.WriteString("CSRF validation failed") + return + } + + // Get form values + username := strings.TrimSpace(string(ctx.PostArgs().Peek("username"))) + email := strings.TrimSpace(string(ctx.PostArgs().Peek("email"))) + userPassword := string(ctx.PostArgs().Peek("password")) + confirmPassword := string(ctx.PostArgs().Peek("confirm_password")) + + // Validate input + if err := validateRegistration(username, email, userPassword, confirmPassword); err != nil { + showRegisterError(ctx, authManager, templateCache, err.Error(), username, email) + return + } + + // Check if username already exists + if _, err := users.GetByUsername(authManager.DB(), username); err == nil { + showRegisterError(ctx, authManager, templateCache, "Username already exists", username, email) + return + } + + // Check if email already exists + if _, err := users.GetByEmail(authManager.DB(), email); err == nil { + showRegisterError(ctx, authManager, templateCache, "Email already registered", username, email) + return + } + + // Hash password + hashedPassword, err := password.Hash(userPassword) + if err != nil { + showRegisterError(ctx, authManager, templateCache, "Failed to process password", username, email) + return + } + + // Create user (this is a simplified approach - in a real app you'd use a proper user creation function) + user := &users.User{ + Username: username, + Email: email, + Password: hashedPassword, + Verified: 1, // Auto-verify for now + Auth: 1, // Enabled + } + + // Insert into database + if err := createUser(authManager, user); err != nil { + showRegisterError(ctx, authManager, templateCache, "Failed to create account", username, email) + return + } + + // Auto-login after registration + authUser := &auth.User{ + ID: user.ID, + Username: user.Username, + Email: user.Email, + } + + middleware.Login(ctx, authManager, authUser) + + ctx.Redirect("/", fasthttp.StatusFound) + } +} + +// processLogout handles logout +func processLogout(authManager *auth.AuthManager) router.Handler { + return func(ctx router.Ctx, params []string) { + // Validate CSRF token + if !csrf.ValidateFormToken(ctx, authManager) { + ctx.SetStatusCode(fasthttp.StatusForbidden) + ctx.WriteString("CSRF validation failed") + return + } + + middleware.Logout(ctx, authManager) + ctx.Redirect("/", fasthttp.StatusFound) + } +} + +// Helper functions + +func showLoginError(ctx router.Ctx, authManager *auth.AuthManager, templateCache *template.Cache, errorMsg string) { + layoutTmpl, err := templateCache.Load("layout.html") + if err != nil { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + fmt.Fprintf(ctx, "Template error: %v", err) + return + } + + loginTmpl, err := templateCache.Load("auth/login.html") + if err != nil { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + fmt.Fprintf(ctx, "Template error: %v", err) + return + } + + var errorHTML string + if errorMsg != "" { + errorHTML = fmt.Sprintf(`
%s
`, errorMsg) + } + + loginFormData := map[string]any{ + "csrf_token": csrf.GetToken(ctx, authManager), + "csrf_field": csrf.HiddenField(ctx, authManager), + "error_message": errorHTML, + } + + loginContent := loginTmpl.RenderNamed(loginFormData) + + data := map[string]any{ + "title": "Login - Dragon Knight", + "content": loginContent, + "topnav": "", + "leftside": "", + "rightside": "", + "totaltime": middleware.GetRequestTime(ctx), + "numqueries": "0", + "version": "1.0.0", + "build": "dev", + } + + ctx.SetStatusCode(fasthttp.StatusBadRequest) + layoutTmpl.WriteTo(ctx, data) +} + +func showRegisterError(ctx router.Ctx, authManager *auth.AuthManager, templateCache *template.Cache, errorMsg, username, email string) { + layoutTmpl, err := templateCache.Load("layout.html") + if err != nil { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + fmt.Fprintf(ctx, "Template error: %v", err) + return + } + + registerTmpl, err := templateCache.Load("auth/register.html") + if err != nil { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + fmt.Fprintf(ctx, "Template error: %v", err) + return + } + + var errorHTML string + if errorMsg != "" { + errorHTML = fmt.Sprintf(`
%s
`, errorMsg) + } + + registerFormData := map[string]any{ + "csrf_token": csrf.GetToken(ctx, authManager), + "csrf_field": csrf.HiddenField(ctx, authManager), + "error_message": errorHTML, + "username": username, + "email": email, + } + + registerContent := registerTmpl.RenderNamed(registerFormData) + + data := map[string]any{ + "title": "Register - Dragon Knight", + "content": registerContent, + "topnav": "", + "leftside": "", + "rightside": "", + "totaltime": middleware.GetRequestTime(ctx), + "numqueries": "0", + "version": "1.0.0", + "build": "dev", + } + + ctx.SetStatusCode(fasthttp.StatusBadRequest) + layoutTmpl.WriteTo(ctx, data) +} + +func validateRegistration(username, email, password, confirmPassword string) error { + if username == "" { + return fmt.Errorf("username is required") + } + if len(username) < 3 { + return fmt.Errorf("username must be at least 3 characters") + } + if email == "" { + return fmt.Errorf("email is required") + } + if !strings.Contains(email, "@") { + return fmt.Errorf("invalid email address") + } + if password == "" { + return fmt.Errorf("password is required") + } + if len(password) < 6 { + return fmt.Errorf("password must be at least 6 characters") + } + if password != confirmPassword { + return fmt.Errorf("passwords do not match") + } + return nil +} + +// createUser inserts a new user into the database +// This is a simplified version - in a real app you'd have a proper users.Create function +func createUser(authManager *auth.AuthManager, user *users.User) error { + db := authManager.DB() + + query := `INSERT INTO users (username, password, email, verified, auth) VALUES (?, ?, ?, ?, ?)` + + err := db.Exec(query, user.Username, user.Password, user.Email, user.Verified, user.Auth) + if err != nil { + return fmt.Errorf("failed to insert user: %w", err) + } + + // Get the user ID (simplified - in real app you'd return it from insert) + createdUser, err := users.GetByUsername(db, user.Username) + if err != nil { + return fmt.Errorf("failed to get created user: %w", err) + } + + user.ID = createdUser.ID + return nil +} diff --git a/internal/routes/doc.go b/internal/routes/doc.go new file mode 100644 index 0000000..b64645d --- /dev/null +++ b/internal/routes/doc.go @@ -0,0 +1,13 @@ +// Package routes organizes HTTP route handlers for the Dragon Knight application. +// Routes are organized by feature area in separate packages to maintain clean +// separation of concerns and make the codebase more maintainable. +// +// # Structure +// +// - auth/ - Authentication routes (login, register, logout) +// - api/ - API endpoints +// - web/ - Web interface routes +// +// Each route package should provide a Setup function that registers its routes +// with the router and returns any necessary dependencies or configuration. +package routes \ No newline at end of file diff --git a/internal/server/server.go b/internal/server/server.go index 1679ecf..7d7fa35 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -12,6 +12,7 @@ import ( "dk/internal/database" "dk/internal/middleware" "dk/internal/router" + "dk/internal/routes" "dk/internal/template" "github.com/valyala/fasthttp" @@ -26,7 +27,7 @@ func Start(port string) error { templateCache := template.NewCache(cwd) // Initialize database - db, err := database.Open("dk.sqlite") + db, err := database.Open("dk.db") if err != nil { return fmt.Errorf("failed to open database: %w", err) } @@ -34,16 +35,48 @@ func Start(port string) error { // Initialize authentication manager authManager := auth.NewAuthManager(db, "sessions.json") - defer authManager.Close() + // Don't defer Close() here - we'll handle it in shutdown // Initialize router r := router.New() - + // Add middleware r.Use(middleware.Timing()) r.Use(middleware.Auth(authManager)) + r.Use(middleware.CSRF(authManager)) - // Hello world endpoint + // Setup route handlers + routes.RegisterAuthRoutes(r, authManager, templateCache) + + // Dashboard (protected route) + r.WithMiddleware(middleware.RequireAuth("/login")).Get("/dashboard", func(ctx router.Ctx, params []string) { + tmpl, err := templateCache.Load("layout.html") + if err != nil { + ctx.SetStatusCode(fasthttp.StatusInternalServerError) + fmt.Fprintf(ctx, "Template error: %v", err) + return + } + + currentUser := middleware.GetCurrentUser(ctx) + totalSessions, activeSessions := authManager.SessionStats() + + data := map[string]any{ + "title": "Dashboard - Dragon Knight", + "content": fmt.Sprintf("Welcome back, %s!", currentUser.Username), + "totaltime": middleware.GetRequestTime(ctx), + "numqueries": "0", + "version": "1.0.0", + "build": "dev", + "total_sessions": totalSessions, + "active_sessions": activeSessions, + "authenticated": true, + "username": currentUser.Username, + } + + tmpl.WriteTo(ctx, data) + }) + + // Hello world endpoint (public) r.Get("/", func(ctx router.Ctx, params []string) { tmpl, err := templateCache.Load("layout.html") if err != nil { @@ -60,7 +93,7 @@ func Start(port string) error { } else { username = "Guest" } - + totalSessions, activeSessions := authManager.SessionStats() data := map[string]any{ @@ -109,32 +142,35 @@ func Start(port string) error { addr := ":" + port log.Printf("Server starting on %s", addr) - + // Setup graceful shutdown server := &fasthttp.Server{ Handler: requestHandler, } - + // Channel to listen for interrupt signal c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGTERM) - + // Start server in a goroutine go func() { if err := server.ListenAndServe(addr); err != nil { log.Printf("Server error: %v", err) } }() - - // Block until we receive a signal + + // Wait for interrupt signal <-c - log.Println("Shutting down server...") - - // Shutdown server gracefully - if err := server.Shutdown(); err != nil { - log.Printf("Server shutdown error: %v", err) + log.Println("Received shutdown signal, shutting down gracefully...") + + // Save sessions before shutdown + log.Println("Saving sessions...") + if err := authManager.Close(); err != nil { + log.Printf("Error saving sessions: %v", err) } - + + // FastHTTP doesn't have a graceful Shutdown method like net/http + // We just let the server stop naturally when the main function exits log.Println("Server stopped") return nil } diff --git a/templates/auth/login.html b/templates/auth/login.html index a5ec071..224ecc8 100644 --- a/templates/auth/login.html +++ b/templates/auth/login.html @@ -1,30 +1,21 @@ -{flashhtml} +{error_message}
- {csrf} + {csrf_field} - - + + - - - - - +
Username:Email/Username:
Password:
Remember me? Yes
- Checking the "Remember Me" option will store your login information in a cookie - so you don't have to enter it next time you get online. - -

- Want to play? You gotta register your own character.

diff --git a/templates/auth/register.html b/templates/auth/register.html index 8396d81..f66d963 100644 --- a/templates/auth/register.html +++ b/templates/auth/register.html @@ -1,12 +1,12 @@ -{flashhtml} +{error_message} - {csrf} + {csrf_field} - + - - - - - - - - - - -
Username: - +
Usernames must be 30 alphanumeric characters or less.


@@ -14,45 +14,29 @@
Password:
Verify Password: - +
- Passwords must be 10 alphanumeric characters or less. + Passwords must be at least 6 characters.


Email Address:
Verify Email: - - {verifytext} + +
+ A valid email address is required.


Character Class: - -
See Help for more information about character classes.

- +