Use of JWT in httpOnly cookie #4632

This discussion has been migrated from our Github Discussion #4632


luwes1y ago

  • I have created an RFC on the Strapi RFC Repo
  • I have checked for existing RFCs before creating this discussion topic

Describe the topic
This is a duplicate of the Strapi JS SDK issue strapi/strapi-sdk-javascript#31 by @sedubois but since the sdk is not maintained I like to open a discussion here.

JWTs are stored in localStorage, but according to this article (Randall Degges - Please Stop Using Local Storage), this is subject to XSS attacks so instead they should be stored in cookies.

please, please, whatever you do, do not store session information (like JSON Web Tokens) in local storage. This is a very bad idea and will open you up to an extremely wide array of attacks that could absolutely cripple your users.

Reply by @pierreburgy

According to many articles, storing tokens in local storage is an important security mistake:

But, tell me if I am wrong, the js-cookie module causes the exact same issue, because the token is accessible through any script running on the same domain. The only way to solve this issue is to store the token from a server with HttpOnly: developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Secure_and_HttpOnly_cookies.

Your suggestions for this topic

  • Would be great to have an answer by a Strapi team member describing why most Strapi examples work with localStorage.
  • I think some extra info in the docs would help beginning Strapi devs.
    Maybe on this page? Strapiā€™s documentation | Strapi Documentation
  • Add example code to implement authorization via a JWT cookie.
  • Discuss if a httpOnly cookie should be the preferred way in a browser context as itā€™s safer and looks simpler too. An authenticated user can easily hit a GET endpoint and view the authenticated content.
2 Likes

Responses to the discussion on Github


luwes1y ago

Author

The providers all return an access token. If the signed in user is to make any requests to the provider API (via Strapi) that access token also has to be stored somewhere. Currently there seems no way to get that access token from the user-permissions service.


derrickmehaffy1y ago

Collaborator

@luwes the JWTs are not stored by Strapi, they are generated on the fly and decrypted per request.

While I agree itā€™s not a good idea to store them in localStorage, there isnā€™t nothing stopping anyone from using httpOnly cookies.

@lauriejim thoughts on adding something to the docs? Personally I feel that goes beyond the realm of Strapi itself (as itā€™s not really a problem of strapi) that being said I have been all in favor of storing user session information to prevent hijacking/repeater attacks in which someone takes a JWT and uses it (from say a different IP Address than it was requested for).

To do the above ^^^ would related to my Discussion topic on actual storage of the JWT in something like Redis with a fallback to either memory or the database. #3866

Edit: Side note the JWT is ā€œcleanedā€ from being passed to any other part of strapi other than the users-permissions plugin.


luwes1y ago

Author

Thanks for your reply. Makes sense mostly. So we as strapi users would need to add extensions/users-permissions/controllers/Auth.js and add a httpOnly cookie there (can only be created by the server) with the JWT token.

To have authorization to the 3rd party API like Twitter, Instagram the gotten OAauth Access Token could be stored in the user DB table or so.

I think the part to store the JWT in Redis, DB, etc. makes less sense. This is needed on the client side right?


derrickmehaffy1y ago

Collaborator

No Redis is server side it would keep a cached state of the user and the token assigned to them/IP Address/ect.

Basically at the point of logout, the client would fire a request and delete that state.

(Iā€™m not entirely familiar with httpOnly cookies, if itā€™s something that Strapi has to send then it could be an option to add)


luwes1y ago

Author

I donā€™t see any benefit to storing the JWT on the server in Redis/DB. The JWT is needed on the client to authenticate the user (to ā€œlog inā€) (for every request). Either in local storage where you pass the JWT as a header Authorization: Bearer in the request from the client OR this is stored in the httpOnly cookie which doesnā€™t require passing that header all the time, itā€™s just gotten from that cookie which is available both on client and server.


derrickmehaffy1y ago

Collaborator

It acts as a means of revoking a JWT which isnā€™t currently possible without forcefully revoking all issued JWTs. When a JWT is created and sent to the client, until it expires it is valid, including if someone steals that JWT and uses it in a ā€œrepeaterā€ attack


derrickmehaffy1y ago

Collaborator

Along with the above ^^^ it would also allow for easier support of refresh tokens to reissue a JWT without the user needing to enter in their account information again.


luwes1y ago

Author

@derrickmehaffy I think I get your point but to be sure this will still require a JWT on the client correct? either via local storage or httpOnly cookie.

It could be a good first step to inform users how to add this in Strapi because a httpOnly cookie can only be set on the server. Most resources strongly recommend not using local storage it seems.


derrickmehaffy1y ago

Collaborator

Generally yes both options could exist but in the cases I mentioned even if someone got their hands on the token they wouldnā€™t be able to do anything.

On the flip side to that there are cases where JWT can still be secure such as with mobile apps keeping it locked inside an app sandbox or similar applications such as desktop apps or server scripts ect.

Benefit of a headless CMS is not all content may come from the browser :slight_smile: So we donā€™t want to lock out people who canā€™t use cookies


derrickmehaffy1y ago

Collaborator

I do agree with your solution though it would be worth added for better security


keymandll1y ago

This was not mentioned yet as far as I see, it may be under one of the links posted, but:

Please note if you passed JWT as an HttpOnly cookie you would also have to implement CSRF protection.

What I ended up implementing in one of my projects is .e.g.:

  1. All authenticated sessions are issued a CSRF token (CSRF tokens are OK to be stored in local storage)
  2. API allows JWT to be passed in either as a cookie (httpOnly) or in Auth header
  3. Only if JWT is received via cookie the API requires a valid CSRF token as well
  4. (Consider) Attribute within JWT to specify if it was issued to a user or an app. If token was issued to user, expect JWT as cookie, otherwise expect JWT in header.

