logout: add support of logout flow

This commit is contained in:
Nikolay Stupak 2019-05-15 15:03:05 +03:00
parent 6658817311
commit d761ad579a
42 changed files with 1760 additions and 1356 deletions

View File

@ -2,17 +2,13 @@
# #
# Unauthorized copying of this file, via any medium is strictly prohibited # Unauthorized copying of this file, via any medium is strictly prohibited
# Proprietary and confidential # Proprietary and confidential
#
# Written by Konstantin Lepa <klepa@i-core.ru>, September 2018
run: run:
test: true test: true
silent: true silent: true
linters-settings: linters-settings:
govet: govet:
check-shadowing: true check-shadowing: true
linters: linters:
disable-all: true disable-all: true
enable: enable:
@ -27,3 +23,5 @@ linters:
- interfacer - interfacer
- unconvert - unconvert
- govet - govet
issues:
exclude-use-default: false

View File

@ -4,6 +4,21 @@ 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/), 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). 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 ## [1.0.0] - 2019-02-18
### Added ### Added
- Add unit tests for server's logic. - Add unit tests for server's logic.

View File

@ -2,10 +2,8 @@
# #
# Unauthorized copying of this file, via any medium is strictly prohibited # Unauthorized copying of this file, via any medium is strictly prohibited
# Proprietary and confidential # Proprietary and confidential
#
# Written by Konstantin Lepa <klepa@i-core.ru>, July 2018
FROM golang:1.11-alpine AS build FROM golang:1.12-alpine AS build
ARG VERSION ARG VERSION
ARG GOPROXY ARG GOPROXY
@ -18,7 +16,7 @@ COPY go.mod .
COPY go.sum . COPY go.sum .
COPY cmd cmd COPY cmd cmd
COPY internal internal COPY internal internal
RUN env CGO_ENABLED=0 go install -ldflags="-w -s -X gopkg.i-core.ru/werther/internal/server.Version=${VERSION}" ./... RUN env CGO_ENABLED=0 go install -ldflags="-w -s -X gopkg.i-core.ru/werther/cmd/werther.Version=${VERSION}" ./...
FROM scratch AS final FROM scratch AS final
COPY --from=build /etc/passwd /etc/passwd COPY --from=build /etc/passwd /etc/passwd

View File

