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つに分割しています:
- ユーザー登録(register)
- ログイン(login)
- アクセストークンリフレッシュ(refreshAccessToken)
- 認証済みユーザー取得(getCurrentUser)
- OAuth ログイン(oauthLogin)
これらが AuthService から呼ばれ、SwiftUI 側とやりとりします。
🔐 認証の基本戦略(セキュリティ設計)
このバックエンドでは認証の基本方針を次のようにしています:
| 仕組み | 内容 |
|---|---|
| accessToken | 有効期限短め(例:15分)。API の保護に使用。 |
| refreshToken | 長めの期限。新しい accessToken を取得するために使う。 |
| MongoDB | ユーザーの永続データ保存(Mail / password / settings)。 |
| Zod | validation に使用。入力値の安全性を担保。 |
| bcrypt | パスワードのハッシュ化。生パスワードは保存しない。 |
🪪 1. ユーザー登録 API — /auth/register
新規ユーザーを作成して JWT を返します。
主な流れは:
- 入力値を Zod でバリデーション
- メール重複チェック
- パスワードハッシュ化(OAuth の場合はスキップ)
- MongoDB に挿入
- accessToken / refreshToken を発行
- クライアントに返却
👇 実装コード全文
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
- Email でユーザー検索
- パスワード照合
- トークン生成
- ユーザーデータを返却
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 |