[SpringBoot(4)] 스프링 시큐리티 - 인가 설정

배지원·2022년 12월 23일
0

실습

목록 보기
23/24

이전시간에는 회원가입과 로그인을 통해 인증을 구현했다면 이번에는 로그인을 했을때 발행되는 토큰을 통해 해당 유저에게 인가를 해주는 기능을 구현할 것이다.

  • 인가를 설정하기 위해서 기존에 파일에 아래와 같은 파일을 수정하거나 추가했다.
    (1) JwtTokenFilter
    (2) SecurityConfig
    (3) UserEntity
    (4) UserRole
    (5) JwtTokenUtil

1. 동작방식

  • 유저가 로그인을 하게 되면 토큰을 발행해줌 (이전에 실습)
  • SecurityConfig에서 인가가 필요해야지만 접속이 가능한 api 주소를 설정함
  • 유저는 해당 api에 접근하게 되면 Header에 가지고 있는 토큰을 서버에서 자신이 발행해준것인지 확인한 후 인가를 해줌
    (1) Header안에 있는 "Bearer 토큰" 형식을 받아와 개발자만 알고 있는 고유 기본키(SecretKey)를 통해 파싱(암호화된 토큰을 복호화함 이때, 토큰이 고유 기본키로 파싱이 도지 않다던가 Bearer로 시작하지 않다면 자신이 발행해준 토큰이 아니므로 차단해버린다.
    (2) 자신이 발행해준 토큰이 맞다면 복호화한 토큰에서 토큰 시간(발행시간, 유지시간)을 꺼내와 토큰이 만료되었는지 확인함 만약, 만료가 되었다면 재로그인을 해서 토큰을 재발급 받아야함
    (3) 토큰시간까지 정상이라면 토큰에서 userName을 꺼내서 DB에서 해당 userName에 대한 데이터를 가져옴
    (4) 가져온 데이터중 role(권한)을 확인함. 일반회원인지 관리자인지 등등
    (5) 해당 role에 해당하는 api까지 접근을 인가해줌(공지사항같은 곳은 일반회원은 접근하지 못하므로 role에 대한 인가가 필요함)


2. CODE

(1) Configuration

JwtTokenFilter

@RequiredArgsConstructor
@Slf4j
public class JwtTokenFilter extends OncePerRequestFilter {
    // OncePerRequestFilter : 매번 들어갈때마다 토큰을 체크를 하는 클래스

    private final UserService userService;
    private final String secretKey;     // 외부에서 받아오는 키와 개발자의 고유키가 일치하는지 확인하기 위해 secretKey가 필요함

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

// 외부에서 Header에 담겨져 있는 토큰을 받아 작업하는 곳
        final String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);    // 외부에서 header에 인증 AUTHORIZATION 값(Bearer 토큰) 담아 전송하는 것을 받을 수 있다.
        log.info("authorizationHeader:{}",authorizationHeader);

        // 정상적인 토큰이 없는 경우(접근 차단)
        // 만약 Header에 토큰이 없거나 토큰이 Bearer로 시작하지 않다면 null을 반환하여 다음 체인(기능)으로 이동
        if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        // 정상적인 토큰이 있는 경우
        // header에 저장된 token분리
        String token;
        try {           
            token = authorizationHeader.split(" ")[1];        // 토큰값만 추출함(Header 토큰에는 "Bearer 토큰"이 실린다. 즉, split을 통해 띄어쓰기를 기준으로 1번재 배열의 값만 추출하면 토큰값이다)
            log.info("token:{}",token);
        }catch (Exception e){   // 토큰 구조가 이상한 경우
            log.error("token 추출에 실패 했습니다.");
            filterChain.doFilter(request, response);
            return;
        }

        // Token이 만료 되었는지 Check
        // 위에서 Header에서 토큰값만 추출한 값과 고유 비밀키를 넘겨줘 토큰 시간을 체크한다.
        if(JwtTokenUtil.isExpired(token, secretKey)){
            filterChain.doFilter(request, response);
            return;
        };

        // Token에서 Claim에서  UserName꺼내기
        String userName = JwtTokenUtil.getUserName(token,secretKey);    // 외부에서 받은 토큰에서 userName값을 추출함
        log.info("userName:{}",userName);

        // UserDetail 가져오기
        UserEntity user = userService.getUserByUserName(userName);  // 외부에서 받은 토큰에서 추출한 userName값을 통해 DB에서 해당 데이터를 찾는다.
        log.info("userRole :{}",user.getRole());

// 문 열어주는 곳(인가 허용)
        // principal에 이름을 넣어 controller에서 해당 이름을 호출하여 사용함
        // new SimpleGrantedAuthority(user.getRole().name()) = user.getRole().name()가 USER 권한을 가질때 해당 사람에게만 인가를 해주기 위해 명단 설정(만약 등급별로 나눌때는 여러개의 값이 들어갈 수 있으므로 List로 저장함)
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(), null, List.of(new SimpleGrantedAuthority(user.getRole().name()))    );
        // 다음으로 권한을 넘겨주기 위해 details를 설정해준다.
        authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);  // 권한 부여(문 열어줌)
        // doFilter 다음 체인으로 넘어간다
        filterChain.doFilter(request, response);
    }
}
  • 토큰을 받아 인가를 판별하는 클래스이다.
  • 우선 외부에서 Header에 담아 토큰을 보내는 것을 request.getHeader(HttpHeaders.AUTHORIZATION)을 통해 받는다.
  • 이때, 토큰의 유무와 형식을 판별하여 내가 부여한 토큰이 맞는지 확인한다.
  • 그 후 "Bearer 토큰"형식 중 토큰만 split(" ")[1]을 통해 추출한다.
  • 추출한 토큰과 고유 기본키(SecretKey)를 JwtTokenUtil클래스로 넘겨 해당 토큰의 시간을 받아와 만료시간을 체크한다.
  • 위와 같이 추출한 토큰과 고유 기본키를 JwtTokenUtil클래스로 넘겨 해당 토큰의 userName을 받아와 DB에서 해당 userName의 데이터를 가져온다
  • 가져온 데이터중 role(권한)부분을 체크해 해당 권한이 있다면 인가를 해주는 방식으로 진행한다.

