Skip to content

Commit

Permalink
add account config option to prevent the account for setting their ow…
Browse files Browse the repository at this point in the history
…n custom password, and enable by default for new accounts

accounts with this option enabled can only generate get a new randomly
generated password. this prevents password reuse across services and weak
passwords. existing accounts keep their current ability to set custom
passwords. only admins can change this setting for an account.

related to issue #286 by skyguy
  • Loading branch information
mjl- committed Feb 15, 2025
1 parent 09975a3 commit 3e53abc
Show file tree
Hide file tree
Showing 16 changed files with 264 additions and 118 deletions.
1 change: 1 addition & 0 deletions admin/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ func MakeAccountConfig(addr smtp.Address) config.Account {
RareWords: 2,
},
},
NoCustomPassword: true,
}
account.AutomaticJunkFlags.Enabled = true
account.AutomaticJunkFlags.JunkMailboxRegexp = "^(junk|spam)"
Expand Down
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ type Account struct {
MaxOutgoingMessagesPerDay int `sconf:"optional" sconf-doc:"Maximum number of outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 1000."`
MaxFirstTimeRecipientsPerDay int `sconf:"optional" sconf-doc:"Maximum number of first-time recipients in outgoing messages for this account in a 24 hour window. This limits the damage to recipients and the reputation of this mail server in case of account compromise. Default 200."`
NoFirstTimeSenderDelay bool `sconf:"optional" sconf-doc:"Do not apply a delay to SMTP connections before accepting an incoming message from a first-time sender. Can be useful for accounts that sends automated responses and want instant replies."`
NoCustomPassword bool `sconf:"optional" sconf-doc:"If set, this account cannot set a password of their own choice, but can only set a new randomly generated password, preventing password reuse across services and use of weak passwords. Custom account passwords can be set by the admin."`
Routes []Route `sconf:"optional" sconf-doc:"Routes for delivering outgoing messages through the queue. Each delivery attempt evaluates these account routes, domain routes and finally global routes. The transport of the first matching route is used in the delivery attempt. If no routes match, which is the default with no configured routes, messages are delivered directly from the queue."`

DNSDomain dns.Domain `sconf:"-"` // Parsed form of Domain.
Expand Down
6 changes: 6 additions & 0 deletions config/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,12 @@ See https://pkg.go.dev/github.com/mjl-/sconf for details.
# responses and want instant replies. (optional)
NoFirstTimeSenderDelay: false
# If set, this account cannot set a password of their own choice, but can only set
# a new randomly generated password, preventing password reuse across services and
# use of weak passwords. Custom account passwords can be set by the admin.
# (optional)
NoCustomPassword: false
# Routes for delivering outgoing messages through the queue. Each delivery attempt
# evaluates these account routes, domain routes and finally global routes. The
# transport of the first matching route is used in the delivery attempt. If no
Expand Down
23 changes: 23 additions & 0 deletions mox-/password.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package mox

import (
cryptorand "crypto/rand"
)

func GeneratePassword() string {
chars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_;:,<.>/"
s := ""
buf := make([]byte, 1)
for i := 0; i < 12; i++ {
for {
cryptorand.Read(buf)
i := int(buf[0])
if i+len(chars) > 255 {
continue // Prevent bias.
}
s += string(chars[i%len(chars)])
break
}
}
return s
}
22 changes: 2 additions & 20 deletions quickstart.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,24 +44,6 @@ import (
//go:embed mox.service
var moxService string

func pwgen() string {
chars := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_;:,<.>/"
s := ""
buf := make([]byte, 1)
for i := 0; i < 12; i++ {
for {
cryptorand.Read(buf)
i := int(buf[0])
if i+len(chars) > 255 {
continue // Prevent bias.
}
s += string(chars[i%len(chars)])
break
}
}
return s
}

func cmdQuickstart(c *cmd) {
c.params = "[-skipdial] [-existing-webserver] [-hostname host] user@domain [user | uid]"
c.help = `Quickstart generates configuration files and prints instructions to quickly set up a mox instance.
Expand Down Expand Up @@ -761,7 +743,7 @@ many authentication failures).

dataDir := "data" // ../data is relative to config/
os.MkdirAll(dataDir, 0770)
adminpw := pwgen()
adminpw := mox.GeneratePassword()
adminpwhash, err := bcrypt.GenerateFromPassword([]byte(adminpw), bcrypt.DefaultCost)
if err != nil {
fatalf("generating hash for generated admin password: %s", err)
Expand Down Expand Up @@ -1000,7 +982,7 @@ and check the admin page for the needed DNS records.`)
}
cleanupPaths = append(cleanupPaths, dataDir, filepath.Join(dataDir, "accounts"), filepath.Join(dataDir, "accounts", accountName), filepath.Join(dataDir, "accounts", accountName, "index.db"))

password := pwgen()
password := mox.GeneratePassword()

// Kludge to cause no logging to be printed about setting a new password.
loglevel := mox.Conf.Log[""]
Expand Down
2 changes: 2 additions & 0 deletions store/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -1593,6 +1593,8 @@ func (a *Account) DeliverMessage(log mlog.Log, tx *bstore.Tx, m *Message, msgFil

// SetPassword saves a new password for this account. This password is used for
// IMAP, SMTP (submission) sessions and the HTTP account web page.
//
// Callers are responsible for checking if the account has NoCustomPassword set.
func (a *Account) SetPassword(log mlog.Log, password string) error {
password, err := precis.OpaqueString.String(password)
if err != nil {
Expand Down
45 changes: 43 additions & 2 deletions webaccount/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,9 +368,16 @@ func (w Account) Logout(ctx context.Context) {
xcheckf(ctx, err, "logout")
}

// SetPassword saves a new password for the account, invalidating the previous password.
// Sessions are not interrupted, and will keep working. New login attempts must use the new password.
// SetPassword saves a new password for the account, invalidating the previous
// password.
//
// Sessions are not interrupted, and will keep working. New login attempts must use
// the new password.
//
// Password must be at least 8 characters.
//
// Setting a user-supplied password is not allowed if NoCustomPassword is set
// for the account.
func (Account) SetPassword(ctx context.Context, password string) {
log := pkglog.WithContext(ctx)
if len(password) < 8 {
Expand All @@ -385,6 +392,38 @@ func (Account) SetPassword(ctx context.Context, password string) {
log.Check(err, "closing account")
}()

accConf, _ := acc.Conf()
if accConf.NoCustomPassword {
xcheckuserf(ctx, errors.New("custom password not allowed"), "setting password")
}

// Retrieve session, resetting password invalidates it.
ls, err := store.SessionUse(ctx, log, reqInfo.AccountName, reqInfo.SessionToken, "")
xcheckf(ctx, err, "get session")

err = acc.SetPassword(log, password)
xcheckf(ctx, err, "setting password")

// Session has been invalidated. Add it again.
err = store.SessionAddToken(ctx, log, &ls)
xcheckf(ctx, err, "restoring session after password reset")
}

// GeneratePassword sets a new randomly generated password for the current account.
// Sessions are not interrupted, and will keep working.
func (Account) GeneratePassword(ctx context.Context) (password string) {
log := pkglog.WithContext(ctx)

reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
acc, err := store.OpenAccount(log, reqInfo.AccountName, false)
xcheckf(ctx, err, "open account")
defer func() {
err := acc.Close()
log.Check(err, "closing account")
}()

password = mox.GeneratePassword()

// Retrieve session, resetting password invalidates it.
ls, err := store.SessionUse(ctx, log, reqInfo.AccountName, reqInfo.SessionToken, "")
xcheckf(ctx, err, "get session")
Expand All @@ -395,6 +434,8 @@ func (Account) SetPassword(ctx context.Context, password string) {
// Session has been invalidated. Add it again.
err = store.SessionAddToken(ctx, log, &ls)
xcheckf(ctx, err, "restoring session after password reset")

return
}

// Account returns information about the account.
Expand Down
Loading

0 comments on commit 3e53abc

Please sign in to comment.