diff --git a/app/pkg/env/env.go b/app/pkg/env/env.go index a98b6578c..4030f933b 100644 --- a/app/pkg/env/env.go +++ b/app/pkg/env/env.go @@ -34,6 +34,22 @@ func Version() string { return fmt.Sprintf("%s-%s", v, commithash) } +type SMTPConfig struct { + Host string `env:"EMAIL_SMTP_HOST"` + Port string `env:"EMAIL_SMTP_PORT"` + Username string `env:"EMAIL_SMTP_USERNAME"` + Password string `env:"EMAIL_SMTP_PASSWORD"` + AuthMechanism string `env:"EMAIL_SMTP_AUTH_MECHANISM,default=AGNOSTIC"` + EnableStartTLS bool `env:"EMAIL_SMTP_ENABLE_STARTTLS,default=true"` + + // Specific settings for Auth Mechanism "XOAUTH2" + // Username will be used for XOAUTH2 as well + ClientId string `env:"EMAIL_SMTP_OAUTH_CLIENT_ID"` + ClientSecret string `env:"EMAIL_SMTP_OAUTH_CLIENT_SECRET"` + TokenUrl string `env:"EMAIL_SMTP_OAUTH_TOKEN_URL"` + Scopes string `env:"EMAIL_SMTP_OAUTH_SCOPES"` // comma-separated +} + type config struct { Environment string `env:"GO_ENV,default=production"` SignUpDisabled bool `env:"SIGNUP_DISABLED,default=false"` @@ -118,13 +134,7 @@ type config struct { Domain string `env:"EMAIL_MAILGUN_DOMAIN"` Region string `env:"EMAIL_MAILGUN_REGION,default=US"` // possible values: US or EU } - SMTP struct { - Host string `env:"EMAIL_SMTP_HOST"` - Port string `env:"EMAIL_SMTP_PORT"` - Username string `env:"EMAIL_SMTP_USERNAME"` - Password string `env:"EMAIL_SMTP_PASSWORD"` - EnableStartTLS bool `env:"EMAIL_SMTP_ENABLE_STARTTLS,default=true"` - } + SMTP SMTPConfig } BlobStorage struct { Type string `env:"BLOB_STORAGE,default=sql"` // possible values: sql, fs or s3 diff --git a/app/services/email/smtp/auth_xoauth2.go b/app/services/email/smtp/auth_xoauth2.go new file mode 100644 index 000000000..e5e13a539 --- /dev/null +++ b/app/services/email/smtp/auth_xoauth2.go @@ -0,0 +1,42 @@ +package smtp + +import ( + "fmt" + gosmtp "net/smtp" + + "github.com/getfider/fider/app/pkg/errors" +) + +type xoauth2Auth struct { + user string + token string + host string +} + +func XOAuth2Auth(user, token, host string) gosmtp.Auth { + return &xoauth2Auth{ + user: user, + token: token, + host: host, + } +} + +func (a *xoauth2Auth) Start(server *gosmtp.ServerInfo) (proto string, toServer []byte, err error) { + if server.Name != a.host { + return "", nil, errors.New("smtp: wrong host name") + } + + if !server.TLS { + return "", nil, errors.New("smtp: XOAUTH2 requires TLS") + } + + resp := fmt.Sprintf("user=%s\x01auth=Bearer %s\x01\x01", a.user, a.token) + return "XOAUTH2", []byte(resp), nil +} + +func (a *xoauth2Auth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + return nil, errors.New("smtp: unexpected server challenge for XOAUTH2") + } + return nil, nil +} diff --git a/app/services/email/smtp/oauth2_token.go b/app/services/email/smtp/oauth2_token.go new file mode 100644 index 000000000..715296044 --- /dev/null +++ b/app/services/email/smtp/oauth2_token.go @@ -0,0 +1,62 @@ +package smtp + +import ( + "context" + "strings" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/clientcredentials" + + "github.com/getfider/fider/app/pkg/errors" +) + +func splitCommaScopes(raw string) []string { + if strings.TrimSpace(raw) == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + +func getClientCredentialsToken(ctx context.Context, tokenURL, clientID, clientSecret string, scopes []string) (string, error) { + if tokenURL == "" { + return "", errors.New("smtp: oauth token url is required") + } + if clientID == "" || clientSecret == "" { + return "", errors.New("smtp: oauth client id/secret are required") + } + + key := tokenSourceKey(tokenURL, clientID, scopes) + + tokenSourceMu.Lock() + tokenSource, ok := tokenSourceByKey[key] + if !ok { + conf := clientcredentials.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + TokenURL: tokenURL, + Scopes: scopes, + } + + base := conf.TokenSource(ctx) + tokenSource = oauth2.ReuseTokenSource(nil, base) + tokenSourceByKey[key] = tokenSource + } + tokenSourceMu.Unlock() + + tok, err := tokenSource.Token() + if err != nil { + return "", errors.Wrap(err, "smtp: failed to fetch oauth token") + } + if tok == nil || tok.AccessToken == "" { + return "", errors.New("smtp: oauth returned empty access token") + } + return tok.AccessToken, nil +} diff --git a/app/services/email/smtp/oauth2_tokensource.go b/app/services/email/smtp/oauth2_tokensource.go new file mode 100644 index 000000000..629332175 --- /dev/null +++ b/app/services/email/smtp/oauth2_tokensource.go @@ -0,0 +1,30 @@ +package smtp + +import ( + "crypto/sha256" + "encoding/hex" + "sort" + "strings" + "sync" + + "golang.org/x/oauth2" +) + +var ( + tokenSourceMu sync.Mutex + tokenSourceByKey = map[string]oauth2.TokenSource{} +) + +func tokenSourceKey(tokenURL, clientID string, scopes []string) string { + normalized := make([]string, 0, len(scopes)) + for _, scope := range scopes { + scope = strings.TrimSpace(scope) + if scope != "" { + normalized = append(normalized, scope) + } + } + sort.Strings(normalized) + + sum := sha256.Sum256([]byte(tokenURL + "|" + clientID + "|" + strings.Join(normalized, ","))) + return hex.EncodeToString(sum[:]) +} diff --git a/app/services/email/smtp/smtp.go b/app/services/email/smtp/smtp.go index a5b8f30a7..2c86b1746 100644 --- a/app/services/email/smtp/smtp.go +++ b/app/services/email/smtp/smtp.go @@ -10,6 +10,7 @@ import ( gosmtp "net/smtp" "net/url" "strconv" + "strings" "time" "github.com/getfider/fider/app/models/cmd" @@ -99,7 +100,11 @@ func sendMail(ctx context.Context, c *cmd.SendMail) { smtpConfig := env.Config.Email.SMTP servername := fmt.Sprintf("%s:%s", smtpConfig.Host, smtpConfig.Port) - auth := authenticate(smtpConfig.Username, smtpConfig.Password, smtpConfig.Host) + auth, err := authenticate(ctx, smtpConfig) + if err != nil { + panic(errors.Wrap(err, "failed to build smtp auth")) + } + err = Send(localname, servername, smtpConfig.EnableStartTLS, auth, email.NoReply, []string{to.Address}, b.Bytes()) if err != nil { panic(errors.Wrap(err, "failed to send email with template %s", c.TemplateName)) @@ -169,11 +174,31 @@ func generateMessageID(localName string) string { return messageID } -func authenticate(username string, password string, host string) gosmtp.Auth { - if username == "" && password == "" { - return nil +func authenticate(ctx context.Context, cfg env.SMTPConfig) (gosmtp.Auth, error) { + mech := strings.ToLower(strings.TrimSpace(cfg.AuthMechanism)) + switch mech { + case "", "agnostic": + if cfg.Username == "" && cfg.Password == "" { + return nil, nil + } + return AgnosticAuth("", cfg.Username, cfg.Password, cfg.Host), nil + + case "xoauth2": + if cfg.Username == "" { + return nil, errors.New("smtp: username is required for XOAUTH2") + } + + scopes := splitCommaScopes(cfg.Scopes) + token, err := getClientCredentialsToken(ctx, cfg.TokenUrl, cfg.ClientId, cfg.ClientSecret, scopes) + if err != nil { + return nil, err + } + + return XOAuth2Auth(cfg.Username, token, cfg.Host), nil + + default: + return nil, errors.New("smtp: unsupported auth mechanism") } - return AgnosticAuth("", username, password, host) } type builder struct {