Spring Security

heyhey·2023년 5월 17일
0

프로젝트

목록 보기
8/8

JWT에 대해서 알아보았습니다. 이제 이 JWT를 생성하고 관리하기 위해 spring security 를 적용한 후기를 기록했습니다.
JWT 관련된 포스트 : https://velog.io/@hey-hey/JWT-3ncumsy3

Spring Security

Spring Security는 Java 기반의 웹 애플리케이션과 서비스의 인증(Authentication)과 권한 부여(Authorization)를 처리하는 강력하고 유연한 보안 프레임워크입니다.

여러 장점 중 제가 선택한 이유는 스프링에서 사용하기가 용이하기 때문이었습니다.
스프링 컨테이너에서 의존성 주입, AOP 등의 기능과 결합이 쉬웠던 것이 결정의 큰 이유가 되었습니다.

Spring Security 적용하기

바로 저희 프로젝트에서 적용해 보겠습니다.

spring security 와 jwt와 관련된 의존성 주입을 해줍니다.

build.gradle

// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'

그다음 필요한 파일들을 만드는데, 저는 전부 한 폴더 안에서 만들어 줬습니다.

security 폴더 안의 파일을 하나씩 만들어 보겠습니다.

사용자 구현

그전에 엔티티를 먼저 수정해줍니다.

User

user 에 roles 과 관련된 부분을 추가해줍니다. 이 부분이 권한과 관련된 역할을 합니다.
roles는 Authority 객체의 array 형태입니다.

public class User{
...
  @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
  @Builder.Default // builder를 사용할 때, 기본값으로 설정
  private List<Authority> roles = new ArrayList<>();


  public void setRoles(List<Authority> roles) {
      this.roles = roles;
      roles.forEach(o -> o.setUser(this));
  }
}

Authority

권한과 관련된 엔티티입니다. Id와 name 이 존재합니다.

@Entity
@Getter @Setter
@AllArgsConstructor @NoArgsConstructor @Builder
public class Authority {

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

    private String authorityName;

    @JoinColumn(name = "userPkId")
    @ManyToOne(fetch = FetchType.LAZY)
    @JsonIgnore
    private User user;

}

CustomUserDetails

Spring Security 는 유저 인증과정에서 UserDetails을 참조해서 인증합니다. UserDetails을 상속해 구현합니다.

UserDetails는 인증과 관련된 사용자의 정보를 담고 있는 인터페이스입니다. UserDetails는 Spring Security가 인증 과정에서 사용자 정보를 가져오고 처리하기 위해 필요한 메서드들을 정의하고 있습니다. 이 인터페이스는 다음 역할을 수행합니다.

  • 사용자 정보 제공
  • 인증처리
  • 권한 부여
  • 사용자 정의

바로 UserDetails를 사용해서 동작하게 되지만, User 엔티티를 사용하기 편리하게 하기 위해서 직접 구현하였습니다.

JWT를 이용할 것이기 떄문에 아래 4개의 속성은 return true 로 설정합니다.

public class CustomUserDetails implements UserDetails {
    private final User user;
    public CustomUserDetails(User user) {
        this.user = user;
    }

    public User getUser() {
        return user;
    }
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getRoles().stream().map(o -> new SimpleGrantedAuthority(
                o.getAuthorityName()
        )).collect(Collectors.toList());
    }

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

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

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

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

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

JpaUserDetailsService

UserDetailsService는 Spring Security에서 사용자 정보를 조회하는 역할을 담당하는 인터페이스입니다. 찾은 사용자 정보를 UserDetails 객체로 변환하여 반환합니다.
스프링 시큐리티는 인증 과정에서 UserDetailsService를 호출하여 사용자 정보를 가져오고 인증을 수행할 수 있습니다.
이 인터페이스는 다음 역할을 수행합니다.

  • 사용자 정보 조회
  • UserDetails 객체 반환
  • 사용자 정의 로직 구현
  • 스프링 시큐리티와 통합
@Service
@RequiredArgsConstructor
public class JpaUserDetailsService implements UserDetailsService {
    private final UserRepository userRepository;
    @Override
    public CustomUserDetails loadUserByUsername(String email) throws UsernameNotFoundException{
        User user = userRepository.findByEmail(email).orElseThrow(
                () -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")
        );
        return new CustomUserDetails(user);
    }
}

