not-a-gardener 개발기 1) 소셜 로그인(OAuth2, 구글, 네이버, 카카오), 기본 로그인

메밀·2023년 2월 21일
0

not-a-gardener

목록 보기
1/13

1. 로그인도 쉽지않다...

지금까지 한 일

🤟 소셜 로그인, 자체 로그인 동시 지원
🤟 Spring boot - React 사이의 CORS 문제 해결
🤟 DB의 Member 테이블의 PK가 username이 아닐 때, 혹은 Security Context에 추가적인 정보를 담고 싶을 때의 해결방안
🤟 사용자 인증 객체 커스터마이징
🤟 Too Many Redirect 오류 해결

프론트는 React, 백엔드는 Spring boot를 사용하였고 긴 삽질을 거쳤다 ㅋㅋㅋ


🌱 버전 정보
Spring boot 2.7.5
Spring Security 5.7.4
React 18.2
MySQL 10.3

1) 소셜 로그인(OAuth2) 플로우

Security Filter Chain

( debug 모드로 확인해본 not-a-gardener의 필터체인 )
  1. 클라이언트에서 유저가 소셜 로그인 버튼을 누른다.
  2. Spring Security의 OAuth2AuthorizationRequestFilter
    • '/oauth2/authorization/{registrationId}'
    • registrationId(ex. google, github)를 이용해 로그인창으로 리다이렉트
  3. 유저가 로그인한다
  4. OAuth2LoginAuthenticationFilter
    • 로그인 이후 요청 처리
    • access token을 가져와 이 토큰으로 OAuth2 서버에서 유저 정보를 가져온다
    • SuccessHandler에서 토큰과 함께 리다이렉트
  5. 프론트에서 리다이렉트 주소와 함께 전달된 토큰을 localStorage에 저장한 뒤, 다음 페이지로 이동한다.

프론트 영역을 제외하면 내가 직접 구현한 부분은 4번에 해당한다.


— 자체 로그인

  1. 클라이언트에서 유저가 로그인한다
  2. LoginController에서 DTO를 받아 LoginService로 넘긴다.
  3. LoginService
    • 아이디/비밀번호 일치 여부 검사
      - MemberRepository의 메소드 사용
    • Security Context에 Authentication 저장
    • 인코딩된 JWT 리턴

— 로그인 이후 인증/인가

  1. 요청이 들어온다.
  2. JwtFilter가 요청을 받아 헤더를 확인한다.
    • 헤더가 없거나 토큰이 유효하지 않으면 요청은 거절된다
    • 헤더가 존재하면 토큰을 기반으로 JwtAuthToken 객체를 만든다
      - JwtAuthToken이 유효하면 토큰 정보를 기반으로 Authentication을 만든다
      - tokenProvider.getAuthentication 메소드에서 member 객체를 만들어 authentication 안에 넣어 반환해준다
    • security context 안에 저장한다
  3. filterChain.doFilter(request, response)

2. 공통 클래스 및 properties

1) properties

로그인 API는 설정했다는 가정 하에 application-oauth.properties 파일을 만들고 설정 내용을 추가한다.

나는 카카오, 구글, 네이버 소셜 로그인을 구현했는데,
구글과 달리 카카오는 Spring Security에 기본 설정이 되어 있지 않아 직접 설정을 등록해주어야 한다.

scope는 OAuth2 서버에서 어떤 정보를 받아올지를 설정하는 항목이다.
나는 이름(닉네임)과 이메일을 받아왔다.


# google
spring.security.oauth2.client.registration.google.client-id=[당신의 client-id]
spring.security.oauth2.client.registration.google.client-secret=[당신의 client-secret]
# 무슨 정보를 받을건지
spring.security.oauth2.client.registration.google.scope=profile,email


# kakao
spring.security.oauth2.client.registration.kakao.client-id=[당신의 client-id]
spring.security.oauth2.client.registration.kakao.client-secret=[당신의 client-secret]
spring.security.oauth2.client.registration.kakao.redirect-uri=http://localhost:8080/login/oauth2/code/kakao
spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.kakao.scope=profile_nickname,account_email
spring.security.oauth2.client.registration.kakao.client-name=kakao
spring.security.oauth2.client.registration.kakao.client-authentication-method=POST

spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize
spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/token
spring.security.oauth2.client.provider.kakao.user-info-uri=https://kapi.kakao.com/v2/user/me
spring.security.oauth2.client.provider.kakao.user-name-attribute=id

# naver
spring.security.oauth2.client.registration.naver.client-id=[당신의 client-id]
spring.security.oauth2.client.registration.naver.client-secret=[당신의 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=nickname,email
spring.security.oauth2.client.registration.naver.client-name=Naver

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

이후 application.properties 하단에 spring.profiles.include=oauth를 추가하여 설정 파일에 OAuth2 설정을 추가한다.

⭐️ .gitignore 꼭 해놓기!


2) Member(Entity)

소셜 로그인을 추가하다보니 PK를 변경해야 했다.
더이상 username이 유니크하지 않기 때문이다.
username과 provider를 동시에 FK로 내보내기 싫어 간단히 member_no를 추가하였다.

@Entity
@Data
@Table(name = "member")
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private int memberNo;

    private String username;

    private String pw;

    @NotNull
    private String email;

    @NotNull
    private String name;

    @NotNull
    private LocalDateTime createDate;

	// 구글, 카카오, 네이버, 자체 로그인 정보 저장
    private String provider;
}

3) UserPrincipal

소셜 로그인으로 얻을 수 있는 OAuth2User와 일반 로그인으로 얻을 수 있는 UserDetailsimplements하여 커스텀 클래스를 만들었다.

이유는 다음과 같다.

  1. Spring Security가 로그인 시에 넣어주는 정보를 확장하기 위해
    (나는 memberNo가 필요하다)

  2. 일반로그인 사용자와 소셜로그인 사용자를 같은 타입으로 저장하고 싶었다
    (일반로그인 사용자를 OAuth2User에 저장하는 건 이치에 맞지 않고,
    UserDetail엔 소셜로그인 사용자의 registrationId 등을 담을 곳이 마땅치 않다)

그냥 implements OAuth2User, UserDetails 한 뒤, 오버라이딩하라는 걸 오버라이딩하면 된다.

@AllArgsConstructor
@ToString
@Getter
public class UserPrincipal implements UserDetails, OAuth2User {
    private Member member;
    private Map<String, Object> oauth2UserAttributes;

    public UserPrincipal(Member member){
        this.member = member;
    }

    /* OAuth2 로그인 사용 */
    public static UserPrincipal create(Member member, Map<String, Object> oauth2UserAttributes){
        return new UserPrincipal(member, oauth2UserAttributes);
    }

    /* 일반 로그인 사용 */
    public static UserPrincipal create(Member member){
        return new UserPrincipal(member, new HashMap<>());
    }

    public Member getMember(){
        return this.member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        List<GrantedAuthority> authorityList = new ArrayList<>();

        GrantedAuthority authority = new SimpleGrantedAuthority("ROLE_USER");
        authorityList.add(authority);

        return authorityList;
    }

    @Override
    public String getPassword() {
        return this.member.getPw();
    }

    @Override
    public String getUsername() {
   		// User를 authentication 할 때 사용할 username을 return.
        // 실제 username이 아니라 PK 값인 memberNo를 넘겨준다
        return String.valueOf(this.member.getMemberNo());
    }

    @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
    @Nullable
    public <A> A getAttribute(String name) {
        return (A) oauth2UserAttributes.get(name);
    }

    @Override
    public Map<String, Object> getAttributes() {
        return Collections.unmodifiableMap(oauth2UserAttributes);
    }

    @Override
    public String getName() {
        return member.getName();
    }
}

4) JwtAuthTokenProvider

