From fb4902dea6d79f142bd9457a5188c5f45198b91c Mon Sep 17 00:00:00 2001 From: ostiwe Date: Wed, 20 Aug 2025 01:24:49 +0300 Subject: [PATCH] feat: Added basic user authorization --- dto/auth.go | 27 ++++++++ migrations/00003_create_users_table.sql | 14 +++++ model/user.go | 25 ++++++++ modules/auth/auth.go | 3 + modules/auth/plain.go | 36 +++++++++++ modules/jwt/claims.go | 38 +++++++++++ modules/jwt/jwt.go | 83 +++++++++++++++++++++++++ pkg/http/errors.go | 1 + pkg/http/extract.go | 18 ++++++ repository/user.go | 31 +++++++++ router/controller/auth/controller.go | 63 +++++++++++++++++++ router/controller/service/controller.go | 14 ++++- router/midlleware/user.go | 33 ++++++++++ router/server.go | 2 + settings/settings.go | 10 ++- 15 files changed, 396 insertions(+), 2 deletions(-) create mode 100644 dto/auth.go create mode 100644 migrations/00003_create_users_table.sql create mode 100644 model/user.go create mode 100644 modules/auth/auth.go create mode 100644 modules/auth/plain.go create mode 100644 modules/jwt/claims.go create mode 100644 modules/jwt/jwt.go create mode 100644 repository/user.go create mode 100644 router/controller/auth/controller.go create mode 100644 router/midlleware/user.go diff --git a/dto/auth.go b/dto/auth.go new file mode 100644 index 0000000..a61e103 --- /dev/null +++ b/dto/auth.go @@ -0,0 +1,27 @@ +package dto + +import ( + "errors" + "net/http" +) + +type LoginRequest struct { + Login string `json:"login"` + Password string `json:"password"` +} + +func (l *LoginRequest) Bind(r *http.Request) error { + if l.Login == "" { + return errors.New("login required") + } + + if l.Password == "" { + return errors.New("password required") + } + + return nil +} + +type LoginResponse struct { + AccessToken string `json:"accessToken"` +} diff --git a/migrations/00003_create_users_table.sql b/migrations/00003_create_users_table.sql new file mode 100644 index 0000000..1f9eba3 --- /dev/null +++ b/migrations/00003_create_users_table.sql @@ -0,0 +1,14 @@ +-- +goose Up +create table if not exists users +( + id serial unique not null primary key, + login varchar(120) unique not null, + password varchar, + + created_at timestamptz default now() not null, + updated_at timestamptz default now() not null +); + +-- +goose Down + +drop table if exists users; diff --git a/model/user.go b/model/user.go new file mode 100644 index 0000000..fda2f0a --- /dev/null +++ b/model/user.go @@ -0,0 +1,25 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type User struct { + ID uint64 `json:"id"` + Login string `json:"login"` + Password string `json:"-"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (*User) TableName() string { + return "users" +} + +func (u *User) BeforeUpdate(*gorm.DB) error { + u.UpdatedAt = time.Now() + + return nil +} diff --git a/modules/auth/auth.go b/modules/auth/auth.go new file mode 100644 index 0000000..aa597c3 --- /dev/null +++ b/modules/auth/auth.go @@ -0,0 +1,3 @@ +package auth + +const BCRYPT_COST = 13 diff --git a/modules/auth/plain.go b/modules/auth/plain.go new file mode 100644 index 0000000..2a23398 --- /dev/null +++ b/modules/auth/plain.go @@ -0,0 +1,36 @@ +package auth + +import ( + "git.ostiwe.com/ostiwe-com/status/modules/jwt" + "git.ostiwe.com/ostiwe-com/status/repository" + "golang.org/x/crypto/bcrypt" +) + +type Module struct { + userRepository repository.User +} + +func New() Module { + return Module{ + userRepository: repository.NewUserRepository(), + } +} + +func (m *Module) Proceed(login, password string) (*string, error) { + lightweightUser, err := m.userRepository.FindByLogin(login) + if err != nil { + return nil, err + } + + err = bcrypt.CompareHashAndPassword([]byte(lightweightUser.Password), []byte(password)) + if err != nil { + return nil, err + } + + jwtString, err := jwt.CreateByUser(lightweightUser) + if err != nil { + return nil, err + } + + return &jwtString, nil +} diff --git a/modules/jwt/claims.go b/modules/jwt/claims.go new file mode 100644 index 0000000..070cc8c --- /dev/null +++ b/modules/jwt/claims.go @@ -0,0 +1,38 @@ +package jwt + +import ( + "os" + "slices" + "strings" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + *jwt.RegisteredClaims + + UserID uint64 `json:"userId"` +} + +func NewClaims() *Claims { + c := &Claims{ + RegisteredClaims: &jwt.RegisteredClaims{}, + } + + var ( + trustedHosts = os.Getenv("JWT_TRUSTED_HOSTS") + audienceList []string + ) + + c.Issuer = os.Getenv("APP_HOST") + + audienceList = append(audienceList, strings.Split(trustedHosts, ",")...) + audienceList = append(audienceList, c.Issuer) + audienceList = slices.DeleteFunc(audienceList, func(s string) bool { + return s == "" + }) + + c.Audience = audienceList + + return c +} diff --git a/modules/jwt/jwt.go b/modules/jwt/jwt.go new file mode 100644 index 0000000..eafe250 --- /dev/null +++ b/modules/jwt/jwt.go @@ -0,0 +1,83 @@ +package jwt + +import ( + "crypto/ed25519" + "os" + "strings" + "time" + + "git.ostiwe.com/ostiwe-com/status/model" + "git.ostiwe.com/ostiwe-com/status/settings" + "github.com/go-chi/jwtauth/v5" + "github.com/golang-jwt/jwt/v5" + "github.com/lestrrat-go/jwx/v2/jwa" +) + +var ( + signKey *ed25519.PrivateKey + publicSignKey *ed25519.PublicKey + signMethod jwt.SigningMethod + + TokenAuth *jwtauth.JWTAuth +) + +func init() { + jwtPublicKeyPath := os.Getenv("JWT_SIGN_PUBLIC_KEY_PATH") + if !strings.HasPrefix(jwtPublicKeyPath, "/") { + jwtPublicKeyPath = settings.WorkingDir + "/" + jwtPublicKeyPath + } + + jwtPrivateKeyPath := os.Getenv("JWT_SIGN_PRIVATE_KEY_PATH") + if !strings.HasPrefix(jwtPrivateKeyPath, "/") { + jwtPrivateKeyPath = settings.WorkingDir + "/" + jwtPrivateKeyPath + } + + publicFile, err := os.ReadFile(jwtPublicKeyPath) + if err != nil { + panic(err) + } + + privateFile, err := os.ReadFile(jwtPrivateKeyPath) + if err != nil { + panic(err) + } + + privateKey, err := jwt.ParseEdPrivateKeyFromPEM(privateFile) + if err != nil { + panic(err) + } + + publicKey, err := jwt.ParseEdPublicKeyFromPEM(publicFile) + if err != nil { + panic(err) + } + + pk, ok := privateKey.(ed25519.PrivateKey) + if !ok { + panic("invalid ed25519 private key") + } + + k, ok := publicKey.(ed25519.PublicKey) + if !ok { + panic("invalid ed25519 public key") + } + + signKey = &pk + publicSignKey = &k + signMethod = jwt.SigningMethodEdDSA + + TokenAuth = jwtauth.New(string(jwa.EdDSA), signKey, publicSignKey) +} + +func CreateByUser(user *model.User) (string, error) { + claims := NewClaims() + claims.Subject = user.Login + claims.UserID = user.ID + + claims.IssuedAt = jwt.NewNumericDate(time.Now()) + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 2)) + + token := jwt.NewWithClaims(signMethod, claims) + + return token.SignedString(signKey) +} diff --git a/pkg/http/errors.go b/pkg/http/errors.go index 811eeb1..f2b22e3 100644 --- a/pkg/http/errors.go +++ b/pkg/http/errors.go @@ -110,6 +110,7 @@ func (r responseErrBuilder) Send(response http.ResponseWriter, request *http.Req r.status = http.StatusInternalServerError } + response.Header().Set("Content-Type", "application/json") response.WriteHeader(r.status) _, err = response.Write(format) diff --git a/pkg/http/extract.go b/pkg/http/extract.go index 155777a..52f740e 100644 --- a/pkg/http/extract.go +++ b/pkg/http/extract.go @@ -1,8 +1,11 @@ package http import ( + "context" "net/http" "strconv" + + "git.ostiwe.com/ostiwe-com/status/model" ) func ExtractLimitOffset(r *http.Request) (int, int, ResponseErrReadyToSend) { @@ -44,3 +47,18 @@ func ExtractLimitOffset(r *http.Request) (int, int, ResponseErrReadyToSend) { return limitInt, offsetInt, nil } + +func GetUser(ctx context.Context) *model.User { + ctxValue := ctx.Value("user") + + if ctxValue == nil { + return nil + } + + user, ok := ctxValue.(*model.User) + if !ok { + return nil + } + + return user +} diff --git a/repository/user.go b/repository/user.go new file mode 100644 index 0000000..dac30b3 --- /dev/null +++ b/repository/user.go @@ -0,0 +1,31 @@ +package repository + +import ( + "git.ostiwe.com/ostiwe-com/status/model" + "git.ostiwe.com/ostiwe-com/status/modules/db" +) + +type User interface { + FindByLogin(login string) (*model.User, error) + FindByID(ID uint64) (*model.User, error) +} + +type userRepository struct { + repository +} + +func NewUserRepository() User { + return &userRepository{repository{db: db.Global}} +} + +func (u userRepository) FindByLogin(login string) (*model.User, error) { + var user *model.User + + return user, u.db.Find(&user, "login = ?", login).Error +} + +func (u userRepository) FindByID(ID uint64) (*model.User, error) { + var user *model.User + + return user, u.db.Find(&user, "id = ?", ID).Error +} diff --git a/router/controller/auth/controller.go b/router/controller/auth/controller.go new file mode 100644 index 0000000..9807a40 --- /dev/null +++ b/router/controller/auth/controller.go @@ -0,0 +1,63 @@ +package auth + +import ( + "net/http" + + "git.ostiwe.com/ostiwe-com/status/dto" + "git.ostiwe.com/ostiwe-com/status/modules/auth" + httpApp "git.ostiwe.com/ostiwe-com/status/pkg/http" + "git.ostiwe.com/ostiwe-com/status/router/controller" + "github.com/go-andiamo/chioas" + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" +) + +type Controller struct { + authModule auth.Module +} + +func (*Controller) New() controller.Controller { + return &Controller{ + authModule: auth.New(), + } +} + +func (c *Controller) plainLogin(w http.ResponseWriter, r *http.Request) { + var payload dto.LoginRequest + + if err := render.Bind(r, &payload); err != nil { + sendErr := httpApp.NewResponseErrBuilder().WithStatusCode(http.StatusBadRequest).WithMessage(err.Error()).Send(w, r) + if sendErr != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + return + } + + jwtString, err := c.authModule.Proceed(payload.Login, payload.Password) + if err != nil { + sendErr := httpApp.NewResponseErrBuilder().WithStatusCode(http.StatusBadRequest).WithMessage(err.Error()).Send(w, r) + if sendErr != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + return + } + + response := dto.LoginResponse{ + AccessToken: *jwtString, + } + + render.JSON(w, r, response) + w.WriteHeader(http.StatusOK) +} + +func (c *Controller) Group(r chi.Router) { + r.Post("/auth/plain", c.plainLogin) +} + +func (c *Controller) Documentate() (*chioas.Paths, *chioas.Components) { + return nil, nil +} diff --git a/router/controller/service/controller.go b/router/controller/service/controller.go index 3c5461b..617b2a1 100644 --- a/router/controller/service/controller.go +++ b/router/controller/service/controller.go @@ -3,13 +3,16 @@ package service import ( "net/http" + "git.ostiwe.com/ostiwe-com/status/modules/jwt" "git.ostiwe.com/ostiwe-com/status/modules/log" http2 "git.ostiwe.com/ostiwe-com/status/pkg/http" "git.ostiwe.com/ostiwe-com/status/repository" "git.ostiwe.com/ostiwe-com/status/router/controller" + "git.ostiwe.com/ostiwe-com/status/router/midlleware" "git.ostiwe.com/ostiwe-com/status/transform" "github.com/go-andiamo/chioas" "github.com/go-chi/chi/v5" + "github.com/go-chi/jwtauth/v5" ) type Controller struct { @@ -32,7 +35,16 @@ func (c *Controller) public(r chi.Router) { } func (c *Controller) internal(r chi.Router) { - r.Get("/api/v1/service", c.GetAllServices) + r.Group(func(r chi.Router) { + r.Use( + jwtauth.Verifier(jwt.TokenAuth), + jwtauth.Authenticator(jwt.TokenAuth), + midlleware.SetUserFromJWT, + ) + + r.Get("/api/v1/service", c.GetAllServices) + }) + } func (c *Controller) GetAllServicesPublic(w http.ResponseWriter, r *http.Request) { diff --git a/router/midlleware/user.go b/router/midlleware/user.go new file mode 100644 index 0000000..9ea1dc8 --- /dev/null +++ b/router/midlleware/user.go @@ -0,0 +1,33 @@ +package midlleware + +import ( + "context" + "net/http" + + "git.ostiwe.com/ostiwe-com/status/repository" + "github.com/go-chi/jwtauth/v5" +) + +func SetUserFromJWT(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + token, _, err := jwtauth.FromContext(ctx) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + + return + } + + userRepo := repository.NewUserRepository() + user, err := userRepo.FindByLogin(token.Subject()) + if err != nil || user == nil { + w.WriteHeader(http.StatusUnauthorized) + + return + } + + ctx = context.WithValue(ctx, "user", user) + next.ServeHTTP(w, r.WithContext(ctx)) + } + return http.HandlerFunc(fn) +} diff --git a/router/server.go b/router/server.go index 1e92791..c29b7ce 100644 --- a/router/server.go +++ b/router/server.go @@ -7,6 +7,7 @@ import ( "git.ostiwe.com/ostiwe-com/status/modules/log" "git.ostiwe.com/ostiwe-com/status/router/controller" + "git.ostiwe.com/ostiwe-com/status/router/controller/auth" "git.ostiwe.com/ostiwe-com/status/router/controller/ping" "git.ostiwe.com/ostiwe-com/status/router/controller/service" "git.ostiwe.com/ostiwe-com/status/version" @@ -19,6 +20,7 @@ func getControllers() []controller.Controller { return []controller.Controller{ new(ping.Controller).New(), new(service.Controller).New(), + new(auth.Controller).New(), } } diff --git a/settings/settings.go b/settings/settings.go index df26b7d..ad15572 100644 --- a/settings/settings.go +++ b/settings/settings.go @@ -11,12 +11,20 @@ import ( var ( AppStartTime time.Time + WorkingDir string ) func init() { appLog.SetGlobalManager(appLog.NewManager()) appLog.Global.Put(appLog.SYSTEM, logrus.New()) + var err error + + WorkingDir, err = os.Getwd() + if err != nil { + panic(err) + } + AppStartTime = time.Now() env := os.Getenv("APP_ENV") @@ -26,7 +34,7 @@ func init() { appLog.Global.Get(appLog.SYSTEM).Debug("Load environment variables for ", env, " env") - err := godotenv.Load() + err = godotenv.Load() if err != nil { appLog.Global.Get(appLog.SYSTEM).Errorf("Error loading .env file %v", err) appLog.Global.Get(appLog.SYSTEM).Exit(1)