JWT 설정

JWT 를 생성하고 검증하는 클래스를 생성해줍니다.

JwtProvider

@RequiredArgsConstructor
@Component
public class JwtProvider {

    @Value("${jwt.secret.key}")
    private String salt;

    private Key secretKey;


//    private final long exp = 1000L * 60 * 5 ;// 5분
    private final long exp = 1000L * 60 * 60 * 24;// 1일

    private final JpaUserDetailsService jpaUserDetailsService;

    @PostConstruct
    protected void init() {
        secretKey = Keys.hmacShaKeyFor(salt.getBytes(StandardCharsets.UTF_8));
    }

    public String createToken(User user, List<Authority> roles) {
        Claims claims = Jwts.claims().setSubject(user.getEmail());
        claims.put("user",new UserDto(user)); // user 정보를 담는다.
        claims.put("id",user.getId());
        claims.put("roles", roles);
        Date now = new Date();
        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + exp))
                .signWith(secretKey, SignatureAlgorithm.HS256)
                .compact();
    }
    // 권한정보 획득
    // Spring Security 인증과정에서 권한확인을 위한 기능
    public Authentication getAuthentication(String token) {
        UserDetails userDetails = jpaUserDetailsService.loadUserByUsername(this.getUserEmail(token));
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getUserEmail(String token) {
        return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().getSubject();
    }

    private Integer getUserId(String token) {
        return (Integer) Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().get("id");
    }


    // Authorization Header를 통해 인증을 한다.
    public String resolveToken(HttpServletRequest request) {
        return request.getHeader("Authorization");
    }

    public Long getUserInfo(HttpServletRequest request) {
        String token = resolveToken(request).split(" ")[1].trim();
        // Integer로 넘어오는데 Long으로 넘겨줘야함
        return getUserId(token).longValue();
    }

    public boolean validateToken(String token) {
        try {
            // Bearer 검증
            if (!token.substring(0, "BEARER ".length()).equalsIgnoreCase("BEARER ")) {
                return false;
            } else {
                token = token.split(" ")[1].trim();
            }
            Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
            // 만료되었을 시 false
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            return false;
        }
    }

}
  • exp
    토큰의 유효시간을 설정할 수 있습니다.

  • @Value("${jwt.secret.key}")
    암호 키를 사용하기 위해 application.yml에 등록을 해준 것을 가져옵니다.

application.yml

jwt:
  secret:
    key: F)J@NcRfUjXn2r5sadf2323@#2fda23
  • createToken
    토큰을 만듭니다.
    sub : 주체로 저는 유저의 이메일을 사용했습니다.
    user id => pk id 인데, token 파싱을 통해 유저 id 를 클레임에 등록해줍니다
    혹여나 필요할까봐, user 객체도 넣어줬습니다.

JwtAuthenticationFilter

filter를 적용함으로써 servlet에 도달하기 전에 검증을 완료합니다.

// Jwt가 유효한 토큰인지 인증하기 위한 Filter
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtProvider jwtProvider;

    public JwtAuthenticationFilter(JwtProvider jwtTokenProvider) {
        this.jwtProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String token = jwtProvider.resolveToken(request);

        if (token != null && jwtProvider.validateToken(token)) {
            // check access token
            token = token.split(" ")[1].trim();
            Authentication auth = jwtProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(auth);
        }

        filterChain.doFilter(request, response);
    }
}

SecurityConfig

설정 파일입니다.

@Configuration // @Configuration을 붙여줘야 Bean으로 등록이 된다.
@RequiredArgsConstructor // final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해준다.
@EnableWebSecurity // Spring Security 설정할 클래스라고 정의한다.
public class SecurityConfig {
    private final JwtProvider jwtProvider;