프론트로 전달할 JwtAuthToken이나 Security Context에 저장할 UsernamePasswordAuthenticationToken를 반환하는 클래스.

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthTokenProviderImpl implements JwtAuthTokenProvider {

    // property의 값을 읽어오는 어노테이션
    @Value("${secret}")
    private String secret;
    private Key key;

    private final UserDetailsService userDetailsService;

    @PostConstruct // 의존성 주입 후 초기화
    public void init(){
        // base64를 byte[]로 변환
        byte[] keyBytes = Decoders.BASE64.decode(secret);
        // byte[]로 Key 생성
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    /**
     * 일반/소셜 로그인 성공 시 UserPrincipal을 만들어 전달하면 JwtAuthToken 발급
     * LoginService, OAuth2SuccessHandler에서 사용
     * @param userPrincipal Authenticaion에 넣어 Security Context에 저장할 유저 정보
     * @return JwtAuthToken 객체
     */
    @Override
    public JwtAuthToken createAuthToken(UserPrincipal userPrincipal){
        // PK
        int memberNo = userPrincipal.getMember().getMemberNo();

        // claims 만들기
        Map<String, String> claims = new HashMap<>();

        claims.put("memberNo", String.valueOf(memberNo));
        claims.put("email", userPrincipal.getMember().getEmail());
        claims.put("name", userPrincipal.getMember().getName());

        // 기한
        Date expiredDate = Date.from(LocalDateTime.now().plusMinutes(180).atZone(ZoneId.systemDefault()).toInstant());

        return new JwtAuthToken(String.valueOf(memberNo), key, claims, expiredDate);
    }

    /**
     * JwtFilter에서 사용
     * 헤더에서 받아온 token을 주면 이 클래스의 멤버변수로 지정된 key 값을 포함하여 JwtAuthToken 객체 리턴
     * 유효한 토큰인지 확인하기 위해 쓴다.
     * @param token 헤더에서 받아온 token
     * @return JwtAuthToken 객체
     */
    @Override
    public JwtAuthToken convertAuthToken(String token) {
        return new JwtAuthToken(token, key);
    }

    /**
     * JwtFilter에서 유효한 토큰인지를 확인한 후 Security Context에 저장할 Authentication 리턴
     * @param authToken 헤더에 담겨 온 Jwt를 decode한 것
     * @return UsernamePasswordAuthenticationToken(userPrincipal, null, role)
     */
    @Override
    public Authentication getAuthentication(JwtAuthToken authToken) {
        if(authToken.validate()){
            // authToken에 담긴 데이터를 받아온다
            Claims claims = authToken.getData();

            log.debug("claims.getSubject(): {}", claims.getSubject());

            UserPrincipal userPrincipal = (UserPrincipal) userDetailsService.loadUserByUsername(claims.getSubject());

            // 권한 없으면 authenticate false => too many redirect 오류 발생
            // principal, credential, role 다 쓰는 생성자 써야 super.setAuthenticated(true); 호출됨!
            return new UsernamePasswordAuthenticationToken(
                    userPrincipal,
                    null,
                    Collections.singleton(new SimpleGrantedAuthority("USER")));
        } else {
            throw new JwtException("token error!");
        }
    }
}

5) JwtAuthToken

토큰 클래스.
프론트로 전달할 JWT 토큰, 백엔드로 전달된 토큰을 decoding하는 메소드를 포함한다.

@Slf4j
@Getter
public class JwtAuthToken {
    public static final String AUTHORITIES_KEY = "USER";
    private final String token;
    private final Key key;

    public JwtAuthToken(String token, Key key) {
        this.token = token;
        this.key = key;
    }

    public JwtAuthToken(String id, Key key, Map<String, String> claims, Date expiredDate){
        this.key = key;
        this.token = createJwtAuthToken(id, claims, expiredDate).get();
    }

    public String getToken(){
        // return token.token;
        return this.token;
    }

    public Optional<String> createJwtAuthToken(String id, Map<String, String> claimMap, Date expiredDate){
        Claims claims = new DefaultClaims(claimMap);
        // claims.put(JwtAuthToken.AUTHORITIES_KEY, role);

        // ofNullable(): 말그대로 null을 허용
        return Optional.ofNullable(Jwts.builder()
                .setSubject(id)
                .addClaims(claims)
                .signWith(key, SignatureAlgorithm.HS256)
                .setExpiration(expiredDate)
                .compact()
        );
    }

    public boolean validate(){
        return getData() != null;
    }

    public Claims getData(){
        try{
            return Jwts
                    // Returns a new JwtParserBuilder instance that can be configured to create an immutable/thread-safe
                    .parserBuilder()
                    // JwtAuthTokenProvider에서 받아온 키 세팅
                    .setSigningKey(key)
                    // JwtParser 객체 리턴
                    .build()
                    // 토큰을 jws로 파싱
                    .parseClaimsJws(token)
                    // 앞서 토큰에 저장한 data들이 담긴 claims를 얻어온다.
                    // String or a Claims instance
                    .getBody();
        } catch(SecurityException e){
            log.info("Invalid JWT signature.");
        } catch(MalformedJwtException e){
            log.info("Invalid JWT token.");
        } catch(ExpiredJwtException e){
            log.info("Expired JWT token.");
        } catch(UnsupportedJwtException e){
            log.info("Unsupported JWT token.");
        } catch(IllegalArgumentException e){
            log.info("JWT token compact of handler are invalid");
        }

        return null;
    }
}


