diff --git a/.gitignore b/.gitignore index 8a48925..f03c47b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /release /data /vendor -/bin \ No newline at end of file +/bin +/.vscode \ No newline at end of file diff --git a/Makefile b/Makefile index 5f5af00..10081b0 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ down: create-client: docker-compose exec hydra \ - sh -c 'HYDRA_URL=http://localhost:4445 hydra clients create -c http://localhost:3000/test/oauth2/callback' + sh -c 'HYDRA_URL=http://localhost:4445 hydra clients create -c http://localhost:3002/oauth2/callback' list-clients: docker-compose exec hydra \ diff --git a/README.md b/README.md index a3f4254..e3a3d98 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,29 @@ # hydra-passwordless +["Login & Consent App"](https://www.ory.sh/hydra/docs/login-consent-flow/) pour le serveur d'authentification OpenID Connect [Hydra](https://www.ory.sh/hydra/). + +Ce middleware permet une authentification de type "[passwordless](https://auth0.com/docs/connections/passwordless)" compatible avec les applications utilisant le protocole [OpenID Connect](https://fr.wikipedia.org/wiki/OpenID_Connect) pour l'authentification de leurs utilisateurs. + ## Démarrer avec les sources ```shell -# Dans un premier terminal, lancer le serveur hydra-passwordless +# Dans un premier terminal, lancer le serveur hydra-passwordless + hydra (via docker-compose/modd) make watch -# Dans un second terminal, lancer le serveur hydra -make hydra - # Dans un dernier terminal, générer le clientId et le clientSecret -# pour le serveur hydra-passwordless +# pour le serveur hydra-passwordless. +# Ces identifiants pourront être utilisés par votre application OIDC. make create-client ``` -Reporter ces éléments dans le fichier de configuration data/server.yml, section "testApp": +### URLs -```yaml -testApp: - enabled: true - clientId: - clientSecret: -``` - -Vous devriez pouvoir accéder à l'URL http://localhost:3000/test, qui vous redirigera automatiquement vers la mire d'authentification. +|URL|Description| +|---|-----------| +|http://localhost:4444/|Points d'entrée OIDC Hydra| +|http://localhost:4445/|API d'administration Hydra| +|http://localhost:3000/|Middleware Hydra hydra-passwordless (Voir ["Hydra- Login & Consent App"](https://www.ory.sh/hydra/docs/login-consent-flow/)) +|http://localhost:3001/|Interface web [FakeSMTP](https://forge.cadoles.com/wpetit/fake-smtp)| ## FAQ diff --git a/cmd/server/container.go b/cmd/server/container.go index 8db4dc8..2feb23b 100644 --- a/cmd/server/container.go +++ b/cmd/server/container.go @@ -1,17 +1,18 @@ package main import ( - "context" "log" "net/http" "time" + "gitlab.com/wpetit/goweb/cqrs" "gitlab.com/wpetit/goweb/template/html" + "forge.cadoles.com/wpetit/hydra-passwordless/internal/command" "forge.cadoles.com/wpetit/hydra-passwordless/internal/config" "forge.cadoles.com/wpetit/hydra-passwordless/internal/hydra" "forge.cadoles.com/wpetit/hydra-passwordless/internal/mail" - "forge.cadoles.com/wpetit/hydra-passwordless/oidc" + "forge.cadoles.com/wpetit/hydra-passwordless/internal/query" "github.com/gorilla/sessions" "github.com/pkg/errors" "gitlab.com/wpetit/goweb/service" @@ -51,6 +52,30 @@ func getServiceContainer(conf *config.Config) (*service.Container, error) { conf.HTTP.CookieEncryptionKey = string(cookieEncryptionKey) } + // Generate random token signing key if none is set + if conf.HTTP.TokenSigningKey == "" { + log.Println("could not find token signing key. generating one...") + + tokenSigningKey, err := gorilla.GenerateRandomBytes(64) + if err != nil { + return nil, errors.Wrap(err, "could not generate token signing key") + } + + conf.HTTP.TokenSigningKey = string(tokenSigningKey) + } + + // Generate random token encryption key if none is set + if conf.HTTP.TokenEncryptionKey == "" { + log.Println("could not find token encryption key. generating one...") + + tokenEncryptionKey, err := gorilla.GenerateRandomBytes(32) + if err != nil { + return nil, errors.Wrap(err, "could not generate token encryption key") + } + + conf.HTTP.TokenEncryptionKey = string(tokenEncryptionKey) + } + // Create and initialize HTTP session service provider cookieStore := sessions.NewCookieStore( []byte(conf.HTTP.CookieAuthenticationKey), @@ -79,19 +104,6 @@ func getServiceContainer(conf *config.Config) (*service.Container, error) { // Create and expose config service provider ctn.Provide(config.ServiceName, config.ServiceProvider(conf)) - if conf.TestApp.Enabled { - ctx := context.Background() - provider, err := oidc.NewProvider(ctx, conf.TestApp.IssuerURL) - if err != nil { - return nil, errors.Wrap(err, "could not create oidc provider") - } - - ctn.Provide(oidc.ServiceName, oidc.ServiceProvider( - oidc.WithCredentials(conf.TestApp.ClientID, conf.TestApp.ClientSecret), - oidc.WithProvider(provider), - )) - } - ctn.Provide(hydra.ServiceName, hydra.ServiceProvider(conf.Hydra.BaseURL, 30*time.Second)) ctn.Provide(mail.ServiceName, mail.ServiceProvider( @@ -100,5 +112,22 @@ func getServiceContainer(conf *config.Config) (*service.Container, error) { mail.WithTLS(conf.SMTP.UseStartTLS, conf.SMTP.InsecureSkipVerify), )) + ctn.Provide(cqrs.ServiceName, cqrs.ServiceProvider()) + + bus, err := cqrs.From(ctn) + if err != nil { + return nil, err + } + + bus.RegisterCommand( + cqrs.MatchCommandRequest(&command.SendConfirmationEmailRequest{}), + cqrs.CommandHandlerFunc(command.HandleSendConfirmationEmailRequest), + ) + + bus.RegisterQuery( + cqrs.MatchQueryRequest(&query.VerifyUserRequest{}), + cqrs.QueryHandlerFunc(query.HandleVerifyUserRequest), + ) + return ctn, nil } diff --git a/cmd/server/template/layouts/consent.html.tmpl b/cmd/server/template/layouts/consent.html.tmpl index b051a8a..0c86046 100644 --- a/cmd/server/template/layouts/consent.html.tmpl +++ b/cmd/server/template/layouts/consent.html.tmpl @@ -1,8 +1,34 @@ -{{define "title"}}Consent{{end}} +{{define "title"}}Autorisation{{end}} {{define "body"}} -
-
- +
+
+
+
+
+ {{template "flash" .}} +

