상세 컨텐츠

본문 제목

[스프링 3팀] 13장. 서비스 인증과 권한 부여

23-24/Spring 3

by 숨니 2024. 1. 5. 10:00

본문

728x90

13.1 보안 용어 이해

13.1.1 인증

  • 인증(authentication): 사용자가 누구인지를 확인하는 단계
  • 가장 대표적인 인증의예가 "로그인"
    • 데이터베이스에 등록된 아이디와 패스워드를 사용자가 입력한 아이디와 비밀번호와 비교해 일치 여부 확인
    • 로그인에 성공하면 애플리케이션 서버는 응답으로 사용자에게 토큰(token)을 전달
    • 로그인에 실패한 사용자는 토큰을 전달받지 못해 원하는 리소 스에 접근할 수 없음

 

13.1.2 인가

  • 인가(authorization): 인증을 통해 검증된 사용자가 애플리케이션 내부의 리소스에 접 근할 때 사용자가 해당 리소스에 접근할 권리가 있는지를 확인하는 과정
  • 예) 로그 인한 사용자가 특정 게시판에 접근해서 글을 보려고 하는 경우 게시판 접근 등급을 확인해 접근을 허가 하거나 거부하는 것
  • 일반적으로 사용자가 인증 단계에서 발급받은 토큰은 인가 내용을 포함함
  • 사용자가 리소스에 접근하면서 토큰을 함께 전달하면 애플리케이션 서버는 토큰을 통해 권한 유무 등을 확인해 인가를 수행

 

13.1.3 접근 주체

  • 접근 주체(principal): 애플리케이션의 기능을 사용하는 주체
  • 접근 주체는 사용자가 될 수도 있고, 디바이스, 시스템 등이 될 수도 있음
  • 애플리케이션은 앞서 소개한 인증 과 정을 통해 접근 주체가 신뢰할 수 있는지 확인
  • 인가 과정을 통해 접근 주체에게 부여된 권한을 확인 하는 과정 등을 거침

 

13.2 스프링 시큐리티

  • 스프링 시큐리티는 애플리케이션의 인증, 인가 등의 보안 기능을 제공하는 스프링 하위 프로젝트 중 하나
  • 보안과 관련된 많은 기능을 제공하여 스프링 시큐리티를 활용하면 더욱 편리하게 원하는 기능을 설계

 

13.3 스프링 시큐리티의 동작 구조

  • 스프링 시큐리티는 서블릿 필터를 기반으로 동작

  • 필터체인(FilterChain)은 서블릿 컨테이너에서 관리하는 ApplicationFilterChain을 의미
  • 클라이언트에서 애플리케이션으로 요청을 보내면 서블릿 컨테이너는 URI를 확인해서 필터와 서블릿을 매핑
  • 스프링 시큐리티는 사용하고자 하는 필터체인을 서블릿 컨테이너의 필터 사이에서 동작시키기 위해 Delegatingfilterproxy를 사용

  • DelegatingFilterProxy는 서블릿 컨테이너의 생명주기와 스프링 애플리케이션 컨텍스트(Application Context) 사이에서 다리 역할을 수행하는 필터 구현체
  • 표준 서블릿 필터를 구현하고 있으며, 역할을 위임할 필터체인 프록시(FilterChainProxy)를 내부에 가짐
  • 필터체인 프록시는 스프링 부트의 자동 설정에 의해 자동 생성
  • 필터체인 프록시는 스프링 시큐리티에서 제공하는 필터로서 보안 필터체인(SecurityFilterChain)을 통해 많은 보안 필터(Security Filter)를 사용
  • 필터체인 프록시에서 사용할 수 있는 보안 필터 체인은 List 형식으로 담을 수 있게 설정돼 있어 URI 패턴에 따라 특정 보안필터 체인을 선택해서 사용
  • 보안필터 체인에서 사용하는 필터는 여러 종류가 있으며, 각 필터마다 실행되는 순서가 다름

 

 

 

  1. 클라이언트로부터 요청을 받으면 서블릿 필터에서 SecurityFilterChain으로 작업이 위임되고 그중 UsernamePas swordAuthenticationFilter(위 그림에서 AuthenticationFilter에 해당)에서 인증을 처리
  2. AuthenticationFilter는 요청 객체(HttpservletRequest)에서 username과 password를 추출해서 토큰을 생성
  3. 그러고 나서 AuthenticationManager에게 토큰을 전달합니다. Authenticationlanager는 인터페이스이며, 일반적으로 사용되는 구현체는 ProviderManager
  4. ProviderManager는 인증을 위해 AuthenticationProvider로 토큰을 전달
  5. AuthenticationProvider는 토큰의 정보를 UserDetailsService에 전달
  6. UserDetailsservice는 전달받은 정보를 통해 데이터베이스에서 일치하는 사용자를 찾아 UserDetails 객체를 생성
  7. 생성된 UserDetails 객체는 AuthenticationProvider로 전달되며, 해당 Provider에서 인증을 수행하고 성공하게 되면 ProviderManager로 권한을 담은 토큰을 전달
  8. ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달
  9. AuthenticationFilter는 검증된 토큰을 SecurityContextHolder에 있는 SecurityContext에 저장

