티스토리 뷰

728x90
반응형

스프링 시큐리티는 뭘까?

 

Spring boot - Spring Security에 대하여

Spring Security..?? 스프링 시큐리티가 뭘까? 프로젝트에 스프링 시큐리티를 적용하면서 적용은 됬는데 어떠한 흐름인지, 어떻게 보안을 적용해주는지 조금 더 깊게 알아야하지 않을까? 라는 생각에

kdg-is.tistory.com

 

Gradle 설정
dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'

	// validation
	implementation 'org.springframework.boot:spring-boot-starter-validation'

	// Swagger 2
	implementation group: 'io.springfox', name: 'springfox-swagger2', version: '2.9.2'
	implementation group: 'io.springfox', name: 'springfox-swagger-ui', version: '2.9.2'

	// BCrypt 사용
	implementation 'org.springframework.boot:spring-boot-starter-security'

	// jwt 사용
	implementation 'io.jsonwebtoken:jjwt:0.9.1'

	// JSONParser, JSONObject설정
	implementation group: 'com.googlecode.json-simple', name: 'json-simple', version: '1.1'
}

 

DB 테이블 설정
CREATE TABLE `유저 테이블` (
  `idx` int NOT NULL AUTO_INCREMENT,
  `name` varchar(70) COLLATE utf8mb4_unicode_ci NOT NULL,
  `email` varchar(400) COLLATE utf8mb4_unicode_ci NOT NULL,
  `password` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  `role` varchar(45) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
  PRIMARY KEY (`idx`),
  KEY `email` (`email`)
) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자 정보 '

 

Security Config 설정
  • 스프링의 security를 사용하기 위하여 설정을 하는 클래스입니다.

@EnableWebSecurity 과 extends WebSecurityConfigurerAdapter

  • WebSecurityConfigurerAdapter를 상속받은 클래스에 @EnableWebSecurity 어노테이션을 사용하면 SpringSecurityFilterChain이 자동으로 포함되며, 기본적인 Web보안을 활성화하겠다는 의미입니다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final JwtProvider jwtProvider;
    private final WebAccessDeniedHandler webAccessDeniedHandler;
    private final AuthenticationEntryPointHandler authenticationEntryPointHandler;
    private final CustomUserDetailService customUserDetailService;

    // AuthenticationManagerBuilder의 passwordEncoder()를 통해 
    // 패스워드 암호화에 사용될 PasswordEncoder 구현체를 지정할 수 있습니다.
    @Override
    protected void configure(AuthenticationManagerBuilder builder) throws Exception {
        builder.userDetailsService(customUserDetailService).passwordEncoder(passwordEncoder());
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean(name = BeanIds.AUTHENTICATION_MANAGER)
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    @Override
    public void configure(WebSecurity web) {
        web.ignoring().antMatchers("/v2/api-docs",
                "/configuration/ui",
                "/swagger-resources/**",
                "/configuration/security",
                "/swagger-ui.html",
                "/webjars/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .httpBasic().disable()
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
                .authorizeRequests()
                .antMatchers("/admin/**/**").hasRole("ADMIN")
                .antMatchers("/app/user/**", "/app/auth/**").permitAll()
                .anyRequest().authenticated()
            .and()
                .exceptionHandling()
                .authenticationEntryPoint(authenticationEntryPointHandler)
                .accessDeniedHandler(webAccessDeniedHandler)
            .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
    }
}
  • .csrf().disable()
    • rest api이므로 csrf 보안이 필요없으므로 disable처리합니다.
  • .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
    • jwt token으로 인증하므로 stateless 하도록 처리합니다.
  • AuthenticationManager authenticationManagerBean()
    • Spring Boot 2.x 부터는 자동으로 등록되지 않습니다.
    • 따라서 자동으로 등록이 되지 않기 때문에 외부로 노출해주는 메서드를 강제로 호출하여 @Bean으로 등록해주어야 합니다.
    • Override 하는 메서드는 authenticationManagerBean() 메서드이지 authenticationManager() 메서드가 아닙니다.

 

 

1) 표현식

- hasRole : 특정 Role에 해당하면 성공

- hasAnyRole : 특정 Role들 중 하나에 해당하면 성공