+ Demande d'autorisation +

+

+ Autorisez vous l'application à utiliser ces informations vous concernant ? +

+
+
+ {{range .RequestedScope}} +
+ +
+ {{end}} + {{ .csrfField }} + + +
+
+
+
{{end}} diff --git a/cmd/server/template/layouts/home.html.tmpl b/cmd/server/template/layouts/home.html.tmpl deleted file mode 100644 index d6366a8..0000000 --- a/cmd/server/template/layouts/home.html.tmpl +++ /dev/null @@ -1,11 +0,0 @@ -{{define "title"}}Accueil{{end}} -{{define "body"}} -
-
- {{template "header" .}} -

Bienvenue !

- {{template "footer" .}} -
-
-{{end}} -{{template "base" .}} diff --git a/docker-compose.yml b/docker-compose.yml index 5226b9e..6b532d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,20 +1,34 @@ version: '2.4' services: - hydra: - image: oryd/hydra:v1.4.2-alpine + mysql: + image: mysql:8 environment: - DSN: memory + MYSQL_ROOT_PASSWORD: hydra + MYSQL_DATABASE: hydra + MYSQL_USER: hydra + MYSQL_PASSWORD: hydra + volumes: + - mysql_data:/var/lib/mysql + hydra: + build: + context: ./misc/containers/hydra + environment: + DSN: mysql://hydra:hydra@tcp(mysql:3306)/hydra URLS_LOGIN: http://localhost:3000/login URLS_CONSENT: http://localhost:3000/consent ports: - 4444:4444 - 4445:4445 - command: serve all --dangerous-force-http + command: hydra serve all --dangerous-force-http smtp: image: bornholm/fake-smtp ports: - 3001:8080 - 2525:2525 + environment: + - FAKESMTP_SMTP_DEBUG=false volumes: - /etc/localtime:/etc/localtime:ro - /etc/timezone:/etc/timezone:ro +volumes: + mysql_data: \ No newline at end of file diff --git a/go.mod b/go.mod index 84c2767..4214f09 100644 --- a/go.mod +++ b/go.mod @@ -3,18 +3,21 @@ module forge.cadoles.com/wpetit/hydra-passwordless go 1.14 require ( - github.com/coreos/go-oidc v2.2.1+incompatible - github.com/davecgh/go-spew v1.1.1 - github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 + github.com/PuerkitoBio/goquery v1.5.1 // indirect + github.com/aymerick/douceur v0.2.0 + github.com/coreos/go-oidc v2.2.1+incompatible // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 // indirect github.com/go-chi/chi v4.1.0+incompatible github.com/gorilla/csrf v1.6.2 + github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/sessions v1.2.0 github.com/pkg/errors v0.9.1 github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect - gitlab.com/wpetit/goweb v0.0.0-20200415164411-636b2dbf8ff7 - golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 + gitlab.com/wpetit/goweb v0.0.0-20200418152305-76dea96a46ce + golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 // indirect gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect gopkg.in/mail.v2 v2.3.1 - gopkg.in/square/go-jose.v2 v2.4.1 // indirect + gopkg.in/square/go-jose.v2 v2.5.1 gopkg.in/yaml.v2 v2.2.8 ) diff --git a/go.sum b/go.sum index 3f9a028..d8bb659 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +cdr.dev/slog v1.3.0 h1:MYN1BChIaVEGxdS7I5cpdyMC0+WfJfK8BETAfzfLUGQ= cdr.dev/slog v1.3.0/go.mod h1:C5OL99WyuOK8YHZdYY57dAPN1jK2WJlCdq2VP6xeQns= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= @@ -16,19 +17,27 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0= github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0= +github.com/PuerkitoBio/goquery v1.5.1 h1:PSPBGne8NIUWw+/7vFBV+kG2J/5MOjbzc7154OaKCSE= +github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= +github.com/alecthomas/chroma v0.7.0 h1:z+0HgTUmkpRDRz0SRSdMaqOLfJV4F+N1FPDZUZIDUzw= github.com/alecthomas/chroma v0.7.0/go.mod h1:1U/PfCsTALWWYHDnsIQkxEBM0+6LLe0v8+RSVMOwxeY= github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI= github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA= github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= +github.com/andybalholm/cascadia v1.1.0 h1:BuuO6sSfQNFRu1LppgbD25Hr2vLYW25JvxHs5zzsLTo= +github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk= github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E= +github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -36,9 +45,11 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs= github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/go-chi/chi v4.1.0+incompatible h1:ETj3cggsVIY2Xao5ExCu6YhEh5MD6JTfcBzS37R260w= @@ -48,6 +59,7 @@ github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3yg github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9 h1:uHTyIjqVhYRhLbJ8nIiOJHkEZZ+5YoOsAbD3sk82NiE= github.com/golang/groupcache v0.0.0-20191027212112-611e8accdfc9/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= @@ -60,16 +72,21 @@ github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Z github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.2-0.20191216170541-340f1ebe299e h1:4WfjkTUTsO6siF8ghDQQk6t7x/FPsv3w6MXkc47do7Q= github.com/google/go-cmp v0.3.2-0.20191216170541-340f1ebe299e/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI= github.com/gorilla/csrf v1.6.2 h1:QqQ/OWwuFp4jMKgBFAzJVW3FMULdyUW7JoM4pEWuqKg= github.com/gorilla/csrf v1.6.2/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI= +github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= +github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= @@ -89,9 +106,11 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E= @@ -115,10 +134,11 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= -gitlab.com/wpetit/goweb v0.0.0-20200415164411-636b2dbf8ff7 h1:lHdiFEjVYTDd6cLfp1fEJUtRFJFyffYCuQFvauZm+OM= -gitlab.com/wpetit/goweb v0.0.0-20200415164411-636b2dbf8ff7/go.mod h1:wqXhN3jywegFzw33pEFAEbsXnshFx0nJ+aXTi4pCtIQ= +gitlab.com/wpetit/goweb v0.0.0-20200418152305-76dea96a46ce h1:B3inZUHFr/FpA3jb+ZeSSHk3FSpB0xkQ0TjePhRokxw= +gitlab.com/wpetit/goweb v0.0.0-20200418152305-76dea96a46ce/go.mod h1:Gfv7cBOw1T2XwXMsLm1d9kAjMAdNtLMjPv+yCzRO9qk= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2 h1:75k/FF0Q2YM8QYo07VPddOLBslDt1MZOdEslOHvmzAs= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= @@ -143,6 +163,7 @@ golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -155,6 +176,8 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553 h1:efeOvDhwQ29Dj3SdAV/MJf8oukgn+8D8WgaCaRMchF8= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2 h1:CCH4IOTTfewWjGOlSp+zGcjutRKlBEZQ6wTn8ozI/nI= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= @@ -175,6 +198,7 @@ golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191210023423-ac6580df4449 h1:gSbV7h1NRL2G1xTg/owz62CST1oJBmxy4QpMMregXVQ= golang.org/x/sys v0.0.0-20191210023423-ac6580df4449/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -199,6 +223,7 @@ golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= @@ -235,8 +260,8 @@ gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8 gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk= gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw= -gopkg.in/square/go-jose.v2 v2.4.1 h1:H0TmLt7/KmzlrDOpa1F+zr0Tk90PbJYBfsVUmRLrf9Y= -gopkg.in/square/go-jose.v2 v2.4.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/square/go-jose.v2 v2.5.1 h1:7odma5RETjNHWJnR32wx8t+Io4djHE1PqxCFx3iiZ2w= +gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/command/send_confirmation_email.go b/internal/command/send_confirmation_email.go new file mode 100644 index 0000000..7552169 --- /dev/null +++ b/internal/command/send_confirmation_email.go @@ -0,0 +1,104 @@ +package command + +import ( + "bytes" + "context" + "fmt" + "log" + + "forge.cadoles.com/wpetit/hydra-passwordless/internal/config" + "forge.cadoles.com/wpetit/hydra-passwordless/internal/hydra" + "forge.cadoles.com/wpetit/hydra-passwordless/internal/mail" + "forge.cadoles.com/wpetit/hydra-passwordless/internal/token" + "github.com/aymerick/douceur/inliner" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/cqrs" + "gitlab.com/wpetit/goweb/middleware/container" + "gitlab.com/wpetit/goweb/service/build" + "gitlab.com/wpetit/goweb/service/template" +) + +type SendConfirmationEmailRequest struct { + Email string + Challenge string + DefaultScheme string + DefaultAddress string +} + +func HandleSendConfirmationEmailRequest(ctx context.Context, cmd cqrs.Command) error { + req, ok := cmd.Request().(*SendConfirmationEmailRequest) + if !ok { + return cqrs.ErrUnexpectedRequest + } + + ctn := container.Must(ctx) + tmpl := template.Must(ctn) + hydr := hydra.Must(ctn) + info := build.Must(ctn) + ml := mail.Must(ctn) + conf := config.Must(ctn) + + _, err := hydr.LoginRequest(req.Challenge) + if err != nil { + return errors.Wrap(err, "could not retrieve hydra login response") + } + + token, err := token.Generate( + conf.HTTP.TokenSigningKey, + conf.HTTP.TokenEncryptionKey, + req.Email, + req.Challenge, + ) + if err != nil { + return errors.Wrap(err, "could not generate jwt") + } + + address := req.DefaultAddress + if conf.HTTP.PublicAddress != "" { + address = conf.HTTP.PublicAddress + } + + scheme := req.DefaultScheme + if scheme == "" { + scheme = "http:" + } + + if conf.HTTP.PublicScheme != "" { + scheme = conf.HTTP.PublicScheme + } + + log.Println(req, scheme) + + verificationLink := fmt.Sprintf("%s//%s/verify?token=%s", scheme, address, token) + + log.Println(verificationLink) + + data := template.Data{ + "BuildInfo": info, + "VerificationLink": verificationLink, + } + + var buf bytes.Buffer + if err := tmpl.Render(&buf, "verification_email.html.tmpl", data); err != nil { + return errors.Wrap(err, "could not render email template") + } + + // Inline CSS for mail clients + html, err := inliner.Inline(buf.String()) + if err != nil { + return errors.Wrap(err, "could not inline css") + } + + err = ml.Send( + mail.WithSender(conf.SMTP.SenderAddress, conf.SMTP.SenderName), + mail.WithRecipients(req.Email), + mail.WithSubject(fmt.Sprintf("[Authentification]")), + mail.WithBody(mail.ContentTypeHTML, html, nil), + mail.WithAlternativeBody(mail.ContentTypeText, "", nil), + ) + if err != nil { + return errors.Wrap(err, "could not send email") + } + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index ee65bd6..50aca7c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,10 +11,9 @@ import ( ) type Config struct { - HTTP HTTPConfig `yaml:"http"` - TestApp TestAppConfig `yaml:"testApp"` - SMTP SMTPConfig `yaml:"smtp"` - Hydra HydraConfig `yaml:"hydra"` + HTTP HTTPConfig `yaml:"http"` + SMTP SMTPConfig `yaml:"smtp"` + Hydra HydraConfig `yaml:"hydra"` } // NewFromFile retrieves the configuration from the given file @@ -37,17 +36,14 @@ type HTTPConfig struct { Address string `yaml:"address"` CookieAuthenticationKey string `yaml:"cookieAuthenticationKey"` CookieEncryptionKey string `yaml:"cookieEncryptionKey"` + TokenSigningKey string `yaml:"tokenSigningKey"` + TokenEncryptionKey string `yaml:"tokenEncryptionKey"` + BasePublicURL string `yaml:"basePublicUrl"` CookieMaxAge int `yaml:"cookieMaxAge"` TemplateDir string `yaml:"templateDir"` PublicDir string `yaml:"publicDir"` -} - -type TestAppConfig struct { - Enabled bool `yaml:"enabled"` - ClientID string `yaml:"clientId"` - ClientSecret string `yaml:"clientSecret"` - IssuerURL string `ymal:"issuerUrl"` - RedirectURL string `yaml:"redirectUrl"` + PublicAddress string `yaml:"publicAddress"` + PublicScheme string `yaml:"publicScheme"` } type SMTPConfig struct { @@ -76,14 +72,13 @@ func NewDefault() *Config { Address: ":3000", CookieAuthenticationKey: "", CookieEncryptionKey: "", + TokenEncryptionKey: "", + TokenSigningKey: "", CookieMaxAge: int((time.Hour * 1).Seconds()), // 1 hour TemplateDir: "template", PublicDir: "public", - }, - TestApp: TestAppConfig{ - Enabled: false, - IssuerURL: "http://localhost:4444/", - RedirectURL: "http://localhost:3000/test/oauth2/callback", + PublicAddress: "", + PublicScheme: "", }, SMTP: SMTPConfig{ Host: "localhost", diff --git a/internal/hydra/client.go b/internal/hydra/client.go index 28b58d8..4dc5c16 100644 --- a/internal/hydra/client.go +++ b/internal/hydra/client.go @@ -41,8 +41,8 @@ func (c *Client) LoginRequest(challenge string) (*LoginResponse, error) { return loginRes, nil } -func (c *Client) AcceptRequest(challenge string, req *AcceptRequest) (*AcceptResponse, error) { - u := fromURL(*c.baseURL, "/oauth2/auth/requests/accept", url.Values{ +func (c *Client) AcceptLoginRequest(challenge string, req *AcceptLoginRequest) (*AcceptResponse, error) { + u := fromURL(*c.baseURL, "/oauth2/auth/requests/login/accept", url.Values{ "login_challenge": []string{challenge}, }) @@ -55,8 +55,8 @@ func (c *Client) AcceptRequest(challenge string, req *AcceptRequest) (*AcceptRes return res, nil } -func (c *Client) RejectRequest(challenge string, req *RejectRequest) (*RejectResponse, error) { - u := fromURL(*c.baseURL, "/oauth2/auth/requests/reject", url.Values{ +func (c *Client) RejectLoginRequest(challenge string, req *RejectRequest) (*RejectResponse, error) { + u := fromURL(*c.baseURL, "/oauth2/auth/requests/login/reject", url.Values{ "login_challenge": []string{challenge}, }) @@ -74,7 +74,57 @@ func (c *Client) LogoutRequest(challenge string) (*LogoutResponse, error) { } func (c *Client) ConsentRequest(challenge string) (*ConsentResponse, error) { - return nil, nil + u := fromURL(*c.baseURL, "/oauth2/auth/requests/consent", url.Values{ + "consent_challenge": []string{challenge}, + }) + + res, err := c.http.Get(u) + if err != nil { + return nil, errors.Wrap(err, "could not retrieve login response") + } + + if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest { + return nil, errors.Wrapf(ErrUnexpectedHydraResponse, "hydra responded with status code '%d'", res.StatusCode) + } + + defer res.Body.Close() + + decoder := json.NewDecoder(res.Body) + consentRes := &ConsentResponse{} + + if err := decoder.Decode(consentRes); err != nil { + return nil, errors.Wrap(err, "could not decode json response") + } + + return consentRes, nil +} + +func (c *Client) AcceptConsentRequest(challenge string, req *AcceptConsentRequest) (*AcceptResponse, error) { + u := fromURL(*c.baseURL, "/oauth2/auth/requests/consent/accept", url.Values{ + "consent_challenge": []string{challenge}, + }) + + res := &AcceptResponse{} + + if err := c.putJSON(u, req, res); err != nil { + return nil, err + } + + return res, nil +} + +func (c *Client) RejectConsentRequest(challenge string, req *RejectRequest) (*RejectResponse, error) { + u := fromURL(*c.baseURL, "/oauth2/auth/requests/consent/reject", url.Values{ + "consent_challenge": []string{challenge}, + }) + + res := &RejectResponse{} + + if err := c.putJSON(u, req, res); err != nil { + return nil, err + } + + return res, nil } func (c *Client) LoginChallenge(r *http.Request) (string, error) { diff --git a/internal/hydra/request.go b/internal/hydra/request.go index 2a30007..8a1df21 100644 --- a/internal/hydra/request.go +++ b/internal/hydra/request.go @@ -1,12 +1,25 @@ package hydra -type AcceptRequest struct { +type AcceptLoginRequest struct { Subject string `json:"subject"` Remember bool `json:"remember"` RememberFor int `json:"remember_for"` ACR string `json:"acr"` } +type AcceptConsentRequest struct { + GrantScope []string `json:"grant_scope"` + GrantAccessTokenAudience []string `json:"grant_access_token_audience"` + Remember bool `json:"remember"` + RememberFor int `json:"remember_for"` + Session AcceptConsentSession `json:"session"` +} + +type AcceptConsentSession struct { + AccessToken map[string]interface{} `json:"access_token"` + IDToken map[string]interface{} `json:"id_token"` +} + type RejectRequest struct { Error string `json:"error"` ErrorDescription string `json:"error_description"` diff --git a/internal/hydra/response.go b/internal/hydra/response.go index bfbf5d0..d5647d2 100644 --- a/internal/hydra/response.go +++ b/internal/hydra/response.go @@ -55,11 +55,12 @@ type LoginResponse struct { RequestURL string `json:"request_url"` RequestedScope []string `json:"requested_scope"` OidcContext OidcContextResponseFragment `json:"oidc_context"` - RequestedAccessTokenAudience string `json:"requested_access_token_audience"` + RequestedAccessTokenAudience []string `json:"requested_access_token_audience"` SessionID string `json:"session_id"` } type AcceptResponse struct { + RedirectTo string `json:"redirect_to"` } type RejectResponse struct { @@ -70,4 +71,13 @@ type LogoutResponse struct { } type ConsentResponse struct { + Challenge string `json:"challenge"` + Skip bool `json:"skip"` + Subject string `json:"subject"` + Client ClientResponseFragment `json:"client"` + RequestURL string `json:"request_url"` + RequestedScope []string `json:"requested_scope"` + OidcContext OidcContextResponseFragment `json:"oidc_context"` + RequestedAccessTokenAudience []string `json:"requested_access_token_audience"` + SessionID string `json:"session_id"` } diff --git a/internal/query/verify_user.go b/internal/query/verify_user.go new file mode 100644 index 0000000..1795679 --- /dev/null +++ b/internal/query/verify_user.go @@ -0,0 +1,47 @@ +package query + +import ( + "context" + + "forge.cadoles.com/wpetit/hydra-passwordless/internal/config" + "forge.cadoles.com/wpetit/hydra-passwordless/internal/token" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/cqrs" + "gitlab.com/wpetit/goweb/middleware/container" +) + +type VerifyUserRequest struct { + Token string +} + +type VerifyUserData struct { + Email string + Challenge string +} + +func HandleVerifyUserRequest(ctx context.Context, qry cqrs.Query) (interface{}, error) { + req, ok := qry.Request().(*VerifyUserRequest) + if !ok { + return nil, cqrs.ErrUnexpectedRequest + } + + ctn := container.Must(ctx) + conf := config.Must(ctn) + + email, challenge, err := token.Verify( + conf.HTTP.TokenSigningKey, + conf.HTTP.TokenEncryptionKey, + req.Token, + ) + + if err != nil { + return nil, errors.Wrap(err, "could not verify token") + } + + data := &VerifyUserData{ + Email: email, + Challenge: challenge, + } + + return data, nil +} diff --git a/internal/route/consent.go b/internal/route/consent.go index 9692b94..e2381e5 100644 --- a/internal/route/consent.go +++ b/internal/route/consent.go @@ -3,18 +3,64 @@ package route import ( "net/http" + "forge.cadoles.com/wpetit/hydra-passwordless/internal/hydra" "github.com/pkg/errors" "gitlab.com/wpetit/goweb/middleware/container" - "gitlab.com/wpetit/goweb/service/template" ) func serveConsentPage(w http.ResponseWriter, r *http.Request) { ctn := container.Must(r.Context()) - tmpl := template.Must(ctn) + //tmpl := template.Must(ctn) + hydr := hydra.Must(ctn) - data := extendTemplateData(w, r, template.Data{}) + challenge, err := hydr.ConsentChallenge(r) + if err != nil { + if err == hydra.ErrChallengeNotFound { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - if err := tmpl.RenderPage(w, "consent.html.tmpl", data); err != nil { - panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path)) + return + } + + panic(errors.Wrap(err, "could not retrieve consent challenge")) } + + res, err := hydr.ConsentRequest(challenge) + if err != nil { + panic(errors.Wrap(err, "could not retrieve hydra consent response")) + } + + if res.Skip { + res, err := hydr.AcceptConsentRequest(challenge, &hydra.AcceptConsentRequest{ + GrantScope: res.RequestedScope, + GrantAccessTokenAudience: res.RequestedAccessTokenAudience, + }) + if err != nil { + panic(errors.Wrap(err, "could not accept hydra consent request")) + } + + http.Redirect(w, r, res.RedirectTo, http.StatusTemporaryRedirect) + return + } + + res2, err := hydr.AcceptConsentRequest(challenge, &hydra.AcceptConsentRequest{ + GrantScope: res.RequestedScope, + GrantAccessTokenAudience: res.RequestedAccessTokenAudience, + }) + if err != nil { + panic(errors.Wrap(err, "could not accept hydra consent request")) + } + + http.Redirect(w, r, res2.RedirectTo, http.StatusTemporaryRedirect) + + // spew.Dump(res) + + // data := extendTemplateData(w, r, template.Data{ + // csrf.TemplateTag: csrf.TemplateField(r), + // "RequestedScope": res.RequestedScope, + // "ConsentChallenge": challenge, + // }) + + // if err := tmpl.RenderPage(w, "consent.html.tmpl", data); err != nil { + // panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path)) + // } } diff --git a/internal/route/home.go b/internal/route/home.go deleted file mode 100644 index 5b58e36..0000000 --- a/internal/route/home.go +++ /dev/null @@ -1,20 +0,0 @@ -package route - -import ( - "net/http" - - "github.com/pkg/errors" - "gitlab.com/wpetit/goweb/middleware/container" - "gitlab.com/wpetit/goweb/service/template" -) - -func serveHomePage(w http.ResponseWriter, r *http.Request) { - ctn := container.Must(r.Context()) - tmpl := template.Must(ctn) - - data := extendTemplateData(w, r, template.Data{}) - - if err := tmpl.RenderPage(w, "home.html.tmpl", data); err != nil { - panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path)) - } -} diff --git a/internal/route/login.go b/internal/route/login.go index 6db368a..bb21f3a 100644 --- a/internal/route/login.go +++ b/internal/route/login.go @@ -1,18 +1,14 @@ package route import ( - "bytes" - "fmt" "net/http" netMail "net/mail" - "github.com/davecgh/go-spew/spew" - - "forge.cadoles.com/wpetit/hydra-passwordless/internal/config" + "forge.cadoles.com/wpetit/hydra-passwordless/internal/command" "forge.cadoles.com/wpetit/hydra-passwordless/internal/hydra" - "forge.cadoles.com/wpetit/hydra-passwordless/internal/mail" "github.com/gorilla/csrf" "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/cqrs" "gitlab.com/wpetit/goweb/middleware/container" "gitlab.com/wpetit/goweb/service/session" "gitlab.com/wpetit/goweb/service/template" @@ -39,7 +35,7 @@ func serveLoginPage(w http.ResponseWriter, r *http.Request) { } if res.Skip { - res, err := hydr.RejectRequest(challenge, &hydra.RejectRequest{ + res, err := hydr.RejectLoginRequest(challenge, &hydra.RejectRequest{ Error: "email_not_validated", ErrorDescription: "The email adress could not be verified.", }) @@ -65,9 +61,10 @@ func serveLoginPage(w http.ResponseWriter, r *http.Request) { } func handleLoginForm(w http.ResponseWriter, r *http.Request) { - ctn := container.Must(r.Context()) + ctx := r.Context() + ctn := container.Must(ctx) tmpl := template.Must(ctn) - hydr := hydra.Must(ctn) + bus := cqrs.Must(ctn) if err := r.ParseForm(); err != nil { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) @@ -113,30 +110,14 @@ func handleLoginForm(w http.ResponseWriter, r *http.Request) { return } - res, err := hydr.LoginRequest(challenge) - if err != nil { - panic(errors.Wrap(err, "could not retrieve hydra login response")) + cmd := &command.SendConfirmationEmailRequest{ + Email: email, + Challenge: challenge, + DefaultScheme: r.URL.Scheme, + DefaultAddress: r.Host, } - - spew.Dump(res) - - ml := mail.Must(ctn) - conf := config.Must(ctn) - - var buf bytes.Buffer - if err := tmpl.Render(&buf, "verification_email.html.tmpl", template.Data{}); err != nil { - panic(errors.Wrap(err, "could not render email template")) - } - - err = ml.Send( - mail.WithSender(conf.SMTP.SenderAddress, conf.SMTP.SenderName), - mail.WithRecipients(email), - mail.WithSubject(fmt.Sprintf("[Authentification]")), - mail.WithBody(mail.ContentTypeHTML, buf.String(), nil), - mail.WithAlternativeBody(mail.ContentTypeText, "", nil), - ) - if err != nil { - panic(errors.Wrap(err, "could not send email")) + if _, err := bus.Exec(ctx, cmd); err != nil { + panic(errors.Wrap(err, "could not execute command")) } data := extendTemplateData(w, r, template.Data{}) diff --git a/internal/route/mount.go b/internal/route/mount.go index 43d34ef..9b28ccb 100644 --- a/internal/route/mount.go +++ b/internal/route/mount.go @@ -1,10 +1,7 @@ package route import ( - "log" - "forge.cadoles.com/wpetit/hydra-passwordless/internal/config" - "forge.cadoles.com/wpetit/hydra-passwordless/oidc" "github.com/go-chi/chi" "github.com/gorilla/csrf" @@ -31,20 +28,9 @@ func Mount(r *chi.Mux, config *config.Config) error { r.Post("/login", handleLoginForm) r.Get("/logout", serveLogoutPage) r.Get("/consent", serveConsentPage) + r.Get("/verify", handleVerification) }) - if config.TestApp.Enabled { - log.Println("test app enabled") - - r.Route("/test", func(r chi.Router) { - r.Group(func(r chi.Router) { - r.Use(oidc.Middleware) - - r.Get("/", serveTestAppHomePage) - }) - }) - } - notFoundHandler := r.NotFoundHandler() r.Get("/*", static.Dir(config.HTTP.PublicDir, "", notFoundHandler)) diff --git a/internal/route/test_app.go b/internal/route/test_app.go deleted file mode 100644 index ba57d32..0000000 --- a/internal/route/test_app.go +++ /dev/null @@ -1,23 +0,0 @@ -package route - -import ( - "log" - "net/http" - - "github.com/pkg/errors" - "gitlab.com/wpetit/goweb/middleware/container" - "gitlab.com/wpetit/goweb/service/template" -) - -func serveTestAppHomePage(w http.ResponseWriter, r *http.Request) { - ctn := container.Must(r.Context()) - tmpl := template.Must(ctn) - - data := extendTemplateData(w, r, template.Data{}) - - log.Println("rendering test app home") - - if err := tmpl.RenderPage(w, "home.html.tmpl", data); err != nil { - panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path)) - } -} diff --git a/internal/route/verify.go b/internal/route/verify.go new file mode 100644 index 0000000..70b6cb0 --- /dev/null +++ b/internal/route/verify.go @@ -0,0 +1,55 @@ +package route + +import ( + "net/http" + + "forge.cadoles.com/wpetit/hydra-passwordless/internal/hydra" + "forge.cadoles.com/wpetit/hydra-passwordless/internal/query" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/cqrs" + "gitlab.com/wpetit/goweb/logger" + "gitlab.com/wpetit/goweb/middleware/container" +) + +func handleVerification(w http.ResponseWriter, r *http.Request) { + ctn := container.Must(r.Context()) + bus := cqrs.Must(ctn) + + token := r.URL.Query().Get("token") + if token == "" { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + + return + } + + qry := &query.VerifyUserRequest{ + Token: token, + } + + ctx := r.Context() + + result, err := bus.Query(ctx, qry) + if err != nil { + logger.Error(ctx, "could not verify token", logger.E(err)) + + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + } + + verifyUserData, ok := result.Data().(*query.VerifyUserData) + if !ok { + panic(errors.New("unexpected result data")) + } + + hydr := hydra.Must(ctn) + + accept := &hydra.AcceptLoginRequest{ + Subject: verifyUserData.Email, + } + + res, err := hydr.AcceptLoginRequest(verifyUserData.Challenge, accept) + if err != nil { + panic(errors.Wrap(err, "could not retrieve hydra accept response")) + } + + http.Redirect(w, r, res.RedirectTo, http.StatusSeeOther) +} diff --git a/internal/token/generate.go b/internal/token/generate.go new file mode 100644 index 0000000..d4d8151 --- /dev/null +++ b/internal/token/generate.go @@ -0,0 +1,56 @@ +package token + +import ( + "github.com/pkg/errors" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +const ( + jwtIssuer = "hydra-passwordless" +) + +type privateClaims struct { + Challenge string `json:"challenge"` +} + +func Generate(signingKey, encryptionKey, email, challenge string) (string, error) { + sig, err := jose.NewSigner( + jose.SigningKey{ + Algorithm: jose.HS256, + Key: []byte(signingKey), + }, + (&jose.SignerOptions{}).WithType("JWT"), + ) + if err != nil { + return "", errors.Wrap(err, "could not create jwt signer") + } + + enc, err := jose.NewEncrypter( + jose.A256GCM, + jose.Recipient{ + Algorithm: jose.DIRECT, + Key: []byte(encryptionKey), + }, + (&jose.EncrypterOptions{}).WithType("JWT").WithContentType("JWT")) + if err != nil { + return "", errors.Wrap(err, "could not create jwt encrypter") + } + + claims := jwt.Claims{ + Subject: email, + Issuer: jwtIssuer, + Audience: jwt.Audience{jwtIssuer}, + } + + privateClaims := privateClaims{ + Challenge: challenge, + } + + raw, err := jwt.SignedAndEncrypted(sig, enc).Claims(claims).Claims(privateClaims).CompactSerialize() + if err != nil { + return "", errors.Wrap(err, "could not sign and encrypt jwt") + } + + return raw, nil +} diff --git a/internal/token/verify.go b/internal/token/verify.go new file mode 100644 index 0000000..cd22868 --- /dev/null +++ b/internal/token/verify.go @@ -0,0 +1,27 @@ +package token + +import ( + "github.com/pkg/errors" + "gopkg.in/square/go-jose.v2/jwt" +) + +func Verify(signingKey, encryptionKey, raw string) (string, string, error) { + token, err := jwt.ParseSignedAndEncrypted(raw) + if err != nil { + return "", "", errors.Wrap(err, "could not parse token") + } + + nested, err := token.Decrypt([]byte(encryptionKey)) + if err != nil { + return "", "", errors.Wrap(err, "could not decrypt token") + } + + baseClaims := jwt.Claims{} + privateClaims := privateClaims{} + + if err := nested.Claims([]byte(signingKey), &baseClaims, &privateClaims); err != nil { + return "", "", errors.Wrap(err, "could not validate claims") + } + + return baseClaims.Subject, privateClaims.Challenge, nil +} diff --git a/misc/containers/hydra/Dockerfile b/misc/containers/hydra/Dockerfile new file mode 100644 index 0000000..f7eabe6 --- /dev/null +++ b/misc/containers/hydra/Dockerfile @@ -0,0 +1,16 @@ +FROM oryd/hydra:v1.4.2-alpine + +USER root + +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint +RUN chmod a+x /usr/local/bin/docker-entrypoint + +COPY first-run.sh /usr/local/bin/docker-first-run +RUN chmod a+x /usr/local/bin/docker-first-run + +RUN mkdir -p /home/ory && chown -R ory: /home/ory + +USER ory + +ENTRYPOINT ["/usr/local/bin/docker-entrypoint"] +CMD ["hydra", "serve", "all"] \ No newline at end of file diff --git a/misc/containers/hydra/docker-entrypoint.sh b/misc/containers/hydra/docker-entrypoint.sh new file mode 100644 index 0000000..86526e6 --- /dev/null +++ b/misc/containers/hydra/docker-entrypoint.sh @@ -0,0 +1,14 @@ +#!/bin/sh + +set -xeo pipefail + +LIFECYCLEFLAGS_DIR="$HOME/.container-lifecycle" + +mkdir -p "$LIFECYCLEFLAGS_DIR" + +if [ ! -f "$LIFECYCLEFLAGS_DIR/first-run" ]; then + /usr/local/bin/docker-first-run + touch "$LIFECYCLEFLAGS_DIR/first-run" +fi + +exec "$@" \ No newline at end of file diff --git a/misc/containers/hydra/first-run.sh b/misc/containers/hydra/first-run.sh new file mode 100644 index 0000000..dab27cd --- /dev/null +++ b/misc/containers/hydra/first-run.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +hydra migrate sql -e -y \ No newline at end of file diff --git a/oidc/client.go b/oidc/client.go deleted file mode 100644 index 577144a..0000000 --- a/oidc/client.go +++ /dev/null @@ -1,101 +0,0 @@ -package oidc - -import ( - "net/http" - - "github.com/coreos/go-oidc" - "github.com/dchest/uniuri" - "github.com/pkg/errors" - "gitlab.com/wpetit/goweb/middleware/container" - "gitlab.com/wpetit/goweb/service/session" - "golang.org/x/oauth2" -) - -type Client struct { - oauth2 *oauth2.Config - provider *oidc.Provider - verifier *oidc.IDTokenVerifier -} - -func (c *Client) Verifier() *oidc.IDTokenVerifier { - return c.verifier -} - -func (c *Client) Provider() *oidc.Provider { - return c.provider -} - -func (c *Client) Redirect(w http.ResponseWriter, r *http.Request) { - ctn := container.Must(r.Context()) - - sess, err := session.Must(ctn).Get(w, r) - if err != nil { - panic(errors.Wrap(err, "could not retrieve session")) - } - - state := uniuri.New() - - sess.Set(SessionOIDCStateKey, state) - - if err := sess.Save(w, r); err != nil { - panic(errors.Wrap(err, "could not save session")) - } - - http.Redirect(w, r, c.oauth2.AuthCodeURL(state), http.StatusFound) -} - -func (c *Client) Validate(w http.ResponseWriter, r *http.Request) (*oidc.IDToken, error) { - ctx := r.Context() - ctn := container.Must(ctx) - - sess, err := session.Must(ctn).Get(w, r) - if err != nil { - return nil, errors.Wrap(err, "could not retrieve session") - } - - state, ok := sess.Get(SessionOIDCStateKey).(string) - if !ok { - return nil, errors.New("invalid state") - } - - if r.URL.Query().Get("state") != state { - return nil, errors.New("state mismatch") - } - - code := r.URL.Query().Get("code") - - token, err := c.oauth2.Exchange(ctx, code) - if err != nil { - return nil, errors.Wrap(err, "could not exchange token") - } - - rawIDToken, ok := token.Extra("id_token").(string) - if !ok { - return nil, errors.New("could not find id token") - } - - idToken, err := c.verifier.Verify(ctx, rawIDToken) - if err != nil { - return nil, errors.Wrap(err, "could not verify id token") - } - - return idToken, nil -} - -func NewClient(opts ...OptionFunc) *Client { - opt := fromDefault(opts...) - - oauth2 := &oauth2.Config{ - ClientID: opt.ClientID, - ClientSecret: opt.ClientSecret, - Endpoint: opt.Provider.Endpoint(), - RedirectURL: opt.RedirectURL, - Scopes: opt.Scopes, - } - - verifier := opt.Provider.Verifier(&oidc.Config{ - ClientID: opt.ClientID, - }) - - return &Client{oauth2, opt.Provider, verifier} -} diff --git a/oidc/middleware.go b/oidc/middleware.go deleted file mode 100644 index c7a4662..0000000 --- a/oidc/middleware.go +++ /dev/null @@ -1,52 +0,0 @@ -package oidc - -import ( - "log" - "net/http" - - "github.com/coreos/go-oidc" - "github.com/pkg/errors" - "gitlab.com/wpetit/goweb/middleware/container" - "gitlab.com/wpetit/goweb/service/session" -) - -const ( - SessionOIDCTokenKey = "oidc-token" - SessionOIDCStateKey = "oidc-state" -) - -func Middleware(next http.Handler) http.Handler { - fn := func(w http.ResponseWriter, r *http.Request) { - if _, err := IDToken(w, r); err != nil { - ctn := container.Must(r.Context()) - - log.Println("retrieving oidc client") - - client := Must(ctn) - - client.Redirect(w, r) - - return - } - - next.ServeHTTP(w, r) - } - - return http.HandlerFunc(fn) -} - -func IDToken(w http.ResponseWriter, r *http.Request) (*oidc.IDToken, error) { - ctn := container.Must(r.Context()) - - sess, err := session.Must(ctn).Get(w, r) - if err != nil { - return nil, errors.Wrap(err, "could not retrieve session") - } - - idToken, ok := sess.Get(SessionOIDCTokenKey).(*oidc.IDToken) - if !ok || idToken == nil { - return nil, errors.New("invalid id token") - } - - return idToken, nil -} diff --git a/oidc/option.go b/oidc/option.go deleted file mode 100644 index b7492fd..0000000 --- a/oidc/option.go +++ /dev/null @@ -1,52 +0,0 @@ -package oidc - -import ( - "context" - - "github.com/coreos/go-oidc" -) - -type OptionFunc func(*Option) - -type Option struct { - Provider *oidc.Provider - ClientID string - ClientSecret string - RedirectURL string - Scopes []string -} - -func WithCredentials(clientID, clientSecret string) OptionFunc { - return func(opt *Option) { - opt.ClientID = clientID - opt.ClientSecret = clientSecret - } -} - -func WithScopes(scopes ...string) OptionFunc { - return func(opt *Option) { - opt.Scopes = scopes - } -} - -func NewProvider(ctx context.Context, issuer string) (*oidc.Provider, error) { - return oidc.NewProvider(ctx, issuer) -} - -func WithProvider(provider *oidc.Provider) OptionFunc { - return func(opt *Option) { - opt.Provider = provider - } -} - -func fromDefault(funcs ...OptionFunc) *Option { - opt := &Option{ - Scopes: []string{oidc.ScopeOpenID}, - } - - for _, f := range funcs { - f(opt) - } - - return opt -} diff --git a/oidc/provider.go b/oidc/provider.go deleted file mode 100644 index 3040e67..0000000 --- a/oidc/provider.go +++ /dev/null @@ -1,11 +0,0 @@ -package oidc - -import "gitlab.com/wpetit/goweb/service" - -func ServiceProvider(opts ...OptionFunc) service.Provider { - client := NewClient(opts...) - - return func(ctn *service.Container) (interface{}, error) { - return client, nil - } -} diff --git a/oidc/service.go b/oidc/service.go deleted file mode 100644 index e6abf8b..0000000 --- a/oidc/service.go +++ /dev/null @@ -1,33 +0,0 @@ -package oidc - -import ( - "github.com/pkg/errors" - "gitlab.com/wpetit/goweb/service" -) - -const ServiceName service.Name = "oidc" - -// From retrieves the oidc service in the given container -func From(container *service.Container) (*Client, error) { - service, err := container.Service(ServiceName) - if err != nil { - return nil, errors.Wrapf(err, "error while retrieving '%s' service", ServiceName) - } - - srv, ok := service.(*Client) - if !ok { - return nil, errors.Errorf("retrieved service is not a valid '%s' service", ServiceName) - } - - return srv, nil -} - -// Must retrieves the oidc service in the given container or panic otherwise -func Must(container *service.Container) *Client { - srv, err := From(container) - if err != nil { - panic(err) - } - - return srv -} diff --git a/scaffold.yml b/scaffold.yml deleted file mode 100644 index 3263f9a..0000000 --- a/scaffold.yml +++ /dev/null @@ -1,14 +0,0 @@ -version: 1 -vars: - - type: string - name: ProjectName - description: Project Name - constraints: - - rule: Input == "" - message: The project name cannot be empty. - - type: string - name: ProjectNamespace - description: The Go module namespace - constraints: - - rule: Input == "" - message: The module namespace cannot be empty.