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

8 years ago
8 years ago
8 years ago
  1. // Package jira implements OAuth1.0a support for arbitrary JIRA installations.
  2. package jira
  3. import (
  4. "crypto/rsa"
  5. "crypto/x509"
  6. "database/sql"
  7. "encoding/json"
  8. "encoding/pem"
  9. "errors"
  10. "fmt"
  11. "net/http"
  12. "strings"
  13. jira "github.com/andygrunwald/go-jira"
  14. "github.com/dghubble/oauth1"
  15. "github.com/matrix-org/go-neb/database"
  16. "github.com/matrix-org/go-neb/realms/jira/urls"
  17. "github.com/matrix-org/go-neb/types"
  18. log "github.com/sirupsen/logrus"
  19. "golang.org/x/net/context"
  20. "maunium.net/go/mautrix/id"
  21. )
  22. // RealmType of the JIRA realm
  23. const RealmType = "jira"
  24. // Realm is an AuthRealm which can process JIRA installations.
  25. //
  26. // Example request:
  27. // {
  28. // "JIRAEndpoint": "matrix.org/jira/",
  29. // "ConsumerName": "goneb",
  30. // "ConsumerKey": "goneb",
  31. // "ConsumerSecret": "random_long_string",
  32. // "PrivateKeyPEM": "-----BEGIN RSA PRIVATE KEY-----\r\nMIIEowIBAAKCAQEA39UhbOvQHEkBP9fGnhU+eSObTAwX9req2l1NiuNaPU9rE7tf6Bk\r\n-----END RSA PRIVATE KEY-----"
  33. // }
  34. type Realm struct {
  35. id string
  36. redirectURL string
  37. privateKey *rsa.PrivateKey
  38. // The HTTPS URL of the JIRA installation to authenticate with.
  39. JIRAEndpoint string
  40. // The desired "Consumer Name" field of the "Application Links" admin page on JIRA.
  41. // Generally this is the name of the service. Users will need to enter this string
  42. // into their JIRA admin web form.
  43. ConsumerName string
  44. // The desired "Consumer Key" field of the "Application Links" admin page on JIRA.
  45. // Generally this is the name of the service. Users will need to enter this string
  46. // into their JIRA admin web form.
  47. ConsumerKey string
  48. // The desired "Consumer Secret" field of the "Application Links" admin page on JIRA.
  49. // This should be a random long string. Users will need to enter this string into
  50. // their JIRA admin web form.
  51. ConsumerSecret string
  52. // A string which contains the private key for performing OAuth 1.0 requests.
  53. // This MUST be in PEM format. It must NOT have a password. Go-NEB will convert this
  54. // into a public key in PEM format and return this to users. Users will need to enter
  55. // the *public* key into their JIRA admin web form.
  56. //
  57. // To generate a private key PEM: (JIRA does not support bit lengths >2048):
  58. // $ openssl genrsa -out privkey.pem 2048
  59. // $ cat privkey.pem
  60. PrivateKeyPEM string
  61. // Optional. If supplied, !jira commands will return this link whenever someone is
  62. // prompted to login to JIRA.
  63. StarterLink string
  64. // The server name of the JIRA installation from /serverInfo.
  65. // This is an informational field populated by Go-NEB post-creation.
  66. Server string
  67. // The JIRA version string from /serverInfo.
  68. // This is an informational field populated by Go-NEB post-creation.
  69. Version string
  70. // The public key for the given private key. This is populated by Go-NEB.
  71. PublicKeyPEM string
  72. // Internal field. True if this realm has already registered a webhook with the JIRA installation.
  73. HasWebhook bool
  74. }
  75. // Session represents a single authentication session between a user and a JIRA endpoint.
  76. // The endpoint is dictated by the realm ID.
  77. type Session struct {
  78. id string // request token
  79. userID id.UserID
  80. realmID string
  81. // Configuration fields
  82. // The secret obtained when requesting an authentication session with JIRA.
  83. RequestSecret string
  84. // A JIRA access token for a Matrix user ID.
  85. AccessToken string
  86. // A JIRA access secret for a Matrix user ID.
  87. AccessSecret string
  88. // Optional. The URL to redirect the client to after authentication.
  89. ClientsRedirectURL string
  90. }
  91. // AuthRequest is a request for authenticating with JIRA
  92. type AuthRequest struct {
  93. // Optional. The URL to redirect to after authentication.
  94. RedirectURL string
  95. }
  96. // AuthResponse is a response to an AuthRequest.
  97. type AuthResponse struct {
  98. // The URL to visit to perform OAuth on this JIRA installation.
  99. URL string
  100. }
  101. // Authenticated returns true if the user has completed the auth process
  102. func (s *Session) Authenticated() bool {
  103. return s.AccessToken != "" && s.AccessSecret != ""
  104. }
  105. // Info returns nothing
  106. func (s *Session) Info() interface{} {
  107. return nil
  108. }
  109. // UserID returns the ID of the user performing the authentication.
  110. func (s *Session) UserID() id.UserID {
  111. return s.userID
  112. }
  113. // RealmID returns the JIRA realm ID which created this session.
  114. func (s *Session) RealmID() string {
  115. return s.realmID
  116. }
  117. // ID returns the OAuth1 request_token which is used when looking up sessions in the redirect
  118. // handler.
  119. func (s *Session) ID() string {
  120. return s.id
  121. }
  122. // ID returns the ID of this JIRA realm.
  123. func (r *Realm) ID() string {
  124. return r.id
  125. }
  126. // Type returns the type of realm this is.
  127. func (r *Realm) Type() string {
  128. return RealmType
  129. }
  130. // Init initialises the private key for this JIRA realm.
  131. func (r *Realm) Init() error {
  132. if err := r.parsePrivateKey(); err != nil {
  133. log.WithError(err).Print("Failed to parse private key")
  134. return err
  135. }
  136. // Parse the messy input URL into a canonicalised form.
  137. ju, err := urls.ParseJIRAURL(r.JIRAEndpoint)
  138. if err != nil {
  139. log.WithError(err).Print("Failed to parse JIRA endpoint")
  140. return err
  141. }
  142. r.JIRAEndpoint = ju.Base
  143. return nil
  144. }
  145. // Register is called when this realm is being created from an external entity
  146. func (r *Realm) Register() error {
  147. if r.ConsumerName == "" || r.ConsumerKey == "" || r.ConsumerSecret == "" || r.PrivateKeyPEM == "" {
  148. return errors.New("ConsumerName, ConsumerKey, ConsumerSecret, PrivateKeyPEM must be specified")
  149. }
  150. if r.JIRAEndpoint == "" {
  151. return errors.New("JIRAEndpoint must be specified")
  152. }
  153. r.HasWebhook = false // never let the user set this; only NEB can.
  154. // Check to see if JIRA endpoint is valid by pinging an endpoint
  155. cli, err := r.JIRAClient("", true)
  156. if err != nil {
  157. return err
  158. }
  159. info, err := jiraServerInfo(cli)
  160. if err != nil {
  161. return err
  162. }
  163. log.WithFields(log.Fields{
  164. "jira_url": r.JIRAEndpoint,
  165. "title": info.ServerTitle,
  166. "version": info.Version,
  167. }).Print("Found JIRA endpoint")
  168. r.Server = info.ServerTitle
  169. r.Version = info.Version
  170. return nil
  171. }
  172. // RequestAuthSession is called by a user wishing to auth with this JIRA realm.
  173. // The request body is of type "jira.AuthRequest". Returns a "jira.AuthResponse".
  174. //
  175. // Request example:
  176. // {
  177. // "RedirectURL": "https://somewhere.somehow"
  178. // }
  179. // Response example:
  180. // {
  181. // "URL": "https://jira.somewhere.com/plugins/servlet/oauth/authorize?oauth_token=7yeuierbgweguiegrTbOT"
  182. // }
  183. func (r *Realm) RequestAuthSession(userID id.UserID, req json.RawMessage) interface{} {
  184. logger := log.WithField("jira_url", r.JIRAEndpoint)
  185. // check if they supplied a redirect URL
  186. var reqBody AuthRequest
  187. if err := json.Unmarshal(req, &reqBody); err != nil {
  188. log.WithError(err).Print("Failed to decode request body")
  189. return nil
  190. }
  191. authConfig := r.oauth1Config(r.JIRAEndpoint)
  192. reqToken, reqSec, err := authConfig.RequestToken()
  193. if err != nil {
  194. logger.WithError(err).Print("Failed to request auth token")
  195. return nil
  196. }
  197. logger.WithField("req_token", reqToken).Print("Received request token")
  198. authURL, err := authConfig.AuthorizationURL(reqToken)
  199. if err != nil {
  200. logger.WithError(err).Print("Failed to create authorization URL")
  201. return nil
  202. }
  203. _, err = database.GetServiceDB().StoreAuthSession(&Session{
  204. id: reqToken,
  205. userID: userID,
  206. realmID: r.id,
  207. RequestSecret: reqSec,
  208. ClientsRedirectURL: reqBody.RedirectURL,
  209. })
  210. if err != nil {
  211. log.WithError(err).Print("Failed to store new auth session")
  212. return nil
  213. }
  214. return &AuthResponse{authURL.String()}
  215. }
  216. // OnReceiveRedirect is called when JIRA installations redirect back to NEB
  217. func (r *Realm) OnReceiveRedirect(w http.ResponseWriter, req *http.Request) {
  218. logger := log.WithField("jira_url", r.JIRAEndpoint)
  219. requestToken, verifier, err := oauth1.ParseAuthorizationCallback(req)
  220. if err != nil {
  221. failWith(logger, w, 400, "Failed to parse authorization callback", err)
  222. return
  223. }
  224. logger = logger.WithField("req_token", requestToken)
  225. logger.Print("Received authorization callback")
  226. session, err := database.GetServiceDB().LoadAuthSessionByID(r.id, requestToken)
  227. if err != nil {
  228. failWith(logger, w, 400, "Unrecognised request token", err)
  229. return
  230. }
  231. jiraSession, ok := session.(*Session)
  232. if !ok {
  233. failWith(logger, w, 500, "Unexpected session type found.", nil)
  234. return
  235. }
  236. logger = logger.WithField("user_id", jiraSession.UserID())
  237. logger.Print("Retrieved auth session for user")
  238. oauthConfig := r.oauth1Config(r.JIRAEndpoint)
  239. accessToken, accessSecret, err := oauthConfig.AccessToken(requestToken, jiraSession.RequestSecret, verifier)
  240. if err != nil {
  241. failWith(logger, w, 502, "Failed exchange for access token.", err)
  242. return
  243. }
  244. logger.Print("Exchanged for access token")
  245. jiraSession.AccessToken = accessToken
  246. jiraSession.AccessSecret = accessSecret
  247. _, err = database.GetServiceDB().StoreAuthSession(jiraSession)
  248. if err != nil {
  249. failWith(logger, w, 500, "Failed to persist JIRA session", err)
  250. return
  251. }
  252. if jiraSession.ClientsRedirectURL != "" {
  253. w.WriteHeader(302)
  254. w.Header().Set("Location", jiraSession.ClientsRedirectURL)
  255. // technically don't need a body but *shrug*
  256. w.Write([]byte(jiraSession.ClientsRedirectURL))
  257. } else {
  258. w.WriteHeader(200)
  259. w.Write([]byte(
  260. fmt.Sprintf("You have successfully linked your JIRA account on %s to %s",
  261. r.JIRAEndpoint, jiraSession.UserID(),
  262. ),
  263. ))
  264. }
  265. }
  266. // AuthSession returns a JIRASession with the given parameters
  267. func (r *Realm) AuthSession(id string, userID id.UserID, realmID string) types.AuthSession {
  268. return &Session{
  269. id: id,
  270. userID: userID,
  271. realmID: realmID,
  272. }
  273. }
  274. // ProjectKeyExists returns true if the given project key exists on this JIRA realm.
  275. // An authenticated client for userID will be used if one exists, else an
  276. // unauthenticated client will be used, which may not be able to see the complete list
  277. // of projects.
  278. func (r *Realm) ProjectKeyExists(userID id.UserID, projectKey string) (bool, error) {
  279. cli, err := r.JIRAClient(userID, true)
  280. if err != nil {
  281. return false, err
  282. }
  283. var projects []jira.Project
  284. req, err := cli.NewRequest("GET", "rest/api/2/project", nil)
  285. if err != nil {
  286. return false, err
  287. }
  288. res, err := cli.Do(req, &projects)
  289. if err != nil {
  290. return false, err
  291. }
  292. if res == nil {
  293. return false, errors.New("No response returned")
  294. }
  295. if res.StatusCode < 200 || res.StatusCode >= 300 {
  296. return false, fmt.Errorf(
  297. "%srest/api/2/project returned code %d",
  298. r.JIRAEndpoint, res.StatusCode,
  299. )
  300. }
  301. for _, p := range projects {
  302. if strings.EqualFold(p.Key, projectKey) {
  303. return true, nil
  304. }
  305. }
  306. return false, nil
  307. }
  308. // JIRAClient returns an authenticated jira.Client for the given userID. Returns an unauthenticated
  309. // client if allowUnauth is true and no authenticated session is found, else returns an error.
  310. func (r *Realm) JIRAClient(userID id.UserID, allowUnauth bool) (*jira.Client, error) {
  311. // Check if user has an auth session.
  312. session, err := database.GetServiceDB().LoadAuthSessionByUser(r.id, userID)
  313. if err != nil {
  314. if err == sql.ErrNoRows {
  315. if allowUnauth {
  316. // make an unauthenticated client
  317. return jira.NewClient(nil, r.JIRAEndpoint)
  318. }
  319. }
  320. return nil, err
  321. }
  322. jsession, ok := session.(*Session)
  323. if !ok {
  324. return nil, errors.New("Failed to cast user session to a Session")
  325. }
  326. // Make sure they finished the auth process
  327. if jsession.AccessSecret == "" || jsession.AccessToken == "" {
  328. if allowUnauth {
  329. // make an unauthenticated client
  330. return jira.NewClient(nil, r.JIRAEndpoint)
  331. }
  332. return nil, errors.New("No authenticated session found for " + userID.String())
  333. }
  334. // make an authenticated client
  335. auth := r.oauth1Config(r.JIRAEndpoint)
  336. httpClient := auth.Client(
  337. context.TODO(),
  338. oauth1.NewToken(jsession.AccessToken, jsession.AccessSecret),
  339. )
  340. return jira.NewClient(httpClient, r.JIRAEndpoint)
  341. }
  342. func (r *Realm) parsePrivateKey() error {
  343. if r.privateKey != nil {
  344. return nil
  345. }
  346. pk, err := loadPrivateKey(r.PrivateKeyPEM)
  347. if err != nil {
  348. return err
  349. }
  350. pub, err := publicKeyAsPEM(pk)
  351. if err != nil {
  352. return err
  353. }
  354. r.PublicKeyPEM = pub
  355. r.privateKey = pk
  356. return nil
  357. }
  358. func (r *Realm) oauth1Config(jiraBaseURL string) *oauth1.Config {
  359. return &oauth1.Config{
  360. ConsumerKey: r.ConsumerKey,
  361. ConsumerSecret: r.ConsumerSecret,
  362. CallbackURL: r.redirectURL,
  363. // TODO: In JIRA Cloud, the Authorization URL is only the Instance BASE_URL:
  364. // https://BASE_URL.atlassian.net.
  365. // It also does not require the + "/plugins/servlet/oauth/authorize"
  366. // We should probably check the provided JIRA base URL to see if it is a cloud one
  367. // then adjust accordingly.
  368. Endpoint: oauth1.Endpoint{
  369. RequestTokenURL: jiraBaseURL + "plugins/servlet/oauth/request-token",
  370. AuthorizeURL: jiraBaseURL + "plugins/servlet/oauth/authorize",
  371. AccessTokenURL: jiraBaseURL + "plugins/servlet/oauth/access-token",
  372. },
  373. Signer: &oauth1.RSASigner{
  374. PrivateKey: r.privateKey,
  375. },
  376. }
  377. }
  378. func loadPrivateKey(privKeyPEM string) (*rsa.PrivateKey, error) {
  379. // Decode PEM to grab the private key type
  380. block, _ := pem.Decode([]byte(privKeyPEM))
  381. if block == nil {
  382. return nil, errors.New("No PEM formatted block found")
  383. }
  384. priv, err := x509.ParsePKCS1PrivateKey(block.Bytes)
  385. if err != nil {
  386. return nil, err
  387. }
  388. return priv, nil
  389. }
  390. func publicKeyAsPEM(pkey *rsa.PrivateKey) (string, error) {
  391. // https://github.com/golang-samples/cipher/blob/master/crypto/rsa_keypair.go
  392. der, err := x509.MarshalPKIXPublicKey(&pkey.PublicKey)
  393. if err != nil {
  394. return "", err
  395. }
  396. block := pem.Block{
  397. Type: "PUBLIC KEY",
  398. Headers: nil,
  399. Bytes: der,
  400. }
  401. return string(pem.EncodeToMemory(&block)), nil
  402. }
  403. // jiraServiceInfo is the HTTP response to JIRA_ENDPOINT/rest/api/2/serverInfo
  404. type jiraServiceInfo struct {
  405. ServerTitle string `json:"serverTitle"`
  406. Version string `json:"version"`
  407. VersionNumbers []int `json:"versionNumbers"`
  408. BaseURL string `json:"baseUrl"`
  409. }
  410. func jiraServerInfo(cli *jira.Client) (*jiraServiceInfo, error) {
  411. var jsi jiraServiceInfo
  412. req, _ := cli.NewRequest("GET", "rest/api/2/serverInfo", nil)
  413. if _, err := cli.Do(req, &jsi); err != nil {
  414. return nil, err
  415. }
  416. return &jsi, nil
  417. }
  418. // TODO: Github has this as well, maybe factor it out?
  419. func failWith(logger *log.Entry, w http.ResponseWriter, code int, msg string, err error) {
  420. logger.WithError(err).Print(msg)
  421. w.WriteHeader(code)
  422. w.Write([]byte(msg))
  423. }
  424. func init() {
  425. types.RegisterAuthRealm(func(realmID, redirectURL string) types.AuthRealm {
  426. return &Realm{id: realmID, redirectURL: redirectURL}
  427. })
  428. }