[Spring] Spring Security를 이용한 로그인 구현 (스프링부트 3.X 버전) [4] - JWT를 이용한 인증

Paek·2024년 1월 30일
4

Spring프로젝트

목록 보기
7/9
post-thumbnail

이 포스팅에서는 스프링 부트 3.2.2 버전을 사용하고, 스프링 시큐리티 6.2.1 버전을 사용합니다.

이번 포스팅부터는 JWT를 사용한 인증방식을 도입해보겠습니다.

JWT란?

JWT는 JSON Web Token의 약자로, 토큰 기반 인증을 지원하는 것입니다. 토큰 기반 인증은 말 그대로 토큰을 인증에 사용하는 것입니다.

토큰은 서버에서 클라이언트를 구분하기 위한 유일한 값입니다. 서버에서 코튼을 생성해서 클라이언트에게 제공하면, 클라이언트는 해당 토큰을 가지고 있다가 여러 요청과 토큰을 함께 보내면 서버에서 유효성 검사 후 토큰이 유효하다면 클라이언트의 요청을 성공적으로 처리해주게 됩니다.

언제 사용하면 좋은가?

토큰은 아래 두가지 경우에 사용하면 좋습니다.

  • 권한 부여 : JWT를 사용하는 가장 일반적인 방식입니다. 사용자가 로그인하면 각 후속 요청에 JWT가 포함되어 사용자가 해당 토큰으로 서비스 및 리소스에 접근할 수 있습니다.
  • 정보 교환 : JWT는 정보를 안전하게 전송하는 좋은 방법입니다. 예를 들어 공개/개인 키 쌍을 사용해 JWT에 서명이 가능하기 때문에 발신자가 누구인지 식별 가능합니다. 또한 헤더와 페이로드를 사용하여 서명을 계산하므로 콘텐츠 변조여부를 식별할 수 있습니다.

(서명이란, 이 토큰을 누가 작성했는지 알아내는것)

토큰 사용의 장점

  1. 크기 : JSON으로 생성된 토큰은 용량이 작기 때문에 매우 빠르게 전달될 수 있습니다.
  2. 안전성 : HMAC 알고리즘 또는 RSA 또는 ECDSA를 사용하는 공개/개인 키 쌍을 사용하여 서명할 수 있기 때문에 무결성이 보장됩니다.
  3. 무상태성(Stateless)
    • 세션 기반 인증은 상태를 갖습니다. 즉, Stateful 서버입니. 세션 기반 인증은 클라이언트의 (인증) 상태를 관리해야합니다. 사용자가 로그인에 성공할 시 세션이 발행되는데, 여기서 문제점은 그 세션을 여러군데에 저장할 수 있다는 점입니다.(브라우저, 서버 메모리 등)
    • 이렇게 저장된 세션을 클라이언트 측에서 서버로 요청을 보낼 때 함께 보내며, 서버는 클라이언트에서 받은 세션 값에 대해 유효성 검사를 실시합니다. 이런 세션 관리는 사용자가 많으면 많아질수록 서버가 처리해야하는 작업의 양이 커지게됩니다.
    • 반대로 Stateless 서버는 이러한 상태를 유지하지 않는 서버입니다. 토큰을 서버가 아닌 클라이언트 측에서 관리합니다. 브라우저의 로컬 스토리지 혹은 쿠키에 저장되고, 이 토큰을 HTTP 요청과 함께 서버 측에 보내면 서버는 이 토큰에 대한 위변조 검사 및 만료 여부 등의 유효성 검사를 실시합니다.
    • 이렇듯 토큰 기반 인증 방식은 서버 측에서 클라이언트의 상태를 직접적으로 관리하지 않으므로 높은 확장성을 가질 수 있게 됩니다.

JWT의 구조

header.payload.signature 세가지로 이루어져 있으며, 각각은 '.'으로 구분합니다. (헤더, 내용, 서명)

https://jwt.io/#debugger-io -> 이 사이트에서 직접 jwt 디버거를 통해 jwt를 생성해보실 수 있습니다.

