Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Store password endpoint #5

Merged
merged 2 commits into from
Oct 27, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
}
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)
pdeziel marked this conversation as resolved.
Show resolved Hide resolved
}
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() {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how about also adding a missing ID check?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's hard to test that on the server side since the ID is part of the URL and it would be a 404, however I've added a client test to ensure that the client catches that case, thanks for the suggestion!

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...)
}
7 changes: 7 additions & 0 deletions pkg/store/mock/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package mock

import "errors"

var (
ErrNotConfigured = errors.New("mock function not configured")
)
Loading