- isAnonymous : 로그인하지 않은 사용자면 성공

- isAuthenticated : 이미 인증된 사용자면 성공

- permitAll : 항상 성공

- denyAll : 항상 실패

- access : 경로 변수나 여러개의 접근 정책 설정

 

2) Resource 지정

- antMatchers : ant 형식으로 지정한 경로 패턴의 resource에 적용

- regexMatchers : 정규표현식으로 지정한 경로 패턴의 resource에 적용

- requestMatchers :  RequestMatcher 인터페이스 구현과 일치하는 resource에 적용

- anyRequest : 기타 resource에 적용

 

 

AuthenticationEntryPoint 구현하여 인증 예외처리

  • 스프림 시큐리티 컨텍스트 내에 존재하는 인증절차 중 인증과정이 실패하거나 인증헤더(Authorization)를 보내지 않게 되는 경우 401이라는 응답값을 던지는데 이를 처리해주는 인터페이스입니다. 401이 떨어질만한 에러가 발생한 경우 commerce() 메서드가 실행됩니다.
@Component
@RequiredArgsConstructor
public class AuthenticationEntryPointHandler implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        String exception = (String) request.getAttribute("exception");
        ErrorCode errorCode;

        /**
         * 토큰이 없는 경우 예외처리
         */
        if(exception == null) {
            errorCode = ErrorCode.UNAUTHORIZEDException;
            setResponse(response, errorCode);
            return;
        }

        /**
         * 토큰이 만료된 경우 예외처리
         */
        if(exception.equals("ExpiredJwtException")) {
            errorCode = ErrorCode.ExpiredJwtException;
            setResponse(response, errorCode);
            return;
        }
    }

    private void setResponse(HttpServletResponse response, ErrorCode errorCode) throws IOException {
        JSONObject json = new JSONObject();
        response.setContentType("application/json;charset=UTF-8");
        response.setCharacterEncoding("utf-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        json.put("code", errorCode.getCode());
        json.put("message", errorCode.getMessage());
        response.getWriter().print(json);
    }
}

 

AccessDeniedHandler를 구현하여 권한 에러 커스텀
@Component
public class WebAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        ErrorCode errorCode = ErrorCode.ForbiddenException;

        response.setContentType("application/json;charset=UTF-8");
        response.setCharacterEncoding("utf-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        JSONObject json = new JSONObject();
        json.put("code", errorCode.getCode());
        json.put("message", errorCode.getMessage());
        response.getWriter().print(json);
    }
}

 

ErrorCode Enum 정의
public enum ErrorCode {

    UsernameOrPasswordNotFoundException (400, "아이디 또는 비밀번호가 일치하지 않습니다.", HttpStatus.BAD_REQUEST),
    ForbiddenException(403, "해당 요청에 대한 권한이 없습니다.", HttpStatus.FORBIDDEN),
    UNAUTHORIZEDException (401, "로그인 후 이용가능합니다.", HttpStatus.UNAUTHORIZED),
    ExpiredJwtException(444, "기존 토큰이 만료되었습니다. 해당 토큰을 가지고 get-newtoken링크로 이동해주세요.", HttpStatus.UNAUTHORIZED),
    ReLogin(445, "모든 토큰이 만료되었습니다. 다시 로그인해주세요.", HttpStatus.UNAUTHORIZED),
    ;

    @Getter
    private int code;

    @Getter
    private String message;

    @Getter
    private HttpStatus status;

    ErrorCode(int code, String message, HttpStatus status) {
        this.code = code;
        this.message = message;
        this.status = status;
    }
}

 

Jwt Provider 구현
  • JwtProvier 클래스는 토큰을 생성하고 해당 토큰이 유효한지 또는 토큰에서 인증 정보를 조회하는 역할을 담당합니다.
@Component
@RequiredArgsConstructor
public class JwtProvider {

    private final String secretKey ="c88d74ba-1554-48a4-b549-b926f5d77c9e";
//    private long accessExpireTime = (60 * 60 * 1000L) * 3; // 3시간 후
        private final long accessExpireTime = 1 * 60 * 1000L;   // 1분
//    private long refreshExpireTime =  ((60 * 60 * 1000L) * 24) * 60; // 60일
        private final long refreshExpireTime = 1 * 60 * 2000L;   // 2분
    private final CustomUserDetailService customUserDetailService;

