angular / universal-starter

Angular Universal starter kit by @AngularClass
2.02k stars 688 forks source link

Any way to do server-side rendering with auth? #373

Closed jackspaniel closed 7 years ago

jackspaniel commented 7 years ago

We're loading a page which loads and renders multiple components on the server side using http.get. This works fine for the anonymous user scenario. But for authenticated users the server-side http.get calls fail - because they don't have the right cookies. In this case should we just be loading everything client-side, or is there some workaround?

Dumber question: is there any way to get something from node resident memory into angular w/o using http.get or some other server-side http call? Like say you're fine with it breaking if angular universal isn't working - is there some sneaky way to get stuff from node straight into server-side angular?

amitjain28 commented 7 years ago

you can use express-session

jackspaniel commented 7 years ago

Angular universal doesn't add the express session cookie when the http.get call is made from the server side. Looks like I may be able to do something with providers and node.module.ts vs. browser.module.ts. I found a video that explains these. Is there any documentation on using providers? I can't find much and most out there is a year old or more. https://www.youtube.com/watch?v=qOjtFjXoebY

ozknemoy commented 7 years ago

google and stackoverflow are silent about this

jackspaniel commented 7 years ago

FYI - here's what we ended up doing to get our auth cookies through from the browser to angular universal:

add to typings.d.ts: declare var Zone: {current: any};

add to ngApp(): originUrl: 'http://' + req.hostname + ':' + configManager.get('server.port');

add to app.node.module.ts:

