// spring-boot-starter-security
implementation 'org.springframework.boot:spring-boot-starter-security'
/* jjwt */
implementation 'io.jsonwebtoken:jjwt:0.9.1'
REST API에는 이러한 화면이 없으므로 UsernamePasswordAuthenticationFilter 앞에 인증 필터를 배치해서 인증 주체를 변경하는 작업 방식으로 구현하겠습니다.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table
@Entity
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 true;
}
@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;
}
}
getAuthorities()
getPassword()
getUsername()
isAccountNonExpired()
isAccountNonLocked()
isCredentialNonExpired()
isEnabled()
UserDetails는 스프링 시큐리티에서 제공하는 개념입니다. UserDetails의 username은 각 사용자를 구분할 수 있는 IDfmf dmlalgkqslek.
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
User getByUid(String uid);
}
@RequiredArgsConstructor
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
private final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.getByUid(username);
}
}
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
private final Logger log = LoggerFactory.getLogger(JwtTokenProvider.class);
private final UserDetailsService userDetailsService;
@Value("${springboot.jwt.secret}")
private String secretKey;
private final long tokenValidMillisecond = 1000L * 60 * 60;
@PostConstruct
protected void init() {
log.info("[JwtTokenProvider] secretKey 초기화 시작");
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
log.info("[JwtTokenProvider] secretKey 초기화 완료");
}
public String createToken(String userUid, List<String> roles) {
log.info("[JwtTokenProvider/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();
log.info("[JwtTokenProvider/createToken] 토큰 생성 완료");
return token;
}
public Authentication getAuthentication(String token) {
log.info("[JwtTokenProvider/getAuthentication] 토큰 조회 시작");
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUsername(token));
log.info("[JwtTokenProvider/getAuthentication] 토큰 조회 완료 Username : {}", userDetails.getUsername());
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public String getUsername(String token) {
log.info("[JwtTokenProvider/getUsername] 토큰에서 회원 정보 추출 시작");
String info = Jwts.parser().setSigningKey(secretKey).parseClaimsJwt(token).getBody().getSubject();
log.info("[JwtTokenProvider/getUsername] 토큰에서 회원 정보 추출 완료 info : {}", info);
return info;
}
public String resolveToken(HttpServletRequest request) {
log.info("[JwtTokenProvider/resolverToken] HTTP 헤더에서 Token 추출");
return request.getHeader("X-AUTH-TOKEN");
}
public boolean validateToken(String token) {
log.info("[JwtTokenProvider/validateToken] 토큰 유효성 체크 시작");
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
log.error("[JwtTokenProvider/validateToken] 토큰 유효성 체크 실패");
return false;
}
}
}
springboot:
jwt:
secret: ~~~
해당 값은 임의로 지정한 값입니다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final Logger log = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
log.info("[JwtAuthenticationFilter/doFilterInternal] token 추출 완료 : {}", token);
log.info("[JwtAuthenticationFilter/doFilterInternal] token 유효성 체크 시작");
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("[JwtAuthenticationFilter/doFilterInternal] token 유효성 체크 완료");
}
filterChain.doFilter(request, response);
}
}
@RequiredArgsConstructor
@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
private final 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");
}
}
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final Logger log = LoggerFactory.getLogger(CustomAccessDeniedHandler.class);
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
log.info("[CustomAccessDeniedHandler/handle] 접근이 막혔을 경우 경로 리다이렉트");
response.sendRedirect("/sign-api/exception");
}
}
@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
private Logger log = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class);
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
ObjectMapper objectMapper = new ObjectMapper();
log.info("[CustomAuthenticationEntryPoint/commence] 인증 실패로 error 발생");
EntryPointErrorResponse entryPointErrorResponse = new EntryPointErrorResponse();
entryPointErrorResponse.setMsg("인증이 실패하였습니다.");
response.setStatus(401);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(objectMapper.writeValueAsString(entryPointErrorResponse));
}
}
@Data
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;
}
@AllArgsConstructor
@Data
public class SignUpResultDTO {
private boolean isSuccess;
private int code;
private String msg;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SignInResultDTO {
private String token;
private boolean isSuccess;
private String msg;
private int code;
}
@Service
@RequiredArgsConstructor
public class SignServiceImpl implements SignService {
private final UserRepository userRepository;
private final JwtTokenProvider jwtTokenProvider;
private final PasswordEncoder passwordEncoder;
private final Logger log = LoggerFactory.getLogger(SignServiceImpl.class);
@Override
public SignUpResultDTO signUp(String id, String password, String name, String role) {
log.info("[SignServiceImpl/signUp] 회원 가입 로직 시작");
User user = null;
if (userRepository.getByUid(id) != null) {
throw new DuplicateUserIdException("동일한 아이디의 사용자가 있습니다.");
}
/* Step 1. 권한별 엔티티 객체 생성 */
if ("admin".equalsIgnoreCase(role)) {
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();
}
/* Step 2. DB에 저장 */
User savedUser = userRepository.save(user);
SignUpResultDTO signUpResultDTO = null;
/* Step 3. 저장이 맞게 되었는지 검증 */
if (savedUser.getName().isEmpty()) {
log.error("[SignServiceImpl/signUp] 회원가입 실패");
signUpResultDTO = SignUpResultDTO.builder()
.isSuccess(Status.FAIL.value())
.code(HttpStatus.BAD_REQUEST.value())
.msg("회원가입을 성공했습니다.")
.build();
} else {
log.info("[SignServiceImpl/signUp] 회원가입 성공");
signUpResultDTO = SignUpResultDTO.builder()
.isSuccess(Status.SUCCESS.value())
.code(HttpStatus.OK.value())
.msg("회원가입을 성공했습니다.")
.build();
}
return signUpResultDTO;
}
...
}
임의로 Status라는 enum을 만들어 처리했다.
@Configuration
public class PasswordEncoderConfiguration {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
로그인 로직은 아래와 같이 동작합니다.
@Override
public SignInResultDTO signIn(String id, String password) throws RuntimeException {
log.info("[SignServiceImpl/signIn] 로그인 시도");
String loginFailMsg = "입력한 정보가 일치하지 않습니다.";
User user = userRepository.getByUid(id);
/* id와 맞는 User가 있는지 조회 */
if (user == null) {
throw new LoginFailedException(loginFailMsg);
}
/* Step 2. 비밀번호 일치여부 */
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new LoginFailedException(loginFailMsg);
}
/* Step 3. 토큰 생성 및 전달 */
String token = jwtTokenProvider.createToken(user.getUid(), user.getRoles());
return SignInResultDTO.builder()
.token(token)
.build();
}
@RestController
@RequestMapping("/sign-api")
@RequiredArgsConstructor
public class SignController {
private final Logger log = LoggerFactory.getLogger(SignController.class);
private final SignService signService;
/**
* 로그인
* @return SignInResultDTO
*/
@PostMapping("/sign-in")
public SignInResultDTO signIn(@Valid SignInRequestDTO signInRequestDTO) {
return signService.signIn(signInRequestDTO.getId(), signInRequestDTO.getPassword());
}
/**
* 회원가입
* @return SignUpResultDTO
*/
@PostMapping("/sign-up")
public SignUpResultDTO signUp(@Valid SignUpRequestDTO signUpRequestDTO) {
return signService.signUp(signUpRequestDTO.getId(), signUpRequestDTO.getPassword(), signUpRequestDTO.getName(), signUpRequestDTO.getRole());
}
}
@RestControllerAdvice
public class CustomExceptionHandler {
private final Logger log = LoggerFactory.getLogger(CustomExceptionHandler.class);
@ExceptionHandler({BindException.class})
public ResponseEntity<ErrorResponseDTO> handleBindException(BindException e) {
String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage();
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponseDTO.builder()
.msg(errorMessage)
.build());
}
@ExceptionHandler({DuplicateUserIdException.class, LoginFailedException.class})
public ResponseEntity<ErrorResponseDTO> handleCustomException(RuntimeException e) {
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ErrorResponseDTO.builder()
.msg(e.getMessage())
.build());
}
}