본문 바로가기

Spring & Spring Boot

Spring Security jwt 적용, 커스터마이징

Spring Security, jwt를 사용하기 위해서 build.gradle의 dependencies에 다음을 추가한다.

implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'

 

 

그 다음, SecurityConfig 를 작성한다.

SecurityConfigSpring Security의 보안 설정을 정의하는 역할을 한다.

보안 구성 및 규칙, 세션 및 로그인 , CSRF 및 CORS , JWT 기반 인증 , 사용자 정의 보안 필터 추가 등을 설정할 수 있다.

 

 

 

SecurityConfig


@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    private final JwtTokenProvider jwtTokenProvider;
    private final CorsConfig corsConfig;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .csrf(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/auth/signup", "/auth/login").permitAll()
                        .requestMatchers("/auth/test").hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .sessionManagement((sessionManagement) ->
                        sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                )
                .addFilter(corsConfig.corsFilter())
                .addFilterBefore(new JwtFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class)
                .logout(logout -> {
                    logout
                            .logoutUrl("/auth/logout") // 로그아웃 URL 지정
                            .logoutSuccessHandler((request, response, authentication) -> {
                                Cookie cookie = new Cookie("accessToken", null);
                                cookie.setMaxAge(0);// 쿠키 만료
                                cookie.setHttpOnly(true);
                                cookie.setPath("/");
                                response.addCookie(cookie);

                                // 로그아웃 성공 시 200 OK 리턴
                                String jsonResponse = new ObjectMapper().writeValueAsString(
                                        ResponseDto.res(HttpStatus.OK, "로그아웃 되었습니다."));

                                // HTTP 상태 코드 200 OK, JSON 형식 리턴
                                response.setStatus(HttpStatus.OK.value());
                                response.setContentType("application/json;charset=UTF-8");//응답 데이터 타입 지정
                                response.getWriter().write(jsonResponse);//응답 데이터 출력
                                response.getWriter().flush();//즉시 응답(더 빠름)
                            })
                            .invalidateHttpSession(true); // HTTP 세션 무효화
                })
                .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }
}

 

securityFilterChain() 함수는 Builder 패턴을 통해 여러 가지 Filter와 설정을 적용해주고 있다(Builder 패턴의 좋은 사례인 것 같다). 중요한 부분을 몇 가지 살펴보자.

 

 

 

1.authorizeHttpRequests()

.authorizeHttpRequests(authorize -> authorize
           .requestMatchers("/auth/signup", "/auth/login").permitAll()
					 .requestMatchers("/auth/test").hasRole("ADMIN")
           .anyRequest().authenticated()
)

 이 부분은 Authorization을 적용할 end point 경로를 설정한다. 현재의 설정을 보면

"/auth/signup", "/auth/login" end point에 대한 요청을 전부 허용(permit)한다고 되어 있다. 로그인과 회원가입은 Authorization이 필요하지 않기 때문이다.

"/auth/test”는 테스트용 end point로, .hasRole("ADMIN")을 통해 ADMIN User가 아니면 이 end point로 접근하지 못하게 했다.

.anyRequest().authenticated()permitAll() 된 end point를 제외한 모든 end point에 Authorization을 적용한다는 의미이다.

 

 

2. sessionManagement()

.sessionManagement((sessionManagement) ->
   sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)

 이 부분은 세션과 관련된 설정을 하는 역할을 한다. Spring Security는 내부(In Memory)에서 사용자 세션을 관리한다. 하지만, 우리가 만드는 Restful API 서버는 기본적으로 Stateless이므로, 세션 정책을 STATELESS로 설정해 Spring Security가 세션을 관리하지 않도록(생성하지도 않고 존재해도 사용하지 않도록) 한다.

 

 

3. addFilter()

