diff --git a/api/main_test.go b/api/main_test.go index 9d87304..c407274 100644 --- a/api/main_test.go +++ b/api/main_test.go @@ -3,11 +3,27 @@ package api import ( "os" "testing" + "time" + db "git.nochill.in/nochill/naice_pos/db/sqlc" + "git.nochill.in/nochill/naice_pos/util" "github.com/gin-gonic/gin" _ "github.com/lib/pq" + "github.com/stretchr/testify/require" ) +func newTestServer(t *testing.T, store db.Store) *Server { + config := util.Config{ + TokenSymmetricKey: util.RandomString(32), + TokenDuration: time.Minute, + } + + server, err := NewServer(config, store) + require.NoError(t, err) + + return server +} + func TestMain(m *testing.M) { gin.SetMode(gin.TestMode) diff --git a/api/product_test.go b/api/product_test.go index 19b23d2..0bceb37 100644 --- a/api/product_test.go +++ b/api/product_test.go @@ -91,7 +91,7 @@ func TestGetProductApi(t *testing.T) { store := mockdb.NewMockStore(ctrl) tc.buildStubs(store) - server := NewServer(store) + server := newTestServer(t, store) recorder := httptest.NewRecorder() url := fmt.Sprintf("/product/%s", tc.productID) diff --git a/api/server.go b/api/server.go index 17287bf..8349dad 100644 --- a/api/server.go +++ b/api/server.go @@ -1,28 +1,52 @@ package api import ( + "fmt" + db "git.nochill.in/nochill/naice_pos/db/sqlc" + "git.nochill.in/nochill/naice_pos/token" + "git.nochill.in/nochill/naice_pos/util" "github.com/gin-gonic/gin" ) type Server struct { - store db.Store - router *gin.Engine + config util.Config + store db.Store + tokenMaker token.Maker + router *gin.Engine } -func NewServer(store db.Store) *Server { - server := &Server{store: store} +func NewServer(config util.Config, store db.Store) (*Server, error) { + tokenMaker, err := token.NewPasetoMaker(config.TokenSymmetricKey) + if err != nil { + return nil, fmt.Errorf("cannot create token maker: %w", err) + } + server := &Server{ + config: config, + store: store, + tokenMaker: tokenMaker, + } + + server.getRoutes() + return server, nil +} + +func (server *Server) getRoutes() { router := gin.Default() + router.POST("/user/login", server.loginUser) + router.POST("/user/merchants", server.createUserMerchant) + router.POST("/products", server.createProduct) router.PATCH("/products", server.updateProduct) router.GET("/product/:id", server.getProduct) + router.POST("/suppliers", server.createSupplier) + router.POST("/purchase-products", server.createPurchase) server.router = router - return server } func (server *Server) Start(address string) error { diff --git a/api/user.go b/api/user.go index 499e8a9..ffa3a67 100644 --- a/api/user.go +++ b/api/user.go @@ -13,12 +13,12 @@ import ( type createUserMerchantRequest struct { Email string `json:"email" binding:"required,email"` - Fullname string `json:"fullname" binding:"required"` + Fullname string `json:"fullname" binding:"required,alphanum"` Password string `json:"password" binding:"required"` - OutletName string `json:"outlet_name" binding:"required"` + OutletName string `json:"outlet_name" binding:"required,alphanum"` } -type createUserMerchantResponse struct { +type userMerchantResponse struct { ID uuid.UUID `json:"id"` IndexID int64 `json:"index_id"` Email string `json:"email"` @@ -28,6 +28,18 @@ type createUserMerchantResponse struct { Outlet db.Merchant `json:"outlet"` } +func newUserMerchantResponse(user db.User, merchant db.Merchant) userMerchantResponse { + return userMerchantResponse{ + ID: user.ID, + IndexID: user.IndexID, + Email: user.Email, + Fullname: user.Fullname, + CreatedAt: sql.NullTime{Valid: true, Time: user.CreatedAt.Time}, + UpdatedAt: sql.NullTime{Valid: true, Time: user.UpdatedAt.Time}, + Outlet: merchant, + } +} + func (server *Server) createUserMerchant(ctx *gin.Context) { var req createUserMerchantRequest if err := ctx.ShouldBindJSON(&req); err != nil { @@ -61,15 +73,74 @@ func (server *Server) createUserMerchant(ctx *gin.Context) { return } - res := createUserMerchantResponse{ + res := userMerchantResponse{ ID: user.OwnerProfile.ID, IndexID: user.OwnerProfile.IndexID, Email: user.OwnerProfile.Email, Fullname: user.OwnerProfile.Fullname, - CreatedAt: sql.NullTime{Valid: true, Time: user.OutletProfile.CreatedAt.Time}, - UpdatedAt: sql.NullTime{Valid: true, Time: user.OutletProfile.UpdatedAt.Time}, + CreatedAt: sql.NullTime{Valid: true, Time: user.OwnerProfile.CreatedAt.Time}, + UpdatedAt: sql.NullTime{Valid: true, Time: user.OwnerProfile.UpdatedAt.Time}, Outlet: user.OutletProfile, } ctx.JSON(http.StatusOK, res) } + +type userLoginRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +type userLoginResponse struct { + AccesToken string `json:"access_token"` + UserMerchantResponse userMerchantResponse +} + +func (server *Server) loginUser(ctx *gin.Context) { + var req userLoginRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, errorResponse(err)) + return + } + + user, err := server.store.GetUserByEmail(ctx, req.Email) + if err != nil { + if err == sql.ErrNoRows { + ctx.JSON(http.StatusNotFound, errorResponse(err)) + return + } + ctx.JSON(http.StatusInternalServerError, errorResponse(err)) + return + } + + outlet, err := server.store.GetMerchantByUserId(ctx, user.ID) + if err != nil { + ctx.JSON(http.StatusInternalServerError, errorResponse(err)) + return + } + + err = util.CheckPassword(req.Password, user.Password) + if err != nil { + ctx.JSON(http.StatusUnauthorized, errorResponse(err)) + return + } + + accessToken, err := server.tokenMaker.CreateToken( + user.Email, + server.config.TokenDuration, + ) + + if err != nil { + ctx.JSON(http.StatusInternalServerError, errorResponse(err)) + return + } + + userMerchant := newUserMerchantResponse(user, outlet) + + res := userLoginResponse{ + AccesToken: accessToken, + UserMerchantResponse: userMerchant, + } + + ctx.JSON(http.StatusOK, res) +} diff --git a/api/user_test.go b/api/user_test.go index 10c4f13..9bb5204 100644 --- a/api/user_test.go +++ b/api/user_test.go @@ -92,7 +92,7 @@ func TestCreateUserMerchantAPI(t *testing.T) { store := mockdb.NewMockStore(ctrl) tc.buildStubs(store) - server := NewServer(store) + server := newTestServer(t, store) recorder := httptest.NewRecorder() data, err := json.Marshal(tc.body) @@ -124,7 +124,7 @@ func RandomUser(t *testing.T) (userMerchant db.UserMerchantTxParams, password st return } -func requireBodyMatchUserMerchant(t *testing.T, body *bytes.Buffer, userMerchant createUserMerchantResponse) { +func requireBodyMatchUserMerchant(t *testing.T, body *bytes.Buffer, userMerchant userMerchantResponse) { data, err := ioutil.ReadAll(body) require.NoError(t, err) diff --git a/db/mock/store.go b/db/mock/store.go index 5372179..732456b 100644 --- a/db/mock/store.go +++ b/db/mock/store.go @@ -288,6 +288,21 @@ func (mr *MockStoreMockRecorder) GetStockForUpdateStock(arg0, arg1 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStockForUpdateStock", reflect.TypeOf((*MockStore)(nil).GetStockForUpdateStock), arg0, arg1) } +// GetUserByEmail mocks base method. +func (m *MockStore) GetUserByEmail(arg0 context.Context, arg1 string) (db.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUserByEmail", arg0, arg1) + ret0, _ := ret[0].(db.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUserByEmail indicates an expected call of GetUserByEmail. +func (mr *MockStoreMockRecorder) GetUserByEmail(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUserByEmail", reflect.TypeOf((*MockStore)(nil).GetUserByEmail), arg0, arg1) +} + // GetUserById mocks base method. func (m *MockStore) GetUserById(arg0 context.Context, arg1 uuid.UUID) (db.User, error) { m.ctrl.T.Helper() diff --git a/db/query/users.sql b/db/query/users.sql index 6ef5463..0ee2502 100644 --- a/db/query/users.sql +++ b/db/query/users.sql @@ -14,4 +14,9 @@ WHERE email = $1; -- name: GetUserById :one SELECT * FROM users -WHERE id = $1; \ No newline at end of file +WHERE id = $1; + +-- name: GetUserByEmail :one +SELECT * +FROM users +WHERE email = $1; \ No newline at end of file diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index bea07fc..3791739 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -27,6 +27,7 @@ type Querier interface { GetPasswordByEmail(ctx context.Context, email string) (string, error) GetProduct(ctx context.Context, id uuid.UUID) (Product, error) GetStockForUpdateStock(ctx context.Context, id uuid.UUID) (Product, error) + GetUserByEmail(ctx context.Context, email string) (User, error) GetUserById(ctx context.Context, id uuid.UUID) (User, error) ListProducts(ctx context.Context, arg ListProductsParams) ([]Product, error) SuppliersList(ctx context.Context, arg SuppliersListParams) ([]Supplier, error) diff --git a/db/sqlc/users.sql.go b/db/sqlc/users.sql.go index e79859f..4600fb4 100644 --- a/db/sqlc/users.sql.go +++ b/db/sqlc/users.sql.go @@ -54,6 +54,27 @@ func (q *Queries) GetPasswordByEmail(ctx context.Context, email string) (string, return password, err } +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, index_id, email, password, fullname, created_at, updated_at +FROM users +WHERE email = $1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.IndexID, + &i.Email, + &i.Password, + &i.Fullname, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + const getUserById = `-- name: GetUserById :one SELECT id, index_id, email, password, fullname, created_at, updated_at FROM users diff --git a/dev.env b/dev.env index 784acd8..cff4011 100644 --- a/dev.env +++ b/dev.env @@ -7,3 +7,6 @@ DB_PORT=5432 DB_SOURCE = postgresql://postgres:awksed123@localhost:5432/nice_pos?sslmode=disable SERVER_ADDRESS = 0.0.0.0:8888 + +TOKEN_SYMMETRIC_KEY=75629266996751511372336382467976 +TOKEN_DURATION = 6h diff --git a/go.mod b/go.mod index 579abde..63a418a 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,13 @@ module git.nochill.in/nochill/naice_pos go 1.20 require ( + github.com/aead/chacha20poly1305 v0.0.0-20201124145622-1a5aba2a8b29 + github.com/dgrijalva/jwt-go v3.2.0+incompatible github.com/gin-gonic/gin v1.9.0 github.com/golang/mock v1.6.0 github.com/google/uuid v1.3.0 github.com/lib/pq v1.10.7 + github.com/o1egl/paseto v1.0.0 github.com/shopspring/decimal v1.3.1 github.com/spf13/viper v1.15.0 github.com/stretchr/testify v1.8.2 @@ -15,6 +18,8 @@ require ( ) require ( + github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect + github.com/aead/poly1305 v0.0.0-20180717145839-3fee0db0b635 // indirect github.com/bytedance/sonic v1.8.3 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -34,6 +39,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.7 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/afero v1.9.3 // indirect github.com/spf13/cast v1.5.0 // indirect diff --git a/go.sum b/go.sum index 2679f11..e2d6e1d 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,13 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= +github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA= +github.com/aead/chacha20poly1305 v0.0.0-20170617001512-233f39982aeb/go.mod h1:UzH9IX1MMqOcwhoNOIjmTQeAxrFgzs50j4golQtXXxU= +github.com/aead/chacha20poly1305 v0.0.0-20201124145622-1a5aba2a8b29 h1:1DcvRPZOdbQRg5nAHt2jrc5QbV0AGuhDdfQI6gXjiFE= +github.com/aead/chacha20poly1305 v0.0.0-20201124145622-1a5aba2a8b29/go.mod h1:UzH9IX1MMqOcwhoNOIjmTQeAxrFgzs50j4golQtXXxU= +github.com/aead/poly1305 v0.0.0-20180717145839-3fee0db0b635 h1:52m0LGchQBBVqJRyYYufQuIbVqRawmubW3OFGqK1ekw= +github.com/aead/poly1305 v0.0.0-20180717145839-3fee0db0b635/go.mod h1:lmLxL+FV291OopO93Bwf9fQLQeLyt33VJRUg5VJ30us= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.8.3 h1:pf6fGl5eqWYKkx1RcD4qpuX+BIUaduv/wTm5ekWJ80M= github.com/bytedance/sonic v1.8.3/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= @@ -55,6 +62,8 @@ github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnht github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -177,8 +186,12 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/o1egl/paseto v1.0.0 h1:bwpvPu2au176w4IBlhbyUv/S5VPptERIA99Oap5qUd0= +github.com/o1egl/paseto v1.0.0/go.mod h1:5HxsZPmw/3RI2pAwGo1HhOOwSdvBpcuVzO7uDkm+CLU= github.com/pelletier/go-toml/v2 v2.0.7 h1:muncTPStnKRos5dpVKULv2FVd4bMOhNePj9CjgDb8Us= github.com/pelletier/go-toml/v2 v2.0.7/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -234,6 +247,7 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20181025213731-e84da0312774/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -332,6 +346,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/main.go b/main.go index f1d6855..f97dfa8 100644 --- a/main.go +++ b/main.go @@ -17,11 +17,14 @@ func main() { } dbConn, err := sql.Open(config.DBDriver, config.DBSource) if err != nil { - log.Fatal("cannot connect to db:", err) + log.Fatal("cannot connect to db: ", err) } store := db.NewStore(dbConn) - server := api.NewServer(store) + server, err := api.NewServer(config, store) + if err != nil { + log.Fatal("cannot make server: ", err) + } err = server.Start(config.ServerAddress) if err != nil { diff --git a/token/jwt_maker.go b/token/jwt_maker.go new file mode 100644 index 0000000..c6104d4 --- /dev/null +++ b/token/jwt_maker.go @@ -0,0 +1,58 @@ +package token + +import ( + "errors" + "fmt" + "time" + + "github.com/dgrijalva/jwt-go" +) + +const minSecretKeySize = 32 + +type JWTMaker struct { + secretKey string +} + +func NewJWTMaker(secretKey string) (Maker, error) { + if len(secretKey) < minSecretKeySize { + return nil, fmt.Errorf("Invalid key: must be at least %d characters", minSecretKeySize) + } + + return &JWTMaker{secretKey}, nil +} + +func (maker *JWTMaker) CreateToken(email string, duration time.Duration) (string, error) { + payload, err := NewPayload(email, duration) + if err != nil { + return "", err + } + + jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, payload) + return jwtToken.SignedString([]byte(maker.secretKey)) +} + +func (maker *JWTMaker) VerifyToken(token string) (*Payload, error) { + keyFunc := func(token *jwt.Token) (interface{}, error) { + _, ok := token.Method.(*jwt.SigningMethodHMAC) + if !ok { + return nil, ErrInvalidToken + } + return []byte(maker.secretKey), nil + } + jwtToken, err := jwt.ParseWithClaims(token, &Payload{}, keyFunc) + if err != nil { + verr, ok := err.(*jwt.ValidationError) + if ok && errors.Is(verr.Inner, ErrExpiredToken) { + return nil, ErrExpiredToken + } + return nil, ErrInvalidToken + } + + payload, ok := jwtToken.Claims.(*Payload) + if !ok { + return nil, ErrInvalidToken + } + + return payload, nil +} diff --git a/token/jwt_maker_test.go b/token/jwt_maker_test.go new file mode 100644 index 0000000..9b1faa1 --- /dev/null +++ b/token/jwt_maker_test.go @@ -0,0 +1,65 @@ +package token + +import ( + "testing" + "time" + + "git.nochill.in/nochill/naice_pos/util" + "github.com/dgrijalva/jwt-go" + "github.com/stretchr/testify/require" +) + +func TestJWTMaker(t *testing.T) { + maker, err := NewJWTMaker(util.RandomString(32)) + require.NoError(t, err) + + email := util.RandomEmail() + duration := time.Minute + + issuedAt := time.Now() + expiredAt := issuedAt.Add(duration) + + token, err := maker.CreateToken(email, duration) + require.NoError(t, err) + require.NotEmpty(t, token) + + payload, err := maker.VerifyToken(token) + require.NoError(t, err) + require.NotEmpty(t, payload) + + require.NotZero(t, payload.ID) + require.Equal(t, email, payload.Email) + require.WithinDuration(t, issuedAt, payload.IssuedAt, time.Second) + require.WithinDuration(t, expiredAt, payload.ExpiredAt, time.Second) +} + +func TestExpiredToken(t *testing.T) { + maker, err := NewJWTMaker(util.RandomString(32)) + require.NoError(t, err) + + token, err := maker.CreateToken(util.RandomEmail(), -time.Minute) + require.NoError(t, err) + require.NotEmpty(t, token) + + payload, err := maker.VerifyToken(token) + require.Error(t, err) + require.EqualError(t, err, ErrExpiredToken.Error()) + require.Nil(t, payload) +} + +func TestInvalidJWTTokenAlgNone(t *testing.T) { + payload, err := NewPayload(util.RandomEmail(), time.Minute) + require.NoError(t, err) + + jwtToken := jwt.NewWithClaims(jwt.SigningMethodNone, payload) + token, err := jwtToken.SignedString(jwt.UnsafeAllowNoneSignatureType) + require.NoError(t, err) + + maker, err := NewJWTMaker(util.RandomString(32)) + require.NoError(t, err) + + payload, err = maker.VerifyToken(token) + require.Error(t, err) + require.EqualError(t, err, ErrInvalidToken.Error()) + require.Nil(t, payload) +} diff --git a/token/maker.go b/token/maker.go new file mode 100644 index 0000000..85313f5 --- /dev/null +++ b/token/maker.go @@ -0,0 +1,10 @@ +package token + +import ( + "time" +) + +type Maker interface { + CreateToken(email string, duration time.Duration) (string, error) + VerifyToken(token string) (*Payload, error) +} diff --git a/token/paseto_maker.go b/token/paseto_maker.go new file mode 100644 index 0000000..0fea221 --- /dev/null +++ b/token/paseto_maker.go @@ -0,0 +1,53 @@ +package token + +import ( + "fmt" + "time" + + "github.com/aead/chacha20poly1305" + "github.com/o1egl/paseto" +) + +type PasetoMaker struct { + paseto *paseto.V2 + symmetricKey []byte +} + +func NewPasetoMaker(symmetricKey string) (Maker, error) { + if len(symmetricKey) != chacha20poly1305.KeySize { + return nil, fmt.Errorf("invalid key size: must be exactly %d characters", chacha20poly1305.KeySize) + } + + maker := &PasetoMaker{ + paseto: paseto.NewV2(), + symmetricKey: []byte(symmetricKey), + } + + return maker, nil +} + +func (maker *PasetoMaker) CreateToken(email string, duration time.Duration) (string, error) { + payload, err := NewPayload(email, duration) + if err != nil { + return "", err + } + + token, err := maker.paseto.Encrypt(maker.symmetricKey, payload, nil) + return token, err +} + +func (maker *PasetoMaker) VerifyToken(token string) (*Payload, error) { + payload := &Payload{} + + err := maker.paseto.Decrypt(token, maker.symmetricKey, payload, nil) + if err != nil { + return nil, ErrInvalidToken + } + + err = payload.Valid() + if err != nil { + return nil, err + } + + return payload, nil +} diff --git a/token/paseto_maker_test.go b/token/paseto_maker_test.go new file mode 100644 index 0000000..7e1c267 --- /dev/null +++ b/token/paseto_maker_test.go @@ -0,0 +1,47 @@ +package token + +import ( + "testing" + "time" + + "git.nochill.in/nochill/naice_pos/util" + "github.com/stretchr/testify/require" +) + +func TestPasetoMaker(t *testing.T) { + maker, err := NewPasetoMaker(util.RandomString(32)) + require.NoError(t, err) + + email := util.RandomEmail() + duration := time.Minute + + issuedAt := time.Now() + expiredAt := issuedAt.Add(duration) + + token, err := maker.CreateToken(email, duration) + require.NoError(t, err) + require.NotEmpty(t, token) + + payload, err := maker.VerifyToken(token) + require.NoError(t, err) + require.NotEmpty(t, payload) + + require.NotZero(t, payload.ID) + require.Equal(t, email, payload.Email) + require.WithinDuration(t, issuedAt, payload.IssuedAt, time.Second) + require.WithinDuration(t, expiredAt, payload.ExpiredAt, time.Second) +} + +func TestExpiredPasetoToken(t *testing.T) { + maker, err := NewPasetoMaker(util.RandomString(32)) + require.NoError(t, err) + + token, err := maker.CreateToken(util.RandomEmail(), -time.Minute) + require.NoError(t, err) + require.NotEmpty(t, token) + + payload, err := maker.VerifyToken(token) + require.Error(t, err) + require.EqualError(t, err, ErrExpiredToken.Error()) + require.Nil(t, payload) +} diff --git a/token/payload.go b/token/payload.go new file mode 100644 index 0000000..1151fbc --- /dev/null +++ b/token/payload.go @@ -0,0 +1,42 @@ +package token + +import ( + "errors" + "time" + + "github.com/google/uuid" +) + +var ( + ErrExpiredToken = errors.New("token has expired") + ErrInvalidToken = errors.New("token is invalid") +) + +type Payload struct { + ID uuid.UUID `json:"id"` + Email string `json:"email"` + IssuedAt time.Time `json:"issued_at"` + ExpiredAt time.Time `json:"expired_at"` +} + +func NewPayload(email string, duration time.Duration) (*Payload, error) { + tokenID, err := uuid.NewRandom() + if err != nil { + return nil, err + } + + payload := &Payload{ + ID: tokenID, + Email: email, + IssuedAt: time.Now(), + ExpiredAt: time.Now().Add(duration), + } + return payload, nil +} + +func (payload *Payload) Valid() error { + if time.Now().After(payload.ExpiredAt) { + return ErrExpiredToken + } + return nil +} diff --git a/util/config.go b/util/config.go index bd1d2a9..e454a36 100644 --- a/util/config.go +++ b/util/config.go @@ -1,11 +1,17 @@ package util -import "github.com/spf13/viper" +import ( + "time" + + "github.com/spf13/viper" +) type Config struct { - DBDriver string `mapstructure:"DB_TYPE"` - DBSource string `mapstructure:"DB_SOURCE"` - ServerAddress string `mapstructure:"SERVER_ADDRESS"` + DBDriver string `mapstructure:"DB_TYPE"` + DBSource string `mapstructure:"DB_SOURCE"` + ServerAddress string `mapstructure:"SERVER_ADDRESS"` + TokenSymmetricKey string `mapstructure:"TOKEN_SYMMETRIC_KEY"` + TokenDuration time.Duration `mapstructure:"TOKEN_DURATION"` } func LoadConfig(path string) (config Config, err error) {