Make it possible to use CS3 as accounts backend instead of account-service

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 <jfd@butonic.de>
This commit is contained in:
Ilja Neumann
2020-12-04 01:56:54 +01:00
parent a3e3dcf561
commit 92a1bc8fb6
14 changed files with 517 additions and 269 deletions

View File

@@ -1,5 +1,10 @@
package middleware
/*
Temporarily disabled
import (
"context"
"fmt"

View File

@@ -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
}

View File

@@ -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),

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}
}

View File

@@ -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