.addFilter(corsConfig.corsFilter())

 이 함수는 Filter를 추가하는 역할을 한다. 커스텀 필터인 CorsFilter를 추가했다. 중요한 부분이 있다. CORS는 브라우저에서 적용되는 보안 기능으로, 서버 응답에 특정 헤더를 추가하고, 브라우저가 이를 확인하여 교차 출처 요청을 허용하거나 차단하는 역할을 하기 때문에 요청의 인증 여부와 관계없이 먼저 이루어져야 한다.

  • CORS의 동작 순서
    1. 브라우저는 사전 요청(OPTIONS 메서드를 사용한 요청)를 보냄.
    2. 서버는 이에 대한 응답으로 CORS 관련 헤더를 설정하여 브라우저에게 허용 여부를 전달함.
    3. 브라우저는 실제 요청을 보냄.

 

 

 

4. addFilterBefore()

.addFilterBefore(new JwtFilter(secretKey),
                        UsernamePasswordAuthenticationFilter.class)

 이 함수는 첫 번째 매개변수에 있는 Filter를 두 번째 매개변수에 있는 Filter보다 먼저 실행되게 한다. Spring Security는 기본적으로 UsernamePasswordAuthenticationFilter 를 사용한다. UsernamePasswordAuthenticationFilterForm based Authentication 방식으로 인증을 진행할 때 아이디, 패스워드 데이터를 파싱하여 인증 요청을 위임하는 필터이다. 우리가 만들고자 하는 서버는 Form based Authentication이 아니다 UsernamePasswordAuthenticationFilter 대신 JwtFilter가 먼저 동작하도록 설정한다(JwtFilter는 후에 구현).

 

 

 

5. logout()

.logout(logout -> {
    logout
            .logoutUrl("/auth/logout") // 로그아웃 URL 지정
            .logoutSuccessHandler((request, response, authentication) -> {
                Cookie cookie = new Cookie("accessToken", null);
                cookie.setMaxAge(0);// 쿠키 만료
                cookie.setHttpOnly(true);
                cookie.setPath("/");
                response.addCookie(cookie);
                // 로그아웃 성공 시 200 OK 리턴
                String jsonResponse = new ObjectMapper().writeValueAsString(
                        ResponseDto.res(HttpStatus.OK, "로그아웃 되었습니다."));
                // HTTP 상태 코드 200 OK, JSON 형식 리턴
                response.setStatus(HttpStatus.OK.value());
                response.setContentType("application/json;charset=UTF-8");//응답 데이터 타입 지정
                response.getWriter().write(jsonResponse);//응답 데이터 출력
                response.getWriter().flush();//즉시 응답(더 빠름)
            })
            .invalidateHttpSession(true); // HTTP 세션 무효화
})

이 부분은 로그아웃 url을 설정할 수 있고, 해당 url에 도착하는 요청에 대해 로그아웃 기능을 구현할 수 있다.

 

 

 

 

 passwordEncoder()함수는 Spring Security에서 제공하는 비밀번호 암호화 인터페이스로, 여기서 정의해두면 어디서든 사용할 수 있다.

 

@Transactional
public ResponseEntity<ResponseDto<Void>> signup(AuthSignupRequestDto authSignupRequestDTO) {
    if (this.isUserExists(authSignupRequestDTO.getUserId()) != null) {
        return new ResponseEntity<>(ResponseDto.res(HttpStatus.CONFLICT, "USER가 이미 존재합니다."), HttpStatus.CONFLICT);
    } else {
        User user = new User(authSignupRequestDTO.getUserId(),
                **passwordEncoder**.encode(authSignupRequestDTO.getPassword()),
                Collections.singleton(Role.ROLE_USER));
        this.userRepository.save(user);
        return new ResponseEntity<>(ResponseDto.res(HttpStatus.CREATED, "회원가입 되었습니다."), HttpStatus.CREATED);
    }
}

(사용한 코드 예시)

 

 

 

 

 

 

 

이제 필요한 클래스를 하나씩 구현해보자.

 

1. User, Role, Status


우선 User에서 사용할 Role과 Status를 구현한다.

public enum Role implements GrantedAuthority {
    ROLE_USER, ROLE_ADMIN;

    @Override
    public String getAuthority() {
        return name();
    }
}
public enum Status {
    ACTIVE, INACTIVE, DELETED
}

 

 

 

이제 User를 구현한다.