@ -2,7 +2,10 @@
# Werther # Werther
Werther is a login provider for ORY Hydra that is an OAuth2 provider. Werther is an identity provider for ORY Hydra that is an OAuth2 provider.
**Important!**
**The current version is compatible with ORY Hydra v1.0.0-rc.12 or higher.**
## Build ## Build
``` ```
@ -12,8 +15,7 @@ go install ./...
## Development ## Development
Assume that your IP is set as $MY_HOST. The instruction will use 4444 TCP port for OAuth2 Provider Hydra, 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 one minute. 3000 TCP port for Login Provider Werther, and 8080 TCP port for a callback. Tokens will be expired in ten minutes.
There is environment variable HYDRA_VERSION that equals to v1.0.0-beta8.
1. Create a network: 1. Create a network:
``` ```
@ -25,25 +27,27 @@ There is environment variable HYDRA_VERSION that equals to v1.0.0-beta8.
docker run --network hydra-net -d --restart always --name hydra \ docker run --network hydra-net -d --restart always --name hydra \
-p 4444:4444 \ -p 4444:4444 \
-p 4445:4445 \ -p 4445:4445 \
-e OAUTH2_SHARE_ERROR_DEBUG=1 \ -e OAUTH2_EXPOSE_INTERNAL_ERRORS=true \
-e LOG_LEVEL=debug \ -e LOG_LEVEL=debug \
-e ACCESS_TOKEN_LIFESPAN=10m \ -e TTL_ACCESS_TOKEN=10m \
-e ID_TOKEN_LIFESPAN=10m \ -e TTL_ID_TOKEN=10m \
-e CORS_ALLOWED_ORIGINS=http://$MY_HOST:8080 \ -e SERVE_PUBLIC_CORS_ENABLED=true \
-e CORS_ALLOWED_CREDENTIALS=true \ -e SERVE_PUBLIC_CORS_ALLOWED_ORIGINS=http://$MY_HOST:8080 \
-e OIDC_DISCOVERY_SCOPES_SUPPORTED=profile,email,phone \ -e SERVE_PUBLIC_CORS_ALLOW_CREDENTIALS=true \
-e OIDC_DISCOVERY_CLAIMS_SUPPORTED=name,family_name,given_name,nickname,email,phone_number \ -e WEBFINGER_OIDC_DISCOVERY_SUPPORTED_SCOPES=profile,email,phone \
-e OAUTH2_CONSENT_URL=http://$MY_HOST:3000/auth/consent \ -e WEBFINGER_OIDC_DISCOVERY_SUPPORTED_CLAIMS=name,family_name,given_name,nickname,email,phone_number \
-e OAUTH2_LOGIN_URL=http://$MY_HOST:3000/auth/login \ -e URLS_SELF_ISSUER=http://localhost:4444 \
-e OAUTH2_ISSUER_URL=http://$MY_HOST:4444 \ -e URLS_SELF_PUBLIC=http://localhost:4444 \
-e DATABASE_URL=memory \ -e URLS_LOGIN=http://$MY_HOST:3000/auth/login \
oryd/hydra:$HYDRA_VERSION serve all --dangerous-force-http -e URLS_CONSENT=http://$MY_HOST:3000/auth/consent \
-e URLS_LOGOUT=http://$MY_HOST:3000/auth/logout \
-e DSN=memory \
oryd/hydra:v1.0.0-rc.12 serve all --dangerous-force-http
``` ```
You can learn additional properties with help command: You can learn additional properties with help command:
``` ```
docker run -it --rm oryd/hydra:$HYDRA_VERSION serve --help docker run -it --rm oryd/hydra:v1.0.0-rc.12 serve --help
``` ```
3. Register a client: 3. Register a client:
@ -57,14 +61,14 @@ There is environment variable HYDRA_VERSION that equals to v1.0.0-beta8.
--response-types id_token,token,"id_token token" \ --response-types id_token,token,"id_token token" \
--grant-types implicit \ --grant-types implicit \
--scope openid,profile,email \ --scope openid,profile,email \
--callbacks http://$MY_HOST:8080 --callbacks http://$MY_HOST:8080 \
--post-logout-callbacks http://$MY_HOST:8080/post-logout-callback
``` ```
4. Run Werther: 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 3000:8080 \
-e WERTHER_LOG_FORMAT=console \ -e WERTHER_IDENTP_HYDRA_URL=http://hydra:4445 \
-e WERTHER_HYDRA_ADMIN_URL=http://hydra:4445 \
-e WERTHER_LDAP_ENDPOINTS=icdc0.icore.local:389,icdc1.icore.local:389 \ -e WERTHER_LDAP_ENDPOINTS=icdc0.icore.local:389,icdc1.icore.local:389 \
-e WERTHER_LDAP_BINDDN=<BINDDN> \ -e WERTHER_LDAP_BINDDN=<BINDDN> \
-e WERTHER_LDAP_BINDPW=<BINDDN_PASSWORD> \ -e WERTHER_LDAP_BINDPW=<BINDDN_PASSWORD> \
@ -78,12 +82,16 @@ There is environment variable HYDRA_VERSION that equals to v1.0.0-beta8.
docker run -it --rm hub.das.i-core.ru/p/base-werther -help docker run -it --rm hub.das.i-core.ru/p/base-werther -help
``` ```
5. Start an authentication process in a browser: 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 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
```
6. Get user info: 7. Get user info:
``` ```
http get "http://$MY_HOST:4444/userinfo" "Authorization: Bearer <ACCESS_TOKEN>" http get "http://$MY_HOST:4444/userinfo" "Authorization: Bearer <ACCESS_TOKEN>"
``` ```
@ -112,19 +120,26 @@ There is environment variable HYDRA_VERSION that equals to v1.0.0-beta8.
Look for details in [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter). Look for details in [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter).
7. Re-get a token by httpie: 8. Re-get a token by httpie:
``` ```
http --session u1 -F -v get \ 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" \ "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>" "Cookie:<COOKIES_FROM_WERTHER_DOMAIN>"
``` ```
8. Delete a user's session from a browser: 9. Delete a user's session from a browser:
``` ```
open "http://$MY_HOST:4444/oauth2/auth/sessions/login/revoke" open "http://$MY_HOST:4444/oauth2/auth/sessions/login/revoke"
``` ```
9. (Optional) Sniff TCP packets between Hydra and 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".
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 docker run -it --rm --net=container:hydra nicolaka/netshoot tcpdump -i eth0 -A -nn port 4444
``` ```

View File

@ -1,4 +1,4 @@
- image: golang:1.11-alpine - image: golang:1.11-alpine
shell: go test -v ./... shell: go test -v ./...
- image: golangci/golangci-lint - image: golangci/golangci-lint:v1.16.0
shell: golangci-lint -v run shell: golangci-lint -v run

View File

@ -3,8 +3,6 @@ Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, July 2018
*/ */
package main // import "gopkg.i-core.ru/werther/cmd/werther" package main // import "gopkg.i-core.ru/werther/cmd/werther"
@ -15,17 +13,35 @@ import (
"net/http" "net/http"
"os" "os"
"github.com/justinas/nosurf"
"github.com/kelseyhightower/envconfig" "github.com/kelseyhightower/envconfig"
"go.uber.org/zap" "go.uber.org/zap"
"gopkg.i-core.ru/werther/internal/server" "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 = ""
// 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
}
func main() { func main() {
flag.Usage = func() { flag.Usage = func() {
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0])
flag.PrintDefaults() flag.PrintDefaults()
fmt.Fprintf(flag.CommandLine.Output(), "\n") fmt.Fprintf(flag.CommandLine.Output(), "\n")
if err := envconfig.Usagef("werther", &server.Config{}, flag.CommandLine.Output(), envconfig.DefaultListFormat); err != nil { if err := envconfig.Usagef("werther", &Config{}, flag.CommandLine.Output(), envconfig.DefaultListFormat); err != nil {
panic(err) panic(err)
} }
} }
@ -33,11 +49,11 @@ func main() {
flag.Parse() flag.Parse()
if *verflag { if *verflag {
fmt.Println("werther", server.Version) fmt.Println("werther", Version)
os.Exit(0) os.Exit(0)
} }
var cnf server.Config var cnf Config
if err := envconfig.Process("werther", &cnf); err != nil { if err := envconfig.Process("werther", &cnf); err != nil {
fmt.Fprintf(os.Stderr, "Invalid configuration: %s\n", err) fmt.Fprintf(os.Stderr, "Invalid configuration: %s\n", err)
os.Exit(1) os.Exit(1)
@ -53,13 +69,20 @@ func main() {
os.Exit(1) os.Exit(1)
} }
srv, err := server.New(cnf, log) htmlRenderer, err := web.NewHTMLRenderer(cnf.Web)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Failed to start the server: %s\n", err) fmt.Fprintf(os.Stderr, "Failed to start the server: %s\n", err)
os.Exit(1) os.Exit(1)
} }
ldap := ldapclient.New(cnf.LDAP)
router := httputil.NewRouter(nosurf.NewPure, logutil.RequestLog(log))
router.AddRoutes(web.NewStaticHandler(cnf.Web), "/static")
router.AddRoutes(identp.NewHandler(cnf.Identp, ldap, htmlRenderer), "/auth")
router.AddRoutes(stat.NewHandler(Version), "/stat")
log = log.Named("main") log = log.Named("main")
log.Info("Werther started", zap.Any("config", cnf), zap.String("version", server.Version)) log.Info("Werther started", zap.Any("config", cnf), zap.String("version", Version))
log.Fatal("Werther finished", zap.Error(http.ListenAndServe(cnf.Listen, srv))) log.Fatal("Werther finished", zap.Error(http.ListenAndServe(cnf.Listen, router)))
} }

View File

@ -5,8 +5,6 @@ Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, February 2019
*/ */
package main package main

18
go.mod
View File

@ -2,31 +2,27 @@ module gopkg.i-core.ru/werther
require ( require (
github.com/OneOfOne/xxhash v1.2.2 // indirect github.com/OneOfOne/xxhash v1.2.2 // indirect
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 // indirect github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883
github.com/cespare/xxhash v1.0.0 // indirect github.com/cespare/xxhash v1.0.0 // indirect
github.com/coocood/freecache v1.0.1 github.com/coocood/freecache v1.0.1
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/elazarl/go-bindata-assetfs v1.0.0 github.com/elazarl/go-bindata-assetfs v1.0.0
github.com/gofrs/uuid v3.2.0+incompatible github.com/gofrs/uuid v3.2.0+incompatible // indirect
github.com/golang/protobuf v1.2.0 // indirect github.com/julienschmidt/httprouter v1.2.0 // indirect
github.com/julienschmidt/httprouter v1.2.0 github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da // indirect
github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da
github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4 github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4
github.com/kelseyhightower/envconfig v1.3.0 github.com/kelseyhightower/envconfig v1.3.0
github.com/kevinburke/go-bindata v3.13.0+incompatible github.com/kevinburke/go-bindata v3.13.0+incompatible
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
github.com/pkg/errors v0.8.1 github.com/pkg/errors v0.8.1
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v0.8.0 github.com/sergi/go-diff v1.0.0 // indirect
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 // indirect
github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e // indirect
github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273 // indirect
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 // indirect github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 // indirect
github.com/stretchr/testify v1.2.2 // indirect github.com/stretchr/testify v1.2.2 // indirect
go.uber.org/atomic v1.2.0 // indirect go.uber.org/atomic v1.2.0 // indirect
go.uber.org/multierr v1.1.0 // indirect go.uber.org/multierr v1.1.0 // indirect
go.uber.org/zap v1.9.1 go.uber.org/zap v1.9.1
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect gopkg.i-core.ru/httputil v1.0.0
gopkg.i-core.ru/logutil v1.0.0
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 // indirect gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 // indirect
gopkg.in/ldap.v2 v2.5.1 gopkg.in/ldap.v2 v2.5.1
) )

26
go.sum
View File

@ -1,7 +1,7 @@
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/cespare/xxhash v1.0.0 h1:naDmySfoNg0nKS62/ujM6e71ZgM2AoVdaqGwMG0w18A= 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/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 h1:oFyo4msX2c0QIKU+kuMJUwsKamJ+AKc2JJrKcMszJ5M=
@ -12,8 +12,6 @@ github.com/elazarl/go-bindata-assetfs v1.0.0 h1:G/bYguwHIzWq9ZoyUQqrjTmJbbYn3j3C
github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= 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 h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g= github.com/julienschmidt/httprouter v1.2.0 h1:TDTW5Yz1mjftljbcKqRcrYhd4XeOoI98t+9HbQbYf7g=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da h1:5y58+OCjoHCYB8182mpf/dEsq0vwTKPOo4zGfH0xW9A= github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da h1:5y58+OCjoHCYB8182mpf/dEsq0vwTKPOo4zGfH0xW9A=
@ -22,22 +20,14 @@ github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4 h1:zL6nij8mNcIiohu
github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4/go.mod h1:Aucr5I5chr4OCuuVB4LTuHVrKHBuyRSo7vM2hqrcb7E= github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4/go.mod h1:Aucr5I5chr4OCuuVB4LTuHVrKHBuyRSo7vM2hqrcb7E=
github.com/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM= github.com/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM=
github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/kelseyhightower/envconfig v1.3.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
github.com/kevinburke/go-bindata v3.13.0+incompatible h1:+1zvIFm0AWO3wJmjRIcKV1cuwF1urt7WsJBhordma/k= github.com/kevinburke/go-bindata v3.13.0+incompatible h1:hThDhUBH4KjTyhfXfOgacEPfFBNjltnzl/xzfLfrPoQ=
github.com/kevinburke/go-bindata v3.13.0+incompatible/go.mod h1:/pEEZ72flUW2p0yi30bslSp9YqD9pysLxunQDdb2CPM= github.com/kevinburke/go-bindata v3.13.0+incompatible/go.mod h1:/pEEZ72flUW2p0yi30bslSp9YqD9pysLxunQDdb2CPM=
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.8.0 h1:1921Yw9Gc3iSc4VQh3PIoOqgPCZS7G/4xQNVUp8Mda8= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e h1:n/3MEhJQjQxrOUCzh1Y3Re6aJUUWRp2M9+Oc3eVn/54=
github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273 h1:agujYaXJSxSo18YNX3jzl+4G6Bstwt+kqv47GS12uL0=
github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= 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/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 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
@ -48,8 +38,10 @@ go.uber.org/multierr v1.1.0 h1:HoEmRHQPVSqub6w2z2d2EOVs2fjyFRGyofhKuyDq0QI=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= 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 h1:XCJQEf3W6eZaVwhRBof6ImoYGJSITeKWsyeh3HFu/5o=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ= gopkg.i-core.ru/httputil v1.0.0 h1:A+6RPcU8pNvA/Zf+0Oy9iozyrwycmvDGSCdqgea2/qo=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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=
gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 h1:JBwmEvLfCqgPcIq8MjVMQxsF3LVL4XG/HH0qiG0+IFY= 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/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU= gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU=

View File

@ -3,35 +3,33 @@ Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, July 2018
*/ */
package hydra package hydra
import ( import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.i-core.ru/werther/internal/oauth2"
) )
// ConsentReqDoer fetches information on the OAuth2 request and then accept or reject the requested authentication process. // ConsentReqDoer fetches information on the OAuth2 request and then accept or reject the requested authentication process.
type ConsentReqDoer struct { type ConsentReqDoer struct {
hydraURL string hydraURL string
rememberFor int
} }
// NewConsentRequest creates a ConsentRequest. // NewConsentReqDoer creates a ConsentRequest.
func NewConsentReqDoer(hydraURL string) *ConsentReqDoer { func NewConsentReqDoer(hydraURL string, rememberFor int) *ConsentReqDoer {
return &ConsentReqDoer{hydraURL: hydraURL} return &ConsentReqDoer{hydraURL: hydraURL, rememberFor: rememberFor}
} }
// InitiateRequest fetches information on the OAuth2 request. // InitiateRequest fetches information on the OAuth2 request.
func (crd *ConsentReqDoer) InitiateRequest(challenge string) (*oauth2.ReqInfo, error) { func (crd *ConsentReqDoer) InitiateRequest(challenge string) (*ReqInfo, error) {
ri, err := initiateRequest(consent, crd.hydraURL, challenge) ri, err := initiateRequest(consent, crd.hydraURL, challenge)
return ri, errors.Wrap(err, "failed to initiate consent request") return ri, errors.Wrap(err, "failed to initiate consent request")
} }
// Accept accepts the requested authentication process, and returns redirect URI. // AcceptConsentRequest accepts the requested authentication process, and returns redirect URI.
func (crd *ConsentReqDoer) AcceptConsentRequest(challenge string, remember bool, rememberFor int, grantScope []string, idToken interface{}) (string, error) { func (crd *ConsentReqDoer) AcceptConsentRequest(challenge string, remember bool, grantScope []string, idToken interface{}) (string, error) {
type session struct { type session struct {
IDToken interface{} `json:"id_token,omitempty"` IDToken interface{} `json:"id_token,omitempty"`
} }
@ -43,7 +41,7 @@ func (crd *ConsentReqDoer) AcceptConsentRequest(challenge string, remember bool,
}{ }{
GrantScope: grantScope, GrantScope: grantScope,
Remember: remember, Remember: remember,
RememberFor: rememberFor, RememberFor: crd.rememberFor,
Session: session{ Session: session{
IDToken: idToken, IDToken: idToken,
}, },

View File

@ -3,8 +3,6 @@ Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, July 2018
*/ */
package hydra package hydra
@ -12,12 +10,20 @@ package hydra
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/url" "net/url"
)
"gopkg.i-core.ru/werther/internal/oauth2" var (
// 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.
ErrChallengeNotFound = errors.New("challenge not found")
// ErrChallengeExpired is an error that happens when a challenge is already used.
ErrChallengeExpired = errors.New("challenge expired")
) )
type reqType string type reqType string
@ -25,10 +31,19 @@ type reqType string
const ( const (
login reqType = "login" login reqType = "login"
consent reqType = "consent" consent reqType = "consent"
logout reqType = "logout"
) )
func initiateRequest(typ reqType, hydraURL, challenge string) (*oauth2.ReqInfo, error) { // ReqInfo contains information on an ongoing login or consent request.
ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%s/%s", string(typ), challenge)) type ReqInfo struct {
Challenge string `json:"challenge"`
RequestedScopes []string `json:"requested_scope"`
Skip bool `json:"skip"`
Subject string `json:"subject"`
}
func initiateRequest(typ reqType, hydraURL, challenge string) (*ReqInfo, error) {
ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%[1]s?%[1]s_challenge=%s", string(typ), challenge))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -49,43 +64,15 @@ func initiateRequest(typ reqType, hydraURL, challenge string) (*oauth2.ReqInfo,
if err != nil { if err != nil {
return nil, err return nil, err
} }
var ri oauth2.ReqInfo var ri ReqInfo
if err := json.Unmarshal(data, &ri); err != nil { if err := json.Unmarshal(data, &ri); err != nil {
return nil, err return nil, err
} }
return &ri, nil return &ri, nil
} }
func checkResponse(resp *http.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode <= 302 {
return nil
}
if resp.StatusCode == 404 {
return oauth2.ErrChallengeNotFound
}
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
type errorResult struct {
Message string `json:"error"`
}
var rs errorResult
if err := json.Unmarshal(data, &rs); err != nil {
return err
}
switch resp.StatusCode {
case 401:
return oauth2.ErrUnauthenticated
case 409:
return oauth2.ErrChallengeExpired
default:
return fmt.Errorf("bad HTTP status code %d", resp.StatusCode)
}
}
func acceptRequest(typ reqType, hydraURL, challenge string, data interface{}) (string, error) { func acceptRequest(typ reqType, hydraURL, challenge string, data interface{}) (string, error) {
ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%s/%s/accept", string(typ), challenge)) ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%[1]s/accept?%[1]s_challenge=%s", string(typ), challenge))
if err != nil { if err != nil {
return "", err return "", err
} }
@ -95,10 +82,12 @@ func acceptRequest(typ reqType, hydraURL, challenge string, data interface{}) (s
} }
u = u.ResolveReference(ref) u = u.ResolveReference(ref)
body, err := json.Marshal(data) var body []byte
if err != nil { if data != nil {
if body, err = json.Marshal(data); err != nil {
return "", err return "", err
} }
}
r, err := http.NewRequest(http.MethodPut, u.String(), bytes.NewBuffer(body)) r, err := http.NewRequest(http.MethodPut, u.String(), bytes.NewBuffer(body))
if err != nil { if err != nil {
@ -113,10 +102,9 @@ func acceptRequest(typ reqType, hydraURL, challenge string, data interface{}) (s
if err := checkResponse(resp); err != nil { if err := checkResponse(resp); err != nil {
return "", err return "", err
} }
type result struct { var rs struct {
RedirectTo string `json:"redirect_to"` RedirectTo string `json:"redirect_to"`
} }
var rs result
dec := json.NewDecoder(resp.Body) dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&rs); err != nil { if err := dec.Decode(&rs); err != nil {
return "", err return "", err
@ -124,6 +112,33 @@ func acceptRequest(typ reqType, hydraURL, challenge string, data interface{}) (s
return rs.RedirectTo, nil return rs.RedirectTo, nil
} }
func checkResponse(resp *http.Response) error {
if resp.StatusCode >= 200 && resp.StatusCode <= 302 {
return nil
}
switch resp.StatusCode {
case 401:
return ErrUnauthenticated
case 404:
return ErrChallengeNotFound
case 409:
return ErrChallengeExpired
default:
var rs struct {
Message string `json:"error"`
}
data, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
if err := json.Unmarshal(data, &rs); err != nil {
return err
}
return fmt.Errorf("bad HTTP status code %d with message %q", resp.StatusCode, rs.Message)
}
}
func parseURL(s string) (*url.URL, error) { func parseURL(s string) (*url.URL, error) {
if len(s) > 0 && s[len(s)-1] != '/' { if len(s) > 0 && s[len(s)-1] != '/' {
s += "/" s += "/"

View File

@ -3,42 +3,40 @@ Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, July 2018
*/ */
package hydra package hydra
import ( import (
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.i-core.ru/werther/internal/oauth2"
) )
// LoginReqDoer fetches information on the OAuth2 request and then accept or reject the requested authentication process. // LoginReqDoer fetches information on the OAuth2 request and then accept or reject the requested authentication process.
type LoginReqDoer struct { type LoginReqDoer struct {
hydraURL string hydraURL string
rememberFor int
} }
// NewLoginRequest creates a LoginRequest. // NewLoginReqDoer creates a LoginRequest.
func NewLoginReqDoer(hydraURL string) *LoginReqDoer { func NewLoginReqDoer(hydraURL string, rememberFor int) *LoginReqDoer {
return &LoginReqDoer{hydraURL: hydraURL} return &LoginReqDoer{hydraURL: hydraURL, rememberFor: rememberFor}
} }
// InitiateRequest fetches information on the OAuth2 request. // InitiateRequest fetches information on the OAuth2 request.
func (lrd *LoginReqDoer) InitiateRequest(challenge string) (*oauth2.ReqInfo, error) { func (lrd *LoginReqDoer) InitiateRequest(challenge string) (*ReqInfo, error) {
ri, err := initiateRequest(login, lrd.hydraURL, challenge) ri, err := initiateRequest(login, lrd.hydraURL, challenge)
return ri, errors.Wrap(err, "failed to initiate login request") return ri, errors.Wrap(err, "failed to initiate login request")
} }
// Accept accepts the requested authentication process, and returns redirect URI. // AcceptLoginRequest accepts the requested authentication process, and returns redirect URI.
func (lrd *LoginReqDoer) AcceptLoginRequest(challenge string, remember bool, rememberFor int, subject string) (string, error) { func (lrd *LoginReqDoer) AcceptLoginRequest(challenge string, remember bool, subject string) (string, error) {
data := struct { data := struct {
Remember bool `json:"remember"` Remember bool `json:"remember"`
RememberFor int `json:"remember_for"` RememberFor int `json:"remember_for"`
Subject string `json:"subject"` Subject string `json:"subject"`
}{ }{
Remember: remember, Remember: remember,
RememberFor: rememberFor, RememberFor: lrd.rememberFor,
Subject: subject, Subject: subject,
} }
redirectURI, err := acceptRequest(login, lrd.hydraURL, challenge, data) redirectURI, err := acceptRequest(login, lrd.hydraURL, challenge, data)

34
internal/hydra/logout.go Normal file
View File

@ -0,0 +1,34 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
*/
package hydra
import (
"github.com/pkg/errors"
)
// LogoutReqDoer fetches information on the OAuth2 request and then accepts or rejects the requested logout process.
type LogoutReqDoer struct {
hydraURL string
}
// NewLogoutReqDoer creates a LogoutRequest.
func NewLogoutReqDoer(hydraURL string) *LogoutReqDoer {
return &LogoutReqDoer{hydraURL: hydraURL}
}
// InitiateRequest fetches information on the OAuth2 request.
func (lrd *LogoutReqDoer) InitiateRequest(challenge string) (*ReqInfo, error) {
ri, err := initiateRequest(logout, lrd.hydraURL, challenge)
return ri, errors.Wrap(err, "failed to initiate logout request")
}
// AcceptLogoutRequest accepts the requested logout process, and returns redirect URI.
func (lrd *LogoutReqDoer) AcceptLogoutRequest(challenge string) (string, error) {
redirectURI, err := acceptRequest(logout, lrd.hydraURL, challenge, nil)
return redirectURI, errors.Wrap(err, "failed to accept logout request")
}

332
internal/identp/identp.go Normal file
View File

@ -0,0 +1,332 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
*/
// Package identp is an implementation of [Login and Consent Flow](https://www.ory.sh/docs/hydra/oauth2)
// between ORY Hydra and Werther Identity Provider.
package identp
import (
"context"
"net/http"
"net/url"
"strings"
"time"
"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)"`
}
// UserManager is an interface that is used for authentication and providing user's claims.
type UserManager interface {
authenticator
oidcClaimsFinder
}
// authenticator is an interface that is used for a user authentication.
//
// Authenticate returns false if the username or password is invalid.
type authenticator interface {
Authenticate(ctx context.Context, username, password string) (ok bool, err error)
}
// oidcClaimsFinder is an interface that is used for searching OpenID Connect claims for the given username.
type oidcClaimsFinder interface {
FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error)
}
// TemplateRenderer renders a template with data and writes it to a http.ResponseWriter.
type TemplateRenderer interface {
RenderTemplate(w http.ResponseWriter, name string, data interface{}) error
}
// LoginTmplData is a data that is needed for rendering the login page.
type LoginTmplData struct {
CSRFToken string
Challenge string
LoginURL string
IsInvalidCredentials bool
IsInternalError bool
}
// Handler provides HTTP handlers that implement [Login and Consent Flow](https://www.ory.sh/docs/hydra/oauth2)
// between ORY Hydra and Werther Identity Provider.
type Handler struct {
Config
um UserManager
tr TemplateRenderer
}
// NewHandler creates a new Handler.
//
// The template's renderer must be able to render a template with name "login.tmpl".
// The template is a template of the login page. It receives struct LoginTmplData as template's data.
func NewHandler(cnf Config, um UserManager, tr TemplateRenderer) *Handler {
return &Handler{Config: cnf, um: um, tr: tr}
}
// AddRoutes registers all required routes for Login & Consent Provider.
func (h *Handler) AddRoutes(apply func(m, p string, h http.Handler, mws ...func(http.Handler) http.Handler)) {
sessionTTL := int(h.SessionTTL.Seconds())
apply(http.MethodGet, "/login", newLoginStartHandler(hydra.NewLoginReqDoer(h.HydraURL, 0), h.tr))
apply(http.MethodPost, "/login", newLoginEndHandler(hydra.NewLoginReqDoer(h.HydraURL, sessionTTL), h.um, h.tr))
apply(http.MethodGet, "/consent", newConsentHandler(hydra.NewConsentReqDoer(h.HydraURL, sessionTTL), h.um, h.ClaimScopes))
apply(http.MethodGet, "/logout", newLogoutHandler(hydra.NewLogoutReqDoer(h.HydraURL)))
}
// oa2LoginReqAcceptor is an interface that is used for accepting an OAuth2 login request.
type oa2LoginReqAcceptor interface {
AcceptLoginRequest(challenge string, remember bool, subject string) (string, error)
}
// oa2LoginReqProcessor is an interface that is used for creating and accepting an OAuth2 login request.
//
// InitiateRequest returns hydra.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
// InitiateRequest returns hydra.ErrChallengeExpired if the OAuth2 provider processed the challenge previously.
type oa2LoginReqProcessor interface {
InitiateRequest(challenge string) (*hydra.ReqInfo, error)
oa2LoginReqAcceptor
}
func newLoginStartHandler(rproc oa2LoginReqProcessor, tmplRenderer TemplateRenderer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.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")
http.Error(w, "No login challenge", http.StatusBadRequest)
return
}
ri, err := rproc.InitiateRequest(challenge)
if err != nil {
switch errors.Cause(err) {
case hydra.ErrChallengeNotFound:
log.Debugw("Unknown login challenge in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Unknown login challenge", http.StatusBadRequest)
return
case hydra.ErrChallengeExpired:
log.Debugw("Login challenge has been used already in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Login challenge has been used already", http.StatusBadRequest)
return
}
log.Infow("Failed to initiate an OAuth2 login request", zap.Error(err), "challenge", challenge)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Infow("A login request is initiated", "challenge", challenge, "username", ri.Subject)
if ri.Skip {
redirectURI, err := rproc.AcceptLoginRequest(challenge, false, ri.Subject)
if err != nil {
log.Infow("Failed to accept an OAuth login request", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.Redirect(w, r, redirectURI, http.StatusFound)
return
}
data := LoginTmplData{
CSRFToken: nosurf.Token(r),
Challenge: challenge,
LoginURL: strings.TrimPrefix(r.URL.String(), "/"),
}
if err := tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
}
func newLoginEndHandler(ra oa2LoginReqAcceptor, auther authenticator, tmplRenderer TemplateRenderer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context()).Sugar()
r.ParseForm()
challenge := r.Form.Get("login_challenge")
if challenge == "" {
log.Debug("No login challenge that is needed by the OAuth2 provider")
http.Error(w, "No login challenge", http.StatusBadRequest)
return
}
data := LoginTmplData{
CSRFToken: nosurf.Token(r),
Challenge: challenge,
LoginURL: r.URL.String(),
}
username, password := r.Form.Get("username"), r.Form.Get("password")
switch ok, err := auther.Authenticate(r.Context(), username, password); {
case err != nil:
data.IsInternalError = true
log.Infow("Failed to authenticate a login request via the OAuth2 provider",
zap.Error(err), "challenge", challenge, "username", username)
if err = tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
case !ok:
data.IsInvalidCredentials = true
log.Debugw("Invalid credentials", zap.Error(err), "challenge", challenge, "username", username)
if err = tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
log.Infow("A username is authenticated", "challenge", challenge, "username", username)
remember := r.Form.Get("remember") != ""
redirectTo, err := ra.AcceptLoginRequest(challenge, remember, username)
if err != nil {
data.IsInternalError = true
log.Infow("Failed to accept a login request via the OAuth2 provider", zap.Error(err))
if err := tmplRenderer.RenderTemplate(w, loginTmplName, data); err != nil {
log.Infow("Failed to render a login page template", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
}
http.Redirect(w, r, redirectTo, http.StatusFound)
}
}
// oa2ConsentReqAcceptor is an interface that is used for creating and accepting an OAuth2 consent request.
//
// InitiateRequest returns hydra.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
// InitiateRequest returns hydra.ErrChallengeExpired if the OAuth2 provider processed the challenge previously.
type oa2ConsentReqProcessor interface {
InitiateRequest(challenge string) (*hydra.ReqInfo, error)
AcceptConsentRequest(challenge string, remember bool, grantScope []string, idToken interface{}) (string, error)
}
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()
challenge := r.URL.Query().Get("consent_challenge")
if challenge == "" {
log.Debug("No consent challenge that is needed by the OAuth2 provider")
http.Error(w, "No consent challenge", http.StatusBadRequest)
return
}
ri, err := rproc.InitiateRequest(challenge)
if err != nil {
switch errors.Cause(err) {
case hydra.ErrChallengeNotFound:
log.Debugw("Unknown consent challenge in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Unknown consent challenge", http.StatusBadRequest)
return
case hydra.ErrChallengeExpired:
log.Debugw("Consent challenge has been used already in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Consent challenge has been used already", http.StatusBadRequest)
return
}
log.Infow("Failed to send an OAuth2 consent request", zap.Error(err), "challenge", challenge)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Infow("A consent request is initiated", "challenge", challenge, "username", ri.Subject)
claims, err := cfinder.FindOIDCClaims(r.Context(), ri.Subject)
if err != nil {
log.Infow("Failed to find user's OIDC claims", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Debugw("Found user's OIDC claims", "claims", claims)
// Remove claims that are not in the requested scopes.
for claim := range claims {
var found bool
// We need to escape a claim due to ClaimScopes' keys contain URL encoded claims.
// It is because of config option's format compatibility.
if scope, ok := claimScopes[url.QueryEscape(claim)]; ok {
for _, rscope := range ri.RequestedScopes {
if rscope == scope {
found = true
break
}
}
}
if !found {
delete(claims, claim)
log.Debugw("Deleted the OIDC claim because it's not in requested scopes", "claim", claim)
}
}
redirectTo, err := rproc.AcceptConsentRequest(challenge, !ri.Skip, ri.RequestedScopes, claims)
if err != nil {
log.Infow("Failed to accept a consent request to the OAuth2 provider", zap.Error(err), "scopes", ri.RequestedScopes, "claims", claims)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Debugw("Accepted the consent request to the OAuth2 provider", "scopes", ri.RequestedScopes, "claims", claims)
http.Redirect(w, r, redirectTo, http.StatusFound)
}
}
// oa2LogoutReqProcessor is an interface that is used for creating and accepting an OAuth2 logout request.
//
// InitiateRequest returns hydra.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
type oa2LogoutReqProcessor interface {
InitiateRequest(challenge string) (*hydra.ReqInfo, error)
AcceptLogoutRequest(challenge string) (string, error)
}
func newLogoutHandler(rproc oa2LogoutReqProcessor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context()).Sugar()
challenge := r.URL.Query().Get("logout_challenge")
if challenge == "" {
log.Debug("No logout challenge that is needed by the OAuth2 provider")
http.Error(w, "No logout challenge", http.StatusBadRequest)
return
}
ri, err := rproc.InitiateRequest(challenge)
if err != nil {
switch errors.Cause(err) {
case hydra.ErrChallengeNotFound:
log.Debugw("Unknown logout challenge in the OAuth2 provider", zap.Error(err), "challenge", challenge)
http.Error(w, "Unknown logout challenge", http.StatusBadRequest)
return
}
log.Infow("Failed to send an OAuth2 logout request", zap.Error(err), "challenge", challenge)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Infow("A logout request is initiated", "challenge", challenge, "username", ri.Subject)
redirectTo, err := rproc.AcceptLogoutRequest(challenge)
if err != nil {
log.Infow("Failed to accept the logout request to the OAuth2 provider", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
log.Debugw("Accepted the logout request to the OAuth2 provider")
http.Redirect(w, r, redirectTo, http.StatusFound)
}
}

View File

@ -0,0 +1,568 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
*/
package identp
import (
"context"
"fmt"
"html/template"
"net/http"
"net/http/httptest"
"reflect"
"regexp"
"strings"
"testing"
"github.com/justinas/nosurf"
"github.com/pkg/errors"
"gopkg.i-core.ru/werther/internal/hydra"
)
func TestHandleLoginStart(t *testing.T) {
testCases := []struct {
name string
challenge string
scopes []string
skip bool
subject string
redirect string
wantStatus int
wantInitErr error
wantAcceptErr error
wantLoc string
wantBody string
}{
{
name: "no login challenge",
wantStatus: http.StatusBadRequest,
},
{
name: "happy path",
challenge: "foo",
wantStatus: http.StatusOK,
wantBody: `
LoginURL: login?login_challenge=foo;
CSRFToken: true;
Challenge: foo;
`,
},
{
name: "skip",
challenge: "foo",
skip: true,
wantLoc: "/",
wantStatus: http.StatusFound,
},
{
name: "unknown challenge",
challenge: "foo",
wantInitErr: hydra.ErrChallengeNotFound,
wantStatus: http.StatusBadRequest,
},
{
name: "used challenge",
challenge: "foo",
wantInitErr: hydra.ErrChallengeExpired,
wantStatus: http.StatusBadRequest,
},
{
name: "init error",
challenge: "foo",
wantInitErr: errors.New("init error"),
wantStatus: http.StatusInternalServerError,
},
{
name: "accept error",
challenge: "foo",
skip: true,
wantAcceptErr: errors.New("accept error"),
wantStatus: http.StatusInternalServerError,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := "/login"
if tc.challenge != "" {
url += "?login_challenge=" + tc.challenge
}
r, err := http.NewRequest("POST", url, nil)
if err != nil {
t.Fatal(err)
}
r.Host = "gopkg.example.org"
rr := httptest.NewRecorder()
tmplRenderer := &testTemplateRenderer{
renderTmplFunc: func(w http.ResponseWriter, name string, data interface{}) error {
if name != "login.tmpl" {
t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name)
}
const loginT = `
LoginURL: {{ .LoginURL }};
CSRFToken: {{ if .CSRFToken }} true {{- else -}} false {{- end }};
Challenge: {{ .Challenge }};
`
tmpl, err := template.New("login").Parse(loginT)
if err != nil {
t.Fatalf("failed to parse template: %s", err)
}
return tmpl.Execute(w, data)
},
}
rproc := testLoginReqProc{
initReqFunc: func(challenge string) (*hydra.ReqInfo, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while initiating the request: got %q; want %q", challenge, tc.challenge)
}
return &hydra.ReqInfo{
Challenge: tc.challenge,
RequestedScopes: tc.scopes,
Skip: tc.skip,
Subject: tc.subject,
}, tc.wantInitErr
},
acceptReqFunc: func(challenge string, remember bool, subject string) (string, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
}
if remember {
t.Error("unexpected enabled remember flag")
}
if subject != tc.subject {
t.Errorf("wrong subject while accepting the request: got %q; want %q", subject, tc.subject)
}
return tc.redirect, tc.wantAcceptErr
},
}
handler := nosurf.New(newLoginStartHandler(rproc, tmplRenderer))
handler.ExemptPath("/login")
handler.ServeHTTP(rr, r)
if status := rr.Code; status != tc.wantStatus {
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
}
wantBody, gotBody := noindent(tc.wantBody), noindent(rr.Body.String())
if wantBody != "" && gotBody != wantBody {
t.Errorf("wrong body:\ngot %q\nwant %q", gotBody, wantBody)
}
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
}
})
}
}
func noindent(s string) string {
wsRe := regexp.MustCompile(`(?:^\s+|(;)\s+)`)
return wsRe.ReplaceAllString(s, "$1 ")
}
type testLoginReqProc struct {
initReqFunc func(string) (*hydra.ReqInfo, error)
acceptReqFunc func(string, bool, string) (string, error)
}
func (lrp testLoginReqProc) InitiateRequest(challenge string) (*hydra.ReqInfo, error) {
return lrp.initReqFunc(challenge)
}
func (lrp testLoginReqProc) AcceptLoginRequest(challenge string, remember bool, subject string) (string, error) {
return lrp.acceptReqFunc(challenge, remember, subject)
}
func TestHandleLoginEnd(t *testing.T) {
testCases := []struct {
name string
challenge string
subject string
webBasePath string
redirect string
wantStatus int
wantAcceptErr error
wantAuthErr error
wantInvAuth bool
wantLoc string
wantBody string
}{
{
name: "no login challenge",
subject: "joe",
wantStatus: http.StatusBadRequest,
},
{
name: "happy path",
challenge: "foo",
subject: "joe",
redirect: "/redirect-to",
wantStatus: http.StatusFound,
wantLoc: "/redirect-to",
},
{
name: "auth unknown error",
challenge: "foo",
subject: "joe",
webBasePath: "testBasePath",
wantStatus: http.StatusOK,
wantInvAuth: false,
wantAuthErr: errors.New("Unknown error"),
wantBody: `
LoginURL: /login;
CSRFToken: T;
Challenge: foo;
InvCreds: F;
IsIntErr: T;
`,
},
{
name: "unauth error",
challenge: "foo",
subject: "joe",
wantStatus: http.StatusOK,
wantInvAuth: true,
wantBody: `
LoginURL: /login;
CSRFToken: T;
Challenge: foo;
InvCreds: T;
IsIntErr: F;
`,
},
{
name: "accept error",
challenge: "foo",
subject: "joe",
wantStatus: http.StatusOK,
wantAcceptErr: errors.New("accept error"),
wantBody: `
LoginURL: /login;
CSRFToken: T;
Challenge: foo;
InvCreds: F;
IsIntErr: T;
`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := "/login"
ps := "username=joe&password=pass"
if tc.challenge != "" {
ps += "&login_challenge=" + tc.challenge
}
r, err := http.NewRequest("POST", url, strings.NewReader(ps))
if err != nil {
t.Fatal(err)
}
r.Host = "gopkg.example.org"
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
tmplRenderer := &testTemplateRenderer{
renderTmplFunc: func(w http.ResponseWriter, name string, data interface{}) error {
if name != "login.tmpl" {
t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name)
}
const loginT = `
LoginURL: {{ .LoginURL }};
CSRFToken: {{ if .CSRFToken -}} T {{- else -}} F {{- end }};
Challenge: {{ .Challenge }};
InvCreds: {{ if .IsInvalidCredentials -}} T {{- else -}} F {{- end }};
IsIntErr: {{ if .IsInternalError -}} T {{- else -}} F {{- end}};
`
tmpl, err := template.New("login").Parse(loginT)
if err != nil {
t.Fatalf("failed to parse template: %s", err)
}
return tmpl.Execute(w, data)
},
}
rproc := testLoginReqProc{
acceptReqFunc: func(challenge string, remember bool, subject string) (string, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
}
if remember {
t.Error("unexpected enabled remember flag")
}
if subject != tc.subject {
t.Errorf("wrong subject while accepting the request: got %q; want %q", subject, tc.subject)
}
return tc.redirect, tc.wantAcceptErr
},
}
auther := testAuthenticator{
authnFunc: func(ctx context.Context, username, password string) (bool, error) {
if username == "" {
t.Error("unexpected empty username")
}
if password == "" {
t.Error("unexpected empty password")
}
return !tc.wantInvAuth, tc.wantAuthErr
},
}
handler := nosurf.New(newLoginEndHandler(rproc, auther, tmplRenderer))
handler.ExemptPath("/login")
handler.ServeHTTP(rr, r)
if status := rr.Code; status != tc.wantStatus {
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
}
wantBody, gotBody := noindent(tc.wantBody), noindent(rr.Body.String())
if wantBody != "" && gotBody != wantBody {
t.Errorf("wrong body:\ngot %q\nwant %q", gotBody, wantBody)
}
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
}
})
}
}
type testTemplateRenderer struct {
renderTmplFunc func(w http.ResponseWriter, name string, data interface{}) error
}
func (tl *testTemplateRenderer) RenderTemplate(w http.ResponseWriter, name string, data interface{}) error {
return tl.renderTmplFunc(w, name, data)
}
type testAuthenticator struct {
authnFunc func(context.Context, string, string) (bool, error)
}
func (au testAuthenticator) Authenticate(ctx context.Context, username, password string) (bool, error) {
return au.authnFunc(ctx, username, password)
}
func TestHandleConsent(t *testing.T) {
testCases := []struct {
name string
challenge string
redirect string
subject string
skip bool
claims map[string]interface{}
scopes []string
wantStatus int
wantAcceptErr error
wantInitErr error
wantFindErr error
wantLoc string
}{
{
name: "no login challenge",
subject: "joe",
wantStatus: http.StatusBadRequest,
},
{
name: "unknown challenge",
challenge: "foo",
wantInitErr: hydra.ErrChallengeNotFound,
wantStatus: http.StatusBadRequest,
},
{
name: "used challenge",
challenge: "foo",
wantInitErr: hydra.ErrChallengeExpired,
wantStatus: http.StatusBadRequest,
},
{
name: "happy path",
challenge: "foo",
subject: "joe",
redirect: "/redirect-to",
wantStatus: http.StatusFound,
wantLoc: "/redirect-to",
claims: map[string]interface{}{"a": "foo", "b": "bar", "c": "baz"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := "/consent"
if tc.challenge != "" {
url += "?consent_challenge=" + tc.challenge
}
r, err := http.NewRequest("GET", url, nil)
if err != nil {
t.Fatal(err)
}
r.Host = "gopkg.example.org"
rr := httptest.NewRecorder()
rproc := testConsentReqProc{
initReqFunc: func(challenge string) (*hydra.ReqInfo, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while initiating the request: got %q; want %q", challenge, tc.challenge)
}
return &hydra.ReqInfo{
Challenge: tc.challenge,
Subject: tc.subject,
RequestedScopes: tc.scopes,
}, tc.wantInitErr
},
acceptReqFunc: func(challenge string, remember bool, grantScope []string, idToken interface{}) (string, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
}
if remember == tc.skip {
t.Error("unexpected enabled remember flag")
}
if len(grantScope) != len(tc.scopes) {
t.Errorf("wrong granted scopes while accepting the request: got %q; want %q", grantScope, tc.scopes)
} else {
for i := range grantScope {
if grantScope[i] != tc.scopes[i] {
t.Errorf("wrong granted scopes while accepting the request: got %q; want %q", grantScope, tc.scopes)
break
}
}
}
if !reflect.DeepEqual(idToken, tc.claims) {
t.Errorf("wrong an id token while accepting the request: got %q; want %q", idToken, tc.claims)
}
return tc.redirect, tc.wantAcceptErr
},
}
cfinder := testOIDCClaimsFinder{
findFunc: func(ctx context.Context, username string) (map[string]interface{}, error) {
if username == "" {
t.Error("unexpected empty username")
}
return tc.claims, tc.wantFindErr
},
}
handler := nosurf.New(newConsentHandler(rproc, cfinder, nil))
handler.ExemptPath("/consent")
handler.ServeHTTP(rr, r)
if status := rr.Code; status != tc.wantStatus {
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
}
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
}
})
}
}
type testConsentReqProc struct {
initReqFunc func(string) (*hydra.ReqInfo, error)
acceptReqFunc func(string, bool, []string, interface{}) (string, error)
}
func (crp testConsentReqProc) InitiateRequest(challenge string) (*hydra.ReqInfo, error) {
return crp.initReqFunc(challenge)
}
func (crp testConsentReqProc) AcceptConsentRequest(challenge string, remember bool, grantScope []string, idToken interface{}) (string, error) {
return crp.acceptReqFunc(challenge, remember, grantScope, idToken)
}
type testOIDCClaimsFinder struct {
findFunc func(context.Context, string) (map[string]interface{}, error)
}
func (cf testOIDCClaimsFinder) FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error) {
return cf.findFunc(ctx, username)
}
func TestLogoutHandler(t *testing.T) {
testCases := []struct {
name string
challenge string
initErr error
acceptErr error
redirectTo string
wantStatus int
wantLoc string
}{
{
name: "no login challenge",
wantStatus: http.StatusBadRequest,
},
{
name: "unknown challenge",
challenge: "foo",
initErr: hydra.ErrChallengeNotFound,
wantStatus: http.StatusBadRequest,
},
{
name: "init logout request error",
challenge: "foo",
initErr: fmt.Errorf("init logout request error"),
wantStatus: http.StatusInternalServerError,
},
{
name: "accept logout request error",
challenge: "foo",
acceptErr: fmt.Errorf("accept logout request error"),
wantStatus: http.StatusInternalServerError,
},
{
name: "happy path",
challenge: "foo",
redirectTo: "/redirect-to",
wantStatus: http.StatusFound,
wantLoc: "/redirect-to",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := "/logout"
if tc.challenge != "" {
url += "?logout_challenge=" + tc.challenge
}
r, err := http.NewRequest("GET", url, nil)
if err != nil {
t.Fatal(err)
}
r.Host = "gopkg.example.org"
rr := httptest.NewRecorder()
rproc := &testLogoutReqProc{
initReqFunc: func(challenge string) (*hydra.ReqInfo, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while initiating the request: got %q; want %q", challenge, tc.challenge)
}
return &hydra.ReqInfo{}, tc.initErr
},
acceptReqFunc: func(challenge string) (string, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
}
return tc.redirectTo, tc.acceptErr
},
}
handler := newLogoutHandler(rproc)
handler.ServeHTTP(rr, r)
if rr.Code != tc.wantStatus {
t.Errorf("wrong status code: got %v; want %v", rr.Code, tc.wantStatus)
}
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
}
})
}
}
type testLogoutReqProc struct {
initReqFunc func(string) (*hydra.ReqInfo, error)
acceptReqFunc func(string) (string, error)
}
func (p *testLogoutReqProc) InitiateRequest(challenge string) (*hydra.ReqInfo, error) {
return p.initReqFunc(challenge)
}
func (p *testLogoutReqProc) AcceptLogoutRequest(challenge string) (string, error) {
return p.acceptReqFunc(challenge)
}

