OAuth2.0 클라이언트와 스프링 시큐리티 6프레임워크 활용.
외부 사이트(구글, 네이버)로부터 인증을 받고 전달받은 유저 데이터를 활용.
JWT를 발급하고 인가를 진행.
인증 받은 데이터는 MySQL 데이터베이스를 활용하여 저장하고 관리.
인증: 네이버/구글 소셜 로그인 후 JWT 발급.
인가: JWT를 통한 경로별 접근 권한.
인증 정보 DB 저장 후 추가 정보 가입.
Spring boot 3.3.1
Spring Security 6.x
OAuth2 Client
Spring Data JPA - MySQL
JJWT 0.12.5
단일 토큰으로 진행 -> 심화에서 다중 토큰했음.
소셜 로그인을 통해 인증 받은 데이터는 우리의 서비스 데이터베이스에 저장을 한 뒤 관리를 진행해야 합니다. 관리를 하지 않고 인증만 받고 사용할 수도 있지만 추가적인 사용자 정보나 어떠한 사용자가 우리의 서비스를 활용하는지 확인하기 위해서는 무조건 관리하는 것을 추천.
OAuth2 클라이언트 JWT방식을 구현하기 위한 가장 기본적인 뼈대 코드이므로 커스텀이 필요하기는 합니다. ex) 다중 토큰
1️⃣ 로그인 성공 후 코드 발급 (redirect_url)
2️⃣ 코드를 통해 Access 토큰 요청
3️⃣ Access 토큰 발급 완료
4️⃣ Access 토큰을 통해 유저 정보 요청
5️⃣ 유저 정보 흭득 완료

빨 -> 파 -> 분홍 순입니다.
로그인 성공하면 우리 서버 특정 경로로 리다이렉트되면서 인증 서버에서는 코드를 보내줍니다. 코드를 통해서 토큰 요청하고 토큰으로 유저 정보 흭득하고 유저 정보 받아서 세션을 생성합니다.
그럼 로그인이 완료되고 세션도 완성해서 해당 사용자를 기억하게 됩니다.
로그인(인증)이 성공하면 JWT 발급 문제와 웹/하이브리드/네이티브앱별 특징에 의해 OAuth2 Code Grant 방식 동작의 책임을 프론트엔드 측에 둘 것인지 백엔드 측에 둘 것인지 많은 고민을 하게 됩니다.

소셜 로그인 버튼을 클릭하면 로그인 로직을 외부 소셜 로그인에 요청을 합니다.
인증 서버에서 로그인 페이지를 띄워주고 로그인이 완료되면 코드를 발급시켜줍니다.
코드를 통해서 인증 서버에 토큰을 요청하고 그 토큰으로 유저 정보를 발급받아 최종적으로 API Client를 통해서 유저 정보를 전송합니다.
백엔드에서 유저 정보를 확인하고 JWT를 발급하여 프론트가 JWT를 흭득합니다.
프론트 측에서 보낸 유저 정보의 진위 여부를 따져야 합니다.
그래서 추가적인 보안 로직이나 터널링 작업을 해야 한다는 점이 까다롭습니다. -> 유저 정보만을 전송하면 다른 사람의 유저 정보도 쉽게 생성되기 때문에 유저 정보의 진위 여부 판단이 꼭 필요합니다.
프론트단에서 (로그인 → 코드 발급 → Access 토큰 → 유저 정보 획득) 과정을 모두 수행
백엔드 단에서 (오저 정보 -> JWT 발급)
- 주로 네이티브앱에서 사용하는 방식.

소셜 로그인 버튼 클릭하면 하이퍼링크로 백엔드 API 메서드는 GET으로 요청을 보냅니다.
로그인 요청 로직으로 인해 백엔드가 인증 서버의 로그인 페이지에서 로그인 성공하게 되면 코드를 발급받습니다.
코드로 토큰 흭득, 유저 정보 요청/발급/확인 해서 JWT를 발급해줍니다.
하이퍼링크로 요청했기 때문에 브라우저에서 JWT를 흭득하기 까다롭습니다. (하지만 방법이 있습니다.)

