본문 바로가기
Spring

OAuth2.0 + JWT를 사용한 토큰 기반 서버 인증 구현하기

by zkdlu 2021. 4. 12.

토큰 기반 인증

인증받은 사용자들에게 토큰을 발급하여, 서버에 접근할 때 토큰 정보를 함께 보내어 인증을 합니다. 서버에서는 사용자 인증 벙보를 세션으로 가지고 있을 필요가 없기 때문에 Stateless한 구조라고 하며, 서버를 확장하기 용이합니다.

 

인증에 사용할 OAuth2 Provider

  • Google 
  • Naver
  • Kakao

 

개발 전 준비 사항

OAuth2는 인증서버에서 로그인 후 인증서버에서 우리의 서버로 토큰을 보내주기 위해 Callback url을 등록해주어야 합니다.

OAuth2를 제공해주는 개발자 센터에 OAuth2 인증을 사용하기 위한 어플리케이션 등록 합니다.

developers.kakao.com/

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

cloud.google.com/

 

클라우드 컴퓨팅 서비스  |  Google Cloud

데이터 관리, 하이브리드 및 멀티 클라우드, AI 및 머신러닝 등 Google의 클라우드 컴퓨팅 서비스로 비즈니스 당면 과제를 해결하세요.

cloud.google.com

developers.naver.com/main/

 

NAVER Developers

네이버 오픈 API들을 활용해 개발자들이 다양한 애플리케이션을 개발할 수 있도록 API 가이드와 SDK를 제공합니다. 제공중인 오픈 API에는 네이버 로그인, 검색, 단축URL, 캡차를 비롯 기계번역, 음

developers.naver.com

 

실습 예제

Spring Boot환경에서 Spring Security를 이용한 인증/인가 구현을 할 것이며 최초 로그인 시 OAuth2 인증을 통해 우리의 서버에 접근할 수 있는 JWT토큰을 발급해줄 것입니다. 

이후 서버에 접근할 때 Request Header에 발급해준 토큰을 함께 보내면 Spring Security의 UsernamePasswordAuthenticationFilter 앞에 등록된 필터에서 인증정보를 생성해줍니다.

 

환경

  • OpenJDK 11
  • Spring boot 2.3.9.RELEASE
  • org.springframework.boot:spring-boot-starter-web
  • org.springframework.boot:spring-boot-starter-security
  • org.springframework.boot:spring-boot-starter-oauth2-client
  • io.jsonwebtoken:jjwt:0.9.1
  • javax.xml.bind:jaxb-api
JWT를 발급하면서 사용하는 메서드에서 javax.xml.bind.DatatypeConverter 클래스를 사용하는데 Java 9부터 XML과 관련된 모듈이 분리되었기 때문에 jaxb-api 의존성을 추가해주어야 합니다.

 

1. 인증이 정상적으로 처리되면 접근할 수 있는 Controller를 작성합니다.

@RestController
public class TestApi {
    @GetMapping("/test")
    public String index() {
        return "Hello World";
    }
}

 

2. Api 접근이 정상적으로 되는지 확인 후 Security 설정을 합니다.

rest api이기 때문에 http 로그인 페이지 폼이 없고, csrf 보안이 필요없습니다. 또한 토큰기반 인증을 할꺼니까 세션도 생성을 하지 않겠습니다.

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic().disable()
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
                .authorizeRequests()
                    .anyRequest().authenticated();
    }
}
아직 OAuth2 인증을 위한 준비가 되지않았으니 관련 설정은 나중에 해줍니다.

 

3. 개발자 센터에 발급해준 id와 secret, 인증서버로 부터 callback을 받은 uri를 지정해줍니다.

Spring boot OAuth2 Client는 Google과 Facebook에 대한 인증서버 정보만 가지고 있기 때문에 카카오와 네이버의 인증서버 정보를 추가해주어야 합니다.
login url은 /oauth2/authorization/<registration id> redirect uri는 /login/oauth2/code/<registration id> 형태로 Spring이 제공하는 기본값에 맞춰 설정해줍니다.

