[프로젝트]JWT 로그인 구현

Inung_92·2023년 3월 17일
1

프로젝트

목록 보기
4/9
post-thumbnail

개요

⚡️ 개발환경

  • Front - React.js
  • Back - SpringFramework(legacy) 4.3.30
  • DB - MySql5.7
  • Server - Tomcat 9
  • Tool - 전자정부프레임워크 3.0(수업목적), VScode

⚡️ 시나리오

  1. 클라이언트로부터 로그인 요청을 받음
  2. 요청 파라미터에 포함된 정보가 DB에 등록된 정보와 일치하는지 체크
  3. 일치할 경우 JWT 토큰을 응답 헤더에 포함하여 클라이언트에게 응답
  4. 일치하지 않을 경우 예외를 발생시켜 예외결과를 클라이언트에게 응답

⚡️ JWT 선택 이유

코드를 보기에 앞서서 서버에 접근한 사용자를 구분하기 위해 왜 JWT(JSON Web Token)을 선택했는지 알아보자.

서버에서 사용자가 인증되었는지 아닌지를 구분할 수 있는 방법에는 여러가지가 있지만 대표적으로는 서버에서 세션을 이용하여 인증하는 방법과 토큰을 이용하여 인증하는 방법으로 구분할 수 있을 것이다.

🖥️ 세션기반 인증

기본적으로 세션(Session)은 서버내에 위치하게된다. 세션에 정보를 등록하면 서버에서 해당 정보를 가지고 있는 것과 유사하다. 또한, 서버는 사용자 정보 등록과 동시에 클라이언트에게 세션ID를 발급할 수 있다. 클라이언트는 이 세션ID를 쿠키 또는 URL 매개변수 등을 통해서 서버에 다시 전달하여 인증을 유지할 수 있는 것이다.

특징은 다음과 같다.

  • 상태 유지 : 사용자의 인증 상태를 서버에 저장하기 때문에 사용자가 로그인한 상태를 서버에서 계속 유지가 가능
  • 보안성 : 사용자의 인증 정보를 서버에 보관하기 때문에, 쿠키나 URL 매개변수로 인증 정보를 전송하는 다른 방식보다 보안성이 높음
  • 확장성 : 서버에서 여러 인스턴스를 운영하더라도 인증 정보를 공유 가능
  • 클라이언트에서 요청을 보낼 때마다 세션 정보를 전송해야 하기 때문에 불필요한 데이터 전송 등으로 인한 네트워크 대역폭의 낭비가 될 수 있으며, 사용자가 많아질수록 서버의 부담이 증가

세션의 동작원리는 다음과 같다.

그렇다면 토큰 기반의 인증방식은 어떤지 알아보자.

🖥️ 토큰기반 인증

토큰은 사용자 인증 정보를 토큰이라는 문자열로 만들어서 클라이언트에게 전달한다. 클라이언트가 요청을 보낼 때마다 토큰을 함께 전송하여 인증을 수행하는 방식이다. 이 말은 세션처럼 서버내에 객체를 저장한다던지의 행위가 생략되고 토큰 유효성을 비교하여 인증을 수행하는 것이다.

특징은 다음과 같다.

  • 상태를 유지하지 않음 : 클라이언트에서 인증 정보를 관리(localStorage 등)하기 때문에 서버에 해당 상태를 저장할 필요가 없음
  • 보안성 : 토큰 자체가 인증 정보를 담고 있으며, 암호화처리된 문자열로 구성되어 있기 때문에 인증 정보를 전송할 필요없이 토큰만을 비교하여 인증을 수행할 수 있음
  • 확장성 : 여러 클라이언트에서 사용될 수 있고, 서버에서 인증 정보를 관리할 필요가 없기 때문에 확장성이 높음
  • 인증 정보를 요청 헤더에 적재하여 전송하기 때문에 세션 기반 인증 방식보다 전송 데이터가 적어 네트워크 대역폭을 절약 할 수 있음

토큰의 동작원리는 다음과 같다.

동작 원리의 그림만 보면 세션과 토큰이 거의 비슷해 보인다. 주요한 차이는 세션은 클라이언트가 요청 시 토큰 방식보다 많은 정보를 담아서 보내야하며, 이러한 정보를 보낼 때 네트워크의 대역폭 비용이 많이든다. 그리고 토큰 기반 인증방식은 요청 정보에 직접적인 주요 정보들이 포함되지 않는다는 점이다.

이렇게 세션과 토큰의 인증방식의 차이를 알아보고나니 토큰 기반 인증방식을 사용해보고 싶었고, 대표적인 JWT를 사용하기로 한 것이다.