헤더(Header)

토큰의 타입(type)해싱 알고리즘(alg)를 지정합니다.

헤더는 일반적으로 토큰의 유형과 서명에 대한 해싱 알고리즘(보통 HMAC SHA256 또는 RSA)를 지정하는 정보를 담고있습니다.

위 사진처럼 JWT에 HS512 해싱 알고리즘을 사용해서 암호화 및 복호화를 해보겠습니다.

참고로, 암호화 방식에서 SHA는 해쉬를 사용한 암호화 방식으로, 복호화가 불가능합니다. 하지만 HMAC은 시크릿 키를 포함하여 암호화하는 방식입니다. (HS 알고리즘 -> HMAC + SHA로 시크릿 키를 가짐)

내용(Payload)

토큰에 담을 정보입니다.

토큰에 담을 하나의 정보, 한 조각의 정보를 클레임(Claim)이라고 합니다. 클레임의 종류에는 3가지가 있습니다.

등록된 클레임 (registered claim)

  • 서비스에 필요한 정보가 아니라 토큰에 대한 정보들을 담기 위해 이름이 이미 정해져 있는 클레임
  • 필수는 아니지만 권장되는 클레임
  • iss(발급자), sub(제목), aud(대상자), exp(만료시간), nbf(토큰의 활성날짜), iat(발급된시간), jti(JWT 고유식별자, 일회용 토큰에 사용)

공개 클레임 (public claim)

  • 공개 클레임은 사용자 정의 클레임으로, 공개용 정보 전달을 위해 사용. 충돌을 방지하기 위해 URI 포맷을 이용

비공개 클레임 (private claim)

  • 비공개 클레임은 등록된 클레임도 아니고, 공개 클레임도 아닌 당사자간에 정보를 공유하기 위해 만들어진 사용자 지정 클레임.
  • 이곳에 인증 정보 등의 서버와 클라이언트간 필요한 정보를 넣어두는 형식
}
  "sub": "accessToken", // 등록된 클레임 (제목)
  "name": "Do Hyun", // 비공개 클레임
  "email" : "dh1010a@naver.com" // 비공개 클레임
  "iat": 1516239022 // 등록된 클레임 (발급된 시간)
}

서명(signature)

토큰을 인코딩하거나 유효성 검증을 할 때 사용하는 고유한 암호화 코드

서명은 위에서 만든 헤더와 페이로드의 값을 각각 BASE64로 인코딩하고, 인코딩 한 값을 비밀 키를 이용하여 헤더에서 정한 알고리즘으로 해싱을 한 후 다시 BASE64로 인코딩 하여 생성합니다.

HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

서명은 HS256 방식의 경우 우리가 만든 헤더와 페이로드, 그리고 나만 아는 개인 키를 넣어서 HS256 암호화 알고리즘을 사용하여 암호화를 하여서 사용합니다.

이는 서버에서 클라이언트로부터 JWT를 받았을 때, JWT의 헤더와 페이로드를 서버에서 똑같이 HS256으로 암호화하여, 클라이언트가 보낸 JWT의 서명고 같으면 요청된 것으로 알고 검증합니다.

RSA의 경우, 시크릿 키를 넣지 않고, 서버의 개인 키로 잠군 후, 토큰을 전송합니다. 클라이언트는 해당 토큰을 받아 다시 서버에 전송시, 그냥 서버의 공개 키로 열어보기만 하면 됩니다.

토큰 기반 인증의 문제점

토큰 기반 인증의 장점이었던 Stateless 서버는 한 가지 문제점이 있습니다. 바로 세션 기반 인증과 같이 서버 쪽에서 관리가 되고 있지 않기 때문에 올바른 사용자인지, 아니면 누군가 토큰을 탈취해 악의적인 의도로 접근해 온 사용자인지 알지 못한다는 것입니다.

때문에 액세스 토큰의 유효 기간을 아예 짧게 설정하는 것으로 나름 보안을 강화할 수 있지만 그러면 사용하는 입장에선 불편함을 느낄 수 밖에 없습다. 이런 문제점을 해결하기 위해 나타난 것이 Refresh Token입니다.

