Update markbates/goth library (#3533)

Signed-off-by: Lauris Bukšis-Haberkorns <lauris@nix.lv>
release/v1.5
Lauris BH 6 years ago committed by GitHub
parent 6f751409b4
commit 7b297808ce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -8,6 +8,10 @@ protocol providers, as long as they implement the `Provider` and `Session` inter
This package was inspired by [https://github.com/intridea/omniauth](https://github.com/intridea/omniauth).
## Goth Needs a New Maintainer
[https://blog.gobuffalo.io/goth-needs-a-new-maintainer-626cd47ca37b](https://blog.gobuffalo.io/goth-needs-a-new-maintainer-626cd47ca37b) - TL;DR: I, @markbates, won't be responding to any more issues, PRs, etc... for this package. A new maintainer needs to be found ASAP. Is this you?
## Installation
```text
@ -18,6 +22,8 @@ $ go get github.com/markbates/goth
* Amazon
* Auth0
* Azure AD
* Battle.net
* Bitbucket
* Box
* Cloud Foundry
@ -26,6 +32,7 @@ $ go get github.com/markbates/goth
* Digital Ocean
* Discord
* Dropbox
* Eve Online
* Facebook
* Fitbit
* GitHub
@ -38,6 +45,7 @@ $ go get github.com/markbates/goth
* Lastfm
* Linkedin
* Meetup
* MicrosoftOnline
* OneDrive
* OpenID Connect (auto discovery)
* Paypal
@ -50,7 +58,9 @@ $ go get github.com/markbates/goth
* Twitch
* Twitter
* Uber
* VK
* Wepay
* Xero
* Yahoo
* Yammer
@ -71,17 +81,51 @@ $ go get github.com/markbates/goth
```text
$ cd goth/examples
$ go get -v
$ go build
$ go build
$ ./examples
```
Now open up your browser and go to [http://localhost:3000](http://localhost:3000) to see the example.
To actually use the different providers, please make sure you configure them given the system environments as defined in the examples/main.go file
To actually use the different providers, please make sure you set environment variables. Example given in the examples/main.go file
## Security Notes
By default, gothic uses a `CookieStore` from the `gorilla/sessions` package to store session data.
As configured, this default store (`gothic.Store`) will generate cookies with `Options`:
```go
&Options{
Path: "/",
Domain: "",
MaxAge: 86400 * 30,
HttpOnly: true,
Secure: false,
}
```
To tailor these fields for your application, you can override the `gothic.Store` variable at startup.
The follow snippet show one way to do this:
```go
key := "" // Replace with your SESSION_SECRET or similar
maxAge := 86400 * 30 // 30 days
isProd := false // Set to true when serving over https
store := sessions.NewCookieStore([]byte(key))
store.MaxAge(maxAge)
store.Options.Path = "/"
store.Options.HttpOnly = true // HttpOnly should always be enabled
store.Options.Secure = isProd
gothic.Store = store
```
## Issues
Issues always stand a significantly better chance of getting fixed if the are accompanied by a
Issues always stand a significantly better chance of getting fixed if they are accompanied by a
pull request.
## Contributing
@ -94,50 +138,3 @@ Would I love to see more providers? Certainly! Would you love to contribute one?
4. Commit your changes (git commit -am 'Add some feature')
5. Push to the branch (git push origin my-new-feature)
6. Create new Pull Request
## Contributors
* Mark Bates
* Tyler Bunnell
* Corey McGrillis
* willemvd
* Rakesh Goyal
* Andy Grunwald
* Glenn Walker
* Kevin Fitzpatrick
* Ben Tranter
* Sharad Ganapathy
* Andrew Chilton
* sharadgana
* Aurorae
* Craig P Jolicoeur
* Zac Bergquist
* Geoff Franks
* Raphael Geronimi
* Noah Shibley
* lumost
* oov
* Felix Lamouroux
* Rafael Quintela
* Tyler
* DenSm
* Samy KACIMI
* dante gray
* Noah
* Jacob Walker
* Marin Martinic
* Roy
* Omni Adams
* Sasa Brankovic
* dkhamsing
* Dante Swift
* Attila Domokos
* Albin Gilles
* Syed Zubairuddin
* Johnny Boursiquot
* Jerome Touffe-Blin
* bryanl
* Masanobu YOSHIOKA
* Jonathan Hall
* HaiMing.Yin
* Sairam Kunala

@ -8,10 +8,18 @@ See https://github.com/markbates/goth/examples/main.go to see this in action.
package gothic
import (
"bytes"
"compress/gzip"
"encoding/base64"
"errors"
"fmt"
"io/ioutil"
"math/rand"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/sessions"
@ -27,15 +35,21 @@ var defaultStore sessions.Store
var keySet = false
var gothicRand *rand.Rand
func init() {
key := []byte(os.Getenv("SESSION_SECRET"))
keySet = len(key) != 0
Store = sessions.NewCookieStore([]byte(key))
cookieStore := sessions.NewCookieStore([]byte(key))
cookieStore.Options.HttpOnly = true
Store = cookieStore
defaultStore = Store
gothicRand = rand.New(rand.NewSource(time.Now().UnixNano()))
}
/*
BeginAuthHandler is a convienence handler for starting the authentication process.
BeginAuthHandler is a convenience handler for starting the authentication process.
It expects to be able to get the name of the provider from the query parameters
as either "provider" or ":provider".
@ -65,8 +79,16 @@ var SetState = func(req *http.Request) string {
return state
}
return "state"
// If a state query param is not passed in, generate a random
// base64-encoded nonce so that the state on the auth URL
// is unguessable, preventing CSRF attacks, as described in
//
// https://auth0.com/docs/protocols/oauth2/oauth-state#keep-reading
nonceBytes := make([]byte, 64)
for i := 0; i < 64; i++ {
nonceBytes[i] = byte(gothicRand.Int63() % 256)
}
return base64.URLEncoding.EncodeToString(nonceBytes)
}
// GetState gets the state returned by the provider during the callback.
@ -87,7 +109,6 @@ I would recommend using the BeginAuthHandler instead of doing all of these steps
yourself, but that's entirely up to you.
*/
func GetAuthURL(res http.ResponseWriter, req *http.Request) (string, error) {
if !keySet && defaultStore == Store {
fmt.Println("goth/gothic: no SESSION_SECRET environment variable is set. The default cookie store is not available and any calls will fail. Ignore this warning if you are using a different store.")
}
@ -130,7 +151,7 @@ as either "provider" or ":provider".
See https://github.com/markbates/goth/examples/main.go to see this in action.
*/
var CompleteUserAuth = func(res http.ResponseWriter, req *http.Request) (goth.User, error) {
defer Logout(res, req)
if !keySet && defaultStore == Store {
fmt.Println("goth/gothic: no SESSION_SECRET environment variable is set. The default cookie store is not available and any calls will fail. Ignore this warning if you are using a different store.")
}
@ -155,6 +176,11 @@ var CompleteUserAuth = func(res http.ResponseWriter, req *http.Request) (goth.Us
return goth.User{}, err
}
err = validateState(req, sess)
if err != nil {
return goth.User{}, err
}
user, err := provider.FetchUser(sess)
if err == nil {
// user can be found with existing session data
@ -173,7 +199,43 @@ var CompleteUserAuth = func(res http.ResponseWriter, req *http.Request) (goth.Us
return goth.User{}, err
}
return provider.FetchUser(sess)
gu, err := provider.FetchUser(sess)
return gu, err
}
// validateState ensures that the state token param from the original
// AuthURL matches the one included in the current (callback) request.
func validateState(req *http.Request, sess goth.Session) error {
rawAuthURL, err := sess.GetAuthURL()
if err != nil {
return err
}
authURL, err := url.Parse(rawAuthURL)
if err != nil {
return err
}
originalState := authURL.Query().Get("state")
if originalState != "" && (originalState != req.URL.Query().Get("state")) {
return errors.New("state token mismatch")
}
return nil
}
// Logout invalidates a user session.
func Logout(res http.ResponseWriter, req *http.Request) error {
session, err := Store.Get(req, SessionName)
if err != nil {
return err
}
session.Options.MaxAge = -1
session.Values = make(map[interface{}]interface{})
err = session.Save(req, res)
if err != nil {
return errors.New("Could not delete user session ")
}
return nil
}
// GetProviderName is a function used to get the name of a provider
@ -184,36 +246,96 @@ var CompleteUserAuth = func(res http.ResponseWriter, req *http.Request) (goth.Us
var GetProviderName = getProviderName
func getProviderName(req *http.Request) (string, error) {
provider := req.URL.Query().Get("provider")
if provider == "" {
if p, ok := mux.Vars(req)["provider"]; ok {
// get all the used providers
providers := goth.GetProviders()
// loop over the used providers, if we already have a valid session for any provider (ie. user is already logged-in with a provider), then return that provider name
for _, provider := range providers {
p := provider.Name()
session, _ := Store.Get(req, p+SessionName)
value := session.Values[p]
if _, ok := value.(string); ok {
return p, nil
}
}
if provider == "" {
provider = req.URL.Query().Get(":provider")
// try to get it from the url param "provider"
if p := req.URL.Query().Get("provider"); p != "" {
return p, nil
}
if provider == "" {
return provider, errors.New("you must select a provider")
// try to get it from the url param ":provider"
if p := req.URL.Query().Get(":provider"); p != "" {
return p, nil
}
// try to get it from the context's value of "provider" key
if p, ok := mux.Vars(req)["provider"]; ok {
return p, nil
}
return provider, nil
// try to get it from the go-context's value of "provider" key
if p, ok := req.Context().Value("provider").(string); ok {
return p, nil
}
// if not found then return an empty string with the corresponding error
return "", errors.New("you must select a provider")
}
func storeInSession(key string, value string, req *http.Request, res http.ResponseWriter) error {
session, _ := Store.Get(req, key + SessionName)
session, _ := Store.Get(req, SessionName)
session.Values[key] = value
if err := updateSessionValue(session, key, value); err != nil {
return err
}
return session.Save(req, res)
}
func getFromSession(key string, req *http.Request) (string, error) {
session, _ := Store.Get(req, key + SessionName)
session, _ := Store.Get(req, SessionName)
value, err := getSessionValue(session, key)
if err != nil {
return "", errors.New("could not find a matching session for this request")
}
return value, nil
}
func getSessionValue(session *sessions.Session, key string) (string, error) {
value := session.Values[key]
if value == nil {
return "", errors.New("could not find a matching session for this request")
return "", fmt.Errorf("could not find a matching session for this request")
}
return value.(string), nil
}
rdata := strings.NewReader(value.(string))
r, err := gzip.NewReader(rdata)
if err != nil {
return "", err
}
s, err := ioutil.ReadAll(r)
if err != nil {
return "", err
}
return string(s), nil
}
func updateSessionValue(session *sessions.Session, key, value string) error {
var b bytes.Buffer
gz := gzip.NewWriter(&b)
if _, err := gz.Write([]byte(value)); err != nil {
return err
}
if err := gz.Flush(); err != nil {
return err
}
if err := gz.Close(); err != nil {
return err
}
session.Values[key] = b.String()
return nil
}

@ -9,9 +9,9 @@ import (
"net/http"
"net/url"
"fmt"
"github.com/markbates/goth"
"golang.org/x/oauth2"
"fmt"
)
const (
@ -26,10 +26,10 @@ const (
// one manually.
func New(clientKey, secret, callbackURL string, scopes ...string) *Provider {
p := &Provider{
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "bitbucket",
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "bitbucket",
}
p.config = newConfig(p, scopes)
return p
@ -125,7 +125,7 @@ func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
func userFromReader(reader io.Reader, user *goth.User) error {
u := struct {
ID string `json:"uuid"`
ID string `json:"uuid"`
Links struct {
Avatar struct {
URL string `json:"href"`

@ -8,15 +8,16 @@ import (
"net/http"
"strings"
"fmt"
"github.com/markbates/goth"
"golang.org/x/oauth2"
"fmt"
)
const (
authURL = "https://www.dropbox.com/1/oauth2/authorize"
tokenURL = "https://api.dropbox.com/1/oauth2/token"
accountURL = "https://api.dropbox.com/1/account/info"
authURL = "https://www.dropbox.com/oauth2/authorize"
tokenURL = "https://api.dropbox.com/oauth2/token"
accountURL = "https://api.dropbox.com/2/users/get_current_account"
)
// Provider is the implementation of `goth.Provider` for accessing Dropbox.
@ -40,10 +41,10 @@ type Session struct {
// create one manually.
func New(clientKey, secret, callbackURL string, scopes ...string) *Provider {
p := &Provider{
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "dropbox",
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "dropbox",
}
p.config = newConfig(p, scopes)
return p
@ -86,7 +87,7 @@ func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName)
}
req, err := http.NewRequest("GET", accountURL, nil)
req, err := http.NewRequest("POST", accountURL, nil)
if err != nil {
return user, err
}
@ -161,7 +162,7 @@ func newConfig(p *Provider, scopes []string) *oauth2.Config {
func userFromReader(r io.Reader, user *goth.User) error {
u := struct {
Name string `json:"display_name"`
Name string `json:"display_name"`
NameDetails struct {
NickName string `json:"familiar_name"`
} `json:"name_details"`

@ -11,12 +11,12 @@ import (
"net/http"
"net/url"
"github.com/markbates/goth"
"golang.org/x/oauth2"
"fmt"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"github.com/markbates/goth"
"golang.org/x/oauth2"
)
const (
@ -30,10 +30,10 @@ const (
// one manually.
func New(clientKey, secret, callbackURL string, scopes ...string) *Provider {
p := &Provider{
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "facebook",
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "facebook",
}
p.config = newConfig(p, scopes)
return p
@ -129,7 +129,7 @@ func userFromReader(reader io.Reader, user *goth.User) error {
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Link string `json:"link"`
Picture struct {
Picture struct {
Data struct {
URL string `json:"url"`
} `json:"data"`

@ -11,9 +11,9 @@ import (
"net/url"
"strconv"
"fmt"
"github.com/markbates/goth"
"golang.org/x/oauth2"
"fmt"
)
// These vars define the Authentication, Token, and Profile URLS for Gitlab. If

@ -11,9 +11,9 @@ import (
"net/url"
"strings"
"fmt"
"github.com/markbates/goth"
"golang.org/x/oauth2"
"fmt"
)
const (
@ -27,10 +27,10 @@ const (
// one manually.
func New(clientKey, secret, callbackURL string, scopes ...string) *Provider {
p := &Provider{
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "gplus",
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "gplus",
}
p.config = newConfig(p, scopes)
return p

@ -1,17 +1,17 @@
package openidConnect
import (
"net/http"
"strings"
"fmt"
"encoding/json"
"bytes"
"encoding/base64"
"io/ioutil"
"encoding/json"
"errors"
"golang.org/x/oauth2"
"fmt"
"github.com/markbates/goth"
"golang.org/x/oauth2"
"io/ioutil"
"net/http"
"strings"
"time"
"bytes"
)
const (
@ -89,14 +89,14 @@ func New(clientKey, secret, callbackURL, openIDAutoDiscoveryURL string, scopes .
Secret: secret,
CallbackURL: callbackURL,
UserIdClaims: []string{subjectClaim},
NameClaims: []string{NameClaim},
NickNameClaims: []string{NicknameClaim, PreferredUsernameClaim},
EmailClaims: []string{EmailClaim},
AvatarURLClaims:[]string{PictureClaim},
FirstNameClaims:[]string{GivenNameClaim},
LastNameClaims: []string{FamilyNameClaim},
LocationClaims: []string{AddressClaim},
UserIdClaims: []string{subjectClaim},
NameClaims: []string{NameClaim},
NickNameClaims: []string{NicknameClaim, PreferredUsernameClaim},
EmailClaims: []string{EmailClaim},
AvatarURLClaims: []string{PictureClaim},
FirstNameClaims: []string{GivenNameClaim},
LastNameClaims: []string{FamilyNameClaim},
LocationClaims: []string{AddressClaim},
providerName: "openid-connect",
}

@ -1,12 +1,12 @@
package openidConnect
import (
"encoding/json"
"errors"
"github.com/markbates/goth"
"encoding/json"
"golang.org/x/oauth2"
"strings"
"time"
"golang.org/x/oauth2"
)
// Session stores data during the auth process with the OpenID Connect provider.

@ -9,10 +9,11 @@ import (
"io/ioutil"
"net/http"
"fmt"
"github.com/markbates/goth"
"github.com/mrjones/oauth"
"golang.org/x/oauth2"
"fmt"
)
var (
@ -30,10 +31,10 @@ var (
// If you'd like to use authenticate instead of authorize, use NewAuthenticate instead.
func New(clientKey, secret, callbackURL string) *Provider {
p := &Provider{
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "twitter",
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "twitter",
}
p.consumer = newConsumer(p, authorizeURL)
return p
@ -43,10 +44,10 @@ func New(clientKey, secret, callbackURL string) *Provider {
// NewAuthenticate uses the authenticate URL instead of the authorize URL.
func NewAuthenticate(clientKey, secret, callbackURL string) *Provider {
p := &Provider{
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "twitter",
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "twitter",
}
p.consumer = newConsumer(p, authenticateURL)
return p
@ -107,7 +108,7 @@ func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
response, err := p.consumer.Get(
endpointProfile,
map[string]string{"include_entities": "false", "skip_status": "true"},
map[string]string{"include_entities": "false", "skip_status": "true", "include_email": "true"},
sess.AccessToken)
if err != nil {
return user, err
@ -126,6 +127,9 @@ func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
user.Name = user.RawData["name"].(string)
user.NickName = user.RawData["screen_name"].(string)
if user.RawData["email"] != nil {
user.Email = user.RawData["email"].(string)
}
user.Description = user.RawData["description"].(string)
user.AvatarURL = user.RawData["profile_image_url"].(string)
user.UserID = user.RawData["id_str"].(string)

58
vendor/vendor.json vendored

@ -666,64 +666,64 @@
"revisionTime": "2017-10-25T03:15:54Z"
},
{
"checksumSHA1": "O3KUfEXQPfdQ+tCMpP2RAIRJJqY=",
"checksumSHA1": "q9MD1ienC+kmKq5i51oAktQEV1E=",
"path": "github.com/markbates/goth",
"revision": "90362394a367f9d77730911973462a53d69662ba",
"revisionTime": "2017-02-23T14:12:10Z"
"revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2018-02-15T02:27:40Z"
},
{
"checksumSHA1": "MkFKwLV3icyUo4oP0BgEs+7+R1Y=",
"checksumSHA1": "+nosptSgGb2qCAR6CSHV2avwmNg=",
"path": "github.com/markbates/goth/gothic",
"revision": "90362394a367f9d77730911973462a53d69662ba",
"revisionTime": "2017-02-23T14:12:10Z"
"revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2018-02-15T02:27:40Z"
},
{
"checksumSHA1": "crNSlQADjX6hcxykON2tFCqY4iw=",
"checksumSHA1": "pJ+Cws/TU22K6tZ/ALFOvvH1K5U=",
"path": "github.com/markbates/goth/providers/bitbucket",
"revision": "90362394a367f9d77730911973462a53d69662ba",
"revisionTime": "2017-02-23T14:12:10Z"
"revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2018-02-15T02:27:40Z"
},
{
"checksumSHA1": "1Kp4DKkJNVn135Xg8H4a6CFBNy8=",
"checksumSHA1": "bKokLof0Pkk5nEhW8NdbfcVzuqk=",
"path": "github.com/markbates/goth/providers/dropbox",
"revision": "90362394a367f9d77730911973462a53d69662ba",
"revisionTime": "2017-02-23T14:12:10Z"
"revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2018-02-15T02:27:40Z"
},
{
"checksumSHA1": "cGs1da29iOBJh5EAH0icKDbN8CA=",
"checksumSHA1": "VzbroIA9R00Ig3iGnOlZLU7d4ls=",
"path": "github.com/markbates/goth/providers/facebook",
"revision": "90362394a367f9d77730911973462a53d69662ba",
"revisionTime": "2017-02-23T14:12:10Z"
"revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2018-02-15T02:27:40Z"
},
{
"checksumSHA1": "P6nBZ850aaekpOcoXNdRhK86bH8=",
"path": "github.com/markbates/goth/providers/github",
"revision": "90362394a367f9d77730911973462a53d69662ba",
"revisionTime": "2017-02-23T14:12:10Z"
"revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2018-02-15T02:27:40Z"
},
{
"checksumSHA1": "o/109paSRy9HqV87gR4zUZMMSzs=",
"checksumSHA1": "ld488t+yGoTwtmiCSSggEX4fxVk=",
"path": "github.com/markbates/goth/providers/gitlab",
"revision": "90362394a367f9d77730911973462a53d69662ba",
"revisionTime": "2017-02-23T14:12:10Z"
"revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2018-02-15T02:27:40Z"
},
{
"checksumSHA1": "cX6kR9y94BWFZvI/7UFrsFsP3FQ=",
"checksumSHA1": "qXEulD7vnwY9hFrxh91Pm5YrvTM=",
"path": "github.com/markbates/goth/providers/gplus",
"revision": "90362394a367f9d77730911973462a53d69662ba",
"revisionTime": "2017-02-23T14:12:10Z"
"revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2018-02-15T02:27:40Z"
},
{
"checksumSHA1": "sMYKhqAUZXM1+T/TjlMhWh8Vveo=",
"checksumSHA1": "wsOBzyp4LKDhfCPmX1LLP7T0S3U=",
"path": "github.com/markbates/goth/providers/openidConnect",
"revision": "90362394a367f9d77730911973462a53d69662ba",
"revisionTime": "2017-02-23T14:12:10Z"
"revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2018-02-15T02:27:40Z"
},
{
"checksumSHA1": "1w0V6jYXaGlEtZcMeYTOAAucvgw=",
"checksumSHA1": "o6RqMbbE8QNZhNT9TsAIRMPI8tg=",
"path": "github.com/markbates/goth/providers/twitter",
"revision": "90362394a367f9d77730911973462a53d69662ba",
"revisionTime": "2017-02-23T14:12:10Z"
"revision": "bc7deaf077a50416cf3a23aa5903d2a7b5a30ada",
"revisionTime": "2018-02-15T02:27:40Z"
},
{
"checksumSHA1": "61HNjGetaBoMp8HBOpuEZRSim8g=",

Loading…
Cancel
Save