diff --git a/README.md b/README.md index 6bf68f3..446bc46 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,22 @@ JIRA installation. Once that is complete, users can OAuth on the target JIRA ins ### Make a request for JIRA Auth -TODO +``` +curl -X POST localhost:4050/admin/requestAuthSession --data-binary '{ + "RealmID": "jirarealm", + "UserID": "@your_user_id:localhost", + "Config": { + } +}' +``` +Returns: +```json +{ + "URL":"https://jira.somewhere.com/plugins/servlet/oauth/authorize?oauth_token=7yeuierbgweguiegrTbOT" +} +``` + +Follow this link and grant access for NEB to act on your behalf. ### Create a JIRA bot diff --git a/src/github.com/matrix-org/go-neb/api.go b/src/github.com/matrix-org/go-neb/api.go index 620fc56..6904a85 100644 --- a/src/github.com/matrix-org/go-neb/api.go +++ b/src/github.com/matrix-org/go-neb/api.go @@ -33,6 +33,10 @@ func (h *requestAuthSessionHandler) OnIncomingRequest(req *http.Request) (interf if err := json.NewDecoder(req.Body).Decode(&body); err != nil { return nil, &errors.HTTPError{err, "Error parsing request JSON", 400} } + log.WithFields(log.Fields{ + "realm_id": body.RealmID, + "user_id": body.UserID, + }).Print("Incoming auth session request") if body.UserID == "" || body.RealmID == "" || body.Config == nil { return nil, &errors.HTTPError{nil, `Must supply a "UserID", a "RealmID" and a "Config"`, 400} @@ -65,6 +69,9 @@ func (rh *realmRedirectHandler) handle(w http.ResponseWriter, req *http.Request) w.WriteHeader(404) return } + log.WithFields(log.Fields{ + "realm_id": realmID, + }).Print("Incoming realm redirect request") realm.OnReceiveRedirect(w, req) } diff --git a/src/github.com/matrix-org/go-neb/realms/github/github.go b/src/github.com/matrix-org/go-neb/realms/github/github.go index f192495..d6e3ef0 100644 --- a/src/github.com/matrix-org/go-neb/realms/github/github.go +++ b/src/github.com/matrix-org/go-neb/realms/github/github.go @@ -13,10 +13,10 @@ import ( ) type githubRealm struct { - id string - ClientSecret string - ClientID string - RedirectBaseURI string + id string + redirectURL string + ClientSecret string + ClientID string } // GithubSession represents an authenticated github session @@ -68,8 +68,7 @@ func (r *githubRealm) RequestAuthSession(userID string, req json.RawMessage) int q.Set("client_id", r.ClientID) q.Set("client_secret", r.ClientSecret) q.Set("state", state) - // TODO: Path is from goneb.go - we should probably factor it out. - q.Set("redirect_uri", r.RedirectBaseURI+"/realms/redirects/"+r.ID()) + q.Set("redirect_uri", r.redirectURL) u.RawQuery = q.Encode() session := &GithubSession{ id: state, // key off the state for redirects @@ -171,7 +170,7 @@ func randomString(length int) (string, error) { } func init() { - types.RegisterAuthRealm(func(realmID string) types.AuthRealm { - return &githubRealm{id: realmID} + types.RegisterAuthRealm(func(realmID, redirectURL string) types.AuthRealm { + return &githubRealm{id: realmID, redirectURL: redirectURL} }) } diff --git a/src/github.com/matrix-org/go-neb/realms/jira/jira.go b/src/github.com/matrix-org/go-neb/realms/jira/jira.go index 795f71e..020f8fc 100644 --- a/src/github.com/matrix-org/go-neb/realms/jira/jira.go +++ b/src/github.com/matrix-org/go-neb/realms/jira/jira.go @@ -9,6 +9,7 @@ import ( log "github.com/Sirupsen/logrus" "github.com/andygrunwald/go-jira" "github.com/dghubble/oauth1" + "github.com/matrix-org/go-neb/database" "github.com/matrix-org/go-neb/realms/jira/urls" "github.com/matrix-org/go-neb/types" "golang.org/x/net/context" @@ -17,6 +18,7 @@ import ( type jiraRealm struct { id string + redirectURL string privateKey *rsa.PrivateKey JIRAEndpoint string Server string // clobbered based on /serverInfo request @@ -28,6 +30,33 @@ type jiraRealm struct { PrivateKeyPEM string } +// JIRASession represents a single authentication session between a user and a JIRA endpoint. +// The endpoint is dictated by the realm ID. +type JIRASession struct { + id string // request token + userID string + realmID string + RequestSecret string + AccessToken string + AccessSecret string +} + +// UserID returns the ID of the user performing the authentication. +func (s *JIRASession) UserID() string { + return s.userID +} + +// RealmID returns the JIRA realm ID which created this session. +func (s *JIRASession) RealmID() string { + return s.realmID +} + +// ID returns the OAuth1 request_token which is used when looking up sessions in the redirect +// handler. +func (s *JIRASession) ID() string { + return s.id +} + func (r *jiraRealm) ID() string { return r.id } @@ -44,21 +73,12 @@ func (r *jiraRealm) Register() error { return errors.New("JIRAEndpoint must be specified") } - // Make sure the private key PEM is actually a private key. - err := r.parsePrivateKey() - if err != nil { - return err - } - - // Parse the messy input URL into a canonicalised form. - ju, err := urls.ParseJIRAURL(r.JIRAEndpoint) - if err != nil { + if err := r.ensureInited(); err != nil { return err } - r.JIRAEndpoint = ju.Base // Check to see if JIRA endpoint is valid by pinging an endpoint - cli, err := r.jiraClient(ju, "", true) + cli, err := r.jiraClient(r.JIRAEndpoint, "", true) if err != nil { return err } @@ -67,7 +87,7 @@ func (r *jiraRealm) Register() error { return err } log.WithFields(log.Fields{ - "jira_url": ju.Base, + "jira_url": r.JIRAEndpoint, "title": info.ServerTitle, "version": info.Version, }).Print("Found JIRA endpoint") @@ -78,20 +98,99 @@ func (r *jiraRealm) Register() error { } func (r *jiraRealm) RequestAuthSession(userID string, req json.RawMessage) interface{} { - return nil + logger := log.WithField("jira_url", r.JIRAEndpoint) + if err := r.ensureInited(); err != nil { + logger.WithError(err).Print("Failed to init realm") + return nil + } + authConfig := r.oauth1Config(r.JIRAEndpoint) + reqToken, reqSec, err := authConfig.RequestToken() + if err != nil { + logger.WithError(err).Print("Failed to request auth token") + return nil + } + logger.WithField("req_token", reqToken).Print("Received request token") + authURL, err := authConfig.AuthorizationURL(reqToken) + if err != nil { + logger.WithError(err).Print("Failed to create authorization URL") + return nil + } + + _, err = database.GetServiceDB().StoreAuthSession(&JIRASession{ + id: reqToken, + userID: userID, + realmID: r.id, + RequestSecret: reqSec, + }) + if err != nil { + log.WithError(err).Print("Failed to store new auth session") + return nil + } + + return &struct { + URL string + }{authURL.String()} } func (r *jiraRealm) OnReceiveRedirect(w http.ResponseWriter, req *http.Request) { + logger := log.WithField("jira_url", r.JIRAEndpoint) + if err := r.ensureInited(); err != nil { + failWith(logger, w, 500, "Failed to initialise realm", err) + return + } + + requestToken, verifier, err := oauth1.ParseAuthorizationCallback(req) + if err != nil { + failWith(logger, w, 400, "Failed to parse authorization callback", err) + return + } + logger = logger.WithField("req_token", requestToken) + logger.Print("Received authorization callback") + session, err := database.GetServiceDB().LoadAuthSessionByID(r.id, requestToken) + if err != nil { + failWith(logger, w, 400, "Unrecognised request token", err) + return + } + jiraSession, ok := session.(*JIRASession) + if !ok { + failWith(logger, w, 500, "Unexpected session type found.", nil) + return + } + logger = logger.WithField("user_id", jiraSession.UserID()) + logger.Print("Retrieved auth session for user") + + oauthConfig := r.oauth1Config(r.JIRAEndpoint) + accessToken, accessSecret, err := oauthConfig.AccessToken(requestToken, jiraSession.RequestSecret, verifier) + if err != nil { + failWith(logger, w, 502, "Failed exchange for access token.", err) + return + } + logger.Print("Exchanged for access token") + + jiraSession.AccessToken = accessToken + jiraSession.AccessSecret = accessSecret + + _, err = database.GetServiceDB().StoreAuthSession(jiraSession) + if err != nil { + failWith(logger, w, 500, "Failed to persist JIRA session", err) + return + } + w.WriteHeader(200) + w.Write([]byte("OK!")) } func (r *jiraRealm) AuthSession(id, userID, realmID string) types.AuthSession { - return nil + return &JIRASession{ + id: id, + userID: userID, + realmID: realmID, + } } // jiraClient returns an authenticated jira.Client for the given userID. Returns an unauthenticated // client if allowUnauth is true and no authenticated session is found, else returns an error. -func (r *jiraRealm) jiraClient(u urls.JIRAURL, userID string, allowUnauth bool) (*jira.Client, error) { +func (r *jiraRealm) jiraClient(jiraBaseURL, userID string, allowUnauth bool) (*jira.Client, error) { // TODO: Check if user has an auth session. Requires access token+secret hasAuthSession := false @@ -99,38 +198,39 @@ func (r *jiraRealm) jiraClient(u urls.JIRAURL, userID string, allowUnauth bool) // make an authenticated client var cli *jira.Client - auth := &oauth1.Config{ - ConsumerKey: r.ConsumerKey, - ConsumerSecret: r.ConsumerSecret, - CallbackURL: u.Base + "realms/redirect/" + r.id, - // TODO: In JIRA Cloud, the Authorization URL is only the Instance BASE_URL: - // https://BASE_URL.atlassian.net. - // It also does not require the + "/plugins/servlet/oauth/authorize" - // We should probably check the provided JIRA base URL to see if it is a cloud one - // then adjust accordingly. - Endpoint: oauth1.Endpoint{ - RequestTokenURL: u.Base + "plugins/servlet/oauth/request-token", - AuthorizeURL: u.Base + "plugins/servlet/oauth/authorize", - AccessTokenURL: u.Base + "plugins/servlet/oauth/access-token", - }, - Signer: &oauth1.RSASigner{ - PrivateKey: r.privateKey, - }, - } + auth := r.oauth1Config(jiraBaseURL) httpClient := auth.Client(context.TODO(), oauth1.NewToken("access_tokenTODO", "access_secretTODO")) - cli, err := jira.NewClient(httpClient, u.Base) + cli, err := jira.NewClient(httpClient, jiraBaseURL) return cli, err } else if allowUnauth { // make an unauthenticated client - cli, err := jira.NewClient(nil, u.Base) + cli, err := jira.NewClient(nil, jiraBaseURL) return cli, err } else { return nil, errors.New("No authenticated session found for " + userID) } } +func (r *jiraRealm) ensureInited() error { + if err := r.parsePrivateKey(); err != nil { + log.WithError(err).Print("Failed to parse private key") + return err + } + // Parse the messy input URL into a canonicalised form. + ju, err := urls.ParseJIRAURL(r.JIRAEndpoint) + if err != nil { + log.WithError(err).Print("Failed to parse JIRA endpoint") + return err + } + r.JIRAEndpoint = ju.Base + return nil +} + func (r *jiraRealm) parsePrivateKey() error { + if r.privateKey != nil { + return nil + } pk, err := loadPrivateKey(r.PrivateKeyPEM) if err != nil { return err @@ -144,6 +244,27 @@ func (r *jiraRealm) parsePrivateKey() error { return nil } +func (r *jiraRealm) oauth1Config(jiraBaseURL string) *oauth1.Config { + return &oauth1.Config{ + ConsumerKey: r.ConsumerKey, + ConsumerSecret: r.ConsumerSecret, + CallbackURL: r.redirectURL, + // TODO: In JIRA Cloud, the Authorization URL is only the Instance BASE_URL: + // https://BASE_URL.atlassian.net. + // It also does not require the + "/plugins/servlet/oauth/authorize" + // We should probably check the provided JIRA base URL to see if it is a cloud one + // then adjust accordingly. + Endpoint: oauth1.Endpoint{ + RequestTokenURL: jiraBaseURL + "plugins/servlet/oauth/request-token", + AuthorizeURL: jiraBaseURL + "plugins/servlet/oauth/authorize", + AccessTokenURL: jiraBaseURL + "plugins/servlet/oauth/access-token", + }, + Signer: &oauth1.RSASigner{ + PrivateKey: r.privateKey, + }, + } +} + func loadPrivateKey(privKeyPEM string) (*rsa.PrivateKey, error) { // Decode PEM to grab the private key type block, _ := pem.Decode([]byte(privKeyPEM)) @@ -183,15 +304,21 @@ type jiraServiceInfo struct { func jiraServerInfo(cli *jira.Client) (*jiraServiceInfo, error) { var jsi jiraServiceInfo req, _ := cli.NewRequest("GET", "rest/api/2/serverInfo", nil) - _, err := cli.Do(req, &jsi) - if err != nil { + if _, err := cli.Do(req, &jsi); err != nil { return nil, err } return &jsi, nil } +// TODO: Github has this as well, maybe factor it out? +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)) +} + func init() { - types.RegisterAuthRealm(func(realmID string) types.AuthRealm { - return &jiraRealm{id: realmID} + types.RegisterAuthRealm(func(realmID, redirectURL string) types.AuthRealm { + return &jiraRealm{id: realmID, redirectURL: redirectURL} }) } diff --git a/src/github.com/matrix-org/go-neb/types/types.go b/src/github.com/matrix-org/go-neb/types/types.go index c010064..da9a00e 100644 --- a/src/github.com/matrix-org/go-neb/types/types.go +++ b/src/github.com/matrix-org/go-neb/types/types.go @@ -87,11 +87,11 @@ type AuthRealm interface { RequestAuthSession(userID string, config json.RawMessage) interface{} } -var realmsByType = map[string]func(string) AuthRealm{} +var realmsByType = map[string]func(string, string) AuthRealm{} // RegisterAuthRealm registers a factory for creating AuthRealm instances. -func RegisterAuthRealm(factory func(string) AuthRealm) { - realmsByType[factory("").Type()] = factory +func RegisterAuthRealm(factory func(string, string) AuthRealm) { + realmsByType[factory("", "").Type()] = factory } // CreateAuthRealm creates an AuthRealm of the given type and realm ID. @@ -101,7 +101,8 @@ func CreateAuthRealm(realmID, realmType string) AuthRealm { if f == nil { return nil } - return f(realmID) + redirectURL := baseURL + "realms/redirects/" + realmID + return f(realmID, redirectURL) } // AuthSession represents a single authentication session between a user and