Skip to content

Commit

Permalink
Implemement authn middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
sevein committed Jul 4, 2024
1 parent 7dca262 commit 0a83685
Show file tree
Hide file tree
Showing 11 changed files with 266 additions and 2 deletions.
1 change: 1 addition & 0 deletions hack/ccp/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.22.5

require (
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.34.1-20240508200655-46a4cf4ba109.1
connectrpc.com/authn v0.1.0
connectrpc.com/connect v1.16.2
connectrpc.com/grpchealth v1.3.0
connectrpc.com/grpcreflect v1.2.0
Expand Down
2 changes: 2 additions & 0 deletions hack/ccp/go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.34.1-20240508200655-46a4cf4ba109.1 h1:LEXWFH/xZ5oOWrC3oOtHbUyBdzRWMCPpAQmKC9v05mA=
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.34.1-20240508200655-46a4cf4ba109.1/go.mod h1:XF+P8+RmfdufmIYpGUC+6bF7S+IlmHDEnCrO3OXaUAQ=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
connectrpc.com/authn v0.1.0 h1:m5weACjLWwgwcjttvUDyTPICJKw74+p2obBVrf8hT9E=
connectrpc.com/authn v0.1.0/go.mod h1:AwNZK/KYbqaJzRYadTuAaoz6sYQSPdORPqh1TOPIkgY=
connectrpc.com/connect v1.16.2 h1:ybd6y+ls7GOlb7Bh5C8+ghA6SvCBajHwxssO2CGFjqE=
connectrpc.com/connect v1.16.2/go.mod h1:n2kgwskMHXC+lVqb18wngEpF95ldBHXjZYJussz5FRc=
connectrpc.com/grpchealth v1.3.0 h1:FA3OIwAvuMokQIXQrY5LbIy8IenftksTP/lG4PbYN+E=
Expand Down
8 changes: 6 additions & 2 deletions hack/ccp/internal/api/admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"sync"
"time"

"connectrpc.com/authn"
"connectrpc.com/connect"
"connectrpc.com/grpchealth"
"connectrpc.com/grpcreflect"
Expand Down Expand Up @@ -63,7 +64,7 @@ func New(logger logr.Logger, config Config, ctrl *controller.Controller, store s
srv.v = v
}

srv.cache = ttlcache.New[adminv1.PackageType, *adminv1.ListPackagesResponse](
srv.cache = ttlcache.New(
ttlcache.WithTTL[adminv1.PackageType, *adminv1.ListPackagesResponse](1 * time.Second),
)
srv.wg.Add(1)
Expand Down Expand Up @@ -98,10 +99,13 @@ func (s *Server) Run() error {
compress1KB,
))

auth := authenticate(s.logger, s.store)
handler := authn.NewMiddleware(auth).Wrap(mux)

s.server = &http.Server{
Addr: s.config.Addr,
Handler: h2c.NewHandler(
corsutil.New(nil).Handler(mux),
corsutil.New(nil).Handler(handler),
&http2.Server{},
),
ReadHeaderTimeout: time.Second,
Expand Down
95 changes: 95 additions & 0 deletions hack/ccp/internal/api/admin/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package admin

import (
"context"
"strings"

"connectrpc.com/authn"
"github.com/go-logr/logr"

"github.com/artefactual/archivematica/hack/ccp/internal/store"
)

var errInvalidAuth = authn.Errorf("invalid authorization")

func authenticate(logger logr.Logger, store store.Store) authn.AuthFunc {
return multiAuthenticate(
authApiKey(logger, store),
)
}

func multiAuthenticate(methods ...authn.AuthFunc) authn.AuthFunc {
return func(ctx context.Context, req authn.Request) (any, error) {
var lastErr error
for _, method := range methods {
result, err := method(ctx, req)
if err == nil {
return result, nil
}
lastErr = err
}
return nil, lastErr
}
}

func authApiKey(logger logr.Logger, store store.Store) authn.AuthFunc {
return func(ctx context.Context, req authn.Request) (any, error) {
auth := req.Header().Get("Authorization")
if auth == "" {
return nil, errInvalidAuth
}

username, key, ok := parseApiKey(auth)
if !ok {
return nil, errInvalidAuth
}

ok, err := store.ValidateUserAPIKey(ctx, username, key)
if err != nil {
logger.Error(err, "Cannot look up user details.")
return nil, errInvalidAuth
}
if !ok {
return nil, errInvalidAuth
}

return username, nil
}
}

// parseApiKey parses the ApiKey string.
// "ApiKey test:test" returns ("test", "test", true).
func parseApiKey(auth string) (username, key string, ok bool) {
const prefix = "ApiKey "
// Case insensitive prefix match.
if len(auth) < len(prefix) || !equalFold(auth[:len(prefix)], prefix) {
return "", "", false
}
username, key, ok = strings.Cut(auth[len(prefix):], ":")
if !ok {
return "", "", false
}
return username, key, true
}

// equalFold is [strings.EqualFold], ASCII only. It reports whether s and t
// are equal, ASCII-case-insensitively.
func equalFold(s, t string) bool {
if len(s) != len(t) {
return false
}
for i := range len(s) {
if lower(s[i]) != lower(t[i]) {
return false
}
}
return true
}

// lower returns the ASCII lowercase version of b.
func lower(b byte) byte {
if 'A' <= b && b <= 'Z' {
return b + ('a' - 'A')
}
return b
}
56 changes: 56 additions & 0 deletions hack/ccp/internal/api/admin/auth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package admin

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

"connectrpc.com/authn"
"github.com/artefactual/archivematica/hack/ccp/internal/store/storemock"
"github.com/go-logr/logr"
"go.artefactual.dev/tools/mockutil"
"go.uber.org/mock/gomock"
"gotest.tools/v3/assert"
)

func TestAuthentication(t *testing.T) {
t.Parallel()

t.Run("Accepts API key", func(t *testing.T) {
t.Parallel()

store := storemock.NewMockStore(gomock.NewController(t))
store.EXPECT().ValidateUserAPIKey(mockutil.Context(), "test", "test").Return(true, nil)

auth := multiAuthenticate(authApiKey(logr.Discard(), store))
handler := authn.NewMiddleware(auth).Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))

req := httptest.NewRequest("GET", "http://example.com/foo", nil)
req.Header.Set("Authorization", "ApiKey test:test")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)

resp := w.Result()

assert.Equal(t, resp.StatusCode, http.StatusOK)
})

