Skip to content
This repository has been archived by the owner on Dec 23, 2023. It is now read-only.

Commit

Permalink
feat: account activation (resolve #11)
Browse files Browse the repository at this point in the history
  • Loading branch information
muety committed Apr 9, 2021
1 parent a220e90 commit 140a8b5
Show file tree
Hide file tree
Showing 13 changed files with 261 additions and 23 deletions.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,6 @@ You can specify configuration options either via a config file (`config.yml`) or
|---------------------------|---------------------------|--------------|---------------------------------------------------------------------|
| `env` | `MW_ENV` | `dev` | Whether to use development- or production settings |
| `mail.domain` | `MW_MAIL_DOMAIN` | - | Default domain for sending mails |
| `mail.verify_senders` | `MW_VERIFY_SENDERS` | `true` | Whether to validate sender addresses and their domains' SPF records |
| `web.listen_v4` | `MW_WEB_LISTEN_V4` | `127.0.0.1:3000` | IP and port for the web server to listen on |
| `web.cors_origin` | - | [`http://localhost:5000`] | List of URLs which to accept CORS requests for |
| `web.public_url` | `MW_PUBLIC_URL` | `http://localhost:3000` | The URL under which your MailWhale server is available from the public internet |
Expand All @@ -172,7 +171,9 @@ You can specify configuration options either via a config file (`config.yml`) or
| `smtp.tls` | `MW_SMTP_TLS` | `false` | Whether to require full TLS (not to be confused with STARTTLS) for the SMTP relay |
| `store.path` | `MW_STORE_PATH` | `./data.gob.db` | Target location of the database file |
| `security.pepper` | `MW_SECURITY_PEPPER`| - | Pepper to use for hashing user passwords |
| `security.allow_signup` | `MW_SECURITY_ALLOW_SIGNUP` | `false` | Whether to allow the registration of new users |
| `security.allow_signup` | `MW_SECURITY_ALLOW_SIGNUP` | `true` | Whether to allow the registration of new users |
| `security.verify_users` | `MW_SECURITY_VERIFY_USERS` | `true` | Whether to require new users to activate their account using a confirmation mail |
| `security.verify_senders` | `MW_SECURITY_VERIFY_SENDERS` | `true` | Whether to validate sender addresses and their domains' SPF records |

### Sender verification & SPF Check
By default, mails are sent using a randomly generated address in the `From` header, which belongs to the domain configured via `mail.domain` (i.e. `user+abcdefgh@wakapi.dev`). Optionally, custom sender addresses can be configured on a per-API-client basis. However, it is recommended to properly configure [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework) on that custom domain and instruct MailWhale to verify that configuration.
Expand Down
5 changes: 3 additions & 2 deletions config.default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ env: dev # Affects log level and a few other things

mail:
domain: mailwhale.dev # Your server's domain name
verify_senders: true # Whether to send verification mail when adding new sender addresses

web:
public_url: 'http://localhost:3000' # Publicly available URL of your instance, required for callback links via e-mail
Expand All @@ -22,4 +21,6 @@ store:

security:
pepper: 'sshhh' # Change this!
allow_signup: true
allow_signup: true
verify_users: true # Whether to send verification mail when registering a new user
verify_senders: true # Whether to send verification mail when adding new sender addresses
18 changes: 13 additions & 5 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ type EmailPasswordTuple struct {
}

type mailConfig struct {
Domain string `yaml:"domain" env:"MW_MAIL_DOMAIN"`
VerifySenders bool `yaml:"verify_senders" env:"MW_MAIL_VERIFY_SENDERS"`
Domain string `yaml:"domain" env:"MW_MAIL_DOMAIN"`
}

type smtpConfig struct {
Expand All @@ -40,16 +39,18 @@ type smtpConfig struct {
type webConfig struct {
ListenV4 string `yaml:"listen_v4" default:"127.0.0.1:3000" env:"MW_WEB_LISTEN_V4"`
CorsOrigins []string `yaml:"cors_origins" env:"MW_WEB_CORS_ORIGINS"`
PublicUrl string `yaml:"public_url" default:"https://mailwhale.dev/" env:"MW_WEB_PUBLIC_URL"`
PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"MW_WEB_PUBLIC_URL"`
}

type storeConfig struct {
Path string `default:"data.gob.db" env:"MW_STORE_PATH"`
}

type securityConfig struct {
Pepper string `env:"MW_SECURITY_PEPPER"`
AllowSignup bool `env:"MW_SECURITY_ALLOW_SIGNUP" yaml:"allow_signup"`
Pepper string `env:"MW_SECURITY_PEPPER"`
AllowSignup bool `yaml:"allow_signup" env:"MW_SECURITY_ALLOW_SIGNUP" default:"true"`
VerifySenders bool `yaml:"verify_senders" default:"true" env:"MW_SECURITY_VERIFY_SENDERS"`
VerifyUsers bool `yaml:"verify_users" default:"true" env:"MW_SECURITY_VERIFY_USERS"`
}

type Config struct {
Expand Down Expand Up @@ -87,6 +88,13 @@ func Load() *Config {
logbuch.Fatal("config option 'listen4' must be specified")
}

logbuch.Info("---")
logbuch.Info("This instance is assumed to be publicly accessible at: %v", config.Web.GetPublicUrl())
logbuch.Info("User registration enabled: %v", config.Security.AllowSignup)
logbuch.Info("Account activation required: %v", config.Security.VerifyUsers)
logbuch.Info("Sender address verification required: %v", config.Security.VerifySenders)
logbuch.Info("---")

Set(config)
return Get()
}
Expand Down
34 changes: 34 additions & 0 deletions service/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

const (
tplPath = "templates"
tplNameVerifyUser = "user_verification"
tplNameVerifySender = "sender_verification"
)

Expand All @@ -29,6 +30,39 @@ func NewMailService() *MailService {
}
}

func (s *MailService) SendUserVerification(user *types.User, token string) error {
tpl, err := s.loadTemplate(tplNameVerifyUser)
if err != nil {
return err
}

type data struct {
VerifyLink string
}

payload := &data{
VerifyLink: fmt.Sprintf(
"%s/api/user/verify?token=%s",
s.config.Web.GetPublicUrl(),
token,
),
}

var rendered bytes.Buffer
if err := tpl.Execute(&rendered, payload); err != nil {
return err
}

mail := &types.Mail{
From: types.MailAddress(fmt.Sprintf("MailWhale System <system@%s>", s.config.Mail.Domain)),
To: []types.MailAddress{types.MailAddress(user.ID)},
Subject: "Verify your MailWhale account",
}
mail.WithHTML(rendered.String())

return s.sendService.Send(mail)
}

func (s *MailService) SendSenderVerification(user *types.User, sender types.SenderAddress, token string) error {
tpl, err := s.loadTemplate(tplNameVerifySender)
if err != nil {
Expand Down
29 changes: 27 additions & 2 deletions service/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/emvi/logbuch"
conf "github.com/muety/mailwhale/config"
"github.com/muety/mailwhale/types"
"github.com/muety/mailwhale/types/dto"
"github.com/muety/mailwhale/util"
"github.com/timshannon/bolthold"
"strings"
Expand Down Expand Up @@ -43,7 +44,7 @@ func (s *UserService) GetById(id string) (*types.User, error) {
return user.Sanitize(), err
}

func (s *UserService) Create(signup *types.Signup) (*types.User, error) {
func (s *UserService) Create(signup *dto.Signup) (*types.User, error) {
user := &types.User{
ID: signup.Email,
Password: util.HashBcrypt(signup.Password, s.config.Security.Pepper),
Expand All @@ -55,6 +56,9 @@ func (s *UserService) Create(signup *types.Signup) (*types.User, error) {
if err := s.store.Insert(user.ID, user); err != nil {
return nil, err
}
if s.config.Security.VerifyUsers {
go s.verifyUser(user)
}
return user.Sanitize(), nil
}

Expand All @@ -64,13 +68,14 @@ func (s *UserService) Update(user *types.User, update *types.User) (*types.User,
}

newSenders := s.extractNewSenders(user, update)
if s.config.Mail.VerifySenders {
if s.config.Security.VerifySenders {
if err := s.spfCheckSenders(newSenders); err != nil {
return nil, err
}
go s.verifySenders(user, newSenders)
}
user.Senders = update.Senders
user.Verified = update.Verified

if !user.IsValid() {
return nil, errors.New("user data invalid")
Expand Down Expand Up @@ -107,6 +112,25 @@ func (s *UserService) spfCheckSenders(senders []types.SenderAddress) error {
return nil
}

func (s *UserService) verifyUser(user *types.User) error {
verification, err := s.verificationService.Create(types.NewVerification(
user,
types.VerificationScopeUser,
user.ID,
))
if err != nil {
logbuch.Error("failed to create user verification token for '%s'", user.ID)
return err
}
if err := s.mailService.SendUserVerification(user, verification.Token); err != nil {
logbuch.Error("failed to send user verification to '%s'", user.ID)
return err
} else {
logbuch.Info("sent user verification mail for '%s'", user.ID)
}
return nil
}

// generates verification tokens for senders addresses and sends them via mail
func (s *UserService) verifySenders(user *types.User, senders []types.SenderAddress) error {
for _, sender := range senders {
Expand All @@ -116,6 +140,7 @@ func (s *UserService) verifySenders(user *types.User, senders []types.SenderAddr
sender.String(),
))
if err != nil {
logbuch.Error("failed to create sender verification token for '%s'", sender.MailAddress.String())
return err
}
if err := s.mailService.SendSenderVerification(user, sender, verification.Token); err != nil {
Expand Down
154 changes: 154 additions & 0 deletions templates/user_verification.tpl.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>MailWhale :: Verify User Account</title>
<style>
@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;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #159ce4 !important;
}
.btn-primary a:hover {
background-color: #159ce4 !important;
border-color: #159ce4 !important;
}
}
</style>
</head>
<body class="" style="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="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #f6f6f6;">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 580px; padding: 10px; width: 580px;">
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 580px; padding: 10px;">

<!-- START CENTERED WHITE CONTAINER -->
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 3px;">

<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper" style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">
<p style="font-family: sans-serif; font-size: 18px; font-weight: 500; margin: 0; Margin-bottom: 15px;">Verify Your Account</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">
Welcome to MailWhale. Please verify your user account by clicking the link below. If you did not request this change, please just ignore this mail.
</p>
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
<tbody>
<tr>
<td align="left" style="font-family: sans-serif; font-size: 14px; vertical-align: top; padding-bottom: 15px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top; background-color: #2F855A; border-radius: 5px; text-align: center;"> <a href="{{ .VerifyLink }}" target="_blank" style="display: inline-block; color: #ffffff; background-color: #159ce4; border: solid 1px #159ce4; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-transform: capitalize; border-color: #159ce4;">Verify</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</td>
</tr>

<!-- END MAIN CONTENT AREA -->
</table>

<!-- START FOOTER -->
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block powered-by" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #999999; text-align: center;">
Powered by <a href="https://mailwhale.dev" style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">MailWhale</a>.
</td>
</tr>
</table>
</div>
<!-- END FOOTER -->

<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>
6 changes: 6 additions & 0 deletions types/dto/signup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package dto

type Signup struct {
Email string `schema:"email"`
Password string `schema:"password"`
}
6 changes: 1 addition & 5 deletions types/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,7 @@ type User struct {
ID string `json:"id" boltholdKey:"ID"`
Password string `json:"-"`
Senders []SenderAddress `json:"senders"`
}

type Signup struct {
Email string `schema:"email"`
Password string `schema:"password"`
Verified bool `json:"verified"`
}

func (u *User) IsValid() bool {
Expand Down
2 changes: 1 addition & 1 deletion version.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.10.0
0.10.1
5 changes: 5 additions & 0 deletions web/handlers/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ func (m *AuthMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

if m.config.Security.VerifyUsers && !u.Verified {
util.RespondEmpty(w, r, http.StatusUnauthorized)
return
}

if !util.CompareBcrypt(u.Password, credentials, m.config.Security.Pepper) {
util.RespondEmpty(w, r, http.StatusUnauthorized)
return
Expand Down
Loading

0 comments on commit 140a8b5

Please sign in to comment.