feat: Added basic user authorization
This commit is contained in:
27
dto/auth.go
Normal file
27
dto/auth.go
Normal 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"`
|
||||||
|
}
|
||||||
14
migrations/00003_create_users_table.sql
Normal file
14
migrations/00003_create_users_table.sql
Normal 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
25
model/user.go
Normal 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
3
modules/auth/auth.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
const BCRYPT_COST = 13
|
||||||
36
modules/auth/plain.go
Normal file
36
modules/auth/plain.go
Normal 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
38
modules/jwt/claims.go
Normal 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
83
modules/jwt/jwt.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
31
repository/user.go
Normal 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
|
||||||
|
}
|
||||||
63
router/controller/auth/controller.go
Normal file
63
router/controller/auth/controller.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
33
router/midlleware/user.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user