[Spring Security 6.x] OAuth2.0(네이버, 구글, 깃허브) + JWT

dh·2024년 9월 23일
0

인증(Authentication)

목록 보기
4/5
post-thumbnail


클라이언트 소셜로그인 버튼(/oauth2/authorization/서비스명) → spring서버의 OAuth2AuthorizationRequestRedirectFilter가 해당 요청을 잡아서 인증서버에 요청 → 인증 서버(네이버) →

로그인페이지 응답 → 네이버 로그인 진행 → 리다이렉트URL, Code(login/oauth2/code/서비스명) → spring서버의 OAuth2LoginAuthenticationFilter에서 가로챈다. → OAuth2LoginAuthenticationProvider에서 code를 꺼낸다. → code를 인증서버 보내서 access토큰 발급 → access토큰을 리소스 서버로 보내서 유저 정보 획득. → OAuth2Service에서 유저정보 획득하고 OAuth2User에 담아서 로그인 진행 → 로그인 성공 → LoginSuccessHandler에서 JWT 발급 후 유저쪽으로 보냄

이후로 클라이언트에서 모든 요청에대해 JWT를 보내서 JWTFilter가 검증을 하고 임시적인 세션을 만든다.

네이버

https://developers.naver.com/main/

네이버 개발자 페이지에서 네이버 로그인 애플리케이션 등록


서비스URL, 리다이렉트URL 등록

구글

https://cloud.google.com/?_gl=1*1acqvsj*_up*MQ..&gclid=EAIaIQobChMI7vPtgoCEiAMVaWgPAh1FwxPWEAAYASAAEgIn2fD_BwE&gclsrc=aw.ds


구글클라우드 -> 오른쪽 상단 콘솔에서 프로젝트 생성
API 및 서비스 -> 사용자 인증정보 만들기 -> 리다이렉트 URI 등록

깃허브

깃허브 -> settings -> Developer Settings -> OAuth Apps 생성

SecurityConfig


@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomOAuth2UserService customOAuth2UserService;

    private final CustomSuccessHandler customSuccessHandler;

    private final JWTUtil jwtUtil;

    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;

    //private final RedisTemplate<String ,String> redisTemplate;
    private final LogoutService logoutService;

    @Value("${client.host}")
    private String clientHost;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        // CORS
        http
                .cors(corsCustomizer -> corsCustomizer.configurationSource(corsConfigurationSource()));

        // CSRF disable
        http
                .csrf((auth) -> auth.disable());

        // Form 로그인 방식 disable
        http
                .formLogin((auth) -> auth.disable());

        // HTTP Basic 인증 방식 disable
        http
                .httpBasic((auth) -> auth.disable());

        // JWTFilter 추가   UsernamePasswordFilter 이전에 등록
        http
                .addFilterBefore(new JWTFilter(jwtUtil, logoutService), UsernamePasswordAuthenticationFilter.class);
        // 로그아웃
        http
                .addFilterBefore(new CustomLogoutFilter(jwtUtil, logoutService), LogoutFilter.class);

        // oauth2
        http
                .oauth2Login((oauth2) -> oauth2
                        .userInfoEndpoint((userInfoEndpointConfig -> userInfoEndpointConfig
                                .userService(customOAuth2UserService)))
                        .successHandler(customSuccessHandler));

        //인증실패시
        http.
                exceptionHandling(exceptionHandling -> exceptionHandling
                        .authenticationEntryPoint(customAuthenticationEntryPoint));


        // 경로별 인가 작업
        http
                .authorizeHttpRequests((auth) -> auth
                        .requestMatchers("/files/**").permitAll()
                        .requestMatchers("/login", "/").permitAll()
                        .requestMatchers("/reissue").permitAll()
                        .requestMatchers("/convert", "/hot6man/convert").permitAll()
                        .anyRequest().authenticated());

        // 세션 설정 : STATELESS
        http
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(Arrays.asList(clientHost, "http://localhost:5173", jenkinsUrl, openviduUrl));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        configuration.setAllowCredentials(true);
        configuration.setAllowedHeaders(Collections.singletonList("*"));
        configuration.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization"));
        configuration.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