JWT 사용하기

⚡️ JWT란?

📖JSON Web Toekn의 줄임말로 JSON 형식으로 인코딩된 문자열 보안토큰이며 주로 인증, 권한 부여 등에 사용

⚡️ 구성

JWT는 다음과 같이 3가지 부분으로 구성된다.

  1. Header(헤더) : 유형 및 암호화 알고리즘 정보를 JSON 형식으로 포함
  2. Payload(페이로드) : JWT에 담길 정보를 포함하는 부분으로 클레임(Claim)이라고도 부름.
    -Registered Claim : 이미 정의된 클레임으로 발급자, 만료일, 주제 등
    -Public Claim : 공개용으로 정의된 클레임, 필요한 정보를 추가
    -Private Claim : 사용자가 임의로 정의할 수 있는 클레임으로 애플리케이션에서 사용하는 정보를 추가
  3. Signature(서명) : 헤더와 페이로드를 Base64로 인코딩한 후, 암호화하여 생성한 서명으로 서버에서 서명을 검증하여 JWT의 유효성을 검증

⚡️ 기능 구현(프론트)

먼저 React를 이용해 다음과 같이 로그인 페이지를 만들었다.

로그인 버튼을 클릭하면 서버로 사용자 정보가 전달되도록 UI를 구성하고, 다음과 같은 함수를 통해 전달한다.

🖥️ 로그인 함수

const handleLogin = async () => {
  if(memberId !== "" && memberPass !== ""){ //member 정보가 미입력 상태면 함수 발동 x
    await axios.post('/api/client/login/member', {
      memberId: memberId,
      memberPass: memberPass
    }).then((response) => { //응답결과 정상적으로 넘어온 경우 아래 로직 수행
      alert("로그인 성공");
	
      localStorage.setItem("accessToken", response.headers.accesstoken); // 토큰 저장
      
      window.location.href="/"; //메인페이지로 이동
    }).catch((err) => { //서버에서 에러가 발생한 경우 경고창 알림
      alert(err.response.data.detail);
  	});
  } else{
    alert("ID 혹은 비밀번호를 입력하세요."); // member 정보 미입력 시 알림
  }
}

로그인 요청 시 사용자의 정보를 axios.post() 방식으로 전달한다. 서버에서 유효한 로그인 정보일 경우 jwt를 발급하여 응답 헤더에 적재하여 전달하면, 해당 응답 헤더 정보를 localStorage에 저장한다.

로그인이 성공하면 위와 같이 로그인 성공이라는 메세지가 출력되면서 아래와 같이 응답 헤더에 토큰이 전달된다.

전달된 응답 토큰은 localStorage에 저장되며 다음과 같은 상태가 된다.

이제 서버에서 어떻게 요청을 처리하고, 토큰을 헤더에 적재하여 응답하는지 알아보자.

⚡️ 기능 구현(서버)

제일 먼저 해야하는 것은 의존성 추가와 Confing를 작성하는 것이다. 시작해보자.

🖥️ 의존성 추가(maven)

<!-- jwt -->
<dependency>
  <groupId>io.jsonwebtoken</groupId>
  <artifactId>jjwt</artifactId>
  <version>0.9.1</version>
</dependency>

<!-- xml 바인드 -->
<dependency>
  <groupId>javax.xml.bind</groupId>
  <artifactId>jaxb-api</artifactId>
  <version>2.1</version>
</dependency>

여기서 javax.xml.bind 의존성을 추가하지 않을 경우 xml을 bind하지 못했다는 에러가 발생한다. 미리 추가하고 진행하자.

🖥️ 빈 등록 및 secretKey 설정

사실 빈을 등록할 필요는 없다. @Component 어노테이션을 사용하여 자동 매핑을 해줄 수 있지만 component-scan의 범위를 고려해야하는 부분도 있고, 무엇보다 secretKey를 코드에 노출하고 싶지 않아서 생성과 동시에 property를 설정하여주기 위하여 빈을 직접 등록했다.

<!-- 생성과 동시에 secretKey 데이터 입력 -->
<beans:bean id="jwtProvider" class="com.edu.surfing.model.member.JwtProvider">
  <beans:property name="secretKey"> <!-- 해당 객체의 변수명과 동일하게 작성 후 setter 선언 -->
    <beans:value>test.jwt.secret</beans:value> <!-- 원하는 데이터 입력 -->
  </beans:property>
</beans:bean>

