스프링부트 3.2.2 버전을 기준으로 하며, 스프링 시큐리티 6.2.1 이 적용되어 있습니다.
프로젝트에는 Spring Web, Spring Data JPA, Lombok, MySQL Driver 가 추가되어 있습니다.
전체 코드는 GITHUB에서 확인하실 수 있습니다
위의 그림은 Spring Security 에서 로그인을 처리할 때 동작하는 순서를 표현한 것입니다.
여기서 주의 깊에 볼 내용은 빨간색 박스로 되어진 부분들입니다.
여러 모듈들을 거쳐 UserDetailsService
에서 UserDetails
를 반환하고,
반환된 UserDetilas
가 SecurityContext 내부의 Authentication
에 들어가게 됩니다.
로그인 과정과 관련된 자세한 내용은 이전 게시글에서 확인하실 수 있습니다.
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
...
}
}
UserDetailsService
가 호출될 때 loadUserByUsername()
메서드가 호출되며, 그 결과로 UserDetails
가 반환되는 것을 확인할 수 있습니다.
public class OAuth2UserService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
...
}
}
소셜 로그인은 DefaultOAuth2UserService
가 호출되는데 이는 OAuth2UserService
를 구현한 클래스입니다.
DefaultOAuth2UserService
는 일반 로그인과는 다르게 OAuth2User
를 반환하고,
반환된 OAuth2User
가 SecurityContext 내부의 Authentication
에 들어가게 됩니다.
위의 그림을 살펴보면 SecurityContext 내부에 Authentication
이 저장되고, 그 내부에는 Principal
이 있는 것을 확인할 수 있습니다.
Principal
에는 UserDetails
와 OAuth2User
타입만 저장될 수 있습니다.
즉, 일반 로그인의 경우 UserDetails
를, 소셜 로그인의 경우 OAuth2User
를 반환하며, 이 두가지 만이 Principal 에 들어갈 수 있습니다.
이렇게 로그인 방식에 따라 다른 객체를 저장하게 된다면 문제가 발생하게 되는데 아래에서 살펴보도록 하겠습니다.
@AuthenticationPrincipal
은 스프링 시큐리티에서 제공하는 어노테이션으로, 현재 인증된 사용자의 Principal
을 메서드 매개변수로 주입받을 때 사용됩니다.
Spring Security 는 사용자의 인증 정보를 SecurityContextHolder 에 저장하고 있습니다.
@AuthenticationPrincipal
어노테이션은 이 SecurityContextHolder 에서 현재 사용자의 Principal
을 추출하여 메서드의 매개변수로 주입해주는 역할을 합니다.
@Controller
public class MyController {
@GetMapping("/user")
public String getCurrentUser(@AuthenticationPrincipal UserDetails userDetails) {
String username = userDetails.getUsername();
...
}
}
위의 예시는 @AuthenticationPrincipal
를 통해 로그인한 사용자의 UserDetails
를 받아와 정보를 추출하는 예시입니다.
만약 로그인 방식에 따라 다르게 저장한다면 다른 타입을 불러와야 하기 때문에 같은 역할을 하는 메서드가 로그인 방식에 따라 다르게 구현되어어야 하는 문제점이 발생합니다.
이러한 문제점을 해결하기 위해 UserDetails
와 OAuth2User
을 동시에 구현하는 클래스를 만들어 사용하였는데 아래에서 살펴보도록 하겠습니다.
프로젝트를 시작하기에 앞서 build.gradle
에 아래와 같이 의존성을 추가해야 합니다.
implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'com.google.code.gson:gson:2.10.1' implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
위 이미지는 Kakao Developer에서 확인할 수 있습니다.
간단하게 설명하자면 인가코드 받기 ➜ 인가코드로 Access Token 받기 ➜ Access Token 으로 사용자 정보 받기, 총 3단계로 분류됩니다.
Spring 에서는 OAuth2Client
를 이용하여, /oauth2/authorization/kakao
로 요청하면 인가코드를 리다이렉트 받아서 Access Token 을 받고 Access Token 으로 사용자 정보를 받는 과정까지 자동으로 처리할 수 있습니다.
위의 코드는 OAuth2LoginAuthenticationProvider
의 authenticate()
의 코드입니다.
authenticate()
내부에서는 인가 코드를 전달 받아, Access Token 을 요청합니다.
전달 받은 Access Token 을 OAuth2UserRequest
에 담아 OAuth2UserService
의 loadUser()
를 호출합니다.
위의 코드는 DefaultOAuth2UserService
의 loadUser()
코드입니다.
loadUser()
는 OAuth2UserRequest
내부에 담긴 Access Token 을 통해 사용자 정보를 받아옵니다. 받아온 사용자 정보는 OAuth2User
를 구현한 DefaultOAuth2User 객체에 담겨 반환됩니다.
저희는 위 클래스를 상속 받는 클래스를 생성하고 super.loadUser()
를 호출하여, 사용자 정보가 담긴 OAuth2User
객체를 반환 받아 로그인을 처리합니다.
spring:
security:
oauth2:
client:
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
registration:
kakao:
client-id: {REST_API_KEY}
client-secret: {CLIENT_SECRET}
client-authentication-method: client_secret_post
redirect-uri: http://localhost:8080/login/oauth2/code/kakao
authorization-grant-type: authorization_code
client-name: kakao
scope:
- profile_nickname
- account_email
authorization-uri
: Kakao 계정으로부터 인가 코드를 얻기 위한 인가 엔드포인트 URI입니다.
token-uri
: 인가 코드를 교환하여 액세스 토큰을 얻기 위한 토큰 엔드포인트 URI입니다.
user-info-uri
: Kakao API를 통해 사용자 정보를 얻기 위한 URI입니다.
user-name-attribute
: 사용자의 고유 식별자를 나타내는 속성입니다. 이는 뒤에서 한 번 더 다루도록 하겠습니다.
client-id
: Kakao에서 발급한 REST API Key입니다.
client-secret
: Kakao에서 발급한 Client Secret입니다.
client-authentication-method
: 클라이언트 인증 방법을 지정합니다.
이전 버전에서는 post 였는데 spring security 6 버전부터 client-secret-post
로 작성합니다.
redirect-uri
: OAuth2 인증이 완료되고 리디렉션될 URI입니다.
authorization-grant-type
: 인가 유형을 지정합니다.
카카오 소셜 로그인은 authorization_code
방식을 사용합니다.
client-name
: 클라이언트의 이름입니다.
scope
: 요청 시 인가할 권한 범위를 나타냅니다.
spring.profiles.include=oauth2
카카오 소셜 로그인 관련 설정을 application-oauth2.yml
로 작성했기 때문에 작성된 파일을 가져오도록 application.properties
에 위의 코드를 작성합니다.
spring.profiles.include
는 스프링 부트 애플리케이션에서 프로파일을 활성화하는 속성입니다. 이 속성을 사용하여 특정 프로파일을 포함시킬 수 있습니다.
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable);
http.sessionManagement(httpSecuritySessionManagementConfigurer -> {
httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
});
http.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry ->
authorizationManagerRequestMatcherRegistry.anyRequest().permitAll());
http.addFilterBefore(jwtVerifyFilter(), UsernamePasswordAuthenticationFilter.class);
http.formLogin(httpSecurityFormLoginConfigurer -> {httpSecurityFormLoginConfigurer
.loginPage("/login")
.successHandler(commonLoginSuccessHandler())
.failureHandler(commonLoginFailHandler());
});
http.oauth2Login(httpSecurityOAuth2LoginConfigurer ->
httpSecurityOAuth2LoginConfigurer.loginPage("/oauth2/login")
.successHandler(commonLoginSuccessHandler())
.userInfoEndpoint(userInfoEndpointConfig ->
userInfoEndpointConfig.userService(oAuth2UserService)));
return http.build();
}
}
이전에는 WebSecurityConfigurerAdapter 를 상속 받아 Security 와 관련된 설정을 했었는데 Spring Security 6 버전부터는 위처럼 filterChain 을 구현하고 스프링 빈으로 등록합니다.
http.csrf(AbstractHttpConfigurer::disable);
Spring Security 의 CSRF(Cross-Site Request Forgery) 보호를 비활성화하는 부분입니다.
CSRF는 웹 애플리케이션에서 악의적인 사용자가 인증된 사용자의 권한을 이용하여 특정 요청을 전송하는 공격을 막기 위한 보안 기술입니다.
http.sessionManagement(httpSecuritySessionManagementConfigurer -> {
httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
});
http.sessionManagement()
메서드는 세션 관리에 대한 설정을 수행하는 데 사용됩니다.
위의 코드에서는 sessionCreationPolicy()
메서드를 호출하여 세션 생성 정책을 설정하고 있습니다.
SessionCreationPolicy.STATELESS
는 세션을 사용하지 않도록 설정합니다. 각 요청은 서로 독립적이며 세션을 유지하지 않습니다.
이는 주로 RESTful API 서비스에서 사용되며, 각 요청이 필요한 모든 정보를 자체적으로 포함하고 있어 세션의 상태를 유지할 필요가 없는 경우에 적합합니다.
http.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry ->
authorizationManagerRequestMatcherRegistry.anyRequest().permitAll());
위의 코드는 Spring Security에서 HTTP 요청에 대한 권한 부여를 설정하는 부분입니다.
authorizeHttpRequests()
는 HTTP 요청에 대한 인가를 설정합니다. spring security 6 이전 버전에서는 authorizeRequests()
를 사용했습니다.
anyRequest()
는 Spring Security 에서 URL 패턴이 특정 조건에 매치되지 않는 모든 요청에 대해 적용되는 설정을 나타냅니다.
URL 패턴을 지정하여 해당 패턴에 대한 권한 설정을 할 때 사용하는 메서드인 antMatcher()
는 requestMatcher()
로 변경되었습니다.
permitAll()
은 특정 URL 패턴에 대한 접근을 모든 사용자에게 허용하는 메서드입니다. 반대로 authenticated()
는 사용자가 인증되었을 경우에만 허용되는 접근 권한을 설정하는 데 사용됩니다.
http.addFilterBefore(jwtVerifyFilter(), UsernamePasswordAuthenticationFilter.class);
addFilterBefore()
는 Spring Security에서 필터를 등록할 때 사용되는 메서드 중 하나입니다. 이 메서드는 특정 필터를 다른 필터 이전에 추가하도록 설정합니다.
http.formLogin(httpSecurityFormLoginConfigurer -> {
httpSecurityFormLoginConfigurer
.loginPage("/login")
.successHandler(commonLoginSuccessHandler())
.failureHandler(commonLoginFailHandler());
});
위 코드는 Spring Security 에서 폼 기반 로그인에 대한 설정을 하는 부분입니다. 여러 설정을 제공하는 http.formLogin()
메서드를 사용하고 있습니다.
.loginPage()
은 사용자가 로그인을 하지 않은 상태에서 보안이 필요한 페이지에 접근했을 때 리다이렉션될 로그인 페이지의 URL을 지정합니다.
그 아래 로그인이 성공했을 때 호출될 핸들러와 실패했을 때 호출될 핸들러를 등록합니다.
http.oauth2Login(httpSecurityOAuth2LoginConfigurer ->
httpSecurityOAuth2LoginConfigurer.loginPage("/oauth2/login")
.successHandler(commonLoginSuccessHandler())
.userInfoEndpoint(userInfoEndpointConfig ->
userInfoEndpointConfig.userService(oAuth2UserService)));
.userInfoEndpoint()
는 OAuth 2.0 UserInfo 엔드포인트에 대한 구성을 하는 부분입니다. UserInfo 엔드포인트는 OAuth 2.0 제공자로부터 사용자 정보를 가져오는 역할을 합니다.
userInfoEndpointConfig.userService()
는 어떤 서비스를 사용하여 사용자 정보를 가져올지를 지정하는 부분입니다. 보통 OAuth2UserService 인터페이스를 구현한 커스텀 서비스를 여기에 등록합니다.
OncePerRequestFilter
를 상속 받고 있으며, 각각의 요청에 대해 딱 한 번만 실행되도록 보장하는 역할을 합니다.UserDetailsService
를, 소셜 로그인이라면 OAuth2UserService
를 거치게 됩니다.해당 서비스는 UserDetails
와 OAuth2User
를 구현하는 PrincipalDetail
을 반환하며, getPrincipal()
을 통해 가져올 수 있습니다.
일반 로그인과 소셜 로그인 모두 로그인을 성공하면 CommonSuccessHandler
의 onAuthenticationSuccess()
를 호출하게 됩니다.
해당 메서드에서 Authentication
에 담긴 PrincipalDetail 를 추출해서 해당 객체가 가진 정보와 함께 Access Token 과 Refresh Token 을 생성하여 프론트로 넘겨주게 됩니다.
아래에서는 소셜 로그인과 관련된 코드 일부를 설명하고 있기에 전체 코드는 GITHUB에서 확인해야 합니다.
public class OAuth2UserService extends DefaultOAuth2UserService {
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);
Map<String, Object> attributes = oAuth2User.getAttributes();
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails()
.getUserInfoEndpoint()
.getUserNameAttributeName();
KakaoUserInfo kakaoUserInfo = new KakaoUserInfo(attributes);
String socialId = kakaoUserInfo.getSocialId();
String name = kakaoUserInfo.getName();
Optional<Member> bySocialId = memberRepository.findBySocialId(socialId);
Member member = bySocialId.orElseGet(() -> saveSocialMember(socialId, name));
return new PrincipalDetail(member, Collections.singleton(new SimpleGrantedAuthority(member.getRole().getValue())),
attributes);
}
}
super.loadUser(userRequest)
로 부모 클래스인 DefaultOAuth2UserService 의 loadUser()
를 호출하여 OAuth2 서비스 제공업체( Kakao )에서 사용자 정보를 가져와 OAuth2User 객체를 생성합니다.
oAuth2User.getAttributes()
를 통해 OAuth2User 에 담긴 사용자 정보에 포함된 속성들을 Map 으로 반환합니다.
userNameAttributeName
을 찾아내는데 OAuth2 서비스 제공업체에서 사용자를 식별하는 데 사용되는 속성의 이름을 의미합니다. Kakao 는 id
속성을 사용합니다.
속성들을 이용해 KakaoUserInfo
객체를 생성합니다. 해당 객체는 attributes
에 담긴 값들 중에 필요한 값을 추출합니다.
추출한 정보를 이용해 DB 에 저장되어 있는지 검사하는데 만약 존재하지 않는다면 saveSocialMember()
를 통해 새로운 객체를 생성하고, PrincipalDetail
객체를 생성해 반환합니다.
public class KakaoUserInfo {
public static String socialId;
public static Map<String, Object> account;
public static Map<String, Object> profile;
public KakaoUserInfo(Map<String, Object> attributes) {
socialId = String.valueOf(attributes.get("id"));
account = (Map<String, Object>) attributes.get("kakao_account");
profile = (Map<String, Object>) account.get("profile");
}
public String getSocialId() {
return socialId;
}
public String getName() {
return String.valueOf(profile.get("nickname"));
}
}
KakaoUserInfo 는 Kakao 를 통해 받아온 사용자에 대한 정보를 추출하여 필요한 정보만을 담아내는 객체입니다.
kakao 에서 전달 받는 데이터의 형태는 아래와 같으며, id 값과 name 값을 추출하기 위해 해당 객체를 사용하였습니다.
responseEntityBody = {
id=30...
connected_at=2024-02-01T11:23:54Z
properties={nickname=XXXX}
kakao_account={
profile_nickname_needs_agreement=false
profile={nickname=XXXX}
has_email=true
email_needs_agreement=false
is_email_valid=true
is_email_verified=true
email=XXXX
}
}
public class CommonLoginSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
PrincipalDetail principal = (PrincipalDetail) authentication.getPrincipal();
Map<String, Object> responseMap = principal.getMemberInfo();
responseMap.put("accessToken", JwtUtils.generateToken(responseMap, JwtConstants.ACCESS_EXP_TIME));
responseMap.put("refreshToken", JwtUtils.generateToken(responseMap, JwtConstants.REFRESH_EXP_TIME));
Gson gson = new Gson();
String json = gson.toJson(responseMap);
response.setContentType("application/json; charset=UTF-8");
PrintWriter writer = response.getWriter();
writer.println(json);
writer.flush();
}
}
로그인이 성공하면 Authentication
객체에 PrincipalDetail
이 담겨서 호출되는데 해당 객체를 뽑아냅니다.
해당 객체의 getMemberInfo()
를 통해 객체에 담겨 있으면서 프론트로 반환할 정보들을 Map 으로 추출합니다.
추출된 정보와 JwtUtils 를 이용하여 Access Token 과 Refresh Token 을 생성하고, 추출된 정보와 함께 프론트로 전달합니다.
public class PrincipalDetail implements UserDetails, OAuth2User {
private Member member;
private Collection<? extends GrantedAuthority> authorities;
private Map<String, Object> attributes;
// 일반 로그인 시
public PrincipalDetail(Member member, Collection<? extends GrantedAuthority> authorities) {
this.member = member;
this.authorities = authorities;
}
// 소셜 로그인 시
public PrincipalDetail(Member member, Collection<? extends GrantedAuthority> authorities, Map<String, Object> attributes) {
this.member = member;
this.authorities = authorities;
this.attributes = attributes;
}
// info 에 들어가는 것들이 토큰에 들어가는 데이터
public Map<String, Object> getMemberInfo() {
Map<String, Object> info = new HashMap<>();
info.put("name", member.getName());
info.put("email", member.getEmail());
info.put("role", member.getRole());
return info;
}
}
위에서 보이는 코드 외에 UserDetails
와 OAuth2User
에 존재하는 메서드를 override 한 메서드들이 존재하는데 github 에서 확인할 수 있습니다.
PrincipalDetail
에서는 사용자 정보를 담기위한 Member, 권한 정보를 담기 위한 authorities, OAuth 2.0 제공자로부터 받아온 사용자 정보를 저장하는 attributes 가 존재합니다.
일반 로그인을 사용하면 Member 와 authorities 만이 존재하는 생성자를, 소셜 로그인을 사용한다면 세 개 전부 존재하는 생성자를 사용하여 객체를 생성하게 됩니다.
getMemberInfo()
에서는 Member 객체에 담겨있으며 프론트로 반환할 데이터들을 추출합니다.
public class JwtVerifyFilter extends OncePerRequestFilter {
...
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader(JwtConstants.JWT_HEADER);
try {
checkAuthorizationHeader(authHeader); // header 가 올바른 형식인지 체크
String token = JwtUtils.getTokenFromHeader(authHeader);
Authentication authentication = JwtUtils.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response); // 다음 필터로 이동
}
}
}
Authorization 헤더에 담긴 Access Token 을 검증하고, 올바르다면 Security Context 에 저장할 Authentication 객체를 생성하는 과정을 거칩니다.
해당 과정은 JwtUtils 에서 진행됩니다.
public class JwtUtils {
...
public static Authentication getAuthentication(String token) {
Map<String, Object> claims = validateToken(token);
String email = (String) claims.get("email");
String name = (String) claims.get("name");
String role = (String) claims.get("role");
MemberRole memberRole = MemberRole.valueOf(role);
Member member = Member.builder().email(email).name(name).role(memberRole).build();
Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority(member.getRole().getValue()));
PrincipalDetail principalDetail = new PrincipalDetail(member, authorities);
return new UsernamePasswordAuthenticationToken(principalDetail, "", authorities);
}
}
validateToken 은 해당 토큰이 정상적인지, 유효 시간은 지나지 않았는지를 체크합니다. 문제가 없다면 토큰에 담긴 정보들을 반환합니다.
Security Context 에 저장하기 위해 Authentication 을 구현한 객체가 필요한데 UsernamePasswordAuthenticationToken
가 해당 역할을 수행합니다.
인증이 완료된 UsernamePasswordAuthenticationToken 을 생성하기 위해서는 아래와 같은 것들이 필요합니다.
public UsernamePasswordAuthenticationToken(Object principal, Object credentials,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
super.setAuthenticated(true); // must use super, as we override
}
principal
은 주로 사용자를 나타내는 객체입니다. credentials
은 주로 사용자의 비밀번호를 나타내는 객체입니다.
authorities
는 해당 사용자가 가지고 있는 권한 목록입니다. GrantedAuthority
의 하위 클래스인 객체들을 포함한 컬렉션입니다.
토큰에 담긴 정보들을 이용해 Member 객체와 권한을 생성해서 principal 에 들어갈 객체인 PrincipalDetail 을 생성합니다.
PrincipalDetail 과 함께 생성된 권한을 생성자에 전달하여 UsernamePasswordAuthenticationToken
객체를 생성하여 반환합니다.
반환된 객체는 JwtVerifyFilter 에서 Security Context 에 저장됩니다.