@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User implements UserDetails {

    @ElementCollection(fetch = FetchType.EAGER)
    @Enumerated(EnumType.STRING)
    @Builder.Default
    private List<Role> roles = new ArrayList<>();
    @Id
    @Column(name = "id", updatable = false, unique = true, nullable = false)
    private String id;
    @Column(name = "password", nullable = false, length = 100)
    private String password;
    @OneToMany(mappedBy = "user", orphanRemoval = true, fetch = FetchType.LAZY)
    @JsonIgnore//json으로 변환할 때 memos를 무시한다.
    private List<Memo> memos;
    @Column(name = "createdAt", nullable = false, updatable = false)
    private Date createdAt;
    @Setter
    @Column(name = "updatedAt", nullable = false)
    private Date updatedAt;

    @Column(name = "status", nullable = false)
    @Enumerated(EnumType.STRING)
    @Builder.Default
    private Status status = Status.ACTIVE;

    public User(String id, String password, Collection<? extends GrantedAuthority> authorities) {
        this.id = id;
        this.password = password;
        this.roles = authorities.stream()
                .map(GrantedAuthority::getAuthority)
                .map(Role::valueOf)
                .collect(Collectors.toList());
        this.createdAt = new Date();
        this.updatedAt = new Date();
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles.stream()
                .map(Role::toString)
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

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

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

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

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

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

    @Override
    public boolean isEnabled() {
        return this.status == Status.ACTIVE;
    }
}

 

 

여기서 눈여겨봐야 할 점 몇 가지를 설명하겠다.

public class User implements UserDetails{
.....
}

Spring Security에 제공할 UserDetails 인터페이스를 User가 구현한다는 뜻이다.

@Override
 public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles.stream()
            .map(Role::toString)
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList());
}

권한 정보를 반환하는 함수로, 오버라이드됐다.

 

 

 

 

 

 

2. JwtFilter


이제 우리가 적용할 JwtFilter에 대해 알아보자.

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
    private final JwtTokenProvider jwtTokenProvider;//직접 구현한 클래스(코드는 이후에)

    @Override
    public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String token = resolveTokenFromCookie(request);

        //토큰이 유효할 경우
        if (token != null && jwtTokenProvider.validateToken(token)) {
            //토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        filterChain.doFilter(request, response);
    }

    private String resolveTokenFromCookie(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("accessToken".equals(cookie.getName())) {
                    return EncodeUtil.decodeJwtBearerToken(cookie.getValue());
                }
            }
        }
        return null;
    }
}

JwtFilter는 OncePerRequestFilter 를 상속받는다. OncePerRequestFilter는 각 HTTP 요청에 대해 한 번만 실행되도록 보장하는 Filter 이다.

OncePerRequestFilter를 상속받는 이유는 필터가 한 번의 요청에 대해 단 한 번만 실행되도록 보장하기 위함인데, Spring Security에서는 각각의 필터가 여러 차례 호출되는 것을 방지하기 위해 OncePerRequestFilter를 도입했다. 이 클래스는 요청이 들어올 때 단 한 번만 실행되도록 보장하며, 서블릿 필터의 doFilter 메서드를 구현할 때 FilterChain을 직접 호출하는 대신 doFilterInternal 메서드를 구현하여 필터 체인을 실행한다. OncePerRequestFilter를 사용함으로써 여러 번 호출되는 필터가 있을 경우, 동일한 작업이 중복으로 수행되어 발생하는 성능 저하 혹은 동일한 필터가 여러 번 호출되어 발생할 수 있는 예상치 못한 부작용을 예방할 수 있다.

resolveTokenFromCookie 는 우선 HttpServletRequest에서 "accessToken" Cookie를 파싱한다. 이 Cookie엔 Jwt Token이 Bearer 형태로 들어 있다. 그 다음, 직접 구현한 EncodeUtil(코드는 이후에) 클래스의 decodeJwtBearerToken() 함수로 Cookie의 값에서 Jwt Token을 추출한다.

doFilterInternal 엔 이 JwtFilter가 수행하는 주요 코드를 작성한다.

한 줄씩 보자

String token = resolveTokenFromCookie(request);

HttpRequest의 Cookie에서 Jwt Token을 가져온다.

