目次
はじめに
Nestjs と Next.js で開発をしていたのですが、ちょっとした認証機能をつけていて Nestjs で作成した signup/singin の処理を行って認証が済まされた際にフロントエンドでトークンを受け取って、それをもとに他の処理をするイメージをしていました。
こんな感じで。
const storeData = async () => {
const { data } = await axios.get(
`${process.env.NEXT_PUBLIC_NESTJS_API_URL}/store`,
{
headers: {
Authorization: `Bearer ${jwt}`,
},
}
)
return data
}
ですが、調べてみると実際に jwt トークンをセキュアに永続化して持っておくのにはちょっと工夫が必要なようでした。
たしかに、ふつうに考えてみたらどうにかしないとリロード時にトークンが消えてしまうのは当たり前ですよね。
でも、一般的にブラウザ内で情報を永続化するためのローカルストレージやセッションストレージではセキュリティに問題があります。
XSS(クロスサイトスクリプティング)などの攻撃を受けてしまうとトークンが攻撃者にアクセスされてしまうリスクがあるのです。
ではどうしたらセキュアにトークンを永続化できるのか
結論から言うと、サーバ側で signup/singin の処理を行なって認証が済まされたタイミングでcookie
に jwt トークンを設定するのです。
ただ、その際に少しだけ気をつけるポイントがあるので以下で解説していきますね。
main.ts
まずはここから。
今回はcookie-parser
というライブラリを使ってcookie
を扱っていきましょう。
以下のコマンドでインストールします。
yarn add cookie-parser
そして、main.ts
でミドルウェアとしてcookie-parser
を登録していきます。
ここで出てくるenableCors
について。
CORS(Cross-Origin Resource Sharing)とは別のドメインからリソースを要求できるようにするメカニズムのことで、今回はフロントエンドで Nestjs の API を使うための設定をしていきます。
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
import { ValidationPipe } from '@nestjs/common'
import * as cookieParser from 'cookie-parser'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
app.use(cookieParser()) // ここでcookie-parserを登録
app.enableCors({
// オリジン間リソース共有
credentials: true, // セッション情報を維持
origin: 'http://localhost:3000', // ここにクライアントのオリジンを指定
})
await app.listen(3737)
}
bootstrap()
auth
main.ts
でアプリケーション全体でcookie
を扱えるようになったので、auth ではcookie
の設定を進めていきます。
auth.module.ts
imports でPassportModule
で認証方法の選択/JwtModule
で秘密鍵の設定と認証の有効期間を設定します。
import { Module } from '@nestjs/common'
import { AuthController } from './auth.controller'
import { AuthService } from './auth.service'
import { TypeOrmModule } from '@nestjs/typeorm'
import { JwtModule } from '@nestjs/jwt'
import { PassportModule } from '@nestjs/passport'
import { JwtStrategy } from './jwt.strategy'
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }), // jwtで認証を行う
JwtModule.register({
secret: 'secretKey', // 秘密鍵を設定
signOptions: { expiresIn: '1d' }, // 有効期間
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
exports: [JwtStrategy],
})
export class AuthModule {}
auth.controller.ts
/auth/signin
でログイン処理を行う。
例えばemail
やpassword
などを受け取って認証処理に必要な値とexpress
のResponse
をauth.service.ts
に渡してビジネスロジックを構築していきましょう。
import { Body, Controller, Post, Res } from '@nestjs/common'
import { AuthService } from './auth.service'
import { CredentialsDto } from './dto/credentials.dto'
import { Response } from 'express'
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
// ログイン
@Post('signin')
async signin(
@Body() credentialsDto: CredentialsDto, // Bodyから受け取った内容
@Res() res: Response // レスポンスオブジェクト
): Promise<{
success: boolean // 認証の成功かどうかを返す
}> {
return await this.authService.signIn(credentialsDto, res)
}
}
auth.service.ts
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { User } from 'src/_entities/user.entity'
import { Repository } from 'typeorm'
import * as bcrypt from 'bcrypt'
import { JwtService } from '@nestjs/jwt'
@Injectable()
export class AuthService {
constructor(
private readonly userRepository: Repository<User>,
private readonly jwtService: JwtService
) {}
// ログイン
async signIn(credentialsDto, res): Promise<{ success: boolean }> {
const { username, password } = credentialsDto // Bodyから受け取った内容からusernameとpasswordを取ってくる
const user = await this.userRepository.findOne({
// DBの中からusernameが一致するユーザーを取得
where: { username },
})
if (user && (await bcrypt.compare(password, user.password))) {
const payload = { id: user.id } // ユーザー情報からJWTを生成
const accessToken = await this.jwtService.sign(payload) // jwtServiceにpayloadを渡してaccessTokenを生成
res.cookie('jwt', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
}) // cookieにkey:'jwt', value:accessTokenを設定
return { success: true }
}
throw new UnauthorizedException(
'ユーザー名またはパスワードを確認してください'
)
}
}
cookie
での設定に関してですが、key,value の設定だけでなくオプションの設定によってセキュアに cookie
を管理することができるのです。
- httpOnly: JavaScript から
cookie
を読み取ることができなくなり XSS 攻撃からブラウザに設定した jwt トークンを保護することができます - secure: 上記コードでは
true
としていますが、暗号化された https でのみcookie
をブラウザからサーバーに送信するようにできます - sameSite:
cookie
がどのようなリクエストで送信されるべきかをブラウザに指示することができます("none", "strict", "lax")
jwt.strategy.ts
Nestjs と passport を連携して jwt を用いた認証を行うための戦略を設定します。
import { Injectable, UnauthorizedException } from '@nestjs/common'
import { PassportStrategy } from '@nestjs/passport'
import { InjectRepository } from '@nestjs/typeorm'
import { ExtractJwt, Strategy } from 'passport-jwt'
import { User } from 'src/_entities/user.entity'
import { Repository } from 'typeorm'
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>
) {
super({
// ※1:ここが要注意ポイント
// jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
jwtFromRequest: ExtractJwt.fromExtractors([
// jwtからrequestを抽出する
(request) => {
return request?.cookies?.jwt
},
]),
ignoreExpiration: false, // jwtの有効期限をチェック(trueだと有効期限を無視)
secretOrKey: 'secretKey',
})
}
async validate(payload: { id: string }) {
const { id } = payload
const user = await this.userRepository.findOne({
where: { id },
})
if (user) {
return user
}
throw new UnauthorizedException()
}
}
※1 ここが一番大変だったところでした。
cookie
での認証の前、フロントエンドでは header でAuthorization
認証をしていたので、コメントアウトしてあるjwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
としていましたが、cookie
を使って認証を行うようにしたのでjwtFromRequest: ExtractJwt.fromExtractors([ (request) => { return request?.cookies?.jwt }, ]),
と書き直さないといけません。
しばらく気が付かずにこんなエラーがでっぱなしでした...
Unhandled Runtime Error
AxiosError: Request failed with status code 401
フロントエンドでの取得方法
取得のやり方はreact-query
などそれぞれですが今回は中身だけ取り扱います。
冒頭で扱ったAuthorization
での取得はこんな感じでしたが、
const storeData = async () => {
const { data } = await axios.get(
`${process.env.NEXT_PUBLIC_NESTJS_API_URL}/store`,
{
headers: {
Authorization: `Bearer ${jwt}`,
},
}
)
return data
}
cookie
ではこうなります。
const storeData = async () => {
const { data } = await axios.get(
`${process.env.NEXT_PUBLIC_NESTJS_API_URL}/store`,
{
withCredentials: true,
}
)
return data
}
withCredentials: true
と設定してリクエストを行うことで cross-origin
のリクエストにもcookie
を含めることができるようになるのです。
参考
さいごに
フロントエンドにしか触れてこなくてバックエンド初心者だとセキュリティ周りのことなんてまるでわからないので、認証周りの処理を学ぶとかなり理解が深まるので一緒に頑張りましょう。
Nestjs 初心者ながら楽しくコードを書けているので、今後も Nestjs の情報を共有していきますのでお楽しみに。