secured editor

This commit is contained in:
Niko Abeler 2023-07-08 13:28:06 +02:00
parent bcf8ba4d9b
commit 197629db9a
15 changed files with 447 additions and 26 deletions

76
app/author_service.go Normal file
View File

@ -0,0 +1,76 @@
package app
import (
"crypto/sha256"
"fmt"
"owl-blogs/app/repository"
"owl-blogs/config"
"owl-blogs/domain/model"
"strings"
"golang.org/x/crypto/bcrypt"
)
type AuthorService struct {
repo repository.AuthorRepository
config config.Config
}
func NewAuthorService(repo repository.AuthorRepository, config config.Config) *AuthorService {
return &AuthorService{repo: repo, config: config}
}
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), 10)
if err != nil {
return "", err
}
return string(bytes), nil
}
func (s *AuthorService) Create(name string, password string) (*model.Author, error) {
hash, err := hashPassword(password)
if err != nil {
return nil, err
}
return s.repo.Create(name, hash)
}
func (s *AuthorService) FindByName(name string) (*model.Author, error) {
return s.repo.FindByName(name)
}
func (s *AuthorService) Authenticate(name string, password string) bool {
author, err := s.repo.FindByName(name)
if err != nil {
return false
}
err = bcrypt.CompareHashAndPassword([]byte(author.PasswordHash), []byte(password))
return err == nil
}
func (s *AuthorService) getSecretKey() string {
return s.config.SECRET_KEY()
}
func (s *AuthorService) CreateToken(name string) (string, error) {
hash := sha256.New()
_, err := hash.Write([]byte(name + s.getSecretKey()))
if err != nil {
return "", err
}
return fmt.Sprintf("%s.%x", name, hash.Sum(nil)), nil
}
func (s *AuthorService) ValidateToken(token string) bool {
parts := strings.Split(token, ".")
witness := parts[len(parts)-1]
name := strings.Join(parts[:len(parts)-1], ".")
hash := sha256.New()
_, err := hash.Write([]byte(name + s.getSecretKey()))
if err != nil {
return false
}
return fmt.Sprintf("%x", hash.Sum(nil)) == witness
}

View File