    public String createAccessToken(AuthDTO.LoginDTO loginDTO) {
        Map<String, Object> headers = new HashMap<>();
        headers.put("type", "token");

        Map<String, Object> payloads = new HashMap<>();
        payloads.put("email", loginDTO.getEmail());

        Date expiration = new Date();
        expiration.setTime(expiration.getTime() + accessExpireTime);

        String jwt = Jwts
                .builder()
                .setHeader(headers)
                .setClaims(payloads)
                .setSubject("user")
                .setExpiration(expiration)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        return jwt;
    }

    public Map<String, String> createRefreshToken(AuthDTO.LoginDTO loginDTO) {
        Map<String, Object> headers = new HashMap<>();
        headers.put("type", "token");

        Map<String, Object> payloads = new HashMap<>();
        payloads.put("email", loginDTO.getEmail());

        Date expiration = new Date();
        expiration.setTime(expiration.getTime() + refreshExpireTime);
        SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH);
        String refreshTokenExpirationAt = simpleDateFormat.format(expiration);

        String jwt = Jwts
                .builder()
                .setHeader(headers)
                .setClaims(payloads)
                .setSubject("user")
                .setExpiration(expiration)
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        Map<String, String> result = new HashMap<>();
        result.put("refreshToken", jwt);
        result.put("refreshTokenExpirationAt", refreshTokenExpirationAt);
        return result;
    }

    public Authentication getAuthentication(String token) {
        UserDetails userDetails = customUserDetailService.loadUserByUsername(this.getUserInfo(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getUserInfo(String token) {
        return (String) Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().get("email");
    }

    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("token");
    }

    public boolean validateJwtToken(ServletRequest request, String authToken) {
        try {
            Jwts.parser().setSigningKey(secretKey).parseClaimsJws(authToken);
            return true;
        } catch (MalformedJwtException e) {
            request.setAttribute("exception", "MalformedJwtException");
        } catch (ExpiredJwtException e) {
            request.setAttribute("exception", "ExpiredJwtException");
        } catch (UnsupportedJwtException e) {
            request.setAttribute("exception", "UnsupportedJwtException");
        } catch (IllegalArgumentException e) {
            request.setAttribute("exception", "IllegalArgumentException");
        }
        return false;
    }
}

 

Jwt를 위한 커스텀 필터 클래스 생성 
  • doFilter() 메서드는 JWT 토큰의 인증 정보를 현재 실행중인 SecurityContext에 저장하는 역할을 수행합니다.
  • request의 header에서 토큰을 가져오고 유효성 체크후 해당 토큰이 유효하다면 SecurityContext애 인증정보를 저장합니다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {

    private final JwtProvider jwtProvider;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
        // 헤더에서 JWT 를 받아옵니다.
        String token = jwtProvider.resolveToken((HttpServletRequest) request);

        // 유효한 토큰인지 확인합니다.
        if (token != null && jwtProvider.validateJwtToken(request, token)) {

            // 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
            Authentication authentication = jwtProvider.getAuthentication(token);

            // SecurityContext 에 Authentication 객체를 저장합니다.
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }
}

 

UserDetailsService 클래스 커스텀
  • DB에서 유저의 정보를 조회하는 역할을 수행합니다.
  • UserDetailsService 인터페이스에서 DB에서 유저정보를 불러오는 중요한 메소드는 loadUserByUsername() 메서드 입니다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {

    private final AuthMapper authMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = authMapper.findByEmail(username);

        if(user == null){
            throw new AuthenticationException(ErrorCode.UsernameOrPasswordNotFoundException);
        }

        return user;
    }
}

 

유저 정보를 조회하기 위한 DAO 클래스
  • UserDetailsService에서 UserDetails 타입으로 반환하기 위하여 UserDetails를 구현해줍니다.
@Setter
@Getter
public class User implements UserDetails {

    private long idx;
    private String name;
    private String email;
    private String password;
    private String role;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(new SimpleGrantedAuthority(this.role));
        return collection;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return email;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

 

유저 정보를 입력받은 DTO 클래스
public class AuthDTO {

