NestJSでマジックリンクを活用したメール認証を実装【パスワードレス認証】

パスワードを使わないマジックリンク認証をNestJSで実装する方法を解説します。メールに送信される認証リンクをクリックするだけでログインできる、ユーザーフレンドリーな認証システムをpassport-magic-linkを使って構築します。

🎯 この記事で実装できること:

  • パスワード不要のワンクリック認証
  • セキュアなワンタイムトークン生成
  • メール送信からログインまでの完全フロー
  • JWT連携でのセッション管理
目次

マジックリンク認証の仕組みとNestJSでの実装メリット

マジックリンク認証の基本的な流れとNestJSでの実装メリットを理解しましょう。

Documentation | NestJS - A progr...
Documentation | NestJS - A progressive Node.js framework Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines ele...

マジックリンク認証フロー

ステップ動作セキュリティポイント
1. メール入力ユーザーがメールアドレスを入力メール形式バリデーション
2. トークン生成ワンタイムトークンを生成有効期限とランダム性
3. メール送信認証リンクをメールで送信HTTPS必須
4. リンククリックユーザーがリンクをクリックトークン検証
5. 認証完了JWTトークンでセッション開始トークン無効化

従来認証との比較

項目パスワード認証マジックリンク認証
ユーザビリティパスワード記憶が必要✅ メールアクセスのみ
セキュリティパスワード漏洩リスク✅ ワンタイムトークン
実装コストパスワードリセット機能必須✅ シンプルな実装
デメイットメール依存、オフライン不可

必要なパッケージとプロジェクトセットアップ

マジックリンク認証に必要なパッケージをインストールしてプロジェクトを設定します。

パッケージインストール

# 認証関連パッケージ
npm install @nestjs/passport passport passport-magic-link
npm install @nestjs/jwt passport-jwt

# メール送信
npm install nodemailer
npm install @types/nodemailer -D

# 設定管理
npm install @nestjs/config

# バリデーション
npm install class-validator class-transformer

環境変数設定

# .env
# JWT設定
JWT_SECRET=your-super-secret-jwt-key
JWT_EXPIRATION=3600

# マジックリンク設定
MAGIC_LINK_SECRET=your-magic-link-secret
MAGIC_LINK_CALLBACK_URL=http://localhost:3000/auth/magic-link/callback

# メール設定(Gmail例)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password

passport-magic-linkでの認証戦略実装

NestJSでPassport戦略を使ったマジックリンク認証を実装します。

MagicLinkStrategy実装

// auth/strategies/magic-link.strategy.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-magic-link';
import { AuthService } from '../auth.service';
import { MailService } from '../mail.service';

@Injectable()
export class MagicLinkStrategy extends PassportStrategy(Strategy, 'magic-link') {
  constructor(
    private configService: ConfigService,
    private authService: AuthService,
    private mailService: MailService,
  ) {
    super({
      secret: configService.get('MAGIC_LINK_SECRET'),
      userFields: ['email'],
      tokenField: 'token',
      callbackUrl: configService.get('MAGIC_LINK_CALLBACK_URL'),
      sendMagicLink: async (destination: string, href: string) => {
        // メール送信処理
        await this.mailService.sendMagicLink(destination, href);
      },
      verify: async (payload: any) => {
        // ユーザー検証・作成処理
        return await this.authService.validateOrCreateUser(payload.destination);
      },
    });
  }
}

AuthService実装

// auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';

@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
    private jwtService: JwtService,
  ) {}

  async validateOrCreateUser(email: string): Promise<User> {
    // 既存ユーザーを検索
    let user = await this.userRepository.findOne({ where: { email } });
    
    // 新規ユーザーの場合は作成
    if (!user) {
      user = this.userRepository.create({
        email,
        isEmailVerified: true, // マジックリンクでメール確認済み
        createdAt: new Date(),
      });
      await this.userRepository.save(user);
    }
    
    return user;
  }

  async generateJwtToken(user: User): Promise<string> {
    const payload = { 
      sub: user.id, 
      email: user.email,
      isEmailVerified: user.isEmailVerified 
    };
    
    return this.jwtService.sign(payload);
  }

  async login(user: User) {
    const token = await this.generateJwtToken(user);
    
    return {
      access_token: token,
      user: {
        id: user.id,
        email: user.email,
        isEmailVerified: user.isEmailVerified,
      },
    };
  }
}

Nodemailerでのメール送信設定

セキュアで信頼性の高いメール送信機能を実装します。

MailService実装

// auth/mail.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';

@Injectable()
export class MailService {
  private transporter;

  constructor(private configService: ConfigService) {
    // SMTP設定
    this.transporter = nodemailer.createTransporter({
      host: this.configService.get('SMTP_HOST'),
      port: this.configService.get('SMTP_PORT'),
      secure: this.configService.get('SMTP_SECURE') === 'true',
      auth: {
        user: this.configService.get('SMTP_USER'),
        pass: this.configService.get('SMTP_PASS'),
      },
    });
  }

  async sendMagicLink(email: string, magicLink: string): Promise<void> {
    const mailOptions = {
      from: `"MyApp" <${this.configService.get('SMTP_USER')}>`,
      to: email,
      subject: '🔗 ログイン用マジックリンク',
      html: this.generateMagicLinkEmail(magicLink),
    };

    try {
      await this.transporter.sendMail(mailOptions);
      console.log(`Magic link sent to: ${email}`);
    } catch (error) {
      console.error('Failed to send magic link:', error);
      throw new Error('メール送信に失敗しました');
    }
  }

