13.1.1 인증(authentication)
인증(authentication)은 사용자가 누구인지 확인하는 단계를 의미한다. (ex. 로그인 한 사용자의 아이디 및 패스워드가 db에 등록된 아이디 및 패스워드가 일치 여부 확인, 일치하면 토큰(token)을 전달한다.)
13.1.2 인가(authorization)
인가(authorization)은 인증을 통해 검증된 사용자가 애플리케이션 내부의 리소스에 접근할 때 사용자가 해당 리소스에 접근할 권리가 있는지를 확인하는 과정을 의미한다. 보통 토큰이 인가 내용을 포함하고 있다.
13.1.3 접근 주체(principal)
접근 주체(principal)은 애플리케이션의 기능을 사용하는 주체를 의미한다.(ex. 사용자, 디바이스, 시스템 등)
스프링 시큐리티는 애플리케이션의 인증, 인가 등의 보안 기능을 제공하는 스프링 하위 프로젝트 중 하나이다.
스프링 시큐리티는 서블릿 필터(Servlet Filter)를 기반으로 동작한다.
스프링 시큐리티는 보안 필터를 사용하기 위해 ApplicationFilterChain에 있는 DelegatingFilterProxy를 사용한다. DelegatingFilterProxy는 내부에 FilterChainProxy를 가지고 있으며, FilterChainProxy는 스프링 부트의 자동 설정에 의해 자동 생성된다. FilterChainProxy는 스프링 시큐리티에서 제공하는 필터로서 보안 필터체인(SecurityFilterChain)을 통해 많은 보안 필터(Security Filter)를 사용할 수 있다.
보안 필터체인은 WebSecurityConfigurerAdapter 클래스를 상속받아 설정 할 수 있다. 여러 보안 필터체인을 만들기 위해서는 WebSecurityConfigurerAdapter 클래스를 상속받는 클래스를 여러 개 생성하면 된다. 여러 보안 필터체인을 사용할 경우, 즉 WebSecurityConfigurerAdapter 클래스를 상속받는 클래스를 여러 개 사용할 경우에는 @Order 어노테이션을 지정해 순서를 정의하는 것이 중요하다.
별도 설정이 없으면 스프링 시큐리티는 보안 필터체인 중 UsernamePasswordAuthenticationFilter를 통해 인증을 처리한다. 인증 수행 과정은 다음과 같다.
1) 클라이언트로부터 요청을 받으면 서블릿 필터에서 SecurityFilterChain으로 작업이 위임되고, 그 중 UsernamePasswordAuthenticationFiler(이하 AuthenticationFilter)에서 인증을 처리한다.
2) AuthenticationFilter는 요청 객체(HttpServletRequest)에서 username과 password를 추출해서 토큰을 생성한다.
3) AuthenticationManager에게 생성된 토큰을 전달한다. AuthenticationManager는 인터페이스이며, 일반적으로 사용되는 구현체는 ProviderManager이다.
4) ProviderManager는 인증을 위해 AuthenticationProvider로 토큰을 전달한다.
5) AuthenticationProvider는 토큰의 정보를 UserDetailService에 전달한다.
6) UserDetailsService는 전달받은 정보를 통해 데이터베이스에서 일치하는 사용자를 찾아 UserDetails 객체를 생성한다.
7) 생성된 UserDetails 객체는 AuthenticationProvider로 전달되며, 해당 Provider에서 인증을 수행하고 성공하게 되면 ProviderManager로 권한을 담은 토큰을 전달한다.
8) ProviderManager는 검증된 토큰을 AuthenticationFilter로 전달한다.
9) AuthenticationFilter는 검증된 토큰을 SecurityContextHolder에 있는 SecurityContext에 저장한다.
JWT(JSON Web Token)는 당사자 간에 정보를 JSON 형태로 안전하게 전송하기 위한 토큰이다.
13.4.1 JWT의 구조
aaaaaa.bbbbbb.cccccc 헤더(Header).내용(Payload).서명(Signature)각 헤더와 내용은 Base64Url 형식으로 인코딩 되어 사용한다.
// 헤더 예제
{
"alg": "HS256",
"typ": "JWT"
}
헤더는 검증과 관련된 두 가지 정보를 포함하고 있는데, alg는 해싱 알고리즘을 지정하며 typ는 토큰의 타입을 지정한다.
등록된 클레임은 아래와 같은 것들이 있다.
- iss(Issuer) - sub(Subject) - aud(Audience) - exp(Expiration) - nbf(Not Before) - iat(Issued at) - jti(JWT ID)
{
"sub": "hello",
"exp": "1602076408",
"userId": "hi",
"username": "pen"
}
13.4.2 JWT 디버거 사용하기
JWT 공식 사이트에서는 더욱 쉽게 JWT를 생성해 볼 수 있다.
13.5.1 UserDetails와 UserDetailsService 구현
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@AuditOverride(forClass = BaseEntity.class)
public class Partner extends BaseEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// id는 인덱스로 사용되기 때문에 고유 식별자 사용
@Column(unique = true, nullable = false)
private String uid;
@Column(unique = true, nullable = false)
private String email;
@Column(nullable = false)
private String name;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String phone;
@Column(nullable = false)
private LocalDate birth;
// 사용권한 분류, Collection 형태 저장할 때 쓰는 어노테이션
@ElementCollection(targetClass = String.class, fetch = FetchType.EAGER)
private List<String> roles;
// 계정이 가지고 있는 권한 목록을 리턴
@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;
}
// 계정 만료 여부 리턴, true면 만료 안 됨
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isAccountNonExpired() {
return true;
}
// 계정이 잠겨있는지 여부 리턴, true면 안 잠김
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isAccountNonLocked() {
return true;
}
// 비밀번호 만료 여부 리턴, true면 만료 안 됨
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 계정 활성화 여부 리턴, true면 활성화
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isEnabled() {
return true;
}
}
@Repository
public interface PartnerRepository extends JpaRepository<Partner, Long> {
Partner getByUid(String uid);
}
13.5.2 JwtTokenProvider 구현
JWT 토큰을 생성하는 JwtTokenProvider 구현
// JwtTokenProvider 구현
@Component
@RequiredArgsConstructor
@Slf4j
public class TokenProvider {
private final PartnerService partnerService;
@Value("${springboot.jwt.secret}")
private String secretKey;
private final long tokenValidMillisecond = 1000L * 60 * 60 * 24;
// secretKey를 Base64 형식으로 인코딩(빈으로 등록될 때 실행되는 메서드)
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder()
.encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
}
// uid와 권한을 입력하여 토큰 생성
public String createToken(String uid, List<String> roles){
Claims claims = Jwts.claims().setSubject(uid);
claims.put("roles", roles);
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidMillisecond))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
// 토큰 인증 정보 조회 및 Authentication 생성
public Authentication getAuthentication(String token) {
UserDetails userDetails = partnerService.loadUserByUsername(getUsername(token));
return new UsernamePasswordAuthenticationToken(userDetails, "",
userDetails.getAuthorities());
}
// 토큰으로부터 uid 추출
public String getUsername(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token)
.getBody().getSubject();
}
// http 헤더에서 token 값 추출
public String resolveToken(HttpServletRequest request){
return request.getHeader("X-AUTH_TOKEN");
}
// 토큰 유효성 체크
public boolean validateToken(String token) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey)
.parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (ExpiredJwtException e) {
throw new CustomException(ErrorCode.NOT_VALID_TOKEN);
}
}
}
13.5.3 JwtAuthenticationFilter 구현
JWT 토큰으로 인증하고 SecurityContextHolder에 추가하는 필터를 설정하는 클래스 구현, 보통 OncePerRequestFilter를 상속받아 구현한다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = tokenProvider.resolveToken(request);
if (token != null && tokenProvider.validateToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
13.5.4 SecurityConfiguration 구현
스프링 시큐리티를 설정하는 대표적인 방법은 WebSecurityConfigureAdapter를 상속받는 Configuration 클래스를 생성하는 것이다.
@Configuration
@RequiredArgsConstructor
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private final TokenProvider tokenProvider;
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
// UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화
httpSecurity.httpBasic().disable()
// csrf 보안 비활성화
.csrf().disable()
// 세션 관리 방식 설정, 세션 사용하지 않을 것이기 때문에 STATELESS 선택
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 애플리케이션에 들어오는 요청에 대한 사용 권한을 체크, url마다 설정 가능
.authorizeRequests()
.antMatchers("/sign-up/**", "/sign-in/**", "/review").permitAll()
.antMatchers("/customer/**").hasRole(Authority.CUSTOMER.toString())
.antMatchers("/partner/**").hasRole(Authority.PARTNER.toString())
.and()
// 권한 확인 과정에서 통과하지 못하는 예외가 발생할 경우 예외 전달
.exceptionHandling()
.accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
// 인증 과정에서 예외가 발생할 경우 예외를 전달
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
// UsernamePasswordAuthenticationFilter 앞에 JwtAuthenticationFilter 적용
.addFilterBefore(new JwtAuthenticationFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class);
}
// 시큐리티 필터 미적용 대상 설정
@Override
public void configure(WebSecurity webSecurity) {
webSecurity.ignoring().antMatchers("/v2/api-docs", "**swagger**");
}
}
하지만 WebSecurityConfigureAdapter가 deprecated되었기 때문에 위 코드를 아래와 같이 변경하였다.
@Configuration
@RequiredArgsConstructor
public class SecurityConfiguration {
private final TokenProvider tokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
// UI를 사용하는 것을 기본값으로 가진 시큐리티 설정을 비활성화
httpSecurity.httpBasic().disable()
// csrf 보안 비활성화
.csrf().disable()
// 세션 관리 방식 설정, 세션 사용하지 않을 것이기 때문에 STATELESS 선택
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 애플리케이션에 들어오는 요청에 대한 사용 권한을 체크, url마다 설정 가능
.authorizeRequests()
.antMatchers("/sign-up/**", "/sign-in/**", "/review").permitAll()
.antMatchers("/customer/**").hasRole(Authority.CUSTOMER.toString())
.antMatchers("/partner/**").hasRole(Authority.PARTNER.toString())
.and()
// 권한 확인 과정에서 통과하지 못하는 예외가 발생할 경우 예외 전달
.exceptionHandling()
.accessDeniedHandler(new CustomAccessDeniedHandler())
.and()
// 인증 과정에서 예외가 발생할 경우 예외를 전달
.exceptionHandling()
.authenticationEntryPoint(new CustomAuthenticationEntryPoint())
.and()
// UsernamePasswordAuthenticationFilter 앞에 JwtAuthenticationFilter 적용
.addFilterBefore(new JwtAuthenticationFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class);
return httpSecurity.build();
}
// 시큐리티 필터 미적용 대상 설정
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return (webSecurity) -> webSecurity.ignoring()
.antMatchers("/v2/api-docs", "**swagger**");
}
}
13.5.5 커스텀 AccessDeniedHandler, AuthenticationEntryPoint 구현
위 설정 클래스에서 권한, 인증이 부적절할 경우 발생하는 exception 핸들링하는 커스텀 클래스이다.
// 액세스 권한이 없는 리소스에 접근할 경우(AccessDeniedException 발생할 경우) 처리
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.sendRedirect("/sign-in");
}
}
// 인증 실패할 경우(AuthenticationException 발생할 경우) 처리
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
ObjectMapper objectMapper = new ObjectMapper();
EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
entryPointErrorResponse.setMsg("인증이 실패하였습니다.");
response.setStatus(401);
response.setContentType("application/json");
response.setCharacterEncoding("utf-8");
response.getWriter().write(objectMapper.writeValueAsString(entryPointErrorResponse));
}
}
// CustomAuthenticationEntryPoint 사용되는 dto
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {
private String msg;
}
13.5.6 회원가입과 로그인 구현
// 회원가입 서비스 구현
@Service
@AllArgsConstructor
@Slf4j
public class SignUpService {
private final PartnerRepository partnerRepository;
private final PasswordEncoder passwordEncoder;
private final PartnerService partnerService;
// 파트너 회원가입(이메일 존재 여부 확인, password 암호화해서 저장, 회원가입 결과 확인 및 반환)
public SignUpDto.Response partnerSignUp(SignUpDto.Request request) {
if (partnerService.emailIsExist(request.getEmail())) {
throw new CustomException(ErrorCode.ALREADY_EMAIL_EXIST);
} else {
request.setPassword(passwordEncoder.encode(request.getPassword()));
Partner savedPartner = partnerRepository.save(Partner.from(request));
SignUpDto.Response response = new SignUpDto.Response();
if (!savedPartner.getName().isEmpty()) {
response.setSuccessResult();
} else {
response.setFailResult();
}
return response;
}
}
}
비밀번호는 db에 암호화하여 보관하기 위해 PasswordEncoder클래스를 사용하였다. PasswordEncoder를 사용하기 위해 전에 만든 SecurityConfiguration 클래스 내부에 아래와 같이 passwordEncoder() 메서드를 정의하여 빈으로 등록하였다.(다른 @Configuraiton 클래스 내에 만들어도 된다.)
// password 암호화 설정
@Bean
public PasswordEncoder passwordEncoder(){
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
회원가입 서비스에서 Partner.from()메서드는 Partner 클래스 내 아래와 같이 구성하였다.
public static Partner from(SignUpDto.Request request){
return Partner.builder()
.uid(UUID.randomUUID().toString().replace("-", ""))
.email(request.getEmail())
.name(request.getName())
.password(request.getPassword())
.phone(request.getPhone())
.birth(request.getBirth())
.roles(Collections.singletonList(UserType.PARTNER.toString()))
.build();
}
회원가입 서비스에 사용하는 Dto는 아래와 같이 구성했다.
public class SignUpDto {
@Getter
@Setter
@AllArgsConstructor
@Builder
public static class Request{
@NotNull(message = "반드시 값이 있어야 합니다.")
@Email(message = "이메일 주소가 유효하지 않습니다.")
private String email;
@NotNull(message = "반드시 값이 있어야 합니다.")
@Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9-_]{2,10}$",
message = "이름은 특수문자를 제외한 2~10자리여야 합니다.")
private String name;
@NotNull(message = "반드시 값이 있어야 합니다.")
@Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}",
message = "비밀번호는 8~16자 영문 대 소문자, 숫자, 특수문자를 사용하세요.")
private String password;
@NotNull(message = "반드시 값이 있어야 합니다.")
@Pattern(regexp = "^01([0|1|6|7|8|9]?)-?([0-9]{3,4})-?([0-9]{4})$",
message = "전화번호가 유효하지 않습니다.")
private String phone;
@NotNull(message = "반드시 값이 있어야 합니다.")
@Past
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd",
timezone = "Asia/Seoul")
private LocalDate birth;
}
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class Response{
private boolean success;
private int code;
private String msg;
public void setSuccessResult(){
this.success = true;
this.code = CommonResponse.SUCCESS.getCode();
this.msg = CommonResponse.SUCCESS.getMsg();
}
public void setFailResult() {
this.success = false;
this.code = CommonResponse.SUCCESS.getCode();
this.msg = CommonResponse.SUCCESS.getMsg();
}
}
}
위에 결과 코드를 전송하기 위한 setSuccessResult() 메서드에 사용된 CommonResponse는 아래와 같이 enum으로 구성하였다.
@Getter
@AllArgsConstructor
public enum CommonResponse {
SUCCESS(0, "성공"), FAIL(-1, "실패");
private final int code;
private final String msg;
}
회원가입을 처리하기 위한 controller는 아래와 같이 구성하였다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/sign-up")
public class SignUpController {
private final SignUpService signUpService;
// 파트너 회원가입
@PostMapping("/partner")
public SignUpDto.Response partnerSignUp(@Valid @RequestBody SignUpDto.Request request){
return signUpService.partnerSignUp(request);
}
// 로그인 서비스 구현
@Service
@AllArgsConstructor
@Slf4j
public class SignInService {
private final PartnerRepository partnerRepository;
private final CustomerRepository customerRepository;
private final TokenProvider tokenProvider;
private final PasswordEncoder passwordEncoder;
// 파트너 로그인(이메일 존재 여부 확인, 패스워드 확인, 로그인 결과 확인 및 반환)
public SignInDto.Response partnerSignIn(SignInDto.Request request) {
Partner partner = partnerRepository.findByEmail(request.getEmail())
.orElseThrow(() -> new CustomException(ErrorCode.NOT_FOUND_USER));
passwordMatch(request.getPassword(), partner.getPassword());
SignInDto.Response response = SignInDto.Response.builder()
.token(tokenProvider.createToken(partner.getUid(), partner.getRoles()))
.build();
response.setSuccessResult();
return response;
}
private void passwordMatch(String requestPassword, String savedPassword){
if (!passwordEncoder.matches(requestPassword, savedPassword)){
throw new CustomException(ErrorCode.INCORRECT_PASSWORD);
}
}
}
SignInDto는 아래와 같이 구성하였다.
public class SignInDto {
@Getter
@Setter
@AllArgsConstructor
public static class Request{
@NotNull(message = "반드시 값이 있어야 합니다.")
private String email;
@NotNull(message = "반드시 값이 있어야 합니다.")
private String password;
}
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Response{
private boolean success;
private int code;
private String msg;
private String token;
public void setSuccessResult(){
this.success = true;
this.code = CommonResponse.SUCCESS.getCode();
this.msg = CommonResponse.SUCCESS.getMsg();
}
public void setFailResult() {
this.success = false;
this.code = CommonResponse.SUCCESS.getCode();
this.msg = CommonResponse.SUCCESS.getMsg();
}
}
}
SignIn을 위한 controller는 아래와 같이 구성하였다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/sign-in")
public class SignInController {
private final SignInService signInService;
// 파트너 로그인
@PostMapping("/partner")
public SignInDto.Response partnerSignIn(@Valid @RequestBody SignInDto.Request request){
return signInService.partnerSignIn(request);
}
}
13.5.7 스프링 시큐리티 테스트