diff --git a/Makefile b/Makefile index 374e0bd..d3b07b4 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,9 @@ create-default-client: hydra \ hydra clients create \ -c http://localhost:3002/oauth2/callback \ - --post-logout-callbacks http://localhost:3002 + --post-logout-callbacks http://localhost:3002 \ + -n "Default App" \ + -a "openid" -a "email" list-clients: diff --git a/cmd/server/template/layouts/email_sent.html.tmpl b/cmd/server/template/layouts/email_sent.html.tmpl index b7923b4..a5e4640 100644 --- a/cmd/server/template/layouts/email_sent.html.tmpl +++ b/cmd/server/template/layouts/email_sent.html.tmpl @@ -1,4 +1,4 @@ -{{define "title"}}Connexion{{end}} +{{define "title"}}Courriel envoyé.{{end}} {{define "body"}}
diff --git a/cmd/server/template/layouts/error.html.tmpl b/cmd/server/template/layouts/error.html.tmpl new file mode 100644 index 0000000..c2f1a7c --- /dev/null +++ b/cmd/server/template/layouts/error.html.tmpl @@ -0,0 +1,20 @@ +{{define "title"}}Erreur{{end}} +{{define "body"}} +
+
+
+
+
+
+
+

{{ .ErrorTitle }}

+

{{ .ErrorDescription }}

+
+
+
+
+
+
+
+{{end}} +{{template "base" .}} \ No newline at end of file diff --git a/cmd/server/template/layouts/home.html.tmpl b/cmd/server/template/layouts/home.html.tmpl new file mode 100644 index 0000000..dacdfe3 --- /dev/null +++ b/cmd/server/template/layouts/home.html.tmpl @@ -0,0 +1,22 @@ +{{define "title"}}Connexion{{end}} +{{define "body"}} +
+
+
+
+
+

+ Hydra Passwordless +

+

+ Version: {{ .BuildInfo.ProjectVersion }} | + Réf.: {{ .BuildInfo.GitRef }} | + Date de construction: {{ .BuildInfo.BuildDate }} +

+
+
+
+
+
+{{end}} +{{template "base" .}} \ 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 3777b93..b695b82 100644 --- a/cmd/server/template/layouts/login.html.tmpl +++ b/cmd/server/template/layouts/login.html.tmpl @@ -7,7 +7,7 @@
{{template "flash" .}}

- Connexion + Connexion à {{ .ClientName }}

Veuillez entrer votre adresse courriel. @@ -21,6 +21,14 @@ name="email" placeholder="john.doe@email.com" />

+
+

+ +

+
{{ .csrfField }} diff --git a/cmd/server/template/layouts/verification_email.html.tmpl b/cmd/server/template/layouts/verification_email.html.tmpl index fce611d..2890fb4 100644 --- a/cmd/server/template/layouts/verification_email.html.tmpl +++ b/cmd/server/template/layouts/verification_email.html.tmpl @@ -4,8 +4,8 @@
-

Bonjour {{ .Email }}

-

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

+

Bonjour,

+

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

