From 1cdb0ebefa0b46ece21c203b6ae5eedefa6dc1f7 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Tue, 21 Jul 2026 11:43:36 +0200 Subject: [PATCH 1/3] fix: improved logging message --- internal/api/response/json.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/response/json.go b/internal/api/response/json.go index f718b28..d3f60ba 100644 --- a/internal/api/response/json.go +++ b/internal/api/response/json.go @@ -14,7 +14,7 @@ func JSON(ctx context.Context, w http.ResponseWriter, status int, data any) { w.WriteHeader(http.StatusInternalServerError) _, err := w.Write([]byte(`{"error": "internal server error: failed to marshal response"}`)) if err != nil { - slog.ErrorContext(ctx, "failed to write json", slog.Any("error", err)) + slog.ErrorContext(ctx, "failed to marshal json", slog.Any("error", err)) return } return -- 2.54.0 From d9bfcea4206ea8277f14afd0c632ecef6c48a4f1 Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Tue, 21 Jul 2026 13:47:59 +0200 Subject: [PATCH 2/3] feat: jwt authentication --- .env.docker | 1 + cmd/api-server/main.go | 9 ++- go.mod | 1 + internal/api/handler/auth.go | 38 +++++++++ internal/api/middleware/jwt.go | 42 ++++++++++ internal/api/request/auth.go | 25 ++++++ internal/api/router/router.go | 15 +++- internal/config/config.go | 4 + internal/domain/auth.go | 136 +++++++++++++++++++++++++++++++++ internal/repository/auth.go | 31 ++++++++ internal/repository/list.go | 2 +- internal/repository/user.go | 2 +- 12 files changed, 296 insertions(+), 10 deletions(-) create mode 100644 internal/api/handler/auth.go create mode 100644 internal/api/middleware/jwt.go create mode 100644 internal/api/request/auth.go create mode 100644 internal/domain/auth.go create mode 100644 internal/repository/auth.go diff --git a/.env.docker b/.env.docker index d8f120f..f99b213 100644 --- a/.env.docker +++ b/.env.docker @@ -1,2 +1,3 @@ DTTMR_OTLP_ENDPOINT=172.17.0.1:4317 DTTMR_DATABASE_URL=postgres://postgres:postgres@172.17.0.1:5432/postgres?sslmode=disable&timezone=utc +DTTMR_JWT_SECRET=V*$#Jt9OlYW0gEB6PyUU$qLYbJ^NC7LZ diff --git a/cmd/api-server/main.go b/cmd/api-server/main.go index 9cf1dab..ce86612 100644 --- a/cmd/api-server/main.go +++ b/cmd/api-server/main.go @@ -76,7 +76,7 @@ func run(serviceName string, serviceVersion string) error { slog.Error("failed to run migrations", slog.Any("error", err)) } - srv := makeServer(db, cfg.Port) + srv := makeServer(db, cfg) go func() { slog.Info("starting http server", "addr", srv.Addr) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -112,14 +112,15 @@ func setupLogging() { slog.SetDefault(logger) } -func makeServer(db *sql.DB, port int) *http.Server { +func makeServer(db *sql.DB, cfg *config.Config) *http.Server { routerConfig := router.Config{ - Database: db, + Database: db, + JWTSecret: cfg.JWTSecret, } mux := router.NewMux(routerConfig) srv := &http.Server{ - Addr: fmt.Sprintf(":%d", port), + Addr: fmt.Sprintf(":%d", cfg.Port), Handler: mux, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second, diff --git a/go.mod b/go.mod index 7b1edd5..bd15699 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect diff --git a/internal/api/handler/auth.go b/internal/api/handler/auth.go new file mode 100644 index 0000000..cc277ad --- /dev/null +++ b/internal/api/handler/auth.go @@ -0,0 +1,38 @@ +package handler + +import ( + "log/slog" + "net/http" + + "github.com/robindittmar/dttmr-api/internal/api/request" + "github.com/robindittmar/dttmr-api/internal/api/response" + "github.com/robindittmar/dttmr-api/internal/domain" +) + +type AuthHandler struct { + AuthService *domain.AuthService +} + +func NewAuthHandler(authService *domain.AuthService) *AuthHandler { + return &AuthHandler{AuthService: authService} +} + +func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + payload, err := request.DecodeLogin(r) + if err != nil { + slog.ErrorContext(ctx, "failed to decode login payload", slog.Any("error", err)) + response.Error(ctx, w, http.StatusBadRequest, "failed to decode request body") + return + } + + token, err := h.AuthService.Login(ctx, payload.Email, payload.Password) + if err != nil { + slog.ErrorContext(ctx, "failed to login", slog.Any("error", err)) + response.Error(ctx, w, http.StatusInternalServerError, "failed to login") + return + } + + response.JSON(ctx, w, http.StatusOK, token) +} diff --git a/internal/api/middleware/jwt.go b/internal/api/middleware/jwt.go new file mode 100644 index 0000000..7615a87 --- /dev/null +++ b/internal/api/middleware/jwt.go @@ -0,0 +1,42 @@ +package middleware + +import ( + "context" + "log/slog" + "net/http" + "strings" + + "github.com/robindittmar/dttmr-api/internal/api/response" + "github.com/robindittmar/dttmr-api/internal/domain" +) + +func WithJWT(authService *domain.AuthService) func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + slog.ErrorContext(ctx, "missing authorization header") + response.Error(ctx, w, http.StatusUnauthorized, "missing authorization header") + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { + slog.ErrorContext(ctx, "invalid authorization header") + response.Error(ctx, w, http.StatusUnauthorized, "invalid authorization header") + return + } + + authContext, err := authService.ParseToken(ctx, parts[1]) + if err != nil { + slog.ErrorContext(ctx, "invalid token", slog.Any("error", err)) + response.Error(ctx, w, http.StatusUnauthorized, "invalid or expired token") + return + } + ctx = context.WithValue(ctx, domain.AuthContextKey, authContext) + + next.ServeHTTP(w, r.WithContext(ctx)) + } + } +} diff --git a/internal/api/request/auth.go b/internal/api/request/auth.go new file mode 100644 index 0000000..a504d5a --- /dev/null +++ b/internal/api/request/auth.go @@ -0,0 +1,25 @@ +package request + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type LoginPayload struct { + Email string `json:"email"` + Password string `json:"password"` +} + +func DecodeLogin(r *http.Request) (LoginPayload, error) { + var payload LoginPayload + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + if err := decoder.Decode(&payload); err != nil { + return payload, fmt.Errorf("error decoding login payload: %w", err) + } + + return payload, nil +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 43d3540..e854d22 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -11,7 +11,8 @@ import ( ) type Config struct { - Database *sql.DB + Database *sql.DB + JWTSecret string } func NewMux(cfg Config) http.Handler { @@ -19,18 +20,24 @@ func NewMux(cfg Config) http.Handler { userService := domain.NewUserService(userRepo) userHandler := handler.NewUserHandler(userService) + authRepo := repository.NewAuthRepo(cfg.Database) + authService := domain.NewAuthService(authRepo, []byte(cfg.JWTSecret)) + authHandler := handler.NewAuthHandler(authService) + listRepo := repository.NewListRepo(cfg.Database) listService := domain.NewListService(listRepo) listHandler := handler.NewListHandler(listService) - mux := http.NewServeMux() + protected := middleware.WithJWT(authService) + mux := http.NewServeMux() mux.HandleFunc("/", handler.DefaultHandler) mux.HandleFunc("GET /health", handler.HealthHandler) + mux.HandleFunc("POST /login", authHandler.Login) - mux.HandleFunc("POST /users", userHandler.CreateUser) + mux.Handle("POST /users", protected(userHandler.CreateUser)) - mux.HandleFunc("POST /lists", listHandler.CreateList) + mux.Handle("POST /lists", protected(listHandler.CreateList)) var httpHandler http.Handler = mux httpHandler = middleware.WithMaxBytes(1024 * 64)(httpHandler) diff --git a/internal/config/config.go b/internal/config/config.go index f3dcdce..5284166 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -12,6 +12,7 @@ type Config struct { Port int OTLPEndpoint string DatabaseURL string + JWTSecret string } func Load() *Config { @@ -19,6 +20,7 @@ func Load() *Config { portFlag := flag.Int("port", 8080, "port to listen on") otlpEndpointFlag := flag.String("otlp-endpoint", "localhost:4317", "otlp endpoint") databaseUrlFlag := flag.String("database-url", "postgres://postgres:postgres@localhost:5432/postgres?sslmode=disable&timezone=utc", "database connection string") + jwtSecretFlag := flag.String("jwt-secret", "5!zM8k@wC0Y5jgrbS8xLC0gW9k7dLaeI", "JWT secret") flag.Parse() @@ -27,12 +29,14 @@ func Load() *Config { Port: *portFlag, OTLPEndpoint: *otlpEndpointFlag, DatabaseURL: *databaseUrlFlag, + JWTSecret: *jwtSecretFlag, } assignStringFromEnv("DTTMR_ENVIRONMENT", &cfg.Environment) assignIntFromEnv("DTTMR_PORT", &cfg.Port) assignStringFromEnv("DTTMR_OTLP_ENDPOINT", &cfg.OTLPEndpoint) assignStringFromEnv("DTTMR_DATABASE_URL", &cfg.DatabaseURL) + assignStringFromEnv("DTTMR_JWT_SECRET", &cfg.JWTSecret) return cfg } diff --git a/internal/domain/auth.go b/internal/domain/auth.go new file mode 100644 index 0000000..9e7b04d --- /dev/null +++ b/internal/domain/auth.go @@ -0,0 +1,136 @@ +package domain + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +type AuthRepository interface { + GetByEmail(ctx context.Context, email string) (*AuthUser, error) +} + +type AuthService struct { + repo AuthRepository + jwtSecret []byte +} + +type AuthToken struct { + Token string `json:"token"` +} + +type AuthUser struct { + ID string + Email string + Name string + PasswordHash string +} + +type AuthContext struct { + UserID string + Email string + Name string +} + +func GetAuthContext(ctx context.Context) (*AuthContext, error) { + v := ctx.Value(AuthContextKey) + if v == nil { + return nil, errors.New("no auth context") + } + ac, ok := v.(*AuthContext) + if !ok { + return nil, errors.New("invalid auth context") + } + return ac, nil +} + +func NewAuthService(r AuthRepository, jwtSecret []byte) *AuthService { + return &AuthService{repo: r, jwtSecret: jwtSecret} +} + +func (s *AuthService) authenticate(ctx context.Context, email string, password string) (*AuthUser, error) { + user, err := s.repo.GetByEmail(ctx, email) + if err != nil { + return user, err + } + + err = bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) + if err != nil { + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return user, errors.New("invalid email or password") + } + return user, err + } + + return user, nil +} + +func (s *AuthService) Login(ctx context.Context, email string, password string) (AuthToken, error) { + var authToken AuthToken + + user, err := s.authenticate(ctx, email, password) + if err != nil { + return authToken, err + } + + authToken.Token, err = s.GenerateToken(user) + if err != nil { + return authToken, err + } + + return authToken, nil +} + +type JWTClaims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Name string `json:"name"` + jwt.RegisteredClaims +} + +type contextKey string + +const AuthContextKey = contextKey("auth") + +func (s *AuthService) GenerateToken(authUser *AuthUser) (string, error) { + claims := JWTClaims{ + UserID: authUser.ID, + Email: authUser.Email, + Name: authUser.Name, + } + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(time.Hour * 24)) + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + tokenString, err := token.SignedString(s.jwtSecret) + if err != nil { + return "", err + } + + return tokenString, nil +} + +func (s *AuthService) ParseToken(ctx context.Context, tokenString string) (*AuthContext, error) { + claims := &JWTClaims{} + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return s.jwtSecret, nil + }) + if err != nil || !token.Valid { + slog.ErrorContext(ctx, "invalid or expired token", slog.Any("token", token)) + return nil, err + } + + return &AuthContext{ + UserID: claims.UserID, + Email: claims.Email, + Name: claims.Name, + }, nil +} diff --git a/internal/repository/auth.go b/internal/repository/auth.go new file mode 100644 index 0000000..88dfc01 --- /dev/null +++ b/internal/repository/auth.go @@ -0,0 +1,31 @@ +package repository + +import ( + "context" + "database/sql" + "fmt" + + "github.com/robindittmar/dttmr-api/internal/domain" +) + +type AuthRepo struct { + db *sql.DB +} + +func NewAuthRepo(db *sql.DB) *AuthRepo { + return &AuthRepo{db: db} +} + +func (r *AuthRepo) GetByEmail(ctx context.Context, email string) (*domain.AuthUser, error) { + user := &domain.AuthUser{} + + err := r.db.QueryRowContext(ctx, + "SELECT id, email, name, password_hash FROM users WHERE email = $1", + email, + ).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash) + if err != nil { + return nil, fmt.Errorf("failed to get user: %w", err) + } + + return user, nil +} diff --git a/internal/repository/list.go b/internal/repository/list.go index fec3bd1..38e51a4 100644 --- a/internal/repository/list.go +++ b/internal/repository/list.go @@ -55,5 +55,5 @@ func (r *ListRepo) CreateList(ctx context.Context, name string, userIDs []string return nil, fmt.Errorf("commit transaction: %w", err) } - return list, err + return list, nil } diff --git a/internal/repository/user.go b/internal/repository/user.go index f956848..4749def 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -37,5 +37,5 @@ func (r *UserRepo) CreateUser(ctx context.Context, email string, name string, pa return nil, fmt.Errorf("commit transaction: %w", err) } - return user, err + return user, nil } -- 2.54.0 From b4f1cfe8cd11b73535110900c43d52347c937c1e Mon Sep 17 00:00:00 2001 From: Robin Dittmar Date: Tue, 21 Jul 2026 13:49:14 +0200 Subject: [PATCH 3/3] fix: go.mod --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index bd15699..5ab6841 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/robindittmar/dttmr-api go 1.26 require ( + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 github.com/jackc/pgx/v5 v5.10.0 github.com/joho/godotenv v1.5.1 @@ -22,7 +23,6 @@ require ( github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect -- 2.54.0