위 과정에서 사용된 UsernamePasswordAuthenticationFilter는 접근 권한을 확인하고 인증이 실패할 경 우 로그인 폼이라는 화면을 보내는 역할을 수행합니다. 이 책에서 실습 중인 프로젝트는 화면이 없는 RESTful 애플리케이션이기 때문에 다른 필터에서 인증 및 인가 처리를 수행해야 합니다. 이 책에서는 JWT 토큰을 사용해 인증을 수행할 예정이라 JWT와 관련된 필터를 생성하고 usernamePasswordAuthentic ationFilter 앞에 배치해서 먼저 인증을 수행할 수 있게 설정하겠습니다.

 

 

스프링 시큐리티에 대한 자세한 내용은 공식 문서를 참고하기 바랍니다.

 https://docs.spring.io/spring-security/site/docs/5.5.3/reference/html5/

 

13.4 JWT

  • JWT: JSON Web Token, 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰
  • JWT는 URL로 이용할 수 있는 문자열로만 구성돼 있으며, 디지털 서명이 적용돼 있어 신뢰도가 높음
  • JWT는 주로 서버와의 통신에서 권한 인가를 위해 사용
  • URL에서 사용할 수 있는 문자열로만 구성돼 있기 때문에 HTTP 구성요소 어디든 위치

13.4.1 JWT의 구조

JWT는 점(.)으로 구분된 아래의 세 부분으로 구성됩니다.

  • 헤더(Header)
  • 내용(Payload)
  • 서명(Signature)

 

 

13.4.1.1 헤더

  • JWT의 헤더는 검증과 관련된 내용을 담음
  • 헤더에는 alg와 typ 속성 두 가지 정보를 가지고 있음
{
    "alg": "H2256"
    "typ": "JWT"
}
  • alg 속성에서는 해싱 알고리즘을 지정
    • 해싱 알고리즘은 보통 SHA256 또는 RSA를 사용
    • 토큰을 검증할 때 사용되는 서명 부분에서 사용됩
    • HS256은 HMAC SHA256 알고리즘을 사용한다는 의미
  • typ 속성에는 토큰의 타입을 지정
  • 이렇게 완성된 헤더는 Base64Url 형식으로 인코딩돼 사용

 

13.4.1.2 내용

  • JWT의 내용에는 토큰에 담는 정보를 포함합니다. 이곳에 포함된 속성들은 클레임(Claim)이라 하며, 크게 세 가지로 분류됩니다.
    • 등록된 클레임(Registered Claims)
    • 공개 클레임(Public Claims)
    • 비공개 클레임(Private Claims)
  • 등록된 클레임은 필수는 아니지만 토큰에 대한 정보를 담기 위해 이미 이름이 정해져 있는 클레임을 뜻 함
    • iss: JWT의 발급자(ssuer) 주체를 나타냅니다. iss의 값은 문자열이나 URI를 포함하는 대소문자를 구분하는 문자 열입니다.
    • Sub: JWT의 제목(Subject)입니다.
    • aud: JWT의 수신인(Audience)입니다. JWT를 처리하려는 각 주체는 해당 값으로 자신을 식별해야 합니다. 요청을 처리하는 주체가 'aud' 값으로 자신을 식별하지 않으면 JWT는 거부됩니다.
    • exp: JWT의 만료시간(Expiration)입니다. 시간은 NumericDate 형식으로 지정해야 합니다.
    • nbf: Not Before 를 의미합니다.
    • iat: JWT가 발급된 시간(ssued at)입니다.
    • jti: JWT의 식별자(JWT ID)입니다. 주로 중복 처리를 방지하기 위해 사용됩니다.
  • 공개 클레임은 키 값을 마음대로 정의할 수 있습니다. 다만 충돌이 발생하지 않을 이름으로 설정
  • 비공개 클레임은 통신 간에 상호 합의되고 등록된 클레임과 공개된 클레임이 아닌 클레임을 의미
{
    "sub": "wikibooks payload",
    "exp": "1603076498",
    "userId": "wikibooks",
    "username": "flature"
}

 

 

13.4.1.3 서명

  • JWT의 서명 부분은 인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생성
  • 예를 들어, HMAC SHA256 알고리즘을 사용해서 서명을 생성
  • 서명은 토큰의 값들을 포함해서 암호화하기 때문에 메시지가 도중에 변경되지 않았는지 확인할 때 사용 됩니다.
HMACSHA256(
    base64UrlEncode(headr) + "." +
    base64UrlEncode(payload),
    secret
)

 

 

