Skip to content

Commit

Permalink
Merge pull request #5 from trisacrypto/sc-22232
Browse files Browse the repository at this point in the history
Store password endpoint
  • Loading branch information
pdeziel authored Oct 27, 2023
2 parents d242a71 + fb294ed commit 2843348
Show file tree
Hide file tree
Showing 11 changed files with 333 additions and 4 deletions.
2 changes: 1 addition & 1 deletion containers/build.sh
100644 → 100755
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/bin/bash

docker build -t trisa/courier:latest -f ./containers/Dockerfile .
docker build -t trisa/courier:latest -f ./containers/courier/Dockerfile .
6 changes: 6 additions & 0 deletions pkg/api/v1/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import "context"

type CourierClient interface {
Status(context.Context) (*StatusReply, error)
StoreCertificatePassword(context.Context, *StorePasswordRequest) error
}

// Reply encodes generic JSON responses from the API.
Expand All @@ -17,3 +18,8 @@ type StatusReply struct {
Uptime string `json:"uptime,omitempty"`
Version string `json:"version,omitempty"`
}

type StorePasswordRequest struct {
ID string `json:"id"`
Password string `json:"password"`
}
60 changes: 60 additions & 0 deletions pkg/api/v1/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
Expand Down Expand Up @@ -80,6 +82,27 @@ func (c *APIv1) Status(ctx context.Context) (out *StatusReply, err error) {
return out, nil
}

// StoreCertificatePassword stores a password for an encrypted certificate.
func (c *APIv1) StoreCertificatePassword(ctx context.Context, in *StorePasswordRequest) (err error) {
if in.ID == "" {
return ErrIDRequired
}

path := fmt.Sprintf("/v1/certs/%s/pkcs12password", in.ID)

// Create the HTTP request
var req *http.Request
if req, err = c.NewRequest(ctx, http.MethodPost, path, in, nil); err != nil {
return err
}

// Do the request
if _, err = c.Do(req, nil, true); err != nil {
return err
}
return nil
}

//===========================================================================
// Client Helpers
//===========================================================================
Expand Down Expand Up @@ -126,3 +149,40 @@ func (c *APIv1) NewRequest(ctx context.Context, method, path string, data interf

return req, nil
}

// Do executes an http request against the server, performs error checking, and
// deserializes response data into the specified struct.
func (s *APIv1) Do(req *http.Request, data interface{}, checkStatus bool) (rep *http.Response, err error) {
if rep, err = s.client.Do(req); err != nil {
return rep, err
}
defer rep.Body.Close()

// Detects http status errors if they've occurred
if checkStatus {
if rep.StatusCode < 200 || rep.StatusCode >= 300 {
// Attempt to read the error response from the generic reply
var reply Reply
if err = json.NewDecoder(rep.Body).Decode(&reply); err == nil {
if reply.Error != "" {
return rep, NewStatusError(rep.StatusCode, reply.Error)
}
}

return rep, errors.New(rep.Status)
}
}

// Deserializes the JSON data from the body
if data != nil && rep.StatusCode >= 200 && rep.StatusCode < 300 && rep.StatusCode != http.StatusNoContent {
// Checks the content type to ensure data deserialization is possible
if ct := rep.Header.Get("Content-Type"); ct != contentType {
return rep, fmt.Errorf("unexpected content type: %q", ct)
}

if err = json.NewDecoder(rep.Body).Decode(data); err != nil {
return nil, fmt.Errorf("could not deserialize response data: %s", err)
}
}
return rep, nil
}
38 changes: 38 additions & 0 deletions pkg/api/v1/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package api_test

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"
"github.com/trisacrypto/courier/pkg/api/v1"
)

