Cookie-based Authorization

This flow provides "Cookie-based Authorization" for HTTP endpoints which are intended for certain users only.

This flow is part of a set of node-red-authorization-examples but also published here for easier lookup

Prerequisites

This example requires the following Node-RED extension:

Additionally, it expects the global flow context to contain an object called UserRegistry which has the same format as described in "node-red-within-express":

  • the object's property names are the ids of registered users
    user ids are strings with no specific format, they may be user names, email addresses or any other data you are free to choose - with two important exceptions: user ids must neither contain any slashes ("/") nor any colons (":") or the authentication mechanisms described below (and the user management described in node-red-user-management-example) will fail. Additionally, upper and lower case in user ids is not distinguished
  • the object's property values are JavaScript objects with the following properties, at least (additional properties may be added at will):
    • Roles
      is either missing or contains a list of strings with the user's roles. There is no specific format for role names except that a role name must not contain any white space
    • Salt
      is a string containing a random "salt" value which is used during PBKDF2 password hash calculation
    • Hash
      is a string containing the actual PBKDF2 hash of the user's password

When used outside "node-red-within-express", the following flows allow such a registry to be loaded from an external JSON file called registeredUsers.json (or to be created if no such file exists or an existing file can not be loaded) and written back after changes:

outside-node-red-within-express

These flows are already part of this example but may be removed (or customized) if not needed.

For testing and debugging purposes, the following flow may also be imported, which dumps the current contents of the user registry onto Node-RED's debug console when clicked:

show-user-registry

This flow is also already part of this example.

Postman Collection

For testing purposes, the GitHub repository for this example additionally contains a Postman collection with a few predefined requests.

A popular approach is to let users log-in and generate access tokens which are then used as "cookies" for the communication between browser and server. Browsers automatically attach such cookies to every request, and the contained tokens may be designed to "expire" or to be deleted upon a "logout".

The token in this example consists of a user id and an expiration time. While it is stored in plain text (and, thus, may be inspected by the client), its value is secured with a "message digest" - as a consequence, any attempt to change the token will inevitably be recognized and lead to authorization loss. On the other hand, any successful token validation automatically refreshes that token - tokens therefore effectively expire after a certain time of inactivity only.

The key used to generate message digests is randomly chosen at server startup - a server restart will therefore automatically invalidate any active tokens.

Token lifetime may be configured - by default, it is set to 2 minutes.

In order to "login", POST a form containing the variables UserId and Password to the proper endpoint (/cookie-auth in this example).

Nota bene: current law often requires users to be informed about cookie usage. The cookie used here counts as a "technically required cookie" which cannot be forbidden if the visited site is expected to work as foreseen.

cookie-auth

The upper outputs are used for successful authentications and logins, the lower ones for failures.

If you require the authenticating user to have a specific role, you may set msg.requiredRole to that role before invoking cookie auth or cookie login - otherwise, user roles will not be checked.

Upon successful authentication, msg.authenticatedUser contains the id of the authenticated user and msg.authorizedRoles contains a (possibly empty) list with the roles of that user.

Try yourself

The following example illustrates how to integrate Cookie-based authentication into Node-RED flows:

  • send a POST request to the shown entry point in order to log-in and then
  • send a GET request to the same entry point to validate that log-in and access the protected resource

(The Postman collection mentioned below facilitates these steps.)

Sending GET requests without prior login (or after token expiration) should fail with status code 401 (Unauthorized)

The login request should either contain

  • a body of type "application/json" with the JSON serialization of an object containing the properties UserId and Password, at least, or
  • a body of type "application/x-www-form-urlencoded" with the form variables UserId and Password, at least

Additional object properties or form variables will be ignored by the authentication itself, but passed on to any following nodes.

try-cookie-auth

Successful login, token validation and token refresh always add the related cookie to the cookies property of the msg object which, thus, automatically becomes part of the flow's response.