t.Run("Rejects invalid API key", func(t *testing.T) {
t.Parallel()

store := storemock.NewMockStore(gomock.NewController(t))
store.EXPECT().ValidateUserAPIKey(mockutil.Context(), "test", "12345").Return(false, nil)

auth := multiAuthenticate(authApiKey(logr.Discard(), store))
handler := authn.NewMiddleware(auth).Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))

req := httptest.NewRequest("GET", "http://example.com/foo", nil)
req.Header.Set("Authorization", "ApiKey test:12345")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)

resp := w.Result()

assert.Equal(t, resp.StatusCode, http.StatusUnauthorized)
})
}
14 changes: 14 additions & 0 deletions hack/ccp/internal/store/mysql.go
Original file line number Diff line number Diff line change
Expand Up @@ -975,6 +975,20 @@ func (s *mysqlStoreImpl) ReadStorageServiceConfig(ctx context.Context) (ret Stor
return ret, nil
}

func (s *mysqlStoreImpl) ValidateUserAPIKey(ctx context.Context, username, key string) (_ bool, err error) {
defer wrap(&err, "ValidateUserAPIKey(%q, %q)", username, key)

_, err = s.queries.ReadUserWithKey(ctx, &sqlc.ReadUserWithKeyParams{
Username: username,
Key: key,
})
if err == sql.ErrNoRows {
return false, nil
}

return true, nil
}

func (s *mysqlStoreImpl) Running() bool {
return s != nil
}
Expand Down
10 changes: 10 additions & 0 deletions hack/ccp/internal/store/sqlcmysql/db.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions hack/ccp/internal/store/sqlcmysql/query.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions hack/ccp/internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ type Store interface {
// Archivematica Storage Service associated to this pipeline.
ReadStorageServiceConfig(ctx context.Context) (StorageServiceConfig, error)

// ValidateUserAPIKey confirms that the username with the given API key
// exists in the database and is active.
ValidateUserAPIKey(ctx context.Context, username, key string) (bool, error)

Running() bool
Close() error
}
Expand Down
39 changes: 39 additions & 0 deletions hack/ccp/internal/store/storemock/mock_store.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions hack/ccp/sqlc/mysql/query.sql
Original file line number Diff line number Diff line change
Expand Up @@ -161,3 +161,14 @@ SELECT name, value, scope FROM DashboardSettings WHERE name LIKE ?;

-- name: ReadDashboardSetting :one
SELECT name, value, scope FROM DashboardSettings WHERE name = ?;

--
-- Authorization
--

-- name: ReadUserWithKey :one
SELECT auth_user.id, auth_user.username, auth_user.is_active
FROM auth_user
JOIN tastypie_apikey ON auth_user.id = tastypie_apikey.user_id
WHERE auth_user.username = ? AND tastypie_apikey.key = ? AND auth_user.is_active = 1
LIMIT 1;

0 comments on commit 0a83685

Please sign in to comment.