Browse Source

CSRF, Initial Setup and Admin User Creation

master
Noah Petherbridge 10 months ago
parent
commit
9348050b4c
19 changed files with 410 additions and 26 deletions
  1. +2
    -0
      go.mod
  2. +6
    -0
      go.sum
  3. +8
    -0
      pkg/constants/constants.go
  4. +7
    -0
      pkg/constants/csrf.go
  5. +0
    -10
      pkg/controllers/index.go
  6. +53
    -2
      pkg/controllers/initial_setup.go
  7. +2
    -2
      pkg/controllers/static_files.go
  8. +48
    -0
      pkg/middleware/csrf.go
  9. +72
    -0
      pkg/models/app_settings.go
  10. +1
    -0
      pkg/models/models.go
  11. +62
    -6
      pkg/models/users.go
  12. +25
    -1
      pkg/responses/errors.go
  13. +27
    -0
      pkg/responses/template_functions.go
  14. +5
    -4
      pkg/responses/templates.go
  15. +3
    -0
      pkg/routes.go
  16. +11
    -0
      pvt-www/_builtin/error.gohtml
  17. +6
    -0
      pvt-www/_builtin/errors/404.gohtml
  18. +62
    -1
      pvt-www/_builtin/initial_setup.gohtml
  19. +10
    -0
      pvt-www/about.md

+ 2
- 0
go.mod View File

@@ -6,5 +6,7 @@ require (
github.com/gorilla/mux v1.7.3
github.com/jinzhu/gorm v1.9.11
github.com/kirsle/blog v0.0.0-20191022175051-d78814b9c99b
github.com/satori/go.uuid v1.2.0
github.com/urfave/negroni v1.0.0
golang.org/x/crypto v0.0.0-20190618222545-ea8f1a30c443
)

+ 6
- 0
go.sum View File

@@ -35,15 +35,19 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/feeds v1.1.1/go.mod h1:Nk0jZrvPFZX1OBe5NPiddPw7CfwF6Q9eqzaBbaightA=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU=
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@@ -88,6 +92,8 @@ github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0=
github.com/shurcooL/highlight_diff v0.0.0-20181222201841-111da2e7d480/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU=


+ 8
- 0
pkg/constants/constants.go View File

@@ -0,0 +1,8 @@
package constants

// Misc constants.
const (
// Password values
PasswordMinLength = 8
BcryptCost = 14
)

+ 7
- 0
pkg/constants/csrf.go View File

@@ -0,0 +1,7 @@
package constants

// CSRF protection constants.
const (
CSRFCookieName = "csrf_token"
CSRFFormName = "_csrf"
)

+ 0
- 10
pkg/controllers/index.go View File

@@ -9,16 +9,6 @@ import (
)

func init() {
glue.Register(glue.Endpoint{
Path: "/about",
Middleware: []mux.MiddlewareFunc{
middleware.ExampleMiddleware,
},
Handler: func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("About Site"))
},
})