View File

@ -3,8 +3,6 @@ Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, July 2018
*/ */
package ldapclient package ldapclient
@ -20,21 +18,23 @@ import (
"github.com/coocood/freecache" "github.com/coocood/freecache"
"github.com/pkg/errors" "github.com/pkg/errors"
"gopkg.i-core.ru/werther/internal/logger" "go.uber.org/zap"
"gopkg.i-core.ru/logutil"
ldap "gopkg.in/ldap.v2" ldap "gopkg.in/ldap.v2"
) )
// Config is a LDAP configuration. // Config is a LDAP configuration.
type Config struct { type Config struct {
Endpoints []string // are LDAP servers Endpoints []string `envconfig:"endpoints" required:"true" desc:"a LDAP's server URLs as \"<address>:<port>\""`
BaseDN string // is a base DN for searching users BaseDN string `envconfig:"basedn" required:"true" desc:"a LDAP base DN for searching users"`
BindDN, BindPass string // is needed for authentication BindDN string `envconfig:"binddn" desc:"a LDAP bind DN"`
RoleBaseDN string // is a base DN for searching roles BindPass string `envconfig:"bindpw" json:"-" desc:"a LDAP bind password"`
RoleAttr string // is LDAP attribute's name for a role's name RoleBaseDN string `envconfig:"role_basedn" required:"true" desc:"a LDAP base DN for searching roles"`
RoleClaim string // is custom OIDC claim name for roles' list RoleAttr string `envconfig:"role_attr" default:"description" desc:"a LDAP attribute for role's name"`
AttrClaims map[string]string // maps a LDAP attribute's name onto an OIDC claim RoleClaim string `ignored:"true"` // is custom OIDC claim name for roles' list
CacheSize int // is a size of claims' cache in KiB 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"`
CacheTTL time.Duration // is a TTL of claims' cache 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"`
} }
// Client is a LDAP client (compatible with Active Directory). // Client is a LDAP client (compatible with Active Directory).
@ -45,6 +45,9 @@ type Client struct {
// New creates a new LDAP client. // New creates a new LDAP client.
func New(cnf Config) *Client { func New(cnf Config) *Client {
if cnf.RoleClaim == "" {
cnf.RoleClaim = "http://i-core.ru/claims/roles"
}
return &Client{ return &Client{
Config: cnf, Config: cnf,
cache: freecache.NewCache(cnf.CacheSize * 1024), cache: freecache.NewCache(cnf.CacheSize * 1024),
@ -86,7 +89,7 @@ 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. // 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 { if ok := cli.cache.Del([]byte(username)); ok {
log := logger.FromContext(ctx) log := logutil.FromContext(ctx)
log.Debug("Cleared user's OIDC claims in the cache") log.Debug("Cleared user's OIDC claims in the cache")
} }
@ -103,13 +106,12 @@ func (cli *Client) dialTCP(ctx context.Context) <-chan *ldap.Conn {
go func(addr string) { go func(addr string) {
defer wg.Done() defer wg.Done()
log := logger.FromContext(ctx) log := logutil.FromContext(ctx).Sugar()
log = log.With("address", addr)
d := net.Dialer{Timeout: ldap.DefaultTimeout} d := net.Dialer{Timeout: ldap.DefaultTimeout}
tcpcn, err := d.DialContext(ctx, "tcp", addr) tcpcn, err := d.DialContext(ctx, "tcp", addr)
if err != nil { if err != nil {
log.Debugw("Failed to create a LDAP connection") log.Debug("Failed to create a LDAP connection", "address", addr)
return return
} }
ldapcn := ldap.NewConn(tcpcn, false) ldapcn := ldap.NewConn(tcpcn, false)
@ -117,7 +119,7 @@ func (cli *Client) dialTCP(ctx context.Context) <-chan *ldap.Conn {
select { select {
case <-ctx.Done(): case <-ctx.Done():
ldapcn.Close() ldapcn.Close()
log.Debugw("a LDAP connection is cancelled") log.Debug("a LDAP connection is cancelled", "address", addr)
return return
case ch <- ldapcn: case ch <- ldapcn:
} }
@ -165,14 +167,14 @@ func (cli *Client) findBasicUserDetails(cn *ldap.Conn, username string, attrs []
// FindOIDCClaims finds all OIDC claims for a user. // FindOIDCClaims finds all OIDC claims for a user.
func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error) { func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error) {
log := logger.FromContext(ctx) log := logutil.FromContext(ctx).Sugar()
// Retrieving from LDAP is slow. So, we try to get claims for the given username from the cache. // 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 { switch cdata, err := cli.cache.Get([]byte(username)); err {
case nil: case nil:
var claims map[string]interface{} var claims map[string]interface{}
if err = json.Unmarshal(cdata, &claims); err != nil { if err = json.Unmarshal(cdata, &claims); err != nil {
log.Infow("Failed to unmarshal user's OIDC claims", "error", err, "data", cdata) log.Info("Failed to unmarshal user's OIDC claims", zap.Error(err), "data", cdata)
return nil, err return nil, err
} }
log.Debug("Retrieved user's OIDC claims from the cache", "claims", claims) log.Debug("Retrieved user's OIDC claims from the cache", "claims", claims)
@ -180,7 +182,7 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
case freecache.ErrNotFound: case freecache.ErrNotFound:
log.Debug("User's OIDC claims is not found in the cache") log.Debug("User's OIDC claims is not found in the cache")
default: default:
log.Infow("Failed to retrieve user's OIDC claims from the cache", "error", err) log.Infow("Failed to retrieve user's OIDC claims from the cache", zap.Error(err))
} }
// Try to make multiple TCP connections to the LDAP server for getting claims. // Try to make multiple TCP connections to the LDAP server for getting claims.
@ -260,10 +262,10 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str
// Save the claims in the cache for future queries. // Save the claims in the cache for future queries.
cdata, err := json.Marshal(claims) cdata, err := json.Marshal(claims)
if err != nil { if err != nil {
log.Infow("Failed to marshal user's OIDC claims for caching", "error", err, "claims", claims) log.Infow("Failed to marshal user's OIDC claims for caching", zap.Error(err), "claims", claims)
} }
if err = cli.cache.Set([]byte(username), cdata, int(cli.CacheTTL.Seconds())); err != nil { if err = cli.cache.Set([]byte(username), cdata, int(cli.CacheTTL.Seconds())); err != nil {
log.Infow("Failed to store user's OIDC claims into the cache", "error", err, "claims", claims) log.Infow("Failed to store user's OIDC claims into the cache", zap.Error(err), "claims", claims)
} }
return claims, nil return claims, nil

View File

@ -1,35 +0,0 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, February 2019
*/
package logger
import (
"context"
"go.uber.org/zap"
)
type requestLogCtxKey int
// requestLogKey is a context's key to store a request's logger.
const requestLogKey requestLogCtxKey = iota
// FromContext returns a request's logger stored in a context.
func FromContext(ctx context.Context) *zap.SugaredLogger {
v := ctx.Value(requestLogKey)
if v == nil {
return zap.NewNop().Sugar()
}
return v.(*zap.SugaredLogger)
}
// WithLogger returns context.Context with a logger's instance.
func WithLogger(ctx context.Context, log *zap.SugaredLogger) context.Context {
return context.WithValue(ctx, requestLogKey, log)
}

View File

@ -1,29 +0,0 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, February 2019
*/
package oauth2
import "errors"
var (
// 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.
ErrChallengeNotFound = errors.New("challenge not found")
// ErrChallengeExpired is an error that happens when a challenge is already used.
ErrChallengeExpired = errors.New("challenge expired")
)
// ReqInfo contains information on an ongoing login or consent request.
type ReqInfo struct {
Challenge string `json:"challenge"`
RequestedScopes []string `json:"requested_scope"`
Skip bool `json:"skip"`
Subject string `json:"subject"`
}

View File

@ -1,48 +0,0 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, February 2019
*/
package server
import (
"net/http"
"time"
"github.com/gofrs/uuid"
"go.uber.org/zap"
"gopkg.i-core.ru/werther/internal/logger"
)
type traceResponseWriter struct {
http.ResponseWriter
statusCode int
}
func (w *traceResponseWriter) WriteHeader(statusCode int) {
w.statusCode = statusCode
w.ResponseWriter.WriteHeader(statusCode)
}
// logw returns a middleware that places a request's ID and logger to a request's context, and logs the request.
func logw(log *zap.SugaredLogger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var (
log = log.With("requestID", uuid.Must(uuid.NewV4()).String())
ctx = logger.WithLogger(r.Context(), log)
)
log.Infow("New request", "method", r.Method, "url", r.URL.String())
start := time.Now()
tw := &traceResponseWriter{w, http.StatusOK}
next.ServeHTTP(w, r.WithContext(ctx))
log.Debugw("The request is handled", "httpStatus", tw.statusCode, "duration", time.Since(start))
})
}
}

View File

@ -1,32 +0,0 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, February 2019
*/
package server
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestTraceResponseWriter(t *testing.T) {
wantStatus := http.StatusBadRequest
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(wantStatus)
})
r, err := http.NewRequest("GET", "http://foo.bar", http.NoBody)
if err != nil {
t.Fatal(err)
}
tw := &traceResponseWriter{ResponseWriter: httptest.NewRecorder()}
h.ServeHTTP(tw, r)
if tw.statusCode != wantStatus {
t.Errorf("invalid HTTP status code %d; want %d", tw.statusCode, wantStatus)
}
}

View File

@ -1,405 +0,0 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, July 2018
*/
package server
import (
"bufio"
"bytes"
"context"
"encoding/json"
"html/template"
"net/http"
"net/url"
"path"
"strings"
"time"
assetfs "github.com/elazarl/go-bindata-assetfs"
"github.com/julienschmidt/httprouter"
"github.com/justinas/alice"
"github.com/justinas/nosurf"
"github.com/pkg/errors"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.uber.org/zap"
"gopkg.i-core.ru/werther/internal/ldapclient"
"gopkg.i-core.ru/werther/internal/logger"
"gopkg.i-core.ru/werther/internal/oauth2"
"gopkg.i-core.ru/werther/internal/oauth2/hydra"
)
// Version will be filled at compile time.
var Version = ""
const internalServerErrorMessage = "Internal Server Error"
// 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>)"`
LDAPEndpoints []string `envconfig:"ldap_endpoints" required:"true" desc:"a LDAP's server URLs as \"<address>:<port>\""`
LDAPBaseDN string `envconfig:"ldap_basedn" required:"true" desc:"a LDAP base DN"`
LDAPBindDN string `envconfig:"ldap_binddn" desc:"a LDAP bind DN"`
LDAPBindPW string `envconfig:"ldap_bindpw" json:"-" desc:"a LDAP bind password"`
LDAPRoleBaseDN string `envconfig:"ldap_role_basedn" required:"true" desc:"a LDAP base DN for searching roles"`
LDAPRoleAttr string `envconfig:"ldap_role_attr" default:"description" desc:"a LDAP attribute for role's name"`
LDAPAttrClaims map[string]string `envconfig:"ldap_attr_claims" default:"name:name,sn:family_name,givenName:given_name,mail:email" desc:"a mapping of LDAP attributes to OIDC claims"`
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)"`
SessionTTL time.Duration `envconfig:"session_ttl" default:"24h" desc:"a session TTL"`
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"`
HydraAdminURL string `envconfig:"hydra_admin_url" required:"true" desc:"a server admin URL of ORY Hydra"`
WebDir string `envconfig:"web_dir" desc:"a path to an external web directory"`
WebBasePath string `envconfig:"web_base_path" default:"/" desc:"a base path of web pages"`
}
// Server is a HTTP server that is a login provider.
type Server struct {
Config
router http.Handler
webldr interface {
loadTemplate(name string) (*template.Template, error)
}
}
// New creates a new Server instance.
func New(cnf Config, log *zap.Logger) (*Server, error) {
srv := &Server{Config: cnf}
var err error
if cnf.WebDir != "" {
srv.webldr, err = newExtWebLoader(cnf.WebDir)
} else {
srv.webldr, err = newIntWebLoader()
}
if err != nil {
return nil, errors.Wrap(err, "failed to init server")
}
ldapcnf := ldapclient.Config{
Endpoints: srv.LDAPEndpoints,
BaseDN: srv.LDAPBaseDN,
BindDN: srv.LDAPBindDN,
BindPass: srv.LDAPBindPW,
RoleBaseDN: srv.LDAPRoleBaseDN,
RoleAttr: srv.LDAPRoleAttr,
RoleClaim: "http://i-core.ru/claims/roles",
AttrClaims: srv.LDAPAttrClaims,
CacheTTL: srv.CacheTTL,
CacheSize: srv.CacheSize,
}
ldap := ldapclient.New(ldapcnf)
router := httprouter.New()
router.Handler(http.MethodGet, "/auth/login", srv.handleLoginStart(hydra.NewLoginReqDoer(cnf.HydraAdminURL)))
router.Handler(http.MethodPost, "/auth/login", srv.handleLoginEnd(hydra.NewLoginReqDoer(cnf.HydraAdminURL), ldap))
router.Handler(http.MethodGet, "/auth/consent", srv.handleConsent(hydra.NewConsentReqDoer(cnf.HydraAdminURL), ldap))
router.Handler(http.MethodGet, "/health/alive", srv.handleHealthAliveAndReady())
router.Handler(http.MethodGet, "/health/ready", srv.handleHealthAliveAndReady())
router.Handler(http.MethodGet, "/version", srv.handleVersion())
router.Handler(http.MethodGet, "/metrics/prometheus", promhttp.Handler())
var fs http.FileSystem = http.Dir(path.Join(cnf.WebDir, "static"))
if cnf.WebDir == "" { // Use embedded web templates
fs = &assetfs.AssetFS{
Asset: Asset,
AssetDir: AssetDir,
AssetInfo: AssetInfo,
Prefix: "static",
}
}
router.ServeFiles("/static/*filepath", fs)
srv.router = alice.New(nosurf.NewPure, logw(log.Sugar())).Then(router)
return srv, nil
}
// ServeHTTP implements the http.Handler interface.
func (srv *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
srv.router.ServeHTTP(w, r)
}
// oa2LoginReqAcceptor is an interface that is used for accepting an OAuth2 login request.
type oa2LoginReqAcceptor interface {
AcceptLoginRequest(challenge string, remember bool, rememberFor int, subject string) (string, error)
}
// oa2LoginReqProcessor is an interface that is used for creating and accepting an OAuth2 login request.
//
// InitiateRequest returns oauth2.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
// InitiateRequest returns oauth2.ErrChallengeExpired if the OAuth2 provider processed the challenge previously.
type oa2LoginReqProcessor interface {
InitiateRequest(challenge string) (*oauth2.ReqInfo, error)
oa2LoginReqAcceptor
}
// loginTmplData is a data that is needed for rendering the login page.
type loginTmplData struct {
CSRFToken string
Challenge string
LoginURL string
WebBasePath string
IsInvalidCredentials bool
IsInternalError bool
}
func (srv *Server) handleLoginStart(rproc oa2LoginReqProcessor) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logger.FromContext(r.Context())
challenge := r.URL.Query().Get("login_challenge")
if challenge == "" {
log.Debug("No login challenge that is needed by the OAuth2 provider")
http.Error(w, "No login challenge", http.StatusBadRequest)
return
}
ri, err := rproc.InitiateRequest(challenge)
if err != nil {
log = log.With("challenge", challenge)
switch errors.Cause(err) {
case oauth2.ErrChallengeNotFound:
log.Debugw("Unknown login challenge in the OAuth2 provider", "error", err)
http.Error(w, "Unknown login challenge", http.StatusBadRequest)
return
case oauth2.ErrChallengeExpired:
log.Debugw("Login challenge has been used already in the OAuth2 provider", "error", err)
http.Error(w, "Login challenge has been used already", http.StatusBadRequest)
return
}
log.Infow("Failed to initiate an OAuth2 login request", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
log.Infow("A login request is initiated", "challenge", challenge, "username", ri.Subject)
if ri.Skip {
redirectURI, err := rproc.AcceptLoginRequest(challenge, false, 0, ri.Subject)
if err != nil {
log.Infow("Failed to accept an OAuth login request", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
http.Redirect(w, r, redirectURI, http.StatusFound)
return
}
data := loginTmplData{
CSRFToken: nosurf.Token(r),
Challenge: challenge,
LoginURL: strings.TrimPrefix(r.URL.String(), "/"),
WebBasePath: srv.WebBasePath,
}
if err := srv.renderLoginTemplate(w, data); err != nil {
log.Infow("Failed to render a login page template", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
}
}
// authenticator is an interface that is used for a user authentication.
//
// Authenticate returns false if the username or password is invalid.
type authenticator interface {
Authenticate(ctx context.Context, username, password string) (ok bool, err error)
}
func (srv *Server) handleLoginEnd(ra oa2LoginReqAcceptor, auther authenticator) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logger.FromContext(r.Context())
r.ParseForm()
challenge := r.Form.Get("login_challenge")
if challenge == "" {
log.Debug("No login challenge that is needed by the OAuth2 provider")
http.Error(w, "No login challenge", http.StatusBadRequest)
return
}
data := loginTmplData{
CSRFToken: nosurf.Token(r),
Challenge: challenge,
LoginURL: r.URL.String(),
WebBasePath: srv.WebBasePath,
}
username, password := r.Form.Get("username"), r.Form.Get("password")
switch ok, err := auther.Authenticate(r.Context(), username, password); {
case err != nil:
data.IsInternalError = true
log.Infow("Failed to authenticate a login request via the OAuth2 provider",
"error", err, "challenge", challenge, "username", username)
if err = srv.renderLoginTemplate(w, data); err != nil {
log.Infow("Failed to render a login page template", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
}
return
case !ok:
data.IsInvalidCredentials = true
log.Debugw("Invalid credentials", "error", err, "challenge", challenge, "username", username)
if err = srv.renderLoginTemplate(w, data); err != nil {
log.Infow("Failed to render a login page template", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
}
return
}
log.Infow("A username is authenticated", "challenge", challenge, "username", username)
remember := r.Form.Get("remember") != ""
redirectTo, err := ra.AcceptLoginRequest(challenge, remember, int(srv.SessionTTL.Seconds()), username)
if err != nil {
data.IsInternalError = true
log.Infow("Failed to accept a login request via the OAuth2 provider", "error", err)
if err := srv.renderLoginTemplate(w, data); err != nil {
log.Infow("Failed to render a login page template", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
}
return
}
http.Redirect(w, r, redirectTo, http.StatusFound)
}
}
// oa2ConsentReqAcceptor is an interface that is used for creating and accepting an OAuth2 consent request.
//
// InitiateRequest returns oauth2.ErrChallengeNotFound if the OAuth2 provider failed to find the challenge.
// InitiateRequest returns oauth2.ErrChallengeExpired if the OAuth2 provider processed the challenge previously.
type oa2ConsentReqProcessor interface {
InitiateRequest(challenge string) (*oauth2.ReqInfo, error)
AcceptConsentRequest(challenge string, remember bool, rememberFor int, grantScope []string, idToken interface{}) (string, error)
}
// oidcClaimsFinder is an interface that is used for searching OpenID Connect claims for the given username.
type oidcClaimsFinder interface {
FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error)
}
func (srv *Server) handleConsent(rproc oa2ConsentReqProcessor, cfinder oidcClaimsFinder) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logger.FromContext(r.Context())
challenge := r.URL.Query().Get("consent_challenge")
if challenge == "" {
log.Debug("No consent challenge that is needed by the OAuth2 provider")
http.Error(w, "No consent challenge", http.StatusBadRequest)
return
}
ri, err := rproc.InitiateRequest(challenge)
if err != nil {
log = log.With("challenge", challenge)
switch errors.Cause(err) {
case oauth2.ErrChallengeNotFound:
log.Debugw("Unknown consent challenge in the OAuth2 provider", "error", err)
http.Error(w, "Unknown consent challenge", http.StatusBadRequest)
return
case oauth2.ErrChallengeExpired:
log.Debugw("Consent challenge has been used already in the OAuth2 provider", "error", err)
http.Error(w, "Consent challenge has been used already", http.StatusBadRequest)
return
}
log.Infow("Failed to send an OAuth2 consent request", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
log.Infow("A consent request is initiated", "challenge", challenge, "username", ri.Subject)
claims, err := cfinder.FindOIDCClaims(r.Context(), ri.Subject)
if err != nil {
log.Infow("Failed to find user's OIDC claims", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
log.Debugw("Found user's OIDC claims", "claims", claims)
// Remove claims that are not in the requested scopes.
for claim := range claims {
var found bool
// We need to escape a claim due to ClaimScopes' keys contain URL encoded claims.
// It is because of config option's format compatibility.
if scope, ok := srv.ClaimScopes[url.QueryEscape(claim)]; ok {
for _, rscope := range ri.RequestedScopes {
if rscope == scope {
found = true
break
}
}
}
if !found {
delete(claims, claim)
log.Debugw("Deleted the OIDC claim because it's not in requested scopes", "claim", claim)
}
}
redirectTo, err := rproc.AcceptConsentRequest(challenge, !ri.Skip, int(srv.SessionTTL.Seconds()), ri.RequestedScopes, claims)
if err != nil {
log.Infow("Failed to accept a consent request to the OAuth2 provider", "error", err, "scopes", ri.RequestedScopes, "claims", claims)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
log.Debugw("Accepted the consent request to the OAuth2 provider", "scopes", ri.RequestedScopes, "claims", claims)
http.Redirect(w, r, redirectTo, http.StatusFound)
}
}
func (srv *Server) renderLoginTemplate(w http.ResponseWriter, data interface{}) error {
t, err := srv.webldr.loadTemplate("login.tmpl")
if err != nil {
return err
}
var (
buf bytes.Buffer
bw = bufio.NewWriter(&buf)
)
if err := t.Execute(bw, data); err != nil {
return err
}
if err := bw.Flush(); err != nil {
return err
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
buf.WriteTo(w)
return nil
}
func (srv *Server) handleHealthAliveAndReady() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logger.FromContext(r.Context())
resp := struct {
Status string `json:"status"`
}{
Status: "ok",
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Infow("Failed to marshal health liveness and readiness status", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
}
}
func (srv *Server) handleVersion() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logger.FromContext(r.Context())
resp := struct {
Version string `json:"version"`
}{
Version: Version,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Infow("Failed to marshal version", "error", err)
http.Error(w, internalServerErrorMessage, http.StatusInternalServerError)
return
}
}
}

View File

@ -1,500 +0,0 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, December 2018
*/
package server
import (
"context"
"html/template"
"net/http"
"net/http/httptest"
"reflect"
"regexp"
"strings"
"testing"
"github.com/justinas/nosurf"
"github.com/pkg/errors"
"gopkg.i-core.ru/werther/internal/oauth2"
)
func TestHandleLoginStart(t *testing.T) {
testCases := []struct {
name string
challenge string
scopes []string
skip bool
subject string
redirect string
wantStatus int
wantInitErr error
wantAcceptErr error
wantLoc string
wantBody string
}{
{
name: "no login challenge",
wantStatus: http.StatusBadRequest,
},
{
name: "happy path",
challenge: "foo",
wantStatus: http.StatusOK,
wantBody: `
WebBasePath: ;
LoginURL: login?login_challenge=foo;
CSRFToken: true;
Challenge: foo;
`,
},
{
name: "skip",
challenge: "foo",
skip: true,
wantLoc: "/",
wantStatus: http.StatusFound,
},
{
name: "unknown challenge",
challenge: "foo",
wantInitErr: oauth2.ErrChallengeNotFound,
wantStatus: http.StatusBadRequest,
},
{
name: "used challenge",
challenge: "foo",
wantInitErr: oauth2.ErrChallengeExpired,
wantStatus: http.StatusBadRequest,
},
{
name: "init error",
challenge: "foo",
wantInitErr: errors.New("init error"),
wantStatus: http.StatusInternalServerError,
},
{
name: "accept error",
challenge: "foo",
skip: true,
wantAcceptErr: errors.New("accept error"),
wantStatus: http.StatusInternalServerError,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := "/login"
if tc.challenge != "" {
url += "?login_challenge=" + tc.challenge
}
r, err := http.NewRequest("POST", url, nil)
if err != nil {
t.Fatal(err)
}
r.Host = "gopkg.example.org"
rr := httptest.NewRecorder()
ldr := &testLoginWeb{}
ldr.loadTmplFunc = func(name string) (*template.Template, error) {
if name != "login.tmpl" {
t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name)
}
const loginT = `
WebBasePath: {{ .WebBasePath }};
LoginURL: {{ .LoginURL }};
CSRFToken: {{ if .CSRFToken }} true {{- else -}} false {{- end }};
Challenge: {{ .Challenge }};
`
tmpl, err := template.New("login").Parse(loginT)
if err != nil {
t.Fatalf("failed to parse template: %s", err)
}
return tmpl, nil
}
srv := &Server{webldr: ldr}
rproc := testLoginReqProc{}
rproc.initReqFunc = func(challenge string) (*oauth2.ReqInfo, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while initiating the request: got %q; want %q", challenge, tc.challenge)
}
return &oauth2.ReqInfo{
Challenge: tc.challenge,
RequestedScopes: tc.scopes,
Skip: tc.skip,
Subject: tc.subject,
}, tc.wantInitErr
}
rproc.acceptReqFunc = func(challenge string, remember bool, rememberFor int, subject string) (string, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
}
if remember {
t.Error("unexpected enabled remember flag")
}
if rememberFor > 0 {
t.Errorf("unexpected remember duration: got %d", rememberFor)
}
if subject != tc.subject {
t.Errorf("wrong subject while accepting the request: got %q; want %q", subject, tc.subject)
}
return tc.redirect, tc.wantAcceptErr
}
handler := nosurf.New(srv.handleLoginStart(rproc))
handler.ExemptPath("/login")
handler.ServeHTTP(rr, r)
if status := rr.Code; status != tc.wantStatus {
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
}
wantBody, gotBody := noindent(tc.wantBody), noindent(rr.Body.String())
if wantBody != "" && gotBody != wantBody {
t.Errorf("wrong body:\ngot %q\nwant %q", gotBody, wantBody)
}
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
}
})
}
}
func noindent(s string) string {
wsRe := regexp.MustCompile(`(?:^\s+|(;)\s+)`)
return wsRe.ReplaceAllString(s, "$1 ")
}
type testLoginReqProc struct {
initReqFunc func(string) (*oauth2.ReqInfo, error)
acceptReqFunc func(string, bool, int, string) (string, error)
}
func (lrp testLoginReqProc) InitiateRequest(challenge string) (*oauth2.ReqInfo, error) {
return lrp.initReqFunc(challenge)
}
func (lrp testLoginReqProc) AcceptLoginRequest(challenge string, remember bool, rememberFor int, subject string) (string, error) {
return lrp.acceptReqFunc(challenge, remember, rememberFor, subject)
}
type testLoginWeb struct {
loadTmplFunc func(string) (*template.Template, error)
}
func (tl *testLoginWeb) loadTemplate(name string) (*template.Template, error) {
return tl.loadTmplFunc(name)
}
func TestHandleLoginEnd(t *testing.T) {
testCases := []struct {
name string
challenge string
subject string
redirect string
wantStatus int
wantAcceptErr error
wantAuthErr error
wantInvAuth bool
wantLoc string
wantBody string
}{
{
name: "no login challenge",
subject: "joe",
wantStatus: http.StatusBadRequest,
},
{
name: "happy path",
challenge: "foo",
subject: "joe",
redirect: "/redirect-to",
wantStatus: http.StatusFound,
wantLoc: "/redirect-to",
},
{
name: "auth unknown error",
challenge: "foo",
subject: "joe",
wantStatus: http.StatusOK,
wantInvAuth: false,
wantAuthErr: errors.New("Unknown error"),
wantBody: `
WebBasePath: ;
LoginURL: /login;
CSRFToken: T;
Challenge: foo;
InvCreds: F;
IsIntErr: T;
`,
},
{
name: "unauth error",
challenge: "foo",
subject: "joe",
wantStatus: http.StatusOK,
wantInvAuth: true,
wantBody: `
WebBasePath: ;
LoginURL: /login;
CSRFToken: T;
Challenge: foo;
InvCreds: T;
IsIntErr: F;
`,
},
{
name: "accept error",
challenge: "foo",
subject: "joe",
wantStatus: http.StatusOK,
wantAcceptErr: errors.New("accept error"),
wantBody: `
WebBasePath: ;
LoginURL: /login;
CSRFToken: T;
Challenge: foo;
InvCreds: F;
IsIntErr: T;
`,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := "/login"
ps := "username=joe&password=pass"
if tc.challenge != "" {
ps += "&login_challenge=" + tc.challenge
}
r, err := http.NewRequest("POST", url, strings.NewReader(ps))
if err != nil {
t.Fatal(err)
}
r.Host = "gopkg.example.org"
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rr := httptest.NewRecorder()
ldr := &testLoginWeb{}
ldr.loadTmplFunc = func(name string) (*template.Template, error) {
if name != "login.tmpl" {
t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name)
}
const loginT = `
WebBasePath: {{ .WebBasePath }};
LoginURL: {{ .LoginURL }};
CSRFToken: {{ if .CSRFToken -}} T {{- else -}} F {{- end }};
Challenge: {{ .Challenge }};
InvCreds: {{ if .IsInvalidCredentials -}} T {{- else -}} F {{- end }};
IsIntErr: {{ if .IsInternalError -}} T {{- else -}} F {{- end}};
`
tmpl, err := template.New("login").Parse(loginT)
if err != nil {
t.Fatalf("failed to parse template: %s", err)
}
return tmpl, nil
}
srv := &Server{
webldr: ldr,
}
rproc := testLoginReqProc{}
rproc.acceptReqFunc = func(challenge string, remember bool, rememberFor int, subject string) (string, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
}
if remember {
t.Error("unexpected enabled remember flag")
}
if rememberFor > 0 {
t.Errorf("unexpected remember duration: got %d", rememberFor)
}
if subject != tc.subject {
t.Errorf("wrong subject while accepting the request: got %q; want %q", subject, tc.subject)
}
return tc.redirect, tc.wantAcceptErr
}
auther := testAuthenticator{}
auther.authnFunc = func(ctx context.Context, username, password string) (bool, error) {
if username == "" {
t.Error("unexpected empty username")
}
if password == "" {
t.Error("unexpected empty password")
}
return !tc.wantInvAuth, tc.wantAuthErr
}
handler := nosurf.New(srv.handleLoginEnd(rproc, auther))
handler.ExemptPath("/login")
handler.ServeHTTP(rr, r)
if status := rr.Code; status != tc.wantStatus {
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
}
wantBody, gotBody := noindent(tc.wantBody), noindent(rr.Body.String())
if wantBody != "" && gotBody != wantBody {
t.Errorf("wrong body:\ngot %q\nwant %q", gotBody, wantBody)
}
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
}
})
}
}
type testAuthenticator struct {
authnFunc func(context.Context, string, string) (bool, error)
}
func (au testAuthenticator) Authenticate(ctx context.Context, username, password string) (bool, error) {
return au.authnFunc(ctx, username, password)
}
func TestHandleConsent(t *testing.T) {
testCases := []struct {
name string
challenge string
redirect string
subject string
skip bool
claims map[string]interface{}
scopes []string
wantStatus int
wantAcceptErr error
wantInitErr error
wantFindErr error
wantLoc string
}{
{
name: "no login challenge",
subject: "joe",
wantStatus: http.StatusBadRequest,
},
{
name: "unknown challenge",
challenge: "foo",
wantInitErr: oauth2.ErrChallengeNotFound,
wantStatus: http.StatusBadRequest,
},
{
name: "used challenge",
challenge: "foo",
wantInitErr: oauth2.ErrChallengeExpired,
wantStatus: http.StatusBadRequest,
},
{
name: "happy path",
challenge: "foo",
subject: "joe",
redirect: "/redirect-to",
wantStatus: http.StatusFound,
wantLoc: "/redirect-to",
claims: map[string]interface{}{"a": "foo", "b": "bar", "c": "baz"},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
url := "/consent"
if tc.challenge != "" {
url += "?consent_challenge=" + tc.challenge
}
r, err := http.NewRequest("GET", url, nil)
if err != nil {
t.Fatal(err)
}
r.Host = "gopkg.example.org"
rr := httptest.NewRecorder()
ldr := &testLoginWeb{}
ldr.loadTmplFunc = func(name string) (*template.Template, error) {
if name != "login.tmpl" {
t.Fatalf("wrong template name: got %q; want \"login.tmpl\"", name)
}
const loginT = ""
tmpl, err := template.New("login").Parse(loginT)
if err != nil {
t.Fatalf("failed to parse template: %s", err)
}
return tmpl, nil
}
srv := &Server{webldr: ldr}
rproc := testConsentReqProc{}
rproc.initReqFunc = func(challenge string) (*oauth2.ReqInfo, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while initiating the request: got %q; want %q", challenge, tc.challenge)
}
return &oauth2.ReqInfo{
Challenge: tc.challenge,
Subject: tc.subject,
RequestedScopes: tc.scopes,
}, tc.wantInitErr
}
rproc.acceptReqFunc = func(challenge string, remember bool, rememberFor int, grantScope []string, idToken interface{}) (string, error) {
if challenge != tc.challenge {
t.Errorf("wrong challenge while accepting the request: got %q; want %q", challenge, tc.challenge)
}
if remember == tc.skip {
t.Error("unexpected enabled remember flag")
}
if rememberFor > 0 {
t.Errorf("unexpected remember duration: got %d", rememberFor)
}
if len(grantScope) != len(tc.scopes) {
t.Errorf("wrong granted scopes while accepting the request: got %q; want %q", grantScope, tc.scopes)
} else {
for i := range grantScope {
if grantScope[i] != tc.scopes[i] {
t.Errorf("wrong granted scopes while accepting the request: got %q; want %q", grantScope, tc.scopes)
break
}
}
}
if !reflect.DeepEqual(idToken, tc.claims) {
t.Errorf("wrong an id token while accepting the request: got %q; want %q", idToken, tc.claims)
}
return tc.redirect, tc.wantAcceptErr
}
cfinder := testOIDCClaimsFinder{}
cfinder.findFunc = func(ctx context.Context, username string) (map[string]interface{}, error) {
if username == "" {
t.Error("unexpected empty username")
}
return tc.claims, tc.wantFindErr
}
handler := nosurf.New(srv.handleConsent(rproc, cfinder))
handler.ExemptPath("/consent")
handler.ServeHTTP(rr, r)
if status := rr.Code; status != tc.wantStatus {
t.Errorf("wrong status code: got %v; want %v", status, tc.wantStatus)
}
if gotLoc := rr.Header().Get("Location"); gotLoc != tc.wantLoc {
t.Errorf("wrong location:\ngot %q\nwant %q", gotLoc, tc.wantLoc)
}
})
}
}
type testConsentReqProc struct {
initReqFunc func(string) (*oauth2.ReqInfo, error)
acceptReqFunc func(string, bool, int, []string, interface{}) (string, error)
}
func (crp testConsentReqProc) InitiateRequest(challenge string) (*oauth2.ReqInfo, error) {
return crp.initReqFunc(challenge)
}
func (crp testConsentReqProc) AcceptConsentRequest(challenge string, remember bool, rememberFor int, grantScope []string, idToken interface{}) (string, error) {
return crp.acceptReqFunc(challenge, remember, rememberFor, grantScope, idToken)
}
type testOIDCClaimsFinder struct {
findFunc func(context.Context, string) (map[string]interface{}, error)
}
func (cf testOIDCClaimsFinder) FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error) {
return cf.findFunc(ctx, username)
}