Also:

  1. Once you have an XSS the outcome of an attack can be a lot more than stealing a JWT token.
  2. Caching JWTs in Redis, etc. is generally a bad idea. The nice thing about a JWT is that itā€™s issued to the client and the server does not have to keep a copy to be able to authenticate a client. If you put user JWTs in another service on ā€œserver-sideā€ you have to make sure that service is really really secure as if that service gets compromised, all cached JWTs will be exposed to the attacker.

yhrchan344d ago

Sorry I was also trying to figure out how to use httpCookie via Strapi as I donā€™t want to store the returned JWT to local storage on the client side. How would I do this? Is this a functionality which exist out of the box from Strapi? I couldnā€™t find such information from the documentations. Any help would be greatly appreciated!

Thanks!


curiousercreative261d ago

  1. Caching JWTs in Redis, etc. is generally a bad idea. The nice thing about a JWT is that itā€™s issued to the client and the server does not have to keep a copy to be able to authenticate a client. If you put user JWTs in another service on ā€œserver-sideā€ you have to make sure that service is really really secure as if that service gets compromised, all cached JWTs will be exposed to the attacker.

@keymandll can you reference anything for the ā€œgenerally a bad ideaā€ claim? Your prescription to have the token store be secure is valid of course, but itā€™d seem that Redis is no harder to secure for most applications and if you want token revocation, youā€™ll need to store them. SO question " Should I store JWT tokens in Redis"

Responses to the discussion on Github - Thread 1


Fl4zher260d ago

I actually have an issue with storing the JWT in localstorage. The issue is that i am using Next.js for SSR my application, and it is not possible to get values from localstorage with SSR.
I would better like the httpOnly cookie method. But another problem is that i am using GraphQL, so the question is - how can i edit the graphql context within Strapi?


sanojsilva241d ago

I do this in /extensions/users-permissions/config/policies/permissions.js

  if (ctx.request && ctx.request.header && !ctx.request.header.authorization) {
    const token = ctx.cookies.get("token");
    if (token) {
      ctx.request.header.authorization = "Bearer " + token;
    }
  }

And to set the token edit this in /extensions/users-permissions/controllers/Auth.js

const jwtToken = strapi.plugins["users-permissions"].services.jwt.issue(
 {
      id: user.id,
  }
);         
ctx.cookies.set("token", jwtToken, {
   httpOnly: true,
   maxAge: 1000 * 60 * 60 * 24 * 365,
   domain: process.env.NODE_ENV === "development" ? "localhost" : "https://livedomain",
 });

arnespremberg238d ago

See this tutorialā€¦ it should lead you to the right solutionā€¦


wesbos91d ago

That tutorial shows how to do it with a client side cookie, which is very similar to localStorage and not httpOnly

Responses to the discussion on Github - Thread 2


flofleche255d ago

Hi @Fl4zher ,