리프레시 토큰은 액세스 토큰이 만료되었을 때 액세스 토큰을 새로 발급하기 위해 필요한 토큰입니다. 액세스 토큰과 다른 점은 리프레시 토큰은 액세스 토큰보다 유효 기간이 길고, 데이터베이스에 저장한다는 점입니다.

RefreshToken 인증 과정

  1. 클라이언트 측에서 서버에 인증 요청
  2. 서버측에서 액세스 토큰과 리프레시 토큰 두 개를 클라이언트 측에 전송
  3. 클라이언트는 전송받은 토큰을 저장
  4. 서버에서는 리프레시 토큰을 데이터베이스에 저장
  5. 클라이언트 측에서 인증이 필요한 API 요청을 서버측에 보낼 때마다 액세스 토큰을 같이 전송
  6. 서버 측에서는 전달받은 액세스 토큰에 대한 유효성 검사 후 유효하다면 클라이언트 요청을 처리. 만약 액세스 토큰이 만료되었다면 토큰이 만료되었다는 에러를 클라이언트에게 전달
  7. 에러를 전달받은 클라이언트는 리프레시 토큰과 함께 새 액세스 토큰을 서버 측에 요청
  8. 서버에서는 리프레시 토큰 유효성 검사를 위해 데이터베이스에서 리프레시 토큰을 조회 후 비교
  9. 유효하다면 새 액세스 토큰을 발급하여 클라이언트에게 전달한다.
    클라이언트는 새 액세스 토큰을 가지고 원래 요청하려던 API를 다시 요청

AccessToken만 사용하게 되면 확실하게 Stateless하게 인증 정보를 처리할 수 있지만, RefreshToken을 사용하게 되는 순간 결국 DB에 정보를 저장하게 되어 Stateless한가..? 라는 의문점이 들었습니다.

찾아보니 많은 분들이 공통적으로 하는 고민인거 같습니다. 하지만 아직 결론은 명확하지 않은 것 같고, 리프레쉬 토큰을 사용했을때의 장점은 'Session에 비해 RefreshToken이 만료된 경우에만 DB에 접근하므로 I/O가 줄어 성능이 향상' 이라고 하는것 같았습니다.

JWT의 주의점

인증에서 사용자가 자격증명을 사용하여 성 공적으로 로그인하면 JSON 웹 토큰이 반환됩니다. 토큰은 자격증명을 주 목적으로 사용하므로 보안 문제에 주의가 필요합니다. 일반적으로 토큰을 필요이상으로 오래 보관하면 안됩니다.

또한 보안에 취약할 수 있기에 민감한 세션 데이터를 브라우저 저장소에 저장해서는 안됩니다.

클라이언트가 보호된 경로 또는 리소스에 접근하려고 할 때 마다, 클라이언트는 일반적으로 Bearer 스키마를 사용하여 Authorization 헤더에서 JWT를 보내야 합니다.

#헤더의 내용
Authorization: Bearer <token>

JWT 사용 준비

build.gradle에 다음과 같은 의존성을 추가합니다.

//== jwt 추가 ==//
	implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
	testAnnotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.projectlombok:lombok'
	implementation 'commons-codec:commons-codec:1.5'
	implementation 'com.auth0:java-jwt:3.13.0'

이제 토큰을 사용하기 위해, Users 엔티티에 RefreshToken을 추가합니다.

/== jwt 토큰 추가 ==//
	@Column(length = 1000)
	private String refreshToken;

	public void updateRefreshToken(String refreshToken) {
		this.refreshToken = refreshToken;
	}

	public void destroyRefreshToken() {
		this.refreshToken = null;
	}

전체 Users 엔티티

