replace http basic auth for web interfaces with session cookie & csrf-based auth

the http basic auth we had was very simple to reason about, and to implement.
but it has a major downside:

there is no way to logout, browsers keep sending credentials. ideally, browsers
themselves would show a button to stop sending credentials.

a related downside: the http auth mechanism doesn't indicate for which server
paths the credentials are.

another downside: the original password is sent to the server with each
request. though sending original passwords to web servers seems to be
considered normal.

our new approach uses session cookies, along with csrf values when we can. the
sessions are server-side managed, automatically extended on each use. this
makes it easy to invalidate sessions and keeps the frontend simpler (than with
long- vs short-term sessions and refreshing). the cookies are httponly,
samesite=strict, scoped to the path of the web interface. cookies are set
"secure" when set over https. the cookie is set by a successful call to Login.
a call to Logout invalidates a session. changing a password invalidates all
sessions for a user, but keeps the session with which the password was changed
alive. the csrf value is also random, and associated with the session cookie.
the csrf must be sent as header for api calls, or as parameter for direct form
posts (where we cannot set a custom header). rest-like calls made directly by
the browser, e.g. for images, don't have a csrf protection. the csrf value is
returned by the Login api call and stored in localstorage.

api calls without credentials return code "user:noAuth", and with bad
credentials return "user:badAuth". the api client recognizes this and triggers
a login. after a login, all auth-failed api calls are automatically retried.
only for "user:badAuth" is an error message displayed in the login form (e.g.
session expired).

in an ideal world, browsers would take care of most session management. a
server would indicate authentication is needed (like http basic auth), and the
browsers uses trusted ui to request credentials for the server & path. the
browser could use safer mechanism than sending original passwords to the
server, such as scram, along with a standard way to create sessions.  for now,
web developers have to do authentication themselves: from showing the login
prompt, ensuring the right session/csrf cookies/localstorage/headers/etc are
sent with each request.

webauthn is a newer way to do authentication, perhaps we'll implement it in the
future. though hardware tokens aren't an attractive option for many users, and
it may be overkill as long as we still do old-fashioned authentication in smtp
& imap where passwords can be sent to the server.

for issue #58
This commit is contained in:
Mechiel Lukkien 2024-01-04 13:10:48 +01:00
parent c930a400be
commit 0f8bf2f220
No known key found for this signature in database
50 changed files with 3560 additions and 832 deletions

View file

@ -115,9 +115,7 @@ https://nlnet.nl/project/Mox/.
## Roadmap
- Integrate account page into webmail
- Improve documentation
- Authentication other than HTTP-basic for webmail/webadmin
- Improve SMTP delivery from queue
- Webmail improvements
- HTTP-based API for sending messages and receiving delivery feedback

View file

@ -174,37 +174,13 @@ type Listener struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 993."`
} `sconf:"optional" sconf-doc:"IMAP over TLS for reading email, by email applications. Requires a TLS config."`
AccountHTTP struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 80."`
Path string `sconf:"optional" sconf-doc:"Path to serve account requests on, e.g. /mox/. Useful if domain serves other resources. Default is /."`
} `sconf:"optional" sconf-doc:"Account web interface, for email users wanting to change their accounts, e.g. set new password, set new delivery rulesets. Served at /."`
AccountHTTPS struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 80."`
Path string `sconf:"optional" sconf-doc:"Path to serve account requests on, e.g. /mox/. Useful if domain serves other resources. Default is /."`
} `sconf:"optional" sconf-doc:"Account web interface listener for HTTPS. Requires a TLS config."`
AdminHTTP struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 80."`
Path string `sconf:"optional" sconf-doc:"Path to serve admin requests on, e.g. /moxadmin/. Useful if domain serves other resources. Default is /admin/."`
} `sconf:"optional" sconf-doc:"Admin web interface, for managing domains, accounts, etc. Served at /admin/. Preferably only enable on non-public IPs. Hint: use 'ssh -L 8080:localhost:80 you@yourmachine' and open http://localhost:8080/admin/, or set up a tunnel (e.g. WireGuard) and add its IP to the mox 'internal' listener."`
AdminHTTPS struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 443."`
Path string `sconf:"optional" sconf-doc:"Path to serve admin requests on, e.g. /moxadmin/. Useful if domain serves other resources. Default is /admin/."`
} `sconf:"optional" sconf-doc:"Admin web interface listener for HTTPS. Requires a TLS config. Preferably only enable on non-public IPs."`
WebmailHTTP struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 80."`
Path string `sconf:"optional" sconf-doc:"Path to serve account requests on. Useful if domain serves other resources. Default is /webmail/."`
} `sconf:"optional" sconf-doc:"Webmail client, for reading email."`
WebmailHTTPS struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 443."`
Path string `sconf:"optional" sconf-doc:"Path to serve account requests on. Useful if domain serves other resources. Default is /webmail/."`
} `sconf:"optional" sconf-doc:"Webmail client, for reading email."`
MetricsHTTP struct {
AccountHTTP WebService `sconf:"optional" sconf-doc:"Account web interface, for email users wanting to change their accounts, e.g. set new password, set new delivery rulesets. Default path is /."`
AccountHTTPS WebService `sconf:"optional" sconf-doc:"Account web interface listener like AccountHTTP, but for HTTPS. Requires a TLS config."`
AdminHTTP WebService `sconf:"optional" sconf-doc:"Admin web interface, for managing domains, accounts, etc. Default path is /admin/. Preferably only enable on non-public IPs. Hint: use 'ssh -L 8080:localhost:80 you@yourmachine' and open http://localhost:8080/admin/, or set up a tunnel (e.g. WireGuard) and add its IP to the mox 'internal' listener."`
AdminHTTPS WebService `sconf:"optional" sconf-doc:"Admin web interface listener like AdminHTTP, but for HTTPS. Requires a TLS config."`
WebmailHTTP WebService `sconf:"optional" sconf-doc:"Webmail client, for reading email. Default path is /webmail/."`
WebmailHTTPS WebService `sconf:"optional" sconf-doc:"Webmail client, like WebmailHTTP, but for HTTPS. Requires a TLS config."`
MetricsHTTP struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 8010."`
} `sconf:"optional" sconf-doc:"Serve prometheus metrics, for monitoring. You should not enable this on a public IP."`
@ -232,6 +208,14 @@ type Listener struct {
} `sconf:"optional" sconf-doc:"All configured WebHandlers will serve on an enabled listener. Either ACME must be configured, or for each WebHandler domain a TLS certificate must be configured."`
}
// WebService is an internal web interface: webmail, account, admin.
type WebService struct {
Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 80 for HTTP and 443 for HTTPS."`
Path string `sconf:"optional" sconf-doc:"Path to serve requests on."`
Forwarded bool `sconf:"optional" sconf-doc:"If set, X-Forwarded-* headers are used for the remote IP address for rate limiting and for the \"secure\" status of cookies."`
}
// Transport is a method to delivery a message. At most one of the fields can
// be non-nil. The non-nil field represents the type of transport. For a
// transport with all fields nil, regular email delivery is done.

View file

@ -271,76 +271,97 @@ describe-static" and "mox config describe-domains":
Port: 0
# Account web interface, for email users wanting to change their accounts, e.g.
# set new password, set new delivery rulesets. Served at /. (optional)
# set new password, set new delivery rulesets. Default path is /. (optional)
AccountHTTP:
Enabled: false
# Default 80. (optional)
# Default 80 for HTTP and 443 for HTTPS. (optional)
Port: 0
# Path to serve account requests on, e.g. /mox/. Useful if domain serves other
# resources. Default is /. (optional)
# Path to serve requests on. (optional)
Path:
# Account web interface listener for HTTPS. Requires a TLS config. (optional)
# If set, X-Forwarded-* headers are used for the remote IP address for rate
# limiting and for the "secure" status of cookies. (optional)
Forwarded: false
# Account web interface listener like AccountHTTP, but for HTTPS. Requires a TLS
# config. (optional)
AccountHTTPS:
Enabled: false
# Default 80. (optional)
# Default 80 for HTTP and 443 for HTTPS. (optional)
Port: 0
# Path to serve account requests on, e.g. /mox/. Useful if domain serves other
# resources. Default is /. (optional)
# Path to serve requests on. (optional)
Path:
# Admin web interface, for managing domains, accounts, etc. Served at /admin/.
# Preferably only enable on non-public IPs. Hint: use 'ssh -L 8080:localhost:80
# you@yourmachine' and open http://localhost:8080/admin/, or set up a tunnel (e.g.
# WireGuard) and add its IP to the mox 'internal' listener. (optional)
# If set, X-Forwarded-* headers are used for the remote IP address for rate
# limiting and for the "secure" status of cookies. (optional)
Forwarded: false
# Admin web interface, for managing domains, accounts, etc. Default path is
# /admin/. Preferably only enable on non-public IPs. Hint: use 'ssh -L
# 8080:localhost:80 you@yourmachine' and open http://localhost:8080/admin/, or set
# up a tunnel (e.g. WireGuard) and add its IP to the mox 'internal' listener.
# (optional)
AdminHTTP:
Enabled: false
# Default 80. (optional)
# Default 80 for HTTP and 443 for HTTPS. (optional)
Port: 0
# Path to serve admin requests on, e.g. /moxadmin/. Useful if domain serves other
# resources. Default is /admin/. (optional)
# Path to serve requests on. (optional)
Path:
# Admin web interface listener for HTTPS. Requires a TLS config. Preferably only
# enable on non-public IPs. (optional)
# If set, X-Forwarded-* headers are used for the remote IP address for rate
# limiting and for the "secure" status of cookies. (optional)
Forwarded: false
# Admin web interface listener like AdminHTTP, but for HTTPS. Requires a TLS
# config. (optional)
AdminHTTPS:
Enabled: false
# Default 443. (optional)
# Default 80 for HTTP and 443 for HTTPS. (optional)
Port: 0
# Path to serve admin requests on, e.g. /moxadmin/. Useful if domain serves other
# resources. Default is /admin/. (optional)
# Path to serve requests on. (optional)
Path:
# Webmail client, for reading email. (optional)
# If set, X-Forwarded-* headers are used for the remote IP address for rate
# limiting and for the "secure" status of cookies. (optional)
Forwarded: false
# Webmail client, for reading email. Default path is /webmail/. (optional)
WebmailHTTP:
Enabled: false
# Default 80. (optional)
# Default 80 for HTTP and 443 for HTTPS. (optional)
Port: 0
# Path to serve account requests on. Useful if domain serves other resources.
# Default is /webmail/. (optional)
# Path to serve requests on. (optional)
Path:
# Webmail client, for reading email. (optional)
# If set, X-Forwarded-* headers are used for the remote IP address for rate
# limiting and for the "secure" status of cookies. (optional)
Forwarded: false
# Webmail client, like WebmailHTTP, but for HTTPS. Requires a TLS config.
# (optional)
WebmailHTTPS:
Enabled: false
# Default 443. (optional)
# Default 80 for HTTP and 443 for HTTPS. (optional)
Port: 0
# Path to serve account requests on. Useful if domain serves other resources.
# Default is /webmail/. (optional)
# Path to serve requests on. (optional)
Path:
# If set, X-Forwarded-* headers are used for the remote IP address for rate
# limiting and for the "secure" status of cookies. (optional)
Forwarded: false
# Serve prometheus metrics, for monitoring. You should not enable this on a public
# IP. (optional)
MetricsHTTP:

6
go.mod
View file