You can definitively add httpOnly cookies from the Strapi server-side by extending the users-permissions plugin (cf. https://strapi.io/documentation/3.0.0-beta.x/concepts/customization.html#plugin-extensions).

Then once youā€™ve extended it to your needs, it is possible to transform the Graphql Schema accordingly (cf. https://strapi.io/documentation/3.0.0-beta.x/plugins/graphql.html#customise-the-graphql-schema).

Iā€™ve made it work for me but youā€™ll have to dig up a little in KoaJS documentation to set cookies properly from the users-permissions controllers (cf. https://koajs.com/ and search there for ctx.cookies.set(name, value, [options])).

Also you should be careful of setting up CORS parameters correctly depending on your projectā€™s hosting configurations (I am using Nginx proxies with different subdomains for the client and the api).

I canā€™t say for sure that Iā€™ve implemented it the best way, but I can tell you that it is possible to achieve this goal for your own project by using the extending capabilities that Strapi offers.


devskope168d ago

How can I alter the users-permissions graphql schema, after implementing auth cookies, I want to override some schema types and resolvers.

See #6989

Responses to the discussion on Github - Thread 3


christopher-talke231d ago

@sanojsilvaā€™s feedback works.

I spent a little bit of time going through this for a project Iā€™m workin on right now, based on his notes I wrote this to make it a bit clearer: https://talke.dev/strapi-user-permissions-jwt-cookies


guillaumeduhan201d ago

Hello @christopher-talke, my last message was wrong sorry! My real question is: if you store your token server-side, you have to call the server on route change to check if authenticated? Thank you so much for this article !


guillaumeduhan200d ago

Thank you, I got my answer here: HttpOnly tells the browser to save the cookie without displaying it to client-side scripts : What is httponly cookie? - Latest Hacking News | Cyber Security News, Hacking Tools and Penetration Testing Courses :slight_smile:


andreasoby173d ago

Hi @christopher-talke, thanks for the thorough article! I have some issues with the httpOnly cookie not being set in Auth.js from line 132. When debugging in Chrome DevTools (Network tab), the Set-Cookie looks about right, but upon checking Cookies in the DevTools Application tab, no ā€œtokenā€ cookie is present. Would you have any idea why that is?


christopher-talke173d ago

Hi @christopher-talke, thanks for the thorough article! I have some issues with the httpOnly cookie not being set in Auth.js from line 132. When debugging in Chrome DevTools (Network tab), the Set-Cookie looks about right, but upon checking Cookies in the DevTools Application tab, no ā€œtokenā€ cookie is present. Would you have any idea why that is?

Can you attach a code snippit, Iā€™ll see If I can provide some feedback!


andreasoby172d ago

Thanks!

Strapi is running on http://localhost:1337 and my app is running on http://localhost:3000

This snippet is from Auth.js:132
`const token = strapi.plugins[ā€œusers-permissionsā€].services.jwt.issue({
id: user.id,
});

    ctx.cookies.set("token", token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production" ? true : false,
      maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age
      domain: process.env.NODE_ENV === "development" ? "localhost" : process.env.PRODUCTION_URL,
    });
    
    ctx.send({
      status: 'Authenticated',
      user: sanitizeEntity(user.toJSON ? user.toJSON() : user, {
        model: strapi.query('user', 'users-permissions').model,
      }),
    });

    console.log('TOKEN COOKIE', ctx.cookies.get('token'));`

Console.log on last line says that ctx.cookies.get('token') is undefined

Screenshot from DevTools Network tab
image

Screenshot from DevTools Application tab
image

Can you see what might be wrong?


devskope171d ago

@andreasoby you need to enable cors credentials in config/middleware.js


andreasoby170d ago

Hi @devskope. Thanks for replying! Iā€™m not sure if the middleware is added in the root config folder of Strapi (./config/middleware.js) and the content looks like this:
module.exports = () => ({ cors: true, });

It doesnā€™t work for me, so I bet Iā€™m not doing this right. Could you point me in the right direction here? andreasoby170d ago

Hi @devskope. Thanks for replying! Iā€™m not sure if the middleware is added in the root config folder of Strapi (./config/middleware.js) and the content looks like this:
module.exports = () => ({ cors: true, });

It doesnā€™t work for me, so I bet Iā€™m not doing this right. Could you point me in the right direction here?


pherm169d ago

@sanojsilvaā€™s feedback works.

I spent a little bit of time going through this for a project Iā€™m workin on right now, based on his notes I wrote this to make it a bit clearer: https://talke.dev/strapi-user-permissions-jwt-cookies

i have a error: TypeError: Cannot read property ā€˜logoutā€™ of undefined at routerChecker.

Fix:
Path ā€”> ./api/logout/config/routes.json ā€”> routes.json
Path ā€”> ./api/logout/controllers/Custom.js ā€”> Custom.js
docs: Strapiā€™s documentation | Strapi Documentation


pherm169d ago

Hi @devskope. Thanks for replying! Iā€™m not sure if the middleware is added in the root config folder of Strapi (./config/middleware.js) and the content looks like this:
module.exports = () => ({ cors: true, });

It doesnā€™t work for me, so I bet Iā€™m not doing this right. Could you point me in the right direction here?

i have same error. Iā€™m checking for any fixā€¦ any idea?


devskope169d ago

Hi @devskope. Thanks for replying! Iā€™m not sure if the middleware is added in the root config folder of Strapi (./config/middleware.js) and the content looks like this:
module.exports = () => ({ cors: true, });
It doesnā€™t work for me, so I bet Iā€™m not doing this right. Could you point me in the right direction here?

i have same error. Iā€™m checking for any fixā€¦ any idea?

./config/middleware.js

module.exports = () => ({ settings: { cors: { enabled: true, credentials: true, origin: <provide-allowed-origins>, // ex. "http://domain.com,localhost:3000" }, }, });

you also have to include credentials on clientside
@andreasoby @pherm


devskope169d ago

@christopher-talke, when overriding the controllers in Auth.js, the callback() function is not the only one that sets a jwt token on the response. There are 5 locations that should be modified to set the cookie.


pherm169d ago

@christopher-talke, when overriding the controllers in Auth.js, the callback() function is not the only one that sets a jwt token on the response. There are 5 locations that should be modified to set the cookie.

thank you @devskope, can explain about 5 locations that should be modified to set the cookie? itā€™s very appreciated


devskope168d ago

thank you @devskope, can explain about 5 locations that should be modified to set the cookie? itā€™s very appreciated

@pherm take a look at this
note where the setAuthCookie function is used


andreasoby167d ago

So far, so good. Iā€™ve managed to set the httpOnly cookie by following the example provided by @devskope. On top of that, I found that ā€œaccess-control-allow-originā€ needs to be added to the cors headers:
./config/middleware.js

module.exports = () => ({
  settings: {
    cors: {
      enabled: true,
      origin: `${process.env.ADMIN_HOST}, ${process.env.CLIENT_HOST}`,
      headers: [
        "Content-Type",
        "Authorization",
        "X-Frame-Options",
        "access-control-allow-origin"
      ]
    },
  },
});

Also, when using axios and signing in the user credentials needs to be enabled:

axios({
  method: 'POST',
  url: `${process.env.REACT_APP_BACKEND_URL}/auth/local`,
  withCredentials: true,
  data: {
    identifier: this.state.username,
    password: this.state.password,
  } 
})
.then(response => {
  // Handle success.
})
.catch(error => {
  // Handle error.
});

Now everything seems to work great!


pherm166d ago

it return flag httpOnly in dev tools?


andreasoby166d ago

It does. Are you using the Auth.js file provided by @devskope ?


pherm166d ago

Yes, but in my code in dev mode donā€™t return from DevTools APPLICATION flag HttpOnly , but return . It is a correct behaviour?


andreasoby165d ago

It should return an httpOnly cookie. Can you share your Auth.js and your folder structure so I can help you debug the issue?


[

pherm](pherm Ā· GitHub)165d ago

It should return an httpOnly cookie. Can you share your Auth.js and your folder structure so I can help you debug the issue?

Screenshot at Jul 16 11-58-15

i fix it, thank you @andrasoby :slight_smile: My issue: /extensions/user-permissions/config/policies/permissions.js ā†’ remove it!


devskope165d ago

@pherm deleting /extensions/user-permissions/config/policies/permissions.js would mean you wonā€™t be able to implement the logic needed to extract the token from the cookie and bind it to the auth header


pherm163d ago

have you implemented GET Request? @andreasoby


andreasoby161d ago

Yes, itā€™s basically the same as the POST request specified above, but without parsing any data object. As @devskope says, you need permissions.js for handling the authentication of the request.

Example:

axios({
  method: 'GET',
  withCredentials: true,
  url: `${process.env.REACT_APP_BACKEND_URL}/users/me`,
})
.then(response => {
  // Handle success.      
})
.catch(error => {
  // Handle error.
});

pherm160d ago

@devskope and @andreasoby as you solved about logout user client side?


ScottEAdams125d ago

For anyone else having any issues with the logout in @devskope excellent walkthrough - https://talke.dev/strapi-user-permissions-jwt-cookies , make sure to either post some empty data if using axios (data: {}) or switch the method in routes.json to GET.


devskope124d ago

@ScottEAdams that walkthrough is courtesy of @christopher-talke :muscle:t2:


ScottEAdams124d ago

Oops, sorry and thank you @christopher-talke !!


ms-mousa
3d ago
I know this quite old and sorry if that bings you all with emails, but just to document how I do this and I think itā€™s actually better than modifying strapi code through extensions.
I add two middlewares: cookieSetter and cookieGetter.
So letā€™s start here:

// ./config/middleware.js
module.exports = {
  load: {
    before: ['cookieGetter', 'responseTime', 'logger', 'cors', 'responses', 'gzip'],
    order: [
      "Define the middlewares' load order by putting their name in this array is the right order",
    ],
    after: ['parser', 'router', 'cookieSetter'],
  },
  settings: {
    cors: {
      origin: ['http://localhost:3000', 'PROD_URL_HERE'],
    },
    cookieGetter: {
      enabled: true
    },
    cookieSetter: {
      enabled: true
    }
  },
}; 

Then one file for each of those middlewares:

// ./middlewares/cookieSetter/index.js
module.exports = (strapi) => {
  return {
    initialize() {
      strapi.app.use(async (ctx, next) => {
        await next();
        const requestURL = ctx.request.url;
        if (requestURL.startsWith('/auth/')) {
          const responseCode = ctx.response.status;
          if (responseCode === 200) {
            const { jwt: jwtToken } = ctx.response.body;
            ctx.cookies.set('token', jwtToken, {
              httpOnly: true,
              secure: process.env.NODE_ENV === 'production',
              maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age
              // discard the domain property if in development mode to make the cookie work
              ...(process.env.NODE_ENV === 'production'
                ? { domain: process.env.PRODUCTION_URL }
                : {}),
            });
          }
        }
      });
    },
  };
};

// ./middlewares/cookieGetter/index.js
module.exports = (strapi) => {
  return {
    initialize() {
      strapi.app.use(async (ctx, next) => {
        if (ctx.request && ctx.request.header && !ctx.request.header.authorization) {
          const token = ctx.cookies.get('token');
          if (token) {
            ctx.request.header.authorization = `Bearer ${token}`;
          }
        }
        await next();
      });
    },
  };
};

Then declare a global axios instance:

import axios from 'axios';

export const Axios = axios.create({
  baseURL: process.env.BACKEND_URL, // http://localhost:1337
  withCredentials: true,
});

Responses to the discussion on Github - Thread 4


tommyboylab152d ago

How is this handled now in 3.1.0 with the inclusion of the Admin JWT token; is there any difference?

https://strapi.io/documentation/v3.x/migration-guide/migration-guide-3.0.x-to-3.1.x.html


tommyboylab138d ago

Iā€™ve updated the example Auth.js file above to use the latest one in Strapi 3.1. Iā€™m looking to use this with Oauth providers, but thereā€™s a current issue being tracked Iā€™ll link below.

It requires setting the url property in /config/server.js to the absolute url of the server youā€™re looking to use for the request.

'use strict';

/**
 * Auth.js controller
 *
 * @description: A set of functions called "actions" for managing `Auth`.
 */

/* eslint-disable no-useless-escape */
const crypto = require('crypto');
const _ = require('lodash');
const grant = require('grant-koa');
const { sanitizeEntity } = require('strapi-utils');
const { getAbsoluteServerUrl } = require('strapi-utils');

const emailRegExp = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
const formatError = error => [
  { messages: [{ id: error.id, message: error.message, field: error.field }] },
];

// JWT Token Setup
const issueAuthToken = (user) => {
  return strapi.plugins["users-permissions"].services.jwt.issue({
    id: user.id,
  });
};
const setAuthCookie = (ctx, token) => {
  ctx.cookies.set("authToken", token, {
    httpOnly: true,
    secure: env.NODE_ENV === "production" ? true : false,
    maxAge: 1000 * 60 * 60 * 24 * 15,
    domain: env.NODE_ENV.startsWith("dev") ? "localhost" : env.CLIENT_HOST,
  });
};

module.exports = {
  async callback(ctx) {
    const provider = ctx.params.provider || 'local';
    const params = ctx.request.body;

    const store = await strapi.store({
      environment: '',
      type: 'plugin',
      name: 'users-permissions',
    });

    if (provider === 'local') {
      if (!_.get(await store.get({ key: 'grant' }), 'email.enabled')) {
        return ctx.badRequest(null, 'This provider is disabled.');
      }

      // The identifier is required.
      if (!params.identifier) {
        return ctx.badRequest(
          null,
          formatError({
            id: 'Auth.form.error.email.provide',
            message: 'Please provide your username or your e-mail.',
          })
        );
      }

      // The password is required.
      if (!params.password) {
        return ctx.badRequest(
          null,
          formatError({
            id: 'Auth.form.error.password.provide',
            message: 'Please provide your password.',
          })
        );
      }

      const query = { provider };

      // Check if the provided identifier is an email or not.
      const isEmail = emailRegExp.test(params.identifier);

      // Set the identifier to the appropriate query field.
      if (isEmail) {
        query.email = params.identifier.toLowerCase();
      } else {
        query.username = params.identifier;
      }

      // Check if the user exists.
      const user = await strapi.query('user', 'users-permissions').findOne(query);

      if (!user) {
        return ctx.badRequest(
          null,
          formatError({
            id: 'Auth.form.error.invalid',
            message: 'Identifier or password invalid.',
          })
        );
      }

      if (
        _.get(await store.get({ key: 'advanced' }), 'email_confirmation') &&
        user.confirmed !== true
      ) {
        return ctx.badRequest(
          null,
          formatError({
            id: 'Auth.form.error.confirmed',
            message: 'Your account email is not confirmed',
          })
        );
      }

      if (user.blocked === true) {
        return ctx.badRequest(
          null,
          formatError({
            id: 'Auth.form.error.blocked',
            message: 'Your account has been blocked by an administrator',
          })
        );
      }

      // The user never authenticated with the `local` provider.
      if (!user.password) {
        return ctx.badRequest(
          null,
          formatError({
            id: 'Auth.form.error.password.local',
            message:
              'This user never set a local password, please login with the provider used during account creation.',
          })
        );
      }

      const validPassword = strapi.plugins['users-permissions'].services.user.validatePassword(
        params.password,
        user.password
      );

      if (!validPassword) {
        return ctx.badRequest(
          null,
          formatError({
            id: 'Auth.form.error.invalid',
            message: 'Identifier or password invalid.',
          })
        );
      } else {
        setAuthCookie(ctx, issueAuthToken(user));
        ctx.send({
          user: sanitizeEntity(user.toJSON ? user.toJSON() : user, {
            model: strapi.query('user', 'users-permissions').model,
          }),
        });
      }
    } else {
      if (!_.get(await store.get({ key: 'grant' }), [provider, 'enabled'])) {
        return ctx.badRequest(
          null,
          formatError({
            id: 'provider.disabled',
            message: 'This provider is disabled.',
          })
        );
      }

      // Connect the user with the third-party provider.
      let user, error;
      try {
        [user, error] = await strapi.plugins['users-permissions'].services.providers.connect(
          provider,
          ctx.query
        );
      } catch ([user, error]) {
        return ctx.badRequest(null, error === 'array' ? error[0] : error);
      }

      if (!user) {
        return ctx.badRequest(null, error === 'array' ? error[0] : error);
      }
      setAuthCookie(ctx, issueAuthToken(user));
      ctx.send({
        user: sanitizeEntity(user.toJSON ? user.toJSON() : user, {
          model: strapi.query('user', 'users-permissions').model,
        }),
      });
    }
  },

  async resetPassword(ctx) {
    const params = _.assign({}, ctx.request.body, ctx.params);

    if (
      params.password &&
      params.passwordConfirmation &&
      params.password === params.passwordConfirmation &&
      params.code
    ) {
      const user = await strapi
        .query('user', 'users-permissions')
        .findOne({ resetPasswordToken: `${params.code}` });

      if (!user) {
        return ctx.badRequest(
          null,
          formatError({
            id: 'Auth.form.error.code.provide',
            message: 'Incorrect code provided.',
          })
        );
      }

      const password = await strapi.plugins['users-permissions'].services.user.hashPassword({
        password: params.password,
      });

      // Update the user.
      await strapi
        .query('user', 'users-permissions')
        .update({ id: user.id }, { resetPasswordToken: null, password });

      setAuthCookie(ctx, issueAuthToken(user));
      ctx.send({
        user: sanitizeEntity(user.toJSON ? user.toJSON() : user, {
          model: strapi.query('user', 'users-permissions').model,
        }),
      });
    } else if (
      params.password &&
      params.passwordConfirmation &&
      params.password !== params.passwordConfirmation
    ) {
      return ctx.badRequest(
        null,
        formatError({
          id: 'Auth.form.error.password.matching',
          message: 'Passwords do not match.',
        })
      );
    } else {
      return ctx.badRequest(
        null,
        formatError({
          id: 'Auth.form.error.params.provide',
          message: 'Incorrect params provided.',
        })
      );
    }
  },

  async connect(ctx, next) {
    const grantConfig = await strapi
      .store({
        environment: '',
        type: 'plugin',
        name: 'users-permissions',
        key: 'grant',
      })
      .get();

    const [requestPath] = ctx.request.url.split('?');
    const provider = requestPath.split('/')[2];

    if (!_.get(grantConfig[provider], 'enabled')) {
      return ctx.badRequest(null, 'This provider is disabled.');
    }
    // Ability to pass OAuth callback dynamically
    grantConfig[provider].callback = _.get(ctx, 'query.callback') || grantConfig[provider].callback;
    grantConfig[provider].redirect_uri = `${strapi.config.server.url}/connect/${provider}/callback`;

    return grant(grantConfig)(ctx, next);
  },

  async forgotPassword(ctx) {
    let { email } = ctx.request.body;

    // Check if the provided email is valid or not.
    const isEmail = emailRegExp.test(email);

    if (isEmail) {
      email = email.toLowerCase();
    } else {
      return ctx.badRequest(
        null,
        formatError({
          id: 'Auth.form.error.email.format',
          message: 'Please provide valid email address.',
        })
      );
    }

    const pluginStore = await strapi.store({
      environment: '',
      type: 'plugin',
      name: 'users-permissions',
    });

    // Find the user by email.
    const user = await strapi.query('user', 'users-permissions').findOne({ email });

    // User not found.
    if (!user) {
      return ctx.badRequest(
        null,
        formatError({
          id: 'Auth.form.error.user.not-exist',
          message: 'This email does not exist.',
        })
      );
    }

    // Generate random token.
    const resetPasswordToken = crypto.randomBytes(64).toString('hex');

    const settings = await pluginStore.get({ key: 'email' }).then(storeEmail => {
      try {
        return storeEmail['reset_password'].options;
      } catch (error) {
        return {};
      }
    });

    const advanced = await pluginStore.get({
      key: 'advanced',
    });

    const userInfo = _.omit(user, ['password', 'resetPasswordToken', 'role', 'provider']);

    settings.message = await strapi.plugins['users-permissions'].services.userspermissions.template(
      settings.message,
      {
        URL: advanced.email_reset_password,
        USER: userInfo,
        TOKEN: resetPasswordToken,
      }
    );

    settings.object = await strapi.plugins['users-permissions'].services.userspermissions.template(
      settings.object,
      {
        USER: userInfo,
      }
    );

    try {
      // Send an email to the user.
      await strapi.plugins['email'].services.email.send({
        to: user.email,
        from:
          settings.from.email || settings.from.name
            ? `${settings.from.name} <${settings.from.email}>`
            : undefined,
        replyTo: settings.response_email,
        subject: settings.object,
        text: settings.message,
        html: settings.message,
      });
    } catch (err) {
      return ctx.badRequest(null, err);
    }

    // Update the user.
    await strapi.query('user', 'users-permissions').update({ id: user.id }, { resetPasswordToken });

    ctx.send({ ok: true });
  },

  async register(ctx) {
    const pluginStore = await strapi.store({
      environment: '',
      type: 'plugin',
      name: 'users-permissions',
    });

    const settings = await pluginStore.get({
      key: 'advanced',
    });

    if (!settings.allow_register) {
      return ctx.badRequest(
        null,
        formatError({
          id: 'Auth.advanced.allow_register',
          message: 'Register action is currently disabled.',
        })
      );
    }

    const params = {
      ..._.omit(ctx.request.body, ['confirmed', 'resetPasswordToken']),
      provider: 'local',
    };

    // Password is required.
    if (!params.password) {
      return ctx.badRequest(
        null,
        formatError({
          id: 'Auth.form.error.password.provide',
          message: 'Please provide your password.',
        })
      );
    }

    // Email is required.
    if (!params.email) {
      return ctx.badRequest(
        null,
        formatError({
          id: 'Auth.form.error.email.provide',
          message: 'Please provide your email.',
        })
      );
    }

    // Throw an error if the password selected by the user
    // contains more than two times the symbol '

More info on current issue here: #6570

Responses to the discussion on Github - Thread 5


iksent119d ago

Solution for Strapi 3.1.1, that worked for me:

https://talke.dev/strapi-user-permissions-jwt-cookies
+

  1. Change call to ā€œfetchā€ at extensions/users-permissions/config/policies/permissions.js:
      // fetch authenticated user
      ctx.state.user = await strapi.plugins[
        'users-permissions'
        ].services.user.fetch({id});
  1. Enable CORS at config/middleware.js:
module.exports = {
  settings: {
    cors: {
      origin: ['http://localhost:8000'],
    },
  },
};

  1. Pass withCredentials: true to all requests (both /auth and /articles)

willhxt109d ago

Im having the same issues mentioned above. The cookie does not appear to be setting on the strapi server.

In my browser in the Network tab, it shows the token cookie. On the Application tab, there is no cookie.

   ctx.cookies.set("token", token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === "production" ? true : false,
      maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age
      domain: process.env.NODE_ENV === "development" ? "localhost" : process.env.PRODUCTION_URL,
    });
    
    ctx.send({
      status: 'Authenticated',
      user: sanitizeEntity(user.toJSON ? user.toJSON() : user, {
        model: strapi.query('user', 'users-permissions').model,
      }),
    });

    console.log('TOKEN COOKIE', ctx.cookies.get('token'));

Console.log result is undefined as previous user posted.

My middleware.js is set up properly:

module.exports = () => ({
  settings: {
    cors: {
      enabled: true,
      credentials: true,
      origin: ['http://localhost:8080'],
      headers: [
        "Content-Type",
        "Authorization",
        "X-Frame-Options",
        "access-control-allow-origin"
      ]
    },
  },
});

I also modified the logout function:

"use strict";

module.exports = {
  async logout(ctx) {
    const token = ctx.cookies.get('token');

    if (token != undefined) {
      ctx.cookies.set('token', null);
      ctx.send({
        authorized: true,
        message: "Successfully logged out.",
      });
    }

    else {
      ctx.send({
        authorized: false,
        message: "Unable to logout. You are not logged in.",
      });
    }

  }
};

When POST to /logout I receive ā€œUnable to logout. You are not logged in.ā€ as it is detecting the token as undefined.

Im at a loss. I do not know why the cookie is being sent via network response, but not actually being set in strapi backend. Any ideas?


iksent109d ago

Hello, did you pass withCredentials: true to your request at frontend?


willhxt109d ago

Yes I did. I am using Vue.js

<script>
  import axios from 'axios';
  export default {
    data() {
      return {
        errors: [],
        form: {
          email: '',
          password: '',
        },
        strapi_url: process.env.STRAPI_URL || 'http://localhost:1337'
      }
    },
    methods: {
      login_user: function() {

        // Get jwt token
        axios.post(this.strapi_url + '/auth/local', {
            withCredentials: true,
            identifier: this.form.email,
            password: this.form.password
        }).then(response => {

          // Store token and set store data
          this.$store.commit('set_logged_in', true);

          // Store email
          this.$store.commit('set_user_email', this.form.email);

          // Re-direct home
          this.$router.push('/');

          console.log('User profile', response.data);
        })
        .catch(error => {
          // Handle error.
          this.errors = [];
          if (error.response.status == 400) {
            var i;
            for (i = 0; i < error.response.data.data[0].messages.length; i++) {
              this.errors.push(error.response.data.data[0].messages[i]);
            }
          }
        });
      },
    },
    created() {
      if (this.$store.getters._logged_in == true) {
        this.$router.push("/");
      }
    }
  }
</script>

iksent109d ago

You are passing it as a data param, but it is not correct. Try this:

axios.post(this.strapi_url + '/auth/local', {
            identifier: this.form.email,
            password: this.form.password
}, {withCredentials: true})

willhxt109d ago

That did the trick. Thank you so much. Been beating my head on the laptop for hours.

Youā€™re awesome!


willhxt109d ago

One last question (Iā€™m still new to Vue and JWT)ā€¦ with this example, what is the best way to confirm if a user is logged in? I know its a bit off topic, but Iā€™m just trying to figure out what I should store inside of my localStorage for Vue.

I assume if I access an API endpoint, and there is no token, it will return a 403 error (I assume).

Should I store only the user characteristics/data in the localStorage? Iā€™m using VuexPersistence localStorage in Vue, so when i close the browser, my localStorage should remain. This could be bad in 14 days when the token expires.

I guess Iā€™m just not sure how to go about checking if I still have a valid cookie/JWT token from Vue and if the user is still logged in.

When the token expires, I obviously need to force the user to logout on the front end. Right now it stores ā€œis_logged_inā€ as part of the localStorage state using mutations/setters and getters.

I also have ideas of having the user re-authenticate after X amount of minutes/hours of being inactive (I plan to tackle this further down the road). For now, I just need some direction or guidance on how to handle confirmation on whether a user is still logged in.

Thanks again, and feel free to re-direct me to a better forum or thread that can better help me learn.

Responses to the discussion on Github - Thread 6


F4brice7439d ago

Hi guys ! thank you for this complete solution. I followed the guide from @christopher-talke and solutions from @devskope adn everything works perfectā€¦ on local but not in production.
My strapi backend is on heroku and my frontend is on netlifly.
So i have to configure httponly cookies with secure: true and `sameSite: ā€˜noneā€™.

  • With a secure flag set to true and sameSite set to ā€˜noneā€™ my backend send me an error500."an intenral servor occured
    `{statusCode: 500, error: ā€œInternal Server Errorā€,
    message: ā€œAn internal server error occurredā€
    }
  • With no secure flag and sameSite set to ā€˜noneā€™ my authentication is working, but my httponly cookies are blocked (normal).
  • With no secure flag and no samesite, my authentication is working but my httponly cookies are blocked (normal).

I donā€™t understand why a secure flag put my backend downā€¦
I have exacltly the same permissions, middleware and auth.js file and everything is working locally (login, register, authenticated routesā€¦) but nothing in production.

in auth.js (i reduce to the essential to limit causes):

ctx.cookies.set(ā€œtokenā€, token, { httpOnly: true, secure: true, maxAge: 1000 * 60 * 60 * 24 * 14, domain: ā€˜https://myBackendProductionUrl.comā€™, sameSite: ā€˜noneā€™, });

my middleware.js (i reduce to the essential to limit causes):

module.exports = () => ({ settings: { cors: { enabled: true, credentials: true, origin: ā€œhttps://myProductionUrl.comā€ headers: [ ā€œContent-Typeā€, ā€œAuthorizationā€, ā€œX-Frame-Optionsā€, ā€œaccess-control-allow-originā€ ] }, }, });

and my axios request is

await axios.post(${backendUrl}/auth/local, { identifier: email, password: password, }, { withCredentials: true } ) //rest of code

Any idea ?
Thanks for all !


ra-devweb26d ago

Hi, I have the same issue do you find any solution for this!?


F4brice7424d ago

Hi,
Sorry i didnā€™t find solutions to this issues.
I removed all of my authentication with httpOnly and rebuilt with Auth0

Have you find any solution? I have Strapi v3.4.1 on my server and so far I couldnā€™t find a working solution.

It seems like the only problem here is cookie itself. I can make POST request from another domain (my backend and frontend are on separate domains) but as soon as I want to authenticate a user with the described method (httponly cookie) it doesnā€™t work on production and I get always 500 from the server. Local, when both backend and frontend run on localhost it does work.

I have tried all the solutions and change the configuration of cors in strapiā€™s config.js multiple times but it seems that nothing really helps.

So my question is how and if it can be done?

I have managed to find out that if in Auth.js the secure property will be changed to false, then it will work but the cookie itself wonā€™t be saved in browser. I could also see that with this solution (this is the very same config as that for local dev server) the authenticated request arenā€™t working as intended - the cookie wonā€™t be send on prod server. Weird enough if I log in locally and then make authenticated request, Strapi does know it was sent by an authenticated user. On production it doesnā€™t work like that and I donā€™t know why.

It could be possible fix by setting proxy: true in server.js file from Strapi. Sadly it breaks my app on the server, where I canā€™t adjust Nginx proxy by myself.

For all those struggling with this cookie auth: I have found a solution for my case.

So my config like something like this:
My Client: https:// client.lorem.com
Strapi: https://strapi.lorem.com

Until now I cannot achieve the very last task: make the browser to save the cookie. The cookie itself was correctly set by the server and attached to the response header but for some reason the browser didnā€™t want to save the cookie, which would be then visible under application/cookies in Chrome.

The solution was quite simple: the key is to set the right URL for cookieā€™s domain in Auth.js.

I was convinced that in my case it should be my clientā€™s URL. Then I removed https:// but still it didnā€™t work. I donā€™t know why but after some time and a few re-deployments of Strapi I could see a yellow icon with a following warning: set-cookie domain attribute was invalid with regards to the current host url next to response set-cookie. It turned out that domain must be set either to lorem.com or strapi.lorem.com. Finally with so adjusted domain the cookie will be saved in the browser under both client and api URL and the whole authentication process will working.

1 Like

Can you clarify which part you are referring to @kwiat1990 ? (Also thank you for the information :wink: )

Yeah, I made changes in Auth.js, where the cookie with userā€™s token is created (I adjusted domainā€™s URL and added sameSite: "none") and I needed also to declare in Strapiā€™s middleware.js cors - only cors option. Important was to add both URLs - client and api.

1 Like

Hi @kwiat1990, can you perhaps share your final working code?
I followed the instructions from https://talke.dev/strapi-user-permissions-jwt-cookies and although I can see the cookie in the browser in the response header, it is not saved by the browser in application/cookies.

My browser isnā€™t saving the cookie, although Strapi does add it to the response header.


Following the instructions from talke.dev I did the following:

// .extensions/users-permissions/controllers/Auth.js

... omitted code ...

const token = strapi.plugins["users-permissions"].services.jwt.issue({
          id: user.id,
        });
        ctx.cookies.set("token", token, {
          httpOnly: true,
          secure: process.env.NODE_ENV === "production" ? true : false,
          maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age
          domain:
            process.env.NODE_ENV === "development"
              ? "localhost"
              : process.env.PRODUCTION_URL,
        });
        ctx.send({
          jwt: strapi.plugins["users-permissions"].services.jwt.issue({
            id: user.id,
          }),
          status: "Authenticated",
          user: sanitizeEntity(user.toJSON ? user.toJSON() : user, {
            model: strapi.query("user", "users-permissions").model,
          }),
        });

... omitted code ...
// .extensions/users-permissions/config/policies/permissions.js
... omitted code ...

let role;

  if (ctx.state.user) {
    // request is already authenticated in a different way
    return next();
  }

  if (ctx.request && ctx.request.header && !ctx.request.header.authorization) {
    const token = ctx.cookies.get("token");
    if (token) {
      ctx.request.header.authorization = "Bearer " + token;
    }
  }

... omitted code ...

and finally

// .config/middleware.js

module.exports = () => ({
  settings: {
    cors: {
      enabled: true,
      credentials: true,
      origin: ["http://localhost:3001"],
      headers: [
        "Content-Type",
        "Authorization",
        "X-Frame-Options",
        "access-control-allow-origin",
      ],
    },
  },
});

On the client-side, I am using a brand new React app with a Login component:

// Login.js - on React Client

const handleSubmit = async (e) => {
    e.preventDefault();
    try {
      const response = await fetch("http://localhost:1337/auth/local", {
        method: "POST",
        credentials: "same-origin",
        // mode: "no-cors",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify(data),
      });
      console.log(response);
    } catch (error) {
      console.log(error);
    }
  };

PS, if I test using Postman as a client, it works just fine.

What am I doing wrong?

@Stephan_Du_Toit yeah, sure. When it comes to the implementation, then I have done everything as described in the tutorial. The only difference is that I went with cookieSetter and cookieGetter as part of Strapiā€™s middleware because I think itā€™s the most elegant solution.

The most crucial thing is to set origin URLs correctly. You have to declare the full path (e.g. ā€œhttps://my-strapi.comā€ and ā€œhttps://my-frontend.comā€) to your backend and your frontend.

This is my middleware.js file:

module.exports = ({ env }) => ({
  load: {
    before: [
      "cookieGetter",
      "responseTime",
      "logger",
      "cors",
      "responses",
      "gzip",
    ],
    order: [
      "Define the middlewares' load order by putting their name in this array is the right order",
    ],
    after: ["parser", "router", "cookieSetter"],
  },
  settings: {
    cors: {
      origin: [
        env("CLIENT_URL", "http://localhost:8080"),
        env("API_URL", "http://localhost:1337"),
      ],
    },
    cookieGetter: {
      enabled: true,
    },
    cookieSetter: {
      enabled: true,
    },
  },
});

Implementation details of both cookie functions are below. IMPORTANT: inside cookie setter you need to declare a domain property (process.env.CLIENT_HOSTNAME), which value is the hostname of your frontend, e.g. ā€œmy-frontend.comā€.

// cookieSetter
module.exports = (strapi) => {
  return {
    initialize() {
      strapi.app.use(async (ctx, next) => {
        await next();
        if (
          ctx.request.url.startsWith("/auth/") &&
          ctx.response.status === 200
        ) {
          const { jwt: jwtToken } = ctx.response.body;
          ctx.cookies.set("token", jwtToken, {
            httpOnly: true,
            secure: process.env.NODE_ENV === "production",
            maxAge: 1000 * 60 * 60 * 24 * 14, // 14 Day Age
            domain: process.env.CLIENT_HOSTNAME,
            sameSite: process.env.NODE_ENV === "development" ? true : "none",
            overwrite: true,
          });
        }
      });
    },
  };
};

// cookieGetter
module.exports = (strapi) => {
  return {
    initialize() {
      strapi.app.use(async (ctx, next) => {
        if (
          ctx.request &&
          ctx.request.header &&
          !ctx.request.header.authorization
        ) {
          const token = ctx.cookies.get("token");
          if (token) {
            ctx.request.header.authorization = `Bearer ${token}`;
          }
        }
        await next();
      });
    },
  };
};

With this code I have a fully functional authorization process, which works like a charm.

3 Likes

Awesome! Thanks so much. I will try it shortly and revert with the result.

@kwiat1990 Thanks so much! It worked. I adapted my code to use the middleware approach. Smooth sailing!

Iā€™m happy that I could help. I have struggled with this one as well and spent way too much time investigating what was wrong.

I think the JWT httpOnly cookie would guarantee sufficient security in combination with CSRF tokens. Does anyone know how to implement these together?