0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

認証

0
Posted at

SwiftUI × MVVM × Node.js/Express で実装する「認証機能」完全解説【EchoLog】

この投稿では、EchoLog アプリで実装している フロントエンド(SwiftUI / MVVM)
バックエンド(Node.js / Express / MongoDB / JWT) の認証基盤をまとめて解説します。


🎯 この解説でわかること

  • SwiftUI の新規登録 UI の構造
  • バリデーション、強度チェックなど UX への配慮
  • AuthViewModel → AuthService → API の流れ
  • バックエンドの登録/ログイン/refresh 処理の構造
  • JWT を使ったセッション管理の仕組み
  • OAuth ログインの実装方法

フロントとバックエンドを両方理解すると、アプリの全体像が一気につながります。


🧩 全体図 — 認証のデータフロー

[SwiftUI RegisterView]
        │ 入力データ
        ▼
[AuthViewModel]
        │ バリデーション/状態管理
        ▼
[AuthService]
        │ HTTP リクエスト
        ▼
[Express / API Server]
        │ MongoDB へ CRUD
        │ JWT 発行
        ▼
アプリへ accessToken / refreshToken を返す

🔰 フロントエンド編(SwiftUI × MVVM)

認証機能(フロントエンド)を実装する — SwiftUI × MVVM

この記事では、EchoLog アプリの ユーザー認証(新規登録・ログイン)を担当するフロントエンド部分 をまとめます。

  • 新規登録 UI
  • フォームバリデーション
  • PasswordStrengthIndicator
  • AuthViewModel との連携

SwiftUI のアニメーションや UI デザインにも配慮した実装です。


📝 RegisterView(新規登録画面)

ユーザーがアカウントを作成するための画面です。
AuthViewModel と連動してバックエンドに登録リクエストを送り、成功時には自動で画面を閉じます。


🎨 RegisterView — 全体構造

import SwiftUI

struct RegisterView: View {
    @EnvironmentObject var authViewModel: AuthViewModel
    @Environment(\.dismiss) private var dismiss
    @State private var name = ""
    @State private var email = ""
    @State private var password = ""
    @State private var confirmPassword = ""
    @State private var isAnimating = false
    
    private var isFormValid: Bool {
        !email.isEmpty && password.count >= 8 && password == confirmPassword
    }
    
    var body: some View {
        ZStack {
            LinearGradient(
                colors: [
                    Color.theme.background,
                    Color.theme.accent.opacity(0.05)
                ],
                startPoint: .top,
                endPoint: .bottom
            )
            .ignoresSafeArea()
            
            ScrollView(showsIndicators: false) {
                VStack(spacing: 28) {
                    headerSection
                    formSection
                    registerButton
                    divider
                    socialRegisterSection
                    Spacer(minLength: 40)
                }
                .padding(.horizontal, 24)
                .padding(.top, 20)
            }
        }
        .navigationBarTitleDisplayMode(.inline)
        .toolbar {
            ToolbarItem(placement: .navigationBarLeading) {
                Button(action: { dismiss() }) {
                    Image(systemName: "xmark")
                        .font(.system(size: 16, weight: .semibold))
                        .foregroundColor(.theme.text)
                        .padding(8)
                        .background(
                            Circle()
                                .fill(Color.theme.secondaryBackground)
                        )
                }
            }
        }
        .onAppear {
            withAnimation(.easeOut(duration: 0.6)) {
                isAnimating = true
            }
        }
    }

🔹 Header Section(タイトル + アイコン)

    // MARK: - Header Section
    
