move to GitHub.
This commit is contained in:
parent
d761ad579a
commit
3bbac7bb74
2
.codecov.yml
Normal file
2
.codecov.yml
Normal file
@ -0,0 +1,2 @@
|
||||
ignore:
|
||||
- "internal/web/templates.go"
|
BIN
.github/media/screenshot.gif
vendored
Normal file
BIN
.github/media/screenshot.gif
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 272 KiB |
0
.gitignore
vendored
0
.gitignore
vendored
@ -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
|
||||
|
29
CHANGELOG.md
29
CHANGELOG.md
@ -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.
|
10
Dockerfile
10
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
|
||||
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -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.
|
328
README.md
328
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 <sup>[1](#myfootnote1)</sup>
|
||||
|
||||
# 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**
|
||||
<!-- To generate the table use the command "npx doctoc --maxlevel 2 README.md" -->
|
||||
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
||||
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
||||
|
||||
|
||||
- [Installing](#installing)
|
||||
- [Usage](#usage)
|
||||
- [Configuration](#configuration)
|
||||
- [User roles](#user-roles)
|
||||
- [UI customization](#ui-customization)
|
||||
- [Resources](#resources)
|
||||
- [Footnotes](#footnotes)
|
||||
- [Contributing](#contributing)
|
||||
- [License](#license)
|
||||
|
||||
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
||||
|
||||
## 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_ENDPOINTS=icdc0.example.local:389,icdc1.example.local:389 \
|
||||
-e WERTHER_LDAP_BINDDN=<BINDDN> \
|
||||
-e WERTHER_LDAP_BINDPW=<BINDDN_PASSWORD> \
|
||||
-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_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 <ACCESS_TOKEN>"
|
||||
```
|
||||
```
|
||||
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:<COOKIES_FROM_WERTHER_DOMAIN>"
|
||||
```
|
||||
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=<BINDDN> \
|
||||
-e WERTHER_LDAP_BINDPW=<BINDDN_PASSWORD> \
|
||||
-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=<id_token>&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. <a name="myfootnote1"></a> 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
|
@ -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
|
@ -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 (<host>:<port>)"`
|
||||
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)))
|
||||
}
|
||||
|
@ -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
|
||||
|
15
go.mod
15
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
|
||||
)
|
||||
|
22
go.sum
22
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=
|
||||
|
@ -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
|
||||
|
240
internal/hydra/consent_test.go
Normal file
240
internal/hydra/consent_test.go
Normal file
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
246
internal/hydra/login_test.go
Normal file
246
internal/hydra/login_test.go
Normal file
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
|
177
internal/hydra/logout_test.go
Normal file
177
internal/hydra/logout_test.go
Normal file
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
@ -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 == "" {
|
||||
|
@ -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) {
|
||||
|
@ -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 \"<address>:<port>\""`
|
||||
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,16 +62,15 @@ type Config struct {
|
||||
// Client is a LDAP client (compatible with Active Directory).
|
||||
type Client struct {
|
||||
Config
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
588
internal/ldapclient/ldapclient_test.go
Normal file
588
internal/ldapclient/ldapclient_test.go
Normal file
@ -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
|
||||
}
|
@ -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"`
|
||||
}{
|
||||
|
64
internal/stat/stat_test.go
Normal file
64
internal/stat/stat_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}))
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user