diff --git a/Makefile b/Makefile index 9bc4418..5f5af00 100644 --- a/Makefile +++ b/Makefile @@ -19,23 +19,20 @@ watch: lint: golangci-lint run --enable-all -hydra: - docker run \ - --rm -it \ - --name hydra-passwordless \ - -e DSN=memory \ - -e URLS_LOGIN=http://localhost:3000/login \ - -e URLS_CONSENT=http://localhost:3000/consent \ - -p 4444:4444 \ - -p 4445:4445 \ - oryd/hydra:v1.4.2-alpine \ - serve all \ - --dangerous-force-http +up: + docker-compose up --build + +down: + docker-compose down -v --remove-orphans create-client: - docker exec -it hydra-passwordless \ + docker-compose exec hydra \ sh -c 'HYDRA_URL=http://localhost:4445 hydra clients create -c http://localhost:3000/test/oauth2/callback' +list-clients: + docker-compose exec hydra \ + sh -c 'HYDRA_URL=http://localhost:4445 hydra clients list' + clean: rm -rf release rm -rf data diff --git a/cmd/server/container.go b/cmd/server/container.go index 1cd8cd3..8db4dc8 100644 --- a/cmd/server/container.go +++ b/cmd/server/container.go @@ -10,6 +10,7 @@ import ( "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" "github.com/gorilla/sessions" "github.com/pkg/errors" @@ -93,5 +94,11 @@ func getServiceContainer(conf *config.Config) (*service.Container, error) { ctn.Provide(hydra.ServiceName, hydra.ServiceProvider(conf.Hydra.BaseURL, 30*time.Second)) + ctn.Provide(mail.ServiceName, mail.ServiceProvider( + mail.WithServer(conf.SMTP.Host, conf.SMTP.Port), + mail.WithCredentials(conf.SMTP.User, conf.SMTP.Password), + mail.WithTLS(conf.SMTP.UseStartTLS, conf.SMTP.InsecureSkipVerify), + )) + return ctn, nil } diff --git a/cmd/server/template/blocks/email.html.tmpl b/cmd/server/template/blocks/email.html.tmpl new file mode 100644 index 0000000..9fb2eb2 --- /dev/null +++ b/cmd/server/template/blocks/email.html.tmpl @@ -0,0 +1,332 @@ +{{define "email"}} + + + + + + + + {{- block "title" . -}}{{- end -}} + + + + + + + + + +
  +
+ + {{- block "title" . -}}{{- end -}} + + + {{- block "content" . -}}{{- end -}} + +
+ + + + +
+
 
+ + +{{end}} \ No newline at end of file diff --git a/cmd/server/template/layouts/login.html.tmpl b/cmd/server/template/layouts/login.html.tmpl index e51b45f..3777b93 100644 --- a/cmd/server/template/layouts/login.html.tmpl +++ b/cmd/server/template/layouts/login.html.tmpl @@ -5,6 +5,7 @@
+ {{template "flash" .}}

Connexion

@@ -16,11 +17,12 @@
{{ .csrfField }} +
diff --git a/cmd/server/template/layouts/verification_email.html.tmpl b/cmd/server/template/layouts/verification_email.html.tmpl new file mode 100644 index 0000000..fce611d --- /dev/null +++ b/cmd/server/template/layouts/verification_email.html.tmpl @@ -0,0 +1,30 @@ +{{define "content"}} + + + + + + +
+

Bonjour {{ .Email }}

+

Vous avez demandé à accéder à l'application "{{ .AppTitle }}". Cliquez sur le lien ci dessous pour vous authentifier.

