nginx reverse proxy setup does not preserve session id when doing CORS requests

15,602

Solution 1

It looks like you have a global proxy directive (proxy_pass), but you're not explicitly handling header forwarding (where, presumably the session token lives as a cookie), and further you need to think about what constitutes a (shared) session cache key.

Might try something like this:

location / {
   proxy_pass  http://127.0.0.1:3000/;                                
   proxy_redirect off;
   proxy_set_header   X-Real-IP  $remote_addr;
   proxy_set_header   X-Forwarded-For  $proxy_add_x_forwarded_for;
   proxy_set_header   X-Forwarded-Proto  $scheme;
   proxy_set_header   Host  $http_host;
   proxy_set_header   X-NginX-Proxy  true;
   proxy_http_version 1.1;
   proxy_cache pcache;
   proxy_cache_key   "$scheme$host$request_method$request_uri";
}

Also, if the node.js server is on the same box, not sure why you would connect via ssl on localhost (proxy_pass https://127.0.0.1:3001/). You might want to consider exposing an SSL-only public face with a rewrite directive by doing a: rewrite ^ https://api.domain.com$request_uri? permanent;

See also: Node.js + Nginx - What now? [SO] on basic setup, and http://www.ruby-forum.com/topic/4408747 for a good discussion on lost (and worse - leaked!) sessions from a nginx reverse proxy configuration snafu.

Solution 2

If you want to have session persistence, you need to be sure that you configured express-session module correctly. resave and saveUninitialized parameters are important.

From docs:

resave

Forces the session to be saved back to the session store, even if the session was never modified during the request. Depending on your store this may be necessary, but it can also create race conditions where a client makes two parallel requests to your server and changes made to the session in one request may get overwritten when the other request ends, even if it made no changes (this behavior also depends on what store you're using).

The default value is true, but using the default has been deprecated, as the default will change in the future. Please research into this setting and choose what is appropriate to your use-case. Typically, you'll want false.

How do I know if this is necessary for my store? The best way to know is to check with your store if it implements the touch method. If it does, then you can safely set resave: false. If it does not implement the touch method and your store sets an expiration date on stored sessions, then you likely need resave: true.

saveUninitialized

Forces a session that is "uninitialized" to be saved to the store. A session is uninitialized when it is new but not modified. Choosing false is useful for implementing login sessions, reducing server storage usage, or complying with laws that require permission before setting a cookie. Choosing false will also help with race conditions where a client makes multiple parallel requests without a session.

The default value is true, but using the default has been deprecated, as the default will change in the future. Please research into this setting and choose what is appropriate to your use-case.

Note if you are using Session in conjunction with PassportJS, Passport will add an empty Passport object to the session for use after a user is authenticated, which will be treated as a modification to the session, causing it to be saved.

One of my applications has this and it's working:

app.use(session({
    store: new RedisStore({
        host: config.redis_instance_local_ip,
        port: config.redis_instance_local_port
    }),
    secret: config.application_session_secret,
    resave: false,
    saveUninitialized: true
}));

Additionally, if you want to have sticky sessions (or session persistence) over multiple nodes load balanced by nginx, you should have commercial version of nginx, nginx plus. See http://nginx.com/products/session-persistence/

Share:
15,602
alexandru.topliceanu
Author by

alexandru.topliceanu

distributed systems enthusiast

Updated on June 05, 2022

Comments

  • alexandru.topliceanu
    alexandru.topliceanu almost 2 years

    Here's my setup: - I have an http server implemented in nodejs that exposes api endpoints. This is reverse proxied through nginx to api.domain.com with ssl. Here's the config:

     1 server {                                                           
     2     listen 80;                                                      
     3     server_name api.domain.com;                                     
     4     access_log /var/log/nginx/api.access.log;                       
     5     location / {                                                    
     6         proxy_pass    http://127.0.0.1:3000/;                       
     7     }                                                               
     8 }                                                                   
     9                                                                     
     10 server {                                                           
     11     listen 443;                                                    
     12     server_name api.domain.com;                                    
     13     access_log /var/log/nginx/api.access.log;                      
     14     ssl on;                                                        
     15     ssl_certificate /path/to/ssl/server.crt;     
     16     ssl_certificate_key /path/to/ssl/server.key;         
     17     location / {
     18         proxy_pass    https://127.0.0.1:3001/;                                
     19     }                                                              
     20 }
    

    Then I have nginx delivering a static context file under dashboard.domain.com that is intended to consume the api from api.domain.com. Here is the setup:

      1 server {                                                                                                                                 
      2     listen 80;                                                                  
      3     server_name dashboard.domain.com;                                        
      4     root /path/to/static/site;                                                                                             
      5 }  
    

    I want to do this using CORS, I made sure the js on the static site is sending the correct Origin header in all requests. I implemented a very simple login mechanism. Here's the coffeescript code I'm using on the api endpoint:

    # server.coffee
    app.configure ->
        app.use middleware.setP3PHeader()
        app.use express.bodyParser()
        app.use express.cookieParser()
        app.use express.session
          secret: conf.session.secret
          key: conf.session.key
          cookie:
              maxAge: conf.session.maxAge
        app.use express.methodOverride()
        app.use express.query()
        app.use express.errorHandler()
    
    # routes.coffee
    app.options '*', shop.cors, shop.options
    app.post '/login', shop.cors, shop.login
    app.post '/logout', shop.cors, shop.logout
    app.get '/current-user', shop.cors, shop.current
    
    # shop.coffee
    exports.options = (req, res) ->
        res.send 200
    
    exports.cors = (req, res, next) ->
        allowed = ['http://dashboard.domain.com', 'http://localhost:3000']
        origin = req.get 'Origin'
        if origin? and origin in allowed
            res.set 'Access-Control-Allow-Origin', origin
            res.set 'Access-Control-Allow-Credentials', true
            res.set 'Access-Control-Allow-Methods', 'GET,POST'
            res.set 'Access-Control-Allow-Headers', 'X-Requested-With, Content-Type'
            next()
        else
            res.send 403, "Not allowed for #{origin}"
    
    exports.login = (req, res) ->
        unless req.body.email? and req.body.password?
            res.send 400, "Request params not correct #{req.body}"
        models.Shop.findOne()
            .where('email').equals(req.body.email)
            .where('password').equals(req.body.password)
            .exec (err, shop) ->
                if err? then return res.send 500, err.message
                unless shop? then return res.send 401, "Not found for #{req.body}"
    
                req.session.shopId = shop.id
                res.send 200, shop.publish()
    
    exports.logout = (req, res) ->
        delete req.session.shopId
        res.send 200
    
    exports.current = (req, res) ->
        unless req.session.shopId?
            return res.send 401, "Not logged in!"
        models.Shop.findById(req.session.shopId)
            .exec (err, shop) ->
                if err? then return send.res 500, err.message
                unless shop? then return res.send 404, "No shop for #{req.session.shopId}"
    
                res.send 200, shop.publish()
    

    The problem is this: 1. I first make a call to /login and I get a new session with a logged in user (req.session.shopId) 2. Then I call /current-user but the session is gone! The session id received by the nodejs server is different and thus it creates a different session