@Entity
@Getter
@Builder
@AllArgsConstructor
@Table(name = "USERS")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Users {

	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "user_id")
	private Long id;

	@Column(nullable = false)
	private String name;

	@Column(nullable = false, unique = true, length = 30)
	private String email;

	@Column(nullable = false)
	private String password;

	@Column(nullable = false)
	private String phoneNum;

	private String imgUrl;

	@Enumerated(EnumType.STRING)
	@JsonIgnore
	private PublicStatus publicStatus;

	@JsonIgnore
	@Enumerated(EnumType.STRING)
	private ShareStatus shareStatus;

	private LocalDate createdAt;

	//== jwt 토큰 추가 ==//
	@Column(length = 1000)
	private String refreshToken;

	public void updateRefreshToken(String refreshToken) {
		this.refreshToken = refreshToken;
	}

	public void destroyRefreshToken() {
		this.refreshToken = null;
	}

	@JsonIgnore
	@OneToMany(mappedBy = "user")
	private List<SharedAlbum> sharedAlbums = new ArrayList<>();

	//== 패스워드 암호화 ==//
	public void encodePassword(PasswordEncoder passwordEncoder){
		this.password = passwordEncoder.encode(password);
	}

}

UsersRepository

유저 레포지토리에 refreshToken을 통해 유저를 조회하는 기능을 추가해주도록 하겠습니다.

public interface UsersRepository extends JpaRepository<Users, Long> {

	Optional<Users> findByEmail(String email);

	boolean existsByEmail(String email);

	Optional<Users> findByPhoneNum(String phoneNum);

	boolean existsByPhoneNum(String phoneNum);

	Optional<Users> findByRefreshToken(String refreshToke);

}

JwtService

JWT와 관련한 비즈니스 로직(AccessToken 생성, RefreshToken생성, RefreshToken 재발급, RefreshToken 삭제, AccessToken 삭제 등)을 작성해보겠습니다.

application.yml에 다음을 추가합니다.

spring:
	profiles:
      include: jwt #jwt.yml 불러오기

이렇게 작성하면 application-jwt에 해당하는 .yml이나 .properties 파일을 읽어올 수 있습니다.

다음 application-jwt.yml을 생성하여 아래와 같이 구성합니다. 이 파일에서는 JWT 관련 시크릿 키, 토큰의 만료 시간 등을 설정해주도록 하겠습니다.

jwt:
  secret: 63fba97a41e0d004e10e8dbbcb9a547819280efb00a54c732aca36a8a58258e4fcc539ffc5159a7f0a7be78b86efe001c12ba6af6debeb0a89e8ce7e82e75455

  access:
    expiration: 80
    header: Authorization

  refresh:
    expiration: 90
    header: Authorization-refresh

accessToken은 80초, refreshToken은 90초로 설정해놨습니다. (향후 변경 예정)

저희는 base64로 인코딩된 암호 키, HS512를 사용할 것이기 때문에, 512비트(64바이트) 이상이 되어야 합니다. 시크릿 키는 아래와 같은 방법으로 생성해주었습니다.


64바이트의 랜덤한 키를 생성해줍니다.

이제, JwtService의 인터페이스 부터 작성하겠습니다.

public interface JwtService {

	String createAccessToken(String email);
	String createRefreshToken();

	void updateRefreshToken(String email, String refreshToken);

	void destroyRefreshToken(String email);

	void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken);
	void sendAccessToken(HttpServletResponse response, String accessToken);

	Optional<String> extractAccessToken(HttpServletRequest request);

	Optional<String> extractRefreshToken(HttpServletRequest request);

	Optional<String> extractEmail(String accessToken);

	void setAccessTokenHeader(HttpServletResponse response, String accessToken);

	void setRefreshTokenHeader(HttpServletResponse response, String refreshToken);

	boolean isTokenValid(String token);

}

구현체는 아래와 같습니다.

@Transactional
@Service
@RequiredArgsConstructor
@Setter(value = AccessLevel.PRIVATE)
@Slf4j
public class JwtServiceImpl implements JwtService {

	//== jwt.yml에 설정된 값 가져오기 ==//
	@Value("${jwt.secret}")
	private String secret;

	@Value("${jwt.access.expiration}")
	private long accessTokenValidityInSeconds;

