From 197629db9a3143565127d431518a5e6a7250aa51 Mon Sep 17 00:00:00 2001 From: Niko Abeler Date: Sat, 8 Jul 2023 13:28:06 +0200 Subject: [PATCH] secured editor --- app/author_service.go | 76 +++++++++++++++++++++++++++++++ app/author_service_test.go | 79 +++++++++++++++++++++++++++++++++ app/repository/interfaces.go | 5 +++ cmd/owl/editor_test.go | 78 ++++++++++++++++++++++++++------ cmd/owl/main.go | 9 +++- config/config.go | 29 ++++++++++++ domain/model/author.go | 6 +++ go.mod | 3 +- go.sum | 4 ++ infra/author_repository.go | 61 +++++++++++++++++++++++++ infra/author_repository_test.go | 47 ++++++++++++++++++++ run_tests.sh | 6 +++ web/app.go | 27 ++++++++--- web/editor_handler.go | 12 ++++- web/middleware/auth.go | 31 +++++++++++++ 15 files changed, 447 insertions(+), 26 deletions(-) create mode 100644 app/author_service.go create mode 100644 app/author_service_test.go create mode 100644 config/config.go create mode 100644 domain/model/author.go create mode 100644 infra/author_repository.go create mode 100644 infra/author_repository_test.go create mode 100755 run_tests.sh create mode 100644 web/middleware/auth.go diff --git a/app/author_service.go b/app/author_service.go new file mode 100644 index 0000000..7b27747 --- /dev/null +++ b/app/author_service.go @@ -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 +} diff --git a/app/author_service_test.go b/app/author_service_test.go new file mode 100644 index 0000000..3c9a03c --- /dev/null +++ b/app/author_service_test.go @@ -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))) +} diff --git a/app/repository/interfaces.go b/app/repository/interfaces.go index 8eed3dc..6c34079 100644 --- a/app/repository/interfaces.go +++ b/app/repository/interfaces.go @@ -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) +} diff --git a/cmd/owl/editor_test.go b/cmd/owl/editor_test.go index 462b8ca..cd9346c 100644 --- a/cmd/owl/editor_test.go +++ b/cmd/owl/editor_test.go @@ -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") + +} diff --git a/cmd/owl/main.go b/cmd/owl/main.go index e4c3aeb..6a557c1 100644 --- a/cmd/owl/main.go +++ b/cmd/owl/main.go @@ -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) } diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..03ae7c8 --- /dev/null +++ b/config/config.go @@ -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 +} diff --git a/domain/model/author.go b/domain/model/author.go new file mode 100644 index 0000000..41b8fbf --- /dev/null +++ b/domain/model/author.go @@ -0,0 +1,6 @@ +package model + +type Author struct { + Name string + PasswordHash string +} diff --git a/go.mod b/go.mod index b920a87..1ccf3ca 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 6bb6d0d..6fc6625 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/infra/author_repository.go b/infra/author_repository.go new file mode 100644 index 0000000..0c7bd98 --- /dev/null +++ b/infra/author_repository.go @@ -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 +} diff --git a/infra/author_repository_test.go b/infra/author_repository_test.go new file mode 100644 index 0000000..7283ca8 --- /dev/null +++ b/infra/author_repository_test.go @@ -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") +} diff --git a/run_tests.sh b/run_tests.sh new file mode 100755 index 0000000..cb823ef --- /dev/null +++ b/run_tests.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -e + +OWL_SECRET_KEY=test-secret-key \ +go test -v -coverprofile=coverage.out ./... diff --git a/web/app.go b/web/app.go index 3accf1e..c08f763 100644 --- a/web/app.go +++ b/web/app.go @@ -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, } } diff --git a/web/editor_handler.go b/web/editor_handler.go index 826bd83..6d87f0e 100644 --- a/web/editor_handler.go +++ b/web/editor_handler.go @@ -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) { diff --git a/web/middleware/auth.go b/web/middleware/auth.go new file mode 100644 index 0000000..5b644e6 --- /dev/null +++ b/web/middleware/auth.go @@ -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() +}