    /**
     * 로그인 시 사용하는 DTO
     */
    @Getter
    @Setter
    public static class LoginDTO {
        @NotBlank
        @ApiModelProperty(value = "아이디", example = "admin@naver.com", required = true)
        private String email;

        @NotBlank
        @ApiModelProperty(value = "비밀번호", example = "12345", required = true)
        private String password;
    }

    /**
     * Refresh Token을 사용하여 새로운 Access Token을 발급받을 때 사용하는 DTO
     */
    @Getter
    @Setter
    public static class GetNewAccessTokenDTO {

        @ApiModelProperty(value = "Refresh Token Index", example = "1", required = true)
        private long refreshIdx;
    }
}

 

토큰을 저장하기 위한 Refresh DAO 클래스
@Getter
@Builder
public class RefreshToken {

    private long idx;
    private String userEmail;
    private String accessToken;
    private String refreshToken;
    private String refreshTokenExpirationAt;
}

 

로그인과 리프레쉬 토큰을 발급 받기 위한 컨트롤러
  • login은 처음 AccessToken과 RefreshToken을 발급해주는 메서드입니다.
  • get-newToken은 AccessToken이 만료된 경우 새로운 AccessToken을 발급해주는 메서드입니다.
@Api(tags = "Auth / 로그인")
@RequestMapping("/app/auth")
@RestController
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/login")
    @ApiOperation(value="로그인")
    public ApiResponse login(@RequestBody @Valid AuthDTO.LoginDTO loginDTO){
        return authService.login(loginDTO);
    }

    @PostMapping("/refreshToken")
    @ApiOperation(value="새로운 토큰 발급")
    public ApiResponse newAccessToken(@RequestBody @Valid AuthDTO.GetNewAccessTokenDTO getNewAccessTokenDTO, HttpServletRequest request) {
        return authService.newAccessToken(getNewAccessTokenDTO, request);
    }
}

 

해당 유저를 조회하고 토큰과 리프레쉬 토큰을 생성하는 서비스
  • Service에서 토큰을 생성하고 클라이언트에게 생성한 토큰과 데이터 베이스에 Refresh Token이 저장되어 있는 테이블의 index 번호를 같이 반환해주고 있습니다.
  • Refresh Token의 index 번호를 반환해주는 이유는 뒤에서 설명하는 새로운 Access Token을 발급받기 위함입니다.
  • ApiResponse와  ResponseMap은 맨 아랫쪽에 코드가 있으니 참고하시면 됩니다.
@Service
@RequiredArgsConstructor
public class AuthService {

    private final JwtProvider jwtProvider;
    private final AuthenticationManager authenticationManager;
    private final AuthMapper authMapper;

    public ApiResponse login(AuthDTO.LoginDTO loginDTO) {
        ResponseMap result = new ResponseMap();

        try {
            authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(loginDTO.getEmail(), loginDTO.getPassword())
            );

            Map createToken = createTokenReturn(loginDTO);
            result.setResponseData("accessToken", createToken.get("accessToken"));
            result.setResponseData("refreshIdx", createToken.get("refreshIdx"));
        } catch (Exception e) {
            e.printStackTrace();
            throw new AuthenticationException(ErrorCode.UsernameOrPasswordNotFoundException);
        }