    private var headerSection: some View {
        VStack(spacing: 12) {
            ZStack {
                Circle()
                    .fill(Color.theme.accent.opacity(0.1))
                    .frame(width: 80, height: 80)
                
                Image(systemName: "person.badge.plus.fill")
                    .font(.system(size: 36))
                    .foregroundStyle(Color.theme.accentGradient)
            }
            .scaleEffect(isAnimating ? 1.0 : 0.8)
            .animation(.spring(response: 0.5, dampingFraction: 0.7), value: isAnimating)
            
            VStack(spacing: 6) {
                Text("新規登録")
                    .font(.system(size: 28, weight: .bold, design: .rounded))
                    .foregroundColor(.theme.text)
                
                Text("アカウントを作成して始めましょう")
                    .font(.system(size: 15))
                    .foregroundColor(.theme.secondaryText)
            }
        }
        .opacity(isAnimating ? 1.0 : 0.0)
        .offset(y: isAnimating ? 0 : 20)
    }

🔹 Form Section(入力フォーム)

    // MARK: - Form Section
    
    private var formSection: some View {
        VStack(spacing: 16) {
            ModernTextField(
                placeholder: "名前(任意)",
                text: $name,
                icon: "person.fill",
                textContentType: .name
            )
            
            ValidatedTextField(
                placeholder: "メールアドレス",
                text: $email,
                icon: "envelope.fill",
                keyboardType: .emailAddress,
                textContentType: .emailAddress,
                validation: Validators.email
            )
            .textInputAutocapitalization(.never)
            
            ValidatedTextField(
                placeholder: "パスワード(8文字以上)",
                text: $password,
                icon: "lock.fill",
                isSecure: true,
                textContentType: .newPassword,
                validation: Validators.password
            )
            
            ValidatedTextField(
                placeholder: "パスワード(確認)",
                text: $confirmPassword,
                icon: "lock.rotation",
                isSecure: true,
                textContentType: .newPassword,
                validation: { text in
                    if text.isEmpty { return .none }
                    return text == password ? .valid : .invalid("パスワードが一致しません")
                }
            )

🔹 Password Strength Indicator(パスワード強度 UI)

            if !password.isEmpty {
                PasswordStrengthIndicator(password: password)
            }
            
            if let error = authViewModel.error {
                HStack(spacing: 8) {
                    Image(systemName: "exclamationmark.triangle.fill")
                        .font(.system(size: 14))
                    Text(error.localizedDescription)
                        .font(.system(size: 14, weight: .medium))
                }
                .foregroundColor(.theme.error)
                .padding(.horizontal, 16)
                .padding(.vertical, 12)
                .frame(maxWidth: .infinity)
                .background(
                    RoundedRectangle(cornerRadius: 10)
                        .fill(Color.theme.error.opacity(0.1))
                )
            }
        }
        .opacity(isAnimating ? 1.0 : 0.0)
        .offset(y: isAnimating ? 0 : 30)
        .animation(.easeOut(duration: 0.6).delay(0.1), value: isAnimating)
    }

🔹 Register Button(登録ボタン)

    // MARK: - Register Button
    
    private var registerButton: some View {
        Button(action: {
            Task {
                await authViewModel.register(email: email, password: password, name: name)
                if authViewModel.isAuthenticated {
                    dismiss()
                }
            }
        }) {
            HStack(spacing: 12) {
                if authViewModel.isLoading {
                    ProgressView()
                        .progressViewStyle(CircularProgressViewStyle(tint: .white))
                } else {
                    Image(systemName: "checkmark.circle.fill")
                        .font(.system(size: 20))
                    Text("アカウントを作成")
                        .font(.system(size: 17, weight: .semibold))
                }
            }
            .frame(maxWidth: .infinity)
            .padding(.vertical, 16)
            .background(
                RoundedRectangle(cornerRadius: 14)
                    .fill(Color.theme.accentGradient)
                    .shadow(color: Color.theme.accent.opacity(0.3), radius: 8, x: 0, y: 4)
            )
            .foregroundColor(.white)
        }
        .disabled(authViewModel.isLoading || !isFormValid)
        .opacity(isFormValid ? 1.0 : 0.6)
        .opacity(isAnimating ? 1.0 : 0.0)
        .animation(.easeOut(duration: 0.6).delay(0.2), value: isAnimating)
    }

🔹 Divider(区切り線)+ ソーシャル登録