Any login or token validation failure automatically deletes the token cookie, comparable to a logout.

Automated Tests

The GitHub repository for this example also contains some flows for automated tests.

[{"id":"a09b9281644a88fc","type":"comment","z":"d8fe099880fd703d","name":"Cookie-based authorization (w/ expiration)","info":"","x":180,"y":40,"wires":[]},{"id":"07b8b379eee00444","type":"function","z":"d8fe099880fd703d","name":"validate authorization","func":"  let Cookie = ((msg.req.cookies || {}).authorization || '').trim()\n  if (Cookie !== '') {\n    let UserRegistry = global.get('UserRegistry') || Object.create(null)\n\n    let [UserId,Expiration,Digest] = Cookie.split(':')\n      UserId = UserId.toLowerCase()\n    if (\n      (UserId !== '') && (UserId in UserRegistry) && (UserRegistry[UserId] != null) &&\n      /^\\d+$/.test(Expiration) && /^[0-9a-fA-F]+$/.test(Digest)\n    ) {\n      let TokenKey = global.get('TokenKey')\n        const HMAC = crypto.createHmac('sha256',TokenKey)\n        HMAC.update(UserId + ':' + Expiration)\n      let expectedDigest = HMAC.digest('hex')\n\n      if (\n        (Digest === expectedDigest) &&\n        (parseInt(Expiration,10) >= Date.now())\n      ) {\n        let UserRoles = UserRegistry[UserId].Roles || []\n        if (\n          (msg.requiredRole == null) ||\n          (UserRoles.indexOf(msg.requiredRole) >= 0)\n        ) {\n          msg.authenticatedUser = UserId\n          msg.authorizedRoles   = UserRegistry[UserId].Roles || []\n\n          msg.cookies = msg.cookies || {}\n          msg.cookies.authorization = Cookie\n          return [msg,null]                                      // authorized\n        } else {\n          msg.cookies = msg.cookies || {}\n          msg.cookies.authorization = null\n\n          msg.payload    = 'Unauthorized'\n          msg.statusCode = 401\n          return [null,msg]                                    // not authorized\n        }\n      }\n    }\n  }\n\n  msg.cookies = msg.cookies || {}\n  msg.cookies.authorization = null\n\n  msg.payload    = 'Unauthorized'\n  msg.statusCode = 401\n  return [null,msg]                                            // not authorized\n","outputs":2,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":160,"y":300,"wires":[["9c83d20d034a8217"],["4a4efb4deeea3a24"]]},{"id":"6f16c88dad72878f","type":"function","z":"d8fe099880fd703d","name":"validate credentials","func":"  let UserId   = (msg.payload.UserId  || '').toLowerCase()\n  let Password = msg.payload.Password || ''\n\n  let UserRegistry = global.get('UserRegistry') || Object.create(null)\n  if ((UserId in UserRegistry) && (UserRegistry[UserId] != null)) {\n    let UserSpecs = UserRegistry[UserId]\n    if (UserSpecs.Password === Password) {              // internal optimization\n      return withAuthorizationOf(UserId,UserSpecs.Roles || [])\n    }\n\n    let PBKDF2Iterations = global.get('PBKDF2Iterations') || 100000\n    crypto.pbkdf2(\n      Password, Buffer.from(UserSpecs.Salt,'hex'), PBKDF2Iterations, 64, 'sha512',\n      function (Error, computedHash) {\n        if ((Error == null) && (computedHash.toString('hex') === UserSpecs.Hash)) {\n          UserSpecs.Password = Password       // speeds up future auth. requests\n          return withAuthorizationOf(UserId,UserSpecs.Roles || [])\n        } else {\n          return withoutAuthorization()\n        }\n      }\n    )\n  } else {\n    return withoutAuthorization()\n  }\n\n  function withAuthorizationOf (UserName, UserRoles) {\n    if ((msg.requiredRole == null) || (UserRoles.indexOf(msg.requiredRole) >= 0)) {\n      msg.authenticatedUser = UserId\n      msg.authorizedRoles   = UserRoles\n\n      node.send([msg,null])\n      node.done()\n    } else {\n      return withoutAuthorization()\n    }\n  }\n\n  function withoutAuthorization () {\n    msg.cookies = msg.cookies || {}\n    msg.cookies.authorization = { value:null }\n\n    msg.payload    = 'Unauthorized'\n    msg.statusCode = 401\n\n    node.send([null,msg])\n    node.done()\n  }\n","outputs":2,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":150,"y":420,"wires":[["8586b1e3f59f5e67"],["0a60fc2822814b75"]]},{"id":"e5bd2fb29dfdf1f1","type":"inject","z":"d8fe099880fd703d","name":"at Startup","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payloadType":"date","x":110,"y":160,"wires":[["0b5dde3db674cb58"]]},{"id":"0b5dde3db674cb58","type":"function","z":"d8fe099880fd703d","name":"generate Token Key","func":"  let TokenKey = global.get('TokenKey')\n  if (TokenKey == null) {     // do not change TokenKey upon Node-RED deployment\n    global.set('TokenKey',crypto.randomBytes(16).toString('hex'))\n  }\n  return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":320,"y":160,"wires":[["7c029d497f0dbc7d"]]},{"id":"7c029d497f0dbc7d","type":"debug","z":"d8fe099880fd703d","name":"Status","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"'token key generated'","statusType":"jsonata","x":530,"y":160,"wires":[]},{"id":"8586b1e3f59f5e67","type":"function","z":"d8fe099880fd703d","name":"create token","func":"  let Expiration   = Date.now() + global.get('TokenLifetime')\n  let TokenContent = msg.authenticatedUser + ':' + Expiration\n\n  let TokenKey = global.get('TokenKey')\n    const HMAC = crypto.createHmac('sha256',TokenKey)\n    HMAC.update(TokenContent)\n  let Digest = HMAC.digest('hex')\n\n  msg.cookies = msg.cookies || {}\n  msg.cookies.authorization = { value:TokenContent + ':' + Digest }\n  return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":350,"y":360,"wires":[["b67fe1f3071cca07"]]},{"id":"9c83d20d034a8217","type":"function","z":"d8fe099880fd703d","name":"refresh token","func":"  let Expiration   = Date.now() + global.get('TokenLifetime')\n  let TokenContent = msg.authenticatedUser + ':' + Expiration\n\n  let TokenKey = global.get('TokenKey')\n    const HMAC = crypto.createHmac('sha256',TokenKey)\n    HMAC.update(TokenContent)\n  let Digest = HMAC.digest('hex')\n\n  msg.cookies = msg.cookies || {}\n  msg.cookies.authorization = { value:TokenContent + ':' + Digest }\n  return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":330,"y":240,"wires":[["8f16cbb0cc28ee30"]]},{"id":"80106edae73330cd","type":"inject","z":"d8fe099880fd703d","name":"at Startup","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payloadType":"date","x":110,"y":100,"wires":[["e1e821402ba2abe1"]]},{"id":"e1e821402ba2abe1","type":"change","z":"d8fe099880fd703d","name":"set token lifetime","rules":[{"t":"set","p":"TokenLifetime","pt":"global","to":"2*60*1000","tot":"jsonata"},{"t":"set","p":"payload","pt":"msg","to":"TokenLifetime","tot":"global"}],"action":"","property":"","from":"","to":"","reg":false,"x":310,"y":100,"wires":[["11640b19fd562bfb"]]},{"id":"11640b19fd562bfb","type":"debug","z":"d8fe099880fd703d","name":"Status","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"payload","statusType":"msg","x":530,"y":100,"wires":[]},{"id":"93bc823a9594e786","type":"reusable-in","z":"d8fe099880fd703d","name":"cookie auth","info":"describe your reusable flow here","scope":"global","x":90,"y":240,"wires":[["07b8b379eee00444"]]},{"id":"a2c8904560f5d8e1","type":"reusable-in","z":"d8fe099880fd703d","name":"cookie login","info":"describe your reusable flow here","scope":"global","x":90,"y":360,"wires":[["6f16c88dad72878f"]]},{"id":"8f16cbb0cc28ee30","type":"reusable-out","z":"d8fe099880fd703d","name":"authorized","position":1,"x":510,"y":240,"wires":[]},{"id":"4a4efb4deeea3a24","type":"reusable-out","z":"d8fe099880fd703d","name":"unauthorized","position":"2","x":510,"y":280,"wires":[]},{"id":"b67fe1f3071cca07","type":"reusable-out","z":"d8fe099880fd703d","name":"success","position":1,"x":520,"y":360,"wires":[]},{"id":"0a60fc2822814b75","type":"reusable-out","z":"d8fe099880fd703d","name":"failure","position":"2","x":530,"y":400,"wires":[]},{"id":"e7e4d431ae21b31c","type":"comment","z":"d8fe099880fd703d","name":"try yourself","info":"","x":80,"y":500,"wires":[]},{"id":"bcce3b9c21368743","type":"http in","z":"d8fe099880fd703d","name":"","url":"cookie-auth","method":"get","upload":false,"swaggerDoc":"","x":100,"y":560,"wires":[["a1d6fa210264c774"]]},{"id":"79102e3ebd685bba","type":"http response","z":"d8fe099880fd703d","name":"","statusCode":"","headers":{},"x":530,"y":620,"wires":[]},{"id":"b146b96c1e6e4802","type":"change","z":"d8fe099880fd703d","name":"inform about success","rules":[{"t":"set","p":"payload","pt":"msg","to":"successfully authorized","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":340,"y":620,"wires":[["79102e3ebd685bba"]]},{"id":"bca7975301895f4d","type":"http in","z":"d8fe099880fd703d","name":"","url":"cookie-auth","method":"post","upload":false,"swaggerDoc":"","x":110,"y":680,"wires":[["6ad54df9077948bd"]]},{"id":"5aa149195cb2bafd","type":"change","z":"d8fe099880fd703d","name":"inform about success","rules":[{"t":"set","p":"payload","pt":"msg","to":"successfully authorized","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":340,"y":740,"wires":[["9603dfde62c5f08b"]]},{"id":"9603dfde62c5f08b","type":"http response","z":"d8fe099880fd703d","name":"","statusCode":"","headers":{},"x":530,"y":740,"wires":[]},{"id":"a1d6fa210264c774","type":"reusable","z":"d8fe099880fd703d","name":"","target":"cookie auth","outputs":2,"x":290,"y":560,"wires":[["b146b96c1e6e4802"],["79102e3ebd685bba"]]},{"id":"6ad54df9077948bd","type":"reusable","z":"d8fe099880fd703d","name":"","target":"cookie login","outputs":2,"x":310,"y":680,"wires":[["5aa149195cb2bafd"],["9603dfde62c5f08b"]]},{"id":"824216bdbb591cea","type":"comment","z":"d8fe099880fd703d","name":"if used without \"node-red-within-express\"","info":"","x":180,"y":820,"wires":[]},{"id":"a58452a677c7be13","type":"inject","z":"d8fe099880fd703d","name":"at Startup","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payloadType":"date","x":100,"y":880,"wires":[["620a731991335960"]]},{"id":"dc02f08e694bc9ae","type":"file in","z":"d8fe099880fd703d","name":"","filename":"./registeredUsers.json","format":"utf8","chunk":false,"sendError":false,"encoding":"utf8","allProps":false,"x":160,"y":1000,"wires":[["14f00e0115539df9"]]},{"id":"4690b38a2f0f4c4c","type":"catch","z":"d8fe099880fd703d","name":"","scope":["dc02f08e694bc9ae","14f00e0115539df9"],"uncaught":false,"x":110,"y":1060,"wires":[["751a4b5f6d827646"]]},{"id":"f47143ada81d0161","type":"debug","z":"d8fe099880fd703d","name":"","active":true,"tosidebar":true,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"'could not load user registry'","statusType":"jsonata","x":510,"y":1060,"wires":[]},{"id":"4a700dfcceee780a","type":"debug","z":"d8fe099880fd703d","name":"Status","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"'user registry available'","statusType":"jsonata","x":530,"y":880,"wires":[]},{"id":"751a4b5f6d827646","type":"function","z":"d8fe099880fd703d","name":"create in global context","func":"  let UserRegistry = Object.create(null)\n    UserRegistry['node-red'] =  {\n      Roles: ['node-red'],\n      Salt: '4486e8d35b8275020b1301226cc77963',\n      Hash: 'ab2b740ea9148aa4f320af3f3ba60ee2e33bb8039c57eea2b29579ff3f3b16bec2401f19e3c6ed8ad36de432b80b6f973a12c41af5d50738e4bb902d0117df53'\n    }\n  global.set('UserRegistry',UserRegistry)\n\n  return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":310,"y":1060,"wires":[["f47143ada81d0161","8318c57d9522aa33"]]},{"id":"8494471c9c395698","type":"file","z":"d8fe099880fd703d","name":"","filename":"./registeredUsers.json","appendNewline":false,"createDir":false,"overwriteFile":"true","encoding":"utf8","x":420,"y":1180,"wires":[["5432a3f68d4f0c24"]]},{"id":"6c62fbae4d2acb44","type":"catch","z":"d8fe099880fd703d","name":"","scope":["8494471c9c395698"],"uncaught":false,"x":110,"y":1300,"wires":[["165a1ce2e9945426","dd9bbfd781f6ea50"]]},{"id":"165a1ce2e9945426","type":"debug","z":"d8fe099880fd703d","name":"","active":true,"tosidebar":true,"console":false,"tostatus":true,"complete":"true","targetType":"full","statusVal":"'could not write user registry'","statusType":"jsonata","x":250,"y":1340,"wires":[]},{"id":"5432a3f68d4f0c24","type":"change","z":"d8fe099880fd703d","name":"restore payload","rules":[{"t":"set","p":"payload","pt":"msg","to":"_payload","tot":"msg"},{"t":"delete","p":"_payload","pt":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":180,"y":1240,"wires":[["6b8835c3f66d4f0b"]]},{"id":"dd9bbfd781f6ea50","type":"change","z":"d8fe099880fd703d","name":"report in payload","rules":[{"t":"set","p":"payload","pt":"msg","to":"'Internal Server Error'","tot":"jsonata"},{"t":"set","p":"statusCode","pt":"msg","to":"500","tot":"str"},{"t":"delete","p":"_payload","pt":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":290,"y":1300,"wires":[["6b8835c3f66d4f0b"]]},{"id":"14f00e0115539df9","type":"function","z":"d8fe099880fd703d","name":"write to global context","func":"  let UserSet = JSON.parse(msg.payload)                             // may fail!\n\n  let UserRegistry = Object.create(null)\n  for (let UserId in UserSet) {\n    if (UserSet.hasOwnProperty(UserId)) {\n      if ((UserId.indexOf('/') >= 0) || (UserId.indexOf(':') >= 0)) {\n        throw 'Invalid character in UserId found'\n      }\n      \n      UserRegistry[UserId.toLowerCase()] = UserSet[UserId]\n    }\n  }\n  global.set('UserRegistry',UserRegistry)\n\n  msg.payload = ''\n  return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":400,"y":1000,"wires":[["8318c57d9522aa33"]]},{"id":"5cc4a021d6704942","type":"function","z":"d8fe099880fd703d","name":"→ catch","func":"// do not pass any msg from here!","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":360,"y":940,"wires":[["751a4b5f6d827646"]]},{"id":"94df8f09e02b2af2","type":"function","z":"d8fe099880fd703d","name":"→ catch","func":"// do not pass any msg from here!","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":300,"y":1120,"wires":[["dd9bbfd781f6ea50"]]},{"id":"ddd48700d0596225","type":"function","z":"d8fe099880fd703d","name":"read from global context","func":"  let UserRegistry = global.get('UserRegistry')\n  let UserSet = {}\n  for (let UserId in UserRegistry) {\n    if (UserRegistry[UserId] == null) {\n      UserSet[UserId] = null\n    } else {\n      let UserEntry = Object.assign({},UserRegistry[UserId])\n        delete UserEntry.Password     // never write passwords in plain text!\n      UserSet[UserId] = UserEntry\n    }\n  }\n\n  msg.payload = JSON.stringify(UserSet)\n  return msg\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":170,"y":1180,"wires":[["8494471c9c395698"]]},{"id":"8318c57d9522aa33","type":"reusable-out","z":"d8fe099880fd703d","name":"return","position":1,"x":530,"y":940,"wires":[]},{"id":"6b8835c3f66d4f0b","type":"reusable-out","z":"d8fe099880fd703d","name":"return","position":1,"x":530,"y":1240,"wires":[]},{"id":"483c14063ef038dd","type":"reusable-in","z":"d8fe099880fd703d","name":"load or create UserRegistry","info":"describe your reusable flow here","scope":"global","x":140,"y":940,"wires":[["5cc4a021d6704942","dc02f08e694bc9ae"]]},{"id":"5e75962e8f9a13df","type":"reusable-in","z":"d8fe099880fd703d","name":"write UserRegistry","info":"describe your reusable flow here","scope":"global","x":110,"y":1120,"wires":[["94df8f09e02b2af2","ddd48700d0596225"]]},{"id":"620a731991335960","type":"reusable","z":"d8fe099880fd703d","name":"","target":"load or create userregistry","outputs":1,"x":320,"y":880,"wires":[["4a700dfcceee780a"]]},{"id":"ad7e203958af3b87","type":"inject","z":"d8fe099880fd703d","name":"show UserRegistry","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payloadType":"date","x":130,"y":1480,"wires":[["4b46df7714a99db8"]]},{"id":"3005efbfdca160fb","type":"comment","z":"d8fe099880fd703d","name":"for testing purposes","info":"","x":110,"y":1420,"wires":[]},{"id":"4b46df7714a99db8","type":"function","z":"d8fe099880fd703d","name":"create output","func":"let UserRegistry = global.get('UserRegistry') || Object.create(null)\n  let UserList = []\n  for (let UserId in UserRegistry) {\n    UserList.push(\n      UserRegistry[UserId] == null ? '[' + UserId + ']' : UserId\n    )\n  }\nmsg.payload = (\n  UserList.length === 0\n  ? '(no user registered)'\n  : 'registered users: \"' + UserList.join('\",\"') + '\"'\n)\nreturn msg","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":340,"y":1480,"wires":[["164c9be0f8434f61"]]},{"id":"164c9be0f8434f61","type":"debug","z":"d8fe099880fd703d","name":"show","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":510,"y":1480,"wires":[]}]

Flow Info

Created 1 year ago
Rating:

Owner

Actions

Rate:

Node Types

Core
  • catch (x2)
  • change (x5)
  • comment (x4)
  • debug (x6)
  • file (x1)
  • file in (x1)
  • function (x11)
  • http in (x2)
  • http response (x2)
  • inject (x4)
Other

Tags

  • authorization
  • authentication
  • cookies
  • security
Copy this flow JSON to your clipboard and then import into Node-RED using the Import From > Clipboard (Ctrl-I) menu option