├── examples ├── github │ └── main.go └── reddit │ └── main.go ├── LICENSE ├── boltUtil.go ├── README.md └── ssgo.go /examples/github/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/captncraig/ssgo" 9 | "github.com/google/go-github/github" 10 | ) 11 | 12 | var gh ssgo.SSO 13 | 14 | func main() { 15 | gh = ssgo.NewGithub(os.Getenv("GH_CLIENT_ID"), os.Getenv("GH_CLIENT_SECRET"), "public_repo", "write:repo_hook") 16 | http.HandleFunc("/login", gh.RedirectToLogin) 17 | http.HandleFunc("/ghauth", gh.ExchangeCodeForToken) 18 | http.HandleFunc("/", gh.Route(loggedOut, loggedIn)) 19 | http.ListenAndServe(":5675", nil) 20 | } 21 | 22 | func loggedOut(w http.ResponseWriter, r *http.Request) { 23 | w.Header().Add("Content-Type", "text/html") 24 | io.WriteString(w, "
This site does stuff. Please Login with github
") 25 | } 26 | 27 | func loggedIn(w http.ResponseWriter, r *http.Request, cred *ssgo.Credentials) { 28 | user, _, err := github.NewClient(cred.Client).Users.Get("") 29 | if err != nil { 30 | io.WriteString(w, err.Error()) 31 | } 32 | io.WriteString(w, user.String()) 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Craig Peterson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/reddit/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/ioutil" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/captncraig/ssgo" 11 | ) 12 | 13 | var sso = ssgo.NewReddit(os.Getenv("REDDIT_ID"), os.Getenv("REDDIT_SECRET"), os.Getenv("REDDIT_REDIRECT"), "identity") 14 | 15 | func main() { 16 | http.HandleFunc("/login", sso.RedirectToLogin) 17 | http.HandleFunc("/redditAuth", sso.ExchangeCodeForToken) 18 | http.HandleFunc("/", sso.Route(loggedOut, loggedIn)) 19 | http.ListenAndServe(":5675", nil) 20 | } 21 | 22 | func loggedOut(w http.ResponseWriter, r *http.Request) { 23 | w.Header().Add("Content-Type", "text/html") 24 | io.WriteString(w, "This site does stuff. Please Login with reddit
") 25 | } 26 | 27 | func loggedIn(w http.ResponseWriter, r *http.Request, c *ssgo.Credentials) { 28 | fmt.Println(c.Token.RefreshToken) 29 | resp, err := c.Client.Get("https://oauth.reddit.com/api/v1/me") 30 | if err != nil { 31 | io.WriteString(w, err.Error()) 32 | return 33 | } 34 | body, err := ioutil.ReadAll(resp.Body) 35 | if err != nil { 36 | io.WriteString(w, err.Error()) 37 | return 38 | } 39 | io.WriteString(w, string(body)) 40 | } 41 | -------------------------------------------------------------------------------- /boltUtil.go: -------------------------------------------------------------------------------- 1 | package ssgo 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/boltdb/bolt" 10 | ) 11 | 12 | var db *bolt.DB 13 | 14 | func init() { 15 | boltPath := os.Getenv("ssgo.boltdb") 16 | if boltPath == "" { 17 | boltPath = "ssgo.db" 18 | } 19 | var err error 20 | db, err = bolt.Open(boltPath, 0600, &bolt.Options{Timeout: 2 * time.Second}) 21 | if err != nil { 22 | panic(err.Error()) 23 | } 24 | } 25 | 26 | func StoreBoltJson(bucket string, key string, data interface{}) error { 27 | j, err := json.Marshal(data) 28 | if err != nil { 29 | return err 30 | } 31 | return db.Update(func(tx *bolt.Tx) error { 32 | b := tx.Bucket([]byte(bucket)) 33 | err := b.Put([]byte(key), j) 34 | return err 35 | }) 36 | } 37 | 38 | func LookupBoltJson(bucket string, key string, v interface{}) error { 39 | return db.View(func(tx *bolt.Tx) error { 40 | b := tx.Bucket([]byte(bucket)) 41 | data := b.Get([]byte(key)) 42 | if data == nil { 43 | return fmt.Errorf("Not found") 44 | } 45 | return json.Unmarshal(data, v) 46 | }) 47 | } 48 | 49 | func EnsureBoltBucketExists(bucket string) error { 50 | return db.Update(func(tx *bolt.Tx) error { 51 | _, err := tx.CreateBucketIfNotExists([]byte(bucket)) 52 | if err != nil { 53 | return fmt.Errorf("create bucket: %s", err) 54 | } 55 | return nil 56 | }) 57 | } 58 | 59 | func GetDb() *bolt.DB { 60 | return db 61 | } 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #ssgo 2 | 3 | This package aims to make it easy to build web applications in Go that use external sites as their primary user account system. 4 | 5 | ## Currently supported sites: 6 | - **github** 7 | - **reddit** 8 | 9 | ## Planned integrations: 10 | - **imgur** 11 | - **twitter** 12 | - **facebook** 13 | - **google** 14 | 15 | # Super easy github integration: 16 | 17 | `import "github.com/captncraig/ssgo/hub"` 18 | 19 | Make an sso object: 20 | 21 | cid, secret := os.Getenv("GH_CLIENT_ID"),os.Getenv("GH_CLIENT_SECRET") 22 | gh = ssgo.NewGithub(cid, secret, "public_repo", "write:repo_hook") 23 | 24 | Link the provided http handlers to whatever endpoint you want them to live at: 25 | 26 | http.HandleFunc("/login", gh.RedirectToLogin) 27 | http.HandleFunc("/ghauth", gh.ExchangeCodeForToken) 28 | 29 | Use the `Route` helper to direct traffic based on a user's cookie value: 30 | 31 | http.HandleFunc("/", gh.Route(loggedOut, loggedIn)) 32 | 33 | The appropriate handler will be invoked for requests, and if the user is logged in to github, you will receive a populated `Credentials` struct to your loggedIn handler. 34 | 35 | `c.Client` will give you an `http.Client` that you can use with [go-github](https://github.com/google/go-github) to make authenticated requests for that user. 36 | 37 | See [this example](https://github.com/captncraig/ssgo/blob/master/examples/github/main.go) for full working code. 38 | 39 | ## internals: 40 | Internally we store a randomly generated `authToken` cookie in the browser, which is a key into a boltDb database that stores the accessToken and some basic account info. You can control the db file name with the `ssgo.boltdb` environment variable if you so choose. 41 | 42 | If your application wants to use the same bolt db as the sso system, you can use the helpers in the ssgo package to load or store json to your own bucket. 43 | 44 | 45 | -------------------------------------------------------------------------------- /ssgo.go: -------------------------------------------------------------------------------- 1 | package ssgo 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "net/http" 7 | "time" 8 | 9 | "golang.org/x/oauth2" 10 | "golang.org/x/oauth2/github" 11 | ) 12 | 13 | //Like an http.HandleFunc, but accepts a credentials object as a third argument. 14 | type AuthenticatedHandler func(w http.ResponseWriter, r *http.Request, credentials *Credentials) 15 | 16 | //Core interface for working with a third-party website 17 | type SSO interface { 18 | //Redirect the request to the provider's authorization page 19 | RedirectToLogin(w http.ResponseWriter, r *http.Request) 20 | //Handle callback from authorization page. This needs to be hosted at the url that is registered with the provider. 21 | ExchangeCodeForToken(w http.ResponseWriter, r *http.Request) 22 | //Lookup the credentials for a given request from the cookie. Will return nil if no valid cookie is found. 23 | LookupToken(r *http.Request) *Credentials 24 | //Basic http handler that looks up the token for you and provides credentials to your handler. Credentials may be nil. 25 | Handle(handler AuthenticatedHandler) http.HandlerFunc 26 | //Select a handler based on whether the user has a valid cookie or not. 27 | Route(loggedOut http.HandlerFunc, loggedIn AuthenticatedHandler) http.HandlerFunc 28 | 29 | ClearCookie(w http.ResponseWriter) 30 | } 31 | 32 | type sso struct { 33 | conf *oauth2.Config 34 | states map[string]time.Time 35 | site string 36 | authOpts []oauth2.AuthCodeOption 37 | } 38 | 39 | //Container for a user's oauth credentials 40 | type Credentials struct { 41 | // Shortname of site they are authenticated with 42 | Site string 43 | // Oauth token for user. 44 | Token *oauth2.Token 45 | // Http client with oauth credentials ready to go. 46 | Client *http.Client 47 | } 48 | 49 | func NewGithub(clientId, clientSecret string, scopes ...string) SSO { 50 | return newSSO(clientId, clientSecret, "", "github", github.Endpoint, scopes, nil) 51 | } 52 | 53 | func NewReddit(clientId, clientSecret, redirectUri string, scopes ...string) SSO { 54 | endpoint := oauth2.Endpoint{ 55 | AuthURL: "https://www.reddit.com/api/v1/authorize", 56 | TokenURL: "https://www.reddit.com/api/v1/access_token", 57 | } 58 | return newSSO(clientId, clientSecret, redirectUri, "reddit", endpoint, scopes, []oauth2.AuthCodeOption{oauth2.SetAuthURLParam("duration", "permanent")}) 59 | } 60 | 61 | func newSSO(clientId, clientSecret, redirectUri, site string, endpoint oauth2.Endpoint, scopes []string, opts []oauth2.AuthCodeOption) SSO { 62 | conf := oauth2.Config{} 63 | conf.Endpoint = endpoint 64 | conf.ClientID = clientId 65 | conf.ClientSecret = clientSecret 66 | conf.Scopes = scopes 67 | if redirectUri != "" { 68 | conf.RedirectURL = redirectUri 69 | } 70 | s := &sso{ 71 | conf: &conf, 72 | states: map[string]time.Time{}, 73 | site: site, 74 | authOpts: opts, 75 | } 76 | EnsureBoltBucketExists(s.bucketName()) 77 | return s 78 | } 79 | 80 | func (s *sso) RedirectToLogin(w http.ResponseWriter, r *http.Request) { 81 | state := randSeq(10) 82 | s.states[state] = time.Now() 83 | http.Redirect(w, r, s.conf.AuthCodeURL(state, s.authOpts...), 302) 84 | } 85 | 86 | func (s *sso) ExchangeCodeForToken(w http.ResponseWriter, r *http.Request) { 87 | var err error 88 | defer func() { 89 | url := "/" 90 | if err != nil { 91 | url += "?ssoError=" + err.Error() 92 | } 93 | http.Redirect(w, r, url, 302) 94 | }() 95 | state := r.FormValue("state") 96 | if _, ok := s.states[state]; state == "" || !ok { 97 | err = fmt.Errorf("bad-state") 98 | return 99 | } 100 | code := r.FormValue("code") 101 | if code == "" { 102 | err = fmt.Errorf("no-code") 103 | return 104 | } 105 | tok, err := s.conf.Exchange(oauth2.NoContext, code) 106 | 107 | if err != nil { 108 | return 109 | } 110 | cookieVal := randSeq(25) 111 | err = StoreBoltJson(s.bucketName(), cookieVal, tok) 112 | if err != nil { 113 | return 114 | } 115 | http.SetCookie(w, &http.Cookie{Name: s.cookieName(), Value: cookieVal, Path: "/", Expires: time.Now().Add(90 * 24 * time.Hour)}) 116 | } 117 | 118 | func (s *sso) ClearCookie(w http.ResponseWriter) { 119 | c := &http.Cookie{Name: s.cookieName(), Value: "", Path: "/", Expires: time.Now().Add(-1 * time.Hour), MaxAge: -1} 120 | http.SetCookie(w, c) 121 | } 122 | 123 | func (s *sso) LookupToken(r *http.Request) *Credentials { 124 | cookie, err := r.Cookie(s.cookieName()) 125 | if err != nil { 126 | return nil 127 | } 128 | tok := oauth2.Token{} 129 | err = LookupBoltJson(s.bucketName(), cookie.Value, &tok) 130 | if err != nil || tok.AccessToken == "" { 131 | return nil 132 | } 133 | 134 | return &Credentials{ 135 | Site: s.site, 136 | Token: &tok, 137 | Client: s.conf.Client(oauth2.NoContext, &tok), 138 | } 139 | } 140 | 141 | func (s *sso) Handle(handler AuthenticatedHandler) http.HandlerFunc { 142 | return func(w http.ResponseWriter, r *http.Request) { 143 | tok := s.LookupToken(r) 144 | handler(w, r, tok) 145 | } 146 | } 147 | func (s *sso) Route(loggedOut http.HandlerFunc, loggedIn AuthenticatedHandler) http.HandlerFunc { 148 | return func(w http.ResponseWriter, r *http.Request) { 149 | tok := s.LookupToken(r) 150 | if tok == nil { 151 | loggedOut(w, r) 152 | } else { 153 | loggedIn(w, r, tok) 154 | } 155 | } 156 | } 157 | 158 | func (s *sso) bucketName() string { 159 | return s.site + "Tokens" 160 | } 161 | func (s *sso) cookieName() string { 162 | return s.site + "Tok" 163 | } 164 | 165 | var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 166 | var r = rand.New(rand.NewSource(time.Now().UnixNano())) 167 | 168 | func randSeq(n int) string { 169 | b := make([]rune, n) 170 | for i := range b { 171 | b[i] = letters[r.Intn(len(letters))] 172 | } 173 | return string(b) 174 | } 175 | --------------------------------------------------------------------------------