Add 'proxy/' from commit '201b9a652685cdfb72ba81c7e7b00ba1c60a0e35'

git-subtree-dir: proxy
git-subtree-mainline: 571d96e856
git-subtree-split: 201b9a6526
This commit is contained in:
A.Unger
2020-09-18 12:47:26 +02:00
107 changed files with 8151 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
package middleware
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
revauser "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/cs3org/reva/pkg/token/manager/jwt"
acc "github.com/owncloud/ocis-accounts/pkg/proto/v0"
"github.com/owncloud/ocis-pkg/v2/log"
"github.com/owncloud/ocis-pkg/v2/oidc"
settings "github.com/owncloud/ocis-settings/pkg/proto/v0"
)
func getAccount(l log.Logger, ac acc.AccountsService, query string) (account *acc.Account, status int) {
resp, err := ac.ListAccounts(context.Background(), &acc.ListAccountsRequest{
Query: query,
PageSize: 2,
})
if err != nil {
l.Error().Err(err).Str("query", query).Msgf("Error fetching from accounts-service")
status = http.StatusInternalServerError
return
}
if len(resp.Accounts) <= 0 {
l.Error().Str("query", query).Msgf("Account not found")
status = http.StatusNotFound
return
}
if len(resp.Accounts) > 1 {
l.Error().Str("query", query).Msgf("More than one account found. Not logging user in.")
status = http.StatusForbidden
return
}
account = resp.Accounts[0]
return
}
func createAccount(l log.Logger, claims *oidc.StandardClaims, ac acc.AccountsService) (*acc.Account, int) {
// TODO check if fields are missing.
req := &acc.CreateAccountRequest{
Account: &acc.Account{
DisplayName: claims.DisplayName,
PreferredName: claims.PreferredUsername,
OnPremisesSamAccountName: claims.PreferredUsername,
Mail: claims.Email,
CreationType: "LocalAccount",
AccountEnabled: true,
// TODO assign uidnumber and gidnumber? better do that in ocis-accounts as it can keep track of the next numbers
},
}
created, err := ac.CreateAccount(context.Background(), req)
if err != nil {
l.Error().Err(err).Interface("account", req.Account).Msg("could not create account")
return nil, http.StatusInternalServerError
}
return created, 0
}
// AccountUUID provides a middleware which mints a jwt and adds it to the proxied request based
// on the oidc-claims
func AccountUUID(opts ...Option) func(next http.Handler) http.Handler {
opt := newOptions(opts...)
return func(next http.Handler) http.Handler {
// TODO: handle error
tokenManager, err := jwt.New(map[string]interface{}{
"secret": opt.TokenManagerConfig.JWTSecret,
"expires": int64(60),
})
if err != nil {
opt.Logger.Fatal().Err(err).Msgf("Could not initialize token-manager")
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
l := opt.Logger
claims := oidc.FromContext(r.Context())
if claims == nil {
next.ServeHTTP(w, r)
return
}
var account *acc.Account
var status int
if claims.Email != "" {
account, status = getAccount(l, opt.AccountsClient, fmt.Sprintf("mail eq '%s'", strings.ReplaceAll(claims.Email, "'", "''")))
} else if claims.PreferredUsername != "" {
account, status = getAccount(l, opt.AccountsClient, fmt.Sprintf("preferred_name eq '%s'", strings.ReplaceAll(claims.PreferredUsername, "'", "''")))
} else if claims.OcisID != "" {
account, status = getAccount(l, opt.AccountsClient, fmt.Sprintf("id eq '%s'", strings.ReplaceAll(claims.OcisID, "'", "''")))
} else {
// TODO allow lookup by custom claim, eg an id ... or sub
l.Error().Err(err).Msgf("Could not lookup account, no mail or preferred_username claim set")
w.WriteHeader(http.StatusInternalServerError)
}
if status != 0 || account == nil {
if status == http.StatusNotFound {
account, status = createAccount(l, claims, opt.AccountsClient)
if status != 0 {
w.WriteHeader(status)
return
}
} else {
w.WriteHeader(status)
return
}
}
if !account.AccountEnabled {
l.Debug().Interface("account", account).Msg("account is disabled")
w.WriteHeader(http.StatusUnauthorized)
return
}
groups := make([]string, len(account.MemberOf))
for i := range account.MemberOf {
// reva needs the unix group name
groups[i] = account.MemberOf[i].OnPremisesSamAccountName
}
// fetch active roles from ocis-settings
assignmentResponse, err := opt.SettingsRoleService.ListRoleAssignments(r.Context(), &settings.ListRoleAssignmentsRequest{AccountUuid: account.Id})
roleIDs := make([]string, 0)
if err != nil {
l.Err(err).Str("accountID", account.Id).Msg("failed to fetch role assignments")
} else {
for _, assignment := range assignmentResponse.Assignments {
roleIDs = append(roleIDs, assignment.RoleId)
}
}
l.Debug().Interface("claims", claims).Interface("account", account).Msgf("Associated claims with uuid")
user := &revauser.User{
Id: &revauser.UserId{
OpaqueId: account.Id,
Idp: claims.Iss,
},
Username: account.OnPremisesSamAccountName,
DisplayName: account.DisplayName,
Mail: account.Mail,
MailVerified: account.ExternalUserState == "" || account.ExternalUserState == "Accepted",
Groups: groups,
Opaque: &types.Opaque{
Map: map[string]*types.OpaqueEntry{},
},
}
user.Opaque.Map["uid"] = &types.OpaqueEntry{
Decoder: "plain",
Value: []byte(strconv.FormatInt(account.UidNumber, 10)),
}
user.Opaque.Map["gid"] = &types.OpaqueEntry{
Decoder: "plain",
Value: []byte(strconv.FormatInt(account.GidNumber, 10)),
}
// encode roleIDs as json string
roleIDsJSON, jsonErr := json.Marshal(roleIDs)
if jsonErr != nil {
l.Err(jsonErr).Str("accountID", account.Id).Msg("failed to marshal roleIDs into json")
} else {
user.Opaque.Map["roles"] = &types.OpaqueEntry{
Decoder: "json",
Value: roleIDsJSON,
}
}
token, err := tokenManager.MintToken(r.Context(), user)
if err != nil {
l.Error().Err(err).Msgf("Could not mint token")
w.WriteHeader(http.StatusInternalServerError)
return
}
r.Header.Set("x-access-token", token)
next.ServeHTTP(w, r)
})
}
}
+106
View File
@@ -0,0 +1,106 @@
package middleware
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/micro/go-micro/v2/client"
"github.com/owncloud/ocis-accounts/pkg/proto/v0"
"github.com/owncloud/ocis-pkg/v2/log"
"github.com/owncloud/ocis-pkg/v2/oidc"
"github.com/owncloud/ocis-proxy/pkg/config"
settings "github.com/owncloud/ocis-settings/pkg/proto/v0"
)
// TODO testing the getAccount method should inject a cache
func TestGetAccountSuccess(t *testing.T) {
svcCache.Invalidate(AccountsKey, "success")
if _, status := getAccount(log.NewLogger(), mockAccountUUIDMiddlewareAccSvc(false, true), "mail eq 'success'"); status != 0 {
t.Errorf("expected an account")
}
}
func TestGetAccountInternalError(t *testing.T) {
svcCache.Invalidate(AccountsKey, "failure")
if _, status := getAccount(log.NewLogger(), mockAccountUUIDMiddlewareAccSvc(true, false), "mail eq 'failure'"); status != http.StatusInternalServerError {
t.Errorf("expected an internal server error")
}
}
func TestAccountUUIDMiddleware(t *testing.T) {
svcCache.Invalidate(AccountsKey, "success")
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
m := AccountUUID(
Logger(log.NewLogger()),
TokenManagerConfig(config.TokenManager{JWTSecret: "secret"}),
AccountsClient(mockAccountUUIDMiddlewareAccSvc(false, true)),
SettingsRoleService(mockAccountUUIDMiddlewareRolesSvc(false)),
)(next)
r := httptest.NewRequest(http.MethodGet, "http://www.example.com", nil)
w := httptest.NewRecorder()
ctx := oidc.NewContext(r.Context(), &oidc.StandardClaims{Email: "success"})
r = r.WithContext(ctx)
m.ServeHTTP(w, r)
if r.Header.Get("x-access-token") == "" {
t.Errorf("expected a token")
}
}
func TestAccountUUIDMiddlewareWithDisabledAccount(t *testing.T) {
svcCache.Invalidate(AccountsKey, "failure")
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
m := AccountUUID(
Logger(log.NewLogger()),
TokenManagerConfig(config.TokenManager{JWTSecret: "secret"}),
AccountsClient(mockAccountUUIDMiddlewareAccSvc(false, false)),
SettingsRoleService(mockAccountUUIDMiddlewareRolesSvc(false)),
)(next)
r := httptest.NewRequest(http.MethodGet, "http://www.example.com", nil)
w := httptest.NewRecorder()
ctx := oidc.NewContext(r.Context(), &oidc.StandardClaims{Email: "failure"})
r = r.WithContext(ctx)
m.ServeHTTP(w, r)
rsp := w.Result()
defer rsp.Body.Close()
if rsp.StatusCode != http.StatusUnauthorized {
t.Errorf("expected a disabled account to be unauthorized, got: %d", rsp.StatusCode)
}
}
func mockAccountUUIDMiddlewareAccSvc(retErr, accEnabled bool) proto.AccountsService {
return &proto.MockAccountsService{
ListFunc: func(ctx context.Context, in *proto.ListAccountsRequest, opts ...client.CallOption) (out *proto.ListAccountsResponse, err error) {
if retErr {
return nil, fmt.Errorf("error returned by mockAccountsService LIST")
}
return &proto.ListAccountsResponse{
Accounts: []*proto.Account{
{
Id: "yay",
AccountEnabled: accEnabled,
},
},
}, nil
},
}
}
func mockAccountUUIDMiddlewareRolesSvc(returnError bool) settings.RoleService {
return &settings.MockRoleService{
ListRoleAssignmentsFunc: func(ctx context.Context, req *settings.ListRoleAssignmentsRequest, opts ...client.CallOption) (res *settings.ListRoleAssignmentsResponse, err error) {
if returnError {
return nil, fmt.Errorf("error returned by mockRoleService.ListRoleAssignments")
}
return &settings.ListRoleAssignmentsResponse{
Assignments: []*settings.UserRoleAssignment{},
}, nil
},
}
}
+77
View File
@@ -0,0 +1,77 @@
package middleware
import (
"net/http"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/cs3org/reva/pkg/rgrpc/status"
tokenpkg "github.com/cs3org/reva/pkg/token"
"github.com/cs3org/reva/pkg/token/manager/jwt"
"github.com/micro/go-micro/v2/errors"
"github.com/owncloud/ocis-accounts/pkg/proto/v0"
"google.golang.org/grpc/metadata"
)
// CreateHome provides a middleware which sends a CreateHome request to the reva gateway
func CreateHome(opts ...Option) func(next http.Handler) http.Handler {
opt := newOptions(opts...)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
accounts := opt.AccountsClient
tokenManager, err := jwt.New(map[string]interface{}{
"secret": opt.TokenManagerConfig.JWTSecret,
})
if err != nil {
opt.Logger.Error().Err(err).Msg("error creating a token manager")
w.WriteHeader(http.StatusInternalServerError)
return
}
token := r.Header.Get("x-access-token")
if token == "" {
next.ServeHTTP(w, r)
return
}
user, err := tokenManager.DismantleToken(r.Context(), token)
if err != nil {
opt.Logger.Err(err).Msg("error getting user from access token")
w.WriteHeader(http.StatusInternalServerError)
return
}
_, err = accounts.GetAccount(r.Context(), &proto.GetAccountRequest{
Id: user.Id.OpaqueId,
})
if err != nil {
e := errors.Parse(err.Error())
if e.Code == http.StatusNotFound {
opt.Logger.Debug().Msgf("account with id %s not found", user.Id.OpaqueId)
next.ServeHTTP(w, r)
return
}
opt.Logger.Err(err).Msgf("error getting user with id %s from accounts service", user.Id.OpaqueId)
w.WriteHeader(http.StatusInternalServerError)
return
}
// we need to pass the token to authenticate the CreateHome request.
//ctx := tokenpkg.ContextSetToken(r.Context(), token)
ctx := metadata.AppendToOutgoingContext(r.Context(), tokenpkg.TokenHeader, token)
createHomeReq := &provider.CreateHomeRequest{}
createHomeRes, err := opt.RevaGatewayClient.CreateHome(ctx, createHomeReq)
if err != nil {
opt.Logger.Err(err).Msg("error calling CreateHome")
} else if createHomeRes.Status.Code != rpc.Code_CODE_OK {
err := status.NewErrorFromCode(createHomeRes.Status.Code, "gateway")
opt.Logger.Err(err).Msg("error when calling Createhome")
}
next.ServeHTTP(w, r)
})
}
}
+19
View File
@@ -0,0 +1,19 @@
package middleware
import (
"fmt"
"net/http"
)
// RedirectToHTTPS redirects insecure requests to https
func RedirectToHTTPS(next http.Handler) http.Handler {
return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
proto := req.Header.Get("x-forwarded-proto")
if proto == "http" || proto == "HTTP" {
http.Redirect(res, req, fmt.Sprintf("https://%s%s", req.Host, req.URL), http.StatusPermanentRedirect)
return
}
next.ServeHTTP(res, req)
})
}
+6
View File
@@ -0,0 +1,6 @@
package middleware
import "net/http"
// M undocummented
type M func(next http.Handler) http.Handler
+119
View File
@@ -0,0 +1,119 @@
package middleware
import (
"context"
"errors"
"net/http"
"strings"
"github.com/coreos/go-oidc"
ocisoidc "github.com/owncloud/ocis-pkg/v2/oidc"
"github.com/owncloud/ocis-proxy/pkg/cache"
"golang.org/x/oauth2"
)
var (
// ErrInvalidToken is returned when the request token is invalid.
ErrInvalidToken = errors.New("invalid or missing token")
// svcCache caches requests for given services to prevent round trips to the service
svcCache = cache.NewCache(
cache.Size(256),
)
)
// OIDCProvider used to mock the oidc provider during tests
type OIDCProvider interface {
UserInfo(ctx context.Context, ts oauth2.TokenSource) (*oidc.UserInfo, error)
}
// OpenIDConnect provides a middleware to check access secured by a static token.
func OpenIDConnect(opts ...Option) func(next http.Handler) http.Handler {
opt := newOptions(opts...)
return func(next http.Handler) http.Handler {
var oidcProvider OIDCProvider
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
header := r.Header.Get("Authorization")
path := r.URL.Path
// Ignore request to "/konnect/v1/userinfo" as this will cause endless loop when getting userinfo
// needs a better idea on how to not hardcode this
if header == "" || !strings.HasPrefix(header, "Bearer ") || path == "/konnect/v1/userinfo" {
next.ServeHTTP(w, r)
return
}
customCtx := context.WithValue(r.Context(), oauth2.HTTPClient, opt.HTTPClient)
// check if oidc provider is initialized
if oidcProvider == nil {
// Lazily initialize a provider
// provider needs to be cached as when it is created
// it will fetch the keys from the issuer using the .well-known
// endpoint
var err error
oidcProvider, err = opt.OIDCProviderFunc()
if err != nil {
opt.Logger.Error().Err(err).Msg("could not initialize oidc provider")
w.WriteHeader(http.StatusInternalServerError)
return
}
}
token := strings.TrimPrefix(header, "Bearer ")
// TODO cache userinfo for access token if we can determine the expiry (which works in case it is a jwt based access token)
oauth2Token := &oauth2.Token{
AccessToken: token,
}
// The claims we want to have
var claims ocisoidc.StandardClaims
userInfo, err := oidcProvider.UserInfo(customCtx, oauth2.StaticTokenSource(oauth2Token))
if err != nil {
opt.Logger.Error().Err(err).Str("token", token).Msg("Failed to get userinfo")
http.Error(w, ErrInvalidToken.Error(), http.StatusUnauthorized)
return
}
if err := userInfo.Claims(&claims); err != nil {
opt.Logger.Error().Err(err).Interface("userinfo", userInfo).Msg("failed to unmarshal userinfo claims")
w.WriteHeader(http.StatusInternalServerError)
return
}
//TODO: This should be read from the token instead of config
claims.Iss = opt.OIDCIss
// inject claims to the request context for the account_uuid middleware.
ctxWithClaims := ocisoidc.NewContext(r.Context(), &claims)
r = r.WithContext(ctxWithClaims)
opt.Logger.Debug().Interface("claims", claims).Interface("userInfo", userInfo).Msg("unmarshalled userinfo")
// store claims in context
// uses the original context, not the one with probably reduced security
nr := r.WithContext(ocisoidc.NewContext(r.Context(), &claims))
next.ServeHTTP(w, nr)
})
}
}
// AccountsCacheEntry stores a request to the accounts service on the cache.
// this type declaration should be on each respective service.
type AccountsCacheEntry struct {
Email string
UUID string
}
const (
// AccountsKey declares the svcKey for the Accounts service.
AccountsKey = "accounts"
// NodeKey declares the key that will be used to store the node address.
// It is shared between services.
NodeKey = "node"
)
@@ -0,0 +1,67 @@
package middleware
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/coreos/go-oidc"
"github.com/owncloud/ocis-pkg/v2/log"
"golang.org/x/oauth2"
)
func TestOpenIDConnectMiddleware(t *testing.T) {
svcCache.Invalidate(AccountsKey, "success")
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})
m := OpenIDConnect(
Logger(log.NewLogger()),
OIDCProviderFunc(func() (OIDCProvider, error) {
return mockOP(false), nil
}),
)(next)
r := httptest.NewRequest(http.MethodGet, "https://idp.example.com", nil)
r.Header.Set("Authorization", "Bearer sometoken")
w := httptest.NewRecorder()
m.ServeHTTP(w, r)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected an internal server error")
}
}
type mockOIDCProvider struct {
UserInfoFunc func(ctx context.Context, ts oauth2.TokenSource) (*oidc.UserInfo, error)
}
// UserInfo will panic if the function has been called, but not mocked
func (m mockOIDCProvider) UserInfo(ctx context.Context, ts oauth2.TokenSource) (*oidc.UserInfo, error) {
if m.UserInfoFunc != nil {
return m.UserInfoFunc(ctx, ts)
}
panic("UserInfo was called in test but not mocked")
}
func mockOP(retErr bool) OIDCProvider {
if retErr {
return &mockOIDCProvider{
UserInfoFunc: func(ctx context.Context, ts oauth2.TokenSource) (*oidc.UserInfo, error) {
return nil, fmt.Errorf("error returned by mockOIDCProvider UserInfo")
},
}
}
return &mockOIDCProvider{
UserInfoFunc: func(ctx context.Context, ts oauth2.TokenSource) (*oidc.UserInfo, error) {
ui := &oidc.UserInfo{
// claims: private ...
}
return ui, nil
},
}
}
+120
View File
@@ -0,0 +1,120 @@
package middleware
import (
settings "github.com/owncloud/ocis-settings/pkg/proto/v0"
"net/http"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
acc "github.com/owncloud/ocis-accounts/pkg/proto/v0"
"github.com/owncloud/ocis-pkg/v2/log"
"github.com/owncloud/ocis-proxy/pkg/config"
storepb "github.com/owncloud/ocis-store/pkg/proto/v0"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
// Logger to use for logging, must be set
Logger log.Logger
// TokenManagerConfig for communicating with the reva token manager
TokenManagerConfig config.TokenManager
// HTTPClient to use for communication with the oidc provider
HTTPClient *http.Client
// AccountsClient for resolving accounts
AccountsClient acc.AccountsService
// SettingsRoleService for the roles API in settings
SettingsRoleService settings.RoleService
// OIDCProviderFunc to lazily initialize a provider, must be set for the oidcProvider middleware
OIDCProviderFunc func() (OIDCProvider, error)
// OIDCIss is the oidc-issuer
OIDCIss string
// RevaGatewayClient to send requests to the reva gateway
RevaGatewayClient gateway.GatewayAPIClient
// Store for persisting data
Store storepb.StoreService
// PreSignedURLConfig to configure the middleware
PreSignedURLConfig config.PreSignedURL
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(l log.Logger) Option {
return func(o *Options) {
o.Logger = l
}
}
// TokenManagerConfig provides a function to set the token manger config option.
func TokenManagerConfig(cfg config.TokenManager) Option {
return func(o *Options) {
o.TokenManagerConfig = cfg
}
}
// HTTPClient provides a function to set the http client config option.
func HTTPClient(c *http.Client) Option {
return func(o *Options) {
o.HTTPClient = c
}
}
// AccountsClient provides a function to set the accounts client config option.
func AccountsClient(ac acc.AccountsService) Option {
return func(o *Options) {
o.AccountsClient = ac
}
}
// SettingsRoleService provides a function to set the role service option.
func SettingsRoleService(rc settings.RoleService) Option {
return func(o *Options) {
o.SettingsRoleService = rc
}
}
// OIDCProviderFunc provides a function to set the the oidc provider function option.
func OIDCProviderFunc(f func() (OIDCProvider, error)) Option {
return func(o *Options) {
o.OIDCProviderFunc = f
}
}
// OIDCIss sets the oidc issuer url
func OIDCIss(iss string) Option {
return func(o *Options) {
o.OIDCIss = iss
}
}
// RevaGatewayClient provides a function to set the the reva gateway service client option.
func RevaGatewayClient(gc gateway.GatewayAPIClient) Option {
return func(o *Options) {
o.RevaGatewayClient = gc
}
}
// Store provides a function to set the store option.
func Store(sc storepb.StoreService) Option {
return func(o *Options) {
o.Store = sc
}
}
// PreSignedURLConfig provides a function to set the PreSignedURL config
func PreSignedURLConfig(cfg config.PreSignedURL) Option {
return func(o *Options) {
o.PreSignedURLConfig = cfg
}
}
+151
View File
@@ -0,0 +1,151 @@
package middleware
import (
"context"
"crypto/sha512"
"encoding/hex"
"net/http"
"strings"
"time"
"github.com/owncloud/ocis-pkg/v2/log"
ocisoidc "github.com/owncloud/ocis-pkg/v2/oidc"
"github.com/owncloud/ocis-proxy/pkg/config"
storepb "github.com/owncloud/ocis-store/pkg/proto/v0"
"golang.org/x/crypto/pbkdf2"
)
const (
iterations = 10000
keyLen = 32
)
// PresignedURL provides a middleware to check access secured by a presigned URL.
func PresignedURL(opts ...Option) func(next http.Handler) http.Handler {
opt := newOptions(opts...)
l := opt.Logger
cfg := opt.PreSignedURLConfig
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isSignedRequest(r) {
if signedRequestIsValid(l, r, opt.Store, cfg) {
// use openid claims to let the account_uuid middleware do a lookup by username
claims := ocisoidc.StandardClaims{
OcisID: r.URL.Query().Get("OC-Credential"),
}
// inject claims to the request context for the account_uuid middleware
ctxWithClaims := ocisoidc.NewContext(r.Context(), &claims)
r = r.WithContext(ctxWithClaims)
next.ServeHTTP(w, r)
} else {
http.Error(w, "Invalid url signature", http.StatusUnauthorized)
return
}
}
next.ServeHTTP(w, r)
})
}
}
func isSignedRequest(r *http.Request) bool {
return r.URL.Query().Get("OC-Signature") != ""
}
func signedRequestIsValid(l log.Logger, r *http.Request, s storepb.StoreService, cfg config.PreSignedURL) bool {
// TODO OC-Algorithm - defined the used algo (e.g. sha256 or sha512 - we should agree on one default algo and make this parameter optional)
// TODO OC-Verb - defines for which http verb the request is valid - defaults to GET OPTIONAL
return allRequiredParametersArePresent(r) &&
requestMethodMatches(r) &&
requestMethodIsAllowed(r.Method, cfg.AllowedHTTPMethods) &&
!urlIsExpired(r, time.Now) &&
signatureIsValid(l, r, s)
}
func allRequiredParametersArePresent(r *http.Request) bool {
// OC-Credential - defines the user scope (shall we use the owncloud user id here - this might leak internal data ....) REQUIRED
// OC-Date - defined the date the url was signed (ISO 8601 UTC) REQUIRED
// OC-Expires - defines the expiry interval in seconds (between 1 and 604800 = 7 days) REQUIRED
// OC-Signature - the computed signature - server will verify the request upon this REQUIRED
return r.URL.Query().Get("OC-Signature") != "" &&
r.URL.Query().Get("OC-Credential") != "" &&
r.URL.Query().Get("OC-Date") != "" &&
r.URL.Query().Get("OC-Expires") != "" &&
r.URL.Query().Get("OC-Verb") != ""
}
func requestMethodMatches(r *http.Request) bool {
return strings.EqualFold(r.Method, r.URL.Query().Get("OC-Verb"))
}
func requestMethodIsAllowed(m string, allowedMethods []string) bool {
for _, allowed := range allowedMethods {
if strings.EqualFold(m, allowed) {
return true
}
}
return false
}
func urlIsExpired(r *http.Request, now func() time.Time) bool {
t, err := time.Parse(time.RFC3339, r.URL.Query().Get("OC-Date"))
if err != nil {
return true
}
expires, err := time.ParseDuration(r.URL.Query().Get("OC-Expires") + "s")
if err != nil {
return true
}
t.Add(expires)
return t.After(now())
}
func signatureIsValid(l log.Logger, r *http.Request, s storepb.StoreService) bool {
signingKey, err := getSigningKey(r.Context(), s, r.URL.Query().Get("OC-Credential"))
if err != nil {
l.Error().Err(err).Msg("could not retrieve signing key")
return false
}
if len(signingKey) == 0 {
l.Error().Err(err).Msg("signing key empty")
return false
}
q := r.URL.Query()
signature := q.Get("OC-Signature")
q.Del("OC-Signature")
r.URL.RawQuery = q.Encode()
url := r.URL.String()
if !r.URL.IsAbs() {
url = "https://" + r.Host + url // TODO where do we get the scheme from
}
return createSignature(url, signingKey) == signature
}
func createSignature(url string, signingKey []byte) string {
// the oc10 signature check: $hash = \hash_pbkdf2("sha512", $url, $signingKey, 10000, 64, false);
// - sets the length of the output string to 64
// - sets raw output to false -> if raw_output is FALSE length corresponds to twice the byte-length of the derived key (as every byte of the key is returned as two hexits).
// TODO change to length 128 in oc10?
// fo golangs pbkdf2.Key we need to use 32 because it will be encoded into 64 hexits later
hash := pbkdf2.Key([]byte(url), signingKey, iterations, keyLen, sha512.New)
return hex.EncodeToString(hash)
}
func getSigningKey(ctx context.Context, s storepb.StoreService, credential string) ([]byte, error) {
res, err := s.Read(ctx, &storepb.ReadRequest{
Options: &storepb.ReadOptions{
Database: "proxy",
Table: "signing-keys",
},
Key: credential,
})
if err != nil || len(res.Records) < 1 {
return []byte{}, err
}
return res.Records[0].Value, nil
}
+121
View File
@@ -0,0 +1,121 @@
package middleware
import (
"net/http/httptest"
"testing"
"time"
)
func TestIsSignedRequest(t *testing.T) {
tests := []struct {
url string
expected bool
}{
{"https://example.com/example.jpg", false},
{"https://example.com/example.jpg?OC-Signature=something", true},
}
for _, tt := range tests {
r := httptest.NewRequest("", tt.url, nil)
result := isSignedRequest(r)
if result != tt.expected {
t.Errorf("with %s expected %t got %t", tt.url, tt.expected, result)
}
}
}
func TestAllRequiredParametersPresent(t *testing.T) {
baseURL := "https://example.com/example.jpg?"
tests := []struct {
params string
expected bool
}{
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Expires=something&OC-Verb=something", true},
{"OC-Credential=something&OC-Date=something&OC-Expires=something&OC-Verb=something", false},
{"OC-Signature=something&OC-Date=something&OC-Expires=something&OC-Verb=something", false},
{"OC-Signature=something&OC-Credential=something&OC-Expires=something&OC-Verb=something", false},
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Verb=something", false},
{"OC-Signature=something&OC-Credential=something&OC-Date=something&OC-Expires=something", false},
}
for _, tt := range tests {
r := httptest.NewRequest("", baseURL+tt.params, nil)
result := allRequiredParametersArePresent(r)
if result != tt.expected {
t.Errorf("with %s expected %t got %t", tt.params, tt.expected, result)
}
}
}
func TestRequestMethodMatches(t *testing.T) {
tests := []struct {
method string
url string
expected bool
}{
{"GET", "https://example.com/example.jpg?OC-Verb=GET", true},
{"GET", "https://example.com/example.jpg?OC-Verb=get", true},
{"POST", "https://example.com/example.jpg?OC-Verb=GET", false},
}
for _, tt := range tests {
r := httptest.NewRequest(tt.method, tt.url, nil)
result := requestMethodMatches(r)
if result != tt.expected {
t.Errorf("with method %s and url %s expected %t got %t", tt.method, tt.url, tt.expected, result)
}
}
}
func TestRequestMethodIsAllowed(t *testing.T) {
tests := []struct {
method string
allowed []string
expected bool
}{
{"GET", []string{}, false},
{"GET", []string{"POST"}, false},
{"GET", []string{"GET"}, true},
{"GET", []string{"get"}, true},
{"GET", []string{"POST", "GET"}, true},
}
for _, tt := range tests {
result := requestMethodIsAllowed(tt.method, tt.allowed)
if result != tt.expected {
t.Errorf("with method %s and allowed methods %s expected %t got %t", tt.method, tt.allowed, tt.expected, result)
}
}
}
func TestUrlIsExpired(t *testing.T) {
nowFunc := func() time.Time {
t, _ := time.Parse(time.RFC3339, "2020-08-19T15:12:43.478Z")
return t
}
tests := []struct {
url string
expected bool
}{
{"http://example.com/example.jpg?OC-Date=2020-08-19T15:02:43.478Z&OC-Expires=1200", false},
{"http://example.com/example.jpg?OC-Date=invalid&OC-Expires=1200", true},
{"http://example.com/example.jpg?OC-Date=2020-08-19T15:02:43.478Z&OC-Expires=invalid", true},
}
for _, tt := range tests {
r := httptest.NewRequest("", tt.url, nil)
result := urlIsExpired(r, nowFunc)
if result != tt.expected {
t.Errorf("with %s expected %t got %t", tt.url, tt.expected, result)
}
}
}
func TestCreateSignature(t *testing.T) {
expected := "27d2ebea381384af3179235114801dcd00f91e46f99fca72575301cf3948101d"
s := createSignature("something", []byte("somerandomkey"))
if s != expected {
t.Fail()
}
}