From 18b230c6df87b59ecc53ded94b2f4b16deaa800f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20M=C3=BCller?= Date: Fri, 20 Aug 2021 10:19:58 +0200 Subject: [PATCH] Site account extensions (#2008) --- changelog/unreleased/siteacc-ext.md | 11 + .../config/http/services/siteacc/_index.md | 74 ++- examples/siteacc/siteacc.toml | 20 +- .../publicstorageprovider.go | 2 +- .../cloud/capabilities/capabilities.go | 22 +- .../http/services/siteacc/data/account.go | 112 ----- internal/http/services/siteacc/panel/panel.go | 80 --- .../services/siteacc/registration/form.go | 76 --- .../services/siteacc/registration/template.go | 216 -------- internal/http/services/siteacc/siteacc.go | 318 +----------- pkg/mentix/connectors/gocdb/query.go | 1 - pkg/mentix/utils/network/network.go | 13 +- pkg/siteacc/account/contact/contact.go | 51 ++ pkg/siteacc/account/contact/template.go | 111 ++++ pkg/siteacc/account/edit/edit.go | 51 ++ pkg/siteacc/account/edit/template.go | 170 +++++++ pkg/siteacc/account/login/login.go | 51 ++ pkg/siteacc/account/login/template.go | 137 +++++ pkg/siteacc/account/manage/manage.go | 51 ++ pkg/siteacc/account/manage/template.go | 105 ++++ pkg/siteacc/account/panel.go | 169 +++++++ .../account/registration/registration.go | 51 ++ pkg/siteacc/account/registration/template.go | 184 +++++++ pkg/siteacc/admin/panel.go | 115 +++++ .../panel => pkg/siteacc/admin}/template.go | 114 +++-- pkg/siteacc/config/config.go | 80 +++ .../siteacc/config/endpoints.go | 24 +- pkg/siteacc/data/account.go | 225 +++++++++ .../siteacc/data/filestorage.go | 3 +- .../template.go => pkg/siteacc/data/scopes.go | 41 +- .../services => pkg}/siteacc/data/storage.go | 0 .../services => pkg}/siteacc/email/email.go | 54 +- pkg/siteacc/email/template.go | 89 ++++ pkg/siteacc/endpoints.go | 475 ++++++++++++++++++ pkg/siteacc/html/panel.go | 165 ++++++ pkg/siteacc/html/provider.go | 60 +++ pkg/siteacc/html/session.go | 116 +++++ pkg/siteacc/html/sessionmanager.go | 186 +++++++ pkg/siteacc/html/template.go | 167 ++++++ .../siteacc/manager/acclistener.go | 33 +- .../siteacc/manager/accmanager.go | 202 +++++--- pkg/siteacc/manager/gocdb/account.go | 100 ++++ pkg/siteacc/manager/gocdb/gocdb.go | 81 +++ pkg/siteacc/manager/token.go | 87 ++++ pkg/siteacc/manager/usersmanager.go | 138 +++++ pkg/siteacc/password/password.go | 83 +++ pkg/siteacc/siteacc.go | 169 +++++++ .../siteacc/sitereg/sitereg.go | 0 pkg/utils/utils.go | 19 + 49 files changed, 3875 insertions(+), 1027 deletions(-) create mode 100644 changelog/unreleased/siteacc-ext.md delete mode 100644 internal/http/services/siteacc/data/account.go delete mode 100644 internal/http/services/siteacc/panel/panel.go delete mode 100644 internal/http/services/siteacc/registration/form.go delete mode 100644 internal/http/services/siteacc/registration/template.go create mode 100644 pkg/siteacc/account/contact/contact.go create mode 100644 pkg/siteacc/account/contact/template.go create mode 100644 pkg/siteacc/account/edit/edit.go create mode 100644 pkg/siteacc/account/edit/template.go create mode 100644 pkg/siteacc/account/login/login.go create mode 100644 pkg/siteacc/account/login/template.go create mode 100644 pkg/siteacc/account/manage/manage.go create mode 100644 pkg/siteacc/account/manage/template.go create mode 100644 pkg/siteacc/account/panel.go create mode 100644 pkg/siteacc/account/registration/registration.go create mode 100644 pkg/siteacc/account/registration/template.go create mode 100644 pkg/siteacc/admin/panel.go rename {internal/http/services/siteacc/panel => pkg/siteacc/admin}/template.go (50%) create mode 100644 pkg/siteacc/config/config.go rename {internal/http/services => pkg}/siteacc/config/endpoints.go (68%) create mode 100644 pkg/siteacc/data/account.go rename {internal/http/services => pkg}/siteacc/data/filestorage.go (98%) rename internal/http/services/siteacc/email/template.go => pkg/siteacc/data/scopes.go (53%) rename {internal/http/services => pkg}/siteacc/data/storage.go (100%) rename {internal/http/services => pkg}/siteacc/email/email.go (52%) create mode 100644 pkg/siteacc/email/template.go create mode 100644 pkg/siteacc/endpoints.go create mode 100644 pkg/siteacc/html/panel.go create mode 100644 pkg/siteacc/html/provider.go create mode 100644 pkg/siteacc/html/session.go create mode 100644 pkg/siteacc/html/sessionmanager.go create mode 100644 pkg/siteacc/html/template.go rename internal/http/services/siteacc/config/config.go => pkg/siteacc/manager/acclistener.go (54%) rename internal/http/services/siteacc/manager.go => pkg/siteacc/manager/accmanager.go (57%) create mode 100644 pkg/siteacc/manager/gocdb/account.go create mode 100644 pkg/siteacc/manager/gocdb/gocdb.go create mode 100644 pkg/siteacc/manager/token.go create mode 100644 pkg/siteacc/manager/usersmanager.go create mode 100644 pkg/siteacc/password/password.go create mode 100644 pkg/siteacc/siteacc.go rename {internal/http/services => pkg}/siteacc/sitereg/sitereg.go (100%) diff --git a/changelog/unreleased/siteacc-ext.md b/changelog/unreleased/siteacc-ext.md new file mode 100644 index 0000000000..ca36b67f0e --- /dev/null +++ b/changelog/unreleased/siteacc-ext.md @@ -0,0 +1,11 @@ +Enhancement: Site account extensions + +This PR heavily extends the site accounts service: +* Extended the accounts information (not just email and name) +* Accounts now have a password +* Users can now "log in" to their accounts and edit it +* Ability to grant access to the GOCDB + +Furthermore, these accounts can now be used to authenticate for logging in to our customized GOCDB. More use cases for these accounts are also planned. + +https://github.com/cs3org/reva/pull/2008 diff --git a/docs/content/en/docs/config/http/services/siteacc/_index.md b/docs/content/en/docs/config/http/services/siteacc/_index.md index d56f901c0f..6de927d5c8 100644 --- a/docs/content/en/docs/config/http/services/siteacc/_index.md +++ b/docs/content/en/docs/config/http/services/siteacc/_index.md @@ -19,28 +19,45 @@ prefix = "/siteacc" {{< /highlight >}} {{% /dir %}} -{{% dir name="enable_registration_form" type="string" default="false" %}} -If set to true, the service will expose a simple form for account registration. +## GOCDB settings +{{% dir name="url" type="string" default="" %}} +The external URL of the central GOCDB instance. +{{< highlight toml >}} +[http.services.siteacc.gocdb] +url = "https://www.sciencemesh.eu/gocdb/" +{{< /highlight >}} +{{% /dir %}} +{{% dir name="write_url" type="string" default="" %}} +The external URL of the GOCDB Write API. {{< highlight toml >}} -[http.services.siteacc] -enable_registration_form = true +[http.services.siteacc.gocdb] +write_url = "https://www.sciencemesh.eu/gocdbpi/" +{{< /highlight >}} +{{% /dir %}} + +{{% dir name="apikey" type="string" default="" %}} +The API key for the GOCDB. +{{< highlight toml >}} +[http.services.siteacc.gocdb] +apikey = "verysecret" {{< /highlight >}} {{% /dir %}} +## Email settings {{% dir name="notifications_mail" type="string" default="" %}} An email address where all notifications are sent to. {{< highlight toml >}} -[http.services.siteacc] +[http.services.siteacc.email] notifications_mail = "notify@example.com" {{< /highlight >}} {{% /dir %}} -## SMTP settings +### SMTP settings {{% dir name="sender_mail" type="string" default="" %}} An email address from which all emails are sent. {{< highlight toml >}} -[http.services.siteacc.smtp] +[http.services.siteacc.email.smtp] sender_mail = "notify@example.com" {{< /highlight >}} {{% /dir %}} @@ -48,7 +65,7 @@ sender_mail = "notify@example.com" {{% dir name="sender_login" type="string" default="" %}} The login name. {{< highlight toml >}} -[http.services.siteacc.smtp] +[http.services.siteacc.email.smtp] sender_login = "hans" {{< /highlight >}} {{% /dir %}} @@ -56,7 +73,7 @@ sender_login = "hans" {{% dir name="sender_password" type="string" default="" %}} The password for the login. {{< highlight toml >}} -[http.services.siteacc.smtp] +[http.services.siteacc.email.smtp] password = "secret" {{< /highlight >}} {{% /dir %}} @@ -64,7 +81,7 @@ password = "secret" {{% dir name="smtp_server" type="string" default="" %}} The SMTP server to use. {{< highlight toml >}} -[http.services.siteacc.smtp] +[http.services.siteacc.email.smtp] smtp_server = "smtp.example.com" {{< /highlight >}} {{% /dir %}} @@ -72,7 +89,7 @@ smtp_server = "smtp.example.com" {{% dir name="smtp_port" type="int" default="25" %}} The SMTP server port to use. {{< highlight toml >}} -[http.services.siteacc.smtp] +[http.services.siteacc.email.smtp] smtp_port = 25 {{< /highlight >}} {{% /dir %}} @@ -80,7 +97,7 @@ smtp_port = 25 {{% dir name="disable_auth" type="bool" default="false" %}} Whether to disable authentication. {{< highlight toml >}} -[http.services.siteacc.smtp] +[http.services.siteacc.email.smtp] disable_auth = true {{< /highlight >}} {{% /dir %}} @@ -111,3 +128,36 @@ The registration service URL. url = "https://iop.example.com/sitereg" {{< /highlight >}} {{% /dir %}} + +## Webserver settings +{{% dir name="url" type="string" default="" %}} +The external URL of the site accounts service. +{{< highlight toml >}} +[http.services.siteacc.webserver] +url = "https://www.sciencemesh.eu/accounts/" +{{< /highlight >}} +{{% /dir %}} + +{{% dir name="session_timeout" type="int" default="300" %}} +The session timeout in seconds. +{{< highlight toml >}} +[http.services.siteacc.webserver] +session_timeout = 600 +{{< /highlight >}} +{{% /dir %}} + +{{% dir name="verify_remote_address" type="bool" default="false" %}} +If true, sessions are only valid if they belong to the same IP. This can cause problems behind proxy servers. +{{< highlight toml >}} +[http.services.siteacc.webserver] +verify_remote_address = true +{{< /highlight >}} +{{% /dir %}} + +{{% dir name="log_sessions" type="bool" default="false" %}} +If enabled, debug information about sessions will be printed. +{{< highlight toml >}} +[http.services.siteacc.webserver] +log_sessions = true +{{< /highlight >}} +{{% /dir %}} diff --git a/examples/siteacc/siteacc.toml b/examples/siteacc/siteacc.toml index 093fe58f86..cf081e748e 100644 --- a/examples/siteacc/siteacc.toml +++ b/examples/siteacc/siteacc.toml @@ -1,11 +1,10 @@ [http] address = "0.0.0.0:9600" -[http.services.siteacc] -# If this is set to true, the service will expose a simple form for account registration -enable_registration_form = true -# All notification emails are sent to this email -notifications_mail = "science.mesh@example.com" +[http.services.siteacc.gocdb] +url = "https://sciencemesh-test.uni-muenster.de/gocdb/" +write_url = "https://sciencemesh-test.uni-muenster.de/gocdbpi/" +apikey = "verysecret" # Set up the storage driver [http.services.siteacc.storage] @@ -13,9 +12,18 @@ driver = "file" [http.services.siteacc.storage.file] file = "/var/revad/accounts.json" +# Email related settings +[http.services.siteacc.email] +notifications_mail = "science.mesh@example.com" + # The SMTP server used for sending emails -[http.services.siteacc.smtp] +[http.services.siteacc.email.smtp] sender_mail = "science.mesh@example.com" smtp_server = "mail.example.com" smtp_port = 25 disable_auth = true + +# The webserver section defines various webserver-related settings +[http.services.siteacc.webserver] +url = "https://sciencemesh-test.uni-muenster.de/api/accounts/" +session_timeout = 60 diff --git a/internal/grpc/services/publicstorageprovider/publicstorageprovider.go b/internal/grpc/services/publicstorageprovider/publicstorageprovider.go index adde74160f..9132811bb1 100644 --- a/internal/grpc/services/publicstorageprovider/publicstorageprovider.go +++ b/internal/grpc/services/publicstorageprovider/publicstorageprovider.go @@ -80,7 +80,7 @@ func parseConfig(m map[string]interface{}) (*config, error) { return c, nil } -// New creates a new Public Storage Provider service. +// New creates a new IsPublic Storage Provider service. func New(m map[string]interface{}, ss *grpc.Server) (rgrpc.Service, error) { c, err := parseConfig(m) if err != nil { diff --git a/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/capabilities.go b/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/capabilities.go index a153c79396..35b7324fb1 100644 --- a/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/capabilities.go +++ b/internal/http/services/owncloud/ocs/handlers/cloud/capabilities/capabilities.go @@ -128,7 +128,7 @@ func (h *Handler) Init(c *config.Config) { h.c.Capabilities.FilesSharing.Public = &data.CapabilitiesFilesSharingPublic{} } - // h.c.Capabilities.FilesSharing.Public.Enabled is boolean + // h.c.Capabilities.FilesSharing.IsPublic.Enabled is boolean h.c.Capabilities.FilesSharing.Public.Enabled = true if h.c.Capabilities.FilesSharing.Public.Password == nil { @@ -139,22 +139,22 @@ func (h *Handler) Init(c *config.Config) { h.c.Capabilities.FilesSharing.Public.Password.EnforcedFor = &data.CapabilitiesFilesSharingPublicPasswordEnforcedFor{} } - // h.c.Capabilities.FilesSharing.Public.Password.EnforcedFor.ReadOnly is boolean - // h.c.Capabilities.FilesSharing.Public.Password.EnforcedFor.ReadWrite is boolean - // h.c.Capabilities.FilesSharing.Public.Password.EnforcedFor.UploadOnly is boolean + // h.c.Capabilities.FilesSharing.IsPublic.Password.EnforcedFor.ReadOnly is boolean + // h.c.Capabilities.FilesSharing.IsPublic.Password.EnforcedFor.ReadWrite is boolean + // h.c.Capabilities.FilesSharing.IsPublic.Password.EnforcedFor.UploadOnly is boolean - // h.c.Capabilities.FilesSharing.Public.Password.Enforced is boolean + // h.c.Capabilities.FilesSharing.IsPublic.Password.Enforced is boolean if h.c.Capabilities.FilesSharing.Public.ExpireDate == nil { h.c.Capabilities.FilesSharing.Public.ExpireDate = &data.CapabilitiesFilesSharingPublicExpireDate{} } - // h.c.Capabilities.FilesSharing.Public.ExpireDate.Enabled is boolean + // h.c.Capabilities.FilesSharing.IsPublic.ExpireDate.Enabled is boolean - // h.c.Capabilities.FilesSharing.Public.SendMail is boolean - // h.c.Capabilities.FilesSharing.Public.SocialShare is boolean - // h.c.Capabilities.FilesSharing.Public.Upload is boolean - // h.c.Capabilities.FilesSharing.Public.Multiple is boolean - // h.c.Capabilities.FilesSharing.Public.SupportsUploadOnly is boolean + // h.c.Capabilities.FilesSharing.IsPublic.SendMail is boolean + // h.c.Capabilities.FilesSharing.IsPublic.SocialShare is boolean + // h.c.Capabilities.FilesSharing.IsPublic.Upload is boolean + // h.c.Capabilities.FilesSharing.IsPublic.Multiple is boolean + // h.c.Capabilities.FilesSharing.IsPublic.SupportsUploadOnly is boolean if h.c.Capabilities.FilesSharing.User == nil { h.c.Capabilities.FilesSharing.User = &data.CapabilitiesFilesSharingUser{} diff --git a/internal/http/services/siteacc/data/account.go b/internal/http/services/siteacc/data/account.go deleted file mode 100644 index 914d5b7e12..0000000000 --- a/internal/http/services/siteacc/data/account.go +++ /dev/null @@ -1,112 +0,0 @@ -// Copyright 2018-2020 CERN -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this filePath except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// In applying this license, CERN does not waive the privileges and immunities -// granted to it by virtue of its status as an Intergovernmental Organization -// or submit itself to any jurisdiction. - -package data - -import ( - "time" - - "github.com/pkg/errors" - - "github.com/cs3org/reva/pkg/mentix/key" - "github.com/cs3org/reva/pkg/utils" -) - -// Account represents a single site account. -type Account struct { - Email string `json:"email"` - FirstName string `json:"firstName"` - LastName string `json:"lastName"` - - DateCreated time.Time `json:"dateCreated"` - DateModified time.Time `json:"dateModified"` - - Data AccountData `json:"data"` -} - -// AccountData holds additional data for a site account. -type AccountData struct { - APIKey key.APIKey `json:"apiKey"` - Authorized bool `json:"authorized"` -} - -// Accounts holds an array of site accounts. -type Accounts = []*Account - -// GetSiteID returns the site ID (generated from the API key) for the given account. -func (acc *Account) GetSiteID() key.SiteIdentifier { - if id, err := key.CalculateSiteID(acc.Data.APIKey, key.SaltFromEmail(acc.Email)); err == nil { - return id - } - - return "" -} - -// Copy copies the data of the given account to this account; if copyData is true, the account data is copied as well. -func (acc *Account) Copy(other *Account, copyData bool) error { - if err := other.verify(); err != nil { - return errors.Wrap(err, "unable to update account data") - } - - // Manually update fields - acc.FirstName = other.FirstName - acc.LastName = other.LastName - - if copyData { - acc.Data = other.Data - } - - return nil -} - -func (acc *Account) verify() error { - if acc.Email == "" { - return errors.Errorf("no email address provided") - } else if !utils.IsEmailValid(acc.Email) { - return errors.Errorf("invalid email address: %v", acc.Email) - } - - if acc.FirstName == "" || acc.LastName == "" { - return errors.Errorf("no or incomplete name provided") - } - - return nil -} - -// NewAccount creates a new site account. -func NewAccount(email string, firstName, lastName string) (*Account, error) { - t := time.Now() - - acc := &Account{ - Email: email, - FirstName: firstName, - LastName: lastName, - DateCreated: t, - DateModified: t, - Data: AccountData{ - APIKey: "", - Authorized: false, - }, - } - - if err := acc.verify(); err != nil { - return nil, err - } - - return acc, nil -} diff --git a/internal/http/services/siteacc/panel/panel.go b/internal/http/services/siteacc/panel/panel.go deleted file mode 100644 index ce8d6cd916..0000000000 --- a/internal/http/services/siteacc/panel/panel.go +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright 2018-2020 CERN -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// In applying this license, CERN does not waive the privileges and immunities -// granted to it by virtue of its status as an Intergovernmental Organization -// or submit itself to any jurisdiction. - -package panel - -import ( - "html/template" - "net/http" - - "github.com/pkg/errors" - "github.com/rs/zerolog" - - "github.com/cs3org/reva/internal/http/services/siteacc/config" - "github.com/cs3org/reva/internal/http/services/siteacc/data" -) - -// Panel represents the web interface panel of the accounts service. -type Panel struct { - conf *config.Configuration - log *zerolog.Logger - - tpl *template.Template -} - -func (panel *Panel) initialize(conf *config.Configuration, log *zerolog.Logger) error { - if conf == nil { - return errors.Errorf("no configuration provided") - } - panel.conf = conf - - if log == nil { - return errors.Errorf("no logger provided") - } - panel.log = log - - // Create the panel template - panel.tpl = template.New("panel") - if _, err := panel.tpl.Parse(panelTemplate); err != nil { - return errors.Wrap(err, "error while parsing panel template") - } - - return nil -} - -// Execute generates the HTTP output of the panel and writes it to the response writer. -func (panel *Panel) Execute(w http.ResponseWriter, accounts *data.Accounts) error { - type TemplateData struct { - Accounts *data.Accounts - } - - tplData := TemplateData{ - Accounts: accounts, - } - - return panel.tpl.Execute(w, tplData) -} - -// NewPanel creates a new web interface panel. -func NewPanel(conf *config.Configuration, log *zerolog.Logger) (*Panel, error) { - panel := &Panel{} - if err := panel.initialize(conf, log); err != nil { - return nil, errors.Wrapf(err, "unable to initialize the panel") - } - return panel, nil -} diff --git a/internal/http/services/siteacc/registration/form.go b/internal/http/services/siteacc/registration/form.go deleted file mode 100644 index e7649e891d..0000000000 --- a/internal/http/services/siteacc/registration/form.go +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright 2018-2020 CERN -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// In applying this license, CERN does not waive the privileges and immunities -// granted to it by virtue of its status as an Intergovernmental Organization -// or submit itself to any jurisdiction. - -package registration - -import ( - "html/template" - "net/http" - - "github.com/pkg/errors" - "github.com/rs/zerolog" - - "github.com/cs3org/reva/internal/http/services/siteacc/config" -) - -// Form represents the web interface form for user account registration. -type Form struct { - conf *config.Configuration - log *zerolog.Logger - - tpl *template.Template -} - -func (form *Form) initialize(conf *config.Configuration, log *zerolog.Logger) error { - if conf == nil { - return errors.Errorf("no configuration provided") - } - form.conf = conf - - if log == nil { - return errors.Errorf("no logger provided") - } - form.log = log - - // Create the form template - form.tpl = template.New("form") - if _, err := form.tpl.Parse(formTemplate); err != nil { - return errors.Wrap(err, "error while parsing form template") - } - - return nil -} - -// Execute generates the HTTP output of the form and writes it to the response writer. -func (form *Form) Execute(w http.ResponseWriter) error { - type TemplateData struct { - } - - tplData := TemplateData{} - - return form.tpl.Execute(w, tplData) -} - -// NewForm creates a new web interface form. -func NewForm(conf *config.Configuration, log *zerolog.Logger) (*Form, error) { - form := &Form{} - if err := form.initialize(conf, log); err != nil { - return nil, errors.Wrapf(err, "unable to initialize the form") - } - return form, nil -} diff --git a/internal/http/services/siteacc/registration/template.go b/internal/http/services/siteacc/registration/template.go deleted file mode 100644 index 8da584177d..0000000000 --- a/internal/http/services/siteacc/registration/template.go +++ /dev/null @@ -1,216 +0,0 @@ -// Copyright 2018-2020 CERN -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// In applying this license, CERN does not waive the privileges and immunities -// granted to it by virtue of its status as an Intergovernmental Organization -// or submit itself to any jurisdiction. - -package registration - -const formTemplate = ` - - - - - - ScienceMesh Account Registration - - - -
-

Welcome to the ScienceMesh Account Registration!

-
-

Fill out the form below to register for a ScienceMesh account. A confirmation email will be sent to you shortly after registration.

-

- After inspection by a ScienceMesh administrator, you will also receive an API key which can then be used in the - ownCloud or - Nextcloud web application. -

-
-
 
-
-
-
-
-
-
-
-
- -
- Fields marked with * are mandatory. -
-
- - -
-
-
- - - -
- - -` diff --git a/internal/http/services/siteacc/siteacc.go b/internal/http/services/siteacc/siteacc.go index e3f5765d39..8684964e9d 100644 --- a/internal/http/services/siteacc/siteacc.go +++ b/internal/http/services/siteacc/siteacc.go @@ -19,20 +19,14 @@ package siteacc import ( - "encoding/json" - "fmt" - "io/ioutil" "net/http" - "net/url" - "strings" + "github.com/cs3org/reva/pkg/siteacc" + "github.com/cs3org/reva/pkg/siteacc/config" "github.com/mitchellh/mapstructure" "github.com/pkg/errors" "github.com/rs/zerolog" - "github.com/cs3org/reva/internal/http/services/siteacc/config" - "github.com/cs3org/reva/internal/http/services/siteacc/data" - "github.com/cs3org/reva/pkg/mentix/key" "github.com/cs3org/reva/pkg/rhttp/global" ) @@ -44,7 +38,7 @@ type svc struct { conf *config.Configuration log *zerolog.Logger - manager *Manager + siteacc *siteacc.SiteAccounts } const ( @@ -63,297 +57,12 @@ func (s *svc) Prefix() string { // Unprotected returns all endpoints that can be queried without prior authorization. func (s *svc) Unprotected() []string { - // The account creation endpoint is always unprotected - endpoints := []string{config.EndpointCreate} - - // If enabled, the registration registrationForm endpoint is also unprotected - if s.conf.EnableRegistrationForm { - endpoints = append(endpoints, config.EndpointRegistration) - } - - return endpoints + return s.siteacc.GetPublicEndpoints() } // Handler serves all HTTP requests. func (s *svc) Handler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer r.Body.Close() - - switch r.URL.Path { - case config.EndpointPanel: - s.handlePanelEndpoint(w, r) - - case config.EndpointRegistration: - if s.conf.EnableRegistrationForm { - s.handleRegistrationEndpoint(w, r) - } - - default: - s.handleRequestEndpoints(w, r) - } - }) -} - -func (s *svc) handlePanelEndpoint(w http.ResponseWriter, r *http.Request) { - if err := s.manager.ShowPanel(w); err != nil { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(fmt.Sprintf("Unable to show the web interface panel: %v", err))) - } -} - -func (s *svc) handleRegistrationEndpoint(w http.ResponseWriter, r *http.Request) { - if err := s.manager.ShowRegistrationForm(w); err != nil { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte(fmt.Sprintf("Unable to show the web interface registration registrationForm: %v", err))) - } -} - -func (s *svc) handleRequestEndpoints(w http.ResponseWriter, r *http.Request) { - // Allow definition of endpoints in a flexible and easy way - type Endpoint struct { - Path string - Method string - Handler func(url.Values, []byte) (interface{}, error) - } - - // Every request to the accounts service results in a standardized JSON response - type Response struct { - Success bool `json:"success"` - Error string `json:"error,omitempty"` - Data interface{} `json:"data,omitempty"` - } - - endpoints := []Endpoint{ - {config.EndpointGenerateAPIKey, http.MethodGet, s.handleGenerateAPIKey}, - {config.EndpointVerifyAPIKey, http.MethodGet, s.handleVerifyAPIKey}, - {config.EndpointAssignAPIKey, http.MethodPost, s.handleAssignAPIKey}, - {config.EndpointList, http.MethodGet, s.handleList}, - {config.EndpointFind, http.MethodGet, s.handleFind}, - {config.EndpointCreate, http.MethodPost, s.handleCreate}, - {config.EndpointUpdate, http.MethodPost, s.handleUpdate}, - {config.EndpointRemove, http.MethodPost, s.handleRemove}, - {config.EndpointAuthorize, http.MethodPost, s.handleAuthorize}, - {config.EndpointIsAuthorized, http.MethodGet, s.handleIsAuthorized}, - {config.EndpointUnregisterSite, http.MethodPost, s.handleUnregisterSite}, - } - - // The default response is an unknown endpoint (for the specified method) - resp := Response{ - Success: false, - Error: fmt.Sprintf("unknown endpoint %v for method %v", r.URL.Path, r.Method), - Data: nil, - } - - // Check each endpoint if it can handle the request - for _, endpoint := range endpoints { - if r.URL.Path == endpoint.Path && r.Method == endpoint.Method { - body, _ := ioutil.ReadAll(r.Body) - - if data, err := endpoint.Handler(r.URL.Query(), body); err == nil { - resp.Success = true - resp.Error = "" - resp.Data = data - } else { - resp.Success = false - resp.Error = fmt.Sprintf("%v", err) - resp.Data = nil - } - - break - } - } - - // Any failure during query handling results in a bad request - if !resp.Success { - w.WriteHeader(http.StatusBadRequest) - } - - jsonData, _ := json.MarshalIndent(&resp, "", "\t") - _, _ = w.Write(jsonData) -} - -func (s *svc) handleGenerateAPIKey(values url.Values, body []byte) (interface{}, error) { - email := values.Get("email") - flags := key.FlagDefault - - if strings.EqualFold(values.Get("isScienceMesh"), "true") { - flags |= key.FlagScienceMesh - } - - if len(email) == 0 { - return nil, errors.Errorf("no email provided") - } - - apiKey, err := key.GenerateAPIKey(key.SaltFromEmail(email), flags) - if err != nil { - return nil, errors.Wrap(err, "unable to generate API key") - } - return map[string]string{"apiKey": apiKey}, nil -} - -func (s *svc) handleVerifyAPIKey(values url.Values, body []byte) (interface{}, error) { - apiKey := values.Get("apiKey") - email := values.Get("email") - - if len(apiKey) == 0 { - return nil, errors.Errorf("no API key provided") - } - - if len(email) == 0 { - return nil, errors.Errorf("no email provided") - } - - err := key.VerifyAPIKey(apiKey, key.SaltFromEmail(email)) - if err != nil { - return nil, errors.Wrap(err, "invalid API key") - } - return nil, nil -} - -func (s *svc) handleAssignAPIKey(values url.Values, body []byte) (interface{}, error) { - account, err := s.unmarshalRequestData(body) - if err != nil { - return nil, err - } - - flags := key.FlagDefault - if _, ok := values["isScienceMesh"]; ok { - flags |= key.FlagScienceMesh - } - - // Assign a new API key to the account through the account manager - if err := s.manager.AssignAPIKeyToAccount(account, flags); err != nil { - return nil, errors.Wrap(err, "unable to assign API key") - } - - return nil, nil -} - -func (s *svc) handleList(values url.Values, body []byte) (interface{}, error) { - return s.manager.CloneAccounts(), nil -} - -func (s *svc) handleFind(values url.Values, body []byte) (interface{}, error) { - account, err := s.findAccount(values.Get("by"), values.Get("value")) - if err != nil { - return nil, err - } - return map[string]interface{}{"account": account}, nil -} - -func (s *svc) handleCreate(values url.Values, body []byte) (interface{}, error) { - account, err := s.unmarshalRequestData(body) - if err != nil { - return nil, err - } - - // Create a new account through the account manager - if err := s.manager.CreateAccount(account); err != nil { - return nil, errors.Wrap(err, "unable to create account") - } - - return nil, nil -} - -func (s *svc) handleUpdate(values url.Values, body []byte) (interface{}, error) { - account, err := s.unmarshalRequestData(body) - if err != nil { - return nil, err - } - - // Update the account through the account manager; only the basic data of an account can be updated through this endpoint - if err := s.manager.UpdateAccount(account, false); err != nil { - return nil, errors.Wrap(err, "unable to update account") - } - - return nil, nil -} - -func (s *svc) handleRemove(values url.Values, body []byte) (interface{}, error) { - account, err := s.unmarshalRequestData(body) - if err != nil { - return nil, err - } - - // Remove the account through the account manager - if err := s.manager.RemoveAccount(account); err != nil { - return nil, errors.Wrap(err, "unable to remove account") - } - - return nil, nil -} - -func (s *svc) handleIsAuthorized(values url.Values, body []byte) (interface{}, error) { - account, err := s.findAccount(values.Get("by"), values.Get("value")) - if err != nil { - return nil, err - } - return account.Data.Authorized, nil -} - -func (s *svc) handleUnregisterSite(values url.Values, body []byte) (interface{}, error) { - account, err := s.unmarshalRequestData(body) - if err != nil { - return nil, err - } - - // Unregister the account's site through the account manager - if err := s.manager.UnregisterAccountSite(account); err != nil { - return nil, errors.Wrap(err, "unable to unregister the site of the given account") - } - - return nil, nil -} - -func (s *svc) handleAuthorize(values url.Values, body []byte) (interface{}, error) { - account, err := s.unmarshalRequestData(body) - if err != nil { - return nil, err - } - - if val := values.Get("status"); len(val) > 0 { - var authorize bool - switch strings.ToLower(val) { - case "true": - authorize = true - - case "false": - authorize = false - - default: - return nil, errors.Errorf("unsupported authorization status %v", val[0]) - } - - // Authorize the account through the account manager - if err := s.manager.AuthorizeAccount(account, authorize); err != nil { - return nil, errors.Wrap(err, "unable to (un)authorize account") - } - } else { - return nil, errors.Errorf("no authorization status provided") - } - - return nil, nil -} - -func (s *svc) unmarshalRequestData(body []byte) (*data.Account, error) { - account := &data.Account{} - if err := json.Unmarshal(body, account); err != nil { - return nil, errors.Wrap(err, "invalid account data") - } - return account, nil -} - -func (s *svc) findAccount(by string, value string) (*data.Account, error) { - if len(by) == 0 && len(value) == 0 { - return nil, errors.Errorf("missing search criteria") - } - - // Find the account using the account manager - account, err := s.manager.FindAccount(by, value) - if err != nil { - return nil, errors.Wrap(err, "user not found") - } - return account, nil + return s.siteacc.RequestHandler() } func parseConfig(m map[string]interface{}) (*config.Configuration, error) { @@ -362,6 +71,12 @@ func parseConfig(m map[string]interface{}) (*config.Configuration, error) { return nil, errors.Wrap(err, "error decoding configuration") } applyDefaultConfig(conf) + conf.Cleanup() + + if conf.Webserver.URL == "" { + return nil, errors.Errorf("no webserver URL specified") + } + return conf, nil } @@ -373,6 +88,11 @@ func applyDefaultConfig(conf *config.Configuration) { if conf.Storage.Driver == "" { conf.Storage.Driver = "file" } + + // Enforce a minimum session timeout of 1 minute (and default to 5 minutes) + if conf.Webserver.SessionTimeout < 60 { + conf.Webserver.SessionTimeout = 5 * 60 + } } // New returns a new Site Accounts service. @@ -383,8 +103,8 @@ func New(m map[string]interface{}, log *zerolog.Logger) (global.Service, error) return nil, err } - // Create the accounts manager instance - mngr, err := newManager(conf, log) + // Create the site accounts instance + siteacc, err := siteacc.New(conf, log) if err != nil { return nil, errors.Wrap(err, "error creating the site accounts service") } @@ -393,7 +113,7 @@ func New(m map[string]interface{}, log *zerolog.Logger) (global.Service, error) s := &svc{ conf: conf, log: log, - manager: mngr, + siteacc: siteacc, } return s, nil } diff --git a/pkg/mentix/connectors/gocdb/query.go b/pkg/mentix/connectors/gocdb/query.go index 9e597d5576..9ce86958c5 100755 --- a/pkg/mentix/connectors/gocdb/query.go +++ b/pkg/mentix/connectors/gocdb/query.go @@ -52,7 +52,6 @@ func QueryGOCDB(address string, method string, isPrivate bool, scope string, api return nil, fmt.Errorf("unable to generate the GOCDB URL: %v", err) } - fmt.Println(endpointURL.String()) data, err := network.ReadEndpoint(endpointURL, nil, true) if err != nil { return nil, fmt.Errorf("unable to read GOCDB endpoint: %v", err) diff --git a/pkg/mentix/utils/network/network.go b/pkg/mentix/utils/network/network.go index 6185ce8fde..23b8ca02ed 100644 --- a/pkg/mentix/utils/network/network.go +++ b/pkg/mentix/utils/network/network.go @@ -54,11 +54,13 @@ func GenerateURL(host string, path string, params URLParams) (*url.URL, error) { fullURL.Path = p.Join(fullURL.Path, path) - query := make(url.Values) - for key, value := range params { - query.Set(key, value) + if len(params) > 0 { + query := make(url.Values) + for key, value := range params { + query.Set(key, value) + } + fullURL.RawQuery = query.Encode() } - fullURL.RawQuery = query.Encode() return fullURL, nil } @@ -79,11 +81,12 @@ func queryEndpoint(method string, endpointURL *url.URL, auth *BasicAuth, checkSt if err != nil { return nil, fmt.Errorf("unable to get data from endpoint: %v", err) } + defer resp.Body.Close() + if checkStatus && resp.StatusCode >= 400 { return nil, fmt.Errorf("invalid response received: %v", resp.Status) } - defer resp.Body.Close() body, _ := ioutil.ReadAll(resp.Body) return body, nil } diff --git a/pkg/siteacc/account/contact/contact.go b/pkg/siteacc/account/contact/contact.go new file mode 100644 index 0000000000..038db6a5a8 --- /dev/null +++ b/pkg/siteacc/account/contact/contact.go @@ -0,0 +1,51 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package contact + +import "github.com/cs3org/reva/pkg/siteacc/html" + +// PanelTemplate is the content provider for the contact form. +type PanelTemplate struct { + html.ContentProvider +} + +// GetTitle returns the title of the panel. +func (template *PanelTemplate) GetTitle() string { + return "ScienceMesh Contact Form" +} + +// GetCaption returns the caption which is displayed on the panel. +func (template *PanelTemplate) GetCaption() string { + return "Contact the ScienceMesh administration" +} + +// GetContentJavaScript delivers additional JavaScript code. +func (template *PanelTemplate) GetContentJavaScript() string { + return tplJavaScript +} + +// GetContentStyleSheet delivers additional stylesheet code. +func (template *PanelTemplate) GetContentStyleSheet() string { + return tplStyleSheet +} + +// GetContentBody delivers the actual body content. +func (template *PanelTemplate) GetContentBody() string { + return tplBody +} diff --git a/pkg/siteacc/account/contact/template.go b/pkg/siteacc/account/contact/template.go new file mode 100644 index 0000000000..9a5b76e1be --- /dev/null +++ b/pkg/siteacc/account/contact/template.go @@ -0,0 +1,111 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package contact + +const tplJavaScript = ` +function verifyForm(formData) { + if (formData.getTrimmed("subject") == "") { + setState(STATE_ERROR, "Please enter a subject.", "form", "subject", true); + return false; + } + + if (formData.getTrimmed("message") == "") { + setState(STATE_ERROR, "Please enter a message.", "form", "message", true); + return false; + } + + return true; +} + +function handleAction(action) { + const formData = new FormData(document.querySelector("form")); + if (!verifyForm(formData)) { + return; + } + + setState(STATE_STATUS, "Sending message... this should only take a moment.", "form", null, false); + + var xhr = new XMLHttpRequest(); + xhr.open("POST", "{{getServerAddress}}/" + action); + xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); + + xhr.onload = function() { + if (this.status == 200) { + setState(STATE_SUCCESS, "Your message was successfully sent! A copy of the message has been sent to your email address."); + } else { + var resp = JSON.parse(this.responseText); + setState(STATE_ERROR, "An error occurred while trying to send your message:
" + resp.error + "", "form", null, true); + } + } + + var postData = { + "subject": formData.getTrimmed("subject"), + "message": formData.getTrimmed("message") + }; + + xhr.send(JSON.stringify(postData)); +} +` + +const tplStyleSheet = ` +html * { + font-family: arial !important; +} + +.mandatory { + color: red; + font-weight: bold; +} +` + +const tplBody = ` +
+

Contact the ScienceMesh administration using the form below.

+

Please include as much information as possible in your request, especially:

+ +
+
 
+
+
+
+
+ +
+
+ +
+ +
+ Fields marked with * are mandatory. +
+
+ + +
+
+
+
+

Go back to the main account page.

+
+` diff --git a/pkg/siteacc/account/edit/edit.go b/pkg/siteacc/account/edit/edit.go new file mode 100644 index 0000000000..a4d40f257c --- /dev/null +++ b/pkg/siteacc/account/edit/edit.go @@ -0,0 +1,51 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package edit + +import "github.com/cs3org/reva/pkg/siteacc/html" + +// PanelTemplate is the content provider for the edit form. +type PanelTemplate struct { + html.ContentProvider +} + +// GetTitle returns the title of the panel. +func (template *PanelTemplate) GetTitle() string { + return "ScienceMesh Account" +} + +// GetCaption returns the caption which is displayed on the panel. +func (template *PanelTemplate) GetCaption() string { + return "Edit your ScienceMesh Account!" +} + +// GetContentJavaScript delivers additional JavaScript code. +func (template *PanelTemplate) GetContentJavaScript() string { + return tplJavaScript +} + +// GetContentStyleSheet delivers additional stylesheet code. +func (template *PanelTemplate) GetContentStyleSheet() string { + return tplStyleSheet +} + +// GetContentBody delivers the actual body content. +func (template *PanelTemplate) GetContentBody() string { + return tplBody +} diff --git a/pkg/siteacc/account/edit/template.go b/pkg/siteacc/account/edit/template.go new file mode 100644 index 0000000000..9cde9f4009 --- /dev/null +++ b/pkg/siteacc/account/edit/template.go @@ -0,0 +1,170 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package edit + +const tplJavaScript = ` +function verifyForm(formData) { + if (formData.getTrimmed("fname") == "") { + setState(STATE_ERROR, "Please specify your first name.", "form", "fname", true); + return false; + } + + if (formData.getTrimmed("lname") == "") { + setState(STATE_ERROR, "Please specify your last name.", "form", "lname", true); + return false; + } + + if (formData.getTrimmed("organization") == "") { + setState(STATE_ERROR, "Please specify your organization/company.", "form", "organization", true); + return false; + } + + if (formData.getTrimmed("role") == "") { + setState(STATE_ERROR, "Please specify your role within the organization/company.", "form", "role", true); + return false; + } + + if (formData.get("password") != "") { + if (formData.get("password2") == "") { + setState(STATE_ERROR, "Please confirm your new password.", "form", "password2", true); + return false; + } + + if (formData.get("password") != formData.get("password2")) { + setState(STATE_ERROR, "The entered passwords do not match.", "form", "password2", true); + return false; + } + } + + return true; +} + +function handleAction(action) { + const formData = new FormData(document.querySelector("form")); + if (!verifyForm(formData)) { + return; + } + + setState(STATE_STATUS, "Updating account... this should only take a moment.", "form", null, false); + + var xhr = new XMLHttpRequest(); + xhr.open("POST", "{{getServerAddress}}/" + action); + xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); + + xhr.onload = function() { + if (this.status == 200) { + setState(STATE_SUCCESS, "Your account was successfully updated!", "form", null, true); + } else { + var resp = JSON.parse(this.responseText); + setState(STATE_ERROR, "An error occurred while trying to update your account:
" + resp.error + "", "form", null, true); + } + } + + var postData = { + "title": formData.getTrimmed("title"), + "firstName": formData.getTrimmed("fname"), + "lastName": formData.getTrimmed("lname"), + "organization": formData.getTrimmed("organization"), + "website": formData.getTrimmed("website"), + "role": formData.getTrimmed("role"), + "phoneNumber": formData.getTrimmed("phone"), + "password": { + "value": formData.get("password") + } + }; + + xhr.send(JSON.stringify(postData)); +} +` + +const tplStyleSheet = ` +html * { + font-family: arial !important; +} + +.mandatory { + color: red; + font-weight: bold; +} +` + +const tplBody = ` +
+

Edit your ScienceMesh account information below.

+

Please note that you cannot modify your email address using this form.

+
+
 
+
+
+
+
+ +
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
 
+ +
If you want to change your password, fill out the fields below. Otherwise, leave them empty to keep your current one.
+
+
+
+
+ +
+ The password must fulfil the following criteria: +
    +
  • Must be at least 8 characters long
  • +
  • Must contain at least 1 lowercase letter
  • +
  • Must contain at least 1 uppercase letter
  • +
  • Must contain at least 1 digit
  • +
+
+ +
+ Fields marked with * are mandatory. +
+
+ + +
+
+
+
+

Go back to the main account page.

+
+` diff --git a/pkg/siteacc/account/login/login.go b/pkg/siteacc/account/login/login.go new file mode 100644 index 0000000000..078a128b32 --- /dev/null +++ b/pkg/siteacc/account/login/login.go @@ -0,0 +1,51 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package login + +import "github.com/cs3org/reva/pkg/siteacc/html" + +// PanelTemplate is the content provider for the login form. +type PanelTemplate struct { + html.ContentProvider +} + +// GetTitle returns the title of the panel. +func (template *PanelTemplate) GetTitle() string { + return "ScienceMesh Account Login" +} + +// GetCaption returns the caption which is displayed on the panel. +func (template *PanelTemplate) GetCaption() string { + return "Login to your ScienceMesh Account!" +} + +// GetContentJavaScript delivers additional JavaScript code. +func (template *PanelTemplate) GetContentJavaScript() string { + return tplJavaScript +} + +// GetContentStyleSheet delivers additional stylesheet code. +func (template *PanelTemplate) GetContentStyleSheet() string { + return tplStyleSheet +} + +// GetContentBody delivers the actual body content. +func (template *PanelTemplate) GetContentBody() string { + return tplBody +} diff --git a/pkg/siteacc/account/login/template.go b/pkg/siteacc/account/login/template.go new file mode 100644 index 0000000000..501ff5609d --- /dev/null +++ b/pkg/siteacc/account/login/template.go @@ -0,0 +1,137 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package login + +const tplJavaScript = ` +function verifyForm(formData, requirePassword = true) { + if (formData.getTrimmed("email") == "") { + setState(STATE_ERROR, "Please enter your email address.", "form", "email", true); + return false; + } + + if (requirePassword) { + if (formData.get("password") == "") { + setState(STATE_ERROR, "Please enter your password.", "form", "password", true); + return false; + } + } + + return true; +} + +function handleAction(action) { + const formData = new FormData(document.querySelector("form")); + if (!verifyForm(formData)) { + return; + } + + setState(STATE_STATUS, "Logging in... this should only take a moment.", "form", null, false); + + var xhr = new XMLHttpRequest(); + xhr.open("POST", "{{getServerAddress}}/" + action); + xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); + + xhr.onload = function() { + if (this.status == 200) { + setState(STATE_SUCCESS, "Your login was successful! Redirecting..."); + window.location.replace("{{getServerAddress}}/account/?path=manage"); + } else { + var resp = JSON.parse(this.responseText); + setState(STATE_ERROR, "An error occurred while trying to login your account:
" + resp.error + "", "form", null, true); + } + } + + var postData = { + "email": formData.getTrimmed("email"), + "password": { + "value": formData.get("password") + } + }; + + xhr.send(JSON.stringify(postData)); +} + +function handleResetPassword() { + const formData = new FormData(document.querySelector("form")); + if (!verifyForm(formData, false)) { + return; + } + + setState(STATE_STATUS, "Resetting password... this should only take a moment.", "form", null, false); + + var xhr = new XMLHttpRequest(); + xhr.open("POST", "{{getServerAddress}}/reset-password"); + xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); + + xhr.onload = function() { + if (this.status == 200) { + setState(STATE_SUCCESS, "Your password was successfully reset! Please check your inbox for your new password.", "form", null, true); + } else { + var resp = JSON.parse(this.responseText); + setState(STATE_ERROR, "An error occurred while trying to reset your password:
" + resp.error + "", "form", null, true); + } + } + + var postData = { + "email": formData.get("email") + }; + + xhr.send(JSON.stringify(postData)); +} +` + +const tplStyleSheet = ` +html * { + font-family: arial !important; +} + +.mandatory { + color: red; + font-weight: bold; +} +` + +const tplBody = ` +
+

Login to your ScienceMesh account using the form below.

+
+
 
+
+
+
+
+
+
+
+ Forgot your password? Click here to reset it. +
+ +
+ Fields marked with * are mandatory. +
+
+ + +
+
+
+
+

Don't' have an account yet? Register here.

+
+` diff --git a/pkg/siteacc/account/manage/manage.go b/pkg/siteacc/account/manage/manage.go new file mode 100644 index 0000000000..eff0ff5af3 --- /dev/null +++ b/pkg/siteacc/account/manage/manage.go @@ -0,0 +1,51 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package manage + +import "github.com/cs3org/reva/pkg/siteacc/html" + +// PanelTemplate is the content provider for the mangement form. +type PanelTemplate struct { + html.ContentProvider +} + +// GetTitle returns the title of the panel. +func (template *PanelTemplate) GetTitle() string { + return "ScienceMesh Account" +} + +// GetCaption returns the caption which is displayed on the panel. +func (template *PanelTemplate) GetCaption() string { + return "Welcome to your ScienceMesh Account!" +} + +// GetContentJavaScript delivers additional JavaScript code. +func (template *PanelTemplate) GetContentJavaScript() string { + return tplJavaScript +} + +// GetContentStyleSheet delivers additional stylesheet code. +func (template *PanelTemplate) GetContentStyleSheet() string { + return tplStyleSheet +} + +// GetContentBody delivers the actual body content. +func (template *PanelTemplate) GetContentBody() string { + return tplBody +} diff --git a/pkg/siteacc/account/manage/template.go b/pkg/siteacc/account/manage/template.go new file mode 100644 index 0000000000..6fde24cc1e --- /dev/null +++ b/pkg/siteacc/account/manage/template.go @@ -0,0 +1,105 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package manage + +const tplJavaScript = ` +function handleEditAccount() { + setState(STATE_STATUS, "Redirecting to the account editor..."); + window.location.replace("{{getServerAddress}}/account/?path=edit"); +} + +function handleRequestAccess() { + setState(STATE_STATUS, "Redirecting to the contact form..."); + window.location.replace("{{getServerAddress}}/account/?path=contact&subject=" + encodeURIComponent("Request GOCDB access")); +} + +function handleRequestKey() { + setState(STATE_STATUS, "Redirecting to the contact form..."); + window.location.replace("{{getServerAddress}}/account/?path=contact&subject=" + encodeURIComponent("Request API key")); +} + +function handleLogout() { + var xhr = new XMLHttpRequest(); + xhr.open("GET", "{{getServerAddress}}/logout"); + xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); + + setState(STATE_STATUS, "Logging out..."); + + xhr.onload = function() { + if (this.status == 200) { + setState(STATE_SUCCESS, "Done! Redirecting..."); + window.location.replace("{{getServerAddress}}/account/?path=login"); + } else { + setState(STATE_ERROR, "An error occurred while logging out: " + this.responseText); + } + } + + xhr.send(); +} +` + +const tplStyleSheet = ` +html * { + font-family: arial !important; +} + +.apikey { + font-family: monospace !important; + background: antiquewhite; + border: 1px solid black; + padding: 2px; +} +` + +const tplBody = ` +
+

Hello {{.Account.FirstName}} {{.Account.LastName}},

+

On this page, you can manage your ScienceMesh user account. This includes editing your personal information, requesting an API key or access to the GOCDB and more.

+
+
 
+
+ Personal information: + +
+
+ Account data: + +
+
+
+ +   + + + + +
+
+` diff --git a/pkg/siteacc/account/panel.go b/pkg/siteacc/account/panel.go new file mode 100644 index 0000000000..5309783824 --- /dev/null +++ b/pkg/siteacc/account/panel.go @@ -0,0 +1,169 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package account + +import ( + "net/http" + "net/url" + "strings" + + "github.com/cs3org/reva/pkg/siteacc/account/contact" + "github.com/cs3org/reva/pkg/siteacc/account/edit" + "github.com/cs3org/reva/pkg/siteacc/account/login" + "github.com/cs3org/reva/pkg/siteacc/account/manage" + "github.com/cs3org/reva/pkg/siteacc/account/registration" + "github.com/cs3org/reva/pkg/siteacc/config" + "github.com/cs3org/reva/pkg/siteacc/data" + "github.com/cs3org/reva/pkg/siteacc/html" + "github.com/pkg/errors" + "github.com/rs/zerolog" +) + +// Panel represents the account panel. +type Panel struct { + html.PanelProvider + + htmlPanel *html.Panel +} + +const ( + templateLogin = "login" + templateManage = "manage" + templateEdit = "edit" + templateContact = "contact" + templateRegistration = "register" +) + +func (panel *Panel) initialize(conf *config.Configuration, log *zerolog.Logger) error { + // Create the internal HTML panel + htmlPanel, err := html.NewPanel("account-panel", panel, conf, log) + if err != nil { + return errors.Wrap(err, "unable to create the account panel") + } + panel.htmlPanel = htmlPanel + + // Add all templates + if err := panel.htmlPanel.AddTemplate(templateLogin, &login.PanelTemplate{}); err != nil { + return errors.Wrap(err, "unable to create the login template") + } + + if err := panel.htmlPanel.AddTemplate(templateManage, &manage.PanelTemplate{}); err != nil { + return errors.Wrap(err, "unable to create the account management template") + } + + if err := panel.htmlPanel.AddTemplate(templateEdit, &edit.PanelTemplate{}); err != nil { + return errors.Wrap(err, "unable to create the account editing template") + } + + if err := panel.htmlPanel.AddTemplate(templateContact, &contact.PanelTemplate{}); err != nil { + return errors.Wrap(err, "unable to create the contact template") + } + + if err := panel.htmlPanel.AddTemplate(templateRegistration, ®istration.PanelTemplate{}); err != nil { + return errors.Wrap(err, "unable to create the registration template") + } + + return nil +} + +// GetActiveTemplate returns the name of the active template. +func (panel *Panel) GetActiveTemplate(session *html.Session, path string) string { + validPaths := []string{templateLogin, templateManage, templateEdit, templateContact, templateRegistration} + template := templateLogin + + // Only allow valid template paths; redirect to the login page otherwise + for _, valid := range validPaths { + if valid == path { + template = path + break + } + } + + return template +} + +// PreExecute is called before the actual template is being executed. +func (panel *Panel) PreExecute(session *html.Session, path string, w http.ResponseWriter, r *http.Request) (html.ExecutionResult, error) { + protectedPaths := []string{templateManage, templateEdit, templateContact} + + if session.LoggedInUser == nil { + // If no user is logged in, redirect protected paths to the login page + for _, protected := range protectedPaths { + if protected == path { + return panel.redirect(templateLogin, w, r), nil + } + } + } else if path == templateLogin || path == templateRegistration { + // If a user is logged in and tries to login or register again, redirect to the main account page + return panel.redirect(templateManage, w, r), nil + } + + return html.ContinueExecution, nil +} + +// Execute generates the HTTP output of the form and writes it to the response writer. +func (panel *Panel) Execute(w http.ResponseWriter, r *http.Request, session *html.Session) error { + dataProvider := func(*html.Session) interface{} { + flatValues := make(map[string]string, len(r.URL.Query())) + for k, v := range r.URL.Query() { + flatValues[strings.Title(k)] = v[0] + } + + type TemplateData struct { + Account *data.Account + Params map[string]string + + Titles []string + } + + return TemplateData{ + Account: session.LoggedInUser, + Params: flatValues, + Titles: []string{"Mr", "Mrs", "Ms", "Prof", "Dr"}, + } + } + return panel.htmlPanel.Execute(w, r, session, dataProvider) +} + +func (panel *Panel) redirect(path string, w http.ResponseWriter, r *http.Request) html.ExecutionResult { + // Check if the original (full) URI path is stored in the request header; if not, use the request URI to get the path + fullPath := r.Header.Get("X-Replaced-Path") + if fullPath == "" { + uri, _ := url.Parse(r.RequestURI) + fullPath = uri.Path + } + + // Modify the original request URL by replacing the path parameter + newURL, _ := url.Parse(fullPath) + params := newURL.Query() + params.Del("path") + params.Add("path", path) + newURL.RawQuery = params.Encode() + http.Redirect(w, r, newURL.String(), http.StatusFound) + return html.AbortExecution +} + +// NewPanel creates a new account panel. +func NewPanel(conf *config.Configuration, log *zerolog.Logger) (*Panel, error) { + form := &Panel{} + if err := form.initialize(conf, log); err != nil { + return nil, errors.Wrapf(err, "unable to initialize the account panel") + } + return form, nil +} diff --git a/pkg/siteacc/account/registration/registration.go b/pkg/siteacc/account/registration/registration.go new file mode 100644 index 0000000000..cbc96dab57 --- /dev/null +++ b/pkg/siteacc/account/registration/registration.go @@ -0,0 +1,51 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package registration + +import "github.com/cs3org/reva/pkg/siteacc/html" + +// PanelTemplate is the content provider for the registration form. +type PanelTemplate struct { + html.ContentProvider +} + +// GetTitle returns the title of the panel. +func (template *PanelTemplate) GetTitle() string { + return "ScienceMesh Account Registration" +} + +// GetCaption returns the caption which is displayed on the panel. +func (template *PanelTemplate) GetCaption() string { + return "Welcome to the ScienceMesh Account Registration!" +} + +// GetContentJavaScript delivers additional JavaScript code. +func (template *PanelTemplate) GetContentJavaScript() string { + return tplJavaScript +} + +// GetContentStyleSheet delivers additional stylesheet code. +func (template *PanelTemplate) GetContentStyleSheet() string { + return tplStyleSheet +} + +// GetContentBody delivers the actual body content. +func (template *PanelTemplate) GetContentBody() string { + return tplBody +} diff --git a/pkg/siteacc/account/registration/template.go b/pkg/siteacc/account/registration/template.go new file mode 100644 index 0000000000..7bac7fbb0d --- /dev/null +++ b/pkg/siteacc/account/registration/template.go @@ -0,0 +1,184 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package registration + +const tplJavaScript = ` +function verifyForm(formData) { + if (formData.getTrimmed("email") == "") { + setState(STATE_ERROR, "Please specify your email address.", "form", "email", true); + return false; + } + + if (formData.getTrimmed("fname") == "") { + setState(STATE_ERROR, "Please specify your first name.", "form", "fname", true); + return false; + } + + if (formData.getTrimmed("lname") == "") { + setState(STATE_ERROR, "Please specify your last name.", "form", "lname", true); + return false; + } + + if (formData.getTrimmed("organization") == "") { + setState(STATE_ERROR, "Please specify your organization/company.", "form", "organization", true); + return false; + } + + if (formData.getTrimmed("role") == "") { + setState(STATE_ERROR, "Please specify your role within the organization/company.", "form", "role", true); + return false; + } + + if (formData.get("password") == "") { + setState(STATE_ERROR, "Please set a password.", "form", "password", true); + return false; + } + + if (formData.get("password2") == "") { + setState(STATE_ERROR, "Please confirm your password.", "form", "password2", true); + return false; + } + + if (formData.get("password") != formData.get("password2")) { + setState(STATE_ERROR, "The entered passwords do not match.", "form", "password2", true); + return false; + } + + return true; +} + +function handleAction(action) { + const formData = new FormData(document.querySelector("form")); + if (!verifyForm(formData)) { + return; + } + + setState(STATE_STATUS, "Sending registration... this should only take a moment.", "form", null, false); + + var xhr = new XMLHttpRequest(); + xhr.open("POST", "{{getServerAddress}}/" + action); + xhr.setRequestHeader('Content-Type', 'application/json; charset=UTF-8'); + + xhr.onload = function() { + if (this.status == 200) { + setState(STATE_SUCCESS, "Your registration was successful! Please check your inbox for a confirmation email. You will be redirected to the login page in a few seconds (if not, click here)."); + window.setTimeout(function() { + window.location.replace("{{getServerAddress}}/account/?path=login"); + }, 3000); + } else { + var resp = JSON.parse(this.responseText); + setState(STATE_ERROR, "An error occurred while trying to register your account:
" + resp.error + "", "form", null, true); + } + } + + var postData = { + "email": formData.getTrimmed("email"), + "title": formData.getTrimmed("title"), + "firstName": formData.getTrimmed("fname"), + "lastName": formData.getTrimmed("lname"), + "organization": formData.getTrimmed("organization"), + "website": formData.getTrimmed("website"), + "role": formData.getTrimmed("role"), + "phoneNumber": formData.getTrimmed("phone"), + "password": { + "value": formData.get("password") + } + }; + + xhr.send(JSON.stringify(postData)); +} +` + +const tplStyleSheet = ` +html * { + font-family: arial !important; +} + +.mandatory { + color: red; + font-weight: bold; +} +` + +const tplBody = ` +
+

Fill out the form below to register for a ScienceMesh account. A confirmation email will be sent to you shortly after registration.

+
+
 
+
+
+
+
+ +
 
+ +
+
+ +
+ +
+
+
+
+ +
+
+
+
+ +
+
+
+
+ +
 
+ +
+
+
+
+ +
+ The password must fulfil the following criteria: +
    +
  • Must be at least 8 characters long
  • +
  • Must contain at least 1 lowercase letter
  • +
  • Must contain at least 1 uppercase letter
  • +
  • Must contain at least 1 digit
  • +
+
+ +
+ Fields marked with * are mandatory. +
+
+ + +
+
+
+
+

Already have an account? Login here.

+
+` diff --git a/pkg/siteacc/admin/panel.go b/pkg/siteacc/admin/panel.go new file mode 100644 index 0000000000..7d274d8b76 --- /dev/null +++ b/pkg/siteacc/admin/panel.go @@ -0,0 +1,115 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package admin + +import ( + "net/http" + + "github.com/cs3org/reva/pkg/siteacc/config" + "github.com/cs3org/reva/pkg/siteacc/data" + "github.com/cs3org/reva/pkg/siteacc/html" + "github.com/pkg/errors" + "github.com/rs/zerolog" +) + +// Panel represents the web interface panel of the accounts service administration. +type Panel struct { + html.PanelProvider + html.ContentProvider + + htmlPanel *html.Panel +} + +const ( + templateMain = "main" +) + +func (panel *Panel) initialize(conf *config.Configuration, log *zerolog.Logger) error { + // Create the internal HTML panel + htmlPanel, err := html.NewPanel("admin-panel", panel, conf, log) + if err != nil { + return errors.Wrap(err, "unable to create the administration panel") + } + panel.htmlPanel = htmlPanel + + // Add all templates + if err := panel.htmlPanel.AddTemplate(templateMain, panel); err != nil { + return errors.Wrap(err, "unable to create the main template") + } + + return nil +} + +// GetActiveTemplate returns the name of the active template. +func (panel *Panel) GetActiveTemplate(*html.Session, string) string { + return templateMain +} + +// GetTitle returns the title of the htmlPanel. +func (panel *Panel) GetTitle() string { + return "ScienceMesh Administration Panel" +} + +// GetCaption returns the caption which is displayed on the htmlPanel. +func (panel *Panel) GetCaption() string { + return "Accounts ({{.Accounts | len}})" +} + +// GetContentJavaScript delivers additional JavaScript code. +func (panel *Panel) GetContentJavaScript() string { + return tplJavaScript +} + +// GetContentStyleSheet delivers additional stylesheet code. +func (panel *Panel) GetContentStyleSheet() string { + return tplStyleSheet +} + +// GetContentBody delivers the actual body content. +func (panel *Panel) GetContentBody() string { + return tplBody +} + +// PreExecute is called before the actual template is being executed. +func (panel *Panel) PreExecute(*html.Session, string, http.ResponseWriter, *http.Request) (html.ExecutionResult, error) { + return html.ContinueExecution, nil +} + +// Execute generates the HTTP output of the htmlPanel and writes it to the response writer. +func (panel *Panel) Execute(w http.ResponseWriter, r *http.Request, session *html.Session, accounts *data.Accounts) error { + dataProvider := func(*html.Session) interface{} { + type TemplateData struct { + Accounts *data.Accounts + } + + return TemplateData{ + Accounts: accounts, + } + } + return panel.htmlPanel.Execute(w, r, session, dataProvider) +} + +// NewPanel creates a new administration panel. +func NewPanel(conf *config.Configuration, log *zerolog.Logger) (*Panel, error) { + panel := &Panel{} + if err := panel.initialize(conf, log); err != nil { + return nil, errors.Wrapf(err, "unable to initialize the administration panel") + } + return panel, nil +} diff --git a/internal/http/services/siteacc/panel/template.go b/pkg/siteacc/admin/template.go similarity index 50% rename from internal/http/services/siteacc/panel/template.go rename to pkg/siteacc/admin/template.go index df3fa7636f..cbe5e4354b 100644 --- a/internal/http/services/siteacc/panel/template.go +++ b/pkg/siteacc/admin/template.go @@ -16,77 +16,78 @@ // granted to it by virtue of its status as an Intergovernmental Organization // or submit itself to any jurisdiction. -package panel +package admin -const panelTemplate = ` - - - - - - Accounts panel - - + } + + var postData = { + "email": email, + }; + + xhr.send(JSON.stringify(postData)); +} +` + +const tplStyleSheet = ` +html * { + font-family: monospace !important; +} +` -

Accounts ({{.Accounts | len}})

-

+const tplBody = ` +

-

- - - +
` diff --git a/pkg/siteacc/config/config.go b/pkg/siteacc/config/config.go new file mode 100644 index 0000000000..639e74c893 --- /dev/null +++ b/pkg/siteacc/config/config.go @@ -0,0 +1,80 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package config + +import ( + "strings" + + "github.com/cs3org/reva/pkg/smtpclient" +) + +// Configuration holds the general service configuration. +type Configuration struct { + Prefix string `mapstructure:"prefix"` + + Storage struct { + Driver string `mapstructure:"driver"` + + File struct { + File string `mapstructure:"file"` + } `mapstructure:"file"` + } `mapstructure:"storage"` + + Email struct { + SMTP *smtpclient.SMTPCredentials `mapstructure:"smtp"` + NotificationsMail string `mapstructure:"notifications_mail"` + } `mapstructure:"email"` + + SiteRegistration struct { + URL string `mapstructure:"url"` + } `mapstructure:"sitereg"` + + Webserver struct { + URL string `mapstructure:"url"` + + SessionTimeout int `mapstructure:"session_timeout"` + VerifyRemoteAddress bool `mapstructure:"verify_remote_address"` + LogSessions bool `mapstructure:"log_sessions"` + } `mapstructure:"webserver"` + + GOCDB struct { + URL string `mapstructure:"url"` + WriteURL string `mapstructure:"write_url"` + + APIKey string `mapstructure:"apikey"` + } `mapstructure:"gocdb"` +} + +// Cleanup cleans up certain settings, normalizing them. +func (cfg *Configuration) Cleanup() { + // Ensure the webserver URL ends with a slash + if cfg.Webserver.URL != "" && !strings.HasSuffix(cfg.Webserver.URL, "/") { + cfg.Webserver.URL += "/" + } + + // Ensure the GOCDB URL ends with a slash + if cfg.GOCDB.URL != "" && !strings.HasSuffix(cfg.GOCDB.URL, "/") { + cfg.GOCDB.URL += "/" + } + + // Ensure the GOCDB Write URL ends with a slash + if cfg.GOCDB.WriteURL != "" && !strings.HasSuffix(cfg.GOCDB.WriteURL, "/") { + cfg.GOCDB.WriteURL += "/" + } +} diff --git a/internal/http/services/siteacc/config/endpoints.go b/pkg/siteacc/config/endpoints.go similarity index 68% rename from internal/http/services/siteacc/config/endpoints.go rename to pkg/siteacc/config/endpoints.go index 8042085e61..feef521aab 100644 --- a/internal/http/services/siteacc/config/endpoints.go +++ b/pkg/siteacc/config/endpoints.go @@ -19,10 +19,10 @@ package config const ( - // EndpointPanel is the endpoint path of the web interface panel. - EndpointPanel = "/panel" - // EndpointRegistration is the endpoint path of the web interface registration form. - EndpointRegistration = "/register" + // EndpointAdministration is the endpoint path of the web interface administration panel. + EndpointAdministration = "/admin" + // EndpointAccount is the endpoint path of the web interface account panel. + EndpointAccount = "/account" // EndpointGenerateAPIKey is the endpoint path of the API key generator. EndpointGenerateAPIKey = "/generate-api-key" @@ -43,10 +43,26 @@ const ( // EndpointRemove is the endpoint path for account removal. EndpointRemove = "/remove" + // EndpointLogin is the endpoint path for (internal) user login. + EndpointLogin = "/login" + // EndpointLogout is the endpoint path for (internal) user logout. + EndpointLogout = "/logout" + // EndpointResetPassword is the endpoint path for resetting user passwords + EndpointResetPassword = "/reset-password" + // EndpointContact is the endpoint path for sending contact emails + EndpointContact = "/contact" + + // EndpointVerifyUserToken is the endpoint path for user token validation. + EndpointVerifyUserToken = "/verify-user-token" + // EndpointAuthorize is the endpoint path for account authorization. EndpointAuthorize = "/authorize" // EndpointIsAuthorized is the endpoint path used to check the authorization status of an account. EndpointIsAuthorized = "/is-authorized" + + // EndpointGrantGOCDBAccess is the endpoint path for granting or revoking GOCDB access. + EndpointGrantGOCDBAccess = "/grant-gocdb-access" + // EndpointUnregisterSite is the endpoint path for site unregistration. EndpointUnregisterSite = "/unregister-site" ) diff --git a/pkg/siteacc/data/account.go b/pkg/siteacc/data/account.go new file mode 100644 index 0000000000..67cd5336a1 --- /dev/null +++ b/pkg/siteacc/data/account.go @@ -0,0 +1,225 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this filePath except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package data + +import ( + "strings" + "time" + + "github.com/cs3org/reva/pkg/siteacc/password" + "github.com/pkg/errors" + + "github.com/cs3org/reva/pkg/mentix/key" + "github.com/cs3org/reva/pkg/utils" +) + +// Account represents a single site account. +type Account struct { + Email string `json:"email"` + Title string `json:"title"` + FirstName string `json:"firstName"` + LastName string `json:"lastName"` + Organization string `json:"organization"` + Website string `json:"website"` + Role string `json:"role"` + PhoneNumber string `json:"phoneNumber"` + + Password password.Password `json:"password"` + + DateCreated time.Time `json:"dateCreated"` + DateModified time.Time `json:"dateModified"` + + Data AccountData `json:"data"` +} + +// AccountData holds additional data for a site account. +type AccountData struct { + APIKey key.APIKey `json:"apiKey"` + GOCDBAccess bool `json:"gocdbAccess"` + Authorized bool `json:"authorized"` +} + +// Accounts holds an array of site accounts. +type Accounts = []*Account + +// GetSiteID returns the site ID (generated from the API key) for the given account. +func (acc *Account) GetSiteID() key.SiteIdentifier { + if id, err := key.CalculateSiteID(acc.Data.APIKey, key.SaltFromEmail(acc.Email)); err == nil { + return id + } + + return "" +} + +// Update copies the data of the given account to this account. +func (acc *Account) Update(other *Account, setPassword bool, copyData bool) error { + if err := other.verify(false); err != nil { + return errors.Wrap(err, "unable to update account data") + } + + // Manually update fields + acc.Title = other.Title + acc.FirstName = other.FirstName + acc.LastName = other.LastName + acc.Organization = other.Organization + acc.Website = other.Website + acc.Role = other.Role + acc.PhoneNumber = other.PhoneNumber + + if setPassword && other.Password.Value != "" { + // If a password was provided, use that as the new one + if err := acc.UpdatePassword(other.Password.Value); err != nil { + return errors.Wrap(err, "unable to update account data") + } + } + + if copyData { + acc.Data = other.Data + } + + return nil +} + +// UpdatePassword assigns a new password to the account, hashing it first. +func (acc *Account) UpdatePassword(pwd string) error { + if err := acc.Password.Set(pwd); err != nil { + return errors.Wrap(err, "unable to update the user password") + } + return nil +} + +// Clone creates a copy of the account; if erasePassword is set to true, the password will be cleared in the cloned object. +func (acc *Account) Clone(erasePassword bool) *Account { + clone := *acc + + if erasePassword { + clone.Password.Clear() + } + + return &clone +} + +// CheckScopeAccess checks whether the user can access the specified scope. +func (acc *Account) CheckScopeAccess(scope string) bool { + hasAccess := false + + switch strings.ToLower(scope) { + case ScopeDefault: + hasAccess = true + + case ScopeGOCDB: + hasAccess = acc.Data.GOCDBAccess + } + + return hasAccess +} + +// Cleanup trims all string entries. +func (acc *Account) Cleanup() { + acc.Email = strings.TrimSpace(acc.Email) + acc.Title = strings.TrimSpace(acc.Title) + acc.FirstName = strings.TrimSpace(acc.FirstName) + acc.LastName = strings.TrimSpace(acc.LastName) + acc.Organization = strings.TrimSpace(acc.Organization) + acc.Website = strings.TrimSpace(acc.Website) + acc.Role = strings.TrimSpace(acc.Role) + acc.PhoneNumber = strings.TrimSpace(acc.PhoneNumber) +} + +func (acc *Account) verify(verifyPassword bool) error { + if acc.Email == "" { + return errors.Errorf("no email address provided") + } else if !utils.IsEmailValid(acc.Email) { + return errors.Errorf("invalid email address: %v", acc.Email) + } + + if acc.FirstName == "" { + return errors.Errorf("no first name provided") + } else if !utils.IsValidName(acc.FirstName) { + return errors.Errorf("first name contains invalid characters: %v", acc.FirstName) + } + + if acc.LastName == "" { + return errors.Errorf("no last name provided") + } else if !utils.IsValidName(acc.LastName) { + return errors.Errorf("last name contains invalid characters: %v", acc.LastName) + } + + if acc.Organization == "" { + return errors.Errorf("no organization provided") + } else if !utils.IsValidName(acc.Organization) { + return errors.Errorf("organization contains invalid characters: %v", acc.Organization) + } + + if acc.Website != "" && !utils.IsValidWebAddress(acc.Website) { + return errors.Errorf("invalid website provided") + } + + if acc.Role == "" { + return errors.Errorf("no role provided") + } else if !utils.IsValidName(acc.Role) { + return errors.Errorf("role contains invalid characters: %v", acc.Role) + } + + if acc.PhoneNumber != "" && !utils.IsValidPhoneNumber(acc.PhoneNumber) { + return errors.Errorf("invalid phone number provided") + } + + if verifyPassword { + if !acc.Password.IsValid() { + return errors.Errorf("no valid password set") + } + } + + return nil +} + +// NewAccount creates a new site account. +func NewAccount(email string, title, firstName, lastName string, organization, website string, role string, phoneNumber string, password string) (*Account, error) { + t := time.Now() + + acc := &Account{ + Email: email, + Title: title, + FirstName: firstName, + LastName: lastName, + Organization: organization, + Website: website, + Role: role, + PhoneNumber: phoneNumber, + DateCreated: t, + DateModified: t, + Data: AccountData{ + APIKey: "", + GOCDBAccess: false, + Authorized: false, + }, + } + + // Set the user password, which also makes sure that the given password is strong enough + if err := acc.UpdatePassword(password); err != nil { + return nil, err + } + + if err := acc.verify(true); err != nil { + return nil, err + } + + return acc, nil +} diff --git a/internal/http/services/siteacc/data/filestorage.go b/pkg/siteacc/data/filestorage.go similarity index 98% rename from internal/http/services/siteacc/data/filestorage.go rename to pkg/siteacc/data/filestorage.go index 22efb99b3e..e7413ac5f6 100644 --- a/internal/http/services/siteacc/data/filestorage.go +++ b/pkg/siteacc/data/filestorage.go @@ -24,10 +24,9 @@ import ( "os" "path/filepath" + "github.com/cs3org/reva/pkg/siteacc/config" "github.com/pkg/errors" "github.com/rs/zerolog" - - "github.com/cs3org/reva/internal/http/services/siteacc/config" ) // FileStorage implements a filePath-based storage. diff --git a/internal/http/services/siteacc/email/template.go b/pkg/siteacc/data/scopes.go similarity index 53% rename from internal/http/services/siteacc/email/template.go rename to pkg/siteacc/data/scopes.go index 25df085bec..f515fb68f1 100644 --- a/internal/http/services/siteacc/email/template.go +++ b/pkg/siteacc/data/scopes.go @@ -16,36 +16,11 @@ // granted to it by virtue of its status as an Intergovernmental Organization // or submit itself to any jurisdiction. -package email - -const accountCreatedTemplate = ` -Dear {{.FirstName}} {{.LastName}}, - -Your ScienceMesh account has been successfully created! - -An administrator will soon create an API key for your account; you will receive a separate email containing the key. - -Kind regards, -The ScienceMesh Team -` - -const apiKeyAssignedTemplate = ` -Dear {{.FirstName}} {{.LastName}}, - -An API key has been created for your account: -{{.Data.APIKey}} - -Keep this key in a safe and secret place! - -Kind regards, -The ScienceMesh Team -` - -const accountAuthorizedTemplate = ` -Dear {{.FirstName}} {{.LastName}}, - -Congratulations - your site registration has been authorized! - -Kind regards, -The ScienceMesh Team -` +package data + +const ( + // ScopeDefault is the default account panel scope. + ScopeDefault = "" + // ScopeGOCDB is used to access the GOCDB. + ScopeGOCDB = "gocdb" +) diff --git a/internal/http/services/siteacc/data/storage.go b/pkg/siteacc/data/storage.go similarity index 100% rename from internal/http/services/siteacc/data/storage.go rename to pkg/siteacc/data/storage.go diff --git a/internal/http/services/siteacc/email/email.go b/pkg/siteacc/email/email.go similarity index 52% rename from internal/http/services/siteacc/email/email.go rename to pkg/siteacc/email/email.go index 72e15f3c66..a8ef373b8b 100644 --- a/internal/http/services/siteacc/email/email.go +++ b/pkg/siteacc/email/email.go @@ -22,25 +22,61 @@ import ( "bytes" "text/template" - "github.com/pkg/errors" - - "github.com/cs3org/reva/internal/http/services/siteacc/data" + "github.com/cs3org/reva/pkg/siteacc/config" + "github.com/cs3org/reva/pkg/siteacc/data" "github.com/cs3org/reva/pkg/smtpclient" + "github.com/pkg/errors" ) +type emailData struct { + Account *data.Account + + AccountsAddress string + GOCDBAddress string + + Params map[string]string +} + +// SendFunction is the definition of email send functions. +type SendFunction = func(*data.Account, []string, map[string]string, config.Configuration) error + +func getEmailData(account *data.Account, conf config.Configuration, params map[string]string) *emailData { + return &emailData{ + Account: account, + AccountsAddress: conf.Webserver.URL, + GOCDBAddress: conf.GOCDB.URL, + Params: params, + } +} + // SendAccountCreated sends an email about account creation. -func SendAccountCreated(account *data.Account, recipients []string, smtp *smtpclient.SMTPCredentials) error { - return send(recipients, "ScienceMesh: Site account created", accountCreatedTemplate, account, smtp) +func SendAccountCreated(account *data.Account, recipients []string, params map[string]string, conf config.Configuration) error { + return send(recipients, "ScienceMesh: Site account created", accountCreatedTemplate, getEmailData(account, conf, params), conf.Email.SMTP) } // SendAPIKeyAssigned sends an email about API key assignment. -func SendAPIKeyAssigned(account *data.Account, recipients []string, smtp *smtpclient.SMTPCredentials) error { - return send(recipients, "ScienceMesh: Your API key", apiKeyAssignedTemplate, account, smtp) +func SendAPIKeyAssigned(account *data.Account, recipients []string, params map[string]string, conf config.Configuration) error { + return send(recipients, "ScienceMesh: Your API key", apiKeyAssignedTemplate, getEmailData(account, conf, params), conf.Email.SMTP) } // SendAccountAuthorized sends an email about account authorization. -func SendAccountAuthorized(account *data.Account, recipients []string, smtp *smtpclient.SMTPCredentials) error { - return send(recipients, "ScienceMesh: Site registration authorized", accountAuthorizedTemplate, account, smtp) +func SendAccountAuthorized(account *data.Account, recipients []string, params map[string]string, conf config.Configuration) error { + return send(recipients, "ScienceMesh: Site registration authorized", accountAuthorizedTemplate, getEmailData(account, conf, params), conf.Email.SMTP) +} + +// SendGOCDBAccessGranted sends an email about granted GOCDB access. +func SendGOCDBAccessGranted(account *data.Account, recipients []string, params map[string]string, conf config.Configuration) error { + return send(recipients, "ScienceMesh: GOCDB access granted", gocdbAccessGrantedTemplate, getEmailData(account, conf, params), conf.Email.SMTP) +} + +// SendPasswordReset sends an email containing the user's new password. +func SendPasswordReset(account *data.Account, recipients []string, params map[string]string, conf config.Configuration) error { + return send(recipients, "ScienceMesh: Password reset", passwordResetTemplate, getEmailData(account, conf, params), conf.Email.SMTP) +} + +// SendContactForm sends a generic contact form to the ScienceMesh admins. +func SendContactForm(account *data.Account, recipients []string, params map[string]string, conf config.Configuration) error { + return send(recipients, "ScienceMesh: Contact form", contactFormTemplate, getEmailData(account, conf, params), conf.Email.SMTP) } func send(recipients []string, subject string, bodyTemplate string, data interface{}, smtp *smtpclient.SMTPCredentials) error { diff --git a/pkg/siteacc/email/template.go b/pkg/siteacc/email/template.go new file mode 100644 index 0000000000..3992a56052 --- /dev/null +++ b/pkg/siteacc/email/template.go @@ -0,0 +1,89 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package email + +const accountCreatedTemplate = ` +Dear {{.Account.FirstName}} {{.Account.LastName}}, + +Your ScienceMesh account has been successfully created! + +Log in to your account by visiting the user account panel: +{{.AccountsAddress}} + +Using this panel, you can modify your information, request an API key or access to the GOCDB, and more. + +Kind regards, +The ScienceMesh Team +` + +const apiKeyAssignedTemplate = ` +Dear {{.Account.FirstName}} {{.Account.LastName}}, + +An API key has been created for your account! + +To view your new API key, log in to your user account panel: +{{.AccountsAddress}} + +Your key will appear on the front page once logged in. + +Kind regards, +The ScienceMesh Team +` + +const accountAuthorizedTemplate = ` +Dear {{.Account.FirstName}} {{.Account.LastName}}, + +Congratulations - your site registration has been authorized! + +Kind regards, +The ScienceMesh Team +` + +const gocdbAccessGrantedTemplate = ` +Dear {{.Account.FirstName}} {{.Account.LastName}}, + +You have been granted access to the ScienceMesh GOCDB instance: +{{.GOCDBAddress}} + +Simply use your regular ScienceMesh account credentials to log in to the GOCDB. + +Kind regards, +The ScienceMesh Team +` + +const passwordResetTemplate = ` +Dear {{.Account.FirstName}} {{.Account.LastName}}, + +Your password has been successfully reset! +Your new password is: {{.Account.Password.Value}} + +We recommend to change this password immediately after logging in. + +Kind regards, +The ScienceMesh Team +` + +const contactFormTemplate = ` +{{.Account.FirstName}} {{.Account.LastName}} ({{.Account.Email}}) has sent the following message: + +{{.Params.Subject}} +--------------------------------------------------------------------------------------------------- + +{{.Params.Message}} +` diff --git a/pkg/siteacc/endpoints.go b/pkg/siteacc/endpoints.go new file mode 100644 index 0000000000..9a09f7efe2 --- /dev/null +++ b/pkg/siteacc/endpoints.go @@ -0,0 +1,475 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package siteacc + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" + + "github.com/cs3org/reva/pkg/mentix/key" + "github.com/cs3org/reva/pkg/siteacc/config" + "github.com/cs3org/reva/pkg/siteacc/data" + "github.com/cs3org/reva/pkg/siteacc/html" + "github.com/cs3org/reva/pkg/siteacc/manager" + "github.com/pkg/errors" +) + +const ( + invokerDefault = "" + invokerUser = "user" +) + +type methodCallback = func(*SiteAccounts, url.Values, []byte, *html.Session) (interface{}, error) + +type endpoint struct { + Path string + Handler func(*SiteAccounts, endpoint, http.ResponseWriter, *http.Request, *html.Session) + MethodCallbacks map[string]methodCallback + IsPublic bool +} + +func createMethodCallbacks(cbGet methodCallback, cbPost methodCallback) map[string]methodCallback { + callbacks := make(map[string]methodCallback) + + if cbGet != nil { + callbacks[http.MethodGet] = cbGet + } + + if cbPost != nil { + callbacks[http.MethodPost] = cbPost + } + + return callbacks +} + +func getEndpoints() []endpoint { + endpoints := []endpoint{ + // Form/panel endpoints + {config.EndpointAdministration, callAdministrationEndpoint, nil, false}, + {config.EndpointAccount, callAccountEndpoint, nil, true}, + // API key endpoints + {config.EndpointGenerateAPIKey, callMethodEndpoint, createMethodCallbacks(handleGenerateAPIKey, nil), false}, + {config.EndpointVerifyAPIKey, callMethodEndpoint, createMethodCallbacks(handleVerifyAPIKey, nil), false}, + {config.EndpointAssignAPIKey, callMethodEndpoint, createMethodCallbacks(nil, handleAssignAPIKey), false}, + // General account endpoints + {config.EndpointList, callMethodEndpoint, createMethodCallbacks(handleList, nil), false}, + {config.EndpointFind, callMethodEndpoint, createMethodCallbacks(handleFind, nil), false}, + {config.EndpointCreate, callMethodEndpoint, createMethodCallbacks(nil, handleCreate), true}, + {config.EndpointUpdate, callMethodEndpoint, createMethodCallbacks(nil, handleUpdate), true}, + {config.EndpointRemove, callMethodEndpoint, createMethodCallbacks(nil, handleRemove), false}, + // Login endpoints + {config.EndpointLogin, callMethodEndpoint, createMethodCallbacks(nil, handleLogin), true}, + {config.EndpointLogout, callMethodEndpoint, createMethodCallbacks(handleLogout, nil), true}, + {config.EndpointResetPassword, callMethodEndpoint, createMethodCallbacks(nil, handleResetPassword), true}, + {config.EndpointContact, callMethodEndpoint, createMethodCallbacks(nil, handleContact), true}, + // Authentication endpoints + {config.EndpointVerifyUserToken, callMethodEndpoint, createMethodCallbacks(handleVerifyUserToken, nil), true}, + // Authorization endpoints + {config.EndpointAuthorize, callMethodEndpoint, createMethodCallbacks(nil, handleAuthorize), false}, + {config.EndpointIsAuthorized, callMethodEndpoint, createMethodCallbacks(handleIsAuthorized, nil), false}, + // Access management endpoints + {config.EndpointGrantGOCDBAccess, callMethodEndpoint, createMethodCallbacks(nil, handleGrantGOCDBAccess), false}, + // Account site endpoints + {config.EndpointUnregisterSite, callMethodEndpoint, createMethodCallbacks(nil, handleUnregisterSite), false}, + } + + return endpoints +} + +func callAdministrationEndpoint(siteacc *SiteAccounts, ep endpoint, w http.ResponseWriter, r *http.Request, session *html.Session) { + if err := siteacc.ShowAdministrationPanel(w, r, session); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(fmt.Sprintf("Unable to show the administration panel: %v", err))) + } +} + +func callAccountEndpoint(siteacc *SiteAccounts, ep endpoint, w http.ResponseWriter, r *http.Request, session *html.Session) { + if err := siteacc.ShowAccountPanel(w, r, session); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(fmt.Sprintf("Unable to show the account panel: %v", err))) + } +} + +func callMethodEndpoint(siteacc *SiteAccounts, ep endpoint, w http.ResponseWriter, r *http.Request, session *html.Session) { + // Every request to the accounts service results in a standardized JSON response + type Response struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Data interface{} `json:"data,omitempty"` + } + + // The default response is an unknown requestHandler (for the specified method) + resp := Response{ + Success: false, + Error: fmt.Sprintf("unknown endpoint %v for method %v", r.URL.Path, r.Method), + Data: nil, + } + + if ep.MethodCallbacks != nil { + // Search for a matching method in the list of callbacks + for method, cb := range ep.MethodCallbacks { + if method == r.Method { + body, _ := ioutil.ReadAll(r.Body) + + if respData, err := cb(siteacc, r.URL.Query(), body, session); err == nil { + resp.Success = true + resp.Error = "" + resp.Data = respData + } else { + resp.Success = false + resp.Error = fmt.Sprintf("%v", err) + resp.Data = nil + } + } + } + } + + // Any failure during query handling results in a bad request + if !resp.Success { + w.WriteHeader(http.StatusBadRequest) + } + + // Responses here are always JSON + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + + jsonData, _ := json.MarshalIndent(&resp, "", "\t") + _, _ = w.Write(jsonData) +} + +func handleGenerateAPIKey(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + email := values.Get("email") + flags := key.FlagDefault + + if strings.EqualFold(values.Get("isScienceMesh"), "true") { + flags |= key.FlagScienceMesh + } + + if len(email) == 0 { + return nil, errors.Errorf("no email provided") + } + + apiKey, err := key.GenerateAPIKey(key.SaltFromEmail(email), flags) + if err != nil { + return nil, errors.Wrap(err, "unable to generate API key") + } + return map[string]string{"apiKey": apiKey}, nil +} + +func handleVerifyAPIKey(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + apiKey := values.Get("apiKey") + email := values.Get("email") + + if len(apiKey) == 0 { + return nil, errors.Errorf("no API key provided") + } + + if len(email) == 0 { + return nil, errors.Errorf("no email provided") + } + + err := key.VerifyAPIKey(apiKey, key.SaltFromEmail(email)) + if err != nil { + return nil, errors.Wrap(err, "invalid API key") + } + return nil, nil +} + +func handleAssignAPIKey(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + account, err := unmarshalRequestData(body) + if err != nil { + return nil, err + } + + flags := key.FlagDefault + if _, ok := values["isScienceMesh"]; ok { + flags |= key.FlagScienceMesh + } + + // Assign a new API key to the account through the accounts manager + if err := siteacc.AccountsManager().AssignAPIKeyToAccount(account, flags); err != nil { + return nil, errors.Wrap(err, "unable to assign API key") + } + + return nil, nil +} + +func handleList(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + return siteacc.AccountsManager().CloneAccounts(true), nil +} + +func handleFind(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + account, err := findAccount(siteacc, values.Get("by"), values.Get("value")) + if err != nil { + return nil, err + } + return map[string]interface{}{"account": account.Clone(true)}, nil +} + +func handleCreate(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + account, err := unmarshalRequestData(body) + if err != nil { + return nil, err + } + + // Create a new account through the accounts manager + if err := siteacc.AccountsManager().CreateAccount(account); err != nil { + return nil, errors.Wrap(err, "unable to create account") + } + + return nil, nil +} + +func handleUpdate(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + account, err := unmarshalRequestData(body) + if err != nil { + return nil, err + } + + setPassword := false + + switch strings.ToLower(values.Get("invoker")) { + case invokerDefault: + // If the endpoint was called through the API, an API key must be provided identifying the account + apiKey := values.Get("apikey") + if apiKey == "" { + return nil, errors.Errorf("no API key provided") + } + + accountFound, err := findAccount(siteacc, manager.FindByAPIKey, apiKey) + if err != nil { + return nil, errors.Wrap(err, "no account for the specified API key found") + } + account.Email = accountFound.Email + + case invokerUser: + // If this endpoint was called by the user, set the account email from the stored session + if session.LoggedInUser == nil { + return nil, errors.Errorf("no user is currently logged in") + } + + account.Email = session.LoggedInUser.Email + setPassword = true + + default: + return nil, errors.Errorf("no invoker provided") + } + + // Update the account through the accounts manager + if err := siteacc.AccountsManager().UpdateAccount(account, setPassword, false); err != nil { + return nil, errors.Wrap(err, "unable to update account") + } + + return nil, nil +} + +func handleRemove(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + account, err := unmarshalRequestData(body) + if err != nil { + return nil, err + } + + // Remove the account through the accounts manager + if err := siteacc.AccountsManager().RemoveAccount(account); err != nil { + return nil, errors.Wrap(err, "unable to remove account") + } + + return nil, nil +} + +func handleIsAuthorized(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + account, err := findAccount(siteacc, values.Get("by"), values.Get("value")) + if err != nil { + return nil, err + } + return account.Data.Authorized, nil +} + +func handleUnregisterSite(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + account, err := unmarshalRequestData(body) + if err != nil { + return nil, err + } + + // Unregister the account's site through the accounts manager + if err := siteacc.AccountsManager().UnregisterAccountSite(account); err != nil { + return nil, errors.Wrap(err, "unable to unregister the site of the given account") + } + + return nil, nil +} + +func handleLogin(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + account, err := unmarshalRequestData(body) + if err != nil { + return nil, err + } + + // Login the user through the users manager + token, err := siteacc.UsersManager().LoginUser(account.Email, account.Password.Value, values.Get("scope"), session) + if err != nil { + return nil, errors.Wrap(err, "unable to login user") + } + + return token, nil +} + +func handleLogout(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + // Logout the user through the users manager + siteacc.UsersManager().LogoutUser(session) + return nil, nil +} + +func handleResetPassword(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + account, err := unmarshalRequestData(body) + if err != nil { + return nil, err + } + + // Reset the password through the users manager + if err := siteacc.AccountsManager().ResetPassword(account.Email); err != nil { + return nil, errors.Wrap(err, "unable to reset password") + } + + return nil, nil +} + +func handleContact(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + if session.LoggedInUser == nil { + return nil, errors.Errorf("no user is currently logged in") + } + + type jsonData struct { + Subject string `json:"subject"` + Message string `json:"message"` + } + contactData := &jsonData{} + if err := json.Unmarshal(body, contactData); err != nil { + return nil, errors.Wrap(err, "invalid form data") + } + + // Send an email through the accounts manager + siteacc.AccountsManager().SendContactForm(session.LoggedInUser, strings.TrimSpace(contactData.Subject), strings.TrimSpace(contactData.Message)) + return nil, nil +} + +func handleVerifyUserToken(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + token := values.Get("token") + if token == "" { + return nil, errors.Errorf("no token specified") + } + + user := values.Get("user") + if user == "" { + return nil, errors.Errorf("no user specified") + } + + // Verify the user token using the users manager + newToken, err := siteacc.UsersManager().VerifyUserToken(token, user, values.Get("scope")) + if err != nil { + return nil, errors.Wrap(err, "token verification failed") + } + + return newToken, nil +} + +func handleAuthorize(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + account, err := unmarshalRequestData(body) + if err != nil { + return nil, err + } + + if val := values.Get("status"); len(val) > 0 { + var authorize bool + switch strings.ToLower(val) { + case "true": + authorize = true + + case "false": + authorize = false + + default: + return nil, errors.Errorf("unsupported authorization status %v", val[0]) + } + + // Authorize the account through the accounts manager + if err := siteacc.AccountsManager().AuthorizeAccount(account, authorize); err != nil { + return nil, errors.Wrap(err, "unable to (un)authorize account") + } + } else { + return nil, errors.Errorf("no authorization status provided") + } + + return nil, nil +} + +func handleGrantGOCDBAccess(siteacc *SiteAccounts, values url.Values, body []byte, session *html.Session) (interface{}, error) { + account, err := unmarshalRequestData(body) + if err != nil { + return nil, err + } + + if val := values.Get("status"); len(val) > 0 { + var grantAccess bool + switch strings.ToLower(val) { + case "true": + grantAccess = true + + case "false": + grantAccess = false + + default: + return nil, errors.Errorf("unsupported access status %v", val[0]) + } + + // Grant access to the account through the accounts manager + if err := siteacc.AccountsManager().GrantGOCDBAccess(account, grantAccess); err != nil { + return nil, errors.Wrap(err, "unable to change the GOCDB access status of the account") + } + } else { + return nil, errors.Errorf("no access status provided") + } + + return nil, nil +} + +func unmarshalRequestData(body []byte) (*data.Account, error) { + account := &data.Account{} + if err := json.Unmarshal(body, account); err != nil { + return nil, errors.Wrap(err, "invalid account data") + } + account.Cleanup() + return account, nil +} + +func findAccount(siteacc *SiteAccounts, by string, value string) (*data.Account, error) { + if len(by) == 0 && len(value) == 0 { + return nil, errors.Errorf("missing search criteria") + } + + // Find the account using the accounts manager + account, err := siteacc.AccountsManager().FindAccount(by, value) + if err != nil { + return nil, errors.Wrap(err, "user not found") + } + return account, nil +} diff --git a/pkg/siteacc/html/panel.go b/pkg/siteacc/html/panel.go new file mode 100644 index 0000000000..91aa7a26ea --- /dev/null +++ b/pkg/siteacc/html/panel.go @@ -0,0 +1,165 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package html + +import ( + "html/template" + "net/http" + "strings" + + "github.com/cs3org/reva/pkg/siteacc/config" + "github.com/pkg/errors" + "github.com/rs/zerolog" +) + +// TemplateID is the type for template identifiers. +type TemplateID = string + +// Panel provides basic HTML panel functionality. +type Panel struct { + conf *config.Configuration + log *zerolog.Logger + + name string + + provider PanelProvider + + templates map[TemplateID]*template.Template +} + +const ( + pathParameterName = "path" +) + +func (panel *Panel) initialize(name string, provider PanelProvider, conf *config.Configuration, log *zerolog.Logger) error { + if name == "" { + return errors.Errorf("no name provided") + } + panel.name = name + + if conf == nil { + return errors.Errorf("no configuration provided") + } + panel.conf = conf + + if log == nil { + return errors.Errorf("no logger provided") + } + panel.log = log + + if provider == nil { + return errors.Errorf("no panel provider provided") + } + panel.provider = provider + + // Create space for the panel templates + panel.templates = make(map[string]*template.Template, 5) + + return nil +} + +func (panel *Panel) compile(provider ContentProvider) (string, error) { + content := panelTemplate + + // Replace placeholders by the values provided by the content provider + content = strings.ReplaceAll(content, "$(TITLE)", provider.GetTitle()) + content = strings.ReplaceAll(content, "$(CAPTION)", provider.GetCaption()) + + content = strings.ReplaceAll(content, "$(CONTENT_JAVASCRIPT)", provider.GetContentJavaScript()) + content = strings.ReplaceAll(content, "$(CONTENT_STYLESHEET)", provider.GetContentStyleSheet()) + content = strings.ReplaceAll(content, "$(CONTENT_BODY)", provider.GetContentBody()) + + return content, nil +} + +// AddTemplate adds and compiles a new template. +func (panel *Panel) AddTemplate(name TemplateID, provider ContentProvider) error { + name = panel.getFullTemplateName(name) + + if provider == nil { + return errors.Errorf("no content provider provided") + } + + content, err := panel.compile(provider) + if err != nil { + return errors.Wrapf(err, "error while compiling panel template %v", name) + } + + tpl := template.New(name) + panel.prepareTemplate(tpl) + + if _, err := tpl.Parse(content); err != nil { + return errors.Wrapf(err, "error while parsing panel template %v", name) + } + panel.templates[name] = tpl + + return nil +} + +// Execute generates the HTTP output of the panel and writes it to the response writer. +func (panel *Panel) Execute(w http.ResponseWriter, r *http.Request, session *Session, dataProvider PanelDataProvider) error { + // Get the path query parameter; the panel provider may use this to determine the template to use + path := r.URL.Query().Get(pathParameterName) + + actTpl := panel.provider.GetActiveTemplate(session, path) + tplName := panel.getFullTemplateName(actTpl) + tpl, ok := panel.templates[tplName] + if !ok { + return errors.Errorf("template %v not found", tplName) + } + + // If a data provider is specified, use it to get additional template data + var data interface{} + if dataProvider != nil { + data = dataProvider(session) + } + + // Perform the pre-execution phase in which the panel provider can intercept the actual execution + if state, err := panel.provider.PreExecute(session, actTpl, w, r); err == nil { + if !state { + return nil + } + } else { + return errors.Wrapf(err, "pre-execution of template %v failed", tplName) + } + + return tpl.Execute(w, data) +} + +func (panel *Panel) prepareTemplate(tpl *template.Template) { + // Add some custom helper functions to the template + tpl.Funcs(template.FuncMap{ + "getServerAddress": func() string { + return strings.TrimRight(panel.conf.Webserver.URL, "/") + }, + }) +} + +func (panel *Panel) getFullTemplateName(name string) string { + return panel.name + "-" + name +} + +// NewPanel creates a new panel. +func NewPanel(name string, provider PanelProvider, conf *config.Configuration, log *zerolog.Logger) (*Panel, error) { + panel := &Panel{} + if err := panel.initialize(name, provider, conf, log); err != nil { + return nil, errors.Wrapf(err, "unable to initialize the panel") + } + return panel, nil +} diff --git a/pkg/siteacc/html/provider.go b/pkg/siteacc/html/provider.go new file mode 100644 index 0000000000..03f1678280 --- /dev/null +++ b/pkg/siteacc/html/provider.go @@ -0,0 +1,60 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package html + +import ( + "net/http" +) + +const ( + // ContinueExecution causes the execution of a panel to continue. + ContinueExecution = true + // AbortExecution causes the execution of a panel to be aborted. + AbortExecution = false +) + +// ExecutionResult is the type returned by the PreExecute function of PanelProvider. +type ExecutionResult = bool + +// PanelProvider handles general panel tasks. +type PanelProvider interface { + // GetActiveTemplate returns the name of the active template. + GetActiveTemplate(*Session, string) string + + // PreExecute is called before the actual template is being executed. + PreExecute(*Session, string, http.ResponseWriter, *http.Request) (ExecutionResult, error) +} + +// PanelDataProvider is the function signature for panel data providers. +type PanelDataProvider = func(*Session) interface{} + +// ContentProvider defines various methods for HTML content providers. +type ContentProvider interface { + // GetTitle returns the title of the panel. + GetTitle() string + // GetCaption returns the caption which is displayed on the panel. + GetCaption() string + + // GetContentJavaScript delivers additional JavaScript code. + GetContentJavaScript() string + // GetContentStyleSheet delivers additional stylesheet code. + GetContentStyleSheet() string + // GetContentBody delivers the actual body content. + GetContentBody() string +} diff --git a/pkg/siteacc/html/session.go b/pkg/siteacc/html/session.go new file mode 100644 index 0000000000..0f6baf744a --- /dev/null +++ b/pkg/siteacc/html/session.go @@ -0,0 +1,116 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package html + +import ( + "net/http" + "net/url" + "strings" + "time" + + "github.com/cs3org/reva/pkg/siteacc/data" + "github.com/google/uuid" + "github.com/pkg/errors" +) + +// Session stores all data associated with an HTML session. +type Session struct { + ID string + MigrationID string + RemoteAddress string + CreationTime time.Time + Timeout time.Duration + + LoggedInUser *data.Account + + Data map[string]interface{} + + expirationTime time.Time + halflifeTime time.Time + + sessionCookieName string +} + +func getRemoteAddress(r *http.Request) string { + // Remove the port number from the remote address + remoteAddress := "" + if address := strings.Split(r.RemoteAddr, ":"); len(address) == 2 { + remoteAddress = address[0] + } + return remoteAddress +} + +// Save stores the session ID in a cookie using a response writer. +func (sess *Session) Save(cookiePath string, w http.ResponseWriter) { + fullURL, _ := url.Parse(cookiePath) + http.SetCookie(w, &http.Cookie{ + Name: sess.sessionCookieName, + Secure: !strings.EqualFold(fullURL.Hostname(), "localhost"), + Value: sess.ID, + MaxAge: int(sess.Timeout / time.Second), + Domain: fullURL.Hostname(), + Path: fullURL.Path, + SameSite: http.SameSiteLaxMode, + }) +} + +// VerifyRequest checks whether the provided request matches the stored session. +func (sess *Session) VerifyRequest(r *http.Request, verifyRemoteAddress bool) error { + cookie, err := r.Cookie(sess.sessionCookieName) + if err != nil { + return errors.Wrap(err, "unable to retrieve client session ID") + } + if cookie.Value != sess.ID { + return errors.Errorf("the session ID doesn't match") + } + + if verifyRemoteAddress && sess.RemoteAddress != "" { + if !strings.EqualFold(getRemoteAddress(r), sess.RemoteAddress) { + return errors.Errorf("remote address has changed (%v != %v)", r.RemoteAddr, sess.RemoteAddress) + } + } + + return nil +} + +// HalftimePassed checks whether the session has passed the first half of its lifetime. +func (sess *Session) HalftimePassed() bool { + return time.Now().After(sess.halflifeTime) +} + +// HasExpired checks whether the session has reached is timeout. +func (sess *Session) HasExpired() bool { + return time.Now().After(sess.expirationTime) +} + +// NewSession creates a new session, giving it a random ID. +func NewSession(name string, timeout time.Duration, r *http.Request) *Session { + session := &Session{ + ID: uuid.NewString(), + MigrationID: "", + RemoteAddress: getRemoteAddress(r), + CreationTime: time.Now(), + Timeout: timeout, + Data: make(map[string]interface{}, 10), + expirationTime: time.Now().Add(timeout), + halflifeTime: time.Now().Add(timeout / 2), + sessionCookieName: name, + } + return session +} diff --git a/pkg/siteacc/html/sessionmanager.go b/pkg/siteacc/html/sessionmanager.go new file mode 100644 index 0000000000..3c9c6d5d18 --- /dev/null +++ b/pkg/siteacc/html/sessionmanager.go @@ -0,0 +1,186 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package html + +import ( + "fmt" + "net/http" + "sync" + "time" + + "github.com/cs3org/reva/pkg/siteacc/config" + "github.com/pkg/errors" + "github.com/rs/zerolog" +) + +// SessionManager manages HTML sessions. +type SessionManager struct { + conf *config.Configuration + log *zerolog.Logger + + sessions map[string]*Session + + sessionName string + + mutex sync.Mutex +} + +func (mngr *SessionManager) initialize(name string, conf *config.Configuration, log *zerolog.Logger) error { + if name == "" { + return errors.Errorf("no session name provided") + } + mngr.sessionName = name + + if conf == nil { + return errors.Errorf("no configuration provided") + } + mngr.conf = conf + + if log == nil { + return errors.Errorf("no logger provided") + } + mngr.log = log + + mngr.sessions = make(map[string]*Session, 100) + + return nil +} + +// HandleRequest performs all session-related tasks during an HTML request. Always returns a valid session object. +func (mngr *SessionManager) HandleRequest(w http.ResponseWriter, r *http.Request) (*Session, error) { + mngr.mutex.Lock() + defer mngr.mutex.Unlock() + + var session *Session + var sessionErr error + + // Try to get the session ID from the request; if none has been set yet, a new one will be assigned + cookie, err := r.Cookie(mngr.sessionName) + if err == nil { + session = mngr.findSession(cookie.Value) + if session != nil { + mngr.logSessionInfo(session, r, "existing session found") + + // Verify the request against the session: If it is invalid, set an error; if the session has expired, create a new one; if it has already passed its halftime, migrate to a new one + if err := session.VerifyRequest(r, mngr.conf.Webserver.VerifyRemoteAddress); err == nil { + if session.HasExpired() { + // The session has expired, so a new one needs to be created + session = nil + + mngr.logSessionInfo(session, r, "session expired") + } else if session.HalftimePassed() { + // The session has passed its halftime, so migrate it to a new one (makes hijacking session IDs harder) + session, err = mngr.migrateSession(session, r) + if err != nil { + session = nil + sessionErr = errors.Wrap(err, "unable to migrate session") + } + + mngr.logSessionInfo(session, r, "session migrated") + } + } else { + session = nil + sessionErr = errors.Wrap(err, "invalid session") + + mngr.logSessionInfo(session, r, "session invalid (verify failed)") + } + } + } else if err != http.ErrNoCookie { + // The session cookie exists but seems to be invalid, so set an error + session = nil + sessionErr = errors.Wrap(err, "unable to get the session ID from the client") + + mngr.logSessionInfo(session, r, fmt.Sprintf("session cookie error: %v", err)) + } + + if session == nil { + // No session found for the client, so create a new one; this will always succeed + session = mngr.createSession(r) + + mngr.logSessionInfo(session, r, "assigned new session") + } + + // Store the session ID on the client side + session.Save(mngr.conf.Webserver.URL, w) + + return session, sessionErr +} + +// PurgeSessions removes any expired sessions. +func (mngr *SessionManager) PurgeSessions() { + mngr.mutex.Lock() + defer mngr.mutex.Unlock() + + var expiredSessions []string + for id, session := range mngr.sessions { + if session.HasExpired() { + expiredSessions = append(expiredSessions, id) + } + } + + for _, id := range expiredSessions { + delete(mngr.sessions, id) + } +} + +func (mngr *SessionManager) createSession(r *http.Request) *Session { + session := NewSession(mngr.sessionName, time.Duration(mngr.conf.Webserver.SessionTimeout)*time.Second, r) + mngr.sessions[session.ID] = session + return session +} + +func (mngr *SessionManager) findSession(id string) *Session { + if session, ok := mngr.sessions[id]; ok { + return session + } + return nil +} + +func (mngr *SessionManager) migrateSession(session *Session, r *http.Request) (*Session, error) { + sessionNew := mngr.createSession(r) + + // Carry over the old session information, thus preserving the existing session + sessionNew.MigrationID = session.ID + sessionNew.LoggedInUser = session.LoggedInUser + sessionNew.Data = session.Data + + // Delete the old session + delete(mngr.sessions, session.ID) + + return sessionNew, nil +} + +func (mngr *SessionManager) logSessionInfo(session *Session, r *http.Request, info string) { + if mngr.conf.Webserver.LogSessions { + if session != nil { + mngr.log.Debug().Str("id", session.ID).Str("address", r.RemoteAddr).Str("path", r.URL.Path).Msg(info) + } else { + mngr.log.Debug().Str("address", r.RemoteAddr).Str("path", r.URL.Path).Msg(info) + } + } +} + +// NewSessionManager creates a new session manager. +func NewSessionManager(name string, conf *config.Configuration, log *zerolog.Logger) (*SessionManager, error) { + mngr := &SessionManager{} + if err := mngr.initialize(name, conf, log); err != nil { + return nil, errors.Wrapf(err, "unable to initialize the session manager") + } + return mngr, nil +} diff --git a/pkg/siteacc/html/template.go b/pkg/siteacc/html/template.go new file mode 100644 index 0000000000..b0fa54706d --- /dev/null +++ b/pkg/siteacc/html/template.go @@ -0,0 +1,167 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package html + +const panelTemplate = ` + + + + + + $(TITLE) + + + +
+

$(CAPTION)

+ + $(CONTENT_BODY) + + + + +
+ + +` diff --git a/internal/http/services/siteacc/config/config.go b/pkg/siteacc/manager/acclistener.go similarity index 54% rename from internal/http/services/siteacc/config/config.go rename to pkg/siteacc/manager/acclistener.go index 60eeec1902..24c1d39110 100644 --- a/internal/http/services/siteacc/config/config.go +++ b/pkg/siteacc/manager/acclistener.go @@ -16,28 +16,19 @@ // granted to it by virtue of its status as an Intergovernmental Organization // or submit itself to any jurisdiction. -package config +package manager -import "github.com/cs3org/reva/pkg/smtpclient" +import "github.com/cs3org/reva/pkg/siteacc/data" -// Configuration holds the general service configuration. -type Configuration struct { - Prefix string `mapstructure:"prefix"` +// AccountsListenerCallback is the generic function type for accounts listeners. +type AccountsListenerCallback = func(AccountsListener, *data.Account) - Storage struct { - Driver string `mapstructure:"driver"` - - File struct { - File string `mapstructure:"file"` - } `mapstructure:"file"` - } `mapstructure:"storage"` - - EnableRegistrationForm bool `mapstructure:"enable_registration_form"` - - SMTP *smtpclient.SMTPCredentials `mapstructure:"smtp"` - NotificationsMail string `mapstructure:"notifications_mail"` - - SiteRegistration struct { - URL string `mapstructure:"url"` - } `mapstructure:"sitereg"` +// AccountsListener is an interface that listens to accounts events. +type AccountsListener interface { + // AccountCreated is called whenever an account was created. + AccountCreated(account *data.Account) + // AccountUpdated is called whenever an account was updated. + AccountUpdated(account *data.Account) + // AccountRemoved is called whenever an account was removed. + AccountRemoved(account *data.Account) } diff --git a/internal/http/services/siteacc/manager.go b/pkg/siteacc/manager/accmanager.go similarity index 57% rename from internal/http/services/siteacc/manager.go rename to pkg/siteacc/manager/accmanager.go index 23916bd117..c92f3212e1 100644 --- a/internal/http/services/siteacc/manager.go +++ b/pkg/siteacc/manager/accmanager.go @@ -16,27 +16,23 @@ // granted to it by virtue of its status as an Intergovernmental Organization // or submit itself to any jurisdiction. -package siteacc +package manager import ( - "bytes" - "encoding/gob" - "net/http" "strings" "sync" "time" - "github.com/pkg/errors" - "github.com/rs/zerolog" - - "github.com/cs3org/reva/internal/http/services/siteacc/config" - "github.com/cs3org/reva/internal/http/services/siteacc/data" - "github.com/cs3org/reva/internal/http/services/siteacc/email" - "github.com/cs3org/reva/internal/http/services/siteacc/panel" - "github.com/cs3org/reva/internal/http/services/siteacc/registration" - "github.com/cs3org/reva/internal/http/services/siteacc/sitereg" "github.com/cs3org/reva/pkg/mentix/key" + "github.com/cs3org/reva/pkg/siteacc/config" + "github.com/cs3org/reva/pkg/siteacc/data" + "github.com/cs3org/reva/pkg/siteacc/email" + "github.com/cs3org/reva/pkg/siteacc/manager/gocdb" + "github.com/cs3org/reva/pkg/siteacc/sitereg" "github.com/cs3org/reva/pkg/smtpclient" + "github.com/pkg/errors" + "github.com/rs/zerolog" + "github.com/sethvargo/go-password/password" ) const ( @@ -48,22 +44,21 @@ const ( FindBySiteID = "siteid" ) -// Manager is responsible for all site account related tasks. -type Manager struct { +// AccountsManager is responsible for all site account related tasks. +type AccountsManager struct { conf *config.Configuration log *zerolog.Logger - accounts data.Accounts - storage data.Storage + accounts data.Accounts + accountsListeners []AccountsListener + storage data.Storage - panel *panel.Panel - registrationForm *registration.Form - smtp *smtpclient.SMTPCredentials + smtp *smtpclient.SMTPCredentials mutex sync.RWMutex } -func (mngr *Manager) initialize(conf *config.Configuration, log *zerolog.Logger) error { +func (mngr *AccountsManager) initialize(conf *config.Configuration, log *zerolog.Logger) error { if conf == nil { return errors.Errorf("no configuration provided") } @@ -84,29 +79,22 @@ func (mngr *Manager) initialize(conf *config.Configuration, log *zerolog.Logger) return errors.Wrap(err, "unable to create accounts storage") } - // Create the web interface panel - if pnl, err := panel.NewPanel(conf, log); err == nil { - mngr.panel = pnl + // Register accounts listeners + if listener, err := gocdb.NewListener(mngr.conf, mngr.log); err == nil { + mngr.accountsListeners = append(mngr.accountsListeners, listener) } else { - return errors.Wrap(err, "unable to create panel") - } - - // Create the web interface registrationForm - if frm, err := registration.NewForm(conf, log); err == nil { - mngr.registrationForm = frm - } else { - return errors.Wrap(err, "unable to create registrationForm") + return errors.Wrap(err, "unable to create the GOCDB accounts listener") } // Create the SMTP client - if conf.SMTP != nil { - mngr.smtp = smtpclient.NewSMTPCredentials(conf.SMTP) + if conf.Email.SMTP != nil { + mngr.smtp = smtpclient.NewSMTPCredentials(conf.Email.SMTP) } return nil } -func (mngr *Manager) createStorage(driver string) (data.Storage, error) { +func (mngr *AccountsManager) createStorage(driver string) (data.Storage, error) { if driver == "file" { return data.NewFileStorage(mngr.conf, mngr.log) } @@ -114,7 +102,7 @@ func (mngr *Manager) createStorage(driver string) (data.Storage, error) { return nil, errors.Errorf("unknown storage driver %v", driver) } -func (mngr *Manager) readAllAccounts() { +func (mngr *AccountsManager) readAllAccounts() { if accounts, err := mngr.storage.ReadAll(); err == nil { mngr.accounts = *accounts } else { @@ -123,14 +111,14 @@ func (mngr *Manager) readAllAccounts() { } } -func (mngr *Manager) writeAllAccounts() { +func (mngr *AccountsManager) writeAllAccounts() { if err := mngr.storage.WriteAll(&mngr.accounts); err != nil { // Just warn when not being able to write accounts mngr.log.Warn().Err(err).Msg("error while writing accounts") } } -func (mngr *Manager) findAccount(by string, value string) (*data.Account, error) { +func (mngr *AccountsManager) findAccount(by string, value string) (*data.Account, error) { if len(value) == 0 { return nil, errors.Errorf("no search value specified") } @@ -157,7 +145,7 @@ func (mngr *Manager) findAccount(by string, value string) (*data.Account, error) return nil, errors.Errorf("no user found matching the specified criteria") } -func (mngr *Manager) findAccountByPredicate(predicate func(*data.Account) bool) *data.Account { +func (mngr *AccountsManager) findAccountByPredicate(predicate func(*data.Account) bool) *data.Account { for _, account := range mngr.accounts { if predicate(account) { return account @@ -166,20 +154,8 @@ func (mngr *Manager) findAccountByPredicate(predicate func(*data.Account) bool) return nil } -// ShowPanel writes the panel HTTP output directly to the response writer. -func (mngr *Manager) ShowPanel(w http.ResponseWriter) error { - // The panel only shows the stored accounts and offers actions through links, so let it use cloned data - accounts := mngr.CloneAccounts() - return mngr.panel.Execute(w, &accounts) -} - -// ShowRegistrationForm writes the registration registrationForm HTTP output directly to the response writer. -func (mngr *Manager) ShowRegistrationForm(w http.ResponseWriter) error { - return mngr.registrationForm.Execute(w) -} - // CreateAccount creates a new account; if an account with the same email address already exists, an error is returned. -func (mngr *Manager) CreateAccount(accountData *data.Account) error { +func (mngr *AccountsManager) CreateAccount(accountData *data.Account) error { mngr.mutex.Lock() defer mngr.mutex.Unlock() @@ -188,12 +164,13 @@ func (mngr *Manager) CreateAccount(accountData *data.Account) error { return errors.Errorf("an account with the specified email address already exists") } - if account, err := data.NewAccount(accountData.Email, accountData.FirstName, accountData.LastName); err == nil { + if account, err := data.NewAccount(accountData.Email, accountData.Title, accountData.FirstName, accountData.LastName, accountData.Organization, accountData.Website, accountData.Role, accountData.PhoneNumber, accountData.Password.Value); err == nil { mngr.accounts = append(mngr.accounts, account) mngr.storage.AccountAdded(account) mngr.writeAllAccounts() - _ = email.SendAccountCreated(account, []string{account.Email, mngr.conf.NotificationsMail}, mngr.smtp) + mngr.sendEmail(account, nil, email.SendAccountCreated) + mngr.callListeners(account, AccountsListener.AccountCreated) } else { return errors.Wrap(err, "error while creating account") } @@ -202,7 +179,7 @@ func (mngr *Manager) CreateAccount(accountData *data.Account) error { } // UpdateAccount updates the account identified by the account email; if no such account exists, an error is returned. -func (mngr *Manager) UpdateAccount(accountData *data.Account, copyData bool) error { +func (mngr *AccountsManager) UpdateAccount(accountData *data.Account, setPassword bool, copyData bool) error { mngr.mutex.Lock() defer mngr.mutex.Unlock() @@ -211,11 +188,13 @@ func (mngr *Manager) UpdateAccount(accountData *data.Account, copyData bool) err return errors.Wrap(err, "user to update not found") } - if err := account.Copy(accountData, copyData); err == nil { + if err := account.Update(accountData, setPassword, copyData); err == nil { account.DateModified = time.Now() mngr.storage.AccountUpdated(account) mngr.writeAllAccounts() + + mngr.callListeners(account, AccountsListener.AccountUpdated) } else { return errors.Wrap(err, "error while updating account") } @@ -223,8 +202,30 @@ func (mngr *Manager) UpdateAccount(accountData *data.Account, copyData bool) err return nil } -// FindAccount is used to find an account by various criteria. -func (mngr *Manager) FindAccount(by string, value string) (*data.Account, error) { +// ResetPassword resets the password for the given user. +func (mngr *AccountsManager) ResetPassword(name string) error { + account, err := mngr.findAccount(FindByEmail, name) + if err != nil { + return errors.Wrap(err, "user to reset password for not found") + } + accountUpd := account.Clone(true) + accountUpd.Password.Value = password.MustGenerate(defaultPasswordLength, 2, 0, false, true) + + err = mngr.UpdateAccount(accountUpd, true, false) + if err == nil { + mngr.sendEmail(accountUpd, nil, email.SendPasswordReset) + } + + return err +} + +// FindAccount is used to find an account by various criteria. The account is cloned to prevent data changes. +func (mngr *AccountsManager) FindAccount(by string, value string) (*data.Account, error) { + return mngr.FindAccountEx(by, value, true) +} + +// FindAccountEx is used to find an account by various criteria and optionally clone the account. +func (mngr *AccountsManager) FindAccountEx(by string, value string, cloneAccount bool) (*data.Account, error) { mngr.mutex.RLock() defer mngr.mutex.RUnlock() @@ -233,13 +234,15 @@ func (mngr *Manager) FindAccount(by string, value string) (*data.Account, error) return nil, err } - // Clone the account to avoid external data changes - clonedAccount := *account - return &clonedAccount, nil + if cloneAccount { + account = account.Clone(false) + } + + return account, nil } // AuthorizeAccount sets the authorization status of the account identified by the account email; if no such account exists, an error is returned. -func (mngr *Manager) AuthorizeAccount(accountData *data.Account, authorized bool) error { +func (mngr *AccountsManager) AuthorizeAccount(accountData *data.Account, authorized bool) error { mngr.mutex.Lock() defer mngr.mutex.Unlock() @@ -255,14 +258,41 @@ func (mngr *Manager) AuthorizeAccount(accountData *data.Account, authorized bool mngr.writeAllAccounts() if account.Data.Authorized && account.Data.Authorized != authorizedOld { - _ = email.SendAccountAuthorized(account, []string{account.Email, mngr.conf.NotificationsMail}, mngr.smtp) + mngr.sendEmail(account, nil, email.SendAccountAuthorized) + } + + mngr.callListeners(account, AccountsListener.AccountUpdated) + + return nil +} + +// GrantGOCDBAccess sets the GOCDB access status of the account identified by the account email; if no such account exists, an error is returned. +func (mngr *AccountsManager) GrantGOCDBAccess(accountData *data.Account, grantAccess bool) error { + mngr.mutex.Lock() + defer mngr.mutex.Unlock() + + account, err := mngr.findAccount(FindByEmail, accountData.Email) + if err != nil { + return errors.Wrap(err, "no account with the specified email exists") } + accessOld := account.Data.GOCDBAccess + account.Data.GOCDBAccess = grantAccess + + mngr.storage.AccountUpdated(account) + mngr.writeAllAccounts() + + if account.Data.GOCDBAccess && account.Data.GOCDBAccess != accessOld { + mngr.sendEmail(account, nil, email.SendGOCDBAccessGranted) + } + + mngr.callListeners(account, AccountsListener.AccountUpdated) + return nil } // AssignAPIKeyToAccount is used to assign a new API key to the account identified by the account email; if no such account exists, an error is returned. -func (mngr *Manager) AssignAPIKeyToAccount(accountData *data.Account, flags int) error { +func (mngr *AccountsManager) AssignAPIKeyToAccount(accountData *data.Account, flags int) error { mngr.mutex.Lock() defer mngr.mutex.Unlock() @@ -293,13 +323,14 @@ func (mngr *Manager) AssignAPIKeyToAccount(accountData *data.Account, flags int) mngr.storage.AccountUpdated(account) mngr.writeAllAccounts() - _ = email.SendAPIKeyAssigned(account, []string{account.Email, mngr.conf.NotificationsMail}, mngr.smtp) + mngr.sendEmail(account, nil, email.SendAPIKeyAssigned) + mngr.callListeners(account, AccountsListener.AccountUpdated) return nil } // UnregisterAccountSite unregisters the site associated with the given account. -func (mngr *Manager) UnregisterAccountSite(accountData *data.Account) error { +func (mngr *AccountsManager) UnregisterAccountSite(accountData *data.Account) error { mngr.mutex.RLock() defer mngr.mutex.RUnlock() @@ -322,7 +353,7 @@ func (mngr *Manager) UnregisterAccountSite(accountData *data.Account) error { } // RemoveAccount removes the account identified by the account email; if no such account exists, an error is returned. -func (mngr *Manager) RemoveAccount(accountData *data.Account) error { +func (mngr *AccountsManager) RemoveAccount(accountData *data.Account) error { mngr.mutex.Lock() defer mngr.mutex.Unlock() @@ -331,6 +362,8 @@ func (mngr *Manager) RemoveAccount(accountData *data.Account) error { mngr.accounts = append(mngr.accounts[:i], mngr.accounts[i+1:]...) mngr.storage.AccountRemoved(account) mngr.writeAllAccounts() + + mngr.callListeners(account, AccountsListener.AccountRemoved) return nil } } @@ -338,30 +371,37 @@ func (mngr *Manager) RemoveAccount(accountData *data.Account) error { return errors.Errorf("no account with the specified email exists") } +// SendContactForm sends a generic email to the ScienceMesh admins. +func (mngr *AccountsManager) SendContactForm(account *data.Account, subject, message string) { + mngr.sendEmail(account, map[string]string{"Subject": subject, "Message": message}, email.SendContactForm) +} + // CloneAccounts retrieves all accounts currently stored by cloning the data, thus avoiding race conflicts and making outside modifications impossible. -func (mngr *Manager) CloneAccounts() data.Accounts { +func (mngr *AccountsManager) CloneAccounts(erasePasswords bool) data.Accounts { mngr.mutex.RLock() defer mngr.mutex.RUnlock() - clone := make(data.Accounts, 0) + clones := make(data.Accounts, 0, len(mngr.accounts)) + for _, acc := range mngr.accounts { + clones = append(clones, acc.Clone(erasePasswords)) + } - // To avoid any "deep copy" packages, use gob en- and decoding instead - var buf bytes.Buffer - enc := gob.NewEncoder(&buf) - dec := gob.NewDecoder(&buf) + return clones +} - if err := enc.Encode(mngr.accounts); err == nil { - if err := dec.Decode(&clone); err != nil { - // In case of an error, create an empty data set - clone = make(data.Accounts, 0) - } +func (mngr *AccountsManager) callListeners(account *data.Account, cb AccountsListenerCallback) { + for _, listener := range mngr.accountsListeners { + cb(listener, account) } +} - return clone +func (mngr *AccountsManager) sendEmail(account *data.Account, params map[string]string, sendFunc email.SendFunction) { + _ = sendFunc(account, []string{account.Email, mngr.conf.Email.NotificationsMail}, params, *mngr.conf) } -func newManager(conf *config.Configuration, log *zerolog.Logger) (*Manager, error) { - mngr := &Manager{} +// NewAccountsManager creates a new accounts manager instance. +func NewAccountsManager(conf *config.Configuration, log *zerolog.Logger) (*AccountsManager, error) { + mngr := &AccountsManager{} if err := mngr.initialize(conf, log); err != nil { return nil, errors.Wrapf(err, "unable to initialize the accounts manager") } diff --git a/pkg/siteacc/manager/gocdb/account.go b/pkg/siteacc/manager/gocdb/account.go new file mode 100644 index 0000000000..e834b44f28 --- /dev/null +++ b/pkg/siteacc/manager/gocdb/account.go @@ -0,0 +1,100 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package gocdb + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "github.com/cs3org/reva/pkg/mentix/utils/network" + "github.com/cs3org/reva/pkg/siteacc/data" + "github.com/pkg/errors" +) + +const ( + opCreateOrUpdate = "CreateOrUpdate" + opDelete = "Delete" +) + +type writeAccountUserData struct { + Email string `json:"Email"` + FirstName string `json:"FirstName"` + LastName string `json:"LastName"` + PhoneNumber string `json:"PhoneNumber"` +} + +type writeAccountData struct { + APIKey string `json:"APIKey"` + Operation string `json:"Operation"` + + Data writeAccountUserData `json:"Data"` +} + +func writeAccount(account *data.Account, operation string, address string, apiKey string) error { + // Fill in the data to send + userData := getWriteAccountData(account) + userData.APIKey = apiKey + userData.Operation = operation + + // Send the data to the GOCDB endpoint + endpointURL, err := network.GenerateURL(address, "/ext/v1/user", network.URLParams{}) + if err != nil { + return errors.Wrap(err, "unable to generate the GOCDB URL") + } + + jsonData, err := json.Marshal(userData) + if err != nil { + return errors.Wrap(err, "unable to marshal the user data") + } + + fmt.Println(string(jsonData)) + + req, err := http.NewRequest(http.MethodPost, endpointURL.String(), bytes.NewReader(jsonData)) + if err != nil { + return errors.Wrap(err, "unable to create HTTP request") + } + req.Header.Set("Content-Type", "application/json; charset=UTF-8") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return errors.Wrap(err, "unable to send data to endpoint") + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + msg, _ := ioutil.ReadAll(resp.Body) + return errors.Errorf("unable to perform request: %v", string(msg)) + } + + return nil +} + +func getWriteAccountData(account *data.Account) *writeAccountData { + return &writeAccountData{ + Data: writeAccountUserData{ + Email: account.Email, + FirstName: account.FirstName, + LastName: account.LastName, + PhoneNumber: account.PhoneNumber, + }, + } +} diff --git a/pkg/siteacc/manager/gocdb/gocdb.go b/pkg/siteacc/manager/gocdb/gocdb.go new file mode 100644 index 0000000000..2aa5e4a9d4 --- /dev/null +++ b/pkg/siteacc/manager/gocdb/gocdb.go @@ -0,0 +1,81 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package gocdb + +import ( + "github.com/cs3org/reva/pkg/siteacc/config" + "github.com/cs3org/reva/pkg/siteacc/data" + "github.com/pkg/errors" + "github.com/rs/zerolog" +) + +// AccountsListener is the GOCDB accounts listener. +type AccountsListener struct { + conf *config.Configuration + log *zerolog.Logger +} + +func (listener *AccountsListener) initialize(conf *config.Configuration, log *zerolog.Logger) error { + if conf == nil { + return errors.Errorf("no configuration provided") + } + listener.conf = conf + + if log == nil { + return errors.Errorf("no logger provided") + } + listener.log = log + + return nil +} + +// AccountCreated is called whenever an account was created. +func (listener *AccountsListener) AccountCreated(account *data.Account) { + listener.updateGOCDB(account, false) +} + +// AccountUpdated is called whenever an account was updated. +func (listener *AccountsListener) AccountUpdated(account *data.Account) { + listener.updateGOCDB(account, false) +} + +// AccountRemoved is called whenever an account was removed. +func (listener *AccountsListener) AccountRemoved(account *data.Account) { + listener.updateGOCDB(account, true) +} + +func (listener *AccountsListener) updateGOCDB(account *data.Account, forceRemoval bool) { + if account != nil && account.Data.GOCDBAccess && !forceRemoval { + if err := writeAccount(account, opCreateOrUpdate, listener.conf.GOCDB.WriteURL, listener.conf.GOCDB.APIKey); err != nil { + listener.log.Err(err).Str("userid", account.Email).Msg("unable to update GOCDB account") + } + } else { + // Errors while deleting an account are ignored (account might not exist at all, for example) + _ = writeAccount(account, opDelete, listener.conf.GOCDB.WriteURL, listener.conf.GOCDB.APIKey) + } +} + +// NewListener creates a new GOCDB accounts listener. +func NewListener(conf *config.Configuration, log *zerolog.Logger) (*AccountsListener, error) { + listener := &AccountsListener{} + if err := listener.initialize(conf, log); err != nil { + return nil, errors.Wrapf(err, "unable to initialize the GOCDB accounts listener") + } + return listener, nil +} diff --git a/pkg/siteacc/manager/token.go b/pkg/siteacc/manager/token.go new file mode 100644 index 0000000000..d1c0309b67 --- /dev/null +++ b/pkg/siteacc/manager/token.go @@ -0,0 +1,87 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package manager + +import ( + "time" + + "github.com/golang-jwt/jwt" + "github.com/pkg/errors" + "github.com/sethvargo/go-password/password" +) + +type userToken struct { + jwt.StandardClaims + + User string `json:"user"` + Scope string `json:"scope"` +} + +const ( + tokenKeyLength = 16 + tokenIssuer = "sciencemesh_siteacc" +) + +var ( + tokenSecret string +) + +func generateUserToken(user string, scope string, timeout int) (string, error) { + // Create a JWT as the user token + claims := userToken{ + StandardClaims: jwt.StandardClaims{ + ExpiresAt: time.Now().Add(time.Duration(timeout) * time.Second).Unix(), + Issuer: tokenIssuer, + IssuedAt: time.Now().Unix(), + }, + User: user, + Scope: scope, + } + + token := jwt.NewWithClaims(jwt.GetSigningMethod("HS256"), claims) + signedToken, err := token.SignedString([]byte(tokenSecret)) + if err != nil { + return "", errors.Wrapf(err, "error signing token with claims %+v", claims) + } + + return signedToken, nil +} + +func extractUserToken(token string) (*userToken, error) { + // Parse the token and try to extract the claims + parsedToken, err := jwt.ParseWithClaims(token, &userToken{}, func(token *jwt.Token) (interface{}, error) { return []byte(tokenSecret), nil }) + if err != nil { + return nil, errors.Wrap(err, "error parsing token") + } + + if claims, ok := parsedToken.Claims.(*userToken); ok && parsedToken.Valid { + if claims.Issuer != tokenIssuer { + return nil, errors.Errorf("invalid token issuer") + } + + return claims, nil + } + + return nil, errors.Errorf("invalid token") +} + +func init() { + // Generate the token secret randomly + tokenSecret = password.MustGenerate(tokenKeyLength, tokenKeyLength/4, tokenKeyLength/4, false, true) +} diff --git a/pkg/siteacc/manager/usersmanager.go b/pkg/siteacc/manager/usersmanager.go new file mode 100644 index 0000000000..4f39b3e4bf --- /dev/null +++ b/pkg/siteacc/manager/usersmanager.go @@ -0,0 +1,138 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package manager + +import ( + "strings" + + "github.com/cs3org/reva/pkg/siteacc/config" + "github.com/cs3org/reva/pkg/siteacc/html" + "github.com/pkg/errors" + "github.com/rs/zerolog" +) + +// UsersManager is responsible for managing logged in users through session objects. +type UsersManager struct { + conf *config.Configuration + log *zerolog.Logger + + accountsManager *AccountsManager +} + +const ( + defaultPasswordLength = 12 +) + +func (mngr *UsersManager) initialize(conf *config.Configuration, log *zerolog.Logger, accountsManager *AccountsManager) error { + if conf == nil { + return errors.Errorf("no configuration provided") + } + mngr.conf = conf + + if log == nil { + return errors.Errorf("no logger provided") + } + mngr.log = log + + if accountsManager == nil { + return errors.Errorf("no accounts manager provided") + } + mngr.accountsManager = accountsManager + + return nil +} + +// LoginUser tries to login a given username/password pair. On success, the corresponding user account is stored in the session and a user token is returned. +func (mngr *UsersManager) LoginUser(name, password string, scope string, session *html.Session) (string, error) { + account, err := mngr.accountsManager.FindAccountEx(FindByEmail, name, false) + if err != nil { + return "", errors.Wrap(err, "no account with the specified email exists") + } + + // Verify the provided password + if !account.Password.Compare(password) { + return "", errors.Errorf("invalid password") + } + + // Check if the user has access to the specified scope + if !account.CheckScopeAccess(scope) { + return "", errors.Errorf("no access to the specified scope granted") + } + + // Store the user account in the session + session.LoggedInUser = account + + // Generate a token that can be used as a "ticket" + token, err := generateUserToken(session.LoggedInUser.Email, scope, mngr.conf.Webserver.SessionTimeout) + if err != nil { + return "", errors.Wrap(err, "unable to generate user token") + } + + return token, nil +} + +// LogoutUser logs the current user out. +func (mngr *UsersManager) LogoutUser(session *html.Session) { + // Just unset the user account stored in the session + session.LoggedInUser = nil +} + +// VerifyUserToken is used to verify a user token against the current session. +func (mngr *UsersManager) VerifyUserToken(token string, user string, scope string) (string, error) { + // Verify the token by trying to extract it + utoken, err := extractUserToken(token) + if err != nil { + return "", errors.Wrap(err, "unable to verify user token") + } + + // Check the provided email against the stored one + if !strings.EqualFold(utoken.User, user) { + return "", errors.Errorf("mismatching user") + } + + // Check if the user account actually exists and has proper scope access + if strings.EqualFold(scope, utoken.Scope) { + if acc, err := mngr.accountsManager.FindAccount(FindByEmail, utoken.User); err == nil { + if !acc.CheckScopeAccess(scope) { + return "", errors.Errorf("no scope access") + } + } else { + return "", errors.Errorf("invalid email") + } + } else { + return "", errors.Errorf("invalid scope") + } + + // Refresh the user token (as a form of keep-alive, since tokens expire quickly) + newToken, err := generateUserToken(utoken.User, utoken.Scope, mngr.conf.Webserver.SessionTimeout) + if err != nil { + return "", errors.Wrap(err, "unable to refresh user token") + } + + return newToken, nil +} + +// NewUsersManager creates a new users manager instance. +func NewUsersManager(conf *config.Configuration, log *zerolog.Logger, accountsManager *AccountsManager) (*UsersManager, error) { + mngr := &UsersManager{} + if err := mngr.initialize(conf, log, accountsManager); err != nil { + return nil, errors.Wrapf(err, "unable to initialize the users manager") + } + return mngr, nil +} diff --git a/pkg/siteacc/password/password.go b/pkg/siteacc/password/password.go new file mode 100644 index 0000000000..a9a59467e4 --- /dev/null +++ b/pkg/siteacc/password/password.go @@ -0,0 +1,83 @@ +// Copyright 2018-2020 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package password + +import ( + "strings" + + "github.com/pkg/errors" + "golang.org/x/crypto/bcrypt" +) + +// Password holds a hash password alongside its salt value. +type Password struct { + Value string `json:"value"` +} + +const ( + passwordMinLength = 8 +) + +// Set sets a new password by hashing the plaintext version using bcrypt. +func (password *Password) Set(pwd string) error { + if err := VerifyPassword(pwd); err != nil { + return errors.Wrap(err, "invalid password") + } + + pwdData, err := bcrypt.GenerateFromPassword([]byte(pwd), bcrypt.DefaultCost) + if err != nil { + return errors.Wrap(err, "unable to generate password hash") + } + password.Value = string(pwdData) + return nil +} + +// Compare checks whether the given password string equals the stored one. +func (password *Password) Compare(pwd string) bool { + return bcrypt.CompareHashAndPassword([]byte(password.Value), []byte(pwd)) == nil +} + +// IsValid checks whether the password is valid. +func (password *Password) IsValid() bool { + // bcrypt hashes are in the form of $[version]$[cost]$[22 character salt][31 character hash], so they have a minimum length of 58 + return len(password.Value) > 58 && strings.Count(password.Value, "$") >= 3 +} + +// Clear resets the password. +func (password *Password) Clear() { + password.Value = "" +} + +// VerifyPassword checks whether the given password abides to the enforced password strength. +func VerifyPassword(pwd string) error { + if len(pwd) < passwordMinLength { + return errors.Errorf("the password must be at least 8 characters long") + } + if !strings.ContainsAny(pwd, "abcdefghijklmnopqrstuvwxyz") { + return errors.Errorf("the password must contain at least one lowercase letter") + } + if !strings.ContainsAny(pwd, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") { + return errors.Errorf("the password must contain at least one uppercase letter") + } + if !strings.ContainsAny(pwd, "0123456789") { + return errors.Errorf("the password must contain at least one digit") + } + + return nil +} diff --git a/pkg/siteacc/siteacc.go b/pkg/siteacc/siteacc.go new file mode 100644 index 0000000000..e42e3ef12d --- /dev/null +++ b/pkg/siteacc/siteacc.go @@ -0,0 +1,169 @@ +// Copyright 2018-2021 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package siteacc + +import ( + "fmt" + "net/http" + + accpanel "github.com/cs3org/reva/pkg/siteacc/account" + "github.com/cs3org/reva/pkg/siteacc/admin" + "github.com/cs3org/reva/pkg/siteacc/config" + "github.com/cs3org/reva/pkg/siteacc/html" + "github.com/cs3org/reva/pkg/siteacc/manager" + "github.com/pkg/errors" + "github.com/rs/zerolog" +) + +// SiteAccounts represents the main Site Accounts service object. +type SiteAccounts struct { + conf *config.Configuration + log *zerolog.Logger + + sessions *html.SessionManager + + accountsManager *manager.AccountsManager + usersManager *manager.UsersManager + + adminPanel *admin.Panel + accountPanel *accpanel.Panel +} + +func (siteacc *SiteAccounts) initialize(conf *config.Configuration, log *zerolog.Logger) error { + if conf == nil { + return fmt.Errorf("no configuration provided") + } + siteacc.conf = conf + + if log == nil { + return fmt.Errorf("no logger provided") + } + siteacc.log = log + + // Create the session mananger + sessions, err := html.NewSessionManager("siteacc_session", conf, log) + if err != nil { + return errors.Wrap(err, "error while creating the session manager") + } + siteacc.sessions = sessions + + // Create the accounts manager instance + amngr, err := manager.NewAccountsManager(conf, log) + if err != nil { + return errors.Wrap(err, "error creating the accounts manager") + } + siteacc.accountsManager = amngr + + // Create the users manager instance + umngr, err := manager.NewUsersManager(conf, log, siteacc.accountsManager) + if err != nil { + return errors.Wrap(err, "error creating the users manager") + } + siteacc.usersManager = umngr + + // Create the admin panel + if pnl, err := admin.NewPanel(conf, log); err == nil { + siteacc.adminPanel = pnl + } else { + return errors.Wrap(err, "unable to create the administration panel") + } + + // Create the account panel + if pnl, err := accpanel.NewPanel(conf, log); err == nil { + siteacc.accountPanel = pnl + } else { + return errors.Wrap(err, "unable to create the account panel") + } + + return nil +} + +// RequestHandler returns the HTTP request handler of the service. +func (siteacc *SiteAccounts) RequestHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + + // Get the active session for the request (or create a new one); a valid session object will always be returned + siteacc.sessions.PurgeSessions() // Remove expired sessions first + session, err := siteacc.sessions.HandleRequest(w, r) + if err != nil { + siteacc.log.Err(err).Msg("an error occurred while handling sessions") + } + + epHandled := false + for _, ep := range getEndpoints() { + if ep.Path == r.URL.Path { + ep.Handler(siteacc, ep, w, r, session) + epHandled = true + break + } + } + + if !epHandled { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(fmt.Sprintf("Unknown endpoint %v", r.URL.Path))) + } + }) +} + +// ShowAdministrationPanel writes the administration panel HTTP output directly to the response writer. +func (siteacc *SiteAccounts) ShowAdministrationPanel(w http.ResponseWriter, r *http.Request, session *html.Session) error { + // The admin panel only shows the stored accounts and offers actions through links, so let it use cloned data + accounts := siteacc.accountsManager.CloneAccounts(true) + return siteacc.adminPanel.Execute(w, r, session, &accounts) +} + +// ShowAccountPanel writes the account panel HTTP output directly to the response writer. +func (siteacc *SiteAccounts) ShowAccountPanel(w http.ResponseWriter, r *http.Request, session *html.Session) error { + return siteacc.accountPanel.Execute(w, r, session) +} + +// AccountsManager returns the central accounts manager instance. +func (siteacc *SiteAccounts) AccountsManager() *manager.AccountsManager { + return siteacc.accountsManager +} + +// UsersManager returns the central users manager instance. +func (siteacc *SiteAccounts) UsersManager() *manager.UsersManager { + return siteacc.usersManager +} + +// GetPublicEndpoints returns a list of all public endpoints. +func (siteacc *SiteAccounts) GetPublicEndpoints() []string { + // TODO: Only for local testing! + // return []string{"/"} + + endpoints := make([]string, 0, 5) + for _, ep := range getEndpoints() { + if ep.IsPublic { + endpoints = append(endpoints, ep.Path) + } + } + return endpoints +} + +// New returns a new Site Accounts service instance. +func New(conf *config.Configuration, log *zerolog.Logger) (*SiteAccounts, error) { + // Configure the accounts service + siteacc := new(SiteAccounts) + if err := siteacc.initialize(conf, log); err != nil { + return nil, fmt.Errorf("unable to initialize SiteAccounts: %v", err) + } + return siteacc, nil +} diff --git a/internal/http/services/siteacc/sitereg/sitereg.go b/pkg/siteacc/sitereg/sitereg.go similarity index 100% rename from internal/http/services/siteacc/sitereg/sitereg.go rename to pkg/siteacc/sitereg/sitereg.go diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go index 244a065dc9..317b6476fc 100644 --- a/pkg/utils/utils.go +++ b/pkg/utils/utils.go @@ -23,6 +23,7 @@ import ( "math/rand" "net" "net/http" + "net/url" "os/user" "path" "path/filepath" @@ -182,6 +183,24 @@ func IsEmailValid(e string) bool { return matchEmail.MatchString(e) } +// IsValidWebAddress checks whether the provided address is a valid URL. +func IsValidWebAddress(address string) bool { + _, err := url.ParseRequestURI(address) + return err == nil +} + +// IsValidPhoneNumber checks whether the provided phone number has a valid format. +func IsValidPhoneNumber(number string) bool { + re := regexp.MustCompile(`^(?:(?:\(?(?:00|\+)([1-4]\d\d|[1-9]\d?)\)?)?[\-\.\ \\\/]?)?((?:\(?\d{1,}\)?[\-\.\ \\\/]?){0,})(?:[\-\.\ \\\/]?(?:#|ext\.?|extension|x)[\-\.\ \\\/]?(\d+))?$`) + return re.MatchString(number) +} + +// IsValidName cheks if the given name doesn't contain any non-alpha, space or dash characters. +func IsValidName(name string) bool { + re := regexp.MustCompile(`^[A-Za-z\s\-]*$`) + return re.MatchString(name) +} + // MarshalProtoV1ToJSON marshals a proto V1 message to a JSON byte array // TODO: update this once we start using V2 in CS3APIs func MarshalProtoV1ToJSON(m proto.Message) ([]byte, error) {