diff --git a/.codecov.yml b/.codecov.yml
new file mode 100644
index 0000000..11f98b7
--- /dev/null
+++ b/.codecov.yml
@@ -0,0 +1,2 @@
+ignore:
+ - "internal/web/templates.go"
\ No newline at end of file
diff --git a/.github/media/screenshot.gif b/.github/media/screenshot.gif
new file mode 100644
index 0000000..3732f97
Binary files /dev/null and b/.github/media/screenshot.gif differ
diff --git a/.gitignore b/.gitignore
deleted file mode 100644
index e69de29..0000000
diff --git a/.golangci.yml b/.golangci.yml
index 5878dc3..e825488 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,7 +1,7 @@
-# Copyright (C) JSC iCore - All Rights Reserved
-#
-# Unauthorized copying of this file, via any medium is strictly prohibited
-# Proprietary and confidential
+# Copyright (c) JSC iCore.
+
+# This source code is licensed under the MIT license found in the
+# LICENSE file in the root directory of this source tree.
run:
test: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index e2bf8f1..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,29 +0,0 @@
-# Changelog
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
-## [1.1.1] - 2019-05-15
-### Added
-- Add gopkg.i-core.ru/logutil as a logger middleware.
-- Add gopkg.i-core.ru/httputil as a HTTP router.
-### Changed
-- Move to Golang 1.12 when build the application in Docker.
-- Update golangci-lint config.
-- Update the copyright.
-### Removed
-- Remove the HTTP handler of Prometheus's metrics.
-
-## [1.1.0] - 2019-05-15
-### Added
-- Add support of logout flow.
-
-## [1.0.0] - 2019-02-18
-### Added
-- Add unit tests for server's logic.
-
-### Changed
-- The url /auth/login accepts the POST parameter login_challenge instead of challenge.
-- The OIDC claim roles is enabled in the scope http://i-core.ru/claims/roles only.
-- Use go.uber.org/zap without any facade.
diff --git a/Dockerfile b/Dockerfile
index cade9e3..872bf78 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
-# Copyright (C) JSC iCore - All Rights Reserved
-#
-# Unauthorized copying of this file, via any medium is strictly prohibited
-# Proprietary and confidential
+# Copyright (c) JSC iCore.
+
+# This source code is licensed under the MIT license found in the
+# LICENSE file in the root directory of this source tree.
FROM golang:1.12-alpine AS build
@@ -16,7 +16,7 @@ COPY go.mod .
COPY go.sum .
COPY cmd cmd
COPY internal internal
-RUN env CGO_ENABLED=0 go install -ldflags="-w -s -X gopkg.i-core.ru/werther/cmd/werther.Version=${VERSION}" ./...
+RUN env CGO_ENABLED=0 go install -ldflags="-w -s -X main.version=${VERSION}" ./...
FROM scratch AS final
COPY --from=build /etc/passwd /etc/passwd
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..071df6f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2019 JSC iCore
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 32dd6c4..b6dc822 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,61 @@
-[![GoDoc](https://godoc.das.i-core.ru/gopkg.i-core.ru/werther?status.svg)](https://godoc.das.i-core.ru/gopkg.i-core.ru/werther)
+# Werther [1](#myfootnote1)
-# Werther
+[![GoDoc][doc-img]][doc] [![Build Status][build-img]][build] [![codecov][codecov-img]][codecov]
-Werther is an identity provider for ORY Hydra that is an OAuth2 provider.
+Werther is an Identity Provider for [ORY Hydra][hydra] over [LDAP][ldap].
+It implements [Login And Consent Flow][hydra-login-consent] and provides basic UI.
-**Important!**
-**The current version is compatible with ORY Hydra v1.0.0-rc.12 or higher.**
+![screenshot](.github/media/screenshot.gif)
-## Build
+**Features**
+- Support [Active Directory][ad];
+- Mapping LDAP attributes to OpenID Connect claims;
+- Mapping LDAP groups to user roles;
+- OAuth 2.0 scopes;
+- Caching users roles;
+- UI customization.
+
+**Limitations**
+- Werther grants all requested permissions to a client without displaying the consent page;
+- Werther confirms a logout request without displaying the logout confirmation page.
+
+**Requirements**
+
+ORY Hydra v1.0.0-rc.12 or higher.
+
+**Table of Contents**
+
+
+
+
+
+- [Installing](#installing)
+- [Usage](#usage)
+- [Configuration](#configuration)
+- [User roles](#user-roles)
+- [UI customization](#ui-customization)
+- [Resources](#resources)
+- [Footnotes](#footnotes)
+- [Contributing](#contributing)
+- [License](#license)
+
+
+
+## Installing
+
+### From Docker
+
+```bash
+docker pull icoreru/werter
```
+
+### From sources
+
+```bash
go install ./...
```
-## Development
-
-Assume that your IP is set as $MY_HOST. The instruction will use 4444 TCP port for OAuth2 Provider Hydra,
-3000 TCP port for Login Provider Werther, and 8080 TCP port for a callback. Tokens will be expired in ten minutes.
+## Usage
1. Create a network:
```
@@ -27,120 +67,206 @@ Assume that your IP is set as $MY_HOST. The instruction will use 4444 TCP port f
docker run --network hydra-net -d --restart always --name hydra \
-p 4444:4444 \
-p 4445:4445 \
- -e OAUTH2_EXPOSE_INTERNAL_ERRORS=true \
- -e LOG_LEVEL=debug \
- -e TTL_ACCESS_TOKEN=10m \
- -e TTL_ID_TOKEN=10m \
- -e SERVE_PUBLIC_CORS_ENABLED=true \
- -e SERVE_PUBLIC_CORS_ALLOWED_ORIGINS=http://$MY_HOST:8080 \
- -e SERVE_PUBLIC_CORS_ALLOW_CREDENTIALS=true \
- -e WEBFINGER_OIDC_DISCOVERY_SUPPORTED_SCOPES=profile,email,phone \
- -e WEBFINGER_OIDC_DISCOVERY_SUPPORTED_CLAIMS=name,family_name,given_name,nickname,email,phone_number \
-e URLS_SELF_ISSUER=http://localhost:4444 \
-e URLS_SELF_PUBLIC=http://localhost:4444 \
- -e URLS_LOGIN=http://$MY_HOST:3000/auth/login \
- -e URLS_CONSENT=http://$MY_HOST:3000/auth/consent \
- -e URLS_LOGOUT=http://$MY_HOST:3000/auth/logout \
+ -e URLS_LOGIN=http://localhost:8080/auth/login \
+ -e URLS_CONSENT=http://localhost:8080/auth/consent \
+ -e URLS_LOGOUT=http://localhost:8080/auth/logout \
+ -e WEBFINGER_OIDC_DISCOVERY_SUPPORTED_SCOPES=profile,email,phone \
+ -e WEBFINGER_OIDC_DISCOVERY_SUPPORTED_CLAIMS=name,family_name,given_name,nickname,email,phone_number \
-e DSN=memory \
- oryd/hydra:v1.0.0-rc.12 serve all --dangerous-force-http
+ oryd/hydra:v1.0.0-rc.12 serve all
```
- You can learn additional properties with help command:
- ```
- docker run -it --rm oryd/hydra:v1.0.0-rc.12 serve --help
- ```
+ Look for details in [ORY Hydra Configuration][hydra-doc-config] and [ORY Hydra Documentation][hydra-doc].
-3. Register a client:
+3. Run Werther:
```
- docker run -it --rm --network hydra-net \
- -e HYDRA_ADMIN_URL=http://hydra:4445 \
- oryd/hydra:$HYDRA_VERSION clients create \
- --skip-tls-verify \
- --id test-client \
- --secret test-secret \
- --response-types id_token,token,"id_token token" \
- --grant-types implicit \
- --scope openid,profile,email \
- --callbacks http://$MY_HOST:8080 \
- --post-logout-callbacks http://$MY_HOST:8080/post-logout-callback
- ```
-
-4. Run Werther:
- ```
- docker run --network hydra-net -d --restart always --name werther -p 3000:8080 \
+ docker run --network hydra-net -d --restart always --name werther \
+ -p 8080:8080 \
-e WERTHER_IDENTP_HYDRA_URL=http://hydra:4445 \
- -e WERTHER_LDAP_ENDPOINTS=icdc0.icore.local:389,icdc1.icore.local:389 \
- -e WERTHER_LDAP_BINDDN= \
- -e WERTHER_LDAP_BINDPW= \
- -e WERTHER_LDAP_BASEDN="DC=icore,DC=local" \
- -e WERTHER_LDAP_ROLE_BASEDN="OU=AppRoles,OU=Domain Groups,DC=icore,DC=local" \
- hub.das.i-core.ru/p/base-werther
+ -e WERTHER_LDAP_ENDPOINTS=icdc0.example.local:389,icdc1.example.local:389 \
+ -e WERTHER_LDAP_BINDDN= \
+ -e WERTHER_LDAP_BINDPW= \
+ -e WERTHER_LDAP_BASEDN="DC=example,DC=local" \
+ -e WERTHER_LDAP_ROLE_BASEDN="OU=AppRoles,OU=Domain Groups,DC=example,DC=local" \
+ icoreru/werther
```
- For all options see option help:
- ```
- docker run -it --rm hub.das.i-core.ru/p/base-werther -help
- ```
+## Configuration
-5. Start an authentication process in a browser to get an access token:
- ```
- open http://$MY_HOST:4444/oauth2/auth?client_id=test-client&response_type=token&scope=openid%20profile%20email&state=12345678
- ```
-6. Start an authentication process in a browser to get an access token and id token:
- ```
- open http://$MY_HOST:4444/oauth2/auth?client_id=test-client&response_type=id_token%20token&scope=openid%20profile%20email&state=12345678&nonce=87654321
- ```
+The application is configured via environment variables.
+Names of the environment variables starts with prefix `WERTHER_`.
+See a list of the environment variables using the command:
-7. Get user info:
- ```
- http get "http://$MY_HOST:4444/userinfo" "Authorization: Bearer "
- ```
+```
+werther -h
+```
- For example, you can get the next output:
- ```
- HTTP/1.1 200 OK
- Content-Length: 218
- Content-Type: application/json
- Date: Tue, 31 Jul 2018 17:17:51 GMT
- Vary: Origin
+## User roles
+In LDAP user's roles are groups in which a user is a member.
+
+The environment variable `WERTHER_LDAP_ROLE_DN` is a DN for searching roles.
+
+For example, create an OU that repserents an application, and then in the created OU
+create groups that represent application's roles:
+
+```
+DC=local
+|-- OU=Domain Groups
+ |-- OU=AppRoles
+ |-- OU=App1
+ |-- CN=app1_role1 (objectClass="group", description="role1")
+ |-- CN=app1_role2 (objectClass="group", description="role2")
+```
+
+Run Werther with the environment variable `WERTHER_LDAP_ROLE_DN`
+that equals to `OU=AppRoles,OU=Domain Groups,DC=local`.
+
+In the above example Werther returns user's roles as a value
+of the user role's claim `https://github.com/i-core/werther/claims/roles`.
+
+```json
+{
+ "https://github.com/i-core/werther/claims/roles": {
+ "App1": ["role1", "role2"],
+ }
+}
+```
+
+To customize the roles claim's name you should set a value of the environment variable `WERTHER_LDAP_ROLE_CLAIM`.
+For more details about claims naming see [OpenID Connect Core 1.0][oidc-spec-additional-claims].
+
+**NB** There are cases when we need to create several roles with the same name in LDAP.
+For example, when we want to configure multiple applications or several environments for the same application.
+
+```
+DC=local
+|-- OU=Domain Groups
+ |-- OU=AppRoles
+ |-- OU=Test
+ |-- OU=App1
+ |-- CN=test_app1_role1 (objectClass="group", description="role1")
+ |-- CN=test_app1_role2 (objectClass="group", description="role2")
+ |-- OU=App2
+ |-- CN=test_app2_role1 (objectClass="group",description-"role1")
+ |-- CN=test_app2_role2 (objectClass="group",description-"role2")
+ |-- OU=Dev
+ |-- OU=App1
+ |-- CN=dev_app1_role1 (objectClass="group", description="role1")
+ |-- CN=dev_app1_role3 (objectClass="group", description="role3")
+ |-- OU=App2
+ |-- CN=dev_app2_role1 (objectClass="group",description-"role1")
+ |-- CN=dev_app2_role4 (objectClass="group",description-"role4")
+```
+
+Active Directory requires unique CNs in a domain. But in Active Directory
+creating groups with the same CN in different OUs is difficult.
+Because of it, Werther uses a LDAP attribute as a role's name instead of CN.
+A name of a LDAP attribute is specified using the environment variable `WERTHER_LDAP_ROLE_ATTR`,
+and has the default value `description`.
+
+In the above example, Werther returns a response that contains the next roles:
+* when the environment variable `WERTHER_LDAP_ROLE_DN` equals to `OU=Test,OU=AppRoles,OU=Domain Groups,DC=local`:
+ ```json
{
- "email": "klepa@i-core.ru",
- "family_name": "Lepa",
- "given_name": "Konstantin",
- "http://i-core.ru/claims/roles": {
- "HeraldTest1": [
- "user"
- ]
- },
- "name": "Konstantin Lepa",
- "sub": "CN=Konstantin Lepa,OU=Domain Users,DC=icore,DC=local"
+ "https://github.com/i-core/werther/claims/roles": {
+ "App1": ["role1", "role2"],
+ "App2": ["role1", "role2"]
+ }
+ }
+ ```
+* when the environment variable `WERTHER_LDAP_ROLE_DN` equals to `OU=Dev,OU=AppRoles,OU=Domain Groups,DC=local`:
+ ```json
+ {
+ "https://github.com/i-core/werther/claims/roles": {
+ "App1": ["role1", "role3"],
+ "App2": ["role1", "role4"]
+ }
}
```
- Look for details in [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter).
+## UI customization
-8. Re-get a token by httpie:
- ```
- http --session u1 -F -v get \
- "http://$MY_HOST:4444/oauth2/auth?client_id=test-client&response_type=token&scope=openid%20profile&state=12345678&prompt=none" \
- "Cookie:"
- ```
+Werther uses the Go templates to render UI pages.
+To customize the UI you should create a directory that contains UI pages' templates.
+After that you should set the directory path to the environment variable `WERTHER_WEB_DIR`:
-9. Delete a user's session from a browser:
- ```
- open "http://$MY_HOST:4444/oauth2/auth/sessions/login/revoke"
- ```
+```bash
+docker run --network hydra-net -d --restart always --name werther \
+ -p 8080:8080 \
+ -v /opt/werther/web:/path/to/custom-login-page/dir \
+ -e WERTHER_IDENTP_HYDRA_URL=http://hydra:4445 \
+ -e WERTHER_LDAP_ENDPOINTS=icdc0.example.local:389,icdc1.example.local:389 \
+ -e WERTHER_LDAP_BINDDN= \
+ -e WERTHER_LDAP_BINDPW= \
+ -e WERTHER_LDAP_BASEDN="DC=example,DC=local" \
+ -e WERTHER_LDAP_ROLE_BASEDN="OU=AppRoles,OU=Domain Groups,DC=example,DC=local" \
+ -e WERTHER_WEB_DIR=/opt/werther/web
+ icoreru/werther
+```
-10. Log a user out from a browser:
- ```
- open http://$MY_HOST:4444/oauth2/sessions/logout?id_token_hint=&post_logout_redirect_uri=http://$MY_HOST:8080/post-logout-callback&state=87654321
- ```
- After a successful logout, a user will be redirected to the page "http://$MY_HOST:8080/post-logout-callback?state=87654321".
+### Custom login page
+A login page's template should contains blocks `title`, `style`, `script`, `content`.
+Each block has access to data that is an object with the next properties:
+- `CSRFToken` (string) - a CSRF token;
+- `Challenge` (string) - a login challenge ID;
+- `LoginURL` (string) - an endpoint that finishes the login process;
+- `IsInvalidCredentials` (bool) - specifies that a user types an invalid username or password;
+- `IsInternalError` (bool) specifies that an internal server error happens when finishing the login process.
-11. (Optional) Sniff TCP packets between Hydra and Werther
- ```
- docker run -it --rm --net=container:hydra nicolaka/netshoot tcpdump -i eth0 -A -nn port 4444
- ```
+When a login page's template contains static resources (like styles, scripts, and images)
+they must be placed in a subdirectory called `static`.
+For a full example of a login page's template see [source code](internal/web/templates).
+
+## Resources
+
+- [Introduction to ORY Hydra, OAuth 2.0, and OpenID Connect][hydra-doc];
+- [ORY Hydra: Integrating with (existing) User Management][hydra-login-consent];
+- [Official User Login & Consent Example](https://github.com/ory/hydra-login-consent-node);
+- [OpenID Connect Core 1.0][oidc-spec-core];
+- [OpenID Connect Session Management 1.0][oidc-spec-session];
+- [OpenID Connect Front-Channel Logout 1.0][oidc-spec-front-channel-logout];
+- [OpenID Connect Back-Channel Logout 1.0][oidc-spec-back-channel-logout].
+
+## Footnotes
+
+1. Werther is named after robot Werther from [Guest from the Future](https://en.wikipedia.org/wiki/Guest_from_the_Future).
+
+## Contributing
+
+Thanks for your interest in contributing to this project.
+Get started with our [Contributing Guide][contrib].
+
+## License
+
+The code in this project is licensed under [MIT license][license].
+
+[doc-img]: https://godoc.org/github.com/i-core/werther?status.svg
+[doc]: https://godoc.org/github.com/i-core/werther
+
+[build-img]: https://travis-ci.com/i-core/werther.svg?branch=master
+[build]: https://travis-ci.com/i-core/werther
+
+[codecov-img]: https://codecov.io/gh/i-core/werther/branch/master/graph/badge.svg
+[codecov]: https://codecov.io/gh/i-core/werther
+
+[contrib]: https://github.com/i-core/.github/blob/master/CONTRIBUTING.md
+[license]: LICENSE
+
+[ldap]: https://ldap.com/
+[ad]: https://docs.microsoft.com/ru-ru/windows/desktop/AD/active-directory-domain-services
+
+[hydra]: https://www.ory.sh/
+[hydra-doc]: https://www.ory.sh/docs/hydra/
+[hydra-login-consent]: https://www.ory.sh/docs/hydra/oauth2
+[hydra-doc-config]: https://www.ory.sh/docs/hydra/configuration
+
+[oidc-spec-core]: https://openid.net/specs/openid-connect-core-1_0.html
+[oidc-spec-additional-claims]: https://openid.net/specs/openid-connect-core-1_0.html#AdditionalClaims
+[oidc-spec-session]: https://openid.net/specs/openid-connect-session-1_0.html
+[oidc-spec-front-channel-logout]: https://openid.net/specs/openid-connect-frontchannel-1_0.html
+[oidc-spec-back-channel-logout]: https://openid.net/specs/openid-connect-backchannel-1_0.html
\ No newline at end of file
diff --git a/ci-testing.yaml b/ci-testing.yaml
deleted file mode 100644
index 6f70358..0000000
--- a/ci-testing.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-- image: golang:1.11-alpine
- shell: go test -v ./...
-- image: golangci/golangci-lint:v1.16.0
- shell: golangci-lint -v run
diff --git a/cmd/werther/main.go b/cmd/werther/main.go
index 5b0a55f..c4fb7d0 100644
--- a/cmd/werther/main.go
+++ b/cmd/werther/main.go
@@ -1,11 +1,11 @@
/*
-Copyright (C) JSC iCore - All Rights Reserved
+Copyright (c) JSC iCore.
-Unauthorized copying of this file, via any medium is strictly prohibited
-Proprietary and confidential
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
*/
-package main // import "gopkg.i-core.ru/werther/cmd/werther"
+package main // import "github.com/i-core/werther/cmd/werther"
import (
"flag"
@@ -13,27 +13,27 @@ import (
"net/http"
"os"
+ "github.com/i-core/rlog"
+ "github.com/i-core/routegroup"
+ "github.com/i-core/werther/internal/identp"
+ "github.com/i-core/werther/internal/ldapclient"
+ "github.com/i-core/werther/internal/stat"
+ "github.com/i-core/werther/internal/web"
"github.com/justinas/nosurf"
"github.com/kelseyhightower/envconfig"
"go.uber.org/zap"
- "gopkg.i-core.ru/httputil"
- "gopkg.i-core.ru/logutil"
- "gopkg.i-core.ru/werther/internal/identp"
- "gopkg.i-core.ru/werther/internal/ldapclient"
- "gopkg.i-core.ru/werther/internal/stat"
- "gopkg.i-core.ru/werther/internal/web"
)
-// Version will be filled at compile time.
-var Version = ""
+// version will be filled at compile time.
+var version = ""
// Config is a server's configuration.
type Config struct {
DevMode bool `envconfig:"dev_mode" default:"false" desc:"a development mode"`
Listen string `default:":8080" desc:"a host and port to listen on (:)"`
- Web web.Config
Identp identp.Config
LDAP ldapclient.Config
+ Web web.Config
}
func main() {
@@ -49,7 +49,7 @@ func main() {
flag.Parse()
if *verflag {
- fmt.Println("werther", Version)
+ fmt.Println("werther", version)
os.Exit(0)
}
@@ -77,12 +77,12 @@ func main() {
ldap := ldapclient.New(cnf.LDAP)
- router := httputil.NewRouter(nosurf.NewPure, logutil.RequestLog(log))
+ router := routegroup.NewRouter(nosurf.NewPure, rlog.NewMiddleware(log))
router.AddRoutes(web.NewStaticHandler(cnf.Web), "/static")
router.AddRoutes(identp.NewHandler(cnf.Identp, ldap, htmlRenderer), "/auth")
- router.AddRoutes(stat.NewHandler(Version), "/stat")
+ router.AddRoutes(stat.NewHandler(version), "/stat")
log = log.Named("main")
- log.Info("Werther started", zap.Any("config", cnf), zap.String("version", Version))
+ log.Info("Werther started", zap.Any("config", cnf), zap.String("version", version))
log.Fatal("Werther finished", zap.Error(http.ListenAndServe(cnf.Listen, router)))
}
diff --git a/cmd/werther/tools.go b/cmd/werther/tools.go
index 6c5ff18..f8ea6bb 100644
--- a/cmd/werther/tools.go
+++ b/cmd/werther/tools.go
@@ -1,10 +1,10 @@
// +build tools
/*
-Copyright (C) JSC iCore - All Rights Reserved
+Copyright (c) JSC iCore.
-Unauthorized copying of this file, via any medium is strictly prohibited
-Proprietary and confidential
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
*/
package main
diff --git a/go.mod b/go.mod
index ead1e11..2141325 100644
--- a/go.mod
+++ b/go.mod
@@ -1,4 +1,4 @@
-module gopkg.i-core.ru/werther
+module github.com/i-core/werther
require (
github.com/OneOfOne/xxhash v1.2.2 // indirect
@@ -7,22 +7,15 @@ require (
github.com/coocood/freecache v1.0.1
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/elazarl/go-bindata-assetfs v1.0.0
- github.com/gofrs/uuid v3.2.0+incompatible // indirect
- github.com/julienschmidt/httprouter v1.2.0 // indirect
- github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da // indirect
+ github.com/i-core/rlog v1.0.0
+ github.com/i-core/routegroup v1.0.0
github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4
github.com/kelseyhightower/envconfig v1.3.0
github.com/kevinburke/go-bindata v3.13.0+incompatible
github.com/pkg/errors v0.8.1
- github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.0.0 // indirect
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 // indirect
- github.com/stretchr/testify v1.2.2 // indirect
- go.uber.org/atomic v1.2.0 // indirect
- go.uber.org/multierr v1.1.0 // indirect
- go.uber.org/zap v1.9.1
- gopkg.i-core.ru/httputil v1.0.0
- gopkg.i-core.ru/logutil v1.0.0
+ go.uber.org/zap v1.10.0
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 // indirect
gopkg.in/ldap.v2 v2.5.1
)
diff --git a/go.sum b/go.sum
index 564515c..7d179e9 100644
--- a/go.sum
+++ b/go.sum
@@ -6,12 +6,17 @@ github.com/cespare/xxhash v1.0.0 h1:naDmySfoNg0nKS62/ujM6e71ZgM2AoVdaqGwMG0w18A=
github.com/cespare/xxhash v1.0.0/go.mod h1:fX/lfQBkSCDXZSUgv6jVIu/EVA3/JNseAX5asI4c4T4=
github.com/coocood/freecache v1.0.1 h1:oFyo4msX2c0QIKU+kuMJUwsKamJ+AKc2JJrKcMszJ5M=
github.com/coocood/freecache v1.0.1/go.mod h1:ePwxCDzOYvARfHdr1pByNct1at3CoKnsipOHwKlNbzI=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/go-bindata-assetfs v1.0.0 h1:G/bYguwHIzWq9ZoyUQqrjTmJbbYn3j3CKKpKinvZLFk=
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
+github.com/i-core/rlog v1.0.0 h1:8CY2rsqvm3Z9cfl3hroppn8LTBwbtL45+ho79JTz8Jg=
+github.com/i-core/rlog v1.0.0/go.mod h1:wTQKCF9IKx2HlNQ2M7dUpP3zIOD5ayqF4X3uQFbwY3g=
+github.com/i-core/routegroup v1.0.0 h1:kTFVBWTWoT2vbhpk0PDemW3GEKV/DwAkQ3qjKnTNygI=
+github.com/i-core/routegroup v1.0.0/go.mod h1:wXq5xEjOOs8xuM2olbaAlxgUbP/u8mVaW0tM/09cmKU=
github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da h1:5y58+OCjoHCYB8182mpf/dEsq0vwTKPOo4zGfH0xW9A=
@@ -30,18 +35,15 @@ github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
-github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
-github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-go.uber.org/atomic v1.2.0 h1:yVVGhClJ8Xi1y4TxhJZE6QFPrz76BrzhWA01n47mSFk=
-go.uber.org/atomic v1.2.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+go.uber.org/atomic v1.4.0 h1:cxzIVoETapQEqDhQu3QfnvXAV4AlzcvUCxkVUFw3+EU=
+go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
-go.uber.org/zap v1.9.1 h1:XCJQEf3W6eZaVwhRBof6ImoYGJSITeKWsyeh3HFu/5o=
-go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
-gopkg.i-core.ru/httputil v1.0.0 h1:A+6RPcU8pNvA/Zf+0Oy9iozyrwycmvDGSCdqgea2/qo=
-gopkg.i-core.ru/httputil v1.0.0/go.mod h1:OrmzAZNj0BuwD6hHQ9tUVQZXVhdm7H9OMP5jbN7D8ro=
-gopkg.i-core.ru/logutil v1.0.0 h1:KsUIPn1D2UktdMgkiWzXeA2QqzTJIPAgdApJxQSeiOM=
-gopkg.i-core.ru/logutil v1.0.0/go.mod h1:FD71nyLCA6P3gkV1WVyvfEtKtS3M+HQXpuUtQT11rrw=
+go.uber.org/zap v1.10.0 h1:ORx85nbTijNz8ljznvCMR1ZBIPKFn3jQrag10X2AsuM=
+go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 h1:JBwmEvLfCqgPcIq8MjVMQxsF3LVL4XG/HH0qiG0+IFY=
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU=
diff --git a/internal/hydra/consent.go b/internal/hydra/consent.go
index 846dcdf..dcac815 100644
--- a/internal/hydra/consent.go
+++ b/internal/hydra/consent.go
@@ -1,8 +1,8 @@
/*
-Copyright (C) JSC iCore - All Rights Reserved
+Copyright (c) JSC iCore.
-Unauthorized copying of this file, via any medium is strictly prohibited
-Proprietary and confidential
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
*/
package hydra
diff --git a/internal/hydra/consent_test.go b/internal/hydra/consent_test.go
new file mode 100644
index 0000000..85e43ab
--- /dev/null
+++ b/internal/hydra/consent_test.go
@@ -0,0 +1,240 @@
+package hydra_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strconv"
+ "testing"
+
+ "github.com/i-core/werther/internal/hydra"
+ "github.com/pkg/errors"
+)
+
+func TestInitiateConsentRequest(t *testing.T) {
+ testCases := []struct {
+ name string
+ challenge string
+ rememberFor int
+ reqInfo *hydra.ReqInfo
+ status int
+ wantErr error
+ }{
+ {
+ name: "challenge is missed",
+ wantErr: hydra.ErrChallengeMissed,
+ },
+ {
+ name: "challenge is not found",
+ challenge: "foo",
+ status: 404,
+ wantErr: hydra.ErrChallengeNotFound,
+ },
+ {
+ name: "challenge is expired",
+ challenge: "foo",
+ status: 409,
+ wantErr: hydra.ErrChallengeExpired,
+ },
+ {
+ name: "happy path",
+ challenge: "foo",
+ status: 200,
+ reqInfo: &hydra.ReqInfo{
+ Challenge: "foo",
+ RequestedScopes: []string{"profile", "email"},
+ Skip: true,
+ Subject: "testSubject",
+ },
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ h := &testInitiateConsentHandler{reqInfo: tc.reqInfo, status: tc.status}
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+ ldr := hydra.NewConsentReqDoer(srv.URL, tc.rememberFor)
+
+ reqInfo, err := ldr.InitiateRequest(tc.challenge)
+
+ if tc.wantErr != nil {
+ if err == nil {
+ t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
+ }
+ err = errors.Cause(err)
+ if err != tc.wantErr {
+ t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
+ }
+
+ if h.challenge != tc.challenge {
+ t.Errorf("\ngot challenge:\n\t%#v\nwant challenge:\n\t%#v", h.challenge, tc.challenge)
+ }
+ if !reflect.DeepEqual(tc.reqInfo, reqInfo) {
+ t.Errorf("\ngot request info:\n\t%#v\nwant request info:\n\t%#v", reqInfo, tc.reqInfo)
+ }
+ })
+ }
+}
+
+type testInitiateConsentHandler struct {
+ reqInfo *hydra.ReqInfo
+ status int
+ challenge string
+}
+
+func (h *testInitiateConsentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/oauth2/auth/requests/consent" {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"error": http.StatusText(http.StatusMethodNotAllowed)}); err != nil {
+ panic(fmt.Sprintf("initial request: failed to write response: %s", err))
+ }
+ return
+ }
+ h.challenge = r.URL.Query().Get("consent_challenge")
+ w.WriteHeader(h.status)
+ if h.status == http.StatusOK {
+ if err := json.NewEncoder(w).Encode(h.reqInfo); err != nil {
+ panic(fmt.Sprintf("initial request: failed to write response: %s", err))
+ }
+ }
+}
+
+func TestAcceptConsentRequest(t *testing.T) {
+ testCases := []struct {
+ name string
+ challenge string
+ rememberFor int
+ remember bool
+ grantScope []interface{}
+ idToken string
+ status int
+ redirect string
+ wantErr error
+ }{
+ {
+ name: "challenge is missed",
+ wantErr: hydra.ErrChallengeMissed,
+ },
+ {
+ name: "challenge is not found",
+ challenge: "foo",
+ rememberFor: 10,
+ remember: true,
+ grantScope: []interface{}{"scope1", "scope2"},
+ idToken: "testToken",
+ status: http.StatusNotFound,
+ wantErr: hydra.ErrChallengeNotFound,
+ },
+ {
+ name: "happy path",
+ challenge: "foo",
+ rememberFor: 10,
+ remember: true,
+ grantScope: []interface{}{"scope1", "scope2"},
+ idToken: "testToken",
+ status: http.StatusOK,
+ redirect: "/test-redirect",
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ h := &testAcceptConsentHandler{challenge: tc.challenge, status: tc.status, redirect: tc.redirect}
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+ ldr := hydra.NewConsentReqDoer(srv.URL, tc.rememberFor)
+
+ var grantScope []string
+ for _, v := range tc.grantScope {
+ grantScope = append(grantScope, v.(string))
+ }
+ redirect, err := ldr.AcceptConsentRequest(tc.challenge, tc.remember, grantScope, tc.idToken)
+
+ if tc.wantErr != nil {
+ if err == nil {
+ t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
+ }
+ err = errors.Cause(err)
+ if err.Error() != tc.wantErr.Error() {
+ t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
+ }
+
+ if h.challenge != tc.challenge {
+ t.Errorf("\ngot challenge:\n\t%#v\nwant challenge:\n\t%#v", h.challenge, tc.challenge)
+ }
+ wantData := map[string]interface{}{
+ "grant_scope": tc.grantScope,
+ "remember": tc.remember,
+ "remember_for": tc.rememberFor,
+ "session": map[string]interface{}{"id_token": tc.idToken},
+ }
+ if !reflect.DeepEqual(h.data, wantData) {
+ t.Errorf("\ngot request data:\n\t%#v\nwant request data:\n\t%#v", h.data, wantData)
+ }
+ if redirect != tc.redirect {
+ t.Errorf("\ngot redirect URL:\n\t%#v\nwant redirect URL:\n\t%#v", redirect, tc.redirect)
+ }
+ })
+ }
+}
+
+type testAcceptConsentHandler struct {
+ challenge string
+ data map[string]interface{}
+ status int
+ redirect string
+}
+
+func (h *testAcceptConsentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut || r.URL.Path != "/oauth2/auth/requests/consent/accept" {
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ return
+ }
+
+ h.challenge = r.URL.Query().Get("consent_challenge")
+ w.WriteHeader(h.status)
+ if r.Body != http.NoBody {
+ // Note: Go JSON Decoder decodes numbers as float64, but we need int.
+ // So we convert numbers to int manually.
+ var raw map[string]json.RawMessage
+ if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
+ panic(fmt.Sprintf("accept request: failed to read request body: %s", err))
+ }
+ h.data = make(map[string]interface{}, len(raw))
+ for key, val := range raw {
+ s := string(val)
+ if i, err := strconv.Atoi(s); err == nil {
+ h.data[key] = i
+ continue
+ }
+ if f, err := strconv.ParseFloat(s, 64); err == nil {
+ h.data[key] = f
+ continue
+ }
+ var v interface{}
+ if err := json.Unmarshal(val, &v); err == nil {
+ h.data[key] = v
+ continue
+ }
+ h.data[key] = val
+ }
+ }
+ if h.status == http.StatusOK {
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"redirect_to": h.redirect}); err != nil {
+ panic(fmt.Sprintf("accept request: failed to write response: %s", err))
+ }
+ }
+}
diff --git a/internal/hydra/hydra.go b/internal/hydra/hydra.go
index bbf7335..8e14479 100644
--- a/internal/hydra/hydra.go
+++ b/internal/hydra/hydra.go
@@ -1,8 +1,8 @@
/*
-Copyright (C) JSC iCore - All Rights Reserved
+Copyright (c) JSC iCore.
-Unauthorized copying of this file, via any medium is strictly prohibited
-Proprietary and confidential
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
*/
package hydra
@@ -18,6 +18,8 @@ import (
)
var (
+ // ErrChallengeMissed is an error that happens when a challenge is missed.
+ ErrChallengeMissed = errors.New("challenge missed")
// ErrUnauthenticated is an error that happens when authentication is failed.
ErrUnauthenticated = errors.New("unauthenticated")
// ErrChallengeNotFound is an error that happens when an unknown challenge is used.
@@ -43,6 +45,9 @@ type ReqInfo struct {
}
func initiateRequest(typ reqType, hydraURL, challenge string) (*ReqInfo, error) {
+ if challenge == "" {
+ return nil, ErrChallengeMissed
+ }
ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%[1]s?%[1]s_challenge=%s", string(typ), challenge))
if err != nil {
return nil, err
@@ -72,6 +77,9 @@ func initiateRequest(typ reqType, hydraURL, challenge string) (*ReqInfo, error)
}
func acceptRequest(typ reqType, hydraURL, challenge string, data interface{}) (string, error) {
+ if challenge == "" {
+ return "", ErrChallengeMissed
+ }
ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%[1]s/accept?%[1]s_challenge=%s", string(typ), challenge))
if err != nil {
return "", err
diff --git a/internal/hydra/login.go b/internal/hydra/login.go
index 6571260..091fcd8 100644
--- a/internal/hydra/login.go
+++ b/internal/hydra/login.go
@@ -1,8 +1,8 @@
/*
-Copyright (C) JSC iCore - All Rights Reserved
+Copyright (c) JSC iCore.
-Unauthorized copying of this file, via any medium is strictly prohibited
-Proprietary and confidential
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
*/
package hydra
diff --git a/internal/hydra/login_test.go b/internal/hydra/login_test.go
new file mode 100644
index 0000000..01a9821
--- /dev/null
+++ b/internal/hydra/login_test.go
@@ -0,0 +1,246 @@
+package hydra_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strconv"
+ "testing"
+
+ "github.com/i-core/werther/internal/hydra"
+ "github.com/pkg/errors"
+)
+
+func TestInitiateLoginRequest(t *testing.T) {
+ testCases := []struct {
+ name string
+ challenge string
+ reqInfo *hydra.ReqInfo
+ status int
+ wantErr error
+ }{
+ {
+ name: "challenge is missed",
+ wantErr: hydra.ErrChallengeMissed,
+ },
+ {
+ name: "unauthenticated",
+ challenge: "foo",
+ status: 401,
+ wantErr: hydra.ErrUnauthenticated,
+ },
+ {
+ name: "challenge is not found",
+ challenge: "foo",
+ status: 404,
+ wantErr: hydra.ErrChallengeNotFound,
+ },
+ {
+ name: "challenge is expired",
+ challenge: "foo",
+ status: 409,
+ wantErr: hydra.ErrChallengeExpired,
+ },
+ {
+ name: "happy path",
+ challenge: "foo",
+ status: 200,
+ reqInfo: &hydra.ReqInfo{
+ Challenge: "foo",
+ RequestedScopes: []string{"profile", "email"},
+ Skip: true,
+ Subject: "testSubject",
+ },
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ h := &testInitiateLoginHandler{reqInfo: tc.reqInfo, status: tc.status}
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+ ldr := hydra.NewLoginReqDoer(srv.URL, 0)
+
+ reqInfo, err := ldr.InitiateRequest(tc.challenge)
+
+ if tc.wantErr != nil {
+ if err == nil {
+ t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
+ }
+ err = errors.Cause(err)
+ if err != tc.wantErr {
+ t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
+ }
+
+ if h.challenge != tc.challenge {
+ t.Errorf("\ngot challenge:\n\t%#v\nwant challenge:\n\t%#v", h.challenge, tc.challenge)
+ }
+ if !reflect.DeepEqual(tc.reqInfo, reqInfo) {
+ t.Errorf("\ngot request info:\n\t%#v\nwant request info:\n\t%#v", reqInfo, tc.reqInfo)
+ }
+ })
+ }
+}
+
+type testInitiateLoginHandler struct {
+ reqInfo *hydra.ReqInfo
+ status int
+ challenge string
+}
+
+func (h *testInitiateLoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/oauth2/auth/requests/login" {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"error": http.StatusText(http.StatusMethodNotAllowed)}); err != nil {
+ panic(fmt.Sprintf("initial request: failed to write response: %s", err))
+ }
+ return
+ }
+ h.challenge = r.URL.Query().Get("login_challenge")
+ w.WriteHeader(h.status)
+ if h.status == http.StatusOK {
+ if err := json.NewEncoder(w).Encode(h.reqInfo); err != nil {
+ panic(fmt.Sprintf("initial request: failed to write response: %s", err))
+ }
+ }
+}
+
+func TestAcceptLoginRequest(t *testing.T) {
+ testCases := []struct {
+ name string
+ challenge string
+ rememberFor int
+ remember bool
+ subject string
+ status int
+ redirect string
+ wantErr error
+ }{
+ {
+ name: "challenge is missed",
+ wantErr: hydra.ErrChallengeMissed,
+ },
+ {
+ name: "unauthenticated",
+ challenge: "foo",
+ rememberFor: 10,
+ remember: true,
+ subject: "testSubject",
+ status: http.StatusUnauthorized,
+ wantErr: hydra.ErrUnauthenticated,
+ },
+ {
+ name: "challenge is not found",
+ challenge: "foo",
+ rememberFor: 10,
+ remember: true,
+ subject: "testSubject",
+ status: http.StatusNotFound,
+ wantErr: hydra.ErrChallengeNotFound,
+ },
+ {
+ name: "happy path",
+ challenge: "foo",
+ rememberFor: 10,
+ remember: true,
+ subject: "testSubject",
+ status: http.StatusOK,
+ redirect: "/test-redirect",
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ h := &testAcceptLoginHandler{challenge: tc.challenge, status: tc.status, redirect: tc.redirect}
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+ ldr := hydra.NewLoginReqDoer(srv.URL, tc.rememberFor)
+
+ redirect, err := ldr.AcceptLoginRequest(tc.challenge, tc.remember, tc.subject)
+
+ if tc.wantErr != nil {
+ if err == nil {
+ t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
+ }
+ err = errors.Cause(err)
+ if err.Error() != tc.wantErr.Error() {
+ t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
+ }
+
+ if h.challenge != tc.challenge {
+ t.Errorf("\ngot challenge:\n\t%#v\nwant challenge:\n\t%#v", h.challenge, tc.challenge)
+ }
+ wantData := map[string]interface{}{
+ "remember": tc.remember,
+ "remember_for": tc.rememberFor,
+ "subject": tc.subject,
+ }
+ if !reflect.DeepEqual(h.data, wantData) {
+ t.Errorf("\ngot request data:\n\t%#v\nwant request data:\n\t%#v", h.data, wantData)
+ }
+ if redirect != tc.redirect {
+ t.Errorf("\ngot redirect URL:\n\t%#v\nwant redirect URL:\n\t%#v", redirect, tc.redirect)
+ }
+ })
+ }
+}
+
+type testAcceptLoginHandler struct {
+ challenge string
+ data map[string]interface{}
+ status int
+ redirect string
+}
+
+func (h *testAcceptLoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut || r.URL.Path != "/oauth2/auth/requests/login/accept" {
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ return
+ }
+
+ h.challenge = r.URL.Query().Get("login_challenge")
+ w.WriteHeader(h.status)
+ if r.Body != http.NoBody {
+ // Note: Go JSON Decoder decodes numbers as float64, but we need int.
+ // So we convert numbers to int manually.
+ var raw map[string]json.RawMessage
+ if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
+ panic(fmt.Sprintf("accept request: failed to read request body: %s", err))
+ }
+ h.data = make(map[string]interface{}, len(raw))
+ for key, val := range raw {
+ s := string(val)
+ if i, err := strconv.Atoi(s); err == nil {
+ h.data[key] = i
+ continue
+ }
+ if f, err := strconv.ParseFloat(s, 64); err == nil {
+ h.data[key] = f
+ continue
+ }
+ var v interface{}
+ if err := json.Unmarshal(val, &v); err == nil {
+ h.data[key] = v
+ continue
+ }
+ h.data[key] = val
+ }
+ }
+ if h.status == http.StatusOK {
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"redirect_to": h.redirect}); err != nil {
+ panic(fmt.Sprintf("accept request: failed to write response: %s", err))
+ }
+ }
+}
diff --git a/internal/hydra/logout.go b/internal/hydra/logout.go
index d5135aa..1f97c20 100644
--- a/internal/hydra/logout.go
+++ b/internal/hydra/logout.go
@@ -1,8 +1,8 @@
/*
-Copyright (C) JSC iCore - All Rights Reserved
+Copyright (c) JSC iCore.
-Unauthorized copying of this file, via any medium is strictly prohibited
-Proprietary and confidential
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
*/
package hydra
diff --git a/internal/hydra/logout_test.go b/internal/hydra/logout_test.go
new file mode 100644
index 0000000..de8f297
--- /dev/null
+++ b/internal/hydra/logout_test.go
@@ -0,0 +1,177 @@
+package hydra_test
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+
+ "github.com/i-core/werther/internal/hydra"
+ "github.com/pkg/errors"
+)
+
+func TestInitiateLogoutRequest(t *testing.T) {
+ testCases := []struct {
+ name string
+ challenge string
+ reqInfo *hydra.ReqInfo
+ status int
+ wantErr error
+ }{
+ {
+ name: "challenge is missed",
+ wantErr: hydra.ErrChallengeMissed,
+ },
+ {
+ name: "challenge is not found",
+ challenge: "foo",
+ status: 404,
+ wantErr: hydra.ErrChallengeNotFound,
+ },
+ {
+ name: "happy path",
+ challenge: "foo",
+ status: 200,
+ reqInfo: &hydra.ReqInfo{
+ Challenge: "foo",
+ },
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ h := &testInitiateLogoutHandler{reqInfo: tc.reqInfo, status: tc.status}
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+ ldr := hydra.NewLogoutReqDoer(srv.URL)
+
+ reqInfo, err := ldr.InitiateRequest(tc.challenge)
+
+ if tc.wantErr != nil {
+ if err == nil {
+ t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
+ }
+ err = errors.Cause(err)
+ if err != tc.wantErr {
+ t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
+ }
+
+ if h.challenge != tc.challenge {
+ t.Errorf("\ngot challenge:\n\t%#v\nwant challenge:\n\t%#v", h.challenge, tc.challenge)
+ }
+ if !reflect.DeepEqual(tc.reqInfo, reqInfo) {
+ t.Errorf("\ngot request info:\n\t%#v\nwant request info:\n\t%#v", reqInfo, tc.reqInfo)
+ }
+ })
+ }
+}
+
+type testInitiateLogoutHandler struct {
+ reqInfo *hydra.ReqInfo
+ status int
+ challenge string
+}
+
+func (h *testInitiateLogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/oauth2/auth/requests/logout" {
+ w.WriteHeader(http.StatusMethodNotAllowed)
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"error": http.StatusText(http.StatusMethodNotAllowed)}); err != nil {
+ panic(fmt.Sprintf("initial request: failed to write response: %s", err))
+ }
+ return
+ }
+ h.challenge = r.URL.Query().Get("logout_challenge")
+ w.WriteHeader(h.status)
+ if h.status == http.StatusOK {
+ if err := json.NewEncoder(w).Encode(h.reqInfo); err != nil {
+ panic(fmt.Sprintf("initial request: failed to write response: %s", err))
+ }
+ }
+}
+
+func TestAcceptLogoutRequest(t *testing.T) {
+ testCases := []struct {
+ name string
+ challenge string
+ status int
+ redirect string
+ wantErr error
+ }{
+ {
+ name: "challenge is missed",
+ wantErr: hydra.ErrChallengeMissed,
+ },
+ {
+ name: "challenge is not found",
+ challenge: "foo",
+ status: http.StatusNotFound,
+ wantErr: hydra.ErrChallengeNotFound,
+ },
+ {
+ name: "happy path",
+ challenge: "foo",
+ status: http.StatusOK,
+ redirect: "/test-redirect",
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ h := &testAcceptLogoutHandler{challenge: tc.challenge, status: tc.status, redirect: tc.redirect}
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+ ldr := hydra.NewLogoutReqDoer(srv.URL)
+
+ redirect, err := ldr.AcceptLogoutRequest(tc.challenge)
+
+ if tc.wantErr != nil {
+ if err == nil {
+ t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
+ }
+ err = errors.Cause(err)
+ if err.Error() != tc.wantErr.Error() {
+ t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
+ }
+ return
+ }
+
+ if err != nil {
+ t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
+ }
+
+ if h.challenge != tc.challenge {
+ t.Errorf("\ngot challenge:\n\t%#v\nwant challenge:\n\t%#v", h.challenge, tc.challenge)
+ }
+ if redirect != tc.redirect {
+ t.Errorf("\ngot redirect URL:\n\t%#v\nwant redirect URL:\n\t%#v", redirect, tc.redirect)
+ }
+ })
+ }
+}
+
+type testAcceptLogoutHandler struct {
+ challenge string
+ status int
+ redirect string
+}
+
+func (h *testAcceptLogoutHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPut || r.URL.Path != "/oauth2/auth/requests/logout/accept" {
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ return
+ }
+
+ h.challenge = r.URL.Query().Get("logout_challenge")
+ w.WriteHeader(h.status)
+ if h.status == http.StatusOK {
+ if err := json.NewEncoder(w).Encode(map[string]interface{}{"redirect_to": h.redirect}); err != nil {
+ panic(fmt.Sprintf("accept request: failed to write response: %s", err))
+ }
+ }
+}
diff --git a/internal/identp/identp.go b/internal/identp/identp.go
index 15bfd5d..32bdffe 100644
--- a/internal/identp/identp.go
+++ b/internal/identp/identp.go
@@ -1,8 +1,8 @@
/*
-Copyright (C) JSC iCore - All Rights Reserved
+Copyright (c) JSC iCore.
-Unauthorized copying of this file, via any medium is strictly prohibited
-Proprietary and confidential
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
*/
// Package identp is an implementation of [Login and Consent Flow](https://www.ory.sh/docs/hydra/oauth2)
@@ -16,20 +16,20 @@ import (
"strings"
"time"
+ "github.com/i-core/rlog"
+ "github.com/i-core/werther/internal/hydra"
"github.com/justinas/nosurf"
"github.com/pkg/errors"
"go.uber.org/zap"
- "gopkg.i-core.ru/logutil"
- "gopkg.i-core.ru/werther/internal/hydra"
)
const loginTmplName = "login.tmpl"
// Config is a Hydra configuration.
type Config struct {
- HydraURL string `envconfig:"hydra_url" required:"true" desc:"a server admin URL of ORY Hydra"`
- SessionTTL time.Duration `envconfig:"session_ttl" default:"24h" desc:"a session TTL"`
- ClaimScopes map[string]string `envconfig:"claim_scopes" default:"name:profile,family_name:profile,given_name:profile,email:email,http%3A%2F%2Fi-core.ru%2Fclaims%2Froles:roles" desc:"a mapping of OIDC claims to scopes (all claims are URL encoded)"`
+ HydraURL string `envconfig:"hydra_url" required:"true" desc:"an admin URL of ORY Hydra Server"`
+ SessionTTL time.Duration `envconfig:"session_ttl" default:"24h" desc:"a user session's TTL"`
+ ClaimScopes map[string]string `envconfig:"claim_scopes" default:"name:profile,family_name:profile,given_name:profile,email:email,http%3A%2F%2Ffithub.com%2Fi-core.ru%2Fwerther%2Fclaims%2Froles:roles" desc:"a mapping of OpenID Connect claims to scopes (all claims are URL encoded)"`
}
// UserManager is an interface that is used for authentication and providing user's claims.
@@ -105,7 +105,7 @@ type oa2LoginReqProcessor interface {
func newLoginStartHandler(rproc oa2LoginReqProcessor, tmplRenderer TemplateRenderer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- log := logutil.FromContext(r.Context()).Sugar()
+ log := rlog.FromContext(r.Context()).Sugar()
challenge := r.URL.Query().Get("login_challenge")
if challenge == "" {
log.Debug("No login challenge that is needed by the OAuth2 provider")
@@ -157,7 +157,7 @@ func newLoginStartHandler(rproc oa2LoginReqProcessor, tmplRenderer TemplateRende
func newLoginEndHandler(ra oa2LoginReqAcceptor, auther authenticator, tmplRenderer TemplateRenderer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- log := logutil.FromContext(r.Context()).Sugar()
+ log := rlog.FromContext(r.Context()).Sugar()
r.ParseForm()
challenge := r.Form.Get("login_challenge")
@@ -223,7 +223,7 @@ type oa2ConsentReqProcessor interface {
func newConsentHandler(rproc oa2ConsentReqProcessor, cfinder oidcClaimsFinder, claimScopes map[string]string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- log := logutil.FromContext(r.Context()).Sugar()
+ log := rlog.FromContext(r.Context()).Sugar()
challenge := r.URL.Query().Get("consent_challenge")
if challenge == "" {
@@ -297,7 +297,7 @@ type oa2LogoutReqProcessor interface {
func newLogoutHandler(rproc oa2LogoutReqProcessor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- log := logutil.FromContext(r.Context()).Sugar()
+ log := rlog.FromContext(r.Context()).Sugar()
challenge := r.URL.Query().Get("logout_challenge")
if challenge == "" {
diff --git a/internal/identp/identp_test.go b/internal/identp/identp_test.go
index 3d990e6..586bd37 100644
--- a/internal/identp/identp_test.go
+++ b/internal/identp/identp_test.go
@@ -1,8 +1,8 @@
/*
-Copyright (C) JSC iCore - All Rights Reserved
+Copyright (c) JSC iCore.
-Unauthorized copying of this file, via any medium is strictly prohibited
-Proprietary and confidential
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
*/
package identp
@@ -18,9 +18,9 @@ import (
"strings"
"testing"
+ "github.com/i-core/werther/internal/hydra"
"github.com/justinas/nosurf"
"github.com/pkg/errors"
- "gopkg.i-core.ru/werther/internal/hydra"
)
func TestHandleLoginStart(t *testing.T) {
diff --git a/internal/ldapclient/ldapclient.go b/internal/ldapclient/ldapclient.go
index a9ff2ca..c7a8b98 100644
--- a/internal/ldapclient/ldapclient.go
+++ b/internal/ldapclient/ldapclient.go
@@ -1,8 +1,8 @@
/*
-Copyright (C) JSC iCore - All Rights Reserved
+Copyright (c) JSC iCore.
-Unauthorized copying of this file, via any medium is strictly prohibited
-Proprietary and confidential
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
*/
package ldapclient
@@ -17,22 +17,44 @@ import (
"time"
"github.com/coocood/freecache"
+ "github.com/i-core/rlog"
"github.com/pkg/errors"
"go.uber.org/zap"
- "gopkg.i-core.ru/logutil"
ldap "gopkg.in/ldap.v2"
)
+var (
+ // errInvalidCredentials is an error that happens when a user's password is invalid.
+ errInvalidCredentials = fmt.Errorf("invalid credentials")
+ // errConnectionTimeout is an error that happens when no one LDAP endpoint responds.
+ errConnectionTimeout = fmt.Errorf("connection timeout")
+ // errMissedUsername is an error that happens
+ errMissedUsername = errors.New("username is missed")
+ // errUnknownUsername is an error that happens
+ errUnknownUsername = errors.New("unknown username")
+)
+
+type conn interface {
+ Bind(bindDN, password string) error
+ SearchUser(user string, attrs ...string) ([]map[string]interface{}, error)
+ SearchUserRoles(user string, attrs ...string) ([]map[string]interface{}, error)
+ Close()
+}
+
+type connector interface {
+ Connect(ctx context.Context, addr string) (conn, error)
+}
+
// Config is a LDAP configuration.
type Config struct {
Endpoints []string `envconfig:"endpoints" required:"true" desc:"a LDAP's server URLs as \":\""`
- BaseDN string `envconfig:"basedn" required:"true" desc:"a LDAP base DN for searching users"`
BindDN string `envconfig:"binddn" desc:"a LDAP bind DN"`
BindPass string `envconfig:"bindpw" json:"-" desc:"a LDAP bind password"`
+ BaseDN string `envconfig:"basedn" required:"true" desc:"a LDAP base DN for searching users"`
+ AttrClaims map[string]string `envconfig:"attr_claims" default:"name:name,sn:family_name,givenName:given_name,mail:email" desc:"a mapping of LDAP attributes to OpenID connect claims"`
RoleBaseDN string `envconfig:"role_basedn" required:"true" desc:"a LDAP base DN for searching roles"`
- RoleAttr string `envconfig:"role_attr" default:"description" desc:"a LDAP attribute for role's name"`
- RoleClaim string `ignored:"true"` // is custom OIDC claim name for roles' list
- AttrClaims map[string]string `envconfig:"attr_claims" default:"name:name,sn:family_name,givenName:given_name,mail:email" desc:"a mapping of LDAP attributes to OIDC claims"`
+ RoleAttr string `envconfig:"role_attr" default:"description" desc:"a LDAP group's attribute that contains a role's name"`
+ RoleClaim string `envconfig:"role_claim" default:"https://github.com/i-core/werther/claims/roles" desc:"a name of an OpenID Connect claim that contains user roles"`
CacheSize int `envconfig:"cache_size" default:"512" desc:"a user info cache's size in KiB"`
CacheTTL time.Duration `envconfig:"cache_ttl" default:"30m" desc:"a user info cache TTL"`
}
@@ -40,17 +62,16 @@ type Config struct {
// Client is a LDAP client (compatible with Active Directory).
type Client struct {
Config
- cache *freecache.Cache
+ connector connector
+ cache *freecache.Cache
}
// New creates a new LDAP client.
func New(cnf Config) *Client {
- if cnf.RoleClaim == "" {
- cnf.RoleClaim = "http://i-core.ru/claims/roles"
- }
return &Client{
- Config: cnf,
- cache: freecache.NewCache(cnf.CacheSize * 1024),
+ Config: cnf,
+ connector: &ldapConnector{BaseDN: cnf.BaseDN, RoleBaseDN: cnf.RoleBaseDN},
+ cache: freecache.NewCache(cnf.CacheSize * 1024),
}
}
@@ -64,10 +85,10 @@ func (cli *Client) Authenticate(ctx context.Context, username, password string)
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
- cn, ok := <-cli.dialTCP(ctx)
+ cn, ok := <-cli.connect(ctx)
cancel()
if !ok {
- return false, errors.New("connection timeout")
+ return false, errConnectionTimeout
}
defer cn.Close()
@@ -81,7 +102,7 @@ func (cli *Client) Authenticate(ctx context.Context, username, password string)
}
if err := cn.Bind(details["dn"].(string), password); err != nil {
- if ldapErr, ok := err.(*ldap.Error); ok && ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials {
+ if err == errInvalidCredentials {
return false, nil
}
return false, err
@@ -89,85 +110,20 @@ func (cli *Client) Authenticate(ctx context.Context, username, password string)
// Clear the claims' cache because of possible re-authentication. We don't want stale claims after re-login.
if ok := cli.cache.Del([]byte(username)); ok {
- log := logutil.FromContext(ctx)
+ log := rlog.FromContext(ctx)
log.Debug("Cleared user's OIDC claims in the cache")
}
return true, nil
}
-func (cli *Client) dialTCP(ctx context.Context) <-chan *ldap.Conn {
- var (
- wg sync.WaitGroup
- ch = make(chan *ldap.Conn)
- )
- wg.Add(len(cli.Endpoints))
- for _, addr := range cli.Endpoints {
- go func(addr string) {
- defer wg.Done()
-
- log := logutil.FromContext(ctx).Sugar()
-
- d := net.Dialer{Timeout: ldap.DefaultTimeout}
- tcpcn, err := d.DialContext(ctx, "tcp", addr)
- if err != nil {
- log.Debug("Failed to create a LDAP connection", "address", addr)
- return
- }
- ldapcn := ldap.NewConn(tcpcn, false)
- ldapcn.Start()
- select {
- case <-ctx.Done():
- ldapcn.Close()
- log.Debug("a LDAP connection is cancelled", "address", addr)
- return
- case ch <- ldapcn:
- }
- }(addr)
- }
- go func() {
- wg.Wait()
- close(ch)
- }()
- return ch
-}
-
-// findBasicUserDetails finds user's LDAP attributes that were specified. It returns nil if no such user.
-func (cli *Client) findBasicUserDetails(cn *ldap.Conn, username string, attrs []string) (map[string]interface{}, error) {
- if cli.BindDN != "" {
- // We need to login to a LDAP server with a service account for retrieving user data.
- if err := cn.Bind(cli.BindDN, cli.BindPass); err != nil {
- return nil, err
- }
- }
-
- query := fmt.Sprintf(
- "(&(|(objectClass=organizationalPerson)(objectClass=inetOrgPerson))"+
- "(|(uid=%[1]s)(mail=%[1]s)(userPrincipalName=%[1]s)(sAMAccountName=%[1]s)))", username)
- entries, err := cli.searchEntries(cn, cli.BaseDN, query, attrs...)
- if err != nil {
- return nil, err
- }
- if len(entries) != 1 {
- // We didn't find the user.
- return nil, nil
- }
-
- var (
- entry = entries[0]
- details = make(map[string]interface{})
- )
- for _, attr := range attrs {
- if v, ok := entry[attr]; ok {
- details[attr] = v
- }
- }
- return details, nil
-}
-
// FindOIDCClaims finds all OIDC claims for a user.
func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error) {
- log := logutil.FromContext(ctx).Sugar()
+ if username == "" {
+ return nil, errMissedUsername
+ }
+
+ log := rlog.FromContext(ctx).Sugar()
// Retrieving from LDAP is slow. So, we try to get claims for the given username from the cache.
switch cdata, err := cli.cache.Get([]byte(username)); err {
@@ -190,10 +146,10 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
- cn, ok := <-cli.dialTCP(ctx)
+ cn, ok := <-cli.connect(ctx)
cancel()
if !ok {
- return nil, errors.New("connection timeout")
+ return nil, errConnectionTimeout
}
defer cn.Close()
@@ -208,11 +164,11 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
return nil, err
}
if details == nil {
- return nil, errors.New("unknown username")
+ return nil, errUnknownUsername
}
log.Infow("Retrieved user's info from LDAP", "details", details)
- // Transform the retrived attributes to corresponding claims.
+ // Transform the retrieved attributes to corresponding claims.
claims := make(map[string]interface{})
for attr, v := range details {
if claim, ok := cli.AttrClaims[attr]; ok {
@@ -222,16 +178,15 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
// User's roles is stored in LDAP as groups. We find all groups in a role's DN
// that include the user as a member.
- query := fmt.Sprintf("(&(objectClass=group)(member=%s))", details["dn"])
- entries, err := cli.searchEntries(cn, cli.RoleBaseDN, query, "dn", cli.RoleAttr)
+ entries, err := cn.SearchUserRoles(fmt.Sprintf("%s", details["dn"]), "dn", cli.RoleAttr)
if err != nil {
return nil, err
}
- roles := make(map[string][]string)
+ roles := make(map[string]interface{})
for _, entry := range entries {
- roleDN := entry["dn"].(string)
- if roleDN == "" {
+ roleDN, ok := entry["dn"].(string)
+ if !ok || roleDN == "" {
log.Infow("No required LDAP attribute for a role", "ldapAttribute", "dn", "entry", entry)
continue
}
@@ -248,14 +203,19 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
}
// The DN without the role's base DN must contain a CN and OU
// where the CN is for uniqueness only, and the OU is an application id.
- v := strings.Split(roleDN[:n-k-1], ",")
- if len(v) != 2 {
+ path := strings.Split(roleDN[:n-k-1], ",")
+ if len(path) != 2 {
log.Infow("A role's DN without the role's base DN must contain two nodes only",
"roleBaseDN", cli.RoleBaseDN, "roleDN", roleDN)
continue
}
- appID := v[1][len("OU="):]
- roles[appID] = append(roles[appID], entry[cli.RoleAttr].(string))
+ appID := path[1][len("OU="):]
+
+ var appRoles []interface{}
+ if v := roles[appID]; v != nil {
+ appRoles = v.([]interface{})
+ }
+ roles[appID] = append(appRoles, entry[cli.RoleAttr])
}
claims[cli.RoleClaim] = roles
@@ -271,11 +231,114 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
return claims, nil
}
+func (cli *Client) connect(ctx context.Context) <-chan conn {
+ var (
+ wg sync.WaitGroup
+ ch = make(chan conn)
+ )
+ wg.Add(len(cli.Endpoints))
+ for _, addr := range cli.Endpoints {
+ go func(addr string) {
+ defer wg.Done()
+
+ log := rlog.FromContext(ctx).Sugar()
+ cn, err := cli.connector.Connect(ctx, addr)
+ if err != nil {
+ log.Debug("Failed to create a LDAP connection", "address", addr)
+ return
+ }
+ select {
+ case <-ctx.Done():
+ cn.Close()
+ log.Debug("a LDAP connection is cancelled", "address", addr)
+ return
+ case ch <- cn:
+ }
+ }(addr)
+ }
+ go func() {
+ wg.Wait()
+ close(ch)
+ }()
+ return ch
+}
+
+// findBasicUserDetails finds user's LDAP attributes that were specified. It returns nil if no such user.
+func (cli *Client) findBasicUserDetails(cn conn, username string, attrs []string) (map[string]interface{}, error) {
+ if cli.BindDN != "" {
+ // We need to login to a LDAP server with a service account for retrieving user data.
+ if err := cn.Bind(cli.BindDN, cli.BindPass); err != nil {
+ return nil, errors.Wrap(err, "failed to login to a LDAP woth a service account")
+ }
+ }
+
+ entries, err := cn.SearchUser(username, attrs...)
+ if err != nil {
+ return nil, err
+ }
+ if len(entries) != 1 {
+ // We didn't find the user.
+ return nil, nil
+ }
+
+ var (
+ entry = entries[0]
+ details = make(map[string]interface{})
+ )
+ for _, attr := range attrs {
+ if v, ok := entry[attr]; ok {
+ details[attr] = v
+ }
+ }
+ return details, nil
+}
+
+type ldapConnector struct {
+ BaseDN string
+ RoleBaseDN string
+}
+
+func (c *ldapConnector) Connect(ctx context.Context, addr string) (conn, error) {
+ d := net.Dialer{Timeout: ldap.DefaultTimeout}
+ tcpcn, err := d.DialContext(ctx, "tcp", addr)
+ if err != nil {
+ return nil, err
+ }
+ ldapcn := ldap.NewConn(tcpcn, false)
+ ldapcn.Start()
+ return &ldapConn{Conn: ldapcn, BaseDN: c.BaseDN, RoleBaseDN: c.RoleBaseDN}, nil
+}
+
+type ldapConn struct {
+ *ldap.Conn
+ BaseDN string
+ RoleBaseDN string
+}
+
+func (c *ldapConn) Bind(bindDN, password string) error {
+ err := c.Conn.Bind(bindDN, password)
+ if ldapErr, ok := err.(*ldap.Error); ok && ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials {
+ return errInvalidCredentials
+ }
+ return err
+}
+
+func (c *ldapConn) SearchUser(user string, attrs ...string) ([]map[string]interface{}, error) {
+ query := fmt.Sprintf(
+ "(&(|(objectClass=organizationalPerson)(objectClass=inetOrgPerson))"+
+ "(|(uid=%[1]s)(mail=%[1]s)(userPrincipalName=%[1]s)(sAMAccountName=%[1]s)))", user)
+ return c.searchEntries(c.BaseDN, query, attrs)
+}
+
+func (c *ldapConn) SearchUserRoles(user string, attrs ...string) ([]map[string]interface{}, error) {
+ query := fmt.Sprintf("(&(objectClass=group)(member=%s))", user)
+ return c.searchEntries(c.RoleBaseDN, query, attrs)
+}
+
// searchEntries executes a LDAP query, and returns a result as entries where each entry is mapping of LDAP attributes.
-func (cli *Client) searchEntries(cn *ldap.Conn, baseDN, query string, attrs ...string) ([]map[string]interface{}, error) {
- res, err := cn.Search(ldap.NewSearchRequest(
- baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, query, attrs, nil,
- ))
+func (c *ldapConn) searchEntries(baseDN, query string, attrs []string) ([]map[string]interface{}, error) {
+ req := ldap.NewSearchRequest(baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, query, attrs, nil)
+ res, err := c.Search(req)
if err != nil {
return nil, err
}
diff --git a/internal/ldapclient/ldapclient_test.go b/internal/ldapclient/ldapclient_test.go
new file mode 100644
index 0000000..37d3264
--- /dev/null
+++ b/internal/ldapclient/ldapclient_test.go
@@ -0,0 +1,588 @@
+package ldapclient
+
+import (
+ "context"
+ "fmt"
+ "reflect"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pkg/errors"
+)
+
+var (
+ errBindUser = fmt.Errorf("bind user error")
+ errSearchUser = fmt.Errorf("search user error")
+ errSearchRoles = fmt.Errorf("search user roles error")
+ users = []map[string]interface{}{
+ {
+ "dn": "user1",
+ "pass": "user1",
+ "a": "valA",
+ "b": "valB",
+ "c": "valC",
+ },
+ {
+ "dn": "user2",
+ "pass": "user2",
+ "a": "valA",
+ "b": "valB",
+ "c": "valC",
+ "roles": []map[string]interface{}{
+ {"dn": "CN=role1,OU=app1,OU=test,DC=local", "test-roles-attr": "r1"},
+ {"dn": "CN=role2,OU=app1,OU=test,DC=local", "test-roles-attr": "r2"},
+ },
+ },
+ {
+ "dn": "user3",
+ "pass": "user3",
+ "a": "valA",
+ "b": "valB",
+ "c": "valC",
+ "roles": []map[string]interface{}{
+ {"dn": "CN=role1,OU=app1,OU=test,DC=local", "test-roles-attr": "r1"},
+ {"dn": "CN=role2,OU=app1,OU=test,DC=local", "test-roles-attr": "r2"},
+ {"dn": "CN=role3,OU=app2,OU=test,DC=local", "test-roles-attr": "r3"},
+ {"dn": "CN=role4,OU=app2,OU=test,DC=local", "test-roles-attr": "r4"},
+ },
+ },
+ {
+ "dn": "user4",
+ "pass": "user4",
+ "a": "valA",
+ "b": "valB",
+ "c": "valC",
+ "roles": []map[string]interface{}{
+ {"dn": "CN=role1,OU=app1,OU=test,DC=local", "test-roles-attr": "r1"},
+ {"test-roles-attr": "r2"},
+ },
+ },
+ {
+ "dn": "user5",
+ "pass": "user5",
+ "a": "valA",
+ "b": "valB",
+ "c": "valC",
+ "roles": []map[string]interface{}{
+ {"dn": "CN=role1,OU=app1,OU=test,DC=local", "test-roles-attr": "r1"},
+ {"dn": "CN=role2,OU=app1,OU=test,DC=local"},
+ },
+ },
+ {
+ "dn": "user6",
+ "pass": "user6",
+ "a": "valA",
+ "b": "valB",
+ "c": "valC",
+ "roles": []map[string]interface{}{
+ {"dn": "CN=role1,OU=test,DC=local", "test-roles-attr": "r1"},
+ },
+ },
+ {
+ "dn": "serviceUser",
+ "pass": "servicePass",
+ },
+ }
+)
+
+func TestAuthenticate(t *testing.T) {
+ testCases := []struct {
+ name string
+ connector *testConnector
+ bindDN string
+ bindPass string
+ user string
+ pass string
+ wantErr error
+ wantAuth bool
+ }{
+ {
+ name: "username is empty",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ },
+ {
+ name: "password is empty",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ user: "user1",
+ },
+ {
+ name: "connection timeout",
+ connector: newTestConnector("ep1", fmt.Errorf("failed to connect to endpoint")),
+ user: "user1",
+ pass: "user1",
+ wantErr: errConnectionTimeout,
+ },
+ {
+ name: "search user error",
+ connector: newTestConnector("ep1", &testConn{userErr: errSearchUser}),
+ user: "user1",
+ pass: "user1",
+ wantErr: errSearchUser,
+ },
+ {
+ name: "user is not found",
+ connector: newTestConnector("ep1", &testConn{}),
+ user: "user1",
+ pass: "user1",
+ },
+ {
+ name: "authentication error",
+ connector: newTestConnector("ep1", &testConn{users: users, bindErr: errBindUser}),
+ user: "user1",
+ pass: "user1",
+ wantErr: errBindUser,
+ },
+ {
+ name: "invalid password",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ user: "user1",
+ pass: "invalid",
+ },
+ {
+ name: "success auth",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ user: "user1",
+ pass: "user1",
+ wantAuth: true,
+ },
+ {
+ name: "auth with invalid service account",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ bindDN: "serviceUser",
+ bindPass: "invalid",
+ user: "user1",
+ pass: "user1",
+ wantErr: errInvalidCredentials,
+ },
+ {
+ name: "auth with valid service account",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ bindDN: "serviceUser",
+ bindPass: "servicePass",
+ user: "user1",
+ pass: "user1",
+ wantAuth: true,
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ client := New(Config{Endpoints: tc.connector.Endpoints(), BindDN: tc.bindDN, BindPass: tc.bindPass})
+ client.connector = tc.connector
+ ok, err := client.Authenticate(context.Background(), tc.user, tc.pass)
+
+ if ok != tc.wantAuth {
+ t.Errorf("got auth: %t, want auth: %t", ok, tc.wantAuth)
+ }
+ if tc.wantErr != nil {
+ if err == nil {
+ t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
+ }
+ err = errors.Cause(err)
+ if err != tc.wantErr {
+ t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
+ }
+ })
+ }
+}
+
+func TestAuthenticateWhenMultipleEndpointsFailed(t *testing.T) {
+ connector := newTestConnector("ep1", fmt.Errorf("error"), "ep2", fmt.Errorf("error"))
+ client := New(Config{Endpoints: connector.Endpoints()})
+ client.connector = connector
+ _, err := client.Authenticate(context.Background(), "user1", "user1")
+
+ if err == nil {
+ t.Fatalf("\ngot no errors\nwant error:\n\t%s", errConnectionTimeout)
+ }
+ err = errors.Cause(err)
+ if err != errConnectionTimeout {
+ t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, errConnectionTimeout)
+ }
+}
+
+func TestAuthenticateWhenOneEndpointFailedAndOneSuccess(t *testing.T) {
+ ep2 := &testConn{users: users}
+ connector := newTestConnector("ep1", fmt.Errorf("error"), "ep2", ep2)
+ client := New(Config{Endpoints: connector.Endpoints()})
+ client.connector = connector
+ ok, err := client.Authenticate(context.Background(), "user1", "user1")
+
+ if err != nil {
+ t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
+ }
+ if !ok {
+ t.Errorf("got auth: %t, want auth: true", ok)
+ }
+ if !ep2.authRequest {
+ t.Error("\ngot: endpoint \"ep2\" is not called, want: endpoint \"ep2\" is called")
+ }
+}
+
+func TestAuthenticateWhenMultipleEndpointsSuccess(t *testing.T) {
+ ep1 := &testConn{users: users}
+ ep2 := &testConn{users: users}
+ connector := newTestConnector("ep1", ep1, "ep2", ep2)
+ client := New(Config{Endpoints: connector.Endpoints()})
+ client.connector = connector
+
+ ok, err := client.Authenticate(context.Background(), "user1", "user1")
+
+ // Wait for closing all opened LDAP connections.
+ time.Sleep(100 * time.Millisecond)
+
+ if err != nil {
+ t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
+ }
+ if !ok {
+ t.Errorf("got auth: %t, want auth: true", ok)
+ }
+ switch {
+ case ep1.authRequest && ep2.authRequest:
+ t.Error("got: every endpoint is called, want: only one endpoint is called")
+ case !ep1.authRequest && !ep2.authRequest:
+ t.Error("got: no one endpoint is not called, want: only one endpoint is called")
+ }
+ var notClosed []string
+ if !ep1.closed {
+ notClosed = append(notClosed, "ep1")
+ }
+ if !ep2.closed {
+ notClosed = append(notClosed, "ep2")
+ }
+ if len(notClosed) > 0 {
+ t.Errorf("got: endpoints %s are not closed, want: all endpoints are closed", strings.Join(notClosed, ", "))
+ }
+}
+
+func TestFindOIDCClaims(t *testing.T) {
+ testCases := []struct {
+ name string
+ connector *testConnector
+ bindDN string
+ bindPass string
+ user string
+ attrClaims map[string]string
+ wantErr error
+ want map[string]interface{}
+ }{
+ {
+ name: "username is empty",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ wantErr: errMissedUsername,
+ },
+ {
+ name: "connection timeout",
+ connector: newTestConnector("ep1", fmt.Errorf("failed to connect to endpoint")),
+ user: "user1",
+ wantErr: errConnectionTimeout,
+ },
+ {
+ name: "search user error",
+ connector: newTestConnector("ep1", &testConn{userErr: errSearchUser}),
+ user: "user1",
+ wantErr: errSearchUser,
+ },
+ {
+ name: "user is not found",
+ connector: newTestConnector("ep1", &testConn{}),
+ user: "user1",
+ wantErr: errUnknownUsername,
+ },
+ {
+ name: "search roles error",
+ connector: newTestConnector("ep1", &testConn{users: users, rolesErr: errSearchRoles}),
+ user: "user1",
+ wantErr: errSearchRoles,
+ },
+ {
+ name: "extra attributes is filtered from claims",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ user: "user1",
+ attrClaims: map[string]string{"dn": "name", "a": "claimA", "b": "claimB"},
+ want: map[string]interface{}{"name": "user1", "claimA": "valA", "claimB": "valB", "roles": nil},
+ },
+ {
+ name: "skip claim if no attribute",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ user: "user1",
+ attrClaims: map[string]string{"dn": "name", "a": "claimA", "d": "claimD"},
+ want: map[string]interface{}{"name": "user1", "claimA": "valA", "roles": nil},
+ },
+ {
+ name: "claims with roles for one application",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ user: "user2",
+ attrClaims: map[string]string{"dn": "name"},
+ want: map[string]interface{}{"name": "user1", "test-roles-claim": map[string][]string{"app1": {"r1", "r2"}}},
+ },
+ {
+ name: "claims with roles for multiple applications",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ user: "user3",
+ attrClaims: map[string]string{"dn": "name"},
+ want: map[string]interface{}{"name": "user1", "test-roles-claim": map[string][]string{"app1": {"r1", "r2"}, "app2": {"r3", "r4"}}},
+ },
+ {
+ name: "skip role without DN",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ user: "user4",
+ attrClaims: map[string]string{"dn": "name"},
+ want: map[string]interface{}{"name": "user1", "roles": map[string][]string{"app1": {"r1"}}},
+ },
+ {
+ name: "skip role without role attribute",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ user: "user5",
+ attrClaims: map[string]string{"dn": "name"},
+ want: map[string]interface{}{"name": "user1", "roles": map[string][]string{"app1": {"r1"}}},
+ },
+ {
+ name: "skip invalid role without role base DN",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ user: "user6",
+ attrClaims: map[string]string{"dn": "name"},
+ want: map[string]interface{}{"name": "user1", "roles": map[string][]string{"app1": {"r1"}}},
+ },
+ {
+ name: "auth with invalid service account",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ bindDN: "serviceUser",
+ bindPass: "invalid",
+ user: "user1",
+ attrClaims: map[string]string{"dn": "name", "a": "claimA", "b": "claimB"},
+ wantErr: errInvalidCredentials,
+ },
+ {
+ name: "auth with valid service account",
+ connector: newTestConnector("ep1", &testConn{users: users}),
+ bindDN: "serviceUser",
+ bindPass: "servicePass",
+ user: "user1",
+ attrClaims: map[string]string{"dn": "name", "a": "claimA", "b": "claimB"},
+ want: map[string]interface{}{"name": "user1", "claimA": "valA", "claimB": "valB", "roles": nil},
+ },
+ }
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ client := New(Config{
+ Endpoints: tc.connector.Endpoints(),
+ BindDN: tc.bindDN,
+ BindPass: tc.bindPass,
+ AttrClaims: tc.attrClaims,
+ RoleBaseDN: "OU=test,DC=local",
+ RoleClaim: "test-roles-claim",
+ RoleAttr: "test-roles-attr",
+ })
+ client.connector = tc.connector
+ got, err := client.FindOIDCClaims(context.Background(), tc.user)
+
+ if tc.wantErr != nil {
+ if err == nil {
+ t.Fatalf("\ngot no errors\nwant error:\n\t%s", tc.wantErr)
+ }
+ err = errors.Cause(err)
+ if err != tc.wantErr {
+ t.Fatalf("\ngot error:\n\t%s\nwant error:\n\t%s", err, tc.wantErr)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("\ngot error:\n\t%s\nwant no errors", err)
+ }
+
+ if reflect.DeepEqual(got, tc.want) {
+ t.Errorf("\ngot claims:\n\t%v\nwant claims:\n\t%v", got, tc.want)
+ }
+ })
+ }
+}
+
+func TestClaimsCache(t *testing.T) {
+ ep := &testConn{users: users}
+ connector := newTestConnector("ep", ep)
+ client := New(Config{
+ Endpoints: connector.Endpoints(),
+ AttrClaims: map[string]string{"dn": "name", "a": "claimA", "d": "claimD"},
+ RoleBaseDN: "OU=test,DC=local",
+ RoleClaim: "test-roles-claim",
+ RoleAttr: "test-roles-attr",
+ })
+ client.connector = connector
+
+ ok, err := client.Authenticate(context.Background(), "user2", "user2")
+
+ if err != nil {
+ t.Fatalf("initial auth: unexpected error: %s", err)
+ }
+ if !ok {
+ t.Fatal("initial auth: got no auth, want auth")
+ }
+
+ claims1, err := client.FindOIDCClaims(context.Background(), "user2")
+
+ if err != nil {
+ t.Fatalf("claims request 1: unexpected error: %s", err)
+ }
+ if claims1 == nil {
+ t.Fatal("claims request 1: got no claims, want claims")
+ }
+ if !ep.claimsRequest {
+ t.Fatal("claims request 1: got claims from cache, want claims from ldap")
+ }
+
+ ep.claimsRequest = false
+
+ claims2, err := client.FindOIDCClaims(context.Background(), "user2")
+
+ if err != nil {
+ t.Fatalf("claims request 2: unexpected error: %s", err)
+ }
+ if claims2 == nil {
+ t.Fatal("claims request 2: got no claims, want claims")
+ }
+ if ep.claimsRequest {
+ t.Fatal("claims request 2: got claims from ldap, want claims from cache")
+ }
+ if !reflect.DeepEqual(claims1, claims2) {
+ t.Fatalf("claims request 2:\ngot claims:\n\t%v\nwant claims:\n\t%v", claims2, claims1)
+ }
+
+ ok, err = client.Authenticate(context.Background(), "user2", "user2")
+
+ if err != nil {
+ t.Fatalf("re-auth: unexpected error: %s", err)
+ }
+ if !ok {
+ t.Fatal("re-auth: got no auth, want auth")
+ }
+
+ claims3, err := client.FindOIDCClaims(context.Background(), "user2")
+
+ if err != nil {
+ t.Fatalf("claims request 3: unexpected error: %s", err)
+ }
+ if claims3 == nil {
+ t.Fatal("claims request 3: got no claims, want claims")
+ }
+ if !ep.claimsRequest {
+ t.Fatal("claims request 3: got claims from cache, want claims from ldap")
+ }
+}
+
+type testConnector struct {
+ conns map[string]interface{}
+}
+
+func newTestConnector(args ...interface{}) *testConnector {
+ if len(args)%2 != 0 {
+ panic("newTestConnector want args in format \"addr1, conn1, addr2, conn2, addr3, err3\"")
+ }
+ conns := make(map[string]interface{})
+ for i := 0; i < len(args)/2; i++ {
+ addr, ok := args[i*2].(string)
+ if !ok {
+ panic("newTestConnector want args in format \"addr1, conn1, addr2, conn2, addr3, err3\"")
+ }
+
+ switch arg := args[i*2+1].(type) {
+ case error, *testConn:
+ conns[addr] = arg
+ default:
+ panic("newTestConnector want args in format \"addr1, conn1, addr2, conn2, addr3, err3\"")
+ }
+ }
+ return &testConnector{conns: conns}
+}
+
+func (c *testConnector) Endpoints() []string {
+ var eps []string
+ for addr := range c.conns {
+ eps = append(eps, addr)
+ }
+ return eps
+}
+
+func (c *testConnector) Connect(ctx context.Context, addr string) (conn, error) {
+ switch v := c.conns[addr].(type) {
+ case error:
+ return nil, v
+ case *testConn:
+ return v, nil
+ default:
+ panic(fmt.Sprintf("Invalid config for endpoint %q", addr))
+ }
+}
+
+type testConn struct {
+ users []map[string]interface{}
+ bindErr error
+ userErr error
+ rolesErr error
+ authRequest bool
+ claimsRequest bool
+ closed bool
+}
+
+func (c *testConn) Bind(bindDN, password string) error {
+ c.authRequest = true
+ if c.bindErr != nil {
+ return c.bindErr
+ }
+ user := c.findUser(bindDN)
+ if user == nil {
+ return fmt.Errorf("user is not found")
+ }
+ if user["pass"] != password {
+ return errInvalidCredentials
+ }
+ return nil
+}
+
+func (c *testConn) SearchUser(bindDN string, attrs ...string) ([]map[string]interface{}, error) {
+ c.claimsRequest = true
+ if c.userErr != nil {
+ return nil, c.userErr
+ }
+ user := c.findUser(bindDN)
+ if user == nil {
+ return nil, nil
+ }
+ return []map[string]interface{}{user}, nil
+}
+
+func (c *testConn) SearchUserRoles(bindDN string, attrs ...string) ([]map[string]interface{}, error) {
+ if c.rolesErr != nil {
+ return nil, c.rolesErr
+ }
+ user := c.findUser(bindDN)
+ if user == nil {
+ return nil, fmt.Errorf("user is not found")
+ }
+ switch roles := user["roles"].(type) {
+ case nil:
+ return nil, nil
+ case []map[string]interface{}:
+ return roles, nil
+ default:
+ return nil, fmt.Errorf("invalid test roles")
+ }
+}
+
+func (c *testConn) findUser(bindDN string) map[string]interface{} {
+ for _, v := range c.users {
+ if v["dn"] == bindDN {
+ return v
+ }
+ }
+ return nil
+}
+
+func (c *testConn) Close() {
+ c.closed = true
+}
diff --git a/internal/stat/stat.go b/internal/stat/stat.go
index 14f7aa4..4dc9f80 100644
--- a/internal/stat/stat.go
+++ b/internal/stat/stat.go
@@ -1,11 +1,18 @@
+/*
+Copyright (c) JSC iCore.
+
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
+*/
+
package stat
import (
"encoding/json"
"net/http"
+ "github.com/i-core/rlog"
"go.uber.org/zap"
- "gopkg.i-core.ru/logutil"
)
// Handler provides HTTP handlers for health checking and versioning.
@@ -20,14 +27,14 @@ func NewHandler(version string) *Handler {
// AddRoutes registers all required routes for the package stat.
func (h *Handler) AddRoutes(apply func(m, p string, h http.Handler, mws ...func(http.Handler) http.Handler)) {
- apply(http.MethodGet, "/health/alive", newHealthAliveAndReadyHandler())
- apply(http.MethodGet, "/health/ready", newHealthAliveAndReadyHandler())
+ apply(http.MethodGet, "/health/alive", newHealthHandler())
+ apply(http.MethodGet, "/health/ready", newHealthHandler())
apply(http.MethodGet, "/version", newVersionHandler(h.version))
}
-func newHealthAliveAndReadyHandler() http.HandlerFunc {
+func newHealthHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- log := logutil.FromContext(r.Context())
+ log := rlog.FromContext(r.Context())
resp := struct {
Status string `json:"status"`
}{
@@ -44,7 +51,7 @@ func newHealthAliveAndReadyHandler() http.HandlerFunc {
func newVersionHandler(version string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
- log := logutil.FromContext(r.Context())
+ log := rlog.FromContext(r.Context())
resp := struct {
Version string `json:"version"`
}{
diff --git a/internal/stat/stat_test.go b/internal/stat/stat_test.go
new file mode 100644
index 0000000..ddadf7e
--- /dev/null
+++ b/internal/stat/stat_test.go
@@ -0,0 +1,64 @@
+package stat
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+
+ "github.com/i-core/routegroup"
+)
+
+func TestHealthHandler(t *testing.T) {
+ rr := httptest.NewRecorder()
+ h := newHealthHandler()
+ h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "http://example.org", nil))
+ testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"status": "ok"})
+}
+
+func TestVersionHandler(t *testing.T) {
+ rr := httptest.NewRecorder()
+ h := newVersionHandler("test-version")
+ h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "http://example.org", nil))
+ testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"version": "test-version"})
+}
+
+func TestStatHandler(t *testing.T) {
+ var (
+ rr *httptest.ResponseRecorder
+ router = routegroup.NewRouter()
+ )
+ router.AddRoutes(NewHandler("test-version"), "/stat")
+
+ rr = httptest.NewRecorder()
+ router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/stat/health/alive", nil))
+ testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"status": "ok"})
+
+ rr = httptest.NewRecorder()
+ router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/stat/health/ready", nil))
+ testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"status": "ok"})
+
+ rr = httptest.NewRecorder()
+ router.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/stat/version", nil))
+ testResp(t, rr, http.StatusOK, "application/json", map[string]interface{}{"version": "test-version"})
+}
+
+func testResp(t *testing.T, rr *httptest.ResponseRecorder, wantStatus int, wantMime string, wantBody interface{}) {
+ if rr.Code != wantStatus {
+ t.Errorf("got status %d, want status %d", rr.Code, wantStatus)
+ }
+
+ if gotMime := rr.Header().Get("Content-Type"); gotMime != wantMime {
+ t.Errorf("got content type %q, want content type %q", gotMime, wantMime)
+ }
+
+ var gotBody interface{}
+ if err := json.NewDecoder(rr.Body).Decode(&gotBody); err != nil {
+ t.Fatalf("failed to decode the request body: %s", err)
+ }
+
+ if !reflect.DeepEqual(gotBody, wantBody) {
+ t.Errorf("got body %#v, want body %#v", gotBody, wantBody)
+ }
+}
diff --git a/internal/web/web.go b/internal/web/web.go
index 4f7d914..7ce0d8f 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -1,8 +1,8 @@
/*
-Copyright (C) JSC iCore - All Rights Reserved
+Copyright (c) JSC iCore.
-Unauthorized copying of this file, via any medium is strictly prohibited
-Proprietary and confidential
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
*/
//go:generate go run github.com/kevinburke/go-bindata/go-bindata -o templates.go -pkg web -prefix templates/ templates/...
@@ -20,8 +20,8 @@ import (
"path"
assetfs "github.com/elazarl/go-bindata-assetfs"
+ "github.com/i-core/routegroup"
"github.com/pkg/errors"
- "gopkg.i-core.ru/httputil"
)
// The file systems provide templates and their resources that are stored in the application's internal assets.
@@ -39,7 +39,7 @@ type Config struct {
// HTMLRenderer renders a HTML page from a Go template.
//
-// A template's source for a HTML page should contains four blocks:
+// A template's source for a HTML page should contain four blocks:
// "title", "style", "js", "content". Block "title" should contain the content of the "title" HTML tag.
// Block "style" should contain "link" HTML tags that are injected to the head of the page.
// Block "js" should contain "script" HTML tags that are injected to the bottom of the page's body.
@@ -149,7 +149,7 @@ func NewStaticHandler(cnf Config) *StaticHandler {
func (h *StaticHandler) AddRoutes(apply func(m, p string, h http.Handler, mws ...func(http.Handler) http.Handler)) {
fileServer := http.FileServer(h.fs)
apply(http.MethodGet, "/*filepath", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- r.URL.Path = httputil.PathParam(r.Context(), "filepath")
+ r.URL.Path = routegroup.PathParam(r.Context(), "filepath")
fileServer.ServeHTTP(w, r)
}))
}
diff --git a/internal/web/web_test.go b/internal/web/web_test.go
index 71fd52f..0d539b7 100644
--- a/internal/web/web_test.go
+++ b/internal/web/web_test.go
@@ -1,8 +1,8 @@
/*
-Copyright (C) JSC iCore - All Rights Reserved
+Copyright (c) JSC iCore.
-Unauthorized copying of this file, via any medium is strictly prohibited
-Proprietary and confidential
+This source code is licensed under the MIT license found in the
+LICENSE file in the root directory of this source tree.
*/
package web
@@ -17,7 +17,7 @@ import (
"testing"
"github.com/andreyvit/diff"
- "gopkg.i-core.ru/httputil"
+ "github.com/i-core/routegroup"
)
func TestHTMLRenderer(t *testing.T) {
@@ -170,7 +170,7 @@ func TestStaticHandler(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/static/"+tc.file, nil)
rr := httptest.NewRecorder()
- router := httputil.NewRouter()
+ router := routegroup.NewRouter()
router.AddRoutes(NewStaticHandler(cnf), "/static")
router.ServeHTTP(rr, r)