3. 소셜 로그인(OAuth2)

1) SpringSecurityConfig

@Configuration
@EnableWebSecurity //(debug = true)
@RequiredArgsConstructor
@Slf4j
public class SpringSecurityConfig {
    private final JwtAuthTokenProvider tokenProvider;
    private final OAuth2MemberService oAuth2MemberService;
    private final OAuth2SuccessHandler successHandler;

    /**
     * 리액트 서버와 통신하기 위해 CORS 문제 해결
     * @return CorsFilter
     */
    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();

        // 요청에 credential 권한이 있는지 없는지
        // Authorization으로 사용자 인증 시 true
        config.setAllowCredentials(true);
        config.addAllowedOrigin("http://localhost:3000"); // 요청 권한을 줄 도메인
        config.addAllowedHeader("*"); // 노출해도 되는 헤더

        // 허용할 메소드.
        // 특정 메소드만 허용하려면 HttpMethod.GET 식으로 추가
        config.addAllowedMethod("*");
        source.registerCorsConfiguration("/**", config);

        return new CorsFilter(source);
    }

    /**
     * Security Filter Chain 커스터마이징
     * @param httpSecurity
     * @return
     * @throws Exception
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity
                .httpBasic().disable() // 로그인 인증창 해제
                .csrf().disable() // REST Api이므로 csrf disable
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // JWT 토큰 인증이므로 세션은 stateless

                .and()
                .authorizeRequests() // 리퀘스트 설정
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll() // Preflight 요청 허용
                .antMatchers("/", "/oauth", "/register").permitAll() // 누구나 접근가능
                .antMatchers("/garden/**").authenticated() // 인증 권한 필요

                .and()
                .addFilter(this.corsFilter()) // CORS 필터 등록
                // 기본 인증 필터인 UsernamePasswordAuthenticationFilter 대신 Custom 필터 등록
                .addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)

                // OAuth2 로그인 설정
                .oauth2Login().loginPage("/")
                // 성공 시 수행할 핸들러
                .successHandler(successHandler)
                // OAuth2 로그인 성공 이후 설정
                .userInfoEndpoint().userService(oAuth2MemberService);

        return httpSecurity.build();
    }

    /**
     * 비밀번호 암호화 및 일치 여부 확인에 사용
     * @return BCryptPasswordEncoder
     */
    @Bean
    public BCryptPasswordEncoder encodePw(){
        return new BCryptPasswordEncoder();
    }

}

Preflight Request
본 요청을 보내기 전에 먼저 권한을 확인하여 본 요청의 유효성을 검사한다.



2) OAuth2MemberService

/**
 * access token을 사용하여 OAuth2 서버에서 유저 정보를 가져온다.
 * 데이터베이스에 Member를 저장/수정한다.
 * 가져온 유저 정보로 UserPrincipal을 만들어 반환한다.
 * UserPrincipal: UserDetails, OAuth2User를 implements한 커스텀 클래스
 */
