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 tradeProvider TradeProvider config Config notify domain.Notifier authMutex deadlock.Mutex } func newController(pool *redis.Pool, userData app.UserDataProvider, s *store.Store, tradeProvider TradeProvider, config Config, n domain.Notifier, ) *Controller { return &Controller{ pool: pool, userData: userData, store: s, tradeProvider: tradeProvider, 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 } // GetTrades godoc // @Summary List active trades // @Description Returns all active List Trading trades // @Tags fix // @Produce json // @Success 200 {array} domain.ListTrade // @Router /qfixdpl/v1/trades [get] func (cont *Controller) GetTrades(ctx *gin.Context) { trades := cont.tradeProvider.GetTrades() ctx.JSON(http.StatusOK, trades) } // GetLogs godoc // @Summary Get raw FIX logs for a trade // @Description Returns raw FIX message logs for a given QuoteReqID // @Tags fix // @Produce json // @Param quoteReqID path string true "QuoteReqID" // @Success 200 {object} domain.Logs // @Router /qfixdpl/v1/trades/{quoteReqID}/logs [get] func (cont *Controller) GetLogs(ctx *gin.Context) { quoteReqID := ctx.Param("quoteReqID") logs, err := cont.store.GetLogsByQuoteReqID(quoteReqID) if err != nil { slog.Error("GetLogs: error fetching logs", "quoteReqID", quoteReqID, "error", err) ctx.JSON(http.StatusInternalServerError, HTTPError{Error: "error fetching logs"}) return } ctx.JSON(http.StatusOK, logs) }