Skip to content

👤 認証機能とユーザー管理の完全実装 #8

Description

@tocie

概要

Firebase Authenticationを使用した認証機能と、Firestoreでのユーザー情報管理システムを完全実装する。

前提条件

タスク一覧

1. 認証サービスの実装

  • src/app/core/auth/auth.service.ts の完全実装
  • Google サインイン機能
  • @keio.jpドメイン制限ロジック
  • サインアウト機能
  • 認証状態の監視

2. ユーザー管理サービスの実装

  • src/app/core/services/user.service.ts の実装
  • Firestore users コレクション操作
  • ユーザープロフィール CRUD 操作
  • 初回ログイン時の自動ユーザー作成

3. 認証ガードの実装

  • src/app/core/guards/auth.guard.ts の実装
  • 認証必須ルートの保護
  • 未認証時のリダイレクト
  • ログイン後のリダイレクト

4. 認証関連コンポーネントの実装

  • ログインページコンポーネント
  • ユーザーメニューコンポーネント
  • プロフィール編集コンポーネント
  • 認証エラーハンドリング

5. 状態管理の実装

  • 認証状態の管理
  • ユーザー情報の管理
  • ローディング状態の管理
  • エラー状態の管理

実装仕様

AuthService の完全実装

// src/app/core/auth/auth.service.ts
import { Injectable } from '@angular/core';
import { AngularFireAuth } from '@angular/fire/compat/auth';
import { GoogleAuthProvider } from 'firebase/auth';
import { Observable, map, switchMap, of } from 'rxjs';
import { UserService } from '../services/user.service';
import { User } from '../models/user.interface';

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  
  // 認証状態の監視
  user$: Observable<User | null> = this.afAuth.authState.pipe(
    switchMap(authUser => {
      if (authUser) {
        return this.userService.getUser(authUser.uid);
      } else {
        return of(null);
      }
    })
  );
  
  // 認証済みかどうかの確認
  isAuthenticated$: Observable<boolean> = this.user$.pipe(
    map(user => \!\!user)
  );
  
  constructor(
    private afAuth: AngularFireAuth,
    private userService: UserService,
    private router: Router,
    private snackBar: MatSnackBar
  ) {}
  
  /**
   * Google サインイン
   */
  async signInWithGoogle(): Promise<void> {
    try {
      const provider = new GoogleAuthProvider();
      const result = await this.afAuth.signInWithPopup(provider);
      
      if (result.user?.email && this.validateKeioEmail(result.user.email)) {
        // @keio.jp ドメインの場合、ユーザー情報を作成/更新
        await this.userService.createOrUpdateUser({
          uid: result.user.uid,
          email: result.user.email,
          displayName: result.user.displayName || '',
          createdAt: new Date(),
          updatedAt: new Date()
        });
        
        this.snackBar.open('ログインしました', '閉じる', { duration: 3000 });
        this.router.navigate(['/dashboard']);
      } else {
        // @keio.jp 以外の場合はサインアウト
        await this.signOut();
        throw new Error('慶應義塾大学のメールアドレス (@keio.jp) でログインしてください');
      }
    } catch (error) {
      console.error('Login error:', error);
      this.snackBar.open(
        error instanceof Error ? error.message : 'ログインに失敗しました', 
        '閉じる', 
        { duration: 5000 }
      );
    }
  }
  
  /**
   * サインアウト
   */
  async signOut(): Promise<void> {
    try {
      await this.afAuth.signOut();
      this.snackBar.open('ログアウトしました', '閉じる', { duration: 3000 });
      this.router.navigate(['/login']);
    } catch (error) {
      console.error('Logout error:', error);
      this.snackBar.open('ログアウトに失敗しました', '閉じる', { duration: 3000 });
    }
  }
  
  /**
   * 現在のユーザーを取得
   */
  getCurrentUser(): Observable<User | null> {
    return this.user$;
  }
  
  /**
   * @keio.jpドメインの検証
   */
  private validateKeioEmail(email: string): boolean {
    return /^[a-zA-Z0-9._%+-]+@keio\.jp$/.test(email);
  }
}

UserService の実装

// src/app/core/services/user.service.ts
import { Injectable } from '@angular/core';
import { AngularFirestore } from '@angular/fire/compat/firestore';
import { Observable } from 'rxjs';
import { User } from '../models/user.interface';

@Injectable({
  providedIn: 'root'
})
export class UserService {
  
  constructor(private firestore: AngularFirestore) {}
  
  /**
   * ユーザー情報を取得
   */
  getUser(uid: string): Observable<User | undefined> {
    return this.firestore.collection('users').doc<User>(uid).valueChanges();
  }
  
  /**
   * ユーザー情報を作成または更新
   */
  async createOrUpdateUser(userData: Partial<User>): Promise<void> {
    if (\!userData.uid) {
      throw new Error('UID is required');
    }
    
    const userRef = this.firestore.collection('users').doc(userData.uid);
    const userDoc = await userRef.get().toPromise();
    
    if (userDoc?.exists) {
      // 既存ユーザーの更新
      await userRef.update({
        ...userData,
        updatedAt: new Date()
      });
    } else {
      // 新規ユーザーの作成
      await userRef.set({
        uid: userData.uid,
        email: userData.email,
        displayName: userData.displayName || '',
        facultyType: '',
        bio: '',
        createdAt: new Date(),
        updatedAt: new Date()
      });
    }
  }
  
  /**
   * プロフィール更新
   */
  async updateProfile(uid: string, profileData: Partial<User>): Promise<void> {
    await this.firestore.collection('users').doc(uid).update({
      ...profileData,
      updatedAt: new Date()
    });
  }
}

