Basic email sending

This commit is contained in:
wpetit 2020-04-24 09:27:07 +02:00
parent d65a7248d1
commit 81778121fb
18 changed files with 757 additions and 36 deletions

View File

@ -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

View File

@ -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
}

View File

@ -0,0 +1,332 @@
{{define "email"}}
<!--
Template based on https://github.com/leemunroe/responsive-html-email-template
The MIT License (MIT)
Copyright (c) [2013] [Lee Munroe]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-->
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>{{- block "title" . -}}{{- end -}}</title>
<style>
/* -------------------------------------
GLOBAL RESETS
------------------------------------- */
img {
border: none;
-ms-interpolation-mode: bicubic;
max-width: 100%; }
body {
background-color: #f6f6f6;
font-family: sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%; }
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%; }
table td {
font-family: sans-serif;
font-size: 14px;
vertical-align: top; }
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
.body {
background-color: #f6f6f6;
width: 100%; }
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block;
margin: 0 auto !important;
/* makes it centered */
max-width: 580px;
padding: 10px;
width: 580px; }
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 580px;
padding: 10px; }
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background: #ffffff;
border-radius: 3px;
width: 100%; }
.wrapper {
box-sizing: border-box;
padding: 20px; }
.content-block {
padding-bottom: 10px;
padding-top: 10px;
}
.footer {
clear: both;
margin-top: 10px;
text-align: center;
width: 100%; }
.footer td,
.footer p,
.footer span,
.footer a {
color: #999999;
font-size: 12px;
text-align: center; }
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1,
h2,
h3,
h4 {
color: #000000;
font-family: sans-serif;
font-weight: 400;
line-height: 1.4;
margin: 0;
margin-bottom: 30px; }
h1 {
font-size: 35px;
font-weight: 300;
text-align: center;
text-transform: capitalize; }
p,
ul,
ol {
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 15px; }
p li,
ul li,
ol li {
list-style-position: inside;
margin-left: 5px; }
a {
color: #3498db;
text-decoration: underline; }
/* -------------------------------------
BUTTONS
------------------------------------- */
.btn {
box-sizing: border-box;
width: 100%; }
.btn > tbody > tr > td {
padding-bottom: 15px; }
.btn table {
width: auto; }
.btn table td {
background-color: #ffffff;
border-radius: 5px;
text-align: center; }
.btn a {
background-color: #ffffff;
border: solid 1px #3498db;
border-radius: 5px;
box-sizing: border-box;
color: #3498db;
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 12px 25px;
text-decoration: none; }
.btn-primary table td {
background-color: #3498db; }
.btn-primary a {
background-color: #3498db;
border-color: #3498db;
color: #ffffff; }
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0; }
.first {
margin-top: 0; }
.align-center {
text-align: center; }
.align-right {
text-align: right; }
.align-left {
text-align: left; }
.clear {
clear: both; }
.mt0 {
margin-top: 0; }
.mb0 {
margin-bottom: 0; }
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0; }
.powered-by a {
text-decoration: none; }
hr {
border: 0;
border-bottom: 1px solid #f6f6f6;
Margin: 20px 0; }
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important; }
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important; }
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important; }
table[class=body] .content {
padding: 0 !important; }
table[class=body] .container {
padding: 0 !important;
width: 100% !important; }
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important; }
table[class=body] .btn table {
width: 100% !important; }
table[class=body] .btn a {
width: 100% !important; }
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important; }}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%; }
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%; }
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important; }
.btn-primary table td:hover {
background-color: #34495e !important; }
.btn-primary a:hover {
background-color: #34495e !important;
border-color: #34495e !important; } }
</style>
</head>
<body class="">
<table border="0" cellpadding="0" cellspacing="0" class="body">
<tr>
<td>&nbsp;</td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<span class="preheader">{{- block "title" . -}}{{- end -}}</span>
<table class="main">
<!-- START MAIN CONTENT AREA -->
{{- block "content" . -}}{{- end -}}
<!-- END MAIN CONTENT AREA -->
</table>
<!-- START FOOTER -->
<div class="footer">
<table border="0" cellpadding="0" cellspacing="0">
{{- block "footer" . -}}{{- end -}}
</table>
</div>
<!-- END FOOTER -->
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td>&nbsp;</td>
</tr>
</table>
</body>
</html>
{{end}}

View File

@ -5,6 +5,7 @@
<div class="container has-text-centered">
<div class="columns">
<div class="column is-4 is-offset-4">
{{template "flash" .}}
<p class="has-text-black title">
Connexion
</p>
@ -16,11 +17,12 @@
<div class="field">
<div class="control">
<input class="input is-large" type="email"
id="email"
id="email" value="{{ .Email }}"
name="email" placeholder="john.doe@email.com" />
</div>
</div>
{{ .csrfField }}
<input name="challenge" type="hidden" value="{{ .LoginChallenge }}" />
<button type="submit" class="button is-link is-medium is-block is-fullwidth">Envoyer</button>
</form>
</div>

View File

@ -0,0 +1,30 @@
{{define "content"}}
<tr>
<td class="wrapper">
<table border="0" cellpadding="0" cellspacing="0">
<tr>
<td>
<p>Bonjour {{ .Email }}</p>
<p>Vous avez demandé à accéder à l'application "{{ .AppTitle }}". Cliquez sur le lien ci dessous pour vous authentifier. </p>
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
<tbody>
<tr>
<td align="left">
<table border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td> <a href="{{ .VerificationLink }}" target="_blank">Accéder à l'application</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</td>
</tr>
{{end}}
{{template "email" .}}

20
docker-compose.yml Normal file
View File

@ -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

3
go.mod
View File

@ -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

13
go.sum
View File

@ -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=

View File

@ -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/",
},
}
}

View File

@ -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{

View File

@ -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")
)

View File

@ -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
}
}

13
internal/hydra/request.go Normal file
View File

@ -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"`
}

View File

@ -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 {

View File

@ -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}
}

View File

@ -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})
}
}

View File

@ -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{})

View File

@ -10,4 +10,8 @@ modd.conf {
**/*.go {
prep: make test
}
docker-compose.yml {
daemon: make up
}