From 6658817311ce702379028d8309c261db84814923 Mon Sep 17 00:00:00 2001 From: Konstantin Lepa Date: Mon, 18 Feb 2019 16:57:54 +0300 Subject: [PATCH] initial commit --- .gitignore | 0 .golangci.yml | 29 ++ CHANGELOG.md | 14 + Dockerfile | 29 ++ README.md | 131 ++++++ ci-testing.yaml | 4 + cmd/werther/main.go | 65 +++ cmd/werther/tools.go | 14 + go.mod | 32 ++ go.sum | 56 +++ internal/ldapclient/ldapclient.go | 291 ++++++++++++ internal/logger/logger.go | 35 ++ internal/oauth2/hydra/consent.go | 53 +++ internal/oauth2/hydra/hydra.go | 136 ++++++ internal/oauth2/hydra/login.go | 46 ++ 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/templates.go | 321 +++++++++++++ internal/server/templates/login.tmpl | 45 ++ internal/server/templates/static/script.js | 34 ++ internal/server/templates/static/style.css | 266 +++++++++++ internal/server/web.go | 127 ++++++ 25 files changed, 2742 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 ci-testing.yaml create mode 100644 cmd/werther/main.go create mode 100644 cmd/werther/tools.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/ldapclient/ldapclient.go create mode 100644 internal/logger/logger.go create mode 100644 internal/oauth2/hydra/consent.go create mode 100644 internal/oauth2/hydra/hydra.go create mode 100644 internal/oauth2/hydra/login.go create mode 100644 internal/oauth2/oauth2.go create mode 100644 internal/server/mw.go create mode 100644 internal/server/mw_test.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/server/templates.go create mode 100644 internal/server/templates/login.tmpl create mode 100644 internal/server/templates/static/script.js create mode 100644 internal/server/templates/static/style.css create mode 100644 internal/server/web.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..ced4530 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,29 @@ +# 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 , September 2018 + +run: + test: true + silent: true + +linters-settings: + govet: + check-shadowing: true + +linters: + disable-all: true + enable: + - deadcode + - gofmt + - goimports + - golint + - varcheck + - structcheck + - megacheck + - ineffassign + - interfacer + - unconvert + - govet diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..51f741c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2019-02-18 +### Added +- Add unit tests for server's logic. + +### Changed +- The url /auth/login accepts the POST parameter login_challenge instead of challenge. +- The OIDC claim roles is enabled in the scope http://i-core.ru/claims/roles only. +- Use go.uber.org/zap without any facade. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..37321b7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# 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 + +FROM golang:1.11-alpine AS build + +ARG VERSION +ARG GOPROXY + +WORKDIR /opt/build + +RUN adduser -D -g '' appuser +RUN apk --update add ca-certificates +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}" ./... + +FROM scratch AS final +COPY --from=build /etc/passwd /etc/passwd +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=build /go/bin/werther /werther + +USER appuser +ENTRYPOINT ["/werther"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d3988a3 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +[![GoDoc](https://godoc.das.i-core.ru/gopkg.i-core.ru/werther?status.svg)](https://godoc.das.i-core.ru/gopkg.i-core.ru/werther) + +# Werther + +Werther is a login provider for ORY Hydra that is an OAuth2 provider. + +## Build +``` +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. + +1. Create a network: + ``` + docker network create hydra-net + ``` + +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 + + ``` + + You can learn additional properties with help command: + ``` + docker run -it --rm oryd/hydra:$HYDRA_VERSION 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 + ``` + +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" \ + -e WERTHER_LDAP_ROLE_BASEDN="OU=AppRoles,OU=Domain Groups,DC=icore,DC=local" \ + hub.das.i-core.ru/p/base-werther + ``` + + For all options see option help: + ``` + docker run -it --rm hub.das.i-core.ru/p/base-werther -help + ``` + +5. Start an authentication process in a browser: + ``` + open http://$MY_HOST:4444/oauth2/auth?client_id=test-client&response_type=token&scope=openid%20profile%20email&state=12345678 + ``` + +6. Get user info: + ``` + http get "http://$MY_HOST:4444/userinfo" "Authorization: Bearer " + ``` + + For example, you can get the next output: + ``` + HTTP/1.1 200 OK + Content-Length: 218 + Content-Type: application/json + Date: Tue, 31 Jul 2018 17:17:51 GMT + Vary: Origin + + { + "email": "klepa@i-core.ru", + "family_name": "Lepa", + "given_name": "Konstantin", + "http://i-core.ru/claims/roles": { + "HeraldTest1": [ + "user" + ] + }, + "name": "Konstantin Lepa", + "sub": "CN=Konstantin Lepa,OU=Domain Users,DC=icore,DC=local" + } + ``` + + 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: + ``` + 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: + ``` + open "http://$MY_HOST:4444/oauth2/auth/sessions/login/revoke" + ``` + +9. (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 new file mode 100644 index 0000000..bb0f62c --- /dev/null +++ b/ci-testing.yaml @@ -0,0 +1,4 @@ +- image: golang:1.11-alpine + shell: go test -v ./... +- image: golangci/golangci-lint + shell: golangci-lint -v run diff --git a/cmd/werther/main.go b/cmd/werther/main.go new file mode 100644 index 0000000..26821a2 --- /dev/null +++ b/cmd/werther/main.go @@ -0,0 +1,65 @@ +/* +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" + +import ( + "flag" + "fmt" + "net/http" + "os" + + "github.com/kelseyhightower/envconfig" + "go.uber.org/zap" + "gopkg.i-core.ru/werther/internal/server" +) + +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 { + panic(err) + } + } + verflag := flag.Bool("version", false, "print a version") + flag.Parse() + + if *verflag { + fmt.Println("werther", server.Version) + os.Exit(0) + } + + var cnf server.Config + if err := envconfig.Process("werther", &cnf); err != nil { + fmt.Fprintf(os.Stderr, "Invalid configuration: %s\n", err) + os.Exit(1) + } + + logFunc := zap.NewProduction + if cnf.DevMode { + logFunc = zap.NewDevelopment + } + log, err := logFunc() + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to create logger: %s\n", err) + os.Exit(1) + } + + srv, err := server.New(cnf, log) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to start the server: %s\n", err) + os.Exit(1) + } + + 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))) +} diff --git a/cmd/werther/tools.go b/cmd/werther/tools.go new file mode 100644 index 0000000..e713bbb --- /dev/null +++ b/cmd/werther/tools.go @@ -0,0 +1,14 @@ +// +build tools + +/* +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 + +import _ "github.com/kevinburke/go-bindata" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..332d28b --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +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/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/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/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.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 new file mode 100644 index 0000000..005fbfd --- /dev/null +++ b/go.sum @@ -0,0 +1,56 @@ +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/cespare/xxhash v1.0.0 h1:naDmySfoNg0nKS62/ujM6e71ZgM2AoVdaqGwMG0w18A= +github.com/cespare/xxhash v1.0.0/go.mod h1:fX/lfQBkSCDXZSUgv6jVIu/EVA3/JNseAX5asI4c4T4= +github.com/coocood/freecache v1.0.1 h1:oFyo4msX2c0QIKU+kuMJUwsKamJ+AKc2JJrKcMszJ5M= +github.com/coocood/freecache v1.0.1/go.mod h1:ePwxCDzOYvARfHdr1pByNct1at3CoKnsipOHwKlNbzI= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/go-bindata-assetfs v1.0.0 h1:G/bYguwHIzWq9ZoyUQqrjTmJbbYn3j3CKKpKinvZLFk= +github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= +github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE= +github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/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= +github.com/justinas/alice v0.0.0-20171023064455-03f45bd4b7da/go.mod h1:oLH0CmIaxCGXD67VKGR5AacGXZSMznlmeqM8RzPrcY8= +github.com/justinas/nosurf v0.0.0-20171023064657-7182011986c4 h1:zL6nij8mNcIiohuVdHfNDSVZOlVTSsUZXkb5v1Kudqk= +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/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/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72 h1:qLC7fQah7D6K1B0ujays3HV9gkFtllcxhzImRR7ArPQ= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +go.uber.org/atomic v1.2.0 h1:yVVGhClJ8Xi1y4TxhJZE6QFPrz76BrzhWA01n47mSFk= +go.uber.org/atomic v1.2.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +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.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= +gopkg.in/ldap.v2 v2.5.1/go.mod h1:oI0cpe/D7HRtBQl8aTg+ZmzFUAvu4lsv3eLXMLGFxWk= diff --git a/internal/ldapclient/ldapclient.go b/internal/ldapclient/ldapclient.go new file mode 100644 index 0000000..57e1cff --- /dev/null +++ b/internal/ldapclient/ldapclient.go @@ -0,0 +1,291 @@ +/* +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 + +import ( + "context" + "encoding/json" + "fmt" + "net" + "strings" + "sync" + "time" + + "github.com/coocood/freecache" + "github.com/pkg/errors" + "gopkg.i-core.ru/werther/internal/logger" + 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 +} + +// Client is a LDAP client (compatible with Active Directory). +type Client struct { + Config + cache *freecache.Cache +} + +// New creates a new LDAP client. +func New(cnf Config) *Client { + return &Client{ + Config: cnf, + cache: freecache.NewCache(cnf.CacheSize * 1024), + } +} + +// Authenticate authenticates a user with a username and password. +// If no username or password in LDAP it returns false and no error. +func (cli *Client) Authenticate(ctx context.Context, username, password string) (bool, error) { + if username == "" || password == "" { + return false, nil + } + + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + + cn, ok := <-cli.dialTCP(ctx) + cancel() + if !ok { + return false, errors.New("connection timeout") + } + defer cn.Close() + + // Find a user DN by his or her username. + details, err := cli.findBasicUserDetails(cn, username, []string{"dn"}) + if err != nil { + return false, err + } + if details == nil { + return false, nil + } + + if err := cn.Bind(details["dn"].(string), password); err != nil { + if ldapErr, ok := err.(*ldap.Error); ok && ldapErr.ResultCode == ldap.LDAPResultInvalidCredentials { + return false, nil + } + return false, err + } + + // 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.Debug("Cleared user's OIDC claims in the cache") + } + + return true, nil +} + +func (cli *Client) dialTCP(ctx context.Context) <-chan *ldap.Conn { + var ( + wg sync.WaitGroup + ch = make(chan *ldap.Conn) + ) + wg.Add(len(cli.Endpoints)) + for _, addr := range cli.Endpoints { + go func(addr string) { + defer wg.Done() + + log := logger.FromContext(ctx) + log = log.With("address", addr) + + d := net.Dialer{Timeout: ldap.DefaultTimeout} + tcpcn, err := d.DialContext(ctx, "tcp", addr) + if err != nil { + log.Debugw("Failed to create a LDAP connection") + return + } + ldapcn := ldap.NewConn(tcpcn, false) + ldapcn.Start() + select { + case <-ctx.Done(): + ldapcn.Close() + log.Debugw("a LDAP connection is cancelled") + return + case ch <- ldapcn: + } + }(addr) + } + go func() { + wg.Wait() + close(ch) + }() + return ch +} + +// findBasicUserDetails finds user's LDAP attributes that were specified. It returns nil if no such user. +func (cli *Client) findBasicUserDetails(cn *ldap.Conn, username string, attrs []string) (map[string]interface{}, error) { + if cli.BindDN != "" { + // We need to login to a LDAP server with a service account for retrieving user data. + if err := cn.Bind(cli.BindDN, cli.BindPass); err != nil { + return nil, err + } + } + + query := fmt.Sprintf( + "(&(|(objectClass=organizationalPerson)(objectClass=inetOrgPerson))"+ + "(|(uid=%[1]s)(mail=%[1]s)(userPrincipalName=%[1]s)(sAMAccountName=%[1]s)))", username) + entries, err := cli.searchEntries(cn, cli.BaseDN, query, attrs...) + if err != nil { + return nil, err + } + if len(entries) != 1 { + // We didn't find the user. + return nil, nil + } + + var ( + entry = entries[0] + details = make(map[string]interface{}) + ) + for _, attr := range attrs { + if v, ok := entry[attr]; ok { + details[attr] = v + } + } + return details, nil +} + +// FindOIDCClaims finds all OIDC claims for a user. +func (cli *Client) FindOIDCClaims(ctx context.Context, username string) (map[string]interface{}, error) { + log := logger.FromContext(ctx) + + // 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) + return nil, err + } + log.Debug("Retrieved user's OIDC claims from the cache", "claims", claims) + return claims, nil + 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) + } + + // Try to make multiple TCP connections to the LDAP server for getting claims. + // Accept the first one, and cancel others. + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + + cn, ok := <-cli.dialTCP(ctx) + cancel() + if !ok { + return nil, errors.New("connection timeout") + } + defer cn.Close() + + // We need to find LDAP attribute's names for all required claims. + attrs := []string{"dn"} + for k := range cli.AttrClaims { + attrs = append(attrs, k) + } + // Find the attributes in the LDAP server. + details, err := cli.findBasicUserDetails(cn, username, attrs) + if err != nil { + return nil, err + } + if details == nil { + return nil, errors.New("unknown username") + } + log.Infow("Retrieved user's info from LDAP", "details", details) + + // Transform the retrived attributes to corresponding claims. + claims := make(map[string]interface{}) + for attr, v := range details { + if claim, ok := cli.AttrClaims[attr]; ok { + claims[claim] = v + } + } + + // User's roles is stored in LDAP as groups. We find all groups in a role's DN + // that include the user as a member. + query := fmt.Sprintf("(&(objectClass=group)(member=%s))", details["dn"]) + entries, err := cli.searchEntries(cn, cli.RoleBaseDN, query, "dn", cli.RoleAttr) + if err != nil { + return nil, err + } + + roles := make(map[string][]string) + for _, entry := range entries { + roleDN := entry["dn"].(string) + if roleDN == "" { + log.Infow("No required LDAP attribute for a role", "ldapAttribute", "dn", "entry", entry) + continue + } + if entry[cli.RoleAttr] == nil { + log.Infow("No required LDAP attribute for a role", "ldapAttribute", cli.RoleAttr, "roleDN", roleDN) + continue + } + + // Ensure that a role's DN is inside of the role's base DN. + // It's sufficient to compare the DN's suffix with the base DN. + n, k := len(roleDN), len(cli.RoleBaseDN) + if n < k || !strings.EqualFold(roleDN[n-k:], cli.RoleBaseDN) { + panic("You should never see that") + } + // The DN without the role's base DN must contain a CN and OU + // where the CN is for uniqueness only, and the OU is an application id. + v := strings.Split(roleDN[:n-k-1], ",") + if len(v) != 2 { + log.Infow("A role's DN without the role's base DN must contain two nodes only", + "roleBaseDN", cli.RoleBaseDN, "roleDN", roleDN) + continue + } + appID := v[1][len("OU="):] + roles[appID] = append(roles[appID], entry[cli.RoleAttr].(string)) + } + claims[cli.RoleClaim] = roles + + // 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) + } + 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) + } + + return claims, nil +} + +// searchEntries executes a LDAP query, and returns a result as entries where each entry is mapping of LDAP attributes. +func (cli *Client) searchEntries(cn *ldap.Conn, baseDN, query string, attrs ...string) ([]map[string]interface{}, error) { + res, err := cn.Search(ldap.NewSearchRequest( + baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, query, attrs, nil, + )) + if err != nil { + return nil, err + } + + var entries []map[string]interface{} + for _, v := range res.Entries { + entry := map[string]interface{}{"dn": v.DN} + for _, attr := range v.Attributes { + // We need the first value only for the named attribute. + entry[attr.Name] = attr.Values[0] + } + entries = append(entries, entry) + } + return entries, nil +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..cbe6934 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,35 @@ +/* +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/hydra/consent.go b/internal/oauth2/hydra/consent.go new file mode 100644 index 0000000..f2c54e2 --- /dev/null +++ b/internal/oauth2/hydra/consent.go @@ -0,0 +1,53 @@ +/* +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 +} + +// NewConsentRequest creates a ConsentRequest. +func NewConsentReqDoer(hydraURL string) *ConsentReqDoer { + return &ConsentReqDoer{hydraURL: hydraURL} +} + +// InitiateRequest fetches information on the OAuth2 request. +func (crd *ConsentReqDoer) InitiateRequest(challenge string) (*oauth2.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) { + type session struct { + IDToken interface{} `json:"id_token,omitempty"` + } + data := struct { + GrantScope []string `json:"grant_scope"` + Remember bool `json:"remember"` + RememberFor int `json:"remember_for"` + Session session `json:"session,omitempty"` + }{ + GrantScope: grantScope, + Remember: remember, + RememberFor: rememberFor, + Session: session{ + IDToken: idToken, + }, + } + redirectURI, err := acceptRequest(consent, crd.hydraURL, challenge, data) + return redirectURI, errors.Wrap(err, "failed to accept consent request") +} diff --git a/internal/oauth2/hydra/hydra.go b/internal/oauth2/hydra/hydra.go new file mode 100644 index 0000000..1e29146 --- /dev/null +++ b/internal/oauth2/hydra/hydra.go @@ -0,0 +1,136 @@ +/* +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 ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + + "gopkg.i-core.ru/werther/internal/oauth2" +) + +type reqType string + +const ( + login reqType = "login" + consent reqType = "consent" +) + +func initiateRequest(typ reqType, hydraURL, challenge string) (*oauth2.ReqInfo, error) { + ref, err := url.Parse(fmt.Sprintf("oauth2/auth/requests/%s/%s", string(typ), challenge)) + if err != nil { + return nil, err + } + u, err := parseURL(hydraURL) + if err != nil { + return nil, err + } + u = u.ResolveReference(ref) + + resp, err := http.Get(u.String()) + if err != nil { + return nil, err + } + if err = checkResponse(resp); err != nil { + return nil, err + } + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var ri oauth2.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)) + if err != nil { + return "", err + } + u, err := parseURL(hydraURL) + if err != nil { + return "", err + } + u = u.ResolveReference(ref) + + body, err := json.Marshal(data) + if err != nil { + return "", err + } + + r, err := http.NewRequest(http.MethodPut, u.String(), bytes.NewBuffer(body)) + if err != nil { + return "", err + } + r.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(r) + if err != nil { + return "", err + } + defer resp.Body.Close() + if err := checkResponse(resp); err != nil { + return "", err + } + type result struct { + RedirectTo string `json:"redirect_to"` + } + var rs result + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&rs); err != nil { + return "", err + } + return rs.RedirectTo, nil +} + +func parseURL(s string) (*url.URL, error) { + if len(s) > 0 && s[len(s)-1] != '/' { + s += "/" + } + u, err := url.Parse(s) + if err != nil { + return nil, err + } + return u, nil +} diff --git a/internal/oauth2/hydra/login.go b/internal/oauth2/hydra/login.go new file mode 100644 index 0000000..fc93198 --- /dev/null +++ b/internal/oauth2/hydra/login.go @@ -0,0 +1,46 @@ +/* +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 +} + +// NewLoginRequest creates a LoginRequest. +func NewLoginReqDoer(hydraURL string) *LoginReqDoer { + return &LoginReqDoer{hydraURL: hydraURL} +} + +// InitiateRequest fetches information on the OAuth2 request. +func (lrd *LoginReqDoer) InitiateRequest(challenge string) (*oauth2.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) { + data := struct { + Remember bool `json:"remember"` + RememberFor int `json:"remember_for"` + Subject string `json:"subject"` + }{ + Remember: remember, + RememberFor: rememberFor, + Subject: subject, + } + redirectURI, err := acceptRequest(login, lrd.hydraURL, challenge, data) + return redirectURI, errors.Wrap(err, "failed to accept login request") +} diff --git a/internal/oauth2/oauth2.go b/internal/oauth2/oauth2.go new file mode 100644 index 0000000..24cb88e --- /dev/null +++ b/internal/oauth2/oauth2.go @@ -0,0 +1,29 @@ +/* +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 new file mode 100644 index 0000000..c151960 --- /dev/null +++ b/internal/server/mw.go @@ -0,0 +1,48 @@ +/* +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 new file mode 100644 index 0000000..90c3bad --- /dev/null +++ b/internal/server/mw_test.go @@ -0,0 +1,32 @@ +/* +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 new file mode 100644 index 0000000..1000bbb --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,405 @@ +/* +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 new file mode 100644 index 0000000..ff9b1bf --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,500 @@ +/* +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/templates.go b/internal/server/templates.go new file mode 100644 index 0000000..e05185e --- /dev/null +++ b/internal/server/templates.go @@ -0,0 +1,321 @@ +// Code generated by go-bindata. DO NOT EDIT. +// sources: +// templates/login.tmpl (1.216kB) +// templates/static/script.js (1.24kB) +// templates/static/style.css (4.316kB) + +package server + +import ( + "bytes" + "compress/gzip" + "crypto/sha256" + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "time" +) + +func bindataRead(data []byte, name string) ([]byte, error) { + gz, err := gzip.NewReader(bytes.NewBuffer(data)) + if err != nil { + return nil, fmt.Errorf("read %q: %v", name, err) + } + + var buf bytes.Buffer + _, err = io.Copy(&buf, gz) + clErr := gz.Close() + + if err != nil { + return nil, fmt.Errorf("read %q: %v", name, err) + } + if clErr != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +type asset struct { + bytes []byte + info os.FileInfo + digest [sha256.Size]byte +} + +type bindataFileInfo struct { + name string + size int64 + mode os.FileMode + modTime time.Time +} + +func (fi bindataFileInfo) Name() string { + return fi.name +} +func (fi bindataFileInfo) Size() int64 { + return fi.size +} +func (fi bindataFileInfo) Mode() os.FileMode { + return fi.mode +} +func (fi bindataFileInfo) ModTime() time.Time { + return fi.modTime +} +func (fi bindataFileInfo) IsDir() bool { + return false +} +func (fi bindataFileInfo) Sys() interface{} { + return nil +} + +var _loginTmpl = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x84\x93\x4f\x6f\xdb\x3c\x0c\xc6\xcf\xce\xa7\x20\x74\x78\x6f\x8d\x3f\xc0\x6b\xe7\x52\x6c\x40\x81\x02\x2b\xda\x0e\x3b\x16\xb2\x44\xc7\x4a\x64\xc9\x20\x15\xaf\x81\xe1\xef\x3e\xc8\xb6\xf2\x67\x4d\xb0\x93\xa5\x87\x0f\x7f\x22\x25\x7a\x18\x40\x63\x6d\x1c\x82\x08\x26\x58\x14\x30\x8e\xab\xec\xd9\x6f\x8d\x83\x17\xf2\xbd\xd1\x48\xf0\x0b\x29\x34\x48\xab\x61\x00\x74\x3a\x3a\x56\x17\x79\x1c\x8e\x29\xaf\xb0\xc6\xed\x81\xd0\x96\xb3\xca\x0d\x62\x10\xd0\x10\xd6\x51\x91\xc1\xa8\x7c\x0a\xac\x15\xb3\xd8\xdc\x01\xee\x78\xa1\xb1\x22\xd3\x05\x08\xc7\x0e\x4b\x11\xf0\x33\xe4\x3b\xd9\xcb\x59\x15\xc0\xa4\xce\xd0\x49\x5b\xef\x58\x6c\x8a\x65\x73\x8f\xae\xbc\x0b\xe8\xc2\x72\x84\x36\x3d\x28\x2b\x99\x4b\x61\x63\xd3\x0f\x9d\xdc\xa2\xd8\xac\xb2\xab\x50\xed\xa9\x9d\xc4\xac\xe8\x92\xd6\x22\x73\xf2\x66\xc3\x00\xa6\x86\xf5\x13\x3f\xb9\x5e\x5a\xa3\x1f\x09\x35\xba\x60\xa4\xe5\xe9\x9c\x2c\xcb\x96\x08\x1c\x18\xc9\xc9\x16\xc1\x13\x74\x92\xf9\xb7\x27\xbd\x20\xd0\x32\x9e\x38\x21\xda\xec\x37\x22\x4f\x67\xc4\x2c\x02\x23\xf5\x48\x80\x31\x78\x99\xbb\xf8\xfe\x73\x15\x77\xff\xa7\xc0\x7c\x05\xb1\xf6\xbc\x9b\x7b\x88\xed\x5c\x77\x3d\x35\x08\x52\x05\xe3\x5d\x29\x86\x01\xd6\xd3\x08\xfc\x7c\x7d\x86\x71\x14\xd0\x62\x68\xbc\x2e\xc5\xcb\x8f\xb7\xf7\xb9\xe1\xac\x30\xae\x3b\xa4\xb7\x69\x8c\xd6\xe8\x04\xc4\xbe\x4a\xa1\x98\xea\x8f\xe0\xf7\x51\xe9\xa5\x3d\x60\x19\x81\x8f\x6f\xaf\xdf\xdf\xa3\x08\xe3\xf8\x4f\xc4\x54\xd5\x87\x6a\xa4\xb5\xe8\xb6\x78\xc5\x49\xe2\xc4\xf9\x0a\x8a\x73\x22\xa0\xb3\x52\x61\xe3\xad\x46\x2a\x45\xba\xf2\x44\x3f\xed\xf3\x1b\x85\xa4\x37\xf9\x8b\x71\x96\x67\xc6\x69\x9f\xa7\x22\x2e\xe6\x45\x35\xa8\xf6\x95\xff\x04\xc2\x16\xdb\x0a\xe9\x21\x4e\x9d\x34\x0e\x69\xb9\xbe\x9b\xf6\x07\xdf\x23\x59\x79\x4c\x9e\xeb\xc2\x92\x2b\x55\x90\xd8\x02\xf2\x93\xff\x16\xf4\xcb\xd1\xf7\x7c\x71\xd1\x4a\xda\xc7\x7f\x48\x9b\xfe\x04\xbd\xda\x58\x59\xa1\x85\xda\xd3\x45\x01\x9b\xd7\x65\x05\x2d\x16\xf9\xe4\x48\x5d\x9e\x73\x2f\x97\xd5\x21\x04\xef\x96\xbe\xf8\x50\xb5\x26\x88\xcd\xf4\xe6\x45\x3e\xc7\xe6\x41\xcd\xe3\x5c\x4e\x3f\xe3\x92\xbc\x7c\xcf\x63\xfd\x27\x00\x00\xff\xff\x7b\xe3\xda\xaf\xc0\x04\x00\x00") + +func loginTmplBytes() ([]byte, error) { + return bindataRead( + _loginTmpl, + "login.tmpl", + ) +} + +func loginTmpl() (*asset, error) { + bytes, err := loginTmplBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "login.tmpl", size: 1216, mode: os.FileMode(0644), modTime: time.Unix(1550576890, 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 +} + +var _staticScriptJs = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x9c\x52\xcb\x6e\xdb\x30\x10\xbc\xeb\x2b\x16\xba\x44\x0e\x54\xe6\x92\x53\x0d\xdd\x9a\xa2\x05\xd2\x53\xda\x53\xd1\x03\x23\x8e\x54\xa2\x7c\x38\x7c\xc8\x28\x6a\xff\x7b\x41\xdb\x7a\xf7\x11\x98\x07\x81\x10\x77\x66\x67\x67\xe7\xee\x96\x3a\xa9\xdf\x92\x47\x50\x84\x40\xc1\x57\xf7\xe4\xcf\xdf\x7d\x75\x4f\xb7\x77\xd9\x5e\x1a\x61\xf7\xcc\x1a\x65\xb9\xa0\x8a\x9a\x68\xea\x20\xad\x29\x36\xf4\x2b\x23\x22\xea\xb8\xa3\xe8\xe1\x1e\x14\x34\x55\x24\x6c\x1d\x35\x4c\x60\x2f\x11\xee\xe7\x13\x14\xea\x60\x5d\x91\x33\x65\x5b\x69\xde\x34\xd6\x69\x92\x66\x17\xc3\x57\xc3\x35\xaa\x9b\x04\x4d\xb7\x9b\x6f\xf9\xa6\x3c\x11\xa6\xb3\xe3\xde\x5f\x49\x98\xa0\x7b\xeb\xc4\x9c\xd0\x41\xe3\x4a\xc2\x04\xd5\xcf\x70\x73\xc2\x53\xf1\xfb\x54\xfb\x2a\xc6\x7c\xb3\xcd\x4e\xd8\xde\x2a\xd6\x71\x15\x41\x15\x79\x78\x2f\xad\x79\x0a\xd6\xf1\x16\xac\x45\xf8\x18\xa0\x8b\xd1\x98\xcd\x36\x9b\x4e\xc0\xea\xef\xa8\x7f\x40\xfc\x03\x3a\x28\xee\x9b\xca\x86\x8a\x65\xe3\x8a\x4c\x54\x8a\x0e\x87\x95\xa4\x8a\xf2\xbc\xdf\xee\x4c\x72\x63\xeb\xe8\x8b\x8b\x9e\x23\x41\x79\x4c\xca\xfa\x9d\x2d\xca\xce\x0a\x06\xbb\x18\x17\xe2\xa1\x83\x09\x8f\xd2\x07\x18\xb8\x22\xf7\xf1\x59\xcb\x90\x97\x63\xb6\x30\x6d\x9f\x02\xa6\x7d\xfb\x9f\xed\xed\x98\x86\xf7\xbc\xc5\xe0\xf4\x75\x83\xd3\xe1\x30\xa0\x67\x53\xad\xd0\xeb\x97\xb9\x6d\xe9\x5c\x74\x33\x69\x0c\xdc\x87\xcf\x9f\x1e\xa9\xa2\xfc\xcb\x65\xb3\xc4\x8d\xa0\x3e\xae\xc4\x1d\xc8\xe1\x25\x4a\x07\x91\x6f\x67\x2c\x60\x3b\x87\xe4\xd9\x3b\x34\x3c\xaa\xd0\x7b\x3b\x86\x3b\x44\x67\xc6\x7f\xc7\xe1\xb6\x48\x88\x5f\x85\xab\x5c\x98\x30\x61\x4e\xde\x2d\x43\xb7\x9c\xef\x6f\xfc\x43\x02\xcb\x55\x6e\x27\x1d\x56\x11\xfa\x03\xa5\x83\xb6\x1d\xd6\xb9\x1e\x67\x3d\xc7\xac\xa4\x86\x2b\x9f\xf4\x1f\xb7\xd9\xef\x00\x00\x00\xff\xff\xf0\x3d\xdb\x34\xd8\x04\x00\x00") + +func staticScriptJsBytes() ([]byte, error) { + return bindataRead( + _staticScriptJs, + "static/script.js", + ) +} + +func staticScriptJs() (*asset, error) { + bytes, err := staticScriptJsBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "static/script.js", size: 1240, mode: os.FileMode(0644), modTime: time.Unix(1545371222, 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 +} + +var _staticStyleCss = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xbc\x57\xfb\x6e\xa3\xb8\x17\xfe\x9f\xa7\x38\xea\x4f\x95\x9a\x9f\x02\x63\x20\xe9\x24\x44\xbb\x3b\xcf\xb0\x6f\x60\xc0\x80\x55\xb0\x91\xed\x4c\x49\x57\xdd\x67\x5f\xf9\xc2\xb5\x24\xcd\xac\xaa\x4d\x66\x2a\x6c\x8e\xcf\xe5\x3b\x9f\x3f\x3b\x3f\x68\xd3\x72\xa1\xe0\x2c\xea\xa7\x4a\xa9\x56\x26\xdf\xbe\x15\x9c\x29\x19\x94\x9c\x97\x35\xc1\x2d\x95\x41\xc6\x9b\x6f\x99\x94\x7f\x14\xb8\xa1\xf5\xe5\xb7\x3f\x79\xca\x15\x4f\x62\x84\x36\x27\xcf\xab\x54\x53\xc3\x5f\x1e\x40\xca\x3b\x5f\xd2\x37\xca\xca\x04\x52\x2e\x72\x22\xfc\x94\x77\x27\xef\xdd\xf3\xfe\xbf\xf5\x92\x04\x17\x8a\x08\xfd\x90\x92\x82\x0b\xf2\x61\x0d\x65\x15\x11\x54\x99\x05\x41\xcd\x4b\xca\xfc\x16\x97\xd6\xee\x95\xe6\xaa\x4a\x20\x7e\x46\x6d\x77\xf2\x00\x5a\x9c\xe7\x66\xd1\xe1\x11\x10\x20\x3d\xd5\x60\x51\x52\x96\x00\x3e\x2b\x6e\x7c\xfc\x68\x48\x4e\x31\x3c\x35\xb8\xf3\xdd\xfa\xdd\x01\xb5\xdd\xc6\x78\x5c\x46\x18\x62\x84\x08\x3d\x9e\xcc\xc4\x34\xc8\xfe\xd1\x46\x79\x37\xd9\x15\x5c\x34\x66\x55\xcb\x25\x55\x94\xb3\x04\x04\xa9\xb1\xa2\x3f\x89\x36\x7a\xf3\x29\xcb\x49\x97\x40\xa8\x47\x29\xce\x5e\x4a\xc1\xcf\x2c\x4f\xe0\x7f\x85\xf9\xd8\x7c\x87\xb4\x86\xb2\xfa\x1a\x90\xa9\x42\xa7\xb2\x28\x37\x0a\x0e\xe1\x9e\x34\x7a\x4e\x91\x4e\xf9\xb8\xa6\x25\x4b\x20\x23\x4c\x11\x71\xea\x01\xad\x70\xce\x5f\xb5\x17\x04\x11\x6a\x3b\x40\x20\xca\x14\x3f\xa1\x2d\xb8\x7f\x41\xb4\xd9\x02\x82\x7d\xdb\x99\xff\x2b\xef\x77\x9b\x7b\x30\x1c\x70\x18\x53\x0f\xf7\x8f\x36\x79\x74\x72\xf3\xc3\xca\x15\x64\x9d\xd1\x34\x6b\xc6\x19\x59\x20\x4d\x59\x7b\x56\x26\x8e\xa6\xa6\x6f\x69\x98\xc0\x83\x25\xe2\xc3\x16\x24\x66\xd2\x97\x44\x50\x83\x2c\x3f\xab\x9a\x32\xe2\xbc\xcf\xe1\x8f\xf4\xf7\xe4\x7d\xe8\xb6\x25\x6c\x32\xa7\x92\x06\x30\xdc\x2f\x5a\xd0\x4f\x5c\xa3\xbb\x4b\x52\xd2\x37\x92\x40\xb8\x6b\xbb\xbb\x81\x1c\xcb\x9c\xfb\x08\x5c\xc7\x27\x88\xa4\x67\xa5\x38\xbb\x1f\x12\x43\x16\x25\x30\x93\x7a\x79\x02\xe7\xb6\x25\x22\xc3\x92\x7c\x82\x57\x94\xe2\xe7\x2c\xfd\x0c\xaf\x11\x9b\x00\x1d\x6c\xae\x19\xaf\xb9\x98\xf1\xfd\x03\x2a\x00\xfe\x2b\x49\x5f\xa8\x4b\xcc\xed\x23\x5c\xd7\x80\x82\x18\x88\x4b\xee\xd6\xbb\xec\x2c\xa4\x8e\xd2\x72\x6a\xf9\x7f\x27\xd0\x13\xf4\xa6\x5c\x0c\x5c\xee\x0b\xf4\xe3\x2b\xe8\x27\x15\xff\x49\x84\x95\xb1\x19\x66\xdf\x8f\xfb\xf4\xfb\xe9\x83\x39\xce\xb4\x3e\xac\xd8\xc7\x87\x1d\x8e\x4f\x30\x2e\x08\x1a\x22\x65\x2f\x4c\x6b\x64\x1c\xd1\x7d\x3e\x3e\x87\x4b\x74\xf7\xbf\xc0\xb9\x59\x28\x80\x86\x32\xbf\x22\xb4\xac\x54\x02\xf1\x2a\x1a\x33\x2e\x66\x9c\x29\x4c\x99\x43\xe1\x1e\x31\x9c\xaa\x1e\x5a\x55\xbd\xd3\xdc\xb3\x3b\x2d\xb6\xd3\x29\x73\x92\x98\x90\x7a\x8e\x30\x95\xc0\xc3\x83\xf6\x94\x53\xd9\xd6\xf8\x92\x40\x5a\xf3\xec\xc5\x20\x55\x13\x2c\xf4\x06\x55\xd5\xc2\x31\x04\x94\x15\x7c\x06\xf1\x5e\x8b\xa5\xcd\x61\x5d\x5f\xd7\x1c\x54\xe1\xd5\x36\xcd\x65\x6e\x02\x63\xfc\xdc\x8e\x4a\xf1\xda\xe3\x8d\xd0\xb4\xb7\x21\xd6\xdf\xf5\x98\xb2\xc5\xcc\xd5\x6f\x8d\x77\xb9\xfe\x2e\x89\x10\x39\x22\xac\xaf\xc7\x33\x0f\xc8\x7c\x86\xc2\x73\x92\x71\x81\x6d\x37\xad\x22\x5f\xf5\x13\x14\x73\x4f\xa4\x88\xd3\xd8\x26\x9e\xf2\xfc\x72\xbf\x48\xf5\x72\x60\x4b\x68\x38\x57\x95\x41\x0f\x33\x45\x71\x4d\xb1\x24\xa6\x42\xbf\xe1\x6f\x3e\x97\xdd\x07\xbb\x52\xe0\x8b\xcc\x70\xed\x92\x15\xa4\x21\x4d\x4a\x84\xbf\x60\xa9\x6d\x8a\x9f\x72\xa5\x78\x93\x40\x64\x09\x3d\xc1\xed\xa8\x25\xce\x94\x5b\x91\xec\x25\xe5\x9d\x2d\x6f\x45\x6c\x46\x8b\x9c\xfe\x1c\x06\xbe\xd6\x85\x1a\x5f\x6e\x6c\x0a\x6d\x51\xd4\xfa\xb8\xab\x68\x9e\x13\xb6\xf0\x36\x9e\x04\xbc\xc5\x19\x55\x97\x5e\x68\x07\x67\x38\x95\xbc\x3e\xab\x75\x19\x04\xa8\x49\xa1\x96\x87\x99\xe9\x2d\x6f\xdd\xd3\x7c\x5b\x2e\xd4\xbd\x97\x00\x3b\x9e\xa5\x56\xe3\x94\xd8\xfb\x5e\x51\x73\xac\x12\x13\xca\x84\xa4\x8c\x0c\xda\x41\x19\xd5\x3d\x5b\x00\x8b\x82\xa3\x05\x7b\xa2\xb9\xe1\x8e\x34\x10\xea\xe9\xcf\x15\x6b\x25\x89\xa9\xff\x30\x88\xf6\xbd\x66\xf5\x11\x50\x10\x1d\x48\x33\x6a\xfb\xfb\x8d\xb6\xcd\x79\x32\x2b\xef\x9e\x45\x93\x39\xfd\xd0\x60\xf1\x72\x83\x01\xe3\x11\xe0\x0f\x1b\x87\x90\x49\x2f\x22\xa7\x8e\x3d\xa4\x76\x7c\x23\x91\x3e\x68\x32\xd1\xc7\x41\x10\xfb\x5b\xd5\x15\x06\xcd\x65\xb4\x3f\xdc\x25\xaf\x69\x0e\xaf\x15\xb5\x56\xb3\xe3\xda\xde\x23\x04\x57\x58\x91\xa7\xdd\x3e\x27\xe5\xc6\x6e\x4e\x79\xf3\xfd\xad\x77\x03\xeb\x22\x5b\xb9\x03\xc2\x69\xa5\xa5\xf4\x77\x3b\x30\x3c\x8e\xfb\x8b\x98\xb9\x7c\x39\x6b\x04\x51\xdb\xe9\x57\x9a\xe6\xbf\x40\xa9\x5f\x6c\xea\xf8\x93\xc4\xdd\x65\xc6\xf4\xfb\x99\x77\xef\x5f\x06\x98\xf4\x6f\xda\xfe\x60\x88\x34\xec\xd6\x20\xea\xa7\x2c\x3a\x61\x18\x1c\xfb\x19\x03\xd1\xb0\x64\x09\x52\xdc\x5f\xfd\x57\x36\x85\x51\x9f\xc4\x0c\x49\x0e\x7f\xc3\x5a\xda\xbf\x5f\x45\x66\x85\xd8\xe3\x2d\x72\x72\x6d\xfc\x9a\x90\x6b\x5c\x1f\xc4\xe7\x7a\x08\x77\x77\xfb\x2f\x6a\xf3\x60\x2d\xb4\x07\xeb\xc1\x3d\xf8\xb4\x4e\xd7\x4a\x97\x80\xdb\x9d\x2b\xa5\x7e\x59\x89\x79\x9e\x7f\x4d\x80\x9b\xcd\x5a\x16\x66\x7e\x92\x86\x47\xb4\x85\xc9\x9f\xcd\xc4\xce\x9e\x65\x33\x0d\x70\xc7\x9e\x13\xca\x7f\x02\x00\x00\xff\xff\x23\x57\xc6\x0d\xdc\x10\x00\x00") + +func staticStyleCssBytes() ([]byte, error) { + return bindataRead( + _staticStyleCss, + "static/style.css", + ) +} + +func staticStyleCss() (*asset, error) { + bytes, err := staticStyleCssBytes() + if err != nil { + return nil, err + } + + info := bindataFileInfo{name: "static/style.css", size: 4316, mode: os.FileMode(0644), modTime: time.Unix(1545371222, 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 +} + +// Asset loads and returns the asset for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func Asset(name string) ([]byte, error) { + canonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[canonicalName]; ok { + a, err := f() + if err != nil { + return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err) + } + return a.bytes, nil + } + return nil, fmt.Errorf("Asset %s not found", name) +} + +// AssetString returns the asset contents as a string (instead of a []byte). +func AssetString(name string) (string, error) { + data, err := Asset(name) + return string(data), err +} + +// MustAsset is like Asset but panics when Asset would return an error. +// It simplifies safe initialization of global variables. +func MustAsset(name string) []byte { + a, err := Asset(name) + if err != nil { + panic("asset: Asset(" + name + "): " + err.Error()) + } + + return a +} + +// MustAssetString is like AssetString but panics when Asset would return an +// error. It simplifies safe initialization of global variables. +func MustAssetString(name string) string { + return string(MustAsset(name)) +} + +// AssetInfo loads and returns the asset info for the given name. +// It returns an error if the asset could not be found or +// could not be loaded. +func AssetInfo(name string) (os.FileInfo, error) { + canonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[canonicalName]; ok { + a, err := f() + if err != nil { + return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err) + } + return a.info, nil + } + return nil, fmt.Errorf("AssetInfo %s not found", name) +} + +// AssetDigest returns the digest of the file with the given name. It returns an +// error if the asset could not be found or the digest could not be loaded. +func AssetDigest(name string) ([sha256.Size]byte, error) { + canonicalName := strings.Replace(name, "\\", "/", -1) + if f, ok := _bindata[canonicalName]; ok { + a, err := f() + if err != nil { + return [sha256.Size]byte{}, fmt.Errorf("AssetDigest %s can't read by error: %v", name, err) + } + return a.digest, nil + } + return [sha256.Size]byte{}, fmt.Errorf("AssetDigest %s not found", name) +} + +// Digests returns a map of all known files and their checksums. +func Digests() (map[string][sha256.Size]byte, error) { + mp := make(map[string][sha256.Size]byte, len(_bindata)) + for name := range _bindata { + a, err := _bindata[name]() + if err != nil { + return nil, err + } + mp[name] = a.digest + } + return mp, nil +} + +// AssetNames returns the names of the assets. +func AssetNames() []string { + names := make([]string, 0, len(_bindata)) + for name := range _bindata { + names = append(names, name) + } + return names +} + +// _bindata is a table, holding each asset generator, mapped to its name. +var _bindata = map[string]func() (*asset, error){ + "login.tmpl": loginTmpl, + + "static/script.js": staticScriptJs, + + "static/style.css": staticStyleCss, +} + +// AssetDir returns the file names below a certain +// directory embedded in the file by go-bindata. +// For example if you run go-bindata on data/... and data contains the +// following hierarchy: +// data/ +// foo.txt +// img/ +// a.png +// b.png +// then AssetDir("data") would return []string{"foo.txt", "img"}, +// AssetDir("data/img") would return []string{"a.png", "b.png"}, +// AssetDir("foo.txt") and AssetDir("notexist") would return an error, and +// AssetDir("") will return []string{"data"}. +func AssetDir(name string) ([]string, error) { + node := _bintree + if len(name) != 0 { + canonicalName := strings.Replace(name, "\\", "/", -1) + pathList := strings.Split(canonicalName, "/") + for _, p := range pathList { + node = node.Children[p] + if node == nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + } + } + if node.Func != nil { + return nil, fmt.Errorf("Asset %s not found", name) + } + rv := make([]string, 0, len(node.Children)) + for childName := range node.Children { + rv = append(rv, childName) + } + return rv, nil +} + +type bintree struct { + Func func() (*asset, error) + Children map[string]*bintree +} + +var _bintree = &bintree{nil, map[string]*bintree{ + "login.tmpl": &bintree{loginTmpl, map[string]*bintree{}}, + "static": &bintree{nil, map[string]*bintree{ + "script.js": &bintree{staticScriptJs, map[string]*bintree{}}, + "style.css": &bintree{staticStyleCss, map[string]*bintree{}}, + }}, +}} + +// RestoreAsset restores an asset under the given directory. +func RestoreAsset(dir, name string) error { + data, err := Asset(name) + if err != nil { + return err + } + info, err := AssetInfo(name) + if err != nil { + return err + } + err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755)) + if err != nil { + return err + } + err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode()) + if err != nil { + return err + } + return os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime()) +} + +// RestoreAssets restores an asset under the given directory recursively. +func RestoreAssets(dir, name string) error { + children, err := AssetDir(name) + // File + if err != nil { + return RestoreAsset(dir, name) + } + // Dir + for _, child := range children { + err = RestoreAssets(dir, filepath.Join(name, child)) + if err != nil { + return err + } + } + return nil +} + +func _filePath(dir, name string) string { + canonicalName := strings.Replace(name, "\\", "/", -1) + return filepath.Join(append([]string{dir}, strings.Split(canonicalName, "/")...)...) +} diff --git a/internal/server/templates/login.tmpl b/internal/server/templates/login.tmpl new file mode 100644 index 0000000..a5a177f --- /dev/null +++ b/internal/server/templates/login.tmpl @@ -0,0 +1,45 @@ +{{ define "title" }} + Login Provider Werther +{{ end }} + +{{ define "style" }} + +{{ end }} + +{{ define "js" }} + +{{ end }} + +{{ define "content" }} + +{{ end }} diff --git a/internal/server/templates/static/script.js b/internal/server/templates/static/script.js new file mode 100644 index 0000000..dd0afb1 --- /dev/null +++ b/internal/server/templates/static/script.js @@ -0,0 +1,34 @@ +/* vim: setl et ts=4 sts=4 sw=4 */ +window.onload = function() { + var userElem = document.querySelector(".login-form input[name='username']"), + passElem = document.querySelector(".login-form input[name='password']"), + remeElem = document.querySelector(".login-form input[name='remember']"), + loginForm = document.querySelector(".login-form"); + + userElem.value = sessionStorage.getItem('username'); + remeElem.checked = sessionStorage.getItem('remember'); + + if (userElem.value == null || userElem.value == "") { + userElem.focus(); + } else { + passElem.focus(); + } + + loginForm.addEventListener("submit", function(e) { + var msgElem = document.querySelector("p.message"); + + if (userElem.value == null || userElem.value == "" || + passElem.value == null || passElem.value == "") { + msgElem.innerHTML = "Username and password are required"; + e.preventDefault(); + return; + } + sessionStorage.setItem('username', userElem.value); + if (remeElem.checked) { + sessionStorage.setItem('remember', remeElem.checked); + } else { + sessionStorage.removeItem('remember'); + } + + }, false); +}; diff --git a/internal/server/templates/static/style.css b/internal/server/templates/static/style.css new file mode 100644 index 0000000..eedaac3 --- /dev/null +++ b/internal/server/templates/static/style.css @@ -0,0 +1,266 @@ +@import url(https://fonts.googleapis.com/css?family=Roboto:300); + +html { + box-sizing: border-box; +} + +*, +::after, +::before { + box-sizing: inherit; +} + +.login-page { + width: 360px; + padding: 8% 0 0; + margin: auto; +} + +@media (max-width: 480px) { + .login-page { + width: 100%; + padding: 8% 5% 0; + } +} + +.form { + position: relative; + z-index: 1; + background: #ffffff; + max-width: 360px; + margin: 0 auto 100px; + padding: 2.815em; + text-align: center; + box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.2), 0 5px 5px 0 rgba(0, 0, 0, 0.24); +} + +@media (max-width: 480px) { + .form { + margin: 15% auto 0; + max-width: 100%; + padding: 0; + box-shadow: none; + } +} + +.form input { + font-family: "Roboto", sans-serif; + outline: 0; + background: #f2f2f2; + width: 100%; + border: 0; + margin: 0 0 15px; + padding: 15px; + box-sizing: border-box; + font-size: 14px; +} + +@media (max-width: 480px) { + .form input { + font-size: 1.5em; + } +} + +.form button { + font-family: "Roboto", sans-serif; + text-transform: uppercase; + outline: 0; + background: #2ba6cb; + width: 100%; + border: 0; + padding: 1.08em; + color: #ffffff; + font-size: 14px; + -webkit-transition: all 0.3 ease; + transition: all 0.3 ease; + cursor: pointer; +} + +@media (max-width: 480px) { + .form button { + padding: 0.8em; + font-size: 1.3em; + } +} + +.form button:hover { + background: #2795b7; +} + +.form button:active { + background: #2384a3; +} + +.form .message { + margin: 0 0 15px; + color: #ff6961; + font-size: 15px; +} + +@media (max-width: 480px) { + .form .message { + min-height: 3em; + font-size: 1.5em; + } +} + +.container { + position: relative; + z-index: 1; + max-width: 300px; + margin: 0 auto; +} + +.container:before, +.container:after { + content: ""; + display: block; + clear: both; +} + +.container .info { + margin: 50px auto; + text-align: center; +} + +.container .info h1 { + margin: 0 0 15px; + padding: 0; + font-size: 36px; + font-weight: 300; + color: #1a1a1a; +} + +.container .info span { + color: #4d4d4d; + font-size: 12px; +} + +.container .info span a { + color: #000000; + text-decoration: none; +} + +.container .info span .fa { + color: #ef3b3a; +} + +body { + font-family: "Roboto", sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.remember-container { + padding-bottom: 2em; + font-size: 90%; +} + +.checkbox { + cursor: pointer; +} + +.checkbox div.checkbox-overlay { + position: relative; + overflow: hidden; +} + +.checkbox input { + opacity: 0; + position: absolute; + cursor: pointer; + left: 0; + margin: 0; + top: 0; + z-index: 1; + width: 100%; + height: 100%; +} + +.checkbox label { + float: left; + line-height: initial; + font-size: 0.9em; + padding: 0.14em 1em; +} + +@media (max-width: 480px) { + .checkbox label { + font-size:1.25em; + padding:0.28em 0.8em; + } +} + +.checkbox div.checkbox-container { + float: left; +} + +.checkbox div.checkbox-container .checkbox-checkmark { + position: relative; + background-color: #eee; + width: 20px; + height: 20px; +} + +.checkbox div.checkbox-checkmark::after { + display: none; + position: absolute; + content: ""; + border: solid white; + -webkit-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + height: 12px; + width: 6px; + left: 7px; + top: 3px; + border-width: 0 2px 3px 0; +} + +@media (max-width: 480px) { + .checkbox div.checkbox-container .checkbox-checkmark { + width: 34px; + height: 34px; + } + + .checkbox div.checkbox-container .checkbox-checkmark:after { + height: 20.4px; + width: 10.2px; + left: 11.9px; + top: 4px; + border-width: 0 3px 5px 0; + } +} + +.checkbox input:checked ~ .checkbox-container > .checkbox-checkmark { + background-color: #2ba6cb; + border: 0; +} + +.checkbox input:checked ~ .checkbox-container > .checkbox-checkmark:after { + display: initial; +} + +.checkbox input:checked:hover ~ .checkbox-container > .checkbox-checkmark { + background-color: #2ba6cb; + border: 0; +} + +.checkbox + input:checked:hover + ~ .checkbox-container + > .checkbox-checkmark:after { + border-color: white; +} + +.checkbox input:hover ~ .checkbox-container > .checkbox-checkmark { + background-color: #ddd; +} + +.checkbox input:hover ~ .checkbox-container > .checkbox-checkmark:after { + display: initial; + border-color: rgba(190, 190, 190, 1); + border-top: 0px; + border-left: 0px; +} + diff --git a/internal/server/web.go b/internal/server/web.go new file mode 100644 index 0000000..a81ff04 --- /dev/null +++ b/internal/server/web.go @@ -0,0 +1,127 @@ +/* +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 }} +`