20230224-0227 JWT

신래은·2023년 2월 27일
0

회원가입에 대한 간단한 service를 구현한 후, security를 적용시켜 보자.

간단한 회원가입 기능 구현

중복 아이디 방지 등 간단한 회원가입기능을 구현해 준다.

Security 관련 설정

security관련 파일을 넣어주면,build.gradle에 securitiy관련을 설정하지 않아서 오류가 생기게 된다.

Security 파일

// security/config/WebSecurityConfig
package com.greenart.flo_service.security.config;

import com.greenart.flo_service.security.filter.JwtAuthenticationFilter;
import com.greenart.flo_service.security.provider.JwtTokenProvider;
import com.greenart.flo_service.security.vo.PermitSettings;
import lombok.RequiredArgsConstructor;
import org.hibernate.validator.internal.util.stereotypes.Immutable;
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.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;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
    // permitSettings.getPermitAllUrls()에 들어가는 값을 application.yaml로 옮겨 놓음. - 여기서 수정 시 오류 발생 가능성 큼
    private final JwtTokenProvider jwtTokenProvider;
    private final PermitSettings permitSettings;
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//       .cors().and() :  vue를 위해 추가한 부분
        http.cors().and()
                .httpBasic().disable().csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().authorizeHttpRequests()
//                permitSettings의 getPermitAllUrls안에 있는 경로는 모두 권한이 없어도 접근 허가
                .requestMatchers(permitSettings.getPermitAllUrls()).permitAll()
//                .requestMatchers("/api/member/join", "/api/member/login").permitAll()
//                그외의 경로는 권한이 있어야 함
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

//   vue를 위해 추가한 부분
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        final CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedMethod("*");
        corsConfiguration.setAllowCredentials(true);
        corsConfiguration.setAllowedHeaders(List.of("Authorization","Cache-Control","Content-Type"));
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**",corsConfiguration);
        return source;
    }
}
//security/filter/JwtAuthenticationFilter
package com.greenart.flo_service.security.filter;

import com.greenart.flo_service.security.provider.JwtTokenProvider;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;

import java.io.IOException;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilter {
    private final JwtTokenProvider jwtTokenProvider;
    // 오류 상황이 발생했을 때, 어떤 값을 돌려줄 것이냐
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = resolveToken((HttpServletRequest) request);
        if(token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }

    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}
//security/provider/JwtTokenProvider
package com.greenart.flo_service.security.provider;

import com.greenart.flo_service.security.vo.TokenVO;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;

// JWT 생성해주는 것 - 권한정보 생성, 유효성 검사
@Component
public class JwtTokenProvider {
    private final Key key;
    // 토큰의 유효시간 설정 - 10분
    private final Integer tokenExpireMinutes = 60 * 24;
    // 토큰의 유효시간 설정 - 60분
    private final Integer refreshExpireMinutes = 60 * 24 * 7;
    public JwtTokenProvider(@Value("${jwt.secretKey}") String secretKey) {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }
    public TokenVO generateToken(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
        Date expires = new Date((new Date()).getTime()+tokenExpireMinutes*60*1000);
        Date refreshExpires = new Date((new Date()).getTime()+refreshExpireMinutes*60*1000);
        String accessToken = Jwts.builder().setSubject(authentication.getName()).claim("auth", authorities).setExpiration(expires)
                .signWith(key, SignatureAlgorithm.HS256).compact();
        String refreshToken = Jwts.builder().setExpiration(refreshExpires)
                .signWith(key, SignatureAlgorithm.HS256).compact();
        return TokenVO.builder().grantType("Bearer").accessToken(accessToken).refreshToken(refreshToken).build();
    }

    public Authentication getAuthentication(String accessToken) {
        Claims claims = parseClaims(accessToken);
        if (claims.get("auth") == null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }
        Collection<? extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("auth").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());

        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }

    public Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }

    // 토큰 유효성 검사
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (SecurityException | MalformedJwtException e) {
            // 토큰이 등록 되지 않음.
            System.out.println("Invalid JWT Token"+e);
        } catch (ExpiredJwtException e) {
            // 토큰이 유효기간이 지남.
            System.out.println("Expired JWT Token"+e);
        } catch(UnsupportedJwtException e) {
            System.out.println("Unsupported JWT Token"+e);
        } catch(IllegalArgumentException e) {
            System.out.println("JWT claims string is empty."+e);
        }
        return false;
    }
}
//security/service/CustomUserDetailService
package com.greenart.flo_service.security.service;

import com.greenart.flo_service.entity.AdminEntity;
import com.greenart.flo_service.repository.AdminRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

// 서버 내부에 만들어져 있는 유저의 정보를 저장하게 되어 있음 - user detail을 생성해서 
@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService {
    private final AdminRepository adminRepo;
    private final PasswordEncoder passwordEncoder;
    // 서비스가 발생하면서 user detail을 생성하고 저장하게 오버라이드 되어있음
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return createUserDetails(adminRepo.findByAdminId(username));
    }
    public UserDetails createUserDetails(AdminEntity member) {
        return User.builder().username(member.getAdminId())
                .password(passwordEncoder.encode(member.getAdminPwd()))
                .roles(member.getAdminRole())
                .build();
    }
}
//secutiry/vo/PermitSettings
package com.greenart.flo_service.security.vo;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Component
@ConfigurationProperties(prefix = "permission")
@Data
public class PermitSettings {
    String[] permitAllUrls;
}
//security/vo/TokenVO
package com.greenart.flo_service.security.vo;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class TokenVO {
    private String grantType;
    private String accessToken;
    private String refreshToken;
}