glue.Register(glue.Endpoint{
Path: "/admin",
Middleware: []mux.MiddlewareFunc{


+ 53
- 2
pkg/controllers/initial_setup.go View File

@@ -1,22 +1,73 @@
package controllers

import (
"errors"
"fmt"
"net/http"

"git.kirsle.net/apps/gophertype/pkg/constants"
"git.kirsle.net/apps/gophertype/pkg/glue"
"git.kirsle.net/apps/gophertype/pkg/middleware"
"git.kirsle.net/apps/gophertype/pkg/models"
"git.kirsle.net/apps/gophertype/pkg/responses"
"github.com/gorilla/mux"
)

func init() {
glue.Register(glue.Endpoint{
Path: "/admin/setup",
Path: "/admin/setup",
Methods: []string{"GET", "POST"},
Middleware: []mux.MiddlewareFunc{
middleware.ExampleMiddleware,
},
Handler: func(w http.ResponseWriter, r *http.Request) {
responses.RenderTemplate(w, "_builtin/initial_setup.gohtml", nil)
// See if we already have an admin account.
if _, err := models.FirstAdmin(); err == nil {
responses.Panic(w, http.StatusForbidden, "This site is already initialized.")
return
}

// Template variables.
v := map[string]interface{}{}

// POST handler: create the admin account.
if r.Method == http.MethodPost {
var (
username = r.FormValue("username")
displayName = r.FormValue("name")
password = r.FormValue("password")
password2 = r.FormValue("password2")
)

// Username and display name validation happens in CreateUser.
// Validate the passwords match here.
if len(password) < constants.PasswordMinLength {
v["Error"] = fmt.Errorf("your password is too short (must be %d+ characters)", constants.PasswordMinLength)
}
if password != password2 {
v["Error"] = errors.New("your passwords don't match")
} else {
admin := models.User{
Username: username,
Name: displayName,
IsAdmin: true,
}
admin.SetPassword(password)

if err := models.CreateUser(admin); err != nil {
v["Error"] = err
} else {
// Admin created! Make the default config.
cfg := models.GetSettings()
cfg.Save()

w.Write([]byte("Success"))
return
}
}
}

responses.RenderTemplate(w, r, "_builtin/initial_setup.gohtml", v)
},
})



+ 2
- 2
pkg/controllers/static_files.go View File

@@ -18,7 +18,7 @@ func CatchAllHandler(w http.ResponseWriter, r *http.Request) {
// Resolve the target path.
filepath, err := responses.ResolveFile(path)
if err != nil {
responses.Panic(w, http.StatusNotFound, "ResolveFile: "+err.Error())
responses.NotFound(w, r)
return
}

@@ -31,7 +31,7 @@ func CatchAllHandler(w http.ResponseWriter, r *http.Request) {
// Is it a Go template?
if strings.HasSuffix(filepath, ".gohtml") {
log.Printf("Resolved to Go Template path %s", filepath)
responses.RenderTemplate(w, filepath, nil)
responses.RenderTemplate(w, r, filepath, nil)
return
}



+ 48
- 0
pkg/middleware/csrf.go View File

@@ -0,0 +1,48 @@
package middleware

import (
"net/http"
"time"

"git.kirsle.net/apps/gophertype/pkg/constants"
"git.kirsle.net/apps/gophertype/pkg/responses"
uuid "github.com/satori/go.uuid"
)

// CSRF prevents Cross-Site Request Forgery.
// All "POST" requests are required to have an "_csrf" variable passed in which
// matches the "csrf_token" HTTP cookie with their request.
func CSRF(next http.Handler) http.Handler {
middleware := func(w http.ResponseWriter, r *http.Request) {
// All requests: verify they have a CSRF cookie, create one if not.
var token string
cookie, err := r.Cookie(constants.CSRFCookieName)
if err == nil {
token = cookie.Value
}

// Generate a token cookie if not found.
if len(token) < 8 || err != nil {
token = uuid.NewV4().String()
cookie = &http.Cookie{
Name: constants.CSRFCookieName,
Value: token,
Expires: time.Now().Add(24 * time.Hour),
}
http.SetCookie(w, cookie)
}

// POST requests: verify token from form parameter.
if r.Method == http.MethodPost {
compare := r.FormValue(constants.CSRFFormName)
if compare != token {
responses.Panic(w, http.StatusForbidden, "CSRF token failure.")
return
}
}

next.ServeHTTP(w, r)
}

return http.HandlerFunc(middleware)
}

+ 72
- 0
pkg/models/app_settings.go View File

@@ -0,0 +1,72 @@
package models

import (
"crypto/rand"
"encoding/base64"

"github.com/jinzhu/gorm"
)

// AppSetting singleton holds the app configuration.
type AppSetting struct {
gorm.Model

// Site information
Title string
Description string
Email string // primary email for notifications
NSFW bool
BaseURL string

// Blog settings
PostsPerPage int

// Mail settings
MailEnabled bool
MailSender string
MailHost string
MailPort int
MailUsername string
MailPassword string

// Security
SecretKey string `json:"-"`
}

// GetSettings gets or creates the App Settings.
func GetSettings() AppSetting {
var s AppSetting
r := DB.First(&s)
if r.Error != nil {
s = AppSetting{
Title: "Untitled Site",
Description: "Just another web blog.",
SecretKey: MakeSecretKey(),
}
}

if s.SecretKey == "" {
s.SecretKey = MakeSecretKey()
}

return s
}

// Save the settings to DB.
func (s AppSetting) Save() error {
if DB.NewRecord(s) {
r := DB.Create(&s)
return r.Error
}
r := DB.Save(&s)
return r.Error
}

// MakeSecretKey generates a secret key for signing HTTP cookies.
func MakeSecretKey() string {
keyLength := 32

b := make([]byte, keyLength)
rand.Read(b)
return base64.URLEncoding.EncodeToString(b)
}

+ 1
- 0
pkg/models/models.go View File

@@ -8,5 +8,6 @@ var DB *gorm.DB
// UseDB registers a database driver.
func UseDB(db *gorm.DB) {
DB = db
DB.AutoMigrate(&AppSetting{})
DB.AutoMigrate(&User{})
}

+ 62
- 6
pkg/models/users.go View File

@@ -1,11 +1,67 @@
package models

import (
"errors"
"fmt"
"strings"

"git.kirsle.net/apps/gophertype/pkg/constants"
"github.com/jinzhu/gorm"
"golang.org/x/crypto/bcrypt"
)

// User account for the site.
type User struct {
ID int `json:"id"`
Username string `json:"username" gorm:"unique"`
Password string `json:"-"`
IsAdmin bool `json:"isAdmin"`
Name string `json:"name"`
Email string `json:"email"`
gorm.Model
Username string `json:"username" gorm:"unique_index"`
HashedPassword string `json:"-"`
IsAdmin bool `json:"isAdmin" gorm:"index"`
Name string `json:"name"`
Email string `json:"email" gorm:"index"`
}

// Validate the User object has everything filled in. Fixes what it can,
// returns an error if something is wrong. Ensures the HashedPassword is hashed.
func (u *User) Validate() error {
u.Username = strings.TrimSpace(strings.ToLower(u.Username))
u.Name = strings.TrimSpace(strings.ToLower(u.Name))

// Defaults
if len(u.Name) == 0 {
u.Name = u.Username
}

if len(u.Username) == 0 {
return errors.New("username is required")
}
return nil
}

// SetPassword stores the hashed password for a user.
func (u *User) SetPassword(password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), constants.BcryptCost)
if err != nil {
return fmt.Errorf("SetPassword: %s", err)
}

u.HashedPassword = string(hash)
fmt.Printf("Set hashed password: %s", u.HashedPassword)
return nil
}

// FirstAdmin returns the admin user with the lowest ID number.
func FirstAdmin() (User, error) {
var user User
r := DB.First(&user, "is_admin", true)
return user, r.Error
}

// CreateUser adds a new user to the database.
func CreateUser(u User) error {
if err := u.Validate(); err != nil {
return err
}

r := DB.Create(&u)
return r.Error
}

+ 25
- 1
pkg/responses/errors.go View File

@@ -1,9 +1,33 @@
package responses

import "net/http"
import (
"log"
"net/http"
)

// Panic gives a simple error with no template or anything fancy.
func Panic(w http.ResponseWriter, code int, message string) {
w.WriteHeader(code)
w.Write([]byte(message))
}

// Error returns an error page.
func Error(w http.ResponseWriter, r *http.Request, code int, message string) {
v := map[string]interface{}{
"Message": message,
}
w.WriteHeader(code)

if err := RenderTemplate(w, r, "_builtin/errors/generic.gohtml", v); err != nil {
log.Printf("responses.Error: failed to render a pretty error template: %s", err)
w.Write([]byte(message))
}
}

// NotFound returns an HTML 404 page.
func NotFound(w http.ResponseWriter, r *http.Request) {
if err := RenderTemplate(w, r, "_builtin/errors/404.gohtml", nil); err != nil {
log.Printf("responses.NotFound: failed to render a pretty error template: %s", err)
w.Write([]byte("Not Found"))
}
}

+ 27
- 0
pkg/responses/template_functions.go View File

@@ -0,0 +1,27 @@
package responses

import (
"fmt"
"html/template"
"net/http"

"git.kirsle.net/apps/gophertype/pkg/constants"
)

// TemplateFuncs available to all templates.
func TemplateFuncs(r *http.Request) template.FuncMap {
return template.FuncMap{
"CSRF": func() template.HTML {
fmt.Println("CSRF() func called")
token, _ := r.Cookie(constants.CSRFCookieName)
return template.HTML(fmt.Sprintf(
`<input type="hidden" name="%s" value="%s">`,
constants.CSRFFormName,
token.Value,
))
},
"TestFunction": func() template.HTML {
return template.HTML("Testing")
},
}
}

+ 5
- 4
pkg/responses/templates.go View File

@@ -87,21 +87,22 @@ func ResolveFile(path string) (string, error) {

// RenderTemplate renders a Go HTML template.
// The io.Writer can be an http.ResponseWriter.
func RenderTemplate(w http.ResponseWriter, tmpl string, vars interface{}) error {
func RenderTemplate(w http.ResponseWriter, r *http.Request, tmpl string, vars interface{}) error {
w.Header().Set("Content-Type", "text/html; charset=utf-8")

// Look for the built-in template.
if b, err := GetFile(tmpl); err == nil {
t, err := template.New(tmpl).Parse(string(b))
t, err := template.New(tmpl).Funcs(TemplateFuncs(r)).Parse(string(b))
if err != nil {
return fmt.Errorf("bundled template '%s': %s", tmpl, err)
log.Printf("bundled template '%s': %s", tmpl, err)
return err
}

// We found the template. Can we find the layout html?
if layout, err := GetFile(".layout.gohtml"); err == nil {
_, err := t.New("layout").Parse(string(layout))
if err != nil {
fmt.Errorf("RenderTemplate(.layout.gohtml): %s", err)
log.Printf("RenderTemplate(.layout.gohtml): %s", err)
}
} else {
log.Printf("RenderTemplate: .layout.gohtml not found to wrap %s", tmpl)


+ 3
- 0
pkg/routes.go View File

@@ -7,6 +7,7 @@ import (

"git.kirsle.net/apps/gophertype/pkg/controllers"
"git.kirsle.net/apps/gophertype/pkg/glue"
"git.kirsle.net/apps/gophertype/pkg/middleware"
"github.com/gorilla/mux"
)

@@ -14,6 +15,8 @@ import (
func (s *Site) SetupRouter() error {
router := mux.NewRouter()

router.Use(middleware.CSRF)

for _, route := range glue.GetControllers() {
log.Printf("Register: %+v", route)
if len(route.Methods) == 0 {


+ 11
- 0
pvt-www/_builtin/error.gohtml View File

@@ -0,0 +1,11 @@
{{ define "title" }}An error has occurred{{ end }}
{{ define "content" }}
<h1>An error has occurred</h1>

{{ if .Message }}
<div class="banner banner-danger">
{{ .Message }}
</div>
{{ end }}

{{ end }}

+ 6
- 0
pvt-www/_builtin/errors/404.gohtml View File

@@ -0,0 +1,6 @@
{{ define "title" }}Not Found{{ end }}
{{ define "content" }}
<h1>Not Found</h1>

The page you were looking for was not found.
{{ end }}

+ 62
- 1
pvt-www/_builtin/initial_setup.gohtml View File

@@ -1,5 +1,66 @@
{{ define "title" }}Initial Setup{{ end }}

{{ define "content" }}
<h1>Initial Setup</h1>

{{ if .Error }}
<div class="banner banner-danger mb-4">
<strong>Error:</strong> {{ .Error }}
</div>
{{ end }}

<p>
Welcome to Gophertype! Fill out the basic configuration below to set up the app.
</p>

<form method="POST" action="/admin/setup">
{{ CSRF }}
<div class="card mb-4">
<div class="card-header">
Create Administrator Login
</div>
<div class="card-body">
<div class="form-row">
<div class="form-group col-md-6">
<label for="username">Username<span class="text-danger">*</span>:</label>
<input type="text" class="form-control"
name="username"
id="username"
placeholder="Admin"
required>
</div>
<div class="form-group col-md-6">
<label for="username">Display Name:</label>
<input type="text" class="form-control"
name="name"
id="name"
placeholder="Soandso">
</div>
</div>

<div class="form-row">
<div class="form-group col-md-6">
<label for="password">Password<span class="text-danger">*</span>:</label>
<input type="password" class="form-control"
name="password"
id="password"
required>
</div>
<div class="form-group col-md-6">
<label for="confirm">Confirm<span class="text-danger">*</span>:</label>
<input type="password" class="form-control"
name="password2"
id="confirm"
required>
</div>
</div>

<div class="form-row">
<div class="col">
<button type="submit"
class="btn btn-primary">Continue</button>
</div>
</div>
</div>
</div>
</form>
{{ end }}

+ 10
- 0
pvt-www/about.md View File

@@ -0,0 +1,10 @@
# About Blog

This is a simple web blog and content management system written in Go.

## Features

* Web blog
* Draft, Private Posts
* Page editor
* You can edit any page from the front-end.

Loading…
Cancel
Save