application.yml 등록

 security:
    oauth2:
      client:
        registration:
          naver:
            client-name: naver
            client-id: [client ID등록]
            client-secret: [client secret 등록]
            redirect-uri: http://localhost:8080/hot6man/login/oauth2/code/naver
            authorization-grant-type: authorization_code
            scope: name, email
          google:
            client-name: google
            client-id: [client ID등록]
            client-secret: [client secret 등록]
            redirect-uri: http://localhost:8080/hot6man/login/oauth2/code/google
            authorization-grant-type: authorization_code
            scope: profile, email
          github:
            client-name: github
            client-id:  [client ID등록]
            client-secret: [client secret 등록]
             redirect-uri: http://localhost:8080/hot6man/login/oauth2/code/github
            
            authorization-grant-type: authorization_code
            scope: user, repo
        provider:
          naver:
            authorization-uri: https://nid.naver.com/oauth2.0/authorize
            token-uri: https://nid.naver.com/oauth2.0/token
            user-info-uri: https://openapi.naver.com/v1/nid/me
            user-name-attribute: response

redirect-uri가 위에서 각각 등록한 callback url과 동일해야합니다.

프론트엔드(Vue)


<a :href="`http://localhost:8080/hot6man/oauth2/authorization/naver`"></a>
<a :href="`http://localhost:8080/hot6man/oauth2/authorization/google`"></a>
<a :href="`http://localhost:8080/hot6man/oauth2/authorization/github`" ></a>

다음과 같이 로그인 경로의 하이퍼링크를 통해 로그인 페이지로 이동합니다.

아이디와 비밀번호를 입력하면 등록한 callback url로 리다이렉트하고 spring서버의 OAuth2LoginAuthenticationFilter에서 가로챈다.
OAuth2LoginAuthenticationProvider에서 code를 꺼내고 code를 인증서버 보내서 access토큰 발급 → access토큰을 리소스 서버로 보내서 유저 정보 획득.

CustomOAuth2UserService

@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class CustomOAuth2UserService extends DefaultOAuth2UserService {

    private final MemberRepository memberRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        //리소스 서버로 부터 받을 유저정보
        OAuth2User oAuth2User = super.loadUser(userRequest);
        log.info("oAuth2User: {}", oAuth2User);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        log.info("registrationId: {}", registrationId);

        OAuth2Response oAuth2Response = null;


        if(registrationId.equals("naver")){
            log.info("naver 로그인");
            oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
        }else if(registrationId.equals("google")){
            log.info("google 로그인");
            oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
        }else if(registrationId.equals("github")){
            log.info("github 로그인");
            oAuth2Response = new GithubResponse(oAuth2User.getAttributes());
        }else{
            return null;
        }

        //리소스 서버에서 발급 받은 정보로 사용자를 특정할 아이디값

        log.info("oAuth2Response: {}", oAuth2Response);
        String username = oAuth2Response.getProvider() + " " + oAuth2Response.getProviderId();
        String email = oAuth2Response.getEmail();
        Member existData = memberRepository.findByEmail(email);

        // 가입한 유저 email이 없을 경우 email DB에 등록하고 로그인
        if(existData == null){
            log.info("신규 가입 이메일");
            Member member = new Member();
            member.setEmail(email);
            member.setNickname(oAuth2Response.getName());
            member.setName(oAuth2Response.getName());
            member.setRole("ROLE_USER");
            member.setLgnMtd(oAuth2Response.getProvider());

            memberRepository.save(member);

            UserDTO userDTO = new UserDTO();
            userDTO.setId(member.getId());
            userDTO.setUsername(username);
            userDTO.setEmail(email);
            userDTO.setName(oAuth2Response.getName());
            userDTO.setRole("ROLE_USER");


            return new CustomOAuth2User(userDTO);
        }
        //이미 가입한 이메일
        else{
            log.info("이미 가입한 이메일");
            //직접 이메일로 가입한 경우
            if(existData.getLgnMtd().equals("builtin")){
                log.info("이메일 가입하기로 등록된 이메일");
                return null;
            }
            // oauth로 가입한 경우 바로 로그인 진행
            else{
                log.info("OAuth 가입하기로 등록된 이메일");
                UserDTO userDTO = new UserDTO();
                userDTO.setId(existData.getId());
                userDTO.setUsername(username);
                userDTO.setEmail(email);
                userDTO.setName(oAuth2Response.getName());
                userDTO.setRole("ROLE_USER");

                return new CustomOAuth2User(userDTO);

            }


        }

    }
}

google과 naver의 oAuth2User 응답 데이터 형식이 조금 다르므로 응답을 담을 인터페이스 생성하여 각자 응답 형식에 맞게 수정

public interface OAuth2Response {

    // 제공자(naver, google, github...)
    String getProvider();

    //제공자가 발급해주는 id(번호)
    String getProviderId();

    //이메일
    String getEmail();

    //사용자 실명(설정한 이름)
    String getName();

}


public class NaverResponse implements OAuth2Response{

    private final Map<String, Object> attribute;

    public NaverResponse(Map<String, Object> attribute) {
        this.attribute = (Map<String, Object>) attribute.get("response");
    }