해당 빈을 등록하는 위치는 기본적으로 servlet-context.xml이 될 것이다. 하지만 나의 경우는 관리자와 클라이언트의 servlet을 구분하여 운용하기 때문에 client-servlet.xml에 포함시켜주었다. 여기서 secretKey의 value는 너무 짧으면 오류가 날 수 있다고하니 조심하자.

🖥️ JwtProvider.java

@Slf4j
@Setter
@Component
public class JwtProvider {
	/* 서명에 사용할 secretKey 설정은 xml에서 property로 직접등록 */
	private String secretKey;

	/*
	 * 토큰 생성 메소드 jwt에 저장할 회원정보를 파라미터로 전달
	 */
	public String createToken(Member member) {
		Date now = new Date(System.currentTimeMillis());
		Long expiration = 1000 * 60 * 60L; //만료기한 설정 시 사용
		
        /* 토큰이 보관할 회원ID */
		Claims claims = Jwts.claims();
		claims.put("memberId", member.getMemberId()); //비공개 클레임 등록

		return Jwts.builder().setHeaderParam("typ", "JWT") // 토큰 타입 지정
				.setSubject("accessToken") // 토큰 제목
				.setIssuedAt(now) // 발급시간
				.setExpiration(new Date(System.currentTimeMillis() + expiration)) // 만료기한
				.setClaims(claims) // 회원 아이디 저장(비공개 클레임)
				.signWith(SignatureAlgorithm.HS256, secretKey) //해싱알고리즘과 시크릿 키
                .compact(); //토큰 직렬화
	}

	/* 토큰 해석 메소드 */
	public String getSubject(String token) throws CustomException {
		Claims claims = Jwts.parser()
				.setSigningKey(DatatypeConverter.parseBase64Binary(secretKey))
				.parseClaimsJwt(token) //토큰 파싱
				.getBody();
		log.debug("해독된 토큰:: " + claims.getSubject());
		return claims.getSubject();
	}

	/* 유효성 확인(해독된 jwt) */
	public boolean vaildToken(String jwt) {
		try {
			Claims claims = Jwts.parser()
					.setSigningKey(DatatypeConverter.parseBase64Binary(secretKey))
					.parseClaimsJwt(jwt) //해독된 토큰 파싱
					.getBody();
			return true;  //유효하다면 true 반환
		} catch (MalformedJwtException e) {
			throw new CustomException(ErrorCode.VALID_TOKEN_SIGNATURE, e);
		} catch (ExpiredJwtException e) {
			throw new CustomException(ErrorCode.VALID_TOKEN_EXPIRED, e);
		} catch (UnsupportedJwtException e) {
			throw new CustomException(ErrorCode.VALID_TOKEN_UNSUPPORTED, e);
		}
	}

}

이 코드에서 우선적으로 사용할 부분은 createToken()이다. 아래 토큰 해석 및 유효성 확인은 다음 기능에서 사용할 메소드들이다. 나의 경우에는 토큰 내에 memberId라는 비공개 클레임을 추가하여 토큰에 저장할 예정이다.

이제 JwtProvider를 이용해보자. DB에서 컨트롤러 순서로 코드를 알아보자.

🖥️ DAO.java

...생략
@Override
public Member selectByLogin(Member member) {
	return sqlSessionTemplate.selectOne("Member.selectByLogin", member);
}
...생략

DAO는 요청으로부터 넘겨받은 member의 정보만 DB에서 확인한다. 트랜잭션이 필요한 부분이 아니기 때문에 null값이 나오더라도 결과만 Service로 전달한다.

🖥️ MemberServiceImpl.java

@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {
	private final MemberDAO memberDAO;
	...생략
	private final JwtProvider jwtProvider;

	@Override
	public String getMemberByLogin(Member member) throws CustomException {
		//로그인 비밀번호 암호화
		String memberPass = PasswordConverter.getCovertedPassword(member.getMemberPass());
		member.setMemberPass(memberPass);
		
		//해당 멤버정보 DB 일치여부 조회
		Member loginMember = memberDAO.selectByLogin(member);
		
		//조건 판단을 통한 토큰 발급
		if(loginMember != null) {
			return jwtProvider.createToken(loginMember); //토큰 반환
		} else {
			throw new CustomException(ErrorCode.MISMATCH_LOGIN_INFO); //예외처리
		}
	}

	...생략
}

DAO로부터 전달받은 멤버의 유효성 여부를 확인하여 토큰발급 또는 예외를 발생한다. 최초에는 DAO에 조건을 판단하여 예외처리하는 로직이 들어있었지만 자세히 생각해보니 DAO는 DB에 접근하는 로직만을 수행하며, 트랜잭션이 없는 상황에서 DAO 내부에서 예외를 발생시킬 조건을 판단을 하기도 부자연스럽고, 필요성을 느끼지 못했기에 Service로 해당 코드를 옮겼다.

