10. Spring/Security

03. 스프링 시큐리티 (Spring Security) - JWT

THE HEYDAZE 2020. 8. 18. 15:33
OS Windows 10 Home 64bit 버전 1903 (OS 빌드 18362.836)
FrameWork SpringBoot 2.3.1.RELEASE
Security spring-boot-starter-security
EditTool IntelliJ IDEA 2019.1.3
BuildTool Gradle

# 참고한 사이트
 

SPRING SECURITY + JWT 회원가입, 로그인 기능 구현

이전에 서블릿 보안과 관련된 포스트(링크)를 작성했던 적이 있습니다. 서블릿 기반의 웹 애플리케이션에서 인증과 인가 과정을 간단하게 설명했습니다. 스프링에서는 마찬가지로 이런 인증과

webfirewood.tistory.com

 

 

[SpringBoot] Spring Security를 이용한 회원가입/로그인/로그아웃

이번 글에서는 Spring Security를 이용하여 회원가입 및 로그인을 구현해보도록 하겠습니다. 전체 코드는 깃헙을 참고하시길 바랍니다. 개발환경 IntelliJ 2019.02 Java 11 SpringBoot 2.1.9 Gradle 5.6 라이브..

victorydntmd.tistory.com

 

[Access Defined, Forbidden 핸들링]

 

Spring ControllerAdvice and authentication/authorization exception handling

In my Spring Boot appliation I have a following web security config: @Configuration @EnableWebSecurity public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter { @Override

stackoverflow.com

 

의존성
implementation 'org.springframework.boot:spring-boot-starter-security' // Spring Security

implementation 'io.jsonwebtoken:jjwt-api:0.10.7' // JWT
runtime 'io.jsonwebtoken:jjwt-impl:0.10.7' // JWT
runtime 'io.jsonwebtoken:jjwt-jackson:0.10.7' // JWT

 

시큐리티 (Security)
@EnableWebSecurity
@RequiredArgsConstructor
public class Security extends WebSecurityConfigurerAdapter {

	// @RequiredArgsConstructor 로 인해 @Autowired 의존성 주입 됨
    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() // rest api이므로 csrf 보안이 필요없으므로 disable처리.
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // jwt token으로 인증할것이므로 세션필요없으므로 생성안함.
                .and()
                .authorizeRequests() // 다음 리퀘스트에 대한 사용권한 체크
                .antMatchers("/v1/sign-in", "/v1/sign-up").permitAll() // 가입 및 인증 주소는 누구나 접근가능
                .antMatchers(HttpMethod.GET, "/exception/**", "helloworld/**", "/error").permitAll() // hellowworld로 시작하는 GET요청 리소스는 누구나 접근가능
                .anyRequest().hasRole(MemberType.BRONZE.name()) // 그외 나머지 요청은 모두 인증된 회원만 접근 가능
//                .anyRequest().hasRole("BRONZE") // 대체 가능 - 검사할 때 ROLE_BRONZE 로 검사됨
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); // UsernamePasswordAuthenticationFilter 전에 JwtAuthenticationFilter 삽입
    }
}

 

JWT 토큰 생성 (JwtTokenProvider)
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

    /** 보호키 */
    private String secretKey = "12345678901234567890123456789012";

    /** 토큰 지속 시간 mill * sec * min * hour * day */
    private long tokenValidTime = 1000L * 60 * 30;

    /** Security UserDetailsService */
    private final UserDetailsService userDetailsService;

	// 객체 초기화
    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
        System.err.println(String.format("secretKey = %s", secretKey));
    }


    // JWT 토큰 생성
    public String createToken(String userPk, List<MemberType> 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> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }
}

 

스프링 커스텀 필터 (JwtAuthenticationFilter)
@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);
    }
}

 

커스텀 서비스 (CustomUserDetailService) - 필터에서 처리할 서비스
@RequiredArgsConstructor
@Service
    public class CustomUserDetailService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String memberId) throws UsernameNotFoundException {
        return memberRepository.findByMemberId(memberId).orElseThrow(() -> new UsernameNotFoundException(memberId));
//        return memberRepository.findByMemberId(memberId).orElseThrow(() -> new MemberNotFoundException(memberId));
    }
}

 

회원 Entity
@Entity
@NoArgsConstructor
@Getter
@Setter
@AllArgsConstructor
@Builder
@EqualsAndHashCode(of = "memberId")
@ToString
public class Member implements UserDetails {

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