프론트에서 로그인 요청을 하고 외부 소셜 로그인에서 로그인 후 코드를 발급받습니다.
이 코드를 API Client를 통해서 백엔드로 코드를 날려줍니다. 그럼 백단은 발급받은 코드로 인증 서버에서 토큰을 발급받고 토큰으로 유저 정보를 요청/발급 받고 유저 정보 확인 후 JWT를 프론트로 발급해줍니다.
잘못된 방식2

잘못된 이유
카카오와 같은 대형 서비스 개발 포럼 및 보안 규격에서는 이와 같은 코드/Access 토큰을 전송하는 방법을 지양한다고 합니다.
액세스 토큰은 1인 사용자인 모바일 기기에서는 SDK에 일임하여 앱에서 관리하고, 여러 사용자가 접근할 수 있는 웹에서는 서버에서 관리하도록 하고 있습니다. (액세스 토큰 지양.)
Access Token을 넘겨주는 게 위험해서 인가 코드를 넘겨주는 게 덜 위험하여 ios SDK에서는 인가 코드를 받을 수 있을 수 있다고 생각할 수 있지만 SDK는 인가 코드 요청부터 액세스 토큰 발급까지 SDK 내에서 한번에 이루어지기 때문에 OAuth2의 스펙인 PKCE 보안으로 인가 코드를 다른 데서 사용이 불가능.
결론
네이티브 앱 개발 시, 카카오와 교신은 앱에서 사용하는 카카오 SDK에 일임, 서버에는 카카오로부터 받은 결과만 저장.

소셜 로그인 요청이 백으로 와(로그인 시도) GET 요청을 보내면 Spring 서버 내부에 OAuth2AuthorizationRequestRedirectFilter가 해당 요청을 잡아서 외부 소셜 로그인 인증 서버에 요청을 보냅니다.
인증 서버는 해당 요청에 대해서 소셜 로그인(ex-네이버/카카오 로그인) 페이지를 클라이언트에게 줍니다.
로그인 성공하게 되면 리다이엑트로 성공과 함께 특정한 코드가 날라오는데 이 코드는 Access Token을 발급받기 위한 코드입니다.
이 코드는 "/login/oauth2/code/서비스명"으로 받게 되고 이 주소는 OAuth2LoginAuthenticationFilter에 의해 가로채지고 OAuth2LoginAuthenticationProvider를 통해 코드를 꺼내게 됩니다.
코드를 가지고 인증 서버를 통해 Access Token을 발급받습니다. 이후 Access Token으로 리소스 서버에 접근해 유저 정보를 흭득합니다.
OAuth2LoginAuthenticationProvider에서 호출하는 OAuth2Service에서 유저 정보를 흭득하고 OAuth2User에 담아서 로그인을 진행하게 됩니다.
로그인 성공하면 성공 핸들러를 진행하고 이때 JWT를 발급해서 유저에게 전송합니다. 전송 시 백엔드에서 모든 과정을 처리하게 되면 토큰을 발급해주고 나서 토큰을 받는 리액트를 작성하기가 쉽지 않습니다. (이 부분은 아래에 추가하겠습니다.)
받은 JWT를 클라이언트는 백엔드 API에 접근할 때 모든 요청에 대해서 JWT를 들고오면 됩니다. 그러면 JWTFilter가 검증해서 내부에 임시 세션을 만들어 앞으로 어떤 내부의 컨트롤러 자원을 가지고 갈 때 세션을 참고해 가지고 가게 합니다.
OAuth2LoginAuthenticationFilter와OAuth2LoginAuthenticationProvider는 의존성에 의해 자동적으로 만들어져 있습니다.
데이터를 받는OAuth2UserDetailsService,OAuth2UserDetails, 성공 핸들러를 구현하고 JWTFilter, JWTUtil(JWT를 발급 및 검증) 을 구현해야 합니다.
#registration
spring.security.oauth2.client.registration.naver.client-name=naver
spring.security.oauth2.client.registration.naver.client-id=발급아이디
spring.security.oauth2.client.registration.naver.client-secret=발급비밀번호
spring.security.oauth2.client.registration.naver.redirect-uri=http://localhost:8080/login/oauth2/code/naver
spring.security.oauth2.client.registration.naver.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.naver.scope=name,email
#provider
spring.security.oauth2.client.provider.naver.authorization-uri=https://nid.naver.com/oauth2.0/authorize
spring.security.oauth2.client.provider.naver.token-uri=https://nid.naver.com/oauth2.0/token
spring.security.oauth2.client.provider.naver.user-info-uri=https://openapi.naver.com/v1/nid/me
spring.security.oauth2.client.provider.naver.user-name-attribute=response
registration
provider (외부 google or naver 서비스에서 인증 서버 주소와 리소스 서버 주소)
registration, provider
registration은 외부 서비스에서 우리 서비스를 특정하기 위해 등록하는 정보여서 등록이 필수적입니다.
하지만 provider의 경우 서비스별로 정해진 값이 존재하며 OAuth2 클라이언트 의존성이 유명한 서비스의 경우 내부적으로 데이터를 가지고 있습니다. (구글, Okta, 페이스북, 깃허브, 등등)
이후 등록하기

