|
|
// Package github implements OAuth2 support for github.com
package github
import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "io/ioutil" "net/http" "net/url"
"github.com/google/go-github/github" "github.com/matrix-org/go-neb/database" "github.com/matrix-org/go-neb/services/github/client" "github.com/matrix-org/go-neb/types" log "github.com/sirupsen/logrus" "maunium.net/go/mautrix/id" )
// RealmType of the Github Realm
const RealmType = "github"
// Realm can handle OAuth processes with github.com
//
// Example request:
// {
// "ClientSecret": "YOUR_CLIENT_SECRET",
// "ClientID": "YOUR_CLIENT_ID"
// }
type Realm struct { id string redirectURL string
// The client secret for this Github application.
ClientSecret string // The client ID for this Github application.
ClientID string // Optional. The URL to redirect the client to after authentication.
StarterLink string }
// Session represents an authenticated github session
type Session struct { id string userID id.UserID realmID string
// AccessToken is the github access token for the user
AccessToken string // Scopes are the set of *ALLOWED* scopes (which may not be the same as the requested scopes)
Scopes string // Optional. The client-supplied URL to redirect them to after the auth process is complete.
ClientsRedirectURL string }
// AuthRequest is a request for authenticating with github.com
type AuthRequest struct { // Optional. The URL to redirect to after authentication.
RedirectURL string }
// AuthResponse is a response to an AuthRequest.
type AuthResponse struct { // The URL to visit to perform OAuth on github.com
URL string }
// Authenticated returns true if the user has completed the auth process
func (s *Session) Authenticated() bool { return s.AccessToken != "" }
// Info returns a list of possible repositories that this session can integrate with.
func (s *Session) Info() interface{} { logger := log.WithFields(log.Fields{ "user_id": s.userID, "realm_id": s.realmID, }) cli := client.New(s.AccessToken) var repos []client.TrimmedRepository
opts := &github.RepositoryListOptions{ Type: "all", ListOptions: github.ListOptions{ PerPage: 100, }, } for { // query for a list of possible projects
rs, resp, err := cli.Repositories.List(context.Background(), "", opts) if err != nil { logger.WithError(err).Print("Failed to query github projects on github.com") return nil }
for _, r := range rs { repos = append(repos, client.TrimRepository(r)) }
if resp.NextPage == 0 { break } opts.ListOptions.Page = resp.NextPage logger.Print("Session.Info() Next => ", resp.NextPage) } logger.Print("Session.Info() Returning ", len(repos), " repos")
return struct { Repos []client.TrimmedRepository }{repos} }
// UserID returns the user_id who authorised with Github
func (s *Session) UserID() id.UserID { return s.userID }
// RealmID returns the realm ID of the realm which performed the authentication
func (s *Session) RealmID() string { return s.realmID }
// ID returns the session ID
func (s *Session) ID() string { return s.id }
// ID returns the realm ID
func (r *Realm) ID() string { return r.id }
// Type is github
func (r *Realm) Type() string { return RealmType }
// Init does nothing.
func (r *Realm) Init() error { return nil }
// Register does nothing.
func (r *Realm) Register() error { return nil }
// RequestAuthSession generates an OAuth2 URL for this user to auth with github via.
// The request body is of type "github.AuthRequest". The response is of type "github.AuthResponse".
//
// Request example:
// {
// "RedirectURL": "https://optional-url.com/to/redirect/to/after/auth"
// }
//
// Response example:
// {
// "URL": "https://github.com/login/oauth/authorize?client_id=abcdef&client_secret=acascacac...."
// }
func (r *Realm) RequestAuthSession(userID id.UserID, req json.RawMessage) interface{} { state, err := randomString(10) if err != nil { log.WithError(err).Print("Failed to generate state param") return nil }
u, _ := url.Parse("https://github.com/login/oauth/authorize") q := u.Query() q.Set("client_id", r.ClientID) q.Set("client_secret", r.ClientSecret) q.Set("state", state) q.Set("redirect_uri", r.redirectURL) q.Set("scope", "admin:repo_hook,admin:org_hook,repo") u.RawQuery = q.Encode() session := &Session{ id: state, // key off the state for redirects
userID: userID, realmID: r.ID(), }
// check if they supplied a redirect URL
var reqBody AuthRequest if err = json.Unmarshal(req, &reqBody); err != nil { log.WithError(err).Print("Failed to decode request body") return nil } session.ClientsRedirectURL = reqBody.RedirectURL log.WithFields(log.Fields{ "clients_redirect_url": session.ClientsRedirectURL, "redirect_url": u.String(), }).Print("RequestAuthSession: Performing redirect")
_, err = database.GetServiceDB().StoreAuthSession(session) if err != nil { log.WithError(err).Print("Failed to store new auth session") return nil }
return &AuthResponse{u.String()} }
// OnReceiveRedirect processes OAuth redirect requests from Github
func (r *Realm) OnReceiveRedirect(w http.ResponseWriter, req *http.Request) { // parse out params from the request
code := req.URL.Query().Get("code") state := req.URL.Query().Get("state") logger := log.WithFields(log.Fields{ "state": state, }) logger.WithField("code", code).Print("GithubRealm: OnReceiveRedirect") if code == "" || state == "" { failWith(logger, w, 400, "code and state are required", nil) return } // load the session (we keyed off the state param)
session, err := database.GetServiceDB().LoadAuthSessionByID(r.ID(), state) if err != nil { // most likely cause
failWith(logger, w, 400, "Provided ?state= param is not recognised.", err) return } ghSession, ok := session.(*Session) if !ok { failWith(logger, w, 500, "Unexpected session found.", nil) return } logger.WithField("user_id", ghSession.UserID()).Print("Mapped redirect to user")
if ghSession.AccessToken != "" && ghSession.Scopes != "" { r.redirectOr(w, 400, "You have already authenticated with Github", logger, ghSession) return }
// exchange code for access_token
res, err := http.PostForm("https://github.com/login/oauth/access_token", url.Values{"client_id": {r.ClientID}, "client_secret": {r.ClientSecret}, "code": {code}}) if err != nil { failWith(logger, w, 502, "Failed to exchange code for token", err) return } defer res.Body.Close() body, err := ioutil.ReadAll(res.Body) if err != nil { failWith(logger, w, 502, "Failed to read token response", err) return } vals, err := url.ParseQuery(string(body)) if err != nil { failWith(logger, w, 502, "Failed to parse token response", err) return }
// update database and return
ghSession.AccessToken = vals.Get("access_token") ghSession.Scopes = vals.Get("scope") logger.WithField("scope", ghSession.Scopes).Print("Scopes granted.") _, err = database.GetServiceDB().StoreAuthSession(ghSession) if err != nil { failWith(logger, w, 500, "Failed to persist session", err) return } r.redirectOr( w, 200, "You have successfully linked your Github account to "+ghSession.UserID().String(), logger, ghSession, ) }
func (r *Realm) redirectOr(w http.ResponseWriter, code int, msg string, logger *log.Entry, ghSession *Session) { if ghSession.ClientsRedirectURL != "" { w.Header().Set("Location", ghSession.ClientsRedirectURL) w.WriteHeader(302) // technically don't need a body but *shrug*
w.Write([]byte(ghSession.ClientsRedirectURL)) } else { failWith(logger, w, code, msg, nil) } }
// AuthSession returns a Github Session for this user
func (r *Realm) AuthSession(id string, userID id.UserID, realmID string) types.AuthSession { return &Session{ id: id, userID: userID, realmID: realmID, } }
func failWith(logger *log.Entry, w http.ResponseWriter, code int, msg string, err error) { logger.WithError(err).Print(msg) w.WriteHeader(code) w.Write([]byte(msg)) }
// Generate a cryptographically secure pseudorandom string with the given number of bytes (length).
// Returns a hex string of the bytes.
func randomString(length int) (string, error) { b := make([]byte, length) _, err := rand.Read(b) if err != nil { return "", err } return hex.EncodeToString(b), nil }
func init() { types.RegisterAuthRealm(func(realmID, redirectURL string) types.AuthRealm { return &Realm{id: realmID, redirectURL: redirectURL} }) }
|