    @Size(max = 20, message = "0~20 글자사이만 가능합니다")
    @Column(name = "member_id", nullable = false, length = 20, unique = true)
    private String memberId;

    @Column(name = "password", nullable = false, length = 255)
    private String password;

    @Column(name = "point", nullable = false)
    private Long point;

    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private List<MemberType> roles = new ArrayList<>();

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

    @Override
    public String getUsername() {
        return memberId;
    }

    @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
public enum MemberType implements GrantedAuthority {
    BRONZE("ROLE_BRONZE", 0L),
    SILVER("ROLE_SILVER", 25L),
    GOLD("ROLE_GOLD", 50L);


    @Getter
    private final String type;

    @Getter
    private final Long level;

    @Override
    public String getAuthority() {
        return type;
    }
}

 

회원 Dto
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class MemberLoginRequestDto {

    private String memberId;

    private String password;

}

 

API 컨트롤러 (MemberController)
@RestController
@RequestMapping("/v1")
@RequiredArgsConstructor
@Slf4j
public class MemberController {

    private final MemberService memberService;

    /** 회원가입 */
    @PostMapping("/sign-up")
    public ResponseEntity<Member>  register (@RequestBody MemberLoginRequestDto resources) throws URISyntaxException{

        log.info("resources -> {}", resources);

        Member member = memberService.register(resources);

        log.info("member -> {}", member);

        URI url = new URI(String.format("/members/%s", member.getId()));

        return ResponseEntity.created(url).body(member);
    }

    /** 로그인 */
    @PostMapping("/sign-in")
    public String login(@RequestBody MemberLoginRequestDto memberLoginRequestDto) {
        return memberService.login(memberLoginRequestDto);
    }

    /** 조회 */
    @GetMapping("/members/{id}")
    public Member details(@PathVariable Long id) {
        return memberService.detail(id);
    }

}

 

서비스 (MemberService)
@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    private final JwtTokenProvider jwtTokenProvider;

    private final PasswordEncoder passwordEncoder;


    public Member detail(Long id) {
        return memberRepository.findById(id).orElseThrow(() ->  new IllegalArgumentException(""+id));
//        return memberRepository.findById(id).orElseThrow(() ->  new MemberNotFoundException(id));
    }

    public Member register(MemberLoginRequestDto resources) {
        Boolean existed = memberRepository.existsByMemberId(resources.getMemberId());

        if (existed) {
            throw new IllegalArgumentException(resources.getMemberId());
//            throw new MemberIdDuplicateException(resources.getMemberId());
        }

        return memberRepository.save(Member.builder()
                .memberId(resources.getMemberId())
                .password(passwordEncoder.encode(resources.getPassword()))
                .point(0L)
                .roles(Collections.singletonList(MemberType.BRONZE))
        .build());
    }

    public String login(MemberLoginRequestDto memberLoginRequestDto) {
        Member member = memberRepository.findByMemberId(memberLoginRequestDto.getMemberId()).orElseThrow(() ->
                    new IllegalArgumentException(memberLoginRequestDto.getMemberId())
//                    new MemberNotFoundException(memberLoginRequestDto.getMemberId())
                );

        return jwtTokenProvider.createToken(member.getMemberId(), member.getRoles());

    }
}

 

레파지토리 (MemberRepository)
public interface MemberRepository extends JpaRepository<Member, Long> {

    Boolean existsByMemberId(String memberId);

    Optional<Member> findByMemberId(String memberId);

}

 

요청 (request)
# sign

POST http://localhost:8080/v1/sign-up
Content-Type: application/json

{
  "memberId": "root",
  "password": "root"
}

###

# login

POST http://localhost:8080/v1/sign-in
Content-Type: application/json

{
  "memberId": "root",
  "password": "root"
}

###

# detail
GET http://localhost:8080/v1/members/1
X-AUTH-TOKEN: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJyb290Iiwicm9sZXMiOlsiQlJPTlpFIl0sImlhdCI6MTU5NzY2MzIxNywiZXhwIjoxNTk3NjY1MDE3fQ.5mA8bNgtt-jVHvXeLGoFXtTEtPc0SJFZ4Ksn98mZeYg

###

 

응답 (response)

 

register

 

login

 

detail

 

 

@Secured 메소드 권한 제어

 

