Dima před 1 rokem
rodič
revize
013e124261

binární
build/app/app


+ 16 - 6
internal/fiber/fiber.go

@@ -12,6 +12,7 @@ import (
 	"github.com/gofiber/fiber/v2"
 	"github.com/gofiber/fiber/v2/middleware/basicauth"
 	"github.com/gofiber/fiber/v2/middleware/cors"
+	"github.com/gofiber/fiber/v2/middleware/limiter"
 	"github.com/gofiber/fiber/v2/middleware/monitor"
 	"github.com/gofiber/fiber/v2/middleware/recover"
 	jwt "github.com/gofiber/jwt/v3"
@@ -19,10 +20,12 @@ import (
 )
 
 const (
-	appName       = "dmitriygnatenko"
-	templatesPath = "./../../internal/templates"
-	staticPath    = "../../web"
-	metricsURI    = "/metrics"
+	appName                     = "dmitriygnatenko"
+	templatesPath               = "./../../internal/templates"
+	staticPath                  = "../../web"
+	metricsURI                  = "/metrics"
+	loginRateLimiterMaxRequests = 10
+	loginRateLimiterExpiration  = 30 * time.Second
 )
 
 func Init(sp interfaces.ServiceProvider) (*fiber.App, error) {
@@ -58,7 +61,13 @@ func Init(sp interfaces.ServiceProvider) (*fiber.App, error) {
 	// Protected handlers
 	admin := fiberApp.Group("/admin", jwtAuth)
 	admin.Get("/", adminHandler.ArticleHandler(sp))
-	admin.Get("/login", adminHandler.LoginHandler(sp))
+
+	admin.All("/login", limiter.New(limiter.Config{
+		Max:        loginRateLimiterMaxRequests,
+		Expiration: loginRateLimiterExpiration,
+	}), adminHandler.LoginHandler(sp))
+
+	admin.All("/logout", adminHandler.LogoutHandler(sp))
 	admin.All("/article/add", adminHandler.AddArticleHandler(sp))
 	admin.All("/article/edit/:id<int>", adminHandler.EditArticleHandler(sp))
 	admin.All("/article/delete/:id<int>", adminHandler.DeleteArticleHandler(sp))
@@ -82,7 +91,8 @@ func getConfig(sp interfaces.ServiceProvider) fiber.Config {
 // nolint
 func getJWTConfig(sp interfaces.ServiceProvider) jwt.Config {
 	return jwt.Config{
-		SigningKey: []byte(sp.GetEnvService().GetJWTSecretKey()),
+		SigningKey:  []byte(sp.GetEnvService().GetJWTSecretKey()),
+		TokenLookup: "cookie:" + sp.GetEnvService().GetJWTCookie(),
 		ErrorHandler: func(fctx *fiber.Ctx, err error) error {
 			return fctx.Redirect("/admin/login")
 		},

+ 1 - 0
internal/interfaces/env.go

@@ -23,6 +23,7 @@ type Env interface {
 	GetSMTPPassword() string
 
 	GetJWTSecretKey() string
+	GetJWTCookie() string
 	GetJWTLifetime() int
 
 	GetBasicAuthUser() string

+ 2 - 0
internal/interfaces/sp.go

@@ -4,7 +4,9 @@ type ServiceProvider interface {
 	GetEnvService() Env
 	GetCacheService() Cache
 	GetMailerService() Mailer
+	GetAuthService() Auth
 	GetArticleRepository() ArticleRepository
 	GetTagRepository() TagRepository
 	GetArticleTagRepository() ArticleTagRepository
+	GetUserRepository() UserRepository
 }

+ 12 - 0
internal/interfaces/user.go

@@ -0,0 +1,12 @@
+package interfaces
+
+import (
+	"context"
+
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
+)
+
+type UserRepository interface {
+	Get(ctx context.Context, username string) (*models.User, error)
+	Add(ctx context.Context, username string, password string) (int, error)
+}

+ 6 - 0
internal/models/auth.go

@@ -0,0 +1,6 @@
+package models
+
+type LoginForm struct {
+	Username string `form:"username" validate:"required"`
+	Password string `form:"password" validate:"required"`
+}

+ 13 - 0
internal/models/user.go

@@ -0,0 +1,13 @@
+package models
+
+import (
+	"time"
+)
+
+type User struct {
+	ID        int
+	Username  string
+	Password  string
+	CreatedAt time.Time
+	UpdatedAt time.Time
+}

+ 516 - 0
internal/repositories/mocks/user_repository_minimock.go

@@ -0,0 +1,516 @@
+package mocks
+
+// Code generated by http://github.com/gojuno/minimock (dev). DO NOT EDIT.
+
+//go:generate minimock -i git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/interfaces.UserRepository -o ./mocks/user_repository_minimock.go -n UserRepositoryMock
+
+import (
+	"context"
+	"sync"
+	mm_atomic "sync/atomic"
+	mm_time "time"
+
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
+	"github.com/gojuno/minimock/v3"
+)
+
+// UserRepositoryMock implements interfaces.UserRepository
+type UserRepositoryMock struct {
+	t minimock.Tester
+
+	funcAdd          func(ctx context.Context, username string, password string) (i1 int, err error)
+	inspectFuncAdd   func(ctx context.Context, username string, password string)
+	afterAddCounter  uint64
+	beforeAddCounter uint64
+	AddMock          mUserRepositoryMockAdd
+
+	funcGet          func(ctx context.Context, username string) (up1 *models.User, err error)
+	inspectFuncGet   func(ctx context.Context, username string)
+	afterGetCounter  uint64
+	beforeGetCounter uint64
+	GetMock          mUserRepositoryMockGet
+}
+
+// NewUserRepositoryMock returns a mock for interfaces.UserRepository
+func NewUserRepositoryMock(t minimock.Tester) *UserRepositoryMock {
+	m := &UserRepositoryMock{t: t}
+	if controller, ok := t.(minimock.MockController); ok {
+		controller.RegisterMocker(m)
+	}
+
+	m.AddMock = mUserRepositoryMockAdd{mock: m}
+	m.AddMock.callArgs = []*UserRepositoryMockAddParams{}
+
+	m.GetMock = mUserRepositoryMockGet{mock: m}
+	m.GetMock.callArgs = []*UserRepositoryMockGetParams{}
+
+	return m
+}
+
+type mUserRepositoryMockAdd struct {
+	mock               *UserRepositoryMock
+	defaultExpectation *UserRepositoryMockAddExpectation
+	expectations       []*UserRepositoryMockAddExpectation
+
+	callArgs []*UserRepositoryMockAddParams
+	mutex    sync.RWMutex
+}
+
+// UserRepositoryMockAddExpectation specifies expectation struct of the UserRepository.Add
+type UserRepositoryMockAddExpectation struct {
+	mock    *UserRepositoryMock
+	params  *UserRepositoryMockAddParams
+	results *UserRepositoryMockAddResults
+	Counter uint64
+}
+
+// UserRepositoryMockAddParams contains parameters of the UserRepository.Add
+type UserRepositoryMockAddParams struct {
+	ctx      context.Context
+	username string
+	password string
+}
+
+// UserRepositoryMockAddResults contains results of the UserRepository.Add
+type UserRepositoryMockAddResults struct {
+	i1  int
+	err error
+}
+
+// Expect sets up expected params for UserRepository.Add
+func (mmAdd *mUserRepositoryMockAdd) Expect(ctx context.Context, username string, password string) *mUserRepositoryMockAdd {
+	if mmAdd.mock.funcAdd != nil {
+		mmAdd.mock.t.Fatalf("UserRepositoryMock.Add mock is already set by Set")
+	}
+
+	if mmAdd.defaultExpectation == nil {
+		mmAdd.defaultExpectation = &UserRepositoryMockAddExpectation{}
+	}
+
+	mmAdd.defaultExpectation.params = &UserRepositoryMockAddParams{ctx, username, password}
+	for _, e := range mmAdd.expectations {
+		if minimock.Equal(e.params, mmAdd.defaultExpectation.params) {
+			mmAdd.mock.t.Fatalf("Expectation set by When has same params: %#v", *mmAdd.defaultExpectation.params)
+		}
+	}
+
+	return mmAdd
+}
+
+// Inspect accepts an inspector function that has same arguments as the UserRepository.Add
+func (mmAdd *mUserRepositoryMockAdd) Inspect(f func(ctx context.Context, username string, password string)) *mUserRepositoryMockAdd {
+	if mmAdd.mock.inspectFuncAdd != nil {
+		mmAdd.mock.t.Fatalf("Inspect function is already set for UserRepositoryMock.Add")
+	}
+
+	mmAdd.mock.inspectFuncAdd = f
+
+	return mmAdd
+}
+
+// Return sets up results that will be returned by UserRepository.Add
+func (mmAdd *mUserRepositoryMockAdd) Return(i1 int, err error) *UserRepositoryMock {
+	if mmAdd.mock.funcAdd != nil {
+		mmAdd.mock.t.Fatalf("UserRepositoryMock.Add mock is already set by Set")
+	}
+
+	if mmAdd.defaultExpectation == nil {
+		mmAdd.defaultExpectation = &UserRepositoryMockAddExpectation{mock: mmAdd.mock}
+	}
+	mmAdd.defaultExpectation.results = &UserRepositoryMockAddResults{i1, err}
+	return mmAdd.mock
+}
+
+// Set uses given function f to mock the UserRepository.Add method
+func (mmAdd *mUserRepositoryMockAdd) Set(f func(ctx context.Context, username string, password string) (i1 int, err error)) *UserRepositoryMock {
+	if mmAdd.defaultExpectation != nil {
+		mmAdd.mock.t.Fatalf("Default expectation is already set for the UserRepository.Add method")
+	}
+
+	if len(mmAdd.expectations) > 0 {
+		mmAdd.mock.t.Fatalf("Some expectations are already set for the UserRepository.Add method")
+	}
+
+	mmAdd.mock.funcAdd = f
+	return mmAdd.mock
+}
+
+// When sets expectation for the UserRepository.Add which will trigger the result defined by the following
+// Then helper
+func (mmAdd *mUserRepositoryMockAdd) When(ctx context.Context, username string, password string) *UserRepositoryMockAddExpectation {
+	if mmAdd.mock.funcAdd != nil {
+		mmAdd.mock.t.Fatalf("UserRepositoryMock.Add mock is already set by Set")
+	}
+
+	expectation := &UserRepositoryMockAddExpectation{
+		mock:   mmAdd.mock,
+		params: &UserRepositoryMockAddParams{ctx, username, password},
+	}
+	mmAdd.expectations = append(mmAdd.expectations, expectation)
+	return expectation
+}
+
+// Then sets up UserRepository.Add return parameters for the expectation previously defined by the When method
+func (e *UserRepositoryMockAddExpectation) Then(i1 int, err error) *UserRepositoryMock {
+	e.results = &UserRepositoryMockAddResults{i1, err}
+	return e.mock
+}
+
+// Add implements interfaces.UserRepository
+func (mmAdd *UserRepositoryMock) Add(ctx context.Context, username string, password string) (i1 int, err error) {
+	mm_atomic.AddUint64(&mmAdd.beforeAddCounter, 1)
+	defer mm_atomic.AddUint64(&mmAdd.afterAddCounter, 1)
+
+	if mmAdd.inspectFuncAdd != nil {
+		mmAdd.inspectFuncAdd(ctx, username, password)
+	}
+
+	mm_params := &UserRepositoryMockAddParams{ctx, username, password}
+
+	// Record call args
+	mmAdd.AddMock.mutex.Lock()
+	mmAdd.AddMock.callArgs = append(mmAdd.AddMock.callArgs, mm_params)
+	mmAdd.AddMock.mutex.Unlock()
+
+	for _, e := range mmAdd.AddMock.expectations {
+		if minimock.Equal(e.params, mm_params) {
+			mm_atomic.AddUint64(&e.Counter, 1)
+			return e.results.i1, e.results.err
+		}
+	}
+
+	if mmAdd.AddMock.defaultExpectation != nil {
+		mm_atomic.AddUint64(&mmAdd.AddMock.defaultExpectation.Counter, 1)
+		mm_want := mmAdd.AddMock.defaultExpectation.params
+		mm_got := UserRepositoryMockAddParams{ctx, username, password}
+		if mm_want != nil && !minimock.Equal(*mm_want, mm_got) {
+			mmAdd.t.Errorf("UserRepositoryMock.Add got unexpected parameters, want: %#v, got: %#v%s\n", *mm_want, mm_got, minimock.Diff(*mm_want, mm_got))
+		}
+
+		mm_results := mmAdd.AddMock.defaultExpectation.results
+		if mm_results == nil {
+			mmAdd.t.Fatal("No results are set for the UserRepositoryMock.Add")
+		}
+		return (*mm_results).i1, (*mm_results).err
+	}
+	if mmAdd.funcAdd != nil {
+		return mmAdd.funcAdd(ctx, username, password)
+	}
+	mmAdd.t.Fatalf("Unexpected call to UserRepositoryMock.Add. %v %v %v", ctx, username, password)
+	return
+}
+
+// AddAfterCounter returns a count of finished UserRepositoryMock.Add invocations
+func (mmAdd *UserRepositoryMock) AddAfterCounter() uint64 {
+	return mm_atomic.LoadUint64(&mmAdd.afterAddCounter)
+}
+
+// AddBeforeCounter returns a count of UserRepositoryMock.Add invocations
+func (mmAdd *UserRepositoryMock) AddBeforeCounter() uint64 {
+	return mm_atomic.LoadUint64(&mmAdd.beforeAddCounter)
+}
+
+// Calls returns a list of arguments used in each call to UserRepositoryMock.Add.
+// The list is in the same order as the calls were made (i.e. recent calls have a higher index)
+func (mmAdd *mUserRepositoryMockAdd) Calls() []*UserRepositoryMockAddParams {
+	mmAdd.mutex.RLock()
+
+	argCopy := make([]*UserRepositoryMockAddParams, len(mmAdd.callArgs))
+	copy(argCopy, mmAdd.callArgs)
+
+	mmAdd.mutex.RUnlock()
+
+	return argCopy
+}
+
+// MinimockAddDone returns true if the count of the Add invocations corresponds
+// the number of defined expectations
+func (m *UserRepositoryMock) MinimockAddDone() bool {
+	for _, e := range m.AddMock.expectations {
+		if mm_atomic.LoadUint64(&e.Counter) < 1 {
+			return false
+		}
+	}
+
+	// if default expectation was set then invocations count should be greater than zero
+	if m.AddMock.defaultExpectation != nil && mm_atomic.LoadUint64(&m.afterAddCounter) < 1 {
+		return false
+	}
+	// if func was set then invocations count should be greater than zero
+	if m.funcAdd != nil && mm_atomic.LoadUint64(&m.afterAddCounter) < 1 {
+		return false
+	}
+	return true
+}
+
+// MinimockAddInspect logs each unmet expectation
+func (m *UserRepositoryMock) MinimockAddInspect() {
+	for _, e := range m.AddMock.expectations {
+		if mm_atomic.LoadUint64(&e.Counter) < 1 {
+			m.t.Errorf("Expected call to UserRepositoryMock.Add with params: %#v", *e.params)
+		}
+	}
+
+	// if default expectation was set then invocations count should be greater than zero
+	if m.AddMock.defaultExpectation != nil && mm_atomic.LoadUint64(&m.afterAddCounter) < 1 {
+		if m.AddMock.defaultExpectation.params == nil {
+			m.t.Error("Expected call to UserRepositoryMock.Add")
+		} else {
+			m.t.Errorf("Expected call to UserRepositoryMock.Add with params: %#v", *m.AddMock.defaultExpectation.params)
+		}
+	}
+	// if func was set then invocations count should be greater than zero
+	if m.funcAdd != nil && mm_atomic.LoadUint64(&m.afterAddCounter) < 1 {
+		m.t.Error("Expected call to UserRepositoryMock.Add")
+	}
+}
+
+type mUserRepositoryMockGet struct {
+	mock               *UserRepositoryMock
+	defaultExpectation *UserRepositoryMockGetExpectation
+	expectations       []*UserRepositoryMockGetExpectation
+
+	callArgs []*UserRepositoryMockGetParams
+	mutex    sync.RWMutex
+}
+
+// UserRepositoryMockGetExpectation specifies expectation struct of the UserRepository.Get
+type UserRepositoryMockGetExpectation struct {
+	mock    *UserRepositoryMock
+	params  *UserRepositoryMockGetParams
+	results *UserRepositoryMockGetResults
+	Counter uint64
+}
+
+// UserRepositoryMockGetParams contains parameters of the UserRepository.Get
+type UserRepositoryMockGetParams struct {
+	ctx      context.Context
+	username string
+}
+
+// UserRepositoryMockGetResults contains results of the UserRepository.Get
+type UserRepositoryMockGetResults struct {
+	up1 *models.User
+	err error
+}
+
+// Expect sets up expected params for UserRepository.Get
+func (mmGet *mUserRepositoryMockGet) Expect(ctx context.Context, username string) *mUserRepositoryMockGet {
+	if mmGet.mock.funcGet != nil {
+		mmGet.mock.t.Fatalf("UserRepositoryMock.Get mock is already set by Set")
+	}
+
+	if mmGet.defaultExpectation == nil {
+		mmGet.defaultExpectation = &UserRepositoryMockGetExpectation{}
+	}
+
+	mmGet.defaultExpectation.params = &UserRepositoryMockGetParams{ctx, username}
+	for _, e := range mmGet.expectations {
+		if minimock.Equal(e.params, mmGet.defaultExpectation.params) {
+			mmGet.mock.t.Fatalf("Expectation set by When has same params: %#v", *mmGet.defaultExpectation.params)
+		}
+	}
+
+	return mmGet
+}
+
+// Inspect accepts an inspector function that has same arguments as the UserRepository.Get
+func (mmGet *mUserRepositoryMockGet) Inspect(f func(ctx context.Context, username string)) *mUserRepositoryMockGet {
+	if mmGet.mock.inspectFuncGet != nil {
+		mmGet.mock.t.Fatalf("Inspect function is already set for UserRepositoryMock.Get")
+	}
+
+	mmGet.mock.inspectFuncGet = f
+
+	return mmGet
+}
+
+// Return sets up results that will be returned by UserRepository.Get
+func (mmGet *mUserRepositoryMockGet) Return(up1 *models.User, err error) *UserRepositoryMock {
+	if mmGet.mock.funcGet != nil {
+		mmGet.mock.t.Fatalf("UserRepositoryMock.Get mock is already set by Set")
+	}
+
+	if mmGet.defaultExpectation == nil {
+		mmGet.defaultExpectation = &UserRepositoryMockGetExpectation{mock: mmGet.mock}
+	}
+	mmGet.defaultExpectation.results = &UserRepositoryMockGetResults{up1, err}
+	return mmGet.mock
+}
+
+// Set uses given function f to mock the UserRepository.Get method
+func (mmGet *mUserRepositoryMockGet) Set(f func(ctx context.Context, username string) (up1 *models.User, err error)) *UserRepositoryMock {
+	if mmGet.defaultExpectation != nil {
+		mmGet.mock.t.Fatalf("Default expectation is already set for the UserRepository.Get method")
+	}
+
+	if len(mmGet.expectations) > 0 {
+		mmGet.mock.t.Fatalf("Some expectations are already set for the UserRepository.Get method")
+	}
+
+	mmGet.mock.funcGet = f
+	return mmGet.mock
+}
+
+// When sets expectation for the UserRepository.Get which will trigger the result defined by the following
+// Then helper
+func (mmGet *mUserRepositoryMockGet) When(ctx context.Context, username string) *UserRepositoryMockGetExpectation {
+	if mmGet.mock.funcGet != nil {
+		mmGet.mock.t.Fatalf("UserRepositoryMock.Get mock is already set by Set")
+	}
+
+	expectation := &UserRepositoryMockGetExpectation{
+		mock:   mmGet.mock,
+		params: &UserRepositoryMockGetParams{ctx, username},
+	}
+	mmGet.expectations = append(mmGet.expectations, expectation)
+	return expectation
+}
+
+// Then sets up UserRepository.Get return parameters for the expectation previously defined by the When method
+func (e *UserRepositoryMockGetExpectation) Then(up1 *models.User, err error) *UserRepositoryMock {
+	e.results = &UserRepositoryMockGetResults{up1, err}
+	return e.mock
+}
+
+// Get implements interfaces.UserRepository
+func (mmGet *UserRepositoryMock) Get(ctx context.Context, username string) (up1 *models.User, err error) {
+	mm_atomic.AddUint64(&mmGet.beforeGetCounter, 1)
+	defer mm_atomic.AddUint64(&mmGet.afterGetCounter, 1)
+
+	if mmGet.inspectFuncGet != nil {
+		mmGet.inspectFuncGet(ctx, username)
+	}
+
+	mm_params := &UserRepositoryMockGetParams{ctx, username}
+
+	// Record call args
+	mmGet.GetMock.mutex.Lock()
+	mmGet.GetMock.callArgs = append(mmGet.GetMock.callArgs, mm_params)
+	mmGet.GetMock.mutex.Unlock()
+
+	for _, e := range mmGet.GetMock.expectations {
+		if minimock.Equal(e.params, mm_params) {
+			mm_atomic.AddUint64(&e.Counter, 1)
+			return e.results.up1, e.results.err
+		}
+	}
+
+	if mmGet.GetMock.defaultExpectation != nil {
+		mm_atomic.AddUint64(&mmGet.GetMock.defaultExpectation.Counter, 1)
+		mm_want := mmGet.GetMock.defaultExpectation.params
+		mm_got := UserRepositoryMockGetParams{ctx, username}
+		if mm_want != nil && !minimock.Equal(*mm_want, mm_got) {
+			mmGet.t.Errorf("UserRepositoryMock.Get got unexpected parameters, want: %#v, got: %#v%s\n", *mm_want, mm_got, minimock.Diff(*mm_want, mm_got))
+		}
+
+		mm_results := mmGet.GetMock.defaultExpectation.results
+		if mm_results == nil {
+			mmGet.t.Fatal("No results are set for the UserRepositoryMock.Get")
+		}
+		return (*mm_results).up1, (*mm_results).err
+	}
+	if mmGet.funcGet != nil {
+		return mmGet.funcGet(ctx, username)
+	}
+	mmGet.t.Fatalf("Unexpected call to UserRepositoryMock.Get. %v %v", ctx, username)
+	return
+}
+
+// GetAfterCounter returns a count of finished UserRepositoryMock.Get invocations
+func (mmGet *UserRepositoryMock) GetAfterCounter() uint64 {
+	return mm_atomic.LoadUint64(&mmGet.afterGetCounter)
+}
+
+// GetBeforeCounter returns a count of UserRepositoryMock.Get invocations
+func (mmGet *UserRepositoryMock) GetBeforeCounter() uint64 {
+	return mm_atomic.LoadUint64(&mmGet.beforeGetCounter)
+}
+
+// Calls returns a list of arguments used in each call to UserRepositoryMock.Get.
+// The list is in the same order as the calls were made (i.e. recent calls have a higher index)
+func (mmGet *mUserRepositoryMockGet) Calls() []*UserRepositoryMockGetParams {
+	mmGet.mutex.RLock()
+
+	argCopy := make([]*UserRepositoryMockGetParams, len(mmGet.callArgs))
+	copy(argCopy, mmGet.callArgs)
+
+	mmGet.mutex.RUnlock()
+
+	return argCopy
+}
+
+// MinimockGetDone returns true if the count of the Get invocations corresponds
+// the number of defined expectations
+func (m *UserRepositoryMock) MinimockGetDone() bool {
+	for _, e := range m.GetMock.expectations {
+		if mm_atomic.LoadUint64(&e.Counter) < 1 {
+			return false
+		}
+	}
+
+	// if default expectation was set then invocations count should be greater than zero
+	if m.GetMock.defaultExpectation != nil && mm_atomic.LoadUint64(&m.afterGetCounter) < 1 {
+		return false
+	}
+	// if func was set then invocations count should be greater than zero
+	if m.funcGet != nil && mm_atomic.LoadUint64(&m.afterGetCounter) < 1 {
+		return false
+	}
+	return true
+}
+
+// MinimockGetInspect logs each unmet expectation
+func (m *UserRepositoryMock) MinimockGetInspect() {
+	for _, e := range m.GetMock.expectations {
+		if mm_atomic.LoadUint64(&e.Counter) < 1 {
+			m.t.Errorf("Expected call to UserRepositoryMock.Get with params: %#v", *e.params)
+		}
+	}
+
+	// if default expectation was set then invocations count should be greater than zero
+	if m.GetMock.defaultExpectation != nil && mm_atomic.LoadUint64(&m.afterGetCounter) < 1 {
+		if m.GetMock.defaultExpectation.params == nil {
+			m.t.Error("Expected call to UserRepositoryMock.Get")
+		} else {
+			m.t.Errorf("Expected call to UserRepositoryMock.Get with params: %#v", *m.GetMock.defaultExpectation.params)
+		}
+	}
+	// if func was set then invocations count should be greater than zero
+	if m.funcGet != nil && mm_atomic.LoadUint64(&m.afterGetCounter) < 1 {
+		m.t.Error("Expected call to UserRepositoryMock.Get")
+	}
+}
+
+// MinimockFinish checks that all mocked methods have been called the expected number of times
+func (m *UserRepositoryMock) MinimockFinish() {
+	if !m.minimockDone() {
+		m.MinimockAddInspect()
+
+		m.MinimockGetInspect()
+		m.t.FailNow()
+	}
+}
+
+// MinimockWait waits for all mocked methods to be called the expected number of times
+func (m *UserRepositoryMock) MinimockWait(timeout mm_time.Duration) {
+	timeoutCh := mm_time.After(timeout)
+	for {
+		if m.minimockDone() {
+			return
+		}
+		select {
+		case <-timeoutCh:
+			m.MinimockFinish()
+			return
+		case <-mm_time.After(10 * mm_time.Millisecond):
+		}
+	}
+}
+
+func (m *UserRepositoryMock) minimockDone() bool {
+	done := true
+	return done &&
+		m.MinimockAddDone() &&
+		m.MinimockGetDone()
+}

+ 68 - 0
internal/repositories/user.go

@@ -0,0 +1,68 @@
+package repositories
+
+//go:generate mkdir -p mocks
+//go:generate rm -rf ./mocks/*_minimock.go
+//go:generate minimock -i git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/interfaces.UserRepository -o ./mocks/ -s "_minimock.go"
+
+import (
+	"context"
+	"database/sql"
+
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/interfaces"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
+	sq "github.com/Masterminds/squirrel"
+)
+
+const (
+	userTableName = "\"user\""
+)
+
+type userRepository struct {
+	db *sql.DB
+}
+
+func InitUserRepository(db *sql.DB) interfaces.UserRepository {
+	return userRepository{db: db}
+}
+
+func (r userRepository) Get(ctx context.Context, username string) (*models.User, error) {
+	query, args, err := sq.Select("id", "username", "password", "created_at", "updated_at").
+		From(userTableName).
+		PlaceholderFormat(sq.Dollar).
+		Where(sq.Eq{"username": username}).
+		ToSql()
+
+	if err != nil {
+		return nil, err
+	}
+
+	var res models.User
+	err = r.db.QueryRowContext(ctx, query, args...).
+		Scan(&res.ID, &res.Username, &res.Password, &res.CreatedAt, &res.UpdatedAt)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &res, nil
+}
+
+func (r userRepository) Add(ctx context.Context, username string, password string) (int, error) {
+	query, args, err := sq.Insert(userTableName).
+		PlaceholderFormat(sq.Dollar).
+		Columns("username", "password").
+		Values(username, password).
+		Suffix("RETURNING id").
+		ToSql()
+
+	if err != nil {
+		return 0, err
+	}
+
+	var id int
+	if err = r.db.QueryRowContext(ctx, query, args...).Scan(&id); err != nil {
+		return 0, err
+	}
+
+	return id, nil
+}

+ 22 - 0
internal/service_provider/sp.go

@@ -3,6 +3,7 @@ package sp
 import (
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/interfaces"
 	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/repositories"
+	authService "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/auth"
 	cacheService "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/cache"
 	dbService "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/db"
 	envService "git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/services/env"
@@ -13,9 +14,11 @@ type ServiceProvider struct {
 	env                  interfaces.Env
 	cache                interfaces.Cache
 	mailer               interfaces.Mailer
+	auth                 interfaces.Auth
 	articleRepository    interfaces.ArticleRepository
 	tagRepository        interfaces.TagRepository
 	articleTagRepository interfaces.ArticleTagRepository
+	userRepository       interfaces.UserRepository
 }
 
 func Init() (interfaces.ServiceProvider, error) {
@@ -40,6 +43,12 @@ func Init() (interfaces.ServiceProvider, error) {
 	}
 	sp.mailer = mailer
 
+	auth, err := authService.Init(sp.env)
+	if err != nil {
+		return nil, err
+	}
+	sp.auth = auth
+
 	db, err := dbService.Init(env)
 	if err != nil {
 		return nil, err
@@ -49,6 +58,7 @@ func Init() (interfaces.ServiceProvider, error) {
 	sp.articleRepository = repositories.InitArticleRepository(db)
 	sp.tagRepository = repositories.InitTagRepository(db)
 	sp.articleTagRepository = repositories.InitArticleTagRepository(db)
+	sp.userRepository = repositories.InitUserRepository(db)
 
 	return sp, nil
 }
@@ -65,6 +75,10 @@ func (sp *ServiceProvider) GetMailerService() interfaces.Mailer {
 	return sp.mailer
 }
 
+func (sp *ServiceProvider) GetAuthService() interfaces.Auth {
+	return sp.auth
+}
+
 func (sp *ServiceProvider) GetArticleRepository() interfaces.ArticleRepository {
 	return sp.articleRepository
 }
@@ -77,6 +91,10 @@ func (sp *ServiceProvider) GetArticleTagRepository() interfaces.ArticleTagReposi
 	return sp.articleTagRepository
 }
 
+func (sp *ServiceProvider) GetUserRepository() interfaces.UserRepository {
+	return sp.userRepository
+}
+
 func InitMock(deps ...interface{}) interfaces.ServiceProvider {
 	sp := ServiceProvider{}
 
@@ -84,6 +102,8 @@ func InitMock(deps ...interface{}) interfaces.ServiceProvider {
 		switch s := d.(type) {
 		case interfaces.Cache:
 			sp.cache = s
+		case interfaces.Auth:
+			sp.auth = s
 		case interfaces.Env:
 			sp.env = s
 		case interfaces.Mailer:
@@ -94,6 +114,8 @@ func InitMock(deps ...interface{}) interfaces.ServiceProvider {
 			sp.articleTagRepository = s
 		case interfaces.TagRepository:
 			sp.tagRepository = s
+		case interfaces.UserRepository:
+			sp.userRepository = s
 		}
 	}
 

+ 5 - 0
internal/services/env/env.go

@@ -28,6 +28,7 @@ type env struct {
 
 	JWTSecretKey string `mapstructure:"JWT_SECRET_KEY"`
 	JWTLifeTime  int    `mapstructure:"JWT_LIFETIME"`
+	JWTCookie    string `mapstructure:"JWT_COOKIE"`
 
 	BasicAuthUser     string `mapstructure:"BASIC_AUTH_USER"`
 	BasicAuthPassword string `mapstructure:"BASIC_AUTH_PASSWORD"`
@@ -135,6 +136,10 @@ func (e *env) GetJWTSecretKey() string {
 	return e.JWTSecretKey
 }
 
+func (e *env) GetJWTCookie() string {
+	return e.JWTCookie
+}
+
 func (e *env) GetJWTLifetime() int {
 	return e.JWTLifeTime
 }

+ 75 - 0
internal/services/handler/admin/auth.go

@@ -0,0 +1,75 @@
+package admin
+
+import (
+	"database/sql"
+	"time"
+
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/interfaces"
+	"git.dmitriygnatenko.ru/dima/dmitriygnatenko-v2/internal/models"
+	"github.com/go-playground/validator/v10"
+	"github.com/gofiber/fiber/v2"
+)
+
+func LoginHandler(sp interfaces.ServiceProvider) fiber.Handler {
+	return func(fctx *fiber.Ctx) error {
+		ctx := fctx.Context()
+		var validate = validator.New()
+		var hasErrors bool
+
+		form := models.LoginForm{}
+
+		if fctx.Method() == fiber.MethodPost {
+			if err := fctx.BodyParser(&form); err != nil {
+				return err
+			}
+
+			if err := validate.Struct(form); err != nil {
+				hasErrors = true
+			}
+
+			if !hasErrors {
+				user, err := sp.GetUserRepository().Get(ctx, form.Username)
+				if err != nil {
+					if err != sql.ErrNoRows {
+						return err
+					}
+					hasErrors = true
+				}
+
+				if !hasErrors {
+					if sp.GetAuthService().IsCorrectPassword(form.Password, user.Password) {
+						token, err := sp.GetAuthService().GenerateToken(*user)
+						if err != nil {
+							return err
+						}
+
+						cookie := new(fiber.Cookie)
+						cookie.Name = sp.GetEnvService().GetJWTCookie()
+						cookie.Value = token
+						cookie.Expires = time.Now().Add(time.Duration(sp.GetEnvService().GetJWTLifetime()) * time.Second)
+						fctx.Cookie(cookie)
+
+						return fctx.Redirect("/admin")
+					}
+					hasErrors = true
+				}
+			}
+		}
+
+		return fctx.Render("admin/login", fiber.Map{
+			"form":      form,
+			"hasErrors": hasErrors,
+		})
+	}
+}
+
+func LogoutHandler(sp interfaces.ServiceProvider) fiber.Handler {
+	return func(fctx *fiber.Ctx) error {
+		cookie := new(fiber.Cookie)
+		cookie.Name = sp.GetEnvService().GetJWTCookie()
+		cookie.Expires = time.Now().Add(-1 * time.Second)
+		fctx.Cookie(cookie)
+
+		return fctx.Redirect("/admin/login")
+	}
+}

+ 5 - 0
internal/templates/admin/_layout.html

@@ -24,6 +24,11 @@
                             Теги
                         </a>
                     </li>
+                    <li class="nav-item mt-3 pt-3 border-top">
+                        <a class="nav-link" href="/admin/logout">
+                            Выход
+                        </a>
+                    </li>
                 </ul>
             </div>
         </nav>

+ 26 - 0
internal/templates/admin/login.html

@@ -0,0 +1,26 @@
+{{ $v := version }}<!doctype html>
+<html>
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Панель администрирования</title>
+    <link href="/favicon.ico" rel="shortcut icon" type="image/x-icon" />
+    <link href="/static/admin/css/bootstrap.min.css?v={{ $v }}" rel="stylesheet">
+    <link href="/static/admin/css/dashboard.css?v={{ $v }}" rel="stylesheet">
+</head>
+<body class="login-page">
+    <main class="login-form">
+        <form method="post">
+            <div class="form-floating">
+                <input type="text" name="username" class="form-control {{ if .hasErrors }}is-invalid{{ end }}" value="{{ .form.Username }}" id="formUsername" placeholder="Имя пользователя">
+                <label for="formUsername">Имя пользователя</label>
+            </div>
+            <div class="form-floating">
+                <input type="password" name="password" class="form-control {{ if .hasErrors }}is-invalid{{ end }}" value="{{ .form.Password }}" id="formPassword" placeholder="Пароль">
+                <label for="formPassword">Пароль</label>
+            </div>
+            <button class="w-100 btn btn-primary" type="submit">Авторизоваться</button>
+        </form>
+    </main>
+</body>
+</html>

+ 2 - 1
readme.md

@@ -27,6 +27,7 @@ SMTP_PASSWORD=5cCd5m2
 ERRORS_EMAIL=example@example.com
 
 JWT_SECRET_KEY=test_secret
+JWT_COOKIE=token
 JWT_LIFETIME=21600
 
 BASIC_AUTH_USER=user
@@ -54,4 +55,4 @@ GA_KEY=UA-1111111-1
 
 ### Метрики (защищены Basic auth)
 
-/metrics/
+/metrics

+ 29 - 0
web/static/admin/css/dashboard.css

@@ -1,6 +1,15 @@
+html, body {
+    height: 100%;
+}
+
 body {
     font-size: .875rem;
 }
+body.login-page {
+    display: flex;
+    align-items: center;
+    background-color: #f5f5f5;
+}
 
 .feather {
     width: 16px;
@@ -140,4 +149,24 @@ body {
     text-align: center;
     white-space: nowrap;
     -webkit-overflow-scrolling: touch;
+}
+
+/* Login page */
+.login-form {
+    width: 100%;
+    max-width: 330px;
+    padding: 15px 15px 50px 15px;
+    margin: auto;
+}
+.login-form .form-floating:focus-within {
+    z-index: 2;
+}
+.login-form input[type="text"] {
+    border-bottom-right-radius: 0;
+    border-bottom-left-radius: 0;
+}
+.login-form input[type="password"] {
+    margin-bottom: 10px;
+    border-top-left-radius: 0;
+    border-top-right-radius: 0;
 }