        return result;
    }

    public ApiResponse newAccessToken(AuthDTO.GetNewAccessTokenDTO getNewAccessTokenDTO, HttpServletRequest request){
        ResponseMap result = new ResponseMap();
        String refreshToken = authMapper.findRefreshTokenByIdx(getNewAccessTokenDTO.getRefreshIdx());

        // AccessToken은 만료되었지만 RefreshToken은 만료되지 않은 경우
        if(jwtProvider.validateJwtToken(request, refreshToken)){
            String email = jwtProvider.getUserInfo(refreshToken);
            AuthDTO.LoginDTO loginDTO = new AuthDTO.LoginDTO();
            loginDTO.setEmail(email);

            Map createToken = createTokenReturn(loginDTO);
            result.setResponseData("accessToken", createToken.get("accessToken"));
            result.setResponseData("refreshIdx", createToken.get("refreshIdx"));
        }else{
            // RefreshToken 또한 만료된 경우는 로그인을 다시 진행해야 한다.
            result.setResponseData("code", ErrorCode.ReLogin.getCode());
            result.setResponseData("message", ErrorCode.ReLogin.getMessage());
            result.setResponseData("HttpStatus", ErrorCode.ReLogin.getStatus());
        }
        return result;
    }
    
    // 토큰을 생성해서 반환
    private Map<String, String> createTokenReturn(AuthDTO.LoginDTO loginDTO) {
        Map result = new HashMap();

        String accessToken = jwtProvider.createAccessToken(loginDTO);
        String refreshToken = jwtProvider.createRefreshToken(loginDTO).get("refreshToken");
        String refreshTokenExpirationAt = jwtProvider.createRefreshToken(loginDTO).get("refreshTokenExpirationAt");

        RefreshToken insertRefreshToken = RefreshToken.builder()
                .userEmail(loginDTO.getEmail())
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .refreshTokenExpirationAt(refreshTokenExpirationAt)
                .build();

        authMapper.insertOrUpdateRefreshToken(insertRefreshToken);

        result.put("accessToken", accessToken);
        result.put("refreshIdx", insertRefreshToken.getIdx());
        return result;
    }
}

 

DB에서 유저를 조회하기 위한 Mapper 인터페이스
@Mapper
public interface AuthMapper {

    // userDetailsService 클래스에서 사용
    User findByEmail(String email);
    
    // AuthService에서 리프레쉬 토큰 발급시 사용
    String findRefreshTokenByIdx(long idx);
    
    // 리프레쉬 토큰 발급 시 insert or update 시 사용
    void insertOrUpdateRefreshToken(RefreshToken refreshToken);
}

 

데이터 베이스와 연동하는 XML
<mapper namespace="매퍼파일 경로.AuthMapper">
    <select id="findByEmail" resultType="User">
        SELECT email, password, role, role_detail FROM User WHERE email = #{email}
    </select>

    <select id="findRefreshTokenByIdx" resultType="String">
        SELECT refresh_token FROM Refresh_Token WHERE idx = #{idx}
    </select>

    <insert id="insertOrUpdateRefreshToken" useGeneratedKeys="true" keyProperty="idx">
        INSERT INTO Refresh_Token (
            user_email,
            access_token,
            refresh_token,
            refresh_token_expiration_at
        )
        VALUES (
           #{userEmail},
           #{accessToken},
           #{refreshToken},
           #{refreshTokenExpirationAt}
        )
        ON DUPLICATE KEY UPDATE
              idx = ( SELECT idx FROM
                        ( SELECT (idx + 1) AS idx FROM Refresh_Token B ORDER BY idx DESC LIMIT 1)
                    AS A),
              access_token = #{accessToken},
              refresh_token = #{refreshToken},
              refresh_token_expiration_at = #{refreshTokenExpirationAt},
              user_email = #{userEmail}
    </insert>
</mapper>

 

실행 흐름 


 

실행 예제 - 1) 관리자 권한에 토큰 없이 접근하는 경우
  • 토큰이 없는 경우 로그인을 하라는 메시지 출력

 

실행 예제 - 2) 사용자 계정으로 로그인 후 권한이 없는 관리자 경로에 접근하는 경우 
  • 사용자 계정으로 로그인 후 권한이 없는 경로에 접근하는 경우 해당 요청에 대한 권한이 없다는 메시지 출력

 

실행 예제 - 3) 관리자로 로그인 후 토큰 발급 후 관리자 권한에 접근
  • 관리자 계정으로 로그인 후 권한이 있다면 해당 목록 출력

 

 

 

Refresh Token을 사용하여 새로운 Access Token 발급


  • 위에서는 token을 발급하여 실행하는 로직에 대하여? 알아보았습니다. 이제부터는 RefreshToken을 사용하여 AccessToken이 만료된 후 어떻게 처리를 해야하는지 알아보겠습니다.

 

실행 흐름 


RefreshToken을 저장할 DB 생성
  • 해당 테이블은 사용자가 로그인 시 AccessToken과 RefreshToken을 보관할 테이블입니다.
  • 상황에 따라 만료일자 등 필요한 칼럼을 넣으면 됩니다.
