Skip to content

Commit 0f3e4ed

Browse files
ImTotemclaude
andcommitted
feat(auth): multi-provider account linking via member_accounts
New table member_accounts (provider, provider_id) → member_id. One school_email can have multiple linked accounts: 학교 이메일 ├─ Google 계정 1 ├─ Google 계정 2 ├─ Notion (future) └─ Figma (future) - Login: looks up member_accounts by (google, email) → member - Register: creates member + google account link - Duplicate check: google email + school_email both checked - Migration 008: creates table + migrates existing emails to accounts - UNIQUE(provider, provider_id) prevents duplicate account links Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5b5fb8c commit 0f3e4ed

4 files changed

Lines changed: 94 additions & 5 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""member_accounts for multi-provider auth
2+
3+
Revision ID: 008
4+
Revises: 007
5+
"""
6+
7+
import sqlalchemy as sa
8+
from alembic import op
9+
10+
revision = "008"
11+
down_revision = "007"
12+
13+
14+
def upgrade() -> None:
15+
op.create_table(
16+
"member_accounts",
17+
sa.Column("id", sa.String, primary_key=True),
18+
sa.Column("member_id", sa.String, sa.ForeignKey("members.id")),
19+
sa.Column("provider", sa.String, nullable=False),
20+
sa.Column("provider_id", sa.String, nullable=False),
21+
sa.Column("created_at", sa.String),
22+
)
23+
op.create_unique_constraint(
24+
"uq_member_accounts_provider",
25+
"member_accounts",
26+
["provider", "provider_id"],
27+
)
28+
op.execute("""
29+
INSERT INTO member_accounts (id, member_id, provider, provider_id, created_at)
30+
SELECT
31+
'MA-' || id,
32+
id,
33+
'google',
34+
email,
35+
join_date
36+
FROM members
37+
WHERE email IS NOT NULL AND email != ''
38+
""")
39+
40+
41+
def downgrade() -> None:
42+
op.drop_table("member_accounts")

src/bcsd_api/auth/service.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ def login(
1919
google_token: str, settings: Settings, repo: PgMemberRepository,
2020
) -> str:
2121
profile = google_auth.verify_token(google_token, settings.google_client_id)
22-
member = repo.find_by_email(profile["email"])
22+
member = repo.find_by_provider("google", profile["email"])
2323
if not member:
2424
raise Unauthorized("member not found, registration required")
2525
payload = {"sub": member["id"], "email": profile["email"]}
@@ -41,13 +41,16 @@ def register(
4141
settings: Settings, repo: PgMemberRepository, conn: Connection,
4242
) -> tuple[str, str]:
4343
profile = google_auth.verify_token(google_token, settings.google_client_id)
44-
_check_duplicate(profile["email"], school_email, repo)
44+
_check_google(profile["email"], repo)
45+
_check_school_email(school_email, repo)
4546
member_id = generate_id("M")
47+
now = _now_kst()
4648
row = _build_row(
4749
member_id, name, profile["email"],
4850
department, student_id, school_email, phone, track, grade,
4951
)
5052
repo.create(row)
53+
repo.add_account(generate_id("MA"), member_id, "google", profile["email"], now)
5154
routing = _resolve_routing(grade, conn)
5255
token = _issue_jwt({"sub": member_id, "email": profile["email"]}, settings)
5356
return token, routing
@@ -60,9 +63,12 @@ def _issue_jwt(payload: dict, settings: Settings) -> str:
6063
)
6164

6265

63-
def _check_duplicate(email: str, school_email: str, repo: PgMemberRepository) -> None:
64-
if repo.find_by_email(email):
66+
def _check_google(email: str, repo: PgMemberRepository) -> None:
67+
if repo.find_account("google", email):
6568
raise Conflict("이미 가입된 Google 계정입니다")
69+
70+
71+
def _check_school_email(school_email: str, repo: PgMemberRepository) -> None:
6672
if repo.find_by_school_email(school_email):
6773
raise Conflict("이미 가입된 학교 이메일입니다")
6874

src/bcsd_api/member/pg_repository.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from sqlalchemy import Connection, insert, select, update
22

33
from bcsd_api.repository import BaseRepository
4-
from bcsd_api.tables import members
4+
from bcsd_api.tables import member_accounts, members
55

66

77
class PgMemberRepository(BaseRepository):
@@ -24,9 +24,41 @@ def find_by_school_email(self, school_email: str) -> dict | None:
2424
return None
2525
return row._asdict()
2626

27+
def find_by_provider(self, provider: str, provider_id: str) -> dict | None:
28+
stmt = (
29+
select(members)
30+
.join(member_accounts, member_accounts.c.member_id == members.c.id)
31+
.where(
32+
member_accounts.c.provider == provider,
33+
member_accounts.c.provider_id == provider_id,
34+
)
35+
)
36+
row = self._conn.execute(stmt).first()
37+
if not row:
38+
return None
39+
return row._asdict()
40+
2741
def create(self, row: dict) -> None:
2842
self._conn.execute(insert(members).values(**row))
2943

44+
def add_account(self, account_id: str, member_id: str, provider: str, provider_id: str, created_at: str) -> None:
45+
self._conn.execute(insert(member_accounts).values(
46+
id=account_id, member_id=member_id,
47+
provider=provider, provider_id=provider_id,
48+
created_at=created_at,
49+
))
50+
51+
def find_account(self, provider: str, provider_id: str) -> dict | None:
52+
row = self._conn.execute(
53+
select(member_accounts).where(
54+
member_accounts.c.provider == provider,
55+
member_accounts.c.provider_id == provider_id,
56+
),
57+
).first()
58+
if not row:
59+
return None
60+
return row._asdict()
61+
3062
def update_status(self, member_id: str, status: str) -> None:
3163
self._conn.execute(
3264
update(members).where(members.c.id == member_id).values(status=status),

src/bcsd_api/tables.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,15 @@
162162
Column("value", Text, nullable=False),
163163
)
164164

165+
member_accounts = Table(
166+
"member_accounts", metadata,
167+
Column("id", String, primary_key=True),
168+
Column("member_id", String, ForeignKey("members.id")),
169+
Column("provider", String, nullable=False),
170+
Column("provider_id", String, nullable=False),
171+
Column("created_at", String),
172+
)
173+
165174
app_settings = Table(
166175
"app_settings", metadata,
167176
Column("key", String, primary_key=True),

0 commit comments

Comments
 (0)