이제 application.yml에 발급 받은 정보들을 삽입해줍니다.
spring:
security:
oauth2:
client:
registration:
naver:
client-name: naver
client-id: QtQxxE1b5yYf8JHAeMtX
client-secret: **********
redirect-uri: http://localhost:8080/login/oauth2/code/naver
authorization-grant-type: authorization_code
scope:
- name
- email
# scope 은 사용 API로 선택했던 것들입니다.
provider:
naver:
authorization-uri:
token-uri:
user-info-uri:
user-name-attribute: response
네이버 소셜 로그인 요청 경로입니다. -> GET 메서드로 "/oauth2/authorization/naver"
scope는 name, email, gender, birthday, mobile 이렇게 요청 가능
새로운 프로젝트 생성!!

구글 소셜 로그인 신청 -> "사용자 인증 정보" 검색
OAuth 동의 화면 -> 구글에서 보여주는 로그인 창에 대한 설정
만들기해서 저장. -> 앱 도메인은 localhost:8080으로 하고 내 구글 이메일로 진행
이후 범위를 추가 또는 삭제 버튼 클릭. -> 3개 선택

이제 사용자 인증 정보에서 OAuth2 사용을 위한 username, secretkey 등을 받오오기.
특정한 secretkey를 발급받을 수 있습니다.