+ + + + + + +
+ + + + + + +
Accéder à l'application
+
+
+ + +{{end}} +{{template "email" .}} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5226b9e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: '2.4' +services: + hydra: + image: oryd/hydra:v1.4.2-alpine + environment: + DSN: memory + URLS_LOGIN: http://localhost:3000/login + URLS_CONSENT: http://localhost:3000/consent + ports: + - 4444:4444 + - 4445:4445 + command: serve all --dangerous-force-http + smtp: + image: bornholm/fake-smtp + ports: + - 3001:8080 + - 2525:2525 + volumes: + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro diff --git a/go.mod b/go.mod index 140091b..84c2767 100644 --- a/go.mod +++ b/go.mod @@ -11,10 +11,9 @@ require ( 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-20200317131025-42aba649c833 + gitlab.com/wpetit/goweb v0.0.0-20200415164411-636b2dbf8ff7 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect - gopkg.in/dgrijalva/jwt-go.v3 v3.2.0 // indirect gopkg.in/mail.v2 v2.3.1 gopkg.in/square/go-jose.v2 v2.4.1 // indirect gopkg.in/yaml.v2 v2.2.8 diff --git a/go.sum b/go.sum index dc869a2..3f9a028 100644 --- a/go.sum +++ b/go.sum @@ -40,7 +40,6 @@ github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k 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/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/go-chi/chi v1.0.0 h1:s/kv1cTXfivYjdKJdyUzNGyAWZ/2t7duW1gKn5ivu+c= 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= github.com/go-chi/chi v4.1.0+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= @@ -83,8 +82,10 @@ github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +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= @@ -100,6 +101,7 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.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/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 h1:J9b7z+QKAmPf4YLrFg6oQUotqHQeUNWwkvo7jZp1GLU= github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35/go.mod h1:prYjPmNq4d1NPVmpShWobRqXY3q7Vp+80DqgxxUrUIA= @@ -109,11 +111,12 @@ github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAm github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 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-20200317131025-42aba649c833 h1:e2HXOwLZOcurBeqA6XwIdXNLZwGN6oXHBhPdhnBrEq8= -gitlab.com/wpetit/goweb v0.0.0-20200317131025-42aba649c833/go.mod h1:wqXhN3jywegFzw33pEFAEbsXnshFx0nJ+aXTi4pCtIQ= +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= 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/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -175,6 +178,7 @@ golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7w 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= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -224,9 +228,8 @@ google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQ gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/dgrijalva/jwt-go.v3 v3.2.0 h1:N46iQqOtHry7Hxzb9PGrP68oovQmj7EhudNoKHvbOvI= -gopkg.in/dgrijalva/jwt-go.v3 v3.2.0/go.mod h1:hdNXC2Z9yC029rvsQ/on2ZNQ44Z2XToVhpXXbR+J05A= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= diff --git a/internal/config/config.go b/internal/config/config.go index ad39bc2..ee65bd6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -57,6 +57,8 @@ type SMTPConfig struct { User string `yaml:"user"` Password string `yaml:"password"` InsecureSkipVerify bool `yaml:"insecureSkipVerify"` + SenderAddress string `yaml:"senderAddress"` + SenderName string `yaml:"senderName"` } type HydraConfig struct { @@ -83,9 +85,16 @@ func NewDefault() *Config { IssuerURL: "http://localhost:4444/", RedirectURL: "http://localhost:3000/test/oauth2/callback", }, - SMTP: SMTPConfig{}, + SMTP: SMTPConfig{ + Host: "localhost", + Port: 2525, + User: "hydra-passwordless", + Password: "hydra-passwordless", + SenderAddress: "noreply@localhost", + SenderName: "noreply", + }, Hydra: HydraConfig{ - BaseURL: "http://localhost:4444/", + BaseURL: "http://localhost:4445/", }, } } diff --git a/internal/hydra/client.go b/internal/hydra/client.go index 86847eb..28b58d8 100644 --- a/internal/hydra/client.go +++ b/internal/hydra/client.go @@ -1,25 +1,72 @@ package hydra import ( + "bytes" + "encoding/json" "net/http" + "net/url" "time" + + "github.com/pkg/errors" ) type Client struct { - baseURL string + baseURL *url.URL http *http.Client } func (c *Client) LoginRequest(challenge string) (*LoginResponse, error) { - return nil, nil + u := fromURL(*c.baseURL, "/oauth2/auth/requests/login", url.Values{ + "login_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) + loginRes := &LoginResponse{} + + if err := decoder.Decode(loginRes); err != nil { + return nil, errors.Wrap(err, "could not decode json response") + } + + return loginRes, nil } -func (c *Client) Accept(challenge string) (*AcceptResponse, error) { - return nil, nil +func (c *Client) AcceptRequest(challenge string, req *AcceptRequest) (*AcceptResponse, error) { + u := fromURL(*c.baseURL, "/oauth2/auth/requests/accept", url.Values{ + "login_challenge": []string{challenge}, + }) + + res := &AcceptResponse{} + + if err := c.putJSON(u, req, res); err != nil { + return nil, err + } + + return res, nil } -func (c *Client) RejectRequest(challenge string) (*RejectResponse, error) { - return nil, nil +func (c *Client) RejectRequest(challenge string, req *RejectRequest) (*RejectResponse, error) { + u := fromURL(*c.baseURL, "/oauth2/auth/requests/reject", url.Values{ + "login_challenge": []string{challenge}, + }) + + res := &RejectResponse{} + + if err := c.putJSON(u, req, res); err != nil { + return nil, err + } + + return res, nil } func (c *Client) LogoutRequest(challenge string) (*LogoutResponse, error) { @@ -51,7 +98,47 @@ func (c *Client) challenge(r *http.Request, name string) (string, error) { return challenge, nil } -func NewClient(baseURL string, httpTimeout time.Duration) *Client { +func (c *Client) putJSON(u string, payload interface{}, result interface{}) error { + var buf bytes.Buffer + + encoder := json.NewEncoder(&buf) + if err := encoder.Encode(payload); err != nil { + return errors.Wrap(err, "could not encode request body") + } + + req, err := http.NewRequest("PUT", u, &buf) + if err != nil { + return errors.Wrap(err, "could not create request") + } + + res, err := c.http.Do(req) + if err != nil { + return errors.Wrap(err, "could not retrieve login response") + } + + if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest { + return errors.Wrapf(ErrUnexpectedHydraResponse, "hydra responded with status code '%d'", res.StatusCode) + } + + defer res.Body.Close() + + decoder := json.NewDecoder(res.Body) + + if err := decoder.Decode(result); err != nil { + return errors.Wrap(err, "could not decode json response") + } + + return nil +} + +func fromURL(url url.URL, path string, query url.Values) string { + url.Path = path + url.RawQuery = query.Encode() + + return url.String() +} + +func NewClient(baseURL *url.URL, httpTimeout time.Duration) *Client { return &Client{ baseURL: baseURL, http: &http.Client{ diff --git a/internal/hydra/error.go b/internal/hydra/error.go index bb93540..8e08555 100644 --- a/internal/hydra/error.go +++ b/internal/hydra/error.go @@ -3,5 +3,6 @@ package hydra import "errors" var ( - ErrChallengeNotFound = errors.New("challenge not found") + ErrUnexpectedHydraResponse = errors.New("unexpected hydra response") + ErrChallengeNotFound = errors.New("challenge not found") ) diff --git a/internal/hydra/provider.go b/internal/hydra/provider.go index 6d37de0..195038e 100644 --- a/internal/hydra/provider.go +++ b/internal/hydra/provider.go @@ -1,15 +1,30 @@ package hydra import ( + "net/url" "time" + "github.com/pkg/errors" "gitlab.com/wpetit/goweb/service" ) -func ServiceProvider(baseURL string, httpTimeout time.Duration) service.Provider { +func ServiceProvider(rawBaseURL string, httpTimeout time.Duration) service.Provider { + var ( + baseURL *url.URL + err error + ) + + baseURL, err = url.Parse(rawBaseURL) + if err != nil { + err = errors.Wrap(err, "could not parse base url") + } + client := NewClient(baseURL, httpTimeout) return func(ctn *service.Container) (interface{}, error) { + if err != nil { + return nil, err + } return client, nil } } diff --git a/internal/hydra/request.go b/internal/hydra/request.go new file mode 100644 index 0000000..2a30007 --- /dev/null +++ b/internal/hydra/request.go @@ -0,0 +1,13 @@ +package hydra + +type AcceptRequest struct { + Subject string `json:"subject"` + Remember bool `json:"remember"` + RememberFor int `json:"remember_for"` + ACR string `json:"acr"` +} + +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 3c9aeab..bfbf5d0 100644 --- a/internal/hydra/response.go +++ b/internal/hydra/response.go @@ -1,12 +1,69 @@ package hydra +import "time" + +// https://www.ory.sh/hydra/docs/reference/api#get-a-login-request + +type ClientResponseFragment struct { + AllowCORSOrigins []string `json:"allowed_cors_origins"` + Audience []string `json:"audience"` + BackChannelLogoutSessionRequired bool `json:"backchannel_logout_session_required"` + BackChannelLogoutURI string `json:"backchannel_logout_uri"` + ClientID string `json:"client_id"` + ClientName string `json:"client_name"` + ClientSecret string `json:"client_secret"` + ClientSecretExpiresAt int `json:"client_secret_expires_at"` + ClientURI string `json:"client_uri"` + Contacts []string `json:"contacts"` + CreatedAt time.Time `json:"created_at"` + FrontChannelLogoutSessionRequired bool `json:"frontchannel_logout_session_required"` + FrontChannelLogoutURL string `json:"frontchannel_logout_uri"` + GrantTypes []string `json:"grant_types"` + JWKS map[string]interface{} `json:"jwks"` + JwksURI string `json:"jwks_uri"` + LogoURI string `json:"logo_uri"` + Metadata map[string]interface{} `json:"metadata"` + Owner string `json:"owner"` + PolicyURI string `json:"policy_uri"` + PostLogoutRedirectURIs []string `json:"post_logout_redirect_uris"` + RedirectURIs []string `json:"redirect_uris"` + RequestObjectSigningAlg string `json:"request_object_signing_alg"` + RequestURIs []string `json:"request_uris"` + ResponseTypes []string `json:"response_types"` + Scope string `json:"scope"` + SectorIdentifierURI string `json:"sector_identifier_uri"` + SubjectType string `json:"subject_type"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` + TosURI string `json:"tos_uri"` + UpdatedAt time.Time `json:"updated_at"` + UserInfoSignedResponseAlg string `json:"userinfo_signed_response_alg"` +} + +type OidcContextResponseFragment struct { + ACRValues []string `json:"acr_values"` + Display string `json:"display"` + IDTokenHintClaims map[string]interface{} `json:"id_token_hint_claims"` + LoginHint string `json:"login_hint"` + UILocales []string `json:"ui_locales"` +} + type LoginResponse 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"` } type AcceptResponse struct { } type RejectResponse struct { + RedirectTo string `json:"redirect_to"` } type LogoutResponse struct { diff --git a/internal/mail/mailer.go b/internal/mail/mailer.go index 822196b..b1dbf3b 100644 --- a/internal/mail/mailer.go +++ b/internal/mail/mailer.go @@ -1,11 +1,17 @@ package mail +const ( + ContentTypeHTML = "text/html" + ContentTypeText = "text/plain" +) + type Option struct { Host string Port int User string Password string InsecureSkipVerify bool + UseStartTLS bool } type OptionFunc func(*Option) @@ -14,6 +20,33 @@ type Mailer struct { opt *Option } -func NewMailer(funcs ...OptionFunc) *Mailer { - return &Mailer{} +func WithTLS(useStartTLS, insecureSkipVerify bool) OptionFunc { + return func(opt *Option) { + opt.UseStartTLS = useStartTLS + opt.InsecureSkipVerify = insecureSkipVerify + } +} + +func WithServer(host string, port int) OptionFunc { + return func(opt *Option) { + opt.Host = host + opt.Port = port + } +} + +func WithCredentials(user, password string) OptionFunc { + return func(opt *Option) { + opt.User = user + opt.Password = password + } +} + +func NewMailer(funcs ...OptionFunc) *Mailer { + opt := &Option{} + + for _, fn := range funcs { + fn(opt) + } + + return &Mailer{opt} } diff --git a/internal/mail/send.go b/internal/mail/send.go index 752a2e8..05da094 100644 --- a/internal/mail/send.go +++ b/internal/mail/send.go @@ -40,10 +40,14 @@ func WithCharset(charset string) func(*SendOption) { } } -func WithFrom(address string, name string) func(*SendOption) { +func WithSender(address string, name string) func(*SendOption) { return WithAddressHeader("From", address, name) } +func WithSubject(subject string) func(*SendOption) { + return WithHeader("Subject", subject) +} + func WithAddressHeader(field, address, name string) func(*SendOption) { return func(opt *SendOption) { opt.AddressHeaders = append(opt.AddressHeaders, AddressHeader{field, address, name}) @@ -56,14 +60,32 @@ func WithHeader(field string, values ...string) func(*SendOption) { } } +func WithRecipients(addresses ...string) func(*SendOption) { + return WithHeader("To", addresses...) +} + +func WithCopies(addresses ...string) func(*SendOption) { + return WithHeader("Cc", addresses...) +} + +func WithInvisibleCopies(addresses ...string) func(*SendOption) { + return WithHeader("Cci", addresses...) +} + func WithBody(contentType string, content string, setting gomail.PartSetting) func(*SendOption) { return func(opt *SendOption) { + if setting == nil { + setting = gomail.SetPartEncoding(gomail.Unencoded) + } opt.Body = Body{contentType, content, setting} } } func WithAlternativeBody(contentType string, content string, setting gomail.PartSetting) func(*SendOption) { return func(opt *SendOption) { + if setting == nil { + setting = gomail.SetPartEncoding(gomail.Unencoded) + } opt.AlternativeBodies = append(opt.AlternativeBodies, Body{contentType, content, setting}) } } diff --git a/internal/route/login.go b/internal/route/login.go index f40cf42..6db368a 100644 --- a/internal/route/login.go +++ b/internal/route/login.go @@ -1,14 +1,20 @@ 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/hydra" + "forge.cadoles.com/wpetit/hydra-passwordless/internal/mail" "github.com/gorilla/csrf" "github.com/pkg/errors" "gitlab.com/wpetit/goweb/middleware/container" + "gitlab.com/wpetit/goweb/service/session" "gitlab.com/wpetit/goweb/service/template" ) @@ -32,12 +38,25 @@ func serveLoginPage(w http.ResponseWriter, r *http.Request) { panic(errors.Wrap(err, "could not retrieve hydra login response")) } - spew.Dump(res) + if res.Skip { + res, err := hydr.RejectRequest(challenge, &hydra.RejectRequest{ + Error: "email_not_validated", + ErrorDescription: "The email adress could not be verified.", + }) + if err != nil { + panic(errors.Wrap(err, "could not reject hydra authentication request")) + } + + http.Redirect(w, r, res.RedirectTo, http.StatusTemporaryRedirect) + return + } tmpl := template.Must(ctn) data := extendTemplateData(w, r, template.Data{ csrf.TemplateTag: csrf.TemplateField(r), + "LoginChallenge": challenge, + "Email": "", }) if err := tmpl.RenderPage(w, "login.html.tmpl", data); err != nil { @@ -48,6 +67,77 @@ func serveLoginPage(w http.ResponseWriter, r *http.Request) { func handleLoginForm(w http.ResponseWriter, r *http.Request) { ctn := container.Must(r.Context()) tmpl := template.Must(ctn) + hydr := hydra.Must(ctn) + + if err := r.ParseForm(); err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + + return + } + + email := r.Form.Get("email") + challenge := r.Form.Get("challenge") + + renderFlashError := func(message string) { + sess, err := session.Must(ctn).Get(w, r) + if err != nil { + panic(errors.Wrap(err, "could not retrieve session")) + } + + sess.AddFlash(session.FlashError, message) + + if err := sess.Save(w, r); err != nil { + panic(errors.Wrap(err, "could not save session")) + } + + data := extendTemplateData(w, r, template.Data{ + csrf.TemplateTag: csrf.TemplateField(r), + "LoginChallenge": challenge, + "Email": email, + }) + + if err := tmpl.RenderPage(w, "login.html.tmpl", data); err != nil { + panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path)) + } + } + + if _, err := netMail.ParseAddress(email); err != nil { + renderFlashError("Veuillez saisir une adresse courriel valide") + + return + } + + if challenge == "" { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + + return + } + + res, err := hydr.LoginRequest(challenge) + if err != nil { + panic(errors.Wrap(err, "could not retrieve hydra login response")) + } + + 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")) + } data := extendTemplateData(w, r, template.Data{}) diff --git a/modd.conf b/modd.conf index 7a9944f..8b5ca34 100644 --- a/modd.conf +++ b/modd.conf @@ -10,4 +10,8 @@ modd.conf { **/*.go { prep: make test +} + +docker-compose.yml { + daemon: make up } \ No newline at end of file