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.
476 lines
14 KiB
476 lines
14 KiB
// Package jira implements OAuth1.0a support for arbitrary JIRA installations.
|
|
package jira
|
|
|
|
import (
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
jira "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"
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.org/x/net/context"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
// RealmType of the JIRA realm
|
|
const RealmType = "jira"
|
|
|
|
// Realm is an AuthRealm which can process JIRA installations.
|
|
//
|
|
// Example request:
|
|
// {
|
|
// "JIRAEndpoint": "matrix.org/jira/",
|
|
// "ConsumerName": "goneb",
|
|
// "ConsumerKey": "goneb",
|
|
// "ConsumerSecret": "random_long_string",
|
|
// "PrivateKeyPEM": "-----BEGIN RSA PRIVATE KEY-----\r\nMIIEowIBAAKCAQEA39UhbOvQHEkBP9fGnhU+eSObTAwX9req2l1NiuNaPU9rE7tf6Bk\r\n-----END RSA PRIVATE KEY-----"
|
|
// }
|
|
type Realm struct {
|
|
id string
|
|
redirectURL string
|
|
privateKey *rsa.PrivateKey
|
|
|
|
// The HTTPS URL of the JIRA installation to authenticate with.
|
|
JIRAEndpoint string
|
|
// The desired "Consumer Name" field of the "Application Links" admin page on JIRA.
|
|
// Generally this is the name of the service. Users will need to enter this string
|
|
// into their JIRA admin web form.
|
|
ConsumerName string
|
|
// The desired "Consumer Key" field of the "Application Links" admin page on JIRA.
|
|
// Generally this is the name of the service. Users will need to enter this string
|
|
// into their JIRA admin web form.
|
|
ConsumerKey string
|
|
// The desired "Consumer Secret" field of the "Application Links" admin page on JIRA.
|
|
// This should be a random long string. Users will need to enter this string into
|
|
// their JIRA admin web form.
|
|
ConsumerSecret string
|
|
// A string which contains the private key for performing OAuth 1.0 requests.
|
|
// This MUST be in PEM format. It must NOT have a password. Go-NEB will convert this
|
|
// into a public key in PEM format and return this to users. Users will need to enter
|
|
// the *public* key into their JIRA admin web form.
|
|
//
|
|
// To generate a private key PEM: (JIRA does not support bit lengths >2048):
|
|
// $ openssl genrsa -out privkey.pem 2048
|
|
// $ cat privkey.pem
|
|
PrivateKeyPEM string
|
|
// Optional. If supplied, !jira commands will return this link whenever someone is
|
|
// prompted to login to JIRA.
|
|
StarterLink string
|
|
|
|
// The server name of the JIRA installation from /serverInfo.
|
|
// This is an informational field populated by Go-NEB post-creation.
|
|
Server string
|
|
// The JIRA version string from /serverInfo.
|
|
// This is an informational field populated by Go-NEB post-creation.
|
|
Version string
|
|
// The public key for the given private key. This is populated by Go-NEB.
|
|
PublicKeyPEM string
|
|
|
|
// Internal field. True if this realm has already registered a webhook with the JIRA installation.
|
|
HasWebhook bool
|
|
}
|
|
|
|
// Session represents a single authentication session between a user and a JIRA endpoint.
|
|
// The endpoint is dictated by the realm ID.
|
|
type Session struct {
|
|
id string // request token
|
|
userID id.UserID
|
|
realmID string
|
|
|
|
// Configuration fields
|
|
|
|
// The secret obtained when requesting an authentication session with JIRA.
|
|
RequestSecret string
|
|
// A JIRA access token for a Matrix user ID.
|
|
AccessToken string
|
|
// A JIRA access secret for a Matrix user ID.
|
|
AccessSecret string
|
|
// Optional. The URL to redirect the client to after authentication.
|
|
ClientsRedirectURL string
|
|
}
|
|
|
|
// AuthRequest is a request for authenticating with JIRA
|
|
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 this JIRA installation.
|
|
URL string
|
|
}
|
|
|
|
// Authenticated returns true if the user has completed the auth process
|
|
func (s *Session) Authenticated() bool {
|
|
return s.AccessToken != "" && s.AccessSecret != ""
|
|
}
|
|
|
|
// Info returns nothing
|
|
func (s *Session) Info() interface{} {
|
|
return nil
|
|
}
|
|
|
|
// UserID returns the ID of the user performing the authentication.
|
|
func (s *Session) UserID() id.UserID {
|
|
return s.userID
|
|
}
|
|
|
|
// RealmID returns the JIRA realm ID which created this session.
|
|
func (s *Session) RealmID() string {
|
|
return s.realmID
|
|
}
|
|
|
|
// ID returns the OAuth1 request_token which is used when looking up sessions in the redirect
|
|
// handler.
|
|
func (s *Session) ID() string {
|
|
return s.id
|
|
}
|
|
|
|
// ID returns the ID of this JIRA realm.
|
|
func (r *Realm) ID() string {
|
|
return r.id
|
|
}
|
|
|
|
// Type returns the type of realm this is.
|
|
func (r *Realm) Type() string {
|
|
return RealmType
|
|
}
|
|
|
|
// Init initialises the private key for this JIRA realm.
|
|
func (r *Realm) Init() 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
|
|
}
|
|
|
|
// Register is called when this realm is being created from an external entity
|
|
func (r *Realm) Register() error {
|
|
if r.ConsumerName == "" || r.ConsumerKey == "" || r.ConsumerSecret == "" || r.PrivateKeyPEM == "" {
|
|
return errors.New("ConsumerName, ConsumerKey, ConsumerSecret, PrivateKeyPEM must be specified")
|
|
}
|
|
if r.JIRAEndpoint == "" {
|
|
return errors.New("JIRAEndpoint must be specified")
|
|
}
|
|
r.HasWebhook = false // never let the user set this; only NEB can.
|
|
|
|
// Check to see if JIRA endpoint is valid by pinging an endpoint
|
|
cli, err := r.JIRAClient("", true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
info, err := jiraServerInfo(cli)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.WithFields(log.Fields{
|
|
"jira_url": r.JIRAEndpoint,
|
|
"title": info.ServerTitle,
|
|
"version": info.Version,
|
|
}).Print("Found JIRA endpoint")
|
|
r.Server = info.ServerTitle
|
|
r.Version = info.Version
|
|
|
|
return nil
|
|
}
|
|
|
|
// RequestAuthSession is called by a user wishing to auth with this JIRA realm.
|
|
// The request body is of type "jira.AuthRequest". Returns a "jira.AuthResponse".
|
|
//
|
|
// Request example:
|
|
// {
|
|
// "RedirectURL": "https://somewhere.somehow"
|
|
// }
|
|
// Response example:
|
|
// {
|
|
// "URL": "https://jira.somewhere.com/plugins/servlet/oauth/authorize?oauth_token=7yeuierbgweguiegrTbOT"
|
|
// }
|
|
func (r *Realm) RequestAuthSession(userID id.UserID, req json.RawMessage) interface{} {
|
|
logger := log.WithField("jira_url", r.JIRAEndpoint)
|
|
|
|
// 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
|
|
}
|
|
|
|
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(&Session{
|
|
id: reqToken,
|
|
userID: userID,
|
|
realmID: r.id,
|
|
RequestSecret: reqSec,
|
|
ClientsRedirectURL: reqBody.RedirectURL,
|
|
})
|
|
if err != nil {
|
|
log.WithError(err).Print("Failed to store new auth session")
|
|
return nil
|
|
}
|
|
|
|
return &AuthResponse{authURL.String()}
|
|
}
|
|
|
|
// OnReceiveRedirect is called when JIRA installations redirect back to NEB
|
|
func (r *Realm) OnReceiveRedirect(w http.ResponseWriter, req *http.Request) {
|
|
logger := log.WithField("jira_url", r.JIRAEndpoint)
|
|
|
|
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.(*Session)
|
|
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
|
|
}
|
|
if jiraSession.ClientsRedirectURL != "" {
|
|
w.WriteHeader(302)
|
|
w.Header().Set("Location", jiraSession.ClientsRedirectURL)
|
|
// technically don't need a body but *shrug*
|
|
w.Write([]byte(jiraSession.ClientsRedirectURL))
|
|
} else {
|
|
w.WriteHeader(200)
|
|
w.Write([]byte(
|
|
fmt.Sprintf("You have successfully linked your JIRA account on %s to %s",
|
|
r.JIRAEndpoint, jiraSession.UserID(),
|
|
),
|
|
))
|
|
}
|
|
}
|
|
|
|
// AuthSession returns a JIRASession with the given parameters
|
|
func (r *Realm) AuthSession(id string, userID id.UserID, realmID string) types.AuthSession {
|
|
return &Session{
|
|
id: id,
|
|
userID: userID,
|
|
realmID: realmID,
|
|
}
|
|
}
|
|
|
|
// ProjectKeyExists returns true if the given project key exists on this JIRA realm.
|
|
// An authenticated client for userID will be used if one exists, else an
|
|
// unauthenticated client will be used, which may not be able to see the complete list
|
|
// of projects.
|
|
func (r *Realm) ProjectKeyExists(userID id.UserID, projectKey string) (bool, error) {
|
|
cli, err := r.JIRAClient(userID, true)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
var projects []jira.Project
|
|
req, err := cli.NewRequest("GET", "rest/api/2/project", nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
res, err := cli.Do(req, &projects)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if res == nil {
|
|
return false, errors.New("No response returned")
|
|
}
|
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
|
return false, fmt.Errorf(
|
|
"%srest/api/2/project returned code %d",
|
|
r.JIRAEndpoint, res.StatusCode,
|
|
)
|
|
}
|
|
|
|
for _, p := range projects {
|
|
if strings.EqualFold(p.Key, projectKey) {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// 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 *Realm) JIRAClient(userID id.UserID, allowUnauth bool) (*jira.Client, error) {
|
|
// Check if user has an auth session.
|
|
session, err := database.GetServiceDB().LoadAuthSessionByUser(r.id, userID)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
if allowUnauth {
|
|
// make an unauthenticated client
|
|
return jira.NewClient(nil, r.JIRAEndpoint)
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
jsession, ok := session.(*Session)
|
|
if !ok {
|
|
return nil, errors.New("Failed to cast user session to a Session")
|
|
}
|
|
// Make sure they finished the auth process
|
|
if jsession.AccessSecret == "" || jsession.AccessToken == "" {
|
|
if allowUnauth {
|
|
// make an unauthenticated client
|
|
return jira.NewClient(nil, r.JIRAEndpoint)
|
|
}
|
|
return nil, errors.New("No authenticated session found for " + userID.String())
|
|
}
|
|
// make an authenticated client
|
|
auth := r.oauth1Config(r.JIRAEndpoint)
|
|
httpClient := auth.Client(
|
|
context.TODO(),
|
|
oauth1.NewToken(jsession.AccessToken, jsession.AccessSecret),
|
|
)
|
|
return jira.NewClient(httpClient, r.JIRAEndpoint)
|
|
}
|
|
|
|
func (r *Realm) parsePrivateKey() error {
|
|
if r.privateKey != nil {
|
|
return nil
|
|
}
|
|
pk, err := loadPrivateKey(r.PrivateKeyPEM)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pub, err := publicKeyAsPEM(pk)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.PublicKeyPEM = pub
|
|
r.privateKey = pk
|
|
return nil
|
|
}
|
|
|
|
func (r *Realm) 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))
|
|
if block == nil {
|
|
return nil, errors.New("No PEM formatted block found")
|
|
}
|
|
|
|
priv, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return priv, nil
|
|
}
|
|
|
|
func publicKeyAsPEM(pkey *rsa.PrivateKey) (string, error) {
|
|
// https://github.com/golang-samples/cipher/blob/master/crypto/rsa_keypair.go
|
|
der, err := x509.MarshalPKIXPublicKey(&pkey.PublicKey)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
block := pem.Block{
|
|
Type: "PUBLIC KEY",
|
|
Headers: nil,
|
|
Bytes: der,
|
|
}
|
|
return string(pem.EncodeToMemory(&block)), nil
|
|
}
|
|
|
|
// jiraServiceInfo is the HTTP response to JIRA_ENDPOINT/rest/api/2/serverInfo
|
|
type jiraServiceInfo struct {
|
|
ServerTitle string `json:"serverTitle"`
|
|
Version string `json:"version"`
|
|
VersionNumbers []int `json:"versionNumbers"`
|
|
BaseURL string `json:"baseUrl"`
|
|
}
|
|
|
|
func jiraServerInfo(cli *jira.Client) (*jiraServiceInfo, error) {
|
|
var jsi jiraServiceInfo
|
|
req, _ := cli.NewRequest("GET", "rest/api/2/serverInfo", 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, redirectURL string) types.AuthRealm {
|
|
return &Realm{id: realmID, redirectURL: redirectURL}
|
|
})
|
|
}
|