diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 31fc1fc88..239ee768e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,7 +42,6 @@ Here is the basic structure of the harbor code base. Some of the key folders / f ``` . ... -├── Makefile # Make file for compile and build code ├── contrib # Contain documents, scripts, and other helpful things which are contributed by the community ├── docs # Keep documents here ├── make # Resource for building and setting up Harbor environment @@ -56,46 +55,56 @@ Here is the basic structure of the harbor code base. Some of the key folders / f The folder graph below shows the structure of the source code folder `harbor/src`, which will be your primary working directory. The key folders are also commented. ``` . -├── adminserver # Source code for the admin server component -│   ├── api -│   ├── auth -│   ├── client -│   ├── handlers -│   ├── systemcfg -│   └── systeminfo +├── chartserver # Source code contains the main logic to handle chart. +├── cmd # Source code contains migrate script to handle DB upgrade. ├── common # Source code for some general components like dao etc. │   ├── api │   ├── config │   ├── dao +│   ├── http +│   ├── job │   ├── models -│   ├── notifier -│   ├── scheduler +│   ├── rbac +│   ├── registryctl │   ├── secret │   ├── security │   └── utils -├── jobservice # Source code for the job service component -│   ├── api -│   ├── config -│   ├── job -│   ├── replication -│   ├── scan -│   └── utils -├── ui # Source code for the harbor service component +├── core # Source code for the main busines logic. Contains rest apis and all service infomation. │   ├── api │   ├── auth │   ├── config │   ├── controllers │   ├── filter +│   ├── label +│   ├── notifier │   ├── promgr │   ├── proxy │   ├── service -│   ├── static +│   ├── systeminfo │   ├── utils │   └── views +├── jobservice # Source code for the job service component +│   ├── api +│   ├── config +│   ├── core +│   ├── env +│   ├── errs +│   ├── job +│   ├── logger +│   ├── models +│   ├── opm +│   ├── period +│   ├── pool +│   ├── runtime +│   ├── tests +│   └── utils ├── portal # The code of harbor web UI │   ├── e2e │   ├── lib # Source code of @harbor/ui npm library which includes the main UI components of web UI │   └── src # General web page UI code of Harbor +├── registryctl # Source code contains the main logic to handle registry. +├── replication # Source code contains the main logic of replication. +├── testing # Some utilities to handle testing. └── vendor # Go code dependencies ├── github.com ├── golang.org @@ -116,7 +125,8 @@ Harbor backend is written in [Go](http://golang.org/). If you don't have a Harbo | 1.4 | 1.9.2 | | 1.5 | 1.9.2 | | 1.6 | 1.9.2 | -| 1.6 | tbd | +| 1.7 | 1.9.2 | +| 1.8 | 1.11.2 | Ensure your GOPATH and PATH have been configured in accordance with the Go environment instructions. @@ -131,9 +141,11 @@ Harbor web UI is built based on [Clarity](https://vmware.github.io/clarity/) and | 1.1 | 2.4.1 | 0.8.7 | | 1.2 | 4.1.3 | 0.9.8 | | 1.3 | 4.3.0 | 0.10.17 | -| 1.4 | 4.3.0 | 0.10.17 | -| 1.5 | 4.3.0 | 0.10.27 | -| 1.6 | 4.3.0 | 0.10.27 | +| 1.4 | 4.3.0 | 0.10.17 | +| 1.5 | 4.3.0 | 0.10.27 | +| 1.6 | 4.3.0 | 0.10.27 | +| 1.7 | 6.0.3 | 0.12.10 | +| 1.8 | 7.1.3 | 1.0.0 | **npm Package Dependency:** Run the following commands to restore the package dependencies. ``` @@ -155,6 +167,8 @@ PR are always welcome, even if they only contain small fixes like typos or a few Please submit a PR broken down into small changes bit by bit. A PR consisting of a lot features and code changes may be hard to review. It is recommended to submit PRs in an incremental fashion. +Note: If you split your pull request to small changes, please make sure any of the changes goes to master will not break anything. Otherwise, it can not be merged until this feature complete. + The graphic shown below describes the overall workflow about how to contribute code to Harbor repository. ![contribute workflow](docs/img/workflow.png) @@ -316,5 +330,4 @@ Documents are written with Markdown text. See [Writing on GitHub](https://help.g ## Design new features -You can propose new designs for existing Harbor features. You can also design -entirely new features. Please do open an issue on Github for discussion first. This is necessary to ensure the overall architecture is consistent and to avoid duplicated work in the roadmap. +You can propose new designs for existing Harbor features. You can also design entirely new features, Please submit a proposal in GitHub.(https://github.com/goharbor/community/tree/master/proposals). Harbor maintainers will review this proposal as soon as possible. This is necessary to ensure the overall architecture is consistent and to avoid duplicated work in the roadmap. diff --git a/docs/README.md b/docs/README.md index dcaa1bc57..cec4cae51 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,7 +32,7 @@ Use Swagger to find out the specs of Harbor API. [Internationalization Guide](developer_guide_i18n.md) How to add your local language to Harbor. -[Python SDK](../contrib/sdk/harbor-py) (by community) +[Python SDK](../contrib/registryapi) (by community) [Harbor CLI](https://github.com/int32bit/harborclient) (by community) diff --git a/make/migrations/postgresql/0004_1.8.0_schema.up.sql b/make/migrations/postgresql/0004_1.8.0_schema.up.sql index 83cbf3a96..aa1e55ea2 100644 --- a/make/migrations/postgresql/0004_1.8.0_schema.up.sql +++ b/make/migrations/postgresql/0004_1.8.0_schema.up.sql @@ -13,6 +13,25 @@ CREATE TABLE robot ( CREATE TRIGGER robot_update_time_at_modtime BEFORE UPDATE ON robot FOR EACH ROW EXECUTE PROCEDURE update_update_time_at_column(); +CREATE TABLE oidc_user ( + id SERIAL NOT NULL, + user_id int NOT NULL, + secret varchar(255) NOT NULL, + /* + Subject and Issuer + Subject: Subject Identifier. + Issuer: Issuer Identifier for the Issuer of the response. + The sub (subject) and iss (issuer) Claims, used together, are the only Claims that an RP can rely upon as a stable identifier for the End-User + */ + subiss varchar(255) NOT NULL, + creation_time timestamp default CURRENT_TIMESTAMP, + update_time timestamp default CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE (subiss) +); + +CREATE TRIGGER odic_user_update_time_at_modtime BEFORE UPDATE ON oidc_user FOR EACH ROW EXECUTE PROCEDURE update_update_time_at_column(); + /*add master role*/ INSERT INTO role (role_code, name) VALUES ('DRWS', 'master'); diff --git a/make/photon/prepare/utils/configs.py b/make/photon/prepare/utils/configs.py index d9324e27f..9a7c402ee 100644 --- a/make/photon/prepare/utils/configs.py +++ b/make/photon/prepare/utils/configs.py @@ -146,27 +146,16 @@ def parse_yaml_config(config_file_path): config_dict['admiral_url'] = "" # Clair configs - clair_configs = configs.get("clair") - if clair_configs: - config_dict['clair_db_password'] = clair_configs.get("db_password") - config_dict['clair_db_host'] = clair_configs.get("db_host") - config_dict['clair_db_port'] = clair_configs.get("db_port") - config_dict['clair_db_username'] = clair_configs.get("db_username") - config_dict['clair_db'] = clair_configs.get("db") - config_dict['clair_updaters_interval'] = clair_configs.get("updaters_interval") - config_dict['clair_http_proxy'] = clair_configs.get('http_proxy') - config_dict['clair_https_proxy'] = clair_configs.get('https_proxy') - config_dict['clair_no_proxy'] = clair_configs.get('no_proxy') - else: - config_dict['clair_db_password'] = '' - config_dict['clair_db_host'] = '' - config_dict['clair_db_port'] = '' - config_dict['clair_db_username'] = '' - config_dict['clair_db'] = '' - config_dict['clair_updaters_interval'] = '' - config_dict['clair_http_proxy'] = '' - config_dict['clair_https_proxy'] = '' - config_dict['clair_no_proxy'] = '' + clair_configs = configs.get("clair") or {} + config_dict['clair_db_password'] = clair_configs.get("db_password") or '' + config_dict['clair_db_host'] = clair_configs.get("db_host") or '' + config_dict['clair_db_port'] = clair_configs.get("db_port") or '' + config_dict['clair_db_username'] = clair_configs.get("db_username") or '' + config_dict['clair_db'] = clair_configs.get("db") or '' + config_dict['clair_updaters_interval'] = clair_configs.get("updaters_interval") or '' + config_dict['clair_http_proxy'] = clair_configs.get('http_proxy') or '' + config_dict['clair_https_proxy'] = clair_configs.get('https_proxy') or '' + config_dict['clair_no_proxy'] = clair_configs.get('no_proxy') or '' # UAA configs config_dict['uaa_endpoint'] = configs.get("uaa_endpoint") diff --git a/make/photon/prepare/utils/notary.py b/make/photon/prepare/utils/notary.py index 85ec2f20e..041d46be1 100644 --- a/make/photon/prepare/utils/notary.py +++ b/make/photon/prepare/utils/notary.py @@ -76,19 +76,14 @@ def prepare_env_notary(customize_crt, nginx_config_dir): os.path.join(notary_config_dir, "server_env")) print("Copying nginx configuration file for notary") - notary_nginx_upstream_template_conf = os.path.join(templates_dir, "nginx", "notary.upstream.conf.jinja") - notary_server_nginx_config = os.path.join(nginx_config_dir, "notary.server.conf") - shutil.copy2(notary_nginx_upstream_template_conf, notary_server_nginx_config) + shutil.copy2( + os.path.join(templates_dir, "nginx", "notary.upstream.conf.jinja"), + os.path.join(nginx_config_dir, "notary.upstream.conf")) mark_file(os.path.join(notary_secret_dir, "notary-signer.crt")) mark_file(os.path.join(notary_secret_dir, "notary-signer.key")) mark_file(os.path.join(notary_secret_dir, "notary-signer-ca.crt")) - # print("Copying sql file for notary DB") - # if os.path.exists(os.path.join(notary_config_dir, "postgresql-initdb.d")): - # shutil.rmtree(os.path.join(notary_config_dir, "postgresql-initdb.d")) - # shutil.copytree(os.path.join(notary_temp_dir, "postgresql-initdb.d"), os.path.join(notary_config_dir, "postgresql-initdb.d")) - def prepare_notary(config_dict, nginx_config_dir, ssl_cert_path, ssl_cert_key_path): diff --git a/src/Gopkg.lock b/src/Gopkg.lock index 8e443ee60..42da4319e 100644 --- a/src/Gopkg.lock +++ b/src/Gopkg.lock @@ -100,6 +100,14 @@ revision = "542e16cac74562eefac970a7d0d1467640d1f1cb" version = "v1.7.0" +[[projects]] + digest = "1:f6e5e1bc64c2908167e6aa9a1fe0c084d515132a1c63ad5b6c84036aa06dc0c1" + name = "github.com/coreos/go-oidc" + packages = ["."] + pruneopts = "UT" + revision = "1180514eaf4d9f38d0d19eef639a1d695e066e72" + version = "v2.0.0" + [[projects]] digest = "1:a2c1d0e43bd3baaa071d1b9ed72c27d78169b2b269f71c105ac4ba34b1be4a39" name = "github.com/davecgh/go-spew" @@ -235,6 +243,17 @@ revision = "1d4117a214abff263b472043871c8666aedb716b" version = "v0.5.1" +[[projects]] + digest = "1:4d02824a56d268f74a6b6fdd944b20b58a77c3d70e81008b3ee0c4f1a6777340" + name = "github.com/gogo/protobuf" + packages = [ + "proto", + "sortkeys", + ] + pruneopts = "UT" + revision = "ba06b47c162d49f2af050fb4c75bcbc86a159d5c" + version = "v1.2.1" + [[projects]] digest = "1:39d9284259004077d3b89109d592fce5f311788745ce94a7ccd4545e536ad3ac" name = "github.com/golang-migrate/migrate" @@ -249,6 +268,14 @@ revision = "bcd996f3df28363f43e2d0935484c4559537a3eb" version = "v3.3.0" +[[projects]] + branch = "master" + digest = "1:1ba1d79f2810270045c328ae5d674321db34e3aae468eb4233883b473c5c0467" + name = "github.com/golang/glog" + packages = ["."] + pruneopts = "UT" + revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" + [[projects]] digest = "1:41e5cefde26c58f1560df2d1c32c2fa85e332d7cb4460d2077ae8fd8e0f3d789" name = "github.com/golang/protobuf" @@ -279,6 +306,14 @@ pruneopts = "UT" revision = "53e6ce116135b80d037921a7fdd5138cf32d7a8a" +[[projects]] + branch = "master" + digest = "1:3ee90c0d94da31b442dde97c99635aaafec68d0b8a3c12ee2075c6bdabeec6bb" + name = "github.com/google/gofuzz" + packages = ["."] + pruneopts = "UT" + revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" + [[projects]] digest = "1:160eabf7a69910fd74f29c692718bc2437c1c1c7d4c9dea9712357752a70e5df" name = "github.com/gorilla/context" @@ -303,6 +338,14 @@ revision = "7f08801859139f86dfafd1c296e2cba9a80d292e" version = "v1.6.0" +[[projects]] + digest = "1:f5a2051c55d05548d2d4fd23d244027b59fbd943217df8aa3b5e170ac2fd6e1b" + name = "github.com/json-iterator/go" + packages = ["."] + pruneopts = "UT" + revision = "0ff49de124c6f76f8494e194af75bde0f1a49a29" + version = "v1.1.6" + [[projects]] branch = "master" digest = "1:bd26bbaf1e9f9dfe829a88f87a0849b56f717c31785443a67668f2c752fa8412" @@ -329,6 +372,22 @@ pruneopts = "UT" revision = "7283ca79f35edb89bc1b4ecae7f86a3680ce737f" +[[projects]] + digest = "1:33422d238f147d247752996a26574ac48dcf472976eda7f5134015f06bf16563" + name = "github.com/modern-go/concurrent" + packages = ["."] + pruneopts = "UT" + revision = "bacd9c7ef1dd9b15be4a9909b8ac7a4e313eec94" + version = "1.0.3" + +[[projects]] + digest = "1:e32bdbdb7c377a07a9a46378290059822efdce5c8d96fe71940d87cb4f918855" + name = "github.com/modern-go/reflect2" + packages = ["."] + pruneopts = "UT" + revision = "4b7aa43c6742a2c18fdef89dd197aaae7dac7ccd" + version = "1.0.1" + [[projects]] digest = "1:159d8a990f45d4891f1f04cb6ad7eb18b307cd02d783f7d37fa7a3b93912b172" name = "github.com/opencontainers/go-digest" @@ -353,6 +412,17 @@ revision = "792786c7400a136282c1664665ae0a8db921c6c2" version = "v1.0.0" +[[projects]] + branch = "master" + digest = "1:bd9efe4e0b0f768302a1e2f0c22458149278de533e521206e5ddc71848c269a0" + name = "github.com/pquerna/cachecontrol" + packages = [ + ".", + "cacheobject", + ] + pruneopts = "UT" + revision = "1555304b9b35fdd2b425bccf1a5613677705e7d0" + [[projects]] digest = "1:3f68283c56d93b885f33c679708079e834815138649e9f59ffbc572c2993e0f8" name = "github.com/robfig/cron" @@ -382,10 +452,12 @@ version = "v1.2.0" [[projects]] - digest = "1:9c94d918a2ac65f60d6b7895b2e9612e4554b40ee2446f2f807cadb3e57da7e2" + digest = "1:ab3259b9f5008a18ff8c1cc34623eccce354f3a9faf5b409983cd6717d64b40b" name = "golang.org/x/crypto" packages = [ "cast5", + "ed25519", + "ed25519/internal/edwards25519", "openpgp", "openpgp/armor", "openpgp/clearsign", @@ -400,11 +472,14 @@ revision = "5f961cd492ac9d43fc33a8ef646bae79d113fd97" [[projects]] - digest = "1:3373df9a79dbfdec0111498a2358444eee9f70c3748ee0e0c2a1ad993978f676" + digest = "1:2a465dcd21dc1094bd90bc28adc168d5c12d4d754b49d67b34362d26bd5c21b2" name = "golang.org/x/net" packages = [ "context", "context/ctxhttp", + "http2", + "http2/hpack", + "lex/httplex", ] pruneopts = "UT" revision = "075e191f18186a8ff2becaf64478e30f4545cdad" @@ -428,6 +503,14 @@ pruneopts = "UT" revision = "571f7bbbe08da2a8955aed9d4db316e78630e9a3" +[[projects]] + branch = "master" + digest = "1:9fdc2b55e8e0fafe4b41884091e51e77344f7dc511c5acedcfd98200003bff90" + name = "golang.org/x/time" + packages = ["rate"] + pruneopts = "UT" + revision = "9d24e82272b4f38b78bc8cff74fa936d31ccd8ef" + [[projects]] digest = "1:52133d6859535332391e6193c8878d06347f28881111efa900392802485e9a18" name = "google.golang.org/appengine" @@ -451,6 +534,14 @@ revision = "4e86f4367175e39f69d9358a5f17b4dda270378d" version = "v1.1" +[[projects]] + digest = "1:2d1fbdc6777e5408cabeb02bf336305e724b925ff4546ded0fa8715a7267922a" + name = "gopkg.in/inf.v0" + packages = ["."] + pruneopts = "UT" + revision = "d2d2541c53f18d2a059457998ce2876cc8e67cbf" + version = "v0.9.1" + [[projects]] digest = "1:79691acfc86fc3204928daf67e44955e8021ec5e10091599d344b0e16de32236" name = "gopkg.in/ldap.v2" @@ -459,6 +550,18 @@ revision = "8168ee085ee43257585e50c6441aadf54ecb2c9f" version = "v2.5.0" +[[projects]] + digest = "1:c0c30f47f9c16f227ba82f0bdfd14fa968453c30b7677a07903b3b4f34b98d49" + name = "gopkg.in/square/go-jose.v2" + packages = [ + ".", + "cipher", + "json", + ] + pruneopts = "UT" + revision = "628223f44a71f715d2881ea69afc795a1e9c01be" + version = "v2.3.0" + [[projects]] digest = "1:2a81c6e126d36ad027328cffaa4888fc3be40f09dc48028d1f93705b718130b9" name = "gopkg.in/yaml.v2" @@ -467,18 +570,108 @@ revision = "7f97868eec74b32b0982dd158a51a446d1da7eb5" version = "v2.1.1" +[[projects]] + digest = "1:7727a365529cdf6af394821dd990b046c56b8afac31e15e78fed58cf7bc179ad" + name = "k8s.io/api" + packages = [ + "admissionregistration/v1alpha1", + "admissionregistration/v1beta1", + "apps/v1", + "apps/v1beta1", + "apps/v1beta2", + "authentication/v1", + "authentication/v1beta1", + "authorization/v1", + "authorization/v1beta1", + "autoscaling/v1", + "autoscaling/v2beta1", + "batch/v1", + "batch/v1beta1", + "batch/v2alpha1", + "certificates/v1beta1", + "core/v1", + "events/v1beta1", + "extensions/v1beta1", + "networking/v1", + "policy/v1beta1", + "rbac/v1", + "rbac/v1alpha1", + "rbac/v1beta1", + "scheduling/v1alpha1", + "scheduling/v1beta1", + "settings/v1alpha1", + "storage/v1", + "storage/v1alpha1", + "storage/v1beta1", + ] + pruneopts = "UT" + revision = "5cb15d34447165a97c76ed5a60e4e99c8a01ecfe" + version = "kubernetes-1.13.4" + [[projects]] branch = "master" - digest = "1:8dce42a5fac31cbfdc756bd244389280a905a5364b21dd44cdcb044ee622bf8b" + digest = "1:d0d43cf61b49d2750351759e1d220134ab7731db608b6716dc4ed792a493027d" name = "k8s.io/apimachinery" - packages = ["pkg/version"] + packages = [ + "pkg/api/errors", + "pkg/api/resource", + "pkg/apis/meta/v1", + "pkg/apis/meta/v1/unstructured", + "pkg/conversion", + "pkg/conversion/queryparams", + "pkg/fields", + "pkg/labels", + "pkg/runtime", + "pkg/runtime/schema", + "pkg/runtime/serializer", + "pkg/runtime/serializer/json", + "pkg/runtime/serializer/protobuf", + "pkg/runtime/serializer/recognizer", + "pkg/runtime/serializer/streaming", + "pkg/runtime/serializer/versioning", + "pkg/selection", + "pkg/types", + "pkg/util/clock", + "pkg/util/errors", + "pkg/util/framer", + "pkg/util/intstr", + "pkg/util/json", + "pkg/util/net", + "pkg/util/runtime", + "pkg/util/sets", + "pkg/util/validation", + "pkg/util/validation/field", + "pkg/util/wait", + "pkg/util/yaml", + "pkg/version", + "pkg/watch", + "third_party/forked/golang/reflect", + ] pruneopts = "UT" revision = "f534d624797b270e5e46104dc7e2c2d61edbb85d" [[projects]] - digest = "1:bf83d8940d59a175dad5ba323b47c6e62b3ba2dc9b2d449f629288deeeeb6582" + digest = "1:b2a0bdcfc59bed6a64d3ade946f9bf807f8fcd105892d940a008b0b2816babe5" name = "k8s.io/client-go" - packages = ["util/homedir"] + packages = [ + "kubernetes/scheme", + "kubernetes/typed/authentication/v1beta1", + "pkg/apis/clientauthentication", + "pkg/apis/clientauthentication/v1alpha1", + "pkg/apis/clientauthentication/v1beta1", + "pkg/version", + "plugin/pkg/client/auth/exec", + "rest", + "rest/watch", + "tools/clientcmd/api", + "tools/metrics", + "transport", + "util/cert", + "util/connrotation", + "util/flowcontrol", + "util/homedir", + "util/integer", + ] pruneopts = "UT" revision = "7d04d0e2a0a1a4d4a1cd6baa432a2301492e4e65" version = "v8.0.0" @@ -525,6 +718,7 @@ "github.com/casbin/casbin/model", "github.com/casbin/casbin/persist", "github.com/casbin/casbin/util", + "github.com/coreos/go-oidc", "github.com/dghubble/sling", "github.com/dgrijalva/jwt-go", "github.com/docker/distribution", @@ -564,6 +758,10 @@ "golang.org/x/oauth2/clientcredentials", "gopkg.in/ldap.v2", "gopkg.in/yaml.v2", + "k8s.io/api/authentication/v1beta1", + "k8s.io/apimachinery/pkg/apis/meta/v1", + "k8s.io/client-go/kubernetes/typed/authentication/v1beta1", + "k8s.io/client-go/rest", "k8s.io/helm/cmd/helm/search", "k8s.io/helm/pkg/chartutil", "k8s.io/helm/pkg/proto/hapi/chart", diff --git a/src/Gopkg.toml b/src/Gopkg.toml index c5666c20a..145dc2681 100644 --- a/src/Gopkg.toml +++ b/src/Gopkg.toml @@ -112,6 +112,14 @@ ignored = ["github.com/goharbor/harbor/tests*"] name = "github.com/robfig/cron" version = "=1.0" +[[constraint]] + name = "github.com/coreos/go-oidc" + version = "=2.0.0" + [[constraint]] name = "gopkg.in/yaml.v2" version = "=2.1.1" + +[[constraint]] + name = "k8s.io/api" + version = "kubernetes-1.13.4" diff --git a/src/common/config/manager.go b/src/common/config/manager.go index fb70b1b3d..0df6eaa47 100644 --- a/src/common/config/manager.go +++ b/src/common/config/manager.go @@ -17,6 +17,7 @@ package config import ( "fmt" "os" + "sync" "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/common/config/metadata" @@ -52,6 +53,7 @@ func NewRESTCfgManager(configURL, secret string) *CfgManager { // InMemoryDriver driver for unit testing type InMemoryDriver struct { + sync.Mutex cfgMap map[string]interface{} } @@ -59,11 +61,19 @@ type InMemoryDriver struct { // it should be invoked before get any user scope config // for system scope config, because it is immutable, no need to call this method func (d *InMemoryDriver) Load() (map[string]interface{}, error) { - return d.cfgMap, nil + d.Lock() + defer d.Unlock() + res := make(map[string]interface{}) + for k, v := range d.cfgMap { + res[k] = v + } + return res, nil } // Save only save user config setting to driver, for example: database, REST func (d *InMemoryDriver) Save(cfg map[string]interface{}) error { + d.Lock() + defer d.Unlock() for k, v := range cfg { d.cfgMap[k] = v } diff --git a/src/common/config/metadata/metadatalist.go b/src/common/config/metadata/metadatalist.go index 3a2caf0d9..89f037d80 100644 --- a/src/common/config/metadata/metadatalist.go +++ b/src/common/config/metadata/metadatalist.go @@ -132,6 +132,7 @@ var ( {Name: "uaa_verify_cert", Scope: UserScope, Group: UAAGroup, EnvKey: "UAA_VERIFY_CERT", DefaultValue: "false", ItemType: &BoolType{}, Editable: false}, {Name: common.HTTPAuthProxyEndpoint, Scope: UserScope, Group: HTTPAuthGroup, ItemType: &StringType{}}, + {Name: common.HTTPAuthProxyTokenReviewEndpoint, Scope: UserScope, Group: HTTPAuthGroup, ItemType: &StringType{}}, {Name: common.HTTPAuthProxySkipCertVerify, Scope: UserScope, Group: HTTPAuthGroup, DefaultValue: "false", ItemType: &BoolType{}}, {Name: common.HTTPAuthProxyAlwaysOnboard, Scope: UserScope, Group: HTTPAuthGroup, DefaultValue: "false", ItemType: &BoolType{}}, diff --git a/src/common/config/metadata/type.go b/src/common/config/metadata/type.go index b9fb36d3e..6ed790c97 100644 --- a/src/common/config/metadata/type.go +++ b/src/common/config/metadata/type.go @@ -61,11 +61,11 @@ type AuthModeType struct { } func (t *AuthModeType) validate(str string) error { - if str == common.LDAPAuth || str == common.DBAuth || str == common.UAAAuth || str == common.HTTPAuth { + if str == common.LDAPAuth || str == common.DBAuth || str == common.UAAAuth || str == common.HTTPAuth || str == common.OIDCAuth { return nil } - return fmt.Errorf("invalid %s, shoud be one of %s, %s, %s, %s", - common.AUTHMode, common.DBAuth, common.LDAPAuth, common.UAAAuth, common.HTTPAuth) + return fmt.Errorf("invalid %s, shoud be one of %s, %s, %s, %s, %s", + common.AUTHMode, common.DBAuth, common.LDAPAuth, common.UAAAuth, common.HTTPAuth, common.OIDCAuth) } // ProjectCreationRestrictionType ... diff --git a/src/common/const.go b/src/common/const.go index 364d071bf..914759909 100644 --- a/src/common/const.go +++ b/src/common/const.go @@ -42,69 +42,70 @@ const ( ResourceTypeImage = "i" ResourceTypeChart = "c" - ExtEndpoint = "ext_endpoint" - AUTHMode = "auth_mode" - DatabaseType = "database_type" - PostGreSQLHOST = "postgresql_host" - PostGreSQLPort = "postgresql_port" - PostGreSQLUsername = "postgresql_username" - PostGreSQLPassword = "postgresql_password" - PostGreSQLDatabase = "postgresql_database" - PostGreSQLSSLMode = "postgresql_sslmode" - SelfRegistration = "self_registration" - CoreURL = "core_url" - JobServiceURL = "jobservice_url" - LDAPURL = "ldap_url" - LDAPSearchDN = "ldap_search_dn" - LDAPSearchPwd = "ldap_search_password" - LDAPBaseDN = "ldap_base_dn" - LDAPUID = "ldap_uid" - LDAPFilter = "ldap_filter" - LDAPScope = "ldap_scope" - LDAPTimeout = "ldap_timeout" - LDAPVerifyCert = "ldap_verify_cert" - LDAPGroupBaseDN = "ldap_group_base_dn" - LDAPGroupSearchFilter = "ldap_group_search_filter" - LDAPGroupAttributeName = "ldap_group_attribute_name" - LDAPGroupSearchScope = "ldap_group_search_scope" - TokenServiceURL = "token_service_url" - RegistryURL = "registry_url" - EmailHost = "email_host" - EmailPort = "email_port" - EmailUsername = "email_username" - EmailPassword = "email_password" - EmailFrom = "email_from" - EmailSSL = "email_ssl" - EmailIdentity = "email_identity" - EmailInsecure = "email_insecure" - ProjectCreationRestriction = "project_creation_restriction" - MaxJobWorkers = "max_job_workers" - TokenExpiration = "token_expiration" - CfgExpiration = "cfg_expiration" - AdminInitialPassword = "admin_initial_password" - AdmiralEndpoint = "admiral_url" - WithNotary = "with_notary" - WithClair = "with_clair" - ScanAllPolicy = "scan_all_policy" - ClairDBPassword = "clair_db_password" - ClairDBHost = "clair_db_host" - ClairDBPort = "clair_db_port" - ClairDB = "clair_db" - ClairDBUsername = "clair_db_username" - ClairDBSSLMode = "clair_db_sslmode" - UAAEndpoint = "uaa_endpoint" - UAAClientID = "uaa_client_id" - UAAClientSecret = "uaa_client_secret" - UAAVerifyCert = "uaa_verify_cert" - HTTPAuthProxyEndpoint = "http_authproxy_endpoint" - HTTPAuthProxySkipCertVerify = "http_authproxy_skip_cert_verify" - HTTPAuthProxyAlwaysOnboard = "http_authproxy_always_onboard" - OIDCName = "oidc_name" - OIDCEndpoint = "oidc_endpoint" - OIDCCLientID = "oidc_client_id" - OIDCClientSecret = "oidc_client_secret" - OIDCSkipCertVerify = "oidc_skip_cert_verify" - OIDCScope = "oidc_scope" + ExtEndpoint = "ext_endpoint" + AUTHMode = "auth_mode" + DatabaseType = "database_type" + PostGreSQLHOST = "postgresql_host" + PostGreSQLPort = "postgresql_port" + PostGreSQLUsername = "postgresql_username" + PostGreSQLPassword = "postgresql_password" + PostGreSQLDatabase = "postgresql_database" + PostGreSQLSSLMode = "postgresql_sslmode" + SelfRegistration = "self_registration" + CoreURL = "core_url" + JobServiceURL = "jobservice_url" + LDAPURL = "ldap_url" + LDAPSearchDN = "ldap_search_dn" + LDAPSearchPwd = "ldap_search_password" + LDAPBaseDN = "ldap_base_dn" + LDAPUID = "ldap_uid" + LDAPFilter = "ldap_filter" + LDAPScope = "ldap_scope" + LDAPTimeout = "ldap_timeout" + LDAPVerifyCert = "ldap_verify_cert" + LDAPGroupBaseDN = "ldap_group_base_dn" + LDAPGroupSearchFilter = "ldap_group_search_filter" + LDAPGroupAttributeName = "ldap_group_attribute_name" + LDAPGroupSearchScope = "ldap_group_search_scope" + TokenServiceURL = "token_service_url" + RegistryURL = "registry_url" + EmailHost = "email_host" + EmailPort = "email_port" + EmailUsername = "email_username" + EmailPassword = "email_password" + EmailFrom = "email_from" + EmailSSL = "email_ssl" + EmailIdentity = "email_identity" + EmailInsecure = "email_insecure" + ProjectCreationRestriction = "project_creation_restriction" + MaxJobWorkers = "max_job_workers" + TokenExpiration = "token_expiration" + CfgExpiration = "cfg_expiration" + AdminInitialPassword = "admin_initial_password" + AdmiralEndpoint = "admiral_url" + WithNotary = "with_notary" + WithClair = "with_clair" + ScanAllPolicy = "scan_all_policy" + ClairDBPassword = "clair_db_password" + ClairDBHost = "clair_db_host" + ClairDBPort = "clair_db_port" + ClairDB = "clair_db" + ClairDBUsername = "clair_db_username" + ClairDBSSLMode = "clair_db_sslmode" + UAAEndpoint = "uaa_endpoint" + UAAClientID = "uaa_client_id" + UAAClientSecret = "uaa_client_secret" + UAAVerifyCert = "uaa_verify_cert" + HTTPAuthProxyEndpoint = "http_authproxy_endpoint" + HTTPAuthProxyTokenReviewEndpoint = "http_authproxy_tokenreview_endpoint" + HTTPAuthProxySkipCertVerify = "http_authproxy_skip_cert_verify" + HTTPAuthProxyAlwaysOnboard = "http_authproxy_always_onboard" + OIDCName = "oidc_name" + OIDCEndpoint = "oidc_endpoint" + OIDCCLientID = "oidc_client_id" + OIDCClientSecret = "oidc_client_secret" + OIDCSkipCertVerify = "oidc_skip_cert_verify" + OIDCScope = "oidc_scope" DefaultClairEndpoint = "http://clair:6060" CfgDriverDB = "db" @@ -128,9 +129,11 @@ const ( DefaultRegistryCtlURL = "http://registryctl:8080" DefaultClairHealthCheckServerURL = "http://clair:6061" // Use this prefix to distinguish harbor user, the prefix contains a special character($), so it cannot be registered as a harbor user. - RobotPrefix = "robot$" - CoreConfigPath = "/api/internal/configurations" - RobotTokenDuration = "robot_token_duration" + RobotPrefix = "robot$" + // Use this prefix to index user who tries to login with web hook token. + AuthProxyUserNamePrefix = "tokenreview$" + CoreConfigPath = "/api/internal/configurations" + RobotTokenDuration = "robot_token_duration" - OIDCCallbackPath = "/c/oidc_callback" + OIDCCallbackPath = "/c/oidc/callback" ) diff --git a/src/common/dao/oidc_user.go b/src/common/dao/oidc_user.go new file mode 100644 index 000000000..6045cff36 --- /dev/null +++ b/src/common/dao/oidc_user.go @@ -0,0 +1,168 @@ +// Copyright Project Harbor Authors +// +// 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. + +package dao + +import ( + "fmt" + "strings" + "time" + + "github.com/astaxie/beego/orm" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/pkg/errors" +) + +var ( + // ErrDupUser ... + ErrDupUser = errors.New("sql: duplicate user in harbor_user") + + // ErrRollBackUser ... + ErrRollBackUser = errors.New("sql: transaction roll back error in harbor_user") + + // ErrDupOIDCUser ... + ErrDupOIDCUser = errors.New("sql: duplicate user in oicd_user") + + // ErrRollBackOIDCUser ... + ErrRollBackOIDCUser = errors.New("sql: transaction roll back error in oicd_user") +) + +// GetOIDCUserByID ... +func GetOIDCUserByID(id int64) (*models.OIDCUser, error) { + oidcUser := &models.OIDCUser{ + ID: id, + } + if err := GetOrmer().Read(oidcUser); err != nil { + if err == orm.ErrNoRows { + return nil, nil + } + return nil, err + } + + return oidcUser, nil +} + +// GetUserBySubIss ... +func GetUserBySubIss(sub, issuer string) (*models.User, error) { + var oidcUsers []models.OIDCUser + n, err := GetOrmer().Raw(`select * from oidc_user where subiss = ? `, sub+issuer).QueryRows(&oidcUsers) + if err != nil { + return nil, err + } + if n == 0 { + return nil, nil + } + + user, err := GetUser(models.User{ + UserID: oidcUsers[0].UserID, + }) + if err != nil { + return nil, err + } + if user == nil { + return nil, fmt.Errorf("can not get user %d", oidcUsers[0].UserID) + } + + return user, nil +} + +// GetOIDCUserByUserID ... +func GetOIDCUserByUserID(userID int) (*models.OIDCUser, error) { + var oidcUsers []models.OIDCUser + n, err := GetOrmer().Raw(`select * from oidc_user where user_id = ? `, userID).QueryRows(&oidcUsers) + if err != nil { + return nil, err + } + if n == 0 { + return nil, nil + } + + return &oidcUsers[0], nil +} + +// UpdateOIDCUser ... +func UpdateOIDCUser(oidcUser *models.OIDCUser) error { + oidcUser.UpdateTime = time.Now() + _, err := GetOrmer().Update(oidcUser) + return err +} + +// DeleteOIDCUser ... +func DeleteOIDCUser(id int64) error { + _, err := GetOrmer().QueryTable(&models.OIDCUser{}).Filter("ID", id).Delete() + return err +} + +// OnBoardOIDCUser onboard OIDC user +// For the api caller, should only care about the ErrDupUser. It could lead to http.StatusConflict. +func OnBoardOIDCUser(u *models.User) error { + if u.OIDCUserMeta == nil { + return errors.New("unable to onboard as empty oidc user") + } + + o := orm.NewOrm() + err := o.Begin() + if err != nil { + return err + } + var errInsert error + + // insert user + now := time.Now() + u.CreationTime = now + userID, err := o.Insert(u) + if err != nil { + errInsert = err + log.Errorf("fail to insert user, %v", err) + if strings.Contains(err.Error(), "duplicate key value violates unique constraint") { + errInsert = errors.Wrap(errInsert, ErrDupUser.Error()) + } + err := o.Rollback() + if err != nil { + log.Errorf("fail to rollback when to onboard oidc user, %v", err) + errInsert = errors.Wrap(errInsert, err.Error()) + return errors.Wrap(errInsert, ErrRollBackUser.Error()) + } + return errInsert + + } + u.UserID = int(userID) + u.OIDCUserMeta.UserID = int(userID) + + // insert oidc user + now = time.Now() + u.OIDCUserMeta.CreationTime = now + _, err = o.Insert(u.OIDCUserMeta) + if err != nil { + errInsert = err + log.Errorf("fail to insert oidc user, %v", err) + if strings.Contains(err.Error(), "duplicate key value violates unique constraint") { + errInsert = errors.Wrap(errInsert, ErrDupOIDCUser.Error()) + } + err := o.Rollback() + if err != nil { + errInsert = errors.Wrap(errInsert, err.Error()) + return errors.Wrap(errInsert, ErrRollBackOIDCUser.Error()) + } + return errInsert + } + err = o.Commit() + if err != nil { + log.Errorf("fail to commit when to onboard oidc user, %v", err) + return fmt.Errorf("fail to commit when to onboard oidc user, %v", err) + } + + return nil +} diff --git a/src/common/dao/oidc_user_test.go b/src/common/dao/oidc_user_test.go new file mode 100644 index 000000000..7ec303c9c --- /dev/null +++ b/src/common/dao/oidc_user_test.go @@ -0,0 +1,186 @@ +// Copyright Project Harbor Authors +// +// 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. + +package dao + +import ( + "testing" + + "github.com/goharbor/harbor/src/common/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOIDCUserMetaDaoMethods(t *testing.T) { + + user111 := models.User{ + Username: "user111", + Email: "user111@email.com", + } + user222 := models.User{ + Username: "user222", + Email: "user222@email.com", + } + userEmptyOuMeta := models.User{ + Username: "userEmptyOuMeta", + Email: "userEmptyOuMeta@email.com", + } + ou111 := models.OIDCUser{ + SubIss: "QWE123123RT1", + Secret: "QWEQWE1", + } + ou222 := models.OIDCUser{ + SubIss: "QWE123123RT2", + Secret: "QWEQWE2", + } + + // onboard OIDC ... + user111.OIDCUserMeta = &ou111 + err := OnBoardOIDCUser(&user111) + require.Nil(t, err) + defer CleanUser(int64(user111.UserID)) + user222.OIDCUserMeta = &ou222 + err = OnBoardOIDCUser(&user222) + require.Nil(t, err) + defer CleanUser(int64(user222.UserID)) + + // empty OIDC user meta ... + err = OnBoardOIDCUser(&userEmptyOuMeta) + require.NotNil(t, err) + assert.Equal(t, "unable to onboard as empty oidc user", err.Error()) + + // test get by ID + oidcUser1, err := GetOIDCUserByID(ou111.ID) + require.Nil(t, err) + assert.Equal(t, ou111.UserID, oidcUser1.UserID) + + // test get by userID + oidcUser2, err := GetOIDCUserByUserID(user111.UserID) + require.Nil(t, err) + assert.Equal(t, "QWE123123RT1", oidcUser2.SubIss) + + // test get by sub and iss + userGetBySubIss, err := GetUserBySubIss("QWE123", "123RT1") + require.Nil(t, err) + assert.Equal(t, "user111@email.com", userGetBySubIss.Email) + + // test update + meta3 := &models.OIDCUser{ + ID: ou111.ID, + UserID: ou111.UserID, + SubIss: "newSub", + } + require.Nil(t, UpdateOIDCUser(meta3)) + oidcUser1Update, err := GetOIDCUserByID(ou111.ID) + require.Nil(t, err) + assert.Equal(t, "newSub", oidcUser1Update.SubIss) + + user, err := GetUserBySubIss("new", "Sub") + require.Nil(t, err) + assert.Equal(t, "user111", user.Username) + + // clear data + defer func() { + _, err := GetOrmer().Raw(`delete from oidc_user`).Exec() + require.Nil(t, err) + }() +} + +func TestOIDCOnboard(t *testing.T) { + user333 := models.User{ + Username: "user333", + Email: "user333@email.com", + } + user555 := models.User{ + Username: "user555", + Email: "user555@email.com", + } + user666 := models.User{ + Username: "user666", + Email: "user666@email.com", + } + userDup := models.User{ + Username: "user333", + Email: "userDup@email.com", + } + + ou333 := &models.OIDCUser{ + SubIss: "QWE123123RT3", + Secret: "QWEQWE333", + } + ou555 := &models.OIDCUser{ + SubIss: "QWE123123RT5", + Secret: "QWEQWE555", + } + ouDup := &models.OIDCUser{ + SubIss: "QWE123123RT3", + Secret: "QWEQWE333", + } + ouDupSub := &models.OIDCUser{ + SubIss: "QWE123123RT3", + Secret: "ouDupSub", + } + + // data prepare ... + user333.OIDCUserMeta = ou333 + err := OnBoardOIDCUser(&user333) + require.Nil(t, err) + defer CleanUser(int64(user333.UserID)) + + // duplicate user -- ErrDupRows + // userDup is duplicate with user333 + userDup.OIDCUserMeta = ou555 + err = OnBoardOIDCUser(&userDup) + require.NotNil(t, err) + require.Contains(t, err.Error(), ErrDupUser.Error()) + exist, err := UserExists(userDup, "email") + require.Nil(t, err) + require.False(t, exist) + + // duplicate OIDC user -- ErrDupRows + // ouDup is duplicate with ou333 + user555.OIDCUserMeta = ouDup + err = OnBoardOIDCUser(&user555) + require.NotNil(t, err) + require.Contains(t, err.Error(), ErrDupOIDCUser.Error()) + exist, err = UserExists(user555, "username") + require.Nil(t, err) + require.False(t, exist) + + // success + user555.OIDCUserMeta = ou555 + err = OnBoardOIDCUser(&user555) + require.Nil(t, err) + exist, err = UserExists(user555, "username") + require.Nil(t, err) + require.True(t, exist) + defer CleanUser(int64(user555.UserID)) + + // duplicate OIDC user's sub -- ErrDupRows + // ouDup is duplicate with ou333 + user666.OIDCUserMeta = ouDupSub + err = OnBoardOIDCUser(&user666) + require.NotNil(t, err) + require.Contains(t, err.Error(), ErrDupOIDCUser.Error()) + exist, err = UserExists(user666, "username") + require.Nil(t, err) + require.False(t, exist) + + // clear data + defer func() { + _, err := GetOrmer().Raw(`delete from oidc_user`).Exec() + require.Nil(t, err) + }() + +} diff --git a/src/common/models/base.go b/src/common/models/base.go index 6bf3e525f..edf7de596 100644 --- a/src/common/models/base.go +++ b/src/common/models/base.go @@ -38,5 +38,6 @@ func init() { new(UserGroup), new(AdminJob), new(JobLog), - new(Robot)) + new(Robot), + new(OIDCUser)) } diff --git a/src/common/models/config.go b/src/common/models/config.go index 9bd145e3f..8d757256d 100644 --- a/src/common/models/config.go +++ b/src/common/models/config.go @@ -67,9 +67,10 @@ type Email struct { // HTTPAuthProxy wraps the settings for HTTP auth proxy type HTTPAuthProxy struct { - Endpoint string `json:"endpoint"` - SkipCertVerify bool `json:"skip_cert_verify"` - AlwaysOnBoard bool `json:"always_onboard"` + Endpoint string `json:"endpoint"` + TokenReviewEndpoint string `json:"tokenreivew_endpoint"` + SkipCertVerify bool `json:"skip_cert_verify"` + AlwaysOnBoard bool `json:"always_onboard"` } // OIDCSetting wraps the settings for OIDC auth endpoint diff --git a/src/common/models/oidc_user.go b/src/common/models/oidc_user.go new file mode 100644 index 000000000..5d6c66c35 --- /dev/null +++ b/src/common/models/oidc_user.go @@ -0,0 +1,20 @@ +package models + +import ( + "time" +) + +// OIDCUser ... +type OIDCUser struct { + ID int64 `orm:"pk;auto;column(id)" json:"id"` + UserID int `orm:"column(user_id)" json:"user_id"` + Secret string `orm:"column(secret)" json:"secret"` + SubIss string `orm:"column(subiss)" json:"subiss"` + CreationTime time.Time `orm:"column(creation_time);auto_now_add" json:"creation_time"` + UpdateTime time.Time `orm:"column(update_time);auto_now" json:"update_time"` +} + +// TableName ... +func (o *OIDCUser) TableName() string { + return "oidc_user" +} diff --git a/src/common/models/user.go b/src/common/models/user.go index c638cff8c..9b224bd80 100644 --- a/src/common/models/user.go +++ b/src/common/models/user.go @@ -41,6 +41,7 @@ type User struct { CreationTime time.Time `orm:"column(creation_time);auto_now_add" json:"creation_time"` UpdateTime time.Time `orm:"column(update_time);auto_now" json:"update_time"` GroupList []*UserGroup `orm:"-" json:"-"` + OIDCUserMeta *OIDCUser `orm:"-" json:"oidc_user_meta,omitempty"` } // UserQuery ... diff --git a/src/common/utils/oidc/helper.go b/src/common/utils/oidc/helper.go new file mode 100644 index 000000000..75a45f6b1 --- /dev/null +++ b/src/common/utils/oidc/helper.go @@ -0,0 +1,188 @@ +// Copyright 2018 Project Harbor Authors +// +// 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. + +package oidc + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + gooidc "github.com/coreos/go-oidc" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/utils/log" + "github.com/goharbor/harbor/src/core/config" + "golang.org/x/oauth2" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" +) + +const googleEndpoint = "https://accounts.google.com" + +type providerHelper struct { + sync.Mutex + ep endpoint + instance atomic.Value + setting atomic.Value +} + +type endpoint struct { + url string + skipCertVerify bool +} + +func (p *providerHelper) get() (*gooidc.Provider, error) { + if p.instance.Load() != nil { + s := p.setting.Load().(models.OIDCSetting) + if s.Endpoint != p.ep.url || s.SkipCertVerify != p.ep.skipCertVerify { // relevant settings have changed, need to re-create provider. + if err := p.create(); err != nil { + return nil, err + } + } + } else { + p.Lock() + defer p.Unlock() + if p.instance.Load() == nil { + if err := p.reload(); err != nil { + return nil, err + } + if err := p.create(); err != nil { + return nil, err + } + go func() { + for { + if err := p.reload(); err != nil { + log.Warningf("Failed to refresh configuration, error: %v", err) + } + time.Sleep(3 * time.Second) + } + }() + } + } + return p.instance.Load().(*gooidc.Provider), nil +} + +func (p *providerHelper) reload() error { + conf, err := config.OIDCSetting() + if err != nil { + return fmt.Errorf("failed to load OIDC setting: %v", err) + } + p.setting.Store(*conf) + return nil +} + +func (p *providerHelper) create() error { + if p.setting.Load() == nil { + return errors.New("the configuration is not loaded") + } + s := p.setting.Load().(models.OIDCSetting) + var client *http.Client + if s.SkipCertVerify { + client = &http.Client{ + Transport: insecureTransport, + } + } else { + client = &http.Client{} + } + ctx := context.Background() + gooidc.ClientContext(ctx, client) + provider, err := gooidc.NewProvider(ctx, s.Endpoint) + if err != nil { + return fmt.Errorf("failed to create OIDC provider, error: %v", err) + } + p.instance.Store(provider) + p.ep = endpoint{ + url: s.Endpoint, + skipCertVerify: s.SkipCertVerify, + } + return nil +} + +var provider = &providerHelper{} + +var insecureTransport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, +} + +// Token wraps the attributes of a oauth2 token plus the attribute of ID token +type Token struct { + *oauth2.Token + IDToken string `json:"id_token"` +} + +func getOauthConf() (*oauth2.Config, error) { + p, err := provider.get() + if err != nil { + return nil, err + } + setting := provider.setting.Load().(models.OIDCSetting) + scopes := []string{} + for _, sc := range setting.Scope { + if strings.HasPrefix(p.Endpoint().AuthURL, googleEndpoint) && sc == gooidc.ScopeOfflineAccess { + log.Warningf("Dropped unsupported scope: %s ", sc) + continue + } + scopes = append(scopes, sc) + } + return &oauth2.Config{ + ClientID: setting.ClientID, + ClientSecret: setting.ClientSecret, + Scopes: scopes, + RedirectURL: setting.RedirectURL, + Endpoint: p.Endpoint(), + }, nil +} + +// AuthCodeURL returns the URL for OIDC provider's consent page. The state should be verified when user is redirected +// back to Harbor. +func AuthCodeURL(state string) (string, error) { + conf, err := getOauthConf() + if err != nil { + log.Errorf("Failed to get OAuth configuration, error: %v", err) + return "", err + } + if strings.HasPrefix(conf.Endpoint.AuthURL, googleEndpoint) { + return conf.AuthCodeURL(state, oauth2.AccessTypeOffline), nil + } + return conf.AuthCodeURL(state), nil +} + +// ExchangeToken get the token from token provider via the code +func ExchangeToken(ctx context.Context, code string) (*Token, error) { + oauth, err := getOauthConf() + if err != nil { + log.Errorf("Failed to get OAuth configuration, error: %v", err) + return nil, err + } + oauthToken, err := oauth.Exchange(ctx, code) + if err != nil { + return nil, err + } + return &Token{Token: oauthToken, IDToken: oauthToken.Extra("id_token").(string)}, nil +} + +// VerifyToken verifies the ID token based on the OIDC settings +func VerifyToken(ctx context.Context, rawIDToken string) (*gooidc.IDToken, error) { + p, err := provider.get() + if err != nil { + return nil, err + } + verifier := p.Verifier(&gooidc.Config{ClientID: provider.setting.Load().(models.OIDCSetting).ClientID}) + return verifier.Verify(ctx, rawIDToken) +} diff --git a/src/common/utils/oidc/helper_test.go b/src/common/utils/oidc/helper_test.go new file mode 100644 index 000000000..6e4f357e2 --- /dev/null +++ b/src/common/utils/oidc/helper_test.go @@ -0,0 +1,98 @@ +// Copyright 2018 Project Harbor Authors +// +// 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. + +package oidc + +import ( + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/core/config" + "github.com/stretchr/testify/assert" + "net/url" + "os" + "strings" + "testing" + "time" +) + +func TestMain(m *testing.M) { + conf := map[string]interface{}{ + common.OIDCName: "test", + common.OIDCEndpoint: "https://accounts.google.com", + common.OIDCSkipCertVerify: "false", + common.OIDCScope: "openid, profile, offline_access", + common.OIDCCLientID: "client", + common.OIDCClientSecret: "secret", + common.ExtEndpoint: "https://harbor.test", + } + + config.InitWithSettings(conf) + + result := m.Run() + if result != 0 { + os.Exit(result) + } +} +func TestHelperLoadConf(t *testing.T) { + testP := &providerHelper{} + assert.Nil(t, testP.setting.Load()) + err := testP.reload() + assert.Nil(t, err) + assert.Equal(t, "test", testP.setting.Load().(models.OIDCSetting).Name) + assert.Equal(t, endpoint{}, testP.ep) +} + +func TestHelperCreate(t *testing.T) { + testP := &providerHelper{} + err := testP.reload() + assert.Nil(t, err) + assert.Nil(t, testP.instance.Load()) + err = testP.create() + assert.Nil(t, err) + assert.EqualValues(t, "https://accounts.google.com", testP.ep.url) + assert.NotNil(t, testP.instance.Load()) +} + +func TestHelperGet(t *testing.T) { + testP := &providerHelper{} + p, err := testP.get() + assert.Nil(t, err) + assert.Equal(t, "https://oauth2.googleapis.com/token", p.Endpoint().TokenURL) + + update := map[string]interface{}{ + common.OIDCName: "test", + common.OIDCEndpoint: "https://accounts.google.com", + common.OIDCSkipCertVerify: "false", + common.OIDCScope: "openid, profile, offline_access", + common.OIDCCLientID: "client", + common.OIDCClientSecret: "new-secret", + common.ExtEndpoint: "https://harbor.test", + } + config.GetCfgManager().UpdateConfig(update) + + t.Log("Sleep for 5 seconds") + time.Sleep(5 * time.Second) + assert.Equal(t, "new-secret", testP.setting.Load().(models.OIDCSetting).ClientSecret) +} + +func TestAuthCodeURL(t *testing.T) { + res, err := AuthCodeURL("random") + assert.Nil(t, err) + u, err := url.ParseRequestURI(res) + assert.Nil(t, err) + q, err := url.ParseQuery(u.RawQuery) + assert.Nil(t, err) + assert.Equal(t, "offline", q.Get("access_type")) + assert.False(t, strings.Contains(q.Get("scope"), "offline_access")) +} diff --git a/src/common/utils/utils.go b/src/common/utils/utils.go index b9f11300e..cea54d342 100644 --- a/src/common/utils/utils.go +++ b/src/common/utils/utils.go @@ -234,3 +234,24 @@ func GetStrValueOfAnyType(value interface{}) string { } return strVal } + +// IsIllegalLength ... +func IsIllegalLength(s string, min int, max int) bool { + if min == -1 { + return (len(s) > max) + } + if max == -1 { + return (len(s) <= min) + } + return (len(s) < min || len(s) > max) +} + +// IsContainIllegalChar ... +func IsContainIllegalChar(s string, illegalChar []string) bool { + for _, c := range illegalChar { + if strings.Index(s, c) >= 0 { + return true + } + } + return false +} diff --git a/src/core/api/project.go b/src/core/api/project.go index 7771b7d77..3903f3861 100644 --- a/src/core/api/project.go +++ b/src/core/api/project.go @@ -508,10 +508,10 @@ func (p *ProjectAPI) Logs() { p.ServeJSON() } -// TODO move this to package models +// TODO move this to pa ckage models func validateProjectReq(req *models.ProjectRequest) error { pn := req.Name - if isIllegalLength(req.Name, projectNameMinLen, projectNameMaxLen) { + if utils.IsIllegalLength(req.Name, projectNameMinLen, projectNameMaxLen) { return fmt.Errorf("Project name is illegal in length. (greater than %d or less than %d)", projectNameMaxLen, projectNameMinLen) } validProjectName := regexp.MustCompile(`^` + restrictedNameChars + `$`) diff --git a/src/core/api/user.go b/src/core/api/user.go index f797096dd..765ed9da6 100644 --- a/src/core/api/user.go +++ b/src/core/api/user.go @@ -16,11 +16,6 @@ package api import ( "fmt" - "net/http" - "regexp" - "strconv" - "strings" - "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/common/dao" "github.com/goharbor/harbor/src/common/models" @@ -29,6 +24,9 @@ import ( "github.com/goharbor/harbor/src/common/utils" "github.com/goharbor/harbor/src/common/utils/log" "github.com/goharbor/harbor/src/core/config" + "net/http" + "regexp" + "strconv" ) // UserAPI handles request to /api/users/{} @@ -446,13 +444,13 @@ func (ua *UserAPI) modifiable() bool { // validate only validate when user register func validate(user models.User) error { - if isIllegalLength(user.Username, 1, 255) { + if utils.IsIllegalLength(user.Username, 1, 255) { return fmt.Errorf("username with illegal length") } - if isContainIllegalChar(user.Username, []string{",", "~", "#", "$", "%"}) { + if utils.IsContainIllegalChar(user.Username, []string{",", "~", "#", "$", "%"}) { return fmt.Errorf("username contains illegal characters") } - if isIllegalLength(user.Password, 8, 20) { + if utils.IsIllegalLength(user.Password, 8, 20) { return fmt.Errorf("password with illegal length") } return commonValidate(user) @@ -469,35 +467,16 @@ func commonValidate(user models.User) error { return fmt.Errorf("Email can't be empty") } - if isIllegalLength(user.Realname, 1, 255) { + if utils.IsIllegalLength(user.Realname, 1, 255) { return fmt.Errorf("realname with illegal length") } - if isContainIllegalChar(user.Realname, []string{",", "~", "#", "$", "%"}) { + if utils.IsContainIllegalChar(user.Realname, []string{",", "~", "#", "$", "%"}) { return fmt.Errorf("realname contains illegal characters") } - if isIllegalLength(user.Comment, -1, 30) { + if utils.IsIllegalLength(user.Comment, -1, 30) { return fmt.Errorf("comment with illegal length") } return nil } - -func isIllegalLength(s string, min int, max int) bool { - if min == -1 { - return (len(s) > max) - } - if max == -1 { - return (len(s) <= min) - } - return (len(s) < min || len(s) > max) -} - -func isContainIllegalChar(s string, illegalChar []string) bool { - for _, c := range illegalChar { - if strings.Index(s, c) >= 0 { - return true - } - } - return false -} diff --git a/src/core/config/config.go b/src/core/config/config.go index 4baa3193d..0b6049091 100644 --- a/src/core/config/config.go +++ b/src/core/config/config.go @@ -471,9 +471,10 @@ func HTTPAuthProxySetting() (*models.HTTPAuthProxy, error) { return nil, err } return &models.HTTPAuthProxy{ - Endpoint: cfgMgr.Get(common.HTTPAuthProxyEndpoint).GetString(), - SkipCertVerify: cfgMgr.Get(common.HTTPAuthProxySkipCertVerify).GetBool(), - AlwaysOnBoard: cfgMgr.Get(common.HTTPAuthProxyAlwaysOnboard).GetBool(), + Endpoint: cfgMgr.Get(common.HTTPAuthProxyEndpoint).GetString(), + TokenReviewEndpoint: cfgMgr.Get(common.HTTPAuthProxyTokenReviewEndpoint).GetString(), + SkipCertVerify: cfgMgr.Get(common.HTTPAuthProxySkipCertVerify).GetBool(), + AlwaysOnBoard: cfgMgr.Get(common.HTTPAuthProxyAlwaysOnboard).GetBool(), }, nil } diff --git a/src/core/config/config_test.go b/src/core/config/config_test.go index 82de10f28..be69533e4 100644 --- a/src/core/config/config_test.go +++ b/src/core/config/config_test.go @@ -260,6 +260,6 @@ func TestOIDCSetting(t *testing.T) { assert.True(t, v.SkipCertVerify) assert.Equal(t, "client", v.ClientID) assert.Equal(t, "secret", v.ClientSecret) - assert.Equal(t, "https://harbor.test/c/oidc_callback", v.RedirectURL) + assert.Equal(t, "https://harbor.test/c/oidc/callback", v.RedirectURL) assert.ElementsMatch(t, []string{"openid", "profile"}, v.Scope) } diff --git a/src/core/controllers/controllers_test.go b/src/core/controllers/controllers_test.go index 85cdeec7c..8d442f20b 100644 --- a/src/core/controllers/controllers_test.go +++ b/src/core/controllers/controllers_test.go @@ -138,4 +138,5 @@ func TestAll(t *testing.T) { w = httptest.NewRecorder() beego.BeeApp.Handlers.ServeHTTP(w, r) assert.Equal(int(404), w.Code, "GET v2/noproject/manifests/1.0 should get a 404 response") + } diff --git a/src/core/controllers/oidc.go b/src/core/controllers/oidc.go new file mode 100644 index 000000000..be1c9ff84 --- /dev/null +++ b/src/core/controllers/oidc.go @@ -0,0 +1,150 @@ +// Copyright 2018 Project Harbor Authors +// +// 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. + +package controllers + +import ( + "encoding/json" + "fmt" + "github.com/goharbor/harbor/src/common" + "github.com/goharbor/harbor/src/common/dao" + "github.com/goharbor/harbor/src/common/models" + "github.com/goharbor/harbor/src/common/utils" + "github.com/goharbor/harbor/src/common/utils/oidc" + "github.com/goharbor/harbor/src/core/api" + "github.com/goharbor/harbor/src/core/config" + "github.com/pkg/errors" + "net/http" + "strings" +) + +const idTokenKey = "oidc_id_token" +const stateKey = "oidc_state" + +// OIDCController handles requests for OIDC login, callback and user onboard +type OIDCController struct { + api.BaseController +} + +type oidcUserData struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Username string `json:"name"` + Email string `json:"email"` +} + +// Prepare include public code path for call request handler of OIDCController +func (oc *OIDCController) Prepare() { + if mode, _ := config.AuthMode(); mode != common.OIDCAuth { + oc.CustomAbort(http.StatusPreconditionFailed, fmt.Sprintf("Auth Mode: %s is not OIDC based.", mode)) + } +} + +// RedirectLogin redirect user's browser to OIDC provider's login page +func (oc *OIDCController) RedirectLogin() { + state := utils.GenerateRandomString() + url, err := oidc.AuthCodeURL(state) + if err != nil { + oc.RenderFormatedError(http.StatusInternalServerError, err) + return + } + oc.SetSession(stateKey, state) + // Force to use the func 'Redirect' of beego.Controller + oc.Controller.Redirect(url, http.StatusFound) +} + +// Callback handles redirection from OIDC provider. It will exchange the token and +// kick off onboard if needed. +func (oc *OIDCController) Callback() { + if oc.Ctx.Request.URL.Query().Get("state") != oc.GetSession(stateKey) { + oc.RenderError(http.StatusBadRequest, "State mismatch.") + return + } + code := oc.Ctx.Request.URL.Query().Get("code") + ctx := oc.Ctx.Request.Context() + token, err := oidc.ExchangeToken(ctx, code) + if err != nil { + oc.RenderFormatedError(http.StatusInternalServerError, err) + return + } + idToken, err := oidc.VerifyToken(ctx, token.IDToken) + if err != nil { + oc.RenderFormatedError(http.StatusInternalServerError, err) + return + } + d := &oidcUserData{} + err = idToken.Claims(d) + if err != nil { + oc.RenderFormatedError(http.StatusInternalServerError, err) + return + } + ouDataStr, err := json.Marshal(d) + if err != nil { + oc.RenderFormatedError(http.StatusInternalServerError, err) + return + } + oc.SetSession(idTokenKey, string(ouDataStr)) + // TODO: check and trigger onboard popup or redirect user to project page + oc.Data["json"] = d + oc.ServeFormatted() +} + +// Onboard handles the request to onboard an user authenticated via OIDC provider +func (oc *OIDCController) Onboard() { + + username := oc.GetString("username") + if utils.IsIllegalLength(username, 1, 255) { + oc.RenderFormatedError(http.StatusBadRequest, errors.New("username with illegal length")) + return + } + if utils.IsContainIllegalChar(username, []string{",", "~", "#", "$", "%"}) { + oc.RenderFormatedError(http.StatusBadRequest, errors.New("username contains illegal characters")) + return + } + + idTokenStr := oc.GetSession(idTokenKey) + d := &oidcUserData{} + err := json.Unmarshal([]byte(idTokenStr.(string)), &d) + if err != nil { + oc.RenderFormatedError(http.StatusInternalServerError, err) + return + } + oidcUser := models.OIDCUser{ + SubIss: d.Subject + d.Issuer, + // TODO: get secret with secret manager. + Secret: utils.GenerateRandomString(), + } + + var email string + if d.Email == "" { + email = utils.GenerateRandomString() + "@harbor.com" + } + user := models.User{ + Username: username, + Email: email, + OIDCUserMeta: &oidcUser, + } + + err = dao.OnBoardOIDCUser(&user) + if err != nil { + if strings.Contains(err.Error(), dao.ErrDupUser.Error()) { + oc.RenderFormatedError(http.StatusConflict, err) + return + } + oc.RenderFormatedError(http.StatusInternalServerError, err) + return + } + + oc.Controller.Redirect(config.GetPortalURL(), http.StatusMovedPermanently) +} diff --git a/src/core/filter/security.go b/src/core/filter/security.go index ae20c405a..9c95182fe 100644 --- a/src/core/filter/security.go +++ b/src/core/filter/security.go @@ -39,6 +39,16 @@ import ( "github.com/goharbor/harbor/src/core/promgr" "github.com/goharbor/harbor/src/core/promgr/pmsdriver/admiral" "strings" + + "encoding/json" + k8s_api_v1beta1 "k8s.io/api/authentication/v1beta1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/runtime/serializer" ) // ContextValueKey for content value @@ -100,6 +110,7 @@ func Init() { // standalone reqCtxModifiers = []ReqCtxModifier{ &secretReqCtxModifier{config.SecretStore}, + &authProxyReqCtxModifier{}, &robotAuthReqCtxModifier{}, &basicAuthReqCtxModifier{}, &sessionReqCtxModifier{}, @@ -194,6 +205,123 @@ func (r *robotAuthReqCtxModifier) Modify(ctx *beegoctx.Context) bool { return true } +type authProxyReqCtxModifier struct{} + +func (ap *authProxyReqCtxModifier) Modify(ctx *beegoctx.Context) bool { + authMode, err := config.AuthMode() + if err != nil { + log.Errorf("fail to get auth mode, %v", err) + return false + } + if authMode != common.HTTPAuth { + return false + } + + // only support docker login + if ctx.Request.URL.Path != "/service/token" { + log.Debug("Auth proxy modifier only handles docker login request.") + return false + } + + proxyUserName, proxyPwd, ok := ctx.Request.BasicAuth() + if !ok { + return false + } + + rawUserName, match := ap.matchAuthProxyUserName(proxyUserName) + if !match { + log.Errorf("User name %s doesn't meet the auth proxy name pattern", proxyUserName) + return false + } + + httpAuthProxyConf, err := config.HTTPAuthProxySetting() + if err != nil { + log.Errorf("fail to get auth proxy settings, %v", err) + return false + } + + // Init auth client with the auth proxy endpoint. + authClientCfg := &rest.Config{ + Host: httpAuthProxyConf.TokenReviewEndpoint, + ContentConfig: rest.ContentConfig{ + GroupVersion: &schema.GroupVersion{}, + NegotiatedSerializer: serializer.DirectCodecFactory{CodecFactory: scheme.Codecs}, + }, + BearerToken: proxyPwd, + TLSClientConfig: rest.TLSClientConfig{ + Insecure: httpAuthProxyConf.SkipCertVerify, + }, + } + authClient, err := rest.RESTClientFor(authClientCfg) + if err != nil { + log.Errorf("fail to create auth client, %v", err) + return false + } + + // Do auth with the token. + tokenReviewRequest := &k8s_api_v1beta1.TokenReview{ + TypeMeta: metav1.TypeMeta{ + Kind: "TokenReview", + APIVersion: "authentication.k8s.io/v1beta1", + }, + Spec: k8s_api_v1beta1.TokenReviewSpec{ + Token: proxyPwd, + }, + } + res := authClient.Post().Body(tokenReviewRequest).Do() + err = res.Error() + if err != nil { + log.Errorf("fail to POST auth request, %v", err) + return false + } + resRaw, err := res.Raw() + if err != nil { + log.Errorf("fail to get raw data of token review, %v", err) + return false + } + + // Parse the auth response, check the user name and authenticated status. + tokenReviewResponse := &k8s_api_v1beta1.TokenReview{} + err = json.Unmarshal(resRaw, &tokenReviewResponse) + if err != nil { + log.Errorf("fail to decode token review, %v", err) + return false + } + if !tokenReviewResponse.Status.Authenticated { + log.Errorf("fail to auth user: %s", rawUserName) + return false + } + user, err := dao.GetUser(models.User{ + Username: rawUserName, + }) + if err != nil { + log.Errorf("fail to get user: %v", err) + return false + } + if user == nil { + log.Errorf("User: %s has not been on boarded yet.", rawUserName) + return false + } + if rawUserName != tokenReviewResponse.Status.User.Username { + log.Errorf("user name doesn't match with token: %s", rawUserName) + return false + } + + log.Debug("using local database project manager") + pm := config.GlobalProjectMgr + log.Debug("creating local database security context for auth proxy...") + securCtx := local.NewSecurityContext(user, pm) + setSecurCtxAndPM(ctx.Request, securCtx, pm) + return true +} + +func (ap *authProxyReqCtxModifier) matchAuthProxyUserName(name string) (string, bool) { + if !strings.HasPrefix(name, common.AuthProxyUserNamePrefix) { + return "", false + } + return strings.Replace(name, common.AuthProxyUserNamePrefix, "", -1), true +} + type basicAuthReqCtxModifier struct{} func (b *basicAuthReqCtxModifier) Modify(ctx *beegoctx.Context) bool { diff --git a/src/core/filter/security_test.go b/src/core/filter/security_test.go index fd73c2f8f..91e2d31a7 100644 --- a/src/core/filter/security_test.go +++ b/src/core/filter/security_test.go @@ -28,6 +28,7 @@ import ( "github.com/astaxie/beego" beegoctx "github.com/astaxie/beego/context" "github.com/astaxie/beego/session" + "github.com/goharbor/harbor/src/common/dao" "github.com/goharbor/harbor/src/common/models" commonsecret "github.com/goharbor/harbor/src/common/secret" "github.com/goharbor/harbor/src/common/security" @@ -40,6 +41,9 @@ import ( "github.com/goharbor/harbor/src/core/promgr" driver_local "github.com/goharbor/harbor/src/core/promgr/pmsdriver/local" "github.com/stretchr/testify/assert" + + "github.com/goharbor/harbor/src/common" + fiter_test "github.com/goharbor/harbor/src/core/filter/test" ) func TestMain(m *testing.M) { @@ -131,6 +135,67 @@ func TestRobotReqCtxModifier(t *testing.T) { assert.False(t, modified) } +func TestAutoProxyReqCtxModifier(t *testing.T) { + + server, err := fiter_test.NewAuthProxyTestServer() + assert.Nil(t, err) + defer server.Close() + + c := map[string]interface{}{ + common.HTTPAuthProxyAlwaysOnboard: "true", + common.HTTPAuthProxySkipCertVerify: "true", + common.HTTPAuthProxyEndpoint: "https://auth.proxy/suffix", + common.HTTPAuthProxyTokenReviewEndpoint: server.URL, + common.AUTHMode: common.HTTPAuth, + } + + config.Upload(c) + v, e := config.HTTPAuthProxySetting() + assert.Nil(t, e) + assert.Equal(t, *v, models.HTTPAuthProxy{ + Endpoint: "https://auth.proxy/suffix", + AlwaysOnBoard: true, + SkipCertVerify: true, + TokenReviewEndpoint: server.URL, + }) + + // No onboard + req, err := http.NewRequest(http.MethodGet, + "http://127.0.0.1/service/token", nil) + if err != nil { + t.Fatalf("failed to create request: %v", req) + } + req.SetBasicAuth("tokenreview$administrator@vsphere.local", "reviEwt0k3n") + ctx, err := newContext(req) + if err != nil { + t.Fatalf("failed to crate context: %v", err) + } + + modifier := &authProxyReqCtxModifier{} + modified := modifier.Modify(ctx) + assert.False(t, modified) + + // Onboard + err = dao.OnBoardUser(&models.User{ + Username: "administrator@vsphere.local", + }) + assert.Nil(t, err) + req, err = http.NewRequest(http.MethodGet, + "http://127.0.0.1/service/token", nil) + if err != nil { + t.Fatalf("failed to create request: %v", req) + } + req.SetBasicAuth("tokenreview$administrator@vsphere.local", "reviEwt0k3n") + ctx, err = newContext(req) + if err != nil { + t.Fatalf("failed to crate context: %v", err) + } + + modifier = &authProxyReqCtxModifier{} + modified = modifier.Modify(ctx) + assert.True(t, modified) +} + func TestBasicAuthReqCtxModifier(t *testing.T) { req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/api/projects/", nil) diff --git a/src/core/filter/test/server.go b/src/core/filter/test/server.go new file mode 100644 index 000000000..e2f25b30d --- /dev/null +++ b/src/core/filter/test/server.go @@ -0,0 +1,89 @@ +package test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "k8s.io/api/authentication/v1beta1" + "net/http" + "net/http/httptest" + "net/url" +) + +// NewAuthProxyTestServer mocks a https server for auth proxy. +func NewAuthProxyTestServer() (*httptest.Server, error) { + const webhookPath = "/authproxy/tokenreview" + + serveHTTP := func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, fmt.Sprintf("unexpected method: %v", r.Method), http.StatusMethodNotAllowed) + return + } + if r.URL.Path != webhookPath { + http.Error(w, fmt.Sprintf("unexpected path: %v", r.URL.Path), http.StatusNotFound) + return + } + + var review v1beta1.TokenReview + bodyData, _ := ioutil.ReadAll(r.Body) + if err := json.Unmarshal(bodyData, &review); err != nil { + http.Error(w, fmt.Sprintf("failed to decode body: %v", err), http.StatusBadRequest) + return + } + // ensure we received the serialized tokenreview as expected + if review.APIVersion != "authentication.k8s.io/v1beta1" { + http.Error(w, fmt.Sprintf("wrong api version: %s", string(bodyData)), http.StatusBadRequest) + return + } + + type userInfo struct { + Username string `json:"username"` + UID string `json:"uid"` + Groups []string `json:"groups"` + Extra map[string][]string `json:"extra"` + } + type status struct { + Authenticated bool `json:"authenticated"` + User userInfo `json:"user"` + Audiences []string `json:"audiences"` + } + + var extra map[string][]string + if review.Status.User.Extra != nil { + extra = map[string][]string{} + for k, v := range review.Status.User.Extra { + extra[k] = v + } + } + + resp := struct { + Kind string `json:"kind"` + APIVersion string `json:"apiVersion"` + Status status `json:"status"` + }{ + Kind: "TokenReview", + APIVersion: v1beta1.SchemeGroupVersion.String(), + Status: status{ + true, + userInfo{ + Username: "administrator@vsphere.local", + UID: review.Status.User.UID, + Groups: review.Status.User.Groups, + Extra: extra, + }, + review.Status.Audiences, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + } + + server := httptest.NewUnstartedServer(http.HandlerFunc(serveHTTP)) + server.StartTLS() + + serverURL, _ := url.Parse(server.URL) + serverURL.Path = webhookPath + server.URL = serverURL.String() + + return server, nil +} diff --git a/src/core/router.go b/src/core/router.go index 5fbd01bce..237d6e3ad 100644 --- a/src/core/router.go +++ b/src/core/router.go @@ -15,6 +15,7 @@ package main import ( + "github.com/goharbor/harbor/src/common" "github.com/goharbor/harbor/src/core/api" "github.com/goharbor/harbor/src/core/config" "github.com/goharbor/harbor/src/core/controllers" @@ -37,6 +38,9 @@ func initRouters() { beego.Router("/c/reset", &controllers.CommonController{}, "post:ResetPassword") beego.Router("/c/userExists", &controllers.CommonController{}, "post:UserExists") beego.Router("/c/sendEmail", &controllers.CommonController{}, "get:SendResetEmail") + beego.Router("/c/oidc/login", &controllers.OIDCController{}, "get:RedirectLogin") + beego.Router("/c/oidc/onboard", &controllers.OIDCController{}, "post:Onboard") + beego.Router(common.OIDCCallbackPath, &controllers.OIDCController{}, "get:Callback") // API: beego.Router("/api/projects/:pid([0-9]+)/members/?:pmid([0-9]+)", &api.ProjectMemberAPI{}) diff --git a/src/portal/e2e/app.e2e-spec.ts b/src/portal/e2e/app.e2e-spec.ts index 02388c458..a924625cb 100644 --- a/src/portal/e2e/app.e2e-spec.ts +++ b/src/portal/e2e/app.e2e-spec.ts @@ -25,6 +25,8 @@ fdescribe('harbor-portal app', function () { it('should display: ' + expectedMsg, () => { page.navigateTo(); - expect(page.getParagraphText()).toEqual(expectedMsg) + page.getParagraphText().then(res => { + expect(res).toEqual(expectedMsg); + }); }); }); diff --git a/src/portal/lib/src/config/config.ts b/src/portal/lib/src/config/config.ts index 2872e1084..0c5c78463 100644 --- a/src/portal/lib/src/config/config.ts +++ b/src/portal/lib/src/config/config.ts @@ -91,7 +91,12 @@ export class Configuration { http_authproxy_endpoint?: StringValueItem; http_authproxy_skip_cert_verify?: BoolValueItem; http_authproxy_always_onboard?: BoolValueItem; - + oidc_name?: StringValueItem; + oidc_endpoint?: StringValueItem; + oidc_client_id?: StringValueItem; + oidc_client_secret?: StringValueItem; + oidc_skip_cert_verify?: BoolValueItem; + oidc_scope?: StringValueItem; public constructor() { this.auth_mode = new StringValueItem("db_auth", true); this.project_creation_restriction = new StringValueItem("everyone", true); @@ -136,5 +141,11 @@ export class Configuration { this.http_authproxy_endpoint = new StringValueItem("", true); this.http_authproxy_skip_cert_verify = new BoolValueItem(false, true); this.http_authproxy_always_onboard = new BoolValueItem(false, true); + this.oidc_name = new StringValueItem('', true); + this.oidc_endpoint = new StringValueItem('', true); + this.oidc_client_id = new StringValueItem('', true); + this.oidc_client_secret = new StringValueItem('', true); + this.oidc_skip_cert_verify = new BoolValueItem(false, true); + this.oidc_scope = new StringValueItem('', true); } } diff --git a/src/portal/lib/src/config/gc/gc-history/gc-history.component.html b/src/portal/lib/src/config/gc/gc-history/gc-history.component.html new file mode 100644 index 000000000..d71057e4d --- /dev/null +++ b/src/portal/lib/src/config/gc/gc-history/gc-history.component.html @@ -0,0 +1,20 @@ +
{{'GC.JOB_HISTORY' | translate}}
+ + {{'GC.JOB_ID' | translate}} + {{'GC.TRIGGER_TYPE' | translate}} + {{'STATUS' | translate}} + {{'START_TIME' | translate}} + {{'UPDATE_TIME' | translate}} + {{'LOGS' | translate}} + + {{job.id }} + {{(job.type ? 'SCHEDULE.'+ job.type.toUpperCase() : '') | translate }} + {{job.status.toUpperCase() | translate}} + {{job.createTime | date:'medium'}} + {{job.updateTime | date:'medium'}} + + + + + {{'GC.LATEST_JOBS' | translate :{param: jobs.length} }} + \ No newline at end of file diff --git a/src/portal/lib/src/config/gc/gc-history/gc-history.component.scss b/src/portal/lib/src/config/gc/gc-history/gc-history.component.scss new file mode 100644 index 000000000..1499c64dd --- /dev/null +++ b/src/portal/lib/src/config/gc/gc-history/gc-history.component.scss @@ -0,0 +1,4 @@ +.history-header { + color: #000; + margin:20px 0 6px 0; +} \ No newline at end of file diff --git a/src/portal/lib/src/config/gc/gc-history/gc-history.component.ts b/src/portal/lib/src/config/gc/gc-history/gc-history.component.ts new file mode 100644 index 000000000..29df7c888 --- /dev/null +++ b/src/portal/lib/src/config/gc/gc-history/gc-history.component.ts @@ -0,0 +1,28 @@ +import { Component, OnInit } from '@angular/core'; +import { GcRepoService } from "../gc.service"; +import { GcJobViewModel } from "../gcLog"; +import { GcViewModelFactory } from "../gc.viewmodel.factory"; + +@Component({ + selector: 'gc-history', + templateUrl: './gc-history.component.html', + styleUrls: ['./gc-history.component.scss'] +}) +export class GcHistoryComponent implements OnInit { + jobs: Array = []; + constructor( + private gcRepoService: GcRepoService, + private gcViewModelFactory: GcViewModelFactory, + ) { } + + ngOnInit() { + this.getJobs(); + } + + getJobs() { + this.gcRepoService.getJobs().subscribe(jobs => { + this.jobs = this.gcViewModelFactory.createJobViewModel(jobs); + }); + } + +} diff --git a/src/portal/lib/src/config/gc/gc.component.html b/src/portal/lib/src/config/gc/gc.component.html index 1b2d5803c..5947b7551 100644 --- a/src/portal/lib/src/config/gc/gc.component.html +++ b/src/portal/lib/src/config/gc/gc.component.html @@ -1,24 +1,4 @@
-
- -
{{'GC.JOB_HISTORY' | translate}}
- - {{'GC.JOB_ID' | translate}} - {{'GC.TRIGGER_TYPE' | translate}} - {{'STATUS' | translate}} - {{'START_TIME' | translate}} - {{'UPDATE_TIME' | translate}} - {{'LOGS' | translate}} - - {{job.id }} - {{(job.type ? 'SCHEDULE.'+ job.type.toUpperCase() : '') | translate }} - {{job.status.toUpperCase() | translate}} - {{job.createTime | date:'medium'}} - {{job.updateTime | date:'medium'}} - - - - - {{'GC.LATEST_JOBS' | translate :{param: jobs.length} }} - \ No newline at end of file + + \ No newline at end of file diff --git a/src/portal/lib/src/config/gc/gc.component.scss b/src/portal/lib/src/config/gc/gc.component.scss index 217f9d3ea..55c10060d 100644 --- a/src/portal/lib/src/config/gc/gc.component.scss +++ b/src/portal/lib/src/config/gc/gc.component.scss @@ -1,45 +1,10 @@ -.flex-layout { - display: flex; - align-items: center; - margin:20px 0; - font-size: .541667rem; -} - -.font-style { - color: #000; - font-size: .541667rem; -} - .cron-selection { margin-top: 20px; -} - -.setting-wrapper { - label { - width: 228px; - } - *:not(:first-child), span { - margin-right: 18px; - } -} - -.normal-wrapper { - > span:first-child { - width: 228px; - } - > span:not(:first-child){ - margin-right: 18px; - } + display: flex; + align-items: center; } .gc-start-btn { width:150px; -} - -.job-header { - margin:20px 0 -10px 0; -} - -.day-selector-wrapper { - display: flex; + margin-top: 47px; } \ No newline at end of file diff --git a/src/portal/lib/src/config/gc/index.ts b/src/portal/lib/src/config/gc/index.ts index b80853d74..8f2b987c0 100644 --- a/src/portal/lib/src/config/gc/index.ts +++ b/src/portal/lib/src/config/gc/index.ts @@ -4,3 +4,5 @@ export * from "./gc.api.repository"; export * from "./gc.service"; export * from "./gc.viewmodel.factory"; export * from "./gcLog"; +export * from "./gc-history/gc-history.component"; + diff --git a/src/portal/lib/src/config/index.ts b/src/portal/lib/src/config/index.ts index af469edc3..5ecae2c6e 100644 --- a/src/portal/lib/src/config/index.ts +++ b/src/portal/lib/src/config/index.ts @@ -5,7 +5,7 @@ import { SystemSettingsComponent } from './system/system-settings.component'; import { VulnerabilityConfigComponent } from './vulnerability/vulnerability-config.component'; import { RegistryConfigComponent } from './registry-config.component'; import { GcComponent } from './gc/gc.component'; - +import { GcHistoryComponent } from './gc/gc-history/gc-history.component'; export * from './config'; export * from './replication/replication-config.component'; @@ -16,6 +16,7 @@ export * from './gc/index'; export const CONFIGURATION_DIRECTIVES: Type[] = [ ReplicationConfigComponent, + GcHistoryComponent, GcComponent, SystemSettingsComponent, VulnerabilityConfigComponent, diff --git a/src/portal/lib/src/config/registry-config.component.html b/src/portal/lib/src/config/registry-config.component.html index 0cd7784ce..ad31cc66c 100644 --- a/src/portal/lib/src/config/registry-config.component.html +++ b/src/portal/lib/src/config/registry-config.component.html @@ -17,6 +17,7 @@ + diff --git a/src/portal/lib/src/config/registry-config.component.spec.ts b/src/portal/lib/src/config/registry-config.component.spec.ts index 79b8eaba0..f08055e2e 100644 --- a/src/portal/lib/src/config/registry-config.component.spec.ts +++ b/src/portal/lib/src/config/registry-config.component.spec.ts @@ -9,6 +9,7 @@ import { VulnerabilityConfigComponent } from './vulnerability/vulnerability-conf import { RegistryConfigComponent } from './registry-config.component'; import { ConfirmationDialogComponent } from '../confirmation-dialog/confirmation-dialog.component'; import { GcComponent } from './gc/gc.component'; +import { GcHistoryComponent } from './gc/gc-history/gc-history.component'; import { CronScheduleComponent } from '../cron-schedule/cron-schedule.component'; import { @@ -67,6 +68,7 @@ describe('RegistryConfigComponent (inline template)', () => { RegistryConfigComponent, ConfirmationDialogComponent, GcComponent, + GcHistoryComponent, CronScheduleComponent ], providers: [ diff --git a/src/portal/lib/src/config/vulnerability/vulnerability-config.component.html b/src/portal/lib/src/config/vulnerability/vulnerability-config.component.html index f6279a82f..a50cec049 100644 --- a/src/portal/lib/src/config/vulnerability/vulnerability-config.component.html +++ b/src/portal/lib/src/config/vulnerability/vulnerability-config.component.html @@ -1,8 +1,8 @@
-
- +
+ @@ -23,9 +23,11 @@ {{ updatedTimestamp | date:'MM/dd/y HH:mm:ss' }} AM
- -
-
-
+
+ +
+
+
+
\ No newline at end of file diff --git a/src/portal/lib/src/config/vulnerability/vulnerability-config.component.scss b/src/portal/lib/src/config/vulnerability/vulnerability-config.component.scss index 6db9a3c4b..49f76c1c1 100644 --- a/src/portal/lib/src/config/vulnerability/vulnerability-config.component.scss +++ b/src/portal/lib/src/config/vulnerability/vulnerability-config.component.scss @@ -31,12 +31,20 @@ } } } +.button-group { + display: flex; + align-items: center; + .btn-scan-right { + button{ + width: 160px; + margin-bottom: 0px; + margin-top: 41px; + } + } +} -.btn-scan-right button{ - width: 160px; - margin-bottom: 0px; - margin-top: 5px; -} -.btn-scan-right span{ - margin-top: 4px; -} +.update-time { + color: #000; + font-size: .541667rem; + width: 200px; +} \ No newline at end of file diff --git a/src/portal/lib/src/cron-schedule/cron-schedule.component.html b/src/portal/lib/src/cron-schedule/cron-schedule.component.html index 6a2a4e359..9f6fcdbe9 100644 --- a/src/portal/lib/src/cron-schedule/cron-schedule.component.html +++ b/src/portal/lib/src/cron-schedule/cron-schedule.component.html @@ -1,21 +1,23 @@
- {{ labelCurrent | translate }} - {{(originScheduleType ? 'SCHEDULE.'+ originScheduleType.toUpperCase(): "") | translate}} - - - {{'CONFIG.TOOLTIP.HOURLY_CRON' | translate}} - - - - {{'CONFIG.TOOLTIP.WEEKLY_CRON' | translate}} - - - - {{'CONFIG.TOOLTIP.DAILY_CRON' | translate}} - - {{ "SCHEDULE.CRON" | translate }} : - {{ oriCron }} -
@@ -31,20 +33,22 @@ {{ "SCHEDULE.CRON" | translate }} : -
-
- - + + + \ No newline at end of file diff --git a/src/portal/lib/src/cron-schedule/cron-schedule.component.scss b/src/portal/lib/src/cron-schedule/cron-schedule.component.scss index 469a6e609..0600f58c5 100644 --- a/src/portal/lib/src/cron-schedule/cron-schedule.component.scss +++ b/src/portal/lib/src/cron-schedule/cron-schedule.component.scss @@ -1,13 +1,11 @@ .flex-layout { - display: flex; - align-items: center; margin: 20px 0; font-size: .541667rem; - } +} .normal-wrapper { > span:first-child { - width: 228px; + width: 200px; } > span:not(:first-child) { @@ -17,13 +15,17 @@ margin-left: -10px; } button { - margin-left: 10px; + margin: 20px 20px 0 200px; } } .setting-wrapper { > span:first-child { - width: 228px; + width: 200px; + } + + .confirm-button { + margin: 20px 0px 0 200px; } *:not(:first-child) { @@ -39,7 +41,10 @@ width: 195px; } } + .font-style { + display: inline-block; color: #000; font-size: .541667rem; - } \ No newline at end of file + width: 200px; + } diff --git a/src/portal/lib/src/cron-schedule/cron-schedule.component.ts b/src/portal/lib/src/cron-schedule/cron-schedule.component.ts index 46e53960a..6e55ad3c2 100644 --- a/src/portal/lib/src/cron-schedule/cron-schedule.component.ts +++ b/src/portal/lib/src/cron-schedule/cron-schedule.component.ts @@ -8,6 +8,7 @@ import { SimpleChange } from "@angular/core"; import { OriginCron } from "../service/interface"; +import { cronRegex } from "../utils"; const SCHEDULE_TYPE = { NONE: "None", DAILY: "Daily", @@ -24,6 +25,7 @@ export class CronScheduleComponent implements OnChanges { @Input() originCron: OriginCron; @Input() labelEdit: string; @Input() labelCurrent: string; + dateInvalid: boolean; originScheduleType: string; oriCron: string; cronString: string; @@ -52,13 +54,26 @@ export class CronScheduleComponent implements OnChanges { } } + inputInvalid() { + this.dateInvalid = cronRegex(this.cronString) ? false : true; + } + + blurInvalid() { + if (!this.cronString) { + this.dateInvalid = true; + } + } + public resetSchedule() { this.originScheduleType = this.scheduleType; - this.oriCron = this.cronString; + this.oriCron = this.cronString.replace(/\s+/g, " ").trim(); this.isEditMode = false; } save(): void { + if (this.dateInvalid && this.scheduleType === SCHEDULE_TYPE.CUSTOM) { + return; + } let scheduleTerm: string = ""; this.resetSchedule(); if (this.scheduleType && this.scheduleType === SCHEDULE_TYPE.NONE) { diff --git a/src/portal/lib/src/service/retag.service.ts b/src/portal/lib/src/service/retag.service.ts index d6c92003d..a4c3f2fab 100644 --- a/src/portal/lib/src/service/retag.service.ts +++ b/src/portal/lib/src/service/retag.service.ts @@ -1,10 +1,11 @@ import { Observable } from "rxjs"; import { Http } from "@angular/http"; -import { Injectable } from "@angular/core"; +import { Injectable, Inject } from "@angular/core"; import { RetagRequest } from "./interface"; import { HTTP_JSON_OPTIONS } from "../utils"; import { catchError } from "rxjs/operators"; import { throwError as observableThrowError } from "rxjs/index"; +import { IServiceConfig, SERVICE_CONFIG } from "../service.config"; /** * Define the service methods to perform images retag. @@ -36,14 +37,16 @@ export abstract class RetagService { @Injectable() export class RetagDefaultService extends RetagService { constructor( - private http: Http + private http: Http, + @Inject(SERVICE_CONFIG) private config: IServiceConfig ) { super(); } retag(request: RetagRequest): Observable { + let baseUrl: string = this.config.repositoryBaseEndpoint ? this.config.repositoryBaseEndpoint : '/api/repositories'; return this.http - .post(`/api/repositories/${request.targetProject}/${request.targetRepo}/tags`, + .post(`${baseUrl}/${request.targetProject}/${request.targetRepo}/tags`, { "tag": request.targetTag, "src_image": request.srcImage, diff --git a/src/portal/lib/src/utils.ts b/src/portal/lib/src/utils.ts index b90d3984d..e49f9a7e3 100644 --- a/src/portal/lib/src/utils.ts +++ b/src/portal/lib/src/utils.ts @@ -343,3 +343,24 @@ export function getChanges(original: any, afterChange: any): { [key: string]: an } return changes; } + +export function cronRegex(testValue: any): boolean { + const regSecond = "^($|#|\\w+\\s*=|(\\?|\\*|(?:[0-5]?\\d)(?:(?:-|\/|\\,)(?:[0-5]?\\d))?" + + "(?:,(?:[0-5]?\\d)(?:(?:-|\/|\\,)(?:[0-5]?\\d))?)*)\\s+"; + const regMinute = "(\\?|\\*|(?:[0-5]?\\d)(?:(?:-|\/|\\,)(?:[0-5]?\\d))?(?:,(?:[0-5]?\\d)(?:(?:-|\/|\\,)(?:[0-5]?\\d))?)*)\\s+"; + const regHour = "(\\?|\\*|(?:[01]?\\d|2[0-3])(?:(?:-|\/|\\,)(?:[01]?\\d|2[0-3]))?(?:,(?:[01]?\\d|2[0-3])" + + "(?:(?:-|\/|\\,)(?:[01]?\\d|2[0-3]))?)*)\\s+"; + const regDay = "(\\?|\\*|(?:0?[1-9]|[12]\\d|3[01])(?:(?:-|\/|\\,)(?:0?[1-9]|[12]\\d|3[01]))?(?:,(?:0?[1-9]|[12]\\d|3[01])" + + "(?:(?:-|\/|\\,)(?:0?[1-9]|[12]\\d|3[01]))?)*)\\s+"; + const regMonth = "(\\?|\\*|(?:[1-9]|1[012])(?:(?:-|\/|\\,)(?:[1-9]|1[012]))?(?:L|W)?(?:,(?:[1-9]|1[012])(?:(?:-|\/|\\,)" + + "(?:[1-9]|1[012]))?(?:L|W)?)*|\\?|\\*|(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)(?:(?:-)" + + "(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC))?(?:,(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC)" + + "(?:(?:-)(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC))?)*)\\s+"; + const regWeek = "(\\?|\\*|(?:[0-6])(?:(?:-|\/|\\,|#)(?:[0-6]))?(?:L)?(?:,(?:[0-6])(?:(?:-|\/|\\,|#)" + + "(?:[0-6]))?(?:L)?)*|\\?|\\*|(?:MON|TUE|WED|THU|FRI|SAT|SUN)(?:(?:-)(?:MON|TUE|WED|THU|FRI|SAT|SUN))?" + + "(?:,(?:MON|TUE|WED|THU|FRI|SAT|SUN)(?:(?:-)(?:MON|TUE|WED|THU|FRI|SAT|SUN))?)*)(|\\s)+"; + const regYear = "(\\?|\\*|(?:|\\d{4})(?:(?:-|\/|\\,)(?:|\\d{4}))?(?:,(?:|\\d{4})(?:(?:-|\/|\\,)(?:|\\d{4}))?)*))$"; + const regEx = regSecond + regMinute + regHour + regDay + regMonth + regWeek + regYear; + let reg = new RegExp(regEx, "i"); + return reg.test(testValue); +} diff --git a/src/portal/src/app/account/sign-in/sign-in.component.html b/src/portal/src/app/account/sign-in/sign-in.component.html index ade54d7d5..2342192e8 100644 --- a/src/portal/src/app/account/sign-in/sign-in.component.html +++ b/src/portal/src/app/account/sign-in/sign-in.component.html @@ -1,42 +1,53 @@ -