func TestStoreCertificatePassword(t *testing.T) {
// Create a test server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/v1/certs/1234/pkcs12password", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer ts.Close()

// Create a client to test the client method
client, err := api.New(ts.URL)
require.NoError(t, err, "could not create client")

// Create a new register request
req := &api.StorePasswordRequest{
ID: "1234",
Password: "hunter2",
}
err = client.StoreCertificatePassword(context.Background(), req)
require.NoError(t, err, "could not execute password store request")

// Should error if there is no ID in the request
req.ID = ""
err = client.StoreCertificatePassword(context.Background(), req)
require.ErrorIs(t, err, api.ErrIDRequired, "client should error if no ID is provided")
}
32 changes: 31 additions & 1 deletion pkg/api/v1/errors.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package api

import (
"encoding/json"
"errors"
"fmt"
"net/http"
Expand All @@ -9,13 +10,15 @@ import (
)

var (
unsuccessful = Reply{Success: false}
notFound = Reply{Success: false, Error: "resource not found"}
notAllowed = Reply{Success: false, Error: "method not allowed"}
ErrEndpointRequired = errors.New("endpoint is required")
ErrIDRequired = errors.New("missing ID in request")
)

func NewStatusError(code int, err string) error {
return StatusError{Code: code, Err: err}
return &StatusError{Code: code, Err: err}
}

type StatusError struct {
Expand All @@ -27,6 +30,33 @@ func (e StatusError) Error() string {
return fmt.Sprintf("[%d]: %s", e.Code, e.Err)
}

// ErrorResponse constructs an new response from the error or returns a success: false.
func ErrorResponse(err interface{}) Reply {
if err == nil {
return unsuccessful
}

rep := Reply{Success: false}
switch err := err.(type) {
case error:
rep.Error = err.Error()
case string:
rep.Error = err
case fmt.Stringer:
rep.Error = err.String()
case json.Marshaler:
data, e := err.MarshalJSON()
if e != nil {
panic(err)
}
rep.Error = string(data)
default:
rep.Error = "unhandled error response"
}

return rep
}

// NotFound returns a standard 404 response.
func NotFound(c *gin.Context) {
c.JSON(http.StatusNotFound, notFound)
Expand Down
39 changes: 39 additions & 0 deletions pkg/certs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package courier

import (
"net/http"

"github.com/gin-gonic/gin"
"github.com/trisacrypto/courier/pkg/api/v1"
)

// StoreCertificatePassword stores the password for an encrypted certificate and
// returns a 204 No Content response.
func (s *Server) StoreCertificatePassword(c *gin.Context) {
var (
err error
req *api.StorePasswordRequest
)

// Parse the request body
req = &api.StorePasswordRequest{}
if err := c.BindJSON(req); err != nil {
c.JSON(http.StatusBadRequest, api.ErrorResponse(err))
return
}

// Password is required
if req.Password == "" {
c.JSON(http.StatusBadRequest, api.ErrorResponse("missing password in request"))
return
}

// Store the password
if err = s.store.UpdatePassword(c.Param("id"), []byte(req.Password)); err != nil {
c.JSON(http.StatusInternalServerError, api.ErrorResponse(err))
return
}

// Return 204 No Content
c.Status(http.StatusNoContent)
}
53 changes: 53 additions & 0 deletions pkg/certs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package courier_test

import (
"context"
"errors"
"net/http"

"github.com/trisacrypto/courier/pkg/api/v1"
)

func (s *courierTestSuite) TestStoreCertificatePassword() {
require := s.Require()

s.Run("HappyPath", func() {
// Configure the store mock to return a successful response
req := &api.StorePasswordRequest{
ID: "certID",
Password: "password",
}
s.store.OnUpdatePassword = func(name string, password []byte) error {
require.Equal(req.ID, name, "wrong password name passed to store")
require.Equal([]byte(req.Password), password, "wrong password passed to store")
return nil
}
defer s.store.Reset()

// Make a request to the endpoint
err := s.client.StoreCertificatePassword(context.Background(), req)
require.NoError(err, "could not store certificate password")
})

s.Run("MissingPassword", func() {
req := &api.StorePasswordRequest{
ID: "certID",
}
err := s.client.StoreCertificatePassword(context.Background(), req)
s.CheckHTTPStatus(err, http.StatusBadRequest, "wrong error code for missing password")
})

s.Run("StoreError", func() {
s.store.OnUpdatePassword = func(name string, password []byte) error {
return errors.New("internal store error")
}
defer s.store.Reset()

req := &api.StorePasswordRequest{
ID: "certID",
Password: "password",
}
err := s.client.StoreCertificatePassword(context.Background(), req)
s.CheckHTTPStatus(err, http.StatusInternalServerError, "wrong error code for store error")
})
}
25 changes: 23 additions & 2 deletions pkg/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package courier

import (
"context"
"errors"
"net"
"net/http"
"os"
Expand All @@ -13,6 +14,8 @@ import (
"github.com/rs/zerolog/log"
"github.com/trisacrypto/courier/pkg/api/v1"
"github.com/trisacrypto/courier/pkg/config"
"github.com/trisacrypto/courier/pkg/store"
"github.com/trisacrypto/courier/pkg/store/local"
)

// New creates a new server object from configuration but does not serve it yet.
Expand All @@ -30,7 +33,15 @@ func New(conf config.Config) (s *Server, err error) {
echan: make(chan error, 1),
}

// TODO: Initialize the configured stores
// Open the store
switch {
case conf.LocalStorage.Enabled:
if s.store, err = local.Open(conf.LocalStorage); err != nil {
return nil, err
}
default:
return nil, errors.New("no storage backend configured")
}

// Create the router
gin.SetMode(conf.Mode)
Expand Down Expand Up @@ -61,6 +72,7 @@ type Server struct {
conf config.Config
srv *http.Server
router *gin.Engine
store store.Store
started time.Time
healthy bool
url string
Expand Down Expand Up @@ -144,7 +156,11 @@ func (s *Server) setupRoutes() (err error) {
// Status route
v1.GET("/status", s.Status)

// TODO: Password and certificate routes
// Certificate routes
certs := v1.Group("/certs")
{
certs.POST("/:id/pkcs12password", s.StoreCertificatePassword)
}
}

// Not found and method not allowed routes
Expand Down Expand Up @@ -183,3 +199,8 @@ func (s *Server) URL() string {
defer s.RUnlock()
return s.url
}

// SetStore directly sets the store for the server.
func (s *Server) SetStore(store store.Store) {
s.store = store
}
15 changes: 15 additions & 0 deletions pkg/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
courier "github.com/trisacrypto/courier/pkg"
"github.com/trisacrypto/courier/pkg/api/v1"
"github.com/trisacrypto/courier/pkg/config"
"github.com/trisacrypto/courier/pkg/store/mock"
)

// The courier test suite allows us to test the courier API by making actual requests
Expand All @@ -17,6 +18,7 @@ type courierTestSuite struct {
suite.Suite
courier *courier.Server
client api.CourierClient
store *mock.Store
}

func (s *courierTestSuite) SetupSuite() {
Expand All @@ -40,6 +42,10 @@ func (s *courierTestSuite) SetupSuite() {
s.courier, err = courier.New(conf)
require.NoError(err, "could not create test server")

// Use a mock store for testing
s.store = mock.New()
s.courier.SetStore(s.store)

// Start the server, which will run for the duration of the test suite
go s.courier.Serve()

Expand All @@ -60,3 +66,12 @@ func (s *courierTestSuite) TearDownSuite() {
func TestCourier(t *testing.T) {
suite.Run(t, new(courierTestSuite))
}

// Check that the correct HTTP status code is in the error
func (s *courierTestSuite) CheckHTTPStatus(err error, status int, msgAndArgs ...interface{}) {
require := s.Require()
require.NotNil(err, "expected an HTTP error")
statusErr, ok := err.(*api.StatusError)
require.True(ok, "expected error to be a StatusError")
require.Equal(status, statusErr.Code, msgAndArgs...)
}
Loading

0 comments on commit 2843348

Please sign in to comment.