프로젝트를 진행하면서 Spring Security + Jwt를 이용한 로그인을 구현하게 되었다.
가장먼저 스프링 시큐리티에 대해서 알아보자.
Spring Security는 Spring과는 별개로 작동하는 보안담당 프레임워크이다.
크게 두 가지의 동작을 수행한다.
1. Authenticatio(인증) : 특정 대상이 "누구"인지 확인하는 절차이다.
2. Authorization(권한) : 인증된 주체가 특정한 곳에 접근 권한을 확인하는 것이다.
인증하는 과정은 위의 그림과 같다. 하나씩 살펴보자.
스프링 시큐리티는 필터를 기반으로 수행된다.
필터와 인터셉터의 차이는 실행되는 시점의 차이이다.
- 필터는 dispatcher servlet으로 요청이 도착하기 전에 동작한다.
- 인터셉터는 dispatcher servlet을 지나고 controller에 도착하기 전에 동작한다.
SecurityContextPersistenceFilter : SecurityContextRepository에서 SecurityContext를 가져오거나 저장하는 역할을 한다.
LogoutFilter : 설정된 로그아웃 URL로 오는 요청을 감시하며, 해당 유저를 로그아웃 처리
(UsernamePassword)AuthenticationFilter : (아이디와 비밀번호를 사용하는 form 기반 인증) 설정된 로그인 URL로 오는 요청을 감시하며, 유저 인증 처리
DefaultLoginPageGeneratingFilter : 인증을 위한 로그인폼 URL을 감시한다.
BasicAuthenticationFilter : HTTP 기본 인증 헤더를 감시하여 처리한다.
RequestCacheAwareFilter : 로그인 성공 후, 원래 요청 정보를 재구성하기 위해 사용된다.
SecurityContextHolderAwareRequestFilter : HttpServletRequestWrapper를 상속한 SecurityContextHolderAwareRequestWapper 클래스로 HttpServletRequest 정보를 감싼다. SecurityContextHolderAwareRequestWrapper 클래스는 필터 체인상의 다음 필터들에게 부가정보를 제공한다.
AnonymousAuthenticationFilter : 이 필터가 호출되는 시점까지 사용자 정보가 인증되지 않았다면 인증토큰에 사용자가 익명 사용자로 나타난다.
SessionManagementFilter : 이 필터는 인증된 사용자와 관련된 모든 세션을 추적한다.
ExceptionTranslationFilter : 이 필터는 보호된 요청을 처리하는 중에 발생할 수 있는 예외를 위임하거나 전달하는 역할을 한다.
FilterSecurityInterceptor : 이 필터는 AccessDecisionManager 로 권한부여 처리를 위임함으로써 접근 제어 결정을 쉽게해준다.
모든 필터를 달달 외울 필요는 없을 것 같고, 대충 필터들이 존재한다는 감만 익히자! 나중에 찾아보면 되니까.
필터만 나열해놓으니까 와닿지 않는다.
필자가 이해한대로 다시 써보자면, 내가 로그아웃 과정을 커스텀 하고 싶을 때 LogoutFilter를 만들어서 커스텀하면 되는 느낌이다.
그리고 새로운 Filter를 생성하고자 할 때는, securityConfig에 Filter 체인을 추가 등록해주면 된다.
필터들은 위의 그림과 같이 체인되어 있다. 임의의 필터를 생성하고 원하는 필터 앞이나 뒤에 삽입하면 될 듯 하다.
쿠키와 세션은 수 없이 많이 들어본 단어이다. 하지만 JWT는 뭔가 생소하다.(나는 그랬다.)
JWT를 접목시키기 위해서는 JWT에 대해서 가장 먼저 알아보아야 할 듯 하다.
Cookie, Session, JWT는 모두 비연결성인 네트워크 서버 특징을 연결성으로 사용하기 위한 방법이다.
자세한 내용은 생략하자!
Cookie & Session은 서버의 어떠한 저장소에 해당 값과 매칭되는 value를 가지고 있어야 한다. 그래서 서버 자원이 많이 사용되는 단점이 있다.
JWT는 Cookie & Session의 자원 문제를 해결하기 위한 방법이다. JWT는 토큰 자체에 유저 정보를 담아서 암호화한 토큰이라고 생각하면 된다. 암호화된 내용은 디코딩 과정을 통해서 해석이 가능하다.
JWT는 3개의 구역이 있다.
header. payload. verify signature
header : Header, Payload, Verify Signature 를 암호화할 방식(alg), 타입(Type) 등을 포함한다.
Payload :서버에서 보낼 데이터 - 일반적으로 user의 id, 유효기간 포함한다.
Verify Signature : Base64 방식으로 인코딩한 Header, Payload, Secret key 를 더한 값이다.
사용자가 로그인을 한다.
서버에서는 계정 정보를 읽어 사용자를 확인 후, 사용자의 고유 ID 값을 부여한 후 기타 정보와 함께 Payload 에 집어넣는다.
JWT 토큰의 유효기간을 설정한다.
암호화할 Secret key 를 이용해 Access Token 을 발급한다.
사용자는 Access Token 을 받아 저장 후, 인증이 필요한 요청마다 토큰을 헤더에 실어 보낸다.
서버에서는 해당 토큰의 Verify Signature 를 Secret key 로 복호화한 후, 조작 여부, 유효기간을 확인한다.
검증이 완료되었을 경우, Payload 를 디코딩 하여 사용자의 ID 에 맞는 데이터를 가져온다.
JWT는 보통 Access Token의 유효기간은 매우 짧다. 이유는 보안 문제 때문이다. 그래서 Refresh Token을 따로 발급해주는데, Access Token이 만료되면 새로운 JWT를 발급할 수 있는 토큰이다.
문제는 "Spring Security와 JWT를 어떻게 같이 사용하는가?" 이다.
차근차근 천천히 해보자.
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
package com.togethersports.tosproejct.config;
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
// authenticationManager를 Bean 등록합니다.
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
//http.httpBasic().disable(); // 일반적인 루트가 아닌 다른 방식으로 요청시 거절, header에 id, pw가 아닌 token(jwt)을 달고 간다. 그래서 basic이 아닌 bearer를 사용한다.
http.httpBasic().disable()
.authorizeRequests()// 요청에 대한 사용권한 체크
.antMatchers("/test").authenticated()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.antMatchers("/**").permitAll()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class); // JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다
// + 토큰에 저장된 유저정보를 활용하여야 하기 때문에 CustomUserDetailService 클래스를 생성합니다.
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
먼저 AuthenticationManager를 Bean으로 등록해준다.
그리고 config를 하나씩 설정해주자.
addFilterBefore() : 필터를 등록한다. 스프링 시큐리티 필터링에 등록해주어야 하기 때문에, 여기에 등록해주어야 한다. 파라미터는 2가지가 들어간다. 왼쪽은 커스텀한 필터링이 들어간다. 오른쪽에 등록한 필터전에 커스텀필터링이 수행된다.
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) : 세션을 사용하지 않는다고 설정한다.
이제 커스텀한 필터를 구현하자.
//해당 클래스는 JwtTokenProvider가 검증을 끝낸 Jwt로부터 유저 정보를 조회해와서 UserPasswordAuthenticationFilter 로 전달합니다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 헤더에서 JWT 를 받아옵니다.
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
// 유효한 토큰인지 확인합니다.
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext 에 Authentication 객체를 저장합니다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
여기서 JwtTokenProvider 또한 커스텀한 Provider이다.
package com.togethersports.tosproejct.jwt;
// 토큰을 생성하고 검증하는 클래스입니다.
// 해당 컴포넌트는 필터클래스에서 사전 검증을 거칩니다.
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
private String secretKey = "myprojectsecret";
// 토큰 유효시간 30분
private long tokenValidTime = 30 * 60 * 1000L;
private final UserDetailsService userDetailsService;
// 객체 초기화, secretKey를 Base64로 인코딩한다.
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// JWT 토큰 생성
public String createToken(String userPk, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위, 보통 여기서 user를 식별하는 값을 넣는다.
claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + tokenValidTime)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과
// signature 에 들어갈 secret값 세팅
.compact();
}
// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰에서 회원 정보 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// Request의 Header에서 token 값을 가져옵니다. "Authorization" : "TOKEN값'
public String resolveToken(HttpServletRequest request) {
return request.getHeader("Authorization");
}
// 토큰의 유효성 + 만료일자 확인
public boolean validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}
우선 User와 UserDetails로 도메인을 분리하기 싫어서, User에 UserDetails를 상속받았다.
package com.togethersports.tosproejct.user;
@Builder
@Data
@Entity
@Table(name = "T_USER")
@NoArgsConstructor
@AllArgsConstructor
public class User implements UserDetails { //UserDetails는 시큐리티가 관리하는 객체이다.
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "USER_SEQUENCE_ID")
private Long userSequenceId;
@Column(name = "USER_EMAIL", nullable = false, length = 100, unique = true)
private String userEmail;
@Column(name = "USER_BIRTH", length = 6)
private String userBirth;
@Column(name = "USER_NICKNAME", length = 15)
private String userNickname;
@Column(name = "GENDER", length = 1)
@Enumerated(EnumType.STRING)
private Gender gender;
@Column(name = "ADMIN", length = 4)
@Enumerated(EnumType.STRING)
private Admin admin;
@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 getPassword() {
return null;
}
@Override
public String getUsername() {
return userEmail;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUserEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
}
}
해당 컨트롤러는 jwt 토큰이 유효한지 판단하는 컨트롤러다.
@RestController
public class TestController {
@PostMapping("/test")
public String test(){
return "<h1>test 통과</h1>";
}
}
임시로 간단하게만 작성했다.
package com.togethersports.tosproejct.user;
@Slf4j
@RestController
@RequiredArgsConstructor
public class UserController {
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
final String BIRTH = "001200";
final String EMAIL = "aabbcc@gmail.com";
final String NICKNAME = "침착맨";
final Long SEQUENCEID = Long.valueOf(1);
final Gender GENDER = Gender.남;
final Admin ADMIN = Admin.일반회원;
User user = User.builder()
.userEmail(EMAIL)
.userBirth(BIRTH)
.userNickname(NICKNAME)
.admin(ADMIN)
.gender(GENDER)
.userSequenceId(SEQUENCEID)
.roles(Collections.singletonList("ROLE_USER")) // 최초 가입시 USER 로 설정
.build();
@PostMapping("/join")
public String join(){
log.info("로그인 시도됨");
userRepository.save(user);
return user.toString();
}
// 로그인
@PostMapping("/login")
public String login(@RequestBody Map<String, String> user) {
log.info("user email = {}", user.get("email"));
User member = userRepository.findByUserEmail(user.get("email"))
.orElseThrow(() -> new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
return jwtTokenProvider.createToken(member.getUsername(), member.getRoles());
}
}
완료 되었다!
포스트맨을 통해서 테스트 해보자.
포스트맨을 키자.
회원가입 URL을 날리자
반환값으로 회원정보가 저장된 것을 볼 수 있다.
반환값으로 JWT가 넘어온 것을 볼 수 있다.
요청 헤더에 "Authorization"를 추가해주고 토큰 값을 입력해준다.
그리고 "/test"로 요청하자.
정상적으로 반환되었다.
헤더내용을 살짝 조작해보자.
12341234로 조작했다. 다시 요청해보자.
요청이 막힌 것을 볼 수 있다.
성공.
여기서 내 토큰을 디코딩해보자.
내가 담은 정보들이 올바르게 있는 것을 알 수 있다.
다음에는 JWT에 필요한 내용들을 담아보자.
감사합니다. 잘 봤습니다.