// Copyright (C) 2019-2023 Nicola Murino // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, version 3. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // Package smtp provides supports for sending emails package smtp import ( "context" "errors" "fmt" "sync" "time" "golang.org/x/oauth2" "golang.org/x/oauth2/google" "golang.org/x/oauth2/microsoft" "github.com/drakkan/sftpgo/v2/internal/logger" "github.com/drakkan/sftpgo/v2/internal/util" ) // Supported OAuth2 providers const ( OAuth2ProviderGoogle = iota OAuth2ProviderMicrosoft ) var supportedOAuth2Providers = []int{OAuth2ProviderGoogle, OAuth2ProviderMicrosoft} // OAuth2Config defines OAuth2 settings type OAuth2Config struct { Provider int `json:"provider" mapstructure:"provider"` // Tenant for Microsoft provider, if empty "common" is used Tenant string `json:"tenant" mapstructure:"tenant"` // ClientID is the application's ID ClientID string `json:"client_id" mapstructure:"client_id"` // ClientSecret is the application's secret ClientSecret string `json:"client_secret" mapstructure:"client_secret"` // Token to use to get/renew access tokens RefreshToken string `json:"refresh_token" mapstructure:"refresh_token"` mu *sync.RWMutex config *oauth2.Config accessToken *oauth2.Token } // Validate validates and initializes the configuration func (c *OAuth2Config) Validate() error { if !util.Contains(supportedOAuth2Providers, c.Provider) { return fmt.Errorf("smtp oauth2: unsupported provider %d", c.Provider) } if c.ClientID == "" { return errors.New("smtp oauth2: client id is required") } if c.ClientSecret == "" { return errors.New("smtp oauth2: client secret is required") } if c.RefreshToken == "" { return errors.New("smtp oauth2: refresh token is required") } c.initialize() return nil } func (c *OAuth2Config) isEqual(other *OAuth2Config) bool { if c.Provider != other.Provider { return false } if c.Tenant != other.Tenant { return false } if c.ClientID != other.ClientID { return false } if c.ClientSecret != other.ClientSecret { return false } if c.RefreshToken != other.RefreshToken { return false } return true } func (c *OAuth2Config) getAccessToken() (string, error) { c.mu.RLock() if c.accessToken.Expiry.After(time.Now().Add(30 * time.Second)) { accessToken := c.accessToken.AccessToken c.mu.RUnlock() return accessToken, nil } logger.Debug(logSender, "", "renew oauth2 token required, current token expires at %s", c.accessToken.Expiry) token := new(oauth2.Token) *token = *c.accessToken c.mu.RUnlock() ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() newToken, err := c.config.TokenSource(ctx, token).Token() if err != nil { logger.Error(logSender, "", "unable to get new token: %v", err) return "", err } accessToken := newToken.AccessToken refreshToken := newToken.RefreshToken if refreshToken != "" && refreshToken != token.RefreshToken { c.mu.Lock() c.RefreshToken = refreshToken c.accessToken = newToken c.mu.Unlock() logger.Debug(logSender, "", "oauth2 refresh token changed") go updateRefreshToken(refreshToken) } if accessToken != token.AccessToken { c.mu.Lock() c.accessToken = newToken c.mu.Unlock() logger.Debug(logSender, "", "new oauth2 token saved, expires at %s", c.accessToken.Expiry) } return accessToken, nil } func (c *OAuth2Config) initialize() { c.mu = new(sync.RWMutex) c.config = c.GetOAuth2() c.accessToken = &oauth2.Token{ TokenType: "Bearer", RefreshToken: c.RefreshToken, } } // GetOAuth2 returns the oauth2 configuration for the provided parameters. func (c *OAuth2Config) GetOAuth2() *oauth2.Config { var endpoint oauth2.Endpoint var scopes []string switch c.Provider { case OAuth2ProviderMicrosoft: endpoint = microsoft.AzureADEndpoint(c.Tenant) scopes = []string{"offline_access", "https://outlook.office.com/SMTP.Send"} default: endpoint = google.Endpoint scopes = []string{"https://mail.google.com/"} } return &oauth2.Config{ ClientID: c.ClientID, ClientSecret: c.ClientSecret, Scopes: scopes, Endpoint: endpoint, } }