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)