JWT를 이용한 로그인 시 브라우저로 로그인하는 예제가 없는 이유
Spring security에서 jwt를 통한 로그인에 관한 글을 보면 대부분의 실습(및 테스트)는
postman같은 API요청 프로그램을 이용해서 합니다.
JWT는 무상태성을 유지하는 토큰 인증방식으로 클라이언트, 서버가 분리되어있는 REST API에서 사용합니다.
그렇기 때문에 만약 여러분이 Thymeleaf, JSP,Mustache 등의 SSR 템플릿 엔진을 사용한다면
JWT를 구현하지 않는것이 좋습니다.
JWT는 일반적으로 클라이언트 LocalStorage 영역에 서버에서 받은 토큰을 보관합니다.
그리고 서버에 요청 시에는 LocalStorage에 있는 토큰을 빼내고
Request Header의 Authorization값에 토큰을 추가해서 요청하는 형태로 만드는데,
위의 템플릿 엔진들은 페이지 이동은 form태그나 a태그 등을 이용하는데 이는 Request Header에 토큰값을 추가할 수 없습니다.
그래서 JWT 예제를 찾아보면 위의 템플릿 엔진에서 만든 예제가 굉장히 드뭅니다.
반대로 postman이나 api 프로그램 또는 Rest API 프로그램에서의 ajax는 Header에 값을 추가할 수 있습니다.
$.ajax({
type : 'GET'
,url : 'api/v1/user/
,contentType : 'application/json ; charset=utf-8'
,beforeSend : function(xhr){
xhr.setRequestHeader("Content-type","application/json");
xhr.setRequestHeader("Authorization",localStorage.getItem("jwtToken"));
}
...
);
(그래서 이 글에서도 ajax로도 할 수 있지만 좀 더 간단한 postman을 이용해 실습할 예정입니다.)
또 Thymeleaf같은 SSR에서 JWT를 구현하면 다음과 같은 태그를 사용할 수 없습니다.
<div sec:authorize="hasRole('ROLE_USER')" ></div>
기존 인증 처리 방식 Cookie와 Session
쿠키의 인증처리방식
쿠키란 웹 브라우저가 보관하는 데이터로서 key,value로 이루어져있습니다.
쿠키 인증확인 예시
Cookie[] cookies=request.getCookies();
if(cookies!=null){
for(Cookie cookie : cookies){
if(cookie.getName().equals("AUTH")){
//여러 쿠키중 AUTH란 쿠키가 있군 = > 인증
}
}
}
다만 쿠키는 다음과 같은 단점이 있어 인증여부에는 사용되지 않는다.
- 브라우저에서 조작이 쉽다. 나쁜 사용자가 직접 "AUTH"란 쿠키를 만들어서 보낼 수 있습니다.
이 경우 나쁜 사용자는 사이트의 id,pw를 몰라도 인증된걸로 여겨질 수 있습니다. - 쿠키의 사이즈가 4KB로 제한되어 있어 충분히 많은 정보를 담을 수 없다.
- 웹 브라우저마다 쿠키에 대한 지원방식이 다르다.
세션인증처리방식
3번 session에 USER정보 저장 예시.
session.setAttribute("USER",user); // user는 사용자 정보를 가지고 있는 객체
5번 session에 USER정보 확인 예시
User user=(User)session.getAttribute("USER");
if(user==null ) {
//로그인 한적 없음
}else{
//로그인 해놓은 상태
}
쿠키인증방식과 달리 보안에도 안전하고, 저장할 데이터의 제한도 없어 아직까지 가장 많이 사용되는 인증방식입니다.
다만 이 역시 몇 가지 단점이 존재합니다.
- 인증에 관한 정보를 서버가 저장하기때문에 서버에 부담이 있다.
동시 접속자가 너무 많은 경우 메모리초과의 위험이 있다. - stateful하기 때문에 http 장점을 발휘하지 못하고 서버 확장에 걸림돌이 된다
새롭게 등장한 토큰인증방식
ID,PW를 통해 인증이 완료되면 서버는 비밀키 또는 공개/개인 키를 이용해 서명한 토큰을 클라이언트에게 전달합니다.
그리고 데이터 요청 시 클라이언트는 요청에 토큰을 포함합니다.(주로 헤더에 포함)
서버는 토큰의 서명 값을 이용하여 토큰이 유효한지 검증합니다.
(인증정보를 클라이언트가 요청에 포함하는 것이 쿠키와 비슷해보이긴 합니다.)
토큰인증방식의 장점
- 토큰은 기본적으로 서버에 클라이언트 상태를 저장하지 않는 무상태성 방식입니다.
- 서버에 정보를 저장할 필요가 없기 때문에 서버를 확장하는데 제약이 없다.
- 또한 유효한 토큰인지 검증만 필요하기 때문에 다른 플랫폼,서비스 간에 사용도 편리함
(같은 회사의 여러 서비스별로 서버가 있을 경우 세션은 서버별로 로그인해야되지만,
토큰은 한군데서만 로그인해도 다른 서비스를 이용할 수 있음)
토큰인증방식의 단점
- 토큰이 탈취 될 우려가 있다. (그래서 유효시간 등으로 보완하기는 한다)
- 요청에 많은 데이터가 담기기 때문에, 요청이 많을 경우 네트워크 부하가 심해진다.
JWT란
JWT(JSON Web Token)는 현재 토큰인증방식에서 가장 많이 사용되고 있는 인터넷표준으로,
인증에 필요한 정보들을 암호화시킨 JSON 토큰을 의미한다.
JWT는 JSON 데이터를 Base64 URL-safe Encode 를 통해 인코딩하여 직렬화한 것이며,
토큰 내부에는 위변조 방지를 위해 개인키를 통한 전자서명도 들어있다.
따라서 사용자가 JWT 를 서버로 전송하면 서버는 서명을 검증하는 과정을 거치게 되며
검증이 완료되면 요청한 응답을 돌려준다.
JWT 구성요소
JWT는 . 을 구분자로 나누어지는 세 가지 문자열의 조합이다.
각각 Header(헤더), PayLoad(내용),Signature (서명) 으로 이루어져있다.
1. 헤더
헤더는 보편적으로 토큰 타입을 명시하는 “typ”와
HMAC, SHA256 등의 서명 알고리즘을 적는 “alg”로 이루어져 있습니다.
2. 페이로드
페이로드에는 토큰에 담을 데이터가 들어있습니다. 이 데이터 하나를 JWT에서는 claim이라고 부릅니다.
claim에는 registered claims, public claims, private claims 세 가지 종류가 있습니다.
Registered claims는 서비스에 필요한 정보가 아닌 토큰에 관한 정보를 담기 위해 이미 등록된 클레임입니다.
반드시 사용할 필요 없이 선택적으로 사용할 수 있습니다.
Public claims는 사용자가 정의할 수 있는 클레임입니다. 이 클레임은 충돌 방지를 위해 IANA(Internet Assigned Numbers Authority) JSON 웹 토큰 레지스트리에 정의하거나, 충돌 방지 네임스페이스를 포함하는 URI로 클레임 이름을 정의해야 합니다.
Private claims는 토큰 사용자 간(서버-클라이언트) 정보 공유를 위해 만들어 사용하는 클레임으로 충돌이 발생할 수 있으니 주의해야 합니다.
3. 서명
서명 부분은 헤더와 페이로드를 서명한 값입니다.
시크릿 키를 이용해 base64 url로 인코딩한 헤더와 페이로드를 헤더에 규정된 해싱 알고리즘으로 서명합니다.
서버는 이 서명과 전달된 헤더, 페이로드를 같은 알고리즘으로 해시한 값이 동일한 것을 확인함으로써 유효한 토큰인지 검사합니다.
jwt Signature 구조
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secretKey
)
이 때 secretKey는 서버측에서 저장해서 갖고있을 비밀키이다. 외부에 공개되면 안된다.
(다만 블로그 글의 경우 실습을 위해 단순히 설정파일에 표시한다.)
실습
서버측에서 JWT를 만들고, 클라이언트 요청 Header에 JWT를 추가해서 실습을 진행해보자.
실습 코드는 https://github.com/gks930620/spring_security_jwt 참고.
build.gradle
//security
implementation 'org.springframework.boot:spring-boot-starter-security'
// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// 외 기타 springboot, jpa, DB 관련
SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/members/login").permitAll()
.antMatchers("/members/test").hasRole("USER")
.anyRequest().authenticated()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
httpBasic().disable().csrf().disable()
- rest api이므로 기존의 인증 및 csrf 보안을 사용하지 않는다는 설정이다. 인증여부를 세션으로 하지 않고
우리가 이후 만들 JWT토큰을 확인하는 코드를 통해 토큰인증방식으로 해야한다.
addFilterBefore(new JwtAUthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
- JWT 인증을 위하여 직접 구현한 필터를 UsernamePasswordAuthenticationFilter 전에 실행하겠다는 설정이다.
TokenInfo.java
@Builder
@Data
@AllArgsConstructor
public class TokenInfo {
private String grantType;
private String accessToken;
private String refreshToken;
}
클라이언트에 보낼 토큰 DTO이다. grantType은 JWT 대한 인증 타입으로, 여기서는 Bearer를 사용한다.
이후 HTTP 헤더에 prefix로 붙여주는 타입이기도 하다.
accessToken, refreshToken이 위에있는 JWT로
Header,Payload,Signature를 인코딩해서 합친 하나의 문자열이다. (JWT=문자 이다.)
한번에 2개의 JWT를 만들어 클라이언트에 보낸다.
application.yml
jwt:
secret: VlwEyVBsYt9V7zq57TejMnVUyzblYcfPQye08f7MGVA9XkHa
spring:
datasource:
url: jdbc:h2:tcp://localhost/~/test
driver-class-name: org.h2.Driver
username: sa
password : sa
jpa:
database-platform: org.hibernate.dialect.H2Dialect
properties.hibernate.hbm2ddl.auto: create
showSql: true
JWT를 만들 때 사용하는 secretKey로 256비트 이상면 된다. 영어로는 32글자 이상이면 된다.
(절대 외부에 공개되서는 안되는 키다. 여기서는 실습이니까...)
JwtTokenProvider.java
JWT 토큰 생성, 토큰 복호화 및 정보 추출, 토큰 유효성 검증의 기능이 구현된 클래스이다.
@Slf4j
@Component
public class JwtTokenProvider {
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// 유저 정보를 가지고 AccessToken, RefreshToken 을 생성하는 메서드
public TokenInfo 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 + 86400000);
String accessToken = Jwts.builder()
.setSubject(authentication.getName())
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + 86400000))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return TokenInfo.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
// JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(String accessToken) {
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
// 토큰 정보를 검증하는 메서드
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
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);
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
토큰 생성부분의 시간만 주의하자. 보통은 30분으로 설정하지만 테스트라 하루로 설정했다.
JwtAuthenticationFilter
클라이언트 요청 시 JWT 인증을 하기 위해 설치하는 커스텀 필터로
UsernamePasswordAuthenticationFilter 이전에 실행된다.
이전에 실행된다는 뜻은 JwtAuthenticationFilter를 통과하면
UsernamePasswordAuthenticationFilter 이후의 필터는 통과한 것으로 본다는 뜻이다.
즉, 기본 Security의 인증여부 확인을 spring한테 맡기는 것이 아니라,
내가 작성한 코드에서 인증여부 확인(JWT토큰 확인)을 하겠다는 뜻
(밑의 테스트에서 예시를 확인해보자)
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 1. Request Header 에서 JWT 토큰 추출
String token = resolveToken((HttpServletRequest) request);
// 2. validateToken 으로 토큰 유효성 검사
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext 에 저장
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
// Request Header 에서 토큰 정보 추출
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7);
}
return null;
}
}
다음은 로그인을 위한 Member 도메인 설정이다.
Member.java
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
public class Member implements UserDetails {
@Id
@Column(updatable = false, unique = true, nullable = false)
private String memberId;
@Column(nullable = false)
private String password;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getUsername() {
return memberId;
}
@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 true;
}
}
MemberRepository.java
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByMemberId(String username);
}
MemberService.java
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
@Transactional
public TokenInfo login(String memberId, String password) {
// 1. Login ID/PW 를 기반으로 Authentication 객체 생성
// 이때 authentication 는 인증 여부를 확인하는 authenticated 값이 false
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(memberId, password);
// 2. 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
// authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 3. 인증 정보를 기반으로 JWT 토큰 생성
TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication);
return tokenInfo;
}
}
로그인 과정은 크게 3단계이다.
1. 로그인 요청으로 들어온 memberId, password를 기반으로 Authentication 객체를 생성한다.
2. authenticate() 메서드를 통해 요청된 Member에 대한 검증이 진행된다. (loadUserByUsername을 통해)
3. 검증이 정상적으로 통과되었다면 인증된 Authentication 객체를 기반으로 JWT 토큰을 생성한다.
CustomUserDetailsService.java
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByMemberId(username)
.map(this::createUserDetails) //값이 있으면 제공된 매핑 함수를 적용하고 결과가 null이 아니면 결과를 Optional자료형으로 리턴. 메소드 참조는 클래스이름(or객체) :: 메소드이름
.orElseThrow(() -> new UsernameNotFoundException("해당하는 유저를 찾을 수 없습니다."));
}
// 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 리턴
private UserDetails createUserDetails(Member member) {
return User.builder()
.username(member.getUsername())
.password(member.getPassword())
.roles(member.getRoles().toArray(new String[0])) //단순히 List를 배열형태로
.build();
}
}
위의 2번과정에서 authenticate() 메서드를 통해 loadUserByUsername 메서드를 실행된다.
해당 메서드는 검증을 위한 유저 객체를 가져오는 부분으로써, 어떤 객체를 검증할 것인지에 대해 직접 구현해주어야 한다.
(loadUserByUsername에서는 DB를 조회해서 있으면 UserDetails객체를 만들어 return한다)
MemberRequestDto.java
@Data
public class MemberLoginRequestDto {
private String memberId;
private String password;
}
로그인 ID,PW입력용 DTO
MemberController.java
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService;
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
@PostConstruct
public void init(){
ArrayList<String> strings = new ArrayList<>();
strings.add("USER");
Member member=Member.builder().memberId("member_A").password(passwordEncoder.encode(" 1234")).roles(strings).build();
memberRepository.save(member);
}
@PostMapping("/login")
public TokenInfo login(@RequestBody MemberLoginRequestDto memberLoginRequestDto) {
String memberId = memberLoginRequestDto.getMemberId();
String password = memberLoginRequestDto.getPassword();
TokenInfo tokenInfo = memberService.login(memberId, password);
return tokenInfo;
}
@PostMapping("/test")
public String test() {
return "success";
}
}
MemberController 빈 생성 후 DB에 더미데이터를 집어 넣는다.
테스트
포트스맨으로 다음과 같이 설정 후 send를 눌러 요청하자.
localhost:8080/members/login
이 요청은 SecurityConfig에서 설정한 JWTAuthenticationFilter를 지난다.
아직 아무런 토큰이 없기때문에 필터에서 아무것도 안하고 그냥 통과한다.
SecurityConfig에서 logins 는 permitAll() 이기때문에 그냥 통과된다.
결과가 이렇게 나오면 성공한 것이다.
이후 다음과 설정 후 요청해보자.
header태그에 Key에 Authorization, value에 "Bearer [위에서 받은 accessToken]"
이 요청도 역시 SecurityConfig에서 설정한 JWTAuthenticationFilter를 지난다.
헤더에 Authorization 헤더가있고 그 값이 유효하면
토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장하게 된다.
이제 이 요청은 "USER" 역할을 가지게 되고 SecurityConfig에서 test"는 hasRole("USER")를 통과하게 된다.
다음 글에서는 Access/Refresh 토큰 인증방식에 대해 다뤄보겠습니다.