
build.gradle 설정
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'com.auth0:java-jwt:4.4.0'
spring security 사용과 oauth2 라이브러리, 그리고 jwt 토큰 사용을 위해 auth0 jwt 라이브러리를 사용하였다.
Spring Security 설정
우선 spring security configuration 설정에 대한 전체 코드를 작성하고 설명하고자 한다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2MemberService oAuth2MemberService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
private final TokenAuthenticationFilter tokenAuthenticationFilter;
/**
* 정적 자원에 대해 보안 적용 x
* ex> HTML, CSS
*/
@Bean
public WebSecurityCustomizer webSecurityCustomizer() { // security를 적용하지 않을 리소스
return web -> web.ignoring()
.requestMatchers("/error", "/favicon.ico");
}
@Bean
public SecurityFilterChain filterChain(
HttpSecurity httpSecurity
) throws Exception {
httpSecurity
// stateless한 rest api를 개발할 것이므로 csrf 공격에 대한 옵션은 꺼둔다.
.csrf(AbstractHttpConfigurer::disable) // csrf 비활성화 -> cookie를 사용하지 않으면 꺼도 된다. (cookie를 사용할 경우 httpOnly(XSS 방어), sameSite(CSRF 방어)로 방어해야 한다.)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 특정 URL에 대한 권한 설정.
.authorizeHttpRequests(authz -> authz
.requestMatchers("/", "/swagger-ui/*", "/api/*", "/login/oauth2/*").permitAll() // 특정 url에 대한 인가 요청 허용
.anyRequest().permitAll()
)
// 세션 사용 x
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.httpBasic(AbstractHttpConfigurer::disable) // 기본 인증 로그인 비활성화
.formLogin(AbstractHttpConfigurer::disable) // 기본 login form 비활성화
.oauth2Login(login -> login
.userInfoEndpoint(userInfoEndpointConfig -> // OAuth2 로그인 성공 이후 사용자 정보를 가져올 때 설정
userInfoEndpointConfig.userService(oAuth2MemberService)
)
.successHandler(oAuth2SuccessHandler) // 로그인 성공 이후 핸들러 처리 로직
)
// jwt 관련 설정
.addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new TokenExceptionFilter(), tokenAuthenticationFilter.getClass()); // 토큰 예외 핸들링
return httpSecurity.build();
}
// cors configuration
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(List.of("*"));
configuration.addAllowedHeader("*");
configuration.setAllowedMethods(List.of("GET", "POST", "PATCH", "DELETE"));
configuration.setAllowCredentials(true);
configuration.addExposedHeader("Authorization"); // Access-Control-Expose-Headers 사용
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
1. OAuth2란
인증을 위한 개방형 표준 프로토콜로, third-party 프로그램에게 리소스 소유자를 대신해 리소스 서버에서 제공하는 자원에 대한 접근 권한을 위임하는 방식으로 작동된다.
쉽게 말해서 third-party 프로그램(구글, 카카오 등)에게 로그인 및 개인정보 관리에 대한 권한을 위임하여 third-party 프로그램이 가지고 있는 사용자에 대한 리소스를 조회할 수 있다.
위의 security config에서 가장 중요하게 봐야하는 건 이녀석들이다
.oauth2Login(login -> login
.userInfoEndpoint(userInfoEndpointConfig -> // OAuth2 로그인 성공 이후 사용자 정보를 가져올 때 설정
userInfoEndpointConfig.userService(oAuth2MemberService)
)
.successHandler(oAuth2SuccessHandler) // 로그인 성공 이후 핸들러 처리 로직
);
실제로 카카오 로그인 flow를 보면 백엔드 서버에서 구현해야 하는 것들이 되게 많다. 하지만 OAuth-Client 라이브러리가 Authorization Code 를 받고, 이를 통해 Access Token을 받아 소셜에 정보를 요청하고 받는 것을 자동으로 처리해주기 때문에 OAuth2UserService 와 로그인 성공 핸들러만 구현하는 방식으로 진행하면 된다.
application.yml 설정
spring:
profiles:
include: private
security:
oauth2:
client:
registration:
kakao:
client-id: ${kakao.client.id} # 앱키 -> REST API 키
client-secret: ${kakao.client.secret} # 카카오 로그인 -> 보안 -> Client Secret 코드
authorization-grant-type: authorization_code
redirect-uri: ${kakao.redirect-uri}
client-authentication-method: client_secret_post # POST 사용 불가 (setRequestEntityConverter를 사용하여 POST를 지원하는 인스턴스를 제공해야한다.)
scope:
- 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 # 식별자 . 카카오의 경우 "id" 사용
인증을 할 때 OAuth2를 사용하도록 사용할 클라이언트(third-party 프로그램) 설정을 해준다.
google, github, facebook은 CommonOAuth2Provider에 기본 설정 값이 등록되어 제공되지만 kakao, naver 등은 provider에 필요한 값들을 등록해줘야 한다.
해당하는 카카오 api key와 secret 키는 따로 yml에 추가하였다.
여기서 scope가 중요한데 소셜 로그인 시 가져와야 하는 정보들을 명시해두어야 한다.
oauth2-client 라이브러리에서는 소셜 로그인에 대해 url 방식이 정해져있다.
1. 로그인 요청
Spring Security에서 기본적으로 제공하는 URL이 있다. -> http://{domain}/oauth2/authorization/{registrationId}
(클라이언트에서 해당 url로 접속하는 방식으로 진행이 됨.)
2. Redirect URL
Spring Security에서 기본적으로 제공하는 URL이 있다. -> http://{domain}/login/oauth2/code/{registrationId}
OAuth-Client 라이브러리가 자동적으로 수행하게 하려면, 다시 말해 OAuth2MemberService의 loadUser() 가 자동적으로 호출되게 하려면 redirect URL 을 /login/oauth2/code/{registrationId} 으로 설정해야 한다.
그렇지 않으면 설정한 redirect URL 뒤에 ?code=XXXX 형식으로 Authorication Code 가 전달되며, OAuth2MemberService 가 자동으로 호출되지 않는다
OAuth2MemberService
이제 security config에 있는 코드들을 하나하나 살펴보도록 하자
/**
* loadUser 메서드가 실행될 시점엔 이미 Access Token이 정상적으로 발급된 상태이며
* super.loadUser 메서드를 통해
* Access Token으로 User 정보를 조회해 옵니다.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class OAuth2MemberService extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
// 소셜에서 전달받은 정보를 가진 OAuth2User 에서 Map 을 추출하여 OAuth2Attribute 를 생성
Map<String, Object> attributes = oAuth2User.getAttributes();
String registrationId = userRequest.getClientRegistration().getRegistrationId();
String userNameAttributeName = userRequest // access token으로 받아온 kakaoId (id)
.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName();
// DB 로직
Member member = getMember(attributes);
return new CustomOauth2User(member, attributes);
}
private Member getMember(Map<String, Object> attributes) {
Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
String email = kakaoAccount.get("email").toString();
Member member = memberRepository.findByEmail(email).orElse(Member.create(email));
memberRepository.save(member);
return member;
}
}
SecurityConfig에서 로그인 성공 이후 사용자 정보를 가져올 클래스(PrincipleDetails)로 OAuth2MemberService를 등록해주었다.
각 명령어줄을 분석해보도록 하자
1. 유저 attributes
DefaultOAuth2UserService는 리소스 서버에서 사용자 정보를 받아오는 클래스인데, 이를 상속 받아 사용자 정보(DefaultOAuth2User의 attributes)를 가져온다.
ex> 구글 기준 attributes
{
"sub": "1234567890",
"name": "user-name",
"email": "user-email",
...
}
2. registrationId
registrationId는 oauth 관련 yml에서 설정한 client.registration의 값을 말한다. (google, kakao)
3. userNameAttributeName
oauth 관련 yml에서 설정한 provider의 user-name-attribute 값을 말한다. (구글은 "sub"이다. CommonOAuth2Provider에서 확인 가능하다.) 이는 유저 attributes에서 식별자에 접근할 때 사용된다. -> attributes.get("sub") (DefaultOAuth2User에서 확인 가능하다.)
OAuth2SuccessHandler
OAuth2 인증에 성공한 뒤 처리해주는 로직을 담아주는 클래스이다.
@Component
@RequiredArgsConstructor
@Slf4j
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
private final JwtProvider jwtProvider;
@Value("${baseUrl.client-redirect-url}")
private String baseUrl;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException {
log.info("success handler 실행");
if (authentication.getPrincipal() instanceof OAuth2User) {
CustomOauth2User principal = (CustomOauth2User) authentication.getPrincipal();
Member member = principal.getMember();
if (member.getNickname() == null) {
String redirectionUri = UriComponentsBuilder.fromUriString(baseUrl)
.queryParam("email", member.getEmail())
.build()
.toUriString();
response.sendRedirect(redirectionUri);
} else {
String refreshToken = jwtProvider.createRefreshToken(member.getEmail());
jwtService.save(new RefreshToken(refreshToken, member.getId())); // refresh token 저장
String accessToken = jwtProvider.generateAccessToken(authentication);
String redirectionUri = UriComponentsBuilder.fromUriString(baseUrl)
.queryParam("accessToken", accessToken)
.queryParam("refreshToken", refreshToken)
.build()
.toUriString();
response.sendRedirect(redirectionUri);
}
} else {
// OAuth2User가 아닌 경우
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
}
}
}
회원 가입 시 사용자의 email 정보를 담아 redirect 시켜주고,
CustomOAuth2User
@Getter
@AllArgsConstructor
public class CustomOauth2User implements UserDetails, OAuth2User {
private Member member; // 사용자의 식별자
private Map<String, Object> attributes; // 기타 사용자 정보 (예: 이메일, 이름 등)
@Override // 사용 안 할 시 빈 collection 반환
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.emptyList();
}
@Override
public String getPassword() {
return null;
}
// 해당하는 member의 email 정보를 반환할 수 있도록 설정.
@Override
public String getUsername() { // *중요* null일 경우 에러 발생 [principalName cannot be empty]
return member.getEmail();
}
// 사용자 계정 만료 여부
@Override
public boolean isAccountNonExpired() {
return true;
}
// 사용자 계정 잠김 여부
@Override
public boolean isAccountNonLocked() {
return true;
}
// 사용자 비밀번호 만료 여부
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 사용자 활성 여부
@Override
public boolean isEnabled() {
return true;
}
// 사용자 id 추출 로직으로 변경
@Override
public String getName() {
return member.getId().toString();
}
}
UserDetails도 같이 구현하여 토큰 생성 시 authentication 객체에서 getName() 호출 시 getUsername()값이 리턴되도록 했다.
위와 같이 되는 이유는 내부 코드를 확인해 보면 찾을 수 있는데,
authentication.getName() -> Principal 객체의 getName()을 호출한다.
Principal 객체에 담기는 것은 UserDetails를 구현하여 직접 생성한 CustomOAuth2User 객체이다.
AbstractAuthenticationToken에서 getName() 호출 시 principal이 UserDetails이면 userDetails.getUsername()을 리턴하도록 되어 있기 때문이다.
JWT provider
@Slf4j
@Component
public class JwtProvider {
private final Algorithm algorithm;
@Value("${jwt.access-token.expiration}")
private Long accessTokenValidTime; // 24시간
@Value("${jwt.refresh-token.expiration}")
private Long refreshTokenValidTime; // 2주
public JwtProvider(@Value("${jwt.secret-key}") String secretKey) {
this.algorithm = Algorithm.HMAC256(secretKey);
}
public String getUsernameFromAccessToken(String token) {
return JWT.require(algorithm).build().verify(token).getClaim("username").asString();
}
// 토큰 유효 및 만료 확인
public boolean validate(String token) throws TokenExpiredException {
try {
JWT.require(algorithm).build().verify(token);
return true;
} catch (TokenExpiredException e) {
throw e;
} catch (JWTVerificationException e) {
return false;
}
}
public String generateAccessTokenByNickname(String nickname) {
return JWT.create()
.withIssuedAt(Instant.now())
.withExpiresAt(Instant.now().plus(accessTokenValidTime, ChronoUnit.HOURS))
.withClaim("username", nickname)
.sign(algorithm);
}
// access 토큰 생성
public String generateAccessToken(Authentication authentication) {
CustomOauth2User principal = (CustomOauth2User) authentication.getPrincipal();
return JWT.create()
.withIssuedAt(Instant.now())
.withExpiresAt(Instant.now().plus(accessTokenValidTime, ChronoUnit.HOURS))
.withClaim("username", principal.getName())
.sign(algorithm);
}
// 액세스 토큰 재발급
public String reissueAccessToken(String accessToken) {
String username = getUsernameFromAccessToken(accessToken);
return generateAccessTokenByNickname(username);
}
// JWT 만료 시간 확인
public boolean isTokenExpired(String token) {
DecodedJWT jwt = JWT.require(algorithm).build().verify(token);
return jwt.getExpiresAt().before(new Date());
}
// 인증 정보 가져오기
public Authentication getAuthentication(String token) {
DecodedJWT decodedJWT = JWT.require(algorithm).build().verify(token);
List<SimpleGrantedAuthority> authorities = getAuthorities("user");
User principal = new User(decodedJWT.getClaim("username").asString(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
private List<SimpleGrantedAuthority> getAuthorities(String role) {
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(role));
return authorities;
}
}
JWT 토큰을 다루는 객체이다. TokenAuthenticationFilter와 TokenExceptionHandler에서 사용될 예정이다.
TokenAuthenticationFilter
@RequiredArgsConstructor
@Component
@Slf4j
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final String TOKEN_PREFIX = "Bearer ";
private final JwtProvider jwtProvider;
private final LoadUserService loadUserService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String accessToken = resolveToken(request);
log.info("Authentication filter 작동.");
log.info("accessToken: {}", accessToken);
if (accessToken != null) {
try {
if (jwtProvider.validate(accessToken)) {
String username = jwtProvider.getUsernameFromAccessToken(accessToken);
verifyAndSaveAuthentication(request, username);
}
} catch (TokenExpiredException e) {
log.info("Access token expired.");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write(e.getMessage());
response.getWriter().flush();
} catch (Exception e) {
log.error("Authentication error: ", e);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write(e.getMessage());
response.getWriter().flush();
return;
}
}
filterChain.doFilter(request, response);
}
private void verifyAndSaveAuthentication(HttpServletRequest request, String username) {
UserDetails userDetails = loadUserService.loadUserByUsername(username);
AbstractAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
private String resolveToken(HttpServletRequest request) {
String token = request.getHeader(AUTHORIZATION);
if (token != null && token.startsWith(TOKEN_PREFIX)) {
return token.substring(TOKEN_PREFIX.length());
}
return null;
}
}
request의 AUTHRIZATION 헤더에 Bearer를 제거한 토큰을 가져와서 토큰의 nullable한지 체크한다.
만약 해당 토큰이 유효하다면 SecurityContextHolder의 Context의 Authentication에다가 해당 AuthenticationToken을 저장할 수 있다.
이렇게 저장한 토큰은 컨트롤러 딴에서 @AuthenticationPrincipal 어노테이션을 통해 사용 가능하다.
예시>
@PostMapping("/like")
@Operation(summary = "게시글 좋아요 기능", description = "body 부분에 담긴 postId를 받아 해당 게시글 좋아요 기능 구현")
public ResponseEntity<String> clickHeart(@Parameter(required = true, description = "게시글 아이디")
@RequestBody @Valid PostLikeRequestDto postLikeRequestDto,
@AuthenticationPrincipal Member member) {
try {
likeHistoryService.setLike(member.getId(), postLikeRequestDto.getPostId());
return ResponseEntity.ok().body("{\"msg\" : \"success\"}");
} catch (Exception e) {
throw new RuntimeException(e);
}
}
해당 Member 객체는 직접 UserDetails를 상속한 객체여야 사용 가능하다.
참고
SpringSecurity+JWT+OAuth2를 사용한 소셜로그인
프로젝트에 적용 된 소셜 로그인 FLOW1️⃣ from Frontend to Authorization Server 로그인 URL 요청 • redirect_uri : OAuth2 provider가 성공적으로 인증을 완료했을 때 redirect 할 URI를 지정합니다.
velog.io
[Spring] Spring Security + OAuth2 + JWT
이번 개인 프로젝트에서 Spring Security를 활용하여 OAuth2 로그인을 구현했다. Spring Security의 OAuth2를 활용하는 방법과 JWT 발급까지 모두 정리해보려고 한다.*참고) 개발 환경은 Spring boot 3, Java 17을 사
do5do.tistory.com
'Back-end > Spring' 카테고리의 다른 글
[Spring] Spring만의 유효성 검사 @Valid, @Validated 정리 (2) | 2024.06.27 |
---|---|
[Spring] createdAt, updatedAt 사용하기 (0) | 2024.06.19 |
[Spring] JPA Hibernate의 ddl-auto 속성 분석하기 (0) | 2024.06.16 |
[Spring] WebClient를 사용한 외부 API 통신 (0) | 2024.06.16 |
[Spring] Swagger 사용 및 JWT과 https 적용해보기 (2) | 2024.06.05 |