diff --git a/.env.example b/.env.example index f14e063..ec019d8 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ # vi: ft=sh # shellcheck disable=SC2034 +JWT_SECRET=secret + # Database things POSTGRES_USER=vex POSTGRES_PASSWORD=pass diff --git a/api/cmd/feeds.go b/api/cmd/feeds.go index 2eabfa4..c42ba56 100644 --- a/api/cmd/feeds.go +++ b/api/cmd/feeds.go @@ -25,7 +25,6 @@ func (h *Handler) AddFeed(c echo.Context) error { if err = c.Validate(&req); err != nil { return err } - log.Printf("%v", req) feed, err := h.feeds.AddFeed(req.Link, req.Tags, user) if err != nil { @@ -35,6 +34,6 @@ func (h *Handler) AddFeed(c echo.Context) error { return c.JSON(201, feed) } -func (h *Handler) RegisterFeedsRoutes(e *echo.Echo) { - e.POST("/feeds", h.AddFeed) +func (h *Handler) RegisterFeedsRoutes(echo *echo.Echo, restricted *echo.Group) { + restricted.POST("/feeds", h.AddFeed) } diff --git a/api/cmd/main.go b/api/cmd/main.go index 28d4b08..7d21b29 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -8,6 +8,7 @@ import ( "github.com/go-playground/validator/v10" "github.com/jmoiron/sqlx" + "github.com/labstack/echo-jwt/v4" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" _ "github.com/lib/pq" @@ -15,7 +16,9 @@ import ( ) type Handler struct { - feeds vex.FeedService + feeds vex.FeedService + users vex.UserService + jwtSecret []byte } func (h *Handler) GetEntries(c echo.Context) error { @@ -49,14 +52,23 @@ func main() { log.Fatal(err) } h := Handler{ - feeds: vex.NewFeedService(db), + feeds: vex.NewFeedService(db), + users: vex.NewUserService(db), + jwtSecret: []byte(os.Getenv("JWT_SECRET")), } e := echo.New() e.Validator = &Validator{validator: validator.New()} e.Use(middleware.Logger()) + + r := e.Group("") + e.Use(echojwt.WithConfig(echojwt.Config{ + SigningKey: h.jwtSecret, + })) + e.GET("/entries", h.GetEntries) - h.RegisterFeedsRoutes(e) + h.RegisterLoginRoutes(e, r) + h.RegisterFeedsRoutes(e, r) e.Start(":1597") } diff --git a/api/cmd/users.go b/api/cmd/users.go new file mode 100644 index 0000000..253bd25 --- /dev/null +++ b/api/cmd/users.go @@ -0,0 +1,106 @@ +package main + +import ( + "net/http" + + "github.com/golang-jwt/jwt" + "github.com/google/uuid" + "github.com/labstack/echo/v4" + "github.com/zoriya/vex" +) + +type LoginDto struct { + Email string `json:"email" validate:"required"` + Password string `json:"password" validate:"required"` +} + +type RegisterDto struct { + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"required"` + Password string `json:"password" validate:"required,max(60)"` +} + +func (h *Handler) Login(c echo.Context) error { + var req LoginDto + err := c.Bind(&req) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + if err = c.Validate(&req); err != nil { + return err + } + + user := h.users.GetByEmail(req.Email) + if user == nil { + return echo.NewHTTPError(403, "Invalid email") + } + if !h.users.CheckPassword(req.Password, user.Password) { + return echo.NewHTTPError(403, "Invalid password") + } + return h.CreateToken(c, user) +} + +func (h *Handler) CreateToken(c echo.Context, user *vex.User) error { + claims := &jwt.StandardClaims{ + Subject: user.Id.String(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + t, err := token.SignedString(h.jwtSecret) + if err != nil { + return err + } + return c.JSON(http.StatusOK, echo.Map{ + "token": t, + }) +} + +func (h *Handler) Register(c echo.Context) error { + var req RegisterDto + err := c.Bind(&req) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, err.Error()) + } + if err = c.Validate(&req); err != nil { + return err + } + + user, err := h.users.Create(req.Name, req.Email, req.Password) + if err != nil { + return err + } + return h.CreateToken(c, &user) +} + +func (h *Handler) GetMe(c echo.Context) error { + id, err := GetCurrentUserId(c) + if err != nil { + return err + } + user := h.users.GetById(id) + if user == nil { + return echo.NewHTTPError(500, "Internal server error") + } + return c.JSON(200, user) +} + +func GetCurrentUserId(c echo.Context) (uuid.UUID, error) { + user := c.Get("user").(*jwt.Token) + if user == nil { + return uuid.UUID{}, echo.NewHTTPError(401, "Unauthorized") + } + claims := user.Claims.(*jwt.StandardClaims) + if claims == nil { + return uuid.UUID{}, echo.NewHTTPError(403, "Missing claims") + } + ret, err := uuid.Parse(claims.Subject) + if err != nil { + return uuid.UUID{}, echo.NewHTTPError(403, "Invalid id") + } + return ret, nil +} + +func (h *Handler) RegisterLoginRoutes(e *echo.Echo, r *echo.Group) { + e.POST("/login", h.Login) + e.POST("/register", h.Register) + r.GET("/register", h.GetMe) +} diff --git a/api/go.mod b/api/go.mod index dc54d69..b144c98 100644 --- a/api/go.mod +++ b/api/go.mod @@ -4,24 +4,26 @@ go 1.22.1 require ( github.com/go-playground/validator/v10 v10.20.0 + github.com/golang-jwt/jwt v3.2.2+incompatible github.com/google/uuid v1.6.0 github.com/jmoiron/sqlx v1.4.0 + github.com/labstack/echo-jwt/v4 v4.2.0 github.com/labstack/echo/v4 v4.12.0 github.com/lib/pq v1.10.9 + golang.org/x/crypto v0.22.0 ) require ( github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang-jwt/jwt/v5 v5.0.0 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect - golang.org/x/crypto v0.22.0 // indirect golang.org/x/net v0.24.0 // indirect golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/api/go.sum b/api/go.sum index b564c85..27884ac 100644 --- a/api/go.sum +++ b/api/go.sum @@ -16,10 +16,14 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE= +github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/labstack/echo-jwt/v4 v4.2.0 h1:odSISV9JgcSCuhgQSV/6Io3i7nUmfM/QkBeR5GVJj5c= +github.com/labstack/echo-jwt/v4 v4.2.0/go.mod h1:MA2RqdXdEn4/uEglx0HcUOgQSyBaTh5JcaHIan3biwU= github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= diff --git a/api/users.go b/api/users.go new file mode 100644 index 0000000..9092ace --- /dev/null +++ b/api/users.go @@ -0,0 +1,66 @@ +package vex + +import ( + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "golang.org/x/crypto/bcrypt" +) + +type User struct { + Id uuid.UUID + Name string + Email string + Password []byte +} + +type UserService struct { + database *sqlx.DB +} + +func NewUserService(db *sqlx.DB) UserService { + return UserService{database: db} +} + +func (s UserService) GetById(id uuid.UUID) *User { + var user User + err := s.database.Get(&user, "select u.* from users as u where u.id = $1", id) + if err != nil { + return nil + } + return &user +} + +func (s UserService) GetByEmail(email string) *User { + var user User + err := s.database.Get(&user, "select u.* from users as u where u.email = $1", email) + if err != nil { + return nil + } + return &user +} + +func (s UserService) CheckPassword(password string, reference []byte) bool { + return bcrypt.CompareHashAndPassword(reference, []byte(password)) == nil +} + +func (s UserService) Create(name string, email string, password string) (User, error) { + pass, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return User{}, err + } + user := User{ + Id: uuid.New(), + Name: name, + Email: email, + Password: pass, + } + _, err = s.database.NamedExec( + `insert into users (id, name, email, password) + values (:id, :name, :email, :password)`, + user, + ) + if err != nil { + return User{}, err + } + return user, nil +}