mirror of https://github.com/matrix-org/go-neb.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
309 lines
8.4 KiB
309 lines
8.4 KiB
// 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}
|
|
})
|
|
}
|