@Service
@Slf4j
@RequiredArgsConstructor
public class OAuth2MemberService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    private final MemberRepository memberRepository;

    /**
     * OAuth2 로그인 성공 정보를 바탕으로 UserPrincipal을 만들어 반환한다
     * @param userRequest 로그인한 유저 리퀘스트
     * @return Authenticaion 객체에 담을 UserPrincipal(Custom)
     * @throws OAuth2AuthenticationException
     */
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        // 성공정보를 바탕으로 DefaultOAuth2UserService 객체를 만든 뒤 User를 받는다
        OAuth2User oAuth2User = new DefaultOAuth2UserService().loadUser(userRequest);

        // 구글 로그인인지, 네이버 로그인인지
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        // OAuth를 지원하는 소셜 서비스들간의 약속
        // 어떤 소셜서비스든 그 서비스에서 각 계정마다의 유니크한 id값을 전달해주겠다
        // 구글은 sub, 네이버는 id 필드가 유니크 필드
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();
        log.debug("registrationId: {}", registrationId);
        log.debug("userNameAttributeName: " + userNameAttributeName);

        // OAuth2UserService를 통해 가져온 데이터를 담을 클래스
        // attribute: {name, id, key, email, picture}
        OAuth2Attribute oAuth2Attribute = OAuth2Attribute.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        // 기존 회원이면 update, 신규 회원이면 save
        Member member = saveOrUpdate(oAuth2Attribute);

        // oAuth2User.getAttributes()로 가져오는 map은 수정 불가능한 맵
        // Member 테이블의 PK를 (username이 아니라) member_no로 잡고 있으므로
        // PK 값을 함께 Security Context에 저장하기 위해 평범한 map으로 변환
        Map<String, Object> attributes = oAuth2Attribute.toMap();
        attributes.put("memberNo", member.getMemberNo());

        // UserPrincipal: Authentication에 담을 OAuth2User와 (일반 로그인 용)UserDetails를 implements한 커스텀 클래스
        return UserPrincipal.create(member, oAuth2Attribute.getAttributes());
    }

    /**
     * DB에 사용자 정보를 저장/수정한다
     * @param oAuth2Attribute 엔티티를 만들 정보들
     * @return
     */
    public Member saveOrUpdate(OAuth2Attribute oAuth2Attribute){
    	// email과 provider로 조회
        // 없으면 oAuth2Attribute의 정보를 기반으로 Member 객체 만듦
        Member member = memberRepository.findByUsernameAndProvider(oAuth2Attribute.getEmail(), oAuth2Attribute.getProvider())
                .orElse(oAuth2Attribute.toEntity());

        return memberRepository.save(member);
    }
}

3) OAuth2SuccessHandler

@Slf4j
@AllArgsConstructor
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    private final JwtAuthTokenProvider jwtAuthTokenProvider;

    /**
     * 로그인 성공 시 부가작업
     * JWT 발급 후 token과 함께 리다이렉트
     * @param request 인증 성공된 리퀘스트
     * @param response
     * @param authentication Security Context의 Authentication
     * @throws IOException
     * @throws ServletException
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
        JwtAuthToken token = jwtAuthTokenProvider.createAuthToken(userPrincipal);

        String targetUrl = "http://localhost:3000/oauth/"
                + token.getToken();

        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }
}

4. 일반 로그인

1) LoginController

@Slf4j
@RestController
@RequiredArgsConstructor
public class HomeController {
    private final LoginService loginService;

    /**
     * 로그인
     * @param memberDto id, pw
     * @return JWT 토큰
     */
    @PostMapping("")
    public String login(@RequestBody MemberDto memberDto){
        return loginService.login(memberDto);
    }

    /* TODO 아이디/비밀번호 찾기 */
    // 메일 보내서 코드 확인
}

2) LoginService

@Slf4j
@Service
@RequiredArgsConstructor
public class LoginServiceImpl implements LoginService {
    private final JwtAuthTokenProvider tokenProvider;
    private final MemberRepository memberRepository;
    private final BCryptPasswordEncoder encoder;

    /**
     * MemberDto의 ID, PW를 사용하여 DB를 통한 인증
     * 인증 성공 시 그 과정에서 받아온 Member 객체를 사용해 UserPrincipal을 만들고,
     * UsernamePasswordAuthenticationToken를 Security Context에 저장
     * 이후 JwtAuthToken을 생성 후 리턴한다.
     *
     * @param memberDto
     * @return JwtAuthToken을 인코딩한 String 값 리턴
     */
    @Override
    public String login(MemberDto memberDto) {
        log.debug("parameter check: {}", memberDto);

        Member member = memberRepository.findByUsername(memberDto.getUsername())
                .orElseThrow(() -> new BadCredentialsException("아이디 오류"));

        // 비밀번호 일치 여부 검사
        if(!encoder.matches(memberDto.getPw(), member.getPw())){
            log.debug("비밀번호 오류");
            throw new BadCredentialsException("비밀번호 오류");
        }

        // 인증 성공
        // member 객체를 포함한 userPrincipal 생성
        UserPrincipal userPrincipal = UserPrincipal.create(member);

        // Authentication에 담을 토큰 생성
        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(userPrincipal, null, Collections.singleton(new SimpleGrantedAuthority("USER")));

        // security context에 저장
        SecurityContextHolder.getContext().setAuthentication(token);

        // 인코딩된 값 리턴
        return tokenProvider.createAuthToken(userPrincipal).getToken();
    }
}

