この記事は何
フロントを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
にする)することで、ログアウトを実現しています。
まとめ
オニオンアーキテクチャの実装を人に説明することの難易度の高さが身に染みてわかる記事になりました。
多分実装読むのが一番早いと思います。
念の為再度置いておきます。
こんな僕の歌で誰かの命を救えれば幸いです。