13.4.2 JWT 디버거 사용하기

  • 이 화면은 Encoded와 Decoded로 나눠져 있음
  • 양측의 내용이 일치하는지 사이트에서 확인할 수도 있고 Decoded의 내용을 변경하면 Encoded의 콘텐츠가 자동으로 반영

 

13.5 스프링 시큐리티와 JWT 적용

  • groupld: com.springboot
  • artifacild: security
  • name: security
  • Developer Tools: Lombok, Spring Configuration Processor
  • Web: Spring Web
  • SQL: Spring Data JPA, MariaDB Driver
        <dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-security</artifactId>
		</dependency>
		<dependency>
			<groupId>io.jsonwebtoken</groupId>
			<artifactId>jjwt</artifactId>
			<version>0.9.1</version>
		</dependency>

 

  • 인증과 인가 코드를 작성하기 위한 의존성 구성

 

 

13.5.1 UserDetails와 UserDetailsService 구현

@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table
public class User implements UserDetails {

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

    @Column(nullable = false, unique = true)
    private String uid;

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String name;

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

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public String getUsername() {
        return this.uid;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
    
    @JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
    @Override
    public boolean isEnabled() {
        return true;
    }
}
  • UserDetail 인터페이스를 구현
    • 입력된 로그인 정보를 가지고 데이터베이스에서 사용자 정보를 가져오는 역할을 수행
  • 각 메세드의 용도 정리
    • getAuthorities(): 계정이 가지고 있는 권한 목록을 리턴합니다.
    • getPassword(): 계정의 비밀번호를 리턴합니다.
    • getusername(): 계정의 이름을 리턴합니다. 일반적으로 아이디를 리턴합니다.
    • isACcountNonExpired(): 계정이 만료됐는지 리턴합니다. true는 만료되지 않았다는 의미입니다.
    • isACcountNonLocked(): 계정이 잠겨있는지 리턴합니다. true는 잠기지 않았다는 의미입니다.
    • isCredentialNonExpired(): 비밀번호가 만료됐는지 리턴합니다. true는 만료되지 않았다는 의미입니다.
    • isEnabled(): 계정이 활성화돼 있는지 리턴합니다. true는 활성화 상태를 의미입니다.

 

public interface UserRepository extends JpaRepository<User, Long> {
    User getByUid(String uid);
}
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final Logger LOGGER = LoggerFactory.getLogger(UserDetailsServiceImpl.class);

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) {
        LOGGER.info("[loadUserByUsername] loadUserByUsername 수행. username : {}", username);
        return userRepository.getByUid(username);
    }

}

 

  • UserDetailsService 인터페이스를 구현
    • loadUserByUsername() 메서드를 구현함
  • UserDetails 스프링 시큐리티에서 제공하는 개념
    • UserDetail을 통해 사용자를 구분하는 id로 사용
    • UserDetails의 구현체로 User 엔티티를 생성했기에 User 객체를 구현

 

13.5.2 JwtTokenProvider 구현

  • JWT 토큰을 생성하는데 필요한 정보를 UserDetails에서 가져오도록 JWT 토큰을 생성하는 JwtTokenProvider를 생성
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {

    private final Logger LOGGER = LoggerFactory.getLogger(JwtTokenProvider.class);
    private final UserDetailsService userDetailsService;

    @Value("${springboot.jwt.secret}")
    private String secretKey = "secretKey";
    private final long tokenValidMillisecond = 1000L * 60 * 60;

    @PostConstruct
    protected void init() {
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
        secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
    }

    public String createToken(String userUid, List<String> roles) {
        LOGGER.info("[createToken] 토큰 생성 시작");
        Claims claims = Jwts.claims().setSubject(userUid);
        claims.put("roles", roles);
        Date now = new Date();

        String token = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidMillisecond))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        LOGGER.info("[createToken] 토큰 생성 완료");
        return token;
    }

    public Authentication getAuthentication(String token) {
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");
        UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails UserName: {}", userDetails.getUsername());
        return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
    }

    public String getUsername(String token) {
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
        String info = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, inof: {}", info);
        return info;
    }

    public String resolveToken(HttpServletRequest request) {
        LOGGER.info("[resolveToken] HTTP 헤더에서 Token 값 추출");
        return request.getHeader("X-AUTH-TOKEN");
    }

    public boolean validateToken(String token) {
        LOGGER.info("[validateToken] 토큰 유효 체크 시작");
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
            LOGGER.info("[validateToken] 토큰 유효 체크 완료");
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
            return false;
        }
    }
}
  • secretKey 가 필요하므로 정의

 

    @PostConstruct
    protected void init() {
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 시작");
        secretKey = Base64.getEncoder()
            .encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
        LOGGER.info("[init] JwtTokenProvider 내 secretKey 초기화 완료");
    }
  • @PostConstruct : 해당 객체가 빈 객체로 주입된 이후 수행되는 메서드
    • JwtTokenProvider 클래스에서는 @Component가 지정되어 애플리케이션이 가동되면 빈으로 자동 주입
    • PostConstruct 가 지정되어 있는 init() 메서드가 자동으로 실행
    • init  메서드에서는 secretKey를 Base64 형식으로 인코딩

 

    public String createToken(String userUid, List<String> roles) {
        LOGGER.info("[createToken] 토큰 생성 시작");
        Claims claims = Jwts.claims().setSubject(userUid);
        claims.put("roles", roles);
        Date now = new Date();

        String token = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidMillisecond))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        LOGGER.info("[createToken] 토큰 생성 완료");
        return token;
    }
  • JWT 토큰의 내용에 값을 넣기 위해 CLains 객체를 생성
  • setSubject() 메서드를 통 해 Sub 속성에 값을 추가하려면 User의 uid 값을 사용
  • 해당 토큰을 사용하는 사용자 의 권한을 확인할 수 있는 role 값을 별개로 추가
  • Jwts. builder()를 사 용해 토큰을 생성

 

    public Authentication getAuthentication(String token) {
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 시작");
        UserDetails userDetails = userDetailsService
            .loadUserByUsername(this.getUsername(token));
        LOGGER.info("[getAuthentication] 토큰 인증 정보 조회 완료, UserDetails UserName: {}"
            , userDetails.getUsername());
        return new UsernamePasswordAuthenticationToken(userDetails, "",
            userDetails.getAuthorities());
    }
  • 필터에서 인증이 성공했을 때 SecurityContextHolder에 저장할 Authentication을 생성하는 역할
  • Authentication을 구현하는 편한 방법은 UsernamePasswordAuthenticationToken을 사용 