@ -5,12 +5,12 @@ go 1.20
require (
github.com/mjl-/adns v0.0.0-20231109160910-82839fe3e6ae
github.com/mjl-/autocert v0.0.0-20231013072455-c361ae2e20a6
github.com/mjl-/bstore v0.0.3
github.com/mjl-/bstore v0.0.4
github.com/mjl-/sconf v0.0.5
github.com/mjl-/sherpa v0.6.6
github.com/mjl-/sherpa v0.6.7
github.com/mjl-/sherpadoc v0.0.12
github.com/mjl-/sherpaprom v0.0.2
github.com/mjl-/sherpats v0.0.4
github.com/mjl-/sherpats v0.0.5
github.com/prometheus/client_golang v1.17.0
go.etcd.io/bbolt v1.3.8
golang.org/x/crypto v0.15.0

12
go.sum
View file

@ -27,19 +27,19 @@ github.com/mjl-/adns v0.0.0-20231109160910-82839fe3e6ae h1:P/kTaQbDFSbmDK+RVjwu7
github.com/mjl-/adns v0.0.0-20231109160910-82839fe3e6ae/go.mod h1:v47qUMJnipnmDTRGaHwpCwzE6oypa5K33mUvBfzZBn8=
github.com/mjl-/autocert v0.0.0-20231013072455-c361ae2e20a6 h1:TEXyTghAN9pmV2ffzdnhmzkML08e1Z/oGywJ9eunbRI=
github.com/mjl-/autocert v0.0.0-20231013072455-c361ae2e20a6/go.mod h1:taMFU86abMxKLPV4Bynhv8enbYmS67b8LG80qZv2Qus=
github.com/mjl-/bstore v0.0.3 h1:7lpZfzADXYbodan3s8Tb5aXXK90oKB9vdDvz0MC5jrw=
github.com/mjl-/bstore v0.0.3/go.mod h1:/cD25FNBaDfvL/plFRxI3Ba3E+wcB0XVOS8nJDqndg0=
github.com/mjl-/bstore v0.0.4 h1:q+R1oAr8+E9yf9q+zxkVjQ18VFqD/E9KmGVoe4FIOBA=
github.com/mjl-/bstore v0.0.4/go.mod h1:/cD25FNBaDfvL/plFRxI3Ba3E+wcB0XVOS8nJDqndg0=
github.com/mjl-/sconf v0.0.5 h1:4CMUTENpSnaeP2g6RKtrs8udTxnJgjX2MCCovxGId6s=
github.com/mjl-/sconf v0.0.5/go.mod h1:uF8OdWtLT8La3i4ln176i1pB0ps9pXGCaABEU55ZkE0=
github.com/mjl-/sherpa v0.6.6 h1:4Xc4/s12W2I/C1genIL8l4ZCLMsTo8498cPSjQcIHGc=
github.com/mjl-/sherpa v0.6.6/go.mod h1:dSpAOdgpwdqQZ72O4n3EHo/tR68eKyan8tYYraUMPNc=
github.com/mjl-/sherpa v0.6.7 h1:C5F8XQdV5nCuS4fvB+ye/ziUQrajEhOoj/t2w5T14BY=
github.com/mjl-/sherpa v0.6.7/go.mod h1:dSpAOdgpwdqQZ72O4n3EHo/tR68eKyan8tYYraUMPNc=
github.com/mjl-/sherpadoc v0.0.0-20190505200843-c0a7f43f5f1d/go.mod h1:5khTKxoKKNXcB8bkVUO6GlzC7PFtMmkHq578lPbmnok=
github.com/mjl-/sherpadoc v0.0.12 h1:6hVe2Z0DnwjC0bfuOwfz8ov7JTCUU49cEaj7h22NiPk=
github.com/mjl-/sherpadoc v0.0.12/go.mod h1:vh5zcsk3j/Tvm725EY+unTZb3EZcZcpiEQzrODSa6+I=
github.com/mjl-/sherpaprom v0.0.2 h1:1dlbkScsNafM5jURI44uiWrZMSwfZtcOFEEq7vx2C1Y=
github.com/mjl-/sherpaprom v0.0.2/go.mod h1:cl5nMNOvqhzMiQJ2FzccQ9ReivjHXe53JhOVkPfSvw4=
github.com/mjl-/sherpats v0.0.4 h1:rZkJO4YV4MfuCi3E4ifzbhpY6VgZgsQoOcL04ABEib4=
github.com/mjl-/sherpats v0.0.4/go.mod h1:MoNZJtLmu8oCZ4Ocv5vZksENN4pp6/SJMlg9uTII4KA=
github.com/mjl-/sherpats v0.0.5 h1:XJBorAxUY/C8IBjCmu741353Ex7CFXrb53NDGCrBu74=
github.com/mjl-/sherpats v0.0.5/go.mod h1:MoNZJtLmu8oCZ4Ocv5vZksENN4pp6/SJMlg9uTII4KA=
github.com/mjl-/xfmt v0.0.2 h1:6dLgd6U3bmDJKtTxsaSYYyMaORoO4hKBAJo4XKkPRko=
github.com/mjl-/xfmt v0.0.2/go.mod h1:DIEOLmETMQHHr4OgwPG7iC37rDiN9MaZIZxNm5hBtL8=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=

View file

@ -534,7 +534,7 @@ func Listen() {
path = l.AccountHTTP.Path
}
srv := ensureServe(false, port, "account-http at "+path)
handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webaccount.Handle)))
handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webaccount.Handler(path, l.AccountHTTP.Forwarded))))
srv.Handle("account", nil, path, handler)
redirectToTrailingSlash(srv, "account", path)
}
@ -545,7 +545,7 @@ func Listen() {
path = l.AccountHTTPS.Path
}
srv := ensureServe(true, port, "account-https at "+path)
handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webaccount.Handle)))
handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webaccount.Handler(path, l.AccountHTTPS.Forwarded))))
srv.Handle("account", nil, path, handler)
redirectToTrailingSlash(srv, "account", path)
}
@ -557,7 +557,7 @@ func Listen() {
path = l.AdminHTTP.Path
}
srv := ensureServe(false, port, "admin-http at "+path)
handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webadmin.Handle)))
handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webadmin.Handler(path, l.AdminHTTP.Forwarded))))
srv.Handle("admin", nil, path, handler)
redirectToTrailingSlash(srv, "admin", path)
}
@ -568,7 +568,7 @@ func Listen() {
path = l.AdminHTTPS.Path
}
srv := ensureServe(true, port, "admin-https at "+path)
handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webadmin.Handle)))
handler := safeHeaders(http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webadmin.Handler(path, l.AdminHTTPS.Forwarded))))
srv.Handle("admin", nil, path, handler)
redirectToTrailingSlash(srv, "admin", path)
}
@ -584,7 +584,8 @@ func Listen() {
path = l.WebmailHTTP.Path
}
srv := ensureServe(false, port, "webmail-http at "+path)
srv.Handle("webmail", nil, path, http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webmail.Handler(maxMsgSize))))
handler := http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webmail.Handler(maxMsgSize, path, l.WebmailHTTP.Forwarded)))
srv.Handle("webmail", nil, path, handler)
redirectToTrailingSlash(srv, "webmail", path)
}
if l.WebmailHTTPS.Enabled {
@ -594,7 +595,8 @@ func Listen() {
path = l.WebmailHTTPS.Path
}
srv := ensureServe(true, port, "webmail-https at "+path)
srv.Handle("webmail", nil, path, http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webmail.Handler(maxMsgSize))))
handler := http.StripPrefix(path[:len(path)-1], http.HandlerFunc(webmail.Handler(maxMsgSize, path, l.WebmailHTTPS.Forwarded)))
srv.Handle("webmail", nil, path, handler)
redirectToTrailingSlash(srv, "webmail", path)
}
@ -788,7 +790,6 @@ func listen1(ip string, port int, tlsConfig *tls.Config, name string, kinds []st
func Serve() {
loadStaticGzipCache(mox.DataDirPath("tmp/httpstaticcompresscache"), 512*1024*1024)
go webadmin.ManageAuthCache()
go webaccount.ImportManage()
for _, serve := range servers {

2
lib.ts
View file

@ -211,6 +211,8 @@ const attr = {
name: (s: string) => _attr('name', s),
min: (s: string) => _attr('min', s),
max: (s: string) => _attr('max', s),
action: (s: string) => _attr('action', s),
method: (s: string) => _attr('method', s),
}
const style = (x: {[k: string]: string | number}) => { return {_styles: x}}
const prop = (x: {[k: string]: any}) => { return {_props: x}}

View file

@ -13,7 +13,7 @@ var (
},
[]string{
"kind", // submission, imap, webmail, webaccount, webadmin (formerly httpaccount, httpadmin)
"variant", // login, plain, scram-sha-256, scram-sha-1, cram-md5, httpbasic
"variant", // login, plain, scram-sha-256, scram-sha-1, cram-md5, weblogin, websessionuse. formerly: httpbasic.
// todo: we currently only use badcreds, but known baduser can be helpful
"result", // ok, baduser, badpassword, badcreds, error, aborted
},

View file

@ -33,6 +33,7 @@ const (
Upgradethreads Panic = "upgradethreads"
Importmanage Panic = "importmanage"
Importmessages Panic = "importmessages"
Store Panic = "store"
Webadmin Panic = "webadmin"
Webmailsendevent Panic = "webmailsendevent"
Webmail Panic = "webmail"

View file

@ -714,12 +714,15 @@ and check the admin page for the needed DNS records.`)
}
internal.AccountHTTP.Enabled = true
internal.AdminHTTP.Enabled = true
internal.MetricsHTTP.Enabled = true
internal.WebmailHTTP.Enabled = true
internal.MetricsHTTP.Enabled = true
if existingWebserver {
internal.AccountHTTP.Port = 1080
internal.AccountHTTP.Forwarded = true
internal.AdminHTTP.Port = 1080
internal.AdminHTTP.Forwarded = true
internal.WebmailHTTP.Port = 1080
internal.WebmailHTTP.Forwarded = true
internal.AutoconfigHTTPS.Enabled = true
internal.AutoconfigHTTPS.Port = 81
internal.AutoconfigHTTPS.NonTLS = true

View file

@ -691,8 +691,29 @@ type DiskUsage struct {
MessageSize int64 // Sum of all messages, for quota accounting.
}
// SessionToken and CSRFToken are types to prevent mixing them up.
// Base64 raw url encoded.
type SessionToken string
type CSRFToken string
// LoginSession represents a login session. We keep a limited number of sessions
// for a user, removing the oldest session when a new one is created.
type LoginSession struct {
ID int64
Created time.Time `bstore:"nonzero,default now"` // Of original login.
Expires time.Time `bstore:"nonzero"` // Extended each time it is used.
SessionTokenBinary [16]byte `bstore:"nonzero"` // Stored in cookie, like "webmailsession" or "webaccountsession".
CSRFTokenBinary [16]byte // For API requests, in "x-mox-csrf" header.
AccountName string `bstore:"nonzero"`
LoginAddress string `bstore:"nonzero"`
// Set when loading from database.
sessionToken SessionToken
csrfToken CSRFToken
}
// Types stored in DB.
var DBTypes = []any{NextUIDValidity{}, Message{}, Recipient{}, Mailbox{}, Subscription{}, Outgoing{}, Password{}, Subjectpass{}, SyncState{}, Upgrade{}, RecipientDomainTLS{}, DiskUsage{}}
var DBTypes = []any{NextUIDValidity{}, Message{}, Recipient{}, Mailbox{}, Subscription{}, Outgoing{}, Password{}, Subjectpass{}, SyncState{}, Upgrade{}, RecipientDomainTLS{}, DiskUsage{}, LoginSession{}}
// Account holds the information about a user, includings mailboxes, messages, imap subscriptions.
type Account struct {
@ -1489,7 +1510,8 @@ func (a *Account) SetPassword(log mlog.Log, password string) error {
if err := tx.Insert(&pw); err != nil {
return fmt.Errorf("inserting new password: %v", err)
}
return nil
return sessionRemoveAll(context.TODO(), log, tx, a.Name)
})
if err == nil {
log.Info("new password set for account", slog.String("account", a.Name))

321
store/session.go Normal file
View file

@ -0,0 +1,321 @@
package store
import (
"context"
cryptorand "crypto/rand"
"encoding/base64"
"errors"
"fmt"
"runtime/debug"
"sync"
"time"
"golang.org/x/exp/slog"
"github.com/mjl-/bstore"
"github.com/mjl-/mox/metrics"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
)
const sessionsPerAccount = 100 // We remove the oldest when 100th is added.
const sessionLifetime = 24 * time.Hour // Extended automatically by use.
const sessionWriteDelay = 5 * time.Minute // Per account, for coalescing writes.
var sessions = struct {
sync.Mutex
// For each account, we keep all sessions (with fixed maximum number) in memory. If
// the map for an account is nil, it is initialized from the database on first use.
accounts map[string]map[SessionToken]LoginSession
// We flush sessions with extended expiration timestamp to disk with a delay, to
// coalesce potentially many changes. The delay is short enough that we don't have
// to care about flushing to disk on shutdown.
pendingFlushes map[string]map[SessionToken]struct{}
}{
accounts: map[string]map[SessionToken]LoginSession{},
pendingFlushes: map[string]map[SessionToken]struct{}{},
}
// Ensure sessions for account are initialized from database. If the sessions were
// initialized from the database, or when alwaysOpenAccount is true, an open
// account is returned (assuming no error occurred).
//
// must be called with sessions lock held.
func ensureAccountSessions(ctx context.Context, log mlog.Log, accountName string, alwaysOpenAccount bool) (*Account, error) {
var acc *Account
accSessions := sessions.accounts[accountName]
if accSessions == nil {
var err error
acc, err = OpenAccount(log, accountName)
if err != nil {
return nil, err
}
// We still hold the lock, not great...
accSessions = map[SessionToken]LoginSession{}
err = bstore.QueryDB[LoginSession](ctx, acc.DB).ForEach(func(ls LoginSession) error {
// We keep strings around for easy comparison.
ls.sessionToken = SessionToken(base64.RawURLEncoding.EncodeToString(ls.SessionTokenBinary[:]))
ls.csrfToken = CSRFToken(base64.RawURLEncoding.EncodeToString(ls.CSRFTokenBinary[:]))
accSessions[ls.sessionToken] = ls
return nil
})
if err != nil {
return nil, err
}
sessions.accounts[accountName] = accSessions
}
if acc == nil && alwaysOpenAccount {
return OpenAccount(log, accountName)
}
return acc, nil
}
// SessionUse checks if a session is valid. If csrfToken is the empty string, no
// CSRF check is done. Otherwise it must be the csrf token associated with the
// session token.
func SessionUse(ctx context.Context, log mlog.Log, accountName string, sessionToken SessionToken, csrfToken CSRFToken) (LoginSession, error) {
sessions.Lock()
defer sessions.Unlock()
acc, err := ensureAccountSessions(ctx, log, accountName, false)
if err != nil {
return LoginSession{}, err
} else if acc != nil {
if err := acc.Close(); err != nil {
return LoginSession{}, fmt.Errorf("closing account: %w", err)
}
}
return sessionUse(ctx, log, accountName, sessionToken, csrfToken)
}
// must be called with sessions lock held.
func sessionUse(ctx context.Context, log mlog.Log, accountName string, sessionToken SessionToken, csrfToken CSRFToken) (LoginSession, error) {
// Check if valid.
ls, ok := sessions.accounts[accountName][sessionToken]
if !ok {
return LoginSession{}, fmt.Errorf("unknown session token")
} else if time.Until(ls.Expires) < 0 {
return LoginSession{}, fmt.Errorf("session expired")
} else if csrfToken != "" && csrfToken != ls.csrfToken {
return LoginSession{}, fmt.Errorf("mismatch between csrf and session tokens")
}
// Extend lifetime.
ls.Expires = time.Now().Add(sessionLifetime)
sessions.accounts[accountName][sessionToken] = ls
// If we haven't scheduled a flush to database yet, schedule one now.
if sessions.pendingFlushes[accountName] == nil {
sessions.pendingFlushes[accountName] = map[SessionToken]struct{}{}
go func() {
pkglog := mlog.New("store", nil)
defer func() {
x := recover()
if x != nil {
pkglog.Error("recover from panic", slog.Any("panic", x))
debug.PrintStack()
metrics.PanicInc(metrics.Store)
}
}()
time.Sleep(sessionWriteDelay)
sessionsDelayedFlush(pkglog, accountName)
}()
}
sessions.pendingFlushes[accountName][ls.sessionToken] = struct{}{}
return ls, nil
}
// wait, then flush all changed sessions for an account.
func sessionsDelayedFlush(log mlog.Log, accountName string) {
sessions.Lock()
defer sessions.Unlock()
sessionTokens := sessions.pendingFlushes[accountName]
delete(sessions.pendingFlushes, accountName)
_, ok := sessions.accounts[accountName]
if !ok {
// Account may have been removed. Nothing to flush.
return
}
acc, err := OpenAccount(log, accountName)
if err != nil && errors.Is(err, ErrAccountUnknown) {
// Account may have been removed. Nothing to flush.
log.Infox("flushing sessions for account", err, slog.String("account", accountName))
return
}
if err != nil {
log.Errorx("open account for flushing changed session tokens", err, slog.String("account", accountName))
return
}
defer func() {
err := acc.Close()
log.Check(err, "closing account")
}()
err = acc.DB.Write(mox.Context, func(tx *bstore.Tx) error {
for sessionToken := range sessionTokens {
ls, ok := sessions.accounts[accountName][sessionToken]
if !ok {
return fmt.Errorf("unknown session token to flush")
}
if err := tx.Update(&ls); err != nil {
return err
}
}
return nil
})
log.Check(err, "flushing changed sessions for account", slog.String("account", accountName))
}
// SessionAddTokens adds a prepared or pre-existing LoginSession to the database and
// cache. Can be used to restore a session token that was used to reset a password.
func SessionAddToken(ctx context.Context, log mlog.Log, ls *LoginSession) error {
sessions.Lock()
defer sessions.Unlock()
acc, err := ensureAccountSessions(ctx, log, ls.AccountName, true)
if err != nil {
return err
}
defer func() {
err := acc.Close()
log.Check(err, "closing account after adding session token")
}()
return sessionAddToken(ctx, log, acc, ls)
}
// caller must hold sessions lock.
func sessionAddToken(ctx context.Context, log mlog.Log, acc *Account, ls *LoginSession) error {
ls.ID = 0
err := acc.DB.Write(ctx, func(tx *bstore.Tx) error {
// Remove sessions if we have too many, starting with expired sessions, and
// removing the oldest if needed.
if len(sessions.accounts[ls.AccountName]) >= sessionsPerAccount {
var oldest LoginSession
for _, ols := range sessions.accounts[ls.AccountName] {
if time.Until(ols.Expires) < 0 {
if err := tx.Delete(&ols); err != nil {
return err
}
delete(sessions.accounts[ls.AccountName], ols.sessionToken)
continue
}
if oldest.ID == 0 || ols.Expires.Before(oldest.Expires) {
oldest = ols
}
}
if len(sessions.accounts[ls.AccountName]) >= sessionsPerAccount {
if err := tx.Delete(&oldest); err != nil {
return err
}
delete(sessions.accounts[ls.AccountName], oldest.sessionToken)
}
}
if err := tx.Insert(ls); err != nil {
return fmt.Errorf("insert: %v", err)
}
return nil
})
if err != nil {
return err
}
sessions.accounts[ls.AccountName][ls.sessionToken] = *ls
return nil
}
// SessionAdd creates a new session token, with csrf token, and adds it to the
// database and in-memory session cache. If there are too many sessions, the oldest
// is removed.
func SessionAdd(ctx context.Context, log mlog.Log, accountName, loginAddress string) (session SessionToken, csrf CSRFToken, rerr error) {
// Prepare new LoginSession.
ls := LoginSession{0, time.Time{}, time.Now().Add(sessionLifetime), [16]byte{}, [16]byte{}, accountName, loginAddress, "", ""}
if _, err := cryptorand.Read(ls.SessionTokenBinary[:]); err != nil {
return "", "", err
}
if _, err := cryptorand.Read(ls.CSRFTokenBinary[:]); err != nil {
return "", "", err
}
ls.sessionToken = SessionToken(base64.RawURLEncoding.EncodeToString(ls.SessionTokenBinary[:]))
ls.csrfToken = CSRFToken(base64.RawURLEncoding.EncodeToString(ls.CSRFTokenBinary[:]))
sessions.Lock()
defer sessions.Unlock()
acc, err := ensureAccountSessions(ctx, log, accountName, true)
if err != nil {
return "", "", err
}
defer func() {
err := acc.Close()
log.Check(err, "closing account")
}()
if err := sessionAddToken(ctx, log, acc, &ls); err != nil {
return "", "", err
}
return ls.sessionToken, ls.csrfToken, nil
}
// SessionRemove removes a session from the database and in-memory cache. Future
// operations using the session token will fail.
func SessionRemove(ctx context.Context, log mlog.Log, accountName string, sessionToken SessionToken) error {
sessions.Lock()
defer sessions.Unlock()
acc, err := ensureAccountSessions(ctx, log, accountName, true)
if err != nil {
return err
}
defer acc.Close()
ls, ok := sessions.accounts[accountName][sessionToken]
if !ok {
return fmt.Errorf("unknown session token")
}
if err := acc.DB.Delete(ctx, &ls); err != nil {
return err
}
delete(sessions.accounts[accountName], sessionToken)
pf := sessions.pendingFlushes[accountName]
if pf != nil {
delete(pf, sessionToken)
}
return nil
}
// sessionRemoveAll removes all session tokens for an account. Useful after a password reset.
func sessionRemoveAll(ctx context.Context, log mlog.Log, tx *bstore.Tx, accountName string) error {
sessions.Lock()
defer sessions.Unlock()
if _, err := bstore.QueryTx[LoginSession](tx).Delete(); err != nil {
return err
}
sessions.accounts[accountName] = map[SessionToken]LoginSession{}
if sessions.pendingFlushes[accountName] != nil {
sessions.pendingFlushes[accountName] = map[SessionToken]struct{}{}
}
return nil
}

7
testdata/webadmin/domains.conf vendored Normal file
View file

@ -0,0 +1,7 @@
Domains:
mox.example: nil
Accounts:
mjl:
Domain: mox.example
Destinations:
mjl@mox.example: nil

12
testdata/webadmin/mox.conf vendored Normal file
View file

@ -0,0 +1,12 @@
DataDir: data
AdminPasswordFile: adminpassword
User: 1000
LogLevel: trace
Hostname: mox.example
Listeners:
local:
IPs:
- 0.0.0.0
Postmaster:
Account: mjl
Mailbox: postmaster

View file

@ -337,9 +337,16 @@ func (ft fieldType) parseValue(p *parser) any {
return l
case kindArray:
n := ft.ArrayLength
var l []any
l := make([]any, n)
fm := p.Fieldmap(n)
for i := 0; i < n; i++ {
l = append(l, ft.ListElem.parseValue(p))
if fm.Nonzero(i) {
l[i] = ft.ListElem.parseValue(p)
} else {
// Always add non-zero elements, or we would
// change the number of elements in a list.
l[i] = ft.ListElem.zeroExportValue()
}
}
return l
case kindMap:

View file

@ -34,9 +34,22 @@ type JSON struct {
// HandlerOpts are options for creating a new handler.
type HandlerOpts struct {
Collector Collector // Holds functions for collecting metrics about function calls and other incoming HTTP requests. May be nil.
LaxParameterParsing bool // If enabled, incoming sherpa function calls will ignore unrecognized fields in struct parameters, instead of failing.
AdjustFunctionNames string // If empty, only the first character of function names are lower cased. For "lowerWord", the first string of capitals is lowercased, for "none", the function name is left as is.
// Holds functions for collecting metrics about function calls and other incoming
// HTTP requests. May be nil.
Collector Collector
// If enabled, incoming sherpa function calls will ignore unrecognized fields in
// struct parameters, instead of failing.
LaxParameterParsing bool
// If empty, only the first character of function names are lower cased. For
// "lowerWord", the first string of capitals is lowercased, for "none", the
// function name is left as is.
AdjustFunctionNames string
// Don't send any CORS headers, and respond to OPTIONS requests with 405 "bad
// method".
NoCORS bool
}
// Raw signals a raw JSON response.
@ -463,13 +476,16 @@ func validCallback(cb string) bool {
// - sherpa.js, a small stand-alone client JavaScript library that makes it trivial to start using this API from a browser.
// - functionName, for function invocations on this API.
//
// HTTP response will have CORS-headers set, and support the OPTIONS HTTP method.
// HTTP response will have CORS-headers set, and support the OPTIONS HTTP method,
// unless the NoCORS option was set.
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
hdr := w.Header()
hdr.Set("Access-Control-Allow-Origin", "*")
hdr.Set("Access-Control-Allow-Methods", "GET, POST")
hdr.Set("Access-Control-Allow-Headers", "Content-Type")
if !h.opts.NoCORS {
hdr.Set("Access-Control-Allow-Origin", "*")
hdr.Set("Access-Control-Allow-Methods", "GET, POST")
hdr.Set("Access-Control-Allow-Headers", "Content-Type")
}
collector := h.opts.Collector
@ -489,10 +505,10 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
case r.URL.Path == "sherpa.json":
switch r.Method {
case "OPTIONS":
switch {
case !h.opts.NoCORS && r.Method == "OPTIONS":
w.WriteHeader(204)
case "GET":
case r.Method == "GET":
collector.JSON()
hdr.Set("Content-Type", "application/json; charset=utf-8")
hdr.Set("Cache-Control", "no-cache")
@ -531,11 +547,11 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
default:
name := r.URL.Path
fn, ok := h.functions[name]
switch r.Method {
case "OPTIONS":
switch {
case !h.opts.NoCORS && r.Method == "OPTIONS":
w.WriteHeader(204)
case "POST":
case r.Method == "POST":
hdr.Set("Cache-Control", "no-store")
if !ok {
@ -593,7 +609,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
respondJSON(w, 200, v)
}
case "GET":
case r.Method == "GET":
hdr.Set("Cache-Control", "no-store")
jsonp := false

View file

@ -494,7 +494,7 @@ func Generate(in io.Reader, out io.Writer, apiNameBaseURL string, opts Options)
xprintf("\t\tconst paramTypes: string[][] = %s\n", mustMarshalJSON(sherpaParamTypes))
xprintf("\t\tconst returnTypes: string[][] = %s\n", mustMarshalJSON(sherpaReturnTypes))
xprintf("\t\tconst params: any[] = [%s]\n", strings.Join(paramNames, ", "))
xprintf("\t\treturn await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as %s\n", returnType)
xprintf("\t\treturn await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as %s\n", returnType)
xprintf("\t}\n")
if i < len(sec.Functions)-1 {
xprintf("\n")
@ -524,14 +524,28 @@ func Generate(in io.Reader, out io.Writer, apiNameBaseURL string, opts Options)
xprintf(`let defaultOptions: ClientOptions = {slicesNullable: %v, mapsNullable: %v, nullableOptional: %v}
export class Client {
constructor(private baseURL=defaultBaseURL, public options?: ClientOptions) {
if (!options) {
this.options = defaultOptions
}
private baseURL: string
public authState: AuthState
public options: ClientOptions
constructor() {
this.authState = {}
this.options = {...defaultOptions}
this.baseURL = this.options.baseURL || defaultBaseURL
}
withAuthToken(token: string): Client {
const c = new Client()
c.authState.token = token
c.options = this.options
return c
}
withOptions(options: ClientOptions): Client {
return new Client(this.baseURL, { ...this.options, ...options })
const c = new Client()
c.authState = this.authState
c.options = { ...this.options, ...options }
return c
}
`, opts.SlicesNullable, opts.MapsNullable, opts.NullableOptional)

View file

@ -100,7 +100,7 @@ class verifier {
const ensure = (ok: boolean, expect: string): any => {
if (!ok) {
error('got ' + JSON.stringify(v) + ', expected ' + expect)
error('got ' + JSON.stringify(v) + ', expected ' + expect)
}
return v
}
@ -241,6 +241,7 @@ class verifier {
export interface ClientOptions {
baseURL?: string
aborter?: {abort?: () => void}
timeoutMsec?: number
skipParamCheck?: boolean
@ -248,9 +249,16 @@ export interface ClientOptions {
slicesNullable?: boolean
mapsNullable?: boolean
nullableOptional?: boolean
csrfHeader?: string
login?: (reason: string) => Promise<string>
}
const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes: string[][], returnTypes: string[][], name: string, params: any[]): Promise<any> => {
export interface AuthState {
token?: string // For csrf request header.
loginPromise?: Promise<void> // To let multiple API calls wait for a single login attempt, not each opening a login popup.
}
const _sherpaCall = async (baseURL: string, authState: AuthState, options: ClientOptions, paramTypes: string[][], returnTypes: string[][], name: string, params: any[]): Promise<any> => {
if (!options.skipParamCheck) {
if (params.length !== paramTypes.length) {
return Promise.reject({ message: 'wrong number of parameters in sherpa call, saw ' + params.length + ' != expected ' + paramTypes.length })
@ -291,14 +299,36 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
await simulate(json)
}
// Immediately create promise, so options.aborter is changed before returning.
const promise = new Promise((resolve, reject) => {
let resolve1 = (v: { code: string, message: string }) => {
const fn = (resolve: (v: any) => void, reject: (v: any) => void) => {
let resolve1 = (v: any) => {
resolve(v)
resolve1 = () => { }
reject1 = () => { }
}
let reject1 = (v: { code: string, message: string }) => {
if ((v.code === 'user:noAuth' || v.code === 'user:badAuth') && options.login) {
const login = options.login
if (!authState.loginPromise) {
authState.loginPromise = new Promise((aresolve, areject) => {
login(v.code === 'user:badAuth' ? (v.message || '') : '')
.then((token) => {
authState.token = token
authState.loginPromise = undefined
aresolve()
}, (err: any) => {
authState.loginPromise = undefined
areject(err)
})
})
}
authState.loginPromise
.then(() => {
fn(resolve, reject)
}, (err: any) => {
reject(err)
})
return
}
reject(v)
resolve1 = () => { }
reject1 = () => { }
@ -313,6 +343,9 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
}
}
req.open('POST', url, true)
if (options.csrfHeader && authState.token) {
req.setRequestHeader(options.csrfHeader, authState.token)
}
if (options.timeoutMsec) {
req.timeout = options.timeoutMsec
}
@ -381,7 +414,7 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
} catch (err) {
reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' })
}
})
return await promise
}
return await new Promise(fn)
}
`

6
vendor/modules.txt vendored
View file

@ -16,13 +16,13 @@ github.com/mjl-/adns/internal/singleflight
# github.com/mjl-/autocert v0.0.0-20231013072455-c361ae2e20a6
## explicit; go 1.20
github.com/mjl-/autocert
# github.com/mjl-/bstore v0.0.3
# github.com/mjl-/bstore v0.0.4
## explicit; go 1.19
github.com/mjl-/bstore
# github.com/mjl-/sconf v0.0.5
## explicit; go 1.12
github.com/mjl-/sconf
# github.com/mjl-/sherpa v0.6.6
# github.com/mjl-/sherpa v0.6.7
## explicit; go 1.12
github.com/mjl-/sherpa
# github.com/mjl-/sherpadoc v0.0.12
@ -32,7 +32,7 @@ github.com/mjl-/sherpadoc/cmd/sherpadoc
# github.com/mjl-/sherpaprom v0.0.2
## explicit; go 1.12
github.com/mjl-/sherpaprom
# github.com/mjl-/sherpats v0.0.4
# github.com/mjl-/sherpats v0.0.5
## explicit; go 1.12
github.com/mjl-/sherpats
github.com/mjl-/sherpats/cmd/sherpats

View file

@ -7,17 +7,16 @@ import (
"archive/zip"
"compress/gzip"
"context"
cryptorand "crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
_ "embed"
@ -29,17 +28,13 @@ import (
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/metrics"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxvar"
"github.com/mjl-/mox/store"
"github.com/mjl-/mox/webauth"
)
func init() {
mox.LimitersInit()
}
var pkglog = mlog.New("webaccount", nil)
//go:embed api.json
@ -60,8 +55,6 @@ var webaccountFile = &mox.WebappFile{
var accountDoc = mustParseAPI("account", accountapiJSON)
var accountSherpaHandler http.Handler
func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
err := json.Unmarshal(buf, &doc)
if err != nil {
@ -70,18 +63,39 @@ func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
return doc
}
var sherpaHandlerOpts *sherpa.HandlerOpts
func makeSherpaHandler(cookiePath string, isForwarded bool) (http.Handler, error) {
return sherpa.NewHandler("/api/", moxvar.Version, Account{cookiePath, isForwarded}, &accountDoc, sherpaHandlerOpts)
}
func init() {
collector, err := sherpaprom.NewCollector("moxaccount", nil)
if err != nil {
pkglog.Fatalx("creating sherpa prometheus collector", err)
}
accountSherpaHandler, err = sherpa.NewHandler("/api/", moxvar.Version, Account{}, &accountDoc, &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"})
sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
// Just to validate.
_, err = makeSherpaHandler("", false)
if err != nil {
pkglog.Fatalx("sherpa handler", err)
}
}
// Handler returns a handler for the webaccount endpoints, customized for the
// cookiePath.
func Handler(cookiePath string, isForwarded bool) func(w http.ResponseWriter, r *http.Request) {
sh, err := makeSherpaHandler(cookiePath, isForwarded)
return func(w http.ResponseWriter, r *http.Request) {
if err != nil {
http.Error(w, "500 - internal server error - cannot handle requests", http.StatusInternalServerError)
return
}
handle(sh, isForwarded, w, r)
}
}
func xcheckf(ctx context.Context, err error, format string, args ...any) {
if err == nil {
return
@ -109,61 +123,12 @@ func xcheckuserf(ctx context.Context, err error, format string, args ...any) {
// Account exports web API functions for the account web interface. All its
// methods are exported under api/. Function calls require valid HTTP
// Authentication credentials of a user.
type Account struct{}
// CheckAuth checks http basic auth, returns login address and account name if
// valid, and writes http response and returns empty string otherwise.
func CheckAuth(ctx context.Context, log mlog.Log, kind string, w http.ResponseWriter, r *http.Request) (address, account string) {
authResult := "error"
start := time.Now()
var addr *net.TCPAddr
defer func() {
metrics.AuthenticationInc(kind, "httpbasic", authResult)
if authResult == "ok" && addr != nil {
mox.LimiterFailedAuth.Reset(addr.IP, start)
}
}()
var err error
var remoteIP net.IP
addr, err = net.ResolveTCPAddr("tcp", r.RemoteAddr)
if err != nil {
log.Errorx("parsing remote address", err, slog.Any("addr", r.RemoteAddr))
} else if addr != nil {
remoteIP = addr.IP
}
if remoteIP != nil && !mox.LimiterFailedAuth.Add(remoteIP, start, 1) {
metrics.AuthenticationRatelimitedInc(kind)
http.Error(w, "429 - too many auth attempts", http.StatusTooManyRequests)
return "", ""
}
// store.OpenEmailAuth has an auth cache, so we don't bcrypt for every auth attempt.
if auth := r.Header.Get("Authorization"); !strings.HasPrefix(auth, "Basic ") {
} else if authBuf, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(auth, "Basic ")); err != nil {
log.Debugx("parsing base64", err)
} else if t := strings.SplitN(string(authBuf), ":", 2); len(t) != 2 {
log.Debug("bad user:pass form")
} else if acc, err := store.OpenEmailAuth(log, t[0], t[1]); err != nil {
if errors.Is(err, store.ErrUnknownCredentials) {
authResult = "badcreds"
log.Info("failed authentication attempt", slog.String("username", t[0]), slog.Any("remote", remoteIP))
}
log.Errorx("open account", err)
} else {
authResult = "ok"
accName := acc.Name
err := acc.Close()
log.Check(err, "closing account")
return t[0], accName
}
// note: browsers don't display the realm to prevent users getting confused by malicious realm messages.
w.Header().Set("WWW-Authenticate", `Basic realm="mox account - login with account email address and password"`)
http.Error(w, "http 401 - unauthorized - mox account - login with account email address and password", http.StatusUnauthorized)
return "", ""
type Account struct {
cookiePath string // From listener, for setting authentication cookies.
isForwarded bool // From listener, whether we look at X-Forwarded-* headers.
}
func Handle(w http.ResponseWriter, r *http.Request) {
func handle(apiHandler http.Handler, isForwarded bool, w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid())
log := pkglog.WithContext(ctx).With(slog.String("userauth", ""))
@ -224,29 +189,52 @@ func Handle(w http.ResponseWriter, r *http.Request) {
}
}
_, accName := CheckAuth(ctx, log, "webaccount", w, r)
if accName == "" {
// Response already sent.
// HTML/JS can be retrieved without authentication.
if r.URL.Path == "/" {
switch r.Method {
case "GET", "HEAD":
webaccountFile.Serve(ctx, log, w, r)
default:
http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
}
return
}
if lw, ok := w.(interface{ AddAttr(a slog.Attr) }); ok {
lw.AddAttr(slog.String("authaccount", accName))
isAPI := strings.HasPrefix(r.URL.Path, "/api/")
// Only allow POST for calls, they will not work cross-domain without CORS.
if isAPI && r.URL.Path != "/api/" && r.Method != "POST" {
http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
return
}
var loginAddress, accName string
var sessionToken store.SessionToken
// All other URLs, except the login endpoint require some authentication.
if r.URL.Path != "/api/LoginPrep" && r.URL.Path != "/api/Login" {
var ok bool
isExport := strings.HasPrefix(r.URL.Path, "/export/")
requireCSRF := isAPI || r.URL.Path == "/import" || isExport
accName, sessionToken, loginAddress, ok = webauth.Check(ctx, log, webauth.Accounts, "webaccount", isForwarded, w, r, isAPI, requireCSRF, isExport)
if !ok {
// Response has been written already.
return
}
}
if isAPI {
reqInfo := requestInfo{loginAddress, accName, sessionToken, w, r}
ctx = context.WithValue(ctx, requestInfoCtxKey, reqInfo)
apiHandler.ServeHTTP(w, r.WithContext(ctx))
return
}
switch r.URL.Path {
case "/":
switch r.Method {
default:
http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
case "/export/mail-export-maildir.tgz", "/export/mail-export-maildir.zip", "/export/mail-export-mbox.tgz", "/export/mail-export-mbox.zip":
if r.Method != "POST" {
http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
return
case "GET", "HEAD":
}
webaccountFile.Serve(ctx, log, w, r)
return
case "/mail-export-maildir.tgz", "/mail-export-maildir.zip", "/mail-export-mbox.tgz", "/mail-export-mbox.zip":
maildir := strings.Contains(r.URL.Path, "maildir")
tgz := strings.Contains(r.URL.Path, ".tgz")
@ -330,11 +318,6 @@ func Handle(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(ImportProgress{Token: token})
default:
if strings.HasPrefix(r.URL.Path, "/api/") {
ctx = context.WithValue(ctx, authCtxKey, accName)
accountSherpaHandler.ServeHTTP(w, r.WithContext(ctx))
return
}
http.NotFound(w, r)
}
}
@ -347,7 +330,54 @@ type ImportProgress struct {
type ctxKey string
var authCtxKey ctxKey = "account"
var requestInfoCtxKey ctxKey = "requestInfo"
type requestInfo struct {
LoginAddress string
AccountName string
SessionToken store.SessionToken
Response http.ResponseWriter
Request *http.Request // For Proto and TLS connection state during message submit.
}
// LoginPrep returns a login token, and also sets it as cookie. Both must be
// present in the call to Login.
func (w Account) LoginPrep(ctx context.Context) string {
log := pkglog.WithContext(ctx)
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
var data [8]byte
_, err := cryptorand.Read(data[:])
xcheckf(ctx, err, "generate token")
loginToken := base64.RawURLEncoding.EncodeToString(data[:])
webauth.LoginPrep(ctx, log, "webaccount", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
return loginToken
}
// Login returns a session token for the credentials, or fails with error code
// "user:badLogin". Call LoginPrep to get a loginToken.
func (w Account) Login(ctx context.Context, loginToken, username, password string) store.CSRFToken {
log := pkglog.WithContext(ctx)
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
csrfToken, err := webauth.Login(ctx, log, webauth.Accounts, "webaccount", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, username, password)
if _, ok := err.(*sherpa.Error); ok {
panic(err)
}
xcheckf(ctx, err, "login")
return csrfToken
}
// Logout invalidates the session token.
func (w Account) Logout(ctx context.Context) {
log := pkglog.WithContext(ctx)
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := webauth.Logout(ctx, log, webauth.Accounts, "webaccount", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, reqInfo.AccountName, reqInfo.SessionToken)
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.
@ -357,15 +387,25 @@ func (Account) SetPassword(ctx context.Context, password string) {
if len(password) < 8 {
panic(&sherpa.Error{Code: "user:error", Message: "password must be at least 8 characters"})
}
accountName := ctx.Value(authCtxKey).(string)
acc, err := store.OpenAccount(log, accountName)
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
acc, err := store.OpenAccount(log, reqInfo.AccountName)
xcheckf(ctx, err, "open account")
defer func() {
err := acc.Close()
log.Check(err, "closing account")
}()
// 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")
}
// Account returns information about the account: full name, the default domain,
@ -373,8 +413,8 @@ func (Account) SetPassword(ctx context.Context, password string) {
// domain). todo: replace with a function that returns the whole account, when
// sherpadoc understands unnamed struct fields.
func (Account) Account(ctx context.Context) (string, dns.Domain, map[string]config.Destination) {
accountName := ctx.Value(authCtxKey).(string)
accConf, ok := mox.Conf.Account(accountName)
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
accConf, ok := mox.Conf.Account(reqInfo.AccountName)
if !ok {
xcheckf(ctx, errors.New("not found"), "looking up account")
}
@ -382,12 +422,12 @@ func (Account) Account(ctx context.Context) (string, dns.Domain, map[string]conf
}
func (Account) AccountSaveFullName(ctx context.Context, fullName string) {
accountName := ctx.Value(authCtxKey).(string)
_, ok := mox.Conf.Account(accountName)
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
_, ok := mox.Conf.Account(reqInfo.AccountName)
if !ok {
xcheckf(ctx, errors.New("not found"), "looking up account")
}
err := mox.AccountFullNameSave(ctx, accountName, fullName)
err := mox.AccountFullNameSave(ctx, reqInfo.AccountName, fullName)
xcheckf(ctx, err, "saving account full name")
}
@ -395,8 +435,8 @@ func (Account) AccountSaveFullName(ctx context.Context, fullName string) {
// OldDest is compared against the current destination. If it does not match, an
// error is returned. Otherwise newDest is saved and the configuration reloaded.
func (Account) DestinationSave(ctx context.Context, destName string, oldDest, newDest config.Destination) {
accountName := ctx.Value(authCtxKey).(string)
accConf, ok := mox.Conf.Account(accountName)
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
accConf, ok := mox.Conf.Account(reqInfo.AccountName)
if !ok {
xcheckf(ctx, errors.New("not found"), "looking up account")
}
@ -414,7 +454,7 @@ func (Account) DestinationSave(ctx context.Context, destName string, oldDest, ne
newDest.HostTLSReports = curDest.HostTLSReports
newDest.DomainTLSReports = curDest.DomainTLSReports
err := mox.DestinationSave(ctx, accountName, destName, newDest)
err := mox.DestinationSave(ctx, reqInfo.AccountName, destName, newDest)
xcheckf(ctx, err, "saving destination")
}

View file

@ -16,6 +16,7 @@ ul { padding-left: 1rem; }
.literal { background-color: #eee; padding: .5em 1em; border: 1px solid #eee; border-radius: 4px; white-space: pre-wrap; font-family: monospace; font-size: 15px; tab-size: 4; }
table td, table th { padding: .2em .5em; }
table > tbody > tr:nth-child(odd) { background-color: #f8f8f8; }
table.slim td, table.slim th { padding: 0; }
.text { max-width: 50em; }
p { margin-bottom: 1em; max-width: 50em; }
[title] { text-decoration: underline; text-decoration-style: dotted; }
@ -28,7 +29,7 @@ fieldset { border: 0; }
</style>
</head>
<body>
<div id="page"><div style="padding: 1em">Loading...</div></div>
<div id="page"><div style="padding: 1em; text-align: center">Loading...</div></div>
<script>/* placeholder */</script>
</body>
</html>

View file

@ -215,6 +215,8 @@ const [dom, style, attr, prop] = (function () {
name: (s) => _attr('name', s),
min: (s) => _attr('min', s),
max: (s) => _attr('max', s),
action: (s) => _attr('action', s),
method: (s) => _attr('method', s),
};
const style = (x) => { return { _styles: x }; };
const prop = (x) => { return { _props: x }; };
@ -224,19 +226,21 @@ const [dom, style, attr, prop] = (function () {
var api;
(function (api) {
api.structTypes = { "Destination": true, "Domain": true, "ImportProgress": true, "Ruleset": true };
api.stringsTypes = {};
api.stringsTypes = { "CSRFToken": true };
api.intsTypes = {};
api.types = {
"Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] },
"Destination": { "Name": "Destination", "Docs": "", "Fields": [{ "Name": "Mailbox", "Docs": "", "Typewords": ["string"] }, { "Name": "Rulesets", "Docs": "", "Typewords": ["[]", "Ruleset"] }, { "Name": "FullName", "Docs": "", "Typewords": ["string"] }] },
"Ruleset": { "Name": "Ruleset", "Docs": "", "Fields": [{ "Name": "SMTPMailFromRegexp", "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": "VerifiedDNSDomain", "Docs": "", "Typewords": ["Domain"] }, { "Name": "ListAllowDNSDomain", "Docs": "", "Typewords": ["Domain"] }] },
"ImportProgress": { "Name": "ImportProgress", "Docs": "", "Fields": [{ "Name": "Token", "Docs": "", "Typewords": ["string"] }] },
"CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null },
};
api.parser = {
Domain: (v) => api.parse("Domain", v),
Destination: (v) => api.parse("Destination", v),
Ruleset: (v) => api.parse("Ruleset", v),
ImportProgress: (v) => api.parse("ImportProgress", v),
CSRFToken: (v) => api.parse("CSRFToken", v),
};
// Account exports web API functions for the account web interface. All its
// methods are exported under api/. Function calls require valid HTTP
@ -244,16 +248,50 @@ var api;
let defaultOptions = { slicesNullable: true, mapsNullable: true, nullableOptional: true };
class Client {
baseURL;
authState;
options;
constructor(baseURL = api.defaultBaseURL, options) {
this.baseURL = baseURL;
this.options = options;
if (!options) {
this.options = defaultOptions;
}
constructor() {
this.authState = {};
this.options = { ...defaultOptions };
this.baseURL = this.options.baseURL || api.defaultBaseURL;
}
withAuthToken(token) {
const c = new Client();
c.authState.token = token;
c.options = this.options;
return c;
}
withOptions(options) {
return new Client(this.baseURL, { ...this.options, ...options });
const c = new Client();
c.authState = this.authState;
c.options = { ...this.options, ...options };
return c;
}
// LoginPrep returns a login token, and also sets it as cookie. Both must be
// present in the call to Login.
async LoginPrep() {
const fn = "LoginPrep";
const paramTypes = [];
const returnTypes = [["string"]];
const params = [];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Login returns a session token for the credentials, or fails with error code
// "user:badLogin". Call LoginPrep to get a loginToken.
async Login(loginToken, username, password) {
const fn = "Login";
const paramTypes = [["string"], ["string"], ["string"]];
const returnTypes = [["CSRFToken"]];
const params = [loginToken, username, password];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Logout invalidates the session token.
async Logout() {
const fn = "Logout";
const paramTypes = [];
const returnTypes = [];
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.
@ -263,7 +301,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [];
const params = [password];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Account returns information about the account: full name, the default domain,
// and the destinations (keys are email addresses, or localparts to the default
@ -274,14 +312,14 @@ var api;
const paramTypes = [];
const returnTypes = [["string"], ["Domain"], ["{}", "Destination"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
async AccountSaveFullName(fullName) {
const fn = "AccountSaveFullName";
const paramTypes = [["string"]];
const returnTypes = [];
const params = [fullName];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DestinationSave updates a destination.
// OldDest is compared against the current destination. If it does not match, an
@ -291,7 +329,7 @@ var api;
const paramTypes = [["string"], ["Destination"], ["Destination"]];
const returnTypes = [];
const params = [destName, oldDest, newDest];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// ImportAbort aborts an import that is in progress. If the import exists and isn't
// finished, no changes will have been made by the import.
@ -300,7 +338,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [];
const params = [importToken];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Types exposes types not used in API method signatures, such as the import form upload.
async Types() {
@ -308,7 +346,7 @@ var api;
const paramTypes = [];
const returnTypes = [["ImportProgress"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
}
api.Client = Client;
@ -496,7 +534,7 @@ var api;
}
}
}
const _sherpaCall = async (baseURL, options, paramTypes, returnTypes, name, params) => {
const _sherpaCall = async (baseURL, authState, options, paramTypes, returnTypes, name, params) => {
if (!options.skipParamCheck) {
if (params.length !== paramTypes.length) {
return Promise.reject({ message: 'wrong number of parameters in sherpa call, saw ' + params.length + ' != expected ' + paramTypes.length });
@ -538,14 +576,36 @@ var api;
if (json) {
await simulate(json);
}
// Immediately create promise, so options.aborter is changed before returning.
const promise = new Promise((resolve, reject) => {
const fn = (resolve, reject) => {
let resolve1 = (v) => {
resolve(v);
resolve1 = () => { };
reject1 = () => { };
};
let reject1 = (v) => {
if ((v.code === 'user:noAuth' || v.code === 'user:badAuth') && options.login) {
const login = options.login;
if (!authState.loginPromise) {
authState.loginPromise = new Promise((aresolve, areject) => {
login(v.code === 'user:badAuth' ? (v.message || '') : '')
.then((token) => {
authState.token = token;
authState.loginPromise = undefined;
aresolve();
}, (err) => {
authState.loginPromise = undefined;
areject(err);
});
});
}
authState.loginPromise
.then(() => {
fn(resolve, reject);
}, (err) => {
reject(err);
});
return;
}
reject(v);
resolve1 = () => { };
reject1 = () => { };
@ -559,6 +619,9 @@ var api;
};
}
req.open('POST', url, true);
if (options.csrfHeader && authState.token) {
req.setRequestHeader(options.csrfHeader, authState.token);
}
if (options.timeoutMsec) {
req.timeout = options.timeoutMsec;
}
@ -632,15 +695,91 @@ var api;
catch (err) {
reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' });
}
});
return await promise;
};
return await new Promise(fn);
};
})(api || (api = {}));
// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten.
const client = new api.Client();
const login = async (reason) => {
return new Promise((resolve, _) => {
const origFocus = document.activeElement;
let reasonElem;
let fieldset;
let username;
let password;
const root = dom.div(style({ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: '#eee', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: '1', animation: 'fadein .15s ease-in' }), dom.div(reasonElem = reason ? dom.div(style({ marginBottom: '2ex', textAlign: 'center' }), reason) : dom.div(), dom.div(style({ backgroundColor: 'white', borderRadius: '.25em', padding: '1em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto', marginBottom: '20vh' }), dom.form(async function submit(e) {
e.preventDefault();
e.stopPropagation();
reasonElem.remove();
try {
fieldset.disabled = true;
const loginToken = await client.LoginPrep();
const token = await client.Login(loginToken, username.value, password.value);
try {
window.localStorage.setItem('webaccountaddress', username.value);
window.localStorage.setItem('webaccountcsrftoken', token);
}
catch (err) {
console.log('saving csrf token in localStorage', err);
}
root.remove();
if (origFocus && origFocus instanceof HTMLElement && origFocus.parentNode) {
origFocus.focus();
}
resolve(token);
}
catch (err) {
console.log('login error', err);
window.alert('Error: ' + errmsg(err));
}
finally {
fieldset.disabled = false;
}
}, fieldset = dom.fieldset(dom.h1('Account'), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Email address', style({ marginBottom: '.5ex' })), username = dom.input(attr.required(''), attr.placeholder('jane@example.org'))), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Password', style({ marginBottom: '.5ex' })), password = dom.input(attr.type('password'), attr.required(''))), dom.div(style({ textAlign: 'center' }), dom.submitbutton('Login')))))));
document.body.appendChild(root);
username.focus();
});
};
const localStorageGet = (k) => {
try {
return window.localStorage.getItem(k);
}
catch (err) {
return null;
}
};
const localStorageRemove = (k) => {
try {
return window.localStorage.removeItem(k);
}
catch (err) {
}
};
const client = new api.Client().withOptions({ csrfHeader: 'x-mox-csrf', login: login }).withAuthToken(localStorageGet('webaccountcsrftoken') || '');
const link = (href, anchorOpt) => dom.a(attr.href(href), attr.rel('noopener noreferrer'), anchorOpt || href);
const crumblink = (text, link) => dom.a(text, attr.href(link));
const crumbs = (...l) => [dom.h1(l.map((e, index) => index === 0 ? e : [' / ', e])), dom.br()];
const crumbs = (...l) => [
dom.div(style({ float: 'right' }), localStorageGet('webaccountaddress') || '(unknown)', ' ', dom.clickbutton('Logout', attr.title('Logout, invalidating this session.'), async function click(e) {
const b = e.target;
try {
b.disabled = true;
await client.Logout();
}
catch (err) {
console.log('logout', err);
window.alert('Error: ' + errmsg(err));
}
finally {
b.disabled = false;
}
localStorageRemove('webaccountaddress');
localStorageRemove('webaccountcsrftoken');
// Reload so all state is cleared from memory.
window.location.reload();
})),
dom.h1(l.map((e, index) => index === 0 ? e : [' / ', e])),
dom.br()
];
const errmsg = (err) => '' + (err.message || '(no error message)');
const footer = dom.div(style({ marginTop: '6ex', opacity: 0.75 }), link('https://github.com/mjl-/mox', 'mox'), ' ', moxversion);
const domainName = (d) => {
@ -755,6 +894,9 @@ const index = async () => {
});
});
};
const exportForm = (filename) => {
return dom.form(attr.target('_blank'), attr.method('POST'), attr.action('export/' + filename), dom.input(attr.type('hidden'), attr.name('csrf'), attr.value(localStorageGet('webaccountcsrftoken') || '')), dom.submitbutton('Export'));
};
dom._kids(page, crumbs('Mox Account'), dom.p('NOTE: Not all account settings can be configured through these pages yet. See the configuration file for more options.'), dom.div('Default domain: ', domain.ASCII ? domainString(domain) : '(none)'), dom.br(), fullNameForm = dom.form(fullNameFieldset = dom.fieldset(dom.label(style({ display: 'inline-block' }), 'Full name', dom.br(), fullName = dom.input(attr.value(accountFullName), attr.title('Name to use in From header when composing messages. Can be overridden per configured address.'))), ' ', dom.submitbutton('Save')), async function submit(e) {
e.preventDefault();
fullNameFieldset.disabled = true;
@ -809,7 +951,7 @@ const index = async () => {
finally {
passwordFieldset.disabled = false;
}
}), dom.br(), dom.h2('Export'), dom.p('Export all messages in all mailboxes. In maildir or mbox format, as .zip or .tgz file.'), dom.ul(dom.li(dom.a('mail-export-maildir.tgz', attr.href('mail-export-maildir.tgz'))), dom.li(dom.a('mail-export-maildir.zip', attr.href('mail-export-maildir.zip'))), dom.li(dom.a('mail-export-mbox.tgz', attr.href('mail-export-mbox.tgz'))), dom.li(dom.a('mail-export-mbox.zip', attr.href('mail-export-mbox.zip')))), dom.br(), dom.h2('Import'), dom.p('Import messages from a .zip or .tgz file with maildirs and/or mbox files.'), importForm = dom.form(async function submit(e) {
}), dom.br(), dom.h2('Export'), dom.p('Export all messages in all mailboxes. In maildir or mbox format, as .zip or .tgz file.'), dom.table(dom._class('slim'), dom.tr(dom.td('Maildirs in .tgz'), dom.td(exportForm('mail-export-maildir.tgz'))), dom.tr(dom.td('Maildirs in .zip'), dom.td(exportForm('mail-export-maildir.zip'))), dom.tr(dom.td('Mbox files in .tgz'), dom.td(exportForm('mail-export-mbox.tgz'))), dom.tr(dom.td('Mbox files in .zip'), dom.td(exportForm('mail-export-mbox.zip')))), dom.br(), dom.h2('Import'), dom.p('Import messages from a .zip or .tgz file with maildirs and/or mbox files.'), importForm = dom.form(async function submit(e) {
e.preventDefault();
e.stopPropagation();
const request = async () => {
@ -820,6 +962,7 @@ const index = async () => {
importProgress.style.display = '';
const xhr = new window.XMLHttpRequest();
xhr.open('POST', 'import', true);
xhr.setRequestHeader('x-mox-csrf', localStorageGet('webaccountcsrftoken') || '');
xhr.upload.addEventListener('progress', (e) => {
if (!e.lengthComputable) {
return;

View file

@ -4,12 +4,121 @@
declare let page: HTMLElement
declare let moxversion: string
const client = new api.Client()
const login = async (reason: string) => {
return new Promise<string>((resolve: (v: string) => void, _) => {
const origFocus = document.activeElement
let reasonElem: HTMLElement
let fieldset: HTMLFieldSetElement
let username: HTMLInputElement
let password: HTMLInputElement
const root = dom.div(
style({position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: '#eee', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: '1', animation: 'fadein .15s ease-in'}),
dom.div(
reasonElem=reason ? dom.div(style({marginBottom: '2ex', textAlign: 'center'}), reason) : dom.div(),
dom.div(
style({backgroundColor: 'white', borderRadius: '.25em', padding: '1em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto', marginBottom: '20vh'}),
dom.form(
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
reasonElem.remove()
try {
fieldset.disabled = true
const loginToken = await client.LoginPrep()
const token = await client.Login(loginToken, username.value, password.value)
try {
window.localStorage.setItem('webaccountaddress', username.value)
window.localStorage.setItem('webaccountcsrftoken', token)
} catch (err) {
console.log('saving csrf token in localStorage', err)
}
root.remove()
if (origFocus && origFocus instanceof HTMLElement && origFocus.parentNode) {
origFocus.focus()
}
resolve(token)
} catch (err) {
console.log('login error', err)
window.alert('Error: ' + errmsg(err))
} finally {
fieldset.disabled = false
}
},
fieldset=dom.fieldset(
dom.h1('Account'),
dom.label(
style({display: 'block', marginBottom: '2ex'}),
dom.div('Email address', style({marginBottom: '.5ex'})),
username=dom.input(attr.required(''), attr.placeholder('jane@example.org')),
),
dom.label(
style({display: 'block', marginBottom: '2ex'}),
dom.div('Password', style({marginBottom: '.5ex'})),
password=dom.input(attr.type('password'), attr.required('')),
),
dom.div(
style({textAlign: 'center'}),
dom.submitbutton('Login'),
),
),
)
)
)
)
document.body.appendChild(root)
username.focus()
})
}
const localStorageGet = (k: string): string | null => {
try {
return window.localStorage.getItem(k)
} catch (err) {
return null
}
}
const localStorageRemove = (k: string) => {
try {
return window.localStorage.removeItem(k)
} catch (err) {
}
}
const client = new api.Client().withOptions({csrfHeader: 'x-mox-csrf', login: login}).withAuthToken(localStorageGet('webaccountcsrftoken') || '')
const link = (href: string, anchorOpt: string) => dom.a(attr.href(href), attr.rel('noopener noreferrer'), anchorOpt || href)
const crumblink = (text: string, link: string) => dom.a(text, attr.href(link))
const crumbs = (...l: ElemArg[]) => [dom.h1(l.map((e, index) => index === 0 ? e : [' / ', e])), dom.br()]
const crumbs = (...l: ElemArg[]) => [
dom.div(
style({float: 'right'}),
localStorageGet('webaccountaddress') || '(unknown)',
' ',
dom.clickbutton('Logout', attr.title('Logout, invalidating this session.'), async function click(e: MouseEvent) {
const b = e.target! as HTMLButtonElement
try {
b.disabled = true
await client.Logout()
} catch (err) {
console.log('logout', err)
window.alert('Error: ' + errmsg(err))
} finally {
b.disabled = false
}
localStorageRemove('webaccountaddress')
localStorageRemove('webaccountcsrftoken')
// Reload so all state is cleared from memory.
window.location.reload()
}),
),
dom.h1(l.map((e, index) => index === 0 ? e : [' / ', e])),
dom.br()
]
const errmsg = (err: unknown) => ''+((err as any).message || '(no error message)')
@ -175,6 +284,14 @@ const index = async () => {
})
}
const exportForm = (filename: string) => {
return dom.form(
attr.target('_blank'), attr.method('POST'), attr.action('export/'+filename),
dom.input(attr.type('hidden'), attr.name('csrf'), attr.value(localStorageGet('webaccountcsrftoken') || '')),
dom.submitbutton('Export'),
)
}
dom._kids(page,
crumbs('Mox Account'),
dom.p('NOTE: Not all account settings can be configured through these pages yet. See the configuration file for more options.'),
@ -291,11 +408,23 @@ const index = async () => {
dom.br(),
dom.h2('Export'),
dom.p('Export all messages in all mailboxes. In maildir or mbox format, as .zip or .tgz file.'),
dom.ul(
dom.li(dom.a('mail-export-maildir.tgz', attr.href('mail-export-maildir.tgz'))),
dom.li(dom.a('mail-export-maildir.zip', attr.href('mail-export-maildir.zip'))),
dom.li(dom.a('mail-export-mbox.tgz', attr.href('mail-export-mbox.tgz'))),
dom.li(dom.a('mail-export-mbox.zip', attr.href('mail-export-mbox.zip'))),
dom.table(dom._class('slim'),
dom.tr(
dom.td('Maildirs in .tgz'),
dom.td(exportForm('mail-export-maildir.tgz')),
),
dom.tr(
dom.td('Maildirs in .zip'),
dom.td(exportForm('mail-export-maildir.zip')),
),
dom.tr(
dom.td('Mbox files in .tgz'),
dom.td(exportForm('mail-export-mbox.tgz')),
),
dom.tr(
dom.td('Mbox files in .zip'),
dom.td(exportForm('mail-export-mbox.zip')),
),
),
dom.br(),
dom.h2('Import'),
@ -318,6 +447,7 @@ const index = async () => {
const xhr = new window.XMLHttpRequest()
xhr.open('POST', 'import', true)
xhr.setRequestHeader('x-mox-csrf', localStorageGet('webaccountcsrftoken') || '')
xhr.upload.addEventListener('progress', (e) => {
if (!e.lengthComputable) {
return

View file

@ -6,29 +6,37 @@ import (
"bytes"
"compress/gzip"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path"
"path/filepath"
"runtime/debug"
"sort"
"strings"
"testing"
"github.com/mjl-/bstore"
"github.com/mjl-/sherpa"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/store"
"github.com/mjl-/mox/webauth"
)
var ctxbg = context.Background()
func init() {
mox.LimitersInit()
webauth.BadAuthDelay = 0
}
func tcheck(t *testing.T, err error, msg string) {
t.Helper()
if err != nil {
@ -36,6 +44,35 @@ func tcheck(t *testing.T, err error, msg string) {
}
}
func readBody(r io.Reader) string {
buf, err := io.ReadAll(r)
if err != nil {
return fmt.Sprintf("read error: %s", err)
}
return fmt.Sprintf("data: %q", buf)
}
func tneedErrorCode(t *testing.T, code string, fn func()) {
t.Helper()
defer func() {
t.Helper()
x := recover()
if x == nil {
debug.PrintStack()
t.Fatalf("expected sherpa user error, saw success")
}
if err, ok := x.(*sherpa.Error); !ok {
debug.PrintStack()
t.Fatalf("expected sherpa error, saw %#v", x)
} else if err.Code != code {
debug.PrintStack()
t.Fatalf("expected sherpa error code %q, saw other sherpa error %#v", code, err)
}
}()
fn()
}
func TestAccount(t *testing.T) {
os.RemoveAll("../testdata/httpaccount/data")
mox.ConfigStaticPath = filepath.FromSlash("../testdata/httpaccount/mox.conf")
@ -44,40 +81,146 @@ func TestAccount(t *testing.T) {
log := mlog.New("webaccount", nil)
acc, err := store.OpenAccount(log, "mjl")
tcheck(t, err, "open account")
err = acc.SetPassword(log, "test1234")
tcheck(t, err, "set password")
defer func() {
err = acc.Close()
tcheck(t, err, "closing account")
}()
defer store.Switchboard()()
test := func(userpass string, expect string) {
t.Helper()
api := Account{cookiePath: "/account/"}
apiHandler, err := makeSherpaHandler(api.cookiePath, false)
tcheck(t, err, "sherpa handler")
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/ignored", nil)
authhdr := "Basic " + base64.StdEncoding.EncodeToString([]byte(userpass))
r.Header.Add("Authorization", authhdr)
_, accName := CheckAuth(ctxbg, log, "webaccount", w, r)
if accName != expect {
t.Fatalf("got %q, expected %q", accName, expect)
// Record HTTP response to get session cookie for login.
respRec := httptest.NewRecorder()
reqInfo := requestInfo{"", "", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
// Missing login token.
tneedErrorCode(t, "user:error", func() { api.Login(ctx, "", "mjl@mox.example", "test1234") })
// Login with loginToken.
loginCookie := &http.Cookie{Name: "webaccountlogin"}
loginCookie.Value = api.LoginPrep(ctx)
reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
csrfToken := api.Login(ctx, loginCookie.Value, "mjl@mox.example", "test1234")
var sessionCookie *http.Cookie
for _, c := range respRec.Result().Cookies() {
if c.Name == "webaccountsession" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatalf("missing session cookie")
}
const authOK = "mjl@mox.example:test1234"
const authBad = "mjl@mox.example:badpassword"
// Valid loginToken, but bad credentials.
loginCookie.Value = api.LoginPrep(ctx)
reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "mjl@mox.example", "badauth") })
tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@mox.example", "badauth") })
tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@baddomain.example", "badauth") })
authCtx := context.WithValue(ctxbg, authCtxKey, "mjl")
type httpHeaders [][2]string
ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"}
test(authOK, "") // No password set yet.
Account{}.SetPassword(authCtx, "test1234")
test(authOK, "mjl")
test(authBad, "")
cookieOK := &http.Cookie{Name: "webaccountsession", Value: sessionCookie.Value}
cookieBad := &http.Cookie{Name: "webaccountsession", Value: "AAAAAAAAAAAAAAAAAAAAAA mjl"}
hdrSessionOK := [2]string{"Cookie", cookieOK.String()}
hdrSessionBad := [2]string{"Cookie", cookieBad.String()}
hdrCSRFOK := [2]string{"x-mox-csrf", string(csrfToken)}
hdrCSRFBad := [2]string{"x-mox-csrf", "AAAAAAAAAAAAAAAAAAAAAA"}
fullName, _, dests := Account{}.Account(authCtx)
Account{}.DestinationSave(authCtx, "mjl@mox.example", dests["mjl@mox.example"], dests["mjl@mox.example"]) // todo: save modified value and compare it afterwards
testHTTP := func(method, path string, headers httpHeaders, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
t.Helper()
Account{}.AccountSaveFullName(authCtx, fullName+" changed") // todo: check if value was changed
Account{}.AccountSaveFullName(authCtx, fullName)
req := httptest.NewRequest(method, path, nil)
for _, kv := range headers {
req.Header.Add(kv[0], kv[1])
}
rr := httptest.NewRecorder()
rr.Body = &bytes.Buffer{}
handle(apiHandler, false, rr, req)
if rr.Code != expStatusCode {
t.Fatalf("got status %d, expected %d (%s)", rr.Code, expStatusCode, readBody(rr.Body))
}
resp := rr.Result()
for _, h := range expHeaders {
if resp.Header.Get(h[0]) != h[1] {
t.Fatalf("for header %q got value %q, expected %q", h[0], resp.Header.Get(h[0]), h[1])
}
}
if check != nil {
check(resp)
}
}
testHTTPAuthAPI := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
t.Helper()
testHTTP(method, path, httpHeaders{hdrCSRFOK, hdrSessionOK}, expStatusCode, expHeaders, check)
}
userAuthError := func(resp *http.Response, expCode string) {
t.Helper()
var response struct {
Error *sherpa.Error `json:"error"`
}
err := json.NewDecoder(resp.Body).Decode(&response)
tcheck(t, err, "parsing response as json")
if response.Error == nil {
t.Fatalf("expected sherpa error with code %s, no error", expCode)
}
if response.Error.Code != expCode {
t.Fatalf("got sherpa error code %q, expected %s", response.Error.Code, expCode)
}
}
badAuth := func(resp *http.Response) {
t.Helper()
userAuthError(resp, "user:badAuth")
}
noAuth := func(resp *http.Response) {
t.Helper()
userAuthError(resp, "user:noAuth")
}
testHTTP("POST", "/api/Bogus", httpHeaders{}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionBad}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionBad}, http.StatusOK, nil, badAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionOK}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionOK}, http.StatusOK, nil, badAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK, hdrSessionBad}, http.StatusOK, nil, badAuth)
testHTTPAuthAPI("GET", "/api/Types", http.StatusMethodNotAllowed, nil, nil)
testHTTPAuthAPI("POST", "/api/Types", http.StatusOK, httpHeaders{ctJSON}, nil)
testHTTP("POST", "/import", httpHeaders{}, http.StatusForbidden, nil, nil)
testHTTP("POST", "/import", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
testHTTP("GET", "/export/mail-export-maildir.tgz", httpHeaders{}, http.StatusForbidden, nil, nil)
testHTTP("GET", "/export/mail-export-maildir.tgz", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
testHTTP("GET", "/export/mail-export-maildir.tgz", httpHeaders{hdrSessionOK}, http.StatusForbidden, nil, nil)
testHTTP("GET", "/export/mail-export-maildir.zip", httpHeaders{}, http.StatusForbidden, nil, nil)
testHTTP("GET", "/export/mail-export-mbox.tgz", httpHeaders{}, http.StatusForbidden, nil, nil)
testHTTP("GET", "/export/mail-export-mbox.zip", httpHeaders{}, http.StatusForbidden, nil, nil)
// SetPassword needs the token.
sessionToken := store.SessionToken(strings.SplitN(sessionCookie.Value, " ", 2)[0])
reqInfo = requestInfo{"mjl@mox.example", "mjl", sessionToken, respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
api.SetPassword(ctx, "test1234")
fullName, _, dests := api.Account(ctx)
api.DestinationSave(ctx, "mjl@mox.example", dests["mjl@mox.example"], dests["mjl@mox.example"]) // todo: save modified value and compare it afterwards
api.AccountSaveFullName(ctx, fullName+" changed") // todo: check if value was changed
api.AccountSaveFullName(ctx, fullName)
go ImportManage()
@ -98,9 +241,10 @@ func TestAccount(t *testing.T) {
r := httptest.NewRequest("POST", "/import", &reqBody)
r.Header.Add("Content-Type", mpw.FormDataContentType())
r.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(authOK)))
r.Header.Add("x-mox-csrf", string(csrfToken))
r.Header.Add("Cookie", cookieOK.String())
w := httptest.NewRecorder()
Handle(w, r)
handle(apiHandler, false, w, r)
if w.Code != http.StatusOK {
t.Fatalf("import, got status code %d, expected 200: %s", w.Code, w.Body.Bytes())
}
@ -181,10 +325,12 @@ func TestAccount(t *testing.T) {
testExport := func(httppath string, iszip bool, expectFiles int) {
t.Helper()
r := httptest.NewRequest("GET", httppath, nil)
r.Header.Add("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(authOK)))
fields := url.Values{"csrf": []string{string(csrfToken)}}
r := httptest.NewRequest("POST", httppath, strings.NewReader(fields.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.Header.Add("Cookie", cookieOK.String())
w := httptest.NewRecorder()
Handle(w, r)
handle(apiHandler, false, w, r)
if w.Code != http.StatusOK {
t.Fatalf("export, got status code %d, expected 200: %s", w.Code, w.Body.Bytes())
}
@ -220,8 +366,11 @@ func TestAccount(t *testing.T) {
}
}
testExport("/mail-export-maildir.tgz", false, 6) // 2 mailboxes, each with 2 messages and a dovecot-keyword file
testExport("/mail-export-maildir.zip", true, 6)
testExport("/mail-export-mbox.tgz", false, 2)
testExport("/mail-export-mbox.zip", true, 2)
testExport("/export/mail-export-maildir.tgz", false, 6) // 2 mailboxes, each with 2 messages and a dovecot-keyword file
testExport("/export/mail-export-maildir.zip", true, 6)
testExport("/export/mail-export-mbox.tgz", false, 2)
testExport("/export/mail-export-mbox.zip", true, 2)
api.Logout(ctx)
tneedErrorCode(t, "server:error", func() { api.Logout(ctx) })
}

View file

@ -2,6 +2,57 @@
"Name": "Account",
"Docs": "Account exports web API functions for the account web interface. All its\nmethods are exported under api/. Function calls require valid HTTP\nAuthentication credentials of a user.",
"Functions": [
{
"Name": "LoginPrep",
"Docs": "LoginPrep returns a login token, and also sets it as cookie. Both must be\npresent in the call to Login.",
"Params": [],
"Returns": [
{
"Name": "r0",
"Typewords": [
"string"
]
}
]
},
{
"Name": "Login",
"Docs": "Login returns a session token for the credentials, or fails with error code\n\"user:badLogin\". Call LoginPrep to get a loginToken.",
"Params": [
{
"Name": "loginToken",
"Typewords": [
"string"
]
},
{
"Name": "username",
"Typewords": [
"string"
]
},
{
"Name": "password",
"Typewords": [
"string"
]
}
],
"Returns": [
{
"Name": "r0",
"Typewords": [
"CSRFToken"
]
}
]
},
{
"Name": "Logout",
"Docs": "Logout invalidates the session token.",
"Params": [],
"Returns": []
},
{
"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.",
@ -241,7 +292,13 @@
}
],
"Ints": [],
"Strings": [],
"Strings": [
{
"Name": "CSRFToken",
"Docs": "",
"Values": null
}
],
"SherpaVersion": 0,
"SherpadocVersion": 1
}

View file

@ -34,14 +34,17 @@ export interface ImportProgress {
Token: string // For fetching progress, or cancelling an import.
}
export type CSRFToken = string
export const structTypes: {[typename: string]: boolean} = {"Destination":true,"Domain":true,"ImportProgress":true,"Ruleset":true}
export const stringsTypes: {[typename: string]: boolean} = {}
export const stringsTypes: {[typename: string]: boolean} = {"CSRFToken":true}
export const intsTypes: {[typename: string]: boolean} = {}
export const types: TypenameMap = {
"Domain": {"Name":"Domain","Docs":"","Fields":[{"Name":"ASCII","Docs":"","Typewords":["string"]},{"Name":"Unicode","Docs":"","Typewords":["string"]}]},
"Destination": {"Name":"Destination","Docs":"","Fields":[{"Name":"Mailbox","Docs":"","Typewords":["string"]},{"Name":"Rulesets","Docs":"","Typewords":["[]","Ruleset"]},{"Name":"FullName","Docs":"","Typewords":["string"]}]},
"Ruleset": {"Name":"Ruleset","Docs":"","Fields":[{"Name":"SMTPMailFromRegexp","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":"VerifiedDNSDomain","Docs":"","Typewords":["Domain"]},{"Name":"ListAllowDNSDomain","Docs":"","Typewords":["Domain"]}]},
"ImportProgress": {"Name":"ImportProgress","Docs":"","Fields":[{"Name":"Token","Docs":"","Typewords":["string"]}]},
"CSRFToken": {"Name":"CSRFToken","Docs":"","Values":null},
}
export const parser = {
@ -49,6 +52,7 @@ export const parser = {
Destination: (v: any) => parse("Destination", v) as Destination,
Ruleset: (v: any) => parse("Ruleset", v) as Ruleset,
ImportProgress: (v: any) => parse("ImportProgress", v) as ImportProgress,
CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken,
}
// Account exports web API functions for the account web interface. All its
@ -57,14 +61,57 @@ export const parser = {
let defaultOptions: ClientOptions = {slicesNullable: true, mapsNullable: true, nullableOptional: true}
export class Client {
constructor(private baseURL=defaultBaseURL, public options?: ClientOptions) {
if (!options) {
this.options = defaultOptions
}
private baseURL: string
public authState: AuthState
public options: ClientOptions
constructor() {
this.authState = {}
this.options = {...defaultOptions}
this.baseURL = this.options.baseURL || defaultBaseURL
}
withAuthToken(token: string): Client {
const c = new Client()
c.authState.token = token
c.options = this.options
return c
}
withOptions(options: ClientOptions): Client {
return new Client(this.baseURL, { ...this.options, ...options })
const c = new Client()
c.authState = this.authState
c.options = { ...this.options, ...options }
return c
}
// LoginPrep returns a login token, and also sets it as cookie. Both must be
// present in the call to Login.
async LoginPrep(): Promise<string> {
const fn: string = "LoginPrep"
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
}
// Login returns a session token for the credentials, or fails with error code
// "user:badLogin". Call LoginPrep to get a loginToken.
async Login(loginToken: string, username: string, password: string): Promise<CSRFToken> {
const fn: string = "Login"
const paramTypes: string[][] = [["string"],["string"],["string"]]
const returnTypes: string[][] = [["CSRFToken"]]
const params: any[] = [loginToken, username, password]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as CSRFToken
}
// Logout invalidates the session token.
async Logout(): Promise<void> {
const fn: string = "Logout"
const paramTypes: string[][] = []
const returnTypes: string[][] = []
const params: any[] = []
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.
@ -75,7 +122,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = []
const params: any[] = [password]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// Account returns information about the account: full name, the default domain,
@ -87,7 +134,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["string"],["Domain"],["{}","Destination"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as [string, Domain, { [key: string]: Destination }]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [string, Domain, { [key: string]: Destination }]
}
async AccountSaveFullName(fullName: string): Promise<void> {
@ -95,7 +142,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = []
const params: any[] = [fullName]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// DestinationSave updates a destination.
@ -106,7 +153,7 @@ export class Client {
const paramTypes: string[][] = [["string"],["Destination"],["Destination"]]
const returnTypes: string[][] = []
const params: any[] = [destName, oldDest, newDest]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// ImportAbort aborts an import that is in progress. If the import exists and isn't
@ -116,7 +163,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = []
const params: any[] = [importToken]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// Types exposes types not used in API method signatures, such as the import form upload.
@ -125,7 +172,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["ImportProgress"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as ImportProgress
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as ImportProgress
}
}
@ -237,7 +284,7 @@ class verifier {
const ensure = (ok: boolean, expect: string): any => {
if (!ok) {
error('got ' + JSON.stringify(v) + ', expected ' + expect)
error('got ' + JSON.stringify(v) + ', expected ' + expect)
}
return v
}
@ -378,6 +425,7 @@ class verifier {
export interface ClientOptions {
baseURL?: string
aborter?: {abort?: () => void}
timeoutMsec?: number
skipParamCheck?: boolean
@ -385,9 +433,16 @@ export interface ClientOptions {
slicesNullable?: boolean
mapsNullable?: boolean
nullableOptional?: boolean
csrfHeader?: string
login?: (reason: string) => Promise<string>
}
const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes: string[][], returnTypes: string[][], name: string, params: any[]): Promise<any> => {
export interface AuthState {
token?: string // For csrf request header.
loginPromise?: Promise<void> // To let multiple API calls wait for a single login attempt, not each opening a login popup.
}
const _sherpaCall = async (baseURL: string, authState: AuthState, options: ClientOptions, paramTypes: string[][], returnTypes: string[][], name: string, params: any[]): Promise<any> => {
if (!options.skipParamCheck) {
if (params.length !== paramTypes.length) {
return Promise.reject({ message: 'wrong number of parameters in sherpa call, saw ' + params.length + ' != expected ' + paramTypes.length })
@ -428,14 +483,36 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
await simulate(json)
}
// Immediately create promise, so options.aborter is changed before returning.
const promise = new Promise((resolve, reject) => {
let resolve1 = (v: { code: string, message: string }) => {
const fn = (resolve: (v: any) => void, reject: (v: any) => void) => {
let resolve1 = (v: any) => {
resolve(v)
resolve1 = () => { }
reject1 = () => { }
}
let reject1 = (v: { code: string, message: string }) => {
if ((v.code === 'user:noAuth' || v.code === 'user:badAuth') && options.login) {
const login = options.login
if (!authState.loginPromise) {
authState.loginPromise = new Promise((aresolve, areject) => {
login(v.code === 'user:badAuth' ? (v.message || '') : '')
.then((token) => {
authState.token = token
authState.loginPromise = undefined
aresolve()
}, (err: any) => {
authState.loginPromise = undefined
areject(err)
})
})
}
authState.loginPromise
.then(() => {
fn(resolve, reject)
}, (err: any) => {
reject(err)
})
return
}
reject(v)
resolve1 = () => { }
reject1 = () => { }
@ -450,6 +527,9 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
}
}
req.open('POST', url, true)
if (options.csrfHeader && authState.token) {
req.setRequestHeader(options.csrfHeader, authState.token)
}
if (options.timeoutMsec) {
req.timeout = options.timeoutMsec
}
@ -518,8 +598,8 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
} catch (err) {
reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' })
}
})
return await promise
}
return await new Promise(fn)
}
}

View file

@ -10,6 +10,7 @@ import (
"context"
"crypto"
"crypto/ed25519"
cryptorand "crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/tls"
@ -32,7 +33,6 @@ import (
_ "embed"
"golang.org/x/crypto/bcrypt"
"golang.org/x/exp/maps"
"golang.org/x/exp/slog"
@ -63,6 +63,7 @@ import (
"github.com/mjl-/mox/store"
"github.com/mjl-/mox/tlsrpt"
"github.com/mjl-/mox/tlsrptdb"
"github.com/mjl-/mox/webauth"
)
var pkglog = mlog.New("webadmin", nil)
@ -85,8 +86,6 @@ var webadminFile = &mox.WebappFile{
var adminDoc = mustParseAPI("admin", adminapiJSON)
var adminSherpaHandler http.Handler
func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
err := json.Unmarshal(buf, &doc)
if err != nil {
@ -95,139 +94,98 @@ func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
return doc
}
var sherpaHandlerOpts *sherpa.HandlerOpts
func makeSherpaHandler(cookiePath string, isForwarded bool) (http.Handler, error) {
return sherpa.NewHandler("/api/", moxvar.Version, Admin{cookiePath, isForwarded}, &adminDoc, sherpaHandlerOpts)
}
func init() {
collector, err := sherpaprom.NewCollector("moxadmin", nil)
if err != nil {
pkglog.Fatalx("creating sherpa prometheus collector", err)
}
adminSherpaHandler, err = sherpa.NewHandler("/api/", moxvar.Version, Admin{}, &adminDoc, &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"})
sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
// Just to validate.
_, err = makeSherpaHandler("", false)
if err != nil {
pkglog.Fatalx("sherpa handler", err)
}
}
// Handler returns a handler for the webadmin endpoints, customized for the
// cookiePath.
func Handler(cookiePath string, isForwarded bool) func(w http.ResponseWriter, r *http.Request) {
sh, err := makeSherpaHandler(cookiePath, isForwarded)
return func(w http.ResponseWriter, r *http.Request) {
if err != nil {
http.Error(w, "500 - internal server error - cannot handle requests", http.StatusInternalServerError)
return
}
handle(sh, isForwarded, w, r)
}
}
// Admin exports web API functions for the admin web interface. All its methods are
// exported under api/. Function calls require valid HTTP Authentication
// credentials of a user.
type Admin struct{}
// We keep a cache for authentication so we don't bcrypt for each incoming HTTP request with HTTP basic auth.
// We keep track of the last successful password hash and Authorization header.
// The cache is cleared periodically, see below.
var authCache struct {
sync.Mutex
lastSuccessHash, lastSuccessAuth string
type Admin struct {
cookiePath string // From listener, for setting authentication cookies.
isForwarded bool // From listener, whether we look at X-Forwarded-* headers.
}
// started when we start serving. not at package init time, because we don't want
// to make goroutines that early.
func ManageAuthCache() {
for {
authCache.Lock()
authCache.lastSuccessHash = ""
authCache.lastSuccessAuth = ""
authCache.Unlock()
time.Sleep(15 * time.Minute)
}
type ctxKey string
var requestInfoCtxKey ctxKey = "requestInfo"
type requestInfo struct {
SessionToken store.SessionToken
Response http.ResponseWriter
Request *http.Request // For Proto and TLS connection state during message submit.
}
// check whether authentication from the config (passwordfile with bcrypt hash)
// matches the authorization header "authHdr". we don't care about any username.
// on (auth) failure, a http response is sent and false returned.
func checkAdminAuth(ctx context.Context, passwordfile string, w http.ResponseWriter, r *http.Request) bool {
log := pkglog.WithContext(ctx)
respondAuthFail := func() bool {
// note: browsers don't display the realm to prevent users getting confused by malicious realm messages.
w.Header().Set("WWW-Authenticate", `Basic realm="mox admin - login with empty username and admin password"`)
http.Error(w, "http 401 - unauthorized - mox admin - login with empty username and admin password", http.StatusUnauthorized)
return false
}
authResult := "error"
start := time.Now()
var addr *net.TCPAddr
defer func() {
metrics.AuthenticationInc("webadmin", "httpbasic", authResult)
if authResult == "ok" && addr != nil {
mox.LimiterFailedAuth.Reset(addr.IP, start)
}
}()
var err error
var remoteIP net.IP
addr, err = net.ResolveTCPAddr("tcp", r.RemoteAddr)
if err != nil {
log.Errorx("parsing remote address", err, slog.Any("addr", r.RemoteAddr))
} else if addr != nil {
remoteIP = addr.IP
}
if remoteIP != nil && !mox.LimiterFailedAuth.Add(remoteIP, start, 1) {
metrics.AuthenticationRatelimitedInc("webadmin")
http.Error(w, "429 - too many auth attempts", http.StatusTooManyRequests)
return false
}
authHdr := r.Header.Get("Authorization")
if !strings.HasPrefix(authHdr, "Basic ") || passwordfile == "" {
return respondAuthFail()
}
buf, err := os.ReadFile(passwordfile)
if err != nil {
log.Errorx("reading admin password file", err, slog.String("path", passwordfile))
return respondAuthFail()
}
passwordhash := strings.TrimSpace(string(buf))
authCache.Lock()
defer authCache.Unlock()
if passwordhash != "" && passwordhash == authCache.lastSuccessHash && authHdr != "" && authCache.lastSuccessAuth == authHdr {
authResult = "ok"
return true
}
auth, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authHdr, "Basic "))
if err != nil {
return respondAuthFail()
}
t := strings.SplitN(string(auth), ":", 2)
if len(t) != 2 || len(t[1]) < 8 {
log.Info("failed authentication attempt", slog.String("username", "admin"), slog.Any("remote", remoteIP))
return respondAuthFail()
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordhash), []byte(t[1])); err != nil {
authResult = "badcreds"
log.Info("failed authentication attempt", slog.String("username", "admin"), slog.Any("remote", remoteIP))
return respondAuthFail()
}
authCache.lastSuccessHash = passwordhash
authCache.lastSuccessAuth = authHdr
authResult = "ok"
return true
}
func Handle(w http.ResponseWriter, r *http.Request) {
func handle(apiHandler http.Handler, isForwarded bool, w http.ResponseWriter, r *http.Request) {
ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid())
if !checkAdminAuth(ctx, mox.ConfigDirPath(mox.Conf.Static.AdminPasswordFile), w, r) {
// Response already sent.
return
}
if lw, ok := w.(interface{ AddAttr(a slog.Attr) }); ok {
lw.AddAttr(slog.Bool("authadmin", true))
}
log := pkglog.WithContext(ctx).With(slog.String("adminauth", ""))
// HTML/JS can be retrieved without authentication.
if r.URL.Path == "/" {
switch r.Method {
case "GET", "HEAD":
webadminFile.Serve(ctx, log, w, r)
default:
http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
return
case "GET", "HEAD":
}
webadminFile.Serve(ctx, pkglog.WithContext(ctx), w, r)
return
}
adminSherpaHandler.ServeHTTP(w, r.WithContext(ctx))
isAPI := strings.HasPrefix(r.URL.Path, "/api/")
// Only allow POST for calls, they will not work cross-domain without CORS.
if isAPI && r.URL.Path != "/api/" && r.Method != "POST" {
http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
return
}
// All other URLs, except the login endpoint require some authentication.
var sessionToken store.SessionToken
if r.URL.Path != "/api/LoginPrep" && r.URL.Path != "/api/Login" {
var ok bool
_, sessionToken, _, ok = webauth.Check(ctx, log, webauth.Admin, "webadmin", isForwarded, w, r, isAPI, isAPI, false)
if !ok {
// Response has been written already.
return
}
}
if isAPI {
reqInfo := requestInfo{sessionToken, w, r}
ctx = context.WithValue(ctx, requestInfoCtxKey, reqInfo)
apiHandler.ServeHTTP(w, r.WithContext(ctx))
return
}
http.NotFound(w, r)
}
func xcheckf(ctx context.Context, err error, format string, args ...any) {
@ -254,6 +212,45 @@ func xcheckuserf(ctx context.Context, err error, format string, args ...any) {
panic(&sherpa.Error{Code: "user:error", Message: errmsg})
}
// LoginPrep returns a login token, and also sets it as cookie. Both must be
// present in the call to Login.
func (w Admin) LoginPrep(ctx context.Context) string {
log := pkglog.WithContext(ctx)
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
var data [8]byte
_, err := cryptorand.Read(data[:])
xcheckf(ctx, err, "generate token")
loginToken := base64.RawURLEncoding.EncodeToString(data[:])
webauth.LoginPrep(ctx, log, "webadmin", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
return loginToken
}
// Login returns a session token for the credentials, or fails with error code
// "user:badLogin". Call LoginPrep to get a loginToken.
func (w Admin) Login(ctx context.Context, loginToken, password string) store.CSRFToken {
log := pkglog.WithContext(ctx)
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
csrfToken, err := webauth.Login(ctx, log, webauth.Admin, "webadmin", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, "", password)
if _, ok := err.(*sherpa.Error); ok {
panic(err)
}
xcheckf(ctx, err, "login")
return csrfToken
}
// Logout invalidates the session token.
func (w Admin) Logout(ctx context.Context) {
log := pkglog.WithContext(ctx)
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := webauth.Logout(ctx, log, webauth.Admin, "webadmin", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, "", reqInfo.SessionToken)
xcheckf(ctx, err, "logout")
}
type Result struct {
Errors []string
Warnings []string

View file

@ -32,7 +32,7 @@ fieldset { border: 0; }
</style>
</head>
<body>
<div id="page"><div style="padding: 1em">Loading...</div></div>
<div id="page"><div style="padding: 1em; text-align: center">Loading...</div></div>
<script>/* placeholder */</script>
</body>
</html>

View file

@ -215,6 +215,8 @@ const [dom, style, attr, prop] = (function () {
name: (s) => _attr('name', s),
min: (s) => _attr('min', s),
max: (s) => _attr('max', s),
action: (s) => _attr('action', s),
method: (s) => _attr('method', s),
};
const style = (x) => { return { _styles: x }; };
const prop = (x) => { return { _props: x }; };
@ -325,7 +327,7 @@ var api;
SPFResult["SPFPermerror"] = "permerror";
})(SPFResult = api.SPFResult || (api.SPFResult = {}));
api.structTypes = { "AuthResults": true, "AutoconfCheckResult": true, "AutodiscoverCheckResult": true, "AutodiscoverSRV": true, "CheckResult": true, "ClientConfigs": true, "ClientConfigsEntry": true, "DANECheckResult": true, "DKIMAuthResult": true, "DKIMCheckResult": true, "DKIMRecord": true, "DMARCCheckResult": true, "DMARCRecord": true, "DMARCSummary": true, "DNSSECResult": true, "DateRange": true, "Directive": true, "Domain": true, "DomainFeedback": true, "Evaluation": true, "EvaluationStat": true, "Extension": true, "FailureDetails": true, "IPDomain": true, "IPRevCheckResult": true, "Identifiers": true, "MTASTSCheckResult": true, "MTASTSRecord": true, "MX": true, "MXCheckResult": true, "Modifier": true, "Msg": true, "Pair": true, "Policy": true, "PolicyEvaluated": true, "PolicyOverrideReason": true, "PolicyPublished": true, "PolicyRecord": true, "Record": true, "Report": true, "ReportMetadata": true, "ReportRecord": true, "Result": true, "ResultPolicy": true, "Reverse": true, "Row": true, "SMTPAuth": true, "SPFAuthResult": true, "SPFCheckResult": true, "SPFRecord": true, "SRV": true, "SRVConfCheckResult": true, "STSMX": true, "Summary": true, "SuppressAddress": true, "TLSCheckResult": true, "TLSRPTCheckResult": true, "TLSRPTDateRange": true, "TLSRPTRecord": true, "TLSRPTSummary": true, "TLSRPTSuppressAddress": true, "TLSReportRecord": true, "TLSResult": true, "Transport": true, "TransportSMTP": true, "TransportSocks": true, "URI": true, "WebForward": true, "WebHandler": true, "WebRedirect": true, "WebStatic": true, "WebserverConfig": true };
api.stringsTypes = { "Align": true, "Alignment": true, "DKIMResult": true, "DMARCPolicy": true, "DMARCResult": true, "Disposition": true, "IP": true, "Localpart": true, "Mode": true, "PolicyOverride": true, "PolicyType": true, "RUA": true, "ResultType": true, "SPFDomainScope": true, "SPFResult": true };
api.stringsTypes = { "Align": true, "Alignment": true, "CSRFToken": true, "DKIMResult": true, "DMARCPolicy": true, "DMARCResult": true, "Disposition": true, "IP": true, "Localpart": true, "Mode": true, "PolicyOverride": true, "PolicyType": true, "RUA": true, "ResultType": true, "SPFDomainScope": true, "SPFResult": true };
api.intsTypes = {};
api.types = {
"CheckResult": { "Name": "CheckResult", "Docs": "", "Fields": [{ "Name": "Domain", "Docs": "", "Typewords": ["string"] }, { "Name": "DNSSEC", "Docs": "", "Typewords": ["DNSSECResult"] }, { "Name": "IPRev", "Docs": "", "Typewords": ["IPRevCheckResult"] }, { "Name": "MX", "Docs": "", "Typewords": ["MXCheckResult"] }, { "Name": "TLS", "Docs": "", "Typewords": ["TLSCheckResult"] }, { "Name": "DANE", "Docs": "", "Typewords": ["DANECheckResult"] }, { "Name": "SPF", "Docs": "", "Typewords": ["SPFCheckResult"] }, { "Name": "DKIM", "Docs": "", "Typewords": ["DKIMCheckResult"] }, { "Name": "DMARC", "Docs": "", "Typewords": ["DMARCCheckResult"] }, { "Name": "HostTLSRPT", "Docs": "", "Typewords": ["TLSRPTCheckResult"] }, { "Name": "DomainTLSRPT", "Docs": "", "Typewords": ["TLSRPTCheckResult"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["MTASTSCheckResult"] }, { "Name": "SRVConf", "Docs": "", "Typewords": ["SRVConfCheckResult"] }, { "Name": "Autoconf", "Docs": "", "Typewords": ["AutoconfCheckResult"] }, { "Name": "Autodiscover", "Docs": "", "Typewords": ["AutodiscoverCheckResult"] }] },
@ -400,6 +402,7 @@ var api;
"SuppressAddress": { "Name": "SuppressAddress", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Inserted", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "ReportingAddress", "Docs": "", "Typewords": ["string"] }, { "Name": "Until", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }] },
"TLSResult": { "Name": "TLSResult", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "PolicyDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "DayUTC", "Docs": "", "Typewords": ["string"] }, { "Name": "RecipientDomain", "Docs": "", "Typewords": ["string"] }, { "Name": "Created", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Updated", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "IsHost", "Docs": "", "Typewords": ["bool"] }, { "Name": "SendReport", "Docs": "", "Typewords": ["bool"] }, { "Name": "SentToRecipientDomain", "Docs": "", "Typewords": ["bool"] }, { "Name": "RecipientDomainReportingAddresses", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "SentToPolicyDomain", "Docs": "", "Typewords": ["bool"] }, { "Name": "Results", "Docs": "", "Typewords": ["[]", "Result"] }] },
"TLSRPTSuppressAddress": { "Name": "TLSRPTSuppressAddress", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Inserted", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "ReportingAddress", "Docs": "", "Typewords": ["string"] }, { "Name": "Until", "Docs": "", "Typewords": ["timestamp"] }, { "Name": "Comment", "Docs": "", "Typewords": ["string"] }] },
"CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null },
"DMARCPolicy": { "Name": "DMARCPolicy", "Docs": "", "Values": [{ "Name": "PolicyEmpty", "Value": "", "Docs": "" }, { "Name": "PolicyNone", "Value": "none", "Docs": "" }, { "Name": "PolicyQuarantine", "Value": "quarantine", "Docs": "" }, { "Name": "PolicyReject", "Value": "reject", "Docs": "" }] },
"Align": { "Name": "Align", "Docs": "", "Values": [{ "Name": "AlignStrict", "Value": "s", "Docs": "" }, { "Name": "AlignRelaxed", "Value": "r", "Docs": "" }] },
"RUA": { "Name": "RUA", "Docs": "", "Values": null },
@ -489,6 +492,7 @@ var api;
SuppressAddress: (v) => api.parse("SuppressAddress", v),
TLSResult: (v) => api.parse("TLSResult", v),
TLSRPTSuppressAddress: (v) => api.parse("TLSRPTSuppressAddress", v),
CSRFToken: (v) => api.parse("CSRFToken", v),
DMARCPolicy: (v) => api.parse("DMARCPolicy", v),
Align: (v) => api.parse("Align", v),
RUA: (v) => api.parse("RUA", v),
@ -511,16 +515,50 @@ var api;
let defaultOptions = { slicesNullable: true, mapsNullable: true, nullableOptional: true };
class Client {
baseURL;
authState;
options;
constructor(baseURL = api.defaultBaseURL, options) {
this.baseURL = baseURL;
this.options = options;
if (!options) {
this.options = defaultOptions;
}
constructor() {
this.authState = {};
this.options = { ...defaultOptions };
this.baseURL = this.options.baseURL || api.defaultBaseURL;
}
withAuthToken(token) {
const c = new Client();
c.authState.token = token;
c.options = this.options;
return c;
}
withOptions(options) {
return new Client(this.baseURL, { ...this.options, ...options });
const c = new Client();
c.authState = this.authState;
c.options = { ...this.options, ...options };
return c;
}
// LoginPrep returns a login token, and also sets it as cookie. Both must be
// present in the call to Login.
async LoginPrep() {
const fn = "LoginPrep";
const paramTypes = [];
const returnTypes = [["string"]];
const params = [];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Login returns a session token for the credentials, or fails with error code
// "user:badLogin". Call LoginPrep to get a loginToken.
async Login(loginToken, password) {
const fn = "Login";
const paramTypes = [["string"], ["string"]];
const returnTypes = [["CSRFToken"]];
const params = [loginToken, password];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Logout invalidates the session token.
async Logout() {
const fn = "Logout";
const paramTypes = [];
const returnTypes = [];
const params = [];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// CheckDomain checks the configuration for the domain, such as MX, SMTP STARTTLS,
// SPF, DKIM, DMARC, TLSRPT, MTASTS, autoconfig, autodiscover.
@ -529,7 +567,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["CheckResult"]];
const params = [domainName];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Domains returns all configured domain names, in UTF-8 for IDNA domains.
async Domains() {
@ -537,7 +575,7 @@ var api;
const paramTypes = [];
const returnTypes = [["[]", "Domain"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Domain returns the dns domain for a (potentially unicode as IDNA) domain name.
async Domain(domain) {
@ -545,7 +583,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["Domain"]];
const params = [domain];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// ParseDomain parses a domain, possibly an IDNA domain.
async ParseDomain(domain) {
@ -553,7 +591,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["Domain"]];
const params = [domain];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DomainLocalparts returns the encoded localparts and accounts configured in domain.
async DomainLocalparts(domain) {
@ -561,7 +599,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["{}", "string"]];
const params = [domain];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Accounts returns the names of all configured accounts.
async Accounts() {
@ -569,7 +607,7 @@ var api;
const paramTypes = [];
const returnTypes = [["[]", "string"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Account returns the parsed configuration of an account.
async Account(account) {
@ -577,7 +615,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["{}", "any"]];
const params = [account];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// ConfigFiles returns the paths and contents of the static and dynamic configuration files.
async ConfigFiles() {
@ -585,7 +623,7 @@ var api;
const paramTypes = [];
const returnTypes = [["string"], ["string"], ["string"], ["string"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MTASTSPolicies returns all mtasts policies from the cache.
async MTASTSPolicies() {
@ -593,7 +631,7 @@ var api;
const paramTypes = [];
const returnTypes = [["[]", "PolicyRecord"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// TLSReports returns TLS reports overlapping with period start/end, for the given
// policy domain (or all domains if empty). The reports are sorted first by period
@ -603,7 +641,7 @@ var api;
const paramTypes = [["timestamp"], ["timestamp"], ["string"]];
const returnTypes = [["[]", "TLSReportRecord"]];
const params = [start, end, policyDomain];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// TLSReportID returns a single TLS report.
async TLSReportID(domain, reportID) {
@ -611,7 +649,7 @@ var api;
const paramTypes = [["string"], ["int64"]];
const returnTypes = [["TLSReportRecord"]];
const params = [domain, reportID];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// TLSRPTSummaries returns a summary of received TLS reports overlapping with
// period start/end for one or all domains (when domain is empty).
@ -621,7 +659,7 @@ var api;
const paramTypes = [["timestamp"], ["timestamp"], ["string"]];
const returnTypes = [["[]", "TLSRPTSummary"]];
const params = [start, end, policyDomain];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DMARCReports returns DMARC reports overlapping with period start/end, for the
// given domain (or all domains if empty). The reports are sorted first by period
@ -631,7 +669,7 @@ var api;
const paramTypes = [["timestamp"], ["timestamp"], ["string"]];
const returnTypes = [["[]", "DomainFeedback"]];
const params = [start, end, domain];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DMARCReportID returns a single DMARC report.
async DMARCReportID(domain, reportID) {
@ -639,7 +677,7 @@ var api;
const paramTypes = [["string"], ["int64"]];
const returnTypes = [["DomainFeedback"]];
const params = [domain, reportID];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DMARCSummaries returns a summary of received DMARC reports overlapping with
// period start/end for one or all domains (when domain is empty).
@ -649,7 +687,7 @@ var api;
const paramTypes = [["timestamp"], ["timestamp"], ["string"]];
const returnTypes = [["[]", "DMARCSummary"]];
const params = [start, end, domain];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// LookupIP does a reverse lookup of ip.
async LookupIP(ip) {
@ -657,7 +695,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["Reverse"]];
const params = [ip];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DNSBLStatus returns the IPs from which outgoing connections may be made and
// their current status in DNSBLs that are configured. The IPs are typically the
@ -671,7 +709,7 @@ var api;
const paramTypes = [];
const returnTypes = [["{}", "{}", "string"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DomainRecords returns lines describing DNS records that should exist for the
// configured domain.
@ -680,7 +718,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["[]", "string"]];
const params = [domain];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DomainAdd adds a new domain and reloads the configuration.
async DomainAdd(domain, accountName, localpart) {
@ -688,7 +726,7 @@ var api;
const paramTypes = [["string"], ["string"], ["string"]];
const returnTypes = [];
const params = [domain, accountName, localpart];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DomainRemove removes an existing domain and reloads the configuration.
async DomainRemove(domain) {
@ -696,7 +734,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [];
const params = [domain];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// AccountAdd adds existing a new account, with an initial email address, and
// reloads the configuration.
@ -705,7 +743,7 @@ var api;
const paramTypes = [["string"], ["string"]];
const returnTypes = [];
const params = [accountName, address];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// AccountRemove removes an existing account and reloads the configuration.
async AccountRemove(accountName) {
@ -713,7 +751,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [];
const params = [accountName];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// AddressAdd adds a new address to the account, which must already exist.
async AddressAdd(address, accountName) {
@ -721,7 +759,7 @@ var api;
const paramTypes = [["string"], ["string"]];
const returnTypes = [];
const params = [address, accountName];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// AddressRemove removes an existing address.
async AddressRemove(address) {
@ -729,7 +767,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [];
const params = [address];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// SetPassword saves a new password for an account, invalidating the previous password.
// Sessions are not interrupted, and will keep working. New login attempts must use the new password.
@ -739,7 +777,7 @@ var api;
const paramTypes = [["string"], ["string"]];
const returnTypes = [];
const params = [accountName, password];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// SetAccountLimits set new limits on outgoing messages for an account.
async SetAccountLimits(accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize) {
@ -747,7 +785,7 @@ var api;
const paramTypes = [["string"], ["int32"], ["int32"], ["int64"]];
const returnTypes = [];
const params = [accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// ClientConfigsDomain returns configurations for email clients, IMAP and
// Submission (SMTP) for the domain.
@ -756,7 +794,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["ClientConfigs"]];
const params = [domain];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// QueueList returns the messages currently in the outgoing queue.
async QueueList() {
@ -764,7 +802,7 @@ var api;
const paramTypes = [];
const returnTypes = [["[]", "Msg"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// QueueSize returns the number of messages currently in the outgoing queue.
async QueueSize() {
@ -772,7 +810,7 @@ var api;
const paramTypes = [];
const returnTypes = [["int32"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// QueueKick initiates delivery of a message from the queue and sets the transport
// to use for delivery.
@ -781,7 +819,7 @@ var api;
const paramTypes = [["int64"], ["string"]];
const returnTypes = [];
const params = [id, transport];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// QueueDrop removes a message from the queue.
async QueueDrop(id) {
@ -789,7 +827,7 @@ var api;
const paramTypes = [["int64"]];
const returnTypes = [];
const params = [id];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// QueueSaveRequireTLS updates the requiretls field for a message in the queue,
// to be used for the next delivery.
@ -798,7 +836,7 @@ var api;
const paramTypes = [["int64"], ["nullable", "bool"]];
const returnTypes = [];
const params = [id, requireTLS];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// LogLevels returns the current log levels.
async LogLevels() {
@ -806,7 +844,7 @@ var api;
const paramTypes = [];
const returnTypes = [["{}", "string"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// LogLevelSet sets a log level for a package.
async LogLevelSet(pkg, levelStr) {
@ -814,7 +852,7 @@ var api;
const paramTypes = [["string"], ["string"]];
const returnTypes = [];
const params = [pkg, levelStr];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// LogLevelRemove removes a log level for a package, which cannot be the empty string.
async LogLevelRemove(pkg) {
@ -822,7 +860,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [];
const params = [pkg];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// CheckUpdatesEnabled returns whether checking for updates is enabled.
async CheckUpdatesEnabled() {
@ -830,7 +868,7 @@ var api;
const paramTypes = [];
const returnTypes = [["bool"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// WebserverConfig returns the current webserver config
async WebserverConfig() {
@ -838,7 +876,7 @@ var api;
const paramTypes = [];
const returnTypes = [["WebserverConfig"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// WebserverConfigSave saves a new webserver config. If oldConf is not equal to
// the current config, an error is returned.
@ -847,7 +885,7 @@ var api;
const paramTypes = [["WebserverConfig"], ["WebserverConfig"]];
const returnTypes = [["WebserverConfig"]];
const params = [oldConf, newConf];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Transports returns the configured transports, for sending email.
async Transports() {
@ -855,7 +893,7 @@ var api;
const paramTypes = [];
const returnTypes = [["{}", "Transport"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DMARCEvaluationStats returns a map of all domains with evaluations to a count of
// the evaluations and whether those evaluations will cause a report to be sent.
@ -864,7 +902,7 @@ var api;
const paramTypes = [];
const returnTypes = [["{}", "EvaluationStat"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DMARCEvaluationsDomain returns all evaluations for aggregate reports for the
// domain, sorted from oldest to most recent.
@ -873,7 +911,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["Domain"], ["[]", "Evaluation"]];
const params = [domain];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DMARCRemoveEvaluations removes evaluations for a domain.
async DMARCRemoveEvaluations(domain) {
@ -881,7 +919,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [];
const params = [domain];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DMARCSuppressAdd adds a reporting address to the suppress list. Outgoing
// reports will be suppressed for a period.
@ -890,7 +928,7 @@ var api;
const paramTypes = [["string"], ["timestamp"], ["string"]];
const returnTypes = [];
const params = [reportingAddress, until, comment];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DMARCSuppressList returns all reporting addresses on the suppress list.
async DMARCSuppressList() {
@ -898,7 +936,7 @@ var api;
const paramTypes = [];
const returnTypes = [["[]", "SuppressAddress"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DMARCSuppressRemove removes a reporting address record from the suppress list.
async DMARCSuppressRemove(id) {
@ -906,7 +944,7 @@ var api;
const paramTypes = [["int64"]];
const returnTypes = [];
const params = [id];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// DMARCSuppressExtend updates the until field of a suppressed reporting address record.
async DMARCSuppressExtend(id, until) {
@ -914,7 +952,7 @@ var api;
const paramTypes = [["int64"], ["timestamp"]];
const returnTypes = [];
const params = [id, until];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// TLSRPTResults returns all TLSRPT results in the database.
async TLSRPTResults() {
@ -922,7 +960,7 @@ var api;
const paramTypes = [];
const returnTypes = [["[]", "TLSResult"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// TLSRPTResultsPolicyDomain returns the TLS results for a domain.
async TLSRPTResultsDomain(isRcptDom, policyDomain) {
@ -930,7 +968,7 @@ var api;
const paramTypes = [["bool"], ["string"]];
const returnTypes = [["Domain"], ["[]", "TLSResult"]];
const params = [isRcptDom, policyDomain];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// LookupTLSRPTRecord looks up a TLSRPT record and returns the parsed form, original txt
// form from DNS, and error with the TLSRPT record as a string.
@ -939,7 +977,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["nullable", "TLSRPTRecord"], ["string"], ["string"]];
const params = [domain];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// TLSRPTRemoveResults removes the TLS results for a domain for the given day. If
// day is empty, all results are removed.
@ -948,7 +986,7 @@ var api;
const paramTypes = [["bool"], ["string"], ["string"]];
const returnTypes = [];
const params = [isRcptDom, domain, day];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// TLSRPTSuppressAdd adds a reporting address to the suppress list. Outgoing
// reports will be suppressed for a period.
@ -957,7 +995,7 @@ var api;
const paramTypes = [["string"], ["timestamp"], ["string"]];
const returnTypes = [];
const params = [reportingAddress, until, comment];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// TLSRPTSuppressList returns all reporting addresses on the suppress list.
async TLSRPTSuppressList() {
@ -965,7 +1003,7 @@ var api;
const paramTypes = [];
const returnTypes = [["[]", "TLSRPTSuppressAddress"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// TLSRPTSuppressRemove removes a reporting address record from the suppress list.
async TLSRPTSuppressRemove(id) {
@ -973,7 +1011,7 @@ var api;
const paramTypes = [["int64"]];
const returnTypes = [];
const params = [id];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// TLSRPTSuppressExtend updates the until field of a suppressed reporting address record.
async TLSRPTSuppressExtend(id, until) {
@ -981,7 +1019,7 @@ var api;
const paramTypes = [["int64"], ["timestamp"]];
const returnTypes = [];
const params = [id, until];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
}
api.Client = Client;
@ -1169,7 +1207,7 @@ var api;
}
}
}
const _sherpaCall = async (baseURL, options, paramTypes, returnTypes, name, params) => {
const _sherpaCall = async (baseURL, authState, options, paramTypes, returnTypes, name, params) => {
if (!options.skipParamCheck) {
if (params.length !== paramTypes.length) {
return Promise.reject({ message: 'wrong number of parameters in sherpa call, saw ' + params.length + ' != expected ' + paramTypes.length });
@ -1211,14 +1249,36 @@ var api;
if (json) {
await simulate(json);
}
// Immediately create promise, so options.aborter is changed before returning.
const promise = new Promise((resolve, reject) => {
const fn = (resolve, reject) => {
let resolve1 = (v) => {
resolve(v);
resolve1 = () => { };
reject1 = () => { };
};
let reject1 = (v) => {
if ((v.code === 'user:noAuth' || v.code === 'user:badAuth') && options.login) {
const login = options.login;
if (!authState.loginPromise) {
authState.loginPromise = new Promise((aresolve, areject) => {
login(v.code === 'user:badAuth' ? (v.message || '') : '')
.then((token) => {
authState.token = token;
authState.loginPromise = undefined;
aresolve();
}, (err) => {
authState.loginPromise = undefined;
areject(err);
});
});
}
authState.loginPromise
.then(() => {
fn(resolve, reject);
}, (err) => {
reject(err);
});
return;
}
reject(v);
resolve1 = () => { };
reject1 = () => { };
@ -1232,6 +1292,9 @@ var api;
};
}
req.open('POST', url, true);
if (options.csrfHeader && authState.token) {
req.setRequestHeader(options.csrfHeader, authState.token);
}
if (options.timeoutMsec) {
req.timeout = options.timeoutMsec;
}
@ -1305,19 +1368,92 @@ var api;
catch (err) {
reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' });
}
});
return await promise;
};
return await new Promise(fn);
};
})(api || (api = {}));
// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten.
const client = new api.Client();
const login = async (reason) => {
return new Promise((resolve, _) => {
const origFocus = document.activeElement;
let reasonElem;
let fieldset;
let password;
const root = dom.div(style({ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: '#eee', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: '1', animation: 'fadein .15s ease-in' }), dom.div(reasonElem = reason ? dom.div(style({ marginBottom: '2ex', textAlign: 'center' }), reason) : dom.div(), dom.div(style({ backgroundColor: 'white', borderRadius: '.25em', padding: '1em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto', marginBottom: '20vh' }), dom.form(async function submit(e) {
e.preventDefault();
e.stopPropagation();
reasonElem.remove();
try {
fieldset.disabled = true;
const loginToken = await client.LoginPrep();
const token = await client.Login(loginToken, password.value);
try {
window.localStorage.setItem('webadmincsrftoken', token);
}
catch (err) {
console.log('saving csrf token in localStorage', err);
}
root.remove();
if (origFocus && origFocus instanceof HTMLElement && origFocus.parentNode) {
origFocus.focus();
}
resolve(token);
}
catch (err) {
console.log('login error', err);
window.alert('Error: ' + errmsg(err));
}
finally {
fieldset.disabled = false;
}
}, fieldset = dom.fieldset(dom.h1('Admin'), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Password', style({ marginBottom: '.5ex' })), password = dom.input(attr.type('password'), attr.required(''))), dom.div(style({ textAlign: 'center' }), dom.submitbutton('Login')))))));
document.body.appendChild(root);
password.focus();
});
};
const localStorageGet = (k) => {
try {
return window.localStorage.getItem(k);
}
catch (err) {
return null;
}
};
const localStorageRemove = (k) => {
try {
return window.localStorage.removeItem(k);
}
catch (err) {
}
};
const client = new api.Client().withOptions({ csrfHeader: 'x-mox-csrf', login: login }).withAuthToken(localStorageGet('webadmincsrftoken') || '');
const green = '#1dea20';
const yellow = '#ffe400';
const red = '#ff7443';
const blue = '#8bc8ff';
const link = (href, anchorOpt) => dom.a(attr.href(href), attr.rel('noopener noreferrer'), anchorOpt || href);
const crumblink = (text, link) => dom.a(text, attr.href(link));
const crumbs = (...l) => [dom.h1(l.map((e, index) => index === 0 ? e : [' / ', e])), dom.br()];
const crumbs = (...l) => [
dom.div(style({ float: 'right' }), dom.clickbutton('Logout', attr.title('Logout, invalidating this session.'), async function click(e) {
const b = e.target;
try {
b.disabled = true;
await client.Logout();
}
catch (err) {
console.log('logout', err);
window.alert('Error: ' + errmsg(err));
}
finally {
b.disabled = false;
}
localStorageRemove('webadmincsrftoken');
// Reload so all state is cleared from memory.
window.location.reload();
})),
dom.h1(l.map((e, index) => index === 0 ? e : [' / ', e])),
dom.br()
];
const errmsg = (err) => '' + (err.message || '(no error message)');
const footer = dom.div(style({ marginTop: '6ex', opacity: 0.75 }), link('https://github.com/mjl-/mox', 'mox'), ' ', moxversion);
const age = (date, future, nowSecs) => {

View file

@ -4,7 +4,83 @@
declare let page: HTMLElement
declare let moxversion: string
const client = new api.Client()
const login = async (reason: string) => {
return new Promise<string>((resolve: (v: string) => void, _) => {
const origFocus = document.activeElement
let reasonElem: HTMLElement
let fieldset: HTMLFieldSetElement
let password: HTMLInputElement
const root = dom.div(
style({position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: '#eee', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: '1', animation: 'fadein .15s ease-in'}),
dom.div(
reasonElem=reason ? dom.div(style({marginBottom: '2ex', textAlign: 'center'}), reason) : dom.div(),
dom.div(
style({backgroundColor: 'white', borderRadius: '.25em', padding: '1em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto', marginBottom: '20vh'}),
dom.form(
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
reasonElem.remove()
try {
fieldset.disabled = true
const loginToken = await client.LoginPrep()
const token = await client.Login(loginToken, password.value)
try {
window.localStorage.setItem('webadmincsrftoken', token)
} catch (err) {
console.log('saving csrf token in localStorage', err)
}
root.remove()
if (origFocus && origFocus instanceof HTMLElement && origFocus.parentNode) {
origFocus.focus()
}
resolve(token)
} catch (err) {
console.log('login error', err)
window.alert('Error: ' + errmsg(err))
} finally {
fieldset.disabled = false
}
},
fieldset=dom.fieldset(
dom.h1('Admin'),
dom.label(
style({display: 'block', marginBottom: '2ex'}),
dom.div('Password', style({marginBottom: '.5ex'})),
password=dom.input(attr.type('password'), attr.required('')),
),
dom.div(
style({textAlign: 'center'}),
dom.submitbutton('Login'),
),
),
)
)
)
)
document.body.appendChild(root)
password.focus()
})
}
const localStorageGet = (k: string): string | null => {
try {
return window.localStorage.getItem(k)
} catch (err) {
return null
}
}
const localStorageRemove = (k: string) => {
try {
return window.localStorage.removeItem(k)
} catch (err) {
}
}
const client = new api.Client().withOptions({csrfHeader: 'x-mox-csrf', login: login}).withAuthToken(localStorageGet('webadmincsrftoken') || '')
const green = '#1dea20'
const yellow = '#ffe400'
@ -14,7 +90,29 @@ const blue = '#8bc8ff'
const link = (href: string, anchorOpt?: string) => dom.a(attr.href(href), attr.rel('noopener noreferrer'), anchorOpt || href)
const crumblink = (text: string, link: string) => dom.a(text, attr.href(link))
const crumbs = (...l: ElemArg[]) => [dom.h1(l.map((e, index) => index === 0 ? e : [' / ', e])), dom.br()]
const crumbs = (...l: ElemArg[]) => [
dom.div(
style({float: 'right'}),
dom.clickbutton('Logout', attr.title('Logout, invalidating this session.'), async function click(e: MouseEvent) {
const b = e.target! as HTMLButtonElement
try {
b.disabled = true
await client.Logout()
} catch (err) {
console.log('logout', err)
window.alert('Error: ' + errmsg(err))
} finally {
b.disabled = false
}
localStorageRemove('webadmincsrftoken')
// Reload so all state is cleared from memory.
window.location.reload()
}),
),
dom.h1(l.map((e, index) => index === 0 ? e : [' / ', e])),
dom.br()
]
const errmsg = (err: unknown) => ''+((err as any).message || '(no error message)')

View file

@ -1,68 +1,205 @@
package webadmin
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime/debug"
"strings"
"testing"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/mjl-/sherpa"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/store"
"github.com/mjl-/mox/webauth"
)
var ctxbg = context.Background()
func init() {
mox.LimitersInit()
webauth.BadAuthDelay = 0
}
func tneedErrorCode(t *testing.T, code string, fn func()) {
t.Helper()
defer func() {
t.Helper()
x := recover()
if x == nil {
debug.PrintStack()
t.Fatalf("expected sherpa user error, saw success")
}
if err, ok := x.(*sherpa.Error); !ok {
debug.PrintStack()
t.Fatalf("expected sherpa error, saw %#v", x)
} else if err.Code != code {
debug.PrintStack()
t.Fatalf("expected sherpa error code %q, saw other sherpa error %#v", code, err)
}
}()
fn()
}
func tcheck(t *testing.T, err error, msg string) {
t.Helper()
if err != nil {
t.Fatalf("%s: %s", msg, err)
}
}
func readBody(r io.Reader) string {
buf, err := io.ReadAll(r)
if err != nil {
return fmt.Sprintf("read error: %s", err)
}
return fmt.Sprintf("data: %q", buf)
}
func TestAdminAuth(t *testing.T) {
test := func(passwordfile, authHdr string, expect bool) {
t.Helper()
w := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/ignored", nil)
if authHdr != "" {
r.Header.Add("Authorization", authHdr)
}
ok := checkAdminAuth(ctxbg, passwordfile, w, r)
if ok != expect {
t.Fatalf("got %v, expected %v", ok, expect)
}
}
const authOK = "Basic YWRtaW46bW94dGVzdDEyMw==" // admin:moxtest123
const authBad = "Basic YWRtaW46YmFkcGFzc3dvcmQ=" // admin:badpassword
const path = "../testdata/http-passwordfile"
os.Remove(path)
defer os.Remove(path)
test(path, authOK, false) // Password file does not exist.
os.RemoveAll("../testdata/webadmin/data")
mox.ConfigStaticPath = filepath.FromSlash("../testdata/webadmin/mox.conf")
mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
mox.MustLoadConfig(true, false)
adminpwhash, err := bcrypt.GenerateFromPassword([]byte("moxtest123"), bcrypt.DefaultCost)
if err != nil {
t.Fatalf("generate bcrypt hash: %v", err)
tcheck(t, err, "generate bcrypt hash")
path := mox.ConfigDirPath(mox.Conf.Static.AdminPasswordFile)
err = os.WriteFile(path, adminpwhash, 0660)
tcheck(t, err, "write password file")
defer os.Remove(path)
api := Admin{cookiePath: "/admin/"}
apiHandler, err := makeSherpaHandler(api.cookiePath, false)
tcheck(t, err, "sherpa handler")
respRec := httptest.NewRecorder()
reqInfo := requestInfo{"", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
// Missing login token.
tneedErrorCode(t, "user:error", func() { api.Login(ctx, "", "moxtest123") })
// Login with loginToken.
loginCookie := &http.Cookie{Name: "webadminlogin"}
loginCookie.Value = api.LoginPrep(ctx)
reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
csrfToken := api.Login(ctx, loginCookie.Value, "moxtest123")
var sessionCookie *http.Cookie
for _, c := range respRec.Result().Cookies() {
if c.Name == "webadminsession" {
sessionCookie = c
break
}
}
if err := os.WriteFile(path, adminpwhash, 0660); err != nil {
t.Fatalf("write password file: %v", err)
if sessionCookie == nil {
t.Fatalf("missing session cookie")
}
// We loop to also exercise the auth cache.
for i := 0; i < 2; i++ {
test(path, "", false) // Empty/missing header.
test(path, "Malformed ", false) // Not "Basic"
test(path, "Basic malformed ", false) // Bad base64.
test(path, "Basic dGVzdA== ", false) // base64 is ok, but wrong tokens inside.
test(path, authBad, false) // Wrong password.
test(path, authOK, true)
// Valid loginToken, but bad credentials.
loginCookie.Value = api.LoginPrep(ctx)
reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "badauth") })
type httpHeaders [][2]string
ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"}
cookieOK := &http.Cookie{Name: "webadminsession", Value: sessionCookie.Value}
cookieBad := &http.Cookie{Name: "webadminsession", Value: "AAAAAAAAAAAAAAAAAAAAAA"}
hdrSessionOK := [2]string{"Cookie", cookieOK.String()}
hdrSessionBad := [2]string{"Cookie", cookieBad.String()}
hdrCSRFOK := [2]string{"x-mox-csrf", string(csrfToken)}
hdrCSRFBad := [2]string{"x-mox-csrf", "AAAAAAAAAAAAAAAAAAAAAA"}
testHTTP := func(method, path string, headers httpHeaders, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
t.Helper()
req := httptest.NewRequest(method, path, nil)
for _, kv := range headers {
req.Header.Add(kv[0], kv[1])
}
rr := httptest.NewRecorder()
rr.Body = &bytes.Buffer{}
handle(apiHandler, false, rr, req)
if rr.Code != expStatusCode {
t.Fatalf("got status %d, expected %d (%s)", rr.Code, expStatusCode, readBody(rr.Body))
}
resp := rr.Result()
for _, h := range expHeaders {
if resp.Header.Get(h[0]) != h[1] {
t.Fatalf("for header %q got value %q, expected %q", h[0], resp.Header.Get(h[0]), h[1])
}
}
if check != nil {
check(resp)
}
}
testHTTPAuthAPI := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
t.Helper()
testHTTP(method, path, httpHeaders{hdrCSRFOK, hdrSessionOK}, expStatusCode, expHeaders, check)
}
userAuthError := func(resp *http.Response, expCode string) {
t.Helper()
var response struct {
Error *sherpa.Error `json:"error"`
}
err := json.NewDecoder(resp.Body).Decode(&response)
tcheck(t, err, "parsing response as json")
if response.Error == nil {
t.Fatalf("expected sherpa error with code %s, no error", expCode)
}
if response.Error.Code != expCode {
t.Fatalf("got sherpa error code %q, expected %s", response.Error.Code, expCode)
}
}
badAuth := func(resp *http.Response) {
t.Helper()
userAuthError(resp, "user:badAuth")
}
noAuth := func(resp *http.Response) {
t.Helper()
userAuthError(resp, "user:noAuth")
}
testHTTP("POST", "/api/Bogus", httpHeaders{}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionBad}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionBad}, http.StatusOK, nil, badAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionOK}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionOK}, http.StatusOK, nil, badAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK, hdrSessionBad}, http.StatusOK, nil, badAuth)
testHTTPAuthAPI("GET", "/api/Transports", http.StatusMethodNotAllowed, nil, nil)
testHTTPAuthAPI("POST", "/api/Transports", http.StatusOK, httpHeaders{ctJSON}, nil)
// Logout needs session token.
reqInfo.SessionToken = store.SessionToken(strings.SplitN(sessionCookie.Value, " ", 2)[0])
ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
api.Logout(ctx)
tneedErrorCode(t, "server:error", func() { api.Logout(ctx) })
}
func TestCheckDomain(t *testing.T) {

View file

@ -2,6 +2,51 @@
"Name": "Admin",
"Docs": "Admin exports web API functions for the admin web interface. All its methods are\nexported under api/. Function calls require valid HTTP Authentication\ncredentials of a user.",
"Functions": [
{
"Name": "LoginPrep",
"Docs": "LoginPrep returns a login token, and also sets it as cookie. Both must be\npresent in the call to Login.",
"Params": [],
"Returns": [
{
"Name": "r0",
"Typewords": [
"string"
]
}
]
},
{
"Name": "Login",
"Docs": "Login returns a session token for the credentials, or fails with error code\n\"user:badLogin\". Call LoginPrep to get a loginToken.",
"Params": [
{
"Name": "loginToken",
"Typewords": [
"string"
]
},
{
"Name": "password",
"Typewords": [
"string"
]
}
],
"Returns": [
{
"Name": "r0",
"Typewords": [
"CSRFToken"
]
}
]
},
{
"Name": "Logout",
"Docs": "Logout invalidates the session token.",
"Params": [],
"Returns": []
},
{
"Name": "CheckDomain",
"Docs": "CheckDomain checks the configuration for the domain, such as MX, SMTP STARTTLS,\nSPF, DKIM, DMARC, TLSRPT, MTASTS, autoconfig, autodiscover.",
@ -4159,6 +4204,11 @@
],
"Ints": [],
"Strings": [
{
"Name": "CSRFToken",
"Docs": "",
"Values": null
},
{
"Name": "DMARCPolicy",
"Docs": "Policy as used in DMARC DNS record for \"p=\" or \"sp=\".",

View file

@ -647,6 +647,8 @@ export interface TLSRPTSuppressAddress {
Comment: string
}
export type CSRFToken = string
// Policy as used in DMARC DNS record for "p=" or "sp=".
export enum DMARCPolicy {
PolicyEmpty = "", // Only for the optional Record.SubdomainPolicy.
@ -769,7 +771,7 @@ export type Localpart = string
export type IP = string
export const structTypes: {[typename: string]: boolean} = {"AuthResults":true,"AutoconfCheckResult":true,"AutodiscoverCheckResult":true,"AutodiscoverSRV":true,"CheckResult":true,"ClientConfigs":true,"ClientConfigsEntry":true,"DANECheckResult":true,"DKIMAuthResult":true,"DKIMCheckResult":true,"DKIMRecord":true,"DMARCCheckResult":true,"DMARCRecord":true,"DMARCSummary":true,"DNSSECResult":true,"DateRange":true,"Directive":true,"Domain":true,"DomainFeedback":true,"Evaluation":true,"EvaluationStat":true,"Extension":true,"FailureDetails":true,"IPDomain":true,"IPRevCheckResult":true,"Identifiers":true,"MTASTSCheckResult":true,"MTASTSRecord":true,"MX":true,"MXCheckResult":true,"Modifier":true,"Msg":true,"Pair":true,"Policy":true,"PolicyEvaluated":true,"PolicyOverrideReason":true,"PolicyPublished":true,"PolicyRecord":true,"Record":true,"Report":true,"ReportMetadata":true,"ReportRecord":true,"Result":true,"ResultPolicy":true,"Reverse":true,"Row":true,"SMTPAuth":true,"SPFAuthResult":true,"SPFCheckResult":true,"SPFRecord":true,"SRV":true,"SRVConfCheckResult":true,"STSMX":true,"Summary":true,"SuppressAddress":true,"TLSCheckResult":true,"TLSRPTCheckResult":true,"TLSRPTDateRange":true,"TLSRPTRecord":true,"TLSRPTSummary":true,"TLSRPTSuppressAddress":true,"TLSReportRecord":true,"TLSResult":true,"Transport":true,"TransportSMTP":true,"TransportSocks":true,"URI":true,"WebForward":true,"WebHandler":true,"WebRedirect":true,"WebStatic":true,"WebserverConfig":true}
export const stringsTypes: {[typename: string]: boolean} = {"Align":true,"Alignment":true,"DKIMResult":true,"DMARCPolicy":true,"DMARCResult":true,"Disposition":true,"IP":true,"Localpart":true,"Mode":true,"PolicyOverride":true,"PolicyType":true,"RUA":true,"ResultType":true,"SPFDomainScope":true,"SPFResult":true}
export const stringsTypes: {[typename: string]: boolean} = {"Align":true,"Alignment":true,"CSRFToken":true,"DKIMResult":true,"DMARCPolicy":true,"DMARCResult":true,"Disposition":true,"IP":true,"Localpart":true,"Mode":true,"PolicyOverride":true,"PolicyType":true,"RUA":true,"ResultType":true,"SPFDomainScope":true,"SPFResult":true}
export const intsTypes: {[typename: string]: boolean} = {}
export const types: TypenameMap = {
"CheckResult": {"Name":"CheckResult","Docs":"","Fields":[{"Name":"Domain","Docs":"","Typewords":["string"]},{"Name":"DNSSEC","Docs":"","Typewords":["DNSSECResult"]},{"Name":"IPRev","Docs":"","Typewords":["IPRevCheckResult"]},{"Name":"MX","Docs":"","Typewords":["MXCheckResult"]},{"Name":"TLS","Docs":"","Typewords":["TLSCheckResult"]},{"Name":"DANE","Docs":"","Typewords":["DANECheckResult"]},{"Name":"SPF","Docs":"","Typewords":["SPFCheckResult"]},{"Name":"DKIM","Docs":"","Typewords":["DKIMCheckResult"]},{"Name":"DMARC","Docs":"","Typewords":["DMARCCheckResult"]},{"Name":"HostTLSRPT","Docs":"","Typewords":["TLSRPTCheckResult"]},{"Name":"DomainTLSRPT","Docs":"","Typewords":["TLSRPTCheckResult"]},{"Name":"MTASTS","Docs":"","Typewords":["MTASTSCheckResult"]},{"Name":"SRVConf","Docs":"","Typewords":["SRVConfCheckResult"]},{"Name":"Autoconf","Docs":"","Typewords":["AutoconfCheckResult"]},{"Name":"Autodiscover","Docs":"","Typewords":["AutodiscoverCheckResult"]}]},
@ -844,6 +846,7 @@ export const types: TypenameMap = {
"SuppressAddress": {"Name":"SuppressAddress","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Inserted","Docs":"","Typewords":["timestamp"]},{"Name":"ReportingAddress","Docs":"","Typewords":["string"]},{"Name":"Until","Docs":"","Typewords":["timestamp"]},{"Name":"Comment","Docs":"","Typewords":["string"]}]},
"TLSResult": {"Name":"TLSResult","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"PolicyDomain","Docs":"","Typewords":["string"]},{"Name":"DayUTC","Docs":"","Typewords":["string"]},{"Name":"RecipientDomain","Docs":"","Typewords":["string"]},{"Name":"Created","Docs":"","Typewords":["timestamp"]},{"Name":"Updated","Docs":"","Typewords":["timestamp"]},{"Name":"IsHost","Docs":"","Typewords":["bool"]},{"Name":"SendReport","Docs":"","Typewords":["bool"]},{"Name":"SentToRecipientDomain","Docs":"","Typewords":["bool"]},{"Name":"RecipientDomainReportingAddresses","Docs":"","Typewords":["[]","string"]},{"Name":"SentToPolicyDomain","Docs":"","Typewords":["bool"]},{"Name":"Results","Docs":"","Typewords":["[]","Result"]}]},
"TLSRPTSuppressAddress": {"Name":"TLSRPTSuppressAddress","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Inserted","Docs":"","Typewords":["timestamp"]},{"Name":"ReportingAddress","Docs":"","Typewords":["string"]},{"Name":"Until","Docs":"","Typewords":["timestamp"]},{"Name":"Comment","Docs":"","Typewords":["string"]}]},
"CSRFToken": {"Name":"CSRFToken","Docs":"","Values":null},
"DMARCPolicy": {"Name":"DMARCPolicy","Docs":"","Values":[{"Name":"PolicyEmpty","Value":"","Docs":""},{"Name":"PolicyNone","Value":"none","Docs":""},{"Name":"PolicyQuarantine","Value":"quarantine","Docs":""},{"Name":"PolicyReject","Value":"reject","Docs":""}]},
"Align": {"Name":"Align","Docs":"","Values":[{"Name":"AlignStrict","Value":"s","Docs":""},{"Name":"AlignRelaxed","Value":"r","Docs":""}]},
"RUA": {"Name":"RUA","Docs":"","Values":null},
@ -934,6 +937,7 @@ export const parser = {
SuppressAddress: (v: any) => parse("SuppressAddress", v) as SuppressAddress,
TLSResult: (v: any) => parse("TLSResult", v) as TLSResult,
TLSRPTSuppressAddress: (v: any) => parse("TLSRPTSuppressAddress", v) as TLSRPTSuppressAddress,
CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken,
DMARCPolicy: (v: any) => parse("DMARCPolicy", v) as DMARCPolicy,
Align: (v: any) => parse("Align", v) as Align,
RUA: (v: any) => parse("RUA", v) as RUA,
@ -957,14 +961,57 @@ export const parser = {
let defaultOptions: ClientOptions = {slicesNullable: true, mapsNullable: true, nullableOptional: true}
export class Client {
constructor(private baseURL=defaultBaseURL, public options?: ClientOptions) {
if (!options) {
this.options = defaultOptions
}
private baseURL: string
public authState: AuthState
public options: ClientOptions
constructor() {
this.authState = {}
this.options = {...defaultOptions}
this.baseURL = this.options.baseURL || defaultBaseURL
}
withAuthToken(token: string): Client {
const c = new Client()
c.authState.token = token
c.options = this.options
return c
}
withOptions(options: ClientOptions): Client {
return new Client(this.baseURL, { ...this.options, ...options })
const c = new Client()
c.authState = this.authState
c.options = { ...this.options, ...options }
return c
}
// LoginPrep returns a login token, and also sets it as cookie. Both must be
// present in the call to Login.
async LoginPrep(): Promise<string> {
const fn: string = "LoginPrep"
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
}
// Login returns a session token for the credentials, or fails with error code
// "user:badLogin". Call LoginPrep to get a loginToken.
async Login(loginToken: string, password: string): Promise<CSRFToken> {
const fn: string = "Login"
const paramTypes: string[][] = [["string"],["string"]]
const returnTypes: string[][] = [["CSRFToken"]]
const params: any[] = [loginToken, password]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as CSRFToken
}
// Logout invalidates the session token.
async Logout(): Promise<void> {
const fn: string = "Logout"
const paramTypes: string[][] = []
const returnTypes: string[][] = []
const params: any[] = []
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// CheckDomain checks the configuration for the domain, such as MX, SMTP STARTTLS,
@ -974,7 +1021,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["CheckResult"]]
const params: any[] = [domainName]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as CheckResult
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as CheckResult
}
// Domains returns all configured domain names, in UTF-8 for IDNA domains.
@ -983,7 +1030,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["[]","Domain"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as Domain[] | null
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as Domain[] | null
}
// Domain returns the dns domain for a (potentially unicode as IDNA) domain name.
@ -992,7 +1039,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["Domain"]]
const params: any[] = [domain]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as Domain
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as Domain
}
// ParseDomain parses a domain, possibly an IDNA domain.
@ -1001,7 +1048,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["Domain"]]
const params: any[] = [domain]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as Domain
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as Domain
}
// DomainLocalparts returns the encoded localparts and accounts configured in domain.
@ -1010,7 +1057,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["{}","string"]]
const params: any[] = [domain]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: string }
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: string }
}
// Accounts returns the names of all configured accounts.
@ -1019,7 +1066,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["[]","string"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as string[] | null
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as string[] | null
}
// Account returns the parsed configuration of an account.
@ -1028,7 +1075,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["{}","any"]]
const params: any[] = [account]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: any }
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: any }
}
// ConfigFiles returns the paths and contents of the static and dynamic configuration files.
@ -1037,7 +1084,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["string"],["string"],["string"],["string"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as [string, string, string, string]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [string, string, string, string]
}
// MTASTSPolicies returns all mtasts policies from the cache.
@ -1046,7 +1093,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["[]","PolicyRecord"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as PolicyRecord[] | null
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as PolicyRecord[] | null
}
// TLSReports returns TLS reports overlapping with period start/end, for the given
@ -1057,7 +1104,7 @@ export class Client {
const paramTypes: string[][] = [["timestamp"],["timestamp"],["string"]]
const returnTypes: string[][] = [["[]","TLSReportRecord"]]
const params: any[] = [start, end, policyDomain]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSReportRecord[] | null
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSReportRecord[] | null
}
// TLSReportID returns a single TLS report.
@ -1066,7 +1113,7 @@ export class Client {
const paramTypes: string[][] = [["string"],["int64"]]
const returnTypes: string[][] = [["TLSReportRecord"]]
const params: any[] = [domain, reportID]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSReportRecord
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSReportRecord
}
// TLSRPTSummaries returns a summary of received TLS reports overlapping with
@ -1077,7 +1124,7 @@ export class Client {
const paramTypes: string[][] = [["timestamp"],["timestamp"],["string"]]
const returnTypes: string[][] = [["[]","TLSRPTSummary"]]
const params: any[] = [start, end, policyDomain]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSRPTSummary[] | null
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSRPTSummary[] | null
}
// DMARCReports returns DMARC reports overlapping with period start/end, for the
@ -1088,7 +1135,7 @@ export class Client {
const paramTypes: string[][] = [["timestamp"],["timestamp"],["string"]]
const returnTypes: string[][] = [["[]","DomainFeedback"]]
const params: any[] = [start, end, domain]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as DomainFeedback[] | null
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as DomainFeedback[] | null
}
// DMARCReportID returns a single DMARC report.
@ -1097,7 +1144,7 @@ export class Client {
const paramTypes: string[][] = [["string"],["int64"]]
const returnTypes: string[][] = [["DomainFeedback"]]
const params: any[] = [domain, reportID]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as DomainFeedback
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as DomainFeedback
}
// DMARCSummaries returns a summary of received DMARC reports overlapping with
@ -1108,7 +1155,7 @@ export class Client {
const paramTypes: string[][] = [["timestamp"],["timestamp"],["string"]]
const returnTypes: string[][] = [["[]","DMARCSummary"]]
const params: any[] = [start, end, domain]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as DMARCSummary[] | null
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as DMARCSummary[] | null
}
// LookupIP does a reverse lookup of ip.
@ -1117,7 +1164,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["Reverse"]]
const params: any[] = [ip]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as Reverse
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as Reverse
}
// DNSBLStatus returns the IPs from which outgoing connections may be made and
@ -1132,7 +1179,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["{}","{}","string"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: { [key: string]: string } }
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: { [key: string]: string } }
}
// DomainRecords returns lines describing DNS records that should exist for the
@ -1142,7 +1189,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["[]","string"]]
const params: any[] = [domain]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as string[] | null
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as string[] | null
}
// DomainAdd adds a new domain and reloads the configuration.
@ -1151,7 +1198,7 @@ export class Client {
const paramTypes: string[][] = [["string"],["string"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [domain, accountName, localpart]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// DomainRemove removes an existing domain and reloads the configuration.
@ -1160,7 +1207,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = []
const params: any[] = [domain]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// AccountAdd adds existing a new account, with an initial email address, and
@ -1170,7 +1217,7 @@ export class Client {
const paramTypes: string[][] = [["string"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [accountName, address]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// AccountRemove removes an existing account and reloads the configuration.
@ -1179,7 +1226,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = []
const params: any[] = [accountName]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// AddressAdd adds a new address to the account, which must already exist.
@ -1188,7 +1235,7 @@ export class Client {
const paramTypes: string[][] = [["string"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [address, accountName]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// AddressRemove removes an existing address.
@ -1197,7 +1244,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = []
const params: any[] = [address]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// SetPassword saves a new password for an account, invalidating the previous password.
@ -1208,7 +1255,7 @@ export class Client {
const paramTypes: string[][] = [["string"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [accountName, password]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// SetAccountLimits set new limits on outgoing messages for an account.
@ -1217,7 +1264,7 @@ export class Client {
const paramTypes: string[][] = [["string"],["int32"],["int32"],["int64"]]
const returnTypes: string[][] = []
const params: any[] = [accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// ClientConfigsDomain returns configurations for email clients, IMAP and
@ -1227,7 +1274,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["ClientConfigs"]]
const params: any[] = [domain]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as ClientConfigs
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as ClientConfigs
}
// QueueList returns the messages currently in the outgoing queue.
@ -1236,7 +1283,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["[]","Msg"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as Msg[] | null
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as Msg[] | null
}
// QueueSize returns the number of messages currently in the outgoing queue.
@ -1245,7 +1292,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["int32"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as number
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as number
}
// QueueKick initiates delivery of a message from the queue and sets the transport
@ -1255,7 +1302,7 @@ export class Client {
const paramTypes: string[][] = [["int64"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [id, transport]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// QueueDrop removes a message from the queue.
@ -1264,7 +1311,7 @@ export class Client {
const paramTypes: string[][] = [["int64"]]
const returnTypes: string[][] = []
const params: any[] = [id]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// QueueSaveRequireTLS updates the requiretls field for a message in the queue,
@ -1274,7 +1321,7 @@ export class Client {
const paramTypes: string[][] = [["int64"],["nullable","bool"]]
const returnTypes: string[][] = []
const params: any[] = [id, requireTLS]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// LogLevels returns the current log levels.
@ -1283,7 +1330,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["{}","string"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: string }
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: string }
}
// LogLevelSet sets a log level for a package.
@ -1292,7 +1339,7 @@ export class Client {
const paramTypes: string[][] = [["string"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [pkg, levelStr]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// LogLevelRemove removes a log level for a package, which cannot be the empty string.
@ -1301,7 +1348,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = []
const params: any[] = [pkg]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// CheckUpdatesEnabled returns whether checking for updates is enabled.
@ -1310,7 +1357,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["bool"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as boolean
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as boolean
}
// WebserverConfig returns the current webserver config
@ -1319,7 +1366,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["WebserverConfig"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as WebserverConfig
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as WebserverConfig
}
// WebserverConfigSave saves a new webserver config. If oldConf is not equal to
@ -1329,7 +1376,7 @@ export class Client {
const paramTypes: string[][] = [["WebserverConfig"],["WebserverConfig"]]
const returnTypes: string[][] = [["WebserverConfig"]]
const params: any[] = [oldConf, newConf]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as WebserverConfig
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as WebserverConfig
}
// Transports returns the configured transports, for sending email.
@ -1338,7 +1385,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["{}","Transport"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: Transport }
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: Transport }
}
// DMARCEvaluationStats returns a map of all domains with evaluations to a count of
@ -1348,7 +1395,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["{}","EvaluationStat"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: EvaluationStat }
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as { [key: string]: EvaluationStat }
}
// DMARCEvaluationsDomain returns all evaluations for aggregate reports for the
@ -1358,7 +1405,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["Domain"],["[]","Evaluation"]]
const params: any[] = [domain]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as [Domain, Evaluation[] | null]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [Domain, Evaluation[] | null]
}
// DMARCRemoveEvaluations removes evaluations for a domain.
@ -1367,7 +1414,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = []
const params: any[] = [domain]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// DMARCSuppressAdd adds a reporting address to the suppress list. Outgoing
@ -1377,7 +1424,7 @@ export class Client {
const paramTypes: string[][] = [["string"],["timestamp"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [reportingAddress, until, comment]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// DMARCSuppressList returns all reporting addresses on the suppress list.
@ -1386,7 +1433,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["[]","SuppressAddress"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as SuppressAddress[] | null
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as SuppressAddress[] | null
}
// DMARCSuppressRemove removes a reporting address record from the suppress list.
@ -1395,7 +1442,7 @@ export class Client {
const paramTypes: string[][] = [["int64"]]
const returnTypes: string[][] = []
const params: any[] = [id]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// DMARCSuppressExtend updates the until field of a suppressed reporting address record.
@ -1404,7 +1451,7 @@ export class Client {
const paramTypes: string[][] = [["int64"],["timestamp"]]
const returnTypes: string[][] = []
const params: any[] = [id, until]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// TLSRPTResults returns all TLSRPT results in the database.
@ -1413,7 +1460,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["[]","TLSResult"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSResult[] | null
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSResult[] | null
}
// TLSRPTResultsPolicyDomain returns the TLS results for a domain.
@ -1422,7 +1469,7 @@ export class Client {
const paramTypes: string[][] = [["bool"],["string"]]
const returnTypes: string[][] = [["Domain"],["[]","TLSResult"]]
const params: any[] = [isRcptDom, policyDomain]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as [Domain, TLSResult[] | null]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [Domain, TLSResult[] | null]
}
// LookupTLSRPTRecord looks up a TLSRPT record and returns the parsed form, original txt
@ -1432,7 +1479,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["nullable","TLSRPTRecord"],["string"],["string"]]
const params: any[] = [domain]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as [TLSRPTRecord | null, string, string]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [TLSRPTRecord | null, string, string]
}
// TLSRPTRemoveResults removes the TLS results for a domain for the given day. If
@ -1442,7 +1489,7 @@ export class Client {
const paramTypes: string[][] = [["bool"],["string"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [isRcptDom, domain, day]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// TLSRPTSuppressAdd adds a reporting address to the suppress list. Outgoing
@ -1452,7 +1499,7 @@ export class Client {
const paramTypes: string[][] = [["string"],["timestamp"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [reportingAddress, until, comment]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// TLSRPTSuppressList returns all reporting addresses on the suppress list.
@ -1461,7 +1508,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["[]","TLSRPTSuppressAddress"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSRPTSuppressAddress[] | null
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as TLSRPTSuppressAddress[] | null
}
// TLSRPTSuppressRemove removes a reporting address record from the suppress list.
@ -1470,7 +1517,7 @@ export class Client {
const paramTypes: string[][] = [["int64"]]
const returnTypes: string[][] = []
const params: any[] = [id]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// TLSRPTSuppressExtend updates the until field of a suppressed reporting address record.
@ -1479,7 +1526,7 @@ export class Client {
const paramTypes: string[][] = [["int64"],["timestamp"]]
const returnTypes: string[][] = []
const params: any[] = [id, until]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
}
@ -1591,7 +1638,7 @@ class verifier {
const ensure = (ok: boolean, expect: string): any => {
if (!ok) {
error('got ' + JSON.stringify(v) + ', expected ' + expect)
error('got ' + JSON.stringify(v) + ', expected ' + expect)
}
return v
}
@ -1732,6 +1779,7 @@ class verifier {
export interface ClientOptions {
baseURL?: string
aborter?: {abort?: () => void}
timeoutMsec?: number
skipParamCheck?: boolean
@ -1739,9 +1787,16 @@ export interface ClientOptions {
slicesNullable?: boolean
mapsNullable?: boolean
nullableOptional?: boolean
csrfHeader?: string
login?: (reason: string) => Promise<string>
}
const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes: string[][], returnTypes: string[][], name: string, params: any[]): Promise<any> => {
export interface AuthState {
token?: string // For csrf request header.
loginPromise?: Promise<void> // To let multiple API calls wait for a single login attempt, not each opening a login popup.
}
const _sherpaCall = async (baseURL: string, authState: AuthState, options: ClientOptions, paramTypes: string[][], returnTypes: string[][], name: string, params: any[]): Promise<any> => {
if (!options.skipParamCheck) {
if (params.length !== paramTypes.length) {
return Promise.reject({ message: 'wrong number of parameters in sherpa call, saw ' + params.length + ' != expected ' + paramTypes.length })
@ -1782,14 +1837,36 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
await simulate(json)
}
// Immediately create promise, so options.aborter is changed before returning.
const promise = new Promise((resolve, reject) => {
let resolve1 = (v: { code: string, message: string }) => {
const fn = (resolve: (v: any) => void, reject: (v: any) => void) => {
let resolve1 = (v: any) => {
resolve(v)
resolve1 = () => { }
reject1 = () => { }
}
let reject1 = (v: { code: string, message: string }) => {
if ((v.code === 'user:noAuth' || v.code === 'user:badAuth') && options.login) {
const login = options.login
if (!authState.loginPromise) {
authState.loginPromise = new Promise((aresolve, areject) => {
login(v.code === 'user:badAuth' ? (v.message || '') : '')
.then((token) => {
authState.token = token
authState.loginPromise = undefined
aresolve()
}, (err: any) => {
authState.loginPromise = undefined
areject(err)
})
})
}
authState.loginPromise
.then(() => {
fn(resolve, reject)
}, (err: any) => {
reject(err)
})
return
}
reject(v)
resolve1 = () => { }
reject1 = () => { }
@ -1804,6 +1881,9 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
}
}
req.open('POST', url, true)
if (options.csrfHeader && authState.token) {
req.setRequestHeader(options.csrfHeader, authState.token)
}
if (options.timeoutMsec) {
req.timeout = options.timeoutMsec
}
@ -1872,8 +1952,8 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
} catch (err) {
reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' })
}
})
return await promise
}
return await new Promise(fn)
}
}

45
webauth/accounts.go Normal file
View file

@ -0,0 +1,45 @@
package webauth
import (
"context"
"errors"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/store"
)
// AccountAuth is for user accounts, with username/password, and sessions stored in
// memory and in the database with lifetimes that are automatically extended.
var Accounts SessionAuth = accountSessionAuth{}
type accountSessionAuth struct{}
func (accountSessionAuth) login(ctx context.Context, log mlog.Log, username, password string) (bool, string, error) {
acc, err := store.OpenEmailAuth(log, username, password)
if err != nil && errors.Is(err, store.ErrUnknownCredentials) {
return false, "", nil
} else if err != nil {
return false, "", err
}
defer func() {
err := acc.Close()
log.Check(err, "closing account")
}()
return true, acc.Name, nil
}
func (accountSessionAuth) add(ctx context.Context, log mlog.Log, accountName string, loginAddress string) (sessionToken store.SessionToken, csrfToken store.CSRFToken, rerr error) {
return store.SessionAdd(ctx, log, accountName, loginAddress)
}
func (accountSessionAuth) use(ctx context.Context, log mlog.Log, accountName string, sessionToken store.SessionToken, csrfToken store.CSRFToken) (loginAddress string, rerr error) {
ls, err := store.SessionUse(ctx, log, accountName, sessionToken, csrfToken)
if err != nil {
return "", err
}
return ls.LoginAddress, nil
}
func (accountSessionAuth) remove(ctx context.Context, log mlog.Log, accountName string, sessionToken store.SessionToken) error {
return store.SessionRemove(ctx, log, accountName, sessionToken)
}

122
webauth/admin.go Normal file
View file

@ -0,0 +1,122 @@
package webauth
import (
"context"
cryptorand "crypto/rand"
"encoding/base64"
"fmt"
"os"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/store"
)
// Admin is for admin logins, with authentication by password, and sessions only
// stored in memory only, with lifetime 12 hour after last use, with a maximum of
// 10 active sessions.
var Admin SessionAuth = &adminSessionAuth{
sessions: map[store.SessionToken]adminSession{},
}
// Good chance of fitting one working day.
const adminSessionLifetime = 12 * time.Hour
type adminSession struct {
sessionToken store.SessionToken
csrfToken store.CSRFToken
expires time.Time
}
type adminSessionAuth struct {
sync.Mutex
sessions map[store.SessionToken]adminSession
}
func (a *adminSessionAuth) login(ctx context.Context, log mlog.Log, username, password string) (bool, string, error) {
a.Lock()
defer a.Unlock()
p := mox.ConfigDirPath(mox.Conf.Static.AdminPasswordFile)
buf, err := os.ReadFile(p)
if err != nil {
return false, "", fmt.Errorf("reading password file: %v", err)
}
passwordhash := strings.TrimSpace(string(buf))
if err := bcrypt.CompareHashAndPassword([]byte(passwordhash), []byte(password)); err != nil {
return false, "", nil
}
return true, "", nil
}
func (a *adminSessionAuth) add(ctx context.Context, log mlog.Log, accountName string, loginAddress string) (sessionToken store.SessionToken, csrfToken store.CSRFToken, rerr error) {
a.Lock()
defer a.Unlock()
// Cleanup expired sessions.
for st, s := range a.sessions {
if time.Until(s.expires) < 0 {
delete(a.sessions, st)
}
}
// Ensure we have at most 10 sessions.
if len(a.sessions) > 10 {
var oldest *store.SessionToken
for _, s := range a.sessions {
if oldest == nil || s.expires.Before(a.sessions[*oldest].expires) {
oldest = &s.sessionToken
}
}
delete(a.sessions, *oldest)
}
// Generate new tokens.
var sessionData, csrfData [16]byte
if _, err := cryptorand.Read(sessionData[:]); err != nil {
return "", "", err
}
if _, err := cryptorand.Read(csrfData[:]); err != nil {
return "", "", err
}
sessionToken = store.SessionToken(base64.RawURLEncoding.EncodeToString(sessionData[:]))
csrfToken = store.CSRFToken(base64.RawURLEncoding.EncodeToString(csrfData[:]))
// Register session.
a.sessions[sessionToken] = adminSession{sessionToken, csrfToken, time.Now().Add(adminSessionLifetime)}
return sessionToken, csrfToken, nil
}
func (a *adminSessionAuth) use(ctx context.Context, log mlog.Log, accountName string, sessionToken store.SessionToken, csrfToken store.CSRFToken) (loginAddress string, rerr error) {
a.Lock()
defer a.Unlock()
s, ok := a.sessions[sessionToken]
if !ok {
return "", fmt.Errorf("unknown session")
} else if time.Until(s.expires) < 0 {
return "", fmt.Errorf("session expired")
} else if csrfToken != "" && csrfToken != s.csrfToken {
return "", fmt.Errorf("mismatch between csrf and session tokens")
}
s.expires = time.Now().Add(adminSessionLifetime)
a.sessions[sessionToken] = s
return "", nil
}
func (a *adminSessionAuth) remove(ctx context.Context, log mlog.Log, accountName string, sessionToken store.SessionToken) error {
a.Lock()
defer a.Unlock()
if _, ok := a.sessions[sessionToken]; !ok {
return fmt.Errorf("unknown session")
}
delete(a.sessions, sessionToken)
return nil
}

301
webauth/webauth.go Normal file
View file

@ -0,0 +1,301 @@
/*
Package webauth handles authentication and session/csrf token management for
the web interfaces (admin, account, mail).
Authentication of web requests is through a session token in a cookie. For API
requests, and other requests where the frontend can send custom headers, a
header ("x-mox-csrf") with a CSRF token is also required and verified to belong
to the session token. For other form POSTS, a field "csrf" is required. Session
tokens and CSRF tokens are different randomly generated values. Session cookies
are "httponly", samesite "strict", and with the path set to the root of the
webadmin/webaccount/webmail. Cookies set over HTTPS are marked "secure".
Cookies don't have an expiration, they can be extended indefinitely by using
them.
To login, a call to LoginPrep must first be made. It sets a random login token
in a cookie, and returns it. The loginToken must be passed to the Login call,
along with login credentials. If the loginToken is missing, the login attempt
fails before checking any credentials. This should prevent third party websites
from tricking a browser into logging in.
Sessions are stored server-side, and their lifetime automatically extended each
time they are used. This makes it easy to invalidate existing sessions after a
password change, and keeps the frontend free from handling long-term vs
short-term sessions.
Sessions for the admin interface have a lifetime of 12 hours after last use,
are only stored in memory (don't survive a server restart), and only 10
sessions can exist at a time (the oldest session is dropped).
Sessions for the account and mail interfaces have a lifetime of 24 hours after
last use, are kept in memory and stored in the database (do survive a server
restart), and only 100 sessions can exist per account (the oldest session is
dropped).
*/
package webauth
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"time"
"golang.org/x/exp/slog"
"github.com/mjl-/sherpa"
"github.com/mjl-/mox/metrics"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/store"
)
// Delay before responding in case of bad authentication attempt.
var BadAuthDelay = time.Second
// SessionAuth handles login and session storage, used for both account and
// admin authentication.
type SessionAuth interface {
login(ctx context.Context, log mlog.Log, username, password string) (valid bool, accountName string, rerr error)
// Add a new session for account and login address.
add(ctx context.Context, log mlog.Log, accountName string, loginAddress string) (sessionToken store.SessionToken, csrfToken store.CSRFToken, rerr error)
// Use an existing session. If csrfToken is empty, no CSRF check must be done.
// Otherwise the CSRF token must be associated with the session token, as returned
// by add. If the token is not valid (e.g. expired, unknown, malformed), an error
// must be returned.
use(ctx context.Context, log mlog.Log, accountName string, sessionToken store.SessionToken, csrfToken store.CSRFToken) (loginAddress string, rerr error)
// Removes a session, invalidating any future use. Must return an error if the
// session is not valid.
remove(ctx context.Context, log mlog.Log, accountName string, sessionToken store.SessionToken) error
}
// Check authentication for a request based on session token in cookie and matching
// csrf in case requireCSRF is set (from header, unless formCSRF is set). Also
// performs rate limiting.
//
// If the returned boolean is true, the request is authenticated. If the returned
// boolean is false, an HTTP error response has already been returned. If rate
// limiting applies (after too many failed authentication attempts), an HTTP status
// 429 is returned. Otherwise, for API requests an error object with either code
// "user:noAuth" or "user:badAuth" is returned. Other unauthenticated requests
// result in HTTP status 403.
//
// sessionAuth verifies login attempts and handles session management.
//
// kind is used for the cookie name (webadmin, webaccount, webmail), and for
// logging/metrics.
func Check(ctx context.Context, log mlog.Log, sessionAuth SessionAuth, kind string, isForwarded bool, w http.ResponseWriter, r *http.Request, isAPI, requireCSRF, postFormCSRF bool) (accountName string, sessionToken store.SessionToken, loginAddress string, ok bool) {
// Respond with an authentication error.
respondAuthError := func(code, msg string) {
if isAPI {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
var result = struct {
Error sherpa.Error `json:"error"`
}{
sherpa.Error{Code: code, Message: msg},
}
json.NewEncoder(w).Encode(result)
} else {
http.Error(w, "403 - forbidden - "+msg, http.StatusForbidden)
}
}
// The frontends cannot inject custom headers for all requests, e.g. images loaded
// as resources. For those, we don't require the CSRF and rely on the session
// cookie with samesite=strict.
// todo future: possibly get a session-tied value to use in paths for resources, and verify server-side that it matches the session token.
var csrfValue string
if requireCSRF && postFormCSRF {
csrfValue = r.PostFormValue("csrf")
} else {
csrfValue = r.Header.Get("x-mox-csrf")
}
csrfToken := store.CSRFToken(csrfValue)
if requireCSRF && csrfToken == "" {
respondAuthError("user:noAuth", "missing required csrf header")
return "", "", "", false
}
// Cookies are named "webmailsession", "webaccountsession", "webadminsession".
cookie, _ := r.Cookie(kind + "session")
if cookie == nil {
respondAuthError("user:noAuth", "no session")
return "", "", "", false
}
ip := remoteIP(log, isForwarded, r)
if ip == nil {
respondAuthError("user:noAuth", "cannot find ip for rate limit check (missing x-forwarded-for header?)")
return "", "", "", false
}
start := time.Now()
if !mox.LimiterFailedAuth.Add(ip, start, 1) {
metrics.AuthenticationRatelimitedInc(kind)
http.Error(w, "429 - too many auth attempts", http.StatusTooManyRequests)
return
}
authResult := "badcreds"
defer func() {
metrics.AuthenticationInc(kind, "websession", authResult)
}()
// Cookie values are of the form: token SP accountname.
// For admin sessions, the accountname is empty (there is no login address either).
t := strings.SplitN(cookie.Value, " ", 2)
if len(t) != 2 {
time.Sleep(BadAuthDelay)
respondAuthError("user:badAuth", "malformed session")
return "", "", "", false
}
sessionToken = store.SessionToken(t[0])
accountName = t[1]
var err error
loginAddress, err = sessionAuth.use(ctx, log, accountName, sessionToken, csrfToken)
if err != nil {
time.Sleep(BadAuthDelay)
respondAuthError("user:badAuth", err.Error())
return "", "", "", false
}
mox.LimiterFailedAuth.Reset(ip, start)
authResult = "ok"
// Add to HTTP logging that this is an authenticated request.
if lw, ok := w.(interface{ AddAttr(a slog.Attr) }); ok {
lw.AddAttr(slog.String("authaccount", accountName))
}
return accountName, sessionToken, loginAddress, true
}
func remoteIP(log mlog.Log, isForwarded bool, r *http.Request) net.IP {
if isForwarded {
s := r.Header.Get("X-Forwarded-For")
ipstr := strings.TrimSpace(strings.Split(s, ",")[0])
return net.ParseIP(ipstr)
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
return net.ParseIP(host)
}
func isHTTPS(isForwarded bool, r *http.Request) bool {
if isForwarded {
return r.Header.Get("X-Forwarded-Proto") == "https"
}
return r.TLS != nil
}
// LoginPrep is an API call that returns a loginToken and also sets it as cookie
// with the same value. The loginToken must be passed to a subsequent call to
// Login, which will check that the loginToken and cookie are both present and
// match before checking the actual login attempt. This would prevent a third party
// site from triggering login attempts by the browser.
func LoginPrep(ctx context.Context, log mlog.Log, kind, cookiePath string, isForwarded bool, w http.ResponseWriter, r *http.Request, token string) {
// todo future: we could sign the login token, and verify it on use, so subdomains cannot set it to known values.
http.SetCookie(w, &http.Cookie{
Name: kind + "login",
Value: token,
Path: cookiePath,
Secure: isHTTPS(isForwarded, r),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
MaxAge: 30, // Only for one login attempt.
})
}
// Login handles a login attempt, checking against the rate limiter, verifying the
// credentials through sessionAuth, and setting a session token cookie on the HTTP
// response and returning the associated CSRF token.
//
// In case of a user error, a *sherpa.Error is returned that sherpa handlers can
// pass to panic. For bad credentials, the error code is "user:loginFailed".
func Login(ctx context.Context, log mlog.Log, sessionAuth SessionAuth, kind, cookiePath string, isForwarded bool, w http.ResponseWriter, r *http.Request, loginToken, username, password string) (store.CSRFToken, error) {
loginCookie, _ := r.Cookie(kind + "login")
if loginCookie == nil || loginCookie.Value != loginToken {
return "", &sherpa.Error{Code: "user:error", Message: "missing login token"}
}
ip := remoteIP(log, isForwarded, r)
if ip == nil {
return "", fmt.Errorf("cannot find ip for rate limit check (missing x-forwarded-for header?)")
}
start := time.Now()
if !mox.LimiterFailedAuth.Add(ip, start, 1) {
metrics.AuthenticationRatelimitedInc(kind)
return "", &sherpa.Error{Code: "user:error", Message: "too many authentication attempts"}
}
valid, accountName, err := sessionAuth.login(ctx, log, username, password)
var authResult string
defer func() {
metrics.AuthenticationInc(kind, "weblogin", authResult)
}()
if err != nil {
authResult = "error"
return "", fmt.Errorf("evaluating login attempt: %v", err)
} else if !valid {
time.Sleep(BadAuthDelay)
authResult = "badcreds"
return "", &sherpa.Error{Code: "user:loginFailed", Message: "invalid credentials"}
}
authResult = "ok"
mox.LimiterFailedAuth.Reset(ip, start)
sessionToken, csrfToken, err := sessionAuth.add(ctx, log, accountName, username)
if err != nil {
log.Errorx("adding session after login", err)
return "", fmt.Errorf("adding session: %v", err)
}
// Add session cookie.
http.SetCookie(w, &http.Cookie{
Name: kind + "session",
Value: string(sessionToken) + " " + accountName,
Path: cookiePath,
Secure: isHTTPS(isForwarded, r),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
// We don't set a max-age. These makes cookies per-session. Browsers are rarely
// restarted nowadays, and they have "continue where you left off", keeping session
// cookies. Our sessions are only valid for max 1 day. Convenience can come from
// the browser remember the password.
})
// Remove cookie used during login.
http.SetCookie(w, &http.Cookie{
Name: kind + "login",
Path: cookiePath,
Secure: isHTTPS(isForwarded, r),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1, // Delete cookie
})
return csrfToken, nil
}
// Logout removes the session token through sessionAuth, and clears the session
// cookie through the HTTP response.
func Logout(ctx context.Context, log mlog.Log, sessionAuth SessionAuth, kind, cookiePath string, isForwarded bool, w http.ResponseWriter, r *http.Request, accountName string, sessionToken store.SessionToken) error {
err := sessionAuth.remove(ctx, log, accountName, sessionToken)
if err != nil {
return fmt.Errorf("removing session: %w", err)
}
http.SetCookie(w, &http.Cookie{
Name: kind + "session",
Path: cookiePath,
Secure: isHTTPS(isForwarded, r),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
MaxAge: -1, // Delete cookie.
})
return nil
}

View file

@ -2,6 +2,7 @@ package webmail
import (
"context"
cryptorand "crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
@ -43,13 +44,16 @@ import (
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/smtpclient"
"github.com/mjl-/mox/store"
"github.com/mjl-/mox/webauth"
)
//go:embed api.json
var webmailapiJSON []byte
type Webmail struct {
maxMessageSize int64 // From listener.
maxMessageSize int64 // From listener.
cookiePath string // From listener.
isForwarded bool // From listener, whether we look at X-Forwarded-* headers.
}
func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
@ -64,8 +68,8 @@ var webmailDoc = mustParseAPI("webmail", webmailapiJSON)
var sherpaHandlerOpts *sherpa.HandlerOpts
func makeSherpaHandler(maxMessageSize int64) (http.Handler, error) {
return sherpa.NewHandler("/api/", moxvar.Version, Webmail{maxMessageSize}, &webmailDoc, sherpaHandlerOpts)
func makeSherpaHandler(maxMessageSize int64, cookiePath string, isForwarded bool) (http.Handler, error) {
return sherpa.NewHandler("/api/", moxvar.Version, Webmail{maxMessageSize, cookiePath, isForwarded}, &webmailDoc, sherpaHandlerOpts)
}
func init() {
@ -74,20 +78,59 @@ func init() {
pkglog.Fatalx("creating sherpa prometheus collector", err)
}
sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none"}
sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
// Just to validate.
_, err = makeSherpaHandler(0)
_, err = makeSherpaHandler(0, "", false)
if err != nil {
pkglog.Fatalx("sherpa handler", err)
}
}
// LoginPrep returns a login token, and also sets it as cookie. Both must be
// present in the call to Login.
func (w Webmail) LoginPrep(ctx context.Context) string {
log := pkglog.WithContext(ctx)
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
var data [8]byte
_, err := cryptorand.Read(data[:])
xcheckf(ctx, err, "generate token")
loginToken := base64.RawURLEncoding.EncodeToString(data[:])
webauth.LoginPrep(ctx, log, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
return loginToken
}
// Login returns a session token for the credentials, or fails with error code
// "user:badLogin". Call LoginPrep to get a loginToken.
func (w Webmail) Login(ctx context.Context, loginToken, username, password string) store.CSRFToken {
log := pkglog.WithContext(ctx)
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
csrfToken, err := webauth.Login(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, username, password)
if _, ok := err.(*sherpa.Error); ok {
panic(err)
}
xcheckf(ctx, err, "login")
return csrfToken
}
// Logout invalidates the session token.
func (w Webmail) Logout(ctx context.Context) {
log := pkglog.WithContext(ctx)
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := webauth.Logout(ctx, log, webauth.Accounts, "webmail", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, reqInfo.AccountName, reqInfo.SessionToken)
xcheckf(ctx, err, "logout")
}
// Token returns a token to use for an SSE connection. A token can only be used for
// a single SSE connection. Tokens are stored in memory for a maximum of 1 minute,
// with at most 10 unused tokens (the most recently created) per account.
func (Webmail) Token(ctx context.Context) string {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
return sseTokens.xgenerate(ctx, reqInfo.AccountName, reqInfo.LoginAddress)
return sseTokens.xgenerate(ctx, reqInfo.AccountName, reqInfo.LoginAddress, reqInfo.SessionToken)
}
// Requests sends a new request for an open SSE connection. Any currently active

View file

@ -2,6 +2,57 @@
"Name": "Webmail",
"Docs": "",
"Functions": [
{
"Name": "LoginPrep",
"Docs": "LoginPrep returns a login token, and also sets it as cookie. Both must be\npresent in the call to Login.",
"Params": [],
"Returns": [
{
"Name": "r0",
"Typewords": [
"string"
]
}
]
},
{
"Name": "Login",
"Docs": "Login returns a session token for the credentials, or fails with error code\n\"user:badLogin\". Call LoginPrep to get a loginToken.",
"Params": [
{
"Name": "loginToken",
"Typewords": [
"string"
]
},
{
"Name": "username",
"Typewords": [
"string"
]
},
{
"Name": "password",
"Typewords": [
"string"
]
}
],
"Returns": [
{
"Name": "r0",
"Typewords": [
"CSRFToken"
]
}
]
},
{
"Name": "Logout",
"Docs": "Logout invalidates the session token.",
"Params": [],
"Returns": []
},
{
"Name": "Token",
"Docs": "Token returns a token to use for an SSE connection. A token can only be used for\na single SSE connection. Tokens are stored in memory for a maximum of 1 minute,\nwith at most 10 unused tokens (the most recently created) per account.",
@ -2626,6 +2677,11 @@
}
],
"Strings": [
{
"Name": "CSRFToken",
"Docs": "",
"Values": null
},
{
"Name": "ThreadMode",
"Docs": "",

View file

@ -479,6 +479,8 @@ export enum Validation {
ValidationNone = 10, // E.g. No records.
}
export type CSRFToken = string
export enum ThreadMode {
ThreadOff = "off",
ThreadOn = "on",
@ -515,7 +517,7 @@ export enum SecurityResult {
export type Localpart = string
export const structTypes: {[typename: string]: boolean} = {"Address":true,"Attachment":true,"ChangeMailboxAdd":true,"ChangeMailboxCounts":true,"ChangeMailboxKeywords":true,"ChangeMailboxRemove":true,"ChangeMailboxRename":true,"ChangeMailboxSpecialUse":true,"ChangeMsgAdd":true,"ChangeMsgFlags":true,"ChangeMsgRemove":true,"ChangeMsgThread":true,"Domain":true,"DomainAddressConfig":true,"Envelope":true,"EventStart":true,"EventViewChanges":true,"EventViewErr":true,"EventViewMsgs":true,"EventViewReset":true,"File":true,"Filter":true,"Flags":true,"ForwardAttachments":true,"Mailbox":true,"Message":true,"MessageAddress":true,"MessageEnvelope":true,"MessageItem":true,"NotFilter":true,"Page":true,"ParsedMessage":true,"Part":true,"Query":true,"RecipientSecurity":true,"Request":true,"SpecialUse":true,"SubmitMessage":true}
export const stringsTypes: {[typename: string]: boolean} = {"AttachmentType":true,"Localpart":true,"SecurityResult":true,"ThreadMode":true}
export const stringsTypes: {[typename: string]: boolean} = {"AttachmentType":true,"CSRFToken":true,"Localpart":true,"SecurityResult":true,"ThreadMode":true}
export const intsTypes: {[typename: string]: boolean} = {"ModSeq":true,"UID":true,"Validation":true}
export const types: TypenameMap = {
"Request": {"Name":"Request","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"SSEID","Docs":"","Typewords":["int64"]},{"Name":"ViewID","Docs":"","Typewords":["int64"]},{"Name":"Cancel","Docs":"","Typewords":["bool"]},{"Name":"Query","Docs":"","Typewords":["Query"]},{"Name":"Page","Docs":"","Typewords":["Page"]}]},
@ -559,6 +561,7 @@ export const types: TypenameMap = {
"UID": {"Name":"UID","Docs":"","Values":null},
"ModSeq": {"Name":"ModSeq","Docs":"","Values":null},
"Validation": {"Name":"Validation","Docs":"","Values":[{"Name":"ValidationUnknown","Value":0,"Docs":""},{"Name":"ValidationStrict","Value":1,"Docs":""},{"Name":"ValidationDMARC","Value":2,"Docs":""},{"Name":"ValidationRelaxed","Value":3,"Docs":""},{"Name":"ValidationPass","Value":4,"Docs":""},{"Name":"ValidationNeutral","Value":5,"Docs":""},{"Name":"ValidationTemperror","Value":6,"Docs":""},{"Name":"ValidationPermerror","Value":7,"Docs":""},{"Name":"ValidationFail","Value":8,"Docs":""},{"Name":"ValidationSoftfail","Value":9,"Docs":""},{"Name":"ValidationNone","Value":10,"Docs":""}]},
"CSRFToken": {"Name":"CSRFToken","Docs":"","Values":null},
"ThreadMode": {"Name":"ThreadMode","Docs":"","Values":[{"Name":"ThreadOff","Value":"off","Docs":""},{"Name":"ThreadOn","Value":"on","Docs":""},{"Name":"ThreadUnread","Value":"unread","Docs":""}]},
"AttachmentType": {"Name":"AttachmentType","Docs":"","Values":[{"Name":"AttachmentIndifferent","Value":"","Docs":""},{"Name":"AttachmentNone","Value":"none","Docs":""},{"Name":"AttachmentAny","Value":"any","Docs":""},{"Name":"AttachmentImage","Value":"image","Docs":""},{"Name":"AttachmentPDF","Value":"pdf","Docs":""},{"Name":"AttachmentArchive","Value":"archive","Docs":""},{"Name":"AttachmentSpreadsheet","Value":"spreadsheet","Docs":""},{"Name":"AttachmentDocument","Value":"document","Docs":""},{"Name":"AttachmentPresentation","Value":"presentation","Docs":""}]},
"SecurityResult": {"Name":"SecurityResult","Docs":"","Values":[{"Name":"SecurityResultError","Value":"error","Docs":""},{"Name":"SecurityResultNo","Value":"no","Docs":""},{"Name":"SecurityResultYes","Value":"yes","Docs":""},{"Name":"SecurityResultUnknown","Value":"unknown","Docs":""}]},
@ -607,6 +610,7 @@ export const parser = {
UID: (v: any) => parse("UID", v) as UID,
ModSeq: (v: any) => parse("ModSeq", v) as ModSeq,
Validation: (v: any) => parse("Validation", v) as Validation,
CSRFToken: (v: any) => parse("CSRFToken", v) as CSRFToken,
ThreadMode: (v: any) => parse("ThreadMode", v) as ThreadMode,
AttachmentType: (v: any) => parse("AttachmentType", v) as AttachmentType,
SecurityResult: (v: any) => parse("SecurityResult", v) as SecurityResult,
@ -616,14 +620,57 @@ export const parser = {
let defaultOptions: ClientOptions = {slicesNullable: true, mapsNullable: true, nullableOptional: true}
export class Client {
constructor(private baseURL=defaultBaseURL, public options?: ClientOptions) {
if (!options) {
this.options = defaultOptions
}
private baseURL: string
public authState: AuthState
public options: ClientOptions
constructor() {
this.authState = {}
this.options = {...defaultOptions}
this.baseURL = this.options.baseURL || defaultBaseURL
}
withAuthToken(token: string): Client {
const c = new Client()
c.authState.token = token
c.options = this.options
return c
}
withOptions(options: ClientOptions): Client {
return new Client(this.baseURL, { ...this.options, ...options })
const c = new Client()
c.authState = this.authState
c.options = { ...this.options, ...options }
return c
}
// LoginPrep returns a login token, and also sets it as cookie. Both must be
// present in the call to Login.
async LoginPrep(): Promise<string> {
const fn: string = "LoginPrep"
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
}
// Login returns a session token for the credentials, or fails with error code
// "user:badLogin". Call LoginPrep to get a loginToken.
async Login(loginToken: string, username: string, password: string): Promise<CSRFToken> {
const fn: string = "Login"
const paramTypes: string[][] = [["string"],["string"],["string"]]
const returnTypes: string[][] = [["CSRFToken"]]
const params: any[] = [loginToken, username, password]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as CSRFToken
}
// Logout invalidates the session token.
async Logout(): Promise<void> {
const fn: string = "Logout"
const paramTypes: string[][] = []
const returnTypes: string[][] = []
const params: any[] = []
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// Token returns a token to use for an SSE connection. A token can only be used for
@ -634,7 +681,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["string"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as string
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as string
}
// Requests sends a new request for an open SSE connection. Any currently active
@ -647,7 +694,7 @@ export class Client {
const paramTypes: string[][] = [["Request"]]
const returnTypes: string[][] = []
const params: any[] = [req]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// ParsedMessage returns enough to render the textual body of a message. It is
@ -657,7 +704,7 @@ export class Client {
const paramTypes: string[][] = [["int64"]]
const returnTypes: string[][] = [["ParsedMessage"]]
const params: any[] = [msgID]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as ParsedMessage
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as ParsedMessage
}
// MessageSubmit sends a message by submitting it the outgoing email queue. The
@ -671,7 +718,7 @@ export class Client {
const paramTypes: string[][] = [["SubmitMessage"]]
const returnTypes: string[][] = []
const params: any[] = [m]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// MessageMove moves messages to another mailbox. If the message is already in
@ -681,7 +728,7 @@ export class Client {
const paramTypes: string[][] = [["[]","int64"],["int64"]]
const returnTypes: string[][] = []
const params: any[] = [messageIDs, mailboxID]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
@ -690,7 +737,7 @@ export class Client {
const paramTypes: string[][] = [["[]","int64"]]
const returnTypes: string[][] = []
const params: any[] = [messageIDs]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
@ -700,7 +747,7 @@ export class Client {
const paramTypes: string[][] = [["[]","int64"],["[]","string"]]
const returnTypes: string[][] = []
const params: any[] = [messageIDs, flaglist]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// FlagsClear clears flags, either system flags like \Seen or custom keywords.
@ -709,7 +756,7 @@ export class Client {
const paramTypes: string[][] = [["[]","int64"],["[]","string"]]
const returnTypes: string[][] = []
const params: any[] = [messageIDs, flaglist]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// MailboxCreate creates a new mailbox.
@ -718,7 +765,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = []
const params: any[] = [name]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// MailboxDelete deletes a mailbox and all its messages.
@ -727,7 +774,7 @@ export class Client {
const paramTypes: string[][] = [["int64"]]
const returnTypes: string[][] = []
const params: any[] = [mailboxID]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
@ -737,7 +784,7 @@ export class Client {
const paramTypes: string[][] = [["int64"]]
const returnTypes: string[][] = []
const params: any[] = [mailboxID]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
@ -747,7 +794,7 @@ export class Client {
const paramTypes: string[][] = [["int64"],["string"]]
const returnTypes: string[][] = []
const params: any[] = [mailboxID, newName]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// CompleteRecipient returns autocomplete matches for a recipient, returning the
@ -758,7 +805,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["[]","string"],["bool"]]
const params: any[] = [search]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as [string[] | null, boolean]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [string[] | null, boolean]
}
// MailboxSetSpecialUse sets the special use flags of a mailbox.
@ -767,7 +814,7 @@ export class Client {
const paramTypes: string[][] = [["Mailbox"]]
const returnTypes: string[][] = []
const params: any[] = [mb]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// ThreadCollapse saves the ThreadCollapse field for the messages and its
@ -778,7 +825,7 @@ export class Client {
const paramTypes: string[][] = [["[]","int64"],["bool"]]
const returnTypes: string[][] = []
const params: any[] = [messageIDs, collapse]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// ThreadMute saves the ThreadMute field for the messages and their children.
@ -788,7 +835,7 @@ export class Client {
const paramTypes: string[][] = [["[]","int64"],["bool"]]
const returnTypes: string[][] = []
const params: any[] = [messageIDs, mute]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as void
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as void
}
// RecipientSecurity looks up security properties of the address in the
@ -798,7 +845,7 @@ export class Client {
const paramTypes: string[][] = [["string"]]
const returnTypes: string[][] = [["RecipientSecurity"]]
const params: any[] = [messageAddressee]
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as RecipientSecurity
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as RecipientSecurity
}
// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
@ -807,7 +854,7 @@ export class Client {
const paramTypes: string[][] = []
const returnTypes: string[][] = [["EventStart"],["EventViewErr"],["EventViewReset"],["EventViewMsgs"],["EventViewChanges"],["ChangeMsgAdd"],["ChangeMsgRemove"],["ChangeMsgFlags"],["ChangeMsgThread"],["ChangeMailboxRemove"],["ChangeMailboxAdd"],["ChangeMailboxRename"],["ChangeMailboxCounts"],["ChangeMailboxSpecialUse"],["ChangeMailboxKeywords"],["Flags"]]
const params: any[] = []
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params) as [EventStart, EventViewErr, EventViewReset, EventViewMsgs, EventViewChanges, ChangeMsgAdd, ChangeMsgRemove, ChangeMsgFlags, ChangeMsgThread, ChangeMailboxRemove, ChangeMailboxAdd, ChangeMailboxRename, ChangeMailboxCounts, ChangeMailboxSpecialUse, ChangeMailboxKeywords, Flags]
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params) as [EventStart, EventViewErr, EventViewReset, EventViewMsgs, EventViewChanges, ChangeMsgAdd, ChangeMsgRemove, ChangeMsgFlags, ChangeMsgThread, ChangeMailboxRemove, ChangeMailboxAdd, ChangeMailboxRename, ChangeMailboxCounts, ChangeMailboxSpecialUse, ChangeMailboxKeywords, Flags]
}
}
@ -919,7 +966,7 @@ class verifier {
const ensure = (ok: boolean, expect: string): any => {
if (!ok) {
error('got ' + JSON.stringify(v) + ', expected ' + expect)
error('got ' + JSON.stringify(v) + ', expected ' + expect)
}
return v
}
@ -1060,6 +1107,7 @@ class verifier {
export interface ClientOptions {
baseURL?: string
aborter?: {abort?: () => void}
timeoutMsec?: number
skipParamCheck?: boolean
@ -1067,9 +1115,16 @@ export interface ClientOptions {
slicesNullable?: boolean
mapsNullable?: boolean
nullableOptional?: boolean
csrfHeader?: string
login?: (reason: string) => Promise<string>
}
const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes: string[][], returnTypes: string[][], name: string, params: any[]): Promise<any> => {
export interface AuthState {
token?: string // For csrf request header.
loginPromise?: Promise<void> // To let multiple API calls wait for a single login attempt, not each opening a login popup.
}
const _sherpaCall = async (baseURL: string, authState: AuthState, options: ClientOptions, paramTypes: string[][], returnTypes: string[][], name: string, params: any[]): Promise<any> => {
if (!options.skipParamCheck) {
if (params.length !== paramTypes.length) {
return Promise.reject({ message: 'wrong number of parameters in sherpa call, saw ' + params.length + ' != expected ' + paramTypes.length })
@ -1110,14 +1165,36 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
await simulate(json)
}
// Immediately create promise, so options.aborter is changed before returning.
const promise = new Promise((resolve, reject) => {
let resolve1 = (v: { code: string, message: string }) => {
const fn = (resolve: (v: any) => void, reject: (v: any) => void) => {
let resolve1 = (v: any) => {
resolve(v)
resolve1 = () => { }
reject1 = () => { }
}
let reject1 = (v: { code: string, message: string }) => {
if ((v.code === 'user:noAuth' || v.code === 'user:badAuth') && options.login) {
const login = options.login
if (!authState.loginPromise) {
authState.loginPromise = new Promise((aresolve, areject) => {
login(v.code === 'user:badAuth' ? (v.message || '') : '')
.then((token) => {
authState.token = token
authState.loginPromise = undefined
aresolve()
}, (err: any) => {
authState.loginPromise = undefined
areject(err)
})
})
}
authState.loginPromise
.then(() => {
fn(resolve, reject)
}, (err: any) => {
reject(err)
})
return
}
reject(v)
resolve1 = () => { }
reject1 = () => { }
@ -1132,6 +1209,9 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
}
}
req.open('POST', url, true)
if (options.csrfHeader && authState.token) {
req.setRequestHeader(options.csrfHeader, authState.token)
}
if (options.timeoutMsec) {
req.timeout = options.timeoutMsec
}
@ -1200,8 +1280,8 @@ const _sherpaCall = async (baseURL: string, options: ClientOptions, paramTypes:
} catch (err) {
reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' })
}
})
return await promise
}
return await new Promise(fn)
}
}

View file

@ -4,11 +4,14 @@ import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime/debug"
"testing"
"golang.org/x/exp/slices"
"github.com/mjl-/bstore"
"github.com/mjl-/sherpa"
@ -19,7 +22,7 @@ import (
"github.com/mjl-/mox/store"
)
func tneedError(t *testing.T, fn func()) {
func tneedErrorCode(t *testing.T, code string, fn func()) {
t.Helper()
defer func() {
t.Helper()
@ -30,16 +33,20 @@ func tneedError(t *testing.T, fn func()) {
}
if err, ok := x.(*sherpa.Error); !ok {
debug.PrintStack()
t.Fatalf("expected sherpa user error, saw %#v", x)
} else if err.Code != "user:error" {
t.Fatalf("expected sherpa error, saw %#v", x)
} else if err.Code != code {
debug.PrintStack()
t.Fatalf("expected sherpa user error, saw other sherpa error %#v", err)
t.Fatalf("expected sherpa error code %q, saw other sherpa error %#v", code, err)
}
}()
fn()
}
func tneedError(t *testing.T, fn func()) {
tneedErrorCode(t, "user:error", fn)
}
// Test API calls.
// todo: test that the actions make the changes they claim to make. we currently just call the functions and have only limited checks that state changed.
func TestAPI(t *testing.T) {
@ -77,8 +84,55 @@ func TestAPI(t *testing.T) {
tdeliver(t, acc, tm)
}
api := Webmail{maxMessageSize: 1024 * 1024}
reqInfo := requestInfo{"mjl@mox.example", "mjl", &http.Request{}}
api := Webmail{maxMessageSize: 1024 * 1024, cookiePath: "/webmail/"}
// Test login, and rate limiter.
loginReqInfo := requestInfo{"mjl@mox.example", "mjl", "", httptest.NewRecorder(), &http.Request{RemoteAddr: "1.1.1.1:1234"}}
loginctx := context.WithValue(ctxbg, requestInfoCtxKey, loginReqInfo)
// Missing login token.
tneedErrorCode(t, "user:error", func() { api.Login(loginctx, "", "mjl@mox.example", "test1234") })
// Login with loginToken.
loginCookie := &http.Cookie{Name: "webmaillogin"}
loginCookie.Value = api.LoginPrep(loginctx)
loginReqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
testLogin := func(username, password string, expErrCodes ...string) {
t.Helper()
defer func() {
x := recover()
expErr := len(expErrCodes) > 0
if (x != nil) != expErr {
t.Fatalf("got %v, expected codes %v", x, expErrCodes)
}
if x == nil {
return
} else if err, ok := x.(*sherpa.Error); !ok {
t.Fatalf("got %#v, expected at most *sherpa.Error", x)
} else if !slices.Contains(expErrCodes, err.Code) {
t.Fatalf("got error code %q, expected %v", err.Code, expErrCodes)
}
}()
api.Login(loginctx, loginCookie.Value, username, password)
}
testLogin("mjl@mox.example", "test1234")
testLogin("mjl@mox.example", "bad", "user:loginFailed")
testLogin("nouser@mox.example", "test1234", "user:loginFailed")
testLogin("nouser@bad.example", "test1234", "user:loginFailed")
for i := 3; i < 10; i++ {
testLogin("bad@bad.example", "test1234", "user:loginFailed")
}
// Ensure rate limiter is triggered, also for slow tests.
for i := 0; i < 10; i++ {
testLogin("bad@bad.example", "test1234", "user:loginFailed", "user:error")
}
testLogin("bad@bad.example", "test1234", "user:error")
// Context with different IP, for clear rate limit history.
reqInfo := requestInfo{"mjl@mox.example", "mjl", "", nil, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
// FlagsAdd

View file

@ -16,6 +16,7 @@ import (
"github.com/mjl-/mox/metrics"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/store"
)
type eventWriter struct {
@ -26,6 +27,11 @@ type eventWriter struct {
sync.Mutex
closed bool
// Before writing an event, we check if session is still valid. If not, we send a
// fatal error instead.
accountName string
sessionToken store.SessionToken
wrote bool // To be reset by user, set on write.
events chan struct {
name string // E.g. "start" for EventStart.
@ -35,8 +41,8 @@ type eventWriter struct {
errors chan error // If we have an events channel, we read errors and abort for them.
}
func newEventWriter(out writeFlusher, waitMin, waitMax time.Duration) *eventWriter {
return &eventWriter{out: out, waitMin: waitMin, waitMax: waitMax}
func newEventWriter(out writeFlusher, waitMin, waitMax time.Duration, accountName string, sessionToken store.SessionToken) *eventWriter {
return &eventWriter{out: out, waitMin: waitMin, waitMax: waitMax, accountName: accountName, sessionToken: sessionToken}
}
// close shuts down the events channel, causing the goroutine (if created) to
@ -72,6 +78,13 @@ var waitGen = mathrand.New(mathrand.NewSource(time.Now().UnixNano()))
// Schedule an event for writing to the connection. If events get a delay, this
// function still returns immediately.
func (ew *eventWriter) xsendEvent(ctx context.Context, log mlog.Log, name string, v any) {
if name != "fatalErr" {
if _, err := store.SessionUse(ctx, log, ew.accountName, ew.sessionToken, ""); err != nil {
ew.xsendEvent(ctx, log, "fatalErr", "session no longer valid")
return
}
}
if (ew.waitMin > 0 || ew.waitMax > 0) && ew.events == nil {
// First write on a connection with delay.
ew.events = make(chan struct {

View file

@ -215,6 +215,8 @@ const [dom, style, attr, prop] = (function () {
name: (s) => _attr('name', s),
min: (s) => _attr('min', s),
max: (s) => _attr('max', s),
action: (s) => _attr('action', s),
method: (s) => _attr('method', s),
};
const style = (x) => { return { _styles: x }; };
const prop = (x) => { return { _props: x }; };
@ -269,7 +271,7 @@ var api;
SecurityResult["SecurityResultUnknown"] = "unknown";
})(SecurityResult = api.SecurityResult || (api.SecurityResult = {}));
api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "ChangeMsgThread": true, "Domain": true, "DomainAddressConfig": true, "Envelope": true, "EventStart": true, "EventViewChanges": true, "EventViewErr": true, "EventViewMsgs": true, "EventViewReset": true, "File": true, "Filter": true, "Flags": true, "ForwardAttachments": true, "Mailbox": true, "Message": true, "MessageAddress": true, "MessageEnvelope": true, "MessageItem": true, "NotFilter": true, "Page": true, "ParsedMessage": true, "Part": true, "Query": true, "RecipientSecurity": true, "Request": true, "SpecialUse": true, "SubmitMessage": true };
api.stringsTypes = { "AttachmentType": true, "Localpart": true, "SecurityResult": true, "ThreadMode": true };
api.stringsTypes = { "AttachmentType": true, "CSRFToken": true, "Localpart": true, "SecurityResult": true, "ThreadMode": true };
api.intsTypes = { "ModSeq": true, "UID": true, "Validation": true };
api.types = {
"Request": { "Name": "Request", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Cancel", "Docs": "", "Typewords": ["bool"] }, { "Name": "Query", "Docs": "", "Typewords": ["Query"] }, { "Name": "Page", "Docs": "", "Typewords": ["Page"] }] },
@ -313,6 +315,7 @@ var api;
"UID": { "Name": "UID", "Docs": "", "Values": null },
"ModSeq": { "Name": "ModSeq", "Docs": "", "Values": null },
"Validation": { "Name": "Validation", "Docs": "", "Values": [{ "Name": "ValidationUnknown", "Value": 0, "Docs": "" }, { "Name": "ValidationStrict", "Value": 1, "Docs": "" }, { "Name": "ValidationDMARC", "Value": 2, "Docs": "" }, { "Name": "ValidationRelaxed", "Value": 3, "Docs": "" }, { "Name": "ValidationPass", "Value": 4, "Docs": "" }, { "Name": "ValidationNeutral", "Value": 5, "Docs": "" }, { "Name": "ValidationTemperror", "Value": 6, "Docs": "" }, { "Name": "ValidationPermerror", "Value": 7, "Docs": "" }, { "Name": "ValidationFail", "Value": 8, "Docs": "" }, { "Name": "ValidationSoftfail", "Value": 9, "Docs": "" }, { "Name": "ValidationNone", "Value": 10, "Docs": "" }] },
"CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null },
"ThreadMode": { "Name": "ThreadMode", "Docs": "", "Values": [{ "Name": "ThreadOff", "Value": "off", "Docs": "" }, { "Name": "ThreadOn", "Value": "on", "Docs": "" }, { "Name": "ThreadUnread", "Value": "unread", "Docs": "" }] },
"AttachmentType": { "Name": "AttachmentType", "Docs": "", "Values": [{ "Name": "AttachmentIndifferent", "Value": "", "Docs": "" }, { "Name": "AttachmentNone", "Value": "none", "Docs": "" }, { "Name": "AttachmentAny", "Value": "any", "Docs": "" }, { "Name": "AttachmentImage", "Value": "image", "Docs": "" }, { "Name": "AttachmentPDF", "Value": "pdf", "Docs": "" }, { "Name": "AttachmentArchive", "Value": "archive", "Docs": "" }, { "Name": "AttachmentSpreadsheet", "Value": "spreadsheet", "Docs": "" }, { "Name": "AttachmentDocument", "Value": "document", "Docs": "" }, { "Name": "AttachmentPresentation", "Value": "presentation", "Docs": "" }] },
"SecurityResult": { "Name": "SecurityResult", "Docs": "", "Values": [{ "Name": "SecurityResultError", "Value": "error", "Docs": "" }, { "Name": "SecurityResultNo", "Value": "no", "Docs": "" }, { "Name": "SecurityResultYes", "Value": "yes", "Docs": "" }, { "Name": "SecurityResultUnknown", "Value": "unknown", "Docs": "" }] },
@ -360,6 +363,7 @@ var api;
UID: (v) => api.parse("UID", v),
ModSeq: (v) => api.parse("ModSeq", v),
Validation: (v) => api.parse("Validation", v),
CSRFToken: (v) => api.parse("CSRFToken", v),
ThreadMode: (v) => api.parse("ThreadMode", v),
AttachmentType: (v) => api.parse("AttachmentType", v),
SecurityResult: (v) => api.parse("SecurityResult", v),
@ -368,16 +372,50 @@ var api;
let defaultOptions = { slicesNullable: true, mapsNullable: true, nullableOptional: true };
class Client {
baseURL;
authState;
options;
constructor(baseURL = api.defaultBaseURL, options) {
this.baseURL = baseURL;
this.options = options;
if (!options) {
this.options = defaultOptions;
}
constructor() {
this.authState = {};
this.options = { ...defaultOptions };
this.baseURL = this.options.baseURL || api.defaultBaseURL;
}
withAuthToken(token) {
const c = new Client();
c.authState.token = token;
c.options = this.options;
return c;
}
withOptions(options) {
return new Client(this.baseURL, { ...this.options, ...options });
const c = new Client();
c.authState = this.authState;
c.options = { ...this.options, ...options };
return c;
}
// LoginPrep returns a login token, and also sets it as cookie. Both must be
// present in the call to Login.
async LoginPrep() {
const fn = "LoginPrep";
const paramTypes = [];
const returnTypes = [["string"]];
const params = [];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Login returns a session token for the credentials, or fails with error code
// "user:badLogin". Call LoginPrep to get a loginToken.
async Login(loginToken, username, password) {
const fn = "Login";
const paramTypes = [["string"], ["string"], ["string"]];
const returnTypes = [["CSRFToken"]];
const params = [loginToken, username, password];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Logout invalidates the session token.
async Logout() {
const fn = "Logout";
const paramTypes = [];
const returnTypes = [];
const params = [];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Token returns a token to use for an SSE connection. A token can only be used for
// a single SSE connection. Tokens are stored in memory for a maximum of 1 minute,
@ -387,7 +425,7 @@ var api;
const paramTypes = [];
const returnTypes = [["string"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Requests sends a new request for an open SSE connection. Any currently active
// request for the connection will be canceled, but this is done asynchrously, so
@ -399,7 +437,7 @@ var api;
const paramTypes = [["Request"]];
const returnTypes = [];
const params = [req];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// ParsedMessage returns enough to render the textual body of a message. It is
// assumed the client already has other fields through MessageItem.
@ -408,7 +446,7 @@ var api;
const paramTypes = [["int64"]];
const returnTypes = [["ParsedMessage"]];
const params = [msgID];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MessageSubmit sends a message by submitting it the outgoing email queue. The
// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
@ -421,7 +459,7 @@ var api;
const paramTypes = [["SubmitMessage"]];
const returnTypes = [];
const params = [m];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MessageMove moves messages to another mailbox. If the message is already in
// the mailbox an error is returned.
@ -430,7 +468,7 @@ var api;
const paramTypes = [["[]", "int64"], ["int64"]];
const returnTypes = [];
const params = [messageIDs, mailboxID];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
async MessageDelete(messageIDs) {
@ -438,7 +476,7 @@ var api;
const paramTypes = [["[]", "int64"]];
const returnTypes = [];
const params = [messageIDs];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
// flags should be lower-case, but will be converted and verified.
@ -447,7 +485,7 @@ var api;
const paramTypes = [["[]", "int64"], ["[]", "string"]];
const returnTypes = [];
const params = [messageIDs, flaglist];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// FlagsClear clears flags, either system flags like \Seen or custom keywords.
async FlagsClear(messageIDs, flaglist) {
@ -455,7 +493,7 @@ var api;
const paramTypes = [["[]", "int64"], ["[]", "string"]];
const returnTypes = [];
const params = [messageIDs, flaglist];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxCreate creates a new mailbox.
async MailboxCreate(name) {
@ -463,7 +501,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [];
const params = [name];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxDelete deletes a mailbox and all its messages.
async MailboxDelete(mailboxID) {
@ -471,7 +509,7 @@ var api;
const paramTypes = [["int64"]];
const returnTypes = [];
const params = [mailboxID];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
// its child mailboxes.
@ -480,7 +518,7 @@ var api;
const paramTypes = [["int64"]];
const returnTypes = [];
const params = [mailboxID];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
// ID and its messages are unchanged.
@ -489,7 +527,7 @@ var api;
const paramTypes = [["int64"], ["string"]];
const returnTypes = [];
const params = [mailboxID, newName];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// CompleteRecipient returns autocomplete matches for a recipient, returning the
// matches, most recently used first, and whether this is the full list and further
@ -499,7 +537,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["[]", "string"], ["bool"]];
const params = [search];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxSetSpecialUse sets the special use flags of a mailbox.
async MailboxSetSpecialUse(mb) {
@ -507,7 +545,7 @@ var api;
const paramTypes = [["Mailbox"]];
const returnTypes = [];
const params = [mb];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// ThreadCollapse saves the ThreadCollapse field for the messages and its
// children. The messageIDs are typically thread roots. But not all roots
@ -517,7 +555,7 @@ var api;
const paramTypes = [["[]", "int64"], ["bool"]];
const returnTypes = [];
const params = [messageIDs, collapse];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// ThreadMute saves the ThreadMute field for the messages and their children.
// If messages are muted, they are also marked collapsed.
@ -526,7 +564,7 @@ var api;
const paramTypes = [["[]", "int64"], ["bool"]];
const returnTypes = [];
const params = [messageIDs, mute];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// RecipientSecurity looks up security properties of the address in the
// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
@ -535,7 +573,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["RecipientSecurity"]];
const params = [messageAddressee];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
async SSETypes() {
@ -543,7 +581,7 @@ var api;
const paramTypes = [];
const returnTypes = [["EventStart"], ["EventViewErr"], ["EventViewReset"], ["EventViewMsgs"], ["EventViewChanges"], ["ChangeMsgAdd"], ["ChangeMsgRemove"], ["ChangeMsgFlags"], ["ChangeMsgThread"], ["ChangeMailboxRemove"], ["ChangeMailboxAdd"], ["ChangeMailboxRename"], ["ChangeMailboxCounts"], ["ChangeMailboxSpecialUse"], ["ChangeMailboxKeywords"], ["Flags"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
}
api.Client = Client;
@ -731,7 +769,7 @@ var api;
}
}
}
const _sherpaCall = async (baseURL, options, paramTypes, returnTypes, name, params) => {
const _sherpaCall = async (baseURL, authState, options, paramTypes, returnTypes, name, params) => {
if (!options.skipParamCheck) {
if (params.length !== paramTypes.length) {
return Promise.reject({ message: 'wrong number of parameters in sherpa call, saw ' + params.length + ' != expected ' + paramTypes.length });
@ -773,14 +811,36 @@ var api;
if (json) {
await simulate(json);
}
// Immediately create promise, so options.aborter is changed before returning.
const promise = new Promise((resolve, reject) => {
const fn = (resolve, reject) => {
let resolve1 = (v) => {
resolve(v);
resolve1 = () => { };
reject1 = () => { };
};
let reject1 = (v) => {
if ((v.code === 'user:noAuth' || v.code === 'user:badAuth') && options.login) {
const login = options.login;
if (!authState.loginPromise) {
authState.loginPromise = new Promise((aresolve, areject) => {
login(v.code === 'user:badAuth' ? (v.message || '') : '')
.then((token) => {
authState.token = token;
authState.loginPromise = undefined;
aresolve();
}, (err) => {
authState.loginPromise = undefined;
areject(err);
});
});
}
authState.loginPromise
.then(() => {
fn(resolve, reject);
}, (err) => {
reject(err);
});
return;
}
reject(v);
resolve1 = () => { };
reject1 = () => { };
@ -794,6 +854,9 @@ var api;
};
}
req.open('POST', url, true);
if (options.csrfHeader && authState.token) {
req.setRequestHeader(options.csrfHeader, authState.token);
}
if (options.timeoutMsec) {
req.timeout = options.timeoutMsec;
}
@ -867,8 +930,8 @@ var api;
catch (err) {
reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' });
}
});
return await promise;
};
return await new Promise(fn);
};
})(api || (api = {}));
// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten.

View file

@ -215,6 +215,8 @@ const [dom, style, attr, prop] = (function () {
name: (s) => _attr('name', s),
min: (s) => _attr('min', s),
max: (s) => _attr('max', s),
action: (s) => _attr('action', s),
method: (s) => _attr('method', s),
};
const style = (x) => { return { _styles: x }; };
const prop = (x) => { return { _props: x }; };
@ -269,7 +271,7 @@ var api;
SecurityResult["SecurityResultUnknown"] = "unknown";
})(SecurityResult = api.SecurityResult || (api.SecurityResult = {}));
api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "ChangeMsgThread": true, "Domain": true, "DomainAddressConfig": true, "Envelope": true, "EventStart": true, "EventViewChanges": true, "EventViewErr": true, "EventViewMsgs": true, "EventViewReset": true, "File": true, "Filter": true, "Flags": true, "ForwardAttachments": true, "Mailbox": true, "Message": true, "MessageAddress": true, "MessageEnvelope": true, "MessageItem": true, "NotFilter": true, "Page": true, "ParsedMessage": true, "Part": true, "Query": true, "RecipientSecurity": true, "Request": true, "SpecialUse": true, "SubmitMessage": true };
api.stringsTypes = { "AttachmentType": true, "Localpart": true, "SecurityResult": true, "ThreadMode": true };
api.stringsTypes = { "AttachmentType": true, "CSRFToken": true, "Localpart": true, "SecurityResult": true, "ThreadMode": true };
api.intsTypes = { "ModSeq": true, "UID": true, "Validation": true };
api.types = {
"Request": { "Name": "Request", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Cancel", "Docs": "", "Typewords": ["bool"] }, { "Name": "Query", "Docs": "", "Typewords": ["Query"] }, { "Name": "Page", "Docs": "", "Typewords": ["Page"] }] },
@ -313,6 +315,7 @@ var api;
"UID": { "Name": "UID", "Docs": "", "Values": null },
"ModSeq": { "Name": "ModSeq", "Docs": "", "Values": null },
"Validation": { "Name": "Validation", "Docs": "", "Values": [{ "Name": "ValidationUnknown", "Value": 0, "Docs": "" }, { "Name": "ValidationStrict", "Value": 1, "Docs": "" }, { "Name": "ValidationDMARC", "Value": 2, "Docs": "" }, { "Name": "ValidationRelaxed", "Value": 3, "Docs": "" }, { "Name": "ValidationPass", "Value": 4, "Docs": "" }, { "Name": "ValidationNeutral", "Value": 5, "Docs": "" }, { "Name": "ValidationTemperror", "Value": 6, "Docs": "" }, { "Name": "ValidationPermerror", "Value": 7, "Docs": "" }, { "Name": "ValidationFail", "Value": 8, "Docs": "" }, { "Name": "ValidationSoftfail", "Value": 9, "Docs": "" }, { "Name": "ValidationNone", "Value": 10, "Docs": "" }] },
"CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null },
"ThreadMode": { "Name": "ThreadMode", "Docs": "", "Values": [{ "Name": "ThreadOff", "Value": "off", "Docs": "" }, { "Name": "ThreadOn", "Value": "on", "Docs": "" }, { "Name": "ThreadUnread", "Value": "unread", "Docs": "" }] },
"AttachmentType": { "Name": "AttachmentType", "Docs": "", "Values": [{ "Name": "AttachmentIndifferent", "Value": "", "Docs": "" }, { "Name": "AttachmentNone", "Value": "none", "Docs": "" }, { "Name": "AttachmentAny", "Value": "any", "Docs": "" }, { "Name": "AttachmentImage", "Value": "image", "Docs": "" }, { "Name": "AttachmentPDF", "Value": "pdf", "Docs": "" }, { "Name": "AttachmentArchive", "Value": "archive", "Docs": "" }, { "Name": "AttachmentSpreadsheet", "Value": "spreadsheet", "Docs": "" }, { "Name": "AttachmentDocument", "Value": "document", "Docs": "" }, { "Name": "AttachmentPresentation", "Value": "presentation", "Docs": "" }] },
"SecurityResult": { "Name": "SecurityResult", "Docs": "", "Values": [{ "Name": "SecurityResultError", "Value": "error", "Docs": "" }, { "Name": "SecurityResultNo", "Value": "no", "Docs": "" }, { "Name": "SecurityResultYes", "Value": "yes", "Docs": "" }, { "Name": "SecurityResultUnknown", "Value": "unknown", "Docs": "" }] },
@ -360,6 +363,7 @@ var api;
UID: (v) => api.parse("UID", v),
ModSeq: (v) => api.parse("ModSeq", v),
Validation: (v) => api.parse("Validation", v),
CSRFToken: (v) => api.parse("CSRFToken", v),
ThreadMode: (v) => api.parse("ThreadMode", v),
AttachmentType: (v) => api.parse("AttachmentType", v),
SecurityResult: (v) => api.parse("SecurityResult", v),
@ -368,16 +372,50 @@ var api;
let defaultOptions = { slicesNullable: true, mapsNullable: true, nullableOptional: true };
class Client {
baseURL;
authState;
options;
constructor(baseURL = api.defaultBaseURL, options) {
this.baseURL = baseURL;
this.options = options;
if (!options) {
this.options = defaultOptions;
}
constructor() {
this.authState = {};
this.options = { ...defaultOptions };
this.baseURL = this.options.baseURL || api.defaultBaseURL;
}
withAuthToken(token) {
const c = new Client();
c.authState.token = token;
c.options = this.options;
return c;
}
withOptions(options) {
return new Client(this.baseURL, { ...this.options, ...options });
const c = new Client();
c.authState = this.authState;
c.options = { ...this.options, ...options };
return c;
}
// LoginPrep returns a login token, and also sets it as cookie. Both must be
// present in the call to Login.
async LoginPrep() {
const fn = "LoginPrep";
const paramTypes = [];
const returnTypes = [["string"]];
const params = [];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Login returns a session token for the credentials, or fails with error code
// "user:badLogin". Call LoginPrep to get a loginToken.
async Login(loginToken, username, password) {
const fn = "Login";
const paramTypes = [["string"], ["string"], ["string"]];
const returnTypes = [["CSRFToken"]];
const params = [loginToken, username, password];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Logout invalidates the session token.
async Logout() {
const fn = "Logout";
const paramTypes = [];
const returnTypes = [];
const params = [];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Token returns a token to use for an SSE connection. A token can only be used for
// a single SSE connection. Tokens are stored in memory for a maximum of 1 minute,
@ -387,7 +425,7 @@ var api;
const paramTypes = [];
const returnTypes = [["string"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Requests sends a new request for an open SSE connection. Any currently active
// request for the connection will be canceled, but this is done asynchrously, so
@ -399,7 +437,7 @@ var api;
const paramTypes = [["Request"]];
const returnTypes = [];
const params = [req];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// ParsedMessage returns enough to render the textual body of a message. It is
// assumed the client already has other fields through MessageItem.
@ -408,7 +446,7 @@ var api;
const paramTypes = [["int64"]];
const returnTypes = [["ParsedMessage"]];
const params = [msgID];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MessageSubmit sends a message by submitting it the outgoing email queue. The
// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
@ -421,7 +459,7 @@ var api;
const paramTypes = [["SubmitMessage"]];
const returnTypes = [];
const params = [m];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MessageMove moves messages to another mailbox. If the message is already in
// the mailbox an error is returned.
@ -430,7 +468,7 @@ var api;
const paramTypes = [["[]", "int64"], ["int64"]];
const returnTypes = [];
const params = [messageIDs, mailboxID];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
async MessageDelete(messageIDs) {
@ -438,7 +476,7 @@ var api;
const paramTypes = [["[]", "int64"]];
const returnTypes = [];
const params = [messageIDs];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
// flags should be lower-case, but will be converted and verified.
@ -447,7 +485,7 @@ var api;
const paramTypes = [["[]", "int64"], ["[]", "string"]];
const returnTypes = [];
const params = [messageIDs, flaglist];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// FlagsClear clears flags, either system flags like \Seen or custom keywords.
async FlagsClear(messageIDs, flaglist) {
@ -455,7 +493,7 @@ var api;
const paramTypes = [["[]", "int64"], ["[]", "string"]];
const returnTypes = [];
const params = [messageIDs, flaglist];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxCreate creates a new mailbox.
async MailboxCreate(name) {
@ -463,7 +501,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [];
const params = [name];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxDelete deletes a mailbox and all its messages.
async MailboxDelete(mailboxID) {
@ -471,7 +509,7 @@ var api;
const paramTypes = [["int64"]];
const returnTypes = [];
const params = [mailboxID];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
// its child mailboxes.
@ -480,7 +518,7 @@ var api;
const paramTypes = [["int64"]];
const returnTypes = [];
const params = [mailboxID];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
// ID and its messages are unchanged.
@ -489,7 +527,7 @@ var api;
const paramTypes = [["int64"], ["string"]];
const returnTypes = [];
const params = [mailboxID, newName];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// CompleteRecipient returns autocomplete matches for a recipient, returning the
// matches, most recently used first, and whether this is the full list and further
@ -499,7 +537,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["[]", "string"], ["bool"]];
const params = [search];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxSetSpecialUse sets the special use flags of a mailbox.
async MailboxSetSpecialUse(mb) {
@ -507,7 +545,7 @@ var api;
const paramTypes = [["Mailbox"]];
const returnTypes = [];
const params = [mb];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// ThreadCollapse saves the ThreadCollapse field for the messages and its
// children. The messageIDs are typically thread roots. But not all roots
@ -517,7 +555,7 @@ var api;
const paramTypes = [["[]", "int64"], ["bool"]];
const returnTypes = [];
const params = [messageIDs, collapse];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// ThreadMute saves the ThreadMute field for the messages and their children.
// If messages are muted, they are also marked collapsed.
@ -526,7 +564,7 @@ var api;
const paramTypes = [["[]", "int64"], ["bool"]];
const returnTypes = [];
const params = [messageIDs, mute];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// RecipientSecurity looks up security properties of the address in the
// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
@ -535,7 +573,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["RecipientSecurity"]];
const params = [messageAddressee];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
async SSETypes() {
@ -543,7 +581,7 @@ var api;
const paramTypes = [];
const returnTypes = [["EventStart"], ["EventViewErr"], ["EventViewReset"], ["EventViewMsgs"], ["EventViewChanges"], ["ChangeMsgAdd"], ["ChangeMsgRemove"], ["ChangeMsgFlags"], ["ChangeMsgThread"], ["ChangeMailboxRemove"], ["ChangeMailboxAdd"], ["ChangeMailboxRename"], ["ChangeMailboxCounts"], ["ChangeMailboxSpecialUse"], ["ChangeMailboxKeywords"], ["Flags"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
}
api.Client = Client;
@ -731,7 +769,7 @@ var api;
}
}
}
const _sherpaCall = async (baseURL, options, paramTypes, returnTypes, name, params) => {
const _sherpaCall = async (baseURL, authState, options, paramTypes, returnTypes, name, params) => {
if (!options.skipParamCheck) {
if (params.length !== paramTypes.length) {
return Promise.reject({ message: 'wrong number of parameters in sherpa call, saw ' + params.length + ' != expected ' + paramTypes.length });
@ -773,14 +811,36 @@ var api;
if (json) {
await simulate(json);
}
// Immediately create promise, so options.aborter is changed before returning.
const promise = new Promise((resolve, reject) => {
const fn = (resolve, reject) => {
let resolve1 = (v) => {
resolve(v);
resolve1 = () => { };
reject1 = () => { };
};
let reject1 = (v) => {
if ((v.code === 'user:noAuth' || v.code === 'user:badAuth') && options.login) {
const login = options.login;
if (!authState.loginPromise) {
authState.loginPromise = new Promise((aresolve, areject) => {
login(v.code === 'user:badAuth' ? (v.message || '') : '')
.then((token) => {
authState.token = token;
authState.loginPromise = undefined;
aresolve();
}, (err) => {
authState.loginPromise = undefined;
areject(err);
});
});
}
authState.loginPromise
.then(() => {
fn(resolve, reject);
}, (err) => {
reject(err);
});
return;
}
reject(v);
resolve1 = () => { };
reject1 = () => { };
@ -794,6 +854,9 @@ var api;
};
}
req.open('POST', url, true);
if (options.csrfHeader && authState.token) {
req.setRequestHeader(options.csrfHeader, authState.token);
}
if (options.timeoutMsec) {
req.timeout = options.timeoutMsec;
}
@ -867,8 +930,8 @@ var api;
catch (err) {
reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' });
}
});
return await promise;
};
return await new Promise(fn);
};
})(api || (api = {}));
// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten.

View file

@ -413,10 +413,11 @@ func sseGet(id int64, accountName string) (sse, bool) {
// ssetoken is a temporary token that has not yet been used to start an SSE
// connection. Created by Token, consumed by a new SSE connection.
type ssetoken struct {
token string // Uniquely generated.
accName string
address string // Address used to authenticate in call that created the token.
validUntil time.Time
token string // Uniquely generated.
accName string
address string // Address used to authenticate in call that created the token.
sessionToken store.SessionToken // SessionToken that created this token, checked before sending updates.
validUntil time.Time
}
// ssetokens maintains unused tokens. We have just one, but it's a type so we
@ -434,11 +435,11 @@ var sseTokens = ssetokens{
// xgenerate creates and saves a new token. It ensures no more than 10 tokens
// per account exist, removing old ones if needed.
func (x *ssetokens) xgenerate(ctx context.Context, accName, address string) string {
func (x *ssetokens) xgenerate(ctx context.Context, accName, address string, sessionToken store.SessionToken) string {
buf := make([]byte, 16)
_, err := cryptrand.Read(buf)
xcheckf(ctx, err, "generating token")
st := ssetoken{base64.RawURLEncoding.EncodeToString(buf), accName, address, time.Now().Add(time.Minute)}
st := ssetoken{base64.RawURLEncoding.EncodeToString(buf), accName, address, sessionToken, time.Now().Add(time.Minute)}
x.Lock()
defer x.Unlock()
@ -456,17 +457,17 @@ func (x *ssetokens) xgenerate(ctx context.Context, accName, address string) stri
}
// check verifies a token, and consumes it if valid.
func (x *ssetokens) check(token string) (string, string, bool, error) {
func (x *ssetokens) check(token string) (string, string, store.SessionToken, bool, error) {
x.Lock()
defer x.Unlock()
st, ok := x.tokens[token]
if !ok {
return "", "", false, nil
return "", "", "", false, nil
}
delete(x.tokens, token)
if i := slices.Index(x.accountTokens[st.accName], st); i < 0 {
return "", "", false, errors.New("internal error, could not find token in account")
return "", "", "", false, errors.New("internal error, could not find token in account")
} else {
copy(x.accountTokens[st.accName][i:], x.accountTokens[st.accName][i+1:])
x.accountTokens[st.accName] = x.accountTokens[st.accName][:len(x.accountTokens[st.accName])-1]
@ -475,9 +476,9 @@ func (x *ssetokens) check(token string) (string, string, bool, error) {
}
}
if time.Now().After(st.validUntil) {
return "", "", false, nil
return "", "", "", false, nil
}
return st.accName, st.address, true, nil
return st.accName, st.address, st.sessionToken, true, nil
}
// ioErr is panicked on i/o errors in serveEvents and handled in a defer.
@ -506,7 +507,7 @@ func serveEvents(ctx context.Context, log mlog.Log, w http.ResponseWriter, r *ht
http.Error(w, "400 - bad request - missing credentials", http.StatusBadRequest)
return
}
accName, address, ok, err := sseTokens.check(token)
accName, address, sessionToken, ok, err := sseTokens.check(token)
if err != nil {
http.Error(w, "500 - internal server error - "+err.Error(), http.StatusInternalServerError)
return
@ -515,6 +516,10 @@ func serveEvents(ctx context.Context, log mlog.Log, w http.ResponseWriter, r *ht
http.Error(w, "400 - bad request - bad token", http.StatusBadRequest)
return
}
if _, err := store.SessionUse(ctx, log, accName, sessionToken, ""); err != nil {
http.Error(w, "400 - bad request - bad session token", http.StatusBadRequest)
return
}
// We can simulate a slow SSE connection. It seems firefox doesn't slow down
// incoming responses with its slow-network similation.
@ -594,7 +599,7 @@ func serveEvents(ctx context.Context, log mlog.Log, w http.ResponseWriter, r *ht
out = httpFlusher{out, flusher}
// We'll be writing outgoing SSE events through writer.
writer = newEventWriter(out, waitMin, waitMax)
writer = newEventWriter(out, waitMin, waitMax, accName, sessionToken)
defer writer.close()
// Fetch initial data.

View file

@ -14,6 +14,7 @@ import (
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
@ -23,6 +24,7 @@ import (
)
func TestView(t *testing.T) {
mox.LimitersInit()
os.RemoveAll("../testdata/webmail/data")
mox.Context = ctxbg
mox.ConfigStaticPath = filepath.FromSlash("../testdata/webmail/mox.conf")
@ -39,10 +41,37 @@ func TestView(t *testing.T) {
pkglog.Check(err, "closing account")
}()
api := Webmail{maxMessageSize: 1024 * 1024}
reqInfo := requestInfo{"mjl@mox.example", "mjl", &http.Request{}}
api := Webmail{maxMessageSize: 1024 * 1024, cookiePath: "/"}
respRec := httptest.NewRecorder()
reqInfo := requestInfo{"mjl@mox.example", "mjl", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
// Prepare loginToken.
loginCookie := &http.Cookie{Name: "webmaillogin"}
loginCookie.Value = api.LoginPrep(ctx)
reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
api.Login(ctx, loginCookie.Value, "mjl@mox.example", "test1234")
var sessionCookie *http.Cookie
for _, c := range respRec.Result().Cookies() {
if c.Name == "webmailsession" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatalf("missing session cookie")
}
sct := strings.SplitN(sessionCookie.Value, " ", 2)
if len(sct) != 2 || sct[1] != "mjl" {
t.Fatalf("unexpected accountname %q in session cookie", sct[1])
}
sessionToken := store.SessionToken(sct[0])
reqInfo = requestInfo{"mjl@mox.example", "mjl", sessionToken, respRec, &http.Request{}}
ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
api.MailboxCreate(ctx, "Lists/Go/Nuts")
var zerom store.Message
@ -74,7 +103,7 @@ func TestView(t *testing.T) {
// We start an actual HTTP server to easily get a body we can do blocking reads on.
// With a httptest.ResponseRecorder, it's a bit more work to parse SSE events as
// they come in.
server := httptest.NewServer(http.HandlerFunc(Handler(1024 * 1024)))
server := httptest.NewServer(http.HandlerFunc(Handler(1024*1024, "/webmail/", false)))
defer server.Close()
serverURL, err := url.Parse(server.URL)
@ -113,7 +142,7 @@ func TestView(t *testing.T) {
tcheck(t, err, "http transaction")
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("got statuscode %d, expected %d", resp.StatusCode, http.StatusOK)
t.Fatalf("got statuscode %d, expected %d (%s)", resp.StatusCode, http.StatusOK, readBody(resp.Body))
}
evr := eventReader{t, bufio.NewReader(resp.Body), resp.Body}

View file

@ -38,25 +38,23 @@ import (
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxio"
"github.com/mjl-/mox/store"
"github.com/mjl-/mox/webaccount"
"github.com/mjl-/mox/webauth"
)
func init() {
mox.LimitersInit()
}
var pkglog = mlog.New("webmail", nil)
type ctxKey string
// We pass the request to the sherpa handler so the TLS info can be used for
// the Received header in submitted messages. Most API calls need just the
// account name.
type ctxKey string
var requestInfoCtxKey ctxKey = "requestInfo"
type requestInfo struct {
LoginAddress string
AccountName string
SessionToken store.SessionToken
Response http.ResponseWriter
Request *http.Request // For Proto and TLS connection state during message submit.
}
@ -171,19 +169,19 @@ func serveContentFallback(log mlog.Log, w http.ResponseWriter, r *http.Request,
}
// Handler returns a handler for the webmail endpoints, customized for the max
// message size coming from the listener.
func Handler(maxMessageSize int64) func(w http.ResponseWriter, r *http.Request) {
sh, err := makeSherpaHandler(maxMessageSize)
// message size coming from the listener and cookiePath.
func Handler(maxMessageSize int64, cookiePath string, isForwarded bool) func(w http.ResponseWriter, r *http.Request) {
sh, err := makeSherpaHandler(maxMessageSize, cookiePath, isForwarded)
return func(w http.ResponseWriter, r *http.Request) {
if err != nil {
http.Error(w, "500 - internal server error - cannot handle requests", http.StatusInternalServerError)
return
}
handle(sh, w, r)
handle(sh, isForwarded, w, r)
}
}
func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
func handle(apiHandler http.Handler, isForwarded bool, w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
log := pkglog.WithContext(ctx).With(slog.String("userauth", ""))
@ -195,17 +193,6 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
return
}
// HTTP Basic authentication for all requests.
loginAddress, accName := webaccount.CheckAuth(ctx, log, "webmail", w, r)
if accName == "" {
// Error response already sent.
return
}
if lw, ok := w.(interface{ AddAttr(a slog.Attr) }); ok {
lw.AddAttr(slog.String("authaccount", accName))
}
defer func() {
x := recover()
if x == nil {
@ -230,13 +217,14 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
switch r.Method {
case "GET", "HEAD":
h := w.Header()
h.Set("X-Frame-Options", "deny")
h.Set("Referrer-Policy", "same-origin")
webmailFile.Serve(ctx, log, w, r)
default:
http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
return
case "GET", "HEAD":
}
webmailFile.Serve(ctx, log, w, r)
return
case "/msg.js", "/text.js":
@ -258,9 +246,27 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
return
}
// API calls.
if strings.HasPrefix(r.URL.Path, "/api/") {
reqInfo := requestInfo{loginAddress, accName, r}
isAPI := strings.HasPrefix(r.URL.Path, "/api/")
// Only allow POST for calls, they will not work cross-domain without CORS.
if isAPI && r.URL.Path != "/api/" && r.Method != "POST" {
http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
return
}
var loginAddress, accName string
var sessionToken store.SessionToken
// All other URLs, except the login endpoint require some authentication.
if r.URL.Path != "/api/LoginPrep" && r.URL.Path != "/api/Login" {
var ok bool
accName, sessionToken, loginAddress, ok = webauth.Check(ctx, log, webauth.Accounts, "webmail", isForwarded, w, r, isAPI, isAPI, false)
if !ok {
// Response has been written already.
return
}
}
if isAPI {
reqInfo := requestInfo{loginAddress, accName, sessionToken, w, r}
ctx = context.WithValue(ctx, requestInfoCtxKey, reqInfo)
apiHandler.ServeHTTP(w, r.WithContext(ctx))
return
@ -412,7 +418,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
headers(false, false, false)
h.Set("Content-Type", "application/zip")
h.Set("Cache-Control", "no-cache, max-age=0")
h.Set("Cache-Control", "no-store, max-age=0")
var subjectSlug string
if p.Envelope != nil {
s := p.Envelope.Subject
@ -537,7 +543,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
params["charset"] = charset
}
h.Set("Content-Type", mime.FormatMediaType(ct, params))
h.Set("Cache-Control", "no-cache, max-age=0")
h.Set("Cache-Control", "no-store, max-age=0")
_, err := io.Copy(w, &moxio.AtReader{R: msgr})
log.Check(err, "writing raw")
@ -567,7 +573,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
allowSelfScript := true
headers(sameorigin, loadExternal, allowSelfScript)
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("Cache-Control", "no-cache, max-age=0")
h.Set("Cache-Control", "no-store, max-age=0")
path := filepath.FromSlash("webmail/msg.html")
fallback := webmailmsgHTML
@ -600,7 +606,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
headers(false, false, false)
h.Set("Content-Type", "application/javascript; charset=utf-8")
h.Set("Cache-Control", "no-cache, max-age=0")
h.Set("Cache-Control", "no-store, max-age=0")
_, err = fmt.Fprintf(w, "window.messageItem = %s;\nwindow.parsedMessage = %s;\n", mijson, pmjson)
log.Check(err, "writing parsedmessage.js")
@ -632,7 +638,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
allowSelfScript := true
headers(sameorigin, false, allowSelfScript)
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("Cache-Control", "no-cache, max-age=0")
h.Set("Cache-Control", "no-store, max-age=0")
// We typically return the embedded file, but during development it's handy to load
// from disk.
@ -659,7 +665,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
headers(sameorigin, allowExternal, false)
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("Cache-Control", "no-cache, max-age=0")
h.Set("Cache-Control", "no-store, max-age=0")
}
// todo: skip certain html parts? e.g. with content-disposition: attachment?
@ -726,7 +732,7 @@ func handle(apiHandler http.Handler, w http.ResponseWriter, r *http.Request) {
ct = strings.ToLower(ap.MediaType + "/" + ap.MediaSubType)
}
h.Set("Content-Type", ct)
h.Set("Cache-Control", "no-cache, max-age=0")
h.Set("Cache-Control", "no-store, max-age=0")
if t[1] == "download" {
name := tryDecodeParam(log, ap.ContentTypeParams["name"])
if name == "" {

View file

@ -15,6 +15,7 @@ table td, table th { padding: .15em .25em; }
.silenttitle { text-decoration: none; }
fieldset { border: 0; }
.loading { opacity: 0.1; animation: fadeout 1s ease-out; }
@keyframes fadein { 0% { opacity: 0 } 100% { opacity: 1 } }
@keyframes fadeout { 0% { opacity: 1 } 100% { opacity: 0.1 } }
.button { display: inline-block; }
button, .button { border-radius: .15em; background-color: #eee; border: 1px solid #888; padding: 0 .15em; color: inherit; /* for ipad, which shows blue or white text */ }
@ -78,7 +79,7 @@ table.search td { padding: .25em; }
</style>
</head>
<body>
<div id="page"><div style="padding: 1em">Loading...</div></div>
<div id="page"><div style="padding: 1em; text-align: center">Loading...</div></div>
<script>/* placeholder */</script>
</body>
</html>

View file

@ -215,6 +215,8 @@ const [dom, style, attr, prop] = (function () {
name: (s) => _attr('name', s),
min: (s) => _attr('min', s),
max: (s) => _attr('max', s),
action: (s) => _attr('action', s),
method: (s) => _attr('method', s),
};
const style = (x) => { return { _styles: x }; };
const prop = (x) => { return { _props: x }; };
@ -269,7 +271,7 @@ var api;
SecurityResult["SecurityResultUnknown"] = "unknown";
})(SecurityResult = api.SecurityResult || (api.SecurityResult = {}));
api.structTypes = { "Address": true, "Attachment": true, "ChangeMailboxAdd": true, "ChangeMailboxCounts": true, "ChangeMailboxKeywords": true, "ChangeMailboxRemove": true, "ChangeMailboxRename": true, "ChangeMailboxSpecialUse": true, "ChangeMsgAdd": true, "ChangeMsgFlags": true, "ChangeMsgRemove": true, "ChangeMsgThread": true, "Domain": true, "DomainAddressConfig": true, "Envelope": true, "EventStart": true, "EventViewChanges": true, "EventViewErr": true, "EventViewMsgs": true, "EventViewReset": true, "File": true, "Filter": true, "Flags": true, "ForwardAttachments": true, "Mailbox": true, "Message": true, "MessageAddress": true, "MessageEnvelope": true, "MessageItem": true, "NotFilter": true, "Page": true, "ParsedMessage": true, "Part": true, "Query": true, "RecipientSecurity": true, "Request": true, "SpecialUse": true, "SubmitMessage": true };
api.stringsTypes = { "AttachmentType": true, "Localpart": true, "SecurityResult": true, "ThreadMode": true };
api.stringsTypes = { "AttachmentType": true, "CSRFToken": true, "Localpart": true, "SecurityResult": true, "ThreadMode": true };
api.intsTypes = { "ModSeq": true, "UID": true, "Validation": true };
api.types = {
"Request": { "Name": "Request", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Cancel", "Docs": "", "Typewords": ["bool"] }, { "Name": "Query", "Docs": "", "Typewords": ["Query"] }, { "Name": "Page", "Docs": "", "Typewords": ["Page"] }] },
@ -313,6 +315,7 @@ var api;
"UID": { "Name": "UID", "Docs": "", "Values": null },
"ModSeq": { "Name": "ModSeq", "Docs": "", "Values": null },
"Validation": { "Name": "Validation", "Docs": "", "Values": [{ "Name": "ValidationUnknown", "Value": 0, "Docs": "" }, { "Name": "ValidationStrict", "Value": 1, "Docs": "" }, { "Name": "ValidationDMARC", "Value": 2, "Docs": "" }, { "Name": "ValidationRelaxed", "Value": 3, "Docs": "" }, { "Name": "ValidationPass", "Value": 4, "Docs": "" }, { "Name": "ValidationNeutral", "Value": 5, "Docs": "" }, { "Name": "ValidationTemperror", "Value": 6, "Docs": "" }, { "Name": "ValidationPermerror", "Value": 7, "Docs": "" }, { "Name": "ValidationFail", "Value": 8, "Docs": "" }, { "Name": "ValidationSoftfail", "Value": 9, "Docs": "" }, { "Name": "ValidationNone", "Value": 10, "Docs": "" }] },
"CSRFToken": { "Name": "CSRFToken", "Docs": "", "Values": null },
"ThreadMode": { "Name": "ThreadMode", "Docs": "", "Values": [{ "Name": "ThreadOff", "Value": "off", "Docs": "" }, { "Name": "ThreadOn", "Value": "on", "Docs": "" }, { "Name": "ThreadUnread", "Value": "unread", "Docs": "" }] },
"AttachmentType": { "Name": "AttachmentType", "Docs": "", "Values": [{ "Name": "AttachmentIndifferent", "Value": "", "Docs": "" }, { "Name": "AttachmentNone", "Value": "none", "Docs": "" }, { "Name": "AttachmentAny", "Value": "any", "Docs": "" }, { "Name": "AttachmentImage", "Value": "image", "Docs": "" }, { "Name": "AttachmentPDF", "Value": "pdf", "Docs": "" }, { "Name": "AttachmentArchive", "Value": "archive", "Docs": "" }, { "Name": "AttachmentSpreadsheet", "Value": "spreadsheet", "Docs": "" }, { "Name": "AttachmentDocument", "Value": "document", "Docs": "" }, { "Name": "AttachmentPresentation", "Value": "presentation", "Docs": "" }] },
"SecurityResult": { "Name": "SecurityResult", "Docs": "", "Values": [{ "Name": "SecurityResultError", "Value": "error", "Docs": "" }, { "Name": "SecurityResultNo", "Value": "no", "Docs": "" }, { "Name": "SecurityResultYes", "Value": "yes", "Docs": "" }, { "Name": "SecurityResultUnknown", "Value": "unknown", "Docs": "" }] },
@ -360,6 +363,7 @@ var api;
UID: (v) => api.parse("UID", v),
ModSeq: (v) => api.parse("ModSeq", v),
Validation: (v) => api.parse("Validation", v),
CSRFToken: (v) => api.parse("CSRFToken", v),
ThreadMode: (v) => api.parse("ThreadMode", v),
AttachmentType: (v) => api.parse("AttachmentType", v),
SecurityResult: (v) => api.parse("SecurityResult", v),
@ -368,16 +372,50 @@ var api;
let defaultOptions = { slicesNullable: true, mapsNullable: true, nullableOptional: true };
class Client {
baseURL;
authState;
options;
constructor(baseURL = api.defaultBaseURL, options) {
this.baseURL = baseURL;
this.options = options;
if (!options) {
this.options = defaultOptions;
}
constructor() {
this.authState = {};
this.options = { ...defaultOptions };
this.baseURL = this.options.baseURL || api.defaultBaseURL;
}
withAuthToken(token) {
const c = new Client();
c.authState.token = token;
c.options = this.options;
return c;
}
withOptions(options) {
return new Client(this.baseURL, { ...this.options, ...options });
const c = new Client();
c.authState = this.authState;
c.options = { ...this.options, ...options };
return c;
}
// LoginPrep returns a login token, and also sets it as cookie. Both must be
// present in the call to Login.
async LoginPrep() {
const fn = "LoginPrep";
const paramTypes = [];
const returnTypes = [["string"]];
const params = [];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Login returns a session token for the credentials, or fails with error code
// "user:badLogin". Call LoginPrep to get a loginToken.
async Login(loginToken, username, password) {
const fn = "Login";
const paramTypes = [["string"], ["string"], ["string"]];
const returnTypes = [["CSRFToken"]];
const params = [loginToken, username, password];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Logout invalidates the session token.
async Logout() {
const fn = "Logout";
const paramTypes = [];
const returnTypes = [];
const params = [];
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Token returns a token to use for an SSE connection. A token can only be used for
// a single SSE connection. Tokens are stored in memory for a maximum of 1 minute,
@ -387,7 +425,7 @@ var api;
const paramTypes = [];
const returnTypes = [["string"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// Requests sends a new request for an open SSE connection. Any currently active
// request for the connection will be canceled, but this is done asynchrously, so
@ -399,7 +437,7 @@ var api;
const paramTypes = [["Request"]];
const returnTypes = [];
const params = [req];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// ParsedMessage returns enough to render the textual body of a message. It is
// assumed the client already has other fields through MessageItem.
@ -408,7 +446,7 @@ var api;
const paramTypes = [["int64"]];
const returnTypes = [["ParsedMessage"]];
const params = [msgID];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MessageSubmit sends a message by submitting it the outgoing email queue. The
// message is sent to all addresses listed in the To, Cc and Bcc addresses, without
@ -421,7 +459,7 @@ var api;
const paramTypes = [["SubmitMessage"]];
const returnTypes = [];
const params = [m];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MessageMove moves messages to another mailbox. If the message is already in
// the mailbox an error is returned.
@ -430,7 +468,7 @@ var api;
const paramTypes = [["[]", "int64"], ["int64"]];
const returnTypes = [];
const params = [messageIDs, mailboxID];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MessageDelete permanently deletes messages, without moving them to the Trash mailbox.
async MessageDelete(messageIDs) {
@ -438,7 +476,7 @@ var api;
const paramTypes = [["[]", "int64"]];
const returnTypes = [];
const params = [messageIDs];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// FlagsAdd adds flags, either system flags like \Seen or custom keywords. The
// flags should be lower-case, but will be converted and verified.
@ -447,7 +485,7 @@ var api;
const paramTypes = [["[]", "int64"], ["[]", "string"]];
const returnTypes = [];
const params = [messageIDs, flaglist];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// FlagsClear clears flags, either system flags like \Seen or custom keywords.
async FlagsClear(messageIDs, flaglist) {
@ -455,7 +493,7 @@ var api;
const paramTypes = [["[]", "int64"], ["[]", "string"]];
const returnTypes = [];
const params = [messageIDs, flaglist];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxCreate creates a new mailbox.
async MailboxCreate(name) {
@ -463,7 +501,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [];
const params = [name];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxDelete deletes a mailbox and all its messages.
async MailboxDelete(mailboxID) {
@ -471,7 +509,7 @@ var api;
const paramTypes = [["int64"]];
const returnTypes = [];
const params = [mailboxID];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxEmpty empties a mailbox, removing all messages from the mailbox, but not
// its child mailboxes.
@ -480,7 +518,7 @@ var api;
const paramTypes = [["int64"]];
const returnTypes = [];
const params = [mailboxID];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxRename renames a mailbox, possibly moving it to a new parent. The mailbox
// ID and its messages are unchanged.
@ -489,7 +527,7 @@ var api;
const paramTypes = [["int64"], ["string"]];
const returnTypes = [];
const params = [mailboxID, newName];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// CompleteRecipient returns autocomplete matches for a recipient, returning the
// matches, most recently used first, and whether this is the full list and further
@ -499,7 +537,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["[]", "string"], ["bool"]];
const params = [search];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// MailboxSetSpecialUse sets the special use flags of a mailbox.
async MailboxSetSpecialUse(mb) {
@ -507,7 +545,7 @@ var api;
const paramTypes = [["Mailbox"]];
const returnTypes = [];
const params = [mb];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// ThreadCollapse saves the ThreadCollapse field for the messages and its
// children. The messageIDs are typically thread roots. But not all roots
@ -517,7 +555,7 @@ var api;
const paramTypes = [["[]", "int64"], ["bool"]];
const returnTypes = [];
const params = [messageIDs, collapse];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// ThreadMute saves the ThreadMute field for the messages and their children.
// If messages are muted, they are also marked collapsed.
@ -526,7 +564,7 @@ var api;
const paramTypes = [["[]", "int64"], ["bool"]];
const returnTypes = [];
const params = [messageIDs, mute];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// RecipientSecurity looks up security properties of the address in the
// single-address message addressee (as it appears in a To/Cc/Bcc/etc header).
@ -535,7 +573,7 @@ var api;
const paramTypes = [["string"]];
const returnTypes = [["RecipientSecurity"]];
const params = [messageAddressee];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
// SSETypes exists to ensure the generated API contains the types, for use in SSE events.
async SSETypes() {
@ -543,7 +581,7 @@ var api;
const paramTypes = [];
const returnTypes = [["EventStart"], ["EventViewErr"], ["EventViewReset"], ["EventViewMsgs"], ["EventViewChanges"], ["ChangeMsgAdd"], ["ChangeMsgRemove"], ["ChangeMsgFlags"], ["ChangeMsgThread"], ["ChangeMailboxRemove"], ["ChangeMailboxAdd"], ["ChangeMailboxRename"], ["ChangeMailboxCounts"], ["ChangeMailboxSpecialUse"], ["ChangeMailboxKeywords"], ["Flags"]];
const params = [];
return await _sherpaCall(this.baseURL, { ...this.options }, paramTypes, returnTypes, fn, params);
return await _sherpaCall(this.baseURL, this.authState, { ...this.options }, paramTypes, returnTypes, fn, params);
}
}
api.Client = Client;
@ -731,7 +769,7 @@ var api;
}
}
}
const _sherpaCall = async (baseURL, options, paramTypes, returnTypes, name, params) => {
const _sherpaCall = async (baseURL, authState, options, paramTypes, returnTypes, name, params) => {
if (!options.skipParamCheck) {
if (params.length !== paramTypes.length) {
return Promise.reject({ message: 'wrong number of parameters in sherpa call, saw ' + params.length + ' != expected ' + paramTypes.length });
@ -773,14 +811,36 @@ var api;
if (json) {
await simulate(json);
}
// Immediately create promise, so options.aborter is changed before returning.
const promise = new Promise((resolve, reject) => {
const fn = (resolve, reject) => {
let resolve1 = (v) => {
resolve(v);
resolve1 = () => { };
reject1 = () => { };
};
let reject1 = (v) => {
if ((v.code === 'user:noAuth' || v.code === 'user:badAuth') && options.login) {
const login = options.login;
if (!authState.loginPromise) {
authState.loginPromise = new Promise((aresolve, areject) => {
login(v.code === 'user:badAuth' ? (v.message || '') : '')
.then((token) => {
authState.token = token;
authState.loginPromise = undefined;
aresolve();
}, (err) => {
authState.loginPromise = undefined;
areject(err);
});
});
}
authState.loginPromise
.then(() => {
fn(resolve, reject);
}, (err) => {
reject(err);
});
return;
}
reject(v);
resolve1 = () => { };
reject1 = () => { };
@ -794,6 +854,9 @@ var api;
};
}
req.open('POST', url, true);
if (options.csrfHeader && authState.token) {
req.setRequestHeader(options.csrfHeader, authState.token);
}
if (options.timeoutMsec) {
req.timeout = options.timeoutMsec;
}
@ -867,8 +930,8 @@ var api;
catch (err) {
reject1({ code: 'sherpa:badData', message: 'cannot marshal to JSON' });
}
});
return await promise;
};
return await new Promise(fn);
};
})(api || (api = {}));
// Javascript is generated from typescript, do not modify generated javascript because changes will be overwritten.
@ -1181,6 +1244,7 @@ const zindexes = {
popover: '5',
attachments: '5',
shortcut: '6',
login: '7',
};
// All logging goes through log() instead of console.log, except "should not happen" logging.
let log = () => { };
@ -1292,7 +1356,61 @@ let domainAddressConfigs = {};
let rejectsMailbox = '';
// Last known server version. For asking to reload.
let lastServerVersion = '';
const client = new api.Client();
const login = async (reason) => {
return new Promise((resolve, _) => {
const origFocus = document.activeElement;
let reasonElem;
let fieldset;
let username;
let password;
const root = dom.div(style({ position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: '#eee', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zindexes.login, animation: 'fadein .15s ease-in' }), dom.div(reasonElem = reason ? dom.div(style({ marginBottom: '2ex', textAlign: 'center' }), reason) : dom.div(), dom.div(style({ backgroundColor: 'white', borderRadius: '.25em', padding: '1em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto', marginBottom: '20vh' }), dom.form(async function submit(e) {
e.preventDefault();
e.stopPropagation();
reasonElem.remove();
try {
fieldset.disabled = true;
const loginToken = await client.LoginPrep();
const token = await client.Login(loginToken, username.value, password.value);
try {
window.localStorage.setItem('webmailcsrftoken', token);
}
catch (err) {
console.log('saving csrf token in localStorage', err);
}
root.remove();
if (origFocus && origFocus instanceof HTMLElement && origFocus.parentNode) {
origFocus.focus();
}
resolve(token);
}
catch (err) {
console.log('login error', err);
window.alert('Error: ' + errmsg(err));
}
finally {
fieldset.disabled = false;
}
}, fieldset = dom.fieldset(dom.h1('Mail'), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Email address', style({ marginBottom: '.5ex' })), username = dom.input(attr.required(''), attr.placeholder('jane@example.org'))), dom.label(style({ display: 'block', marginBottom: '2ex' }), dom.div('Password', style({ marginBottom: '.5ex' })), password = dom.input(attr.type('password'), attr.required(''))), dom.div(style({ textAlign: 'center' }), dom.submitbutton('Login')))))));
document.body.appendChild(root);
username.focus();
});
};
const localStorageGet = (k) => {
try {
return window.localStorage.getItem(k);
}
catch (err) {
return null;
}
};
const localStorageRemove = (k) => {
try {
return window.localStorage.removeItem(k);
}
catch (err) {
}
};
const client = new api.Client().withOptions({ csrfHeader: 'x-mox-csrf', login: login }).withAuthToken(localStorageGet('webmailcsrftoken') || '');
// Link returns a clickable link with rel="noopener noreferrer".
const link = (href, anchorOpt) => dom.a(attr.href(href), attr.rel('noopener noreferrer'), attr.target('_blank'), anchorOpt || href);
// Returns first own account address matching an address in l.
@ -5289,6 +5407,7 @@ const newSearchView = (searchbarElem, mailboxlistView, startSearch, searchViewCl
const init = async () => {
let connectionElem; // SSE connection status/error. Empty when connected.
let layoutElem; // Select dropdown for layout.
let loginAddressElem;
let msglistscrollElem;
let queryactivityElem; // We show ... when a query is active and data is forthcoming.
// Shown at the bottom of msglistscrollElem, immediately below the msglistView, when appropriate.
@ -5679,7 +5798,16 @@ const init = async () => {
else {
selectLayout(layoutElem.value);
}
}), ' ', dom.clickbutton('Tooltip', attr.title('Show tooltips, based on the title attributes (underdotted text) for the focused element and all user interface elements below it. Use the keyboard shortcut "ctrl ?" instead of clicking on the tooltip button, which changes focus to the tooltip button.'), clickCmd(cmdTooltip, shortcuts)), ' ', dom.clickbutton('Help', attr.title('Show popup with basic usage information and a keyboard shortcuts.'), clickCmd(cmdHelp, shortcuts)), ' ', link('https://github.com/mjl-/mox', 'mox')))), dom.div(style({ flexGrow: '1' }), style({ position: 'relative' }), mailboxesElem = dom.div(dom._class('mailboxesbar'), style({ position: 'absolute', left: 0, width: settings.mailboxesWidth + 'px', top: 0, bottom: 0 }), style({ display: 'flex', flexDirection: 'column', alignContent: 'stretch' }), dom.div(dom._class('pad', 'yscrollauto'), style({ flexGrow: '1' }), style({ position: 'relative' }), mailboxlistView.root)), mailboxessplitElem = dom.div(style({ position: 'absolute', left: 'calc(' + settings.mailboxesWidth + 'px - 2px)', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter }), dom.div(style({ position: 'absolute', width: '1px', top: 0, bottom: 0, left: '2px', right: '2px', backgroundColor: '#aaa' })), function mousedown(e) {
}), ' ', dom.clickbutton('Tooltip', attr.title('Show tooltips, based on the title attributes (underdotted text) for the focused element and all user interface elements below it. Use the keyboard shortcut "ctrl ?" instead of clicking on the tooltip button, which changes focus to the tooltip button.'), clickCmd(cmdTooltip, shortcuts)), ' ', dom.clickbutton('Help', attr.title('Show popup with basic usage information and a keyboard shortcuts.'), clickCmd(cmdHelp, shortcuts)), ' ', loginAddressElem = dom.span(), ' ', dom.clickbutton('Logout', attr.title('Logout, invalidating this session.'), async function click(e) {
await withStatus('Logging out', client.Logout(), e.target);
localStorageRemove('webmailcsrftoken');
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Reload so all state is cleared from memory.
window.location.reload();
})))), dom.div(style({ flexGrow: '1' }), style({ position: 'relative' }), mailboxesElem = dom.div(dom._class('mailboxesbar'), style({ position: 'absolute', left: 0, width: settings.mailboxesWidth + 'px', top: 0, bottom: 0 }), style({ display: 'flex', flexDirection: 'column', alignContent: 'stretch' }), dom.div(dom._class('pad', 'yscrollauto'), style({ flexGrow: '1' }), style({ position: 'relative' }), mailboxlistView.root)), mailboxessplitElem = dom.div(style({ position: 'absolute', left: 'calc(' + settings.mailboxesWidth + 'px - 2px)', width: '5px', top: 0, bottom: 0, cursor: 'ew-resize', zIndex: zindexes.splitter }), dom.div(style({ position: 'absolute', width: '1px', top: 0, bottom: 0, left: '2px', right: '2px', backgroundColor: '#aaa' })), function mousedown(e) {
startDrag(e, (e) => {
mailboxesElem.style.width = Math.round(e.clientX) + 'px';
topcomposeboxElem.style.width = Math.round(e.clientX) + 'px';
@ -6029,6 +6157,7 @@ const init = async () => {
connecting = false;
sseID = start.SSEID;
loginAddress = start.LoginAddress;
dom._kids(loginAddressElem, loginAddress.User + '@' + (loginAddress.Domain.Unicode || loginAddress.Domain.ASCII));
const loginAddr = formatEmailASCII(loginAddress);
accountAddresses = start.Addresses || [];
accountAddresses.sort((a, b) => {

View file

@ -107,6 +107,7 @@ const zindexes = {
popover: '5',
attachments: '5',
shortcut: '6',
login: '7',
}
// From HTML.
@ -230,7 +231,89 @@ let rejectsMailbox: string = ''
// Last known server version. For asking to reload.
let lastServerVersion: string = ''
const client = new api.Client()
const login = async (reason: string) => {
return new Promise<string>((resolve: (v: string) => void, _) => {
const origFocus = document.activeElement
let reasonElem: HTMLElement
let fieldset: HTMLFieldSetElement
let username: HTMLInputElement
let password: HTMLInputElement
const root = dom.div(
style({position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, backgroundColor: '#eee', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: zindexes.login, animation: 'fadein .15s ease-in'}),
dom.div(
reasonElem=reason ? dom.div(style({marginBottom: '2ex', textAlign: 'center'}), reason) : dom.div(),
dom.div(
style({backgroundColor: 'white', borderRadius: '.25em', padding: '1em', boxShadow: '0 0 20px rgba(0, 0, 0, 0.1)', border: '1px solid #ddd', maxWidth: '95vw', overflowX: 'auto', maxHeight: '95vh', overflowY: 'auto', marginBottom: '20vh'}),
dom.form(
async function submit(e: SubmitEvent) {
e.preventDefault()
e.stopPropagation()
reasonElem.remove()
try {
fieldset.disabled = true
const loginToken = await client.LoginPrep()
const token = await client.Login(loginToken, username.value, password.value)
try {
window.localStorage.setItem('webmailcsrftoken', token)
} catch (err) {
console.log('saving csrf token in localStorage', err)
}
root.remove()
if (origFocus && origFocus instanceof HTMLElement && origFocus.parentNode) {
origFocus.focus()
}
resolve(token)
} catch (err) {
console.log('login error', err)
window.alert('Error: ' + errmsg(err))
} finally {
fieldset.disabled = false
}
},
fieldset=dom.fieldset(
dom.h1('Mail'),
dom.label(
style({display: 'block', marginBottom: '2ex'}),
dom.div('Email address', style({marginBottom: '.5ex'})),
username=dom.input(attr.required(''), attr.placeholder('jane@example.org')),
),
dom.label(
style({display: 'block', marginBottom: '2ex'}),
dom.div('Password', style({marginBottom: '.5ex'})),
password=dom.input(attr.type('password'), attr.required('')),
),
dom.div(
style({textAlign: 'center'}),
dom.submitbutton('Login'),
),
),
)
)
)
)
document.body.appendChild(root)
username.focus()
})
}
const localStorageGet = (k: string): string | null => {
try {
return window.localStorage.getItem(k)
} catch (err) {
return null
}
}
const localStorageRemove = (k: string) => {
try {
return window.localStorage.removeItem(k)
} catch (err) {
}
}
const client = new api.Client().withOptions({csrfHeader: 'x-mox-csrf', login: login}).withAuthToken(localStorageGet('webmailcsrftoken') || '')
// Link returns a clickable link with rel="noopener noreferrer".
const link = (href: string, anchorOpt?: string): HTMLElement => dom.a(attr.href(href), attr.rel('noopener noreferrer'), attr.target('_blank'), anchorOpt || href)
@ -5350,6 +5433,7 @@ type listMailboxes = () => api.Mailbox[]
const init = async () => {
let connectionElem: HTMLElement // SSE connection status/error. Empty when connected.
let layoutElem: HTMLSelectElement // Select dropdown for layout.
let loginAddressElem: HTMLElement
let msglistscrollElem: HTMLElement
let queryactivityElem: HTMLElement // We show ... when a query is active and data is forthcoming.
@ -5915,7 +5999,18 @@ const init = async () => {
' ',
dom.clickbutton('Help', attr.title('Show popup with basic usage information and a keyboard shortcuts.'), clickCmd(cmdHelp, shortcuts)),
' ',
link('https://github.com/mjl-/mox', 'mox'),
loginAddressElem=dom.span(),
' ',
dom.clickbutton('Logout', attr.title('Logout, invalidating this session.'), async function click(e: MouseEvent) {
await withStatus('Logging out', client.Logout(), e.target! as HTMLButtonElement)
localStorageRemove('webmailcsrftoken')
if (eventSource) {
eventSource.close()
eventSource = null
}
// Reload so all state is cleared from memory.
window.location.reload()
}),
),
),
),
@ -6354,6 +6449,7 @@ const init = async () => {
connecting = false
sseID = start.SSEID
loginAddress = start.LoginAddress
dom._kids(loginAddressElem, loginAddress.User + '@' + (loginAddress.Domain.Unicode || loginAddress.Domain.ASCII))
const loginAddr = formatEmailASCII(loginAddress)
accountAddresses = start.Addresses || []
accountAddresses.sort((a, b) => {

View file

@ -4,7 +4,7 @@ import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
@ -20,14 +20,21 @@ import (
"golang.org/x/net/html"
"github.com/mjl-/sherpa"
"github.com/mjl-/mox/message"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxio"
"github.com/mjl-/mox/store"
"github.com/mjl-/mox/webauth"
)
var ctxbg = context.Background()
func init() {
webauth.BadAuthDelay = 0
}
func tcheck(t *testing.T, err error, msg string) {
t.Helper()
if err != nil {
@ -267,6 +274,14 @@ func tdeliver(t *testing.T, acc *store.Account, tm *testmsg) {
tm.ID = m.ID
}
func readBody(r io.Reader) string {
buf, err := io.ReadAll(r)
if err != nil {
return fmt.Sprintf("read error: %s", err)
}
return fmt.Sprintf("data: %q", buf)
}
// Test scenario with an account with some mailboxes, messages, then make all
// kinds of changes and we check if we get the right events.
// todo: check more of the results, we currently mostly check http statuses,
@ -288,13 +303,34 @@ func TestWebmail(t *testing.T) {
pkglog.Check(err, "closing account")
}()
api := Webmail{maxMessageSize: 1024 * 1024}
apiHandler, err := makeSherpaHandler(api.maxMessageSize)
api := Webmail{maxMessageSize: 1024 * 1024, cookiePath: "/webmail/"}
apiHandler, err := makeSherpaHandler(api.maxMessageSize, api.cookiePath, false)
tcheck(t, err, "sherpa handler")
reqInfo := requestInfo{"mjl@mox.example", "mjl", &http.Request{}}
respRec := httptest.NewRecorder()
reqInfo := requestInfo{"", "", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
// Prepare loginToken.
loginCookie := &http.Cookie{Name: "webmaillogin"}
loginCookie.Value = api.LoginPrep(ctx)
reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
csrfToken := api.Login(ctx, loginCookie.Value, "mjl@mox.example", "test1234")
var sessionCookie *http.Cookie
for _, c := range respRec.Result().Cookies() {
if c.Name == "webmailsession" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatalf("missing session cookie")
}
reqInfo = requestInfo{"mjl@mox.example", "mjl", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
tneedError(t, func() { api.MailboxCreate(ctx, "Inbox") }) // Cannot create inbox.
tneedError(t, func() { api.MailboxCreate(ctx, "Archive") }) // Already exists.
api.MailboxCreate(ctx, "Testbox1")
@ -324,10 +360,12 @@ func TestWebmail(t *testing.T) {
ctJS := [2]string{"Content-Type", "application/javascript; charset=utf-8"}
ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"}
const authOK = "mjl@mox.example:test1234"
const authBad = "mjl@mox.example:badpassword"
hdrAuthOK := [2]string{"Authorization", "Basic " + base64.StdEncoding.EncodeToString([]byte(authOK))}
hdrAuthBad := [2]string{"Authorization", "Basic " + base64.StdEncoding.EncodeToString([]byte(authBad))}
cookieOK := &http.Cookie{Name: "webmailsession", Value: sessionCookie.Value}
cookieBad := &http.Cookie{Name: "webmailsession", Value: "AAAAAAAAAAAAAAAAAAAAAA mjl"}
hdrSessionOK := [2]string{"Cookie", cookieOK.String()}
hdrSessionBad := [2]string{"Cookie", cookieBad.String()}
hdrCSRFOK := [2]string{"x-mox-csrf", string(csrfToken)}
hdrCSRFBad := [2]string{"x-mox-csrf", "AAAAAAAAAAAAAAAAAAAAAA"}
testHTTP := func(method, path string, headers httpHeaders, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
t.Helper()
@ -337,9 +375,10 @@ func TestWebmail(t *testing.T) {
req.Header.Add(kv[0], kv[1])
}
rr := httptest.NewRecorder()
handle(apiHandler, rr, req)
rr.Body = &bytes.Buffer{}
handle(apiHandler, false, rr, req)
if rr.Code != expStatusCode {
t.Fatalf("got status %d, expected %d", rr.Code, expStatusCode)
t.Fatalf("got status %d, expected %d (%s)", rr.Code, expStatusCode, readBody(rr.Body))
}
resp := rr.Result()
@ -353,41 +392,75 @@ func TestWebmail(t *testing.T) {
check(resp)
}
}
testHTTPAuth := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
testHTTPAuthAPI := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
t.Helper()
testHTTP(method, path, httpHeaders{hdrAuthOK}, expStatusCode, expHeaders, check)
testHTTP(method, path, httpHeaders{hdrCSRFOK, hdrSessionOK}, expStatusCode, expHeaders, check)
}
testHTTPAuthREST := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
t.Helper()
testHTTP(method, path, httpHeaders{hdrSessionOK}, expStatusCode, expHeaders, check)
}
userAuthError := func(resp *http.Response, expCode string) {
t.Helper()
var response struct {
Error *sherpa.Error `json:"error"`
}
err := json.NewDecoder(resp.Body).Decode(&response)
tcheck(t, err, "parsing response as json")
if response.Error == nil {
t.Fatalf("expected sherpa error with code %s, no error", expCode)
}
if response.Error.Code != expCode {
t.Fatalf("got sherpa error code %q, expected %s", response.Error.Code, expCode)
}
}
badAuth := func(resp *http.Response) {
t.Helper()
userAuthError(resp, "user:badAuth")
}
noAuth := func(resp *http.Response) {
t.Helper()
userAuthError(resp, "user:noAuth")
}
// HTTP webmail
testHTTP("GET", "/", httpHeaders{}, http.StatusUnauthorized, nil, nil)
testHTTP("GET", "/", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil)
testHTTPAuth("GET", "/", http.StatusOK, httpHeaders{ctHTML}, nil)
testHTTPAuth("POST", "/", http.StatusMethodNotAllowed, nil, nil)
testHTTP("GET", "/", httpHeaders{hdrAuthOK, [2]string{"Accept-Encoding", "gzip"}}, http.StatusOK, httpHeaders{ctHTML, [2]string{"Content-Encoding", "gzip"}}, nil)
testHTTP("GET", "/msg.js", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil)
testHTTPAuth("POST", "/msg.js", http.StatusMethodNotAllowed, nil, nil)
testHTTPAuth("GET", "/msg.js", http.StatusOK, httpHeaders{ctJS}, nil)
testHTTP("GET", "/text.js", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil)
testHTTPAuth("GET", "/text.js", http.StatusOK, httpHeaders{ctJS}, nil)
testHTTP("GET", "/", httpHeaders{}, http.StatusOK, nil, nil)
testHTTP("POST", "/", httpHeaders{}, http.StatusMethodNotAllowed, nil, nil)
testHTTP("GET", "/", httpHeaders{[2]string{"Accept-Encoding", "gzip"}}, http.StatusOK, httpHeaders{ctHTML, [2]string{"Content-Encoding", "gzip"}}, nil)
testHTTP("GET", "/msg.js", httpHeaders{}, http.StatusOK, httpHeaders{ctJS}, nil)
testHTTP("POST", "/msg.js", httpHeaders{}, http.StatusMethodNotAllowed, nil, nil)
testHTTP("GET", "/text.js", httpHeaders{}, http.StatusOK, httpHeaders{ctJS}, nil)
testHTTP("POST", "/text.js", httpHeaders{}, http.StatusMethodNotAllowed, nil, nil)
testHTTP("GET", "/api/Bogus", httpHeaders{}, http.StatusUnauthorized, nil, nil)
testHTTP("GET", "/api/Bogus", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil)
testHTTPAuth("GET", "/api/Bogus", http.StatusNotFound, nil, nil)
testHTTPAuth("GET", "/api/SSETypes", http.StatusOK, httpHeaders{ctJSON}, nil)
testHTTP("POST", "/api/Bogus", httpHeaders{}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionBad}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionBad}, http.StatusOK, nil, badAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionOK}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionOK}, http.StatusOK, nil, badAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK, hdrSessionBad}, http.StatusOK, nil, badAuth)
testHTTPAuthAPI("GET", "/api/Bogus", http.StatusMethodNotAllowed, nil, nil)
testHTTPAuthAPI("POST", "/api/Bogus", http.StatusNotFound, nil, nil)
testHTTPAuthAPI("POST", "/api/SSETypes", http.StatusOK, httpHeaders{ctJSON}, nil)
// Unknown.
testHTTPAuth("GET", "/other", http.StatusNotFound, nil, nil)
testHTTP("GET", "/other", httpHeaders{}, http.StatusForbidden, nil, nil)
// HTTP message, generic
testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), nil, http.StatusUnauthorized, nil, nil)
testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil)
testHTTPAuth("GET", fmt.Sprintf("/msg/%v/attachments.zip", 0), http.StatusNotFound, nil, nil)
testHTTPAuth("GET", fmt.Sprintf("/msg/%v/attachments.zip", testmsgs[len(testmsgs)-1].ID+1), http.StatusNotFound, nil, nil)
testHTTPAuth("GET", fmt.Sprintf("/msg/%v/bogus", inboxMinimal.ID), http.StatusNotFound, nil, nil)
testHTTPAuth("GET", fmt.Sprintf("/msg/%v/view/bogus", inboxMinimal.ID), http.StatusNotFound, nil, nil)
testHTTPAuth("GET", fmt.Sprintf("/msg/%v/bogus/0", inboxMinimal.ID), http.StatusNotFound, nil, nil)
testHTTPAuth("GET", "/msg/", http.StatusNotFound, nil, nil)
testHTTPAuth("POST", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), http.StatusMethodNotAllowed, nil, nil)
testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), nil, http.StatusForbidden, nil, nil)
testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), httpHeaders{hdrCSRFBad}, http.StatusForbidden, nil, nil)
testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), httpHeaders{hdrCSRFOK}, http.StatusForbidden, nil, nil)
testHTTP("GET", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/attachments.zip", 0), http.StatusNotFound, nil, nil)
testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/attachments.zip", testmsgs[len(testmsgs)-1].ID+1), http.StatusNotFound, nil, nil)
testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/bogus", inboxMinimal.ID), http.StatusNotFound, nil, nil)
testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/view/bogus", inboxMinimal.ID), http.StatusNotFound, nil, nil)
testHTTPAuthREST("GET", fmt.Sprintf("/msg/%v/bogus/0", inboxMinimal.ID), http.StatusNotFound, nil, nil)
testHTTPAuthREST("GET", "/msg/", http.StatusNotFound, nil, nil)
testHTTPAuthREST("POST", fmt.Sprintf("/msg/%v/attachments.zip", inboxMinimal.ID), http.StatusMethodNotAllowed, nil, nil)
// HTTP message: attachments.zip
ctZip := [2]string{"Content-Type", "application/zip"}
@ -415,39 +488,39 @@ func TestWebmail(t *testing.T) {
}
pathInboxMinimal := fmt.Sprintf("/msg/%d", inboxMinimal.ID)
testHTTP("GET", pathInboxMinimal+"/attachments.zip", httpHeaders{}, http.StatusUnauthorized, nil, nil)
testHTTP("GET", pathInboxMinimal+"/attachments.zip", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil)
testHTTP("GET", pathInboxMinimal+"/attachments.zip", httpHeaders{}, http.StatusForbidden, nil, nil)
testHTTP("GET", pathInboxMinimal+"/attachments.zip", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
testHTTPAuth("GET", pathInboxMinimal+"/attachments.zip", http.StatusOK, httpHeaders{ctZip}, func(resp *http.Response) {
testHTTPAuthREST("GET", pathInboxMinimal+"/attachments.zip", http.StatusOK, httpHeaders{ctZip}, func(resp *http.Response) {
checkZip(resp, nil)
})
pathInboxRelAlt := fmt.Sprintf("/msg/%d", inboxAltRel.ID)
testHTTPAuth("GET", pathInboxRelAlt+"/attachments.zip", http.StatusOK, httpHeaders{ctZip}, func(resp *http.Response) {
testHTTPAuthREST("GET", pathInboxRelAlt+"/attachments.zip", http.StatusOK, httpHeaders{ctZip}, func(resp *http.Response) {
checkZip(resp, [][2]string{{"test1.png", "PNG..."}})
})
pathInboxAttachments := fmt.Sprintf("/msg/%d", inboxAttachments.ID)
testHTTPAuth("GET", pathInboxAttachments+"/attachments.zip", http.StatusOK, httpHeaders{ctZip}, func(resp *http.Response) {
testHTTPAuthREST("GET", pathInboxAttachments+"/attachments.zip", http.StatusOK, httpHeaders{ctZip}, func(resp *http.Response) {
checkZip(resp, [][2]string{{"attachment-1.png", "PNG..."}, {"attachment-2.png", "PNG..."}, {"test.jpg", "JPG..."}, {"test-1.jpg", "JPG..."}})
})
// HTTP message: raw
pathInboxAltRel := fmt.Sprintf("/msg/%d", inboxAltRel.ID)
pathInboxText := fmt.Sprintf("/msg/%d", inboxText.ID)
testHTTP("GET", pathInboxAltRel+"/raw", httpHeaders{}, http.StatusUnauthorized, nil, nil)
testHTTP("GET", pathInboxAltRel+"/raw", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil)
testHTTPAuth("GET", pathInboxAltRel+"/raw", http.StatusOK, httpHeaders{ctTextNoCharset}, nil)
testHTTPAuth("GET", pathInboxText+"/raw", http.StatusOK, httpHeaders{ctText}, nil)
testHTTP("GET", pathInboxAltRel+"/raw", httpHeaders{}, http.StatusForbidden, nil, nil)
testHTTP("GET", pathInboxAltRel+"/raw", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/raw", http.StatusOK, httpHeaders{ctTextNoCharset}, nil)
testHTTPAuthREST("GET", pathInboxText+"/raw", http.StatusOK, httpHeaders{ctText}, nil)
// HTTP message: parsedmessage.js
testHTTP("GET", pathInboxMinimal+"/parsedmessage.js", httpHeaders{}, http.StatusUnauthorized, nil, nil)
testHTTP("GET", pathInboxMinimal+"/parsedmessage.js", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil)
testHTTPAuth("GET", pathInboxMinimal+"/parsedmessage.js", http.StatusOK, httpHeaders{ctJS}, nil)
testHTTP("GET", pathInboxMinimal+"/parsedmessage.js", httpHeaders{}, http.StatusForbidden, nil, nil)
testHTTP("GET", pathInboxMinimal+"/parsedmessage.js", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
testHTTPAuthREST("GET", pathInboxMinimal+"/parsedmessage.js", http.StatusOK, httpHeaders{ctJS}, nil)
mox.LimitersInit()
// HTTP message: text,html,htmlexternal and msgtext,msghtml,msghtmlexternal
for _, elem := range []string{"text", "html", "htmlexternal", "msgtext", "msghtml", "msghtmlexternal"} {
testHTTP("GET", pathInboxAltRel+"/"+elem, httpHeaders{}, http.StatusUnauthorized, nil, nil)
testHTTP("GET", pathInboxAltRel+"/"+elem, httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil)
testHTTP("GET", pathInboxAltRel+"/"+elem, httpHeaders{}, http.StatusForbidden, nil, nil)
testHTTP("GET", pathInboxAltRel+"/"+elem, httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
mox.LimitersInit() // Reset, for too many failures.
}
@ -486,37 +559,46 @@ func TestWebmail(t *testing.T) {
"Content-Security-Policy",
"frame-ancestors 'self'; default-src 'none'; img-src data: http: https: 'unsafe-inline'; style-src 'unsafe-inline' data: http: https:; font-src data: http: https: 'unsafe-inline'; media-src 'unsafe-inline' data: http: https:; script-src 'unsafe-inline' 'self'; frame-src 'self'; connect-src 'self'",
}
testHTTPAuth("GET", pathInboxAltRel+"/text", http.StatusOK, httpHeaders{ctHTML, cspText}, nil)
testHTTPAuth("GET", pathInboxAltRel+"/html", http.StatusOK, httpHeaders{ctHTML, cspHTML}, nil)
testHTTPAuth("GET", pathInboxAltRel+"/htmlexternal", http.StatusOK, httpHeaders{ctHTML, cspHTMLExternal}, nil)
testHTTPAuth("GET", pathInboxAltRel+"/msgtext", http.StatusOK, httpHeaders{ctHTML, cspText}, nil)
testHTTPAuth("GET", pathInboxAltRel+"/msghtml", http.StatusOK, httpHeaders{ctHTML, cspMsgHTML}, nil)
testHTTPAuth("GET", pathInboxAltRel+"/msghtmlexternal", http.StatusOK, httpHeaders{ctHTML, cspMsgHTMLExternal}, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/text", http.StatusOK, httpHeaders{ctHTML, cspText}, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/html", http.StatusOK, httpHeaders{ctHTML, cspHTML}, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/htmlexternal", http.StatusOK, httpHeaders{ctHTML, cspHTMLExternal}, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/msgtext", http.StatusOK, httpHeaders{ctHTML, cspText}, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/msghtml", http.StatusOK, httpHeaders{ctHTML, cspMsgHTML}, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/msghtmlexternal", http.StatusOK, httpHeaders{ctHTML, cspMsgHTMLExternal}, nil)
testHTTPAuth("GET", pathInboxAltRel+"/html?sameorigin=true", http.StatusOK, httpHeaders{ctHTML, cspHTMLSameOrigin}, nil)
testHTTPAuth("GET", pathInboxAltRel+"/htmlexternal?sameorigin=true", http.StatusOK, httpHeaders{ctHTML, cspHTMLExternalSameOrigin}, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/html?sameorigin=true", http.StatusOK, httpHeaders{ctHTML, cspHTMLSameOrigin}, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/htmlexternal?sameorigin=true", http.StatusOK, httpHeaders{ctHTML, cspHTMLExternalSameOrigin}, nil)
// No HTML part.
for _, elem := range []string{"html", "htmlexternal", "msghtml", "msghtmlexternal"} {
testHTTPAuth("GET", pathInboxText+"/"+elem, http.StatusBadRequest, nil, nil)
testHTTPAuthREST("GET", pathInboxText+"/"+elem, http.StatusBadRequest, nil, nil)
}
// No text part.
pathInboxHTML := fmt.Sprintf("/msg/%d", inboxHTML.ID)
for _, elem := range []string{"text", "msgtext"} {
testHTTPAuth("GET", pathInboxHTML+"/"+elem, http.StatusBadRequest, nil, nil)
testHTTPAuthREST("GET", pathInboxHTML+"/"+elem, http.StatusBadRequest, nil, nil)
}
// HTTP message part: view,viewtext,download
for _, elem := range []string{"view", "viewtext", "download"} {
testHTTP("GET", pathInboxAltRel+"/"+elem+"/0", httpHeaders{}, http.StatusUnauthorized, nil, nil)
testHTTP("GET", pathInboxAltRel+"/"+elem+"/0", httpHeaders{hdrAuthBad}, http.StatusUnauthorized, nil, nil)
testHTTPAuth("GET", pathInboxAltRel+"/"+elem+"/0", http.StatusOK, nil, nil)
testHTTPAuth("GET", pathInboxAltRel+"/"+elem+"/0.0", http.StatusOK, nil, nil)
testHTTPAuth("GET", pathInboxAltRel+"/"+elem+"/0.1", http.StatusOK, nil, nil)
testHTTPAuth("GET", pathInboxAltRel+"/"+elem+"/0.2", http.StatusNotFound, nil, nil)
testHTTPAuth("GET", pathInboxAltRel+"/"+elem+"/1", http.StatusNotFound, nil, nil)
testHTTP("GET", pathInboxAltRel+"/"+elem+"/0", httpHeaders{}, http.StatusForbidden, nil, nil)
testHTTP("GET", pathInboxAltRel+"/"+elem+"/0", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/0", http.StatusOK, nil, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/0.0", http.StatusOK, nil, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/0.1", http.StatusOK, nil, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/0.2", http.StatusNotFound, nil, nil)
testHTTPAuthREST("GET", pathInboxAltRel+"/"+elem+"/1", http.StatusNotFound, nil, nil)
}
// Logout invalidates the session. Must work exactly once.
// Normally the generic /api/ auth check returns a user error. We bypass it and
// check for the server error.
sessionToken := store.SessionToken(strings.SplitN(sessionCookie.Value, " ", 2)[0])
reqInfo = requestInfo{"mjl@mox.example", "mjl", sessionToken, httptest.NewRecorder(), &http.Request{RemoteAddr: "127.0.0.1:1234"}}
ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
api.Logout(ctx)
tneedErrorCode(t, "server:error", func() { api.Logout(ctx) })
}
func TestSanitize(t *testing.T) {