5. 로그인 이후

1) JwtFilter

@Slf4j
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
    // 로그인 이후 토큰 자체에 대한 검증
    public static final String AUTHORIZATION_HEADER = "Authorization";
    private final JwtAuthTokenProvider tokenProvider;

    private Optional<String> resolveToken(HttpServletRequest request){
        // Request의 Header에 담긴 토큰 값을 가져온다

        String authToken = request.getHeader(AUTHORIZATION_HEADER);
        log.debug("authToken: " + authToken);

        // 공백 혹은 null이 아니면
        if(StringUtils.hasText(authToken)){
            return Optional.of(authToken);
        } else {
            return Optional.empty();
        }
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        log.debug("*** JWT FILTER ***");
        log.debug("주소: {}", request.getRequestURL());

        Optional<String> header = resolveToken(request);

        // Optional 안의 객체가 null이 아니면
        if(header.isPresent()){
            String token = header.get().split(" ")[1].trim();
            log.debug("split 이후: " + token);

            // header의 token로 token, key를 포함하는 새로운 JwtAuthToken 만들기
            JwtAuthToken jwtAuthToken = tokenProvider.convertAuthToken(token);

            // boolean validate() -> getData(): claims or null
            // 정상 토큰이면 해당 토큰으로 Authentication을 가져와서 SecurityContext에 저장
            if(jwtAuthToken.validate()){
                // UsernamePasswordAuthenticationToken(유저, authToken, 권한)
                Authentication authentication = tokenProvider.getAuthentication(jwtAuthToken);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }
}

tokenProvider.getAuthentication(jwtAuthToken);

상술한 TokenProvider에 포함된 메소드로, 커스텀한 UserDetailsService 클래스를 사용한다.
(나의ㅋㅋㅋ) 이해를 돕기 위해 한 번 더 첨부한다.

@Override
    public Authentication getAuthentication(JwtAuthToken authToken) {
        if(authToken.validate()){
            // authToken에 담긴 데이터를 받아온다
            Claims claims = authToken.getData();

            // memberNo가 들어있다.
            log.debug("claims.getSubject(): {}", claims.getSubject());

            UserPrincipal userPrincipal = (UserPrincipal) userDetailsService.loadUserByUsername(claims.getSubject());

            // 권한 없으면 authenticate false => too many redirect 오류 발생
            // principal, credential, role 다 쓰는 생성자 써야 super.setAuthenticated(true); 호출됨!
            return new UsernamePasswordAuthenticationToken(
                    userPrincipal,
                    null,
                    Collections.singleton(new SimpleGrantedAuthority("USER")));
        } else {
            throw new JwtException("token error!");
        }
    }

💡 TOO MANY REDIRECT 오류

이 부분에서 자꾸만 Too Many Redirect라는 오류가 발생했다.
(프론트를 기준으로) 최초 로그인 성공 시 '/garden'이라는 페이지로 이동하는데,
어쩐지 'localhost:8080/'이라는 GET 요청이 반복적으로 나타나는 것이다.

원인은 UsernamePasswordAuthenticationToken 생성자였다.

사실 not-a-gardener엔 ROLE이 불필요하여 principal, credential 두 개의 파라미터로 구성된 생성자를 사용하여 UsernamePasswordAuthenticationToken을 만들었다.

그런데 이 생성자는 authenticated 값에 false를 저장한다.

결국 Spring Security는 해당 사용자를 인증되지 않은 사용자로 판단하여,
계속 로그인 페이지로 돌려보내고 있었던 것이다.

principal, credentials, authorities를 모두 포함하는 생성자를 쓰면 해결된다.


2) CustomUserDetailsSerivce

