출처 : https://webfirewood.tistory.com/115
이 블로그 예제를 사용했다.
Project 생성
Spring Boot 버전은 2.7.0
아래와 같이 dependency 추가 해준다.
plugins {
id 'org.springframework.boot' version '2.7.0'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
id 'java'
}
group = 'me.yeonsang'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
tasks.named('test') {
useJUnitPlatform()
}
Class 생성
1.Domain, Repository
회원가입, 로그인 기능을 구현할 것이므로 User 클래스를 생성. JPA를 사용하므로 아래와 같이 작성
package me.chagari.jwttest.domain;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "users")
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 = 300, nullable = false)
private String password;
}
Table 명을 정해준 이유는 실행 시 Exception이 나면서 테이블 생성에서부터 막히기 때문이다. User라는 걸로 이미 해당 DB에 잡혀있을 수 있기 때문에 복수명으로 users라고 지정해줬다.
JPA를 사용하므로 Repository 도 생성해준다.
package me.chagari.jwttest.domain;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User,Long> {
}
2.security setting
security를 사용하므로 설정을 해줘야한다.
spring boot 2.7.0 부터는 WebSecurityConfigureAdpater가 사용 중지가 되었다. 그래서 아래와 같이 설정한다.
package me.chagari.jwttest.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.httpBasic()
.and()
.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.antMatchers("/**").permitAll();
return http.build();
}
}
3.jwt provider
토큰 발급과 검증을 하는 클래스 생성
package me.chagari.jwttest.config.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;
import java.util.Base64;
import java.util.Date;
import java.util.List;
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
private String secretKey = "chargari";
//유효 시간
private long tokenValidTime = 30 * 60 * 1000L;
private final UserDetailsService userDetailsService;
//객체 초기화 .secretKey 를 Base64 로 인코딩
@PostConstruct //우선 순위 1
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// Create JWT Token
public String createToken(String userPk, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userPk); // 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
.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> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e){
return false;
}
}
}
4.Filter 생성
토큰 발급과 검증을 하는 컴포넌트를 생성했다고 해도 filter에 추가하지 않으면 의미가 없기 때문에 CustomFilter 를 만들어준다.
package me.chagari.jwttest.config.security;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.GenericFilterBean;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// header 에서 JWT 추출
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
// validate Token
if(token != null && jwtTokenProvider.validateToken(token)){
Authentication authentication = jwtTokenProvider.getAuthentication(token);
//security context 에 authentication 객체 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request,response);
}
}
5.Security Config
JWT Filter에서 유저 정보를 받고 UsernamePasswordAuthenticationFilter로 넘어가야 하므로 UsernamePasswordAuthenticationFilter 전에다가 등록. 아까 설정한 security config 클래스에 몇가지 더 추가 해준다.
package me.chagari.jwttest.config;
import lombok.RequiredArgsConstructor;
import me.chagari.jwttest.config.security.JwtAuthenticationFilter;
import me.chagari.jwttest.config.security.JwtTokenProvider;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
//password encoder setting
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
//authenticationManger bean 등록
@Bean
public AuthenticationManager authenticationManagerBean(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.httpBasic().disable() // rest api 만을 고려
.csrf().disable() // csrf 보안 토큰 disable 처리
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //session 사용 x
.and()
.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.antMatchers("/**").permitAll()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); // UsernamePasswordAuthenticationFilter 전에 넣는다.
return http.build();
}
}
6.CustomUserDetailsService 생성
JWT 에서 받은 유저 데이터를 활용해야하므로 UserDetailsService을 구현한 커스텀 구현체 생성
package me.chagari.jwttest.config.security.user;
import lombok.RequiredArgsConstructor;
import me.chagari.jwttest.domain.UserRepository;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return (UserDetails) userRepository.findByEmail(username)
.orElseThrow(()-> new UsernameNotFoundException("User Not Found!"));
}
}
findByEmail을 사용중이다. Repository 인터페이스에서 findByEmail 메서드를 입력해주자.
package me.chagari.jwttest.domain;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User,Long> {
Optional<User> findByEmail(String email);
}
7.User Class 수정
SpringSecurity는 UserDetails 객체를 통해 권한 정보를 관리하기 때문에 User 클래스에 UserDetails 를 구현하고 추가 정보를 재정의 해야 한다.
package me.chagari.jwttest.domain;
import lombok.*;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.stream.Collectors;
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Entity
@Table(name = "users")
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 = 300, 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 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;
}
}
8.Controller 생성
직접 회원가입 로그인을 해야하므로 RestController를 생성해준다.
package me.chagari.jwttest.controller;
import lombok.RequiredArgsConstructor;
import me.chagari.jwttest.config.security.JwtTokenProvider;
import me.chagari.jwttest.domain.User;
import me.chagari.jwttest.domain.UserRepository;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.Collections;
import java.util.Map;
@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"))
.build()).getId();
}
@PostMapping("/login")
public String login(@RequestBody Map<String,String>user){
User member = userRepository.findByEmail(user.get("email"))
.orElseThrow(()-> new IllegalArgumentException("가입되지 않은 Email"));
if(!passwordEncoder.matches(user.get("password"), member.getPassword())) {
throw new IllegalArgumentException("잘못된 비밀번호");
}
return jwtTokenProvider.createToken(member.getUsername(), member.getRoles());
}
}
TEST
PostMan 을 사용해서 test를 했다.
회원가입
회원가입 시에는 Body에 JSON 타입으로 /join으로 넘기면 된다.
로그인
/join 대신 /login을 넣으면 된다.
로그인에 성공하게 되면 아래와 같이 토큰값이 반환된다.
이제 이 토큰 값을 가지고 권한이 필요한 경로로 접근할때 header에 X-AUTH-TOKEN에다가 로그인할 때 받은 토큰을 넣어주면 된다.
JWT로 회원가입, 로그인만 테스트 해보는 거여서 빠진 부분이 좀 있다.
Comment