Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.dev.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000

MIGRATIONS_DIR=migrations

POSTGRES_HOST=gamidoc-backend-dev-pg
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=gamidoc
POSTGRES_USER=gamidoc
POSTGRES_PASSWORD=gamidoc

REDIS_HOST=gamidoc-backend-dev-redis
REDIS_HOST=localhost
REDIS_PORT=6379

JWT_SECRET=dev-secret
Expand Down
14 changes: 14 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o gamidoc-backend ./cmd/gamidoc-backend/

FROM alpine:3.21
WORKDIR /app
COPY --from=builder /app/gamidoc-backend .
COPY --from=builder /app/migrations ./migrations
COPY --from=builder /app/rule ./rule
EXPOSE 8080
CMD ["sh", "-c", "./gamidoc-backend migrate up && ./gamidoc-backend serve"]
22 changes: 17 additions & 5 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,18 @@ type Config struct {
PostgresDB string
PostgresUser string
PostgresPassword string
PostgresSSLMode string

RedisHost string
RedisPort string
RedisHost string
RedisPort string
RedisPassword string
RedisTLS bool

JWTSecret string
JWTExpiresIn time.Duration

RefreshTokenTTL time.Duration

SessionTTL time.Duration

ObjectStorageProvider string
Expand All @@ -58,7 +63,8 @@ type Config struct {
}

func Load() Config {
expiresIn := parseDurationWithFallback(getEnv("JWT_EXPIRES_IN", "24h"), 24*time.Hour)
expiresIn := parseDurationWithFallback(getEnv("JWT_EXPIRES_IN", "15m"), 15*time.Minute)
refreshTTL := parseDurationWithFallback(getEnv("REFRESH_TOKEN_TTL", "168h"), 168*time.Hour)
sessionTTL := parseDurationWithFallback(getEnv("SESSION_TTL", "48h"), 48*time.Hour)

return Config{
Expand All @@ -77,10 +83,14 @@ func Load() Config {
PostgresDB: getEnv("POSTGRES_DB", "gamidoc"),
PostgresUser: getEnv("POSTGRES_USER", "gamidoc"),
PostgresPassword: getEnv("POSTGRES_PASSWORD", "gamidoc"),
PostgresSSLMode: getEnv("POSTGRES_SSLMODE", "disable"),
RedisHost: getEnv("REDIS_HOST", "localhost"),
RedisPort: getEnv("REDIS_PORT", "6379"),
RedisPassword: getEnv("REDIS_PASSWORD", ""),
RedisTLS: getEnvBool("REDIS_TLS", false),
JWTSecret: getEnv("JWT_SECRET", "dev-secret"),
JWTExpiresIn: expiresIn,
RefreshTokenTTL: refreshTTL,
SessionTTL: sessionTTL,
ObjectStorageProvider: getEnv("OBJECT_STORAGE_PROVIDER", "local"),
ObjectStoragePublicBaseURL: getEnv("OBJECT_STORAGE_PUBLIC_BASE_URL", getEnv("PDF_BASE_URL", "/files/pdfs")),
Expand Down Expand Up @@ -156,23 +166,25 @@ func (c Config) ValidateCore() error {

func (c Config) PostgresDSN() string {
return fmt.Sprintf(
"host=%s port=%s dbname=%s user=%s password=%s sslmode=disable",
"host=%s port=%s dbname=%s user=%s password=%s sslmode=%s",
c.PostgresHost,
c.PostgresPort,
c.PostgresDB,
c.PostgresUser,
c.PostgresPassword,
c.PostgresSSLMode,
)
}

func (c Config) PostgresURL() string {
return fmt.Sprintf(
"postgresql://%s:%s@%s:%s/%s?sslmode=disable",
"postgresql://%s:%s@%s:%s/%s?sslmode=%s",
c.PostgresUser,
c.PostgresPassword,
c.PostgresHost,
c.PostgresPort,
c.PostgresDB,
c.PostgresSSLMode,
)
}

Expand Down
Binary file added gamidoc-backend.exe
Binary file not shown.
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/gamidoc/backend
go 1.26.1

require (
github.com/alicebob/miniredis/v2 v2.38.0
github.com/aws/aws-sdk-go-v2/config v1.32.16
github.com/aws/aws-sdk-go-v2/credentials v1.19.15
github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0
Expand Down Expand Up @@ -36,5 +37,6 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
)
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A=
Expand Down Expand Up @@ -71,6 +73,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
Expand Down
13 changes: 10 additions & 3 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"log/slog"
"net/http"
"os"
"strings"

"github.com/gamidoc/backend/config"
"github.com/gamidoc/backend/internal/auth"
Expand Down Expand Up @@ -41,7 +42,7 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}

redisClient := rediscache.New(cfg.RedisAddr())
redisClient := rediscache.New(cfg.RedisAddr(), cfg.RedisPassword, cfg.RedisTLS)

startupCtx, cancel := context.WithTimeout(context.Background(), cfg.HTTPReadTimeout)
defer cancel()
Expand All @@ -58,6 +59,8 @@ func New(cfg config.Config) (*App, error) {
}

tokenManager := token.NewManager(cfg.JWTSecret, cfg.JWTExpiresIn)
refreshStore := token.NewRefreshStore(redisClient.Raw(), cfg.RefreshTokenTTL)
tokenBlacklist := token.NewBlacklist(redisClient.Raw())

wizardService := wizard.NewService()

Expand All @@ -70,9 +73,11 @@ func New(cfg config.Config) (*App, error) {
recommendationEngine := recommendation.NewEngine(rules)
recommendationService := recommendation.NewService(recommendationEngine)

secureCookie := strings.EqualFold(strings.TrimSpace(cfg.AppEnv), "production")

userRepository := postgres.NewUserRepository(pg)
authService := auth.NewService(userRepository, tokenManager)
authHandler := auth.NewHandler(authService, tokenManager)
authService := auth.NewService(userRepository, tokenManager, refreshStore, tokenBlacklist)
authHandler := auth.NewHandler(authService, tokenManager, tokenBlacklist, cfg.RefreshTokenTTL, secureCookie)

projectRepository := postgres.NewProjectRepository(pg)
sessionRepository := rediscache.NewSessionRepository(redisClient, cfg.SessionTTL)
Expand All @@ -89,6 +94,7 @@ func New(cfg config.Config) (*App, error) {
_ = redisClient.Close()
return nil, err
}
projectService.WithPDFCleaner(store)

m, err := bootstrap.NewMailer(cfg)
if err != nil {
Expand Down Expand Up @@ -125,6 +131,7 @@ func New(cfg config.Config) (*App, error) {
Postgres: application.pg,
Redis: application.redis,
TokenManager: tokenManager,
TokenBlacklist: tokenBlacklist,
AuthHandler: authHandler.Routes(),
ProjectHandler: projectHandler,
SessionHandler: sessionHandler,
Expand Down
95 changes: 91 additions & 4 deletions internal/auth/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"encoding/json"
"errors"
"net/http"
"strings"
"time"

appmiddleware "github.com/gamidoc/backend/internal/http/middleware"
"github.com/gamidoc/backend/internal/http/response"
Expand All @@ -12,15 +14,23 @@ import (
"github.com/go-chi/chi/v5"
)

const refreshCookieName = "refresh_token"

type Handler struct {
service *Service
tokenManager *token.Manager
blacklist *token.Blacklist
refreshTTL time.Duration
secureCookie bool
}

func NewHandler(service *Service, tokenManager *token.Manager) *Handler {
func NewHandler(service *Service, tokenManager *token.Manager, blacklist *token.Blacklist, refreshTTL time.Duration, secureCookie bool) *Handler {
return &Handler{
service: service,
tokenManager: tokenManager,
blacklist: blacklist,
refreshTTL: refreshTTL,
secureCookie: secureCookie,
}
}

Expand All @@ -29,7 +39,9 @@ func (h *Handler) Routes() chi.Router {

r.Post("/register", h.register)
r.Post("/login", h.login)
r.With(appmiddleware.RequireAuth(h.tokenManager)).Get("/me", h.me)
r.Post("/refresh", h.refresh)
r.Post("/logout", h.logout)
r.With(appmiddleware.RequireAuth(h.tokenManager, h.blacklist)).Get("/me", h.me)

return r
}
Expand All @@ -56,7 +68,11 @@ func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
return
}

response.WriteJSON(w, http.StatusCreated, result)
h.setRefreshCookie(w, result.RefreshToken)
response.WriteJSON(w, http.StatusCreated, map[string]any{
"access_token": result.AccessToken,
"user": result.User,
})
}

func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
Expand All @@ -77,7 +93,54 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
return
}

response.WriteJSON(w, http.StatusOK, result)
h.setRefreshCookie(w, result.RefreshToken)
response.WriteJSON(w, http.StatusOK, map[string]any{
"access_token": result.AccessToken,
"user": result.User,
})
}

func (h *Handler) refresh(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(refreshCookieName)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
response.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Missing refresh token", nil)
return
}

result, err := h.service.Refresh(r.Context(), cookie.Value)
if err != nil {
h.clearRefreshCookie(w)
response.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Invalid or expired refresh token", nil)
return
}

h.setRefreshCookie(w, result.RefreshToken)
response.WriteJSON(w, http.StatusOK, map[string]any{
"access_token": result.AccessToken,
"user": result.User,
})
}

func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
// Extract access token from Authorization header
accessToken := ""
if header := r.Header.Get("Authorization"); header != "" {
parts := strings.SplitN(header, " ", 2)
if len(parts) == 2 && parts[0] == "Bearer" {
accessToken = parts[1]
}
}

// Extract refresh token from cookie
refreshToken := ""
if cookie, err := r.Cookie(refreshCookieName); err == nil {
refreshToken = cookie.Value
}

_ = h.service.Logout(r.Context(), accessToken, refreshToken)

h.clearRefreshCookie(w)
response.WriteJSON(w, http.StatusOK, map[string]any{"status": "ok"})
}

func (h *Handler) me(w http.ResponseWriter, r *http.Request) {
Expand All @@ -99,3 +162,27 @@ func (h *Handler) me(w http.ResponseWriter, r *http.Request) {

response.WriteJSON(w, http.StatusOK, currentUser)
}

func (h *Handler) setRefreshCookie(w http.ResponseWriter, tokenValue string) {
http.SetCookie(w, &http.Cookie{
Name: refreshCookieName,
Value: tokenValue,
Path: "/api/v1/auth",
HttpOnly: true,
Secure: h.secureCookie,
SameSite: http.SameSiteStrictMode,
MaxAge: int(h.refreshTTL.Seconds()),
})
}

func (h *Handler) clearRefreshCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: refreshCookieName,
Value: "",
Path: "/api/v1/auth",
HttpOnly: true,
Secure: h.secureCookie,
SameSite: http.SameSiteStrictMode,
MaxAge: -1,
})
}
Loading
Loading