View File

@ -1,127 +0,0 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
Written by Konstantin Lepa <klepa@i-core.ru>, December 2018
*/
//go:generate go run github.com/kevinburke/go-bindata/go-bindata -o templates.go -pkg server -prefix templates/ templates/...
package server
import (
"fmt"
"html/template"
"os"
"path"
"path/filepath"
"strings"
"github.com/pkg/errors"
)
// intWebLoader is a loader that is used for serving embedded HTML/JS/CSS static files.
// They are embedded in a generated Go code.
type intWebLoader struct {
tmpls map[string]*template.Template
}
// newIntWebLoader creates an internal web loader's instance.
func newIntWebLoader() (*intWebLoader, error) {
mainTmpl, err := template.New("main").Parse(mainT)
if err != nil {
return nil, errors.Wrap(err, "failed to parse the main template")
}
tmpls := make(map[string]*template.Template)
for _, name := range AssetNames() {
t, err := mainTmpl.Clone()
if err != nil {
return nil, errors.Wrap(err, "failed to clone the main template")
}
asset, err := Asset(name)
if err != nil {
return nil, errors.Wrapf(err, "failed to load asset %q", name)
}
tmpls[path.Base(name)] = template.Must(t.Parse(string(asset)))
}
return &intWebLoader{tmpls: tmpls}, nil
}
func (wl *intWebLoader) loadTemplate(name string) (*template.Template, error) {
t, ok := wl.tmpls[name]
if !ok {
return nil, fmt.Errorf("the template %q does not exist", name)
}
return t, nil
}
// extWebLoader is a loader that is used for serving HTML/JS/CSS static files.
// The files must be provided at startup.
type extWebLoader struct {
root *template.Template
paths map[string]string
}
// newExtWebLoader creates an external web loader's instance.
// The implementation affords to replace static files without restart of the app.
func newExtWebLoader(webDir string) (*extWebLoader, error) {
if _, err := os.Stat(webDir); err != nil {
return nil, errors.Wrapf(err, "failed to load web dir %q", webDir)
}
files, err := filepath.Glob(path.Join(webDir, "*.tmpl"))
if err != nil {
return nil, errors.Wrapf(err, "failed to load templates from web dir %q", webDir)
}
for i, f := range files {
if !strings.HasSuffix(f, ".tmpl") {
files = append(files[:i], files[i+1:]...)
}
}
for i, f := range files {
files[i] = path.Join("web", f)
}
mainTmpl, err := template.New("main").Parse(mainT)
if err != nil {
return nil, errors.Wrap(err, "failed to parse the main template")
}
paths := make(map[string]string)
for _, f := range files {
paths[path.Base(f)] = f
}
return &extWebLoader{root: mainTmpl, paths: paths}, nil
}
func (wl *extWebLoader) loadTemplate(name string) (*template.Template, error) {
p, ok := wl.paths[name]
if !ok {
return nil, fmt.Errorf("the template %q does not exist", name)
}
t, err := wl.root.Clone()
if err != nil {
return nil, errors.Wrapf(err, "failed to clone the template %q", name)
}
return t.ParseFiles(p)
}
const mainT = `{{ define "main" }}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" . }}{{ end }}</title>
<base href={{ .WebBasePath }}>
{{ block "style". }}{{ end }}
</head>
<body>
{{ block "content" . }}<h1>NO CONTENT</h1>{{ end }}
{{ block "js" . }}{{ end }}
</body>
</html>
{{ end }}
`