//토큰이 유효할 경우
if (token != null && jwtTokenProvider.validateToken(token)) {
.......
}

가져온 Jwt Token이 유효한지 검사한다.

//토큰이 유효할 경우
if (token != null && jwtTokenProvider.validateToken(token)) {
    Authentication authentication = jwtTokenProvider.getAuthentication(token);
    .....
}

Jwt Token에서 Authentication 객체를 가지고 온다.

//토큰이 유효할 경우
if (token != null && jwtTokenProvider.validateToken(token)) {
		.....
    SecurityContextHolder.getContext().setAuthentication(authentication);
}

가지고 온 Authentication 객체를 SecurityContext에 저장한다.

filterChain.doFilter(request, response);

다음 Filter로 요청을 전달한다. 이 부분이 없으면 다음 단계의 필터가 실행되지 않는다.

 

 

 

 

3. JwtTokenProvider


@Slf4j
@Component
public class JwtTokenProvider {
    private final Key key;

    private final long expiration;

    public JwtTokenProvider(@Value("${jwt.secret}") String secretKey, @Value("${jwt.expiration}") long expire) {
        try {
            byte[] keyBytes = Decoders.BASE64.decode(secretKey);
            key = Keys.hmacShaKeyFor(keyBytes);
            expiration = expire;
        } catch (Exception e) {
            log.error("Error initializing JwtTokenProvider", e);
            throw new RuntimeException("Error initializing JwtTokenProvider", e);
        }
    }

    // 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
    public JwtTokenInfo generateToken(Authentication authentication) {
        // 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + expiration);

        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())//userId 이다
                .claim("auth", authorities)
                .setIssuedAt(new Date(now))
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return JwtTokenInfo.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .build();
    }

    // Jwt 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
    public Authentication getAuthentication(String accessToken) {
        // 토큰 복호화
        Claims claims = parseClaims(accessToken);

        if (claims.get("auth") == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }

        String authoritiesString = claims.get("auth").toString();

        if (StringUtils.hasText(authoritiesString)) {
            Collection<? extends GrantedAuthority> authorities =
                    Arrays.stream(authoritiesString.split(","))
                            .map(SimpleGrantedAuthority::new)
                            .collect(Collectors.toList());

            // UserDetails 객체를 만들어서 Authentication 리턴
            // 일반적으로 보안 토큰에는 사용자의 비밀번호를 포함시키지 않고, 사용자를 식별하는 정보와 권한 정보만을 포함
            UserDetails principal = new User(claims.getSubject(), "", authorities);
            return new UsernamePasswordAuthenticationToken(principal, "", authorities);
        } else {
            throw new RuntimeException("권한 정보가 비어 있습니다.");
        }
    }

    // 토큰 정보를 검증하는 메서드
    public boolean validateToken(String accessToken) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        } catch (Exception e) {
            log.info("JWT Signature is invalid.", e);
        }
        return false;
    }

    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

 

 

생성자

private final Key key;

private final long expiration;

public JwtTokenProvider(@Value("${jwt.secret}") String secretKey, @Value("${jwt.expiration}") long expire) {
    try {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        key = Keys.hmacShaKeyFor(keyBytes);
        expiration = expire;
    } catch (Exception e) {
        log.error("Error initializing JwtTokenProvider", e);
        throw new RuntimeException("Error initializing JwtTokenProvider", e);
    }
}

application.properties에서 jwt.secret과 jwt.expiration을 가져와 key와 expiration에 설정해준다.

 

 

 

 

 

generateToken()은 Jwt Token을 만들어 JwtTokenInfo에 넣어 반환한다.

 

  • JwtTokenInfo는 다음과 같다.
@Builder
@Data
@AllArgsConstructor
public class JwtTokenInfo {
    private String grantType;
    private String accessToken;
}

 

 

public JwtTokenInfo generateToken(Authentication authentication) {
        // 권한 가져오기
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        // Access Token 생성
        Date accessTokenExpiresIn = new Date(now + expiration);

        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())//userId이다
                .claim("auth", authorities)
                .setIssuedAt(new Date(now))
                .setExpiration(accessTokenExpiresIn)
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();

        return JwtTokenInfo.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .build();
}

