From d761ad579a2cd9ff32efff1f09a8a80cee096813 Mon Sep 17 00:00:00 2001 From: Nikolay Stupak Date: Wed, 15 May 2019 15:03:05 +0300 Subject: [PATCH] logout: add support of logout flow --- .golangci.yml | 6 +- CHANGELOG.md | 15 + Dockerfile | 6 +- README.md | 103 ++-- ci-testing.yaml | 2 +- cmd/werther/main.go | 41 +- cmd/werther/tools.go | 2 - go.mod | 18 +- go.sum | 26 +- internal/{oauth2 => }/hydra/consent.go | 20 +- internal/{oauth2 => }/hydra/hydra.go | 95 +-- internal/{oauth2 => }/hydra/login.go | 20 +- internal/hydra/logout.go | 34 ++ internal/identp/identp.go | 332 ++++++++++ internal/identp/identp_test.go | 568 ++++++++++++++++++ internal/ldapclient/ldapclient.go | 46 +- internal/logger/logger.go | 35 -- internal/oauth2/oauth2.go | 29 - internal/server/mw.go | 48 -- internal/server/mw_test.go | 32 - internal/server/server.go | 405 ------------- internal/server/server_test.go | 500 --------------- internal/server/web.go | 127 ---- internal/stat/stat.go | 60 ++ internal/{server => web}/templates.go | 8 +- internal/{server => web}/templates/login.tmpl | 0 .../templates/static/script.js | 0 .../templates/static/style.css | 0 .../external_template_happy_path/golden.file | 34 ++ .../external_template_happy_path/login.tmpl | 31 + .../external_template_happy_path/main.tmpl | 15 + .../external_template_not_found/main.tmpl | 16 + .../internal_template_happy_path/golden.file | 34 ++ .../internal_template_happy_path/login.tmpl | 31 + .../internal_template_happy_path/main.tmpl | 15 + .../internal_template_not_found/main.tmpl | 16 + .../static/test.file | 1 + .../static/stub.file | 1 + .../static/test.file | 1 + .../static/stub.file | 1 + internal/web/web.go | 155 +++++ internal/web/web_test.go | 187 ++++++ 42 files changed, 1760 insertions(+), 1356 deletions(-) rename internal/{oauth2 => }/hydra/consent.go (67%) rename internal/{oauth2 => }/hydra/hydra.go (56%) rename internal/{oauth2 => }/hydra/login.go (64%) create mode 100644 internal/hydra/logout.go create mode 100644 internal/identp/identp.go create mode 100644 internal/identp/identp_test.go delete mode 100644 internal/logger/logger.go delete mode 100644 internal/oauth2/oauth2.go delete mode 100644 internal/server/mw.go delete mode 100644 internal/server/mw_test.go delete mode 100644 internal/server/server.go delete mode 100644 internal/server/server_test.go delete mode 100644 internal/server/web.go create mode 100644 internal/stat/stat.go rename internal/{server => web}/templates.go (98%) rename internal/{server => web}/templates/login.tmpl (100%) rename internal/{server => web}/templates/static/script.js (100%) rename internal/{server => web}/templates/static/style.css (100%) create mode 100644 internal/web/testdata/TestHTMLRenderer/external_template_happy_path/golden.file create mode 100644 internal/web/testdata/TestHTMLRenderer/external_template_happy_path/login.tmpl create mode 100644 internal/web/testdata/TestHTMLRenderer/external_template_happy_path/main.tmpl create mode 100644 internal/web/testdata/TestHTMLRenderer/external_template_not_found/main.tmpl create mode 100644 internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/golden.file create mode 100644 internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/login.tmpl create mode 100644 internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/main.tmpl create mode 100644 internal/web/testdata/TestHTMLRenderer/internal_template_not_found/main.tmpl create mode 100644 internal/web/testdata/TestStaticHandler/external_resource_happy_path/static/test.file create mode 100644 internal/web/testdata/TestStaticHandler/external_resource_not_found/static/stub.file create mode 100644 internal/web/testdata/TestStaticHandler/internal_resource_happy_path/static/test.file create mode 100644 internal/web/testdata/TestStaticHandler/internal_resource_not_found/static/stub.file create mode 100644 internal/web/web.go create mode 100644 internal/web/web_test.go diff --git a/.golangci.yml b/.golangci.yml index ced4530..5878dc3 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -2,17 +2,13 @@ # # Unauthorized copying of this file, via any medium is strictly prohibited # Proprietary and confidential -# -# Written by Konstantin Lepa , September 2018 run: test: true silent: true - linters-settings: govet: check-shadowing: true - linters: disable-all: true enable: @@ -27,3 +23,5 @@ linters: - interfacer - unconvert - govet +issues: + exclude-use-default: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f741c..e2bf8f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.1] - 2019-05-15 +### Added +- Add gopkg.i-core.ru/logutil as a logger middleware. +- Add gopkg.i-core.ru/httputil as a HTTP router. +### Changed +- Move to Golang 1.12 when build the application in Docker. +- Update golangci-lint config. +- Update the copyright. +### Removed +- Remove the HTTP handler of Prometheus's metrics. + +## [1.1.0] - 2019-05-15 +### Added +- Add support of logout flow. + ## [1.0.0] - 2019-02-18 ### Added - Add unit tests for server's logic. diff --git a/Dockerfile b/Dockerfile index 37321b7..cade9e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,10 +2,8 @@ # # Unauthorized copying of this file, via any medium is strictly prohibited # Proprietary and confidential -# -# Written by Konstantin Lepa , July 2018 -FROM golang:1.11-alpine AS build +FROM golang:1.12-alpine AS build ARG VERSION ARG GOPROXY @@ -18,7 +16,7 @@ COPY go.mod . COPY go.sum . COPY cmd cmd COPY internal internal -RUN env CGO_ENABLED=0 go install -ldflags="-w -s -X gopkg.i-core.ru/werther/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 COPY --from=build /etc/passwd /etc/passwd diff --git a/README.md b/README.md index d3988a3..32dd6c4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,10 @@ # 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 ``` @@ -12,8 +15,7 @@ go install ./... ## Development Assume that your IP is set as $MY_HOST. The instruction will use 4444 TCP port for OAuth2 Provider Hydra, -3000 TCP port for Login Provider Werther, and 8080 TCP port for a callback. Tokens will be expired in one minute. -There is environment variable HYDRA_VERSION that equals to v1.0.0-beta8. +3000 TCP port for Login Provider Werther, and 8080 TCP port for a callback. Tokens will be expired in ten minutes. 1. Create a network: ``` @@ -22,53 +24,55 @@ There is environment variable HYDRA_VERSION that equals to v1.0.0-beta8. 2. Run ORY Hydra: ``` - docker run --network hydra-net -d --restart always --name hydra \ - -p 4444:4444 \ - -p 4445:4445 \ - -e OAUTH2_SHARE_ERROR_DEBUG=1 \ - -e LOG_LEVEL=debug \ - -e ACCESS_TOKEN_LIFESPAN=10m \ - -e ID_TOKEN_LIFESPAN=10m \ - -e CORS_ALLOWED_ORIGINS=http://$MY_HOST:8080 \ - -e CORS_ALLOWED_CREDENTIALS=true \ - -e OIDC_DISCOVERY_SCOPES_SUPPORTED=profile,email,phone \ - -e OIDC_DISCOVERY_CLAIMS_SUPPORTED=name,family_name,given_name,nickname,email,phone_number \ - -e OAUTH2_CONSENT_URL=http://$MY_HOST:3000/auth/consent \ - -e OAUTH2_LOGIN_URL=http://$MY_HOST:3000/auth/login \ - -e OAUTH2_ISSUER_URL=http://$MY_HOST:4444 \ - -e DATABASE_URL=memory \ - oryd/hydra:$HYDRA_VERSION serve all --dangerous-force-http - + docker run --network hydra-net -d --restart always --name hydra \ + -p 4444:4444 \ + -p 4445:4445 \ + -e OAUTH2_EXPOSE_INTERNAL_ERRORS=true \ + -e LOG_LEVEL=debug \ + -e TTL_ACCESS_TOKEN=10m \ + -e TTL_ID_TOKEN=10m \ + -e SERVE_PUBLIC_CORS_ENABLED=true \ + -e SERVE_PUBLIC_CORS_ALLOWED_ORIGINS=http://$MY_HOST:8080 \ + -e SERVE_PUBLIC_CORS_ALLOW_CREDENTIALS=true \ + -e WEBFINGER_OIDC_DISCOVERY_SUPPORTED_SCOPES=profile,email,phone \ + -e WEBFINGER_OIDC_DISCOVERY_SUPPORTED_CLAIMS=name,family_name,given_name,nickname,email,phone_number \ + -e URLS_SELF_ISSUER=http://localhost:4444 \ + -e URLS_SELF_PUBLIC=http://localhost:4444 \ + -e URLS_LOGIN=http://$MY_HOST:3000/auth/login \ + -e URLS_CONSENT=http://$MY_HOST:3000/auth/consent \ + -e URLS_LOGOUT=http://$MY_HOST:3000/auth/logout \ + -e DSN=memory \ + oryd/hydra:v1.0.0-rc.12 serve all --dangerous-force-http ``` 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: ``` - docker run -it --rm --network hydra-net \ - -e HYDRA_ADMIN_URL=http://hydra:4445 \ - oryd/hydra:$HYDRA_VERSION clients create \ - --skip-tls-verify \ - --id test-client \ - --secret test-secret \ - --response-types id_token,token,"id_token token" \ - --grant-types implicit \ - --scope openid,profile,email \ - --callbacks http://$MY_HOST:8080 + docker run -it --rm --network hydra-net \ + -e HYDRA_ADMIN_URL=http://hydra:4445 \ + oryd/hydra:$HYDRA_VERSION clients create \ + --skip-tls-verify \ + --id test-client \ + --secret test-secret \ + --response-types id_token,token,"id_token token" \ + --grant-types implicit \ + --scope openid,profile,email \ + --callbacks http://$MY_HOST:8080 \ + --post-logout-callbacks http://$MY_HOST:8080/post-logout-callback ``` 4. Run Werther: ``` - docker run --network hydra-net -d --restart always --name werther -p 3000:8080 \ - -e WERTHER_LOG_FORMAT=console \ - -e WERTHER_HYDRA_ADMIN_URL=http://hydra:4445 \ - -e WERTHER_LDAP_ENDPOINTS=icdc0.icore.local:389,icdc1.icore.local:389 \ - -e WERTHER_LDAP_BINDDN= \ - -e WERTHER_LDAP_BINDPW= \ - -e WERTHER_LDAP_BASEDN="DC=icore,DC=local" \ + docker run --network hydra-net -d --restart always --name werther -p 3000:8080 \ + -e WERTHER_IDENTP_HYDRA_URL=http://hydra:4445 \ + -e WERTHER_LDAP_ENDPOINTS=icdc0.icore.local:389,icdc1.icore.local:389 \ + -e WERTHER_LDAP_BINDDN= \ + -e WERTHER_LDAP_BINDPW= \ + -e WERTHER_LDAP_BASEDN="DC=icore,DC=local" \ -e WERTHER_LDAP_ROLE_BASEDN="OU=AppRoles,OU=Domain Groups,DC=icore,DC=local" \ hub.das.i-core.ru/p/base-werther ``` @@ -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 ``` -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 ``` +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 " ``` @@ -95,7 +103,7 @@ There is environment variable HYDRA_VERSION that equals to v1.0.0-beta8. Content-Type: application/json Date: Tue, 31 Jul 2018 17:17:51 GMT Vary: Origin - + { "email": "klepa@i-core.ru", "family_name": "Lepa", @@ -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). -7. Re-get a token by httpie: +8. Re-get a token by httpie: ``` http --session u1 -F -v get \ "http://$MY_HOST:4444/oauth2/auth?client_id=test-client&response_type=token&scope=openid%20profile&state=12345678&prompt=none" \ "Cookie:" ``` -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" ``` -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=&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 ``` diff --git a/ci-testing.yaml b/ci-testing.yaml index bb0f62c..6f70358 100644 --- a/ci-testing.yaml +++ b/ci-testing.yaml @@ -1,4 +1,4 @@ - image: golang:1.11-alpine shell: go test -v ./... -- image: golangci/golangci-lint +- image: golangci/golangci-lint:v1.16.0 shell: golangci-lint -v run diff --git a/cmd/werther/main.go b/cmd/werther/main.go index 26821a2..5b0a55f 100644 --- a/cmd/werther/main.go +++ b/cmd/werther/main.go @@ -3,8 +3,6 @@ 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 , July 2018 */ package main // import "gopkg.i-core.ru/werther/cmd/werther" @@ -15,17 +13,35 @@ import ( "net/http" "os" + "github.com/justinas/nosurf" "github.com/kelseyhightower/envconfig" "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 (:)"` + Web web.Config + Identp identp.Config + LDAP ldapclient.Config +} + func main() { flag.Usage = func() { fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) flag.PrintDefaults() 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) } } @@ -33,11 +49,11 @@ func main() { flag.Parse() if *verflag { - fmt.Println("werther", server.Version) + fmt.Println("werther", Version) os.Exit(0) } - var cnf server.Config + var cnf Config if err := envconfig.Process("werther", &cnf); err != nil { fmt.Fprintf(os.Stderr, "Invalid configuration: %s\n", err) os.Exit(1) @@ -53,13 +69,20 @@ func main() { os.Exit(1) } - srv, err := server.New(cnf, log) + htmlRenderer, err := web.NewHTMLRenderer(cnf.Web) if err != nil { fmt.Fprintf(os.Stderr, "Failed to start the server: %s\n", err) 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.Info("Werther started", zap.Any("config", cnf), zap.String("version", server.Version)) - log.Fatal("Werther finished", zap.Error(http.ListenAndServe(cnf.Listen, srv))) + log.Info("Werther started", zap.Any("config", cnf), zap.String("version", Version)) + log.Fatal("Werther finished", zap.Error(http.ListenAndServe(cnf.Listen, router))) } diff --git a/cmd/werther/tools.go b/cmd/werther/tools.go index e713bbb..6c5ff18 100644 --- a/cmd/werther/tools.go +++ b/cmd/werther/tools.go @@ -5,8 +5,6 @@ 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 , February 2019 */ package main diff --git a/go.mod b/go.mod index 332d28b..ead1e11 100644 --- a/go.mod +++ b/go.mod @@ -2,31 +2,27 @@ module gopkg.i-core.ru/werther require ( 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/coocood/freecache v1.0.1 github.com/davecgh/go-spew v1.1.1 // indirect github.com/elazarl/go-bindata-assetfs v1.0.0 - github.com/gofrs/uuid v3.2.0+incompatible - github.com/golang/protobuf v1.2.0 // indirect - github.com/julienschmidt/httprouter v1.2.0 - github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da + github.com/gofrs/uuid v3.2.0+incompatible // indirect + github.com/julienschmidt/httprouter v1.2.0 // indirect + github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da // indirect github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4 github.com/kelseyhightower/envconfig v1.3.0 github.com/kevinburke/go-bindata v3.13.0+incompatible - github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/pkg/errors v0.8.1 github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v0.8.0 - 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/sergi/go-diff v1.0.0 // indirect github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 // indirect github.com/stretchr/testify v1.2.2 // indirect go.uber.org/atomic v1.2.0 // indirect go.uber.org/multierr v1.1.0 // indirect go.uber.org/zap v1.9.1 - 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/ldap.v2 v2.5.1 ) diff --git a/go.sum b/go.sum index 005fbfd..564515c 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= 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/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +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/go.mod h1:fX/lfQBkSCDXZSUgv6jVIu/EVA3/JNseAX5asI4c4T4= 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/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/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/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 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/kelseyhightower/envconfig v1.3.0 h1:IvRS4f2VcIQy6j4ORGIf9145T/AsUB+oY8LyvN8BXNM= 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/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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/prometheus/client_golang v0.8.0 h1:1921Yw9Gc3iSc4VQh3PIoOqgPCZS7G/4xQNVUp8Mda8= -github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= -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/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= @@ -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/zap v1.9.1 h1:XCJQEf3W6eZaVwhRBof6ImoYGJSITeKWsyeh3HFu/5o= 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= -golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +gopkg.i-core.ru/httputil v1.0.0 h1:A+6RPcU8pNvA/Zf+0Oy9iozyrwycmvDGSCdqgea2/qo= +gopkg.i-core.ru/httputil v1.0.0/go.mod h1:OrmzAZNj0BuwD6hHQ9tUVQZXVhdm7H9OMP5jbN7D8ro= +gopkg.i-core.ru/logutil v1.0.0 h1:KsUIPn1D2UktdMgkiWzXeA2QqzTJIPAgdApJxQSeiOM= +gopkg.i-core.ru/logutil v1.0.0/go.mod h1:FD71nyLCA6P3gkV1WVyvfEtKtS3M+HQXpuUtQT11rrw= gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225 h1:JBwmEvLfCqgPcIq8MjVMQxsF3LVL4XG/HH0qiG0+IFY= gopkg.in/asn1-ber.v1 v1.0.0-20170511165959-379148ca0225/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/ldap.v2 v2.5.1 h1:wiu0okdNfjlBzg6UWvd1Hn8Y+Ux17/u/4nlk4CQr6tU= diff --git a/internal/oauth2/hydra/consent.go b/internal/hydra/consent.go similarity index 67% rename from internal/oauth2/hydra/consent.go rename to internal/hydra/consent.go index f2c54e2..846dcdf 100644 --- a/internal/oauth2/hydra/consent.go +++ b/internal/hydra/consent.go @@ -3,35 +3,33 @@ 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 , July 2018 */ package hydra import ( "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. type ConsentReqDoer struct { - hydraURL string + hydraURL string + rememberFor int } -// NewConsentRequest creates a ConsentRequest. -func NewConsentReqDoer(hydraURL string) *ConsentReqDoer { - return &ConsentReqDoer{hydraURL: hydraURL} +// NewConsentReqDoer creates a ConsentRequest. +func NewConsentReqDoer(hydraURL string, rememberFor int) *ConsentReqDoer { + return &ConsentReqDoer{hydraURL: hydraURL, rememberFor: rememberFor} } // 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) return ri, errors.Wrap(err, "failed to initiate consent request") } -// Accept 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) { +// AcceptConsentRequest accepts the requested authentication process, and returns redirect URI. +func (crd *ConsentReqDoer) AcceptConsentRequest(challenge string, remember bool, grantScope []string, idToken interface{}) (string, error) { type session struct { IDToken interface{} `json:"id_token,omitempty"` } @@ -43,7 +41,7 @@ func (crd *ConsentReqDoer) AcceptConsentRequest(challenge string, remember bool, }{ GrantScope: grantScope, Remember: remember, - RememberFor: rememberFor, + RememberFor: crd.rememberFor, Session: session{ IDToken: idToken, }, diff --git a/internal/oauth2/hydra/hydra.go b/internal/hydra/hydra.go similarity index 56% rename from internal/oauth2/hydra/hydra.go rename to internal/hydra/hydra.go index 1e29146..bbf7335 100644 --- a/internal/oauth2/hydra/hydra.go +++ b/internal/hydra/hydra.go @@ -3,8 +3,6 @@ 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 , July 2018 */ package hydra @@ -12,12 +10,20 @@ package hydra import ( "bytes" "encoding/json" + "errors" "fmt" "io/ioutil" "net/http" "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 @@ -25,10 +31,19 @@ type reqType string const ( login reqType = "login" consent reqType = "consent" + logout reqType = "logout" ) -func initiateRequest(typ reqType, hydraURL, challenge string) (*oauth2.ReqInfo, error) { - ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%s/%s", string(typ), challenge)) +// 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"` +} + +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 { return nil, err } @@ -49,43 +64,15 @@ func initiateRequest(typ reqType, hydraURL, challenge string) (*oauth2.ReqInfo, if err != nil { return nil, err } - var ri oauth2.ReqInfo + var ri ReqInfo if err := json.Unmarshal(data, &ri); err != nil { return nil, err } 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) { - 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 { return "", err } @@ -95,9 +82,11 @@ func acceptRequest(typ reqType, hydraURL, challenge string, data interface{}) (s } u = u.ResolveReference(ref) - body, err := json.Marshal(data) - if err != nil { - return "", err + var body []byte + if data != nil { + if body, err = json.Marshal(data); err != nil { + return "", err + } } r, err := http.NewRequest(http.MethodPut, u.String(), bytes.NewBuffer(body)) @@ -113,10 +102,9 @@ func acceptRequest(typ reqType, hydraURL, challenge string, data interface{}) (s if err := checkResponse(resp); err != nil { return "", err } - type result struct { + var rs struct { RedirectTo string `json:"redirect_to"` } - var rs result dec := json.NewDecoder(resp.Body) if err := dec.Decode(&rs); err != nil { return "", err @@ -124,6 +112,33 @@ func acceptRequest(typ reqType, hydraURL, challenge string, data interface{}) (s 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) { if len(s) > 0 && s[len(s)-1] != '/' { s += "/" diff --git a/internal/oauth2/hydra/login.go b/internal/hydra/login.go similarity index 64% rename from internal/oauth2/hydra/login.go rename to internal/hydra/login.go index fc93198..6571260 100644 --- a/internal/oauth2/hydra/login.go +++ b/internal/hydra/login.go @@ -3,42 +3,40 @@ 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 , July 2018 */ package hydra import ( "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. type LoginReqDoer struct { - hydraURL string + hydraURL string + rememberFor int } -// NewLoginRequest creates a LoginRequest. -func NewLoginReqDoer(hydraURL string) *LoginReqDoer { - return &LoginReqDoer{hydraURL: hydraURL} +// NewLoginReqDoer creates a LoginRequest. +func NewLoginReqDoer(hydraURL string, rememberFor int) *LoginReqDoer { + return &LoginReqDoer{hydraURL: hydraURL, rememberFor: rememberFor} } // 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) return ri, errors.Wrap(err, "failed to initiate login request") } -// Accept accepts the requested authentication process, and returns redirect URI. -func (lrd *LoginReqDoer) AcceptLoginRequest(challenge string, remember bool, rememberFor int, subject string) (string, error) { +// AcceptLoginRequest accepts the requested authentication process, and returns redirect URI. +func (lrd *LoginReqDoer) AcceptLoginRequest(challenge string, remember bool, subject string) (string, error) { data := struct { Remember bool `json:"remember"` RememberFor int `json:"remember_for"` Subject string `json:"subject"` }{ Remember: remember, - RememberFor: rememberFor, + RememberFor: lrd.rememberFor, Subject: subject, } redirectURI, err := acceptRequest(login, lrd.hydraURL, challenge, data) diff --git a/internal/hydra/logout.go b/internal/hydra/logout.go new file mode 100644 index 0000000..d5135aa --- /dev/null +++ b/internal/hydra/logout.go @@ -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") +} diff --git a/internal/identp/identp.go b/internal/identp/identp.go new file mode 100644 index 0000000..15bfd5d --- /dev/null +++ b/internal/identp/identp.go @@ -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) + } +} diff --git a/internal/identp/identp_test.go b/internal/identp/identp_test.go new file mode 100644 index 0000000..3d990e6 --- /dev/null +++ b/internal/identp/identp_test.go @@ -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) +} diff --git a/internal/ldapclient/ldapclient.go b/internal/ldapclient/ldapclient.go index 57e1cff..a9ff2ca 100644 --- a/internal/ldapclient/ldapclient.go +++ b/internal/ldapclient/ldapclient.go @@ -3,8 +3,6 @@ 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 , July 2018 */ package ldapclient @@ -20,21 +18,23 @@ import ( "github.com/coocood/freecache" "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" ) // Config is a LDAP configuration. type Config struct { - Endpoints []string // are LDAP servers - BaseDN string // is a base DN for searching users - BindDN, BindPass string // is needed for authentication - RoleBaseDN string // is a base DN for searching roles - RoleAttr string // is LDAP attribute's name for a role's name - RoleClaim string // is custom OIDC claim name for roles' list - AttrClaims map[string]string // maps a LDAP attribute's name onto an OIDC claim - CacheSize int // is a size of claims' cache in KiB - CacheTTL time.Duration // is a TTL of claims' cache + Endpoints []string `envconfig:"endpoints" required:"true" desc:"a LDAP's server URLs as \"
:\""` + BaseDN string `envconfig:"basedn" required:"true" desc:"a LDAP base DN for searching users"` + BindDN string `envconfig:"binddn" desc:"a LDAP bind DN"` + BindPass string `envconfig:"bindpw" json:"-" desc:"a LDAP bind password"` + RoleBaseDN string `envconfig:"role_basedn" required:"true" desc:"a LDAP base DN for searching roles"` + RoleAttr string `envconfig:"role_attr" default:"description" desc:"a LDAP attribute for role's name"` + RoleClaim string `ignored:"true"` // is custom OIDC claim name for roles' list + AttrClaims map[string]string `envconfig:"attr_claims" default:"name:name,sn:family_name,givenName:given_name,mail:email" desc:"a mapping of LDAP attributes to OIDC claims"` + 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). @@ -45,6 +45,9 @@ type Client struct { // New creates a new LDAP client. func New(cnf Config) *Client { + if cnf.RoleClaim == "" { + cnf.RoleClaim = "http://i-core.ru/claims/roles" + } return &Client{ Config: cnf, cache: freecache.NewCache(cnf.CacheSize * 1024), @@ -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. 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") } @@ -103,13 +106,12 @@ func (cli *Client) dialTCP(ctx context.Context) <-chan *ldap.Conn { go func(addr string) { defer wg.Done() - log := logger.FromContext(ctx) - log = log.With("address", addr) + log := logutil.FromContext(ctx).Sugar() d := net.Dialer{Timeout: ldap.DefaultTimeout} tcpcn, err := d.DialContext(ctx, "tcp", addr) if err != nil { - log.Debugw("Failed to create a LDAP connection") + log.Debug("Failed to create a LDAP connection", "address", addr) return } ldapcn := ldap.NewConn(tcpcn, false) @@ -117,7 +119,7 @@ func (cli *Client) dialTCP(ctx context.Context) <-chan *ldap.Conn { select { case <-ctx.Done(): ldapcn.Close() - log.Debugw("a LDAP connection is cancelled") + log.Debug("a LDAP connection is cancelled", "address", addr) return 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. 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. switch cdata, err := cli.cache.Get([]byte(username)); err { case nil: var claims map[string]interface{} 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 } 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: log.Debug("User's OIDC claims is not found in the cache") 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. @@ -260,10 +262,10 @@ func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[str // Save the claims in the cache for future queries. cdata, err := json.Marshal(claims) 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 { - 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 diff --git a/internal/logger/logger.go b/internal/logger/logger.go deleted file mode 100644 index cbe6934..0000000 --- a/internal/logger/logger.go +++ /dev/null @@ -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 , 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) -} diff --git a/internal/oauth2/oauth2.go b/internal/oauth2/oauth2.go deleted file mode 100644 index 24cb88e..0000000 --- a/internal/oauth2/oauth2.go +++ /dev/null @@ -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 , 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"` -} diff --git a/internal/server/mw.go b/internal/server/mw.go deleted file mode 100644 index c151960..0000000 --- a/internal/server/mw.go +++ /dev/null @@ -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 , 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)) - }) - } -} diff --git a/internal/server/mw_test.go b/internal/server/mw_test.go deleted file mode 100644 index 90c3bad..0000000 --- a/internal/server/mw_test.go +++ /dev/null @@ -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 , 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) - } -} diff --git a/internal/server/server.go b/internal/server/server.go deleted file mode 100644 index 1000bbb..0000000 --- a/internal/server/server.go +++ /dev/null @@ -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 , 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 (:)"` - LDAPEndpoints []string `envconfig:"ldap_endpoints" required:"true" desc:"a LDAP's server URLs as \"
:\""` - 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 - } - } -} diff --git a/internal/server/server_test.go b/internal/server/server_test.go deleted file mode 100644 index ff9b1bf..0000000 --- a/internal/server/server_test.go +++ /dev/null @@ -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 , 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) -} diff --git a/internal/server/web.go b/internal/server/web.go deleted file mode 100644 index a81ff04..0000000 --- a/internal/server/web.go +++ /dev/null @@ -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 , 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" }} - - - - - {{ block "title" . }}{{ end }} - - {{ block "style". }}{{ end }} - - - {{ block "content" . }}

NO CONTENT

{{ end }} - {{ block "js" . }}{{ end }} - - -{{ end }} -` diff --git a/internal/stat/stat.go b/internal/stat/stat.go new file mode 100644 index 0000000..14f7aa4 --- /dev/null +++ b/internal/stat/stat.go @@ -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 + } + } +} diff --git a/internal/server/templates.go b/internal/web/templates.go similarity index 98% rename from internal/server/templates.go rename to internal/web/templates.go index e05185e..f3a9d27 100644 --- a/internal/server/templates.go +++ b/internal/web/templates.go @@ -4,7 +4,7 @@ // templates/static/script.js (1.24kB) // templates/static/style.css (4.316kB) -package server +package web import ( "bytes" @@ -86,7 +86,7 @@ func loginTmpl() (*asset, error) { 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}} return a, nil } @@ -106,7 +106,7 @@ func staticScriptJs() (*asset, error) { 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}} return a, nil } @@ -126,7 +126,7 @@ func staticStyleCss() (*asset, error) { 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}} return a, nil } diff --git a/internal/server/templates/login.tmpl b/internal/web/templates/login.tmpl similarity index 100% rename from internal/server/templates/login.tmpl rename to internal/web/templates/login.tmpl diff --git a/internal/server/templates/static/script.js b/internal/web/templates/static/script.js similarity index 100% rename from internal/server/templates/static/script.js rename to internal/web/templates/static/script.js diff --git a/internal/server/templates/static/style.css b/internal/web/templates/static/style.css similarity index 100% rename from internal/server/templates/static/style.css rename to internal/web/templates/static/style.css diff --git a/internal/web/testdata/TestHTMLRenderer/external_template_happy_path/golden.file b/internal/web/testdata/TestHTMLRenderer/external_template_happy_path/golden.file new file mode 100644 index 0000000..9919b54 --- /dev/null +++ b/internal/web/testdata/TestHTMLRenderer/external_template_happy_path/golden.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; \ No newline at end of file diff --git a/internal/web/testdata/TestHTMLRenderer/external_template_happy_path/login.tmpl b/internal/web/testdata/TestHTMLRenderer/external_template_happy_path/login.tmpl new file mode 100644 index 0000000..209aa2a --- /dev/null +++ b/internal/web/testdata/TestHTMLRenderer/external_template_happy_path/login.tmpl @@ -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 }} \ No newline at end of file diff --git a/internal/web/testdata/TestHTMLRenderer/external_template_happy_path/main.tmpl b/internal/web/testdata/TestHTMLRenderer/external_template_happy_path/main.tmpl new file mode 100644 index 0000000..168ea4f --- /dev/null +++ b/internal/web/testdata/TestHTMLRenderer/external_template_happy_path/main.tmpl @@ -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 }} \ No newline at end of file diff --git a/internal/web/testdata/TestHTMLRenderer/external_template_not_found/main.tmpl b/internal/web/testdata/TestHTMLRenderer/external_template_not_found/main.tmpl new file mode 100644 index 0000000..9167e1e --- /dev/null +++ b/internal/web/testdata/TestHTMLRenderer/external_template_not_found/main.tmpl @@ -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 }} \ No newline at end of file diff --git a/internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/golden.file b/internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/golden.file new file mode 100644 index 0000000..579707a --- /dev/null +++ b/internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/golden.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; \ No newline at end of file diff --git a/internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/login.tmpl b/internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/login.tmpl new file mode 100644 index 0000000..209aa2a --- /dev/null +++ b/internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/login.tmpl @@ -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 }} \ No newline at end of file diff --git a/internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/main.tmpl b/internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/main.tmpl new file mode 100644 index 0000000..41489b0 --- /dev/null +++ b/internal/web/testdata/TestHTMLRenderer/internal_template_happy_path/main.tmpl @@ -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 }} \ No newline at end of file diff --git a/internal/web/testdata/TestHTMLRenderer/internal_template_not_found/main.tmpl b/internal/web/testdata/TestHTMLRenderer/internal_template_not_found/main.tmpl new file mode 100644 index 0000000..bd3e9db --- /dev/null +++ b/internal/web/testdata/TestHTMLRenderer/internal_template_not_found/main.tmpl @@ -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 }} \ No newline at end of file diff --git a/internal/web/testdata/TestStaticHandler/external_resource_happy_path/static/test.file b/internal/web/testdata/TestStaticHandler/external_resource_happy_path/static/test.file new file mode 100644 index 0000000..30d74d2 --- /dev/null +++ b/internal/web/testdata/TestStaticHandler/external_resource_happy_path/static/test.file @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/internal/web/testdata/TestStaticHandler/external_resource_not_found/static/stub.file b/internal/web/testdata/TestStaticHandler/external_resource_not_found/static/stub.file new file mode 100644 index 0000000..c6695cd --- /dev/null +++ b/internal/web/testdata/TestStaticHandler/external_resource_not_found/static/stub.file @@ -0,0 +1 @@ +The file is needed to commit the parent directory to Git. \ No newline at end of file diff --git a/internal/web/testdata/TestStaticHandler/internal_resource_happy_path/static/test.file b/internal/web/testdata/TestStaticHandler/internal_resource_happy_path/static/test.file new file mode 100644 index 0000000..30d74d2 --- /dev/null +++ b/internal/web/testdata/TestStaticHandler/internal_resource_happy_path/static/test.file @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/internal/web/testdata/TestStaticHandler/internal_resource_not_found/static/stub.file b/internal/web/testdata/TestStaticHandler/internal_resource_not_found/static/stub.file new file mode 100644 index 0000000..c6695cd --- /dev/null +++ b/internal/web/testdata/TestStaticHandler/internal_resource_not_found/static/stub.file @@ -0,0 +1 @@ +The file is needed to commit the parent directory to Git. \ No newline at end of file diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..4f7d914 --- /dev/null +++ b/internal/web/web.go @@ -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" }} + + + + + {{ block "title" .Data }}{{ end }} + + {{ block "style" .Data }}{{ end }} + + + {{ block "content" .Data }}

NO CONTENT

{{ end }} + {{ block "js" .Data }}{{ end }} + + +{{ 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) + })) +} diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..71fd52f --- /dev/null +++ b/internal/web/web_test.go @@ -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) + } + } + }) + } +}