60
internal/stat/stat.go Normal file
View File

@ -0,0 +1,60 @@
package stat
import (
"encoding/json"
"net/http"
"go.uber.org/zap"
"gopkg.i-core.ru/logutil"
)
// Handler provides HTTP handlers for health checking and versioning.
type Handler struct {
version string
}
// NewHandler creates a new Handler.
func NewHandler(version string) *Handler {
return &Handler{version: version}
}
// 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, "/version", newVersionHandler(h.version))
}
func newHealthAliveAndReadyHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context())
resp := struct {
Status string `json:"status"`
}{
Status: "ok",
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Info("Failed to marshal health liveness and readiness status", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
}
func newVersionHandler(version string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logutil.FromContext(r.Context())
resp := struct {
Version string `json:"version"`
}{
Version: version,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Info("Failed to marshal version", zap.Error(err))
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
}

View File

@ -4,7 +4,7 @@
// templates/static/script.js (1.24kB) // templates/static/script.js (1.24kB)
// templates/static/style.css (4.316kB) // templates/static/style.css (4.316kB)
package server package web
import ( import (
"bytes" "bytes"
@ -86,7 +86,7 @@ func loginTmpl() (*asset, error) {
return nil, err return nil, err
} }
info := bindataFileInfo{name: "login.tmpl", size: 1216, mode: os.FileMode(0644), modTime: time.Unix(1550576890, 0)} info := bindataFileInfo{name: "login.tmpl", size: 1216, mode: os.FileMode(0644), modTime: time.Unix(1557837078, 0)}
a := &asset{bytes: bytes, info: info, digest: [32]uint8{0x57, 0xfc, 0x84, 0x28, 0x53, 0xbc, 0x7e, 0xe8, 0xf7, 0x63, 0x72, 0x3, 0x51, 0x21, 0xda, 0x48, 0x1f, 0x45, 0x63, 0xe8, 0x32, 0x66, 0x1b, 0xfd, 0x47, 0xca, 0x33, 0x3c, 0x36, 0x79, 0xe4, 0xf9}} a := &asset{bytes: bytes, info: info, digest: [32]uint8{0x57, 0xfc, 0x84, 0x28, 0x53, 0xbc, 0x7e, 0xe8, 0xf7, 0x63, 0x72, 0x3, 0x51, 0x21, 0xda, 0x48, 0x1f, 0x45, 0x63, 0xe8, 0x32, 0x66, 0x1b, 0xfd, 0x47, 0xca, 0x33, 0x3c, 0x36, 0x79, 0xe4, 0xf9}}
return a, nil return a, nil
} }
@ -106,7 +106,7 @@ func staticScriptJs() (*asset, error) {
return nil, err return nil, err
} }
info := bindataFileInfo{name: "static/script.js", size: 1240, mode: os.FileMode(0644), modTime: time.Unix(1545371222, 0)} info := bindataFileInfo{name: "static/script.js", size: 1240, mode: os.FileMode(0644), modTime: time.Unix(1557837078, 0)}
a := &asset{bytes: bytes, info: info, digest: [32]uint8{0x21, 0x83, 0x40, 0xc4, 0xb1, 0x4e, 0x2c, 0xf8, 0x84, 0x11, 0x9b, 0x80, 0xc2, 0xe6, 0xab, 0xb5, 0xf8, 0xd5, 0x3b, 0xc9, 0x2e, 0x5b, 0x12, 0x7, 0x29, 0x2f, 0x21, 0x5f, 0x59, 0x35, 0xf7, 0xad}} a := &asset{bytes: bytes, info: info, digest: [32]uint8{0x21, 0x83, 0x40, 0xc4, 0xb1, 0x4e, 0x2c, 0xf8, 0x84, 0x11, 0x9b, 0x80, 0xc2, 0xe6, 0xab, 0xb5, 0xf8, 0xd5, 0x3b, 0xc9, 0x2e, 0x5b, 0x12, 0x7, 0x29, 0x2f, 0x21, 0x5f, 0x59, 0x35, 0xf7, 0xad}}
return a, nil return a, nil
} }
@ -126,7 +126,7 @@ func staticStyleCss() (*asset, error) {
return nil, err return nil, err
} }
info := bindataFileInfo{name: "static/style.css", size: 4316, mode: os.FileMode(0644), modTime: time.Unix(1545371222, 0)} info := bindataFileInfo{name: "static/style.css", size: 4316, mode: os.FileMode(0644), modTime: time.Unix(1557837078, 0)}
a := &asset{bytes: bytes, info: info, digest: [32]uint8{0xf8, 0x53, 0xba, 0x33, 0x44, 0x16, 0x28, 0xdc, 0x9c, 0x4f, 0x69, 0xd7, 0x30, 0x5, 0x56, 0x8f, 0x1f, 0x78, 0xe3, 0x53, 0x41, 0xe6, 0x42, 0x95, 0x4, 0xaa, 0x5b, 0x40, 0xc, 0x30, 0x4d, 0x68}} a := &asset{bytes: bytes, info: info, digest: [32]uint8{0xf8, 0x53, 0xba, 0x33, 0x44, 0x16, 0x28, 0xdc, 0x9c, 0x4f, 0x69, 0xd7, 0x30, 0x5, 0x56, 0x8f, 0x1f, 0x78, 0xe3, 0x53, 0x41, 0xe6, 0x42, 0x95, 0x4, 0xaa, 0x5b, 0x40, 0xc, 0x30, 0x4d, 0x68}}
return a, nil return a, nil
} }