Authentication을 기반으로 Jwt Token을 만들 것이다. 우선 User의 권한들(User Entity의 roles)을 authorities에 가져온다. 그 다음 sub에 userId, claim에 “auth”라는 이름으로 authorities 를 넣고, 나머지 설정을 해 준 후, JwtTokenInfo에 넣어 반환한다.

 

 

 

 

parseClaims() 는 Jwt Token에서 Claim을 파싱하는 함수이다.

private Claims parseClaims(String accessToken) {
    try {
        return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
    } catch (ExpiredJwtException e) {
        return e.getClaims();
    }
}

 

 

 

getAuthentication()은 Jwt 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내 Authentication을 반환한다.

public Authentication getAuthentication(String accessToken) {
    // 토큰 복호화
    Claims claims = parseClaims(accessToken);
    if (claims.get("auth") == null) {
        throw new RuntimeException("권한 정보가 없는 토큰입니다.");
    }
    String authoritiesString = claims.get("auth").toString();
    if (StringUtils.hasText(authoritiesString)) {
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(authoritiesString.split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());
        // UserDetails 객체를 만들어서 Authentication 리턴
        // 일반적으로 보안 토큰에는 사용자의 비밀번호를 포함시키지 않고, 사용자를 식별하는 정보와 권한 정보만을 포함
        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    } else {
        throw new RuntimeException("권한 정보가 비어 있습니다.");
    }
}

Jwt Token에서 claim을 파싱하고 위의 generateToken() 에서 “auth”에 넣어준 User의 권한 정보가 있는지 검사한다. 그 다음, User의 권한 정보를 추출해 UserDetails를 만들어 Authentication 의 구현체인 UsernamePasswordAuthenticationToken 로 반환한다.

 

 

 

validateToken() 은 Jwt Token을 검증하는 함수이다.

public boolean validateToken(String accessToken) {
    try {
        Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken);
        return true;
    } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
        log.info("Invalid JWT Token", e);
    } catch (ExpiredJwtException e) {
        log.info("Expired JWT Token", e);
    } catch (UnsupportedJwtException e) {
        log.info("Unsupported JWT Token", e);
    } catch (IllegalArgumentException e) {
        log.info("JWT claims string is empty.", e);
    } catch (Exception e) {
        log.info("JWT Signature is invalid.", e);
    }
    return false;
}

 

 

 

 

 

4. AuthService


@Service
@AllArgsConstructor
public class AuthService {
    private final UserRepository userRepository;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final JwtTokenProvider jwtTokenProvider;

    public ResponseEntity<ResponseDto<Void>> login(AuthLoginRequestDto authLoginRequestDTO,
                                                   HttpServletResponse response) {
        // 1. Login ID/PW 를 기반으로 Authentication 객체 생성
        // 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
                authLoginRequestDTO.getUserId(),
                authLoginRequestDTO.getPassword());

        // 2. 실제 검증 (User id, pw 체크)이 이루어지는 부분
        // authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
        // 이 부분이 성공적으로 실행되면 SecurityContextHolder 에 인증 정보가 저장됨
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

        String jwtToken = jwtTokenProvider.generateToken(authentication).getAccessToken();
        // 3. 인증 정보를 기반으로 Jwt 토큰 생성, Cookie에 담아서 전달
        Cookie cookie = new Cookie("accessToken",
                EncodeUtil.encodeJwtBearerToken(jwtToken));

        cookie.setMaxAge(60 * 60 * 24);//하루
        cookie.setHttpOnly(true);//자바스크립트로 접근 불가
        cookie.setPath("/");//모든 경로에서 접근 가능
        response.addCookie(cookie);
        return new ResponseEntity<>(ResponseDto.res(HttpStatus.OK, "로그인 되었습니다."), HttpStatus.OK);
    }

    @Transactional
    public ResponseEntity<ResponseDto<Void>> signup(AuthSignupRequestDto authSignupRequestDTO) {
        if (this.isUserExists(authSignupRequestDTO.getUserId()) != null) {
            return new ResponseEntity<>(ResponseDto.res(HttpStatus.CONFLICT, "USER가 이미 존재합니다."), HttpStatus.CONFLICT);
        } else {
            User user = new User(authSignupRequestDTO.getUserId(), authSignupRequestDTO.getPassword(),
                    Collections.singleton(Role.ROLE_USER));
            this.userRepository.save(user);
            return new ResponseEntity<>(ResponseDto.res(HttpStatus.CREATED, "회원가입 되었습니다."), HttpStatus.CREATED);
        }
    }

    private User isUserExists(String userId) {
        return this.userRepository.findById(userId).orElse(null);
    }
}

 

 

 

