🏆토이 프로젝트에서 생긴 일

[Spring] Spring Security 를 사용하여 OAuth2 와 JWT를 구현해보자

pkyung 2023. 12. 8. 17:26
반응형

 

 

안녕하세요. 오랜만에 돌아왔습니다. 

 

 

최근에 재밌는 사이드프로젝트를 해보려고합니다. 이 프로젝트에서는 OAuth2 로그인과 jwt를 사용하기로 해서 급하게 미뤄두었던 로그인 부분에 대해서 공부를 했습니다. 

 

 

강의도 듣고 블로그 글도 꽤나 많이 읽었는데요. OAuth2를 구현하는 글, jwt를 구현하는 글은 많았지만 OAuth2 이후 처리를 다룬 글이 별로 없어서 삽질을 조금 했습니다. 

로직에 대해서도 고민을 많이 했는데 로그인 인증 성공 시에 user 정보를 바탕으로 토큰을 header에 저장했습니다. 

 

부족한 부분이 있다면 댓글 남겨주세요. 

 

 

네이버 로그인과 구글 로그인으로 구현 진행했으며 폴더 관리는 아래와 같이 했습니다. 

 

1. build.gradle

jpa, oauth2, security, web, lombok, devtools, mysql, jwt 의존성을 프로젝트에 넣어주었습니다. 

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    implementation 'mysql:mysql-connector-java:8.0.28'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'

    implementation "io.jsonwebtoken:jjwt:0.9.1"
}

 

 

 

2. application.yml

 

yaml 파일입니다. 

기본 yml파일에는 db 정보와 encoding 정보를 가지고 있습니다. 그리고 jwt의 secretKey와 security 정보는 다른 yml파일에서 관리하기 위해 프로파일을 include 해주었습니다. 

spring:
  profiles:
    include:
      - jwt
      - security
  mvc:
    hiddenmethod:
      filter:
        enabled: true
  datasource:
    username: 유저 이름
    password: 비밀번호
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/데이터베이스 이름?serverTimezone=UTC
  jpa:
    hibernate.ddl-auto: update
    generate-ddl: true
    show-sql: true
server:
  servlet:
    encoding:
      force-response: true
      charset: UTF-8

 

application-jwt.yml

jwt:
  secretKey: 시크릿 키

 

application-security.yml

 

아래의 링크에서 api 신청을 해서 사용할 수 있습니다. 

https://console.cloud.google.com/welcome?hl=ko&project=oauth2study-407105

 

Google 클라우드 플랫폼

로그인 Google 클라우드 플랫폼으로 이동

accounts.google.com

https://developers.naver.com/products/login/api/api.md

 

네이버 로그인 - INTRO

환영합니다 네이버 로그인의 올바른 적용방법을 알아볼까요? 네이버 로그인을 통해 신규 회원을 늘리고, 기존 회원은 간편하게 로그인하게 하려면 제대로 적용하는 것이 중요합니다! 이에 올바

developers.naver.com

 

구글 로그인의 경우 spring security에 provider가 구현이 되어있는데 네이버 로그인은 그렇지 않아서 직접 입력해주어야합니다. 

spring:
  security:
    oauth2:
      client:
        registration:
          google:
            client-id: 클라이언트 아이디
            client-secret: 클라이언트 시크릿
            scope: email, profile
          naver:
            client-id: 클라이언트 아이디
            client-secret: 클라이언트 시크릿
            scope: email, profile
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/login/oauth2/code/naver
        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

 

 

 

3. SecurityConfig 설정

토큰 기반으로 인증을 진행할 것이기에 session은 생성하지 않았습니다. 

 

spring security는 filter 기반으로 인증을 진행합니다. 

JwtTokenFilter는 UsernamePasswordAuthenticationFilter 필터 이전에 처리되게 작성했습니다. 이 필터는 jwtToken이 올바른 토큰인지 확인하는 로직이 들어있습니다. 

 