	@Value("${jwt.refresh.expiration}")
	private long refreshTokenValidityInSeconds;
	@Value("${jwt.access.header}")
	private String accessHeader;
	@Value("${jwt.refresh.header}")
	private String refreshHeader;

	//== 1 ==//
	private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";
	private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken";
	private static final String USERNAME_CLAIM = "email";
	private static final String BEARER = "Bearer ";

	private final UsersRepository usersRepository;
	private final ObjectMapper objectMapper;

	//== 메서드 ==//

	@Override
	public String createAccessToken(String email) {
		return JWT.create()
				.withSubject(ACCESS_TOKEN_SUBJECT)
				.withExpiresAt(new Date(System.currentTimeMillis() + accessTokenValidityInSeconds * 1000))
				.withClaim(USERNAME_CLAIM, email)
				.sign(Algorithm.HMAC512(secret));
	}

	@Override
	public String createRefreshToken() {
		return JWT.create()
				.withSubject(REFRESH_TOKEN_SUBJECT)
				.withExpiresAt(new Date(System.currentTimeMillis() + refreshTokenValidityInSeconds * 1000))
				.sign(Algorithm.HMAC512(secret));
	}

	@Override
	public void updateRefreshToken(String email, String refreshToken) {
		usersRepository.findByEmail(email)
				.ifPresentOrElse(
						users -> users.updateRefreshToken(refreshToken),
						() -> new Exception("회원 조회 실패")
				);
	}

	@Override
	public void destroyRefreshToken(String email) {
		usersRepository.findByEmail(email)
				.ifPresentOrElse(
						users -> users.destroyRefreshToken(),
						() -> new Exception("회원 조회 실패")
				);
	}

	@Override
	public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) {
		response.setStatus(HttpServletResponse.SC_OK);

		setAccessTokenHeader(response, accessToken);
		setRefreshTokenHeader(response, refreshToken);

		Map<String, String> tokenMap = new HashMap<>();
		tokenMap.put(ACCESS_TOKEN_SUBJECT, accessToken);
		tokenMap.put(REFRESH_TOKEN_SUBJECT, refreshToken);

	}

	@Override
	public void sendAccessToken(HttpServletResponse response, String accessToken) {
		response.setStatus(HttpServletResponse.SC_OK);

		setAccessTokenHeader(response, accessToken);

		Map<String, String> tokenMap = new HashMap<>();
		tokenMap.put(ACCESS_TOKEN_SUBJECT, accessToken);
	}

	@Override
	public Optional<String> extractAccessToken(HttpServletRequest request) {
		return Optional.ofNullable(request.getHeader(accessHeader)).filter(
				accessToken -> accessToken.startsWith(BEARER)
		).map(accessToken -> accessToken.replace(BEARER, ""));
	}

	@Override
	public Optional<String> extractRefreshToken(HttpServletRequest request) {
		return Optional.ofNullable(request.getHeader(refreshHeader)).filter(
				refreshToken -> refreshToken.startsWith(BEARER)
		).map(refreshToken -> refreshToken.replace(BEARER, ""));
	}

	@Override
	public Optional<String> extractEmail(String accessToken) {
		try {
			return Optional.ofNullable(
					JWT.require(Algorithm.HMAC512(secret)).build().verify(accessToken).getClaim(USERNAME_CLAIM)
							.asString());
		} catch (Exception e) {
			log.error(e.getMessage());
			return Optional.empty();
		}
	}

	@Override
	public void setAccessTokenHeader(HttpServletResponse response, String accessToken) {
		response.setHeader(accessHeader, accessToken);
	}

	@Override
	public void setRefreshTokenHeader(HttpServletResponse response, String refreshToken) {
		response.setHeader(refreshHeader, refreshToken);
	}

	@Override
	public boolean isTokenValid(String token) {
		try {
			JWT.require(Algorithm.HMAC512(secret)).build().verify(token);
			return true;
		} catch (Exception e) {
			log.error("유효하지 않은 Token입니다", e.getMessage());
			return false;
		}
	}
}
  1. JWT에 넣어줄 Subject, Claim으로는 email을 사용할 것이기 때문에 UserName에 대한 클레임을 "email"로 지정해주었습니다. 그리고 항상 JWT의 헤더에 들어오는 값으로는 'Authorization = Bearer [토큰]' 의 형식을 가지도록 할 것이기에 BEARER을 미리 지정해 주었습니다.

