この記事は何
フロントをNext.jsのAppRouter(AppRouter出たてあたりに作ったやつなので、v13でかつ、フォルダ構成のデファクトスタンダードすら成立してなかった中での試行錯誤だったで、今見るとその辺がもうごちゃごちゃ)、API Routesを別サーバーのAPIサーバーへの踏み台的に使ってみつつ、
APIサーバーは別でNestJSで実装し、認証認可の機構をそちらに持たせてみたかったんです。
Twitter紐付け機能を見越して、「Twitterでログイン出来るがあくまでフロントとの認可処理はAPIサーバーで発行したJWT」という機構にしたかったのですが、
Next.jsのNextAuthによる実装や、NestJS単体でのOAuth認証の実装は出てきても、⤴︎のような実装が当時なかなか検索にヒットしませんでした。
(結局Twitterログイン → JWTによる認可まで実装したのち、飽きちゃって他のログインの仕組みを導入出来なかったのですが。たはは)
「Twitterでログイン出来るがあくまでフロントとの認可処理はAPIサーバーで発行したJWT」の、特にAPI側の実装にフォーカスした記事となります。
書くこと
- NestJSについてざっくり
- NestJS de オニオンアーキテクチャ ミ⭐︎ を軽く
- Twitter認証の実装を詳しく
- Twitter認証 ~ JWTの発行・認可処理の実装を詳しく
書かないこと
- フロント側
- 「お前これ一年前に作ったシステムじゃねえか。なんで今更これやねん」への弁明
- (ネタがなかった訳じゃないんです....! 決して....! この機に復習がしたかっただけ....!)
作ったもの
先に置いておきます。
フロント側
API側
NestJSについて
あくまでアドカレ記事なので、NestJSについてもざっくりまとめておこうと思います。
NestJSは、TypeScriptベースで作られたNode.js向けのフレームワークで、サーバーサイド開発をより構造的かつ保守性の高い形で進めるためのツールです。
「Angularっぽい思想をNode.js上に持ち込んだ」みたいな感じがします。
デコレーターやDI(依存性注入)といった構造化・抽象化の機能がしっかりしています。
APIサーバーのエンドポイント周りはもちろん、ビジネスロジックの整理やテストのしやすさなどにもメリットが出てきます。
Angularがそもそもモジュール式構造でDDDLikeであるため、NestJSもDDDLikeなアーキテクチャに非常に相性が良く、中規模のアプリケーション開発では非常に強力かと思います。
NestJS de オニオンアーキテクチャ ミ⭐︎
この章は上記事に書いた内容の簡易的な要約です。
app以下でドメイン層、アプリケーション層、インフラストラクチャ層にディレクトリを分割し、
それぞれがドメインロジック・ビジネスロジック・外部APIアクセスや永続化、細かいアプリケーションの懸念点に対するアプローチを担当します。
全ての実装が抽象に依存しており、また実装はモジュール構造によってアプリケーションに適用されるため、実装の一切が実体に依存しません。
投稿(都々逸 = dodoitsu)が作成される際のフローを具体的に表すとこうなります。
-
main.tsのbootstrap関数でNestFactory.create(AppModule);される
アプリケーション起動時、main.tsのbootstrap関数でNestFactory.create(AppModule)が呼ばれ、AppModuleを起点にアプリケーション全体が初期化されます。
つまり、main.tsがアプリケーションのエントリーポイントになっているわけですね。 -
AppModule (app.module.ts)のコンストラクタでDodoitsuModuleが import され、アプリケーションに適用される
AppModuleはアプリ全体の根幹モジュールで、ここにDodoitsuModuleがimportされています。これによって、Dodoitsuに関する機能がアプリケーション全体に組み込まれ、利用可能な状態になります。 -
DodoitsuModule (application/dodoitsu/dodoitsu.module.ts)でDodoitsuControllerが指定されて適用されている
DodoitsuModuleでは、DodoitsuControllerが指定されており、HTTPリクエストに対するエントリーポイントが定義されます。
ここでエンドポイント(POST /dodoitsuなど)が提供されるわけですね。 -
DodoitsuApplicationServiceのcreateDodoitsuが呼び出される
DodoitsuControllerがHTTPリクエストを受けると、そのビジネスロジックを担当するDodoitsuApplicationServiceのcreateDodoitsuメソッドが呼び出されます。
ここで「Dodoitsuを作成する」というアプリケーション層の処理が行われます。
つまり、Controllerはリクエストを拾うだけで、実際のビジネスロジックはApplicationServiceに任せています。 -
DodoitsuService(ドメインサービス) のcreate関数が呼ばれる
DodoitsuApplicationServiceの中で、ドメインロジックを担当するDodoitsuServiceのcreateメソッドが呼び出されます。
ここでは、Dodoitsuというドメインに沿った処理(バリデーションやドメインオブジェクト生成など)が行われることが想定されます。 -
DodoitsuServiceのcreate関数でDodoitsuRepositoryの抽象create関数が呼び出される
ドメインサービスは自前でデータを永続化しないため、DodoitsuRepositoryという抽象化されたリポジトリを介してデータベースへの書き込みが行われます。
DodoitsuService.createでDodoitsuRepositoryのcreateメソッドが呼ばれ、実際にDodoitsuがDBに保存される流れとなります。
きっしょいっすね
Twitter認証の実装を詳しく
ここからは実際に、APIサーバー側でどのようにTwitterログイン → JWT発行までのフローが実装されているかを見ていきます。
このリポジトリでは、NestJSでPassportを用いたOAuth戦略(twitter.strategy.ts)とJWT戦略(jwt.strategy.ts)、そしてAuthServiceやAuthControllerを介して、以下の流れで認証が行われています。
全体の流れ
AuthController(src/application/auth/auth.controller.ts) が/auth/twitterエンドポイントに対してAuthGuard('twitter')を用い、Twitterログインフローを開始。- Twitterでユーザーが認可を行うと、
twitter/callbackエンドポイントで再度AuthControllerがAuthGuard('twitter')を通じてリクエストを受け、AuthService.handleCallbackが呼ばれる。 -
AuthService(src/domain/auth/auth.service.ts) にて、req.userに格納されたTwitterアカウント情報を基にユーザーをDBにUPSERT(すでにユーザーがいれば取得、なければ新規作成)。
ここで生成・取得されたユーザーエンティティはUserリポジトリ(UserRepository)を通してDBとやり取りされています。 -
AuthService.generateTokensにて、JWT access_token と refresh_tokenを発行。
refresh_tokenはDB上に保存され、再認証時に利用可能。 -
最終的に、フロントエンドコールバックURLに
access_tokenとrefresh_tokenを付与してリダイレクト。
このaccess_tokenを以降のAPIリクエストでAuthorization: Bearer <access_token>形式でヘッダに付与すれば、認証済みとしてAPIにアクセス可能となる。
TwitterStrategy でのTwitter OAuth処理
@Injectable()
export class TwitterStrategy extends PassportStrategy(Strategy, 'twitter') {
constructor(private configService: ConfigService) {
super({
consumerKey: configService.get<string>('auth.twitter.consumerKey'),
consumerSecret: configService.get<string>('auth.twitter.consumerSecret'),
callbackURL: configService.get<string>('auth.twitter.callbackUrl'),
});
}
async validate(_, __, profile: any, done: (err: any, user: TwitterUserPayload) => void) {
const { username, displayName, photos } = profile;
const user = {
twitterId: username,
name: displayName,
photo: photos[0].value,
};
done(null, user);
}
}
TwitterStrategyはPassportのStrategyクラスを拡張し、twitterストラテジー名で登録しています。
validateメソッド内で、Twitterから返ってきたユーザー情報(profile)を整形し、{ twitterId, name, photo }形式のオブジェクトとしてdoneコールバックを呼び出します。
この時点でreq.userにTwitterユーザー情報が紐づけられ、AuthController側のCallbackハンドラで利用できるようになります。
AuthController でのCallback処理
@Get('twitter/callback')
@UseGuards(AuthGuard('twitter'))
async signInWithTwitterRedirect(@Req() req, @Res() res) {
const user = await this.authService.handleCallback(req);
const tokens = await this.authService.generateTokens(user);
res.redirect(
`${this.configService.get<string>('dodoitsuLifeCallbackUrl')}?token=${
tokens.access_token
}&refresh_token=${tokens.refresh_token}`,
);
}
@UseGuards(AuthGuard('twitter'))が付与されているため、このエンドポイントへTwitterからリダイレクトされた際にtwitter.strategy.tsで定義したvalidateが発火し、req.userにTwitterユーザー情報が注入されます。
続いて、this.authService.handleCallback(req)が呼ばれ、ユーザー情報をDBにUPSERTします。
最後にthis.authService.generateTokensでJWTを発行し、フロントエンド用のコールバックURLにaccess_tokenとrefresh_tokenをクエリパラメータとして付与し、リダイレクトしています。
AuthService でのユーザーUPSERTとトークン発行
async handleCallback(req: Request & { user: TwitterUserPayload }): Promise<User> {
const user = req.user;
const userEntity = new User();
userEntity.twitterId = user.twitterId;
userEntity.name = user.name;
userEntity.photo = user.photo.replace('_normal', '');
const existingUser = await this.userService.findOne({
twitterId: userEntity.twitterId,
});
if (existingUser) {
return existingUser;
}
return await this.userService.create(userEntity);
}
handleCallbackでは、req.user(TwitterStrategyが投入)からTwitterIDでユーザーを検索し、なければ新規作成しています。この処理はUserServiceを通じてUserRepositoryに委譲しており、DDDライクなレイヤリングに基づき、AuthServiceはあくまで認証関連のユースケースロジックを担当するだけで、実際のDB書き込みはインフラ層のリポジトリ(UserRepository)が行っています。
async generateTokens(user: User): Promise<{ access_token: string; refresh_token: string }> {
const payload: JwtPayload = {
userId: user.id,
};
const accessToken = await this.jwtService.sign(payload, {
secret: this.configService.get<string>('auth.jwt.secret'),
expiresIn: this.configService.get<string>('auth.jwt.expire'),
});
const refreshToken = await this.generateRefreshToken(user);
return {
access_token: accessToken,
refresh_token: refreshToken,
};
}
async generateRefreshToken(user: User): Promise<string> {
const refreshToken = uuidv4();
user.refreshToken = refreshToken;
await this.userRepository.save(user);
return refreshToken;
}
generateTokensでaccess_tokenとrefresh_tokenを発行します。
access_tokenはjwtService.sign()でJWTを生成、refresh_tokenはUUIDで適当な文字列を生成してDBに保存しておきます。
このrefresh_tokenは後ほど/auth/refresh-tokenエンドポイントで新しいaccess_tokenを発行する際に使用されます。
JwtStrategy でのJWT検証
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor(private readonly userService: UserService, private readonly configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: true,
secretOrKey: configService.get<string>('auth.jwt.secret'),
});
}
async validate(payload: JwtPayload): Promise<User> {
const user = await this.userService.findOne({ id: payload.userId });
if (!user) {
throw new UnauthorizedException('User does not exist');
}
return user;
}
}
JwtStrategyはBearerトークンからJWTを抽出し、シークレットキーで検証します。
validateメソッドでJWT内に格納されたuserIdからユーザーを取得し、該当ユーザーが存在しなければ401エラーを投げます。
これにより、APIリクエスト時にAuthorization: Bearer <access_token>ヘッダを付与していれば、自動的にこのJwtStrategyで検証され、req.userにユーザー情報が注入されます。
AuthControllerでのトークンリフレッシュとログアウト
@Post('refresh-token')
@HttpCode(HttpStatus.OK)
async refreshToken(@Body() body: TokenRefreshDto) {
const user = await this.authService.handleRefreshToken(body.refreshToken);
if (!user) throw Error('User not found');
const tokens = await this.authService.generateTokens(user);
return tokens;
}
refresh-tokenエンドポイントでは、refresh_tokenを受け取り、DBからユーザーを特定、再度generateTokensで新しいJWTを発行します。
これにより、access_tokenが期限切れになっても、refresh_tokenが有効な限り新たなaccess_tokenを取得できます。
また、logoutエンドポイントではrefresh_tokenを無効化(DBでrefreshTokenをnullにする)することで、ログアウトを実現しています。
まとめ
オニオンアーキテクチャの実装を人に説明することの難易度の高さが身に染みてわかる記事になりました。
多分実装読むのが一番早いと思います。
念の為再度置いておきます。
こんな僕の歌で誰かの命を救えれば幸いです。