export function getRequest() {
  const req = Zone.current.get('req') || {};
  return req;
}
export function getResponse() {
  return Zone.current.get('res') || {};
}
...
providers: [
 { provide: 'req', useFactory: getRequest },
 { provide: 'res', useFactory: getResponse },

add to app.browser.module.ts:

// Dummy methods for browser - to retain symmetry with serverside requests
export function getRequest() {
  // the request object only lives on the server
  return { headers: null};
}
export function getResponse() {
  // the response object is sent as the index.html and lives on the server
  return {};
}
...
providers: [
 { provide: 'req', useFactory: getRequest },
 { provide: 'res', useFactory: getResponse },

In our service, to add cookie headers only (we don't want stuff like the gzip headers):

...
 constructor(@Inject('req') private req: any, ...) {
...
   // for server side headers pass cookie only from browser request
   // pass nothing for browser case (headers is null)
   this.req.headers = (this.req.headers) ? {cookie: this.req.headers.cookie} : null;

   return this.http.get(layoutApiUrl, {headers: this.req.headers}) 
...

I'm looking at doing this in an HTTP interceptor instead. But sadly HTTP interceptors are not as straightforward in angular 2. Will post that if I get it working.

ozknemoy commented 7 years ago

may be it helps. i use ng-http-interceptor

import { HttpInterceptorModule } from 'ng-http-interceptor';
@NgModule({
    ...
    imports: [ 
        HttpInterceptorModule,
    ...

and in my (http) service:
httpInterceptor.request().addInterceptor((data, method) => {
            if ($auth.isAuthenticated()) {
                let headers = this.doWitAuth();
                if (/(put|post)/i.test(method)) {
                    data[2] = {headers: headers};
                } else if (/(delete)/i.test(method)) {
                    data[1] = {headers: headers};
                }
            }
            return data;
        });

doWitAuth() {
        let _headers = new this.headers();
        _headers.append('X-AUTH-TOKEN', this.getToken());
        return _headers
    }
MarkPieszak commented 7 years ago

HttpInterceptors look like they'll be added in 4.1 which will be helpful for situations like this as well! @ozknemoy

ozknemoy commented 7 years ago

solve our issue. server check auth by serching 'X-AUTH-TOKEN' in header

node.module.ts :
@NgModule({
...
providers: [ LocalStorage, ... ]
})
export class MainModule {
  constructor(
      public cache: CacheService,
      @Inject('req') private req: any,
      public localStorage: LocalStorage
  ) {

      // LocalStorage is a class for store and handle auth information which will be used when node send request to server

   // getCookie() returns string or undefined
      var _token = localStorage.getCookie('token',req.headers.cookie);
  // set 'token' in server-side 'localStorage'
      localStorage.set('token', _token );
}...

httpService.ts:
import { Http,Headers } from '@angular/http';
...
getWithAuth(url) {
     // set headers wherever you need
        return this.http.get(this.BASE_URL + url, {
            headers: this.doWitAuth()
        }).map(r=> r.json())
    }

doWitAuth() {
        let _headers = new this.headers();// from Headers
        _headers.append('X-AUTH-TOKEN', this.localStorage.get('token'));
        return _headers
    }
....

dont need to use interceptors in that case. as middleware that code do not interfere with the rest of frontend app

AmrineA commented 7 years ago

@ozknemoy It looks like what you're doing is using some type of LocalStorage that is available in node to hold the token and provide it. That being said, does this work if multiple users call the server at the same time? What I'm wondering is if the LocalStorage is used on the server side, would the values stored in there be available to any users who access the server. When it's used on the client, it's obviously only in that one user's browser, but with it being on the server, I'm wondering if the values stored in it are separated per user.

ozknemoy commented 7 years ago

@AmrineA it stores only inside app, like local variable. node do not memorize this state

jackspaniel commented 7 years ago

So is the httpInterceptor method posted by ozknemoy the preferred solution?

Toxicable commented 7 years ago

@jackspaniel Angular now has HttpClient which can do HttpInterceptors see more here https://angular.io/guide/http

jackspaniel commented 7 years ago

Right I know it has HttpInterceptors. But it still can't do auth out of the box. So I guess the method above is the best solution.

FYI - most of our pages require a logged-in user, which we identify by browser cookies. So we have to replicate that when an angular page template is run on the server-side - which makes ajax calls to fill in content. By default those calls don't pass on any cookie headers from the originating request.

MarkPieszak commented 7 years ago

We can make a quick example of it in some Docs markdown file in angular/universal soon. But yes basically you want to grab what you need from the Request object and put it inside of an http interceptor and voila you should be all set! 👍

matmeylan commented 6 years ago

@MarkPieszak Did you get the chance to make the quick example in the docs ? I can't get my head around it.

Gomathipriya commented 6 years ago

@jackspaniel Can you please share the code how you did this?

jackspaniel commented 6 years ago

I did. It's all above.

I would definitely use an http interceptor now though that they are available. The code shouldn't change much, just where you put some of it.

stephanegg commented 6 years ago

@MarkPieszak any news ? It would be amazing if you could provide a quick example in the docs. This is a requirement for most of apps with authentification and a sensible point in terms of security, it would definitely find its place in the docs 👍

denis-andrikevich commented 6 years ago

up

samber commented 6 years ago

You cannot use local storage, as data from your browser are not available during the server side rendering. Please note that using localstorage for auth is not recommended !

If you use cookie, this is how to do:

// server.ts

import * as express from 'express';
import * as cookieparser from 'cookie-parser';

const app = express();
app.use(cookieparser());

// https://github.com/angular/angular/issues/15730
import * as xhr2 from 'xhr2';
xhr2.prototype._restrictedHeaders.cookie = false;
// server.ts

app.get('*', (req, res) => {
  console.time(`GET: ${req.originalUrl}`);
  res.render(
    'index', {
      req: req,
      res: res,
      providers: [
        {
          provide: 'REQUEST', useValue: (req)
        },
        {
          provide: 'RESPONSE', useValue: (res)
        },
      ]
    },
    (err, html) => {
      console.timeEnd(`GET: ${req.originalUrl}`);
      if (!!err) throw err;
      res.send(html);
    }
  );
});
// src/app/services/http.helper.ts

import { Injectable, Inject, PLATFORM_ID } from "@angular/core";
import { isPlatformBrowser, isPlatformServer } from '@angular/common';

interface IHttpOptions {
    body?: any;
    headers?: HttpHeaders | {
        [header: string]: string | string[];
    };
    params?: HttpParams | {
        [param: string]: string | string[];
    };
    reportProgress?: boolean;
    responseType?: 'arraybuffer' | 'blob' | 'json' | 'text';
    withCredentials?: boolean;
}

@Injectable()
class MyHttpClient extends HttpClient {

    constructor(
        handler: HttpHandler,
        private injector: Injector,
        @Inject(PLATFORM_ID) private platformId: Object,
    ) {
        super(handler);
    }

    // `first` is either method or httprequest
    // overwrites `request()` from `HttpClient`
    request(first: string | HttpRequest<any>, url?: string, options: IHttpOptions = {}): Observable<any> {
        // ensures headers properties are not null
        if (!options)
            options = {};
        if (!options.headers)
            options.headers = new HttpHeaders();
        if (typeof first !== "string" && !first.headers)
            first = (first as HttpRequest<any>).clone({ headers: new HttpHeaders() });

        // xhr withCredentials flag
        if (typeof first !== "string")
            first = (first as HttpRequest<any>).clone({
                withCredentials: true,
            });
        options.withCredentials = true;

        // if we are server side, then import cookie header from express
        if (isPlatformServer(this.platformId)) {
            const req: any = this.injector.get('REQUEST');
            const rawCookies = !!req.headers['cookie'] ? req.headers['cookie'] : '';

            if (typeof first !== "string")
                first = (first as HttpRequest<any>).clone({ setHeaders: {'cookie': rawCookies} });
            options.headers = (options.headers as HttpHeaders).set('cookie', rawCookies);
        }

        return super.request(first as (any), url, options);
    }

}
// anything.ts

import { Component } from '@angular/core';
import { MyHttpClient } from './services/http.helper'

@Component({
  selector: 'my-component',
})
class MyComponent {

    constructor(private http: MyHttpClient) { }

    doThings() {
        // your server will receive the right cookies
        this.http.get("https://example.com/foo/bar")
            .subscribe(...............);
    }

}

Enjoy ;)

AG-private commented 6 years ago

@samber Thanks! Worked for me well.

DionisisKav commented 6 years ago

This solves the problem of passing the request.cookies to the server side . But If your api, that is executed on server returns you a new token, how you will pass it back to the client and store it as a cookie ?

m98 commented 6 years ago

Cookies store in the browser, and by every HTTP request, they send it back to the server. Then you already have access to cookies both on server and browser, and you don't need to do any additional thing. You just need to write a service which is responsible for reading cookies both on server and browser

DionisisKav commented 6 years ago

@m98 i'm not 100 % sure what you are telling me .Let me reproduce my problem providing you some code.


    intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

      const split = request.url.split('api', 2);
      const targetUrl = split[1];

       request = this.cloneRequest(request, targetUrl);
       if (isPlatformServer(this.platformId)) {
            const req: express.Request = this.injector.get('REQUEST');
            const rootDomain = req.hostname.split('.').slice(-2).join('.');
            console.log(rootDomain);

            if (request.url.match(/^https?:\/\/([^/:]+)/)[1].endsWith(rootDomain)) {
                const cookieString = Object.keys(req.cookies).reduce((accumulator, cookieName) => {
                    accumulator += cookieName + '=' + req.cookies[cookieName] + ';';
                    return accumulator;
                }, '');
                request = request.clone({
                    headers: request.headers.set('Cookie', cookieString)
                });
            }
        }

     return next.handle(request)
     .do(event) => {

      };

    }

This is my apihttpInterceptor that takes the injected request and passing the request.cookies to the xhr requests. I 'm using jwt authentication so every 1 hour the token expires and the server responds with a new Set-Cookie property . This cookie does not automatically passes back to my client.Although if I run angular without SSR it does. So with the understanding i have about angular i guess I have to do 2 steps so that the cookie that was set from my api is accessible on angular

1)provide via express somehow the cookie value from the server side rendering back to the angular 2) initialize a service that reads that cookie value if exists and sets a cookie on the browser

Is that how i should handle it ? If yes how will i provide the cookie value from ssr to angular. It would help me a lot if you could provide some code(pseudocode)

Sorry if I'm asking something dump but i'm really struggling with this part of SSR

Sanafan commented 5 years ago

This is what i get when following your solution @samber - maybe you can help me to solve this

Access to XMLHttpRequest at 'http://localhost:3000/users/authenticate' from origin 'http://localhost:4200' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

samber commented 5 years ago

@Sanafan

This is a CORS issue (when web app is on a different address than api).

sunojvijayan commented 5 years ago

@samber
I am getting this error.

ERROR in src/app/custom.service.ts(3,10): error TS2305: Module '"/Users/name/Documents/AngularJS/angular-universal-transfer-state/angularApp/src/app/services/http.helper"' has no exported member 'MyHttpClient'.

samber commented 5 years ago

@sunojvijayan

Did you check /Users/name/Documents/AngularJS/angular-universal-transfer-state/angularApp/src/app/services/http.helper file exist ?

flaneuse commented 5 years ago

@sunojvijayan I had to import "MyHttpClient" in app.module.ts and add it as a provider:

import { MyHttpClient } from './_services/auth-helper.service';
...
providers: [MyHttpClient],

However, I'm getting a StaticInjectorError when I use it, though it seems to be working properly.

ERROR { Error: StaticInjectorError(AppServerModule)[REQUEST]: 
  StaticInjectorError(Platform: core)[REQUEST]: 
    NullInjectorError: No provider for REQUEST!

I'm importing both HttpModule and HttpClientModule in app.module.ts. Any ideas @samber? (And thanks for posting your solution!)

flaneuse commented 5 years ago

FYI I fixed the StaticInjectorError by importing the request via import { REQUEST } from '@nguniversal/express-engine/tokens'; à la https://github.com/angular/universal/issues/709#issuecomment-429083563

so the constructor statement is now:

import { REQUEST } from '@nguniversal/express-engine/tokens';

...

  constructor(
    handler: HttpHandler,
    private injector: Injector,
    @Inject(PLATFORM_ID) private platformId: Object,
    @Optional() @Inject(REQUEST) private req: any
  ) {
    super(handler);
  }

and the isPlatformServer call is:

    if (isPlatformServer(this.platformId)) {
      // const req: any = this.injector.get('REQUEST'); --> StaticInjectorError.  Replaced by import of REQUEST
      const rawCookies = !!this.req.headers['cookie'] ? this.req.headers['cookie'] : '';

      if (typeof first !== "string")
        first = (first as HttpRequest<any>).clone({ setHeaders: { 'cookie': rawCookies } });
      options.headers = (options.headers as HttpHeaders).set('cookie', rawCookies);
    }
MaxFlower commented 5 years ago

In my case that was guide option. If you do redirect to login page for not logged user it means redirect goes before request. I fixed it by refactoring canActivate property in my Routes (it deepens on project).

cnoter commented 5 years ago

My case, even if I did use

import * as xhr2 from 'xhr2'; xhr2.prototype._restrictedHeaders.cookie = false;

(in main.server.ts or server.ts)

I cannot set cookie in http interceptor (I can make send cookie to client http interceptor from client express server)

Refused to set unsafe header "cookie"

keep showing in terminal console.

So in server side rendering timing, I set custom-header in interceptor, (because even httpOnly cookie can be used from client interceptor in ssr) and using that custom header in api server(express) instead, as my auth token.

and with that, I did also implement xsrf token for security(between my client and api server).

vahidvdn commented 5 years ago

@cnoter Just see this example, it works like a charm.

ErikWitkowski commented 5 years ago

@m98 i'm not 100 % sure what you are telling me .Let me reproduce my problem providing you some code.


    intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {

      const split = request.url.split('api', 2);
      const targetUrl = split[1];

       request = this.cloneRequest(request, targetUrl);
       if (isPlatformServer(this.platformId)) {
            const req: express.Request = this.injector.get('REQUEST');
            const rootDomain = req.hostname.split('.').slice(-2).join('.');
            console.log(rootDomain);

            if (request.url.match(/^https?:\/\/([^/:]+)/)[1].endsWith(rootDomain)) {
                const cookieString = Object.keys(req.cookies).reduce((accumulator, cookieName) => {
                    accumulator += cookieName + '=' + req.cookies[cookieName] + ';';
                    return accumulator;
                }, '');
                request = request.clone({
                    headers: request.headers.set('Cookie', cookieString)
                });
            }
        }

     return next.handle(request)
     .do(event) => {

      };

    }

This is my apihttpInterceptor that takes the injected request and passing the request.cookies to the xhr requests. I 'm using jwt authentication so every 1 hour the token expires and the server responds with a new Set-Cookie property . This cookie does not automatically passes back to my client.Although if I run angular without SSR it does. So with the understanding i have about angular i guess I have to do 2 steps so that the cookie that was set from my api is accessible on angular

1)provide via express somehow the cookie value from the server side rendering back to the angular 2) initialize a service that reads that cookie value if exists and sets a cookie on the browser

Is that how i should handle it ? If yes how will i provide the cookie value from ssr to angular. It would help me a lot if you could provide some code(pseudocode)

Sorry if I'm asking something dump but i'm really struggling with this part of SSR

@DionisisKav did you find a solution for it?

cj-wang commented 5 years ago

Just FYR I used response.cookie('accessToken', accessToken) from the server side to set the cookie each time after login or token refresh. In the ssr server main file, app.use(cookieParser()) can extract the cookie from requests and make it available at request.cookies.accessToken. Then when the ng app is running on server side, we can get the token from cookie and login with the client auth module, which is Nebular Auth in my case, like below:

@Component({
  selector: 'ngx-app',
  template: '<router-outlet></router-outlet>',
})
export class AppComponent implements OnInit {

  constructor(
    @Inject(PLATFORM_ID) private platform: Object,
    @Optional() @Inject(REQUEST) private request: Request,
    protected authStrategy: NbPasswordAuthStrategy,
    protected tokenService: NbTokenService,
    private analytics: AnalyticsService,
  ) {}

  ngOnInit() {
    if (isPlatformServer(this.platform) && this.request) {
      this.tokenService.clear();
      const accessToken = this.request.cookies.accessToken;
      if (accessToken) {
        const token = this.authStrategy.createToken(accessToken, false);
        this.tokenService.set(token);
      }
    }
    this.analytics.trackPageViews();
  }
}

The rest should be handled by the client auth module properly, same as running in browser. Full example: https://github.com/cj-wang/mean-start-2/blob/master/server/src/api/auth/auth.controller.ts https://github.com/cj-wang/mean-start-2/blob/master/server/src/main.ssr.ts https://github.com/cj-wang/mean-start-2/blob/master/client/src/app/app.component.ts

kovaletsyurii commented 5 years ago

Hi, I see next message in terminal every time when i try options.headers = (options.headers as HttpHeaders).set('cookie', rawCookies);

Refused to set unsafe header "cookie

Do you have solution for it?