build.gradle에서 아래 내용을 입력해 준다.

//	Spring Security Test Implementation
	testImplementation'org.springframework.security:spring-security-test'
//	Spring Security & JWT Implementation
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation "io.jsonwebtoken:jjwt-api:0.11.5"
	implementation "io.jsonwebtoken:jjwt-jackson:0.11.5"
	implementation "io.jsonwebtoken:jjwt-impl:0.11.5"

gradle에 변경사항이 생기면 오른쪽 코끼리모양이 뜬다. 이것을 클릭한다.

gradle에 적용을 한 후에는, application.yaml에 아래 내용을 추가하면 프로그램이 돌아간다.

// application.yaml
jwt:
  secretKey: sfjkaspfoiwefjllkSDFIJWELjnwpoqfkjaklnfvpCVMLERPfwscxklwfvwAJFSLKJEmndlwefj
permission:
  permit-all-urls:
    - /api/member/login
    - /api/member/join

secretKey는 일정길이 이상으로 설정해 주어야 한다.

MemberInfo를 Security 사용가능하게 하려면 MemberInterface를 구현해야한다.
MemberInfoVO가 UserDetails를 상속받도록 설정하고 빨간줄에 커서를 대고, Alt+Shift+Enter를 클릭하면 사진과 같은 창이 뜬다.
모든 항목을 만들어주면, 간단하게 아래 코드와 같이 여러 기능이 생성된다.

// MemberInfoVO
package com.example.security_test.vo.entity;

import com.fasterxml.jackson.annotation.JsonIgnore;
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 java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.HashSet;
import java.util.Set;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class MemberInfoVO implements UserDetails {
    private String mi_seq;
    private String mi_id;
    private String mi_pwd;
    private String mi_name;
    private String mi_nickname;
    private Integer mi_status;
    private LocalDateTime mi_reg_dt;
    private String mi_role;

    @Override
    @JsonIgnore
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Set<GrantedAuthority> roles =  new HashSet<>();
        roles.add(new SimpleGrantedAuthority(this.mi_role));
        return roles;
    }

    @Override
    @JsonIgnore
    public String getPassword() {
        return null;
    }

    @Override
    @JsonIgnore
    public String getUsername() {
        return this.mi_id;
    }

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

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

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

    @Override
    @JsonIgnore
    public boolean isEnabled() {
        return mi_status == 1;
    }
}

로그인 응답 VO를 생성

//LoginResponseVo.java
package com.example.security_test.vo.response;

import com.example.security_test.security.vo.TokenVO;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.el.parser.Token;
import org.springframework.http.HttpStatus;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class LoginResponseVO {
    private Boolean status;
    private String message;
    private TokenVO token;
    private HttpStatus code;

}

토큰을 발급하기 위해서 Service에 아래 항목들을 연결해 준다.

private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
private final CustomUserDetailService customUserDetailService;	

그리고 Service에서 로그인 하는 기능을 구현한다.

package com.greenart.flo_service.security.config;

import com.greenart.flo_service.security.filter.JwtAuthenticationFilter;
import com.greenart.flo_service.security.provider.JwtTokenProvider;
import com.greenart.flo_service.security.vo.PermitSettings;
import lombok.RequiredArgsConstructor;
import org.hibernate.validator.internal.util.stereotypes.Immutable;
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.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;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
    // permitSettings.getPermitAllUrls()에 들어가는 값을 application.yaml로 옮겨 놓음. - 여기서 수정 시 오류 발생 가능성 큼
    private final JwtTokenProvider jwtTokenProvider;
    private final PermitSettings permitSettings;
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//       .cors().and() :  vue를 위해 추가한 부분
        http.cors().and()
                .httpBasic().disable().csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and().authorizeHttpRequests()
//                permitSettings의 getPermitAllUrls안에 있는 경로는 모두 권한이 없어도 접근 허가
                .requestMatchers(permitSettings.getPermitAllUrls()).permitAll()
//                .requestMatchers("/api/member/join", "/api/member/login").permitAll()
//                그외의 경로는 권한이 있어야 함
                .anyRequest().authenticated()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

//   vue를 위해 추가한 부분
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        final CorsConfiguration corsConfiguration = new CorsConfiguration();
        corsConfiguration.addAllowedOrigin("*");
        corsConfiguration.addAllowedMethod("*");
        corsConfiguration.setAllowCredentials(true);
        corsConfiguration.setAllowedHeaders(List.of("Authorization","Cache-Control","Content-Type"));
        final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**",corsConfiguration);
        return source;
    }
}

로그인 시 아래와 같이 토큰이 발급되는 것을 확인 할 수 있다.

이제부터, application.yaml에 등록되지 않은 경로의 요청은 토큰 값을 함께 실어보내지 않는다면 아래와 같이 기능이 제대로 작동하지 않음을 알 수 있다.

Autorization에 BrearerHeader에 token 값을 실어 보내면 값이 출력된다.

아래 두 부분의 위치를 바꿔주면, 특정한 api에만 security가 걸린다.

0개의 댓글