feat: Added basic user authorization

This commit is contained in:
2025-08-20 01:24:49 +03:00
parent b72066f19e
commit fb4902dea6
15 changed files with 396 additions and 2 deletions

27
dto/auth.go Normal file
View File

@@ -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"`
}

View File

@@ -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;

25
model/user.go Normal file
View File

@@ -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
}

3
modules/auth/auth.go Normal file
View File

@@ -0,0 +1,3 @@
package auth
const BCRYPT_COST = 13

36
modules/auth/plain.go Normal file
View File

@@ -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
}

38
modules/jwt/claims.go Normal file
View File

@@ -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
}

83
modules/jwt/jwt.go Normal file
View File

@@ -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)
}

View File

@@ -110,6 +110,7 @@ func (r responseErrBuilder) Send(response http.ResponseWriter, request *http.Req
r.status = http.StatusInternalServerError r.status = http.StatusInternalServerError
} }
response.Header().Set("Content-Type", "application/json")
response.WriteHeader(r.status) response.WriteHeader(r.status)
_, err = response.Write(format) _, err = response.Write(format)

View File

@@ -1,8 +1,11 @@
package http package http
import ( import (
"context"
"net/http" "net/http"
"strconv" "strconv"
"git.ostiwe.com/ostiwe-com/status/model"
) )
func ExtractLimitOffset(r *http.Request) (int, int, ResponseErrReadyToSend) { func ExtractLimitOffset(r *http.Request) (int, int, ResponseErrReadyToSend) {
@@ -44,3 +47,18 @@ func ExtractLimitOffset(r *http.Request) (int, int, ResponseErrReadyToSend) {
return limitInt, offsetInt, nil 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
}

31
repository/user.go Normal file
View File

@@ -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
}

View File

@@ -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
}

View File

@@ -3,13 +3,16 @@ package service
import ( import (
"net/http" "net/http"
"git.ostiwe.com/ostiwe-com/status/modules/jwt"
"git.ostiwe.com/ostiwe-com/status/modules/log" "git.ostiwe.com/ostiwe-com/status/modules/log"
http2 "git.ostiwe.com/ostiwe-com/status/pkg/http" http2 "git.ostiwe.com/ostiwe-com/status/pkg/http"
"git.ostiwe.com/ostiwe-com/status/repository" "git.ostiwe.com/ostiwe-com/status/repository"
"git.ostiwe.com/ostiwe-com/status/router/controller" "git.ostiwe.com/ostiwe-com/status/router/controller"
"git.ostiwe.com/ostiwe-com/status/router/midlleware"
"git.ostiwe.com/ostiwe-com/status/transform" "git.ostiwe.com/ostiwe-com/status/transform"
"github.com/go-andiamo/chioas" "github.com/go-andiamo/chioas"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/jwtauth/v5"
) )
type Controller struct { type Controller struct {
@@ -32,7 +35,16 @@ func (c *Controller) public(r chi.Router) {
} }
func (c *Controller) internal(r chi.Router) { func (c *Controller) internal(r chi.Router) {
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) r.Get("/api/v1/service", c.GetAllServices)
})
} }
func (c *Controller) GetAllServicesPublic(w http.ResponseWriter, r *http.Request) { func (c *Controller) GetAllServicesPublic(w http.ResponseWriter, r *http.Request) {

33
router/midlleware/user.go Normal file
View File

@@ -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)
}

View File

@@ -7,6 +7,7 @@ import (
"git.ostiwe.com/ostiwe-com/status/modules/log" "git.ostiwe.com/ostiwe-com/status/modules/log"
"git.ostiwe.com/ostiwe-com/status/router/controller" "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/ping"
"git.ostiwe.com/ostiwe-com/status/router/controller/service" "git.ostiwe.com/ostiwe-com/status/router/controller/service"
"git.ostiwe.com/ostiwe-com/status/version" "git.ostiwe.com/ostiwe-com/status/version"
@@ -19,6 +20,7 @@ func getControllers() []controller.Controller {
return []controller.Controller{ return []controller.Controller{
new(ping.Controller).New(), new(ping.Controller).New(),
new(service.Controller).New(), new(service.Controller).New(),
new(auth.Controller).New(),
} }
} }

View File

@@ -11,12 +11,20 @@ import (
var ( var (
AppStartTime time.Time AppStartTime time.Time
WorkingDir string
) )
func init() { func init() {
appLog.SetGlobalManager(appLog.NewManager()) appLog.SetGlobalManager(appLog.NewManager())
appLog.Global.Put(appLog.SYSTEM, logrus.New()) appLog.Global.Put(appLog.SYSTEM, logrus.New())
var err error
WorkingDir, err = os.Getwd()
if err != nil {
panic(err)
}
AppStartTime = time.Now() AppStartTime = time.Now()
env := os.Getenv("APP_ENV") env := os.Getenv("APP_ENV")
@@ -26,7 +34,7 @@ func init() {
appLog.Global.Get(appLog.SYSTEM).Debug("Load environment variables for ", env, " env") appLog.Global.Get(appLog.SYSTEM).Debug("Load environment variables for ", env, " env")
err := godotenv.Load() err = godotenv.Load()
if err != nil { if err != nil {
appLog.Global.Get(appLog.SYSTEM).Errorf("Error loading .env file %v", err) appLog.Global.Get(appLog.SYSTEM).Errorf("Error loading .env file %v", err)
appLog.Global.Get(appLog.SYSTEM).Exit(1) appLog.Global.Get(appLog.SYSTEM).Exit(1)