이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- Social Login 계정 연결은 왜 로그인 성공보다 더 민감한 작업인가?
- 같은 이메일 자동 연결이 어떤 계정 탈취 시나리오로 이어질 수 있는가?
- 연결, 해제, 재연결에 어떤 재인증과 감사 로그가 필요한가?
개요
Social Login의 어려운 지점은 provider 인증 자체보다 내부 계정과 외부 provider 계정을 연결하는 정책이다. 연결이 잘못되면 공격자는 자신의 provider 계정을 피해자의 내부 계정에 붙여 이후 정상 로그인처럼 접근할 수 있다.
가장 흔한 실수는 이메일이 같으면 같은 사용자라고 가정하는 것이다. 이메일은 provider마다 검증 의미가 다르고, 변경되거나 재사용될 수 있으며, 일부 provider는 email claim을 항상 안정적으로 주지 않는다.
계정 연결은 내부 계정의 로그인 수단을 추가하는 보안 민감 작업이다. 따라서 기존 세션 재인증, provider subject 확인, 사용자 알림, 연결/해제 감사 로그가 필요하다.
이 문서의 핵심은 “소셜 로그인 성공”과 “내부 계정에 외부 identity를 연결”하는 행위를 분리하는 것이다.
공격 시나리오
- 공격자는 피해자 이메일과 같은 이메일을 표시하는 provider 계정을 준비한다.
- 애플리케이션이 email만 보고 기존 내부 계정에 자동 연결하면 공격자 provider 계정이 피해자 계정 로그인 수단이 된다.
- 피해자가 로그인한 상태에서 CSRF 또는 UI deception으로 “소셜 계정 연결” flow를 시작하게 만든다.
- state 검증이 약하면 공격자가 시작한 provider 인증 결과가 피해자 세션에 연결된다.
- 연결 해제에 재인증이 없으면 탈취한 짧은 세션으로 기존 로그인 수단을 제거한다.
- 알림과 감사 로그가 없으면 사용자는 이상 연결을 늦게 발견한다.
취약한 요청/응답 예시
GET /login/oauth2/code/github?code=attacker-provider-code&state=valid HTTP/1.1
Host: app.example.com
Cookie: SESSION=victim-sessionString email = oidcUser.getEmail();
Member member = memberRepository.findByEmail(email)
.orElseGet(() -> memberRepository.save(Member.newSocial(email)));
socialAccountRepository.save(new SocialAccount(member.getId(), "github", oidcUser.getName()));HTTP/1.1 302 Found
Location: /settings/connections문제는 기존 내부 계정 소유 확인 없이 email만으로 연결하고, 연결 행위를 별도 보안 이벤트로 다루지 않는다는 점이다.
주제별 핵심 판단
- 로그인과 계정 연결은 다른 업무 행위다.
- 연결은 기존 내부 계정의 재인증 후 수행해야 한다.
- 내부 계정 연결 키는
(provider, subject)다. - email은 보조 속성이며
email_verified가 있어도 자동 연결의 충분 조건은 아니다. - provider subject는 같은 provider 안에서 안정적인 외부 식별자로 저장한다.
- 연결과 해제는 사용자 알림과 감사 로그가 필요하다.
- 하나의 provider subject가 여러 내부 계정에 연결되지 않도록 unique constraint를 둔다.
개선된 요청/응답 예시
POST /api/me/social-connections/github/start HTTP/1.1
Host: app.example.com
Cookie: SESSION=victim-session
Content-Type: application/json
{"reauthToken":"recent-password-or-mfa-proof"}HTTP/1.1 302 Found
Location: /oauth2/authorization/github?link_intent=account_linkpublic void linkSocialAccount(MemberId memberId, ExternalIdentity identity, LinkIntent intent) {
if (!intent.isRecentlyReauthenticated()) {
throw new ReauthenticationRequiredException();
}
if (socialAccountRepository.existsByProviderAndSubject(identity.provider(), identity.subject())) {
throw new SocialAccountAlreadyLinkedException();
}
socialAccountRepository.save(SocialAccount.linked(memberId, identity));
audit.recordSocialLinked(memberId, identity.provider(), identity.subjectHash());
notification.sendSocialLinkedNotice(memberId, identity.provider());
}연결 flow는 로그인 flow와 별도 intent를 갖고, 최근 재인증이 있는 세션에서만 완료된다.
방어 설계
- 신규 가입, 기존 계정 로그인, 외부 계정 연결을 별도 flow로 나눈다.
- 기존 계정에 provider를 추가하려면 최근 비밀번호 확인, MFA, 또는 강한 세션 재인증을 요구한다.
- provider subject와 issuer를 저장하고 unique constraint를 둔다.
- email이 같은 미연결 provider 계정은 자동 연결하지 말고 사용자 확인 flow로 보낸다.
- 연결 해제는 최소 하나의 복구 가능한 로그인 수단이 남아 있는지 확인한다.
- 연결, 해제, 실패, 중복 시도를 모두 감사 로그로 남긴다.
- 사용자에게 연결/해제 알림을 보내고 모르는 연결이면 취소할 경로를 제공한다.
- 고객지원의 수동 연결도 관리자 감사 로그와 별도 승인 절차를 둔다.
Spring/HTTP 예시
@PostMapping("/api/me/social-connections/{provider}/link")
public ResponseEntity<Void> completeLink(@PathVariable String provider,
@AuthenticationPrincipal DomainUser user,
@RequestBody LinkCompletionRequest request) {
LinkIntent intent = linkIntentStore.consume(request.intentId(), user.memberId(), provider);
ExternalIdentity identity = oidcResultStore.consume(request.oidcResultId(), provider);
socialAccountService.linkSocialAccount(user.memberId(), identity, intent);
return ResponseEntity.noContent().build();
}@Table(
name = "social_account",
uniqueConstraints = @UniqueConstraint(
name = "uk_social_provider_subject",
columnNames = {"provider", "subject"}
)
)
class SocialAccount {
private String provider;
private String issuer;
private String subject;
private Long memberId;
}실제 구현은 Spring Security success handler나 service에서 이 정책을 호출할 수 있다. 중요한 점은 OAuth 성공 callback이 곧바로 내부 계정 연결을 의미하지 않게 만드는 것이다.
운영 로그와 감사 지점
level=INFO event=social_account_linked result=allowed memberId=1004 provider=github subjectHash=6b91 actor=member:1004 reauthAgeSeconds=120 traceId=a19f3e22level=WARN event=social_account_link_rejected result=blocked memberId=1004 provider=github reason=missing_recent_reauthentication traceId=a19f3e22- subject 원문과 provider token은 로그에 남기지 않는다.
- 연결과 해제는 actor, target member, provider, subject hash, reauth age를 남긴다.
- 중복 연결 시도는 계정 탈취 탐지 신호가 될 수 있다.
- 고객지원 수동 연결은 ticket id와 승인자를 남긴다.
- 연결 알림 발송 실패도 운영 이벤트로 본다.
실전 판단 기준
- 같은 이메일 자동 연결은 기본적으로 위험한 정책이다.
- 연결 flow가 로그인 flow와 같은 success handler에 묻혀 있으면 리뷰가 어렵다.
- “소셜 로그인 추가” 버튼이 재인증 없이 동작하면 세션 탈취 피해가 커진다.
- provider subject unique constraint가 없으면 한 외부 계정이 여러 내부 계정에 붙을 수 있다.
- 연결 해제에 제한이 없으면 공격자가 복구 수단을 제거할 수 있다.
- 사용자 알림이 없으면 공격 탐지 책임이 전부 서버 로그에만 남는다.
테스트 포인트
- 같은 이메일을 가진 미연결 provider 계정이 자동으로 기존 계정에 붙지 않는지 확인한다.
- 최근 재인증 없이 연결 요청이 거부되는지 확인한다.
- 이미 다른 내부 계정에 연결된 provider subject 연결이 거부되는지 확인한다.
- 연결 해제 후 로그인 수단이 0개가 되는 요청이 거부되는지 확인한다.
- 연결/해제 알림과 감사 로그가 생성되는지 확인한다.
- 고객지원 수동 연결이 ticket id 없이 불가능한지 확인한다.
위험 신호!
findByEmail()결과에 바로 social account를 저장한다.email_verified만으로 기존 내부 계정 연결을 자동 승인한다.- provider subject를 저장하지 않는다.
- social account 연결과 해제에 재인증이 없다.
- 연결 알림이나 감사 로그가 없다.
- provider subject unique constraint가 없다.
- 고객지원이 관리자 화면에서 임의로 연결을 바꿀 수 있다.
확인 질문
확인 질문
- Social Login 계정 연결이 로그인보다 위험할 수 있는 이유는 무엇인가?
- 내부 계정에 새로운 로그인 수단을 추가하는 행위라 잘못 연결되면 지속적인 계정 탈취 경로가 생기기 때문이다.
- 같은 이메일 자동 연결은 왜 위험한가?
- 이메일 claim은 provider별 검증 의미와 안정성이 다르며, 동일 이메일이 곧 동일 사용자 증명은 아니기 때문이다.
- 연결 감사 로그에는 무엇이 필요한가?
- actor, memberId, provider, subject hash, action, result, reason, reauthentication age, traceId가 필요하다.