Merge pull request 'Add JWT authentication' (#7) from dev into main
This commit was merged in pull request #7.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
type Config struct {
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user