본문 바로가기
스프링

[Spring Security] + [JWT] 스프링 시큐리티 JWT 로그인[실습]

by DarrenH 2022. 8. 12.
반응형

저번 포스팅에 이어서 이번에는 직접 코드를 작성하도록하겠습니다. 

 

버전 : Spring Boot 2.7.2 

SQL : JPA

IDEA : IntelliJ

 

디렉토리 구조

 

저희가 Spring Security와 JWT를 사용해야하므로 해당 Gradle을 추가해줍니다.

implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
implementation 'io.jsonwebtoken:jjwt:0.9.1'

SpringSecurity 설정을 해주겠습니다. Spring Security를 사용하기위한 환경설정 Class라 생각하시면 될 것 같습니다.

 

스프링 버전에따라 Config작성법이 다른데 2.7 이전 버전에서는 WebSecurityConfigurerAdapter를 상속받지 않는다.(지원X)

public class SecurityConfig extends WebSecurityConfigurerAdapter

참고 자료

https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter

 

Spring Security without the WebSecurityConfigurerAdapter

<p>In Spring Security 5.7.0-M2 we <a href="https://github.com/spring-projects/spring-security/issues/10822">deprecated</a> the <code>WebSecurityConfigurerAdapter</code>, as we encourage users to move towards a component-based security configuration.</p> <p

spring.io

 

위 코드와 같이 WebSecurityComfigurerAdapter를 상속받아 configure메소드를 오버라이딩하여 작성했지만,

현재 실습하는 프로젝트의 Spring Boot 버전은 2.7.2 이므로 지원을 하지않는다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf().disable();
    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 전에 넣는다

    http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

    return http.build();
}

따라서 이런 식으로 SecurityFilterChain을 Bean에 등록하여 환경설정을 해준다. 

 

package com.Security;

import com.Jwt.JwtTokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
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.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

    private final JwtTokenProvider jwtTokenProvider;


    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }


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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable();
        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 전에 넣는다

        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);

        return http.build();
    }

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
                .antMatchers("/h2-console/**")
                .requestMatchers(PathRequest.toStaticResources().atCommonLocations());
    }
}

AuthenticationManager를 Bean에 등록해준다.

 

PasswordEncoder의 경우는 회원가입시 비밀번호를 암호화하기위해 Bean에 등록해주었습니다.

 

그리고 기존에 configure의 내용을 FilterChain메소드에 적어준다.

 

우리는 Header에 토큰을 주입하기 때문에 httpBasic().disable()해준다.

 authorizeRequests() : 요청에 대한 사용권한 체크

 antMatchers("/경로").authenticated() : 해당 경로는 사용권한을 체크한다.

 antMatchers("/경로/**").hasRole("Role") : 해당 Role 권한이있는 사용자만 허용한다.

 antMatchers("/경로").permitAll() : 해당 경로로는 권한체크안한다.

 

※ 본 예제에서는 test, admin, user를 제외한 경로는 권한체크를 안하게 했습니다.

addFilterBefore(커스텀 필터, 기존 필터) : 사용자가 커스텀한 필터를 기존필터 앞에다가 적용시켜준다. 그러면 기존필터 수행전에 커스텀한 필터가 수행된다.

 

JwtAuthenticationFilter를 커스텀하여 작성한다.

 

package com.Security;

import com.Jwt.JwtTokenProvider;
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 {
        //헤더에서 JWT를 받아온다.
        String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);

        //토큰 유효성 검사
        if(token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }
}

 

해당 필터에서 JWT를 이용하는 필터를 만들것이다. 해당 필터를 수행할때 수행할 내용을 doFilter메서드 안에 작성해준다.

request에있는 Token을 가지고 온 뒤, 토큰의 유효성을 검사하는 필터를 작성하였다. 마지막에서는 chain.doFilter(request, response); 를 작성하여 필터를 이어준다.

 

package com.Jwt;

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 = "Darren";

    //유효시간 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에 저장되는 정보단위, PK값
        claims.put("roles", roles);
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims) //정보 저장
                .setIssuedAt(now)  //토큰 발행시간 정보
                .setExpiration(new Date(now.getTime() + tokenValidTime)) //Expire Time
                .signWith(SignatureAlgorithm.HS256, secretKey)  //암호화 알고리즘
                .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();
    }

    //Header에서 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;
        }
    }

}

토큰과 관련된 클래스입니다. 이 클래스에서는 토큰을 생성, 조회, 검사 하는 메서드를 작성했습니다. 

본 예제에서는 Token을 만들때 payload부분에 저희 User테이블의 PK값인 ID를 넣어주었습니다. 

 

package com.Model;

import com.Role.Role;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
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;

@Builder
@Data
@Entity
@NoArgsConstructor
@AllArgsConstructor
public class Users implements UserDetails {

    @Id
    private String id;

    @Column(length = 255)
    private String password;

    @Column(length = 255, nullable = false)
    private String email;

    private String gender;

    @Column(length = 13)
    private String phone;

