|
@ -9,6 +9,7 @@ import ( |
|
|
log "github.com/Sirupsen/logrus" |
|
|
log "github.com/Sirupsen/logrus" |
|
|
"github.com/andygrunwald/go-jira" |
|
|
"github.com/andygrunwald/go-jira" |
|
|
"github.com/dghubble/oauth1" |
|
|
"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/realms/jira/urls" |
|
|
"github.com/matrix-org/go-neb/types" |
|
|
"github.com/matrix-org/go-neb/types" |
|
|
"golang.org/x/net/context" |
|
|
"golang.org/x/net/context" |
|
@ -17,6 +18,7 @@ import ( |
|
|
|
|
|
|
|
|
type jiraRealm struct { |
|
|
type jiraRealm struct { |
|
|
id string |
|
|
id string |
|
|
|
|
|
redirectURL string |
|
|
privateKey *rsa.PrivateKey |
|
|
privateKey *rsa.PrivateKey |
|
|
JIRAEndpoint string |
|
|
JIRAEndpoint string |
|
|
Server string // clobbered based on /serverInfo request
|
|
|
Server string // clobbered based on /serverInfo request
|
|
@ -28,6 +30,33 @@ type jiraRealm struct { |
|
|
PrivateKeyPEM string |
|
|
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 { |
|
|
func (r *jiraRealm) ID() string { |
|
|
return r.id |
|
|
return r.id |
|
|
} |
|
|
} |
|
@ -44,21 +73,12 @@ func (r *jiraRealm) Register() error { |
|
|
return errors.New("JIRAEndpoint must be specified") |
|
|
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 |
|
|
return err |
|
|
} |
|
|
} |
|
|
r.JIRAEndpoint = ju.Base |
|
|
|
|
|
|
|
|
|
|
|
// Check to see if JIRA endpoint is valid by pinging an endpoint
|
|
|
// 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 { |
|
|
if err != nil { |
|
|
return err |
|
|
return err |
|
|
} |
|
|
} |
|
@ -67,7 +87,7 @@ func (r *jiraRealm) Register() error { |
|
|
return err |
|
|
return err |
|
|
} |
|
|
} |
|
|
log.WithFields(log.Fields{ |
|
|
log.WithFields(log.Fields{ |
|
|
"jira_url": ju.Base, |
|
|
|
|
|
|
|
|
"jira_url": r.JIRAEndpoint, |
|
|
"title": info.ServerTitle, |
|
|
"title": info.ServerTitle, |
|
|
"version": info.Version, |
|
|
"version": info.Version, |
|
|
}).Print("Found JIRA endpoint") |
|
|
}).Print("Found JIRA endpoint") |
|
@ -78,20 +98,99 @@ func (r *jiraRealm) Register() error { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
func (r *jiraRealm) RequestAuthSession(userID string, req json.RawMessage) interface{} { |
|
|
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) { |
|
|
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 { |
|
|
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
|
|
|
// 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.
|
|
|
// 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
|
|
|
// TODO: Check if user has an auth session. Requires access token+secret
|
|
|
hasAuthSession := false |
|
|
hasAuthSession := false |
|
|
|
|
|
|
|
@ -99,38 +198,39 @@ func (r *jiraRealm) jiraClient(u urls.JIRAURL, userID string, allowUnauth bool) |
|
|
// make an authenticated client
|
|
|
// make an authenticated client
|
|
|
var cli *jira.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")) |
|
|
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 |
|
|
return cli, err |
|
|
} else if allowUnauth { |
|
|
} else if allowUnauth { |
|
|
// make an unauthenticated client
|
|
|
// make an unauthenticated client
|
|
|
cli, err := jira.NewClient(nil, u.Base) |
|
|
|
|
|
|
|
|
cli, err := jira.NewClient(nil, jiraBaseURL) |
|
|
return cli, err |
|
|
return cli, err |
|
|
} else { |
|
|
} else { |
|
|
return nil, errors.New("No authenticated session found for " + userID) |
|
|
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 { |
|
|
func (r *jiraRealm) parsePrivateKey() error { |
|
|
|
|
|
if r.privateKey != nil { |
|
|
|
|
|
return nil |
|
|
|
|
|
} |
|
|
pk, err := loadPrivateKey(r.PrivateKeyPEM) |
|
|
pk, err := loadPrivateKey(r.PrivateKeyPEM) |
|
|
if err != nil { |
|
|
if err != nil { |
|
|
return err |
|
|
return err |
|
@ -144,6 +244,27 @@ func (r *jiraRealm) parsePrivateKey() error { |
|
|
return nil |
|
|
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) { |
|
|
func loadPrivateKey(privKeyPEM string) (*rsa.PrivateKey, error) { |
|
|
// Decode PEM to grab the private key type
|
|
|
// Decode PEM to grab the private key type
|
|
|
block, _ := pem.Decode([]byte(privKeyPEM)) |
|
|
block, _ := pem.Decode([]byte(privKeyPEM)) |
|
@ -183,15 +304,21 @@ type jiraServiceInfo struct { |
|
|
func jiraServerInfo(cli *jira.Client) (*jiraServiceInfo, error) { |
|
|
func jiraServerInfo(cli *jira.Client) (*jiraServiceInfo, error) { |
|
|
var jsi jiraServiceInfo |
|
|
var jsi jiraServiceInfo |
|
|
req, _ := cli.NewRequest("GET", "rest/api/2/serverInfo", nil) |
|
|
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 nil, err |
|
|
} |
|
|
} |
|
|
return &jsi, nil |
|
|
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() { |
|
|
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} |
|
|
}) |
|
|
}) |
|
|
} |
|
|
} |