spring:
  security:
    oauth2:
      client:
        registration:
          kakao:
            client-id: <your id>
            client-secret: <your secret>
            redirect-uri: <your url>/login/oauth2/code/kakao
            authorization-grant-type: authorization_code
            client-authentication-method: POST
            client-name: Kakao
            scope:
              - profile
              - account_email
          naver:
            client-id: <your id>
            client-secret: <your secret>
            redirect-uri:  <your url>/login/oauth2/code/naver
            authorization-grant-type: authorization_code
            scope:
              - name
              - email
          google:
            client-id: <your id>
            client-secret: your secret>
            scope:
              - profile
              - email
        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
          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

 

 

4. OAuth2 인증을 완료하고 받은 데이터로 우리의 서비스에 접근할 수 있도록 인증 정보를 생성해주는 서비스를 작성합니다.

OAuth2UserService 인터페이스를 구현한 CustomOAuth2UserService를 만들어줍니다. OAuth2 인증 후 보내주는 데이터가 각 인증 서버마다 다르기 때문에 이곳에서 별도의 분기 처리를 해줘야 합니다.

@Slf4j
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        OAuth2UserService<OAuth2UserRequest, OAuth2User> oAuth2UserService = new DefaultOAuth2UserService();
        OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest);

        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        String userNameAttributeName = userRequest.getClientRegistration()
                .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName();

        OAuth2Attribute oAuth2Attribute =
                OAuth2Attribute.of(registrationId, userNameAttributeName, oAuth2User.getAttributes());

        log.info("{}", oAuth2Attribute);

        var memberAttribute = oAuth2Attribute.convertToMap();

        return new DefaultOAuth2User(
                Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                memberAttribute, "email");
    }
}

@ToString
@Builder(access = AccessLevel.PRIVATE)
@Getter
class OAuth2Attribute {
    private Map<String, Object> attributes;
    private String attributeKey;
    private String email;
    private String name;
    private String picture;

    static OAuth2Attribute of(String provider, String attributeKey, 
                                        Map<String, Object> attributes) {
        switch (provider) {
            case "google":
                return ofGoogle(attributeKey, attributes);
            case "kakao":
                return ofKakao("email", attributes);
            case "naver":
                return ofNaver("id", attributes);
            default:
                throw new RuntimeException();
        }
    }

    private static OAuth2Attribute ofGoogle(String attributeKey, 
                                                Map<String, Object> attributes) {
        return OAuth2Attribute.builder()
                .name((String) attributes.get("name"))
                .email((String) attributes.get("email"))
                .picture((String)attributes.get("picture"))
                .attributes(attributes)
                .attributeKey(attributeKey)
                .build();
    }

    private static OAuth2Attribute ofKakao(String attributeKey, 
                                               Map<String, Object> attributes) {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account");
        Map<String, Object> kakaoProfile = (Map<String, Object>) kakaoAccount.get("profile");

        return OAuth2Attribute.builder()
                .name((String) kakaoProfile.get("nickname"))
                .email((String) kakaoAccount.get("email"))
                .picture((String)kakaoProfile.get("profile_image_url"))
                .attributes(kakaoAccount)
                .attributeKey(attributeKey)
                .build();
    }

    private static OAuth2Attribute ofNaver(String attributeKey, 
                                               Map<String, Object> attributes) {
        Map<String, Object> response = (Map<String, Object>) attributes.get("response");

        return OAuth2Attribute.builder()
                .name((String) response.get("name"))
                .email((String) response.get("email"))
                .picture((String) response.get("profile_image"))
                .attributes(response)
                .attributeKey(attributeKey)
                .build();
    }

    Map<String, Object> convertToMap() {
        Map<String, Object> map = new HashMap<>();
        map.put("id", attributeKey);
        map.put("key", attributeKey);
        map.put("name", name);
        map.put("email", email);
        map.put("picture", picture);

        return map;
    }
}

 