diff --git a/docker-compose.yml b/docker-compose.yml index 6b532d6..cf672a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,10 @@ services: DSN: mysql://hydra:hydra@tcp(mysql:3306)/hydra URLS_LOGIN: http://localhost:3000/login URLS_CONSENT: http://localhost:3000/consent + URLS_LOGOUT: http://localhost:3000/logout + SUPPORTED_SCOPES: email + SUPPORTED_CLAIMS: email,email_verified + SECRETS_SYSTEM: fAAya66yXNib52lbXpo16bxy1jD4NZrX ports: - 4444:4444 - 4445:4445 diff --git a/internal/command/send_confirmation_email.go b/internal/command/send_confirmation_email.go index 7552169..3c0b098 100644 --- a/internal/command/send_confirmation_email.go +++ b/internal/command/send_confirmation_email.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "log" "forge.cadoles.com/wpetit/hydra-passwordless/internal/config" "forge.cadoles.com/wpetit/hydra-passwordless/internal/hydra" @@ -23,6 +22,9 @@ type SendConfirmationEmailRequest struct { Challenge string DefaultScheme string DefaultAddress string + RememberMe bool + ClientName string + ClientURI string } func HandleSendConfirmationEmailRequest(ctx context.Context, cmd cqrs.Command) error { @@ -48,6 +50,7 @@ func HandleSendConfirmationEmailRequest(ctx context.Context, cmd cqrs.Command) e conf.HTTP.TokenEncryptionKey, req.Email, req.Challenge, + req.RememberMe, ) if err != nil { return errors.Wrap(err, "could not generate jwt") @@ -67,13 +70,11 @@ func HandleSendConfirmationEmailRequest(ctx context.Context, cmd cqrs.Command) e 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{ + "ClientName": req.ClientName, + "ClientURI": req.ClientURI, "BuildInfo": info, "VerificationLink": verificationLink, } @@ -92,7 +93,7 @@ func HandleSendConfirmationEmailRequest(ctx context.Context, cmd cqrs.Command) e err = ml.Send( mail.WithSender(conf.SMTP.SenderAddress, conf.SMTP.SenderName), mail.WithRecipients(req.Email), - mail.WithSubject(fmt.Sprintf("[Authentification]")), + mail.WithSubject(fmt.Sprintf("[%s] Connexion", req.ClientName)), mail.WithBody(mail.ContentTypeHTML, html, nil), mail.WithAlternativeBody(mail.ContentTypeText, "", nil), ) diff --git a/internal/hydra/client.go b/internal/hydra/client.go index 50050ee..ebcf8e7 100644 --- a/internal/hydra/client.go +++ b/internal/hydra/client.go @@ -70,7 +70,57 @@ func (c *Client) RejectLoginRequest(challenge string, req *RejectRequest) (*Reje } func (c *Client) LogoutRequest(challenge string) (*LogoutResponse, error) { - return nil, nil + u := fromURL(*c.baseURL, "/oauth2/auth/requests/logout", url.Values{ + "logout_challenge": []string{challenge}, + }) + + res, err := c.http.Get(u) + if err != nil { + return nil, errors.Wrap(err, "could not retrieve logout 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) + logoutRes := &LogoutResponse{} + + if err := decoder.Decode(logoutRes); err != nil { + return nil, errors.Wrap(err, "could not decode json response") + } + + return logoutRes, nil +} + +func (c *Client) AcceptLogoutRequest(challenge string) (*AcceptResponse, error) { + u := fromURL(*c.baseURL, "/oauth2/auth/requests/logout/accept", url.Values{ + "logout_challenge": []string{challenge}, + }) + + res := &AcceptResponse{} + + if err := c.putJSON(u, nil, res); err != nil { + return nil, err + } + + return res, nil +} + +func (c *Client) RejectLogoutRequest(challenge string, req *RejectRequest) (*RejectResponse, error) { + u := fromURL(*c.baseURL, "/oauth2/auth/requests/logout/reject", url.Values{ + "logout_challenge": []string{challenge}, + }) + + res := &RejectResponse{} + + if err := c.putJSON(u, req, res); err != nil { + return nil, err + } + + return res, nil } func (c *Client) ConsentRequest(challenge string) (*ConsentResponse, error) { diff --git a/internal/hydra/request.go b/internal/hydra/request.go index 8a1df21..3e59d04 100644 --- a/internal/hydra/request.go +++ b/internal/hydra/request.go @@ -1,12 +1,15 @@ package hydra type AcceptLoginRequest struct { - Subject string `json:"subject"` - Remember bool `json:"remember"` - RememberFor int `json:"remember_for"` - ACR string `json:"acr"` + Subject string `json:"subject"` + Remember bool `json:"remember"` + RememberFor int `json:"remember_for"` + ACR string `json:"acr"` + Context map[string]interface{} `json:"context"` } +type AcceptLogoutRequest struct{} + type AcceptConsentRequest struct { GrantScope []string `json:"grant_scope"` GrantAccessTokenAudience []string `json:"grant_access_token_audience"` diff --git a/internal/hydra/response.go b/internal/hydra/response.go index d5647d2..5f10ded 100644 --- a/internal/hydra/response.go +++ b/internal/hydra/response.go @@ -68,6 +68,10 @@ type RejectResponse struct { } type LogoutResponse struct { + Subject string `json:"subject"` + SessionID string `json:"sid"` + RPInitiated bool `json:"rp_initiated"` + RequestURL string `json:"request_url"` } type ConsentResponse struct { @@ -80,4 +84,5 @@ type ConsentResponse struct { OidcContext OidcContextResponseFragment `json:"oidc_context"` RequestedAccessTokenAudience []string `json:"requested_access_token_audience"` SessionID string `json:"session_id"` + Context map[string]interface{} `json:"context"` } diff --git a/internal/query/verify_user.go b/internal/query/verify_user.go index 1795679..0b46246 100644 --- a/internal/query/verify_user.go +++ b/internal/query/verify_user.go @@ -15,8 +15,9 @@ type VerifyUserRequest struct { } type VerifyUserData struct { - Email string - Challenge string + Email string + Challenge string + RememberMe bool } func HandleVerifyUserRequest(ctx context.Context, qry cqrs.Query) (interface{}, error) { @@ -28,7 +29,7 @@ func HandleVerifyUserRequest(ctx context.Context, qry cqrs.Query) (interface{}, ctn := container.Must(ctx) conf := config.Must(ctn) - email, challenge, err := token.Verify( + email, challenge, rememberMe, err := token.Verify( conf.HTTP.TokenSigningKey, conf.HTTP.TokenEncryptionKey, req.Token, @@ -39,8 +40,9 @@ func HandleVerifyUserRequest(ctx context.Context, qry cqrs.Query) (interface{}, } data := &VerifyUserData{ - Email: email, - Challenge: challenge, + Email: email, + Challenge: challenge, + RememberMe: rememberMe, } return data, nil diff --git a/internal/route/consent.go b/internal/route/consent.go index e2381e5..7ff73a7 100644 --- a/internal/route/consent.go +++ b/internal/route/consent.go @@ -10,7 +10,6 @@ import ( func serveConsentPage(w http.ResponseWriter, r *http.Request) { ctn := container.Must(r.Context()) - //tmpl := template.Must(ctn) hydr := hydra.Must(ctn) challenge, err := hydr.ConsentChallenge(r) @@ -24,43 +23,29 @@ func serveConsentPage(w http.ResponseWriter, r *http.Request) { panic(errors.Wrap(err, "could not retrieve consent challenge")) } - res, err := hydr.ConsentRequest(challenge) + consentRes, 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")) - } + scopes := []string{"email"} + scopes = append(scopes, consentRes.RequestedScope...) - http.Redirect(w, r, res.RedirectTo, http.StatusTemporaryRedirect) - return + acceptConsentReq := &hydra.AcceptConsentRequest{ + GrantScope: scopes, + GrantAccessTokenAudience: consentRes.RequestedAccessTokenAudience, + Session: hydra.AcceptConsentSession{ + IDToken: map[string]interface{}{ + "email": consentRes.Context["email"], + "email_verified": true, + }, + }, } - res2, err := hydr.AcceptConsentRequest(challenge, &hydra.AcceptConsentRequest{ - GrantScope: res.RequestedScope, - GrantAccessTokenAudience: res.RequestedAccessTokenAudience, - }) + acceptRes, err := hydr.AcceptConsentRequest(challenge, acceptConsentReq) 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)) - // } + http.Redirect(w, r, acceptRes.RedirectTo, http.StatusTemporaryRedirect) } diff --git a/internal/route/helper.go b/internal/route/helper.go index cf7c774..548fe4a 100644 --- a/internal/route/helper.go +++ b/internal/route/helper.go @@ -22,3 +22,21 @@ func extendTemplateData(w http.ResponseWriter, r *http.Request, data template.Da return data } + +func renderErrorPage(w http.ResponseWriter, r *http.Request, statusCode int, title, description string) error { + ctn := container.Must(r.Context()) + tmpl := template.Must(ctn) + + data := extendTemplateData(w, r, template.Data{ + "ErrorTitle": title, + "ErrorDescription": description, + }) + + w.WriteHeader(statusCode) + + if err := tmpl.RenderPage(w, "error.html.tmpl", data); err != nil { + return errors.Wrap(err, "could not render error page") + } + + return nil +} diff --git a/internal/route/home.go b/internal/route/home.go new file mode 100644 index 0000000..5b58e36 --- /dev/null +++ b/internal/route/home.go @@ -0,0 +1,20 @@ +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 bb21f3a..1a1fa17 100644 --- a/internal/route/login.go +++ b/internal/route/login.go @@ -21,7 +21,15 @@ func serveLoginPage(w http.ResponseWriter, r *http.Request) { challenge, err := hydr.LoginChallenge(r) if err != nil { if err == hydra.ErrChallengeNotFound { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + err := renderErrorPage( + w, r, + http.StatusBadRequest, + "Requête invalide", + "Certaines informations requises afin de réaliser votre requête sont absentes.", + ) + if err != nil { + panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path)) + } return } @@ -35,15 +43,20 @@ func serveLoginPage(w http.ResponseWriter, r *http.Request) { } if res.Skip { - res, err := hydr.RejectLoginRequest(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")) + accept := &hydra.AcceptLoginRequest{ + Subject: res.Subject, + Context: map[string]interface{}{ + "email": res.Subject, + }, } - http.Redirect(w, r, res.RedirectTo, http.StatusTemporaryRedirect) + res, err := hydr.AcceptLoginRequest(challenge, accept) + if err != nil { + panic(errors.Wrap(err, "could not retrieve hydra accept response")) + } + + http.Redirect(w, r, res.RedirectTo, http.StatusSeeOther) + return } @@ -53,6 +66,8 @@ func serveLoginPage(w http.ResponseWriter, r *http.Request) { csrf.TemplateTag: csrf.TemplateField(r), "LoginChallenge": challenge, "Email": "", + "ClientName": res.Client.ClientName, + "ClientURI": res.Client.ClientURI, }) if err := tmpl.RenderPage(w, "login.html.tmpl", data); err != nil { @@ -64,6 +79,7 @@ func handleLoginForm(w http.ResponseWriter, r *http.Request) { 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 { @@ -72,9 +88,30 @@ func handleLoginForm(w http.ResponseWriter, r *http.Request) { return } - email := r.Form.Get("email") challenge := r.Form.Get("challenge") + if challenge == "" { + err := renderErrorPage( + w, r, + http.StatusBadRequest, + "Requête invalide", + "Certaines informations requises sont manquantes pour pouvoir réaliser votre requête.", + ) + if err != nil { + panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path)) + } + + return + } + + res, err := hydr.LoginRequest(challenge) + if err != nil { + panic(errors.Wrap(err, "could not retrieve hydra login response")) + } + + email := r.Form.Get("email") + rememberMe := r.Form.Get("rememberMe") + renderFlashError := func(message string) { sess, err := session.Must(ctn).Get(w, r) if err != nil { @@ -91,6 +128,8 @@ func handleLoginForm(w http.ResponseWriter, r *http.Request) { csrf.TemplateTag: csrf.TemplateField(r), "LoginChallenge": challenge, "Email": email, + "ClientName": res.Client.ClientName, + "ClientURI": res.Client.ClientURI, }) if err := tmpl.RenderPage(w, "login.html.tmpl", data); err != nil { @@ -104,17 +143,14 @@ func handleLoginForm(w http.ResponseWriter, r *http.Request) { return } - if challenge == "" { - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - - return - } - cmd := &command.SendConfirmationEmailRequest{ Email: email, Challenge: challenge, DefaultScheme: r.URL.Scheme, DefaultAddress: r.Host, + RememberMe: rememberMe == "on", + ClientName: res.Client.ClientName, + ClientURI: res.Client.ClientURI, } if _, err := bus.Exec(ctx, cmd); err != nil { panic(errors.Wrap(err, "could not execute command")) diff --git a/internal/route/logout.go b/internal/route/logout.go index 204a91e..bf95bde 100644 --- a/internal/route/logout.go +++ b/internal/route/logout.go @@ -2,8 +2,36 @@ package route import ( "net/http" + + "forge.cadoles.com/wpetit/hydra-passwordless/internal/hydra" + "github.com/pkg/errors" + "gitlab.com/wpetit/goweb/middleware/container" ) func serveLogoutPage(w http.ResponseWriter, r *http.Request) { + ctn := container.Must(r.Context()) + hydr := hydra.Must(ctn) + challenge, err := hydr.LogoutChallenge(r) + if err != nil { + if err == hydra.ErrChallengeNotFound { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + + return + } + + panic(errors.Wrap(err, "could not retrieve logout challenge")) + } + + _, err = hydr.LogoutRequest(challenge) + if err != nil { + panic(errors.Wrap(err, "could not retrieve hydra logout response")) + } + + acceptRes, err := hydr.AcceptLogoutRequest(challenge) + if err != nil { + panic(errors.Wrap(err, "could not retrieve hydra accept logout response")) + } + + http.Redirect(w, r, acceptRes.RedirectTo, http.StatusSeeOther) } diff --git a/internal/route/mount.go b/internal/route/mount.go index 9b28ccb..b75583d 100644 --- a/internal/route/mount.go +++ b/internal/route/mount.go @@ -23,7 +23,7 @@ func Mount(r *chi.Mux, config *config.Config) error { r.Group(func(r chi.Router) { r.Use(csrfMiddleware) - + r.Get("/", serveHomePage) r.Get("/login", serveLoginPage) r.Post("/login", handleLoginForm) r.Get("/logout", serveLogoutPage) diff --git a/internal/route/verify.go b/internal/route/verify.go index 70b6cb0..db0a0f6 100644 --- a/internal/route/verify.go +++ b/internal/route/verify.go @@ -32,6 +32,18 @@ func handleVerification(w http.ResponseWriter, r *http.Request) { if err != nil { logger.Error(ctx, "could not verify token", logger.E(err)) + err := renderErrorPage( + w, r, + http.StatusBadRequest, + "Lien invalide", + "Le lien de connexion utilisé est invalide ou a expiré.", + ) + if err != nil { + panic(errors.Wrapf(err, "could not render '%s' page", r.URL.Path)) + } + + return + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) } @@ -43,7 +55,12 @@ func handleVerification(w http.ResponseWriter, r *http.Request) { hydr := hydra.Must(ctn) accept := &hydra.AcceptLoginRequest{ - Subject: verifyUserData.Email, + Subject: verifyUserData.Email, + Remember: verifyUserData.RememberMe, + RememberFor: 3600, + Context: map[string]interface{}{ + "email": verifyUserData.Email, + }, } res, err := hydr.AcceptLoginRequest(verifyUserData.Challenge, accept) diff --git a/internal/token/generate.go b/internal/token/generate.go index d4d8151..53f5c3f 100644 --- a/internal/token/generate.go +++ b/internal/token/generate.go @@ -11,10 +11,11 @@ const ( ) type privateClaims struct { - Challenge string `json:"challenge"` + Challenge string `json:"challenge"` + RememberMe bool `json:"remember"` } -func Generate(signingKey, encryptionKey, email, challenge string) (string, error) { +func Generate(signingKey, encryptionKey, email, challenge string, rememberMe bool) (string, error) { sig, err := jose.NewSigner( jose.SigningKey{ Algorithm: jose.HS256, @@ -44,7 +45,8 @@ func Generate(signingKey, encryptionKey, email, challenge string) (string, error } privateClaims := privateClaims{ - Challenge: challenge, + Challenge: challenge, + RememberMe: rememberMe, } raw, err := jwt.SignedAndEncrypted(sig, enc).Claims(claims).Claims(privateClaims).CompactSerialize() diff --git a/internal/token/verify.go b/internal/token/verify.go index cd22868..a9315ab 100644 --- a/internal/token/verify.go +++ b/internal/token/verify.go @@ -5,23 +5,23 @@ import ( "gopkg.in/square/go-jose.v2/jwt" ) -func Verify(signingKey, encryptionKey, raw string) (string, string, error) { +func Verify(signingKey, encryptionKey, raw string) (string, string, bool, error) { token, err := jwt.ParseSignedAndEncrypted(raw) if err != nil { - return "", "", errors.Wrap(err, "could not parse token") + return "", "", false, errors.Wrap(err, "could not parse token") } nested, err := token.Decrypt([]byte(encryptionKey)) if err != nil { - return "", "", errors.Wrap(err, "could not decrypt token") + return "", "", false, 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 "", "", false, errors.Wrap(err, "could not validate claims") } - return baseClaims.Subject, privateClaims.Challenge, nil + return baseClaims.Subject, privateClaims.Challenge, privateClaims.RememberMe, nil }