그리고 oAuth2 로그인이 성공하면 principalDetailsService 라는 userService로 들어갑니다. 여기서 성공하면 SuccessHandler로 실패하면 failureHandler로 들어갑니다. 

 

SecurityConfig.java

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final OAuth2UserService principalDetailsService;
    private final OAuth2SuccessHandler oAuth2SuccessHandler;
    private final OAuth2FailureHandler oAuth2FailureHandler;
    private final JwtTokenFilter jwtTokenFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/user/**").authenticated()
                .antMatchers("/register").autheticated()
                .anyRequest().permitAll()
                .and()
                .addFilterBefore(jwtTokenFilter, UsernamePasswordAuthenticationFilter.class)
                .oauth2Login()
                .userInfoEndpoint()
                .userService(principalDetailsService)
                .and()
                .successHandler(oAuth2SuccessHandler)
                .failureHandler(oAuth2FailureHandler);
        return http.build();
    }
}

 

 

 

4. User domain, dto

User.java

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@Entity
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String email;
    private String username;
    private String nickname;

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }
}

 

UserRepository.java (interface)

public interface UserRepository extends Repository<User, Long> {
    Optional<User> findByUsername(String username);
    void save(User user);
}

 

LoginRequestDto.java

@Builder
@Getter
public class LoginRequestDto {

    private String nickname;
}

 

 

 

5. JwtTokenUtil

JwtTokenUtil.java

이 파일은 토큰을 생성하고 관리하는 역할을 합니다. 

createToken은 새로운 jwt 토큰을 생성하고 getLoginId는 추출된 claims 에서 loginId를 꺼냅니다. isExpired는 이 토큰이 만료 되었는지 확인하고 extractClaims 는 claims를 추출합니다. 

public class JwtTokenUtil {

    public static String createToken(String loginId, String key, long expireTimeMs) {
        Claims claims = Jwts.claims();
        claims.put("loginId", loginId);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + expireTimeMs))
                .signWith(SignatureAlgorithm.HS256, key)
                .compact();
    }

    public static String getLoginId(String token, String secretKey) {
        return extractClaims(token, secretKey).get("loginId").toString();
    }

    public static boolean isExpired(String token, String secretKey) {
        Date expiredDate = extractClaims(token, secretKey).getExpiration();
        return expiredDate.before(new Date());
    }

    public static Claims extractClaims(String token, String secretKey) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody();
    }
}

 

 

 

6. JwtTokenFilter

JwtTokenFilter.java

사용자 요청에서 토큰을 추출하여 분기들을 통과하면 권한을 부여하고 그렇지 않으면 다음 필터를 진행합니다. 분기문은 헤더가 비어있거나 Bearer로 시작하지 않거나 토큰이 만료되었는지를 확인합니다. 

@RequiredArgsConstructor
@Component
public class JwtTokenFilter extends OncePerRequestFilter {

    @Value("${jwt.secretKey}")
    private String secretKey;
    private final UserService userService;


    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
        
        // 헤더가 비었는지
        if (authorizationHeader == null) {
            filterChain.doFilter(request, response);
            return;
        }

        // 토큰이 Bearer 로 시작하는지
        if (!authorizationHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // 토큰이 Bearer 로 시작하니 빈칸을 기준으로 분할하여 토큰 추출
        String token = authorizationHeader.split(" ")[1];

        // 토큰이 만료되었는지 
        if (JwtTokenUtil.isExpired(token, secretKey)) {
            filterChain.doFilter(request, response);
            return;
        }

        String loginId = JwtTokenUtil.getLoginId(token, secretKey);

        User loginUser = userService.getLoginUserByLoginId(loginId);

        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                loginUser.getUsername(), null, Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")));
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        filterChain.doFilter(request, response);

    }
}

 

 

7. OAuth2UserService, OAuth2UserInfo

OAuth2UserService.java

소셜 로그인을 성공하면 유저 정보를 갖고 오게됩니다. 이를 통해 우리 어플리케이션의 회원 가입을 강제적으로 시키게 됩니다. 

 

username은 google_asdfasdfasdf(구글로그인 정보 중 sub 정보), naver_asdfasdfasdf(네이버 로그인 정보 중 id 정보) 의 형태로 저장하려합니다. 

 

네이버의 경우 attributes 안에 response 안에서 값을 접근할 수 있어서 NaverUserInfo 생성자에 값을 넣어줄 때 .get("response") 로 작업을 한 번 더 해주었습니다. 

 

이 로직을 성공하게 되면 SuccessHandler로 그렇지 않으면 FailureHandler로 이동합니다. 

@AllArgsConstructor
@Service
public class OAuth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

	    // 소셜에서 인증 받은 유저 정보 가져오기
        OAuth2User oAuth2User = super.loadUser(userRequest);
        
        // 어떤 소셜 로그인지 확인한 뒤
        // getClientRegistration().getREgistrationId() -> "google" or "naver"
        OAuth2UserInfo oAuth2UserInfo = null;
        if (userRequest.getClientRegistration().getRegistrationId().equals("google")) {
            oAuth2UserInfo = new GoogleUserInfo(oAuth2User.getAttributes());
        } else if (userRequest.getClientRegistration().getRegistrationId().equals("naver")) {
            oAuth2UserInfo = new NaverUserInfo((Map) oAuth2User.getAttributes().get("response"));
        }
        
	    // username 과 email 정보 추출함
        String provider = oAuth2UserInfo.getProvider();
        String providerId = oAuth2UserInfo.getProviderId();
        String username = provider + "_" + providerId;
        String email = oAuth2UserInfo.getEmail();
        
        
        // 추출한 username으로 db에서 검색
        Optional<User> user = userRepository.findByUsername(username);
        
	    // DefaultOAuth2User에 넣게될 username을 map으로 만듦
        HashMap<String, Object> usernameMap = new HashMap<>();
        usernameMap.put("username", username);

	    // 이미 존재하지 않는 회원일 경우 User db에 저장
        if (!user.isPresent()) {
            userRepository.save(
                    User.builder()
                            .email(email)
                            .username(username)
                            .build()
            );
        }

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                usernameMap, "username"
        );

    }
}

 

OAuth2UserInfo.java (interface)

구글 유저 정보와 네이버 유저 정보에 접근하는 방법이 달라서 OAuth2UserInfo라는 인터페이스를 생성하여 정보를 가져왔습니다. 

public interface OAuth2UserInfo {
    String getProviderId();
    String getProvider();
    String getEmail();
}

 

NaverUserInfo.java

public class NaverUserInfo implements OAuth2UserInfo{
    private Map<String, Object> attributes;

    public NaverUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }
    @Override
    public String getProviderId() {
        return (String) attributes.get("id");
    }

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

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

 

GoogleUserInfo.java

public class GoogleUserInfo implements OAuth2UserInfo{
    private Map<String, Object> attributes;

    public GoogleUserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }
    @Override
    public String getProviderId() {
        return (String) attributes.get("sub");
    }

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

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

 

 

8. OAuth2SuccessHandler

OAuth2SuccessHandler.java

회원가입 또는 로그인이 성공하게 되면 username을 jwt payload의 claims에 저장하게 되며 유효 시간은 1시간으로 설정하여 토큰을 생성합니다. 

그 후, 응답 헤더의 Authorization에 토큰을 저장했습니다. 

@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    @Value("${jwt.secretKey}")
    private String secretKey;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                        Authentication authentication) throws IOException, ServletException {

        OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();

        String username = (String) oAuth2User.getAttributes().get("username");

        // 토큰 유효시간은 60분
        String token = JwtTokenUtil.createToken(username, secretKey, 3600000);

        // 응답 헤더에 토큰 추가
        response.addHeader("Authorization", "Bearer " + token);

    }

}

 

OAuth2FailureHandler.java

회원 가입 또는 로그인이 실패하면 이 로직으로 오게 됩니다. 어떤 url로 redirect 할 지 설정할 수 있습니다. 

@RequiredArgsConstructor
@Component
public class OAuth2FailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        response.sendRedirect("/oauthFail");
    }
}

 

 

9. LoginController, LoginService, UserService

소셜 로그인 이후, User의 닉네임을 바꿔보기 위하여 진행한 부분입니다. 

여기는 알아서 원하시는 로직 짜시면 됩니다. 

 

LoginController.java

@AllArgsConstructor
@RestController
public class LoginController {

    private final LoginService loginService;

    @GetMapping("/register")
    public String register(LoginRequestDto loginRequestDto, Authentication auth) {
        return loginService.register(loginRequestDto, auth.getName());
    }
}

 

LoginService.java

@AllArgsConstructor
@Service
public class LoginService {

    private final UserRepository userRepository;

    @Transactional
    public String register(LoginRequestDto loginRequestDto, String username) {
        User user = userRepository.findByUsername(username).orElseThrow(() -> new IllegalArgumentException("해당 멤버가 없습니다."));
        user.setNickname(loginRequestDto.getNickname());
        return loginRequestDto.getNickname();
    }
}

 

UserService.java

@AllArgsConstructor
@Service
public class UserService {
    private final UserRepository userRepository;

    public User getLoginUserByLoginId(String loginId) {
        return userRepository.findByUsername(loginId)
                .orElseThrow(() -> new IllegalArgumentException("해당 로그인 유저가 없습니다."));
    }
}

 

 

10. 테스트

localhost:8080/login에 접근하게 되면 이런 창이 뜨게 됩니다. 

버튼을 누르면 

 

소셜 로그인 창이 뜹니다. 

 

db table을 확인해보면 값일 잘 들어간 것으로 확인됩니다. 

 

토큰의 경우 http response의 헤더에 담았었습니다.  f12 -> network 창에 들어가면 생성된 토큰을 볼 수 있습니다. 

 

Postman을 통해 닉네임도 바꿔보려합니다. Auth에 위의 토큰에서 Bearer 를 제거해서 넣어준 뒤, 요청을 보냅니다.

 

nickname 또한 잘 들어간 것을 확인할 수 있습니다. 

 

11. 각종 레퍼런스

공부하면서 도움이 된 자료들입니다. 

 

https://www.udemy.com/share/104jcw3@AcQ2QnRhjmk6dAdCHLNd3GjyjrLJQhdgj3vlbWJlcUMTPWB5PtxAzv7sqcszGcD4GA==/

https://youtube.com/playlist?list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah&si=W1QftPKLPAObBFJw

 

Springboot - 시큐리티 특강

함께 사는 세상 하지만 믿지는마.. 왜냐면 남탓할꺼자낭!!

www.youtube.com

https://chb2005.tistory.com/178

 

[Spring Boot] Spring Security를 사용한 Jwt Token 로그인 구현

JWT란? JSON Web Token의 줄임말로 JSON 객체로 정보를 주고 받을 때, 안전하게 전송하기 위한 방식 HMAC, RSA 등의 암호화 방식을 사용해 서명함 로그인 기능 구현 등에 사용 JWT 구조 JWT는 Header, Payload, Sig

chb2005.tistory.com

https://inkyu-yoon.github.io/docs/Language/SpringBoot/OauthLogin#7-oauth2successhandler-%EC%84%A4%EC%A0%95

 

· Spring Security 소셜 로그인 로직 구현하기

👩🏻‍💻 지식 창고 📚

inkyu-yoon.github.io

https://velog.io/@jkijki12/Spring-Boot-OAuth2-JWT-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EB%A6%AC%EA%B8%B0

 

[Spring Boot] OAuth2 + JWT + React 적용해보리기

오늘 팀원이랑 이야기를 해보다가 우려했던 일이 벌어졌다.. 우려했던 일이란?우려했던 일(현재 문제점)개선 방안OAuth2란?With Spring Boot구현현재까지 구현되었던 프로젝트의 로그인과정을 살펴보

velog.io

 

반응형