보안 용어 이해
인증
- 인증(authentication): 사용자가 누구인지를 확인하는 단계
- 가장 대표적인 인증의예가 "로그인"
- 데이터베이스에 등록된 아이디와 패스워드를 사용자가 입력한 아이디와 비밀번호와 비교해 일치 여부 확인
- 로그인에 성공하면 애플리케이션 서버는 응답으로 사용자에게 토큰(token)을 전달
- 로그인에 실패한 사용자는 토큰을 전달받지 못해 원하는 리소 스에 접근할 수 없음
인가
- 인가(authorization): 인증을 통해 검증된 사용자가 애플리케이션 내부의 리소스에 접 근할 때 사용자가 해당 리소스에 접근할 권리가 있는지를 확인하는 과정
- 예) 로그 인한 사용자가 특정 게시판에 접근해서 글을 보려고 하는 경우 게시판 접근 등급을 확인해 접근을 허가 하거나 거부하는 것
- 일반적으로 사용자가 인증 단계에서 발급받은 토큰은 인가 내용을 포함함
- 사용자가 리소스에 접근하면서 토큰을 함께 전달하면 애플리케이션 서버는 토큰을 통해 권한 유무 등을 확인해 인가를 수행
접근 주체
- 접근 주체(principal): 애플리케이션의 기능을 사용하는 주체
- 접근 주체는 사용자가 될 수도 있고, 디바이스, 시스템 등이 될 수도 있음
- 애플리케이션은 앞서 소개한 인증 과 정을 통해 접근 주체가 신뢰할 수 있는지 확인
- 인가 과정을 통해 접근 주체에게 부여된 권한을 확인 하는 과정 등을 거침
스프링 시큐리티
- 스프링 시큐리티는 애플리케이션의 인증, 인가 등의 보안 기능을 제공하는 스프링 하위 프로젝트 중 하나
- 보안과 관련된 많은 기능을 제공하여 스프링 시큐리티를 활용하면 더욱 편리하게 원하는 기능을 설계
스프링 시큐리티의 동작 구조
- 스프링 시큐리티는 서블릿 필터를 기반으로 동작
- 필터체인(FilterChain)은 서블릿 컨테이너에서 관리하는 ApplicationFilterChain을 의미
- 클라이언트에서 애플리케이션으로 요청을 보내면 서블릿 컨테이너는 URI를 확인해서 필터와 서블릿을 매핑
- 스프링 시큐리티는 사용하고자 하는 필터체인을 서블릿 컨테이너의 필터 사이에서 동작시키기 위해 Delegatingfilterproxy를 사용
- DelegatingFilterProxy는 서블릿 컨테이너의 생명주기와 스프링 애플리케이션 컨텍스트(Application Context) 사이에서 다리 역할을 수행하는 필터 구현체
- 표준 서블릿 필터를 구현하고 있으며, 역할을 위임할 필터체인 프록시(FilterChainProxy)를 내부에 가짐
- 필터체인 프록시는 스프링 부트의 자동 설정에 의해 자동 생성
- 필터체인 프록시는 스프링 시큐리티에서 제공하는 필터로서 보안 필터체인(SecurityFilterChain)을 통해 많은 보안 필터(Security Filter)를 사용
- 필터체인 프록시에서 사용할 수 있는 보안 필터 체인은 List 형식으로 담을 수 있게 설정돼 있어 URI 패턴에 따라 특정 보안필터 체인을 선택해서 사용
- 보안필터 체인에서 사용하는 필터는 여러 종류가 있으며, 각 필터마다 실행되는 순서가 다름
- 클라이언트로부터 요청을 받으면 서블릿 필터에서 SecurityFilterChain으로 작업이 위임되고 그중 UsernamePas swordAuthenticationFilter(위 그림에서 AuthenticationFilter에 해당)에서 인증을 처리
- AuthenticationFilter는 요청 객체(HttpservletRequest)에서 username과 password를 추출해서 토큰을 생성
- 그러고 나서 AuthenticationManager에게 토큰을 전달합니다. Authenticationlanager는 인터페이스이며, 일반적으로 사용되는 구현체는 ProviderManager
- ProviderManager는 인증을 위해 AuthenticationProvider로 토큰을 전달
- AuthenticationProvider는 토큰의 정보를 UserDetailsService에 전달
- UserDetailsservice는 전달받은 정보를 통해 데이터베이스에서 일치하는 사용자를 찾아 UserDetails 객체를 생성
- 생성된 UserDetails 객체는 AuthenticationProvider로 전달되며, 해당 Provider에서 인증을 수행하고 성공하게 되면 ProviderManager로 권한을 담은 토큰을 전달
- ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달
- AuthenticationFilter는 검증된 토큰을 SecurityContextHolder에 있는 SecurityContext에 저장
위 과정에서 사용된 UsernamePasswordAuthenticationFilter는 접근 권한을 확인하고 인증이 실패할 경 우 로그인 폼이라는 화면을 보내는 역할을 수행합니다. 이 책에서 실습 중인 프로젝트는 화면이 없는 RESTful 애플리케이션이기 때문에 다른 필터에서 인증 및 인가 처리를 수행해야 합니다. 이 책에서는 JWT 토큰을 사용해 인증을 수행할 예정이라 JWT와 관련된 필터를 생성하고 usernamePasswordAuthentic ationFilter 앞에 배치해서 먼저 인증을 수행할 수 있게 설정하겠습니다.
스프링 시큐리티에 대한 자세한 내용은 공식 문서를 참고하기 바랍니다.
• https://docs.spring.io/spring-security/site/docs/5.5.3/reference/html5/
JWT
- JWT: JSON Web Token, 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰
- JWT는 URL로 이용할 수 있는 문자열로만 구성돼 있으며, 디지털 서명이 적용돼 있어 신뢰도가 높음
- JWT는 주로 서버와의 통신에서 권한 인가를 위해 사용
- URL에서 사용할 수 있는 문자열로만 구성돼 있기 때문에 HTTP 구성요소 어디든 위치
JWT의 구조
JWT는 점(.)으로 구분된 아래의 세 부분으로 구성됩니다.
- 헤더(Header)
- 내용(Payload)
- 서명(Signature)
헤더
- JWT의 헤더는 검증과 관련된 내용을 담음
- 헤더에는 alg와 typ 속성 두 가지 정보를 가지고 있음
{
"alg": "H2256"
"typ": "JWT"
}
- alg 속성에서는 해싱 알고리즘을 지정
- 해싱 알고리즘은 보통 SHA256 또는 RSA를 사용
- 토큰을 검증할 때 사용되는 서명 부분에서 사용됩
- HS256은 HMAC SHA256 알고리즘을 사용한다는 의미
- typ 속성에는 토큰의 타입을 지정
- 이렇게 완성된 헤더는 Base64Url 형식으로 인코딩돼 사용
내용
- 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"
}
서명
- JWT의 서명 부분은 인코딩된 헤더, 인코딩된 내용, 비밀키, 헤더의 알고리즘 속성값을 가져와 생성
- 예를 들어, HMAC SHA256 알고리즘을 사용해서 서명을 생성
- 서명은 토큰의 값들을 포함해서 암호화하기 때문에 메시지가 도중에 변경되지 않았는지 확인할 때 사용 됩니다.
HMACSHA256(
base64UrlEncode(headr) + "." +
base64UrlEncode(payload),
secret
)
JWT 디버거 사용하기
- JWT 공식 사이트(https://wt.io/#debugger-io)에서는 더욱 쉽게 JWT를 생성 가능
- 이 화면은 Encoded와 Decoded로 나눠져 있음
- 양측의 내용이 일치하는지 사이트에서 확인할 수도 있고 Decoded의 내용을 변경하면 Encoded의 콘텐츠가 자동으로 반영
스프링 시큐리티와 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>
- 인증과 인가 코드를 작성하기 위한 의존성 구성
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 객체를 구현
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은 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 타입의 값을 리턴
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에 추가 하는 작업을 수행
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 메서드에 대한 내용 생략
커스텀 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 객체를 바디 값으로 파싱
회원가입과 로그인 구현
- 인증에 사용되는 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() 메서드는 아이디와 패스워드를 입력받아 처리
- 1d를 기반으로 UserRepository에서 User 엔티티를 가져옵니다.
- PasswordEncoder를 사용해 데이터베이스에 저장돼 있던 패스워드와 입력받은 패스워드가 일치하는지 확인하는 작업을 수행합니다. 이번 예제에서는 패스워드가 일치하지 않아 예외를 발생시키는 데 RuntimeException을 사용했지 만 별도의 커스텀 예외를 만들어서 사용하기도 합니다.
- 패스워드가 일치해서 인증을 통과하면 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;
}
}
스프링 시큐리티 테스트
클라이언트의 입장이 되어 스프링 시큐리티가 동작하는 상황에서 테스트를 수행
정상적인 동작 시나리오
가. 회원가입
- 회원가입에 성공한다.
- 회원가입에 성공한 계정 정보를 기반으로 로그인을 성공한다.
A. 로그인에 성공하면서 토큰을 발급받는다. - 상품 컨트롤러의 상품 등록 AP를 호출한다.
A. API 호출 시 로그인 과정에서 받은 토큰을 헤더에 추가해서 전달한다. - 정상적으로 상품 등록을 마친다
나. 로그인
- 응답으로 받은 토큰값을 헤더에 넣어 사용가능
다. 상품 등록 API를 통해 상품 등록
- 상품이 정상적으로 등록되었는지를 확인하려면 상품 조회 API를 이용해 확인
- 조회 기능은 별도의 토큰이 필요하지 않음
비정상적인 동작 시나리오 - 인증 예외 발생
- 스프링 시큐리티의 동작을 확인하는 시나리오에서 비정상적인 동작은 크게 두 가지로 구분
- 바로 인증이 실패한 경우
- 인가가 실패한 경우
가. 인증 과정에서 예외가 발생하는 상황
- 회원가입에 성공한다.
- 회원가입에 성공한 계정 정보를 기반으로 로그인에 성공한다.
A. 로그인에 성공하면서 토큰을 발급받는다. - 상품 컨트롤러의 상품 등록 AP를 호출한다.
A. API 호출 시 로그인 과정에서 받은 토큰을 변조해서 헤더에 추가한 후 전달한다. - 인증 예외 메시지가 응답으로 돌아온다.
나. 상품 등록 API를 사용하는 과정에서 토큰의 값을 변조해서 전달하는 과정을 통해 인증 예외가 발생하는지 확인하는 과정
- 인증에 실패했기 때문에 ustomAuthenticationEntryPoint에 구현한 예외 상황에 대한 메시지가 담긴 응답이 애플리케이션에서 생성되고 클라이언트에게 전달됨
비정상적인 동작 시나리오 - 인가 예외 발생
- 회원가입에 성공한다.
- 회원가입에 성공한 계정 정보를 기반으로 로그인에 성공한다.
A. 로그인에 성공하면서 토큰을 발급받는다. - 상품 컨트롤러의 상품 등록 API를 호출한다.
A. API 호출 시 로그인 과정에서 받은 토큰을 헤더에 추가한 후 전달한다. - 인가 예외 발생으로 /exception으로 리다이렉트 후 예외 메시지가 응답으로 돌아온다.
Quiz
- ( 인증 )는 사용자가 누구인지를 확인하는 단계로 로그인이 대표적인 사례이다.
- 인증을 통해 검증된 사용자가 애플리케이션 내부의 리소스에 접 근할 때 사용자가 해당 리소스에 접근할 권리가 있는지를 확인하는 과정은 ( 인가 )라고 한다.
- ( JWT )는 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰을 의미하며, 헤더, 내용, 서명 세 부분으로 나뉘어져 있습니다.
- ( JWT 디버거 )를 사용하면 인코드와 디코드로 나누어 쉽겨 변환할 수 있다.
- WebSecurityConfigureAdapter는 현재 deprecated 되었기 때문에 개발자가 직접 커스텀할 설정들을 ( @Bean )으로 등록하여 사용하면 됩니다.
- ( UserDetail ) 인터페이스를 구현하여 입력된 로그인 정보들로 데이터베이스에서 사용자 정보 가져오는 역할을 수행합니다.
- @PostConstruct가 있는 경우 ( init ) 메서드가 자동 실행됩니다.
- 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.
'프레임워크 > Spring' 카테고리의 다른 글
연관관계 매핑 (1) | 2023.11.26 |
---|---|
ORM의 개념과 JPA (0) | 2023.10.30 |
JPA와 스프링 데이터 JPA (0) | 2023.10.03 |