만들기 하면 OAuth 클라이언트가 생성됩니다. 이걸 또 yml에 넣어주면 됩니다.
spring:
security:
oauth2:
client:
registration:
google:
client-name: google
client-id:
client-secret:
redirect-uri: http://localhost:8080/login/oauth2/code/google
authorization-grant-type: authorization_code
scope:
- profile
- email
리소스 서버에서 제공하는 유저 정보는 구현이 필요합니다.
@Slf4j
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
log.info("OAuth2Uer가 뭐야? : {}", oAuth2User);
System.out.println(oAuth2User);
// 사용자가 인증을 시도한 OAuth2 클라이언트의 고유 식별자(등록 ID)
// 일반적으로 Google, Naver, Facebook 등과 같은 특정 OAuth2 프로바이더의 이름으로 설정
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2Response oAuth2Response = null;
// 해당 DTO의 데이터를 받음.
if (registrationId.equals("naver")) {
oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
}
else if (registrationId.equals("google")) {
oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
}
else {
return null;
}
}
}
네이버면 네이버, 구글이면 구글인 응답 DTO로 userRequest 데이터를 받아야 합니다.
이유는 네이버와 구글의 경우 JSON 데이터가 다르기 때문입니다.
네이버는 response라는 키 내부에 우리가 원하는 데이터가 들어가 있지만 구글의 경우 외부에 담겨있기 때문에 각각 응답을 다르게 받아야 합니다.
네이버와 구글의 JSON Response 방식
{ resultcode=00, message=success, response={id=123123123, name=이름} }{ resultcode=00, message=success, id=123123123, name=이름 }
public class NaverResponse implements OAuth2Response {
private final Map<String, Object> attributes;
public NaverResponse(Map<String, Object> attributes) {
this.attributes = (Map<String, Object>) attributes.get("response"); // response 라는 키에 대해서 값을 넣어줌
}
@Override
public String getProvider() {
return "Naver";
}
@Override
public String getProviderId() {
return attributes.get("id").toString(); // id라는 키에 대한 값
}
@Override
public String getEmail() {
return attributes.get("email").toString();
}
@Override
public String getName() {
return attributes.get("name").toString();
}
}
다음은 구글입니다.
public class GoogleResponse implements OAuth2Response{
private final Map<String, Object> attribute;
public GoogleResponse(Map<String, Object> attribute) {
this.attribute = attribute;
}
@Override
public String getProvider() {
return "google";
}
@Override
public String getProviderId() {
return attribute.get("sub").toString();
}
@Override
public String getEmail() {
return attribute.get("email").toString();
}
@Override
public String getName() {
return attribute.get("name").toString();
}
}
CustomOAuth2UserService 를 SecurityConfig의 .oauth2Login에 등록해야 진행할 수 있습니다.
등록해두면 OAuth2 로그인 의존성이 데이터를 받았을 때 OAuth2Service에 데이터를 넣어줍니다.
http
.oauth2Login((oauth2) -> oauth2
.userInfoEndpoint((userInfoEndpointConfig -> userInfoEndpointConfig
.userService(customOAuth2UserService)))) // Spring Security에서 OAuth 2.0 기반의 인증을 설정하는 메소드입니다. 이 설정은 사용자가 OAuth 2.0 프로바이더(예: Google, Facebook 등)를 통해 로그인할 수 있도록 해줍니다.
usernam을 만들어야 합니다. -> 리소스 서버에서 제공해주는 값은 해당 유저들이 겹칠 수 있기 때문에 중복되지 않도록 우리 서버에서 관리할 수 있는 username을 만들어야 합니다. 이것을 DTO에 담아서 넘겨주면 로그인이 완성됩니다.
이 작업들을 CustomAuth2User DTO에 담아서 반환하면 됩니다.
//리소스 서버에서 발급 받은 정보로 사용자를 특정할 아이디값을 만듦.
String username = oAuth2Response.getProvider()+" "+oAuth2Response.getProviderId();
log.info("username: {}", username);
UserDto userDto = new UserDto();
userDto.setUsername(username); // 우리 서버에서 만들어 줄 username값
userDto.setName(oAuth2Response.getName()); // 사용자의 id를 담을 name값
userDto.setRole("ROLE_USER");
// 우리 서버에서 특정할 수 있는 username이 있고 이것을 DTO에 담아서 넘겨주면 로그인이 완성됩니다.
return new CustomOAuth2User(userDto); // 이것도 DTO
@RequiredArgsConstructor
public class CustomOAuth2User implements OAuth2User {
private final UserDto userDto;
// username 같은 값을 반환해주는데 구글과 네이버가 응답이 많이 다르므로 제외
@Override
public Map<String, Object> getAttributes() {
return null;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return userDto.getRole();
}
});
return collection;
}
@Override
public String getName() {
return userDto.getName();
}
public String getUsername() {
return userDto.getUsername();
}
}
인증 부분은 외부의 소셜 로그인 서비스를 사용하기 때문에 외부 서비스에 유저 정보가 저장되어 있고 로그인 성공하면 해당 정보를 우리 쪽으로 알려주지만 우리 쪽에서도 해당 정보를 관리 해야 합니다. 우리 서비스를 어떤 사용자가 사용하고 있는지 알아야 하고 사용자에 대한 추가적인 정보를 넣을 수 있도록 관리해야 하기 때문에 우리 서비스에서도 DB에 유저 정보를 저장해야 합니다. 이 부분은 CustomOAuth2UserService 에서 진행합니다.
로그인 성공해서 데이터가 날라오면 우리 DB에 해당 유저에 대해 이름을 가지는 동일한 데이터가 존재하는지 확인하고 없으면 신규 저장, 이미 존재한다면 업데이트해주는 로직을 추가해서 DB에서 관리하도록 해주면 됩니다.
@Entity
@Getter
@Setter
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private String username;
private String name;
private String email;
private String role;
}
@Slf4j
@Service
@RequiredArgsConstructor
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
log.info("OAuth2Uer가 뭐야? : {}", oAuth2User);
System.out.println(oAuth2User);
// 사용자가 인증을 시도한 OAuth2 클라이언트의 고유 식별자(등록 ID)
// 일반적으로 Google, Naver, Facebook 등과 같은 특정 OAuth2 프로바이더의 이름으로 설정
String registrationId = userRequest.getClientRegistration().getRegistrationId();
OAuth2Response oAuth2Response = null;
// 해당 DTO의 데이터를 받음.
if (registrationId.equals("naver")) {
oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
}
else if (registrationId.equals("google")) {
oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
}
else {
return null;
}
// 리소스 서버에서 발급 받은 정보로 사용자를 특정할 아이디값을 만듦.
String username = oAuth2Response.getProvider()+" "+oAuth2Response.getProviderId();
log.info("username: {}", username);
// usernmae을 가지고 DB에 해당 유저가 존재하는지 확인
Member existData = memberRepository.findByUsername(username);
if (existData == null) {
Member member = new Member();
member.setUsername(username);
member.setEmail(oAuth2Response.getEmail());
member.setName(oAuth2Response.getName());
member.setRole("ROLE_USER");
memberRepository.save(member);
UserDto userDto = new UserDto();
userDto.setUsername(username); // 우리 서버에서 만들어 줄 username값
userDto.setName(oAuth2Response.getName()); // 사용자의 id를 담을 name값
userDto.setRole("ROLE_USER");
// 우리 서버에서 특정할 수 있는 username이 있고 이것을 DTO에 담아서 넘겨주면 로그인이 완성됩니다.
return new CustomOAuth2User(userDto); // 이것도 DTO
}
else {
// 이미 유저가 존재하는 경우 -> 데이터 업데이트
existData.setEmail(oAuth2Response.getEmail());
existData.setName(oAuth2Response.getName());
memberRepository.save(existData);
UserDto userDTO = new UserDto();
userDTO.setUsername(existData.getUsername());
userDTO.setName(oAuth2Response.getName()); // 기존에 있던 것이 아닌 새로운 것에서 가져와야 함
userDTO.setRole(existData.getRole());
return new CustomOAuth2User(userDTO);
}
}
}
로그인 완료되면 LoginSuccessHandler에서 JWT를 발급해줘야 합니다. 이 JWT를 가지고 검증해서 데이터가 확실한지 체크해야 합니다. 그래서 발급, 검증을 담당할 클래스를 만들어야 합니다.
JWTUtil에는 username, role, 생성일, 만료일 데이터 정보가 존재하고 JWTUtil 생성자, username 확인 메서드, role 확인 메서드, 만료일 확인 메서드가 존재할 것입니다.
@Component
public class JWTUtil {
private final SecretKey secretKey; // 객체 키 생성
public JWTUtil(@Value("${spring.jwt.secret}") String secret) {
// 이 키는 특정하게 JWT에서 객체 타입으로 만들어서 저장.-> 키를 암호화 진행
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
/**
* 검증 진행할 메서드 3개
* 우리 서버에서 가져온 것이 맞는지 확인.
*/
public String getUsername(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
/**
* 토큰 생성 메서드
*/
public String createJwt(String username, String role, Long expiredMs) {
return Jwts.builder()
.claim("username", username) // claim으로 특정한 키에 대한 데이터를 넣어줌.
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis())) // 토큰이 언제 발생
.expiration(new Date(System.currentTimeMillis() + expiredMs)) // 만료 기간
.signWith(secretKey) // 토큰 시그니쳐를 만들어서 암호화 진행.
.compact(); // 토큰 생성.
}
}
여기서 성공 핸들러를 커스텀해서 내부에 JWT 발급 구현을 진행하면 됩니다. 이것은 OAuth2 클라이언트 로그인에 대해서 모든 책임을 백엔드가 가질 경우입니다. 하이퍼링크로 백엔드에 요청했기 때문에 하이퍼링크에 대해서 JWT를 발급받으려면 어떻게 해야 할지 생각이 필요합니다.