usernamePasswordAuthenticationToken의 구조

  • UsernamePasswordAuthenticationToken은 AbstractAuthenticationToken을 상속받
    • Abstract AuthenticationToken은 Authentication 인터페이스의 구현
  • 토큰 클래스를 사용하려면 초기화를 위한 UserDetails가 필요
  • Jwts.parser()를 통해 secretKey를 설정하고 클레임을 추출해서 토큰을 생성할 때 넣었던 sub 값을 추출

 

    public String getUsername(String token) {
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출");
        String info = Jwts.parser().setSigningKey(secretKey)
            .parseClaimsJws(token).getBody().getSubject();
        LOGGER.info("[getUsername] 토큰 기반 회원 구별 정보 추출 완료, inof: {}", info);
        return info;
    }
  • HttpservletRequest를 파라미터로 받아 헤더 값으로 전달된 'X-AUTH-TOKEN' 값을 가져와 리턴
  • 클라이언트가 헤더를 통해 애플리케이션 서버로 JWT 토큰 값을 전달해야 정상적인 추출이 가능
  • 헤더의 이름은 임의로 변경 가능

 

    public boolean validateToken(String token) {
        LOGGER.info("[validateToken] 토큰 유효 체크 시작");
        try {
            Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey)
                .parseClaimsJws(token);
            LOGGER.info("[validateToken] 토큰 유효 체크 완료");
            return !claims.getBody().getExpiration().before(new Date());
        } catch (Exception e) {
            LOGGER.info("[validateToken] 토큰 유효 체크 예외 발생");
            return false;
        }
    }
  • 토큰을 전달받아 클레임의 유효기간을 체크하고 boolean 타입의 값을 리턴

 

13.5.3 JwtAuthenticationFilter 구현

  • JwtAuthenticationFilter는 JWT 토큰으로 인증하고 SecurityContextHolder에 추가하는 필터 설정 클래스
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final Logger LOGGER = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
    private final JwtTokenProvider jwtTokenProvider;

    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest servletRequest,
        HttpServletResponse servletResponse,
        FilterChain filterChain) throws ServletException, IOException {
        String token = jwtTokenProvider.resolveToken(servletRequest);
        LOGGER.info("[doFilterInternal] token 값 추출 완료. token : {}", token);

        LOGGER.info("[doFilterInternal] token 값 유효성 체크 시작");
        if (token != null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            LOGGER.info("[doFilterInternal] token 값 유효성 체크 완료");
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }
}
  • OncePerRequestFilte: 필터를 상속받아 사용하는 방법으로 구현
    • 대표적으로 많이 사용되는 상속 객체는 GenericFilterBean과 0ncePerRequestFilter
  • OncePerRequestFilte로부터 오버라이딩한 doFilterInternal() 메서드
  • doFilter() 메서드를 기준준으로 앞에 작성한 코드는 서블릿이 실행되기 전에 실행되고, 뒤에 작성한 코드는 서블릿이 실행된 후 에 실행됨
  • 메서드의 내부 로직을 보면 1wtTokenProvider를 통해 servletRequest에서 토큰을 추출하고, 토큰에 대한 유효성을 검사
  • 토큰이 유효하다면 Authentication 객체를 생성해서 SecurityContextHolder에 추가 하는 작업을 수행

 

