security, jwt 적용

dev·2021년 3월 14일
0

spring

목록 보기
2/3

이전 게시글에 security Sample을 간단하게 적용하는 법을 했습니다.
이번에는 jwt를 적용하는 법을 해보겠습니다.

JWT(Json Web Token)

JWT(Json Web Token)은 JSON 객체를 통해 안전하게 정보를 전송할 수 있는 웹표준(RFC7519) 입니다. JWT는 '.'을 구분자로 세 부분으로 구분되어 있는 문자열로 이루어져 있습니다.

(헤더.내용.서명)

각각 헤더는 토큰 타입과 해싱 알고리즘을 저장하고, 내용은 실제로 전달할 정보, 서명에는 위변조를 방지하기위한 값이 들어가게 됩니다
JWT는 JSON 객체를 암호화 하여 만든 문자열 값으로 위, 변조가 어려운 정보라고 할 수 있습니다. 또, 다른 토큰들과 달리 토큰 자체에 데이터를 가지고 있다는 특징이 있습니다. JWT의 이러한 특징 때문에 사용자의 인증 요청시 필요한 정보를 전달하는 객체로 사용할 수 있습니다

JWT 적용

해당 프로젝트 pom.xml dependency에 JWT를 추가해줍니다.

    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>${jjwt.version}</version>
    </dependency>

User 정보를 담을 Entity 객체와 Repository가 필요(본인에 맞게 수정 필요)

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class User {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

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

@Column(length = 300, nullable = false)
private String password;

}

public interface UserRepository extends JpaRepository<User, Long> {
}

security(본인에 맞게 수정 필요)

Spring Security를 사용하기 위해서는 Spring Security Filter Chain 을 사용한다는 것을 명시해 줘야 합니다. 이것은 WebSecurityConfigurerAdapter를 상속받은 클래스에 @EnableWebSecurity 어노테이션을 달아주면 해결됩니다. 자세한 것은 코드를 통해 설명하겠습니다.

@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.httpBasic()
.and()
.authorizeRequests()
.antMatchers("/admin/").hasRole("admin")
.antMatchers("/
").permitAll();
//"admin/**"으로 요청이 올경우 인증 요구
}
}

위 코드에서 WebSecurityConfigurerAdapter를 상속받은 WebSecurityConfig에 @EnableWebSecurity 애너테이션이 붙어 있는 것을 볼 수 있습니다. 그리고 Override 된 confiure 메소드에서 "/admin/**" 형식의 URL로 들어오는 요청에 대해 인증을 요구하고 있습니다.

인증을 요구하는 경로로 요청이 들어올 경우 아래와 같이 인증 정보를 요구하게 됩니다

이렇게 하면 로그인 화면이 뜰겁니다

이제 추가된 라이브러리를 사용해서 JWT를 생성하고 검증하는 컴포넌트를 만들어 보도록 하겠습니다. JWT에는 토큰 만료 시간이나 회원 권한 정보등을 저장할 수 있습니다.

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
private String secretKey = "webfirewood";
// 토큰 유효시간 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 roles) {
Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위
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 값을 가져옵니다. "X-AUTH-TOKEN" : "TOKEN값'
public String resolveToken(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
// 토큰의 유효성 + 만료일자 확인
public boolean validateToken(String jwtToken) {
try {
Jws claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}

토큰을 생성하고 검증하는 컴포넌트를 완성했지만 실제로 이 컴포넌트를 이용하는 것은 인증 작업을 진행하는 Filter 입니다. 이 필터는 검증이 끝난 JWT로부터 유저정보를 받아와서 UsernamePasswordAuthenticationFilter 로 전달해야 할 것입니다.

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

이제 다시 SecurityCinfiguration 클래스로 돌아가서 작성한 필터를 등록해 주고 필요한 부분을 채워 넣도록 하겠습니다.

@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
// 암호화에 필요한 PasswordEncoder 를 Bean 등록합니다.
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// authenticationManager를 Bean 등록합니다.
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.httpBasic().disable() // rest api 만을 고려하여 기본 설정은 해제하겠습니다.
.csrf().disable() // csrf 보안 토큰 disable처리. .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 토큰 기반 인증이므로 세션 역시 사용하지 않습니다.
.and()
.authorizeRequests() // 요청에 대한 사용권한 체크
.antMatchers("/admin/").hasRole("ADMIN")
.antMatchers("/user/
").hasRole("USER")
.anyRequest().permitAll() // 그외 나머지 요청은 누구나 접근 가능
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class);
// JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다
}
}

토큰에 저장된 유저 정보를 활용해야 하기 때문에 CustomUserDetatilService 라는 이름의 클래스를 만들고 UserDetailsService를 상속받아 재정의 하는 과정을 진행합니다.

@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
}

위 코드를 보면 UserRepository 에서 Email 을 통해 유저를 찾는 findByEmail 메소드를 사용하는 것을 알 수 있습니다. UserRepository 에 findByEmail 메소드를 추가해 줍니다.

public interface UserRepository extends JpaRepository<User, Long> {
Optional findByEmail(String email);
}

SpringSecurity는 UserDetails 객체를 통해 권한 정보를 관리하기 때문에 User 클래스에 UserDetails 를 구현하고 추가 정보를 재정의 해야 합니다. Entity와 UserDetails는 구분할 수도 같은 클래스에서 관리할 수도 있습니다. 여기에서는 같은 클래스에서 관리하는 방법을 사용하도록 하겠습니다.

@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(length = 100, nullable = false, unique = true)
private String email;
@Column(length = 30, nullable = false)
private String password;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}

getUsername을 통해 spring security에서 사용하는 username을 가져갑니다. 저희가 사용할 username은 email 입니다. 이제 실제로 Controller 에서 회원 가입과 로그인을 통한 인증 과정을 진행해 보도록 하겠습니다.

@RequiredArgsConstructor
@RestController
public class UserController {

private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;

// 회원가입
@PostMapping("/join")
public Long join(@RequestBody Map<String, String> user) {
    return userRepository.save(User.builder()
            .email(user.get("email"))
            .password(passwordEncoder.encode(user.get("password")))
            .roles(Collections.singletonList("ROLE_USER")) // 최초 가입시 USER 로 설정
            .build()).getId();
}

// 로그인
@PostMapping("/login")
public String login(@RequestBody Map<String, String> user) {
    User member = userRepository.findByEmail(user.get("email"))
            .orElseThrow(() -> new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
    if (!passwordEncoder.matches(user.get("password"), member.getPassword())) {
        throw new IllegalArgumentException("잘못된 비밀번호입니다.");
    }
    return jwtTokenProvider.createToken(member.getUsername(), member.getRoles());
}

}
이제 애플리케이션을 실행하고 POSTMAN, swagger등의 도구를 이용해 테스트를 해 봅니다. /join 으로 POST 요청을 보내서 회원가입을 한 뒤, /login 으로 POST 요청을 보내면 토큰을 생성해 반환 하는 것을 볼 수 있습니다.

참고 문서 : https://www.youtube.com/watch?v=fG21HKnYt6g&list=TLPQMTQwMzIwMjFXsQ4hOBZtwQ&index=2https://mangkyu.tistory.com/77https://webfirewood.tistory.com/115

profile
studying

0개의 댓글