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

Implement authn middleware #69

Merged
merged 2 commits into from
Jul 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
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;
4 changes: 2 additions & 2 deletions src/dashboard/src/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@
client_local = threading.local()


def get_client(user_id, client_class=None):
def get_client(user, client_class=None):
"""Return the client for communicating with the MCPServer."""
if not getattr(client_local, "client", None):
client_class = client_class or CCPClient
lang = get_language() or "en"
client_local.client = client_class(user_id, lang)
client_local.client = client_class(user, lang)

return client_local.client

Expand Down
Loading
Loading