View File

@ -0,0 +1,34 @@
external template
WebBasePath: testBasePath;
Title:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Style:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Js:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Content:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;

View File

@ -0,0 +1,31 @@
{{- define "title" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "style" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "js" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "content" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}

View File

@ -0,0 +1,15 @@
{{- define "main" }}external template
WebBasePath: {{ .WebBasePath }};
Title:
{{ block "title" .Data }}{{ end }}
Style:
{{ block "style" .Data }}{{ end }}
Js:
{{ block "js" .Data }}{{ end }}
Content:
{{ block "content" .Data }}{{ end }}
{{- end }}

View File

@ -0,0 +1,16 @@
{{ define "main" }}
external template
WebBasePath: {{ .WebBasePath }};
Title:
{{ block "title" .Data }}{{ end }}
Style:
{{ block "style" .Data }}{{ end }}
Js:
{{ block "js" .Data }}{{ end }}
Content:
{{ block "content" .Data }}{{ end }}
{{ end }}

View File

@ -0,0 +1,34 @@
internal template
WebBasePath: testBasePath;
Title:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Style:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Js:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;
Content:
CSRFToken: testCSRFToken;
Challenge: testChalenge;
LoginURL: testLoginURL;
IsInvalidCredentials: true;
IsInternalError: true;

View File

@ -0,0 +1,31 @@
{{- define "title" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "style" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "js" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}
{{- define "content" }}
CSRFToken: {{ .CSRFToken }};
Challenge: {{ .Challenge }};
LoginURL: {{ .LoginURL }};
IsInvalidCredentials: {{ .IsInvalidCredentials }};
IsInternalError: {{ .IsInternalError }};
{{- end }}

View File

@ -0,0 +1,15 @@
{{- define "main" }}internal template
WebBasePath: {{ .WebBasePath }};
Title:
{{ block "title" .Data }}{{ end }}
Style:
{{ block "style" .Data }}{{ end }}
Js:
{{ block "js" .Data }}{{ end }}
Content:
{{ block "content" .Data }}{{ end }}
{{- end }}

View File

@ -0,0 +1,16 @@
{{ define "main" }}
internal template
WebBasePath: {{ .WebBasePath }};
Title:
{{ block "title" .Data }}{{ end }}
Style:
{{ block "style" .Data }}{{ end }}
Js:
{{ block "js" .Data }}{{ end }}
Content:
{{ block "content" .Data }}{{ end }}
{{ end }}

View File

@ -0,0 +1 @@
The file is needed to commit the parent directory to Git.

View File

@ -0,0 +1 @@
The file is needed to commit the parent directory to Git.

155
internal/web/web.go Normal file
View File

@ -0,0 +1,155 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
*/
//go:generate go run github.com/kevinburke/go-bindata/go-bindata -o templates.go -pkg web -prefix templates/ templates/...
package web
import (
"bufio"
"bytes"
"fmt"
"html/template"
"io/ioutil"
"net/http"
"os"
"path"
assetfs "github.com/elazarl/go-bindata-assetfs"
"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.
// The variables are needed to be able to override them in tests.
var (
intTmplsFS http.FileSystem = &assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: AssetInfo}
intStaticFS http.FileSystem = &assetfs.AssetFS{Asset: Asset, AssetDir: AssetDir, AssetInfo: AssetInfo, Prefix: "static"}
)
// Config is a configuration of a template's renderer and HTTP handler for static files.
type Config struct {
Dir string `envconfig:"dir" desc:"a path to an external web directory"`
BasePath string `envconfig:"base_path" default:"/" desc:"a base path of web pages"`
}
// HTMLRenderer renders a HTML page from a Go template.
//
// A template's source for a HTML page should contains 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.
// Block "content" should contain HTML content that is injected to the start of the page's body.
// Each block has access to data that is specified using the method "RenderTemplate" of HTMLRenderer.
//
// By default, HTMLRenderer loads a template's source from the application's internal assets.
// The application's internal assets include the login page's template only (template with name "login.tmpl").
//
// Besides it, HTMLRenderer can load templates' sources from an external directory.
// The external directory is specified via a config.
//
// Templates can contain links to resources (styles and scripts). In that case, the template's directory has to
// contain directory "static" with these resources. To provide these resources to a user you should register
// StaticHandler in the application's HTTP router with path "/static".
type HTMLRenderer struct {
Config
mainTmpl *template.Template
fs http.FileSystem
}
// NewHTMLRenderer returns a new instance of HTMLRenderer.
func NewHTMLRenderer(cnf Config) (*HTMLRenderer, error) {
mainTmpl, err := template.New("main").Parse(mainT)
if err != nil {
return nil, errors.Wrap(err, "failed to create template's renderer")
}
fs := intTmplsFS
if cnf.Dir != "" {
fs = http.Dir(cnf.Dir)
}
return &HTMLRenderer{Config: cnf, mainTmpl: mainTmpl, fs: fs}, nil
}
// RenderTemplate renders a HTML page from a template with the specified name using the specified data.
func (r *HTMLRenderer) RenderTemplate(w http.ResponseWriter, name string, data interface{}) error {
f, err := r.fs.Open(name)
if err != nil {
if v, ok := err.(*os.PathError); ok {
if os.IsNotExist(v.Err) {
return fmt.Errorf("the template %q does not exist", name)
}
}
return fmt.Errorf("failed to open template %q: %s", name, err)
}
b, err := ioutil.ReadAll(f)
if err != nil {
return fmt.Errorf("failed to read template %q: %s", name, err)
}
t, err := r.mainTmpl.Clone()
if err != nil {
return errors.Wrapf(err, "failed to clone the main template for template %q: %s", name, err)
}
t, err = t.Parse(string(b))
if err != nil {
return errors.Wrapf(err, "failed to parse template %q: %s", name, err)
}
var (
buf bytes.Buffer
bw = bufio.NewWriter(&buf)
)
if err = t.Execute(bw, map[string]interface{}{"WebBasePath": r.BasePath, "Data": data}); err != nil {
return err
}
if err = bw.Flush(); err != nil {
return err
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, err = buf.WriteTo(w)
return err
}
var mainT = `{{ define "main" }}
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ block "title" .Data }}{{ end }}</title>
<base href={{ .WebBasePath }}>
{{ block "style" .Data }}{{ end }}
</head>
<body>
{{ block "content" .Data }}<h1>NO CONTENT</h1>{{ end }}
{{ block "js" .Data }}{{ end }}
</body>
</html>
{{ end }}
`
// StaticHandler provides HTTP handler that serves static files.
type StaticHandler struct {
fs http.FileSystem
}
// NewStaticHandler creates a new instance of StaticHandler.
func NewStaticHandler(cnf Config) *StaticHandler {
fs := intStaticFS
if cnf.Dir != "" {
fs = http.Dir(path.Join(cnf.Dir, "static"))
}
return &StaticHandler{fs: fs}
}
// AddRoutes registers a route that serves static files.
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")
fileServer.ServeHTTP(w, r)
}))
}