회원정보가 일치할 경우 Controller에서는 어떻게 처리하는지 알아보자.

🖥️ MemberController.java

@RestController
@RequiredArgsConstructor
public class MemberController {
	private Logger log = LoggerFactory.getLogger(this.getClass());

	private final MemberService memberService;
	
	@PostMapping("/login/member")
	public ResponseEntity<String> handleLogin(@RequestBody Member member){
		log.debug("------ " + member.getMemberId() + "님 로그인 시도 -------");
		
		String accessToken = memberService.getMemberByLogin(member);
		log.debug(member.getMemberId() + "님에게 발급된 jwt:: " + accessToken);
		
		/* 응답 헤더에 jwt를 저장하여 전송 */
		HttpHeaders responseHeaders = new HttpHeaders();
		responseHeaders.set("accessToken", accessToken);
		
		log.debug("------ " + member.getMemberId() + "님 로그인 -------");
		return ResponseEntity.ok().headers(responseHeaders).body("Response tiwh header using ResponseEntity");
	}

}

이렇게 전달해주면 위에서 본 것처럼 로그인 정보가 일치한 회원에게는 JWT가 발급될 것이다. 이렇게 발급된 JWT는 클라이언트가 요청 헤더에 Authorization: 'Bearer 토큰' 형태로 전송하면 서버에서는 해당 토큰의 유효성을 검증하여 결과를 반환해주는 형태가 된다.

여기서 Bearer는 Authorization 헤더의 일부로 사용되는 인증 스키마의 하나로 서버에서는 'Bearer'를 제외한 실제 토큰 값만을 추출하여 사용하지만 요청 자체에 포함을 시켜주면 JWT 토큰을 사용한 인증 요청이라는 것을 서버에서 더욱 편하게 파악할 수 있다.

여기서 의문은 애초에 'Bearer' 키워드를 포함시켜 반환해주면 되는거 아닌가? 라는 생각이 들었었다. 이 부분에 대해서 알아보니 다음과 같았다.

  • 기본적으로 응답 헤더에 'Bearer'를 포함시킬 수는 있음
  • 하지만, 복잡도가 증가하고 보안상의 이슈가 발생할 가능성이 증가
  • 서버측에서 'Bearer'를 포함하여 응답하면 클라이언트에서 중간에 토큰값을 가로채어서 재사용할 수 있는 상황이 발생함
  • 또한, 'Bearer'를 포함하여 응답하면 클라이언트에서는 한번 더 분리해야 하는 작업이 필요함

결론적으로 서버측에서는 그냥 응답헤더로 전달하고, 클라이언트에서 요청이 있는 경우에 포함하여 요청하는 것이 올바른 사용법이라고 한다.


마무리

스프링에는 다양한 로그인 인증방식이 존재하지만 jwt를 계속 사용해보고 싶었었다. 하지만 spring security와 사용하는 예제만 있어서 해당 예제들의 부분부분을 잘라서 실습하다보니 부족한 부분이 분명히 있을 것이다. 이러한 부분을들 보완하고, 다음에는 interceptor를 통해 인증이 필요한 요청시 토큰을 인증하는 로직을 구현할 예정이다.

회원가입, 로그인은 실제로 내가 서비스를 사용할 때는 그냥 아무생각이 없었고, 최초 구현시에도 금방하겠지.... 했었다. 하지만 세세하게 고민할 부분이 많고, 프론트에서는 정규식 검사도 해야하는 등의 신경써야할 부분들이 굉장히 많았던 것 같다.

계속 진행을 하면서 다듬어가야겠지만 클라이언트에서 요청한 로그인 정보가 서버에서 유효성 검증을 마치고 토큰으로 반환되는 기능을 구현해보니 재미가 있었다. 언제나 과정은 머리가 지끈거릴 정도로 아프다. spring boot를 사용하지 않은 부분때문도 있지만 이해하는데 시간이 걸리기 때문이다.

하지만 원하는 정도의 결과를 얻어냈을 때의 쾌감은 대단한 것 같다. 절로 웃음이 날 수 밖에 없으니 말이다.

이제 프론트를 좀 더 구성하고, 예약 등에서 토큰 인증을 통한 구현을 시도해볼 예정이다.

그럼 이만.👊🏽

profile
서핑하는 개발자🏄🏽

0개의 댓글