From 92a1bc8fb68a2ab20ecf13fdef6ca3ddbc4ec561 Mon Sep 17 00:00:00 2001 From: Ilja Neumann Date: Fri, 4 Dec 2020 01:56:54 +0100 Subject: [PATCH] Make it possible to use CS3 as accounts backend instead of account-service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configureable via: PROXY_ACCOUNT_BACKEND_TYPE=cs3 PROXY_ACCOUNT_BACKEND_TYPE=accounts (default) By using a backend which implements the CS3 user-api (currently provided by reva/storage) it is possible to bypass the ocis-accounts service and for example use ldap directly. Hides user and auth related communication behind a facade (user/backend) to minimize logic-duplication across middlewares. Allows to switich the account backend from accounts to cs3. Co-authored-by: Jörn Friedrich Dreyer --- .../unreleased/allow-ldap-user-backend.md | 12 + proxy/pkg/command/server.go | 28 ++- proxy/pkg/config/config.go | 1 + proxy/pkg/flagset/flagset.go | 8 + ...lver_test.go => _account_resolver_test.go} | 5 + proxy/pkg/middleware/account_resolver.go | 187 ++++----------- proxy/pkg/middleware/authentication.go | 1 + proxy/pkg/middleware/basic_auth.go | 49 ++-- proxy/pkg/middleware/create_home.go | 29 --- proxy/pkg/middleware/options.go | 10 + proxy/pkg/middleware/signed_url_auth.go | 72 ++---- proxy/pkg/user/backend/accounts.go | 218 ++++++++++++++++++ proxy/pkg/user/backend/backend.go | 66 ++++++ proxy/pkg/user/backend/cs3.go | 100 ++++++++ 14 files changed, 517 insertions(+), 269 deletions(-) create mode 100644 changelog/unreleased/allow-ldap-user-backend.md rename proxy/pkg/middleware/{account_resolver_test.go => _account_resolver_test.go} (99%) create mode 100644 proxy/pkg/user/backend/accounts.go create mode 100644 proxy/pkg/user/backend/backend.go create mode 100644 proxy/pkg/user/backend/cs3.go diff --git a/changelog/unreleased/allow-ldap-user-backend.md b/changelog/unreleased/allow-ldap-user-backend.md new file mode 100644 index 0000000000..341bd9cad1 --- /dev/null +++ b/changelog/unreleased/allow-ldap-user-backend.md @@ -0,0 +1,12 @@ +Change: CS3 can be used as accounts-backend + +Tags: proxy + +PROXY_ACCOUNT_BACKEND_TYPE=cs3 +PROXY_ACCOUNT_BACKEND_TYPE=accounts (default) + +By using a backend which implements the CS3 user-api (currently provided by reva/storage) it is possible to bypass +the ocis-accounts service and for example use ldap directly. + + +https://github.com/owncloud/ocis/pull/1020 diff --git a/proxy/pkg/command/server.go b/proxy/pkg/command/server.go index a29de79d74..145cdbb743 100644 --- a/proxy/pkg/command/server.go +++ b/proxy/pkg/command/server.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "fmt" + "github.com/owncloud/ocis/proxy/pkg/user/backend" "net/http" "os" "os/signal" @@ -250,10 +251,24 @@ func Server(cfg *config.Config) *cli.Command { } func loadMiddlewares(ctx context.Context, l log.Logger, cfg *config.Config) alice.Chain { - accountsClient := acc.NewAccountsService("com.owncloud.api.accounts", grpc.DefaultClient) rolesClient := settings.NewRoleService("com.owncloud.api.settings", grpc.DefaultClient) - storeClient := storepb.NewStoreService("com.owncloud.api.store", grpc.DefaultClient) revaClient, err := cs3.GetGatewayServiceClient(cfg.Reva.Address) + var userProvider backend.UserBackend + switch cfg.AccountBackend { + case "accounts": + userProvider = backend.NewAccountsServiceUserBackend( + acc.NewAccountsService("com.owncloud.api.accounts", grpc.DefaultClient), + rolesClient, + cfg.OIDC.Issuer, + l, + ) + case "cs3": + userProvider = backend.NewCS3UserBackend(revaClient, rolesClient, revaClient, l) + default: + l.Fatal().Msgf("Invalid accounts backend type '%s'", cfg.AccountBackend) + } + + storeClient := storepb.NewStoreService("com.owncloud.api.store", grpc.DefaultClient) if err != nil { l.Error().Err(err). Str("gateway", cfg.Reva.Address). @@ -290,27 +305,24 @@ func loadMiddlewares(ctx context.Context, l log.Logger, cfg *config.Config) alic // basic Options middleware.Logger(l), middleware.EnableBasicAuth(cfg.EnableBasicAuth), - middleware.AccountsClient(accountsClient), + middleware.UserProvider(userProvider), middleware.OIDCIss(cfg.OIDC.Issuer), middleware.CredentialsByUserAgent(cfg.Reva.Middleware.Auth.CredentialsByUserAgent), ), middleware.SignedURLAuth( middleware.Logger(l), middleware.PreSignedURLConfig(cfg.PreSignedURL), - middleware.AccountsClient(accountsClient), + middleware.UserProvider(userProvider), middleware.Store(storeClient), ), middleware.AccountResolver( middleware.Logger(l), - middleware.AccountsClient(accountsClient), - middleware.OIDCIss(cfg.OIDC.Issuer), + middleware.UserProvider(userProvider), middleware.TokenManagerConfig(cfg.TokenManager), middleware.AutoprovisionAccounts(cfg.AutoprovisionAccounts), - middleware.SettingsRoleService(rolesClient), ), middleware.CreateHome( middleware.Logger(l), - middleware.AccountsClient(accountsClient), middleware.TokenManagerConfig(cfg.TokenManager), middleware.RevaGatewayClient(revaClient), ), diff --git a/proxy/pkg/config/config.go b/proxy/pkg/config/config.go index 2f42bbe0be..9af1fc418d 100644 --- a/proxy/pkg/config/config.go +++ b/proxy/pkg/config/config.go @@ -115,6 +115,7 @@ type Config struct { PolicySelector *PolicySelector `mapstructure:"policy_selector"` Reva Reva PreSignedURL PreSignedURL + AccountBackend string AutoprovisionAccounts bool EnableBasicAuth bool InsecureBackends bool diff --git a/proxy/pkg/flagset/flagset.go b/proxy/pkg/flagset/flagset.go index e76373dc0d..013005d526 100644 --- a/proxy/pkg/flagset/flagset.go +++ b/proxy/pkg/flagset/flagset.go @@ -256,6 +256,14 @@ func ServerWithConfig(cfg *config.Config) []cli.Flag { Destination: &cfg.EnableBasicAuth, }, + &cli.StringFlag{ + Name: "account-backend-type", + Value: "accounts", + Usage: "account-backend-type", + EnvVars: []string{"PROXY_ACCOUNT_BACKEND_TYPE"}, + Destination: &cfg.AccountBackend, + }, + // Reva Middlewares Config &cli.StringSliceFlag{ Name: "proxy-user-agent-lock-in", diff --git a/proxy/pkg/middleware/account_resolver_test.go b/proxy/pkg/middleware/_account_resolver_test.go similarity index 99% rename from proxy/pkg/middleware/account_resolver_test.go rename to proxy/pkg/middleware/_account_resolver_test.go index f5ad54ede2..484e1664bb 100644 --- a/proxy/pkg/middleware/account_resolver_test.go +++ b/proxy/pkg/middleware/_account_resolver_test.go @@ -1,5 +1,10 @@ package middleware +/* + +Temporarily disabled + + import ( "context" "fmt" diff --git a/proxy/pkg/middleware/account_resolver.go b/proxy/pkg/middleware/account_resolver.go index 8099af32b0..50e4241025 100644 --- a/proxy/pkg/middleware/account_resolver.go +++ b/proxy/pkg/middleware/account_resolver.go @@ -1,21 +1,14 @@ package middleware import ( - "context" - "encoding/json" - "fmt" + "github.com/owncloud/ocis/proxy/pkg/user/backend" "net/http" - "strconv" - "strings" - revaUser "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" tokenPkg "github.com/cs3org/reva/pkg/token" "github.com/cs3org/reva/pkg/token/manager/jwt" - accounts "github.com/owncloud/ocis/accounts/pkg/proto/v0" + revauser "github.com/cs3org/reva/pkg/user" "github.com/owncloud/ocis/ocis-pkg/log" "github.com/owncloud/ocis/ocis-pkg/oidc" - settings "github.com/owncloud/ocis/settings/pkg/proto/v0" ) // AccountResolver provides a middleware which mints a jwt and adds it to the proxied request based @@ -37,117 +30,68 @@ func AccountResolver(optionSetters ...Option) func(next http.Handler) http.Handl next: next, logger: logger, tokenManager: tokenManager, - accountsClient: options.AccountsClient, - oidcIss: options.OIDCIss, - autoprovisionAccounts: options.AutoprovisionAccounts, - settingsRoleService: options.SettingsRoleService, + userProvider: options.UserProvider, + autoProvisionAccounts: options.AutoprovisionAccounts, } } } type accountResolver struct { - oidcIss string - autoprovisionAccounts bool next http.Handler logger log.Logger tokenManager tokenPkg.Manager - accountsClient accounts.AccountsService - settingsRoleService settings.RoleService + userProvider backend.UserBackend + autoProvisionAccounts bool } func (m accountResolver) ServeHTTP(w http.ResponseWriter, req *http.Request) { - var account *accounts.Account - var status int - claims := oidc.FromContext(req.Context()) + u, ok := revauser.ContextGetUser(req.Context()) - if claims == nil { + if claims == nil && !ok { m.next.ServeHTTP(w, req) return } - switch { - case claims.Email != "": - account, status = getAccount(m.logger, m.accountsClient, fmt.Sprintf("mail eq '%s'", strings.ReplaceAll(claims.Email, "'", "''"))) - case claims.PreferredUsername != "": - account, status = getAccount(m.logger, m.accountsClient, fmt.Sprintf("preferred_name eq '%s'", strings.ReplaceAll(claims.PreferredUsername, "'", "''"))) - case claims.OcisID != "": - account, status = getAccount(m.logger, m.accountsClient, fmt.Sprintf("id eq '%s'", strings.ReplaceAll(claims.OcisID, "'", "''"))) - default: - // TODO allow lookup by custom claim, eg an id ... or sub - m.logger.Error().Msg("Could not lookup account, no mail or preferred_username claim set") - w.WriteHeader(http.StatusInternalServerError) - } - - if m.autoprovisionAccounts && status == http.StatusNotFound { - account, status = createAccount(m.logger, claims, m.accountsClient) - } - - if status != 0 || account == nil { - w.WriteHeader(status) - return - } - - if !account.AccountEnabled { - m.logger.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 := m.settingsRoleService.ListRoleAssignments(req.Context(), &settings.ListRoleAssignmentsRequest{AccountUuid: account.Id}) - roleIDs := make([]string, 0) - if err != nil { - m.logger.Err(err).Str("accountID", account.Id).Msg("failed to fetch role assignments") - } else { - for _, assignment := range assignmentResponse.Assignments { - roleIDs = append(roleIDs, assignment.RoleId) + if u == nil && claims != nil { + var claim, value string + switch { + case claims.Email != "": + claim, value = "mail", claims.Email + case claims.PreferredUsername != "": + claim, value = "username", claims.PreferredUsername + case claims.OcisID != "": + //claim, value = "id", claims.OcisID + default: + // TODO allow lookup by custom claim, eg an id ... or sub + m.logger.Error().Msg("Could not lookup account, no mail or preferred_username claim set") + w.WriteHeader(http.StatusInternalServerError) } - } - m.logger.Debug().Interface("claims", claims).Interface("account", account).Msgf("associated claims with uuid") + var err error + u, err = m.userProvider.GetUserByClaims(req.Context(), claim, value, true) - 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 { - m.logger.Err(jsonErr).Str("accountID", account.Id).Msg("failed to marshal roleIDs into json") - } else { - user.Opaque.Map["roles"] = &types.OpaqueEntry{ - Decoder: "json", - Value: roleIDsJSON, + if m.autoProvisionAccounts && err == backend.ErrAccountNotFound { + m.logger.Debug().Interface("claims", claims).Interface("user", u).Msgf("User by claim not found... autoprovisioning.") + u, err = m.userProvider.CreateUserFromClaims(req.Context(), claims) } + + if err == backend.ErrAccountNotFound || err == backend.ErrAccountDisabled { + m.logger.Debug().Interface("claims", claims).Interface("user", u).Msgf("Unautorized") + w.WriteHeader(http.StatusUnauthorized) + return + } + + if err != nil { + m.logger.Error().Err(err).Msg("Could not get user by claim") + w.WriteHeader(http.StatusInternalServerError) + return + } + + m.logger.Debug().Interface("claims", claims).Interface("user", u).Msgf("associated claims with uuid") } - token, err := m.tokenManager.MintToken(req.Context(), user) + token, err := m.tokenManager.MintToken(req.Context(), u) if err != nil { m.logger.Error().Err(err).Msgf("could not mint token") @@ -159,52 +103,3 @@ func (m accountResolver) ServeHTTP(w http.ResponseWriter, req *http.Request) { m.next.ServeHTTP(w, req) } - -func getAccount(logger log.Logger, ac accounts.AccountsService, query string) (account *accounts.Account, status int) { - resp, err := ac.ListAccounts(context.Background(), &accounts.ListAccountsRequest{ - Query: query, - PageSize: 2, - }) - - if err != nil { - logger.Error().Err(err).Str("query", query).Msgf("error fetching from accounts-service") - status = http.StatusInternalServerError - return - } - - if len(resp.Accounts) <= 0 { - logger.Error().Str("query", query).Msgf("account not found") - status = http.StatusNotFound - return - } - - if len(resp.Accounts) > 1 { - logger.Error().Str("query", query).Msgf("more than one account found, aborting") - status = http.StatusForbidden - return - } - - account = resp.Accounts[0] - return -} - -func createAccount(l log.Logger, claims *oidc.StandardClaims, ac accounts.AccountsService) (*accounts.Account, int) { - // TODO check if fields are missing. - req := &accounts.CreateAccountRequest{ - Account: &accounts.Account{ - DisplayName: claims.DisplayName, - PreferredName: claims.PreferredUsername, - OnPremisesSamAccountName: claims.PreferredUsername, - Mail: claims.Email, - CreationType: "LocalAccount", - AccountEnabled: true, - }, - } - 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 -} diff --git a/proxy/pkg/middleware/authentication.go b/proxy/pkg/middleware/authentication.go index 2600ab3c44..9759a6a80b 100644 --- a/proxy/pkg/middleware/authentication.go +++ b/proxy/pkg/middleware/authentication.go @@ -122,6 +122,7 @@ func newOIDCAuth(options Options) func(http.Handler) http.Handler { // newBasicAuth returns a configured basic middleware func newBasicAuth(options Options) func(http.Handler) http.Handler { return BasicAuth( + UserProvider(options.UserProvider), Logger(options.Logger), EnableBasicAuth(options.EnableBasicAuth), AccountsClient(options.AccountsClient), diff --git a/proxy/pkg/middleware/basic_auth.go b/proxy/pkg/middleware/basic_auth.go index c16eb8defb..df710c6e4b 100644 --- a/proxy/pkg/middleware/basic_auth.go +++ b/proxy/pkg/middleware/basic_auth.go @@ -2,12 +2,11 @@ package middleware import ( "fmt" - "net/http" - "strings" - - accounts "github.com/owncloud/ocis/accounts/pkg/proto/v0" "github.com/owncloud/ocis/ocis-pkg/log" "github.com/owncloud/ocis/ocis-pkg/oidc" + "github.com/owncloud/ocis/proxy/pkg/user/backend" + "net/http" + "strings" ) const publicFilesEndpoint = "/remote.php/dav/public-files/" @@ -16,16 +15,15 @@ const publicFilesEndpoint = "/remote.php/dav/public-files/" func BasicAuth(optionSetters ...Option) func(next http.Handler) http.Handler { options := newOptions(optionSetters...) logger := options.Logger - oidcIss := options.OIDCIss if options.EnableBasicAuth { options.Logger.Warn().Msg("basic auth enabled, use only for testing or development") } h := basicAuth{ - logger: logger, - enabled: options.EnableBasicAuth, - accountsClient: options.AccountsClient, + logger: logger, + enabled: options.EnableBasicAuth, + userProvider: options.UserProvider, } return func(next http.Handler) http.Handler { @@ -40,14 +38,15 @@ func BasicAuth(optionSetters ...Option) func(next http.Handler) http.Handler { } removeSuperfluousAuthenticate(w) - account, ok := h.getAccount(req) + login, password, _ := req.BasicAuth() + user, err := h.userProvider.Authenticate(req.Context(), login, password) // touch is a user agent locking guard, when touched changes to true it indicates the User-Agent on the // request is configured to support only one challenge, it it remains untouched, there are no considera- // tions and we should write all available authentication challenges to the response. touch := false - if !ok { + if err != nil { for k, v := range options.CredentialsByUserAgent { if strings.Contains(k, req.UserAgent()) { removeSuperfluousAuthenticate(w) @@ -67,8 +66,10 @@ func BasicAuth(optionSetters ...Option) func(next http.Handler) http.Handler { } claims := &oidc.StandardClaims{ - OcisID: account.Id, - Iss: oidcIss, + OcisID: user.Id.OpaqueId, + Iss: user.Id.Idp, + PreferredUsername: user.Username, + Email: user.Mail, } next.ServeHTTP(w, req.WithContext(oidc.NewContext(req.Context(), claims))) @@ -78,35 +79,17 @@ func BasicAuth(optionSetters ...Option) func(next http.Handler) http.Handler { } type basicAuth struct { - logger log.Logger - enabled bool - accountsClient accounts.AccountsService + logger log.Logger + enabled bool + userProvider backend.UserBackend } func (m basicAuth) isPublicLink(req *http.Request) bool { login, _, ok := req.BasicAuth() - return ok && login == "public" && strings.HasPrefix(req.URL.Path, publicFilesEndpoint) } func (m basicAuth) isBasicAuth(req *http.Request) bool { login, password, ok := req.BasicAuth() - return m.enabled && ok && login != "" && password != "" } - -func (m basicAuth) getAccount(req *http.Request) (*accounts.Account, bool) { - login, password, _ := req.BasicAuth() - - account, status := getAccount( - m.logger, - m.accountsClient, - fmt.Sprintf( - "login eq '%s' and password eq '%s'", - strings.ReplaceAll(login, "'", "''"), - strings.ReplaceAll(password, "'", "''"), - ), - ) - - return account, status == 0 -} diff --git a/proxy/pkg/middleware/create_home.go b/proxy/pkg/middleware/create_home.go index 37f4e154a8..f6b083f915 100644 --- a/proxy/pkg/middleware/create_home.go +++ b/proxy/pkg/middleware/create_home.go @@ -9,8 +9,6 @@ import ( "github.com/cs3org/reva/pkg/rgrpc/status" tokenPkg "github.com/cs3org/reva/pkg/token" "github.com/cs3org/reva/pkg/token/manager/jwt" - microErrors "github.com/micro/go-micro/v2/errors" - accounts "github.com/owncloud/ocis/accounts/pkg/proto/v0" "github.com/owncloud/ocis/ocis-pkg/log" "google.golang.org/grpc/metadata" ) @@ -31,7 +29,6 @@ func CreateHome(optionSetters ...Option) func(next http.Handler) http.Handler { return &createHome{ next: next, logger: logger, - accountsClient: options.AccountsClient, tokenManager: tokenManager, revaGatewayClient: options.RevaGatewayClient, } @@ -41,7 +38,6 @@ func CreateHome(optionSetters ...Option) func(next http.Handler) http.Handler { type createHome struct { next http.Handler logger log.Logger - accountsClient accounts.AccountsService tokenManager tokenPkg.Manager revaGatewayClient gateway.GatewayAPIClient } @@ -54,31 +50,6 @@ func (m createHome) ServeHTTP(w http.ResponseWriter, req *http.Request) { token := req.Header.Get("x-access-token") - user, err := m.tokenManager.DismantleToken(req.Context(), token) - if err != nil { - m.logger.Logger.Err(err).Msg("error getting user from access token") - w.WriteHeader(http.StatusInternalServerError) - return - } - - _, err = m.accountsClient.GetAccount(req.Context(), &accounts.GetAccountRequest{ - Id: user.Id.OpaqueId, - }) - - if err != nil { - e := microErrors.Parse(err.Error()) - - if e.Code == http.StatusNotFound { - m.logger.Debug().Msgf("account with id %s not found", user.Id.OpaqueId) - m.next.ServeHTTP(w, req) - return - } - - m.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(req.Context(), tokenPkg.TokenHeader, token) diff --git a/proxy/pkg/middleware/options.go b/proxy/pkg/middleware/options.go index 27906f9a5c..f9a8ce86eb 100644 --- a/proxy/pkg/middleware/options.go +++ b/proxy/pkg/middleware/options.go @@ -1,6 +1,7 @@ package middleware import ( + "github.com/owncloud/ocis/proxy/pkg/user/backend" "net/http" "time" @@ -26,6 +27,8 @@ type Options struct { HTTPClient *http.Client // AccountsClient for resolving accounts AccountsClient acc.AccountsService + // UP + UserProvider backend.UserBackend // SettingsRoleService for the roles API in settings SettingsRoleService settings.RoleService // OIDCProviderFunc to lazily initialize an oidc provider, must be set for the oidc_auth middleware @@ -165,3 +168,10 @@ func TokenCacheTTL(ttl time.Duration) Option { o.UserinfoCacheTTL = ttl } } + +// UserProvider sets the accounts user provider +func UserProvider(up backend.UserBackend) Option { + return func(o *Options) { + o.UserProvider = up + } +} diff --git a/proxy/pkg/middleware/signed_url_auth.go b/proxy/pkg/middleware/signed_url_auth.go index 1e39df603f..46715ac6b5 100644 --- a/proxy/pkg/middleware/signed_url_auth.go +++ b/proxy/pkg/middleware/signed_url_auth.go @@ -6,15 +6,14 @@ import ( "encoding/hex" "errors" "fmt" + revauser "github.com/cs3org/reva/pkg/user" + "github.com/owncloud/ocis/proxy/pkg/user/backend" "net/http" "net/url" "strings" "time" - "github.com/google/uuid" - accounts "github.com/owncloud/ocis/accounts/pkg/proto/v0" "github.com/owncloud/ocis/ocis-pkg/log" - ocisoidc "github.com/owncloud/ocis/ocis-pkg/oidc" "github.com/owncloud/ocis/proxy/pkg/config" store "github.com/owncloud/ocis/store/pkg/proto/v0" "golang.org/x/crypto/pbkdf2" @@ -29,8 +28,8 @@ func SignedURLAuth(optionSetters ...Option) func(next http.Handler) http.Handler next: next, logger: options.Logger, preSignedURLConfig: options.PreSignedURLConfig, - accountsClient: options.AccountsClient, store: options.Store, + userProvider: options.UserProvider, } } } @@ -39,7 +38,7 @@ type signedURLAuth struct { next http.Handler logger log.Logger preSignedURLConfig config.PreSignedURL - accountsClient accounts.AccountsService + userProvider backend.UserBackend store store.StoreService } @@ -49,51 +48,22 @@ func (m signedURLAuth) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } + user, err := m.userProvider.GetUserByClaims(req.Context(), "username", req.URL.Query().Get("OC-Credential"), true) + if err != nil { + m.logger.Error().Err(err).Msg("Could not get user by claim") + w.WriteHeader(http.StatusInternalServerError) + } + + ctx := revauser.ContextSetUser(req.Context(), user) + + req = req.WithContext(ctx) + if err := m.validate(req); err != nil { http.Error(w, "Invalid url signature", http.StatusUnauthorized) return } - claims, err := m.claims(req.URL.Query().Get("OC-Credential")) - if err != nil { - http.Error(w, "Invalid url signature", http.StatusUnauthorized) - return - } - - m.next.ServeHTTP(w, req.WithContext(ocisoidc.NewContext(req.Context(), claims))) -} - -func (m signedURLAuth) claims(credential string) (*ocisoidc.StandardClaims, error) { - // use openid claims to let the account_uuid middleware do a lookup by username - claims := ocisoidc.StandardClaims{ - OcisID: credential, - } - - // OC10 username is handled as id, if we get a credantial that is not of type uuid we expect - // that it is a PreferredUsername und we need to get the corresponding uuid - if _, err := uuid.Parse(claims.OcisID); err != nil { - // todo caching - account, status := getAccount( - m.logger, - m.accountsClient, - fmt.Sprintf( - "preferred_name eq '%s'", - strings.ReplaceAll( - claims.OcisID, - "'", - "''", - ), - ), - ) - - if status != 0 || account == nil { - return nil, fmt.Errorf("no oc-credential found for %v", claims.OcisID) - } - - claims.OcisID = account.Id - } - - return &claims, nil + m.next.ServeHTTP(w, req) } func (m signedURLAuth) shouldServe(req *http.Request) bool { @@ -194,7 +164,8 @@ func (m signedURLAuth) urlIsExpired(query url.Values, now func() time.Time) (exp } func (m signedURLAuth) signatureIsValid(req *http.Request) (ok bool, err error) { - signingKey, err := m.getSigningKey(req.Context(), req.URL.Query().Get("OC-Credential")) + u := revauser.ContextMustGetUser(req.Context()) + signingKey, err := m.getSigningKey(req.Context(), u.Id.OpaqueId) if err != nil { m.logger.Error().Err(err).Msg("could not retrieve signing key") return false, err @@ -225,18 +196,13 @@ func (m signedURLAuth) createSignature(url string, signingKey []byte) string { return hex.EncodeToString(hash) } -func (m signedURLAuth) getSigningKey(ctx context.Context, credential string) ([]byte, error) { - claims, err := m.claims(credential) - if err != nil { - return []byte{}, err - } - +func (m signedURLAuth) getSigningKey(ctx context.Context, ocisID string) ([]byte, error) { res, err := m.store.Read(ctx, &store.ReadRequest{ Options: &store.ReadOptions{ Database: "proxy", Table: "signing-keys", }, - Key: claims.OcisID, + Key: ocisID, }) if err != nil || len(res.Records) < 1 { return []byte{}, err diff --git a/proxy/pkg/user/backend/accounts.go b/proxy/pkg/user/backend/accounts.go new file mode 100644 index 0000000000..665074ea1b --- /dev/null +++ b/proxy/pkg/user/backend/accounts.go @@ -0,0 +1,218 @@ +package backend + +import ( + "context" + "fmt" + cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + accounts "github.com/owncloud/ocis/accounts/pkg/proto/v0" + "github.com/owncloud/ocis/ocis-pkg/log" + "github.com/owncloud/ocis/ocis-pkg/oidc" + settings "github.com/owncloud/ocis/settings/pkg/proto/v0" + "net/http" + "strconv" + "strings" +) + +// NewAccountsServiceUserBackend creates a user-provider which fetches users from the ocis accounts-service +func NewAccountsServiceUserBackend(ac accounts.AccountsService, rs settings.RoleService, oidcISS string, logger log.Logger) UserBackend { + return &accountsServiceBackend{ + accountsClient: ac, + settingsRoleService: rs, + OIDCIss: oidcISS, + logger: logger, + } +} + +type accountsServiceBackend struct { + accountsClient accounts.AccountsService + settingsRoleService settings.RoleService + OIDCIss string + logger log.Logger +} + +func (a accountsServiceBackend) GetUserByClaims(ctx context.Context, claim, value string, withRoles bool) (*cs3.User, error) { + var account *accounts.Account + var status int + var query string + + switch claim { + case "mail": + query = fmt.Sprintf("mail eq '%s'", strings.ReplaceAll(value, "'", "''")) + case "username": + query = fmt.Sprintf("preferred_name eq '%s'", strings.ReplaceAll(value, "'", "''")) + case "id": + query = fmt.Sprintf("id eq '%s'", strings.ReplaceAll(value, "'", "''")) + default: + return nil, fmt.Errorf("invalid user by claim lookup must be 'mail', 'username' or 'id") + } + + account, status = a.getAccount(ctx, query) + if status == http.StatusNotFound { + return nil, ErrAccountNotFound + } + + if status != 0 || account == nil { + return nil, fmt.Errorf("could not get account, got status: %d", status) + } + + if !account.AccountEnabled { + return nil, ErrAccountDisabled + } + + user := a.accountToUser(account) + + if !withRoles { + return user, nil + } + + if err := injectRoles(ctx, user, a.settingsRoleService); err != nil { + a.logger.Warn().Err(err).Msgf("Could not load roles... continuing without") + } + + return user, nil + +} + +// Authenticate authenticates against the accounts services and returns the user on success +func (a *accountsServiceBackend) Authenticate(ctx context.Context, username string, password string) (*cs3.User, error) { + query := fmt.Sprintf( + "login eq '%s' and password eq '%s'", + strings.ReplaceAll(username, "'", "''"), + strings.ReplaceAll(password, "'", "''"), + ) + account, status := a.getAccount(ctx, query) + + if status != 0 { + return nil, fmt.Errorf("could not authenticate with username, password for user %s. Status: %d", username, status) + } + + user := a.accountToUser(account) + + if err := injectRoles(ctx, user, a.settingsRoleService); err != nil { + a.logger.Warn().Err(err).Msgf("Could not load roles... continuing without") + } + + return user, nil +} + +func (a accountsServiceBackend) CreateUserFromClaims(ctx context.Context, claims *oidc.StandardClaims) (*cs3.User, error) { + // TODO check if fields are missing. + req := &accounts.CreateAccountRequest{ + Account: &accounts.Account{ + DisplayName: claims.DisplayName, + PreferredName: claims.PreferredUsername, + OnPremisesSamAccountName: claims.PreferredUsername, + Mail: claims.Email, + CreationType: "LocalAccount", + AccountEnabled: true, + }, + } + created, err := a.accountsClient.CreateAccount(context.Background(), req) + if err != nil { + return nil, err + } + + user := a.accountToUser(created) + + if err := injectRoles(ctx, user, a.settingsRoleService); err != nil { + a.logger.Warn().Err(err).Msgf("Could not load roles... continuing without") + } + + return user, nil +} + +func (a accountsServiceBackend) GetUserGroups(ctx context.Context, userID string) { + panic("implement me") +} + +// accountToUser converts an owncloud account struct to a reva user struct. In the proxy +// we work with the reva struct as a token can be minted from it. +func (a *accountsServiceBackend) accountToUser(account *accounts.Account) *cs3.User { + user := &cs3.User{ + Id: &cs3.UserId{ + OpaqueId: account.Id, + Idp: a.OIDCIss, + }, + Username: account.OnPremisesSamAccountName, + DisplayName: account.DisplayName, + Mail: account.Mail, + MailVerified: account.ExternalUserState == "" || account.ExternalUserState == "Accepted", + Groups: expandGroups(account), + 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)), + } + return user +} + +func (a *accountsServiceBackend) getAccount(ctx context.Context, query string) (account *accounts.Account, status int) { + resp, err := a.accountsClient.ListAccounts(ctx, &accounts.ListAccountsRequest{ + Query: query, + PageSize: 2, + }) + + if err != nil { + a.logger.Error().Err(err).Str("query", query).Msgf("error fetching from accounts-service") + status = http.StatusInternalServerError + return + } + + if len(resp.Accounts) <= 0 { + a.logger.Error().Str("query", query).Msgf("account not found") + status = http.StatusNotFound + return + } + + if len(resp.Accounts) > 1 { + a.logger.Error().Str("query", query).Msgf("more than one account found, aborting") + status = http.StatusForbidden + return + } + + account = resp.Accounts[0] + return +} + +func expandGroups(account *accounts.Account) []string { + groups := make([]string, len(account.MemberOf)) + for i := range account.MemberOf { + // reva needs the unix group name + groups[i] = account.MemberOf[i].OnPremisesSamAccountName + } + return groups +} + +// injectRoles adds roles from the roles-service to the user-struct by mutating an existing struct +func injectRoles(ctx context.Context, u *cs3.User, ss settings.RoleService) error { + roleIDs, err := loadRolesIDs(ctx, u.Id.OpaqueId, ss) + if err != nil { + return err + } + + if len(roleIDs) == 0 { + return nil + } + + enc, err := encodeRoleIDs(roleIDs) + if err != nil { + return err + } + + u.Opaque = &types.Opaque{ + Map: map[string]*types.OpaqueEntry{ + "roles": enc, + }, + } + + return nil +} diff --git a/proxy/pkg/user/backend/backend.go b/proxy/pkg/user/backend/backend.go new file mode 100644 index 0000000000..1253927977 --- /dev/null +++ b/proxy/pkg/user/backend/backend.go @@ -0,0 +1,66 @@ +package backend + +import ( + "context" + "encoding/json" + "errors" + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/owncloud/ocis/ocis-pkg/oidc" + settings "github.com/owncloud/ocis/settings/pkg/proto/v0" + "google.golang.org/grpc" +) + +var ( + // ErrAccountNotFound account not found + ErrAccountNotFound = errors.New("user not found") + // ErrAccountDisabled account disabled + ErrAccountDisabled = errors.New("account disabled") + // ErrNotSupported operation not supported by user-backend + ErrNotSupported = errors.New("operation not supported") +) + +// UserBackend allows the proxy to retrieve users from different user-backends (accounts-service, CS3) +type UserBackend interface { + GetUserByClaims(ctx context.Context, claim, value string, withRoles bool) (*cs3.User, error) + Authenticate(ctx context.Context, username string, password string) (*cs3.User, error) + CreateUserFromClaims(ctx context.Context, claims *oidc.StandardClaims) (*cs3.User, error) + GetUserGroups(ctx context.Context, userID string) +} + +// RevaAuthenticator helper interface to mock auth-method from reva gateway-client. +type RevaAuthenticator interface { + Authenticate(ctx context.Context, in *gateway.AuthenticateRequest, opts ...grpc.CallOption) (*gateway.AuthenticateResponse, error) +} + +// loadRolesIDs returns the role-ids assigned to an user +func loadRolesIDs(ctx context.Context, opaqueUserID string, rs settings.RoleService) ([]string, error) { + req := &settings.ListRoleAssignmentsRequest{AccountUuid: opaqueUserID} + assignmentResponse, err := rs.ListRoleAssignments(ctx, req) + + if err != nil { + return nil, err + } + + roleIDs := make([]string, 0) + + for _, assignment := range assignmentResponse.Assignments { + roleIDs = append(roleIDs, assignment.RoleId) + } + + return roleIDs, nil +} + +// encodeRoleIDs encoded the given role id's in to reva-specific format to be able to mint a token from them +func encodeRoleIDs(roleIDs []string) (*types.OpaqueEntry, error) { + roleIDsJSON, err := json.Marshal(roleIDs) + if err != nil { + return nil, err + } + + return &types.OpaqueEntry{ + Decoder: "json", + Value: roleIDsJSON, + }, nil +} diff --git a/proxy/pkg/user/backend/cs3.go b/proxy/pkg/user/backend/cs3.go new file mode 100644 index 0000000000..8d830d0715 --- /dev/null +++ b/proxy/pkg/user/backend/cs3.go @@ -0,0 +1,100 @@ +package backend + +import ( + "context" + "fmt" + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/owncloud/ocis/ocis-pkg/log" + "github.com/owncloud/ocis/ocis-pkg/oidc" + settings "github.com/owncloud/ocis/settings/pkg/proto/v0" +) + +type cs3backend struct { + userProvider cs3.UserAPIClient + settingsRoleService settings.RoleService + authProvider RevaAuthenticator + logger log.Logger +} + +// NewCS3UserBackend creates a user-provider which fetches users from a CS3 UserBackend +func NewCS3UserBackend(up cs3.UserAPIClient, rs settings.RoleService, ap RevaAuthenticator, logger log.Logger) UserBackend { + return &cs3backend{ + userProvider: up, + settingsRoleService: rs, + authProvider: ap, + logger: logger, + } +} + +func (c *cs3backend) GetUserByClaims(ctx context.Context, claim, value string, withRoles bool) (*cs3.User, error) { + res, err := c.userProvider.GetUserByClaim(ctx, &cs3.GetUserByClaimRequest{ + Claim: claim, + Value: value, + }) + + switch { + case err != nil: + return nil, fmt.Errorf("could not get user by claim %v with value %v: %w", claim, value, err) + case res.Status.Code != rpcv1beta1.Code_CODE_OK: + if res.Status.Code == rpcv1beta1.Code_CODE_NOT_FOUND { + return nil, ErrAccountNotFound + } + return nil, fmt.Errorf("could not get user by claim %v with value %v : %w ", claim, value, err) + } + + user := res.User + + if !withRoles { + return user, nil + } + + roleIDs, err := loadRolesIDs(ctx, user.Id.OpaqueId, c.settingsRoleService) + if err != nil { + c.logger.Error().Err(err).Msg("Could not load roles") + } + + if len(roleIDs) == 0 { + return user, nil + + } + + enc, err := encodeRoleIDs(roleIDs) + if err != nil { + c.logger.Error().Err(err).Msg("Could not encode loaded roles") + } + + user.Opaque = &types.Opaque{ + Map: map[string]*types.OpaqueEntry{ + "roles": enc, + }, + } + + return res.User, nil +} + +func (c *cs3backend) Authenticate(ctx context.Context, username string, password string) (*cs3.User, error) { + res, err := c.authProvider.Authenticate(ctx, &gateway.AuthenticateRequest{ + ClientId: username, + ClientSecret: password, + }) + + switch { + case err != nil: + return nil, fmt.Errorf("could not authenticate with username and password user: %s, %w", username, err) + case res.Status.Code != rpcv1beta1.Code_CODE_OK: + return nil, fmt.Errorf("could not authenticate with username and password user: %s, got code: %d", username, res.Status.Code) + } + + return res.User, nil +} + +func (c *cs3backend) CreateUserFromClaims(ctx context.Context, claims *oidc.StandardClaims) (*cs3.User, error) { + return nil, fmt.Errorf("CS3 Backend does not support creating users from claims") +} + +func (c cs3backend) GetUserGroups(ctx context.Context, userID string) { + panic("implement me") +}