  private generateMagicLinkEmail(magicLink: string): string {
    return `
      <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
        <h2 style="color: #333;">ログイン用マジックリンク</h2>
        <p>以下のボタンをクリックしてログインしてください:</p>
        
        <div style="text-align: center; margin: 30px 0;">
          <a href="${magicLink}" 
             style="background-color: #007bff; color: white; padding: 12px 24px; 
                    text-decoration: none; border-radius: 6px; display: inline-block;">
            🔗 ログインする
          </a>
        </div>
        
        <p style="color: #666; font-size: 14px;">
          このリンクは10分間有効です。<br>
          もしこのメールに心当たりがない場合は、無視してください。
        </p>
        
        <hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
        <p style="color: #999; font-size: 12px;">
          リンクが機能しない場合は、以下のURLをコピーしてブラウザに貼り付けてください:<br>
          <code style="background: #f5f5f5; padding: 4px; word-break: break-all;">${magicLink}</code>
        </p>
      </div>
    `;
  }
}

セキュリティ対策:有効期限とワンタイム使用

セキュアなマジックリンク認証のための重要な対策を実装します。

AuthController実装

// auth/auth.controller.ts
import { Controller, Post, Get, Body, Req, Res, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express';
import { AuthService } from './auth.service';
import { RequestMagicLinkDto } from './dto/request-magic-link.dto';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('magic-link/request')
  @UseGuards(AuthGuard('magic-link'))
  async requestMagicLink(
    @Body() requestMagicLinkDto: RequestMagicLinkDto,
    @Res() res: Response,
  ) {
    // passport-magic-linkがメール送信を処理
    return res.json({
      message: 'マジックリンクを送信しました。メールをご確認ください。',
      success: true,
    });
  }

  @Get('magic-link/callback')
  @UseGuards(AuthGuard('magic-link'))
  async magicLinkCallback(@Req() req: Request, @Res() res: Response) {
    // 認証成功後の処理
    const user = req.user as any;
    const loginResult = await this.authService.login(user);

    // JWTをクッキーに設定(セキュア)
    res.cookie('access_token', loginResult.access_token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      maxAge: 3600000, // 1時間
    });

    // フロントエンドにリダイレクト
    return res.redirect(`${process.env.FRONTEND_URL}/dashboard`);
  }

  @Post('logout')
  async logout(@Res() res: Response) {
    res.clearCookie('access_token');
    return res.json({ message: 'ログアウトしました', success: true });
  }
}

バリデーションDTO

// auth/dto/request-magic-link.dto.ts
import { IsEmail, IsNotEmpty } from 'class-validator';

export class RequestMagicLinkDto {
  @IsEmail({}, { message: '有効なメールアドレスを入力してください' })
  @IsNotEmpty({ message: 'メールアドレスは必須です' })
  email: string;
}

セキュリティ強化ポイント

対策実装方法効果
短い有効期限10分でトークン無効化トークン漏洩リスク軽減
ワンタイム使用使用後即座に無効化リプレイ攻撃防止
HTTPS必須本番環境での強制設定通信内容暗号化
レート制限1分間に3回までスパム攻撃防止
ログ記録認証試行の詳細記録不正アクセス検知

実際の使用例とエンドポイントテスト

実装したマジックリンク認証をテストして動作確認を行います。

AuthModule設定

// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';

import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { MailService } from './mail.service';
import { MagicLinkStrategy } from './strategies/magic-link.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { User } from './entities/user.entity';

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    PassportModule,
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get('JWT_SECRET'),
        signOptions: {
          expiresIn: configService.get('JWT_EXPIRATION') + 's',
        },
      }),
      inject: [ConfigService],
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, MailService, MagicLinkStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

API使用例

# 1. マジックリンク送信リクエスト
curl -X POST http://localhost:3000/auth/magic-link/request \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'

# レスポンス例
{
  "message": "マジックリンクを送信しました。メールをご確認ください。",
  "success": true
}

# 2. 保護されたルートのテスト(JWT必須)
curl -X GET http://localhost:3000/profile \
  -H "Authorization: Bearer YOUR_JWT_TOKEN"

フロントエンド統合例

// React/Vue.jsでの使用例
const requestMagicLink = async (email) => {
  try {
    const response = await fetch('/auth/magic-link/request', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ email }),
    });
    
    const data = await response.json();
    
    if (data.success) {
      alert('メールをご確認ください!');
    }
  } catch (error) {
    console.error('マジックリンク送信エラー:', error);
  }
};

完成!セキュアなパスワードレス認証システム

これでNestJSを使ったマジックリンク認証システムが完成しました。

🎉 実装できたこと

  • パスワードレス認証:メールアドレスのみでログイン
  • セキュアなトークン:ワンタイム・短期間有効
  • ユーザーフレンドリー:パスワード記憶不要
  • 拡張性:JWT連携で他システムとの統合容易

さらなる改善案

  • Redis連携:大規模アプリでのトークン管理
  • 2FA対応:SMS認証との組み合わせ
  • Analytics連携:ログイン成功率の分析
  • A/Bテスト:メールテンプレートの最適化

重要なリンク集

マジックリンク認証で、ユーザーにとってもシステムにとってもセキュアで快適な認証体験を提供しましょう。パスワード管理の煩雑さから解放された、モダンな認証システムの完成です!

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次