login 메서드만 보자

public ResponseEntity<ResponseDto<Void>> login(AuthLoginRequestDto authLoginRequestDTO,
                                               HttpServletResponse response) {
    // 1. Login ID/PW 를 기반으로 Authentication 객체 생성
    // 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
            authLoginRequestDTO.getUserId(),
            authLoginRequestDTO.getPassword());
    // 2. 실제 검증 (User id, pw 체크)이 이루어지는 부분
    // authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
    // 이 부분이 성공적으로 실행되면 SecurityContextHolder 에 인증 정보가 저장됨
    // 이 Authentication 객체의 authenticated 속성이 true로 설정되어 있으면 사용자가 성공적으로 인증되었음을 의미
    Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
    String jwtToken = jwtTokenProvider.generateToken(authentication).getAccessToken();
    // 3. 인증 정보를 기반으로 Jwt 토큰 생성, Cookie에 담아서 전달
    Cookie cookie = new Cookie("accessToken",
            EncodeUtil.encodeJwtBearerToken(jwtToken));
    cookie.setMaxAge(60 * 60 * 24);//하루
    cookie.setHttpOnly(true);//자바스크립트로 접근 불가
    cookie.setPath("/");//모든 경로에서 접근 가능
    response.addCookie(cookie);
    return new ResponseEntity<>(ResponseDto.res(HttpStatus.OK, "로그인 되었습니다."), HttpStatus.OK);
}

 

Dto에 있는 id, password를 통해 UsernamePasswordAuthenticationToken을 만든다.

그 다음, authenticationManagerBuilder.getObject().authenticate(authenticationToken)를 호출하는데, 이 과정에서 UsernamePasswordAuthenticationToken 을 AuthenticationManager에게 넘기고 AuthenticationManager는 이를 AuthenticationProvider에게 넘긴다. AuthenticationProvider는 UserDetailService의 loadUserByUsername(해당 User가 DB에 있는지를 검사 후 UserDetails 를 반환하는 메서드)이라는 메서드를 통해 UserDetails를 얻고, 이 UserDetails를 통해 Authentication객체를 다시 AuthenticationManager에게 넘겨준다. 이때, Authentication 객체의 authenticated 속성이 true로 설정되어 있으면 사용자가 성공적으로 인증되었음을 의미한다. 성공이면 SecurityContextHolder에 Authentication을 저장한다.

이후, jwtTokenProvider.generateToken(authentication).getAccessToken(); 을 사용해 Authentication 객체로 JwtToken을 얻어 Response의 Cookie에 넣어 준다.

 

 

 

5. CustomUserDetailsService


CustomUserDetailsService는 UserDetailsService의 구현으로, UserDetailsService의 loadUserByUsername메서드를 오버라이드하기 위해 사용한다. 해당 메서드는 검증을 위한 유저 객체를 가져오는 부분으로써, 어떤 객체를 검증할 것인지에 대해 직접 구현해주어야 한다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        return userRepository.findById(userId)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다."));
    }

    // 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 리턴
    private UserDetails createUserDetails(User user) {
        validateUser(user);
        return User.builder()
                .id(user.getId())
                .password(user.getPassword())
                .roles(user.getRoles())
                .build();
    }

    private void validateUser(User user) {
        if (!user.isEnabled()) {
            throw new UsernameNotFoundException("사용할 수 없는 계정입니다.");
        }
    }
}

 

userId를 가지고 UserDetails를 반환한다. 여기서 의문이 들 수 있다.