    private static final String[] AUTH_LIST = {
            "/api/auth/**",
            "/swagger-resources/**",
            "/swagger-ui/**",
            "/swagger-ui.html",
            "/v3/api-docs/**",
            "/webjars/**",
            "/files/**"
    };


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // ID, Password 문자열을 Base64로 인코딩하여 전달하는 구조
                .httpBasic().disable()
                // 쿠키 기반이 아닌 JWT 기반이므로 사용하지 않음
                .csrf().disable()
                // CORS 설정
                .cors(c -> {
                            CorsConfigurationSource source = request -> {
                                // Cors 허용 패턴
                                CorsConfiguration config = new CorsConfiguration();
                                config.setAllowedOrigins(
                                        List.of("*")
                                );
                                config.setAllowedMethods(
                                        List.of("*")
                                );
                                return config;
                            };
                            c.configurationSource(source);
                        }
                )
                // Spring Security 세션 정책 : 세션을 생성 및 사용하지 않음
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                // 조건별로 요청 허용/제한 설정
                .authorizeRequests()
                //AUTH_LIST는 모두 승인 ⭐️
                .antMatchers(AUTH_LIST).permitAll()
                // /admin으로 시작하는 요청은 ADMIN 권한이 있는 유저에게만 허용
                .antMatchers("/api/admin/**").hasRole("ADMIN")
                // /user 로 시작하는 요청은 USER 권한이 있는 유저에게만 허용
                .antMatchers("/api/**").hasRole("USER")
                .anyRequest().denyAll()
                .and()
                // JWT 인증 필터 적용
                .addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
                // 에러 핸들링
                .exceptionHandling()
                .accessDeniedHandler(new AccessDeniedHandler() {
                    @Override
                    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
                        // 권한 문제가 발생했을 때 이 부분을 호출한다.
                        response.setStatus(403);
                        response.setCharacterEncoding("utf-8");
                        response.setContentType("text/html; charset=UTF-8");
                        response.getWriter().write("권한이 없는 사용자입니다.");
                    }
                })
                .authenticationEntryPoint(new AuthenticationEntryPoint() {
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
                        // 인증문제가 발생했을 때 이 부분을 호출한다.
                        response.setStatus(401);
                        response.setCharacterEncoding("utf-8");
                        response.setContentType("text/html; charset=UTF-8");
                        response.getWriter().write("인증되지 않은 사용자입니다.");
                    }
                })
                ;


        return http.build();
    }


    @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

}
  • AUTH_LIST
    인증이 없이도 허가되는 페이지 리스트를 정의합니다.
    로그인/회원가입에는 인증이 없어야 하기 때문에 인증 api 부분과,swagger 관련된 페이지, 이미지를 부를 때 필요한 페이지를 추가로 넣어줬습니다.

  • .cors()
    cors 설정 부분입니다. 저는 모든 요청을 허가해줬습니다.

  • .addFilterBefore()

인증을 처리하는 필터로 아까 만든 JwtAuthenticationFilter()를 사용했습니다.

회원가입 & 로그인

AuthController

컨트롤러를 등록해줍니다.

@RestController
@RequiredArgsConstructor
@Slf4j
@Api(value = "유저 인증 API", tags = {"auth"})
@RequestMapping("/api/auth")
public class AuthController {
    private final UserRepository userRepository;
    private final AuthService authService;

    @PostMapping(value="/register")
    @ApiOperation(value = "회원가입")
    public ResponseEntity<Long> register( CreateUserForm request) throws Exception {
        return new ResponseEntity<>(authService.register(request), HttpStatus.OK);
    }
    @PostMapping(value = "/login")
    @ApiOperation(value = "로그인")
    public ResponseEntity<AuthResponse> login(@RequestBody LoginForm request) throws Exception {
        return new ResponseEntity<>(authService.login(request), HttpStatus.OK);
    }

}

AuthService

@Service
@Transactional // JPA의 모든 변경은 트랜잭션 안에서 이루어져야 한다.
@RequiredArgsConstructor // final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해준다.
public class AuthService {
    private final UserRepository userRepository;

    private final UserService userService;
    private final PasswordEncoder passwordEncoder;
    private final JwtProvider jwtProvider;

