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(`