Commit e4ef52dd authored by ale's avatar ale
Browse files

Merge branch 'account-activation' into 'master'

Account activation

See merge request !4
parents 9b337191 53270a5e
Pipeline #408 passed with stages
in 1 minute and 18 seconds
......@@ -7,10 +7,11 @@ import (
"errors"
"flag"
"fmt"
"html/template"
htemplate "html/template"
"io/ioutil"
"net/http"
"strings"
ttemplate "text/template"
log "github.com/Sirupsen/logrus"
"github.com/google/subcommands"
......@@ -36,6 +37,7 @@ var defaultTestData = []byte(`{
type serverCommand struct {
addr string
publicURL string
sslCert string
sslKey string
sslCA string
......@@ -57,6 +59,9 @@ type serverCommand struct {
cookieEncKey string
actionAuthKey string
actionEncKey string
senderAddr string
senderName string
}
func newServerCommand() *serverCommand { return &serverCommand{} }
......@@ -72,6 +77,7 @@ func (c *serverCommand) Usage() string {
func (c *serverCommand) SetFlags(f *flag.FlagSet) {
f.StringVar(&c.addr, "addr", ":4443", "address to listen on")
f.StringVar(&c.publicURL, "public-url", "", "public URL for the application")
f.StringVar(&c.sslCert, "ssl-cert", "", "SSL certificate")
f.StringVar(&c.sslKey, "ssl-key", "", "SSL private key")
f.StringVar(&c.sslCA, "ssl-ca", "", "SSL CA (for client certificate verification)")
......@@ -90,6 +96,8 @@ func (c *serverCommand) SetFlags(f *flag.FlagSet) {
f.StringVar(&c.cookieEncKey, "cookie-enc-key", "", "encryption key for cookies")
f.StringVar(&c.actionAuthKey, "action-auth-key", "", "authentication key for e-mail action tokens")
f.StringVar(&c.actionEncKey, "action-enc-key", "", "encryption key for e-mail action tokens")
f.StringVar(&c.senderAddr, "sender-addr", "idp@localhost", "email sender address")
f.StringVar(&c.senderName, "sender-name", "IDP", "email sender name")
setFlagDefaultsFromEnv(f)
}
......@@ -109,6 +117,9 @@ func (c *serverCommand) checkArgs() error {
if c.clientID == "" || c.clientSecret == "" {
return errors.New("--client-id and --client-secret must be set")
}
if c.publicURL == "" {
return errors.New("--public-url must be set")
}
if c.csrfSecret == "" {
log.Warn("--csrf-secret is not set, generating random key")
c.csrfSecret = string(securecookie.GenerateRandomKey(32))
......@@ -165,16 +176,21 @@ func (c *serverCommand) run(ctx context.Context) error {
return fmt.Errorf("Connection to Hydra failed: %v", err)
}
tpl := template.Must(template.ParseGlob("./templates/*.html"))
htmlTpl := htemplate.Must(htemplate.ParseGlob("./templates/*.html"))
emailTpl := ttemplate.Must(ttemplate.ParseGlob("./templates/email/*.txt"))
app, err := idpapp.NewApp(hc, &idpapp.Config{
PublicURL: c.publicURL,
CookieAuthKey: []byte(c.cookieAuthKey),
CookieEncKey: []byte(c.cookieEncKey),
ActionAuthKey: []byte(c.actionAuthKey),
ActionEncKey: []byte(c.actionEncKey),
InsecureCookies: c.insecureCookies,
Database: database,
Template: tpl,
Template: htmlTpl,
EmailTemplate: emailTpl,
SenderAddr: c.senderAddr,
SenderName: c.senderName,
})
if err != nil {
return err
......
package integration
import (
"context"
"io/ioutil"
"net/url"
"regexp"
"strings"
"testing"
"git.autistici.org/ale/idp/web/admin"
"golang.org/x/oauth2/clientcredentials"
)
var linkRx = regexp.MustCompile(`https?://\S+`)
func TestAccountActivation_SendsEmail(t *testing.T) {
srv, h, mailer := newTestAppWithHydraAndMailer(t)
defer srv.Close()
defer h.Close()
// Create a new user via the admin client.
adminClient := admin.NewClient(consentURL, &clientcredentials.Config{
ClientID: "admin-api-client",
ClientSecret: "admin-api-secret",
TokenURL: "http://localhost:4444/oauth2/token",
Scopes: []string{"openid", "idpadmin"},
})
if err := adminClient.CreateUser(context.Background(), &admin.CreateUserRequest{
Name: "user1",
Email: "user1@example.com",
Password: "sample-password",
}); err != nil {
t.Fatal("CreateUser", err)
}
// Verify that an email has been sent to the user.
if len(mailer.sent) != 1 {
t.Fatalf("expected 1 recipient, found %d", len(mailer.sent))
}
if msgs := mailer.sent["user1@example.com"]; len(msgs) != 1 {
t.Fatalf("expected 1 email to user1@example.com, found %d", len(msgs))
}
msgBody := mailer.sent["user1@example.com"][0]
activationLink := string(linkRx.Find(msgBody))
if activationLink == "" {
t.Fatal("could not find activation link in body:", string(msgBody))
}
// Create a client and click on the activation link.
c := newTestHTTPClient()
_, err := c.Get(activationLink)
if err != nil {
t.Fatal(err)
}
// Set a new password for the user.
resp, err := c.PostForm(activationLink, url.Values{
"password": []string{"new-password"},
"password_confirm": []string{"new-password"},
})
if err != nil {
t.Fatal("PostForm", err)
}
body, _ := ioutil.ReadAll(resp.Body)
if !strings.Contains(string(body), "Your account is active") {
t.Fatal("final response does not contain expected result:", string(body))
}
}
package integration
import (
"html/template"
"log"
htemplate "html/template"
"io"
"io/ioutil"
"net/http"
"net/http/cookiejar"
"net/url"
......@@ -11,11 +12,13 @@ import (
"path/filepath"
"strings"
"testing"
ttemplate "text/template"
"time"
"git.autistici.org/ale/idp/db/inmemory"
idpapp "git.autistici.org/ale/idp/web/app"
log "github.com/Sirupsen/logrus"
"github.com/gorilla/securecookie"
hydra "github.com/ory/hydra/sdk"
)
......@@ -32,7 +35,7 @@ type hydraProcess struct {
cmd *exec.Cmd
}
func newHydra(consentURL string) *hydraProcess {
func newHydra(t testing.TB, consentURL string) *hydraProcess {
env := os.Environ()
env = append(env, "DATABASE_URL=memory")
env = append(env, "FORCE_ROOT_CLIENT_CREDENTIALS=demo:demo")
......@@ -44,7 +47,7 @@ func newHydra(consentURL string) *hydraProcess {
cmd.Env = env
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
log.Fatal(err)
t.Fatal("Could not start Hydra:", err)
}
h := &hydraProcess{cmd: cmd}
h.waitUntilReady()
......@@ -92,6 +95,22 @@ func (h *hydraProcess) Close() {
h.cmd.Wait()
}
type testMailer struct {
sent map[string][][]byte
}
func newTestMailer() *testMailer {
return &testMailer{
sent: make(map[string][][]byte),
}
}
func (m *testMailer) SendMail(rcpt, to string, r io.Reader) error {
data, _ := ioutil.ReadAll(r)
m.sent[rcpt] = append(m.sent[rcpt], data)
return nil
}
var defaultData = []byte(`{
"test": {
"name": "test",
......@@ -101,8 +120,13 @@ var defaultData = []byte(`{
}
}`)
func newTestApp(t testing.TB) *idpapp.App {
tpl := template.Must(template.ParseGlob("../resources/templates/*.html"))
func newTestApp(t testing.TB) (*idpapp.App, *testMailer) {
// Initialize logging.
log.SetOutput(os.Stdout)
log.SetLevel(log.DebugLevel)
htmlTpl := htemplate.Must(htemplate.ParseGlob("../resources/templates/*.html"))
emailTpl := ttemplate.Must(ttemplate.ParseGlob("../resources/templates/email/*.txt"))
database := inmemory.NewDB(defaultData)
hc, err := hydra.Connect(
hydra.ClientID("demo"),
......@@ -112,19 +136,25 @@ func newTestApp(t testing.TB) *idpapp.App {
if err != nil {
t.Fatal(err)
}
mailer := newTestMailer()
app, err := idpapp.NewApp(hc, &idpapp.Config{
PublicURL: consentURL,
CookieAuthKey: securecookie.GenerateRandomKey(32),
CookieEncKey: securecookie.GenerateRandomKey(32),
ActionAuthKey: securecookie.GenerateRandomKey(32),
ActionEncKey: securecookie.GenerateRandomKey(32),
InsecureCookies: true,
Database: database,
Template: tpl,
Template: htmlTpl,
EmailTemplate: emailTpl,
Mailer: mailer,
SenderAddr: "idp@localhost",
SenderName: "IDP",
})
if err != nil {
t.Fatal(err)
}
return app
return app, mailer
}
func newTestServer(app *idpapp.App) *http.Server {
......@@ -148,12 +178,19 @@ func newTestHTTPClient() *http.Client {
}
func newTestAppWithHydra(t testing.TB) (*http.Server, *hydraProcess) {
h := newHydra(consentURL + "/auth/idp/challenge")
app := newTestApp(t)
h := newHydra(t, consentURL + "/auth/idp/challenge")
app, _ := newTestApp(t)
srv := newTestServer(app)
return srv, h
}
func newTestAppWithHydraAndMailer(t testing.TB) (*http.Server, *hydraProcess, *testMailer) {
h := newHydra(t, consentURL + "/auth/idp/challenge")
app, mailer := newTestApp(t)
srv := newTestServer(app)
return srv, h, mailer
}
func TestApp_HomepageRedirect(t *testing.T) {
srv, h := newTestAppWithHydra(t)
defer srv.Close()
......
<html>
<body>
<h1>Set a password for your account</h1>
<p>
The activation of your account is almost complete. You must now
pick a new password:
</p>
<form action="/account/callback/activation" method="post">
{{.CSRF}}
<input type="hidden" name="action" value="{{.Action}}">
<p>
New password:
<input type="password" name="password">
{{if .FormError}}
{{if .FormError.HasErrors "password"}}
<span class="error">{{.FormError.FieldError "password"}}</span>
{{end}}
{{end}}
</p>
<p>
Confirm:
<input type="password" name="password_confirm">
{{if .FormError}}
{{if .FormError.HasErrors "password_confirm"}}
<span class="error">{{.FormError.FieldError "password_confirm"}}</span>
{{end}}
{{end}}
</p>
<input type="submit" value=" Set password ">
</form>
</body>
</html>
<html>
<body>
<h1>Your account is active!</h1>
<p>
<a href="/account/overview">
Click here to go to the account overview page.
</a>
</p>
</body>
</html>
From: {{.Sender}}
To: {{.Recipient}}
Subject: Activate your account
Content-Type: text/plain
Hello,
to activate your account on {{.PublicURL}}, just click on this link:
{{.ActionURL}}
From: {{.Sender}}
To: {{.Recipient}}
Subject: Recovery of your account
Content-Type: text/plain
Hello,
a password reset has been requested for your account {{.Username}}.
If this was not done by you, you can safely ignore this request and
delete this message. Otherwise, you can complete the password
reset action by clicking on the following link:
{{.ActionURL}}
Bye!
......@@ -36,3 +36,8 @@ type Database interface {
// is relatively low).
Do(context.Context, func(Txn) error) error
}
// Mailer is an interface to something that can send emails.
type Mailer interface {
SendMail(string, string, map[string]interface{}) error
}
......@@ -212,3 +212,26 @@ func (u *User) EnableOTP() (*otp.Key, error) {
return key, nil
}
const (
AccountActivationActionName = "account_activation"
accountActivationActionTimeout = 30 * 86400 * time.Second
)
func newAccountActivationAction(user *User) *Action {
a := NewAction(AccountActivationActionName, accountActivationActionTimeout)
a.Values["user"] = user.Name
return a
}
func (u *User) SendActivationEmail(mailer Mailer, publicURL string, actionMgr *ActionManager) error {
actionURL, err := actionMgr.SignURL(newAccountActivationAction(u), publicURL+"/account/callback/activation")
if err != nil {
return err
}
return mailer.SendMail(u.Email, "activation.txt", map[string]interface{}{
"ActionURL": actionURL,
"Username": u.Name,
})
}
......@@ -6,12 +6,12 @@ import (
"errors"
"fmt"
"html/template"
"log"
"net/http"
"regexp"
"strings"
"time"
log "github.com/Sirupsen/logrus"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
"github.com/ory/hydra/firewall"
......@@ -26,14 +26,20 @@ const adminScope = "idpadmin"
// Service implements the admin API web service.
type Service struct {
*web.Service
hc *hydra.Client
hc *hydra.Client
mailer idp.Mailer
actions *idp.ActionManager
publicURL string
}
// NewService returns a new Service.
func NewService(db idp.Database, tpl *template.Template, store sessions.Store, hc *hydra.Client, insecureCookies bool) *Service {
func NewService(db idp.Database, tpl *template.Template, store sessions.Store, hc *hydra.Client, mailer idp.Mailer, actionMgr *idp.ActionManager, publicURL string, insecureCookies bool) *Service {
return &Service{
Service: web.NewService(db, tpl, store, insecureCookies),
hc: hc,
Service: web.NewService(db, tpl, store, insecureCookies),
mailer: mailer,
publicURL: publicURL,
actions: actionMgr,
hc: hc,
}
}
......@@ -107,6 +113,10 @@ func (s *Service) handleCreateUser(w http.ResponseWriter, r *http.Request) {
}
if err := req.Validate(); err != nil {
log.WithFields(log.Fields{
"request": req,
"error": err,
}).Error("bad CreateUser request")
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
......@@ -133,9 +143,29 @@ func (s *Service) handleCreateUser(w http.ResponseWriter, r *http.Request) {
return err
}
s.AddUserLogEntry(r, txn, user, idp.UserLogTypeAdmin, "user created")
return txn.Commit()
if err := txn.Commit(); err != nil {
return err
}
if !req.SkipConfirmation {
// Database transaction has been committed, let's send
// the activation email. This has a lower chance of
// failing.
if err := user.SendActivationEmail(s.mailer, s.publicURL, s.actions); err != nil {
log.WithFields(log.Fields{
"user": user.Name,
"error": err,
}).Errorf("failed to send activation email to %s", user.Email)
}
}
return nil
}); err != nil {
log.Printf("user creation failed: %v", err)
log.WithFields(log.Fields{
"user": req.Name,
"email": req.Email,
"error": err,
}).Error("user creation failed")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
......
......@@ -7,7 +7,6 @@ import (
"fmt"
"io/ioutil"
"net/http"
"regexp"
"git.autistici.org/ale/idp"
......@@ -28,13 +27,10 @@ func NewClient(uri string, creds *clientcredentials.Config) *Client {
}
}
var errorBodyRx = regexp.MustCompile(`<h1>(.*)</h1>`)
func decodeErrorFromBody(resp *http.Response) string {
body, _ := ioutil.ReadAll(resp.Body)
m := errorBodyRx.FindSubmatch(body)
if len(m) > 1 {
return string(m[1])
if len(body) > 0 {
return string(body)
}
return resp.Status[4:]
}
......
......@@ -3,6 +3,7 @@ package app
import (
htemplate "html/template"
"net/http"
"strings"
ttemplate "text/template"
"github.com/gorilla/mux"
......@@ -18,9 +19,7 @@ import (
// Config specifies the configurable parameters of the application.
type Config struct {
HydraURL string
ClientID string
ClientSecret string
PublicURL string
CookieEncKey []byte
CookieAuthKey []byte
InsecureCookies bool
......@@ -28,6 +27,9 @@ type Config struct {
ActionAuthKey []byte
Template *htemplate.Template
EmailTemplate *ttemplate.Template
Mailer EmailBackend
SenderAddr string
SenderName string
Database idp.Database
}
......@@ -41,15 +43,21 @@ type App struct {
// NewApp creates a new application.
func NewApp(hc *hydra.Client, config *Config) (*App, error) {
if config.Mailer == nil {
config.Mailer = &sendmailBackend{}
}
mailer := newTemplateMailer(config.EmailTemplate, config.SenderAddr, config.SenderName, config.Mailer)
store := sessions.NewCookieStore(config.CookieAuthKey, config.CookieEncKey)
actionMgr := idp.NewActionManager(config.ActionAuthKey, config.ActionEncKey)
loginSrv := login.NewService(config.Database, config.Template, store, config.InsecureCookies)
acct := mgmt.NewService(config.Database, config.Template, store, mailer, actionMgr, config.InsecureCookies)
admin := admin.NewService(config.Database, config.Template, store, hc, mailer, actionMgr, strings.TrimRight(config.PublicURL, "/"), config.InsecureCookies)
idpSrv, err := consent.NewService(config.Database, config.Template, store, hc, config.InsecureCookies)
if err != nil {
return nil, err
}
mailer := &nullMailer{tpl: config.EmailTemplate}
acct := mgmt.NewService(config.Database, config.Template, store, mailer, config.ActionAuthKey, config.ActionEncKey, config.InsecureCookies)
admin := admin.NewService(config.Database, config.Template, store, hc, config.InsecureCookies)
return &App{
login: loginSrv,
idp: idpSrv,
......
package app
import (
"bytes"
"io"
"net/mail"
"os/exec"
"text/template"
)
type templateMailer struct {
tpl *template.Template
backend EmailBackend
sender *mail.Address
}
func newTemplateMailer(tpl *template.Template, senderAddr, senderName string, backend EmailBackend) *templateMailer {
return &templateMailer{
tpl: tpl,
backend: backend,
sender: &mail.Address{
Name: senderName,
Address: senderAddr,
},
}
}
func (m *templateMailer) SendMail(rcpt, templateName string, ctx map[string]interface{}) error {
ctx["Sender"] = m.sender
ctx["Recipient"] = rcpt
var buf bytes.Buffer
if err := m.tpl.ExecuteTemplate(&buf, templateName, ctx); err != nil {
return err
}
return m.backend.SendMail(rcpt, m.sender.Address, &buf)
}
type EmailBackend interface {
SendMail(string, string, io.Reader) error
}
type sendmailBackend struct{}
func (s *sendmailBackend) SendMail(rcpt, sender string, r io.Reader) error {
cmd := exec.Command("/usr/sbin/sendmail", "-i", "-f", sender, "-t")
cmd.Stdin = r
return cmd.Run()
}
package app
import (
"bytes"
"log"
"text/template"
)