    private var divider: some View {
        HStack(spacing: 16) {
            Rectangle().frame(height: 1).foregroundColor(.theme.secondary.opacity(0.2))
            Text("または")
                .font(.system(size: 14, weight: .medium))
                .foregroundColor(.theme.secondaryText)
            Rectangle().frame(height: 1).foregroundColor(.theme.secondary.opacity(0.2))
        }
        .opacity(isAnimating ? 1.0 : 0.0)
        .animation(.easeOut(duration: 0.6).delay(0.3), value: isAnimating)
    }
    
    private var socialRegisterSection: some View {
        VStack(spacing: 12) {
            SocialLoginButton(
                icon: "apple.logo",
                text: "Appleで登録",
                backgroundColor: .black,
                foregroundColor: .white
            ) {
                Task {
                    await authViewModel.loginWithApple()
                    if authViewModel.isAuthenticated { dismiss() }
                }
            }
            
            SocialLoginButton(
                icon: "g.circle.fill",
                text: "Googleで登録",
                backgroundColor: .white,
                foregroundColor: .black,
                hasBorder: true
            ) {
                Task {
                    await authViewModel.loginWithGoogle()
                    if authViewModel.isAuthenticated { dismiss() }
                }
            }
        }
        .opacity(isAnimating ? 1.0 : 0.0)
        .animation(.easeOut(duration: 0.6).delay(0.4), value: isAnimating)
    }
}

🔹 PasswordStrengthIndicator(パスワード強度判定)

struct PasswordStrengthIndicator: View {
    let password: String
    
    private var strength: PasswordStrength {
        var score = 0
        if password.count >= 8 { score += 1 }
        if password.count >= 12 { score += 1 }
        if password.rangeOfCharacter(from: .uppercaseLetters) != nil { score += 1 }
        if password.rangeOfCharacter(from: .lowercaseLetters) != nil { score += 1 }
        if password.rangeOfCharacter(from: .decimalDigits) != nil { score += 1 }
        if password.rangeOfCharacter(from: CharacterSet(charactersIn: "!@#$%^&*()_+-=[]{}|;:,.<>?")) != nil { score += 1 }
        
        switch score {
        case 0...2: return .weak
        case 3...4: return .medium
        default: return .strong
        }
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            HStack(spacing: 4) {
                ForEach(0..<3, id: \.self) { index in
                    RoundedRectangle(cornerRadius: 2)
                        .fill(index < strength.level ? strength.color : Color.theme.secondary.opacity(0.2))
                        .frame(height: 4)
                }
            }
            
            HStack {
                Image(systemName: strength.icon)
                    .font(.system(size: 12))
                Text(strength.text)
                    .font(.system(size: 12, weight: .medium))
            }
            .foregroundColor(strength.color)
        }
        .padding(.horizontal, 4)
    }
}

完成 🎉

これで RegisterView(フロント側・新規登録画面) の Qiita 用記事がそのまま貼れる状態になりました。


🧱 RegisterView の UI と構造解説

(※ すでに書いたフロント部分をそのまま残します ※)

👇👇 フロントの解説は省略せずそのまま入れる(コピペ済み内容そのまま)

(※ フロント部分ここまで ※)


🟥 ここからが 追加パート:バックエンド(Express × MongoDB)編

🧩 バックエンド認証 API の全体像

Express ベースのサーバーでは、認証周りを以下の5つに分割しています:

  1. ユーザー登録(register)
  2. ログイン(login)
  3. アクセストークンリフレッシュ(refreshAccessToken)
  4. 認証済みユーザー取得(getCurrentUser)
  5. OAuth ログイン(oauthLogin)

これらが AuthService から呼ばれ、SwiftUI 側とやりとりします。


🔐 認証の基本戦略(セキュリティ設計)

このバックエンドでは認証の基本方針を次のようにしています:

