Spring Security를 사용하여, 구글 소셜 로그인을 먼저 구현하고자 합니다.
첫번째로 살펴볼 코드는 시큐리티 관련 설정 코드를 작성한 파일(SecurityConfig)입니다.
스프링 스큐리티의 특징을 잘 알면, 코드에 대해서 쉽게 이해할 수 있습니다.
💭 스프링 스큐리티 관련 설정에 왜 Filter라는 단어가 있을까요?? 어떠한 관계가 있을까요??
스프링 스큐리티는 가장 중요한 것은 Filter라고 할 수 있을 만큼 떼어낼 수 없는 사이입니다.
따라서, 스프링 스큐리티는 서블릿 필터 체인을 구성하고, 요청을 거치게 됩니다.
즉, 필터 체인을 통과하게 되면 자원의 해당 servlet을 접근할 수 있습니다.
SecurityConfig 파일에서는 이러한 필터들에 대한 설정을 커스텀하고 새로운 필터를 추가할 수도 있습니다.
👍 해당 프로젝트가 스프링 스큐리티를 사용했다면, SecurityConfig를 잘 살펴보면 어떠한 흐름으로 인증과 인가를 하고 있는지 살펴볼 수 있습니다.
그림 출처 : https://youmekko.github.io/2018/04/26/2018-04-26-Filter/
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
private final CustomOAuth2UserService customOAuth2UserService;
private final TokenService tokenService;
private final OAuth2SuccessHandler oAuth2SuccessHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.headers().frameOptions().disable()
.and()
.authorizeRequests()
.antMatchers("/h2-console/**", "/",).permitAll()
.antMatchers("/api/v1/**").hasRole(Role.USER.name())
.anyRequest().authenticated()
.and()
.logout()
.logoutSuccessUrl("/")
.and()
.addFilterBefore(new JwtAuthFilter(tokenService),
UsernamePasswordAuthenticationFilter.class)
.oauth2Login()
.successHandler(oAuth2SuccessHandler)
.userInfoEndpoint()
.userService(customOAuth2UserService);
return http.build();
}
}
1️⃣ @EnableWebSecurity
2️⃣ sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
3️⃣ .authorizeRequests() .antMatchers("/h2-console/**", "/",).permitAll() .antMatchers("/api/v1/**").hasRole(Role.USER.name()) .anyRequest().authenticated()
4️⃣ .logout().logoutSuccessUrl("/")
5️⃣ .addFilterBefore(new JwtAuthFilter(tokenService),UsernamePasswordAuthenticationFilter.class)
6️⃣ .oauth2Login().successHandler(oAuth2SuccessHandler).userInfoEndpoint().userService(customOAuth2UserService)
먼저, 소셜 로그인을 살펴보겠습니다.
아래의 첫번째 코드는 소셜 로그인의 서비스 로직을 나타냈습니다.
@RequiredArgsConstructor
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();
OAuth2User oAuth2User = delegate.loadUser(userRequest);
//로그인 진행중인 서비스를 구분하는 ID -> 여러 개의 소셜 로그인할 때 사용하는 ID
String registrationId = userRequest.getClientRegistration().getRegistrationId();
//OAuth2 로그인 진행 시 키가 되는 필드값(Primary Key) -> 구글은 기본적으로 해당 값 지원("sub")
//그러나, 네이버, 카카오 로그인 시 필요한 값
String userNameAttributeName = userRequest.getClientRegistration()
.getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();
//OAuth2UserSevice를 통해 가져온 OAuth2User의 attribute를 담은 클래스
OAuthAttributes attributes = OAuthAttributes.of(registrationId,
userNameAttributeName, oAuth2User.getAttributes());
//우리의 서비스에 회원가입이나 기존 회원의 정보를 업데이트를 한다.
User user = saveOrUpdate(attributes);
return new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(user.getRoleKey())),
attributes.getAttributes(), attributes.getNameAttributeKey());
}
private User saveOrUpdate(OAuthAttributes attributes) {
//소셜 로그인의 회원 정보가 업데이트 되었다면, 기존 DB에 저장된 회원의 이름을 업데이트해줍니다.
User user = userRepository.findByEmail(attributes.getEmail())
.map(entity -> entity.update(attributes.getName()))
.orElse(attributes.toEntity());
//만약에 DB에 등록된 이메일이 아니라면, save하여 DB에 등록(회원가입)을 진행시켜준다.
return userRepository.save(user);
}
}
@Getter
@NoArgsConstructor
@Entity
public class User extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "USER_ID")
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String email;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Role role;
@Builder
public User(String name, String email, Role role) {
this.name = name;
this.email = email;
this.role = role;
}
public User update(String name) {
this.name = name;
return this;
}
public String getRoleKey() {
return this.role.getKey();
}
}
@Getter
@RequiredArgsConstructor
public enum Role {
USER("ROLE_USER", "일반 사용자");
private final String key;
private final String title;
}
☝️ 구글과 네이버 등 해당 기업의 서버에 회원 정보를 요청할 때 사용하므로 기업에서 제공하는 정보에 맞게 Dto를 구성해야 합니다.
예를 들어, 구글은 프로필 이미지 Url을 제공할 수 있지만, 다른 소셜 기업은 이미지 Url을 제공하지 않을 수 있습니다.
@Getter
public class OAuthAttributes {
private Map<String, Object> attributes;
private String nameAttributeKey;
private String name;
private String email;
private String pictureURL;
@Builder
public OAuthAttributes(Map<String, Object> attributes,
String nameAttributeKey,
String name, String email, String pictureURL) {
this.attributes = attributes;
this.nameAttributeKey = nameAttributeKey;
this.name = name;
this.email = email;
this.pictureURL = pictureURL;
}
public static OAuthAttributes of(String registrationId,
String userNameAttributeName,
Map<String, Object> attributes) {
return ofGoogle(userNameAttributeName, attributes);
}
private static OAuthAttributes ofGoogle(String userNameAttributeName,
Map<String, Object> attributes) {
return OAuthAttributes.builder()
.name((String) attributes.get("name"))
.email((String) attributes.get("email"))
.pictureURL((String) attributes.get("picture"))
.attributes(attributes)
.nameAttributeKey(userNameAttributeName)
.build();
}
public User toEntity() {
return User.builder()
.name(name)
.email(email)
.role(Role.USER)
.build();
}
}
소셜 로그인 성공 후에는 token을 생성하여 클라이언트쪽에서도 앞으로 api를 요청할 때마다 해당 토큰 같이 넘겨주면 인증과 인가를 하지 않아도 됩니다. 따라서 리다이렉트된 url에서 해당 토큰을 파싱해야 합니다.
@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final TokenService tokenService;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response
, Authentication authentication) throws IOException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
String email = oAuth2User.getAttribute("email");
String name = oAuth2User.getAttribute("name");
String token = tokenService.generateToken(name, email, "USER");
String targetUrl = UriComponentsBuilder.fromUriString("/")
.queryParam("token", token)
.build().toUriString();
getRedirectStrategy().sendRedirect(request, response, targetUrl);
}
}
Part1에서 FilterChain에 추가한 JwtAuthFilter를 직접 정의해보겠습니다.
@RequiredArgsConstructor
public class JwtAuthFilter extends GenericFilterBean {
private final TokenService tokenService;
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
//HttpServletRequest에서 헤더에 "X-AUTH-TOKEN"에 작성된 token을 가져옵니다.
String token = tokenService.resolveToken((HttpServletRequest) request);
//헤더에 작성된 토큰이 있는지 확인하고, 토큰이 만료되었는지 확인합니다.
if (token != null && tokenService.validateToken(token)) {
//토큰에서 secret key를 사용하여 회원의 이메일을 가져옵니다.
String email = tokenService.getEmail(token);
//인증된 회원의 정보를 SecurityContextHolder에 저장합니다.
//현재는 역할이 ROLE_USER뿐이라서 권한을 직접 주는 형태로 하였으나, 권한이 여러개인 경우 변경해야 합니다.
Authentication auth = new UsernamePasswordAuthenticationToken(email, "",
Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")));
SecurityContextHolder.getContext().setAuthentication(auth);
}
//토큰이 없거나 만료된 토큰이라면, 다시 소셜 로그인을 진행하는 과정을 수행합니다.
chain.doFilter(request, response);
}
}
@Service
public class TokenService {
private Key secretKey;
@Value("${security.jwt.token.secret-key}")
private String SECRET_KEY;
@Value("${security.jwt.token.expire-length}")
private Long EXPIRE_LENGTH;
@PostConstruct
protected void init() {
secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(SECRET_KEY));
}
public String generateToken(String name, String email, String role) {
Claims claims = Jwts.claims().setSubject(email);
claims.put("name", name);
claims.put("role", role);
return Jwts.builder().setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + EXPIRE_LENGTH))
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
public boolean validateToken(String token) {
try {
Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(secretKey)
.build().parseClaimsJws(token);
return claims.getBody().getExpiration()
.after(new Date(System.currentTimeMillis()));
} catch (Exception e) {
return false;
}
}
public String getEmail(String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build().parseClaimsJws(token).getBody().getSubject();
}
public String resolveToken(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
}