13.5.4 SecurityConfiguration 구현

  • 실습을 통해 스프링 시큐리티를 적용하기 위한 컴포넌트를 구현
  • 스프링 시큐리티와 관련된 설정: webSecurityConfigureAdapter를 상속받는 Configuration 클래스를 구현하는 것
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    private final JwtTokenProvider jwtTokenProvider;

    @Autowired
    public SecurityConfiguration(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        httpSecurity.httpBasic().disable()

                .csrf().disable()

                .sessionManagement()
                .sessionCreationPolicy(
                        SessionCreationPolicy.STATELESS) 

                .and()
                .authorizeRequests() 
                .antMatchers("/sign-api/sign-in", "/sign-api/sign-up",
                        "/sign-api/exception").permitAll()
                .antMatchers(HttpMethod.GET, "/product/**").permitAll() 

                .antMatchers("**exception**").permitAll()

                .anyRequest().hasRole("ADMIN") 

                .and()
                .exceptionHandling()
                    .accessDeniedHandler(new CustomAccessDeniedHandler())
                .and()
                .exceptionHandling()
                    .authenticationEntryPoint(new CustomAuthenticationEntryPoint())

                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
                        UsernamePasswordAuthenticationFilter.class); 
    }

    @Override
    public void configure(WebSecurity webSecurity) {
        webSecurity.ignoring().antMatchers("/v2/api-docs", "/swagger-resources/**",
                "/swagger-ui.html", "/webjars/**", "/swagger/**",
                "/sign-api/exception");
    }
}

 

WebSecurityConfigurerAdapter 는 현재 deprecated 되었습니다. 공식문서(https://spring.io/blog/2022/02/21/spring-security-without-the-websecurityconfigureradapter/)를 참고하여 내용을 변경할 수 있습니다.

* configure 메서드에 대한 내용 생략

 

13.5.5 커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현

  • 스프링 시큐리티를 적용하기 위한 컴포넌트를 구현을 마침
  • 스프링 시큐리티와 관련된 설정을 진행
    • 스프링 시큐리티를 설정하는 대표적인 방법은 webSecurityConfigureAdapter를 상속받는 Configuration 클래스를 구현하는 것
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private final Logger LOGGER = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
        AccessDeniedException exception) throws IOException {
        LOGGER.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
        response.sendRedirect("/sign-api/exception");
    }
}
  • AccessDeniedException은 액세스 권한이 없는 리소스에 접근할 경우 발생하는 예외로 처리
  • 이 예외를 처리 하기 위해 AccessDeniedHandler 인터페이스가 사용, SecurityConfiguration에도 exceptionHandling() 메서드를 통해 추가
  • AccessDeniedHandler의 구현 클래스인 (ustomAccessDeniedHandler 클래스는 handle() 메서드를 오버라이딩
    • HttpServletRequest와 HttpservletResponse, AccessDeniedException을 파라미터로 가져옵니다.
  • 이번 예제에서는 response에서 리다이렉트하는 sendRedirect() 메서드를 활용하는 방식으로 구현

 

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final Logger LOGGER = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class);

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException ex) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        LOGGER.info("[commence] 인증 실패로 response.sendError 발생");

        EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
        entryPointErrorResponse.setMsg("인증이 실패하였습니다.");

        response.setStatus(401);
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().write(objectMapper.writeValueAsString(entryPointErrorResponse));
    }
}
  • 인증이 실패한 상황을 처리하기 위한 AuthenticationEntryPoint 인터체이스를 구현한 클래스

 

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {

    private String msg;

}

 

  • 클래스 구조는 앞에서 본 AccessDeniedHandler와 크게 다르지 않으며, Commence() 메서드를 오버라이딩해서 코드를 구현
  • comence() 메서드는 HttpServletRequest, HttpservletResponse, AuthenticationException을 매개변수로 받음
  • 예외 처리를 위해 리다이렉트가 아니라 직접 Response를 생성해서 클라이언트에게 응답하는 방식으로 구현
  • 컨트롤러에서는 응답을 위한 설정들이 자동으로 구현되기 때문에 별도의 작업이 필요하지 않았지만 여기서는 응답값을 설정할 필요가 있음
    • 메시지를 담기 위해 EntryPointErrorResponse 객체를 사용해 메시지 설정
    • response에 상태 코드(status)와 콘텐츠 타입(Content-type) 등을 설정한 후 0bjectMapper를 사용해 EntryPointErrorResponse 객체를 바디 값으로 파싱

 

13.5.6 회원가입과 로그인 구현

  • 인증에 사용되는 UserDetails 인터페이스의 구현체 클래스로 User 엔티티를 생성
  • User 객체를 생성하기 위해 회원가입을 구현하고 user 객체로 인증을 시도하는 로그인을 구현
  • 회원가입과 로그인의 도메인은 Sign으로 통합해서 표현할 예정
  • 각각 Sign-up, Sign-in으로 구 분해서 기능을 구현

 

