Add graph /me/changePassword endpoint

To allow users to change their own password when using the Graph LDAP backend.

Closes:	#3063
This commit is contained in:
Ralf Haferkamp
2022-05-17 14:23:38 +02:00
committed by Ralf Haferkamp
parent cbd7ea77f7
commit c41cf92553
13 changed files with 648 additions and 44 deletions
+3 -1
View File
@@ -17,12 +17,14 @@ import (
"google.golang.org/grpc"
)
//go:generate make generate
//go:generate make -C ../../.. generate
// GatewayClient is the subset of the gateway.GatewayAPIClient that is being used to interact with the gateway
type GatewayClient interface {
//gateway.GatewayAPIClient
// Authenticates a user.
Authenticate(ctx context.Context, in *gateway.AuthenticateRequest, opts ...grpc.CallOption) (*gateway.AuthenticateResponse, error)
// Returns the home path for the given authenticated user.
// When a user has access to multiple storage providers, one of them is the home.
GetHome(ctx context.Context, in *provider.GetHomeRequest, opts ...grpc.CallOption) (*provider.GetHomeResponse, error)
@@ -54,6 +54,11 @@ func (i instrument) PatchUser(w http.ResponseWriter, r *http.Request) {
i.next.PatchUser(w, r)
}
// ChangeOwnPassword implements the Service interface.
func (i instrument) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
i.next.ChangeOwnPassword(w, r)
}
// GetGroups implements the Service interface.
func (i instrument) GetGroups(w http.ResponseWriter, r *http.Request) {
i.next.GetGroups(w, r)
@@ -54,6 +54,11 @@ func (l logging) PatchUser(w http.ResponseWriter, r *http.Request) {
l.next.PatchUser(w, r)
}
// ChangeOwnPassword implements the Service interface.
func (l logging) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
l.next.ChangeOwnPassword(w, r)
}
// GetGroups implements the Service interface.
func (l logging) GetGroups(w http.ResponseWriter, r *http.Request) {
l.next.GetGroups(w, r)
@@ -5,6 +5,7 @@ import (
"github.com/cs3org/reva/v2/pkg/events"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/config"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/identity"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/ocis-pkg/roles"
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
@@ -19,6 +20,7 @@ type Options struct {
Config *config.Config
Middleware []func(http.Handler) http.Handler
GatewayClient GatewayClient
IdentityBackend identity.Backend
HTTPClient HTTPClient
RoleService settingssvc.RoleService
RoleManager *roles.Manager
@@ -64,6 +66,13 @@ func WithGatewayClient(val GatewayClient) Option {
}
}
// WithIdentityBackend provides a function to set the IdentityBackend option.
func WithIdentityBackend(val identity.Backend) Option {
return func(o *Options) {
o.IdentityBackend = val
}
}
// WithHTTPClient provides a function to set the http client option.
func WithHTTPClient(val HTTPClient) Option {
return func(o *Options) {
@@ -0,0 +1,88 @@
package svc
import (
"encoding/json"
"net/http"
"strings"
"github.com/CiscoM31/godata"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
revactx "github.com/cs3org/reva/v2/pkg/ctx"
"github.com/go-chi/render"
libregraph "github.com/owncloud/libre-graph-api-go"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/service/v0/errorcode"
)
// ChangeOwnPassword implements the Service interface. It allows the user to change
// its own password
func (g Graph) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
u, ok := revactx.ContextGetUser(ctx)
if !ok {
g.logger.Error().Msg("user not in context")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "user not in context")
return
}
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
_, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
if err != nil {
g.logger.Err(err).Interface("query", r.URL.Query()).Msg("query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
cpw := libregraph.NewPasswordChange()
err = json.NewDecoder(r.Body).Decode(cpw)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
currentPw := cpw.GetCurrentPassword()
if currentPw == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "current password cannot be empty")
return
}
newPw := cpw.GetNewPassword()
if newPw == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "new password cannot be empty")
return
}
if newPw == currentPw {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "new password must be differnt from current password")
return
}
authReq := &gateway.AuthenticateRequest{
Type: "basic",
ClientId: u.Username,
ClientSecret: currentPw,
}
authRes, err := g.gatewayClient.Authenticate(r.Context(), authReq)
if err != nil {
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
if authRes.Status.Code != cs3rpc.Code_CODE_OK {
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "password change failed")
return
}
newPwProfile := libregraph.NewPasswordProfile()
newPwProfile.SetPassword(newPw)
changes := libregraph.NewUser()
changes.SetPasswordProfile(*newPwProfile)
_, err = g.identityBackend.UpdateUser(ctx, u.Id.OpaqueId, *changes)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusInternalServerError, "password change failed")
g.logger.Debug().Err(err).Str("userid", u.Id.OpaqueId).Msg("failed to update user password")
return
}
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
@@ -0,0 +1,159 @@
package svc_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
revactx "github.com/cs3org/reva/v2/pkg/ctx"
"github.com/cs3org/reva/v2/pkg/rgrpc/status"
"github.com/go-ldap/ldap/v3"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/owncloud/libre-graph-api-go"
"github.com/owncloud/ocis/v2/extensions/graph/mocks"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/config"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/config/defaults"
"github.com/owncloud/ocis/v2/extensions/graph/pkg/identity"
service "github.com/owncloud/ocis/v2/extensions/graph/pkg/service/v0"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/stretchr/testify/mock"
)
type changePwTest struct {
desc string
currentpw string
newpw string
expected int
}
var _ = Describe("Users changing their own password", func() {
var (
svc service.Service
gatewayClient *mocks.GatewayClient
httpClient *mocks.HTTPClient
ldapClient *mocks.Client
ldapConfig config.LDAP
identityBackend identity.Backend
eventsPublisher mocks.Publisher
ctx context.Context
cfg *config.Config
user *userv1beta1.User
err error
)
JustBeforeEach(func() {
ctx = context.Background()
cfg = defaults.FullDefaultConfig()
cfg.TokenManager.JWTSecret = "loremipsum"
gatewayClient = &mocks.GatewayClient{}
ldapClient = mockedLDAPClient()
ldapConfig = config.LDAP{
WriteEnabled: true,
UserDisplayNameAttribute: "displayName",
UserNameAttribute: "uid",
UserEmailAttribute: "mail",
UserIDAttribute: "ownclouduuid",
UserSearchScope: "sub",
GroupNameAttribute: "cn",
GroupIDAttribute: "ownclouduui",
GroupSearchScope: "sub",
}
loggger := log.NewLogger()
identityBackend, err = identity.NewLDAPBackend(ldapClient, ldapConfig, &loggger)
Expect(err).To(BeNil())
httpClient = &mocks.HTTPClient{}
eventsPublisher = mocks.Publisher{}
svc = service.NewService(
service.Config(cfg),
service.WithGatewayClient(gatewayClient),
service.WithIdentityBackend(identityBackend),
service.WithHTTPClient(httpClient),
service.EventsPublisher(&eventsPublisher),
)
user = &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "user",
},
}
ctx = revactx.ContextSetUser(ctx, user)
})
It("fails if no user in context", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/changePassword", nil)
rr := httptest.NewRecorder()
svc.ChangeOwnPassword(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
})
DescribeTable("changing the password",
func(current string, newpw string, authresult string, expected int) {
switch authresult {
case "error":
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(nil, errors.New("fail"))
case "deny":
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{
Status: status.NewPermissionDenied(ctx, errors.New("wrong password"), "wrong password"),
Token: "authtoken",
}, nil)
default:
gatewayClient.On("Authenticate", mock.Anything, mock.Anything).Return(&gateway.AuthenticateResponse{
Status: status.NewOK(ctx),
Token: "authtoken",
}, nil)
}
cpw := libregraph.NewPasswordChange()
cpw.SetCurrentPassword(current)
cpw.SetNewPassword(newpw)
body, _ := json.Marshal(cpw)
b := bytes.NewBuffer(body)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/me/changePassword", b).WithContext(ctx)
rr := httptest.NewRecorder()
svc.ChangeOwnPassword(rr, r)
Expect(rr.Code).To(Equal(expected))
},
Entry("fails when current password is empty", "", "newpassword", "", http.StatusBadRequest),
Entry("fails when new password is empty", "currentpassword", "", "", http.StatusBadRequest),
Entry("fails when current and new password are equal", "password", "password", "", http.StatusBadRequest),
Entry("fails authentication with current password errors", "currentpassword", "newpassword", "error", http.StatusInternalServerError),
Entry("fails when current password is wrong", "currentpassword", "newpassword", "deny", http.StatusInternalServerError),
Entry("succeeds when current password is correct", "currentpassword", "newpassword", "", http.StatusNoContent),
)
})
func mockedLDAPClient() *mocks.Client {
lm := &mocks.Client{}
userEntry := ldap.NewEntry("uid=test", map[string][]string{
"uid": {"test"},
"displayName": {"test"},
"mail": {"test@example.org"},
})
lm.On("Search", mock.Anything, mock.Anything, mock.Anything, mock.Anything,
mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
Return(
&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}},
nil)
mr := ldap.NewModifyRequest("uid=test", nil)
mr.Changes = []ldap.Change{
{
Operation: ldap.ReplaceAttribute,
Modification: ldap.PartialAttribute{
Type: "userPassword",
Vals: []string{"newpassword"},
},
},
}
lm.On("Modify", mr).Return(nil)
return lm
}
+40 -36
View File
@@ -35,6 +35,7 @@ type Service interface {
PostUser(http.ResponseWriter, *http.Request)
DeleteUser(http.ResponseWriter, *http.Request)
PatchUser(http.ResponseWriter, *http.Request)
ChangeOwnPassword(http.ResponseWriter, *http.Request)
GetGroups(http.ResponseWriter, *http.Request)
GetGroup(http.ResponseWriter, *http.Request)
@@ -55,46 +56,10 @@ func NewService(opts ...Option) Service {
m := chi.NewMux()
m.Use(options.Middleware...)
var backend identity.Backend
switch options.Config.Identity.Backend {
case "cs3":
backend = &identity.CS3{
Config: options.Config.Reva,
Logger: &options.Logger,
}
case "ldap":
var err error
var tlsConf *tls.Config
if options.Config.Identity.LDAP.Insecure {
tlsConf = &tls.Config{
//nolint:gosec // We need the ability to run with "insecure" (dev/testing)
InsecureSkipVerify: options.Config.Identity.LDAP.Insecure,
}
}
conn := ldap.NewLDAPWithReconnect(&options.Logger,
ldap.Config{
URI: options.Config.Identity.LDAP.URI,
BindDN: options.Config.Identity.LDAP.BindDN,
BindPassword: options.Config.Identity.LDAP.BindPassword,
TLSConfig: tlsConf,
},
)
if backend, err = identity.NewLDAPBackend(conn, options.Config.Identity.LDAP, &options.Logger); err != nil {
options.Logger.Error().Msgf("Error initializing LDAP Backend: '%s'", err)
return nil
}
default:
options.Logger.Error().Msgf("Unknown Identity Backend: '%s'", options.Config.Identity.Backend)
return nil
}
svc := Graph{
config: options.Config,
mux: m,
logger: &options.Logger,
identityBackend: backend,
spacePropertiesCache: ttlcache.NewCache(),
eventsPublisher: options.EventsPublisher,
}
@@ -108,6 +73,44 @@ func NewService(opts ...Option) Service {
} else {
svc.gatewayClient = options.GatewayClient
}
if options.IdentityBackend == nil {
switch options.Config.Identity.Backend {
case "cs3":
svc.identityBackend = &identity.CS3{
Config: options.Config.Reva,
Logger: &options.Logger,
}
case "ldap":
var err error
var tlsConf *tls.Config
if options.Config.Identity.LDAP.Insecure {
tlsConf = &tls.Config{
//nolint:gosec // We need the ability to run with "insecure" (dev/testing)
InsecureSkipVerify: options.Config.Identity.LDAP.Insecure,
}
}
conn := ldap.NewLDAPWithReconnect(&options.Logger,
ldap.Config{
URI: options.Config.Identity.LDAP.URI,
BindDN: options.Config.Identity.LDAP.BindDN,
BindPassword: options.Config.Identity.LDAP.BindPassword,
TLSConfig: tlsConf,
},
)
if svc.identityBackend, err = identity.NewLDAPBackend(conn, options.Config.Identity.LDAP, &options.Logger); err != nil {
options.Logger.Error().Msgf("Error initializing LDAP Backend: '%s'", err)
return nil
}
default:
options.Logger.Error().Msgf("Unknown Identity Backend: '%s'", options.Config.Identity.Backend)
return nil
}
} else {
svc.identityBackend = options.IdentityBackend
}
if options.HTTPClient == nil {
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{
InsecureSkipVerify: options.Config.Spaces.Insecure, //nolint:gosec
@@ -143,6 +146,7 @@ func NewService(opts ...Option) Service {
r.Get("/", svc.GetMe)
r.Get("/drives", svc.GetDrives)
r.Get("/drive/root/children", svc.GetRootDriveChildren)
r.Post("/changePassword", svc.ChangeOwnPassword)
})
r.Route("/users", func(r chi.Router) {
r.With(requireAdmin).Get("/", svc.GetUsers)
@@ -50,6 +50,11 @@ func (t tracing) PatchUser(w http.ResponseWriter, r *http.Request) {
t.next.PatchUser(w, r)
}
// ChangeOwnPassword implements the Service interface.
func (t tracing) ChangeOwnPassword(w http.ResponseWriter, r *http.Request) {
t.next.ChangeOwnPassword(w, r)
}
// GetGroups implements the Service interface.
func (t tracing) GetGroups(w http.ResponseWriter, r *http.Request) {
t.next.GetGroups(w, r)