@Slf4j
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    private final MemberRepository memberRepository;

    /**
     *
     * @param username memberNo가 저장되어 있다
     * @return member 객체를 포함한 UserPrincipal -> UsernamePasswordAuthenticationToken에 넣는다
     * @throws UsernameNotFoundException
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Member member = memberRepository.findById(Integer.parseInt(username))
                .orElseThrow(() -> new UsernameNotFoundException("해당 유저를 찾을 수 없습니다."));

        return new UserPrincipal(member);
    }
}

6. React

1) Login.jsx

별 건 없고 간편 로그인 버튼과 inputCheck 내부 axios의 콜백함수 정도만 보면 될 것 같다.

function Login(){
  console.log("Login");
  
  const [login, setLogin] = useState({
    username: "",
    pw: ""
  })

  const [msg, setMsg] = useState('');

  const onChange = (e) => {
    const {name, value} = e.target;
    setLogin(setLogin => ({...login, [name]: value}))
  }

  const navigate = useNavigate();

  // 입력 값 확인 및 submit
  const inputCheck = (e) => {
    e.preventDefault(); // reload 막기

      axios.post("/", login)
      .then((res) => {
        console.log("token", res);

        // local storage에 토큰 저장
        localStorage.setItem("login", res.data);

        // garden 페이지로 이동
        navigate('/garden');
      })
      .catch((error) => {
         setMsg(error.response.data.message);
      });

  }

  return (
    <div className="bg-light min-vh-100 d-flex align-items-center">
      <CContainer fluid>
        <CRow className="justify-content-center">
          <CCol md={8}>
            <CCardGroup>
              <CCard className="p-4">
                <CCardHeader className="mb-0">
                  <h2>로그인</h2>
                </CCardHeader>
                <CCardBody>
                  <p className="text-medium-emphasis">{msg}</p>
                  <CForm onSubmit={inputCheck} method="POST">
                    <CInputGroup className="mb-3">
                      <CInputGroupText>
                        <CIcon icon={cilUser} />
                      </CInputGroupText>
                      <CFormInput placeholder="ID" name="username" onChange={onChange}/>
                    </CInputGroup>
                    <CInputGroup className="mb-4">
                      <CInputGroupText>
                        <CIcon icon={cilLockLocked} />
                      </CInputGroupText>
                      <CFormInput
                        name="pw"
                        type="password"
                        placeholder="PW"
                        onChange={onChange}
                      />
                    </CInputGroup>
                    <CRow>
                      <CCol xs={4}>
                        <CButton type="submit" color="primary" className="px-4 align-self-start">로그인</CButton>
                      </CCol>
                      <CCol xs={4}></CCol>
                      <CCol xs={4}>
                        <CButton type="button" color="light" className="px-4 align-self-end">
                          계정 찾기
                        </CButton>
                      </CCol> 
                    </CRow>
                  </CForm>
                  <CRow className='mt-5'>
                    <h6>간편 로그인</h6>
                    <hr />
                    <CCol xs={4}>
                     <a href="http://localhost:8080/oauth2/authorization/kakao" class="social-button" id="kakao-connect"></a>
                    </CCol>
                    <CCol xs={4}>
                      <a href="http://localhost:8080/oauth2/authorization/google" class="social-button" id="google-connect"></a>
                    </CCol>
                    <CCol xs={4}>
                      <a href="http://localhost:8080/oauth2/authorization/naver" class="social-button" id="naver-connect"></a>
                    </CCol>
                  </CRow>
                </CCardBody>
              </CCard>
            </CCardGroup>
          </CCol>
        </CRow>
      </CContainer>
    </div>
  )
}

export default Login

2) RedirectHandler.js

OAuth2 로그인 이후 도착한 JWT 토큰을 localStorage에 추가한다.

우선 App.js에 다음과 같은 Route를 추가한다.

<Route path="/oauth/:token" element={<RedirectHandler />} />

:/token은 파라미터로 도착한 토큰을 받기위한 변수다.

RedirectHandler에서는 기본 로그인과 마찬가지로 localStorage에 토큰을 저장한 후, '/garden' 페이지로 보내준다.

const RedirectHandler = () => {
    let { token } = useParams();
    const navigate = useNavigate();

    console.log("token", token);

    useEffect(function(){
        localStorage.setItem("login", token);
        navigate("/garden");
    }, [])
    
    return (
        <></>
    )
}

export default RedirectHandler;

7. 나가며

1) 아쉬운 점

UsernamePasswordToken을 커스터마이징하거나 대체할 클래스를 알아봐야겠다.
username의 타입이 String인 탓에, 내가 필요한 member PK를 쓰기 위해선 계속 Integer.parseInt를 해야한다. 모양새가 좋지 않다.

2) 그래도 성공이라니 너무 좋아!

돌아돌아 왔지만...

auto-increment에 무슨 일이 있었던 건지 감도 안 잡히지만...

어쨌든 소셜 로그인, 일반 로그인 모두 성공이라니 행복하다😆!

0개의 댓글