아래 메서드들은 이름과 내용을 보시면 충분히 이해할 수 있을것 같습니다.

JWT 토큰 생성 메서드

JWT.create() //JWT 토큰을 생성하는 빌더를 반환합니다.

	.withSubject(ACCESS_TOKEN_SUBJECT) 
    //빌더를 통해 JWT의 Subject를 정합니다. AccessToken이므로 위에서 설정했던 
    //AccessToken의 subject를 합니다.
    
	.withExpiresAt(new Date(System.currentTimeMillis() + accessTokenValidityInSeconds * 1000))
    //만료시간을 설정하는 것입니다. 현재 시간 + 저희가 설정한 시간(밀리초) * 1000을 하면
    //현재 accessTokenValidityInSeconds이 80이기 때문에
    //현재시간에 80 * 1000 밀리초를 더한 '현재시간 + 80초'가 설정이 되고
    //따라서 80초 이후에 이 토큰은 만료됩니다.
    
	.withClaim(USERNAME_CLAIM, email)
    //클레임으로는 email 하나만 사용합니다.
    //추가적으로 식별자나, 이름 등의 정보를 더 추가가능합니다.
    //추가하는 경우 .withClaim(클래임 이름, 클래임 값) 으로 설정합니다.
    
	.sign(Algorithm.HMAC512(secret));
    //HMAC512 알고리즘을 사용하여, 저희가 지정한 secret 키로 암호화 합니다.

RefreshToken의 경우에는 email을 넣지 않습니다. 리프레쉬 토큰은 엑세스 토큰 재발급의 용도로만 사용할 것이기 때문에 정보는 최대한 넣지 않고 DB에 보관하도록 하겠습니다.

토큰에서 User정보 추출

  JWT.require(Algorithm.HMAC512(secret))
//토큰의 서명의 유효성을 검사하는데 사용할 알고리즘이 있는
//JWT verifier builder를 반환합니다

    .build()//반환된 빌더로 JWT verifier를 생성합니다
    
    .verify(accessToken)//accessToken을 검증하고 유효하지 않다면 예외를 발생시킵니다.
    .getClaim(USERNAME_CLAIM)//claim을 가져옵니다
    .asString();

테스트 코드 및 전체 코드는 맨 아래에서 확인하실 수 있습니다.

LoginSuccessJWTProvideHandler 수정 - JWT 발급

로그인이 성공했을때의 동작을 관리하는 LoginSuccessJWTProvideHandler를 수정해 로그인 성공시 JWT 토큰을 발급할 수 있도록 하겠습니다.

@Slf4j
@RequiredArgsConstructor
public class LoginSuccessJWTProvideHandler extends SimpleUrlAuthenticationSuccessHandler {

	private final JwtService jwtService;
	private final UsersRepository usersRepository;

	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
										Authentication authentication) throws IOException, ServletException {
		String email = extractEmail(authentication);
		String accessToken = jwtService.createAccessToken(email);
		String refreshToken = jwtService.createRefreshToken();

		jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken);
		usersRepository.findByEmail(email).ifPresent(
				users -> users.updateRefreshToken(refreshToken)
		);

		log.info( "로그인에 성공합니다. email: {}" , email);
		log.info( "AccessToken 을 발급합니다. AccessToken: {}" ,accessToken);
		log.info( "RefreshToken 을 발급합니다. RefreshToken: {}" ,refreshToken);

		response.getWriter().write("success");
	}

	private String extractEmail(Authentication authentication) {
		UserDetails userDetails = (UserDetails) authentication.getPrincipal();
		return userDetails.getUsername();
	}
}