AuthGuard の実装

// src/app/core/guards/auth.guard.ts
import { Injectable } from '@angular/core';
import { CanActivate, Router, ActivatedRouteSnapshot, RouterStateSnapshot } from '@angular/router';
import { Observable, map, take } from 'rxjs';
import { AuthService } from '../auth/auth.service';

@Injectable({
  providedIn: 'root'
})
export class AuthGuard implements CanActivate {
  
  constructor(
    private authService: AuthService,
    private router: Router
  ) {}
  
  canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot
  ): Observable<boolean> {
    return this.authService.isAuthenticated$.pipe(
      take(1),
      map(isAuthenticated => {
        if (isAuthenticated) {
          return true;
        } else {
          // 未認証の場合はログインページにリダイレクト
          this.router.navigate(['/login'], { 
            queryParams: { returnUrl: state.url } 
          });
          return false;
        }
      })
    );
  }
}

ログインコンポーネント

// src/app/features/auth/login/login.component.ts
import { Component } from '@angular/core';
import { AuthService } from '../../../core/auth/auth.service';

@Component({
  selector: 'app-login',
  template: `
    <div class="login-container">
      <mat-card class="login-card">
        <mat-card-header>
          <mat-card-title>KCC Syllabus</mat-card-title>
          <mat-card-subtitle>慶應義塾大学通信教育課程</mat-card-subtitle>
        </mat-card-header>
        
        <mat-card-content>
          <div class="login-description">
            <p>科目情報共有プラットフォームにログイン</p>
            <p class="login-note">
              ※ @keio.jp のメールアドレスでのみログイン可能です
            </p>
          </div>
        </mat-card-content>
        
        <mat-card-actions>
          <button 
            mat-raised-button 
            color="primary" 
            (click)="signInWithGoogle()"
            [disabled]="isLoading"
            class="google-signin-button">
            <mat-icon>account_circle</mat-icon>
            Googleでログイン
          </button>
        </mat-card-actions>
      </mat-card>
    </div>
  `,
  styleUrls: ['./login.component.scss']
})
export class LoginComponent {
  isLoading = false;
  
  constructor(private authService: AuthService) {}
  
  async signInWithGoogle() {
    this.isLoading = true;
    try {
      await this.authService.signInWithGoogle();
    } finally {
      this.isLoading = false;
    }
  }
}

ユーザーメニューコンポーネント

// src/app/shared/components/user-menu/user-menu.component.ts
import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { AuthService } from '../../../core/auth/auth.service';
import { User } from '../../../core/models/user.interface';

@Component({
  selector: 'app-user-menu',
  template: `
    <ng-container *ngIf="user$ | async as user; else loginButton">
      <button mat-button [matMenuTriggerFor]="userMenu" class="user-button">
        <mat-icon>account_circle</mat-icon>
        <span>{{ user.displayName || user.email }}</span>
        <mat-icon>arrow_drop_down</mat-icon>
      </button>
      
      <mat-menu #userMenu="matMenu">
        <button mat-menu-item routerLink="/profile">
          <mat-icon>person</mat-icon>
          <span>プロフィール</span>
        </button>
        <button mat-menu-item routerLink="/my-posts">
          <mat-icon>post_add</mat-icon>
          <span>自分の投稿</span>
        </button>
        <mat-divider></mat-divider>
        <button mat-menu-item (click)="signOut()">
          <mat-icon>logout</mat-icon>
          <span>ログアウト</span>
        </button>
      </mat-menu>
    </ng-container>
    
    <ng-template #loginButton>
      <button mat-button routerLink="/login" color="accent">
        <mat-icon>login</mat-icon>
        ログイン
      </button>
    </ng-template>
  `
})
export class UserMenuComponent {
  user$: Observable<User | null> = this.authService.getCurrentUser();
  
  constructor(private authService: AuthService) {}
  
  signOut() {
    this.authService.signOut();
  }
}

データモデル

User インターフェース

// src/app/core/models/user.interface.ts
export interface User {
  uid: string;
  email: string;
  displayName?: string;
  facultyType?: string;
  bio?: string;
  createdAt: Date;
  updatedAt: Date;
}

ルーティング設定

// src/app/app-routing.module.ts
const routes: Routes = [
  { path: '', redirectTo: '/dashboard', pathMatch: 'full' },
  { path: 'login', component: LoginComponent },
  { 
    path: 'dashboard', 
    component: DashboardComponent, 
    canActivate: [AuthGuard] 
  },
  { 
    path: 'profile', 
    component: ProfileComponent, 
    canActivate: [AuthGuard] 
  },
  { path: '**', redirectTo: '/dashboard' }
];

Acceptance Criteria

  • @keio.jpドメインでのログインが正常に動作する
  • 他ドメインでのログイン試行が適切に拒否される
  • 認証状態がアプリ全体で適切に管理される
  • AuthGuard が保護されたルートを正しく保護する
  • ユーザー情報がFirestoreに適切に保存される
  • ログアウト機能が正常に動作する
  • エラーハンドリングが適切に実装されている
  • UIが直感的で使いやすい

テスト項目

  • @keio.jp ドメインでの正常ログイン
  • gmail.com などでのログイン拒否
  • 認証状態の永続化
  • AuthGuard の動作確認
  • ユーザー情報の CRUD 操作
  • エラー処理の確認

参照

次のステップ

フェーズ2の科目関連機能とコミュニティ機能の実装へ

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions