[spring] (7) Spring Security와 JWT

orca·2022년 11월 23일
1

Spring

목록 보기
7/13

JWT = JSON Web Token

JSON Web Token (JWT) is an open standard that defines a compact and self-contained way for securely transmitting information between parties as a JSON object.
JWT는 당사자 간의 정보를 JSON 객체로 안전하고 효율적으로 전송하기 위한 표준!
JWT

payload :
토큰에 담을 정보가 들어있는 부분, 한 개 이상의 claim으로 이루어짐

claim :
정보의 한 조각, “name” : value

{
  "username": "John Doe"
}

subject :
토큰에 대한 제목, 애플리케이션에서는 유저에 대한 식별값이 됨

JWT공식사이트 에서 직접 클레임을 작성하여 JWT를 발급해보았다

eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IkpvaG4gRG9lIn0.BrhGCohqhPFYSQMzw3F4OZeN1dgTZwPUBobFOYEVx1SgZ2fuMDNUV36p51aXtxAAPwSzlsORJf-mFWQvdYZTWg

어디서 많이 봤던 결과값이 나왔다

JWT로 토큰 기반 인증을 구현해보자

JwtUtil.java

1️⃣ 토큰을 만드는 부분
username을 받아서 액세스 토큰, 리프레쉬 토큰을 만드는 부분을 구현

@Component
public class JwtUtil {

    @Value("${haden.jwtSecret}")
    private String jwtSecret;
    private final long ACCESS_TOKEN_VALID_PERIOD = 1000L*60*30;
    private final long REFRESH_TOKEN_VALID_PERIOD = 1000L*60*60*24*7;

    public TokenDto generateToken(String username){
        Date now = new Date();
        Date accessTokenExpireIn = new Date(now.getTime() + ACCESS_TOKEN_VALID_PERIOD);

        String accessToken = Jwts.builder()
                .setClaims(Jwts.claims().setSubject(username))
                .setIssuedAt(now)
                .setExpiration(accessTokenExpireIn)
                .signWith(SignatureAlgorithm.HS512, jwtSecret)
                .compact();

        String refreshToken = Jwts.builder()
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + REFRESH_TOKEN_VALID_PERIOD))
                .signWith(SignatureAlgorithm.HS512, jwtSecret)
                .compact();


        return new TokenDto(accessToken, refreshToken, accessTokenExpireIn.getTime());
    }
}

.setClaims(Jwts.*claims*().setSubject(username))
:username 으로 클레임을 만들건데, 그걸 Subject로 설정할거야

.setIssuedAt(now)

:지금 발급되는 토큰이야

.setExpiration(accessTokenExpireIn)

:지금으로부터 ACCESS_TOKEN_VALID_PERIOD 까지 유효해
.signWith(SignatureAlgorithm.*HS512*, jwtSecret)

: HS512 로 인코딩 할거고, jwtSecret 이라는 키를 쓸거야

2️⃣ 토큰으로부터 username을 가져오는 부분
username 클레임이 Subject였으므로 .parseClaimsJws(token).getBody().getSubject() 으로 가져올 수 있음

 @Component
public class JwtUtil {
...
    public String getUsername(String token){
        return Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token).getBody().getSubject();
    }
}

3️⃣ 토큰이 유효한지 확인하는 부분
토큰이 jwtSecret 으로 파싱이 된다면 유효한 것으로 판단

 @Component
public class JwtUtil {
...
    public boolean isTokenValid(String token){
        try{
            Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);
            return true;
        }catch (JwtException e){
            e.printStackTrace();
        }
        return false;
    }
}

JwtFilter.java
OncePerRequestFilter 를 확장하는 JwtFilter 클래스를 만들어보자
JwtFilter는 request에서 파싱한 토큰을 검증하고, Spring Security에 검증된 유저에 대한 정보를 전달함

@Component
public class JwtFilter extends OncePerRequestFilter {

    @Autowired
    private JwtUtil jwtUtil;
    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = parseJwt(request);
        if(token != null && jwtUtil.isTokenValid(token)){
            String username = jwtUtil.getUsername(token);
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            UsernamePasswordAuthenticationToken authenticationToken =
                    new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); //UsernamePasswordAuthenticationToken을 생성하는 부분
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        filterChain.doFilter(request, response);
    }

		private String parseJwt(HttpServletRequest request){
        String headerAuth = request.getHeader("Authorization");
        if(StringUtils.hasText(headerAuth)&&headerAuth.startsWith("Bearer")){
            return headerAuth.substring(7);
        }
        return null;
    }
}

parseJwt(request):
request로 부터 Jwt 토큰을 추출함

jwtUtil.getUsername(token); :
토큰이 유효하면 토큰으로부터 Subject인 username을 가져와~

SecurityContextHolder.*getContext*().setAuthentication(authenticationToken):
해당 유저 정보로 UsernamePasswordAuthenticationToken 을 만들어 SecurityContextHolder 에 인증 정보 전달

짚고 넘어가는 Security Context Holder

💬 Spring Security는 Authentication과 Authorization에 대한 부분을 Filter로서 처리

Spring Security는 같은 스레드의 앱 내에서 어디서든 SecurityContextHolder 의 인증 정보를 확인할 수 있도록 구현됨
SecurityContextHolderSecurityContext 를 갖고 있다

SecurityContext를 만들어 SecurityContextHolder에 인증 정보를 전달하는 예제

SecurityContext context = SecurityContextHolder.createEmptyContext(); 

Authentication authentication =
    new TestingAuthenticationToken("username", "password", "ROLE_USER"); 
context.setAuthentication(authentication);

SecurityContextHolder.setContext(context);

💡 용어 정리

  • SecurityContextHolder - Spring Security가 인증된 클라이언트의 세부 정보를 보관하는 곳
    The SecurityContextHolder is where Spring Security stores the details of who is authenticated.
  • SecurityContext- 현재 인증된 클라이언트의 Authentication을 가지고 있는 객체
    is obtained from the SecurityContextHolder and contains the Authentication of the currently authenticated user.
  • Authentication  - 클라이언트에 대한 일종의 자격 증명
    Can be the input to AuthenticationManager to provide the credentials a user has provided to authenticate or the current user from the SecurityContext.
  • Granted Authority- 인증된 클라이언트에게 부여된 권한
    An authority that is granted to the principal on the Authentication (i.e. roles, scopes, etc.)

📄 Servlet Authentication Architecture

UserDetailsImpl.java

UserDetails는 Spring Security에서 사용자의 정보를 담는 객체임
아래와 같이 UserDetails의 구현체를 만듦

public class UserDetailsImpl implements UserDetails {

    private User user;
    private Collection<? extends GrantedAuthority> authorities;

    public static UserDetailsImpl from(User user){
        SimpleGrantedAuthority simpleGrantedAuthority =
                new SimpleGrantedAuthority(user.getRole().toString());
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(simpleGrantedAuthority);

        UserDetailsImpl userDetails = new UserDetailsImpl();
        userDetails.user = user;
        userDetails.authorities = collection;

        return userDetails;
    }

    public User getUser(){
        return user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

   ...

}
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    private final UserRepository userRepository;

    @Autowired
    public UserDetailsServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        User user = userRepository.findByUsername(username)
                .orElseThrow(()->new UsernameNotFoundException("user not found"));

        return UserDetailsImpl.from(user);
    }
}

UserDetailsServiceImpl 는 username 을 받아 유저 DB에서 검색후 UserDetailsImpl 객체를 생성함

WebSecurityConfig.java
다 만든 JwtFilterWebSecurityConfig에 등록

@EnableWebSecurity
@Configuration
@EnableGlobalMethodSecurity(securedEnabled = true)
public class WebSecurityConfig {

    @Autowired
    private JwtFilter jwtFilter;

    @Autowired
    private JwtAuthEntryPoint jwtAuthEntryPoint;

    @Bean
    public BCryptPasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.csrf().disable()
                .cors().disable();