    @Override
    public String getProvider() {
        return "naver";
    }

    @Override
    public String getProviderId() {
        return attribute.get("id").toString();
    }

    @Override
    public String getEmail() {
        return attribute.get("email").toString();
    }

    @Override
    public String getName() {
        return attribute.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();
    }
}

DB에 없는 처음 로그인한 ID면 DB에 넣어서 바로 가입을 진행한다.
그리고 user의 정보를 userDTO에 넣고 CustomOAuth2User에 userDTO를 담아서 보내준다.

CustomOAuth2User

public class CustomOAuth2User implements OAuth2User {

    private final UserDTO userDTO;

    public CustomOAuth2User(UserDTO userDTO) {
        this.userDTO = userDTO;
    }

    @Override
    public Map<String, Object> getAttributes() {
        return null;
    }

    //role값
    @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();
    }

    public Long getId(){ return userDTO.getId(); }

    public String getEmail(){return userDTO.getEmail(); }

    @Override
    public String toString() {
        return "CustomOAuth2User{" +
                "userDTO=" + userDTO +
                '}';
    }
}

CustomSuccessHandler

로그인이 성공하여 CustomSuccessHandler에서 토큰 발급을 처리한다.

@Component
@RequiredArgsConstructor
@Slf4j
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    @Value("${client.host}")
    private String clinetHost;

    private final LoginService loginService;


    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        log.info("[CustomSuccessHandler]");
        //OAuth2User
        CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal();

        String email = customUserDetails.getEmail();


        Map<String, Cookie> cookies = loginService.login(email);


        response.addCookie(cookies.get("access"));
        response.addCookie(cookies.get("refresh"));
        response.sendRedirect(clinetHost+"/afterlogin");

    }



}
@Service
@RequiredArgsConstructor
@Slf4j
public class LoginService {

    @Value("${jwt.access-token.expiretime}")
    private Long accessExpiretime;

    @Value("${jwt.refresh-token.expiretime}")
    private Long refreshExpiretime;

    private final MemberRepository memberRepository;
    private final JWTUtil jwtUtil;
    private final RedisTemplate<String ,String> redisTemplate;

    public Map<String, Cookie> login(String email) {
        log.info("loginService email: {}", email);
        Member member = memberRepository.findByEmail(email);

        MemberDto memberDto = MemberDto.builder()
                .id(member.getId())
                .email(member.getEmail())
                .name(member.getName())
                .nickname(member.getNickname())
                .role(member.getRole())
                .build();


        //JWT 생성
        String accessToken = jwtUtil.createAccessToken(memberDto, accessExpiretime);
        String refreshToken = jwtUtil.createRefreshToken(memberDto, refreshExpiretime);

        //리프레시토큰 저장
        redisTemplate.opsForValue().set(member.getId().toString(), refreshToken, refreshExpiretime, TimeUnit.MILLISECONDS);

        Map<String, Cookie> map = new HashMap<>();
        Cookie access = createCookie("access", accessToken, accessExpiretime);
        Cookie refresh = createCookie("refresh", refreshToken, refreshExpiretime);
        map.put("access",access);
        map.put("refresh",refresh);
        return map;
    }

    private Cookie createCookie(String key, String value, Long expiretime){
        log.info("JWT 담을 쿠키 생성");
        Cookie cookie = new Cookie(key, value);
        cookie.setMaxAge(expiretime.intValue());
        //cookie.setSecure(true);   //https 프로토골
        cookie.setPath("/");
        cookie.setHttpOnly(true);

        return cookie;
    }
}
@Component
@Slf4j
public class JWTUtil {

    private SecretKey secretKey;

    public JWTUtil(@Value("${jwt.salt}")String secret){
        secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
    }

    public String getUsername(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("username", String.class);
    }

    public String getName(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("name", String.class);
    }

    public String getRole(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
    }

    public Long getId(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("id", Long.class);
    }

    public String getEmail(String token) {

        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("email", String.class)
                .replace("\"", "");
    }



    public Boolean isExpired(String token) {
        log.info("[JWTUtil] 토큰 만료 검증");
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
    }

    public long getRemainingTime(String token) {
        Date expiration1 = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration();

        long currentTimeMillis = System.currentTimeMillis();

        // 남은 시간 계산 (밀리초 단위)
        long remainingTimeMillis = expiration1.getTime() - currentTimeMillis;

        return remainingTimeMillis;
    }

    public String getCategory(String token){
        return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
    }