쿠키 방식으로 발급을 진행하면 됩니다. 네이버나 카카오 같은 곳에서도 쿠키 방식을 사용한다고 합니다.
@Component
@RequiredArgsConstructor
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JWTUtil jwtUtil;
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
// username과 role 값 받아오기
//OAuth2User
CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal();
String username = customUserDetails.getUsername();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
String token = jwtUtil.createJwt(username, role, 60*60*60L);
response.addCookie(createCookie("Authorization", token));
response.sendRedirect("http://localhost:3000/");
}
private Cookie createCookie(String key, String value) {
Cookie cookie = new Cookie(key, value);
cookie.setMaxAge(60*60*60);
// cookie.setSecure(true); // HTTPS 라면 이것이 필요
cookie.setPath("/"); // 쿠키가 보일 위치는 전역으로 함.
cookie.setHttpOnly(true); // JS가 해당 쿠키를 가져가지 못하게 방지
return cookie;
}
}
username과 role 값 받아야 하는 이유 : JWT를 만들 때 우리가 username, role값을 넣어서 JWT를 만들었기 때문입니다.
작성 후 SecurityConfig에서 등록해주면 됩니다.
.authorizeRequests((auth) -> auth
.requestMatchers("/login", "/", "/signup", "/reissue").permitAll() // 모든 권한 허용
.requestMatchers("/admin").hasRole("ADMIN") // ADMIN 권한을 가진 사람만 허용
.anyRequest().authenticated()) // 그 외의 다른 요청들은 로그인한 사용자만 접근할 수 있다.
@RequiredArgsConstructor
public class JWTFilter extends OncePerRequestFilter {
// 내부 쿠키에서 JWT를 꺼내서 JWT가 알맞는지 검증이 필요하기 때문에 필요
private final JWTUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// JWT를 request 해서 검증을 진행하는데 그러기 위해서는 JWTUtil을 통해서 필터를 검증할 메서드를 가져와야 한다.
// request에서 Authorization 헤더를 찾음
String authorization = request.getHeader("Authorization");
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals("Authorization")) {
authorization = cookie.getValue();
}
}
//Authorization 헤더 검증
if (authorization == null) {
System.out.println("token null");
filterChain.doFilter(request, response);
//조건이 해당되면 메소드 종료 (필수)
return;
}
//토큰
String token = authorization;
//토큰 소멸 시간 검증
if (jwtUtil.isExpired(token)) {
System.out.println("token expired");
filterChain.doFilter(request, response);
//조건이 해당되면 메소드 종료 (필수)
return;
}
//토큰에서 username과 role 획득
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
//userDTO를 생성하여 값 set
UserDto userDto = new UserDto();
userDto.setUsername(username);
userDto.setRole(role);
//UserDetails에 회원 정보 객체 담기
CustomOAuth2User customOAuth2User = new CustomOAuth2User(userDto);
//스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(customOAuth2User, null, customOAuth2User.getAuthorities());
//세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}
검증한 이후 SecurityConfig에 등록해야 합니다.
addFilterBefore 함수에 적용하여 특정한 필터 이전에 해당 필터를 등록하도록 합니다.
즉 UsernamePasswordAuthenticationFilter 이전에 등록한 것.
JWT가 존재하고 올바르다면 일시적인 요청에 대해서 일시적인 세션이 생성되어 인증이 완료됩니다.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Collections.singletonList("http://localhost:3000"));
configuration.setAllowedMethods(Collections.singletonList("*"));
configuration.setAllowCredentials(true);
configuration.setAllowedHeaders(Collections.singletonList("*"));
configuration.setMaxAge(3600L);
configuration.setExposedHeaders(Collections.singletonList("Set-Cookie"));
configuration.setExposedHeaders(Collections.singletonList("Authorization"));
return configuration;
}
}));
return http.build();
}
컨트롤러 단에서 보내준 데이터를 받을 수 있도록 하는 클래스
public class CorsMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry corsRegistry) {
corsRegistry.addMapping("/**") // 내부 값은 특정한 모든 경로에서 매핑
.exposedHeaders("Set-Cookie") // 노출할 헤더값
.allowedOrigins("http://localhost:3000");
}
}
프론트 쪽에서 하이퍼링크를 통해서 백엔드 쪽에 요청을 보내면 백엔드가 naver, google에 로그인 페이지를 잘 응답해주는지 확인하고 쿠키에서 JWT가 발급되었는지도 확인해볼 것입니다.
발급받은 JWT를 통해서 특정한 스프링 컨트롤러에 데이터 요청을 보낼 때 데이터가 잘 넘어 오는지도 확인해볼 것입니다.
const onNaverLogin = () => {
window.location.href = "http://localhost:8080/oauth2/authorization/naver"
}
function Login(props) {
return (
<>
<h1>Login</h1>
<button onClick={onNaverLogin}>naver login</button>
</>
);
}
axios
.get("http://localhost:8080/", {withCredentials: true})
.then((res) => {
alert(JSON.stringify(res.data))
})
.catch((error) => alert(error))
현재까지 데이터가 잘 넘어가고 CORS 문제도 잘 해결이 된 상태입니다.
하지만 JSON으로 받았는데 String으로 응답했기 때문에 현재는 에러가 발생하는 상태입니다.
JWT가 만료돼서 JWTFilter에서 요청이 거절되면 로그인 경로에 OAuth2 로그인이 실패하고 재요청을 하게 되면 무한 루프에 빠집니다. -> 새로 고침 같은 현상이 계속 발생.
JWTFilter를 OAuth2LoginAuthenticationFilter 뒤에 위치 시키거나(가장 안전) 앞단에 위치시키고 싶다면 JWTFilter 내부에 if문을 통해 특정 경로 요청은 넘어가도록 진행하면 됩니다.
.addFilterAfter(new JWTFilter(jwtUtil), OAuth2LoginAuthenticationFilter.class)
이 경우 .addFilterBefore를 한 것은JWTUtil이 UsernamePasswordAuthenticationFilter 앞단이 위치합니다. 즉 JWTUtil이 먼저 실행됩니다.
위 코드의 경우 JWTUtil이 OAuth2LoginAuthenticationFilter 뒷단에 위치하여 JWTUtil이 나중에 실행됩니다.
위 방법이 싫다고 한다면 doFilterInternal에 적용하면 됩니다.
String requestUri = request.getRequestURI();
if (requestUri.matches("^\\/login(?:\\/.*)?$")) {
filterChain.doFilter(request, response);
return;
}
if (requestUri.matches("^\\/oauth2(?:\\/.*)?$")) {
filterChain.doFilter(request, response);
return;
}
OAuth 2.0 인증 흐름을 처리하여 사용자의 인증 요청을 관리하고, 액세스 토큰을 받아 사용자 정보를 조회하여 인증을 완료하는 필터입니다.
1. 사용자 인증 요청
사용자가 애플리케이션에서 OAuth 2.0 로그인을 선택하면, OAuth2LoginAuthenticationFilter가 해당 요청을 가로채고 OAuth 제공자의 인증 URL로 리다이렉션합니다.
2. OAuth 제공자에서 인증:
사용자는 OAuth 제공자(예: Google, Facebook 등)에서 로그인하고, 애플리케이션에 대한 접근 권한을 허용합니다.
3. 인증 코드 수신:
인증이 완료된 후, OAuth 제공자는 애플리케이션의 리다이렉트 URI로 인증 코드를 포함한 응답을 보냅니다. 이 URI는 애플리케이션에서 미리 등록된 URI여야 합니다.
4. 토큰 요청:
OAuth2LoginAuthenticationFilter는 리다이렉트 URI에서 수신한 인증 코드를 사용하여 OAuth 제공자에게 액세스 토큰을 요청합니다. 이 요청은 클라이언트 ID, 클라이언트 시크릿, 인증 코드 등을 포함합니다.
5. 사용자 정보 조회:
액세스 토큰을 수신하면, 필터는 이를 사용하여 OAuth 제공자에게 사용자 정보를 요청합니다. 이 단계에서 사용자의 프로필 정보(이메일, 이름 등)를 가져옵니다.
6. 사용자 인증 및 SecurityContext 설정:
필터는 수신한 사용자 정보를 기반으로 사용자를 인증하고, SecurityContext에 저장합니다. 이로 인해 애플리케이션 내에서 사용자의 인증 상태를 유지할 수 있습니다.
7. 최종 리다이렉션:
인증이 완료되면 사용자는 애플리케이션의 원래 요청 위치로 리다이렉트됩니다.
발생하는 문제 -> 로그인 성공 핸들러를 통해 JWT를 발급하는데, 발급하는 형식이 다릅니다.
OAuth2 방식에서는 쿠키로, 일반 로그인에서는 헤더로 발급을 합니다. 발급 받은 JWT로 요청할 때는 JWTFilter에서 검증을 하는데 구현 방식이 쿠키와 헤더로 다르기 때문에 통합을 해야 합니다.
JWTFilter는 하나의 로직이 좋기 때문에 헤더 방식으로 통합을 한다면 아래 방식을 통해 진행하면 됩니다.
OAuth2는 쿠키로 토큰을 발급하는데 헤더는 사용 못 하는지?
JWT 발급 자체는 쿠키로만 가능합니다. 헤더로 발급을 진행하면 하이퍼 링크로 받을 수 없습니다.
하지만 첫 발급 이후에는 헤더로 JWT를 이동할 수 있습니다.
1. 로그인 성공하면 핸들러에서 쿠키로 JWT 발급
2. 프론트의 특정 페이지로 리다이렉션
3. 프론트의 특정 페이지는 axios나 fetch를 통해 쿠키를(credentials-true)를 가지고 다시 백엔드로 접근하여 JWT를 받아옴.
4. 헤더로 받아온 JWT를 로컬 스토리지 등에 보관하여 사용
yml에 나의 Client_ID와 Client_Secret이 왜 필요한지?
Client_ID는 애플리케이션을 고유하게 식별하는 값입니다. 네이버나 구글의 인증 서버는 이 값을 통해 어떤 애플리케이션이 요청하고 있는지를 확인합니다.
이 두 값은 API를 호출할 때 필요한 인증 정보를 제공하는데 사용됩니다. Access Token을 요청할 때 이 값들이 필요합니다.
애플리케이션이 요청하는 권한과 함께 이 두 값을 사용하여 인증을 수행합니다.
나의 네이버와 구글 Client_ID와 Secret으로 yml에 작성했는데 다른 사람들은 어떻게 로그인 되는지?
Client_ID와 Client_Password는 애플리케이션을 식별하는 정보로 여러분의 애플리케이션이 네이버와 구글에 요청을 보낼 때 사용합니다.
사용자가 로그인하고 권한을 승인하면, OAuth2 제공자는 애플리케이션의 redirect_uri로 authorization code를 반환합니다.
애플리케이션은 받은 authorization code를 사용하여 Access Token을 요청합니다. 이때 Client_ID와 Client_Secret도 함께 전송됩니다.
OAuth2 제공자는 유효한 요청일 경우 Access Token을 발급합니다. 이 Access Token은 각 사용자의 세션을 식별하는 데 사용됩니다.
애플리케이션은 Access Token을 사용하여 네이버 또는 구글 API에 요청을 보내고, 해당 사용자의 정보를 가져옵니다.
결론 : 다른 사용자는 Client_ID와 Client_Secret을 사용하지 않습니다.
이 두 값은 애플리케이션의 서버 측에서만 사용하는 값입니다. 사용자는 자신의 계정 정보를 입력하여 로그인을 하고 권한이 승인되면, 인증 서버는 애플리케이션에 authorization code를 반환합니다.
애플리케이션 서버는 이 authorization code를 사용하여 Access Token을 사용할 때 Client_ID와 Client_Secret을 사용합니다.
🖇️ https://www.youtube.com/watch?v=xsmKOo-sJ3c&t=2s
🖇️ https://www.devyummi.com/page?id=669377831d21ce96d930fbcb
🖇️ 카카오로 가능