fiumba
This commit is contained in:
21
src/client/api/async/model.go
Normal file
21
src/client/api/async/model.go
Normal file
@ -0,0 +1,21 @@
|
||||
// Package async defines functions to assist application with parallel jobs
|
||||
package async
|
||||
|
||||
//go:generate go-enum -f=$GOFILE --lower --marshal
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
//nolint:varcheck // This is ok to keep here for future use. Remove this comment when start using qos constant.
|
||||
const (
|
||||
qos = 2 // Quality of Service. 2 -> Only once
|
||||
retryInterval = time.Second * 5
|
||||
)
|
||||
|
||||
// Origin specify where is the server located
|
||||
// ENUM(
|
||||
// Local
|
||||
// Server
|
||||
// )
|
||||
type Origin int //nolint:recvcheck // The methods of this are autogenerated
|
||||
186
src/client/api/async/mqtt_client.go
Normal file
186
src/client/api/async/mqtt_client.go
Normal file
@ -0,0 +1,186 @@
|
||||
package async
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
blog "log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
mqtt "github.com/eclipse/paho.mqtt.golang"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"quantex.com/qfixdpl/src/app"
|
||||
"quantex.com/qfixdpl/src/app/version"
|
||||
"quantex.com/qfixdpl/src/common/tracerr"
|
||||
"quantex.com/qfixdpl/src/domain"
|
||||
)
|
||||
|
||||
const defaultTokenExpireSeconds = 60
|
||||
|
||||
type MQTTManager struct {
|
||||
client mqtt.Client
|
||||
config app.Async
|
||||
notify domain.Notifier
|
||||
}
|
||||
|
||||
func New(cfg app.Async, n domain.Notifier) *MQTTManager {
|
||||
manager := &MQTTManager{
|
||||
config: cfg,
|
||||
notify: n,
|
||||
}
|
||||
|
||||
manager.client = manager.newClient(version.AppName+strconv.FormatInt(time.Now().Unix(), 10), nil)
|
||||
|
||||
return manager
|
||||
}
|
||||
|
||||
func (m *MQTTManager) Start() {
|
||||
token := m.client.Connect()
|
||||
if token.Wait() && token.Error() != nil {
|
||||
slog.Error("Error trying to connect to broker = " + token.Error().Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
slog.Info("MQTT Client successfully created!")
|
||||
}
|
||||
|
||||
//nolint:ireturn,nolintlint // We don't control this
|
||||
func (m *MQTTManager) newClient(clientID string, onConnectHandler mqtt.OnConnectHandler) mqtt.Client {
|
||||
mqtt.ERROR = blog.New(os.Stdout, "", 0)
|
||||
clientID = clientID + "_" + strconv.Itoa(time.Now().Nanosecond())
|
||||
opts := mqtt.NewClientOptions()
|
||||
|
||||
err := checkMQTTConfig(m.config)
|
||||
if err != nil {
|
||||
panic(err.Error())
|
||||
}
|
||||
|
||||
mqttBkr := fmt.Sprintf("%s://%s", m.config.Protocol, m.config.URL)
|
||||
|
||||
opts.AddBroker(mqttBkr).SetClientID(clientID)
|
||||
opts.SetAutoReconnect(true)
|
||||
opts.SetKeepAlive(5 * time.Second)
|
||||
opts.SetPingTimeout(2 * time.Second)
|
||||
opts.SetMaxReconnectInterval(2 * time.Second)
|
||||
opts.SetDefaultPublishHandler(msgHandler)
|
||||
opts.SetCredentialsProvider(m.credentialHandler)
|
||||
opts.OnConnectionLost = m.connectionLostHandler
|
||||
|
||||
if onConnectHandler != nil {
|
||||
opts.SetOnConnectHandler(onConnectHandler)
|
||||
}
|
||||
|
||||
return mqtt.NewClient(opts)
|
||||
}
|
||||
|
||||
func (m *MQTTManager) credentialHandler() (username, password string) {
|
||||
token, err := generateMqttToken(version.AppName, m.config.Secret, defaultTokenExpireSeconds)
|
||||
if err != nil {
|
||||
msg := tracerr.Errorf("Error getting token = %w", err)
|
||||
slog.Error(msg.Error())
|
||||
|
||||
m.notify.SendMsg(domain.MessageChannelError, msg.Error(), domain.MessageStatusStopper, nil)
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return version.AppName, token
|
||||
}
|
||||
|
||||
func (m *MQTTManager) Subscribe(topic string, handler func(topic string, msg []byte)) {
|
||||
t := path.Join(m.config.Subdomain, "quantex/", topic)
|
||||
token := m.client.Subscribe(t, qos, func(_ mqtt.Client, msg mqtt.Message) {
|
||||
handler(msg.Topic(), msg.Payload())
|
||||
})
|
||||
m.CheckSubscribe(token, t)
|
||||
}
|
||||
|
||||
func (m *MQTTManager) Publish(topic string, msg any) {
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
slog.Error("error. could not send alert msg: " + err.Error())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Publish a message
|
||||
t := path.Join(m.config.Subdomain, "quantex/", topic)
|
||||
token := m.client.Publish(t, qos, false, payload)
|
||||
m.CheckPublish(token, t, payload)
|
||||
}
|
||||
|
||||
func (m *MQTTManager) connectionLostHandler(client mqtt.Client, reason error) {
|
||||
opts := client.OptionsReader()
|
||||
msg := fmt.Sprintf("MQTT Connection lost for client: %s. Reason: %s", opts.ClientID(), reason.Error())
|
||||
slog.Warn(msg)
|
||||
m.notify.SendMsg(domain.MessageChannelError, msg, domain.MessageStatusWarning, nil)
|
||||
}
|
||||
|
||||
func msgHandler(_ mqtt.Client, msg mqtt.Message) {
|
||||
slog.Info(fmt.Sprintf("Message received: [mqtt] -> [A] | received: '%s' topic: '%s'",
|
||||
msg.Payload(), msg.Topic()))
|
||||
}
|
||||
|
||||
func generateMqttToken(user, secret string, exp int64) (string, error) {
|
||||
// Create a new token object, specifying signing method and the claims
|
||||
// you would like it to contain.
|
||||
now := time.Now().Unix()
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"username": user,
|
||||
"exp": now + exp,
|
||||
"iat": now,
|
||||
})
|
||||
|
||||
// Sign and get the complete encoded token as a string using the secret
|
||||
tokenString, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
e := tracerr.Errorf("error generating the MQTT token %w", err)
|
||||
|
||||
return "", e
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
// CheckPublish check the result of publish action.
|
||||
// For asynchronous check use:
|
||||
// go CheckPublish(token, topic, payload)
|
||||
func (m *MQTTManager) CheckPublish(token mqtt.Token, topic string, payload []byte) {
|
||||
slog.Info("[mqtt: %s] <- %s\n", topic, string(payload))
|
||||
|
||||
errMsg := tracerr.Errorf("MQTT Publish Error. Topic: %s. Error: %w", topic, token.Error())
|
||||
m.checkToken(token, errMsg)
|
||||
}
|
||||
|
||||
// CheckSubscribe check the result of subscribe action.
|
||||
// For asynchronous check use:
|
||||
// go CheckSubscribe(token, topic)
|
||||
func (m *MQTTManager) CheckSubscribe(token mqtt.Token, topic string) {
|
||||
slog.Info("subscribing to [mqtt: " + topic + "]")
|
||||
|
||||
errMsg := tracerr.Errorf("MQTT Subscriber Error. Topic: %s. Error: %w", topic, token.Error())
|
||||
m.checkToken(token, errMsg)
|
||||
}
|
||||
|
||||
func (m *MQTTManager) checkToken(token mqtt.Token, errMsg error) {
|
||||
if token.Wait() && token.Error() != nil {
|
||||
err := tracerr.Errorf("checkToken error: %w", errMsg)
|
||||
slog.Error(err.Error())
|
||||
m.notify.SendMsg(domain.MessageChannelError, err.Error(), domain.MessageStatusWarning, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func checkMQTTConfig(config app.Async) error {
|
||||
if config.Protocol == "" ||
|
||||
config.URL == "" ||
|
||||
config.Subdomain == "" ||
|
||||
config.Secret == "" {
|
||||
return tracerr.Errorf("mqtt configuration is needed: Protocol, URL, Subdomain and/or Secret are empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
290
src/client/api/rest/controller.go
Normal file
290
src/client/api/rest/controller.go
Normal file
@ -0,0 +1,290 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gomodule/redigo/redis"
|
||||
"github.com/sasha-s/go-deadlock"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
|
||||
"quantex.com/qfixdpl/src/app"
|
||||
"quantex.com/qfixdpl/src/app/version"
|
||||
"quantex.com/qfixdpl/src/client/store"
|
||||
"quantex.com/qfixdpl/src/common/tracerr"
|
||||
"quantex.com/qfixdpl/src/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
ProdEnv = "prod"
|
||||
TokenExpireTime = 1200 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
responseKey = "responseKey"
|
||||
sessionTokenKey = "sessionTokenKey"
|
||||
)
|
||||
|
||||
type Controller struct {
|
||||
pool *redis.Pool
|
||||
userData app.UserDataProvider
|
||||
store *store.Store
|
||||
config Config
|
||||
notify domain.Notifier
|
||||
authMutex deadlock.Mutex
|
||||
}
|
||||
|
||||
func newController(pool *redis.Pool, userData app.UserDataProvider,
|
||||
s *store.Store, config Config, n domain.Notifier,
|
||||
) *Controller {
|
||||
return &Controller{
|
||||
pool: pool,
|
||||
userData: userData,
|
||||
store: s,
|
||||
config: config,
|
||||
notify: n,
|
||||
}
|
||||
}
|
||||
|
||||
func (cont *Controller) GetUser(ctx *gin.Context) app.User {
|
||||
// This is set on the AuthRequired middleware
|
||||
response, ok := ctx.Get(responseKey)
|
||||
if !ok {
|
||||
// TODO log this issue
|
||||
ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "internal server error"})
|
||||
}
|
||||
|
||||
val, ok := response.([]uint8)
|
||||
if !ok {
|
||||
// TODO log this issue
|
||||
ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "internal server error"})
|
||||
}
|
||||
|
||||
return cont.userData.GetUserByEmail(string(val))
|
||||
}
|
||||
|
||||
// Login godoc
|
||||
// @Summary Login
|
||||
// @Description Authenticate a User using credentials
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param credentials body Credentials true "Authentication"
|
||||
// @Success 200 {object} Session
|
||||
// @Failure 400 {object} HTTPError
|
||||
// @Failure 401 {object} HTTPError
|
||||
// @Failure 404 {object} HTTPError
|
||||
// @Failure 500 {object} HTTPError
|
||||
// @Router /auth/login [post]
|
||||
func (cont *Controller) Login(ctx *gin.Context) {
|
||||
defer cont.authMutex.Unlock()
|
||||
cont.authMutex.Lock()
|
||||
|
||||
setHeaders(ctx, cont.config)
|
||||
|
||||
// Get the JSON body and decode into credentials
|
||||
var creds Credentials
|
||||
if err := ctx.ShouldBindJSON(&creds); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, HTTPError{Error: err.Error()})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Get the expected Password from our in memory map
|
||||
|
||||
expectedUser := cont.userData.GetUserByEmail(creds.Email)
|
||||
|
||||
// If a Password exists for the given User
|
||||
// AND, if it is the same as the Password we received, the we can move ahead
|
||||
// if NOT, then we return an "Unauthorized" status
|
||||
if expectedUser.Email == "" || expectedUser.Password != creds.Password {
|
||||
ctx.JSON(http.StatusUnauthorized, HTTPError{Error: "Invalid credentials"})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new random session token
|
||||
sessionToken := uuid.NewV4().String()
|
||||
|
||||
// Set the token in the cache, along with the User whom it represents
|
||||
// The token has an expiry time of 120 seconds
|
||||
conn := cont.pool.Get()
|
||||
defer func() {
|
||||
err := conn.Close()
|
||||
if err != nil {
|
||||
e := tracerr.Errorf("error closing connection: %w", err)
|
||||
slog.Error(e.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
_, err := conn.Do("SETEX", sessionToken, TokenExpireTime, creds.Email)
|
||||
if err != nil {
|
||||
slog.Error(tracerr.Errorf("Error setting token in redis cache: %w", err).Error())
|
||||
// If there is an error in setting the cache, return an internal server error
|
||||
ctx.JSON(http.StatusInternalServerError, HTTPError{Error: err.Error()})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Finally, we set the client cookie for "session_token" as the session token we just generated
|
||||
// we also set an expiry time of 120 seconds, the same as the cache
|
||||
|
||||
// Finally, we set the client cookie for "session_token" as the session token we just generated
|
||||
// we also set an expiry time of TokenExpireTime, the same as the cache
|
||||
cookie := &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: sessionToken,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
Expires: time.Now().Add(TokenExpireTime),
|
||||
}
|
||||
|
||||
if version.Environment() == version.EnvironmentTypeDev {
|
||||
cookie.SameSite = http.SameSiteNoneMode
|
||||
}
|
||||
|
||||
http.SetCookie(ctx.Writer, cookie)
|
||||
|
||||
ctx.JSON(http.StatusOK, Session{Email: creds.Email})
|
||||
}
|
||||
|
||||
// Refresh godoc
|
||||
// @Summary Refresh the authorization token
|
||||
// @Description This endpoint must be called periodically to get a new token before the current one expires
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} Msg
|
||||
// @Failure 400 {object} HTTPError
|
||||
// @Failure 401 {object} HTTPError
|
||||
// @Failure 404 {object} HTTPError
|
||||
// @Failure 500 {object} HTTPError
|
||||
// @Router /auth/refresh [get]
|
||||
func (cont *Controller) Refresh(ctx *gin.Context) {
|
||||
defer cont.authMutex.Unlock()
|
||||
cont.authMutex.Lock()
|
||||
|
||||
setHeaders(ctx, cont.config)
|
||||
|
||||
// (BEGIN) The code until this point is the same as the first part of the `Welcome` route
|
||||
response, ok1 := ctx.Get(responseKey)
|
||||
if !ok1 {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"})
|
||||
}
|
||||
// (END) The code uptil this point is the same as the first part of the `Welcome` route
|
||||
|
||||
// Now, create a new session token for the current User
|
||||
newSessionToken := uuid.NewV4().String()
|
||||
|
||||
conn := cont.pool.Get()
|
||||
defer func() {
|
||||
err := conn.Close()
|
||||
if err != nil {
|
||||
e := tracerr.Errorf("error closing connection: %w", err)
|
||||
slog.Error(e.Error())
|
||||
}
|
||||
}()
|
||||
|
||||
_, err := conn.Do("SETEX", newSessionToken, "120", fmt.Sprintf("%s", response))
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Set the new token as the Users `session_token` cookie
|
||||
cookie := &http.Cookie{
|
||||
Name: "session_token",
|
||||
Value: newSessionToken,
|
||||
HttpOnly: true,
|
||||
Path: "/",
|
||||
Secure: true,
|
||||
Expires: time.Now().Add(TokenExpireTime),
|
||||
}
|
||||
|
||||
if version.Environment() == version.EnvironmentTypeDev {
|
||||
cookie.SameSite = http.SameSiteNoneMode
|
||||
}
|
||||
|
||||
http.SetCookie(ctx.Writer, cookie)
|
||||
|
||||
ctx.JSON(http.StatusOK, Msg{Text: "Token updated"})
|
||||
}
|
||||
|
||||
// HealthCheck godoc
|
||||
// @Summary Health check
|
||||
// @Description Return service health
|
||||
// @Tags health
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /health [get]
|
||||
func (cont *Controller) HealthCheck(ctx *gin.Context) {
|
||||
// ensure CORS and other headers are set consistently
|
||||
setHeaders(ctx, cont.config)
|
||||
|
||||
status := struct {
|
||||
Status string `json:"status"`
|
||||
Build string `json:"build"`
|
||||
Sha string `json:"sha"`
|
||||
JwtAuthentications string `json:"jwtAuthentications,omitempty"`
|
||||
}{
|
||||
Status: "ok",
|
||||
Build: version.BuildBranch(),
|
||||
Sha: version.BuildHash(),
|
||||
}
|
||||
|
||||
// Only check JWT authentication if enabled
|
||||
if cont.config.EnableJWTAuth {
|
||||
status.JwtAuthentications = "ok"
|
||||
|
||||
user, err := cont.store.UserByEmail("fede")
|
||||
if err != nil || user == nil {
|
||||
|
||||
status.JwtAuthentications = "error"
|
||||
status.Status = "degraded"
|
||||
|
||||
err = tracerr.Errorf("error fetching user: %w", err)
|
||||
|
||||
slog.Error(err.Error())
|
||||
|
||||
ctx.JSON(http.StatusInternalServerError, status)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// return a minimal JSON health response
|
||||
ctx.JSON(http.StatusOK, status)
|
||||
}
|
||||
|
||||
// revive:disable:cyclomatic // We need this complexity
|
||||
func setHeaders(ctx *gin.Context, config Config) {
|
||||
origin := ctx.Request.Header.Get("Origin")
|
||||
|
||||
if allowed(origin, config) {
|
||||
ctx.Writer.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
ctx.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
ctx.Writer.Header().Set("Access-Control-Allow-Headers",
|
||||
"Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin,"+
|
||||
"UserCache-Control, X-Requested-With")
|
||||
ctx.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
|
||||
}
|
||||
}
|
||||
|
||||
func allowed(origin string, config Config) bool {
|
||||
if version.Environment() == version.EnvironmentTypeProd {
|
||||
return origin == "https://monitor.quantex.com.ar"
|
||||
}
|
||||
|
||||
for _, o := range config.AllowedOrigins {
|
||||
if o == origin {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
254
src/client/api/rest/midlewares.go
Normal file
254
src/client/api/rest/midlewares.go
Normal file
@ -0,0 +1,254 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/rs/zerolog/log"
|
||||
|
||||
"quantex.com/qfixdpl/src/app"
|
||||
jwttoken "quantex.com/qfixdpl/src/common/jwttoken"
|
||||
"quantex.com/qfixdpl/src/common/tracerr"
|
||||
)
|
||||
|
||||
const ErrorField = "error"
|
||||
|
||||
func (cont *Controller) PartyAdmin(c *gin.Context) {
|
||||
if cont.GetUser(c).IsPartyAdmin {
|
||||
return
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ErrorField: "Unauthorized Admin"})
|
||||
}
|
||||
|
||||
func (cont *Controller) CanSendOrder(c *gin.Context) {
|
||||
if cont.GetUser(c).IsViewer {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ErrorField: "Rol Viewer is unauthorized to send orders"})
|
||||
}
|
||||
}
|
||||
|
||||
func (cont *Controller) Middleman(c *gin.Context) {
|
||||
if cont.GetUser(c).IsMiddleman {
|
||||
return
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ErrorField: "Unauthorized Middleman"})
|
||||
}
|
||||
|
||||
func (cont *Controller) BackOfficeUser(c *gin.Context) {
|
||||
if cont.GetUser(c).IsBackOffice {
|
||||
return
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ErrorField: "Unauthorized User"})
|
||||
}
|
||||
|
||||
func (cont *Controller) SuperUser(c *gin.Context) {
|
||||
if cont.GetUser(c).IsSuperUser {
|
||||
return
|
||||
}
|
||||
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ErrorField: "Unauthorized Admin User"})
|
||||
}
|
||||
|
||||
func (cont *Controller) Options(c *gin.Context) {
|
||||
if c.Request.Method != http.MethodOptions {
|
||||
c.Next()
|
||||
} else {
|
||||
setHeaders(c, cont.config)
|
||||
c.AbortWithStatus(http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func (cont *Controller) AuthRequired(ctx *gin.Context) {
|
||||
setHeaders(ctx, cont.config)
|
||||
|
||||
if c, err := ctx.Cookie("session_token"); c != "" && err == nil {
|
||||
cont.SessionCookieAuth(ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// check header for Token Auth
|
||||
reqToken := ctx.GetHeader("Authorization")
|
||||
if reqToken != "" {
|
||||
cont.AuthorizationAuth(reqToken, ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Error().Msg("Token Auth Unauthorized: missing session cookie and Authorization header")
|
||||
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ErrorField: "Unauthorized - AuthRequired 1"})
|
||||
}
|
||||
|
||||
func (cont *Controller) AuthorizationAuth(reqToken string, ctx *gin.Context) {
|
||||
token := strings.TrimSpace(strings.TrimPrefix(reqToken, "Bearer"))
|
||||
|
||||
if cont.config.EnableJWTAuth && jwttoken.IsJWT(token) {
|
||||
cont.JWTTokenAuth(token, ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
cont.BearerTokenAuth(token, ctx)
|
||||
}
|
||||
|
||||
func (cont *Controller) BearerTokenAuth(reqToken string, ctx *gin.Context) {
|
||||
if !strings.HasPrefix(reqToken, "Bearer ") {
|
||||
log.Error().Msg("Token Auth Unauthorized: missing Bearer prefix")
|
||||
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ErrorField: "Token Auth Unauthorized"})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
token := strings.Split(reqToken, "Bearer ")
|
||||
if len(token) != 2 {
|
||||
|
||||
log.Error().Msg("Token Auth Unauthorized at TokenAuth: invalid token format")
|
||||
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ErrorField: "Token Auth Unauthorized 1"})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
user, err := cont.validateUserToken(token[1])
|
||||
if err != nil {
|
||||
err = errors.New("Token Auth Unauthorized at TokenAuth - %s" + err.Error())
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ErrorField: "Token Auth Unauthorized 2"})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Info().Msgf("User %s authenticated successfully at TokenAuth", user.Email)
|
||||
|
||||
ctx.Set(responseKey, []byte(user.Email))
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func (cont *Controller) JWTTokenAuth(token string, ctx *gin.Context) {
|
||||
from := ctx.Query("from")
|
||||
|
||||
serviceAuth, err := jwttoken.Validate(from, token, cont.config.AuthorizedServices)
|
||||
if err != nil || serviceAuth == nil || serviceAuth.Token == nil {
|
||||
err := tracerr.Errorf("invalid token or claims: %w", err)
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized,
|
||||
gin.H{ErrorField: err.Error()})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
user, err := cont.validateUserToken(*serviceAuth.Token)
|
||||
if err != nil {
|
||||
err = tracerr.Errorf("Token Auth Unauthorized at JWTTokenAuth: %w", err)
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ErrorField: "Invalid credentials"})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Set("authorized_service", serviceAuth)
|
||||
ctx.Set(responseKey, []byte(user.Email)) // TODO: will services be treated as users? if not remove this line
|
||||
|
||||
log.Info().Str("issuer", serviceAuth.Name).Msg("Service authenticated successfully")
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func (cont *Controller) validateUserToken(token string) (user *app.User, err error) {
|
||||
userInfo := strings.Split(token, ":")
|
||||
if len(userInfo) != 2 || userInfo[1] == "" {
|
||||
err = tracerr.Errorf("invalid token format at validateUserToken")
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
email := userInfo[0]
|
||||
|
||||
user, err = cont.store.UserByEmail(email)
|
||||
if user == nil || err != nil {
|
||||
err = tracerr.Errorf("user not found at validateUserToken: %w", err)
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tkn := userInfo[1]
|
||||
|
||||
if user.Token != tkn {
|
||||
err = tracerr.Errorf("invalid token credentials at validateUserToken")
|
||||
|
||||
log.Error().Msg(err.Error())
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Info().Str("email", user.Email).Msg("Service user validated successfully")
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (cont *Controller) SessionCookieAuth(ctx *gin.Context) {
|
||||
// We can obtain the session token from the requests cookies, which come with every handler
|
||||
sessionToken, err := ctx.Cookie("session_token")
|
||||
if err != nil {
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
// If the cookie is not set, return an unauthorized status
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ErrorField: "Unauthorized - AuthRequired 1"})
|
||||
|
||||
return
|
||||
}
|
||||
// For any other type of error, return a bad handler status
|
||||
ctx.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ErrorField: "Bad handler - AuthRequired 2"})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
cont.validateCookieToExternal(sessionToken, ctx)
|
||||
}
|
||||
|
||||
func (cont *Controller) validateCookieToExternal(sessionToken string, ctx *gin.Context) {
|
||||
ok, err := cont.store.ValidateSession(sessionToken)
|
||||
if err != nil {
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ErrorField: "Unauthorized - AuthRequired 4"})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !ok {
|
||||
// If the session token is not valid, return an unauthorized error
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ErrorField: "Unauthorized - AuthRequired 5"})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func IPWhiteList(whitelist map[string]bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !whitelist[c.ClientIP()] {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"status": http.StatusForbidden,
|
||||
"message": "Permission denied",
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
18
src/client/api/rest/model.go
Normal file
18
src/client/api/rest/model.go
Normal file
@ -0,0 +1,18 @@
|
||||
package rest
|
||||
|
||||
type HTTPError struct {
|
||||
Error string
|
||||
}
|
||||
|
||||
type Msg struct {
|
||||
Text string
|
||||
}
|
||||
|
||||
type Credentials struct {
|
||||
Email string `json:"email" binding:"required" example:"user1"`
|
||||
Password string `json:"password" binding:"required" example:"password1"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Email string
|
||||
}
|
||||
39
src/client/api/rest/routes.go
Normal file
39
src/client/api/rest/routes.go
Normal file
@ -0,0 +1,39 @@
|
||||
package rest
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
|
||||
_ "quantex.com/qfixdpl/src/client/api/rest/docs" // Swag needs this import to work properly
|
||||
)
|
||||
|
||||
func SetRoutes(api *API) {
|
||||
cont := api.Controller
|
||||
|
||||
v1 := api.Router.Group("/qfixdpl/v1")
|
||||
api.Router.Use(cont.Options)
|
||||
{
|
||||
auth := v1.Group("/auth")
|
||||
auth.POST("/login", cont.Login)
|
||||
}
|
||||
|
||||
qfixdpl := v1.Group("/")
|
||||
qfixdpl.Use(cont.AuthRequired)
|
||||
qfixdpl.GET("/health", cont.HealthCheck)
|
||||
|
||||
backoffice := qfixdpl.Group("/backoffice")
|
||||
backoffice.Use(cont.BackOfficeUser)
|
||||
|
||||
admin := qfixdpl.Group("/admin")
|
||||
admin.Use(cont.SuperUser)
|
||||
|
||||
SetSwagger(v1, cont)
|
||||
}
|
||||
|
||||
func SetSwagger(path *gin.RouterGroup, cont *Controller) {
|
||||
auth := path.Group("/")
|
||||
auth.Use(cont.AuthRequired)
|
||||
|
||||
auth.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
}
|
||||
95
src/client/api/rest/server.go
Normal file
95
src/client/api/rest/server.go
Normal file
@ -0,0 +1,95 @@
|
||||
// Package rest defines all API rest functionality
|
||||
package rest
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gomodule/redigo/redis"
|
||||
|
||||
"quantex.com/qfixdpl/src/app"
|
||||
"quantex.com/qfixdpl/src/app/version"
|
||||
"quantex.com/qfixdpl/src/client/store"
|
||||
"quantex.com/qfixdpl/src/common/logger"
|
||||
"quantex.com/qfixdpl/src/common/tracerr"
|
||||
"quantex.com/qfixdpl/src/domain"
|
||||
)
|
||||
|
||||
const RedisMaxIdle = 3000 // In ms
|
||||
|
||||
type API struct {
|
||||
Router *gin.Engine
|
||||
Controller *Controller
|
||||
Port string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
AllowedOrigins []string
|
||||
External map[string]app.ExtAuth `toml:"External"`
|
||||
AuthorizedServices map[string]app.AuthorizedService `toml:"AuthorizedServices"`
|
||||
Port string
|
||||
EnableJWTAuth bool
|
||||
}
|
||||
|
||||
func New(userData app.UserDataProvider, storeInstance *store.Store, config Config, notify domain.Notifier) *API {
|
||||
// Set up Gin
|
||||
var engine *gin.Engine
|
||||
if version.Environment() == version.EnvironmentTypeProd {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
engine = gin.New()
|
||||
engine.Use(gin.Recovery())
|
||||
// Use a custom logger middleware
|
||||
engine.Use(logger.GinLoggerMiddleware(slog.Default()))
|
||||
} else {
|
||||
gin.SetMode(gin.DebugMode)
|
||||
engine = gin.New()
|
||||
// Don't use recovery middleware in debug mode
|
||||
engine.Use(gin.Logger())
|
||||
}
|
||||
|
||||
err := engine.SetTrustedProxies([]string{"127.0.0.1"})
|
||||
if err != nil {
|
||||
panic("error setting trusted proxies: %v" + err.Error())
|
||||
}
|
||||
|
||||
if config.Port == "" {
|
||||
panic("API Base Port can not be empty!")
|
||||
}
|
||||
|
||||
api := &API{
|
||||
Controller: newController(NewPool(), userData, storeInstance, config, notify),
|
||||
Router: engine,
|
||||
Port: config.Port,
|
||||
}
|
||||
|
||||
SetRoutes(api)
|
||||
|
||||
return api
|
||||
}
|
||||
|
||||
func NewPool() *redis.Pool {
|
||||
return &redis.Pool{
|
||||
MaxIdle: RedisMaxIdle,
|
||||
IdleTimeout: 1 * time.Second,
|
||||
Dial: func() (redis.Conn, error) {
|
||||
c, err := redis.DialURL("redis://localhost")
|
||||
if err != nil {
|
||||
return nil, tracerr.Errorf("error connecting to Redis: %w", err)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the API
|
||||
func (api *API) Run() {
|
||||
// Gin blocks the calling gorutine, so we start it in its own gorutine
|
||||
// calling directly go api.Router.Run doesn't prevent having to press double
|
||||
// ctrl+c to stop the service
|
||||
go func() {
|
||||
// start the server
|
||||
slog.Error(api.Router.Run("localhost:" + api.Port).Error())
|
||||
}()
|
||||
}
|
||||
1
src/client/api/rest/traslator.go
Normal file
1
src/client/api/rest/traslator.go
Normal file
@ -0,0 +1 @@
|
||||
package rest
|
||||
1
src/client/api/rest/validator.go
Normal file
1
src/client/api/rest/validator.go
Normal file
@ -0,0 +1 @@
|
||||
package rest
|
||||
Reference in New Issue
Block a user