먼저 서비스 레이어를 구현

public interface SignService {

    SignUpResultDto signUp(String id, String password, String name, String role);

    SignInResultDto signIn(String id, String password) throws RuntimeException;

}

 

 

SignService 인터페이스를 구현한 SignServiceImpl 클래스의 전체 코드

@Service
public class SignServiceImpl implements SignService {

    private final Logger LOGGER = LoggerFactory.getLogger(SignServiceImpl.class);

    public UserRepository userRepository;
    public JwtTokenProvider jwtTokenProvider;
    public PasswordEncoder passwordEncoder;

    @Autowired
    public SignServiceImpl(UserRepository userRepository, JwtTokenProvider jwtTokenProvider,
                           PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.jwtTokenProvider = jwtTokenProvider;
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public SignUpResultDto signUp(String id, String password, String name, String role) {
        LOGGER.info("[getSignUpResult] 회원 가입 정보 전달");
        User user;
        if (role.equalsIgnoreCase("admin")) {
            user = User.builder()
                    .uid(id)
                    .name(name)
                    .password(passwordEncoder.encode(password))
                    .roles(Collections.singletonList("ROLE_ADMIN"))
                    .build();
        } else {
            user = User.builder()
                    .uid(id)
                    .name(name)
                    .password(passwordEncoder.encode(password))
                    .roles(Collections.singletonList("ROLE_USER"))
                    .build();
        }

        User savedUser = userRepository.save(user);
        SignUpResultDto signUpResultDto = new SignInResultDto();

        LOGGER.info("[getSignUpResult] userEntity 값이 들어왔는지 확인 후 결과값 주입");
        if (!savedUser.getName().isEmpty()) {
            LOGGER.info("[getSignUpResult] 정상 처리 완료");
            setSuccessResult(signUpResultDto);
        } else {
            LOGGER.info("[getSignUpResult] 실패 처리 완료");
            setFailResult(signUpResultDto);
        }
        return signUpResultDto;
    }

    @Override
    public SignInResultDto signIn(String id, String password) throws RuntimeException {
        LOGGER.info("[getSignInResult] signDataHandler 로 회원 정보 요청");
        User user = userRepository.getByUid(id);
        LOGGER.info("[getSignInResult] Id : {}", id);

        LOGGER.info("[getSignInResult] 패스워드 비교 수행");
        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new RuntimeException();
        }
        LOGGER.info("[getSignInResult] 패스워드 일치");

        LOGGER.info("[getSignInResult] SignInResultDto 객체 생성");
        SignInResultDto signInResultDto = SignInResultDto.builder()
                .token(jwtTokenProvider.createToken(String.valueOf(user.getUid()),
                        user.getRoles()))
                .build();

        LOGGER.info("[getSignInResult] SignInResultDto 객체에 값 주입");
        setSuccessResult(signInResultDto);

        return signInResultDto;
    }

    private void setSuccessResult(SignUpResultDto result) {
        result.setSuccess(true);
        result.setCode(CommonResponse.SUCCESS.getCode());
        result.setMsg(CommonResponse.SUCCESS.getMsg());
    }

    private void setFailResult(SignUpResultDto result) {
        result.setSuccess(false);
        result.setCode(CommonResponse.FAIL.getCode());
        result.setMsg(CommonResponse.FAIL.getMsg());
    }
}

 

  • 회원가입과 로그인을 구현하기 위해 세 가지 객체에 대한 의존성 주입
  • 회원가입을 구현합니다. 현재 애플리케이션에서는 ADMIN과 USER로 권한을 구분
  • signup() 메서드는 그에 맞게 전달받은 role 객체를 확인해 User 엔티티의 roles 변수에 추가해서 엔티티를 생성
  • 패스워드는 암호화해서 저장해야 하기 때문에 PasswordEncoder를 활용해 인코딩을 수행

 

PasswordEncoder는 Configuration 클래스를 생성하고 @Bean 객체로 등록하도록 구현

@Configuration
public class PasswordEncoderConfiguration {

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

}
  • 빈 객체를 등록하기 위해서 생성된 클래스이기
    • SecurityConfiguration 클래스 같은 이미 생성된 @Configuration 클래스 내부에 passwordEncoder () 메서드를 정의해도 충분
  • 이렇게 생성된 엔티티를 UserRepository를 통해 저장
  • 실제 엔터프라이즈 환경에서는 회원가입을 위한 필드도 많고 코드도 복잡하겠지만 이 책에서는 부가적인 사항들은 모두 배제하고 회원가입 자체만 구현
  • 이제 회원으로 가입한 사용자의 아이디와 패스워드를 가지고 로그인을 수행
  • 로그인은 미리 저장돼 있는 계정 정보와 요청을 통해 전달된 계정 정보가 일치하는지 확인하는 작업
  • signIn() 메서드는 아이디와 패스워드를 입력받아 처리
  1. 1d를 기반으로 UserRepository에서 User 엔티티를 가져옵니다.
  2. PasswordEncoder를 사용해 데이터베이스에 저장돼 있던 패스워드와 입력받은 패스워드가 일치하는지 확인하는 작업을 수행합니다. 이번 예제에서는 패스워드가 일치하지 않아 예외를 발생시키는 데 RuntimeException을 사용했지 만 별도의 커스텀 예외를 만들어서 사용하기도 합니다.
  3. 패스워드가 일치해서 인증을 통과하면 JwtTokenProvider를 통해 id와 role 값을 전달해서 토큰을 생성한 후
    Response에 담아 전달합니다.
  • 결과 데이터를 설정하는 메서드
  • 회원가입과 로그인 메서드에서 사용 할 수 있게 설정돼 있으며, 각 메서드는 DTO를 전달받아 값을 설정
    • CommonResponse를 새로 작성

 

public enum CommonResponse {

    SUCCESS(0, "Success"), FAIL(-1, "Fail");

    int code;
    String msg;

    CommonResponse(int code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public int getCode() {
        return code;
    }

    public String getMsg() {
        return msg;
    }

}

 

 

  • 회원가입과 로그인을 API로 노출하는 컨트롤러를 생성
  • 서비스 레이어로 요청을 전달하고 응답하는 역할만 하기에 코드만 소개
@RestController
@RequestMapping("/sign-api")
public class SignController {

    private final Logger LOGGER = LoggerFactory.getLogger(SignController.class);
    private final SignService signService;

    @Autowired
    public SignController(SignService signService) {
        this.signService = signService;
    }

    @PostMapping(value = "/sign-in")
    public SignInResultDto signIn(
        @ApiParam(value = "ID", required = true) @RequestParam String id,
        @ApiParam(value = "Password", required = true) @RequestParam String password)
        throws RuntimeException {
        LOGGER.info("[signIn] 로그인을 시도하고 있습니다. id : {}, pw : ****", id);
        SignInResultDto signInResultDto = signService.signIn(id, password);

        if (signInResultDto.getCode() == 0) {
            LOGGER.info("[signIn] 정상적으로 로그인되었습니다. id : {}, token : {}", id,
                signInResultDto.getToken());
        }
        return signInResultDto;
    }

    @PostMapping(value = "/sign-up")
    public SignUpResultDto signUp(
        @ApiParam(value = "ID", required = true) @RequestParam String id,
        @ApiParam(value = "비밀번호", required = true) @RequestParam String password,
        @ApiParam(value = "이름", required = true) @RequestParam String name,
        @ApiParam(value = "권한", required = true) @RequestParam String role) {
        LOGGER.info("[signUp] 회원가입을 수행합니다. id : {}, password : ****,
            name : {}, role : {}", id, name, role);
        SignUpResultDto signUpResultDto = signService.signUp(id, password, name, role);

        LOGGER.info("[signUp] 회원가입을 완료했습니다. id : {}", id);
        return signUpResultDto;
    }

    @GetMapping(value = "/exception")
    public void exceptionTest() throws RuntimeException {
        throw new RuntimeException("접근이 금지되었습니다.");
    }

    @ExceptionHandler(value = RuntimeException.class)
    public ResponseEntity<Map<String, String>> ExceptionHandler(RuntimeException e) {
        HttpHeaders responseHeaders = new HttpHeaders();
        //responseHeaders.add(HttpHeaders.CONTENT_TYPE, "application/json");
        HttpStatus httpStatus = HttpStatus.BAD_REQUEST;

        LOGGER.error("ExceptionHandler 호출, {}, {}", e.getCause(), e.getMessage());

        Map<String, String> map = new HashMap<>();
        map.put("error type", httpStatus.getReasonPhrase());
        map.put("code", "400");
        map.put("message", "에러 발생");

        return new ResponseEntity<>(map, responseHeaders, httpStatus);
    }

}

 

Response로 전달되는 SignUpResultDto와 SignInResultDto 클래스 소개

@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignUpResultDto {

    private boolean success;

    private int code;

    private String msg;

}
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class SignInResultDto extends SignUpResultDto {

    private String token;

    @Builder
    public SignInResultDto(boolean success, int code, String msg, String token) {
        super(success, code, msg);
        this.token = token;
    }

}

 

 

13.5.7 스프링 시큐리티 테스트

클라이언트의 입장이 되어 스프링 시큐리티가 동작하는 상황에서 테스트를 수행

 

13.5.7.1 정상적인 동작 시나리오

가. 회원가입

  1. 회원가입에 성공한다.
  2. 회원가입에 성공한 계정 정보를 기반으로 로그인을 성공한다.
    A. 로그인에 성공하면서 토큰을 발급받는다.
  3. 상품 컨트롤러의 상품 등록 AP를 호출한다.
    A. API 호출 시 로그인 과정에서 받은 토큰을 헤더에 추가해서 전달한다.
  4. 정상적으로 상품 등록을 마친다

 

 

나. 로그인

