From 3e53abc4dbed4e6d72733ea1a25b22cd7a55ceb9 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sat, 15 Feb 2025 12:44:18 +0100 Subject: [PATCH] add account config option to prevent the account for setting their own 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 --- admin/admin.go | 1 + config/config.go | 1 + config/doc.go | 6 +++ mox-/password.go | 23 +++++++++ quickstart.go | 22 +-------- store/account.go | 2 + webaccount/account.go | 45 ++++++++++++++++- webaccount/account.js | 81 +++++++++++++++++++------------ webaccount/account.ts | 109 ++++++++++++++++++++++-------------------- webaccount/api.json | 22 ++++++++- webaccount/api.ts | 24 ++++++++-- webadmin/admin.go | 3 +- webadmin/admin.js | 13 ++--- webadmin/admin.ts | 12 ++++- webadmin/api.json | 13 +++++ webadmin/api.ts | 9 ++-- 16 files changed, 266 insertions(+), 120 deletions(-) create mode 100644 mox-/password.go diff --git a/admin/admin.go b/admin/admin.go index 49650f1..7c91463 100644 --- a/admin/admin.go +++ b/admin/admin.go @@ -119,6 +119,7 @@ func MakeAccountConfig(addr smtp.Address) config.Account { RareWords: 2, }, }, + NoCustomPassword: true, } account.AutomaticJunkFlags.Enabled = true account.AutomaticJunkFlags.JunkMailboxRegexp = "^(junk|spam)" diff --git a/config/config.go b/config/config.go index 6cee9a2..1735c78 100644 --- a/config/config.go +++ b/config/config.go @@ -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. diff --git a/config/doc.go b/config/doc.go index 5bc5529..95e4edd 100644 --- a/config/doc.go +++ b/config/doc.go @@ -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 diff --git a/mox-/password.go b/mox-/password.go new file mode 100644 index 0000000..0498007 --- /dev/null +++ b/mox-/password.go @@ -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 +} diff --git a/quickstart.go b/quickstart.go index 770d5c2..ee54d76 100644 --- a/quickstart.go +++ b/quickstart.go @@ -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. @@ -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) @@ -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[""] diff --git a/store/account.go b/store/account.go index b013bac..c6d04c7 100644 --- a/store/account.go +++ b/store/account.go @@ -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 { diff --git a/webaccount/account.go b/webaccount/account.go index be728ae..43f6cab 100644 --- a/webaccount/account.go +++ b/webaccount/account.go @@ -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 { @@ -385,6 +392,11 @@ 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") @@ -397,6 +409,35 @@ func (Account) SetPassword(ctx context.Context, password string) { 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") + + 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") + + return +} + // Account returns information about the account. // StorageUsed is the sum of the sizes of all messages, in bytes. // StorageLimit is the maximum storage that can be used, or 0 if there is no limit. diff --git a/webaccount/account.js b/webaccount/account.js index b81ac47..fb79d9a 100644 --- a/webaccount/account.js +++ b/webaccount/account.js @@ -272,7 +272,7 @@ var api; api.stringsTypes = { "AuthResult": true, "CSRFToken": true, "Localpart": true, "OutgoingEvent": true }; api.intsTypes = {}; api.types = { - "Account": { "Name": "Account", "Docs": "", "Fields": [{ "Name": "OutgoingWebhook", "Docs": "", "Typewords": ["nullable", "OutgoingWebhook"] }, { "Name": "IncomingWebhook", "Docs": "", "Typewords": ["nullable", "IncomingWebhook"] }, { "Name": "FromIDLoginAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "KeepRetiredMessagePeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "KeepRetiredWebhookPeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginDisabled", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destinations", "Docs": "", "Typewords": ["{}", "Destination"] }, { "Name": "SubjectPass", "Docs": "", "Typewords": ["SubjectPass"] }, { "Name": "QuotaMessageSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "KeepRejects", "Docs": "", "Typewords": ["bool"] }, { "Name": "AutomaticJunkFlags", "Docs": "", "Typewords": ["AutomaticJunkFlags"] }, { "Name": "JunkFilter", "Docs": "", "Typewords": ["nullable", "JunkFilter"] }, { "Name": "MaxOutgoingMessagesPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxFirstTimeRecipientsPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "NoFirstTimeSenderDelay", "Docs": "", "Typewords": ["bool"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["[]", "AddressAlias"] }] }, + "Account": { "Name": "Account", "Docs": "", "Fields": [{ "Name": "OutgoingWebhook", "Docs": "", "Typewords": ["nullable", "OutgoingWebhook"] }, { "Name": "IncomingWebhook", "Docs": "", "Typewords": ["nullable", "IncomingWebhook"] }, { "Name": "FromIDLoginAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "KeepRetiredMessagePeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "KeepRetiredWebhookPeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginDisabled", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destinations", "Docs": "", "Typewords": ["{}", "Destination"] }, { "Name": "SubjectPass", "Docs": "", "Typewords": ["SubjectPass"] }, { "Name": "QuotaMessageSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "KeepRejects", "Docs": "", "Typewords": ["bool"] }, { "Name": "AutomaticJunkFlags", "Docs": "", "Typewords": ["AutomaticJunkFlags"] }, { "Name": "JunkFilter", "Docs": "", "Typewords": ["nullable", "JunkFilter"] }, { "Name": "MaxOutgoingMessagesPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxFirstTimeRecipientsPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "NoFirstTimeSenderDelay", "Docs": "", "Typewords": ["bool"] }, { "Name": "NoCustomPassword", "Docs": "", "Typewords": ["bool"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["[]", "AddressAlias"] }] }, "OutgoingWebhook": { "Name": "OutgoingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }, { "Name": "Events", "Docs": "", "Typewords": ["[]", "string"] }] }, "IncomingWebhook": { "Name": "IncomingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }] }, "Destination": { "Name": "Destination", "Docs": "", "Fields": [{ "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Rulesets", "Docs": "", "Typewords": ["[]", "Ruleset"] }, { "Name": "SMTPError", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }] }, @@ -380,9 +380,16 @@ var api; const params = []; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } - // 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. async SetPassword(password) { const fn = "SetPassword"; const paramTypes = [["string"]]; @@ -390,6 +397,15 @@ var api; const params = [password]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } + // GeneratePassword sets a new randomly generated password for the current account. + // Sessions are not interrupted, and will keep working. + async GeneratePassword() { + const fn = "GeneratePassword"; + const paramTypes = []; + const returnTypes = [["string"]]; + const params = []; + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); + } // Account returns information about the account. // StorageUsed is the sum of the sizes of all messages, in bytes. // StorageLimit is the maximum storage that can be used, or 0 if there is no limit. @@ -1465,34 +1481,39 @@ const index = async () => { }), dom.br(), dom.h2('Addresses'), dom.ul(Object.entries(acc.Destinations || {}).length === 0 ? dom.li('(None, login disabled)') : [], Object.entries(acc.Destinations || {}).sort().map(t => dom.li(dom.a(prewrap(t[0]), attr.href('#destinations/' + encodeURIComponent(t[0]))), t[0].startsWith('@') ? ' (catchall)' : []))), dom.br(), dom.h2('Aliases/lists'), dom.table(dom.thead(dom.tr(dom.th('Alias address', attr.title('Messages sent to this address will be delivered to all members of the alias/list. A member does not receive a message if their address is in the message From header.')), dom.th('Subscription address', attr.title('Address subscribed to the alias/list.')), dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')), dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')), dom.th())), (acc.Aliases || []).length === 0 ? dom.tr(dom.td(attr.colspan('5'), 'None')) : [], (acc.Aliases || []).sort((a, b) => a.Alias.LocalpartStr < b.Alias.LocalpartStr ? -1 : (domainName(a.Alias.Domain) < domainName(b.Alias.Domain) ? -1 : 1)).map(a => dom.tr(dom.td(prewrap(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain))), dom.td(prewrap(a.SubscriptionAddress)), dom.td(a.Alias.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.Alias.AllowMsgFrom ? 'Yes' : 'No'), dom.td((a.MemberAddresses || []).length === 0 ? [] : dom.clickbutton('Show members', function click() { popup(dom.h1('Members of alias ', prewrap(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain))), dom.ul((a.MemberAddresses || []).map(addr => dom.li(prewrap(addr))))); - }))))), dom.br(), dom.h2('Recent login attempts', attr.title('Login attempts are stored for 30 days. At most 10000 failed login attempts are stored to prevent unlimited growth of the database.')), renderLoginAttempts(recentLoginAttempts || []), dom.br(), recentLoginAttempts && recentLoginAttempts.length >= 10 ? dom.p('See ', dom.a(attr.href('#loginattempts'), 'all login attempts'), '.') : dom.br(), dom.h2('Change password'), passwordForm = dom.form(passwordFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'New password', dom.br(), password1 = dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''), function focus() { - passwordHint.style.display = ''; - })), ' ', dom.label(style({ display: 'inline-block' }), 'New password repeat', dom.br(), password2 = dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''))), ' ', dom.submitbutton('Change password')), passwordHint = dom.div(style({ display: 'none', marginTop: '.5ex' }), dom.clickbutton('Generate random password', function click(e) { - e.preventDefault(); - let b = new Uint8Array(1); - let s = ''; - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_;:,<.>/'; - while (s.length < 12) { - self.crypto.getRandomValues(b); - if (Math.ceil(b[0] / chars.length) * chars.length > 255) { - continue; // Prevent bias. + }))))), dom.br(), dom.h2('Recent login attempts', attr.title('Login attempts are stored for 30 days. At most 10000 failed login attempts are stored to prevent unlimited growth of the database.')), renderLoginAttempts(recentLoginAttempts || []), dom.br(), recentLoginAttempts && recentLoginAttempts.length >= 10 ? dom.p('See ', dom.a(attr.href('#loginattempts'), 'all login attempts'), '.') : dom.br(), dom.h2('Change password'), acc.NoCustomPassword ? + dom.div(dom.clickbutton('Generate and set new password', attr.title('Automatically generate a new password and set it for this account. Custom passwords risk reuse across services and are currently disabled for this account.'), async function click(e) { + const password = await check(e.target, client.GeneratePassword()); + window.alert('New password: ' + password + '\n\nStore it securely, for example in a password manager.'); + })) : + passwordForm = dom.form(passwordFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'New password', dom.br(), password1 = dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''), function focus() { + passwordHint.style.display = ''; + })), ' ', dom.label(style({ display: 'inline-block' }), 'New password repeat', dom.br(), password2 = dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''))), ' ', dom.submitbutton('Change password')), passwordHint = dom.div(style({ display: 'none', marginTop: '.5ex' }), dom.clickbutton('Generate random password', function click(e) { + e.preventDefault(); + let b = new Uint8Array(1); + let s = ''; + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_;:,<.>/'; + while (s.length < 12) { + self.crypto.getRandomValues(b); + if (Math.ceil(b[0] / chars.length) * chars.length > 255) { + continue; // Prevent bias. + } + s += chars[b[0] % chars.length]; } - s += chars[b[0] % chars.length]; - } - password1.type = 'text'; - password2.type = 'text'; - password1.value = s; - password2.value = s; - }), dom.div(dom._class('text'), box(yellow, 'Important: Bots will try to bruteforce your password. Connections with failed authentication attempts will be rate limited but attackers WILL find passwords reused at other services and weak passwords. If your account is compromised, spammers are likely to abuse your system, spamming your address and the wider internet in your name. So please pick a random, unguessable password, preferrably at least 12 characters.'))), async function submit(e) { - e.stopPropagation(); - e.preventDefault(); - if (!password1.value || password1.value !== password2.value) { - window.alert('Passwords do not match.'); - return; - } - await check(passwordFieldset, client.SetPassword(password1.value)); - passwordForm.reset(); - }), dom.br(), dom.h2('TLS public keys'), dom.p('For TLS client authentication with certificates, for IMAP and/or submission (SMTP). Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration or constraints are not verified.'), (() => { + password1.type = 'text'; + password2.type = 'text'; + password1.value = s; + password2.value = s; + }), dom.div(dom._class('text'), box(yellow, 'Important: Bots will try to bruteforce your password. Connections with failed authentication attempts will be rate limited but attackers WILL find passwords reused at other services and weak passwords. If your account is compromised, spammers are likely to abuse your system, spamming your address and the wider internet in your name. So please pick a random, unguessable password, preferrably at least 12 characters.'))), async function submit(e) { + e.stopPropagation(); + e.preventDefault(); + if (!password1.value || password1.value !== password2.value) { + window.alert('Passwords do not match.'); + return; + } + await check(passwordFieldset, client.SetPassword(password1.value)); + passwordForm.reset(); + }), dom.br(), dom.h2('TLS public keys'), dom.p('For TLS client authentication with certificates, for IMAP and/or submission (SMTP). Only the public key of the certificate is used during TLS authentication, to identify this account. Names, expiration or constraints are not verified.'), (() => { let elem = dom.div(); const preauthHelp = 'New IMAP immediate TLS connections authenticated with a client certificate are automatically switched to "authenticated" state with an untagged IMAP "preauth" message by default. IMAP connections have a state machine specifying when commands are allowed. Authenticating is not allowed while in the "authenticated" state. Enable this option to work around clients that would try to authenticated anyway.'; const render = () => { diff --git a/webaccount/account.ts b/webaccount/account.ts index 8599678..670c97e 100644 --- a/webaccount/account.ts +++ b/webaccount/account.ts @@ -828,60 +828,67 @@ const index = async () => { recentLoginAttempts && recentLoginAttempts.length >= 10 ? dom.p('See ', dom.a(attr.href('#loginattempts'), 'all login attempts'), '.') : dom.br(), dom.h2('Change password'), - passwordForm=dom.form( - passwordFieldset=dom.fieldset( - dom.label( - style({display: 'inline-block'}), - 'New password', - dom.br(), - password1=dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''), function focus() { - passwordHint.style.display = '' - }), - ), - ' ', - dom.label( - style({display: 'inline-block'}), - 'New password repeat', - dom.br(), - password2=dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required('')), - ), - ' ', - dom.submitbutton('Change password'), - ), - passwordHint=dom.div( - style({display: 'none', marginTop: '.5ex'}), - dom.clickbutton('Generate random password', function click(e: MouseEvent) { - e.preventDefault() - let b = new Uint8Array(1) - let s = '' - const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_;:,<.>/' - while (s.length < 12) { - self.crypto.getRandomValues(b) - if (Math.ceil(b[0]/chars.length)*chars.length > 255) { - continue // Prevent bias. - } - s += chars[b[0]%chars.length] - } - password1.type = 'text' - password2.type = 'text' - password1.value = s - password2.value = s + acc.NoCustomPassword ? + dom.div( + dom.clickbutton('Generate and set new password', attr.title('Automatically generate a new password and set it for this account. Custom passwords risk reuse across services and are currently disabled for this account.'), async function click(e: {target: HTMLButtonElement}) { + const password = await check(e.target, client.GeneratePassword()) + window.alert('New password: '+password+'\n\nStore it securely, for example in a password manager.') }), - dom.div(dom._class('text'), - box(yellow, 'Important: Bots will try to bruteforce your password. Connections with failed authentication attempts will be rate limited but attackers WILL find passwords reused at other services and weak passwords. If your account is compromised, spammers are likely to abuse your system, spamming your address and the wider internet in your name. So please pick a random, unguessable password, preferrably at least 12 characters.'), + ) : + passwordForm=dom.form( + passwordFieldset=dom.fieldset( + dom.label( + style({display: 'inline-block'}), + 'New password', + dom.br(), + password1=dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''), function focus() { + passwordHint.style.display = '' + }), + ), + ' ', + dom.label( + style({display: 'inline-block'}), + 'New password repeat', + dom.br(), + password2=dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required('')), + ), + ' ', + dom.submitbutton('Change password'), ), + passwordHint=dom.div( + style({display: 'none', marginTop: '.5ex'}), + dom.clickbutton('Generate random password', function click(e: MouseEvent) { + e.preventDefault() + let b = new Uint8Array(1) + let s = '' + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_;:,<.>/' + while (s.length < 12) { + self.crypto.getRandomValues(b) + if (Math.ceil(b[0]/chars.length)*chars.length > 255) { + continue // Prevent bias. + } + s += chars[b[0]%chars.length] + } + password1.type = 'text' + password2.type = 'text' + password1.value = s + password2.value = s + }), + dom.div(dom._class('text'), + box(yellow, 'Important: Bots will try to bruteforce your password. Connections with failed authentication attempts will be rate limited but attackers WILL find passwords reused at other services and weak passwords. If your account is compromised, spammers are likely to abuse your system, spamming your address and the wider internet in your name. So please pick a random, unguessable password, preferrably at least 12 characters.'), + ), + ), + async function submit(e: SubmitEvent) { + e.stopPropagation() + e.preventDefault() + if (!password1.value || password1.value !== password2.value) { + window.alert('Passwords do not match.') + return + } + await check(passwordFieldset, client.SetPassword(password1.value)) + passwordForm.reset() + }, ), - async function submit(e: SubmitEvent) { - e.stopPropagation() - e.preventDefault() - if (!password1.value || password1.value !== password2.value) { - window.alert('Passwords do not match.') - return - } - await check(passwordFieldset, client.SetPassword(password1.value)) - passwordForm.reset() - }, - ), dom.br(), dom.h2('TLS public keys'), diff --git a/webaccount/api.json b/webaccount/api.json index d590e67..fd243ab 100644 --- a/webaccount/api.json +++ b/webaccount/api.json @@ -55,7 +55,7 @@ }, { "Name": "SetPassword", - "Docs": "SetPassword saves a new password for the account, invalidating the previous password.\nSessions are not interrupted, and will keep working. New login attempts must use the new password.\nPassword must be at least 8 characters.", + "Docs": "SetPassword saves a new password for the account, invalidating the previous\npassword.\n\nSessions are not interrupted, and will keep working. New login attempts must use\nthe new password.\n\nPassword must be at least 8 characters.\n\nSetting a user-supplied password is not allowed if NoCustomPassword is set\nfor the account.", "Params": [ { "Name": "password", @@ -66,6 +66,19 @@ ], "Returns": [] }, + { + "Name": "GeneratePassword", + "Docs": "GeneratePassword sets a new randomly generated password for the current account.\nSessions are not interrupted, and will keep working.", + "Params": [], + "Returns": [ + { + "Name": "password", + "Typewords": [ + "string" + ] + } + ] + }, { "Name": "Account", "Docs": "Account returns information about the account.\nStorageUsed is the sum of the sizes of all messages, in bytes.\nStorageLimit is the maximum storage that can be used, or 0 if there is no limit.", @@ -695,6 +708,13 @@ "bool" ] }, + { + "Name": "NoCustomPassword", + "Docs": "", + "Typewords": [ + "bool" + ] + }, { "Name": "Routes", "Docs": "", diff --git a/webaccount/api.ts b/webaccount/api.ts index a7b7c19..c2e0f42 100644 --- a/webaccount/api.ts +++ b/webaccount/api.ts @@ -22,6 +22,7 @@ export interface Account { MaxOutgoingMessagesPerDay: number MaxFirstTimeRecipientsPerDay: number NoFirstTimeSenderDelay: boolean + NoCustomPassword: boolean Routes?: Route[] | null DNSDomain: Domain // Parsed form of Domain. Aliases?: AddressAlias[] | null @@ -294,7 +295,7 @@ export const structTypes: {[typename: string]: boolean} = {"Account":true,"Addre export const stringsTypes: {[typename: string]: boolean} = {"AuthResult":true,"CSRFToken":true,"Localpart":true,"OutgoingEvent":true} export const intsTypes: {[typename: string]: boolean} = {} export const types: TypenameMap = { - "Account": {"Name":"Account","Docs":"","Fields":[{"Name":"OutgoingWebhook","Docs":"","Typewords":["nullable","OutgoingWebhook"]},{"Name":"IncomingWebhook","Docs":"","Typewords":["nullable","IncomingWebhook"]},{"Name":"FromIDLoginAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"KeepRetiredMessagePeriod","Docs":"","Typewords":["int64"]},{"Name":"KeepRetiredWebhookPeriod","Docs":"","Typewords":["int64"]},{"Name":"LoginDisabled","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]},{"Name":"Destinations","Docs":"","Typewords":["{}","Destination"]},{"Name":"SubjectPass","Docs":"","Typewords":["SubjectPass"]},{"Name":"QuotaMessageSize","Docs":"","Typewords":["int64"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"KeepRejects","Docs":"","Typewords":["bool"]},{"Name":"AutomaticJunkFlags","Docs":"","Typewords":["AutomaticJunkFlags"]},{"Name":"JunkFilter","Docs":"","Typewords":["nullable","JunkFilter"]},{"Name":"MaxOutgoingMessagesPerDay","Docs":"","Typewords":["int32"]},{"Name":"MaxFirstTimeRecipientsPerDay","Docs":"","Typewords":["int32"]},{"Name":"NoFirstTimeSenderDelay","Docs":"","Typewords":["bool"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"Aliases","Docs":"","Typewords":["[]","AddressAlias"]}]}, + "Account": {"Name":"Account","Docs":"","Fields":[{"Name":"OutgoingWebhook","Docs":"","Typewords":["nullable","OutgoingWebhook"]},{"Name":"IncomingWebhook","Docs":"","Typewords":["nullable","IncomingWebhook"]},{"Name":"FromIDLoginAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"KeepRetiredMessagePeriod","Docs":"","Typewords":["int64"]},{"Name":"KeepRetiredWebhookPeriod","Docs":"","Typewords":["int64"]},{"Name":"LoginDisabled","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]},{"Name":"Destinations","Docs":"","Typewords":["{}","Destination"]},{"Name":"SubjectPass","Docs":"","Typewords":["SubjectPass"]},{"Name":"QuotaMessageSize","Docs":"","Typewords":["int64"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"KeepRejects","Docs":"","Typewords":["bool"]},{"Name":"AutomaticJunkFlags","Docs":"","Typewords":["AutomaticJunkFlags"]},{"Name":"JunkFilter","Docs":"","Typewords":["nullable","JunkFilter"]},{"Name":"MaxOutgoingMessagesPerDay","Docs":"","Typewords":["int32"]},{"Name":"MaxFirstTimeRecipientsPerDay","Docs":"","Typewords":["int32"]},{"Name":"NoFirstTimeSenderDelay","Docs":"","Typewords":["bool"]},{"Name":"NoCustomPassword","Docs":"","Typewords":["bool"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"Aliases","Docs":"","Typewords":["[]","AddressAlias"]}]}, "OutgoingWebhook": {"Name":"OutgoingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]},{"Name":"Events","Docs":"","Typewords":["[]","string"]}]}, "IncomingWebhook": {"Name":"IncomingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]}]}, "Destination": {"Name":"Destination","Docs":"","Fields":[{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Rulesets","Docs":"","Typewords":["[]","Ruleset"]},{"Name":"SMTPError","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]}]}, @@ -412,9 +413,16 @@ export class Client { return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void } - // 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. async SetPassword(password: string): Promise { const fn: string = "SetPassword" const paramTypes: string[][] = [["string"]] @@ -423,6 +431,16 @@ export class Client { return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void } + // GeneratePassword sets a new randomly generated password for the current account. + // Sessions are not interrupted, and will keep working. + async GeneratePassword(): Promise { + const fn: string = "GeneratePassword" + const paramTypes: string[][] = [] + const returnTypes: string[][] = [["string"]] + const params: any[] = [] + return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as string + } + // Account returns information about the account. // StorageUsed is the sum of the sizes of all messages, in bytes. // StorageLimit is the maximum storage that can be used, or 0 if there is no limit. diff --git a/webadmin/admin.go b/webadmin/admin.go index d0a5ebf..cdea600 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -2007,12 +2007,13 @@ func (Admin) SetPassword(ctx context.Context, accountName, password string) { } // AccountSettingsSave set new settings for an account that only an admin can set. -func (Admin) AccountSettingsSave(ctx context.Context, accountName string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int, maxMsgSize int64, firstTimeSenderDelay bool) { +func (Admin) AccountSettingsSave(ctx context.Context, accountName string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int, maxMsgSize int64, firstTimeSenderDelay, noCustomPassword bool) { err := admin.AccountSave(ctx, accountName, func(acc *config.Account) { acc.MaxOutgoingMessagesPerDay = maxOutgoingMessagesPerDay acc.MaxFirstTimeRecipientsPerDay = maxFirstTimeRecipientsPerDay acc.QuotaMessageSize = maxMsgSize acc.NoFirstTimeSenderDelay = !firstTimeSenderDelay + acc.NoCustomPassword = noCustomPassword }) xcheckf(ctx, err, "saving account settings") } diff --git a/webadmin/admin.js b/webadmin/admin.js index 793edbd..d911022 100644 --- a/webadmin/admin.js +++ b/webadmin/admin.js @@ -311,7 +311,7 @@ var api; "Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Localpart", "Docs": "", "Typewords": ["Localpart"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, "Destination": { "Name": "Destination", "Docs": "", "Fields": [{ "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Rulesets", "Docs": "", "Typewords": ["[]", "Ruleset"] }, { "Name": "SMTPError", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }] }, "Ruleset": { "Name": "Ruleset", "Docs": "", "Fields": [{ "Name": "SMTPMailFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "MsgFromRegexp", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "HeadersRegexp", "Docs": "", "Typewords": ["{}", "string"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ListAllowDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "AcceptRejectsToMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }, { "Name": "VerifiedDNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ListAllowDNSDomain", "Docs": "", "Typewords": ["Domain"] }] }, - "Account": { "Name": "Account", "Docs": "", "Fields": [{ "Name": "OutgoingWebhook", "Docs": "", "Typewords": ["nullable", "OutgoingWebhook"] }, { "Name": "IncomingWebhook", "Docs": "", "Typewords": ["nullable", "IncomingWebhook"] }, { "Name": "FromIDLoginAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "KeepRetiredMessagePeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "KeepRetiredWebhookPeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginDisabled", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destinations", "Docs": "", "Typewords": ["{}", "Destination"] }, { "Name": "SubjectPass", "Docs": "", "Typewords": ["SubjectPass"] }, { "Name": "QuotaMessageSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "KeepRejects", "Docs": "", "Typewords": ["bool"] }, { "Name": "AutomaticJunkFlags", "Docs": "", "Typewords": ["AutomaticJunkFlags"] }, { "Name": "JunkFilter", "Docs": "", "Typewords": ["nullable", "JunkFilter"] }, { "Name": "MaxOutgoingMessagesPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxFirstTimeRecipientsPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "NoFirstTimeSenderDelay", "Docs": "", "Typewords": ["bool"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["[]", "AddressAlias"] }] }, + "Account": { "Name": "Account", "Docs": "", "Fields": [{ "Name": "OutgoingWebhook", "Docs": "", "Typewords": ["nullable", "OutgoingWebhook"] }, { "Name": "IncomingWebhook", "Docs": "", "Typewords": ["nullable", "IncomingWebhook"] }, { "Name": "FromIDLoginAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "KeepRetiredMessagePeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "KeepRetiredWebhookPeriod", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginDisabled", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "Description", "Docs": "", "Typewords": ["string"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }, { "Name": "Destinations", "Docs": "", "Typewords": ["{}", "Destination"] }, { "Name": "SubjectPass", "Docs": "", "Typewords": ["SubjectPass"] }, { "Name": "QuotaMessageSize", "Docs": "", "Typewords": ["int64"] }, { "Name": "RejectsMailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "KeepRejects", "Docs": "", "Typewords": ["bool"] }, { "Name": "AutomaticJunkFlags", "Docs": "", "Typewords": ["AutomaticJunkFlags"] }, { "Name": "JunkFilter", "Docs": "", "Typewords": ["nullable", "JunkFilter"] }, { "Name": "MaxOutgoingMessagesPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "MaxFirstTimeRecipientsPerDay", "Docs": "", "Typewords": ["int32"] }, { "Name": "NoFirstTimeSenderDelay", "Docs": "", "Typewords": ["bool"] }, { "Name": "NoCustomPassword", "Docs": "", "Typewords": ["bool"] }, { "Name": "Routes", "Docs": "", "Typewords": ["[]", "Route"] }, { "Name": "DNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "Aliases", "Docs": "", "Typewords": ["[]", "AddressAlias"] }] }, "OutgoingWebhook": { "Name": "OutgoingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }, { "Name": "Events", "Docs": "", "Typewords": ["[]", "string"] }] }, "IncomingWebhook": { "Name": "IncomingWebhook", "Docs": "", "Fields": [{ "Name": "URL", "Docs": "", "Typewords": ["string"] }, { "Name": "Authorization", "Docs": "", "Typewords": ["string"] }] }, "SubjectPass": { "Name": "SubjectPass", "Docs": "", "Fields": [{ "Name": "Period", "Docs": "", "Typewords": ["int64"] }] }, @@ -794,11 +794,11 @@ var api; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } // AccountSettingsSave set new settings for an account that only an admin can set. - async AccountSettingsSave(accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize, firstTimeSenderDelay) { + async AccountSettingsSave(accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize, firstTimeSenderDelay, noCustomPassword) { const fn = "AccountSettingsSave"; - const paramTypes = [["string"], ["int32"], ["int32"], ["int64"], ["bool"]]; + const paramTypes = [["string"], ["int32"], ["int32"], ["int64"], ["bool"], ["bool"]]; const returnTypes = []; - const params = [accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize, firstTimeSenderDelay]; + const params = [accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize, firstTimeSenderDelay, noCustomPassword]; return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params); } // AccountLoginDisabledSave saves the LoginDisabled field of an account. @@ -2174,6 +2174,7 @@ const account = async (name) => { let maxFirstTimeRecipientsPerDay; let quotaMessageSize; let firstTimeSenderDelay; + let noCustomPassword; let formPassword; let fieldsetPassword; let password; @@ -2236,10 +2237,10 @@ const account = async (name) => { }, fieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), dom.span('Localpart', attr.title('The localpart is the part before the "@"-sign of an email address. If empty, a catchall address is configured for the domain.')), dom.br(), localpart = dom.input()), '@', dom.label(style({ display: 'inline-block' }), dom.span('Domain'), dom.br(), domain = dom.select((domains || []).map(d => dom.option(domainName(d.Domain), domainName(d.Domain) === config.Domain ? attr.selected('') : [])))), ' ', dom.submitbutton('Add address'))), dom.br(), dom.h2('Alias (list) membership'), dom.table(dom.thead(dom.tr(dom.th('Alias address', attr.title('Messages sent to this address will be delivered to all members of the alias/list. A member does not receive a message if their address is in the message From header.')), dom.th('Subscription address'), dom.th('Allowed senders', attr.title('Whether only members can send through the alias/list, or anyone.')), dom.th('Send as alias address', attr.title('If enabled, messages can be sent with the alias address in the message "From" header.')), dom.th('Members visible', attr.title('If enabled, members can see the addresses of other members.')))), (config.Aliases || []).length === 0 ? dom.tr(dom.td(attr.colspan('6'), 'None')) : [], (config.Aliases || []).sort((a, b) => a.Alias.LocalpartStr < b.Alias.LocalpartStr ? -1 : (domainName(a.Alias.Domain) < domainName(b.Alias.Domain) ? -1 : 1)).map(a => dom.tr(dom.td(dom.a(prewrap(a.Alias.LocalpartStr, '@', domainName(a.Alias.Domain)), attr.href('#domains/' + domainName(a.Alias.Domain) + '/alias/' + encodeURIComponent(a.Alias.LocalpartStr)))), dom.td(prewrap(a.SubscriptionAddress)), dom.td(a.Alias.PostPublic ? 'Anyone' : 'Members only'), dom.td(a.Alias.AllowMsgFrom ? 'Yes' : 'No'), dom.td(a.Alias.ListMembers ? 'Yes' : 'No'), dom.td(dom.clickbutton('Remove', async function click(e) { await check(e.target, client.AliasAddressesRemove(a.Alias.LocalpartStr, domainName(a.Alias.Domain), [a.SubscriptionAddress])); window.location.reload(); // todo: reload less - }))))), dom.br(), dom.h2('Settings'), dom.form(fieldsetSettings = dom.fieldset(dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum outgoing messages per day', attr.title('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. MaxOutgoingMessagesPerDay in configuration file.')), dom.br(), maxOutgoingMessagesPerDay = dom.input(attr.type('number'), attr.required(''), attr.value('' + (config.MaxOutgoingMessagesPerDay || 1000)))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum first-time recipients per day', attr.title('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. MaxFirstTimeRecipientsPerDay in configuration file.')), dom.br(), maxFirstTimeRecipientsPerDay = dom.input(attr.type('number'), attr.required(''), attr.value('' + (config.MaxFirstTimeRecipientsPerDay || 200)))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Disk usage quota: Maximum total message size ', attr.title('Default maximum total message size in bytes for the account, overriding any globally configured default maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages to an account beyond its maximum total size will result in an error. Useful to prevent a single account from filling storage. Use units "k" for kilobytes, or "m", "g", "t".')), dom.br(), quotaMessageSize = dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize))), ' Current usage is ', formatQuotaSize(Math.floor(diskUsage / (1024 * 1024)) * 1024 * 1024), '.'), dom.div(style({ display: 'block', marginBottom: '.5ex' }), dom.label(firstTimeSenderDelay = dom.input(attr.type('checkbox'), config.NoFirstTimeSenderDelay ? [] : attr.checked('')), ' ', dom.span('Delay deliveries from first-time senders.', attr.title('To slow down potential spammers, when the message is misclassified as non-junk. Turning off the delay can be useful when the account processes messages automatically and needs fast responses.')))), dom.submitbutton('Save')), async function submit(e) { + }))))), dom.br(), dom.h2('Settings'), dom.form(fieldsetSettings = dom.fieldset(dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum outgoing messages per day', attr.title('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. MaxOutgoingMessagesPerDay in configuration file.')), dom.br(), maxOutgoingMessagesPerDay = dom.input(attr.type('number'), attr.required(''), attr.value('' + (config.MaxOutgoingMessagesPerDay || 1000)))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Maximum first-time recipients per day', attr.title('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. MaxFirstTimeRecipientsPerDay in configuration file.')), dom.br(), maxFirstTimeRecipientsPerDay = dom.input(attr.type('number'), attr.required(''), attr.value('' + (config.MaxFirstTimeRecipientsPerDay || 200)))), dom.label(style({ display: 'block', marginBottom: '.5ex' }), dom.span('Disk usage quota: Maximum total message size ', attr.title('Default maximum total message size in bytes for the account, overriding any globally configured default maximum size if non-zero. A negative value can be used to have no limit in case there is a limit by default. Attempting to add new messages to an account beyond its maximum total size will result in an error. Useful to prevent a single account from filling storage. Use units "k" for kilobytes, or "m", "g", "t".')), dom.br(), quotaMessageSize = dom.input(attr.value(formatQuotaSize(config.QuotaMessageSize))), ' Current usage is ', formatQuotaSize(Math.floor(diskUsage / (1024 * 1024)) * 1024 * 1024), '.'), dom.div(style({ display: 'block', marginBottom: '.5ex' }), dom.label(firstTimeSenderDelay = dom.input(attr.type('checkbox'), config.NoFirstTimeSenderDelay ? [] : attr.checked('')), ' ', dom.span('Delay deliveries from first-time senders', attr.title('To slow down potential spammers, when the message is misclassified as non-junk. Turning off the delay can be useful when the account processes messages automatically and needs fast responses.')))), dom.div(style({ display: 'block', marginBottom: '.5ex' }), dom.label(noCustomPassword = dom.input(attr.type('checkbox'), config.NoCustomPassword ? attr.checked('') : []), ' ', dom.span("Don't allow account to set a password of their choice", attr.title('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.')))), dom.submitbutton('Save')), async function submit(e) { e.stopPropagation(); e.preventDefault(); - await check(fieldsetSettings, (async () => await client.AccountSettingsSave(name, parseInt(maxOutgoingMessagesPerDay.value) || 0, parseInt(maxFirstTimeRecipientsPerDay.value) || 0, xparseSize(quotaMessageSize.value), firstTimeSenderDelay.checked))()); + await check(fieldsetSettings, (async () => await client.AccountSettingsSave(name, parseInt(maxOutgoingMessagesPerDay.value) || 0, parseInt(maxFirstTimeRecipientsPerDay.value) || 0, xparseSize(quotaMessageSize.value), firstTimeSenderDelay.checked, noCustomPassword.checked))()); }), dom.br(), dom.h2('Set new password'), formPassword = dom.form(fieldsetPassword = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'New password', dom.br(), password = dom.input(attr.type('password'), attr.autocomplete('new-password'), attr.required(''), function focus() { passwordHint.style.display = ''; })), ' ', dom.submitbutton('Change password')), passwordHint = dom.div(style({ display: 'none', marginTop: '.5ex' }), dom.clickbutton('Generate random password', function click(e) { diff --git a/webadmin/admin.ts b/webadmin/admin.ts index d2cc07d..79078c6 100644 --- a/webadmin/admin.ts +++ b/webadmin/admin.ts @@ -868,6 +868,7 @@ const account = async (name: string) => { let maxFirstTimeRecipientsPerDay: HTMLInputElement let quotaMessageSize: HTMLInputElement let firstTimeSenderDelay: HTMLInputElement + let noCustomPassword: HTMLInputElement let formPassword: HTMLFormElement let fieldsetPassword: HTMLFieldSetElement @@ -1031,7 +1032,14 @@ const account = async (name: string) => { style({display: 'block', marginBottom: '.5ex'}), dom.label( firstTimeSenderDelay=dom.input(attr.type('checkbox'), config.NoFirstTimeSenderDelay ? [] : attr.checked('')), ' ', - dom.span('Delay deliveries from first-time senders.', attr.title('To slow down potential spammers, when the message is misclassified as non-junk. Turning off the delay can be useful when the account processes messages automatically and needs fast responses.')), + dom.span('Delay deliveries from first-time senders', attr.title('To slow down potential spammers, when the message is misclassified as non-junk. Turning off the delay can be useful when the account processes messages automatically and needs fast responses.')), + ), + ), + dom.div( + style({display: 'block', marginBottom: '.5ex'}), + dom.label( + noCustomPassword=dom.input(attr.type('checkbox'), config.NoCustomPassword ? attr.checked('') : []), ' ', + dom.span("Don't allow account to set a password of their choice", attr.title('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.')), ), ), dom.submitbutton('Save'), @@ -1039,7 +1047,7 @@ const account = async (name: string) => { async function submit(e: SubmitEvent) { e.stopPropagation() e.preventDefault() - await check(fieldsetSettings, (async () => await client.AccountSettingsSave(name, parseInt(maxOutgoingMessagesPerDay.value) || 0, parseInt(maxFirstTimeRecipientsPerDay.value) || 0, xparseSize(quotaMessageSize.value), firstTimeSenderDelay.checked))()) + await check(fieldsetSettings, (async () => await client.AccountSettingsSave(name, parseInt(maxOutgoingMessagesPerDay.value) || 0, parseInt(maxFirstTimeRecipientsPerDay.value) || 0, xparseSize(quotaMessageSize.value), firstTimeSenderDelay.checked, noCustomPassword.checked))()) }, ), dom.br(), diff --git a/webadmin/api.json b/webadmin/api.json index 80d7577..bb173db 100644 --- a/webadmin/api.json +++ b/webadmin/api.json @@ -688,6 +688,12 @@ "Typewords": [ "bool" ] + }, + { + "Name": "noCustomPassword", + "Typewords": [ + "bool" + ] } ], "Returns": [] @@ -4159,6 +4165,13 @@ "bool" ] }, + { + "Name": "NoCustomPassword", + "Docs": "", + "Typewords": [ + "bool" + ] + }, { "Name": "Routes", "Docs": "", diff --git a/webadmin/api.ts b/webadmin/api.ts index ccb4f3e..1972fc7 100644 --- a/webadmin/api.ts +++ b/webadmin/api.ts @@ -399,6 +399,7 @@ export interface Account { MaxOutgoingMessagesPerDay: number MaxFirstTimeRecipientsPerDay: number NoFirstTimeSenderDelay: boolean + NoCustomPassword: boolean Routes?: Route[] | null DNSDomain: Domain // Parsed form of Domain. Aliases?: AddressAlias[] | null @@ -1195,7 +1196,7 @@ export const types: TypenameMap = { "Address": {"Name":"Address","Docs":"","Fields":[{"Name":"Localpart","Docs":"","Typewords":["Localpart"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]}, "Destination": {"Name":"Destination","Docs":"","Fields":[{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Rulesets","Docs":"","Typewords":["[]","Ruleset"]},{"Name":"SMTPError","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]}]}, "Ruleset": {"Name":"Ruleset","Docs":"","Fields":[{"Name":"SMTPMailFromRegexp","Docs":"","Typewords":["string"]},{"Name":"MsgFromRegexp","Docs":"","Typewords":["string"]},{"Name":"VerifiedDomain","Docs":"","Typewords":["string"]},{"Name":"HeadersRegexp","Docs":"","Typewords":["{}","string"]},{"Name":"IsForward","Docs":"","Typewords":["bool"]},{"Name":"ListAllowDomain","Docs":"","Typewords":["string"]},{"Name":"AcceptRejectsToMailbox","Docs":"","Typewords":["string"]},{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Comment","Docs":"","Typewords":["string"]},{"Name":"VerifiedDNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"ListAllowDNSDomain","Docs":"","Typewords":["Domain"]}]}, - "Account": {"Name":"Account","Docs":"","Fields":[{"Name":"OutgoingWebhook","Docs":"","Typewords":["nullable","OutgoingWebhook"]},{"Name":"IncomingWebhook","Docs":"","Typewords":["nullable","IncomingWebhook"]},{"Name":"FromIDLoginAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"KeepRetiredMessagePeriod","Docs":"","Typewords":["int64"]},{"Name":"KeepRetiredWebhookPeriod","Docs":"","Typewords":["int64"]},{"Name":"LoginDisabled","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]},{"Name":"Destinations","Docs":"","Typewords":["{}","Destination"]},{"Name":"SubjectPass","Docs":"","Typewords":["SubjectPass"]},{"Name":"QuotaMessageSize","Docs":"","Typewords":["int64"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"KeepRejects","Docs":"","Typewords":["bool"]},{"Name":"AutomaticJunkFlags","Docs":"","Typewords":["AutomaticJunkFlags"]},{"Name":"JunkFilter","Docs":"","Typewords":["nullable","JunkFilter"]},{"Name":"MaxOutgoingMessagesPerDay","Docs":"","Typewords":["int32"]},{"Name":"MaxFirstTimeRecipientsPerDay","Docs":"","Typewords":["int32"]},{"Name":"NoFirstTimeSenderDelay","Docs":"","Typewords":["bool"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"Aliases","Docs":"","Typewords":["[]","AddressAlias"]}]}, + "Account": {"Name":"Account","Docs":"","Fields":[{"Name":"OutgoingWebhook","Docs":"","Typewords":["nullable","OutgoingWebhook"]},{"Name":"IncomingWebhook","Docs":"","Typewords":["nullable","IncomingWebhook"]},{"Name":"FromIDLoginAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"KeepRetiredMessagePeriod","Docs":"","Typewords":["int64"]},{"Name":"KeepRetiredWebhookPeriod","Docs":"","Typewords":["int64"]},{"Name":"LoginDisabled","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"Description","Docs":"","Typewords":["string"]},{"Name":"FullName","Docs":"","Typewords":["string"]},{"Name":"Destinations","Docs":"","Typewords":["{}","Destination"]},{"Name":"SubjectPass","Docs":"","Typewords":["SubjectPass"]},{"Name":"QuotaMessageSize","Docs":"","Typewords":["int64"]},{"Name":"RejectsMailbox","Docs":"","Typewords":["string"]},{"Name":"KeepRejects","Docs":"","Typewords":["bool"]},{"Name":"AutomaticJunkFlags","Docs":"","Typewords":["AutomaticJunkFlags"]},{"Name":"JunkFilter","Docs":"","Typewords":["nullable","JunkFilter"]},{"Name":"MaxOutgoingMessagesPerDay","Docs":"","Typewords":["int32"]},{"Name":"MaxFirstTimeRecipientsPerDay","Docs":"","Typewords":["int32"]},{"Name":"NoFirstTimeSenderDelay","Docs":"","Typewords":["bool"]},{"Name":"NoCustomPassword","Docs":"","Typewords":["bool"]},{"Name":"Routes","Docs":"","Typewords":["[]","Route"]},{"Name":"DNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"Aliases","Docs":"","Typewords":["[]","AddressAlias"]}]}, "OutgoingWebhook": {"Name":"OutgoingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]},{"Name":"Events","Docs":"","Typewords":["[]","string"]}]}, "IncomingWebhook": {"Name":"IncomingWebhook","Docs":"","Fields":[{"Name":"URL","Docs":"","Typewords":["string"]},{"Name":"Authorization","Docs":"","Typewords":["string"]}]}, "SubjectPass": {"Name":"SubjectPass","Docs":"","Fields":[{"Name":"Period","Docs":"","Typewords":["int64"]}]}, @@ -1715,11 +1716,11 @@ export class Client { } // AccountSettingsSave set new settings for an account that only an admin can set. - async AccountSettingsSave(accountName: string, maxOutgoingMessagesPerDay: number, maxFirstTimeRecipientsPerDay: number, maxMsgSize: number, firstTimeSenderDelay: boolean): Promise { + async AccountSettingsSave(accountName: string, maxOutgoingMessagesPerDay: number, maxFirstTimeRecipientsPerDay: number, maxMsgSize: number, firstTimeSenderDelay: boolean, noCustomPassword: boolean): Promise { const fn: string = "AccountSettingsSave" - const paramTypes: string[][] = [["string"],["int32"],["int32"],["int64"],["bool"]] + const paramTypes: string[][] = [["string"],["int32"],["int32"],["int64"],["bool"],["bool"]] const returnTypes: string[][] = [] - const params: any[] = [accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize, firstTimeSenderDelay] + const params: any[] = [accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize, firstTimeSenderDelay, noCustomPassword] return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void }