위 과정에서 사용된 UsernamePasswordAuthenticationFilter는 접근 권한을 확인하고 인증이 실패할 경 우 로그인 폼이라는 화면을 보내는 역할을 수행합니다. 이 책에서 실습 중인 프로젝트는 화면이 없는 RESTful 애플리케이션이기 때문에 다른 필터에서 인증 및 인가 처리를 수행해야 합니다. 이 책에서는 JWT 토큰을 사용해 인증을 수행할 예정이라 JWT와 관련된 필터를 생성하고 usernamePasswordAuthentic ationFilter 앞에 배치해서 먼저 인증을 수행할 수 있게 설정하겠습니다.
스프링 시큐리티에 대한 자세한 내용은 공식 문서를 참고하기 바랍니다.
• https://docs.spring.io/spring-security/site/docs/5.5.3/reference/html5/
JWT는 점(.)으로 구분된 아래의 세 부분으로 구성됩니다.
{
"alg": "H2256"
"typ": "JWT"
}
{
"sub": "wikibooks payload",
"exp": "1603076498",
"userId": "wikibooks",
"username": "flature"
}
HMACSHA256(
base64UrlEncode(headr) + "." +
base64UrlEncode(payload),
secret
)
<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>
@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;
}
}
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);
}
}
@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;
}
}
}
@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 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;
}
}
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);
}
}
@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 메서드에 대한 내용 생략
@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");
}
}
@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));
}
}
@Data
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class EntryPointErrorResponse {
private String msg;
}
먼저 서비스 레이어를 구현
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());
}
}
PasswordEncoder는 Configuration 클래스를 생성하고 @Bean 객체로 등록하도록 구현
@Configuration
public class PasswordEncoderConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
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;
}
}
@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;
}
}
클라이언트의 입장이 되어 스프링 시큐리티가 동작하는 상황에서 테스트를 수행
가. 회원가입
나. 로그인
다. 상품 등록 API를 통해 상품 등록
가. 인증 과정에서 예외가 발생하는 상황
나. 상품 등록 API를 사용하는 과정에서 토큰의 값을 변조해서 전달하는 과정을 통해 인증 예외가 발생하는지 확인하는 과정
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.
ⓒ 다라
[스프링 3팀] 12. 서버 간 통신 & 섹션 0. 시큐리티 (0) | 2023.12.29 |
---|---|
[스프링 3팀] 10장. 유효성 검사와 예외 처리 (2) | 2023.12.22 |
[스프링3] 9장 연관관계 매핑 (1) | 2023.12.01 |
[스프링3] 8장. Spring Data JPA 활용 (0) | 2023.11.24 |
[스프링 3] 07 테스트 코드 작성하기 (0) | 2023.11.17 |