    //토큰 생성
    public String createJwt(MemberDto memberDto, String category, Long expiredMs) {
        log.info("[JWTUtil] JWT토큰 생성");
        Long id = memberDto.getId();
        String role = memberDto.getRole();
        String name = memberDto.getName();
        String email = memberDto.getEmail();

        return Jwts.builder()
                .claim("name",name)
                .claim("email",email)
                .claim("id", id)
                .claim("category",category)
                .claim("role", role)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + expiredMs))
                .signWith(secretKey)
                .compact();
    }


    public String createEmailJwt(String email){
        return Jwts.builder()
                .claim("email",email)
                .issuedAt(new Date(System.currentTimeMillis()))
                .expiration(new Date(System.currentTimeMillis() + 259200000))
                .signWith(secretKey)
                .compact();
    }


    public String createAccessToken(MemberDto memberDto, Long expiredMs) {
        return createJwt(memberDto, "access", expiredMs);
    }

    public String createRefreshToken(MemberDto memberDto, Long expiredMs) {
        return createJwt(memberDto, "refresh", expiredMs);
    }




}

Access Token과 Refresh Token을 생성하여 쿠키에 담는다.
Refresh Token은 Redis에 토큰만료시간만큼 설정하고 저장한다.

Redis로 Refresh Token을 관리하는 이유?

로그아웃을 하면 쿠키를 삭제 처리 해줄거지만 Refresh토큰이 탈취당한 상황이라면 로그아웃을 했음에도 토큰의 만료시간은 남아있기 때문에 탈취된 Refresh토큰으로 access Token을 리이슈할 수 있다고 생각.
그래서 만료시간이 남아 있어도 못 쓰게 하기 위해 로그아웃시 Rfresh Token을 BlackList처리를 할건데, 이것을 일반DB에 저장하면 BlackList된 토큰인지 검증하는 과정과 기간만료시 삭제할 때마다 DB커넥션이 생길것.
하지만 Redis는 TTL(Time-To-Live)기능으로 데이터의 만료시간을 설정하여 일정시간이 지난 뒤에는 자동으로 Refresh Token이 삭제되어 따로 관리하지 않아도 되고, BlackList를 검증할 때도 In-Memory기반으로 DB에 비해 속도가 빨라서 서버와 DB간의 병목을 줄일 수 있다고 판단.

토큰을 쿠키로 받는 이유?

하이퍼링크를 통해 로그인 페이지로 이동하여 로그인을 수행합니다.
api요청이 아니라 하이퍼링크로 실행하여 JWT를 응답헤더로 받을 로직이 없습니다.
저는 Refresh Token은 쿠키에 저장할거지만, Access Token은 local storage에 저장할 계획이라 서버에서 토큰들을 쿠키에 저장하고 프론트로 /afterlogin이라는 경로로 리다이렉트 시킵니다.
/afterlogin으로 리다이렉트되면 화면이 onmount되자마자 다시 서버로 쿠키들을 보내서 응답 헤더로 Access Token을 넘겨받고 다시 local storage에 저장합니다.

vue 요청

 const authSaveAccessLocalStorage = async () => {
    await saveAccessLocalStorage(
      async (response) => {
        let accessToken = response.headers['authorization'];
    
        if (accessToken) {
          accessToken = (accessToken || '').split(' ')[1];

          try {
            await setAuthData(accessToken); // setUser가 완료될 때까지 기다림
            console.log("액세스토큰 로컬스토리지에 저장");
            isLogined.value = true;
            // '/'로 리디렉트
            router.replace({ path: '/' });

          } catch (error) {
            console.error('Error setting user:', error);
          }
        }
      },
      (error) => {
        console.error('Error:', error);
      }
    );
  };

spring


@RestController
@Slf4j
public class JwtHeaderController {
    @CrossOrigin(origins = "http://localhost:5173", allowCredentials = "true")
    @GetMapping("/convert")
    public ResponseEntity<?> convertJwtHeader(HttpServletRequest request, HttpServletResponse response, @CookieValue(value = "access", required = false) Cookie accessCookie) throws IOException {
        log.info("/convert 컨트롤러");
        String access = accessCookie.getValue();
        System.out.println("coockie = "+access);

        if (access != null) {
            // 액세스 토큰을 로컬 스토리지에 저장하기 위해 헤더에 포함
            //리프레시 토큰 쿠키 값 0
            Cookie newCookie = new Cookie("access",null);
            newCookie.setMaxAge(0);
            newCookie.setPath("/");
            response.addCookie(newCookie);
            response.setHeader("Authorization", "Bearer " + access);
            
            // 리다이렉트
            return new ResponseEntity<>(NORMAL_RESPONSE.getHttpStatus());
        } else {
            return new ResponseEntity<>(INVALID_TOKEN.getHttpStatus());
        }

    }
}

0개의 댓글