[Project] Spring Boot 3.x + SpringSecurity + JWT 로그인 구현하기

현주·2024년 3월 4일
2

📌 Srping Security와 JWT에 대한 자세한 개념들은 아래 포스팅을 참고해주세요.

Spring Boot의 버전이 3.x 버전으로 올라가면서 Spring Security를 구현할 때 deprecated된 메서드들이 생겼다 !

그래서 Spring Security + JWT 로그인을 어떻게 구현해야하는지 정리해보았다 !

아래는 순서대로 생성하면 되는 클래스들이다.

( 사실 Spring boot 2.x 버전과 달리 변경된건 SecurityConfig 클래스 코드들밖에 없지만 그래도 다시 공부할겸 정리했댜 )

📌 인증 vs 권한

  • 인증 ( Authentication )
    ➜ 자신이 누구라고 주장하는 사람을 확인하는 절차 ( 신원 확인 )
    ⠀ ⠀
  • 권한 ( Authorization )
    ➜ 특정 작업이나 리소스에 대해 접근을 허용하는 과정 ( 접근 권한 )

💡 Spring Security 참고
💡 JWT 참고


1. User

➜ 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;
	}
}

2. 회원가입 관련 로직

  • controller
  • service
  • repository
  • mapper
  • dto

3. application.yml에 설정 추가

➜ 인증 관련 클래스에서 @Value 애너테이션으로 가져오기 위함

jwt:
  secret: randomTestValueItisNotUsedInProdEnv
  expiration: 1800000000 # 30 minutes
  refresh:
    expiration: 604800000 # 7 days
⠀ ⠀
admin:
  email: test@gmail.com,test1@gmail.com

4. Authorities

➜ 사용자 권한 정보를 나타내는 엔티티

@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);
	}
}

5. AuthoritiesUtils

➜ 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 객체 리스트로 반환
	}
}

6. UserPrincipal

➜ 사용자의 주요 정보를 담고 있는 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;
	}
}

7. CustomUserDetailService

➜ 사용자의 상세 정보를 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 객체 생성 후 반환 ( 사용자의 세부 정보들 )
	}
}

💡 여기까지 구현하고 잠깐 살펴보는 로그인 과정 !

  1. 사용자가 로그인 페이지에 자격 증명(credentials)을 입력하고 로그인 버튼 클릭
  2. Spring Security는 사용자가 제공한 자격 증명(여기서는 이메일)을 기반으로 CustomUserDetailService의 loadUserByUsername 메서드 호출
  3. loadUserByUsername 메서드 내에서는 주어진 이메일을 사용하여 사용자를 데이터베이스에서 검색
  4. 사용자가 데이터베이스에서 발견되면, 해당 사용자에 대한 세부 정보를 포함하는 UserPrincipal 객체 생성
  5. 생성된 UserPrincipal 객체를 Spring Security에 반환
  6. Spring Security는 반환된 UserPrincipal 객체를 사용하여 사용자의 비밀번호를 검증하고, 인증(authentication)을 수행
  7. 사용자가 제공한 비밀번호가 저장된 비밀번호와 일치하면, 인증은 성공하고 사용자는 애플리케이션에 로그인

8. LoginDto

➜ 클라이언트의 Username + Password 정보만 담는 단순 dto 클래스

@Getter
@Setter
public class LoginDto {
	private String email;
	private String password;
}

9. AuthToken

➜ 토큰을 생성하고 유효성을 검증하는 데 사용
➜ 역할 : 토큰 생성 / 검증 / 사용자 정보 추출

@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 반환
	}
}

10. AuthTokenProvider

➜ 역할 : 토큰 생성 / 토큰 변환 / 권한 정보 생성 / 인증 정보 반환

@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());
	}
}

11. JwtConfig

➜ 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
}

12. RefreshToken

➜ 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;
}

13. RefreshTokenRepository

public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {}

14. RefreshService

@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);
	}
}

15. JwtAuthenticationFilter

➜ 사용자의 로그인 인증을 처리하고 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. 인증된 상태로 리다이렉트 / 특정 페이지로 이동 등)
	}
}

16. Handler 추가

✔️ 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);
	}
}

17. HeaderUtils

➜ 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;
	}
}

18. JwtVerificationFilter

➜ 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인 경우에만 필터를 건너뛰도록 설정
	}
}

19. controller - RefreshController

➜ 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();
	}
}

20. SecurityConfig ( Spring Boot 3.x 버전 )

@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

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도 잘 담겨 온다구요 ~

0개의 댓글