@ -0,0 +1,79 @@
package app_test
import (
"owl-blogs/app"
"owl-blogs/infra"
"owl-blogs/test"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
type testConfig struct {
}
func (c *testConfig) SECRET_KEY() string {
return "test"
}
func getAutherService() *app.AuthorService {
db := test.NewMockDb()
authorRepo := infra.NewDefaultAuthorRepo(db)
authorService := app.NewAuthorService(authorRepo, &testConfig{})
return authorService
}
func TestAuthorCreate(t *testing.T) {
authorService := getAutherService()
author, err := authorService.Create("test", "test")
require.NoError(t, err)
require.Equal(t, "test", author.Name)
require.NotEmpty(t, author.PasswordHash)
require.NotEqual(t, "test", author.PasswordHash)
}
func TestAuthorFindByName(t *testing.T) {
authorService := getAutherService()
_, err := authorService.Create("test", "test")
require.NoError(t, err)
author, err := authorService.FindByName("test")
require.NoError(t, err)
require.Equal(t, "test", author.Name)
require.NotEmpty(t, author.PasswordHash)
require.NotEqual(t, "test", author.PasswordHash)
}
func TestAuthorAuthenticate(t *testing.T) {
authorService := getAutherService()
_, err := authorService.Create("test", "test")
require.NoError(t, err)
require.True(t, authorService.Authenticate("test", "test"))
require.False(t, authorService.Authenticate("test", "test1"))
require.False(t, authorService.Authenticate("test1", "test"))
}
func TestAuthorCreateToken(t *testing.T) {
authorService := getAutherService()
_, err := authorService.Create("test", "test")
require.NoError(t, err)
token, err := authorService.CreateToken("test")
require.NoError(t, err)
require.NotEmpty(t, token)
require.NotEqual(t, "test", token)
}
func TestAuthorValidateToken(t *testing.T) {
authorService := getAutherService()
_, err := authorService.Create("test", "test")
require.NoError(t, err)
token, err := authorService.CreateToken("test")
require.NoError(t, err)
require.True(t, authorService.ValidateToken(token))
require.False(t, authorService.ValidateToken(token[:len(token)-2]))
require.False(t, authorService.ValidateToken("test"))
require.False(t, authorService.ValidateToken("test.test"))
require.False(t, authorService.ValidateToken(strings.Replace(token, "test", "test1", 1)))
}

View File

@ -17,3 +17,8 @@ type BinaryRepository interface {
Create(name string, data []byte) (*model.BinaryFile, error)
FindById(id string) (*model.BinaryFile, error)
}
type AuthorRepository interface {
Create(name string, passwordHash string) (*model.Author, error)
FindByName(name string) (*model.Author, error)
}

View File

@ -4,46 +4,64 @@ import (
"bytes"
"io"
"io/ioutil"
"math/rand"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"owl-blogs/app"
"owl-blogs/domain/model"
"owl-blogs/infra"
"owl-blogs/test"
"path"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func testDbName() string {
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
rand.Seed(time.Now().UnixNano())
b := make([]rune, 6)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
func getUserToken(service *app.AuthorService) string {
_, err := service.Create("test", "test")
if err != nil {
panic(err)
}
return "/tmp/" + string(b) + ".db"
token, err := service.CreateToken("test")
if err != nil {
panic(err)
}
return token
}
func TestEditorFormGet(t *testing.T) {
db := infra.NewSqliteDB(testDbName())
app := App(db).FiberApp
db := test.NewMockDb()
owlApp := App(db)
app := owlApp.FiberApp
token := getUserToken(owlApp.AuthorService)
req := httptest.NewRequest("GET", "/editor/ImageEntry", nil)
req.AddCookie(&http.Cookie{Name: "token", Value: token})
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, 200, resp.StatusCode)
}
func TestEditorFormPost(t *testing.T) {
dbName := testDbName()
db := infra.NewSqliteDB(dbName)
func TestEditorFormGetNoAuth(t *testing.T) {
db := test.NewMockDb()
owlApp := App(db)
app := owlApp.FiberApp
req := httptest.NewRequest("GET", "/editor/ImageEntry", nil)
req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"})
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, 302, resp.StatusCode)
}
func TestEditorFormPost(t *testing.T) {
db := test.NewMockDb()
owlApp := App(db)
app := owlApp.FiberApp
token := getUserToken(owlApp.AuthorService)
repo := infra.NewEntryRepository(db, owlApp.Registry)
binRepo := infra.NewBinaryFileRepo(db)
@ -67,6 +85,7 @@ func TestEditorFormPost(t *testing.T) {
req := httptest.NewRequest("POST", "/editor/ImageEntry", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.AddCookie(&http.Cookie{Name: "token", Value: token})
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, 302, resp.StatusCode)
@ -84,3 +103,34 @@ func TestEditorFormPost(t *testing.T) {
require.Equal(t, fileBytes, bin.Data)
}
func TestEditorFormPostNoAuth(t *testing.T) {
db := test.NewMockDb()
owlApp := App(db)
app := owlApp.FiberApp
fileDir, _ := os.Getwd()
fileName := "../../test/fixtures/test.png"
filePath := path.Join(fileDir, fileName)
file, err := os.Open(filePath)
require.NoError(t, err)
defer file.Close()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, _ := writer.CreateFormFile("ImageId", filepath.Base(file.Name()))
io.Copy(part, file)
part, _ = writer.CreateFormField("Content")
io.WriteString(part, "test content")
writer.Close()
req := httptest.NewRequest("POST", "/editor/ImageEntry", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.AddCookie(&http.Cookie{Name: "token", Value: "invalid"})
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, 302, resp.StatusCode)
require.Contains(t, resp.Header.Get("Location"), "/auth/login")
}

View File

@ -2,6 +2,7 @@ package main
import (
"owl-blogs/app"
"owl-blogs/config"
"owl-blogs/domain/model"
"owl-blogs/infra"
"owl-blogs/web"
@ -10,16 +11,20 @@ import (
const DbPath = "owlblogs.db"
func App(db infra.Database) *web.WebApp {
registry := app.NewEntryTypeRegistry()
config := config.NewConfig()
registry := app.NewEntryTypeRegistry()
registry.Register(&model.ImageEntry{})
entryRepo := infra.NewEntryRepository(db, registry)
binRepo := infra.NewBinaryFileRepo(db)
authorRepo := infra.NewDefaultAuthorRepo(db)
entryService := app.NewEntryService(entryRepo)
binaryService := app.NewBinaryFileService(binRepo)
return web.NewWebApp(entryService, registry, binaryService)
authorService := app.NewAuthorService(authorRepo, config)
return web.NewWebApp(entryService, registry, binaryService, authorService)
}

29
config/config.go Normal file
View File

@ -0,0 +1,29 @@
package config
import "os"
type Config interface {
SECRET_KEY() string
}
type EnvConfig struct {
secretKey string
}
func getEnvOrPanic(key string) string {
value, set := os.LookupEnv(key)
if !set {
panic("Environment variable " + key + " is not set")
}
return value
}
func NewConfig() Config {
return &EnvConfig{
secretKey: getEnvOrPanic("OWL_SECRET_KEY"),
}
}
func (c *EnvConfig) SECRET_KEY() string {
return c.secretKey
}

6
domain/model/author.go Normal file
View File

@ -0,0 +1,6 @@
package model
type Author struct {
Name string
PasswordHash string
}

3
go.mod
View File

@ -24,6 +24,7 @@ require (
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.47.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.9.0 // indirect
golang.org/x/crypto v0.11.0 // indirect
golang.org/x/sys v0.10.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

4
go.sum
View File

@ -58,6 +58,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -83,6 +85,8 @@ golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA=

View File

@ -0,0 +1,61 @@
package infra
import (
"owl-blogs/domain/model"
"github.com/jmoiron/sqlx"
)
type sqlAuthor struct {
Name string `db:"name"`
PasswordHash string `db:"password_hash"`
}
type DefaultAuthorRepo struct {
db *sqlx.DB
}
func NewDefaultAuthorRepo(db Database) *DefaultAuthorRepo {
sqlxdb := db.Get()
// Create table if not exists
sqlxdb.MustExec(`
CREATE TABLE IF NOT EXISTS authors (
name TEXT PRIMARY KEY,
password_hash TEXT NOT NULL
);
`)
return &DefaultAuthorRepo{
db: sqlxdb,
}
}
// FindByName implements repository.AuthorRepository.
func (r *DefaultAuthorRepo) FindByName(name string) (*model.Author, error) {
var author sqlAuthor
err := r.db.Get(&author, "SELECT * FROM authors WHERE name = ?", name)
if err != nil {
return nil, err
}
return &model.Author{
Name: author.Name,
PasswordHash: author.PasswordHash,
}, nil
}
// Create implements repository.AuthorRepository.
func (r *DefaultAuthorRepo) Create(name string, passwordHash string) (*model.Author, error) {
author := sqlAuthor{
Name: name,
PasswordHash: passwordHash,
}
_, err := r.db.NamedExec("INSERT INTO authors (name, password_hash) VALUES (:name, :password_hash)", author)
if err != nil {
return nil, err
}
return &model.Author{
Name: author.Name,
PasswordHash: author.PasswordHash,
}, nil
}

View File

@ -0,0 +1,47 @@
package infra_test
import (
"owl-blogs/app/repository"
"owl-blogs/infra"
"owl-blogs/test"
"testing"
"github.com/stretchr/testify/require"
)
func setupAutherRepo() repository.AuthorRepository {
db := test.NewMockDb()
repo := infra.NewDefaultAuthorRepo(db)
return repo
}
func TestAuthorRepoCreate(t *testing.T) {
repo := setupAutherRepo()
author, err := repo.Create("name", "password")
require.NoError(t, err)
author, err = repo.FindByName(author.Name)
require.NoError(t, err)
require.Equal(t, author.Name, "name")
require.Equal(t, author.PasswordHash, "password")
}
func TestAuthorRepoNoSideEffect(t *testing.T) {
repo := setupAutherRepo()
author, err := repo.Create("name1", "password1")
require.NoError(t, err)
author2, err := repo.Create("name2", "password2")
require.NoError(t, err)
author, err = repo.FindByName(author.Name)
require.NoError(t, err)
author2, err = repo.FindByName(author2.Name)
require.NoError(t, err)
require.Equal(t, author.Name, "name1")
require.Equal(t, author.PasswordHash, "password1")
require.Equal(t, author2.Name, "name2")
require.Equal(t, author2.PasswordHash, "password2")
}

6
run_tests.sh Executable file
View File

@ -0,0 +1,6 @@
#!/bin/bash
set -e
OWL_SECRET_KEY=test-secret-key \
go test -v -coverprofile=coverage.out ./...

View File

@ -2,6 +2,7 @@ package web
import (
"owl-blogs/app"
"owl-blogs/web/middleware"
"github.com/gofiber/fiber/v2"
)
@ -11,9 +12,15 @@ type WebApp struct {
EntryService *app.EntryService
BinaryService *app.BinaryService
Registry *app.EntryTypeRegistry
AuthorService *app.AuthorService
}
func NewWebApp(entryService *app.EntryService, typeRegistry *app.EntryTypeRegistry, binService *app.BinaryService) *WebApp {
func NewWebApp(
entryService *app.EntryService,
typeRegistry *app.EntryTypeRegistry,
binService *app.BinaryService,
authorService *app.AuthorService,
) *WebApp {
app := fiber.New()
indexHandler := NewIndexHandler(entryService)
@ -25,15 +32,20 @@ func NewWebApp(entryService *app.EntryService, typeRegistry *app.EntryTypeRegist
editorListHandler := NewEditorListHandler(typeRegistry)
editorHandler := NewEditorHandler(entryService, typeRegistry, binService)
// Login
app.Get("/auth/login", loginHandler.HandleGet)
app.Post("/auth/login", loginHandler.HandlePost)
// Editor
editor := app.Group("/editor")
editor.Use(middleware.NewAuthMiddleware(authorService).Handle)
editor.Get("/", editorListHandler.Handle)
editor.Get("/:editor/", editorHandler.HandleGet)
editor.Post("/:editor/", editorHandler.HandlePost)
// app.ServeFiles("/static/*filepath", http.Dir(repo.StaticDir()))
app.Get("/", indexHandler.Handle)
app.Get("/lists/:list/", listHandler.Handle)
// Editor
app.Get("/editor/auth/", loginHandler.HandleGet)
app.Post("/editor/auth/", loginHandler.HandlePost)
app.Get("/editor/", editorListHandler.Handle)
app.Get("/editor/:editor/", editorHandler.HandleGet)
app.Post("/editor/:editor/", editorHandler.HandlePost)
// Media
app.Get("/media/*filepath", mediaHandler.Handle)
// RSS
@ -57,6 +69,7 @@ func NewWebApp(entryService *app.EntryService, typeRegistry *app.EntryTypeRegist
EntryService: entryService,
Registry: typeRegistry,
BinaryService: binService,
AuthorService: authorService,
}
}

View File

@ -15,8 +15,16 @@ type EditorHandler struct {
registry *app.EntryTypeRegistry
}
func NewEditorHandler(entryService *app.EntryService, registry *app.EntryTypeRegistry, binService *app.BinaryService) *EditorHandler {
return &EditorHandler{entrySvc: entryService, registry: registry, binSvc: binService}
func NewEditorHandler(
entryService *app.EntryService,
registry *app.EntryTypeRegistry,
binService *app.BinaryService,
) *EditorHandler {
return &EditorHandler{
entrySvc: entryService,
registry: registry,
binSvc: binService,
}
}
func (h *EditorHandler) paramToEntry(c *fiber.Ctx) (model.Entry, error) {

31
web/middleware/auth.go Normal file
View File

@ -0,0 +1,31 @@
package middleware
import (
"owl-blogs/app"
"github.com/gofiber/fiber/v2"
)
type AuthMiddleware struct {
authorService *app.AuthorService
}
func NewAuthMiddleware(authorService *app.AuthorService) *AuthMiddleware {
return &AuthMiddleware{authorService: authorService}
}
func (m *AuthMiddleware) Handle(c *fiber.Ctx) error {
// get token from cookie
token := c.Cookies("token")
if token == "" {
return c.Redirect("/auth/login")
}
// check token
valid := m.authorService.ValidateToken(token)
if !valid {
return c.Redirect("/auth/login")
}
return c.Next()
}