UserRepository의 findById만 사용하면 비밀번호 검증은 어떻게 할 것인가?

password 검증은 CustomUserDetailsService 에서 하지 않고 일반적으로 AuthenticationProvider 타입의 구현체( 기본은 DaoAuthenticationProvider) 에서 처리한다.

DaoAuthenticationProvider코드를 보면 패스워드를 비교 검증하고 있다.

@Override
@SuppressWarnings("deprecation")
protected void additionalAuthenticationChecks(UserDetails userDetails,
                                              UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
    if (authentication.getCredentials() == null) {
        this.logger.debug("Failed to authenticate since no credentials provided");
        throw new BadCredentialsException(this.messages
                .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
    }
    String presentedPassword = authentication.getCredentials().toString();
    if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {
        this.logger.debug("Failed to authenticate since password does not match stored value");
        throw new BadCredentialsException(this.messages
                .getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
    }
}

 

 

 

6. SecurityUtil


 SecurityContextHolder의 인증 정보는 같은 스레드 의 어플리케이션 내 어디서든 확인 가능하다. 이를 사용해 유용한 Util 클래스를 만들 수 있다.

public class SecurityUtil {
    public static String getCurrentUserId() {
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || authentication.getName() == null) {
            throw new RuntimeException("No authentication information.");
        }
        return authentication.getName();
    }
}

SecurityContextHolder에서 Authentication 객체를 가져와 getName() 함수를 통해 User의 Id를 우리 애플리케이션 어디에서든 얻을 수 있다. 이 함수를 통해 코드 중복을 막을 수 있다. 이런 상황에서 사용할 수 있다.

 

사용 예시

//MemoService의 코드
public ResponseEntity<ResponseDto<Void>> addMemo(MemoCreateRequestDto memoCreateRequestDTO) {
    User user = this.userRepository.findById(SecurityUtil.getCurrentUserId()).orElseThrow();
    Memo memo = new Memo(memoCreateRequestDTO.getTitle(), memoCreateRequestDTO.getContent(), user);
    this.memoRepository.save(memo);
    return new ResponseEntity<>(ResponseDto.res(
            HttpStatus.CREATED,
            "Success"
    ), HttpStatus.CREATED);
}

 

 

 

 

 

 

이렇게 Spring Security에서 jwt를 사용하기 위한 클래스들의 구현이 끝났다(해당 포스팅에서는 accessToken만 다뤘다). 

우리가 구현한 이 클래들이 어떤 흐름으로 사용되는지 보자.

 

흐름은 다음과 같다.

상황 1️⃣. 로그인하는 상황(해당 유저에 대한 인증 정보가 없는 상황)

  1. 유저가 로그인 요청 (Http Request)
  2. 요청은 JwtFilter로 전달
  3. JwtFilter에서 Request Cookie에서 JwtToken을 추출(없을 것임)
  4. JwtFilterdoFilter()를 통해 다음 Filter로 넘어감
  5. 다음 Filter를 통과
  6. 요청은 AuthController의 “/auth/login”에 도착
  7. AuthController에서 AuthServicelogin() 호출
  8. AuthService의 login()에서 우선 UsernamePasswordAuthenticationToken 을 만들고
  9. UsernamePasswordAuthenticationToken 을 통해 사용자를 인증한 후, UserDetails를 얻고
  10. UserDetails를 통해 Authentication 객체를 만들고 SecurityContextHolder 에 저장
  11. 만든 Authentication를 통해 Jwt Token을 만들어 Response Cookie에 Jwt Token을 넣음

상황 2️⃣. 로그인은 이미 했고(Jwt 토큰을 포함한 요청), 인증, 인가가 필요한 요청을 하는 상황

  1. Http Request
  2. 요청은 JwtFilter로 전달
  3. JwtFilter에서 Request Cookie에서 JwtToken을 추출, 유효성 검사(유효하다고 가정)
  4. 토큰이 유효할 경우 JwtTokenProvidergetAuthentication(token) 을 통해 Jwt Token에서 Authentication 객체 추출
  5. SecurityContextHolder.getContext().setAuthentication(authentication)을 통해 Authentication 객체SecurityContext에 저장.