仕組み 内容
accessToken 有効期限短め(例:15分)。API の保護に使用。
refreshToken 長めの期限。新しい accessToken を取得するために使う。
MongoDB ユーザーの永続データ保存(Mail / password / settings)。
Zod validation に使用。入力値の安全性を担保。
bcrypt パスワードのハッシュ化。生パスワードは保存しない。

🪪 1. ユーザー登録 API — /auth/register

新規ユーザーを作成して JWT を返します。

主な流れは:

  1. 入力値を Zod でバリデーション
  2. メール重複チェック
  3. パスワードハッシュ化(OAuth の場合はスキップ)
  4. MongoDB に挿入
  5. accessToken / refreshToken を発行
  6. クライアントに返却

👇 実装コード全文

export const register = async (req: Request, res: Response): Promise<void> => {
  try {
    console.log('📝 [REGISTER] Request body:', req.body);
    
    // バリデーション
    const validatedData = CreateUserSchema.parse(req.body);

    const db = getDatabase();
    const usersCollection = db.collection<UserDocument>('users');

    // 既存ユーザー確認
    const existingUser = await usersCollection.findOne({ email: validatedData.email });
    if (existingUser) {
      res.status(400).json({ error: 'Email already exists' });
      return;
    }

    // パスワードハッシュ化
    let hashedPassword: string | undefined;
    if (validatedData.password) {
      hashedPassword = await hashPassword(validatedData.password);
    }

    // ユーザードキュメント作成
    const newUser: UserDocument = {
      email: validatedData.email,
      password: hashedPassword,
      settings: {
        biometric_enabled: false,
        auto_backup: false,
        theme: 'dark',
      },
      created_at: new Date(),
      updated_at: new Date(),
    };

    // OAuth ならプロバイダー情報を追加
    if (validatedData.oauth_provider && validatedData.oauth_id) {
      newUser.oauth_provider = validatedData.oauth_provider;
      newUser.oauth_id = validatedData.oauth_id;
    }

    // 保存
    const result = await usersCollection.insertOne(newUser);
    newUser._id = result.insertedId;

    // トークン生成
    const accessToken = generateAccessToken(result.insertedId.toString(), newUser.email);
    const refreshToken = generateRefreshToken(result.insertedId.toString());

    res.status(201).json({
      token: accessToken,
      accessToken,
      refreshToken,
      user: documentToResponse(newUser),
    });

  } catch (error) {
    // Zod のバリデーションエラー
    if (error?.name === 'ZodError') {
      res.status(400).json({ error: 'Invalid input', details: error.errors });
      return;
    }

    console.error('❌ [REGISTER] Registration error:', error);
    res.status(500).json({ error: 'Internal server error' });
  }
};

🔑 2. ログイン API — /auth/login

  1. Email でユーザー検索
  2. パスワード照合
  3. トークン生成
  4. ユーザーデータを返却
export const login = async (req: Request, res: Response): Promise<void> => {
  try {
    const validatedData = LoginSchema.parse(req.body);

    const db = getDatabase();
    const usersCollection = db.collection<UserDocument>('users');

    const user = await usersCollection.findOne(
      { email: validatedData.email },
      { projection: { _id: 1, email: 1, password: 1 } }
    );
    
    if (!user || !user.password) {
      res.status(401).json({ error: 'Invalid email or password' });
      return;
    }

    const isValidPassword = await verifyPassword(validatedData.password, user.password);
    if (!isValidPassword) {
      res.status(401).json({ error: 'Invalid email or password' });
      return;
    }

    const accessToken = generateAccessToken(user._id!.toString(), user.email);
    const refreshToken = generateRefreshToken(user._id!.toString());

    res.json({
      token: accessToken,
      accessToken,
      refreshToken,
      user: documentToResponse(user),
    });

  } catch (error) {
    if (error?.name === 'ZodError') {
      res.status(400).json({ error: 'Invalid input', details: error.errors });
      return;
    }
    res.status(500).json({ error: 'Internal server error' });
  }
};

