diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b4044f1 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +POSTGRESQL_CONNECTION_STRING=postgres://todo:password@localhost:5432/todo +JWT_SECRET_KEY="supersecretjwtkey" diff --git a/cmd/main.go b/cmd/main.go index 5541279..97d9da7 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,58 +1,105 @@ package main import ( - "database/sql" + "context" "log" "net/http" "os" + "gitea.michaelthomson.dev/mthomson/habits/internal/auth" + authhandler "gitea.michaelthomson.dev/mthomson/habits/internal/auth/handler" + authservice "gitea.michaelthomson.dev/mthomson/habits/internal/auth/service" "gitea.michaelthomson.dev/mthomson/habits/internal/logging" "gitea.michaelthomson.dev/mthomson/habits/internal/middleware" "gitea.michaelthomson.dev/mthomson/habits/internal/migrate" todohandler "gitea.michaelthomson.dev/mthomson/habits/internal/todo/handler" - todorepository "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository/postgres" + todorepository "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository" todoservice "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" - _ "github.com/jackc/pgx/v5/stdlib" + userhandler "gitea.michaelthomson.dev/mthomson/habits/internal/user/handler" + userrepository "gitea.michaelthomson.dev/mthomson/habits/internal/user/repository" + userservice "gitea.michaelthomson.dev/mthomson/habits/internal/user/service" + pgxuuid "github.com/jackc/pgx-gofrs-uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/joho/godotenv" ) func main() { // create logger logger := logging.NewLogger() - // create middlewares - contextMiddleware := middleware.ContextMiddleware(logger) - loggingMiddleware := middleware.LoggingMiddleware(logger) + // load env + err := godotenv.Load() + if err != nil { + logger.Error(err.Error()) + os.Exit(1) + } - stack := []middleware.Middleware{ - contextMiddleware, + jwtSecretKey := os.Getenv("JWT_SECRET_KEY") + postgresqlConnectionString := os.Getenv("POSTGRESQL_CONNECTION_STRING") + + // create hasher instance + argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256) + + // create middlewares + traceMiddleware := middleware.TraceMiddleware(logger) + loggingMiddleware := middleware.LoggingMiddleware(logger) + authMiddleware := middleware.AuthMiddleware(logger, []byte(jwtSecretKey)) + + unauthenticatedStack := []middleware.Middleware{ + traceMiddleware, loggingMiddleware, } + authenticatedStack := []middleware.Middleware{ + traceMiddleware, + loggingMiddleware, + authMiddleware, + } + // create db pool - postgresUrl := "postgres://todo:password@localhost:5432/todo" - db, err := sql.Open("pgx", postgresUrl) + dbconfig, err := pgxpool.ParseConfig(postgresqlConnectionString) if err != nil { logger.Error(err.Error()) - os.Exit(1); + os.Exit(1) + } + + dbconfig.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + pgxuuid.Register(conn.TypeMap()) + return nil + } + + db, err := pgxpool.NewWithConfig(context.Background(), dbconfig) + if err != nil { + logger.Error(err.Error()) + os.Exit(1) } // run migrations migrate.Migrate(logger, db) // create repos - todoRepository := todorepository.NewPostgresTodoRepository(logger, db) + todoRepository := todorepository.NewTodoRepository(logger, db) + userRepository := userrepository.NewUserRepository(logger, db) // create services todoService := todoservice.NewTodoService(logger, todoRepository) + userService := userservice.NewUserService(logger, userRepository, argon2IdHash) + authService := authservice.NewAuthService(logger, []byte(jwtSecretKey), userRepository, argon2IdHash) // create mux mux := http.NewServeMux() // register handlers - mux.Handle("GET /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoGet(logger, todoService), stack)) - mux.Handle("POST /todo", middleware.CompileMiddleware(todohandler.HandleTodoCreate(logger, todoService), stack)) - mux.Handle("DELETE /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoDelete(logger, todoService), stack)) - mux.Handle("PUT /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoUpdate(logger, todoService), stack)) + // auth + mux.Handle("POST /login", middleware.CompileMiddleware(authhandler.HandleLogin(logger, authService), unauthenticatedStack)) + // users + mux.Handle("POST /register", middleware.CompileMiddleware(userhandler.HandleRegisterUser(logger, userService), unauthenticatedStack)) + // todos + mux.Handle("GET /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoGet(logger, todoService), authenticatedStack)) + mux.Handle("POST /todo", middleware.CompileMiddleware(todohandler.HandleTodoCreate(logger, todoService), authenticatedStack)) + mux.Handle("DELETE /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoDelete(logger, todoService), authenticatedStack)) + mux.Handle("PUT /todo/{id}", middleware.CompileMiddleware(todohandler.HandleTodoUpdate(logger, todoService), authenticatedStack)) // create server server := &http.Server{ diff --git a/go.mod b/go.mod index 7698d2c..3375f33 100644 --- a/go.mod +++ b/go.mod @@ -1,65 +1,76 @@ module gitea.michaelthomson.dev/mthomson/habits -go 1.22.9 +go 1.23.0 + +toolchain go1.23.9 require ( - github.com/jackc/pgx/v5 v5.7.2 - github.com/mattn/go-sqlite3 v1.14.24 - github.com/testcontainers/testcontainers-go v0.35.0 - github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0 + github.com/gofrs/uuid/v5 v5.3.2 + github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2 + github.com/jackc/pgx/v5 v5.7.4 + github.com/joho/godotenv v1.5.1 + github.com/testcontainers/testcontainers-go v0.37.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 + golang.org/x/crypto v0.38.0 ) require ( - dario.cat/mergo v1.0.0 // indirect - github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/cenkalti/backoff/v4 v4.2.1 // indirect - github.com/containerd/containerd v1.7.18 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/docker v27.1.1+incompatible // indirect + github.com/docker/docker v28.1.1+incompatible // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.8.3 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.17.4 // indirect - github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect - github.com/magiconair/properties v1.8.7 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect + github.com/magiconair/properties v1.8.10 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect - github.com/moby/sys/sequential v0.5.0 // indirect - github.com/moby/sys/user v0.1.0 // indirect - github.com/moby/term v0.5.0 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/morikuni/aec v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/shirou/gopsutil/v3 v3.23.12 // indirect - github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/shirou/gopsutil/v4 v4.25.4 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/stretchr/testify v1.9.0 // indirect - github.com/tklauser/go-sysconf v0.3.12 // indirect - github.com/tklauser/numcpus v0.6.1 // indirect - github.com/yusufpapurcu/wmi v1.2.3 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect - go.opentelemetry.io/otel v1.24.0 // indirect - go.opentelemetry.io/otel/metric v1.24.0 // indirect - go.opentelemetry.io/otel/trace v1.24.0 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/tklauser/go-sysconf v0.3.15 // indirect + github.com/tklauser/numcpus v0.10.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect + golang.org/x/sync v0.14.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect + google.golang.org/protobuf v1.35.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ed357d5..2dcb4f7 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,13 @@ -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= -github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/containerd/containerd v1.7.18 h1:jqjZTQNfXGoEaZdW1WwPU0RqSn1Bm2Ay/KJPUuO8nao= -github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= @@ -23,27 +21,32 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY= -github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I= +github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc= +github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= +github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 h1:YBftPWNWd4WwGqtY2yeZL2ef8rHAxPBD8KFhJpmcqms= @@ -52,118 +55,121 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= -github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2 h1:QWdhlQz98hUe1xmjADOl2mr8ERLrOqj0KWLdkrnNsRQ= +github.com/jackc/pgx-gofrs-uuid v0.0.0-20230224015001-1d428863c2e2/go.mod h1:Ti7pyNDU/UpXKmBTeFgxTvzYDM9xHLiYKMsLdt4b9cg= +github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg= +github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= -github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= -github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= -github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= -github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mdelapenya/tlscert v0.1.0 h1:YTpF579PYUX475eOL+6zyEO3ngLTOUWck78NBuJVXaM= -github.com/mdelapenya/tlscert v0.1.0/go.mod h1:wrbyM/DwbFCeCeqdPX/8c6hNOqQgbf0rUDErE1uD+64= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= +github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= -github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= -github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= -github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= -github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= -github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= -github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg= -github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= -github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= -github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= -github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= -github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= -github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= -github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/shirou/gopsutil/v4 v4.25.4 h1:cdtFO363VEOOFrUCjZRh4XVJkb548lyF0q0uTeMqYPw= +github.com/shirou/gopsutil/v4 v4.25.4/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= -github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= -github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0 h1:eEGx9kYzZb2cNhRbBrNOCL/YPOM7+RMJiy3bB+ie0/I= -github.com/testcontainers/testcontainers-go/modules/postgres v0.35.0/go.mod h1:hfH71Mia/WWLBgMD2YctYcMlfsbnT0hflweL1dy8Q4s= -github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= -github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= -github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= -github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/testcontainers/testcontainers-go v0.37.0 h1:L2Qc0vkTw2EHWQ08djon0D2uw7Z/PtHS/QzZZ5Ra/hg= +github.com/testcontainers/testcontainers-go v0.37.0/go.mod h1:QPzbxZhQ6Bclip9igjLFj6z0hs01bU8lrl2dHQmgFGM= +github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 h1:hsVwFkS6s+79MbKEO+W7A1wNIw1fmkMtF4fg83m6kbc= +github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0/go.mod h1:Qj/eGbRbO/rEYdcRLmN+bEojzatP/+NS1y8ojl2PQsc= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= -github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= -go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo= -go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= -go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI= -go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco= -go.opentelemetry.io/otel/sdk v1.19.0 h1:6USY6zH+L8uMH8L3t1enZPR3WFEmSTADlqldyHtJi3o= -go.opentelemetry.io/otel/sdk v1.19.0/go.mod h1:NedEbbS4w3C6zElbLdPJKOpJQOrGUJ+GfzpjUvI0v1A= -go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI= -go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -171,17 +177,15 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= -golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -192,20 +196,20 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto v0.0.0-20230920204549-e6e6cdab5c13 h1:vlzZttNJGVqTsRFU9AmdnrcO1Znh8Ew9kCD//yjigk0= +google.golang.org/genproto v0.0.0-20230526203410-71b5a4ffd15e h1:Ao9GzfUMPH3zjVfzXG5rlWlk+Q8MXWKwWpwVQE1MXfw= google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237 h1:RFiFrvy37/mpSpdySBDrUdipW/dHwsRwh3J3+A9VgT4= google.golang.org/genproto/googleapis/api v0.0.0-20240318140521-94a12d6c2237/go.mod h1:Z5Iiy3jtmioajWHDGFk7CeugTyHtPvMHA4UTmUkyalE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA= google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/internal/auth/handler/login.go b/internal/auth/handler/login.go new file mode 100644 index 0000000..03fac61 --- /dev/null +++ b/internal/auth/handler/login.go @@ -0,0 +1,66 @@ +package handler + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + + "gitea.michaelthomson.dev/mthomson/habits/internal/auth/service" +) + +type Loginer interface { + Login(ctx context.Context, email string, password string) (string, error) +} + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +func HandleLogin(logger *slog.Logger, authService Loginer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + loginRequest := LoginRequest{} + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + err := decoder.Decode(&loginRequest) + + if err != nil { + logger.ErrorContext(ctx, err.Error()) + http.Error(w, "", http.StatusBadRequest) + return + } + + token, err := authService.Login(ctx, loginRequest.Email, loginRequest.Password) + + if err == service.ErrUnauthorized { + http.Error(w, "", http.StatusUnauthorized) + return + } + + if err == service.ErrNotFound { + http.Error(w, "", http.StatusUnauthorized) + return + } + + if err != nil { + logger.ErrorContext(ctx, err.Error()) + http.Error(w, "", http.StatusInternalServerError) + return + } + + cookie := http.Cookie{ + Name: "token", + Value: token, + Path: "/", + MaxAge: 3600, + HttpOnly: true, + Secure: false, + SameSite: http.SameSiteLaxMode, + } + http.SetCookie(w, &cookie) + + w.WriteHeader(http.StatusOK) + } +} diff --git a/internal/auth/handler/login_test.go b/internal/auth/handler/login_test.go new file mode 100644 index 0000000..0d3c389 --- /dev/null +++ b/internal/auth/handler/login_test.go @@ -0,0 +1,168 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + + "errors" + "net/http" + "net/http/httptest" + "testing" + + "gitea.michaelthomson.dev/mthomson/habits/internal/auth/service" +) + +type MockLoginer struct { + LoginFunc func(ctx context.Context, email string, password string) (string, error) +} + +func (m *MockLoginer) Login(ctx context.Context, email string, password string) (string, error) { + return m.LoginFunc(ctx, email, password) +} + +func TestLogin(t *testing.T) { + logger := slog.Default() + t.Run("returns 200 for existing user with correct credentials", func(t *testing.T) { + loginRequest := LoginRequest{Email: "test@test.com", Password: "password"} + + token := "examplejwt" + + service := MockLoginer{ + LoginFunc: func(ctx context.Context, email string, password string) (string, error) { + return token, nil + }, + } + + handler := HandleLogin(logger, &service) + + requestBody, err := json.Marshal(loginRequest) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", loginRequest, err) + } + + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + + handler(res, req) + + AssertStatusCodes(t, res.Code, http.StatusOK) + AssertToken(t, res, token) + }) + + t.Run("returns 401 for existing user with incorrect credentials", func(t *testing.T) { + loginRequest := LoginRequest{Email: "test@test.com", Password: "password"} + + service := MockLoginer{ + LoginFunc: func(ctx context.Context, email string, password string) (string, error) { + return "", service.ErrUnauthorized + }, + } + + handler := HandleLogin(logger, &service) + + requestBody, err := json.Marshal(loginRequest) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", loginRequest, err) + } + + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + + handler(res, req) + + AssertStatusCodes(t, res.Code, http.StatusUnauthorized) + }) + + t.Run("returns 401 for non-existing user", func(t *testing.T) { + loginRequest := LoginRequest{Email: "test@test.com", Password: "password"} + + service := MockLoginer{ + LoginFunc: func(ctx context.Context, email string, password string) (string, error) { + return "", service.ErrNotFound + }, + } + + handler := HandleLogin(logger, &service) + + requestBody, err := json.Marshal(loginRequest) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", loginRequest, err) + } + + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + + handler(res, req) + + AssertStatusCodes(t, res.Code, http.StatusUnauthorized) + }) + + t.Run("returns 400 with bad json", func(t *testing.T) { + handler := HandleLogin(logger, nil) + + badStruct := struct { + Foo string + }{ + Foo: "bar", + } + + requestBody, err := json.Marshal(badStruct) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", badStruct, err) + } + + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + + handler(res, req) + + AssertStatusCodes(t, res.Code, http.StatusBadRequest) + }) + + t.Run("returns 500 arbitrary errors", func(t *testing.T) { + loginRequest := LoginRequest{Email: "test@test.com", Password: "password"} + + service := MockLoginer{ + LoginFunc: func(ctx context.Context, email string, password string) (string, error) { + return "", errors.New("foo bar") + }, + } + + handler := HandleLogin(logger, &service) + + requestBody, err := json.Marshal(loginRequest) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", loginRequest, err) + } + + req := httptest.NewRequest(http.MethodPost, "/login", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + + handler(res, req) + + AssertStatusCodes(t, res.Code, http.StatusInternalServerError) + }) + +} + +func AssertStatusCodes(t testing.TB, got, want int) { + t.Helper() + if got != want { + t.Errorf("got status code: %v, want status code: %v", want, got) + } +} + +func AssertToken(t testing.TB, res *httptest.ResponseRecorder, want string) { + t.Helper() + got := res.Result().Cookies()[0].Value + if got != want { + t.Errorf("got cookie: %q, want cookie: %q", got, want) + } +} diff --git a/internal/auth/hashing.go b/internal/auth/hashing.go new file mode 100644 index 0000000..9610c9b --- /dev/null +++ b/internal/auth/hashing.go @@ -0,0 +1,70 @@ +package auth + +import ( + "bytes" + "crypto/rand" + "errors" + + "golang.org/x/crypto/argon2" +) + +type HashSalt struct { + Hash, Salt []byte +} + +type Argon2IdHash struct { + time uint32 + memory uint32 + threads uint8 + keyLen uint32 + saltLen uint32 +} + +var ( + ErrNoMatch error = errors.New("hash doesn't match") +) + +func NewArgon2IdHash(time, saltLen uint32, memory uint32, threads uint8, keyLen uint32) *Argon2IdHash { + return &Argon2IdHash{ + time: time, + saltLen: saltLen, + memory: memory, + threads: threads, + keyLen: keyLen, + } +} + +func (a *Argon2IdHash) GenerateHash(password, salt []byte) (*HashSalt, error) { + var err error + if len(salt) == 0 { + salt, err = randomSecret(a.saltLen) + } + if err != nil { + return nil, err + } + hash := argon2.IDKey(password, salt, a.time, a.memory, a.threads, a.keyLen) + return &HashSalt{Hash: hash, Salt: salt}, nil +} + +func (a *Argon2IdHash) Compare(hash, salt, password []byte) error { + hashSalt, err := a.GenerateHash(password, salt) + if err != nil { + return err + } + + if !bytes.Equal(hash, hashSalt.Hash) { + return ErrNoMatch + } + return nil +} + +func randomSecret(length uint32) ([]byte, error) { + secret := make([]byte, length) + + _, err := rand.Read(secret) + if err != nil { + return nil, err + } + + return secret, nil +} diff --git a/internal/auth/service/service.go b/internal/auth/service/service.go new file mode 100644 index 0000000..1dabbae --- /dev/null +++ b/internal/auth/service/service.go @@ -0,0 +1,90 @@ +package service + +import ( + "context" + "errors" + "log/slog" + "time" + + "gitea.michaelthomson.dev/mthomson/habits/internal/auth" + userrepository "gitea.michaelthomson.dev/mthomson/habits/internal/user/repository" + "github.com/golang-jwt/jwt/v5" +) + +var ( + ErrNotFound error = errors.New("user cannot be found") + ErrUnauthorized error = errors.New("user password incorrect") +) + +type UserRepository interface { + Create(ctx context.Context, user userrepository.UserRow) (userrepository.UserRow, error) + GetByEmail(ctx context.Context, email string) (userrepository.UserRow, error) +} + +type AuthService struct { + logger *slog.Logger + jwtKey []byte + userRepository UserRepository + argon2IdHash *auth.Argon2IdHash +} + +func NewAuthService(logger *slog.Logger, jwtKey []byte, userRepository UserRepository, argon2IdHash *auth.Argon2IdHash) *AuthService { + return &AuthService{ + logger: logger, + jwtKey: jwtKey, + userRepository: userRepository, + argon2IdHash: argon2IdHash, + } +} + +func (a AuthService) Login(ctx context.Context, email string, password string) (string, error) { + // get user if exists + userRow, err := a.userRepository.GetByEmail(ctx, email) + if err != nil { + if err == userrepository.ErrNotFound { + return "", ErrNotFound + } + + a.logger.ErrorContext(ctx, err.Error()) + return "", err + } + + // compare hashed passswords + err = a.argon2IdHash.Compare(userRow.HashedPassword, userRow.Salt, []byte(password)) + + if err == auth.ErrNoMatch { + return "", ErrUnauthorized + } + + if err != nil { + a.logger.ErrorContext(ctx, err.Error()) + return "", err + } + + // create token and return it + token, err := a.CreateToken(ctx, email) + if err != nil { + a.logger.ErrorContext(ctx, err.Error()) + return "", err + } + + return token, nil +} + +func (a AuthService) CreateToken(ctx context.Context, email string) (string, error) { + // Create a new JWT token with claims + claims := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "sub": email, + "iss": "todo-app", + "aud": "user", + "exp": time.Now().Add(time.Hour).Unix(), + "iat": time.Now().Unix(), + }) + + tokenString, err := claims.SignedString(a.jwtKey) + if err != nil { + a.logger.ErrorContext(ctx, err.Error()) + } + + return tokenString, nil +} diff --git a/internal/auth/service/service_test.go b/internal/auth/service/service_test.go new file mode 100644 index 0000000..cffa456 --- /dev/null +++ b/internal/auth/service/service_test.go @@ -0,0 +1,81 @@ +package service_test + +import ( + "context" + "log/slog" + "testing" + + "gitea.michaelthomson.dev/mthomson/habits/internal/auth" + authservice "gitea.michaelthomson.dev/mthomson/habits/internal/auth/service" + "gitea.michaelthomson.dev/mthomson/habits/internal/test" + "gitea.michaelthomson.dev/mthomson/habits/internal/user/repository" + userservice "gitea.michaelthomson.dev/mthomson/habits/internal/user/service" + "github.com/gofrs/uuid/v5" +) + +func TestLogin(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slog.Default() + tdb := test.NewTestDatabase(t) + defer tdb.TearDown() + userRepository := repository.NewUserRepository(logger, tdb.Db) + argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256) + + userService := userservice.NewUserService(logger, userRepository, argon2IdHash) + authService := authservice.NewAuthService(logger, []byte("secretkey"), userRepository, argon2IdHash) + + _, err := userService.Register(ctx, "test@test.com", "supersecurepassword") + AssertNoError(t, err) + + t.Run("login existing user with correct credentials", func(t *testing.T) { + want, err := authService.CreateToken(ctx, "test@test.com") + AssertNoError(t, err) + got, err := authService.Login(ctx, "test@test.com", "supersecurepassword") + + AssertNoError(t, err) + AssertTokens(t, got, want) + }) + + t.Run("login existing user with incorrect credentials", func(t *testing.T) { + _, err := authService.Login(ctx, "test@test.com", "superwrongpassword") + + AssertErrors(t, err, authservice.ErrUnauthorized) + }) + + t.Run("login nonexistant user", func(t *testing.T) { + _, err := authService.Login(ctx, "foo@test.com", "supersecurepassword") + + AssertErrors(t, err, authservice.ErrNotFound) + }) +} + +func AssertErrors(t testing.TB, got, want error) { + t.Helper() + if got != want { + t.Errorf("got error: %v, want error: %v", want, got) + } +} + +func AssertNoError(t testing.TB, err error) { + t.Helper() + if err != nil { + t.Errorf("expected no error, got %v", err) + } +} + +func AssertTokens(t testing.TB, got, want string) { + t.Helper() + if got != want { + t.Errorf("expected matching tokens, got %q, want %q", got, want) + } +} + +func NewUUID(t testing.TB) uuid.UUID { + t.Helper() + uuid, err := uuid.NewV4() + if err != nil { + t.Errorf("error generation uuid: %v", err) + } + return uuid +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go index df6f811..d967cc6 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -20,7 +20,7 @@ func (h *ContextHandler) Handle(ctx context.Context, r slog.Record) error { } func NewLogger() *slog.Logger { - baseHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: false}) + baseHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{AddSource: true}) customHandler := &ContextHandler{Handler: baseHandler} logger := slog.New(customHandler) diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..3728a95 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,60 @@ +package middleware + +import ( + "context" + "log/slog" + "net/http" + + "github.com/golang-jwt/jwt/v5" +) + +const EmailKey contextKey = "email" + +func AuthMiddleware(logger *slog.Logger, jwtKey []byte) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("token") + if err == http.ErrNoCookie { + logger.WarnContext(r.Context(), "token not provided") + w.WriteHeader(http.StatusUnauthorized) + return + } + + if err != nil { + logger.ErrorContext(r.Context(), err.Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } + + tokenString := cookie.Value + + token, err := jwt.Parse(tokenString, func(t *jwt.Token) (any, error) { + return jwtKey, nil + }) + + if !token.Valid { + logger.WarnContext(r.Context(), err.Error()) + w.WriteHeader(http.StatusUnauthorized) + return + } + + if err != nil { + logger.ErrorContext(r.Context(), err.Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } + + email, err := token.Claims.GetSubject() + if err != nil { + logger.ErrorContext(r.Context(), err.Error()) + w.WriteHeader(http.StatusInternalServerError) + return + } + + ctx := context.WithValue(r.Context(), EmailKey, email) + newReq := r.WithContext(ctx) + + next.ServeHTTP(w, newReq) + }) + } +} diff --git a/internal/middleware/context.go b/internal/middleware/trace.go similarity index 51% rename from internal/middleware/context.go rename to internal/middleware/trace.go index 35211b3..3029add 100644 --- a/internal/middleware/context.go +++ b/internal/middleware/trace.go @@ -5,17 +5,19 @@ import ( "log/slog" "net/http" - "github.com/google/uuid" + "github.com/gofrs/uuid/v5" ) -type contextKey string const TraceIdKey contextKey = "trace_id" -func ContextMiddleware(logger *slog.Logger) func(http.Handler) http.Handler { +func TraceMiddleware(logger *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - traceid := uuid.NewString() - ctx := context.WithValue(r.Context(), TraceIdKey, traceid) + traceid, err := uuid.NewV4() + if err != nil { + logger.ErrorContext(r.Context(), err.Error()) + } + ctx := context.WithValue(r.Context(), TraceIdKey, traceid.String()) newReq := r.WithContext(ctx) next.ServeHTTP(w, newReq) diff --git a/internal/middleware/util.go b/internal/middleware/util.go index 50ed4fb..cf06793 100644 --- a/internal/middleware/util.go +++ b/internal/middleware/util.go @@ -4,6 +4,8 @@ import "net/http" type Middleware func(http.Handler) http.Handler +type contextKey string + func CompileMiddleware(h http.Handler, m []Middleware) http.Handler { if len(m) < 1 { return h diff --git a/internal/migrate/migrate.go b/internal/migrate/migrate.go index a4a4f26..cf8a159 100644 --- a/internal/migrate/migrate.go +++ b/internal/migrate/migrate.go @@ -1,13 +1,14 @@ package migrate import ( - "database/sql" + "context" "embed" "fmt" "log" "log/slog" - _ "github.com/jackc/pgx/v5/stdlib" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" ) //go:embed migrations/*.sql @@ -18,7 +19,7 @@ type Migration struct { Name string } -func Migrate(logger *slog.Logger, db *sql.DB) { +func Migrate(logger *slog.Logger, db *pgxpool.Pool) { logger.Info("Running migrations...") migrationTableSql := ` CREATE TABLE IF NOT EXISTS migrations( @@ -26,7 +27,7 @@ func Migrate(logger *slog.Logger, db *sql.DB) { name VARCHAR(50) );` - _, err := db.Exec(migrationTableSql) + _, err := db.Exec(context.Background(), migrationTableSql) if err != nil { log.Fatal(err) } @@ -38,21 +39,21 @@ func Migrate(logger *slog.Logger, db *sql.DB) { for _, file := range files { var migration Migration - row := db.QueryRow("SELECT * FROM migrations WHERE name = $1;", file.Name()) + row := db.QueryRow(context.Background(), "SELECT * FROM migrations WHERE name = $1;", file.Name()) err = row.Scan(&migration.Version, &migration.Name) - if err == sql.ErrNoRows { + if err == pgx.ErrNoRows { logger.Info(fmt.Sprintf("Running migration: %s", file.Name())) migrationSql, err := migrations.ReadFile(fmt.Sprintf("migrations/%s", file.Name())) if err != nil { log.Fatal(err) } - _, err = db.Exec(string(migrationSql)) + _, err = db.Exec(context.Background(), string(migrationSql)) if err != nil { log.Fatal(err) } - _, err = db.Exec("INSERT INTO migrations(name) VALUES($1);", file.Name()) + _, err = db.Exec(context.Background(), "INSERT INTO migrations(name) VALUES($1);", file.Name()) if err != nil { log.Fatal(err) } diff --git a/internal/migrate/migrations/1-init_db.sql b/internal/migrate/migrations/1-init_db.sql index 084a3fa..79d92cc 100644 --- a/internal/migrate/migrations/1-init_db.sql +++ b/internal/migrate/migrations/1-init_db.sql @@ -3,3 +3,10 @@ CREATE TABLE todo( name VARCHAR(50), done BOOLEAN ); + +CREATE TABLE users( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR NOT NULL UNIQUE, + hashed_password bytea NOT NULL, + salt bytea NOT NULL +); diff --git a/internal/test/test_database.go b/internal/test/test_database.go index a6919e0..1ca7562 100644 --- a/internal/test/test_database.go +++ b/internal/test/test_database.go @@ -2,19 +2,21 @@ package test import ( "context" - "database/sql" "log/slog" "testing" "time" "gitea.michaelthomson.dev/mthomson/habits/internal/migrate" + pgxuuid "github.com/jackc/pgx-gofrs-uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/testcontainers/testcontainers-go/wait" ) type TestDatabase struct { - Db *sql.DB + Db *pgxpool.Pool container testcontainers.Container } @@ -42,8 +44,18 @@ func NewTestDatabase(tb testing.TB) *TestDatabase { tb.Fatalf("Failed to get connection string: %v", err) } + dbconfig, err := pgxpool.ParseConfig(connectionString) + if err != nil { + tb.Fatalf("Failed to create db config: %v", err) + } + + dbconfig.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error { + pgxuuid.Register(conn.TypeMap()) + return nil + } + // create db pool - db, err := sql.Open("pgx", connectionString) + db, err := pgxpool.NewWithConfig(context.Background(), dbconfig) if err != nil { tb.Fatalf("Failed to open db pool: %v", err) } diff --git a/internal/todo/repository/inmemory/inmemory.go b/internal/todo/repository/inmemory/inmemory.go deleted file mode 100644 index 8d4f109..0000000 --- a/internal/todo/repository/inmemory/inmemory.go +++ /dev/null @@ -1,59 +0,0 @@ -package inmemory - -import ( - "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository" -) - -type InMemoryTodoRepository struct { - Db map[int64]repository.TodoRow - id int64 -} - -func NewInMemoryTodoRepository() InMemoryTodoRepository { - return InMemoryTodoRepository{ - Db: make(map[int64]repository.TodoRow), - id: 1, - } -} - -func (r *InMemoryTodoRepository) GetById(id int64) (repository.TodoRow, error) { - todo, found := r.Db[id] - - if !found { - return todo, repository.ErrNotFound - } - - return todo, nil -} - -func (r *InMemoryTodoRepository) Create(todo repository.TodoRow) (repository.TodoRow, error) { - todo.Id = r.id - r.Db[r.id] = todo - r.id++ - - return todo, nil -} - -func (r *InMemoryTodoRepository) Update(todo repository.TodoRow) error { - _, found := r.Db[todo.Id] - - if !found { - return repository.ErrNotFound - } - - r.Db[todo.Id] = todo - - return nil -} - -func (r *InMemoryTodoRepository) Delete(id int64) error { - _, found := r.Db[id] - - if !found { - return repository.ErrNotFound - } - - delete(r.Db, id) - - return nil -} diff --git a/internal/todo/repository/postgres/postgres.go b/internal/todo/repository/postgres/postgres.go deleted file mode 100644 index c1c834f..0000000 --- a/internal/todo/repository/postgres/postgres.go +++ /dev/null @@ -1,94 +0,0 @@ -package postgres - -import ( - "context" - "database/sql" - "log/slog" - - "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository" -) - -type PostgresTodoRepository struct { - logger *slog.Logger - db *sql.DB -} - -func NewPostgresTodoRepository(logger *slog.Logger, db *sql.DB) *PostgresTodoRepository { - return &PostgresTodoRepository{ - logger: logger, - db: db, - } -} - -func (r *PostgresTodoRepository) GetById(ctx context.Context, id int64) (repository.TodoRow, error) { - todo := repository.TodoRow{} - - err := r.db.QueryRow("SELECT * FROM todo WHERE id = $1;", id).Scan(&todo.Id, &todo.Name, &todo.Done) - - if err != nil { - if err == sql.ErrNoRows { - return todo, repository.ErrNotFound - } - - r.logger.ErrorContext(ctx, err.Error()) - return todo, err - } - - return todo, nil -} - -func (r *PostgresTodoRepository) Create(ctx context.Context, todo repository.TodoRow) (repository.TodoRow, error) { - result := r.db.QueryRow("INSERT INTO todo (name, done) VALUES ($1, $2) RETURNING id;", todo.Name, todo.Done) - err := result.Scan(&todo.Id) - - if err != nil { - r.logger.ErrorContext(ctx, err.Error()) - return repository.TodoRow{}, err - } - - return todo, nil -} - -func (r *PostgresTodoRepository) Update(ctx context.Context, todo repository.TodoRow) error { - result, err := r.db.Exec("UPDATE todo SET name = $1, done = $2 WHERE id = $3;", todo.Name, todo.Done, todo.Id) - - if err != nil { - r.logger.ErrorContext(ctx, err.Error()) - return err - } - - rowsAffected, err := result.RowsAffected() - - if err != nil { - r.logger.ErrorContext(ctx, err.Error()) - return err - } - - if rowsAffected == 0 { - return repository.ErrNotFound - } - - return nil -} - -func (r *PostgresTodoRepository) Delete(ctx context.Context, id int64) error { - result, err := r.db.Exec("DELETE FROM todo WHERE id = $1;", id) - - if err != nil { - r.logger.ErrorContext(ctx, err.Error()) - return err - } - - rowsAffected, err := result.RowsAffected() - - if err != nil { - r.logger.ErrorContext(ctx, err.Error()) - return err - } - - if rowsAffected == 0 { - return repository.ErrNotFound - } - - return nil -} diff --git a/internal/todo/repository/repository.go b/internal/todo/repository/repository.go index ff46533..39fe063 100644 --- a/internal/todo/repository/repository.go +++ b/internal/todo/repository/repository.go @@ -1,7 +1,12 @@ package repository import ( + "context" "errors" + "log/slog" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" ) var ( @@ -21,3 +26,78 @@ func NewTodoRow(name string, done bool) TodoRow { func (t TodoRow) Equal(todo TodoRow) bool { return t.Id == todo.Id && t.Name == todo.Name && t.Done == todo.Done } + +type TodoRepository struct { + logger *slog.Logger + db *pgxpool.Pool +} + +func NewTodoRepository(logger *slog.Logger, db *pgxpool.Pool) *TodoRepository { + return &TodoRepository{ + logger: logger, + db: db, + } +} + +func (r *TodoRepository) GetById(ctx context.Context, id int64) (TodoRow, error) { + todo := TodoRow{} + + err := r.db.QueryRow(ctx, "SELECT * FROM todo WHERE id = $1;", id).Scan(&todo.Id, &todo.Name, &todo.Done) + + if err != nil { + if err == pgx.ErrNoRows { + return todo, ErrNotFound + } + + r.logger.ErrorContext(ctx, err.Error()) + return todo, err + } + + return todo, nil +} + +func (r *TodoRepository) Create(ctx context.Context, todo TodoRow) (TodoRow, error) { + result := r.db.QueryRow(ctx, "INSERT INTO todo (name, done) VALUES ($1, $2) RETURNING id;", todo.Name, todo.Done) + err := result.Scan(&todo.Id) + + if err != nil { + r.logger.ErrorContext(ctx, err.Error()) + return TodoRow{}, err + } + + return todo, nil +} + +func (r *TodoRepository) Update(ctx context.Context, todo TodoRow) error { + result, err := r.db.Exec(ctx, "UPDATE todo SET name = $1, done = $2 WHERE id = $3;", todo.Name, todo.Done, todo.Id) + + if err != nil { + r.logger.ErrorContext(ctx, err.Error()) + return err + } + + rowsAffected := result.RowsAffected() + + if rowsAffected == 0 { + return ErrNotFound + } + + return nil +} + +func (r *TodoRepository) Delete(ctx context.Context, id int64) error { + result, err := r.db.Exec(ctx, "DELETE FROM todo WHERE id = $1;", id) + + if err != nil { + r.logger.ErrorContext(ctx, err.Error()) + return err + } + + rowsAffected := result.RowsAffected() + + if rowsAffected == 0 { + return ErrNotFound + } + + return nil +} diff --git a/internal/todo/repository/postgres/postgres_test.go b/internal/todo/repository/repository_test.go similarity index 94% rename from internal/todo/repository/postgres/postgres_test.go rename to internal/todo/repository/repository_test.go index a858954..ca514a6 100644 --- a/internal/todo/repository/postgres/postgres_test.go +++ b/internal/todo/repository/repository_test.go @@ -1,4 +1,4 @@ -package postgres +package repository_test import ( "context" @@ -8,7 +8,6 @@ import ( "gitea.michaelthomson.dev/mthomson/habits/internal/test" "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository" - _ "github.com/jackc/pgx/v5/stdlib" ) func TestCRUD(t *testing.T) { @@ -16,7 +15,7 @@ func TestCRUD(t *testing.T) { logger := slog.Default() tdb := test.NewTestDatabase(t) defer tdb.TearDown() - r := NewPostgresTodoRepository(logger, tdb.Db) + r := repository.NewTodoRepository(logger, tdb.Db) t.Run("creates new todo", func(t *testing.T) { want := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false} diff --git a/internal/todo/repository/sqlite/sqlite.go b/internal/todo/repository/sqlite/sqlite.go deleted file mode 100644 index 5988b5c..0000000 --- a/internal/todo/repository/sqlite/sqlite.go +++ /dev/null @@ -1,92 +0,0 @@ -package sqlite - -import ( - "database/sql" - - "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository" -) - -type SqliteTodoRepository struct { - db *sql.DB -} - -func NewSqliteTodoRepository(db *sql.DB) *SqliteTodoRepository { - return &SqliteTodoRepository{ - db: db, - } -} - -func (r *SqliteTodoRepository) GetById(id int64) (repository.TodoRow, error) { - todo := repository.TodoRow{} - - row := r.db.QueryRow("SELECT * FROM todo WHERE id = ?;", id) - err := row.Scan(&todo.Id, &todo.Name, &todo.Done) - - if err != nil { - if err == sql.ErrNoRows { - return todo, repository.ErrNotFound - } - - return todo, err - } - - return todo, nil -} - -func (r *SqliteTodoRepository) Create(todo repository.TodoRow) (repository.TodoRow, error) { - result, err := r.db.Exec("INSERT INTO todo (name, done) VALUES (?, ?)", todo.Name, todo.Done) - - if err != nil { - return repository.TodoRow{}, err - } - - id, err := result.LastInsertId() - - if err != nil { - return repository.TodoRow{}, err - } - - todo.Id = id - - return todo, nil -} - -func (r *SqliteTodoRepository) Update(todo repository.TodoRow) error { - result, err := r.db.Exec("UPDATE todo SET name = ?, done = ? WHERE id = ?", todo.Name, todo.Done, todo.Id) - - if err != nil { - return err - } - - rowsAffected, err := result.RowsAffected() - - if err != nil { - return err - } - - if rowsAffected == 0 { - return repository.ErrNotFound - } - - return nil -} - -func (r *SqliteTodoRepository) Delete(id int64) error { - result, err := r.db.Exec("DELETE FROM todo WHERE id = ?", id) - - if err != nil { - return err - } - - rowsAffected, err := result.RowsAffected() - - if err != nil { - return err - } - - if rowsAffected == 0 { - return repository.ErrNotFound - } - - return nil -} diff --git a/internal/todo/service/service_test.go b/internal/todo/service/service_test.go index bf1c84c..4f9ea5a 100644 --- a/internal/todo/service/service_test.go +++ b/internal/todo/service/service_test.go @@ -7,9 +7,7 @@ import ( "gitea.michaelthomson.dev/mthomson/habits/internal/test" "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository" - "gitea.michaelthomson.dev/mthomson/habits/internal/todo/repository/postgres" "gitea.michaelthomson.dev/mthomson/habits/internal/todo/service" - _ "github.com/jackc/pgx/v5/stdlib" ) func TestCreateTodo(t *testing.T) { @@ -18,7 +16,7 @@ func TestCreateTodo(t *testing.T) { logger := slog.Default() tdb := test.NewTestDatabase(t) defer tdb.TearDown() - r := postgres.NewPostgresTodoRepository(logger, tdb.Db) + r := repository.NewTodoRepository(logger, tdb.Db) todoService := service.NewTodoService(logger, r) @@ -37,11 +35,11 @@ func TestGetTodo(t *testing.T) { logger := slog.Default() tdb := test.NewTestDatabase(t) defer tdb.TearDown() - r := postgres.NewPostgresTodoRepository(logger, tdb.Db) + r := repository.NewTodoRepository(logger, tdb.Db) row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false} _, err := r.Create(ctx, row) - AssertNoError(t, err); + AssertNoError(t, err) todoService := service.NewTodoService(logger, r) @@ -64,11 +62,11 @@ func TestDeleteTodo(t *testing.T) { logger := slog.Default() tdb := test.NewTestDatabase(t) defer tdb.TearDown() - r := postgres.NewPostgresTodoRepository(logger, tdb.Db) + r := repository.NewTodoRepository(logger, tdb.Db) row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false} _, err := r.Create(ctx, row) - AssertNoError(t, err); + AssertNoError(t, err) todoService := service.NewTodoService(logger, r) @@ -91,11 +89,11 @@ func TestUpdateTodo(t *testing.T) { logger := slog.Default() tdb := test.NewTestDatabase(t) defer tdb.TearDown() - r := postgres.NewPostgresTodoRepository(logger, tdb.Db) + r := repository.NewTodoRepository(logger, tdb.Db) row := repository.TodoRow{Id: 1, Name: "clean dishes", Done: false} _, err := r.Create(ctx, row) - AssertNoError(t, err); + AssertNoError(t, err) todoService := service.NewTodoService(logger, r) diff --git a/internal/user/handler/register.go b/internal/user/handler/register.go new file mode 100644 index 0000000..98d9f1b --- /dev/null +++ b/internal/user/handler/register.go @@ -0,0 +1,67 @@ +package handler + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + + "gitea.michaelthomson.dev/mthomson/habits/internal/user/service" + "github.com/gofrs/uuid/v5" +) + +type UserRegisterer interface { + Register(ctx context.Context, email string, password string) (uuid.UUID, error) +} + +type RegisterRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type RegisterResponse struct { + Id string `json:"id"` +} + +func HandleRegisterUser(logger *slog.Logger, userService UserRegisterer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + registerRequest := RegisterRequest{} + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + err := decoder.Decode(®isterRequest) + + if err != nil { + logger.ErrorContext(ctx, err.Error()) + http.Error(w, "", http.StatusBadRequest) + return + } + + uuid, err := userService.Register(ctx, registerRequest.Email, registerRequest.Password) + + if err != nil { + if err == service.ErrUserExists { + http.Error(w, "", http.StatusConflict) + return + } + + logger.ErrorContext(ctx, err.Error()) + http.Error(w, "", http.StatusInternalServerError) + return + } + + response := RegisterResponse{uuid.String()} + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + err = json.NewEncoder(w).Encode(response) + + if err != nil { + logger.ErrorContext(ctx, err.Error()) + http.Error(w, "", http.StatusInternalServerError) + return + } + + } +} diff --git a/internal/user/handler/register_test.go b/internal/user/handler/register_test.go new file mode 100644 index 0000000..4cd1c43 --- /dev/null +++ b/internal/user/handler/register_test.go @@ -0,0 +1,148 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + + "errors" + "net/http" + "net/http/httptest" + "testing" + + "gitea.michaelthomson.dev/mthomson/habits/internal/user/service" + "github.com/gofrs/uuid/v5" +) + +type MockUserRegisterer struct { + RegisterUserFunc func(ctx context.Context, email string, password string) (uuid.UUID, error) +} + +func (m *MockUserRegisterer) Register(ctx context.Context, email string, password string) (uuid.UUID, error) { + return m.RegisterUserFunc(ctx, email, password) +} + +func TestCreateUser(t *testing.T) { + logger := slog.Default() + t.Run("create user", func(t *testing.T) { + createUserRequest := RegisterRequest{Email: "test@test.com", Password: "password"} + + newUUID := NewUUID(t) + + service := MockUserRegisterer{ + RegisterUserFunc: func(ctx context.Context, email string, password string) (uuid.UUID, error) { + return newUUID, nil + }, + } + + handler := HandleRegisterUser(logger, &service) + + requestBody, err := json.Marshal(createUserRequest) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", createUserRequest, err) + } + + req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + + handler(res, req) + + AssertStatusCodes(t, res.Code, http.StatusOK) + }) + + t.Run("returns 409 when user exists", func(t *testing.T) { + createUserRequest := RegisterRequest{Email: "test@test.com", Password: "password"} + + newUUID := NewUUID(t) + + service := MockUserRegisterer{ + RegisterUserFunc: func(ctx context.Context, email string, password string) (uuid.UUID, error) { + return newUUID, service.ErrUserExists + }, + } + + handler := HandleRegisterUser(logger, &service) + + requestBody, err := json.Marshal(createUserRequest) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", createUserRequest, err) + } + + req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + + handler(res, req) + + AssertStatusCodes(t, res.Code, http.StatusConflict) + }) + + t.Run("returns 400 with bad json", func(t *testing.T) { + handler := HandleRegisterUser(logger, nil) + + badStruct := struct { + Foo string + }{ + Foo: "bar", + } + + requestBody, err := json.Marshal(badStruct) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", badStruct, err) + } + + req := httptest.NewRequest(http.MethodPost, "/register", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + + handler(res, req) + + AssertStatusCodes(t, res.Code, http.StatusBadRequest) + }) + + t.Run("returns 500 arbitrary errors", func(t *testing.T) { + createUserRequest := RegisterRequest{Email: "test@test.com", Password: "password"} + + newUUID := NewUUID(t) + + service := MockUserRegisterer{ + RegisterUserFunc: func(ctx context.Context, email string, password string) (uuid.UUID, error) { + return newUUID, errors.New("foo bar") + }, + } + + handler := HandleRegisterUser(logger, &service) + + requestBody, err := json.Marshal(createUserRequest) + + if err != nil { + t.Fatalf("Failed to marshal request %+v: %v", createUserRequest, err) + } + + req := httptest.NewRequest(http.MethodPost, "/user", bytes.NewBuffer(requestBody)) + res := httptest.NewRecorder() + + handler(res, req) + + AssertStatusCodes(t, res.Code, http.StatusInternalServerError) + }) + +} + +func AssertStatusCodes(t testing.TB, got, want int) { + t.Helper() + if got != want { + t.Errorf("got status code: %v, want status code: %v", want, got) + } +} + +func NewUUID(t testing.TB) uuid.UUID { + t.Helper() + uuid, err := uuid.NewV4() + if err != nil { + t.Errorf("error generation uuid: %v", err) + } + return uuid +} diff --git a/internal/user/repository/repository.go b/internal/user/repository/repository.go new file mode 100644 index 0000000..72dc086 --- /dev/null +++ b/internal/user/repository/repository.go @@ -0,0 +1,130 @@ +package repository + +import ( + "bytes" + "context" + "errors" + "log/slog" + + "github.com/gofrs/uuid/v5" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ( + ErrNotFound error = errors.New("user cannot be found") +) + +type UserRow struct { + Id uuid.UUID + Email string + HashedPassword []byte + Salt []byte +} + +func (u UserRow) Equal(user UserRow) bool { + return u.Id == user.Id && u.Email == user.Email && bytes.Equal(u.HashedPassword, user.HashedPassword) && bytes.Equal(u.Salt, user.Salt) +} + +type UserRepository struct { + logger *slog.Logger + db *pgxpool.Pool +} + +func NewUserRepository(logger *slog.Logger, db *pgxpool.Pool) *UserRepository { + return &UserRepository{ + logger: logger, + db: db, + } +} + +func (r *UserRepository) GetById(ctx context.Context, id uuid.UUID) (UserRow, error) { + user := UserRow{} + + err := r.db.QueryRow(ctx, "SELECT * FROM users WHERE id = $1;", id).Scan(&user.Id, &user.Email, &user.HashedPassword, &user.Salt) + + if err != nil { + if err == pgx.ErrNoRows { + return user, ErrNotFound + } + + r.logger.ErrorContext(ctx, err.Error()) + return user, err + } + + return user, nil +} + +func (r *UserRepository) GetByEmail(ctx context.Context, email string) (UserRow, error) { + user := UserRow{} + + err := r.db.QueryRow(ctx, "SELECT * FROM users WHERE email = $1;", email).Scan(&user.Id, &user.Email, &user.HashedPassword, &user.Salt) + + if err != nil { + if err == pgx.ErrNoRows { + return user, ErrNotFound + } + + r.logger.ErrorContext(ctx, err.Error()) + return user, err + } + + return user, nil +} + +func (r *UserRepository) Create(ctx context.Context, user UserRow) (UserRow, error) { + var result pgx.Row + if user.Id.IsNil() { + result = r.db.QueryRow(ctx, "INSERT INTO users (email, hashed_password, salt) VALUES ($1, $2, $3) RETURNING id;", user.Email, user.HashedPassword, user.Salt) + + err := result.Scan(&user.Id) + + if err != nil { + r.logger.ErrorContext(ctx, err.Error()) + return UserRow{}, err + } + } else { + _, err := r.db.Exec(ctx, "INSERT INTO users (id, email, hashed_password, salt) VALUES ($1, $2, $3, $4);", user.Id, user.Email, user.HashedPassword, user.Salt) + + if err != nil { + r.logger.ErrorContext(ctx, err.Error()) + return UserRow{}, err + } + } + + return user, nil +} + +func (r *UserRepository) Update(ctx context.Context, user UserRow) error { + result, err := r.db.Exec(ctx, "UPDATE users SET email = $1, hashed_password = $2, salt = $3 WHERE id = $4;", user.Email, user.HashedPassword, user.Salt, user.Id) + + if err != nil { + r.logger.ErrorContext(ctx, err.Error()) + return err + } + + rowsAffected := result.RowsAffected() + + if rowsAffected == 0 { + return ErrNotFound + } + + return nil +} + +func (r *UserRepository) Delete(ctx context.Context, id uuid.UUID) error { + result, err := r.db.Exec(ctx, "DELETE FROM users WHERE id = $1;", id) + + if err != nil { + r.logger.ErrorContext(ctx, err.Error()) + return err + } + + rowsAffected := result.RowsAffected() + + if rowsAffected == 0 { + return ErrNotFound + } + + return nil +} diff --git a/internal/user/repository/repository_test.go b/internal/user/repository/repository_test.go new file mode 100644 index 0000000..79d3933 --- /dev/null +++ b/internal/user/repository/repository_test.go @@ -0,0 +1,99 @@ +package repository_test + +import ( + "context" + "errors" + "log/slog" + "testing" + + "gitea.michaelthomson.dev/mthomson/habits/internal/auth" + "gitea.michaelthomson.dev/mthomson/habits/internal/test" + "gitea.michaelthomson.dev/mthomson/habits/internal/user/repository" + "github.com/gofrs/uuid/v5" +) + +func TestCRUD(t *testing.T) { + ctx := context.Background() + logger := slog.Default() + tdb := test.NewTestDatabase(t) + defer tdb.TearDown() + r := repository.NewUserRepository(logger, tdb.Db) + uuid := NewUUID(t) + argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256) + + hashSalt, err := argon2IdHash.GenerateHash([]byte("supersecurepassword"), []byte("supersecuresalt")) + + if err != nil { + t.Errorf("could not generate hash: %v", err) + } + + t.Run("creates new user", func(t *testing.T) { + newUser := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt} + _, err := r.Create(ctx, newUser) + AssertNoError(t, err) + }) + + t.Run("gets user by id", func(t *testing.T) { + want := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt} + got, err := r.GetById(ctx, uuid) + AssertNoError(t, err) + AssertUserRows(t, got, want) + }) + + t.Run("gets user by email", func(t *testing.T) { + want := repository.UserRow{Id: uuid, Email: "test@test.com", HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt} + got, err := r.GetByEmail(ctx, "test@test.com") + AssertNoError(t, err) + AssertUserRows(t, got, want) + }) + + t.Run("updates user", func(t *testing.T) { + want := repository.UserRow{Id: uuid, Email: "new@test.com", HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt} + err := r.Update(ctx, want) + AssertNoError(t, err) + + got, err := r.GetById(ctx, uuid) + AssertNoError(t, err) + AssertUserRows(t, got, want) + }) + + t.Run("deletes user", func(t *testing.T) { + err := r.Delete(ctx, uuid) + AssertNoError(t, err) + + want := repository.ErrNotFound + _, got := r.GetById(ctx, uuid) + + AssertErrors(t, got, want) + }) +} + +func AssertErrors(t testing.TB, got, want error) { + t.Helper() + if !errors.Is(got, want) { + t.Errorf("got error: %v, want error: %v", want, got) + } +} + +func AssertNoError(t testing.TB, err error) { + t.Helper() + if err != nil { + t.Errorf("expected no error, got %v", err) + } +} + +func AssertUserRows(t testing.TB, got, want repository.UserRow) { + t.Helper() + if !got.Equal(want) { + t.Errorf("got %+v want %+v", got, want) + } +} + +func NewUUID(t testing.TB) uuid.UUID { + t.Helper() + uuid, err := uuid.NewV4() + if err != nil { + t.Errorf("error generation uuid: %v", err) + } + return uuid +} diff --git a/internal/user/service/service.go b/internal/user/service/service.go new file mode 100644 index 0000000..fac8c2e --- /dev/null +++ b/internal/user/service/service.go @@ -0,0 +1,185 @@ +package service + +import ( + "context" + "errors" + "log/slog" + + "gitea.michaelthomson.dev/mthomson/habits/internal/auth" + "gitea.michaelthomson.dev/mthomson/habits/internal/user/repository" + "github.com/gofrs/uuid/v5" +) + +var ( + ErrNotFound error = errors.New("user cannot be found") + ErrUserExists error = errors.New("user already exists") +) + +type User struct { + Id uuid.UUID + Email string +} + +func UserFromUserRow(userRow repository.UserRow) User { + return User{Id: userRow.Id, Email: userRow.Email} +} + +func (t User) Equal(user User) bool { + return t.Id == user.Id && t.Email == user.Email +} + +type UserRepository interface { + Create(ctx context.Context, user repository.UserRow) (repository.UserRow, error) + GetById(ctx context.Context, id uuid.UUID) (repository.UserRow, error) + GetByEmail(ctx context.Context, email string) (repository.UserRow, error) + Update(ctx context.Context, user repository.UserRow) error + Delete(ctx context.Context, id uuid.UUID) error +} + +type UserService struct { + logger *slog.Logger + repo UserRepository + argon2IdHash *auth.Argon2IdHash +} + +func NewUserService(logger *slog.Logger, userRepo UserRepository, argon2IdHash *auth.Argon2IdHash) *UserService { + return &UserService{ + logger: logger, + repo: userRepo, + argon2IdHash: argon2IdHash, + } +} + +func (s *UserService) GetUser(ctx context.Context, id uuid.UUID) (User, error) { + user, err := s.repo.GetById(ctx, id) + + if err != nil { + if err == repository.ErrNotFound { + return User{}, ErrNotFound + } + + s.logger.ErrorContext(ctx, err.Error()) + return User{}, err + } + + return UserFromUserRow(user), err +} + +func (s *UserService) Register(ctx context.Context, email string, password string) (uuid.UUID, error) { + uuid, err := uuid.NewV4() + + if err != nil { + s.logger.ErrorContext(ctx, err.Error()) + return uuid, err + } + + _, err = s.repo.GetByEmail(ctx, email) + + if err != repository.ErrNotFound { + return uuid, ErrUserExists + } + + hashSalt, err := s.argon2IdHash.GenerateHash([]byte(password), nil) + + if err != nil { + s.logger.ErrorContext(ctx, err.Error()) + return uuid, err + } + + userRow := repository.UserRow{Id: uuid, Email: email, HashedPassword: hashSalt.Hash, Salt: hashSalt.Salt} + + _, err = s.repo.Create(ctx, userRow) + + if err != nil { + s.logger.ErrorContext(ctx, err.Error()) + return uuid, err + } + + return uuid, err +} + +func (s *UserService) DeleteUser(ctx context.Context, id uuid.UUID) error { + err := s.repo.Delete(ctx, id) + + if err == repository.ErrNotFound { + return ErrNotFound + } + + if err != nil { + s.logger.ErrorContext(ctx, err.Error()) + } + + return err +} + +func (s *UserService) UpdateUserEmail(ctx context.Context, id uuid.UUID, email string) error { + user, err := s.repo.GetById(ctx, id) + + if err == repository.ErrNotFound { + return ErrNotFound + } + + if err != nil { + s.logger.ErrorContext(ctx, err.Error()) + return err + } + + _, err = s.repo.GetByEmail(ctx, email) + + switch err { + case repository.ErrNotFound: + user.Email = email + + err = s.repo.Update(ctx, user) + + if err == repository.ErrNotFound { + return ErrNotFound + } + + if err != nil { + s.logger.ErrorContext(ctx, err.Error()) + } + + return err + case nil: + return ErrUserExists + default: + s.logger.ErrorContext(ctx, err.Error()) + return err + } +} + +func (s *UserService) UpdateUserPassword(ctx context.Context, id uuid.UUID, password string) error { + user, err := s.repo.GetById(ctx, id) + + if err == repository.ErrNotFound { + return ErrNotFound + } + + if err != nil { + s.logger.ErrorContext(ctx, err.Error()) + return err + } + + hashSalt, err := s.argon2IdHash.GenerateHash([]byte(password), nil) + + if err != nil { + s.logger.ErrorContext(ctx, err.Error()) + return err + } + + user.HashedPassword = hashSalt.Hash + user.Salt = hashSalt.Salt + + err = s.repo.Update(ctx, user) + + if err == repository.ErrNotFound { + return ErrNotFound + } + + if err != nil { + s.logger.ErrorContext(ctx, err.Error()) + } + + return err +} diff --git a/internal/user/service/service_test.go b/internal/user/service/service_test.go new file mode 100644 index 0000000..d63f67e --- /dev/null +++ b/internal/user/service/service_test.go @@ -0,0 +1,149 @@ +package service_test + +import ( + "context" + "log/slog" + "testing" + + "gitea.michaelthomson.dev/mthomson/habits/internal/auth" + "gitea.michaelthomson.dev/mthomson/habits/internal/test" + "gitea.michaelthomson.dev/mthomson/habits/internal/user/repository" + "gitea.michaelthomson.dev/mthomson/habits/internal/user/service" + "github.com/gofrs/uuid/v5" +) + +func TestRegisterUser(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slog.Default() + tdb := test.NewTestDatabase(t) + defer tdb.TearDown() + r := repository.NewUserRepository(logger, tdb.Db) + argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256) + + userService := service.NewUserService(logger, r, argon2IdHash) + + t.Run("Create user", func(t *testing.T) { + _, err := userService.Register(ctx, "test@test.com", "supersecurepassword") + + AssertNoError(t, err) + }) +} + +func TestGetUser(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slog.Default() + tdb := test.NewTestDatabase(t) + defer tdb.TearDown() + r := repository.NewUserRepository(logger, tdb.Db) + argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256) + + userService := service.NewUserService(logger, r, argon2IdHash) + + uuid, err := userService.Register(ctx, "test@test.com", "supersecurepassword") + AssertNoError(t, err) + + t.Run("Get exisiting user", func(t *testing.T) { + _, err := userService.GetUser(ctx, uuid) + + AssertNoError(t, err) + }) + + t.Run("Get non-existant user", func(t *testing.T) { + _, err := userService.GetUser(ctx, NewUUID(t)) + + AssertErrors(t, err, service.ErrNotFound) + }) +} + +func TestDeleteUser(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slog.Default() + tdb := test.NewTestDatabase(t) + defer tdb.TearDown() + r := repository.NewUserRepository(logger, tdb.Db) + argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256) + + userService := service.NewUserService(logger, r, argon2IdHash) + + uuid, err := userService.Register(ctx, "test@test.com", "supersecurepassword") + AssertNoError(t, err) + + t.Run("Delete exisiting user", func(t *testing.T) { + err := userService.DeleteUser(ctx, uuid) + + AssertNoError(t, err) + }) + + t.Run("Delete non-existant user", func(t *testing.T) { + err := userService.DeleteUser(ctx, uuid) + + AssertErrors(t, err, service.ErrNotFound) + }) +} + +func TestUpdateUserEmail(t *testing.T) { + t.Parallel() + ctx := context.Background() + logger := slog.Default() + tdb := test.NewTestDatabase(t) + defer tdb.TearDown() + r := repository.NewUserRepository(logger, tdb.Db) + argon2IdHash := auth.NewArgon2IdHash(1, 32, 64*1024, 32, 256) + + userService := service.NewUserService(logger, r, argon2IdHash) + + uuid, err := userService.Register(ctx, "test@test.com", "supersecurepassword") + AssertNoError(t, err) + + t.Run("Update existing user email", func(t *testing.T) { + err := userService.UpdateUserEmail(ctx, uuid, "newemail@test.com") + + AssertNoError(t, err) + + newUser, err := userService.GetUser(ctx, uuid) + + AssertNoError(t, err) + + if newUser.Email != "newemail@test.com" { + t.Errorf("Emails do not match wanted %q, got %q", "newemail@test.com", newUser.Email) + } + }) + + t.Run("Update non-existant user", func(t *testing.T) { + err := userService.UpdateUserEmail(ctx, NewUUID(t), "newemail@test.com") + + AssertErrors(t, err, service.ErrNotFound) + }) +} + +func AssertErrors(t testing.TB, got, want error) { + t.Helper() + if got != want { + t.Errorf("got error: %v, want error: %v", want, got) + } +} + +func AssertNoError(t testing.TB, err error) { + t.Helper() + if err != nil { + t.Errorf("expected no error, got %v", err) + } +} + +func AssertUsers(t testing.TB, got, want service.User) { + if !got.Equal(want) { + t.Errorf("got %+v want %+v", got, want) + } +} + +func NewUUID(t testing.TB) uuid.UUID { + t.Helper() + uuid, err := uuid.NewV4() + if err != nil { + t.Errorf("error generation uuid: %v", err) + } + return uuid +}