        httpSecurity.authorizeRequests()
                .antMatchers("/auth/**").permitAll()
                .anyRequest()
                .authenticated()

                .and()
                .exceptionHandling()
                .authenticationEntryPoint(jwtAuthEntryPoint)

                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

   
         return httpSecurity.build();
    }

}

JwtAuthEntryPoint.java
인증되지 않은 사용자가 secured 리소스를 요청할 때 트리거됨

@Component
public class JwtAuthEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

SC_UNAUTHORIZED 401 에러를 보낸다

회원가입/로그인에 적용

User 회원가입 / 로그인 로직을 만들어서 적용해보자

@RestController
public class UserController {
    @Autowired
    private final UserService userService;

    @Autowired
    JwtUtil jwtUtil;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping("/auth/signup")
    public ResponseEntity registerUser(@RequestBody SignupForm form){
        return ResponseEntity.ok(userService.registerUser(form));
    }

    @PostMapping("/auth/signin")
    public ResponseEntity loginUser(@RequestBody SigninForm form){
        return ResponseEntity.ok(userService.loginUser(form));
    }

}
@Service
public class UserService {
    @Autowired
    private final UserRepository userRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    @Autowired
    private JwtUtil jwtUtil;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User registerUser(SignupForm form) {
        Optional<User> found = userRepository.findByUsername(form.getUsername());
        if(found.isPresent()){
            throw new IllegalArgumentException("중복중복");
        }

        UserRoleEnum role = UserRoleEnum.USER;

        User user = User.from(form, passwordEncoder.encode(form.getPassword()), role);
        return userRepository.save(user);
    }


    public TokenDto loginUser(SigninForm form) {
        User user = userRepository.findByUsername(form.getUsername())
                .orElseThrow(()->new IllegalArgumentException("유저가 존재하지 않음"));

        //유저가 존재하면
        if(passwordEncoder.matches(form.getPassword(), user.getPassword())){ //패스워드 확인 후 맞으면
                //토큰 발급
                return jwtUtil.generateToken(user.getUsername());
        }

        throw new IllegalArgumentException("패스워드가 다름");
    }
}

기타 사용된 코드

@Getter
@Entity(name = "TUSER")
@NoArgsConstructor
public class User {

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

    @Column(nullable = false, unique = true)
    private String username;

    @Column(nullable = false)
    private String password;

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

    @Column(nullable = false)
    @Enumerated(value = EnumType.STRING)
    private UserRoleEnum role;

    public static User from(SignupForm form, String password, UserRoleEnum role){
        User user = new User();
        user.username = form.getUsername();
        user.password = password;
        user.email = form.getEmail();
        user.role = role;
        return user;
    }

}
@Getter
@Setter
public class SigninForm {
    private String username;
    private String password;
}
@Getter
@Setter
public class SignupForm {
    private String username;
    private String password;
    private String email;
    private String adminToken= "";
}
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}
public enum UserRoleEnum {
    USER,
    ADMIN
    ;
}

결과

@PostMapping("/auth/signup")

@PostMapping("/auth/signin")

기존에 만들어둔 /auth/** 가 아닌 URL로 리퀘스트 보내봄

응답이 잘 오는 것 = 토큰 인증이 잘 된 걸 확인

SecurityContextHolder에 저장된 Authentication 정보를 활용해 현재 로그인한(=인증된 토큰으로 요청한) 유저 정보를 가져와보자!

@RestController
public class UserController {
    @Autowired
    private final UserService userService;

    @Autowired
    JwtUtil jwtUtil;

    public UserController(UserService userService) {
        this.userService = userService;
    }
...
    @GetMapping("/user/search")
    public void searchUser(@AuthenticationPrincipal UserDetailsImpl userDetails){
        System.out.println("username: " +userDetails.getUsername());
        System.out.println("pwd: " +userDetails.getPassword());
    }
}

@AuthenticationPrincipal UserDetailsImpl 로 간단하게 활용이 가능함



현재 로그인된 유저 정보가 잘 뜨는 것을 확인

0개의 댓글