📌 Srping Security와 JWT에 대한 자세한 개념들은 아래 포스팅을 참고해주세요.
Spring Boot의 버전이 3.x 버전으로 올라가면서 Spring Security를 구현할 때 deprecated된 메서드들이 생겼다 !
그래서 Spring Security + JWT 로그인을 어떻게 구현해야하는지 정리해보았다 !
아래는 순서대로 생성하면 되는 클래스들이다.
( 사실 Spring boot 2.x 버전과 달리 변경된건 SecurityConfig 클래스 코드들밖에 없지만 그래도 다시 공부할겸 정리했댜 )
📌 인증 vs 권한
- 인증 ( Authentication )
➜ 자신이 누구라고 주장하는 사람을 확인하는 절차 ( 신원 확인 )
⠀ ⠀- 권한 ( Authorization )
➜ 특정 작업이나 리소스에 대해 접근을 허용하는 과정 ( 접근 권한 )
➜ Role이 포함된 User 엔티티
@NoArgsConstructor @Getter @Setter @Entity @Table(name = "users") public class User extends Auditable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) public Long id; ⠀ @Email @Column(nullable = false, updatable = false, unique = true, length = 100) private String email; ⠀ @Column(nullable = false, length = 100) private String password; ⠀ ... ⠀ @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.EAGER) private List<Authorities> roles; ⠀ public enum UserRole { USER, ADMIN; } }
➜ 인증 관련 클래스에서 @Value 애너테이션으로 가져오기 위함
jwt: secret: randomTestValueItisNotUsedInProdEnv expiration: 1800000000 # 30 minutes refresh: expiration: 604800000 # 7 days ⠀ ⠀ admin: email: test@gmail.com,test1@gmail.com
➜ 사용자 권한 정보를 나타내는 엔티티
@AllArgsConstructor @NoArgsConstructor @Getter @Entity public class Authorities extends Auditable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; ⠀ @ManyToOne(cascade = CascadeType.ALL) @JoinColumn(name = "user_email") private User user; ⠀ @Enumerated(value = EnumType.STRING) @Column(nullable = false) private User.UserRole role; ⠀ public Authorities(User user, String role) { this.user = user; this.role = User.UserRole.valueOf(role); } }
➜ Role 기반의 유저 권한을 생성하기 위함
@Component public class AuthoritiesUtils { public static Set<String> ADMINS_EMAIL; ⠀ // (1) 관리자 이메일 설정하는 부분 @Value("${admin.email}") // application.yml에서 가져옴 public void setkey(String value) { ADMINS_EMAIL = Set.of(Arrays.stream(value.split(",")).map(String::trim).toArray(String[]::new)); } ⠀ // (2) 유저의 role 생성하는 부분 public static List<String> createRoles(String email) { // 관리자 이메일이 비어있지 않고 / 요청으로 들어온 email이 관리자 이메일 셋에 포함되어있으면 User.UserRole를 리스트로 반환 if (ADMINS_EMAIL != null && ADMINS_EMAIL.contains(email)) { return Stream.of(User.UserRole.values()) .map(User.UserRole::name) .toList(); } ⠀ // 관리자 이메일이 비어있거나 / 요청으로 들어온 eamil이 관리자 이메일 셋에 포함되어있지 않으면 사용자의 기본 권한인 UserRole.USER 반환 return List.of(User.UserRole.USER.name()); } ⠀ // (3) role을 기반으로 유저에게 권한을 부여하는 부분 public static List<Authorities> createAuthorities(User user) { return createRoles(user.getEmail()).stream() .map(role -> new Authorities(user, role)) .toList(); // 아까 role 생성한 부분에서 유저의 이메일 넣어서 반환된 리스트 (role 구분된 리스트)가지고, 각 권한을 Authorities 엔티티로 매핑해서 리스트로 반환 } ⠀ // (4) 문자열 형태의 role 목록을 받아서 Spring Security에서 사용하는 GrantedAuthority 형태로 변환하여 반환하는 메서드 public static List<GrantedAuthority> getAuthorities(List<String> roles) { return roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) .collect(Collectors.toList()); // 역할 목록을 스트림으로 // 각 역할에 대해 "ROLE_" 접두사를 붙여서 Spring Security에서 인식하는 Authority 객체인 SimpleGrantedAuthority로 변환하고 // 변환된 권한 객체들을 리스트로 반환 } ⠀ // (5) Authorities 엔터티 객체 목록을 받아서 해당 권한들을 Spring Security의 GrantedAuthority로 변환하는 메서드 public static List<GrantedAuthority> getAuthoritiesByEntity(List<Authorities> roles) { return roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.getRole().name())) .collect(Collectors.toList()); // 각 Authorities 엔티티 객체에서 해당 권한의 역할을 반환하여 // 이 값을 SimpleGrantedAuthority의 생성자에 전달하여 // "ROLE_" 접두사를 추가한 후 GrantedAuthority 객체 리스트로 반환 } }
➜ 사용자의 주요 정보를 담고 있는 UserDetails 구현체
➜ 인증 및 권한 부여 작업 시 사용됨
@Getter @Setter @Slf4j public class UserPrincipal extends User implements UserDetails { // TODO : ouath2 private Map<String, Object> attribues; ⠀ ⠀ public UserPrincipal(User user) { setEmail(user.getEmail()); setPassword(user.getPassword()); setRoles(user.getRoles()); setProviderType(user.getProviderType()); } ⠀ ⠀ // User 객체에서 필요한 정보를 가져와 UserPrincipal 객체 초기화 public static UserPrincipal create(User user) { return new UserPrincipal(user); } ⠀ ⠀ // 주어진 User 객체를 기반으로 새로운 UserPrincipal 객체 생성 public static UserPrincipal create(User user, Map<String, Object> attribues) { UserPrincipal userPrincipal = create(user); userPrincipal.setAttribues(attribues); ⠀ ⠀ return userPrincipal; } ⠀ ⠀ // 사용자의 권한 반환 @Override public Collection<? extends GrantedAuthority> getAuthorities() { return AuthoritiesUtils.getAuthoritiesByEntity(getRoles()); } ⠀ ⠀ // 사용자의 이름 반환 @Override public String getUsername() { return this.getEmail(); } ⠀ ⠀ // 이 밑으로는 계정에 대한 유효성 검증하는 메서드들 ⠀ ⠀ // 계정이 만료되지 않았는지 @Override public boolean isAccountNonExpired() { return this.getUserStatus().equals(UserStatus.MEMBER_ACTIVE); } ⠀ ⠀ // 계정이 잠기지 않았는지 @Override public boolean isAccountNonLocked() { return true; } ⠀ ⠀ // 크리덴셜(Ex.password)이 만료되지 않았는지 @Override public boolean isCredentialsNonExpired() { return true; } ⠀ ⠀ // 사용자가 활성화되어있는지 @Override public boolean isEnabled() { return true; } }
➜ 사용자의 상세 정보를 load하기 위한 UserDetailsService 구현체
➜ 인증 및 권한 부여 작업 시 사용됨
@Service public class CustomUserDetailService implements UserDetailsService { private final JpaUserRepository userRepository; ⠀ ⠀ public CustomUserDetailService(JpaUserRepository userRepository) { this.userRepository = userRepository; } ⠀ ⠀ @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = userRepository.findByEmail(username) .orElseThrow(() -> new CustomLogicException(ExceptionCode.MEMBER_NONE)); return UserPrincipal.create(user); // 이메일 주소 기반으로 사용자 찾아서 (찾지 못하면 에러 반환) // UserPrincipal.create(user)를 호출하여 해당 사용자에 대한 userPrincipal 객체 생성 후 반환 ( 사용자의 세부 정보들 ) } }
loadUserByUsername 메서드 호출loadUserByUsername 메서드 내에서는 주어진 이메일을 사용하여 사용자를 데이터베이스에서 검색UserPrincipal 객체 생성UserPrincipal 객체를 Spring Security에 반환UserPrincipal 객체를 사용하여 사용자의 비밀번호를 검증하고, 인증(authentication)을 수행➜ 클라이언트의 Username + Password 정보만 담는 단순 dto 클래스
@Getter @Setter public class LoginDto { private String email; private String password; }
➜ 토큰을 생성하고 유효성을 검증하는 데 사용
➜ 역할 : 토큰 생성 / 검증 / 사용자 정보 추출
@Slf4j // 토큰 데이터 클래스 public class AuthToken { @Getter private final String token; private final Key key; ⠀ ⠀ private static final String AUTHORITIES_KEY = "role"; ⠀ ⠀ // 생성자 1 - 기본 생성자 public AuthToken(String token, Key key) { this.key = key; this.token = token; } ⠀ ⠀ // 생성자 2 AuthToken(String id, Date expiry, Key key) { this.key = key; this.token = createAccessToken(id, expiry); } ⠀ ⠀ // 생성자 3 AuthToken(String id, String role, Date expiry, Key key) { this.key = key; this.token = createAccessToken(id, role, expiry); } ⠀ ⠀ // 생성자 4 AuthToken(String id, List<String> roles, Date expiry, Key key) { this.key = key; this.token = createAccessToken(id, roles, expiry); } ⠀ ⠀ // (1) id와 만료 기한으로 accessToken 생성 private String createAccessToken(String id, Date expiry) { return Jwts.builder() .setSubject(id) // JWT의 "sub" (subject) 클레임에 사용자 id를 설정 ( 사용자 고유 식별자 ) .signWith(key, SignatureAlgorithm.HS256) // HS256 알고리즘과 key를 사용하여 jwt 서명 // ( 서명 - JWT가 변경되지 않았음을 보장하고, 무결성을 유지하기 위해 사용 ) .setExpiration(expiry) // 만료 기한 설정 .compact(); // jwt를 문자열로 변환 } ⠀ ⠀ // (2) id, role, 만료 기한으로 accessToken 생성 ( 한 유저의 role이 하나인 경우 ) private String createAccessToken(String id, String role, Date expiry) { return Jwts.builder() .setSubject(id) .claim(AUTHORITIES_KEY, role) // jwt 클레인에 사용자의 역할 정보 추가 (AUTHORITIES_KEY("role")은 클레임 이름 - role 변수는 값) .signWith(key, SignatureAlgorithm.HS256) .setExpiration(expiry) .compact(); } ⠀ ⠀ // (3) id, roles, 만료 기한으로 accessToken 생성 ( 한 유저의 role이 두개 이상인 경우 ) private String createAccessToken(String id, List<String> roles, Date expiry) { return Jwts.builder() .setSubject(id) .claim(AUTHORITIES_KEY, roles) .signWith(key, SignatureAlgorithm.HS256) .setExpiration(expiry) .compact(); } ⠀ ⠀ // (4) 토큰이 유효한지 여부 검증 public boolean isTokenValid() { return getValidTokenClaims() != null; // 유효하면 true } ⠀ ⠀ ⠀ // (4) 토큰의 만료 여부 검증 public boolean isTokenExpired() { return getExpiredTokenClaims() != null; // 만료됐으면 true } ⠀ ⠀⠀ ⠀ // (5) 만료되지 않은 토큰의 클레임 추출 // JWT 구문 분석 과정에서 예외 발생 시, 해당 예외 처리 후 null 반환 public Claims getValidTokenClaims() { try { return Jwts.parserBuilder() // jwt 구문 분석을 위한 builder 객체 생성 .setSigningKey(key) // 빌더 객체에 대해 서명 키 설정 ( jwt 유효성 검사하는 데 사용됨 ) .build() // 실제 jwt 파서 객체 생성 .parseClaimsJws(token) // 생성된 jwt 파서를 사용해서 주어진 토큰 구문 분석 ( 여기서 서명이 유효한지 확인 ) .getBody(); // 구문 분석된 jwt 클레임(본문) 반환 } catch (MalformedJwtException e) { // jwt 토큰 형식이 올바르지 않을 경우 발생 log.info("Invalid JWT token."); } catch (ExpiredJwtException e) { // jwt 토큰의 유효 기간이 만료된 경우 발생 log.info("Expired JWT token."); } catch (UnsupportedJwtException e) { // jwt 토큰이 지원되지 않는 형식이거나, 지원되지 않는 기능을 사용할 경우 발생 log.info("Unsupported JWT token."); } catch (IllegalArgumentException e) { // 잘못된 인자가 전달되었을 경우 발생 log.info("JWT token compact of handler are invalid."); } catch (io.jsonwebtoken.security.SignatureException e) { // jwt 토큰의 서명이 유효하지 않은 경우 발생 throw new CustomLogicException(ExceptionCode.TOKEN_INVALID); } return null; } ⠀ ⠀ // (6) 만료된 토큰의 클레임 추출 public Claims getExpiredTokenClaims() { try { Jwts.parserBuilder() .setSigningKey(key) .build() .parseClaimsJws(token) .getBody(); } catch (ExpiredJwtException e) { log.info("Expired JWT token."); return e.getClaims(); // 예외가 발생한 경우 (jwt 토큰의 만료 기간이 지났을 경우) // 로그 출력 후, 토큰의 클레임 대신 예외에서 가져온 클레임 반환 } return null; // 예외가 발생하지 않는다면 null 반환 } }
➜ 역할 : 토큰 생성 / 토큰 변환 / 권한 정보 생성 / 인증 정보 반환
@Slf4j public class AuthTokenProvider { private final Key key; private final long tokenValidTime; private final long refreshTokenValidTime; private static final String AUTHORITIES_KEY = "role"; ⠀ ⠀ public AuthTokenProvider(String secret, long tokenValidTime, long refreshTokenValidTime) { this.key = Keys.hmacShaKeyFor(secret.getBytes()); // 주어진 비밀키를 사용하여 HMAC-SHA 기반의 키 생성 this.tokenValidTime = tokenValidTime; this.refreshTokenValidTime = refreshTokenValidTime; } ⠀ ⠀ // 액세스 토큰 생성 public AuthToken createAccessToken(String id, Date expiry) { return new AuthToken(id, expiry, key); } ⠀ ⠀ // 엑세스 토큰 생성 public AuthToken createAccessToken(String id, String role, Date expiry) { return new AuthToken(id, role, expiry, key); } ⠀ ⠀ // 엑세스 토큰 생성 public AuthToken createAccessToken(String id, List<String> role) { return new AuthToken(id, role, new Date(System.currentTimeMillis() + tokenValidTime), key); // 만료 기한 ➜ 현재 시간 + tokenValidTime } ⠀ ⠀ // 만료된 엑세스 토큰 생성 public AuthToken createExpiredAccessToken(String id, List<String> role) { return new AuthToken(id, role, new Date(System.currentTimeMillis() - tokenValidTime), key); // 현재 시간 - tokenValidTime ➜ 현재시간 이전부터 유효하지 않도록 } ⠀ ⠀ // 리프레시 토큰 생성 public AuthToken createRefreshToken(String id) { return new AuthToken(id, new Date(System.currentTimeMillis() + refreshTokenValidTime), key); } ⠀ ⠀ // 토큰 문자열로 AuthToken 객체 만드는 메서드 ( 토큰 검증 및 관련 작업 가능 ) public AuthToken convertAuthToken(String token) { return new AuthToken(token, key); } ⠀ ⠀ // Authentication 객체 생성 메서드 public Authentication getAuthentication(AuthToken authToken) { if (authToken.isTokenValid()) { // 토큰 유효성 확인 Claims claims = authToken.getValidTokenClaims(); Collection<? extends GrantedAuthority> authorities = getAuthorities((List) claims.get(AUTHORITIES_KEY)); // 토큰에서 추출한 role 바탕으로 권한 정보 생성 // 권한 정보를 만들 때 앞에 "ROLE_" 을 붙였기 때문에 클레임에 "role"이라는 string이 포함된 클레임 ⠀ ⠀ log.debug("claims subject := [{}]", claims.getSubject()); ⠀ ⠀ // loadUserByUsername 메서드에서 만든 userDetails 생성 UserDetails userDetails = customUserDetailService.loadUserByUsername(authToken.getValidTokenClaims().getSubject()); ⠀ ⠀ // 생성된 User 객체와 권한 정보를 사용하여 UsernamePasswordAuthenticationToken 생성 return new UsernamePasswordAuthenticationToken(userDetails, authToken, userDetails.getAuthorities()); } else { throw new CustomLogicException(ExceptionCode.USER_NONE); // 여기서 USER_NONE 을 던지는 이유는 이미 isTokenValid 메서드에서 검증할 때, 여러 유효성 검사(만료 등)를 거쳤기 때문에 // 다른 부분에서 유효성 검사에 걸렸다면 그 익셉션이 발생했을 것. // 여기까지 왔다면 유저가 없는 것 밖에 없음 } } ⠀ ⠀ // roles 기반으로 인증된(신원 확인된) Authorities 생성하는 메서드 public static Collection<? extends GrantedAuthority> getAuthorities(List<String> roles) { return roles.stream() .map(role -> role.startsWith("ROLE_") ? new SimpleGrantedAuthority(role) : new SimpleGrantedAuthority("ROLE_" + role)) // 그런데 이미 getAuthentication 메서드 내에서 이 getAuthorities 메서드를 호출할 때 role 스트링이 붙은 애들만 인자로 넣어주었기에 // ROLE_을 붙이는 과정은 무의미 하지만 / 다른 데 재사용될 가능성이 있기에 넣어두기 .collect(Collectors.toList()); } }
➜ JWT 설정 클래스
@Configuration @Getter public class JwtConfig { @Value("${jwt.secret}") private String secret; @Value("${jwt.expiration}") private Long tokenValidTime; @Value("${jwt.refresh.expiration}") private Long refreshTokenValidTime; ⠀ @Bean public AuthTokenProvider authTokenProvider() { return new AuthTokenProvider(secret, tokenValidTime, refreshTokenValidTime); } ⠀ // TODO : oauth2 }
➜ RefreshToken 엔티티
@Entity @AllArgsConstructor @NoArgsConstructor @Setter @Getter @Builder public class RefreshToken extends Auditable { @Id @Column( unique = true) ⠀ ⠀ @NotNull private String email; ⠀ ⠀ @NotNull private String token; ⠀ ⠀ private Date expiryDate; }
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> { ⠀ }
@Service @Transactional public class RefreshService { private final RefreshTokenRepository refreshTokenRepository; private final AuthTokenProvider authTokenProvider; ⠀ ⠀ public RefreshService(RefreshTokenRepository refreshTokenRepository, AuthTokenProvider authTokenProvider) { this.refreshTokenRepository = refreshTokenRepository; this.authTokenProvider = authTokenProvider; } ⠀ ⠀ // 리프레시 토큰 저장 public void saveRefreshToken(String email, AuthToken authToken) { refreshTokenRepository.findById(email) // 해당 이메일에 대한 리프레시 토큰 조회 .ifPresentOrElse( // 존재한다면 리프레시 토큰 업데이트 후 저장 refreshToken -> { // 토큰과 만료 날짜 새로 업데이트 refreshToken.setToken(authToken.getToken()); refreshToken.setExpiryDate(authToken.getValidTokenClaims().getExpiration()); }, () -> { RefreshToken refreshToken = RefreshToken.builder() .email(email) .token(authToken.getToken()) .expiryDate(authToken.getValidTokenClaims().getExpiration()) .build(); refreshTokenRepository.save(refreshToken); } ); } ⠀ ⠀ // 리프레시 토큰으로 액세스 토큰 갱신 public void refresh(HttpServletRequest request, HttpServletResponse response) { AuthToken accessToken = authTokenProvider.convertAuthToken(getAccessToken(request)); validateAccessTokenCheck(accessToken); ⠀ ⠀ String userEmail = accessToken.getExpiredTokenClaims().getSubject(); RefreshToken refreshToken = refreshTokenRepository.findById(userEmail) .orElseThrow(() -> new CustomLogicException(ExceptionCode.REFRESH_TOKEN_NOT_FOUND)); validateRefreshTokenCheck(refreshToken, authTokenProvider.convertAuthToken(getHeaderRefreshToken(request))); ⠀ ⠀ // 새 엑세스 토큰 생성 AuthToken newAccessToken = authTokenProvider.createAccessToken(userEmail, (List<String>)accessToken.getExpiredTokenClaims().get("role")); ⠀ ⠀ response.addHeader("Authorization", "Bearer " + newAccessToken.getToken()); } ⠀ ⠀ // 엑세스 토큰 유효성 확인 public void validateAccessTokenCheck(AuthToken authToken) { if (!authToken.isTokenExpired()) // 만료되지 않았다면 에러 (만료가 되어야 리프레시 토큰으로 다시 발급받을 수 있기 때문) throw new CustomLogicException(ExceptionCode.TOKEN_INVALID); ⠀ ⠀ if (authToken.getExpiredTokenClaims() == null) // 만료된 토큰의 클레임이 null인지 확인 throw new CustomLogicException(ExceptionCode.TOKEN_INVALID); // 토큰이 만료되었을 때, 해당 토큰의 클레임을 가져올 수 있다면 이는 토큰이 잘못된 것으로 간주 } ⠀ ⠀ // 리프레시 토큰 유효성 검증 public void validateRefreshTokenCheck(RefreshToken refreshToken, AuthToken headerRefreshToken) { if(!headerRefreshToken.isTokenValid()) // 만료되었다면 에러 throw new CustomLogicException(ExceptionCode.REFRESH_TOKEN_INVALID); ⠀ ⠀ if (!refreshToken.getToken().equals(headerRefreshToken.getToken())) // 리프레시 토큰이 같지 않다면 에러 throw new CustomLogicException(ExceptionCode.REFRESH_TOKEN_NOT_MATCH); } }
➜ 사용자의 로그인 인증을 처리하고 JWT 토큰을 반환하는 역할
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { private final AuthTokenProvider authTokenProvider; private final AuthenticationManager authenticationManager; private final RefreshService refreshService; ⠀ ⠀ public JwtAuthenticationFilter(AuthTokenProvider authTokenProvider, AuthenticationManager authenticationManager, RefreshService refreshService) { this.authTokenProvider = authTokenProvider; this.authenticationManager = authenticationManager; this.refreshService = refreshService; } ⠀ ⠀ // 요청으로 들어온 로그인 정보 확인 / 인증 시도 @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) { Gson gson = new Gson(); LoginDto loginDto = null; ⠀ ⠀ try { loginDto = gson.fromJson(request.getReader(), LoginDto.class); // gson 라이브러리를 사용해서 사용자의 로그인 정보를 읽어와 HTTP 요청의 바디에서 JSON 형식의 데이터를 읽어와 JAVA 객체로 변환하는 역할 } catch (IOException e) { throw new CustomLogicException(ExceptionCode.INVALID_ELEMENT); } ⠀ ⠀ // 로그인 정보를 성공적으로 읽어오지 못한 경우 if (loginDto == null) { try { ErrorResponder.sendErrorResponse(response, HttpStatus.BAD_REQUEST); } catch (IOException e) { throw new RuntimeException(e); } ⠀ ⠀ return null; } ⠀ ⠀ // 로그인 정보를 성공적으로 읽어온 경우 UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword()); // LoginDto 객체에서 이메일과 비밀번호를 추출하여 사용자의 인증 토큰인 UsernamePasswordAuthenticationToken 객체 생성 ⠀ ⠀ return authenticationManager.authenticate(authenticationToken); // AuthenticationManager 를 사용하여 사용자의 인증을 시도하고, 인증이 성공하면 해당 인증 객체를 반환 } ⠀ ⠀ // 사용자의 인증이 성공했을 때 호출되는 메서드 @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { User user = (User) authResult.getPrincipal(); AuthToken accessToken = authTokenProvider.createAccessToken(user.getEmail(), user.getRoles().stream().map(role -> role.getRole().name()).collect(Collectors.toList())); AuthToken refreshToken = authTokenProvider.createRefreshToken(user.getEmail()); ⠀ ⠀ // 응답에 토큰 정보 추가 response.addHeader("Authorization", "Bearer" + accessToken.getToken()); response.addHeader("RefreshToken", "Bearer" + refreshToken.getToken()); ⠀ ⠀ // 리프레시 토큰 저장 refreshService.saveRefreshToken(user.getEmail(), refreshToken); ⠀ ⠀ // 사용자의 인증이 성공했음을 처리 getSuccessHandler().onAuthenticationSuccess(request, response, authResult); // 사용자가 성공적으로 로그인한 후에 수행해야할 추가적인 작업 처리하는 메서드 (Ex. 인증된 상태로 리다이렉트 / 특정 페이지로 이동 등) } }
UserAuthenticationSuccessHandler➜ 로그인 성공 시
@Slf4j public class UserAuthenticationSuccessHandler implements AuthenticationSuccessHandler { ⠀ @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { response.setStatus(200); response.getWriter().write(new Gson().toJson(new SingleResponse<>("Login Success"))); log.info("LOGIN SUCCESS : " + authentication.getName()); } }
UserAuthenticationFailureHandler➜ 로그인 실패 시
@Slf4j public class UserAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { log.info("LOGIN FAILED : " + exception.getMessage()); sendErrorResponse(response); } ⠀ // 인증 실패 시 HttpServletResponse 를 통해 오류 응답을 전송 private void sendErrorResponse(HttpServletResponse response) throws java.io.IOException { // gson을 사용하여 ErrorResponse 객체 생성하고, 해당 객체를 JSON 형태로 변환하여 응답 본문에 작성 Gson gson = new Gson(); ErrorResponse errorResponse = ErrorResponse.of(HttpStatus.UNAUTHORIZED, "LOGIN FAILED"); response.setStatus(errorResponse.getStatus()); response.getWriter().write(gson.toJson(errorResponse)); } }
UserAccessDeniedHandler➜ 접근 권한이 없을 시
@Slf4j public class UserAccessDeniedHandler implements AccessDeniedHandler { ⠀ @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { ErrorResponder.sendErrorResponse(response, HttpStatus.FORBIDDEN); log.warn("Forbidden error happened: {}", accessDeniedException.getMessage()); } }
UserAuthenticationEntryPoint➜ 사용자의 인증이 실패하거나 인증되지 않은 상태에서 보호된 리소스에 접근하려고 할 때 호출되는 인증 진입 지점 정의
@Slf4j public class UserAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { Exception exception = (Exception) request.getAttribute("exception"); ErrorResponder.sendErrorResponse(response, HttpStatus.UNAUTHORIZED); ⠀ logExceptionMessage(authException, exception); } ⠀ private void logExceptionMessage(AuthenticationException authenticationException, Exception exception) { String message = exception != null ? exception.getMessage() : authenticationException.getMessage(); log.warn("Unauthorized error happened: {}", message); } }
➜ Http 요청 헤더에서 Access Token, Refresh Token 추출하는 유틸리티 클래스
public class HeaderUtils {
private final static String HEADER_AUTHORIZATION = "Authorization";
private final static String TOKEN_PREFIX = "Bearer "; // 띄어쓰기 포함
private final static String HEADER_REFRESH_TOKEN = "RefreshToken";
// Access Token 추출
public static String getAccessToken(HttpServletRequest request) {
String headerValue = request.getHeader(HEADER_AUTHORIZATION);
// 요청에서 Authorization 헤더 값 가져와서
// null인 경우 null 반환
if (headerValue == null) {
return null;
}
// 존재하는데 Bearer로 시작한다면
if (headerValue.startsWith(TOKEN_PREFIX)) {
return headerValue.substring(TOKEN_PREFIX.length());
// 해당 문자열 제거한 나머지 부분을 엑세스 토큰으로 간주하여 반환
// substring(TOKEN_PREFIX.length()) --> TOKEN_PREFIX의 길이부터 문자열의 끝까지의 부분 문자열 반환
}
// Bearer로 시작하지 않는다면 null 반환
return null;
}
// Refresh Token 추출
public static String getHeaderRefreshToken(HttpServletRequest request) {
String headerValue = request.getHeader(HEADER_REFRESH_TOKEN);
// 요청에서 refresh token 값 가져와서
// null인 경우 null 반환
if (headerValue == null) {
return null;
}
if (headerValue.startsWith(TOKEN_PREFIX)) {
return headerValue.substring(TOKEN_PREFIX.length());
}
return null;
}
}
➜ Http 요청에서 받은 JWT 토큰의 유효성 검사 필터
public class JwtVerificationFilter extends OncePerRequestFilter { private final AuthTokenProvider authTokenProvider; // TODO : Redis 추가? ⠀ public JwtVerificationFilter(AuthTokenProvider authTokenProvider) { this.authTokenProvider = authTokenProvider; } ⠀ @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String tokenStr = HeaderUtils.getAccessToken(request); // AccessToken 추출해서 Bearer 제외하고 나머지 토큰으로 인식한 후에 AuthToken token = authTokenProvider.convertAuthToken(tokenStr); // 해당 str로 토큰으로 변환 ⠀ ⠀ // securityContextHolder에 유저 정보를 저장해주는 로직 if (token.isTokenValid()) { Authentication authentication = authTokenProvider.getAuthentication(token); ⠀ SecurityContextHolder.getContext().setAuthentication(authentication); } ⠀ filterChain.doFilter(request, response); // 현재 필터가 다음에 호출될 필터 또는 서블릿으로 요청을 전달하는 역할 // 이 메서드를 호출하면 현재 필터가 다음 필터로 제어를 전달하고, 다음 필터가 없거나 체인의 끝에 도달하면 서블릿이 실행됨 } ⠀ // 특정 조건에서 필터를 건너뛰도록 설정하는 메서드 @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { String tokenStr = HeaderUtils.getAccessToken(request); return tokenStr == null; // 요청에 포함된 액세스 토큰을 가져와서 이 토큰이 null인 경우에만 필터를 건너뛰도록 설정 } }
➜ Refresh Token 갱신 controller
@RestController @Validated @RequestMapping("/api/v1/auth") @Tag(name = "[인증]") public class RefreshController { private final RefreshService refreshService; ⠀ public RefreshController(RefreshService refreshService) { this.refreshService = refreshService; } ⠀ ⠀ @PostMapping("/refresh") @Operation(summary = "리프레시 토큰을 사용하여 엑세스 토큰을 갱신합니다.") public ResponseEntity refresh(HttpServletRequest request, HttpServletResponse response) { refreshService.refresh(request, response); return ResponseEntity.ok().build(); } }
@Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { private final AuthTokenProvider authTokenProvider; private final RefreshService refreshService; // TODO : oauth2 ⠀ ⠀ public SecurityConfig(AuthTokenProvider authTokenProvider, RefreshService refreshService) { this.authTokenProvider = authTokenProvider; this.refreshService = refreshService; } ⠀ ⠀ @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } ⠀ ⠀ @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } ⠀ ⠀ @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationManager authenticationManager) throws Exception { JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authTokenProvider, authenticationManager, refreshService); // // 사용자의 JWT 토큰을 검증하고 인증을 수행하는 JwtAuthenticationFilter 생성 jwtAuthenticationFilter.setFilterProcessesUrl("/api/v1/auth/login"); // JWT 인증 필터가 인증을 수행할 엔드포인트 설정 jwtAuthenticationFilter.setAuthenticationSuccessHandler(new UserAuthenticationSuccessHandler()); // 로그인 성공 시 실행될 핸들러 jwtAuthenticationFilter.setAuthenticationFailureHandler(new UserAuthenticationFailureHandler()); //로그인 실패 시 실행될 핸들러 JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(authTokenProvider); // JWT 토큰의 유효성을 검증할 JwtVerificationFilter 생성 builder.addFilter(jwtVerificationFilter) // HttpSecurity에 추가 ⠀ ⠀ http.csrf(csrf -> csrf.disable()) // CSRF .cors(Customizer.withDefaults()) // CORS .headers(header -> header.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 관리 상태 없음 .formLogin(form -> form.disable()) // FormLogin 비활성화 .httpBasic(AbstractHttpConfigurer::disable) // BasicHttp 비활성화 .addFilterBefore(jwtVerificationFilter, UsernamePasswordAuthenticationFilter.class) // Custom 필터 추가 .addFilter(jwtAuthenticationFilter) // Custom 필터 추가 .exceptionHandling( exceptionHandling -> exceptionHandling .authenticationEntryPoint(authenticationEntryPoint()) .accessDeniedHandler(accessDeniedHandler())) .authorizeHttpRequests( authorize -> authorize .requestMatchers(HttpMethod.GET, "/api/v1/members/**").authenticated() .requestMatchers("/api/v1/members/**").permitAll() ⠀ ⠀ .requestMatchers("/h2/**").permitAll() .anyRequest().permitAll() ); ⠀ ⠀ return http.build(); } ⠀ ⠀ // 인증되지 않은 사용자의 요청이 보안 제약에 위배되었을 때 호출되는 엔트리 포인트 정의 // 401 Unauthorized 응답을 생성하는 데 사용 @Bean public AuthenticationEntryPoint authenticationEntryPoint() { return new UserAuthenticationEntryPoint(); } ⠀ ⠀ // 인가되지 않은 사용자의 요청이 보안 제약에 위배되었을 때 호출되는 핸들러 정의 // 403 Forbidden 응답을 생성하는 데 사용 @Bean public AccessDeniedHandler accessDeniedHandler() { return new UserAccessDeniedHandler(); } }
Spring Boot 2.x 버전의 SecurityConfig에서는 FilterChain을 아래와 같이 구현했었다.
@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http.csrf().disable() .headers().frameOptions().disable() .and() .cors().and() .formLogin().disable() .httpBasic().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and().apply(customFilterConfigurer) .and().exceptionHandling() .accessDeniedHandler(accessDeniedHandler()) .authenticationEntryPoint(authenticationEntryPoint()) .and().authorizeRequests( authorize -> authorize .antMatchers(HttpMethod.GET, "/api/v1/users/**").authenticated() .antMatchers(HttpMethod.PATCH, "/api/v1/users/**").authenticated() .antMatchers("/api/v1/users/**").permitAll() ... .antMatchers("/h2/**").permitAll() .anyRequest().permitAll() ); // http.oauth2 관련 추가 return http.build(); }
그런데 Spring Boot 3.x 버전으로 변경되면서
.and(), .disable(), apply() 등이 deprecated 되었고,
.authorizeRequests()가 .authorizeHttpRequests()로 변경,
.antMatchers()가 .requestMatchers()로 변경 되었다!
그리고 람다식을 쓰도록 권장한다고 한다.
여기까지 구현하면 로그인 구현 끄읕
postman으로 요청을 보내도 아래와 같이 로그인이 잘 작동하는 것을 볼 수 있다!

헤더에 AccessToken이랑 RefreshToken도 잘 담겨 온다구요 ~