SecurityConfig 수정

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

	private final UserDetailsServiceImpl userDetailsService;
	private final ObjectMapper objectMapper;
	private final UsersRepository usersRepository;
	private final JwtService jwtService;

	// 특정 HTTP 요청에 대한 웹 기반 보안 구성
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http	.csrf(AbstractHttpConfigurer::disable)
				.httpBasic(AbstractHttpConfigurer::disable)
				.formLogin(AbstractHttpConfigurer::disable)
				.authorizeHttpRequests((authorize) -> authorize
						.requestMatchers("/feed/**", "/albums/**", "/photo/**", "/user/signup", "/", "/login", "/album/init").permitAll()
						.anyRequest().authenticated())
//				.formLogin(formLogin -> formLogin
//						.loginPage("/login")
//						.defaultSuccessUrl("/home"))
				.logout((logout) -> logout
						.logoutSuccessUrl("/login")
						.invalidateHttpSession(true))
				.sessionManagement(session -> session
					.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
		);
		http
				.addFilterAfter(jsonUsernamePasswordLoginFilter(), LogoutFilter.class) // 추가 : 커스터마이징 된 필터를 SpringSecurityFilterChain에 등록
				.addFilterBefore(jwtAuthenticationProcessingFilter(), JsonUsernamePasswordAuthenticationFilter.class);
		return http.build();
	}

	// 인증 관리자 관련 설정
	@Bean
	public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
		DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();

		daoAuthenticationProvider.setUserDetailsService(userDetailsService);
		daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());

		return daoAuthenticationProvider;
	}

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

	@Bean
	public AuthenticationManager authenticationManager() throws Exception {//2 - AuthenticationManager 등록
		DaoAuthenticationProvider provider = daoAuthenticationProvider();//DaoAuthenticationProvider 사용
		return new ProviderManager(provider);
	}

    @Bean
    public LoginSuccessJWTProvideHandler loginSuccessJWTProvideHandler(){
        return new LoginSuccessJWTProvideHandler(jwtService, usersRepository);
    }

    @Bean
    public LoginFailureHandler loginFailureHandler(){
        return new LoginFailureHandler();
    }

	@Bean
	public JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter() throws Exception {
		JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter = new JsonUsernamePasswordAuthenticationFilter(objectMapper);
		jsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager());
		jsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(loginSuccessJWTProvideHandler());
		jsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(loginFailureHandler());
		return jsonUsernamePasswordLoginFilter;
	}

	@Bean
	public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter(){
		JwtAuthenticationProcessingFilter jsonUsernamePasswordLoginFilter = new JwtAuthenticationProcessingFilter(jwtService, usersRepository);

		return jsonUsernamePasswordLoginFilter;
	}

}

usersRepository의 종속성을 추가해줍니다. 추가로 MemberRepository와 JwtService를 필드에서 받아오도록 수정하겠습니다.

또한 .addFilterBefore(jwtAuthenticationProcessingFilter(), JsonUsernamePasswordAuthenticationFilter.class);를 통해 새로 jwt 인증을 진행할 시큐리티 필터를 기존 jwtAuthenticationProcessingFilter() 앞에 추가하여 동작하도록 하였습니다.

AccessToken을 이용한 인증 필터

필터의 요구사항은 다음과 같습니다.

  • "/login "으로 오는 요청에 대해서는 필터를 작동시키지 않고, JsonUsernamePasswordAuthenticationFilter에게 로그인 처리를 위임합니다.

그 외에 들어오는 모든 요청에 대해서 작동합니다.

RefreshToken을 포함하여 요청이 전송되는 경우는 다음 네 가지 상황이 있을 수 있습니다. 

  • 둘 다 유효한 경우 -> AccessToken재발급, 인증은 진행하지 않음.
  • RefreshToken은 유효하고, AccessToken은 없거나 유효하지 않은 경우 -> AccessToken 재발급
  • RefreshToken은 없거나 유효하지 않고, AccessToken은 유효한 경우 -> 인증은 성공되나, RefreshToken을 재발급하지는 않음
  • RefreshToken과 AccessToken 모두 없거나 유효하지 않은 경우 -> 인증에 실패합니다.

