Prechádzať zdrojové kódy

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 dní pred
rodič
commit
9f15d93e05
7 zmenil súbory, kde vykonal 127 pridanie a 13 odobranie
  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
 package form
 
 
+import "net/http"
+
 /* strFromPtr converts string pointer to a string */
 /* strFromPtr converts string pointer to a string */
 func strFromPtr(str *string) (s string) {
 func strFromPtr(str *string) (s string) {
 	if str != nil {
 	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 */
 /* Generate new CharField field with type password */
 func NewPasswordField(Name string) *FormField {
 func NewPasswordField(Name string) *FormField {
 	field := NewCharField(Name, nil).SetType("password")
 	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.")
 	EInvalidFloatValue = errors.New("Field value must be float.")
 	ERequiredField     = errors.New("Field is required")
 	ERequiredField     = errors.New("Field is required")
 	EFormHasErrors     = errors.New("Form has errors")
 	EFormHasErrors     = errors.New("Form has errors")
+	EInvalidCSRF       = errors.New("Invalid CSRF token")
 )
 )

+ 17 - 13
forms.go

@@ -3,6 +3,7 @@ package form
 import (
 import (
 	"bytes"
 	"bytes"
 	"context"
 	"context"
+	"embed"
 	"html/template"
 	"html/template"
 	"strconv"
 	"strconv"
 )
 )
@@ -25,6 +26,7 @@ type FormField struct {
 	Help        string
 	Help        string
 	Required    bool
 	Required    bool
 	AutoFocus   bool
 	AutoFocus   bool
+	Sticky      bool
 	Validators  ValidatorsList
 	Validators  ValidatorsList
 }
 }
 
 
@@ -123,21 +125,23 @@ func (f *FormField) GetChecked() bool {
 }
 }
 
 
 /* formFieldTemplate is a template to render FormField element in HTML format */
 /* 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 */
 /* 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 */
 /* HTML renders FormField element in html format */
 func (f *FormField) HTML() template.HTML {
 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
 		ff.Error = nil
 		/* store value in *FormField */
 		/* store value in *FormField */
+		defaultValue := ff.Value
 		if v, ok := r.PostForm[ff.Name]; ok {
 		if v, ok := r.PostForm[ff.Name]; ok {
 			ff.Value = v[0]
 			ff.Value = v[0]
 		} else {
 		} else {
@@ -74,6 +75,10 @@ func ValidateForm(r *http.Request, p any) (formErr error) {
 				break
 				break
 			}
 			}
 		}
 		}
+		/* restore default value if necessary */
+		if ff.Sticky {
+			ff.Value = defaultValue
+		}
 	}
 	}
 	return
 	return
 }
 }

+ 11 - 0
validators.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"context"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
+	"net/http"
 	"net/mail"
 	"net/mail"
 	"strings"
 	"strings"
 	"unicode/utf8"
 	"unicode/utf8"
@@ -227,3 +228,13 @@ func ValidEmail(field *FormField, ctx context.Context) error {
 	}
 	}
 	return nil
 	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
+	}
+}