SecurityConfig

@EnableWebSecurity  // 스프링 시큐리티를 활성화하는 어노테이션
@Configuration      // 스프링의 기본 설정 정보들의 환경 세팅을 돕는 어노테이션
@RequiredArgsConstructor
// @EnableGlobalMethodSecurity(prePostEnabled = true)  // Controller에서 특정 페이지에 권한이 있는 유저만 접근을 허용할 경우
public class SecurityConfig {

    private final UserService userService;

    @Value("${jwt.token.secret}")
    private String secretKey;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity             // SecurityFilterChain에서 요청에 접근할 수 있어서 인증, 인가 서비스에 사용
                .httpBasic().disable()      // http basic auth 기반으로 로그인 인증창이 뜬다. 기본 인증을 이용하지 않으려면 .disable()을 추가해준다.
                .csrf().disable()       // csrf, api server이용시 .disable (html tag를 통한 공격)
                .cors()   //  다른 도메인의 리소스에 대해 접근이 허용되는지 체크
                .and()  // 묶음 구분(httpBasic(),crsf,cors가 한묶음)
                .authorizeRequests()    // 각 경로 path별 권한 처리
                .antMatchers("/api/v1/users/join", "/api/v1/users/login","/api/v1/hello").permitAll()   // 안에 작성된 경로의 api 요청은 인증 없이 모두 허용한다.
                .antMatchers("/api/v1/**").authenticated()  // 문 만들기(인증이 있어야 접근이 가능한 곳)
                .and()
                .sessionManagement()        // 세션 관리 기능을 작동한다.      .maximunSessions(숫자)로 최대 허용가능 세션 수를 정할수 있다.(-1로 하면 무제한 허용)
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // jwt사용하는 경우 씀(STATELESS는 인증 정보를 서버에 담지 않는다.)
                .and()
                // UserNamePasswordAuthenticationFilter(로그인 필터)적용하기 전에 JWTTokenFilter를 적용 하라는 뜻 입니다.
                // 로그인하기 전에 토큰을 받아 인가를 부여해주기 위한 기능
                .addFilterBefore(new JwtTokenFilter(userService, secretKey), UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}
  • Spring Security를 설정하는 클래스이다.
  • 여기에서는 인가에 대한 설정을 할 수 있다.
.authorizeRequests()    // 각 경로 path별 권한 처리
.antMatchers("/api/v1/users/join", "/api/v1/users/login","/api/v1/hello").permitAll()   // 안에 작성된 경로의 api 요청은 인증 없이 모두 허용한다.
.antMatchers("/api/v1/**").authenticated()  // 문 만들기(인증이 있어야 접근이 가능한 곳)
  • authorizeRequests( )를 통해 인가가 필요한 api주소를 설정할 수 있다.
  • .antMatchers( )를 통해 api주소를 입력하면 되는데 이때
    • .permitAll()은 인가필요없이 모두 접근이 허용이 가능한 것이고
    • .authenticated()은 인가를 해줘야만 접근이 가능한 것이다.

따라서 "/api/v1"으로 시작하는 주소중 join,login,hello을 제외한 모든 곳에서는 인가가 필요한 것이다.


(2) Domain

UserRole

@AllArgsConstructor
@Getter
public enum UserRole {
    ADMIN,USER
}
  • 권한의 종류를 정해두는 클래스로 권한에는 그때그때 데이터가 생기는 것이 아니라 정해둔 틀안에서 지정하는 것이기 때문에 enum을 통해 ADMIN(관리자),USER(일반유저) 2개로 구성하였다.

UserEntity

@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@Entity
@Setter
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@EntityListeners(AuditingEntityListener.class)
public class UserEntity extends BaseTimeEntity{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;            // 키 값
    private String password;    // 비밀번호
    private String userName;    // 유저 아이디

