saip.learning notes

Google 로그인: OAuth2와 OpenID Connect

보안 · 마지막 수정 2026-10-24

한 줄 요약

우리 서비스는 비밀번호를 받지 않는다. 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). 큰 흐름은 같다.

  1. 프로젝트 만들기 (예: saip-blog)
  2. OAuth 동의 화면: 앱 이름, 지원 이메일, 사용자 유형 외부, 테스트 사용자에 내 계정 추가
  3. 사용자 인증 정보 → OAuth 클라이언트 ID: 유형 웹 애플리케이션, 승인된 리디렉션 URI에 http://localhost:8080/login/oauth2/code/google
  4. 발급된 클라이언트 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

로그인하고 댓글 달기