diff --git a/go.mod b/go.mod index 2c25b01..d7eddb3 100644 --- a/go.mod +++ b/go.mod @@ -2,13 +2,17 @@ module nfeeder go 1.26.1 -require github.com/jackc/pgx/v5 v5.9.1 +require ( + github.com/go-chi/chi/v5 v5.2.5 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/jackc/pgx/v5 v5.9.1 + golang.org/x/crypto v0.50.0 +) require ( - github.com/go-chi/chi/v5 v5.2.5 // 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 - golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 43868dc..0d2e24a 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ 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/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -18,10 +20,12 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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= diff --git a/internal/web/handlers_auth.go b/internal/web/handlers_auth.go new file mode 100644 index 0000000..bb98cb8 --- /dev/null +++ b/internal/web/handlers_auth.go @@ -0,0 +1,109 @@ +package web + +import ( + "fmt" + "log" + "net/http" + "nfeeder/internal/db" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +const ISSUER = "nfeeder-app" + +var jwtKey = []byte("very_super_duper_secret") + +func (s *Server) handleRegister() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + email := r.FormValue("email") + password := r.FormValue("password") + + // hash password + hashpw, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "Internal Error", http.StatusInternalServerError) + return + } + + // create the user + user, err := s.store.CreateUser(r.Context(), db.CreateUserParams{ + Email: email, + Password: string(hashpw), + }) + if err != nil { + // Log the actual error for yourself, send a generic one to the user + log.Printf("failed to create user: %v", err) + http.Error(w, "Could not create user", http.StatusBadRequest) + return + } + + // Auto-login after reg + s.issueTokenAndRedirect(w, r, fmt.Sprintf("%d", user.ID)) + } +} + +func (s *Server) handleLogin() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + email := r.FormValue("email") + password := r.FormValue("password") + + user, err := s.store.GetUserByEmail(r.Context(), email) + if err != nil { + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } + + // compare passwords + err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) + if err != nil { + http.Error(w, "Invalid credentials", http.StatusUnauthorized) + return + } + + s.issueTokenAndRedirect(w, r, fmt.Sprintf("%d", user.ID)) + } +} + +func (s *Server) handleLogout() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // To logout, we simply instruct the browser to delete the cookie + http.SetCookie(w, &http.Cookie{ + Name: "nfeeder_token", + Value: "", + Path: "/", + Expires: time.Unix(0, 0), // Expire immediately + HttpOnly: true, + }) + http.Redirect(w, r, "/login", http.StatusSeeOther) + } +} + +func (s *Server) issueTokenAndRedirect(w http.ResponseWriter, r *http.Request, userID string) { + claims := jwt.RegisteredClaims{ + Issuer: ISSUER, + Subject: userID, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(jwtKey) + if err != nil { + http.Error(w, "Internal Error", http.StatusInternalServerError) + return + } + + http.SetCookie(w, &http.Cookie{ + Name: "nfeeder_token", + Value: tokenString, + Path: "/", + HttpOnly: true, + Secure: false, // Set to true in prod for HTTPS + SameSite: http.SameSiteLaxMode, + Expires: time.Now().Add(24 * time.Hour), + }) + + http.Redirect(w, r, "/", http.StatusSeeOther) +} diff --git a/internal/web/middleware.go b/internal/web/middleware.go new file mode 100644 index 0000000..2740baf --- /dev/null +++ b/internal/web/middleware.go @@ -0,0 +1,53 @@ +package web + +import ( + "context" + "fmt" + "net/http" + + "github.com/golang-jwt/jwt/v5" +) + +type contextKey string + +const userIDKey contextKey = "userID" + +func (s *Server) hasAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("nfeeder_token") + if err != nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + // parse and validate + claims := &jwt.RegisteredClaims{} + token, err := jwt.ParseWithClaims(cookie.Value, claims, func(t *jwt.Token) (interface{}, error) { + // check alg + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"]) + } + + return jwtKey, nil + }) + + /// golang-jwt the library internally runs a Valid(): + /// Time Check: It compares time.Now() against the ExpiresAt value. + /// Not Before Check: It checks if the nbf (Not Before) time has passed. + /// Issued At Check: It ensures the iat isn't in the future. + if err != nil || !token.Valid { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + // Verify issuer + if claims.Issuer != ISSUER { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + + // Success! Store userID in context for handlers to use + ctx := context.WithValue(r.Context(), userIDKey, claims.Subject) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/internal/web/routes.go b/internal/web/routes.go index e620e29..5fc3139 100644 --- a/internal/web/routes.go +++ b/internal/web/routes.go @@ -18,11 +18,21 @@ func (s *Server) setupRoutes() *chi.Mux { // Public routes router.Group(func(r chi.Router) { + // Auth Routes + router.Post("/register", s.handleRegister()) + router.Post("/login", s.handleLogin()) + router.Post("/logout", s.handleLogout()) + + // public pages r.Get("/", s.handleHome()) }) - //s.router.Get("/", s.handleIndex()) - //s.router.Post("/users", s.handleCreateUser()) + // User routes + router.Group(func(r chi.Router) { + // Requires log in + r.Use(s.hasAuth) + router.Mount("/users", s.userRoutes()) + }) return router }