Skip to content

Commit

Permalink
feat: redis
Browse files Browse the repository at this point in the history
  • Loading branch information
rafaalrazzak committed Oct 3, 2024
1 parent cac80b5 commit 8b2e8cb
Show file tree
Hide file tree
Showing 6 changed files with 175 additions and 216 deletions.
66 changes: 37 additions & 29 deletions bunapp/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"syscall"
"time"

"github.com/redis/go-redis/v9"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/uptrace/bun/driver/pgdriver"
Expand All @@ -28,27 +29,23 @@ func AppFromContext(ctx context.Context) *App {

// ContextWithApp sets the App instance in the context.
func ContextWithApp(ctx context.Context, app *App) context.Context {
ctx = context.WithValue(ctx, appCtxKey{}, app)
return ctx
return context.WithValue(ctx, appCtxKey{}, app)
}

// App represents the application context.
type App struct {
ctx context.Context
cfg *AppConfig

stopping uint32
stopCh chan struct{}

ctx context.Context
cfg *AppConfig
stopping uint32
stopCh chan struct{}
onStop appHooks
onAfterStop appHooks

router *bunrouter.Router
apiRouter *bunrouter.Group

// lazy init
dbOnce sync.Once
db *bun.DB
router *bunrouter.Router
apiRouter *bunrouter.Group
dbOnce sync.Once
db *bun.DB
redisOnce sync.Once
redisClient *redis.Client
}

// New creates a new App instance.
Expand Down Expand Up @@ -79,11 +76,12 @@ func Start(ctx context.Context, service, envName string) (context.Context, *App,
// StartConfig initializes the app with the provided configuration.
func StartConfig(ctx context.Context, cfg *AppConfig) (context.Context, *App, error) {
rand.Seed(time.Now().UnixNano())

app := New(ctx, cfg)

if err := onStart.Run(ctx, app); err != nil {
return nil, nil, err
}

return app.ctx, app, nil
}

Expand Down Expand Up @@ -141,14 +139,9 @@ func (app *App) APIRouter() *bunrouter.Group {
// DB initializes and returns the database connection.
func (app *App) DB() *bun.DB {
app.dbOnce.Do(func() {
dsn := app.cfg.DB.DSN
sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))

sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(app.cfg.DbUrl)))
db := bun.NewDB(sqldb, pgdialect.New())
db.AddQueryHook(bundebug.NewQueryHook(
bundebug.WithEnabled(app.IsDebug()),
bundebug.FromEnv(""),
))
db.AddQueryHook(bundebug.NewQueryHook(bundebug.WithEnabled(app.IsDebug())))

app.OnStop("db.Close", func(ctx context.Context, _ *App) error {
return db.Close()
Expand All @@ -159,14 +152,29 @@ func (app *App) DB() *bun.DB {
return app.db
}

// Redis initializes and returns the Redis client.
func (app *App) Redis() *redis.Client {

opt, err := redis.ParseURL(app.cfg.RedisUrl)
if err != nil {
panic(err)
}

app.redisOnce.Do(func() {
client := redis.NewClient(opt)

app.OnStop("redis.Close", func(ctx context.Context, _ *App) error {
return client.Close()
})

app.redisClient = client
})
return app.redisClient
}

// WaitExitSignal listens for termination signals and returns the first received signal.
func WaitExitSignal() os.Signal {
ch := make(chan os.Signal, 3)
signal.Notify(
ch,
syscall.SIGINT,
syscall.SIGQUIT,
syscall.SIGTERM,
)
signal.Notify(ch, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM)
return <-ch
}
9 changes: 4 additions & 5 deletions bunapp/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,8 @@ type AppConfig struct {
Env string
Debug bool
SecretKey string
DB struct {
DSN string
}
DbUrl string
RedisUrl string
}

// LoadConfig loads the configuration from a .env file.
Expand All @@ -51,9 +50,9 @@ func LoadConfig(service, env string) (*AppConfig, error) {
Env: env,
Debug: os.Getenv("DEBUG") == "true",
SecretKey: os.Getenv("SECRET_KEY"),
DbUrl: os.Getenv("DATABASE_URL"),
RedisUrl: os.Getenv("REDIS_URL"),
}

cfg.DB.DSN = os.Getenv("DATABASE_DSN")

return cfg, nil
}
18 changes: 18 additions & 0 deletions bunapp/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package bunapp

import "strconv"

const BaseKey = "ecampus"

// RedisKeys provides functions to generate Redis keys.
type RedisKeys struct{}

// NewRedisKeys creates a new instance of RedisKeys.
func NewRedisKeys() RedisKeys {
return RedisKeys{}
}

// Session generates a Redis session key using user ID and session token.
func (rk RedisKeys) Session(userID int64, sessionToken string) string {
return BaseKey + ":session:" + strconv.FormatInt(userID, 10) + ":" + sessionToken
}
114 changes: 98 additions & 16 deletions ecampus/auth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package ecampus

import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"net/http"
"time"

"ecampus-be/bunapp"
"ecampus-be/ecampus/helpers"
Expand All @@ -14,10 +16,12 @@ import (
"github.com/uptrace/bunrouter"
)

// AuthHandler handles authentication requests.
type AuthHandler struct {
app *bunapp.App
}

// NewAuthHandler creates a new AuthHandler instance.
func NewAuthHandler(app *bunapp.App) *AuthHandler {
return &AuthHandler{app: app}
}
Expand All @@ -41,7 +45,6 @@ func (h *AuthHandler) Register(w http.ResponseWriter, req bunrouter.Request) err
return httpsuccess.Created(w, "User registered successfully", nil)
}

// Login handles user login.
func (h *AuthHandler) Login(w http.ResponseWriter, req bunrouter.Request) error {
var creds Credentials
if err := decodeJSON(req.Body, &creds); err != nil {
Expand All @@ -53,27 +56,78 @@ func (h *AuthHandler) Login(w http.ResponseWriter, req bunrouter.Request) error
return httperror.From(err, h.app.IsDebug())
}

if match, err := helpers.VerifyPassword(creds.Password, user.Password); err != nil {
return httperror.From(err, h.app.IsDebug())
} else if !match {
if match, err := helpers.VerifyPassword(creds.Password, user.Password); err != nil || !match {
return httperror.New(http.StatusUnauthorized, "invalid_credentials", "Invalid credentials")
}

token, err := GenerateSessionToken(user)
// Generate a random session token
token, err := generateRandomToken(32) // 32 bytes for the token
if err != nil {
return httperror.From(err, h.app.IsDebug())
}

// Encrypt the token
encryptedToken, err := helpers.EncryptAES([]byte(h.app.Config().SecretKey), []byte(token))
if err != nil {
return httperror.From(err, h.app.IsDebug())
}

return httpsuccess.Created(w, "Logged in successfully", map[string]string{"token": token})
sessionKey := bunapp.RedisKeys{}.Session(user.ID, token)

sessionData := map[string]interface{}{
"user_id": user.ID,
"username": user.Username,
"email": user.Email,
"name": user.Name,
"group": user.Group,
"year": user.Year,
"role": fmt.Sprintf("%v", user.Role),
"major": fmt.Sprintf("%v", user.Major),
"ip": req.RemoteAddr,
"user_agent": req.UserAgent(),
}

// Store session data in Redis
if err := h.app.Redis().HMSet(req.Context(), sessionKey, sessionData).Err(); err != nil {
return httperror.From(err, h.app.IsDebug())
}

// Set expiry for the session data
if err := h.app.Redis().Expire(req.Context(), sessionKey, 24*time.Hour).Err(); err != nil {
return httperror.From(err, h.app.IsDebug())
}

return httpsuccess.Created(w, "Logged in successfully", map[string]string{"token": string(encryptedToken)})
}

// Logout handles user logout.
func (h *AuthHandler) Logout(w http.ResponseWriter, req bunrouter.Request) error {
token := req.Header.Get("Authorization")
if token == "" {
return httperror.New(http.StatusUnauthorized, "unauthorized", "Unauthorized")
}

actualToken := extractToken(token)
if actualToken == "" {
return httperror.New(http.StatusUnauthorized, "unauthorized", "Unauthorized")
}

// Construct Redis key using the user ID and session token for deletion
userId, err := h.getUserIdFromToken(req.Context(), actualToken)
if err != nil {
return httperror.New(http.StatusUnauthorized, "unauthorized", "Unauthorized")
}
redisKey := bunapp.RedisKeys{}.Session(userId, actualToken)

// Delete the session token from Redis
if err := h.app.Redis().Del(req.Context(), redisKey).Err(); err != nil {
return httperror.From(err, h.app.IsDebug())
}

return httpsuccess.NoContent(w, "Logged out successfully")
}

// Helper Functions

// hashPassword hashes the user's password.
func (h *AuthHandler) hashPassword(user *User) error {
hashedPassword, err := helpers.HashPassword(user.Password)
if err != nil {
Expand All @@ -83,24 +137,52 @@ func (h *AuthHandler) hashPassword(user *User) error {
return nil
}

// insertUser inserts a new user into the database.
func (h *AuthHandler) insertUser(ctx context.Context, user *User) error {
_, err := h.app.DB().NewInsert().Model(user).Exec(ctx)
return err
}

// getUserByUsername retrieves a user by their username.
func (h *AuthHandler) getUserByUsername(ctx context.Context, username string) (*User, error) {
var user User
err := h.app.DB().NewSelect().Model(&user).Where("username = ?", username).Scan(ctx)
return &user, err
if err := h.app.DB().NewSelect().Model(&user).Where("username = ?", username).Scan(ctx); err != nil {
return nil, err
}
return &user, nil
}

// decodeJSON decodes JSON from the request body.
func decodeJSON(body io.ReadCloser, v interface{}) error {
defer func(body io.ReadCloser) {
err := body.Close()
if err != nil {
fmt.Println(err)
}
}(body)
defer body.Close() // Close the body after decoding
return json.NewDecoder(body).Decode(v)
}

// getUserIdFromToken retrieves the user ID associated with the session token.
func (h *AuthHandler) getUserIdFromToken(ctx context.Context, token string) (int64, error) {
var userID int64
if err := h.app.Redis().Get(ctx, token).Scan(&userID); err != nil {
return 0, err
}
return userID, nil
}

// extractToken extracts the actual token from the "Bearer <token>" format.
func extractToken(authHeader string) string {
const prefix = "Bearer "
if len(authHeader) > len(prefix) && authHeader[:len(prefix)] == prefix {
return authHeader[len(prefix):]
}
return ""
}

// generateRandomToken generates a random token of the specified length.
func generateRandomToken(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random token: %w", err)
}

// Convert bytes to a hexadecimal string
return fmt.Sprintf("%x", bytes), nil
}
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ toolchain go1.23.1
require (
github.com/bwmarrin/snowflake v0.3.0
github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.6.1
github.com/stretchr/testify v1.9.0
github.com/uptrace/bun v1.2.3
github.com/uptrace/bun/dbfixture v1.2.3
Expand All @@ -18,18 +19,19 @@ require (
github.com/uptrace/bunrouter/extra/reqlog v1.0.9
github.com/urfave/cli/v2 v2.3.0
go4.org v0.0.0-20201209231011-d4a079459e60
golang.org/x/crypto v0.27.0
)

require (
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fatih/color v1.17.0 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/puzpuzpuz/xsync/v3 v3.4.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
Expand All @@ -38,9 +40,7 @@ require (
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
go.opentelemetry.io/otel v1.1.0 // indirect
go.opentelemetry.io/otel/trace v1.1.0 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/sys v0.25.0 // indirect
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
mellium.im/sasl v0.3.1 // indirect
)
Loading

0 comments on commit 8b2e8cb

Please sign in to comment.