@RequiredArgsConstructor
@Component
public class JwtTokenProvider {

    /** 보호키 */
    private String secretKey = "12345678901234567890123456789012";

    /** 토큰 지속 시간 mill * sec * min * hour * day */
    private long tokenValidTime = 1000L * 60 * 30;

    /** Security UserDetailsService */
    private final UserDetailsService userDetailsService;

    @PostConstruct
    protected void init() {
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
        System.err.println(String.format("secretKey = %s", secretKey));
    }


    // JWT 토큰 생성 - CREATE
    public String createToken(String userPk, Set<MemberType> 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 토큰 생성 - UPDATE
    public String createToken(Member member) {
        Claims claims = Jwts.claims().setSubject(member.getMemberId());
        claims.put("roles", member.getRoles());
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidTime))
                .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();
    }

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

 

@Entity
@NoArgsConstructor
@Getter
@Setter
@AllArgsConstructor
@Builder
@EqualsAndHashCode(of = "memberId")
@ToString
public class Member implements UserDetails {

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

    @Size(max = 20, message = "0~20 글자사이만 가능합니다")
    @Column(name = "member_id", nullable = false, length = 20, unique = true)
    private String memberId;

    @Column(name = "password", nullable = false, length = 255)
    private String password;

    @Column(name = "point", nullable = false)
    private Long point;

    @ElementCollection(fetch = FetchType.EAGER)
    @Builder.Default
    private Set<MemberType> roles = new HashSet<>();

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

    @Override
    public String getUsername() {
        return memberId;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }

    public void addRoles(Set<MemberType> roles) {
        this.roles.addAll(roles);
    }
}

 

@RestController
@RequestMapping("/v1")
@RequiredArgsConstructor
@Slf4j
public class MemberController {

    private final MemberService memberService;



	// ... 생략
    
    
    
    
    
    /** 수정 */
    @PatchMapping("/members/{id}")
    public String update(@RequestBody Member resources, @PathVariable Long id) {
        log.info("resources -> {}", resources);
        log.info("id -> {}", id);

        return memberService.update(resources, id);
    }
}

 

@Service
@RequiredArgsConstructor
@EnableGlobalMethodSecurity(securedEnabled = true) // @Secured 어노테이션 적용
public class MemberService {

    private final MemberRepository memberRepository;

    private final JwtTokenProvider jwtTokenProvider;

    private final PasswordEncoder passwordEncoder;

    @Secured("ROLE_GOLD") // ROLE_GOLD 권한만 가능
    public Member detail(Long id) {
        return memberRepository.findById(id).orElseThrow(() ->  new MemberNotFoundException(id));
    }

    public List<Member> list() {
        return memberRepository.findAll();
    }

    public Member create(Member resources) {

        Boolean existed = memberRepository.existsByMemberId(resources.getMemberId());

        if (existed) {
            throw new MemberIdDuplicateException(resources.getMemberId());
        }

        return memberRepository.save(Member.builder()
                .memberId(resources.getMemberId())
                .password(passwordEncoder.encode(resources.getPassword()))
                .roles(Collections.singleton(MemberType.BRONZE))
                .point(0L)
                .build());
    }
    
    
    // ... 생략
    
    
    public String update(Member resources, Long id) {

        Member member = memberRepository.findById(id).orElseThrow(() ->
            new MemberNotFoundException(resources.getMemberId())
        );

        member.setPoint(resources.getPoint());

        member.addRoles(resources.getRoles()); // 권한 추가

        return jwtTokenProvider.createToken(memberRepository.save(member));

    }
    
}

 

이미지 클릭

 

위 코드에서 기존에 List<MemberType> 이 였던 것을 Set<MemberType> 으로 바꿨습니다.

멤버타입은 중복을 가질 수 없기 때문

 

위 코드에서 멤버를 확인하는 작업 코드는 제거바람 

필터에서 이미 DB 조회를 했기 때문에 메소드에서도 굳이 확인 작업할 필요가 없기 때문

 

큰 분류 권한 : 익명유저, 유저, 관리자

작은 분류 : 브론즈, 실버, 골드, 자기 자신

처리를 할 필요가 있음

 

 

모바일 접근

아이피 주소, 접근 브라우저, 시간 처리하기

비지니스 로직 세부화 하기

세부 권한별로 접근 제한 하기

카카오톡 처럼 원하는 리다이렉트 설정해보기