187
internal/web/web_test.go Normal file
View File

@ -0,0 +1,187 @@
/*
Copyright (C) JSC iCore - All Rights Reserved
Unauthorized copying of this file, via any medium is strictly prohibited
Proprietary and confidential
*/
package web
import (
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"path"
"testing"
"github.com/andreyvit/diff"
"gopkg.i-core.ru/httputil"
)
func TestHTMLRenderer(t *testing.T) {
testCases := []struct {
name string
ext bool
basePath string
data interface{}
wantErr error
}{
{
name: "internal template not found",
wantErr: fmt.Errorf(`the template "login.tmpl" does not exist`),
},
{
name: "internal template happy path",
basePath: "testBasePath",
data: map[string]interface{}{
"CSRFToken": "testCSRFToken",
"Challenge": "testChalenge",
"LoginURL": "testLoginURL",
"IsInvalidCredentials": true,
"IsInternalError": true,
},
},
{
name: "external template not found",
ext: true,
wantErr: fmt.Errorf(`the template "login.tmpl" does not exist`),
},
{
name: "external template happy path",
ext: true,
basePath: "testBasePath",
data: map[string]interface{}{
"CSRFToken": "testCSRFToken",
"Challenge": "testChalenge",
"LoginURL": "testLoginURL",
"IsInvalidCredentials": true,
"IsInternalError": true,
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tstDir := path.Join("testdata", t.Name())
// Read the main template.
var originMainT = mainT
defer func() { mainT = originMainT }()
f, err := os.Open(path.Join(tstDir, "main.tmpl"))
if err != nil {
t.Fatalf("failed to open main template: %s", err)
}
fc, err := ioutil.ReadAll(f)
if err != nil {
t.Fatalf("failed to read main template: %s", err)
}
mainT = string(fc)
// Create the template renderer.
cnf := Config{BasePath: tc.basePath}
if tc.ext {
cnf.Dir = tstDir
} else {
origin := intTmplsFS
defer func() { intTmplsFS = origin }()
intTmplsFS = http.Dir(tstDir)
}
r, err := NewHTMLRenderer(cnf)
if err != nil {
t.Fatalf("failed to create the template renderer: %s", err)
}
rr := httptest.NewRecorder()
err = r.RenderTemplate(rr, "login.tmpl", tc.data)
if tc.wantErr != nil {
if err == nil {
t.Fatalf("\ngot not errors\nwant error\n\t%s", tc.wantErr)
}
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)
}
f, err = os.Open(path.Join(tstDir, "golden.file"))
if err != nil {
t.Fatalf("failed to open golden file: %s", err)
}
fc, err = ioutil.ReadAll(f)
if err != nil {
t.Fatalf("failed to read golden file: %s", err)
}
if got, want := rr.Body.String(), string(fc); got != want {
t.Errorf("\nbody diff (-want +got):\n%s", diff.LineDiff(want, got))
}
})
}
}
func TestStaticHandler(t *testing.T) {
testCases := []struct {
name string
ext bool
file string
wantStatus int
wantBody string
}{
{
name: "internal resource not found",
file: "not.found",
wantStatus: http.StatusNotFound,
},
{
name: "internal resource happy path",
file: "test.file",
wantStatus: http.StatusOK,
wantBody: "test",
},
{
name: "external resource not found",
ext: true,
file: "not.found",
wantStatus: http.StatusNotFound,
},
{
name: "external resource happy path",
ext: true,
file: "test.file",
wantStatus: http.StatusOK,
wantBody: "test",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tstDir := path.Join("testdata", t.Name())
cnf := Config{}
if tc.ext {
cnf.Dir = tstDir
} else {
origin := intStaticFS
defer func() { intStaticFS = origin }()
intStaticFS = http.Dir(path.Join(tstDir, "static"))
}
r := httptest.NewRequest(http.MethodGet, "/static/"+tc.file, nil)
rr := httptest.NewRecorder()
router := httputil.NewRouter()
router.AddRoutes(NewStaticHandler(cnf), "/static")
router.ServeHTTP(rr, r)
if rr.Code != tc.wantStatus {
t.Errorf("got status %d, want status %d", rr.Code, tc.wantStatus)
}
if tc.wantBody != "" {
if got := rr.Body.String(); got != tc.wantBody {
t.Errorf("got body %q, want body %q", got, tc.wantBody)
}
}
})
}
}