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
|
||||
}
|
||||
|
||||
response.Header().Set("Content-Type", "application/json")
|
||||
response.WriteHeader(r.status)
|
||||
|
||||
_, err = response.Write(format)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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 (
|
||||
"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.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) {
|
||||
|
||||
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/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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user