はじめに
前回の記事のCRUDとバリデーションに引き続き、今回は新規登録とログイン周りの実装を行いました。
実装
新規登録を実装するにあたり、以下の機能を追加する必要があります。
- emailが登録済の場合はエラーを返す
- パスワードをハッシュ化してDBに保存
- DBに保存されたユーザIDをブラウザのCookieに保存
- (新規登録後)リクエストにCookieを付加
今回signup()
とsignin()
というメソッドが新しく必要になるのですが、UsersService
の肥大化を避けるため、認証周りのメソッドに関してはAuthService
に追加していきます。
また、パスワードを保存するにあたり、Saltを利用したハッシュ化を行います。
Saltというのはランダムな文字列で、パスワードに連結してハッシュ化する際に使用します。
パスワードをそのままハッシュ化するだけだと元のデータを推測されてしまう可能性が高くなるため、このような工夫が必要となります。
Saltはユーザごとに生成しDBに保存します。
保存したSaltはログイン時のパスワード比較に使用します。
新規登録
AuthService
と新規登録メソッドsignup()
を作成します。
constructorでUsersService
を呼び出し、emailの使用済確認でfind()
、ユーザの作成でcreate()
を使用します。
次にrandomBytes(8).toString('hex')
でSaltを作成します。
「16進数でエンコードされたString形式で、ランダムで長さが少なくとも8バイト」というSaltの条件を満たしています。
ハッシュ化した後は、salt + '.' + hash.toString('hex')
でSaltとハッシュ値を.
で連結し、これをパスワードとしてDBに保存します。
ログイン時に同じSaltでパスワードをハッシュ化しないと照合できないため、Saltもあわせて保存するようにします。
import { BadRequestException, Injectable } from '@nestjs/common';
import { UsersService } from './users.service';
import { randomBytes, scrypt as _scrypt } from 'crypto';
import { promisify } from 'util';
const scrypt = promisify(_scrypt);
@Injectable()
export class AuthService {
constructor(private usersService: UsersService) {}
async siginup(email: string, password: string) {
// emailが使用済かどうか確認
const users = await this.usersService.find(email);
if (users.length) {
throw new BadRequestException('email in use');
}
// Salt生成
const salt = randomBytes(8).toString('hex');
// パスワードとSaltを連結してハッシュ化
const hash = (await scrypt(password, salt, 32)) as Buffer;
// Saltとハッシュ値を結合
const result = salt + '.' + hash.toString('hex');
// 新しいユーザの作成と保存
const user = await this.usersService.create(email, result);
// ユーザを返す
return user;
}
}
UsersController
に新規登録用のルートハンドラーを登録します。
export class UsersController {
constructor(
private usersService: UsersService,
private authService: AuthService,
) {}
@Post('/signup')
createUser(@Body() body: CreateUserDto) {
return this.authService.siginup(body.email, body.password);
}
リクエストを投げると以下のようなレコードがテーブルに登録されます。
POST http://localhost:3000/auth/signup
content-type: application/json
{
"email": "asdf10@asdf.com",
"password": "asdlfkajsd"
}
ログイン
AuthService
にsignin()
メソッドを追加します。
この中では、登録したemailが存在しているかどうかを確認したあとに、パスワードの照合を行います。
新規登録時に"Salt.ハッシュ値"としてパスワードを保存したので、const [salt, storedHash] = user.password.split('.')
でSaltとハッシュ値を分割して取り出します。
そして入力したパスワードをSaltでハッシュ化し、保存したハッシュ値と同じ値であればuser
を返すようにします。
async signin(email: string, password: string) {
const [user] = await this.usersService.find(email);
if (!user) {
throw new NotFoundException('user not found');
}
const [salt, storedHash] = user.password.split('.');
// 入力したパスワードの照合
const hash = (await scrypt(password, salt, 32)) as Buffer;
if (storedHash !== hash.toString('hex')) {
throw new BadRequestException('bad password');
}
return user;
}
UsersController
にルートハンドラーを登録します。
@Post('/signin')
signin(@Body() body: CreateUserDto) {
return this.authService.signin(body.email, body.password);
}
CookieによるSession管理
ユーザのSessionデータを保持するには以下の方法があるのですが、今回はcoookie-session
というライブラリで2の方法を実装します。
- サーバでSessionデータ、クライアントではSession IDのみ保持。
- クライアントでSessionデータを保持
cookie-session
でSessionデータを管理する流れは以下のようになります。
- クライアントが暗号化されたCookieヘッダーを付加したリクエストをサーバへなげる
-
cookie-session
がCookieヘッダーを復号してSessionオブジェクトに変換する -
@Session()
デコレータでリクエストハンドラー内でSessionオブジェクトにアクセスする - Sessionオブジェクトを操作(CRUD)する
-
cookie-session
が更新されたSessionオブジェクトを暗号化する - 暗号化されたSessionをSet-Cookieヘッダーに付加してクライアントにレスポンスとして返す(Sessionに変更がなければSet-Cookieは返さない)
実装のはじめに、以下のパッケージをインストールします。
npm install cookie-session @types/cookie-session
main.ts
でcookie-session
をインポートし、bootstrap()
内に記述します。
const cookieSession = require('cookie-session');
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(
cookieSession({
keys: ['key1'],
}),
);
次にリクエストのCookieヘッダーをSessionオブジェクトに変換してアクセスするために、リクエストハンドラーcreateUser()
signin()
signout()
に、@Session()
デコレータを付加した引数session
を記述します。
そして、createUser()
signin()
ではSessionデータにユーザIDを書き込み、signout()
ではユーザIDを削除(null)します。
@Post('/signup')
async createUser(@Body() body: CreateUserDto, @Session() session: any) {
const user = await this.authService.siginup(body.email, body.password);
session.userId = user.id;
return user;
}
@Post('/signin')
async signin(@Body() body: CreateUserDto, @Session() session: any) {
const user = await this.authService.signin(body.email, body.password);
session.userId = user.id;
return user;
}
@Post('/signout')
signOut(@Session() session: any) {
session.userId = null;
}
試しにログインしてみると、以下のようにSet-Cookieヘッダーを付加したレスポンスが返ってきています。
POST http://localhost:3000/auth/signin
content-type: application/json
{
"email": "test1@test.com",
"password": "12345"
}
Interceptor + Decoratorでルートハンドラーに現在のログインユーザを伝える
どのユーザかログイン中であるかを返すwhoAmI()
というルートハンドラーを新しく作成します。
この際、ユーザ情報user
を返す@CurrentUser()
というカスタムデコレータを作成します。
@Get('/whoami')
whoAmI(@CurrentUser() user: User) {
return user;
}
@CurrentUser()
内でユーザ情報をDBから取得したいのですが、DecoratorはDIの外にあるためUsersService
のインスタンスを読み込めず、find()
メソッドを使用することができません。
この問題を解消するために、Decoratorでリクエストを受ける前にInterceptorを置き、Interceptorでユーザ情報を取得してからDecoratorに渡すようにします。
import {
NestInterceptor,
ExecutionContext,
CallHandler,
Injectable,
} from '@nestjs/common';
import { UsersService } from '../users.service';
@Injectable()
export class CurrentUserInterceptor implements NestInterceptor {
constructor(private usersService: UsersService) {}
async intercept(context: ExecutionContext, handler: CallHandler) {
const request = context.switchToHttp().getRequest();
const { userId } = request.session || {};
if (userId) {
const user = await this.usersService.findOne(userId);
request.currentUser = user;
}
return handler.handle();
}
}
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: never, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest();
return request.currentUser;
},
);
Interceptorをコントローラ自体にデコレートすることで、すべてのルートハンドラーにInterceptorの処理が適用されます。
@UseInterceptors(CurrentUserInterceptor)
export class UsersController {
コントローラごとにデコレータを付加するのが面倒な場合には、Module内で以下を記述してあげることでModule内のすべてのルートハンドラーにInterceptorを適用することができます。
@Module({
imports: [TypeOrmModule.forFeature([User])],
controllers: [UsersController],
providers: [
UsersService,
AuthService,
{
provide: APP_INTERCEPTOR,
useClass: CurrentUserInterceptor,
},
],
})
Guardで未ログインユーザに特定の操作を許可しない
Guardを利用することで、ユーザへ特定のルート(Controllerもしくはルートハンドラーごと)へのアクセスを制限することができます。
例えば、以下のAuthGuard()
ではリクエストのCookie(Sessionオブジェクト)にユーザIDが含まれていればtrue
を返します。
import { CanActivate, ExecutionContext } from '@nestjs/common';
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
return request.session.userId;
}
}
ルートハンドラーに@UseGuards(AuthGuard)
デコレータを付加することで、AuthGuard()
がtrue
を返すときだけアクセスすることができます。
@Get('/whoami')
@UseGuards(AuthGuard)
whoAmI(@CurrentUser() user: User) {
return user;
}
参考資料