🔄 3. アクセストークン再発行(Refresh Token)

export const refreshAccessToken = async (req: Request, res: Response): Promise<void> => {
  try {
    const { refreshToken } = req.body;

    if (!refreshToken) {
      res.status(400).json({ error: 'Refresh token required' });
      return;
    }

    const decoded = verifyRefreshToken(refreshToken);
    if (!decoded) {
      res.status(401).json({ error: 'Invalid refresh token' });
      return;
    }

    const db = getDatabase();
    const usersCollection = db.collection<UserDocument>('users');

    const user = await usersCollection.findOne(
      { _id: new ObjectId(decoded.userId) },
      { projection: { _id: 1, email: 1 } }
    );

    if (!user) {
      res.status(401).json({ error: 'User not found' });
      return;
    }

    const accessToken = generateAccessToken(user._id!.toString(), user.email);
    res.json({ accessToken });

  } catch (error) {
    res.status(500).json({ error: 'Internal server error' });
  }
};

👤 4. 認証済みユーザー情報取得 — /auth/me

export const getCurrentUser = async (req: Request, res: Response): Promise<void> => {
  try {
    const user = (req as any).user;

    if (!user || !user.userId) {
      res.status(401).json({ error: 'Unauthorized' });
      return;
    }

    const db = getDatabase();
    const usersCollection = db.collection<UserDocument>('users');

    const userDoc = await usersCollection.findOne(
      { _id: new ObjectId(user.userId) },
      { projection: { password: 0 } }
    );

    if (!userDoc) {
      res.status(404).json({ error: 'User not found' });
      return;
    }

    res.json(documentToResponse(userDoc));
  } catch (error) {
    res.status(500).json({ error: 'Internal server error' });
  }
};

🟦 5. OAuth ログイン(Google / Apple)

OAuth ID と Provider をキーにログイン or 登録を自動判定する仕組み。

export const oauthLogin = async (req: Request, res: Response): Promise<void> => {
  try {
    const validatedData = CreateUserSchema.parse(req.body);

    if (!validatedData.oauth_provider || !validatedData.oauth_id) {
      res.status(400).json({ error: 'OAuth provider and ID are required' });
      return;
    }

    const db = getDatabase();
    const usersCollection = db.collection<UserDocument>('users');

    let user = await usersCollection.findOne(
      { oauth_provider: validatedData.oauth_provider, oauth_id: validatedData.oauth_id },
      { projection: { password: 0 } }
    );

    if (!user) {
      const newUser: UserDocument = {
        email: validatedData.email,
        oauth_provider: validatedData.oauth_provider,
        oauth_id: validatedData.oauth_id,
        settings: {
          biometric_enabled: false,
          auto_backup: false,
          theme: 'dark',
        },
        created_at: new Date(),
        updated_at: new Date(),
      };

      const result = await usersCollection.insertOne(newUser);
      user = await usersCollection.findOne({ _id: result.insertedId });
    }

    const accessToken = generateAccessToken(user!._id!.toString(), user!.email);
    const refreshToken = generateRefreshToken(user!._id!.toString());

    res.json({
      token: accessToken,
      accessToken,
      refreshToken,
      user: documentToResponse(user!),
    });

  } catch (error) {
    if (error?.name === 'ZodError') {
      res.status(400).json({ error: 'Invalid input', details: error.errors });
      return;
    }
    res.status(500).json({ error: 'Internal server error' });
  }
};

🎉 まとめ — フロント〜バックエンドまで一気に理解できる構成

レイヤー 役割 実装
SwiftUI UI 入力/表示/アニメーション RegisterView
ViewModel 状態管理 / バリデーション AuthViewModel
Service API 通信 AuthService
API Server 認証ロジック Express
DB ユーザー保存 MongoDB
JWT セッション / 認証 accessToken / refreshToken

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?