    @Enumerated(EnumType.STRING)    // Enum 클래스인 UserRole의 값을 받아와 저장함
    private UserRole role;        // 권한
}
  • role 변수에 대한 값은 Enum클래스에 대한 데이터를 받아 그대로 데이터를 저장하기 위해 @Enumerated(EnumType.STRING) 어노테이션을 사용한다.

UserJoinRequest

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class UserJoinRequest {
    private String userName;
    private String password;

    // 유저에게 입력받은 값을 UserEntity에 저장함
    public UserEntity toEntity(String pwd){
        UserEntity userEntity = UserEntity.builder()
                .userName(this.userName)
                .password(pwd)
                .role(UserRole.USER)           // 관리자를 제외한 회원은 모두 일반회원이므로 default값으로 저장
                .build();

        return userEntity;
    }
}
  • 유저가 로그인을할때 입력한 데이터를 받아오는 DTO로 해당 DTO를 Entity로 변환할때 role(권한) 부분에서는 enum에 저장된 값을 대입하여 변환한다.

(3) Util

JwtTokenUtil

public class JwtTokenUtil {

//    토큰 고유키값을 여기서 지정해서 할 수 있음. 그렇게 되면 아래 createToken에서 생성자에는 key값을 안받아도 됨
//    @Value("${jwt.token.secret}")
//    private String secretKey;

    // 외부에서 받아온 토큰을 고유 키값으로 데이터를 추출한다.
    private static Claims extractClaims(String token, String key) {
        // 현재 암호화 되어 있는 token을 고유 키값을 가지고 풀어낸다.
        return Jwts.parser().setSigningKey(key).parseClaimsJws(token).getBody();    
    }

    // 추출된 토큰에서 userName 찾기
    public static String getUserName(String token,String secretkey){
        return extractClaims(token, secretkey).get("userName").toString();  // 풀어낸 토큰값중 userName의 값을 반환해줌
    }

    // 추출된 토큰에서 토큰날짜 찾기
    public static boolean isExpired(String token, String secretkey) {
        // expire timestamp를 return함
        Date expiredDate = extractClaims(token, secretkey).getExpiration(); // 풀어낸 토큰값중 날짜만 가져옴
        return expiredDate.before(new Date());      // 받아온 토큰에 있는 날짜가 현재 시간보다 전인지 확인함
    }
    
    public static String createToken(String userName, long expireTimeMs, String key) {
        Claims claims = Jwts.claims(); // 일종의 map
        claims.put("userName", userName);

        return Jwts.builder()       // 토큰 생성
                .setClaims(claims)
                .setIssuedAt(new Date(System.currentTimeMillis()))      //  시작 시간 : 현재 시간기준으로 만들어짐
                .setExpiration(new Date(System.currentTimeMillis() + expireTimeMs))     // 끝나는 시간 : 지금 시간 + 정해둔 시간
                .signWith(SignatureAlgorithm.HS256, key)        //  암호화 알고리즘, secret 값 세팅
                .compact()
                ;
    }
}
  • 토큰에 대한 설정을 설계하는 클래스이며 토큰을 사용하는 기능을 모아둔 클래스이기도 하다.
    extractClaims( ) : Header에서 받아온 토큰을 고유 기본키(SecretKey)를 통해 Parser(추출)한다.
    getUserName( ) : 추출한 토큰에서 userName값을 찾는다
    isExpired( ) : 추출한 토큰에서 토큰 만료시간을 찾아와 현재 시간과 비교하여 True, False를 출력한다.
    createToken( ) : 토큰을 생성한다.
profile
Web Developer

0개의 댓글