5. 토큰을 발급하고 검증 할 수 있는 컴포넌트를 작성합니다.

토큰은 API서버에 접근하기 위한 인증 토큰과 인증 토큰이 만료되었을 경우 리프레쉬에 사용할 리프레쉬 토큰으로 이루어져 있습니다.

인증 토큰의 만료시간은 10분, 리프레쉬 토큰의 만료시간은 3주로 하였습니다.

@ToString
@NoArgsConstructor
@Getter
public class Token {
    private String token;
    private String refreshToken;

    public Token(String token, String refreshToken) {
        this.token = token;
        this.refreshToken = refreshToken;
    }
}

@Service
public class TokenService {
    private String secretKey = "token-secret-key";

    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
    }

    @Override
    public Token generateToken(String uid, String role) {
        long tokenPeriod = 1000L * 60L * 10L;
        long refreshPeriod = 1000L * 60L * 60L * 24L * 30L * 3L;

        Claims claims = Jwts.claims().setSubject(uid);
        claims.put("role", role);

        Date now = new Date();
        return new Token(
            Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenPeriod))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact(), 
            Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + refreshPeriod))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact());
    }

    @Override
    public boolean verifyToken(String token) {
        try {
            Jws<Claims> claims = Jwts.parser()
                    .setSigningKey(secretKey)
                    .parseClaimsJws(token);
            return claims.getBody()
                    .getExpiration()
                    .after(new Date());
        } catch (Exception e) {
            return false;
        }
    }

    @Override
    public String getUid(String token) {
        return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
    }
}

 

6. OAuth2 로그인 성공 핸들러에서 토큰을 생성 후 response header에 추가해서 보내줍니다.

핸들러에서는 유저 서비스를 이용해 회원가입 및 로그인 처리가 가능합니다.

@NoArgsConstructor
@Getter
public class UserDto {
    private String email;
    private String name;
    private String picture;

    @Builder
    public UserDto(String email, String name, String picture) {
        this.email = email;
        this.name = name;
        this.picture = picture;
    }
}

@Component
public class UserRequestMapper {
    public UserDto toDto(OAuth2User oAuth2User) {
        var attributes = oAuth2User.getAttributes();
        return UserDto.builder()
                .email((String)attributes.get("email"))
                .name((String)attributes.get("name"))
                .picture((String)attributes.get("picture"))
                .build();
    }

    public UserFindRequest toFindDto(UserDto userDto) {
        return new UserFindRequest(userDto.getEmail());
    }
}

@Slf4j
@RequiredArgsConstructor
@Component
public class OAuth2SuccessHandler implements AuthenticationSuccessHandler {
    private final TokenService tokenService;
    private final UserRequestMapper userRequestMapper;
    private final ObjectMapper objectMapper;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) 
                throws IOException, ServletException {
        OAuth2User oAuth2User = (OAuth2User)authentication.getPrincipal();
        UserDto userDto = userRequestMapper.toDto(oAuth2User);
        
        // 최초 로그인이라면 회원가입 처리를 한다.

        Token token = tokenService.generateToken(userDto.getEmail(), "USER");
        log.info("{}", token);

        writeTokenResponse(response, token);
    }

    private void writeTokenResponse(HttpServletResponse response, Token token) 
                throws IOException {
        response.setContentType("text/html;charset=UTF-8");

        response.addHeader("Auth", token.getToken());
        response.addHeader("Refresh", token.getRefreshToken());
        response.setContentType("application/json;charset=UTF-8");

        var writer = response.getWriter();
        writer.println(objectMapper.writeValueAsString(token));
        writer.flush();
    }
}

 

7. Security 설정에 OAuth2 로그인을 활성화하고 앞서 만든 서비스와 인증이 성공하면 처리할 Handler를 등록합니다.