CREATE TABLE `Refresh_Token` (
  `idx` bigint NOT NULL AUTO_INCREMENT,
  `access_token` text NOT NULL,
  `refresh_token` text NOT NULL,
  `refresh_token_expiration_at` datetime DEFAULT NULL,
  `user_email` varchar(100) NOT NULL,
  PRIMARY KEY (`idx`),
  UNIQUE KEY `user_email` (`user_email`)
) ENGINE=InnoDB AUTO_INCREMENT=183 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci

 

컨트롤러
  • 새로운 토큰을 발급받기 위해서 클라이언트는 /app/auth/get-newToken이라는 경로로 요청을 하게 됩니다.
@Api(tags = "Auth / 로그인")
@RequestMapping("/app/auth")
@RestController
@RequiredArgsConstructor
public class AuthController {

    private final AuthService authService;

    @PostMapping("/login")
    public ApiResponse login(@RequestBody @Valid AuthDTO.LoginDTO loginDTO){
        return authService.login(loginDTO);
    }

    @PostMapping("/get-newToken")
    public ApiResponse newAccessToken(@RequestBody @Valid AuthDTO.GetNewAccessTokenDTO getNewAccessTokenDTO, HttpServletRequest request) {
        return authService.newAccessToken(getNewAccessTokenDTO, request);
    }
}

 

DTO 클래스
  • 위의 컨트롤러에서 사용하는 DTO는 GetNewAccessTokenDTO 클래스를 사용하여 사용자로부터 index를 받습니다.
public class AuthDTO {

     ... 다른 DTO 클래스
     
    /**
     * Refresh Token을 사용하여 새로운 Access Token을 발급받을 때 사용하는 DTO
     */
    @Getter
    @Setter
    public static class GetNewAccessTokenDTO {

        @ApiModelProperty(value = "Refresh Token Index", example = "1", required = true)
        private long refreshIdx;
    }
}

 

서비스
  • newAccessToken 메서드에서는 사용자로부터 받은 index를 사용하여 DB에서 Refresh Token을 가져오게 되고 해당 Refresh Token이 정상적인지 또는 만료가 되지 않았는지 확인 후 새로운 Access Token과 index를 사용자에게 반환합니다.
@Service
@RequiredArgsConstructor
public class AuthService {

    private final JwtProvider jwtProvider;
    private final AuthenticationManager authenticationManager;
    private final AuthMapper authMapper;
  
    public ApiResponse newAccessToken(AuthDTO.GetNewAccessTokenDTO getNewAccessTokenDTO, HttpServletRequest request){
        ResponseMap result = new ResponseMap();
        String refreshToken = authMapper.findRefreshTokenByIdx(getNewAccessTokenDTO.getRefreshIdx());

        // AccessToken은 만료되었지만 RefreshToken은 만료되지 않은 경우
        if(jwtProvider.validateJwtToken(request, refreshToken)){
            String email = jwtProvider.getUserInfo(refreshToken);
            AuthDTO.LoginDTO loginDTO = new AuthDTO.LoginDTO();
            loginDTO.setEmail(email);

            Map createToken = createTokenReturn(loginDTO);
            result.setResponseData("accessToken", createToken.get("accessToken"));
            result.setResponseData("refreshIdx", createToken.get("refreshIdx"));
        }else{
            // RefreshToken 또한 만료된 경우는 로그인을 다시 진행해야 한다.
            result.setResponseData("code", ErrorCode.ReLogin.getCode());
            result.setResponseData("message", ErrorCode.ReLogin.getMessage());
            result.setResponseData("HttpStatus", ErrorCode.ReLogin.getStatus());
        }
        return result;
    }

    // 토큰을 생성해서 반환
    private Map<String, String> createTokenReturn(AuthDTO.LoginDTO loginDTO) {
        Map result = new HashMap();

        String accessToken = jwtProvider.createAccessToken(loginDTO);
        String refreshToken = jwtProvider.createRefreshToken(loginDTO).get("refreshToken");
        String refreshTokenExpirationAt = jwtProvider.createRefreshToken(loginDTO).get("refreshTokenExpirationAt");

        RefreshToken insertRefreshToken = RefreshToken.builder()
                .userEmail(loginDTO.getEmail())
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .refreshTokenExpirationAt(refreshTokenExpirationAt)
                .build();

        authMapper.insertOrUpdateRefreshToken(insertRefreshToken);

        result.put("accessToken", accessToken);
        result.put("refreshIdx", insertRefreshToken.getIdx());
        return result;
    }
}

 