  • 응답으로 받은 토큰값을 헤더에 넣어 사용가능

 

다. 상품 등록 API를 통해 상품 등록

  • 상품이 정상적으로 등록되었는지를 확인하려면 상품 조회 API를 이용해 확인
  • 조회 기능은 별도의 토큰이 필요하지 않음

 

13.5.7.2 비정상적인 동작 시나리오 - 인증 예외 발생

  • 스프링 시큐리티의 동작을 확인하는 시나리오에서 비정상적인 동작은 크게 두 가지로 구분
    1. 바로 인증이 실패한 경우
    2. 인가가 실패한 경우

 

가. 인증 과정에서 예외가 발생하는 상황

  1. 회원가입에 성공한다.
  2. 회원가입에 성공한 계정 정보를 기반으로 로그인에 성공한다.
    A. 로그인에 성공하면서 토큰을 발급받는다.
  3. 상품 컨트롤러의 상품 등록 AP를 호출한다.
    A. API 호출 시 로그인 과정에서 받은 토큰을 변조해서 헤더에 추가한 후 전달한다.
  4. 인증 예외 메시지가 응답으로 돌아온다.

 

나. 상품 등록 API를 사용하는 과정에서 토큰의 값을 변조해서 전달하는 과정을 통해 인증 예외가 발생하는지 확인하는 과정

  • 인증에 실패했기 때문에 ustomAuthenticationEntryPoint에 구현한 예외 상황에 대한 메시지가 담긴 응답이 애플리케이션에서 생성되고 클라이언트에게 전달됨

 

13.5.7.3 비정상적인 동작 시나리오 - 인가 예외 발생

  1. 회원가입에 성공한다.
  2. 회원가입에 성공한 계정 정보를 기반으로 로그인에 성공한다.
    A. 로그인에 성공하면서 토큰을 발급받는다.
  3. 상품 컨트롤러의 상품 등록 API를 호출한다.
    A. API 호출 시 로그인 과정에서 받은 토큰을 헤더에 추가한 후 전달한다.
  4. 인가 예외 발생으로 /exception으로 리다이렉트 후 예외 메시지가 응답으로 돌아온다.

 

Quiz

  1. ( 인증 )는  사용자가 누구인지를 확인하는 단계로 로그인이 대표적인 사례이다.
  2. 인증을 통해 검증된 사용자가 애플리케이션 내부의 리소스에 접 근할 때 사용자가 해당 리소스에 접근할 권리가 있는지를 확인하는 과정은 ( 인가 )라고 한다.
  3. ( JWT )는 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰을 의미하며, 헤더, 내용, 서명 세 부분으로 나뉘어져 있습니다.
  4. ( JWT 디버거 )를 사용하면 인코드와 디코드로 나누어 쉽겨 변환할 수 있다.
  5. WebSecurityConfigureAdapter는 현재 deprecated  되었기 때문에 개발자가 직접 커스텀할 설정들을 ( @Bean )으로 등록하여 사용하면 됩니다.
  6. ( UserDetail ) 인터페이스를 구현하여 입력된 로그인 정보들로 데이터베이스에서 사용자 정보 가져오는 역할을 수행합니다.
  7. @PostConstruct가 있는 경우 ( init ) 메서드가 자동 실행됩니다.
  8. JWT 토큰에 값을 넣기 위해 clains 객체를 생성하고 토큰을 생성하는 코드를 작성하시오.
    public String createToken(String userUid, List<String> roles) {
        LOGGER.info("[createToken] 토큰 생성 시작");
        // 코드 작성

        // 코드 이어서 작성
        String token = 

        LOGGER.info("[createToken] 토큰 생성 완료");
        return token;
    }

 

9. 액세스 권한이 없는 리소스에 접근하는 경우 예외로 처리하기 위해 AccessDeniedHandler를 구현한 CustomAccessDeniedHandler 클래스를 작성하시오.

 

 

 

8. 

    public String createToken(String userUid, List<String> roles) {
        LOGGER.info("[createToken] 토큰 생성 시작");
        Claims claims = Jwts.claims().setSubject(userUid);
        claims.put("roles", roles);
        Date now = new Date();

        String token = Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(new Date(now.getTime() + tokenValidMillisecond))
                .signWith(SignatureAlgorithm.HS256, secretKey)
                .compact();

        LOGGER.info("[createToken] 토큰 생성 완료");
        return token;
    }

9.

@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    private final Logger LOGGER = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
        AccessDeniedException exception) throws IOException {
        LOGGER.info("[handle] 접근이 막혔을 경우 경로 리다이렉트");
        response.sendRedirect("/sign-api/exception");
    }
}

[출처] 장정우, 스프링 부트 핵심 가이드』, 위키북스(2022), p.159-207.
 

ⓒ 다라

 

728x90

관련글 더보기