@RequiredArgsConstructor
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final CustomOAuth2UserService oAuth2UserService;
    private final OAuth2SuccessHandler successHandler;
    
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic().disable()
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
                .authorizeRequests()
                    .anyRequest().authenticated()
            .and()
                .oauth2Login()
                    .successHandler(successHandler)
                    .userInfoEndpoint().userService(oAuth2UserService);
    }
}

 

8. 프로젝트 실행 후 /oauth2/authorization/naver 로 접근하면 네이버 로그인을 시도하고, 인증이 되면 토큰을 발급해주는 것을 확인할 수 있습니다.

9. 발급받은 토큰을 이용하여 Security 인증을 처리하는 필터를 만들어 줍니다.

API 서버에 접근할 떄 Auth헤더에 발급받은 토큰을 함께 보내면 토큰값에서 유저정보를 가져와 회원가입이 되었는지 검증 후 인증을 할 수 있습니다.

 

필터는 토큰이 존재하는지? 토큰이 유효한 토큰인지? 토큰의 유효기간이 지나지 않았는지? 를 검증합니다.

@RequiredArgsConstructor
public class JwtAuthFilter extends GenericFilterBean {
    private final TokenService tokenService;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = ((HttpServletRequest)request).getHeader("Auth");

        if (token != null && tokenService.verifyToken(token)) {
            String email = tokenService.getUid(token);

            // DB연동을 안했으니 이메일 정보로 유저를 만들어주겠습니다
            UserDto userDto = UserDto.builder()
                    .email(email)
                    .name("이름이에용")
                    .picture("프로필 이미지에요").build();

            Authentication auth = getAuthentication(userDto);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        chain.doFilter(request, response);
    }

    public Authentication getAuthentication(UserDto member) {
        return new UsernamePasswordAuthenticationToken(member, "",
                Arrays.asList(new SimpleGrantedAuthority("ROLE_USER")));
    }
}

10. UsernamePasswordAuthenticationFilter필터 앞에 만든 JwtAuthFilter를 등록합니다.

추가로 토큰이 만료되어 인증을 하지 못하면 /token/expired로 리다이렉트하여 Refresh요청을 해야한다는 것을 알려주고 Refresh를 할 수 있도록 /token/** 을 전체 허용해줍니다.

@RequiredArgsConstructor
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    private final CustomOAuth2UserService oAuth2UserService;
    private final OAuth2SuccessHandler successHandler;
    private final TokenService tokenService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.httpBasic().disable()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
                .authorizeRequests()
                .antMatchers("/token/**").permitAll()
                .anyRequest().authenticated()
            .and()
                .oauth2Login().loginPage("/token/expired")
                    .successHandler(successHandler)
                    .userInfoEndpoint().userService(oAuth2UserService);

        http.addFilterBefore(new JwtAuthFilter(tokenService), UsernamePasswordAuthenticationFilter.class);
    }
}

 

+ 토큰이 만료되었을 경우 Refresh 요청을 하기 위한 endpoint를 만들어줍니다.

@RequiredArgsConstructor
@RestController
public class TokenController {
    private final TokenService tokenService;

    @GetMapping("/token/expired")
    public String auth() {
        throw new RuntimeException();
    }

    @GetMapping("/token/refresh")
    public String refreshAuth(HttpServletRequest request, HttpServletResponse response) {
        String token = request.getHeader("Refresh");

        if (token != null && tokenService.verifyToken(token)) {
            String email = tokenService.getUid(token);
            Token newToken = tokenService.generateToken(email, "USER");

            response.addHeader("Auth", newToken.getToken());
            response.addHeader("Refresh", newToken.getRefreshToken());
            response.setContentType("application/json;charset=UTF-8");

            return "HAPPY NEW TOKEN";
        }

        throw new RuntimeException();
    }
}

 

 

 

github.com/zkdlu/spring-boot/tree/main/spring-oauth-jwt

 

zkdlu/spring-boot

Contribute to zkdlu/spring-boot development by creating an account on GitHub.

github.com