개요
소개
- molly 프로젝트는 반려동물의 건강 관리를 수월하게 하고자 반려동물 건강 및 예방접종 일정 관리를 도와주는 웹 서비스이다.
- molly 프로젝트는 백엔드로 스프링을 사용하고 프론트엔드로 리액트를 사용한다.
- 스프링과 리액트는 rest api 방식으로 통신한다.
- 스프링에서는 스프링 시큐리티를 사용한다.
- oauth2 소셜 로그인 기능을 지원한다.
- 구글, 카카오
- 소셜 로그인이 완료되면 사용자는 서버로부터 액세스 토큰과 리프래시 토큰을 발급받는다.
- jwt 토큰 방식 사용
소셜 로그인 기능을 지원하는 이유
- 편리성
- 소셜 로그인은 사용자가 따로 가입을 하지 않아도 되므로 가입 절차를 간소화시킬 수 있다.
- 또한, 다른 사이트에서 이미 사용 중인 계정을 활용해 로그인할 수 있으므로 로그인 절차 자체도 간편하다.
- 보안성
- 사용자가 직접 로그인 정보를 입력하지 않기 때문에 불법적인 로그인 시도나 개인정보 유출 등의 위협이 줄어든다.
- 소셜 로그인에서는 인증 서비스를 제공하는 측에서 보안에 대한 책임을 지기 때문에 보안성이 보장된다.
- 개발 편의성
- 소셜 로그인 API는 다양한 프로그래밍 언어와 플랫폼에서 지원되므로, 개발자들이 로그인 기능을 쉽게 구현할 수 있다.
사용하는 기술 스택
백엔드 기술 스택
- Spring Boot 2.7.9
- Java 11
- Spring Security
- 추가 dependency
- oauth2-client
- jwt
- redis
프론트엔드 기술 스택
- React
JWT
JWT란?
JWT
- JWT( JSON Web Token)는 인증을 위한 데이터 형식으로, 정보를 안전하게 전송하기 위한 표준 기술이다.
- JWT는 JSON 객체를 사용하여 데이터를 전송하며, 암호화된 문자열로 구성된다.
- 이 문자열은 서버와 클라이언트 간의 인증과 정보전달에 사용된다.
- 즉, JWT(JSON Web Token)은 클라이언트와 서버 간에 안전하게 정보를 전달하기 위한 인증 권한 부여 메커니즘으로 ,토큰 기반 인증(Token-Based Authentication) 방식에 많이 사용된다.
- JWT 토큰은 헤더, 페이로드, 서명으로 구성되어 있다.
구조
- JWT는 세 부분(Header, Payload, Signature)으로 구성된다.
- 각 부분은 Base64Url로 인코딩 되어 표현된다.
- 첫 번째 부분은 헤더(Header)로, 토큰의 타입과 암호화 알고리즘 정보를 포함한다.
- JWT 토큰의 정보를 담고 있는 메타데이터이다.
- 주로 사용되는 알고리즘과 토큰의 타입들을 지정한다.
- 그림의 JWT 구조 예시에서 보이듯 JWT 헤더는 JSON 형식으로 인코딩되며, 암호화 알고리즘과 토큰 타입을 지정한다.
- "alg"는 암호화 알고리즘을 지정하며, 대표적으로 HS256(HMAC SHA-256), RS256(RSASHA-256) 등이 있다.
- "typ"는 토큰 타입을 지정하며, JWT인 경우 "JWT"를 사용한다.
- 두 번째 부분은 페이로드(Payload)로, 사용자 정보와 같은 클레임(Claim)이 포함된다.
- JWT 토큰에 담겨 있는 정보를 의미한다.
- 사용자 ID, 권한, 만료 시간 등을 포함할 수 있다.
- 그림의 JWT 구조 예시에서 보이듯 JWT 페이로드는 JSON 형식으로 인코딩되며, 토큰에 포함할 클레임(Claim) 정보를 지정한다.
- "sub"는 토큰의 주체(Subject)를 지정하며, 일반적으로 사용자의 ID나 이메일 주소를 사용한다.
- "name"은 토큰의 주체의 이름을 지정한다.
- "iat"는 토큰 발급 시간을 지정한다.
- 세 번째 부분은 서명(Signature)으로, 이전 두 부분을 기반으로 생성되며, 서버에서 검증할 수 있도록 디지털 서명이 포함된다.
- JWT 토큰의 무결성을 검증하기 위한 서명이다.
- 비밀키를 사용하여 헤더와 페이로드를 지정된 암호화 알고리즘으로 서명하고, 이를 서명 부분에 추가한다.
- 서명을 통해 jwt의 무결성(Integrity)과 인증(Authentication)을 보장할 수 있다.
장단점
- 장점
- JWT는 토큰 기반 인증 방식으로, 서버에서 사용자 인증 정보를 저장하지 않아도 된다.
- 이로 인해 서버 부하를 줄일 수 있다.
- 클라이언트와 서버 간의 상태를 저장하지 않기 때문에 Stateless한 구조를 가진다.
- JWT는 표준화된 형식으로, 인증 정보를 전달하기 용이하다.
- Base64 URL 인코딩된 문자열로 구성되어 있기 때문에 url에 포함하여 사용하기 유용하다.
- JWT는 서명을 통해 무결성을 검증하기 때문에, 변조된 토큰이 발견되면 유효하지 않은 것으로 간주한다.
- 개인정보와 같은 민감한 정보를 포함시킬 수 없기 때문에, 보안성이 높다.
- JWT는 토큰 기반 인증 방식으로, 서버에서 사용자 인증 정보를 저장하지 않아도 된다.
- 단점
- JWT는 토큰이 클라이언트에게 노출될 경우 보안 위험성이 존재한다.
- 이를 방지하기 위해 HTTPS와 같은 보안 프로토콜을 사용하는 것이 좋다.
- JWT 토큰의 정보가 해독될 경우, 사용자 정보가 노출될 수 있다.
- JWT 토큰에는 만료 기간이 포함되어 있지만, 토큰이 한 번 발급되면 만료 기간 전에는 취소할 수 없다.
- 토큰을 분실하거나 탈취당한 경우 보안상 취약하다.
- jwt 토큰의 길이가 상대적으로 길어 URL 길이 제한이 있는 경우 사용하기 어렵다.
- JWT는 토큰이 클라이언트에게 노출될 경우 보안 위험성이 존재한다.
- JWT 토큰의 만료 기간이 경과하면, 사용자는 다시 인증 과정을 거쳐야 한다.
- 이를 간단히 처리하기 위해 리프래시(Refresh) 토큰을 사용할 수 있다.
- 리프래시 토큰은 기존 jwt 토큰의 만료 기간이 경과하면 발급되는 새로운 토큰으로, 만료 기간이 더 길게 설정된다.
- 이를 통해 사용자는 일정 기간 동안 인증을 유지할 수 있다.
세션(Session) 방식 vs. JWT(Json Web Token) 방식
세션(Session) 방식과 JWT(JSON Web Token) 방식은 모두 웹 애플리케이션에서 사용자 인증 및 인가에 사용되는 방식이다.
각 방식에는 장단점이 있으며, JWT 방식을 사용할 때의 장점은 다음과 같다:
장점
- 상태를 저장하지 않는다: JWT는 토큰 기반의 인증 방식으로, 서버에서 세션 정보를 저장할 필요가 없다.
토큰은 클라이언트에 저장되고 필요할 때마다 서버로 전송된다.
이는 서버에서 세션 상태를 유지할 필요가 없으므로 확장성이 좋아진다.
세션 방식은 서버에서 세션 정보를 저장하고 관리해야 하므로 서버에 부하가 생길 수 있다.
- 클라이언트와 서버의 독립성: JWT는 토큰에 필요한 모든 정보를 포함하므로 서버의 인증이나 인가에 대한 의존도가 줄어든다.
이는 서로 다른 도메인 간에 인증 및 인가를 공유하는 경우 유용하다.
세션 방식은 일반적으로 도메인 간 공유가 어렵다.
- 확장성과 분산 시스템 지원: JWT는 분산 시스템에서 사용하기에 적합하다.
서버 간에 토큰을 공유하거나 검증할 수 있으며, 서비스를 수평으로 확장하는 데 용이하다.
세션 방식은 일반적으로 세션 정보를 중앙 집중식 스토리지에 저장하므로 분산 시스템에서의 확장성이 낮을 수 있다.
- 권한 부여 및 정보 전달:
JWT에는 토큰 자체에 사용자 권한이 포함될 수 있으므로, 토큰 검증을 통해 권한을 확인하는 데 유용하다.
또한, JWT에 추가 정보를 포함시켜 클레임(Claim)을 만들 수 있다.
이는 클라이언트와 서버 간에 추가 데이터를 전달하는 데 사용될 수 있다.
세션 방식과 JWT 방식의 각각의 장단점을 살펴본다:
세션 방식의 장점:
- 서버에서 세션 상태를 직접 관리할 수 있으므로 보안 측면에서 더 안전하다.
- 세션을 적극적으로 제어할 수 있으며, 로그아웃이나 세션 만료와 같은 동작을 쉽게 구현할 수 있다.
세션 방식의 단점:
- 서버에 세션 정보를 저장하고 관리해야 하므로 서버 부하가 발생할 수 있다.
- 세션 정보를 서버에 저장하기 때문에 확장성이 낮을 수 있다.
- 도메인 간 공유가 어렵다.
JWT 방식의 장점:
- 상태를 저장하지 않으므로 서버 부하가 줄어들고 확장성이 좋아진다.
- 클라이언트와 서버의 독립성이 높아져 다양한 도메인 간 공유가 가능하다.
- 분산 시스템에서 확장성이 좋고 서비스 수평 확장에 용이합니다.토큰에 권한 정보를 포함할 수 있고, 클레임을 통해 추가 정보를 전달할 수 있다.
JWT 방식의 단점:
- 토큰의 크기가 커질 수 있으므로 트래픽이 많은 상황에서는 약간의 오버헤드가 발생할 수 있다.
- 토큰 자체에 정보가 포함되어 있기 때문에 토큰을 유출하면 해당 정보가 노출될 수 있다.
따라서 토큰의 안전한 보관과 전송이 중요하다.
요약하면, JWT 방식은 상태를 저장하지 않고 클라이언트에 토큰을 전달하는 토큰 기반의 인증 방식이다.
이를 통해 확장성과 독립성을 향상시킬 수 있으며, 분산 시스템에서 효과적으로 작동할 수 있다.
세션 방식은 세션 정보를 서버에 저장하고 관리하며, 보안 측면에서 더 안전하지만 확장성과 독립성 면에서는 제한적일 수 있다.
따라서 애플리케이션의 요구사항과 환경에 맞게 적절한 방식을 선택해야 한다.
리프래시 토큰
리프래시 토큰
- 리프래시 토큰은 서버에서 발급하며, 쿠키나 http 요청 헤더에 담아 클라이언트와 서버 간의 인증을 수행한다.
- 일정 시간마다 토큰을 갱신하거나 사용자의 요청에 따라 갱신한다.
- JWT 액세스 토큰이 만료된 경우 ,클라이언트는 리프래시 토큰을 사용해 새로운 액세스 토큰을 발급받아야 한다.
리프래시 토큰 사용법
- 서버에서 리프래시 토큰을 생성한다.
- 클라이언트에서는 리프래시 토큰을 저장한다.
- 일반적으로 쿠키에 저장한다.
- 액세스 토큰이 만료된 경우 클라이언트는 HTTP 요청 헤더나 쿠키에 저장된 리프래시 토큰을 서버에 전송한다.
- 서버에서는 전송받은 리프래시 토큰을 검증한다.
- 유효한 경우, 새로운 액세스 토큰과 리프래시 토큰을 발급한다.
- 이때, 발급된 애겟스 토큰과 리프래시 토큰은 이전 토큰과 다른 값을 가지며, 일반적으로 새로운 만료 시간을 가진다.
- 유효한 경우, 새로운 액세스 토큰과 리프래시 토큰을 발급한다.
- 클라이언트는 새로운 액세스 토큰과 리프래시 토큰을 저장한다.
스프링 환경에서 JWT를 사용하는 방법
첫 번째 방법. 스프링 시큐리티(Spring Security) 사용
- 스프링 시큐리티는 JWT 토큰을 생성하고, 검증하며 이를 이용하여 인증과 권한 부여를 수행할 수 있다.
- 스프링 시큐리티를 사용하기 위해서는 의존성을 추가하고 설정 파일에 JWT 관련 설정을 추가해야 한다.
두 번째 방법. 스프링의 JWT 라이브러리 사용
- 이 방법의 경우 스프링 시큐리티를 사용하지 않는 경우 사용할 수 있다.
- 이 방법을 사용하기 위해서는 의존성을 추가하고 JWT를 생성하고 검증하기 위한 별도의 클래스를 정의해야 한다.
- JWT 라이브러리 추가
- 스프링 프로젝트에 JWT 라이브러리를 추가해야 한다.
- 가장 많이 사용되는 JWT 라이브러리 중 하나이 'jjwt' 라이브러리를 사용할 수 있다.
- 본 프로젝트에서는 Java JWT 라이브러리를 사용한다.
- JWT 인증 필터 구현
- 액세스 토큰을 검증하고, 인증 정보를 추출하기 위한 JWT 인증 필터를 구현해야 한다.
- 스프링 시큐리티에서는 JWT를 이용한 인증을 지원하도록 JwtAuthenticationFilter와 JwtAuthorizationFilter 클래스를 제공한다.
- JwtAuthenticationFilter는 클라이언트가 보낸 JWT 토큰을 검증하고, 인증 정보를 추출하는 역할을 한다.
- 이를 위해 JWT 라이브러리를 사용해 토큰을 검증하고, 추출한 정보를 Authentication 객체에 담아 인증 정보를 제공한다.
- Authentication 객체 참고: https://yenjjun187.tistory.com/595
- 이를 위해 JWT 라이브러리를 사용해 토큰을 검증하고, 추출한 정보를 Authentication 객체에 담아 인증 정보를 제공한다.
- JwtAuthorizationFilter는 인증된 사용자의 권한을 검증하는 역할을 한다.
- 이를 위해 Authentication 객체를 사용해 인증된 사용자의 권한을 확인하고 권한이 없는 경우 403 Forbidden 에러를 반환한다.
- 액세스 토큰 발급
- 액세스 토큰을 발급하기 위해서는, 클라이언트가 인증 정보를 제공한 후, 인증된 사용자의 정보를 포함한 JWT 토큰을 발급해야 한다.
- 이때, JWT 라이브러리를 사용하여 JWT 토큰을 생성하고 필요한 정보를 담아 반환한다.
- 리프래시 토큰 사용
- 액세스 토큰이 만료된 경우, 클라이언트는 리프래시 토큰을 사용하여 새로운 액세스 토큰을 발급받아야 한다.
- 이를 위해, 서버는 전송받은 리프래시 토큰을 검증하고, 새로운 액세스 토큰을 발급한다.
- 액세스 토큰이 만료된 경우, 클라이언트는 리프래시 토큰을 사용하여 새로운 액세스 토큰을 발급받아야 한다.
- 토큰 저장
- 액세스 토큰과 리프래시 토큰은 클라이언트 측에서 저장해야 한다.
- 본 프로젝트는 서버에서도 Redis를 사용해 리프래시 토큰을 별도로 저장한다.
- 두 가지 방법 모두 JWT를 사용하여 인증을 수행할 수 있다.
- 선택할 방법은 프로젝트의 요구 사항에 따라 다르게 결정된다.
- molly 프로젝트에서는 두 가지 방법 모두 사용한다.
OAUTH2 소셜 로그인
OAUTH2?
- OAuth2는 클라이언트 애플리케이션이 인증 및 권한 부여를 요청하고, 인증 서버는 요청한 사용자의 동의를 받은 후에 클라이언트 애플리케이션에 액세스 토큰을 제공해 보호된 리소스에 액세스할 수 있게 한다.
- OAuth2 승인 방식은 크게 4가지가 있다.
OAUTH2 승인 방식의 종류
- Authorization Code Grant Type
- 가장 일반적인 승인 방식이다.
- 서버측 애플리케이션에서 사용된다.
- 클라이언트가 사용자를 인증하고 권한을 요청하는 요청을 만들고, 이를 인증 서버에 전송한다.
- 인증 서버는 사용자에게 인증 페이지를 표시하고 ,사용자는 인증을 수행한다..
- 인증이 성공하면 인증 서버는 액세스 토큰과 함께 리다이렉션 URL을 클라이언트에게 반환한다.
- 클라이언트는 액세스 토큰을 사용해 보호된 리소스에 액세스할 수 있다.
- Implicit Grant Type
- 사용자 인증 및 권한 부여를 위한 프로토콜이다.
- 웹 브라우저 또는 JavaScript 기반 클라이언트에서 사용된다.
- Authorization Code Grant와 달리 클라이언트에게 액세스 토큰이 직접 반환된다.
- 이 승인 방식에서는 액세스 토큰이 노출될 위험이 있으므로 보안이 취약하다.
- 보안을 향상시키기 위해 클라이언트 측에서 암호화를 수행하거나, Access Token을 사용하기 전에 서버 측에서 다시 한 번 확인을 수행해야 한다.
- Resource Owner Password Credentials Grant Type
- 사용자 이름과 암호를 사용하여 인증하고 ,액세스 토큰을 반환한다.
- 사용자가 클라이언트에 직접 로그인하며, 비밀번호를 클라이언트에게 제공한다.
- 클라이언트는 이 정보를 사용해 인증 서버에 요청을 보내고, 액세스 토큰을 받는다.
- 이 승인 방식은 보안성이 낮아 권장되지 않는다.
- Client Credentials Grant Type
- 클라이언트 자체가 리소스에 대한 액세스를 요청하고, 인증 서버로부터 액세스 토큰을 받는 프로토콜이다.
- 사용자가 아닌 클라이언트 자체가 API에 접근한다.
- 참고: https://cheese10yun.github.io/spring-oauth2-provider/
- 일반적으로 "인증 서버"는 OAuth 2.0의 인증 서버를 의미한다.
- 따라서 우리 프로젝트에서 인증 서버는 소셜 로그인을 처리하기 위한 소셜 서비스의 OAuth 2.0 인증 서버를 의미한다.
- 스프링 부트 서버는 클라이언트로부터 받은 인증 정보를 이용해 소셜 서비스의 API를 호출하고, 그 결과를 클라이언트에게 반환하는 역할을 수행한다.
- 본 프로젝트에서는 OAuth 2.0 Authorization Code Grant Type을 사용한다.
- 이 방식은 일반적으로 클라이언트(리액트가 소셜 서비스(카카오, 구글 등)로부터 인증 코드를 받아, 이를 서버(스프링)로 보내 인증 코드와 함께 Access Token을 요청하고, Access Token을 받아 서버로부터 보호된 리소스(사용자 정보 등)에 접근할 수 있는 방식이다.
소셜 로그인
소셜 로그인?
- 소셜 로그인은 사용자가 다른 웹사이트나 앱에서 이미 사용하고 있는 소셜 미디어 서비스의 계정을 사용해 새로운 웹사이트나 앱에 로그인할 수 있는 기능을 말한다.
- 예를 들어 Facebook, Google, Kakao 드으이 소셜 미디어 계정을 사용해 다른 웹사이트나 앱에 로그인할 수 있다.
- 소셜 로그인은 사용자가 다른 웹사이트나 앱에 가입할 필요 없이 손쉽게 로그인할 수 있으므로 ,사용자 경험을 향상시키고 보안성을 높일 수 있다.
- 또한, 웹사이트나 앱에서 별도로 사용자 정보를 관리할 필요가 없으므로 개발자 입장에서 편리하다는 장점을 가진다.
소셜 로그인의 장단점
- 장점
- 사용자 친화적
- 소셜 로그인은 사용자가 웹사이트나 앱에 새로운 계정을 만들 필요가 없으므로 사용자에게 편의성을 제공한다.
- 로그인 정보 수집 용이
- 웹사이트나 앱에서 사용자 정보를 수집하는 데 도움이 된다.
- 사용자가 로그인하면 소셜 미디어 회사는웹사이트나 앱에서 사용자 정보를 수집하도록 허용하는 권한을 부여한다.
- 보안
- 사용자의 로그인 정보를 암호화하여 보호한다.
- 일부 소셜 미디어 회사는 사용자가 로그인할 때 두 단계 인증을 사용하도록 권장한다.
- 사용자 유지
- 소셜 로그인은 사용자의 계정을 유지하는 데 도움이 된다.
- 사용자가 웹사이트나 앱에서 로그인할 때마다 매번 새로운 계정을 만들지 않아도 된다.
- 사용자 친화적
- 단점
- 개인정보 보호
- 소셜 로그인을 사용하면 개인정보가 다른 회사에 공유될 가능성이 있다.
- 사용자가 로그인할 때 사용자 정보가 수집되어 다른 회사에서 사용될 수 있다.
- 종속성
- 소셜 로그인을 사용하면 해당 소셜 미디어 회사가 서비스를 중단하거나사용자 계정을 삭제하는 경우 웹사이트나 앱에 로그인할 수 없게 된다.
- 사용자 경험
- 일부 사용자는 소셜 로그인을 사용하지 않고 자체 계정을 만들기를 원할 수 있다.
- 또한, 일부 사용자는 다른 사람의 로그인 정보를 사용해 소셜 로그인을 사용하려 할 수 있다.
- 속도
- 소셜 로그인은 추가적인 인증 단계가 있기 때문에 로그인 프로세스가 더 느릴 수 있다.
- 개인정보 보호
- 결론적으로, 소셜 로그인은 사용자에게 많은 편의성을 제공하지만, 개인정보 보호와 종속성 등의 단점도 있따.
소셜 로그인 과정
- 소셜 로그인 과정에서 가장 중요한 것은 두가지이다.
- 인증처리 완료 - 인증 코드드
- 권한 부여 - 액세스 토큰
- 일반적으로 소셜 서버로부터 인증 토큰과 액세스 토큰을 발급받아 해당 소셜 서비스에 사용자 정보를 요청한다.
- 본 프로젝트에서 소셜 로그인은 다음과 같이 진행된다.
- 클라이언트(React)에서 소셜 로그인 버튼을 클릭하면, 해당 소셜 서비스의 로그인 페이지로 이동하게 된다.
- 사용자가 해당 소셜 서비스에 로그인하면, 해당 서비스는 사용자 정보를 포함한 인증 코드(authorization code)를 클라이언트에게 반환한다.
- 클라이언트(React)는 반환된 인증 코드를 가지고, 스프링 부트 서버에 인증 코드를 보내 인증 코드를 교환한다.
- 스프링부트 서버는 교환한 인증 코드를 사용해 해당 소셜 서비스에서 사용자 정보를 요청하고, 소셜 서비스로부터 받은 사용자 정보를 가지고 ㅡ프링 시큐리티에서 제공하는 Principal 객체를 생성한다.
- 그 후, 스프링 시큐리티는 Principal 객체를 이용해 로그인을 처리하고 ,클라이언트에게 access token을 반환한다.
- 클라이언트는 access token을 이용해 다른 API 요청을 할 수 있다.
- 따라서, 소셜 로그인 과정은 다음과 같다.
- 클라이언트(React)에서 소셜 로그인 버튼을 클릭한다.
- 해당소셜 서비스의 로그인 페이지로 이동 후 로그인한다.
- 인증 코드를 반환받는다.
- 클라이언트(React)가 스프링 부트 서버에 인증 코드를 보내 교환한다.
- 스프링 부트 서버가 해당 소셜 서비스에 사용자 정보를 요청하고, 소셜 서비스로부터 받은 사용자 정보를 가지고 Principal 객체를 생성한다.
- 스프링 시큐리티는 Principal 객체를 이용해 로그인 처리 후, 클라이언트에게 access token을 반환한다.
- 클라이언트는 access token을 이요해 다른 API 요청이 가능해진다.
- 참고: https://sudo-minz.tistory.com/77
구글 소셜 로그인 설정
https://www.youtube.com/watch?v=9ui2i-SgBpk&list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah&index=7
카카오 소셜 로그인 설정
https://yenjjun187.tistory.com/793
스프링 구현 코드
패키지 구조
applicatio.yml
spring:
profiles:
active:
- dev
include: BUNDLE-KEY
security:
oauth2:
client:
registration:
google:
client-id: ${google.client-id}
client-secret: ${google.client-secret}
scope:
- email
- profile
kakao:
client-id: ${kakao.client-id}
redirect-uri: ${kakao.redirect-url}
authorization-grant-type: authorization_code
client-authentication-method: POST
client-name: Kakao
scope:
- profile_nickname
- account_email
provider:
kakao:
authorization-uri: https://kauth.kakao.com/oauth/authorize
token-uri: https://kauth.kakao.com/oauth/token
user-info-uri: https://kapi.kakao.com/v2/user/me
user-name-attribute: id
- Spring Security에서 OAuth2를 구현할 대, 각 소셜 서비스마다 provider의 설정 정보가 달라 provider를 별도로 작성해줘야 한다.
- 예를 들어, Kakao와 Google은 OAuth2 프로토콜을 사용하지만, 각각의 소셜 서비스는 사용자 정보를 요청할 때 필요한 인증 정보 (ex. API Key, Secret Key 등)가 다르다.
- 또한, 각 소셜 서비스에서 사용자 정보를 반환하는 API의 URL이 다를 수 있다.
- 따라서, 각각의 소셜 서비스에 대한 provider를 작성하여 해당 서비스의 설정 정보를 입력해줘야 Spring Security에서 OAuth2를 구현할 수 있다.
- 이러한 설정을 통해 소셜 서비스의 API를 호출해 사용자 정보를 가져올 수 있다.
[참고]
스프링 시큐리티에서 'oauth2-client' 라이브러리를 사용할 대, 서비스 제공 업체 (구글, 카카오 등)에 대한 설정을 하기 위해서는 해당 업체의 'clientId', 'clientSecret', 'authorizationUri', 'tokenUri', 'userInfoUri' 등의 정보가 필요하다.
스프링 시큐리티에서는 이러한 정보를 각각의 provider에 대한 설정으로 관리한다.
예를 들어 google은 기본적으로 스프링 시큐리티에서 제공하는 'GoogleOAuth2ClientConfiguration' 클래스에서 미리 설정되어 있으므로, 따로 설정하지 않아도 된다.
그러나 kakao는 기본 제공되는 프로바이더가 아니기 때문에, 해당 정보를 별도로 설정해주어야 한다.
따라서 yml 파일에서 kakao에 대한 provider를 설정해주는 것이다.
SecurityConfig
@Slf4j
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomOAuth2UserService oAuth2UserService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final OAuth2FailureHandler oAuth2FailureHandler;
private final JwtProcess jwtProcess;
private final RefreshTokenRedisRepository refreshTokenRedisRepository;
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
public class CustomSecurityFilterManager extends AbstractHttpConfigurer<CustomSecurityFilterManager, HttpSecurity> {
@Override
public void configure(HttpSecurity http) throws Exception {
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
http.addFilter(new JwtAuthenticationFilter(authenticationManager, jwtProcess, refreshTokenRedisRepository));
http.addFilter(new JwtAuthorizationFilter(authenticationManager, jwtProcess));
}
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.headers().frameOptions().disable(); // iframe 허용안함
http.csrf().disable(); // csrf 허용안함
http.cors().configurationSource(configurationSource());
// 인증 실패 처리
http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
CustomResponseUtil.fail(response, "로그인이 필요합니다", HttpStatus.UNAUTHORIZED);
});
// 권한 실패
http.exceptionHandling().accessDeniedHandler((request, response, e) -> {
CustomResponseUtil.fail(response, "권한이 없습니다", HttpStatus.FORBIDDEN);
});
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.formLogin().disable();
http.httpBasic().disable();
// 필터 적용
http.apply(new CustomSecurityFilterManager());
http.authorizeHttpRequests()
.antMatchers("/api/auth/**").authenticated()
.antMatchers("/api/admin/**").hasRole("" + AccountEnum.ADMIN)
.anyRequest().permitAll();
http
.oauth2Login().loginPage("/token/expired")
.successHandler(oAuth2SuccessHandler)
.failureHandler(oAuth2FailureHandler)
.userInfoEndpoint().userService(oAuth2UserService);
return http.build();
}
public CorsConfigurationSource configurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*"); // GET, POST, PUT, DELETE (Javascript 요청 허용)
configuration.addAllowedOriginPattern("*"); // 모든 IP 주소 허용 (프론트 엔드 ip만 허용하도록 할 수도 있다.)
configuration.setAllowCredentials(true); // 클라이언트에서 쿠키 요청 허용
configuration.addExposedHeader(JwtVO.ACCESS_TOKEN_HEADER);
configuration.addExposedHeader(JwtVO.REFRESH_TOKEN_HEADER);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); // 모든 주소요청에 위 설정을 적용
return source;
}
}
- 스프링 시큐리티 설정 파일이다.
- OAuth2를 이용한 소셜 로그인 기능, JWT 토큰 인증 방식 등을 설정한다.
- JWT 토큰으로 인증을 처리하고, OAuth2를 이용해 사용자 정보를 가져온다.
- oauth2Login() 메서드를 사용해 OAuth2 로그인 설정을 수행한다.
- successHandler(), failureHandler()는 로그인 성공, 실패 시 수행할 동작을 지정한다.
- userInfoEndpoint().userService()는 OAuth2에서 사용자 정보를 가져오는 서비스를 지정한다.
PrincipalDetails
public class PrincipalDetails implements OAuth2User, UserDetails {
private Account account;
private Map<String, Object> attributes;
public PrincipalDetails(Account account) {
this.account = account;
}
public PrincipalDetails(Account account, Map<String, Object> attributes) {
this.account = account;
this.attributes = attributes;
}
public Account getAccount() {
return account;
}
@Override
public Map<String, Object> getAttributes() {
return attributes;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(() -> "ROLE_" + account.getRole());
return authorities;
}
@Override
public String getPassword() {
return account.getPassword();
}
@Override
public String getUsername() {
return account.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public String getName() {
return account.getId() + "";
}
}
- PrincipalDetails 클래스는 OAuth2User와 UserDetails 인터페이스를 구한다.
- UserDetails 클래스의 경우 소셜 로그인이 아닌 UserDetailsService를 인터페이스를 구현할 때 필요한 것으로 이 글에서는 다루지 않는다.
- OAuth2User는 Spring Security의 OAuth2 클라이언트 인증 과정을 통해 인증된 사용자 정보를 나타내는 인터페이스이다.
- OAuth2 인증 서버로부터 전달받은 사용자 정보를 캡슐화하여 제공하며, 사용자 이름, 이메일, 프로필 사진 등의 정보를 제공한다.
- OAuth2User 인터페이스를 사용하면, OAuth2 클라이언트를 통해 로그인한 사용자 정보를 손쉽게 가져올 수 있다.
- 예를 들어, 구글(Google) OAuth2 인증 서버로부터 인증된 사용자 정보를 다음과 같이 가져올 수 있다.
- 위 코드에서 @AuthenticationPrincipal 애노테이션은 현재 인증된 사용자 정보를 가져오는 데 사용된다.
- OAuth2User 인터페이스를 구현하는 객체가 파라미터로 전달되면, 해당 객체를 사용해 사용자 정보를 가져올 수 있다.
- OAuth2User 인터페이스를 사용해 소셜 로그인 기능을 구현할 경우, 각 OAuth2 인증 서버마다 제공하는 사용자 정보의 형식이 다를 수 있다.
- 이에 따라, OAuth2User 인터페이스를 구현한 구현체도 서로 다를 수 있다.
- 이를 고려해, 각 OAuth2 인증 서버별로 사용자 정보를 가져오는 구현체를 제공하는 OAuth2UserService 인터페이스가 제공된다.
- 이를 사용해, 각 OAuth2 인증 서버마다 사용자 정보를 가져오는 로직을 구현할 수 있다.
- OAuth2User 인터페이스는 OAuth2 인증 프로토콜을 통해 인증된 사용자의 정보를 나타내며, 이 인터페이스는 다음과 같은 메서드를 제공한다.
- getName() : 사용자 이름을 반환한다.
- getAuthoriteis() : 사용자의 권환을 반환한다.
- getAttributes() : 사용자 정보를 담은 Map 객체를 반환한다.
OAuth2UserService
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final AccountRepository accountRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
return processOAuth2User(userRequest, oAuth2User);
}
private OAuth2User processOAuth2User(OAuth2UserRequest userRequest, OAuth2User oAuth2User) {
Map<String, Object> attributes = oAuth2User.getAttributes();
// Attribute를 파싱해서 공통 객체로 묶는다. 관리가 편함.
OAuth2UserInfo oAuth2UserInfo = null;
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String username = "";
if (registrationId.equals("google")) {
log.debug("구글 로그인 요청!");
String providerId = oAuth2User.getAttribute("sub");
username = registrationId + "_" + providerId;
oAuth2UserInfo = new GoogleUserInfo(attributes);
} else if (registrationId.equals("kakao")) {
log.debug("카카오 로그인 요청!");
long providerId = (long) attributes.get("id");
username = registrationId + "_" + providerId;
oAuth2UserInfo = new KakaoUserInfo(attributes);
} else {
log.error("지원하지 않는 소셜 로그인");
throw new OAuth2AuthenticationException("지원하지 않는 소셜 로그인입니다");
}
Optional<Account> accountOptional = accountRepository.findByUsername(username);
Account account;
String oauthEmail = oAuth2UserInfo.getEmail();
if (accountOptional.isEmpty()) {
account = Account.builder()
.username(username)
.email(oauthEmail)
.role(AccountEnum.CUSTOMER)
.build();
accountRepository.save(account);
} else {
account = accountOptional.get();
}
return new PrincipalDetails(account, attributes);
}
}
- CustomOAuth2UserService 클스는 DefaultOAuth2UserService 클래스를 상속받아 OAuth2User 인터페이스를 구현한다.
- DefaultOAuth2UserService는 Spring Security OAuth2 클라이언트에 의해 발행된 인증 코드(authentication code)를 교환하여 사용자의 OAuth2User 객체를 가져오는 기능을 제공하는 Spring Security의 구현체이다.
- DefaultOAuth2UserService는 OAuth2UserService 인터페이스를 구현하고 있으며, loadUser() 메서드를 구현한다.
- OAuth2User 인터페이스는 OAuth2 인증을 통해 인증된 사용자 정보를 제공한다.
- loadUser 메서드는 OAuth2UserRequest 객체를 인자로 받아 OAuth2User 객체(PrincipalDetails)를 반환한다.
- 이 메서드는 OAuth2 인증 요청이 들어올 때 실행되며, OAuth2Provider의 인증 서버로부터 OAuth2 사용자 정보를 가져오기 위해 사용된다.
- processOAuth2User 메서드는 OAuth2UserRequest와 OAuth2User객체를 인자로 받아, OAuth2UserInfo 인터페이스를 구현한 구현체인 GoogleUserInfo 또는 KakaoUserInfo 클래스 객체를 생성하고, 해당 클래스에서 제공하는 정보를 토대로 회원가입 또는 로그인을 수행한다.
- 회원가입이 수행되는 경우, 사용자 정보를 저장한 후, PrincipalDetails 객체를 생성해 반환한다.
- 위 코드는 OAuth2Provider로부터 받아온 사용자 정보를 파싱해 회원가입 또는 로그인 처리를 하고, 인증된 사용자 정보를 기반으로 PrincipalDetails 객체를 생성해 반환한다.
OAuth2UserInfo
OAuth2UserInfo
public interface OAuth2UserInfo {
String getProviderId();
String getProvider();
String getEmail();
String getName();
}
GoogleUserInfo
public class GoogleUserInfo implements OAuth2UserInfo{
private Map<String, Object> attributes;
public GoogleUserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
@Override
public String getProviderId() {
return (String) attributes.get("sub");
}
@Override
public String getName() {
return (String) attributes.get("name");
}
@Override
public String getEmail() {
return (String) attributes.get("email");
}
@Override
public String getProvider() {
return "google";
}
}
KakaoUserInfo
public class KakaoUserInfo implements OAuth2UserInfo {
private Map<String, Object> attributes;
public KakaoUserInfo(Map<String, Object> attributes) {
this.attributes = attributes;
}
@Override
public String getProviderId() {
return (String) attributes.get("id");
}
@Override
public String getName() {
Map<String, Object> properties = (Map<String, Object>) attributes.get("properties");
return (String) properties.get("nickname");
}
@Override
public String getEmail() {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
return (String) kakaoAccount.get("email");
}
@Override
public String getProvider() {
return "kakao";
}
}
OAuth2SuccessHandler
@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtProcess jwtProcess;
private final RefreshTokenRedisRepository refreshTokenRedisRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
PrincipalDetails principal = (PrincipalDetails) authentication.getPrincipal();
Account account = principal.getAccount();
String additionalInputUri = "";
// JwtToken(AccessToken) 생성
String accessToken = jwtProcess.createAccessToken(principal);
// Redis에 RefreshToken 저장
String refreshToken = saveRefreshToken(account);
// response.addHeader("authorization", jwtToken);
// response.addHeader("accountId", account.getId().toString());
if (account.getNickname() == null) {
additionalInputUri = "home/signup";
}
String redirectUrl = makeRedirectUrl(additionalInputUri, principal, accessToken, refreshToken);
log.debug("accessToken={}", accessToken);
log.debug("redirectUrl={}", redirectUrl);
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
}
private String saveRefreshToken(Account account) {
String accountId = account.getId().toString();
String role = account.getRole().toString();
String refreshToken =
jwtProcess.createRefreshToken(accountId, role);
log.debug("생성된 refreshToken={}", refreshToken);
refreshTokenRedisRepository.save(RefreshToken.builder()
.id(accountId)
.role(role)
.refreshToken(refreshToken)
.build());
return refreshToken;
}
private String makeRedirectUrl(String uri, PrincipalDetails principal, String jwtToken, String refreshToken) {
Account account = principal.getAccount();
return UriComponentsBuilder.fromUriString("http://localhost:3000/" + uri)
.queryParam("accountId", account.getId())
.queryParam("accessToken", jwtToken)
.queryParam("refreshToken", refreshToken)
.build().toUriString();
}
}
- 이 코드는 Spring Security OAuth2 인증에 성공했을 때 실행되는 핸들러이다.
- 주요 기능
- JwtProcess : JWT 토큰을 생성하는 클래스이다.
- RefreshTokenRedisRepository: Redis에 Refresh Token을 저장하고 조회하는 리포지토리이다.
- onAuthenticationSuccess(): OAuth2 인증에 성공했을 때 실행된다.
- AccessToken과 RefreshToken을 생성하고, Redis에 RefreshToken을 저장한다.
- 이후 클라이언트 애프릴케이션에게 Redirect URL을 반환한다.
- saveRefreshToken(): Redis에 RefreshToken을 저장한다.
- makeReduriectUrl(): 클라이언트 애플리케이션에게 전달할 Redirect URL을 생성한다.
- OAuth2 인증을 처리하는 Spring Secruity 프로젝트에서 OAuth2SuccessHandler를 Bean으로 등록해 사용한다.
- OAuth2 인증에 성공했을 때, AccessToken과 RefreshToken을 생성하고 Redis에 저장하며, 클라이언트 애플리케이션에게 Redirect URL을 반환한다.
문제점!!
OAuth2SuccessHandler 코드에는 문제가 존재한다.
소셜 로그인이 완료되면 사용자는 다음 주소에 리다이렉션된다.
http://localhost:3000/?accountId=2&accessToken=Bearer%20eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiLssL3snZjshKTqs4TtlITroZzsoJ3tirjsnbzri7nrsLHrqrDrpqwiLCJyb2xlIjoiQ1VTVE9NRVIiLCJpZCI6MiwiZXhwIjoxNjgxMjg0ODQ4fQ.4YIo7u1Z-RmTu4rQoiNzx54f0XU8RxPQ6BwoqBySBwyYSr9AZSYv9dnZVgl2EUniddVXCQIWYHdMAa_PAv6aPA&refreshToken=Bearer%20eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiLssL3snZjshKTqs4TtlITroZzsoJ3tirjsnbzri7nrsLHrqrDrpqwiLCJyb2xlIjoiQ1VTVE9NRVIiLCJpZCI6IjIiLCJleHAiOjE2ODI0OTI2NDh9.oUI-NVzYQ0-dTlJi45-nUGzynUWtBU7TU1cymhLL68Am-tjCIQeEBwchcnb_FOEJONSUMhJjN_o5Unnqufa4TA
파라미터로 액세스 토큰과 리프래시 토큰이 노출된다.
이것은 다음과 같은 이유로 좋지 않다.
1. 보안 위협
- 파라미터로 토큰을 노출하면 다른 사람이 해당 토큰을 가로채 악용할 수 있다.
- 이는 악성 공격자가 사용자 계정을 해킹하거나 데이터를 수정 또는 삭제할 수 잇는 문제를 발생시킬 수 있다.
2. 토큰 만료
- 보안상의 이유로 토큰은 일정 기감나다 갱신되어야 한다.
- 파라미터로 토큰을 전송하면 해당 토큰이 노출될 가능성이 높기 때문에, 만료되기 전에 새로운 토큰으로 교체해야 할 수도 있다.
3. 로깅
- 파라미터에 포함된 토큰은 서버 로그에 기록될 간으성이 높다.
- 이는 민감한 정보를 저장하는 데 사용되는 경우, 중요한 정보가 노출될 수 있기 때문에 보안 문제를 야기할 수 있다.
4. 보안 강화 요구 사항
- 일부 규제 기관에서는 보안을 강화하기 위해 파라미터에 토큰을 포함시키는 것을 금지하고 있다.
문제점 해결!!
Redirection 시에는 헤더에 토큰을 넘겨줄 수 없어 쿠키에 토큰값을 담아 문제를 해결한다.
로그인을 수행하면 다음과 같이 쿠키에 토큰값이 담기는 것을 확인할 수 있다.
클라이언트에서는 해당 쿠키값을 조회해 사용하면 된다.
JwtProcess
@Component
public class JwtProcess {
@Value("${jwt.subject}")
private String jwtSubject;
@Value("${jwt.secret}")
private String secret;
public String createAccessToken(PrincipalDetails principalDetails) {
Account account = principalDetails.getAccount();
return createNewAccessToken(account.getId(), account.getRole().toString());
}
public String createNewAccessToken(Long accountId, String role) {
String jwtToken = JWT.create()
.withSubject(jwtSubject)
.withExpiresAt(new Date(System.currentTimeMillis() + JwtVO.ACCESS_TOKEN_EXPIRATION_TIME))
.withClaim("id", accountId)
.withClaim("role", role)
.sign(Algorithm.HMAC512(secret));
return JwtVO.TOKEN_PREFIX + jwtToken;
}
public String createRefreshToken(String accountId, String role) {
String refreshToken = JWT.create()
.withSubject(jwtSubject)
.withExpiresAt(new Date(System.currentTimeMillis() + JwtVO.REFRESH_TOKEN_EXPIRATION_TIME))
.withClaim("id", accountId)
.withClaim("role", role)
.sign(Algorithm.HMAC512(secret));
return JwtVO.TOKEN_PREFIX + refreshToken;
}
// 토큰 검증 (return 되는 LoginUser 객체를 강제로 시큐리티 세션에 직접 주입할 예정) - 강제 로그인
public PrincipalDetails verify(String token) {
DecodedJWT decodedJWT = isSatisfiedToken(token);
Long id = decodedJWT.getClaim("id").asLong();
String role = decodedJWT.getClaim("role").asString();
Account account = Account.builder().id(id).role(AccountEnum.valueOf(role)).build();
return new PrincipalDetails(account);
}
public DecodedJWT isSatisfiedToken(String token) {
return JWT.require(Algorithm.HMAC512(secret)).build().verify(token);
}
}
JwtVO
public interface JwtVO {
public static final int ACCESS_TOKEN_EXPIRATION_TIME = 1000 * 60 * 30; // 30분
public static final int REFRESH_TOKEN_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 14; // 2주
public static final String TOKEN_PREFIX = "Bearer ";
public static final String ACCESS_TOKEN_HEADER = "Authorization";
public static final String REFRESH_TOKEN_HEADER = "Refresh-Token";
}
JwtAuthorizationFilter
public class JwtAuthorizationFilter extends BasicAuthenticationFilter {
private JwtProcess jwtProcess;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, JwtProcess jwtProcess) {
super(authenticationManager);
this.jwtProcess = jwtProcess;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
// 토큰이 존재하는지 검사한다.
if (isHeaderVerify(request)) {
String token = request.getHeader(JwtVO.ACCESS_TOKEN_HEADER).replace(JwtVO.TOKEN_PREFIX, "");
try {
PrincipalDetails loginAccount = jwtProcess.verify(token);
// 임시 세션
Authentication authentication =
new UsernamePasswordAuthenticationToken(
loginAccount,
null,
loginAccount.getAuthorities());
// 강제 로그인이 진행된다.
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
fail(response);
return;
}
}
chain.doFilter(request, response);
}
private static void fail(HttpServletResponse response) throws IOException {
ObjectMapper om = new ObjectMapper();
ResponseDto<String> responseDto = new ResponseDto<>(-1, "만료된 토큰입니다", null);
String responseBody = om.writeValueAsString(responseDto);
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpStatus.BAD_REQUEST.value());
response.getWriter().write(responseBody);
response.getWriter().flush();
response.getWriter().close();
}
private boolean isHeaderVerify(HttpServletRequest request) {
String header = request.getHeader(JwtVO.ACCESS_TOKEN_HEADER);
if (header == null || !header.startsWith(JwtVO.TOKEN_PREFIX)) {
return false;
} else {
return true;
}
}
}
리액트 구현 코드
import React from 'react';
import styles from '../css/Login.module.css';
import { useNavigate } from 'react-router-dom';
const Login = () => {
const KAKAO_AUTH_URL = `http://localhost:8080/oauth2/authorization/kakao`;
const GOOGLE_AUTH_URL = `http://localhost:8080/oauth2/authorization/google`;
const kakaoLogin = () => {
window.location.href = KAKAO_AUTH_URL;
}
const googleLogin = () => {
window.location.href = GOOGLE_AUTH_URL;
}
const navigate = useNavigate();
return (
<div className={styles.container}>
<div className={styles.login}>
<div onClick={() => navigate('/')}>
<img src={process.env.PUBLIC_URL + '/molly-logo-title.png'} alt="molly-logo" width="120px"/>
</div>
<p>반려동물 예방접종 일정관리 사이트</p>
<p>간편 로그인 👋</p>
<div onClick={kakaoLogin}>
<img src='img/kakao_login_medium_wide.png' alt="kakao"/>
</div>
<div style={{marginTop: "10px"}} onClick={googleLogin}>
<img src='img/google_login.png' alt="kakao" width="81%"/>
</div>
</div>
</div>
);
};
export default Login;
- 원하는 소셜 로고 이미지를 클릭하면 상단에 정의한 url로 요청을 보낸다.
- 카카오: http://localhost:8080/oauth2/authorization/kakao
- 구글: http://localhost:8080/oauth2/authorization/google
- 여기서 중요한 건 oauth2-client 라이브러리를사용하는 경우 경로가 'http://localhost:8080/oauth2/authorization/소셜'로 정해져 있다는 것이다.
- 이걸 모르고 kakao developers에 설정한 redirect url을 사용하다 동작하지 않은 경험이 있다.
참고
- 인증 흐름 이해
'기술 블로그 > MOLLY' 카테고리의 다른 글
@DataJpaTest (1) | 2023.05.15 |
---|---|
@PatchMapping validation 에러 (0) | 2023.04.30 |
사용자 요청 시 권한 처리에 관하여 (0) | 2023.04.09 |