Răsfoiți Sursa

Various improvements:

  1. Add double-submit CSRF verification
  2. Use embed for form field tempaltes
  3. Add Sticky form field to restore default field value after
     validators have been processed
  4. Add addtional constructors for hidden form fields and CSRF related
     form fields
Bozhin Zafirov 6 zile în urmă
părinte
comite
9f15d93e05
7 a modificat fișierele cu 127 adăugiri și 13 ștergeri
  1. 26 0
      constructors.go
  2. 57 0
      csrf.go
  3. 1 0
      errors.go
  4. 17 13
      forms.go
  5. 10 0
      templates/field.html
  6. 5 0
      validate.go
  7. 11 0
      validators.go

+ 26 - 0
constructors.go

@@ -1,5 +1,7 @@
 package form
 
+import "net/http"
+
 /* strFromPtr converts string pointer to a string */
 func strFromPtr(str *string) (s string) {
 	if str != nil {
@@ -18,6 +20,30 @@ func NewCharField(Name string, Value *string) *FormField {
 	}
 }
 
+/* Generate a new hidden text field */
+func NewHiddenField(Name string, Value *string) *FormField {
+	return &FormField{
+		Name:  Name,
+		Value: strFromPtr(Value),
+		Class: "form-control",
+		Type:  "hidden",
+	}
+}
+
+/* Generate a new CSRF field */
+func NewCsrfField(w http.ResponseWriter, r *http.Request, secure bool) *FormField {
+	return &FormField{
+		Name:   csrfFieldName,
+		Value:  csrfToken(w, r, secure),
+		Class:  "form-control",
+		Type:   "hidden",
+		Sticky: true,
+		Validators: ValidatorsList{
+			ValidCSRF(r),
+		},
+	}
+}
+
 /* Generate new CharField field with type password */
 func NewPasswordField(Name string) *FormField {
 	field := NewCharField(Name, nil).SetType("password")

+ 57 - 0
csrf.go

@@ -0,0 +1,57 @@
+package form
+
+import (
+	"crypto/rand"
+	"crypto/subtle"
+	"encoding/base64"
+	"net/http"
+	"time"
+)
+
+const (
+	csrfCookieName = "_csrf"
+	csrfFieldName  = "_csrf"
+	csrfTTL        = time.Minute * 30
+	csrfTokenSize  = 32
+)
+
+/* csrfToken generates and configures a new double-submit CSRF token */
+func csrfToken(w http.ResponseWriter, r *http.Request, secure bool) string {
+	/* get csrf cookie */
+	cookie, err := r.Cookie(csrfCookieName)
+	if err != nil {
+		/* cookie does not yet exist, generate a new token */
+		b := make([]byte, csrfTokenSize)
+		rand.Read(b)
+		token := base64.RawURLEncoding.EncodeToString(b)
+		/* render new cookie */
+		http.SetCookie(w, &http.Cookie{
+			Name:     csrfCookieName,
+			Value:    token,
+			Path:     "/",
+			HttpOnly: false,
+			SameSite: http.SameSiteLaxMode,
+			Secure:   secure,
+			MaxAge:   int(csrfTTL.Seconds()),
+		})
+		return token
+	}
+	/* return existing csrf token */
+	return cookie.Value
+}
+
+/* csrfVerify checks if double-submit CSRF token is valid */
+func csrfVerify(r *http.Request) bool {
+	/* get csrf form field value */
+	formToken := r.PostFormValue(csrfFieldName)
+	if formToken == "" {
+		return false
+	}
+	/* get csrf cookie */
+	cookie, err := r.Cookie(csrfCookieName)
+	if err != nil {
+		return false
+	}
+	/* compare the results */
+	return subtle.ConstantTimeCompare([]byte(formToken), []byte(cookie.Value)) == 1
+}

+ 1 - 0
errors.go

@@ -14,4 +14,5 @@ var (
 	EInvalidFloatValue = errors.New("Field value must be float.")
 	ERequiredField     = errors.New("Field is required")
 	EFormHasErrors     = errors.New("Form has errors")
+	EInvalidCSRF       = errors.New("Invalid CSRF token")
 )

+ 17 - 13
forms.go

@@ -3,6 +3,7 @@ package form
 import (
 	"bytes"
 	"context"
+	"embed"
 	"html/template"
 	"strconv"
 )
@@ -25,6 +26,7 @@ type FormField struct {
 	Help        string
 	Required    bool
 	AutoFocus   bool
+	Sticky      bool
 	Validators  ValidatorsList
 }
 
@@ -123,21 +125,23 @@ func (f *FormField) GetChecked() bool {
 }
 
 /* formFieldTemplate is a template to render FormField element in HTML format */
-const formFieldTemplate = `
-	{{ if .Label }}<label class="form-label" for="{{ .Name }}">{{ .Label }}</label>{{ end }}
-	<input type="{{ .Type }}" id="{{ .Name }}" name="{{ .Name }}"
-		{{- if .Class }} class="{{ .Class }}"{{ end }}
-		{{- if and .Value (ne .Type "password") }} value="{{ .Value }}"{{ end }}
-		{{- if .Placeholder}} placeholder="{{ .Placeholder }}"{{ end }}
-		{{- if .Help }} aria-describedby="{{ .Name }}Help"{{ end }}
-		{{- if .Required }} required{{ end }}
-		{{- if .AutoFocus }} autofocus{{ end }}>
-		{{ if .Help }}<div id="{{ .Name }}Help" class="form-text">{{ .Help }}</div>{{ end }}
-	{{ if .Error }}{{ range $e := .Error }}<div class="text-danger">{{ $e }}</div>{{ end }}{{ end }}
-`
+//go:embed templates/*
+var formTemplates embed.FS
+
+/* must checks for compile/start up errors */
+func must(r any, e error) any {
+	if e != nil {
+		panic(e)
+	}
+	return r
+}
 
 /* formTemplate is compiled template to render FormField element in HTML format */
-var formTemplate = template.Must(template.New("FormField").Parse(formFieldTemplate))
+var formTemplate = template.Must(
+	template.New("FormField").Parse(
+		string(must(formTemplates.ReadFile("templates/field.html")).([]byte)),
+	),
+)
 
 /* HTML renders FormField element in html format */
 func (f *FormField) HTML() template.HTML {

+ 10 - 0
templates/field.html

@@ -0,0 +1,10 @@
+{{ if .Label }}<label class="form-label" for="{{ .Name }}">{{ .Label }}</label>{{ end }}
+<input type="{{ .Type }}" id="{{ .Name }}" name="{{ .Name }}"
+	{{- if .Class }} class="{{ .Class }}"{{ end }}
+	{{- if and .Value (ne .Type "password") }} value="{{ .Value }}"{{ end }}
+	{{- if .Placeholder}} placeholder="{{ .Placeholder }}"{{ end }}
+	{{- if .Help }} aria-describedby="{{ .Name }}Help"{{ end }}
+	{{- if .Required }} required{{ end }}
+	{{- if .AutoFocus }} autofocus{{ end }}>
+	{{ if .Help }}<div id="{{ .Name }}Help" class="form-text">{{ .Help }}</div>{{ end }}
+{{ if .Error }}{{ range $e := .Error }}<div class="text-danger">{{ $e }}</div>{{ end }}{{ end }}

+ 5 - 0
validate.go

@@ -48,6 +48,7 @@ func ValidateForm(r *http.Request, p any) (formErr error) {
 		}
 		ff.Error = nil
 		/* store value in *FormField */
+		defaultValue := ff.Value
 		if v, ok := r.PostForm[ff.Name]; ok {
 			ff.Value = v[0]
 		} else {
@@ -74,6 +75,10 @@ func ValidateForm(r *http.Request, p any) (formErr error) {
 				break
 			}
 		}
+		/* restore default value if necessary */
+		if ff.Sticky {
+			ff.Value = defaultValue
+		}
 	}
 	return
 }

+ 11 - 0
validators.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"net/http"
 	"net/mail"
 	"strings"
 	"unicode/utf8"
@@ -227,3 +228,13 @@ func ValidEmail(field *FormField, ctx context.Context) error {
 	}
 	return nil
 }
+
+/* ValidCSRF checks if CSRF token is valid */
+func ValidCSRF(r *http.Request) ValidatorFunc {
+	return func(field *FormField, ctx context.Context) error {
+		if !csrfVerify(r) {
+			return EInvalidCSRF
+		}
+		return nil
+	}
+}