问题
I have a component in Angular where I'm using HttpClient to do a GET request to the server to get the currently signed in user. Since this is an SSR app the code runs both on the client and the server. The problem is that when it runs on the server, the session data is not available, which means that the request to the backend can't be authenticated, so it fails. On the client the session data is available so the request succeeds.
I use express-session
with the following session options:
const sessionOptions: session.SessionOptions = {
secret: process.env.SESSION_SECRET || 'placeholder',
resave: false,
saveUninitialized: true,
cookie: { secure: false },
};
server.use(session(sessionOptions));
I use Twitter OAuth for authentication.
const router = Router();
router.get('/sessions/connect', (req, res) => {
const twitterAuth = new TwitterAuth(req);
twitterAuth.consumer.getOAuthRequestToken((error, oauthToken, oauthTokenSecret, _) => {
if (error) {
console.error('Error getting OAuth request token:', error);
res.sendStatus(500);
} else {
req.session.oauthRequestToken = oauthToken;
req.session.oauthRequestTokenSecret = oauthTokenSecret;
res.redirect(`https://twitter.com/oauth/authorize?oauth_token=${oauthToken}`);
}
});
});
router.get('/sessions/disconnect', (req, res) => {
req.session.oauthRequestToken = null;
req.session.oauthRequestTokenSecret = null;
res.redirect('/');
});
router.get('/sessions/callback', (req, res) => {
const twitterAuth = new TwitterAuth(req);
const oauthVerifier = req.query.oauth_verifier as string;
twitterAuth.consumer.getOAuthAccessToken(
req.session.oauthRequestToken,
req.session.oauthRequestTokenSecret,
oauthVerifier,
async (error, oauthAccessToken, oauthAccessTokenSecret, results) => {
if (error) {
console.error('Error getting OAuth access token:', error, `[${oauthAccessToken}] [${oauthAccessTokenSecret}] [${results}]`);
res.sendStatus(500);
} else {
req.session.oauthAccessToken = oauthAccessToken;
req.session.oauthAccessTokenSecret = oauthAccessTokenSecret;
const twitter = twitterAuth.api(req.session);
try {
const response = await twitter.get('account/verify_credentials', {});
const screenName = response.screen_name;
console.log(`Signed in with @${screenName}`);
console.log(response);
res.redirect('/');
} catch (err) {
console.error(err);
res.sendStatus(500);
}
}
}
);
});
router.get('/user', async (req, res) => {
const twitterAuth = new TwitterAuth(req);
const twitter = twitterAuth.api(req.session);
try {
const response = await twitter.get('account/verify_credentials', {});
res.json({
name: response.name,
screenName: response.screen_name,
});
} catch (err) {
console.error(err);
res.sendStatus(401);
}
});
On the client the GET request looks something like this:
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError } from 'rxjs/operators';
import { makeStateKey, TransferState } from '@angular/platform-browser';
import { User } from '../types/user';
const USER_KEY = makeStateKey('user');
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.sass']
})
export class HomeComponent implements OnInit {
user?: User;
constructor(private httpClient: HttpClient, private state: TransferState) {}
ngOnInit(): void {
this.user = this.state.get(USER_KEY, null);
if (!this.user) {
this.httpClient.get('/api/twitter/user').pipe(catchError(this.handleHttpError)).subscribe((user: User) => {
this.user = user;
this.state.set(USER_KEY, user);
});
}
}
// [irrelevant code omitted]
}
The idea is that the GET request is first executed on the server, then the user is saved using TransferState so that it'll be made available to the client once the same code runs again on the client. The problem, though, is that the request fails on the server with the following error:
ERROR HttpErrorResponse {
headers: HttpHeaders {
normalizedNames: Map(0) {},
lazyUpdate: null,
lazyInit: [Function (anonymous)]
},
status: 401,
statusText: 'Unauthorized',
url: 'https://<domain>/api/twitter/user',
ok: false,
name: 'HttpErrorResponse',
message: 'Http failure response for https://<domain>/api/twitter/user: 401 Unauthorized',
error: 'Unauthorized'
When I console.log the expressjs request.session object for the client GET call and the server GET call, I notice that the server GET call has a different session ID, and that it thus lacks the tokens and token secrets to authenticate the request. How can I make sure that both the client and the server share the same session ID and the same tokens?
回答1:
I couldn't find a way to get this to work with sessions, but I did find a way to make it work by using just cookies.
import { APP_BASE_HREF } from '@angular/common';
import { REQUEST, RESPONSE } from '@nguniversal/express-engine/tokens';
import * as cookieParser from 'cookie-parser';
import * as cookieEncrypter from 'cookie-encrypter';
// …
server.use(cookieParser(cookieSecret));
server.use(cookieEncrypter(cookieSecret));
// All regular routes use the Universal engine
server.get('*', (req, res) => {
res.render(indexHtml, {
req,
res,
providers: [
{ provide: APP_BASE_HREF, useValue: req.baseUrl },
{ provide: REQUEST, useValue: req },
{ provide: RESPONSE, useValue: res }
]
});
});
Then, for the auth endpoints I did this:
const cookieOptions: CookieOptions = {
httpOnly: true,
signed: true,
};
const router = Router();
router.get('/auth/connect', (req, res) => {
const twitterAuth = new TwitterAuth(req);
twitterAuth.consumer.getOAuthRequestToken((error, oauthToken, oauthTokenSecret, _) => {
if (error) {
console.error('Error getting OAuth request token:', error);
res.sendStatus(500);
} else {
res.cookie('oauthRequestToken', oauthToken, cookieOptions);
res.cookie('oauthRequestTokenSecret', oauthTokenSecret, cookieOptions);
res.redirect(`https://twitter.com/oauth/authorize?oauth_token=${oauthToken}`);
}
});
});
router.get('/auth/disconnect', (req, res) => {
res.clearCookie('oauthRequestToken', { signed: true });
res.clearCookie('oauthRequestTokenSecret', { signed: true });
res.clearCookie('oauthAccessToken', { signed: true });
res.clearCookie('oauthAccessTokenSecret', { signed: true });
res.redirect('/');
});
router.get('/auth/callback', (req, res) => {
const twitterAuth = new TwitterAuth(req);
const oauthVerifier = req.query.oauth_verifier as string;
twitterAuth.consumer.getOAuthAccessToken(
req.signedCookies.oauthRequestToken,
req.signedCookies.oauthRequestTokenSecret,
oauthVerifier,
async (error, oauthAccessToken, oauthAccessTokenSecret, results) => {
if (error) {
console.error('Error getting OAuth access token: ', error);
res.sendStatus(error.statusCode);
} else {
res.cookie('oauthAccessToken', oauthAccessToken, cookieOptions);
res.cookie('oauthAccessTokenSecret', oauthAccessTokenSecret, cookieOptions);
const twitter = twitterAuth.api({ oauthAccessToken, oauthAccessTokenSecret });
try {
const response = await twitter.get('account/verify_credentials', {});
const screenName = response.screen_name;
console.log(`Signed in with @${screenName}`);
console.log(response);
res.redirect('/');
} catch (err) {
console.error(err);
res.sendStatus(500);
}
}
}
);
});
router.get('/user', async (req, res) => {
const twitterAuth = new TwitterAuth(req);
const twitter = twitterAuth.api(req.signedCookies);
try {
const response = await twitter.get('account/verify_credentials', {});
res.json({
name: response.name,
screenName: response.screen_name,
});
} catch (err) {
console.error(err);
res.sendStatus(401);
}
});
On the client side much hasn't really changed:
import { Component, OnInit } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError } from 'rxjs/operators';
import { makeStateKey, TransferState } from '@angular/platform-browser';
import { User } from '../types/user';
const USER_KEY = makeStateKey('user');
@Component({
selector: 'app-home',
templateUrl: './home.component.html',
styleUrls: ['./home.component.sass']
})
export class HomeComponent implements OnInit {
user?: User;
constructor(private httpClient: HttpClient, private state: TransferState) {}
ngOnInit(): void {
this.user = this.state.get(USER_KEY, null);
if (!this.user) {
// The request path must be absolute as opposed to relative
this.httpClient.get('https://<domain>/api/twitter/user').pipe(catchError(this.handleHttpError)).subscribe((user: User) => {
this.user = user;
this.state.set(USER_KEY, user);
});
}
}
// [irrelevant code omitted]
}
In order to expose the cookies to the server I had to create the following interceptor:
import { Injectable, Optional, Inject } from '@angular/core';
import { HttpInterceptor, HttpHandler, HttpRequest, HttpEvent } from '@angular/common/http';
import { REQUEST } from '@nguniversal/express-engine/tokens';
import { Observable } from 'rxjs';
@Injectable()
export class CookieInterceptor implements HttpInterceptor {
constructor(@Optional() @Inject(REQUEST) private httpRequest) {}
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
// If optional request is provided, we are server side
if (this.httpRequest) {
req = req.clone({
setHeaders: { Cookie: this.httpRequest.headers.cookie }
});
}
return next.handle(req);
}
}
Then, I had to add it in app.server.module.ts
:
import { NgModule } from '@angular/core';
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server';
import { CookieBackendModule } from 'ngx-cookie-backend';
import { HttpClientModule, XhrFactory, HTTP_INTERCEPTORS } from '@angular/common/http';
import * as xhr2 from 'xhr2';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
import { CookieInterceptor } from '../../cookie-interceptor';
class ServerXhr implements XhrFactory {
build(): XMLHttpRequest {
xhr2.XMLHttpRequest.prototype._restrictedHeaders = {};
return new xhr2.XMLHttpRequest();
}
}
@NgModule({
imports: [
AppModule,
ServerModule,
ServerTransferStateModule,
HttpClientModule,
CookieBackendModule.forRoot(),
],
bootstrap: [AppComponent],
providers: [
{ provide: XhrFactory, useClass: ServerXhr },
{ provide: HTTP_INTERCEPTORS, useClass: CookieInterceptor, multi: true }
],
})
export class AppServerModule {}
Notice the ServerXhr
class that also needs to be added as a provider.
Solution inspired by this answer.
来源:https://stackoverflow.com/questions/63594437/angular-10-ssr-and-expressjs-sessions