    public Long register(CreateUserForm createUserForm) throws Exception{
        try{
            User user = new User();
            user.setEmail(createUserForm.getEmail());
            user.setPassword(passwordEncoder.encode(createUserForm.getPassword()));
            user.setNickName(createUserForm.getNickname());
            user.setPhoneNumber(createUserForm.getPhoneNumber());

            MultipartFile file = createUserForm.getProfileImage();
            if (file != null) {
                Image image = new Image(file);
                user.setProfileImage(image);
            }

            user.setRoles(Collections.singletonList(Authority.builder().authorityName("ROLE_USER").build()));
            return userService.saveUser(user);

        }catch (Exception e){
            throw new Exception(e);
        }
    }


    public AuthResponse login(LoginForm request) throws Exception {
        User user = userRepository.findByEmail(request.getEmail()).orElseThrow(
                () -> new BadCredentialsException("사용자를 찾을 수 없습니다.")
        );
        if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
            throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
        }
        return AuthResponse.builder()
                .id(user.getId())
                .email(user.getEmail())
                .nickName(user.getNickName())
                .phoneNumber(user.getPhoneNumber())
                .token(jwtProvider.createToken(user, user.getRoles()))
                .build();
    }

   
    public AuthResponse getUser(String userId) throws Exception {
        User user = userRepository.findByEmail(userId)
                .orElseThrow(() -> new Exception("계정을 찾을 수 없습니다."));
        return new AuthResponse(user);
    }

}

회원가입에서는 패스워드를 설정할 때, 인코더를 통해서 변경해줘야 합니다.
그리고 user에 roles를 설정해주는 것이 권한을 추가해주면서 회원가입의 주 로직입니다.

로그인에서는 유저를 먼저 찾고, 유저와 패스워드가 일치하는지 확인합니다.
passwordEncodermatches() 를 통해서 확인할 수 있습니다.

AuthResponse은 로그인을 하면, 토큰을 주는 객체 입니다.
새로운 token을 (jwtProvider.createToken(user, user.getRoles()))을 이용해서 만들어줍니다.

AuthResponse

@Getter @Setter
@AllArgsConstructor // 모든 필드 값을 파라미터로 받는 생성자를 생성
@NoArgsConstructor // 파라미터가 없는 생성자를 생성
@Builder
public class AuthResponse {

    private Long id;
    private String email;
    private String nickName;
    private String phoneNumber;
    private String token;

    public AuthResponse(User user) {
        this.id = user.getId();
        this.email = user.getEmail();
        this.nickName = user.getNickName();
        this.phoneNumber = user.getPhoneNumber();
    }
}

토큰에서 user id 추출하기

회원 정보를 항상 실어 보내지 않고, 토큰을 통해서 작업을 하기 위해서, 유저 Id를 받아서 사용해보겠습니다. 여기서는 user Update 부분을 통해 예시를 들어보겠습니다.
(서비스 로직은 생략하겠습니다. )

@PutMapping("")
public ResponseEntity<UserDto> updateUser(HttpServletRequest request, UpdateUserForm updateUserForm)throws Exception{
    Long userId = jwtProvider.getUserInfo(request);

    return new ResponseEntity<>(
            userService.updateUser(userId, updateUserForm), HttpStatus.OK
        );
    }

HttpServletRequest request 에 토큰 정보가 실려 있습니다.

JwtProvider 에 getUserInfo() 를 통해서 userId를 가져옵니다.
token을 파싱해서, key 가 id 인 value 를 가져오는 로직을 만들어서 사용하였습니다.

public Long getUserInfo(HttpServletRequest request) {
    String token = resolveToken(request).split(" ")[1].trim();
    // Integer로 넘어오는데 Long으로 넘겨줘야함
    return getUserId(token).longValue();
}
private Integer getUserId(String token) {
    return (Integer) Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().get("id");
}

이제 토큰을 통해 그 유저가 자신의 정보가 맞다면, 수정 삭제가 가능하게끔 하는 로직도 가능해집니다.
시큐리티를 적용해보며 인증과 권한의 중요성에 대해서 경험할 수 있었습니다. 구현하기에는 어려웠지만, 확실히 인증이 추가되었기 때문에 이제 서비스다운 서비스가 되었다 라는 생각이 듭니다.

참고 : https://velog.io/@junho5336/SpringBoot-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-with.-SpringSecurity-JWT

profile
주경야독

0개의 댓글