    @Column(length = 8)
    @Enumerated(EnumType.STRING)
    private Role role;

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

UserDetails를 implemsnts하게 되면 다음 과 같은 메서드를 구현해야합니다.

저희가 [이론]때 스프링 시큐리티 동작방식에 대해 설명드렸는데요 그때 보시면 UserDetails타입으로 반환해야한다고 했는데 그때 사용하는 메서드들입니다.

 

- getUsername() : 사용자의 이름 (본 예제에서는 email을 출력)

- isAccountNonExpired() : 계정 만료 여부 (true 만료 안됨)

- isAccountNonLocked() : 계정 잠김 여부 (true 잠기지 않음)

- isCredentialsNonExpired() : 비밀번호 만료 여부 (true 만료 안됨)

- isEnabled() : 계정 활성화 여부 (true 활성화)

- getAuthorities() : 계정의 권한 목록

 

package com.Service;

import com.Repository.UsersRepository;
import lombok.RequiredArgsConstructor;
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;

@Service
@RequiredArgsConstructor
public class UserService implements UserDetailsService {

    private final UsersRepository usersRepository;

    @Override
    public UserDetails loadUserByUsername(String id) throws UsernameNotFoundException {
        return usersRepository.findById(id)
                .orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
    }

}

UserDetailsService를 implemnets를 하게되면 loadUserByUsername 메서드를 구현해야합니다. 이부분이 저번 포스팅때 UserDetailsService에서 해당 id가 DB에 존재하는지 확인 후 UserDetails로 리턴하는 메서드입니다.

 

package com.Controller;

import com.Jwt.JwtTokenProvider;
import com.Model.Users;
import com.Repository.UsersRepository;
import com.Role.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
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 UsersController {
    @Autowired
    private PasswordEncoder passwordEncoder;

    private final UsersRepository usersRepository;

    private final JwtTokenProvider jwtTokenProvider;

    @PostMapping("/test")
    public String test(){

        return "<h1>test 통과</h1>";
    }

    @PostMapping("/join")
    public String signUp(@RequestBody Map<String, String> user) {
        Role role = Role.from(user.get("role"));
        return usersRepository.save(Users.builder()
                .id(user.get("id"))
                .password(passwordEncoder.encode(user.get("password")))
                .email(user.get("email"))
                .gender(user.get("gender"))
                .phone(user.get("phone"))
                .role(role)
                .roles(Collections.singletonList(role.getValue()))
                .build()).toString();
    }

    @PostMapping("/login")
    public String login(@RequestBody Map<String, String> user) {
        Users userinfo = usersRepository.findByEmail(user.get("email"))
                .orElseThrow(() -> new IllegalArgumentException("가입되지 않은 아이디입니다."));
        if(!passwordEncoder.matches(user.get("password"), userinfo.getPassword())) {
            throw new IllegalArgumentException("잘못된 비밀번호 입니다.");
        }

        return jwtTokenProvider.createToken(userinfo.getId(), userinfo.getRoles());
    }

}

 

아까 SecurityConfig 클래스에서 PasswordEncoder를 Bean에 주입했으므로 @Autowired 를 붙여준다.

/join으로 요청시 Role은 EnumType이므로 user.get("role")은 반환값이 String이므로 from메소드를 이용하여 Role 타입으로 리턴하게끔 해주었습니다.

 

package com.Role;

import com.fasterxml.jackson.annotation.JsonCreator;
import lombok.AllArgsConstructor;
import lombok.Getter;


@AllArgsConstructor
@Getter
public enum Role {
    ADMIN("ROLE_ADMIN"),
    USER("ROLE_USER");

    private String value;

    @JsonCreator
    public static Role from(String s) {
        return Role.valueOf(s.toUpperCase());
    }
}

다음과 같이 작성해주었습니다.

 

 

Repository

package com.Repository;

import com.Model.Users;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UsersRepository extends JpaRepository<Users, String> {
    Optional<Users> findById(String id);
    Optional<Users> findByEmail(String email);
    Page<Users> findAll(Pageable pageable);
}

 

 

Application

package com;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@SpringBootApplication
public class JwtApplication {
    public static void main(String[] args) {
        SpringApplication.run(JwtApplication.class, args);
    }
}

 

application.properties

spring.devtools.livereload.enabled=true
spring.freemarker.cache=false

# DATABASE
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.datasource.url=jdbc:h2:~/local
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=jwt
spring.datasource.password=

# JPA
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.show_sql=true

Test

JwtApplication을 실행하여 서버를 활성화시켜준 뒤 POSTMAN을 이용하여 테스트해보겠습니다.

다음과 같이 API요청을 받으면 회원가입이 되고 암호화도 잘 되고, Role도 정상적으로 Insert된 것을 확인할 수 있습니다.

 

로그인 후 JWT을 리턴받았습니다. 이 토큰을 가지고 로그인을 진행할건데, 프론트측에서 Header에 넣어주지만 저희는 프론트없이 진행하기 때문에 포스트맨을 통해 직접 넣어주겠습니다.

 

이런식으로 Header에 Authorization에 방금 발급받은 JWT를 넣어주면 정상적으로 작동합니다.

 

 

참고 자료 

https://velog.io/@jkijki12/Spirng-Security-Jwt-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0

 

Spirng Security + Jwt 로그인 적용하기

프로젝트를 진행하면서 Spring Security + Jwt를 이용한 로그인을 구현하게 되었다. 목차 Spring Security JWT Spring SEcurity + JWT Spring Security > 가장먼저 스프링 시큐리티에 대해서 알아보자. Sprin

velog.io

 

반응형