Google 로그인: OAuth2와 OpenID Connect
한 줄 요약
우리 서비스는 비밀번호를 받지 않는다. Google이 "이 사람이 이 계정 주인 맞다"는 보증서(ID 토큰)를 써 주고, 우리는 그 보증서만 확인한다.
Tip
비유: 호텔(우리 서비스)은 손님 신원을 직접 조사하지 않고 여권(Google이 서명한 ID 토큰)을 확인한다. 위조할 수 없으니 믿을 수 있다.
개념
| 용어 | 뜻 |
|---|---|
| OAuth2 | 다른 서비스에 권한을 위임하는 표준. 원래 목적은 "내 구글 캘린더를 이 앱이 읽어도 돼" 같은 위임 |
| OpenID Connect (OIDC) | OAuth2 위에 **"이 사람이 누구인지"**를 알려주는 층을 얹은 표준. 로그인에 쓰는 건 사실 이쪽 |
| ID 토큰 | Google이 서명한 JWT. 이메일, 이름, sub(고유 번호), email_verified가 들어 있다 |
sub |
Google 계정의 바뀌지 않는 고유 번호. 이메일은 바뀔 수 있으니 사용자 식별은 sub로 한다 |
왜 직접 로그인을 만들지 않나
- 비밀번호를 저장하지 않으면 유출될 비밀번호도 없다. 해싱, 재설정 메일, 무차별 대입 방어를 만들 필요가 없다.
- 2단계 인증, 의심 로그인 감지 같은 건 Google이 이미 해 준다.
- 회원가입 화면이 없어서 방문자가 댓글 다는 문턱이 낮다.
흐름 (Authorization Code Flow)
sequenceDiagram
participant B as 브라우저
participant S as 우리 서버
participant G as Google
B->>S: "로그인" 클릭
S->>B: Google로 가세요 (클라이언트 ID, 돌아올 주소)
B->>G: Google 로그인 화면
G->>B: 로그인 OK, 일회용 code를 들고 돌아가세요
B->>S: /login/oauth2/code/google?code=...
S->>G: code + 클라이언트 시크릿으로 토큰 요청 (서버끼리 직접)
G->>S: ID 토큰 (이메일, 이름, sub)
S->>B: 로그인 완료, 세션 쿠키 발급
Important
브라우저는 일회용 code만 보고, 실제 토큰 교환은 서버와 Google이 직접 한다. 시크릿이 브라우저에 절대 나가지 않는 구조다.
PKCE와 state: 따로 안 해도 되는 것들
| 장치 | 막는 것 | 누가 하나 |
|---|---|---|
state |
남이 시작한 로그인 응답을 내 브라우저에 끼워 넣기(로그인 CSRF) | Spring Security 자동 |
nonce |
ID 토큰 재사용 | Spring Security 자동(OIDC) |
| PKCE | 중간에 가로챈 code를 다른 사람이 토큰으로 바꾸기 |
Spring Security 자동 |
PKCE는 로그인 시작 때 무작위 값의 해시를 Google에 보내 두고, code를 바꿀 때 원래 값을 보여 주는 방식이다. code만 훔친 사람은 원래 값을 모른다.
로그인 뒤 원래 페이지로
노트를 보다가 "로그인하고 댓글 달기"를 누르면 로그인 후 그 노트로 돌아와야 한다. Spring Security는 로그인이 필요한 주소에 막혔을 때 그 요청을 세션에 저장해 두었다가(saved request) 로그인이 끝나면 거기로 보낸다. 그래서 로그인이 필요한 주소(/notes/{slug}/comments/login)를 하나 만들고, 그 주소는 노트의 댓글 위치로 리다이렉트만 한다.
Warning
?redirect=https://...처럼 돌아갈 주소를 파라미터로 받으면 오픈 리다이렉트가 된다. 공격자가 내 도메인 링크로 피싱 사이트에 보낼 수 있다. 서버가 저장한 요청만 쓰면 이 문제가 없다.
Google Cloud Console에서 정하는 세 가지
| 항목 | 역할 | 공개 여부 |
|---|---|---|
| 클라이언트 ID | 우리 서비스의 이름표 | 공개(주소창에도 보임) |
| 클라이언트 시크릿 | 우리 서버만 아는 비밀번호. code를 토큰으로 바꿀 때 사용 | 비밀. 환경변수로만 |
| 승인된 리디렉션 URI | 로그인 후 돌아올 주소 | 등록된 주소로만 돌아갈 수 있음 |
리디렉션 URI를 미리 등록하는 이유: 누가 가짜 사이트를 만들어 "로그인 끝나면 내 사이트로 보내 줘"라고 해도 Google이 거절한다. 그래서 로컬(http://localhost:8080/login/oauth2/code/google)과 실서버 도메인을 각각 등록해야 한다.
실제 설정 순서 (Google Cloud Console)
메뉴 이름은 자주 바뀐다(새 화면에서는 Google Auth Platform). 큰 흐름은 같다.
- 프로젝트 만들기 (예:
saip-blog) - OAuth 동의 화면: 앱 이름, 지원 이메일, 사용자 유형 외부, 테스트 사용자에 내 계정 추가
- 사용자 인증 정보 → OAuth 클라이언트 ID: 유형 웹 애플리케이션, 승인된 리디렉션 URI에
http://localhost:8080/login/oauth2/code/google - 발급된 클라이언트 ID와 시크릿을 환경변수로 저장
Note
동의 화면이 테스트 중 상태면 테스트 사용자로 등록한 계정만 로그인할 수 있다. 실제 공개 전에 프로덕션으로 바꾼다.
시크릿을 환경변수에 넣는 방법
# Claude 대화창이 아니라 일반 PowerShell 창에서 실행. 값은 프롬프트에 붙여넣는다.
[Environment]::SetEnvironmentVariable("GOOGLE_CLIENT_ID", (Read-Host "클라이언트 ID"), "User")
[Environment]::SetEnvironmentVariable("GOOGLE_CLIENT_SECRET", (Read-Host "클라이언트 시크릿"), "User")
[Environment]::SetEnvironmentVariable("BLOG_ADMIN_EMAIL", (Read-Host "관리자 Google 이메일"), "User")
Warning
AI 대화창에 시크릿을 붙여넣으면 대화 기록에 남는다. Read-Host로 받으면 명령 기록에도 값이 남지 않는다.
"User"범위로 저장해야 다음에 켜는 프로그램들이 읽는다.$env:X = ...는 그 창에서만 유효하고 창을 닫으면 사라진다.- 저장한 뒤에는 앱을 실행할 터미널 앱 자체를 다시 켜야 새 값을 물려받는다.
- 들어갔는지는 값을 출력하지 말고 "있는지"만 확인한다.
-not [string]::IsNullOrEmpty([Environment]::GetEnvironmentVariable("GOOGLE_CLIENT_ID", "User"))
Spring Boot 설정
// build.gradle (Boot 4 기준 스타터 이름)
implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-client'
# application.yml: 비밀값은 환경변수로만 받는다
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID:unset}
client-secret: ${GOOGLE_CLIENT_SECRET:unset}
scope: openid,profile,email
google은 Spring이 미리 알고 있는 공급자라서 인증 주소, 토큰 주소를 따로 적지 않아도 된다.scope에openid가 들어가면 OIDC로 동작해서 ID 토큰을 받는다.- 기본값
unset을 둔 이유: 환경변수가 없어도 앱은 뜨고 로그인만 실패하게. 테스트나 다른 기능 개발이 Google 설정에 묶이지 않는다.
http.oauth2Login(o -> o.userInfoEndpoint(u -> u.userAuthoritiesMapper(adminAuthorities)));
관리자 판정: 인증과 인가는 다르다
- 인증(누구인가): Google이 해결
- 인가(무엇을 할 수 있나): 우리 코드가 결정
로그인한 모두에게 ROLE_USER, 이메일이 설정값 BLOG_ADMIN_EMAIL과 같으면 ROLE_ADMIN을 준다. 이걸 GrantedAuthoritiesMapper에서 한다.
Warning
이메일 비교만으로는 부족하다. Google이 email_verified=true로 확인한 이메일일 때만 관리자로 인정해야 한다. 확인되지 않은 이메일로 관리자를 사칭하는 길을 막는다. 대소문자는 무시하고 비교한다.
-
email_verified가true인가 - 설정된 관리자 이메일이 비어 있으면 아무도 관리자가 아닌가
- 대소문자가 다른 같은 이메일도 같게 보는가
구현: GrantedAuthoritiesMapper
@Component
public class AdminAuthorities implements GrantedAuthoritiesMapper {
private final String adminEmail;
public AdminAuthorities(@Value("${blog.admin-email:}") String adminEmail) {
this.adminEmail = adminEmail.strip().toLowerCase(Locale.ROOT);
}
@Override
public Collection<? extends GrantedAuthority> mapAuthorities(Collection<? extends GrantedAuthority> authorities) {
Set<GrantedAuthority> mapped = new HashSet<>(authorities);
mapped.add(new SimpleGrantedAuthority("ROLE_USER"));
if (authorities.stream().anyMatch(this::isAdmin)) {
mapped.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
}
return mapped;
}
private boolean isAdmin(GrantedAuthority authority) {
if (adminEmail.isEmpty() || !(authority instanceof OidcUserAuthority oidc)) {
return false; // 설정이 비어 있으면 아무도 관리자가 아니다 (fail closed)
}
OidcIdToken token = oidc.getIdToken();
return Boolean.TRUE.equals(token.getEmailVerified()) && token.getEmail() != null
&& adminEmail.equals(token.getEmail().toLowerCase(Locale.ROOT));
}
}
경로별 권한 규칙
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/notes/*/comments/**").authenticated()
.anyRequest().permitAll())
.oauth2Login(o -> o.userInfoEndpoint(u -> u.userAuthoritiesMapper(adminAuthorities)))
.logout(l -> l.logoutSuccessUrl("/"));
| 누가 | /admin 접근 결과 |
|---|---|
| 비로그인 | Google 로그인으로 리다이렉트 |
| 로그인한 일반 사용자 | 403 |
| 관리자 | 200 |
hasRole("ADMIN")은 내부적으로 ROLE_ADMIN 권한을 찾는다. 접두사 ROLE_은 Spring 관례다.
화면에서 로그인 상태 보여주기
thymeleaf-extras-springsecurity의 sec:authorize로 상태별로 다른 메뉴를 보여준다.
<a sec:authorize="!isAuthenticated()" th:href="@{/oauth2/authorization/google}">로그인</a>
<a sec:authorize="hasRole('ADMIN')" th:href="@{/admin}">관리</a>
<span sec:authorize="isAuthenticated()" th:text="${#authentication.principal.fullName}">이름</span>
<form sec:authorize="isAuthenticated()" th:action="@{/logout}" method="post">
<button type="submit">로그아웃</button>
</form>
Tip
로그아웃이 링크가 아니라 POST 폼인 이유: GET으로 로그아웃되면 다른 사이트가 <img src=".../logout"> 한 줄로 사용자를 강제 로그아웃시킬 수 있다. 폼에는 CSRF 토큰이 자동으로 붙는다.
Caution
메뉴를 숨기는 건 편의일 뿐 보안이 아니다. 실제 차단은 서버의 authorizeHttpRequests가 한다. 화면에서 링크를 숨겨도 주소를 직접 치면 들어올 수 있기 때문이다.
로그인 이후: 세션 쿠키
매 요청마다 Google에 다녀올 수 없으니, 로그인이 끝나면 서버가 "이 브라우저는 로그인했음"을 기억하고 세션 쿠키(입장 팔찌)를 준다. 브라우저는 이후 요청에 이 쿠키를 자동으로 붙인다. 이 "자동으로" 때문에 CSRF 방어가 필요해진다. 자세한 건 웹 보안 기본기 노트 참고.
테스트는 Google 없이
spring-security-test의 oidcLogin()으로 가짜 로그인 사용자를 만들어 MockMvc 요청에 붙인다. 실제 Google 호출이 없어서 빠르고, 시크릿이 없어도 돌아간다.
mvc.perform(get("/admin").with(oidcLogin()
.idToken(t -> t.subject("u1").claim("name", "유저"))
.authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().isOk());
겪은 문제와 해결
- 리다이렉트 주소 테스트 실패: 비로그인으로
/admin에 가면/oauth2/authorization/google로 보내는데, Spring은 상대 경로로 응답한다.redirectedUrlPattern("**/oauth2/...")패턴은 앞에 아무것도 없는 상대 경로와 맞지 않아서 실패했다. 정확한 값 비교redirectedUrl("/oauth2/authorization/google")로 바꿨다. - 기본 비밀번호 로그가 사라짐: Spring Security만 넣으면 시작할 때
Using generated security password라는 임시 사용자 비밀번호가 찍힌다. OAuth2 로그인을 켜면 이 메모리 사용자가 자동으로 꺼져서 로그도 사라진다. 의도한 동작이다. - 에러 화면에서 사이드바가 "노트 없음"으로 보임: 403 화면은 노트 목록을 넣어 주는 컨트롤러를 거치지 않는다. 템플릿에서
tree != null일 때만 "노트 없음"을 띄우도록 고쳤다. - 환경변수를 설정했는데 앱이 못 읽음: 값이
User범위 레지스트리에 없으면(창 안에서만$env:로 넣은 경우 등) 새로 켠 프로그램은 모른다. 위의 "있는지만 확인" 명령으로 점검한다. - 값이
true로 들어감:SetEnvironmentVariable은 인자가 3개(이름, 값, 범위)다. 값 자리에 다른 게 들어가 실제 ID 대신true가 저장됐다. "있는지만 확인" 명령은True를 보여 줘서 알아채기 어렵다. 로그인에서invalid_client같은 오류가 나면 값을Read-Host로 다시 넣는다.
댓글 0
로그인하고 댓글 달기