Token을 저장 및 조회할 Mapper 인터페이스 생성
@Mapper
public interface AuthMapper {

    // userDetailsService 클래스에서 사용
    User findByEmail(String email);
    
    // AuthService에서 리프레쉬 토큰 발급시 사용
    String findRefreshTokenByIdx(long idx);
    
    // 리프레쉬 토큰 발급 시 insert or update 시 사용
    void insertOrUpdateRefreshToken(RefreshToken refreshToken);
}

 

토큰을 DB에 저장할 SQL 작성
  • 여기서 중요한 SQL은 insertOrUpdateRefreshToken이라는 아이디를 가진 SQL문입니다.
  • 해당 SQL은 Refresh_Token이라는 테이블에 동일한 이메일이 있는 경우 Update를 하게되고 동일한 이메일이 없는 경우 Insert를 하게됩니다. 또한 매번 테이블의 idx를 반환하게 되는데 idx 칼럼에 서브쿼리를 사용하여 + 1를 하여 같은 요청을 하더라도 동일한 idx를 반환하지 않도록 처리했습니다.
  • idx가 변하지 않는다면 특정한 idx로 탈취할 우려가 있기 때문입니다.
<mapper namespace="매퍼파일 경로.AuthMapper">
    <select id="findByEmail" resultType="User">
        SELECT email, password, role, role_detail FROM User WHERE email = #{email}
    </select>

    <select id="findRefreshTokenByIdx" resultType="String">
        SELECT refresh_token FROM Refresh_Token WHERE idx = #{idx}
    </select>

    <insert id="insertOrUpdateRefreshToken" useGeneratedKeys="true" keyProperty="idx">
        INSERT INTO Refresh_Token (
            user_email,
            access_token,
            refresh_token,
            refresh_token_expiration_at
        )
        VALUES (
           #{userEmail},
           #{accessToken},
           #{refreshToken},
           #{refreshTokenExpirationAt}
        )
        ON DUPLICATE KEY UPDATE
              idx = ( SELECT idx FROM
                        ( SELECT (idx + 1) AS idx FROM Refresh_Token B ORDER BY idx DESC LIMIT 1)
                    AS A),
              access_token = #{accessToken},
              refresh_token = #{refreshToken},
              refresh_token_expiration_at = #{refreshTokenExpirationAt},
              user_email = #{userEmail}
    </insert>
</mapper>

 

실행 예제 - 1) AccessToken이 만료된 경우
  • AccessToken이 만료된 경우 예외 처리

 

실행 예제 - 2) AccessToken이 만료되어 새로운 AccessToken을 발급받은 경우
  • AccessToken이 만료되어 클라이언트가 가지고 있던 index 번호를 활용하여 새로운 Access Token 발급

 

실행 예제 - 3) AccessToken과 RefreshToken 둘다 만료된 경우
  • 클라이언트가 index번호를 넘겨줬지만 Refresh Token도 만료된 경우

* 처음에는 RefreshToken을 어디에 저장하고 사용할지 많은 고민을 하였습니다. 세션? 쿠키? DB?등 많은 저장 장소가 있지만 각각의 장단점이 있어서 고민해 본 결과 DB에 저장을 하게 되었습니다.

 

ApiResponse Class 와 ResponseMap Class 참고
@Setter
@Getter
public class ApiResponse {

    private int code = HttpStatus.OK.value();
    private Object result;

    public ApiResponse() {}

    public ApiResponse(int code, Object result) {
        this.code = code;
        this.result = result;
    }

    public void setResult(Object result) {
        this.result = result;
    }
}

public class ResponseMap extends ApiResponse{

    private Map responseData = new HashMap();

    public ResponseMap() {
        setResult(responseData);
    }

    public void setResponseData(String key, Object value) {
        this.responseData.put(key, value);
    }
}

 

 

언제나 피드백은 환영입니다!
궁금한 점은 댓글 달아주시면 최대한 답변해드리겠습니다!

728x90
반응형