From 7fd01b01e818dfb55443ba591a6009f923258c12 Mon Sep 17 00:00:00 2001 From: Ilia Frenkel Date: Wed, 28 Jul 2021 21:42:48 +1000 Subject: [PATCH] Add password protected pastes --- docker-compose-dev.yml | 36 +++---- src/api/api.go | 4 + src/api/http/http.go | 87 ++++++++++++++++- src/api/paste/memory/memory.go | 10 +- src/api/paste/sqldb/sqldb.go | 10 +- src/web/http/http.go | 131 ++++++++++++++++++++++++++ src/web/templates/paste-password.html | 33 +++++++ 7 files changed, 288 insertions(+), 23 deletions(-) create mode 100644 src/web/templates/paste-password.html diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 5a07ce5..3e11f38 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -21,7 +21,7 @@ services: networks: - go-pb_dev volumes: - - postgres-data:/var/lib/postgresql/data + - postgres-data-dev:/var/lib/postgresql/data - ./scripts/db-healthcheck.sh:/db-healthcheck.sh environment: - PGDATA=/var/lib/postgresql/data/pgdata @@ -50,22 +50,22 @@ services: ports: - 8888:8080 - app: - image: iliafrenkel/go-pb:latest - container_name: app - depends_on: - db-server: - condition: service_healthy - restart: unless-stopped - networks: - - go-pb_dev - environment: - - GOPB_API_DB_CONN_STRING=host=db-server user=iliaf password=iliaf dbname=iliaf port=5432 sslmode=disable - - GOPB_WEB_LOGO=bighead.svg - - GOPB_WEB_HOST=0.0.0.0 - - GOPB_API_TOKEN_SECRET=very#secret#api#token#donotshare - - GOPB_WEB_COOKIE_AUTH_KEY=verysecretcookieauthkeydontshare - ports: - - 8080:8080 + # app: + # image: iliafrenkel/go-pb:latest + # container_name: app + # depends_on: + # db-server: + # condition: service_healthy + # restart: unless-stopped + # networks: + # - go-pb_dev + # environment: + # - GOPB_API_DB_CONN_STRING=host=db-server user=iliaf password=iliaf dbname=iliaf port=5432 sslmode=disable + # - GOPB_WEB_LOGO=bighead.svg + # - GOPB_WEB_HOST=0.0.0.0 + # - GOPB_API_TOKEN_SECRET=very#secret#api#token#donotshare + # - GOPB_WEB_COOKIE_AUTH_KEY=verysecretcookieauthkeydontshare + # ports: + # - 8080:8080 ## ############################################################################### diff --git a/src/api/api.go b/src/api/api.go index e25dfe1..d065f4f 100644 --- a/src/api/api.go +++ b/src/api/api.go @@ -110,6 +110,10 @@ type PasteForm struct { UserID int64 `json:"user_id"` } +type PastePassword struct { + Password string `json:"password" form:"password" binding:"required"` +} + // PasteService is the interface that defines methods for working with Pastes. // // Implementations should define the underlying storage such as database, diff --git a/src/api/http/http.go b/src/api/http/http.go index 9d610c9..29ae015 100644 --- a/src/api/http/http.go +++ b/src/api/http/http.go @@ -22,6 +22,7 @@ import ( "github.com/gin-gonic/gin" "github.com/iliafrenkel/go-pb/src/api" "github.com/iliafrenkel/go-pb/src/api/base62" + "golang.org/x/crypto/bcrypt" ) // APIServerOptions defines various parameters needed to run the ApiServer @@ -69,6 +70,7 @@ type APIServer struct { // // The routes are: // GET /paste/{id} - get paste by ID +// POST /paste/{id} - get password protected paste by ID // POST /paste - create new paste // DELETE /paste/{id} - delete paste by ID // GET /paste/list/{id} - get a list of pastes by UserID @@ -112,6 +114,7 @@ func New(pSvc api.PasteService, uSvc api.UserService, opts APIServerOptions) *AP paste := handler.Router.Group("/paste") { paste.GET("/:id", handler.handlePasteGet) + paste.POST("/:id", handler.verifyJSONMiddleware(new(api.PastePassword)), handler.handlePasteGetWithPassword) paste.POST("", handler.verifyJSONMiddleware(new(api.PasteForm)), handler.handlePasteCreate) paste.DELETE("/:id", handler.handlePasteDelete) paste.GET("/list/:id", handler.handlePasteList) @@ -277,10 +280,12 @@ func (h *APIServer) verifyJSONMiddleware(data interface{}) gin.HandlerFunc { } // handlePasteGet is an HTTP handler for the GET /paste/{id} route, it returns -// the paste as a JSON string or 404 Not Found. +// the paste as a JSON string or 404 Not Found. If paste is password protected +// we return 401 Unauthorised and the caller has to POST to /paste/{id} to with +// the password to get the paste. func (h *APIServer) handlePasteGet(c *gin.Context) { // We expect the id parameter as base62 encoded string, we try to decode - // it into a uint64 paste id and return 404 if we can't. + // it into a int64 paste id and return 404 if we can't. id, err := base62.Decode(c.Param("id")) if err != nil { c.JSON(http.StatusNotFound, api.HTTPError{ @@ -290,7 +295,9 @@ func (h *APIServer) handlePasteGet(c *gin.Context) { return } - p, err := h.PasteService.Get(int64(id)) + p, err := h.PasteService.Get(id) + // Service returned an error and we don't know what to do here. We log the + // error and send 500 InternalServerError back to the caller. if err != nil { log.Println("handlePasteGet: unexpected error: ", err.Error()) c.JSON(http.StatusInternalServerError, api.HTTPError{ @@ -299,6 +306,7 @@ func (h *APIServer) handlePasteGet(c *gin.Context) { }) return } + // Service call was successful but returned nil. Respond with 404 NoFound. if p == nil { c.JSON(http.StatusNotFound, api.HTTPError{ Code: http.StatusNotFound, @@ -306,7 +314,17 @@ func (h *APIServer) handlePasteGet(c *gin.Context) { }) return } + // Check if the paste is password protected. If yes, we return 401 + // Unauthorized and the caller must POST back with the password. + if p.Password != "" { + c.JSON(http.StatusUnauthorized, api.HTTPError{ + Code: http.StatusUnauthorized, + Message: "Paste is password protected", + }) + return + } + // All is good, send the paste back to the caller. c.JSON(http.StatusOK, p) // We "burn" the paste if DeleteAfterRead flag is set. @@ -315,6 +333,69 @@ func (h *APIServer) handlePasteGet(c *gin.Context) { } } +func (h *APIServer) handlePasteGetWithPassword(c *gin.Context) { + // We expect the id parameter as base62 encoded string, we try to decode + // it into a int64 paste id and return 404 if we can't. + id, err := base62.Decode(c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, api.HTTPError{ + Code: http.StatusNotFound, + Message: "Paste not found", + }) + return + } + // Get the password from the context, the verifyJSONMiddlerware should've + // prepared it for us. + var pwd string + if data, ok := c.Get("payload"); !ok { + log.Println("handlePasteGetWithPassword: unexpected error: ", err.Error()) + c.JSON(http.StatusInternalServerError, api.HTTPError{ + Code: http.StatusInternalServerError, + Message: fmt.Sprintf("%s: %s", http.StatusText(http.StatusInternalServerError), err.Error()), + }) + return + } else { + pwd = data.(*api.PastePassword).Password + } + // Get the paste + p, err := h.PasteService.Get(id) + // Service returned an error and we don't know what to do here. We log the + // error and send 500 InternalServerError back to the caller. + if err != nil { + log.Println("handlePasteGet: unexpected error: ", err.Error()) + c.JSON(http.StatusInternalServerError, api.HTTPError{ + Code: http.StatusInternalServerError, + Message: fmt.Sprintf("%s: %s", http.StatusText(http.StatusInternalServerError), err.Error()), + }) + return + } + // Service call was successful but returned nil. Respond with 404 NoFound. + if p == nil { + c.JSON(http.StatusNotFound, api.HTTPError{ + Code: http.StatusNotFound, + Message: "Paste not found", + }) + return + } + // Verify the password + if err := bcrypt.CompareHashAndPassword([]byte(p.Password), []byte(pwd)); err != nil { + c.JSON(http.StatusUnauthorized, api.HTTPError{ + Code: http.StatusUnauthorized, + Message: "Paste password is incorrect", + }) + return + } + + // All is good, send the paste back to the caller. + c.JSON(http.StatusOK, p) + + // We "burn" the paste if DeleteAfterRead flag is set. + if p.DeleteAfterRead { + h.PasteService.Delete(p.ID) + } + +} + // handlePasteCreate is an HTTP handler for the POST /paste route. It expects // the new paste as a JSON sting in the body of the request. Returns newly // created paste as a JSON string and the 'Location' header set to the new diff --git a/src/api/paste/memory/memory.go b/src/api/paste/memory/memory.go index df403dd..4146750 100644 --- a/src/api/paste/memory/memory.go +++ b/src/api/paste/memory/memory.go @@ -20,6 +20,7 @@ import ( "time" "github.com/iliafrenkel/go-pb/src/api" + "golang.org/x/crypto/bcrypt" ) // PasteService stores all the pastes in memory and implements the @@ -81,7 +82,14 @@ func (s *PasteService) Create(p api.PasteForm) (*api.Paste, error) { return nil, fmt.Errorf("unknown duration format: %s", p.Expires) } } - // Create new paste with a randomly generated ID + // Create new paste with a randomly generated ID and a hashed password. + if p.Password != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(p.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + p.Password = string(hash) + } newPaste := api.Paste{ ID: rand.Int63(), Title: p.Title, diff --git a/src/api/paste/sqldb/sqldb.go b/src/api/paste/sqldb/sqldb.go index 2521872..a786ed0 100644 --- a/src/api/paste/sqldb/sqldb.go +++ b/src/api/paste/sqldb/sqldb.go @@ -14,6 +14,7 @@ import ( "time" "github.com/iliafrenkel/go-pb/src/api" + "golang.org/x/crypto/bcrypt" "gorm.io/gorm" ) @@ -104,7 +105,14 @@ func (s *PasteService) Create(p api.PasteForm) (*api.Paste, error) { return nil, fmt.Errorf("unknown duration format: %s", p.Expires) } } - // Create new paste with a randomly generated ID + // Create new paste with a randomly generated ID and a hashed password. + if p.Password != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(p.Password), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + p.Password = string(hash) + } newPaste := api.Paste{ ID: rand.Int63(), Title: p.Title, diff --git a/src/web/http/http.go b/src/web/http/http.go index f3e5128..5b8d278 100644 --- a/src/web/http/http.go +++ b/src/web/http/http.go @@ -133,6 +133,7 @@ func New(opts WebServerOptions) *WebServer { handler.Router.GET("/u/register", handler.handleUserRegister) handler.Router.POST("/u/register", handler.handleDoUserRegister) handler.Router.GET("/p/:id", handler.handleGetPaste) + handler.Router.POST("/p/:id", handler.handleGetPasteWithPassword) handler.Router.GET("/p/list", handler.handleGetPastesList) handler.Router.POST("/p/", handler.handleCreatePaste) @@ -502,6 +503,25 @@ func (h *WebServer) handleGetPaste(c *gin.Context) { h.showError(c) return } + // Check if paste is password protected + if resp.StatusCode == http.StatusUnauthorized { + username, _ := c.Get("username") + c.HTML( + http.StatusOK, + "paste-password.html", + gin.H{ + "title": h.Options.BrandName + " - Paste", + "brand": h.Options.BrandName, + "tagline": h.Options.BrandTagline, + "logo": h.Options.Logo, + "id": id, + "message": "This paste is protected by a password", + "username": username, + "version": h.Options.Version, + }, + ) + return + } // If API response code is not 200 return it and log an error if resp.StatusCode != http.StatusOK { log.Println("handlePaste: API returned: ", resp.StatusCode) @@ -578,6 +598,117 @@ func (h *WebServer) handleGetPaste(c *gin.Context) { ) } +// handleGetPasteWithPassword queries the API for a paste and returns a page +// that displays it. It uses the "view.html" template. +func (h *WebServer) handleGetPasteWithPassword(c *gin.Context) { + // Query the API for a paste by ID + id := c.Param("id") + var form api.PastePassword + if err := c.ShouldBind(&form); err != nil { + log.Println("handleGetPasteWithPassword: failed to bind to form data: ", err) + c.Set("errorCode", http.StatusBadRequest) + c.Set("errorText", http.StatusText(http.StatusBadRequest)) + h.showError(c) + return + } + psw, _ := json.Marshal(form) + data, code, err := h.makeAPICall( + "/paste/"+id, + "POST", + bytes.NewBuffer(psw), + map[int]struct{}{ + http.StatusOK: {}, + http.StatusUnauthorized: {}, + }) + if err != nil { + log.Println("handleGetPasteWithPassword: error talking to API: ", err) + c.Set("errorCode", code) + c.Set("errorText", http.StatusText(code)) + c.Set("errorMessage", "Oops! It looks like something went wrong. Don't worry, we have notified the authorities.") + h.showError(c) + return + } + // Check if API responded with NotAuthorized + if code == http.StatusUnauthorized { + username, _ := c.Get("username") + c.HTML( + http.StatusOK, + "paste-password.html", + gin.H{ + "title": h.Options.BrandName + " - Paste", + "brand": h.Options.BrandName, + "tagline": h.Options.BrandTagline, + "logo": h.Options.Logo, + "id": id, + "message": "This paste is protected by a password", + "username": username, + "version": h.Options.Version, + }, + ) + return + } + // Check if API responded with some other error + if code != http.StatusOK { + log.Println("handleGetPasteWithPassword: API returned an error: ", err) + c.Set("errorCode", http.StatusInternalServerError) + c.Set("errorText", http.StatusText(http.StatusInternalServerError)) + c.Set("errorMessage", "Oops! It looks like something went wrong. Don't worry, we have notified the authorities.") + h.showError(c) + return + } + // Get user pastes + userid, _ := c.Get("user_id") + var pastes []api.Paste + + if userid != nil && userid.(int64) != 0 { + data, code, err := h.makeAPICall( + "/paste/list/"+fmt.Sprintf("%d", userid), + "GET", + nil, + map[int]struct{}{ + http.StatusOK: {}, + }) + if err != nil { + log.Println("handleGetPasteWithPassword: error talking to API: ", err) + } else if code != http.StatusOK { + log.Println("handleGetPasteWithPassword: API returned: ", code) + } else { + if err := json.Unmarshal(data, &pastes); err != nil { + log.Println("handleGetPasteWithPassword: failed to parse API response", err) + } + } + sort.Slice(pastes, func(i, j int) bool { return pastes[i].Created.After(pastes[j].Created) }) + } + var p api.Paste + if err := json.Unmarshal(data, &p); err != nil { + log.Println("handleGetPasteWithPassword: failed to parse API response", err) + c.Set("errorCode", http.StatusInternalServerError) + c.Set("errorText", http.StatusText(http.StatusInternalServerError)) + c.Set("errorMessage", "Oops! It looks like something went wrong. Don't worry, we have notified the authorities.") + h.showError(c) + return + } + + // Send HTML + username, _ := c.Get("username") + c.HTML( + http.StatusOK, + "view.html", + gin.H{ + "title": h.Options.BrandName + " - Paste", + "brand": h.Options.BrandName, + "tagline": h.Options.BrandTagline, + "logo": h.Options.Logo, + "Paste": &p, + "URL": p.URL(), + "Server": h.Options.Proto + "://" + h.Options.Addr, + "username": username, + "pastes": pastes, + "version": h.Options.Version, + }, + ) +} + // handleGetPastesList returns a page with the list of pastes for the current // user. func (h *WebServer) handleGetPastesList(c *gin.Context) { diff --git a/src/web/templates/paste-password.html b/src/web/templates/paste-password.html new file mode 100644 index 0000000..9e6cda0 --- /dev/null +++ b/src/web/templates/paste-password.html @@ -0,0 +1,33 @@ + + + + {{template "head.html" .title}} + + + + {{template "header.html" .}} + +
+
+
+
+
Paste password
+
+
+ + +
+
+ +
+
+
+
+

{{ .message }}

+
+
+ + {{template "footer.html" .version}} + + +