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.

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