JwtAuthenticationProcessingFilter

OncePerRequestFilter을 상속받아 구현해보겠습니다.

OncePerRequestFilter는 모든 서블릿 컨테이너에서 요청 디스패치당 단일 실행을 보장하는 것을 목표로 하는 필터 기본 클래스 입니다.

@RequiredArgsConstructor
public class JwtAuthenticationProcessingFilter extends OncePerRequestFilter {

	private final JwtService jwtService;
	private final UsersRepository usersRepository;

	private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();//5

	private final String NO_CHECK_URL = "/login";//1

	/**
	 * 1. 리프레시 토큰이 오는 경우 -> 유효하면 AccessToken 재발급후, 필터 진행 X, 바로 튕기기
	 *
	 * 2. 리프레시 토큰은 없고 AccessToken만 있는 경우 -> 유저정보 저장후 필터 계속 진행
	 */
	@Override
	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
		if(request.getRequestURI().equals(NO_CHECK_URL)) {
			filterChain.doFilter(request, response);
			return;//안해주면 아래로 내려가서 계속 필터를 진행하게됨
		}

		String refreshToken = jwtService
				.extractRefreshToken(request)
				.filter(jwtService::isTokenValid)
				.orElse(null); //2


		if(refreshToken != null){
			checkRefreshTokenAndReIssueAccessToken(response, refreshToken);//3
			return;
		}

		checkAccessTokenAndAuthentication(request, response, filterChain);//4
	}

	private void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
		jwtService.extractAccessToken(request).filter(jwtService::isTokenValid).ifPresent(

				accessToken -> jwtService.extractEmail(accessToken).ifPresent(

						email -> usersRepository.findByEmail(email).ifPresent(

								users -> saveAuthentication(users)
						)
				)
		);

		filterChain.doFilter(request,response);
	}



	private void saveAuthentication(Users users) {
		UserDetailsImpl userDetails = new UserDetailsImpl(users);

		Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null,authoritiesMapper.mapAuthorities(userDetails.getAuthorities()));


		SecurityContext context = SecurityContextHolder.createEmptyContext();//5
		context.setAuthentication(authentication);
		SecurityContextHolder.setContext(context);
	}

	private void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) {
		usersRepository.findByRefreshToken(refreshToken).ifPresent(
				users -> jwtService.sendAccessToken(response, jwtService.createAccessToken(users.getEmail()))
		);


	}
}
  1. "/login" 으로 들어오는 요청에 대해서는 작동하지 않습니다.

  2. RefreshToken이 없거나 유효하지 않다면 null을 반환합니다.

  3. refreshToken이 유효하다면 해당 refreshToken을 가진 유저정보를 찾아오고, 존재한다면 AccessToken을 재발급합니다.
    이때 바로 return시키는데, 그 이유는 refreshToken만 보낸 경우에는 인증을 처리하지 않게 하기 위해서입니다. 

  4. refreshToken이 없다면 AccessToken을 검사하는 로직을 수행합니다.
    request에서 AccessToken을 추출한 후, 있다면 해당 AccessToken에서 email을 추출합니다. email이 추출되었다면 해당 회원을 찾아와서 그 정보를 가지고 인증처리를 합니다. 이때 SecurityContextHolderAuthentication 객체를 만들어 반환하는데, NullAuthoritiesMapper가 쓰입니다.

이는 스프링 시큐리티에서 제공해주는 것입니다.

전체 테스트 코드 및 제가 구현한 코드는 아래에서 확인하시면 됩니다.

다음 포스팅 부터는 본격적으로 API 기능 구현을 해보겠습니다.

  • 다른 블로그를 참고하여 진행한 프로젝트라 리팩토링이 진행되지 않아 변수명 및 클래스 이름이 난잡합니다.. 감안하고 이정도 느낌이구나~ 정도로 읽어주시면 감사하겠습니다!

참고

기타 참고

profile
티스토리로 이전했습니다. https://100cblog.tistory.com/

0개의 댓글