@EnableWebSecurity // 기본적인 Web 보안을 활성화한다
public class SecurityConfig extends WebSecurityConfigurerAdapter { // 추가적인 설정을 위해 WebSecurityConfigurer를 implements 할 수도 있다
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests() // HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다는 의미
.antMatchers("/api/hello").permitAll() // /api/hello에 대한 요청은 인증이 필요 없다
.anyRequest().authenticated(); // 나머지 요청은 인증이 필요하다
}
}
jwt:
header: Authorization
#HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
#echo 'silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret' 이라는 문자열을 base64로 인코딩
secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
token-validity-in-seconds: 86400 # 초
필요한 의존성들을 추가해준다.
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'
토큰의 생성, 토큰의 유효성 검증등을 담당한다.
@Component
public class TokenProvider implements InitializingBean {
private final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
private static final String AUTHORITIES_KEY = "auth";
private final String secret;
private final long tokenValidityInMilliseconds;
private Key key;
public TokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
this.secret = secret;
this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
}
// 빈이 생성이 되고 의존성 주입 받은 secret값을 Base64 Decode해서 key변수에 할당
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// Authentication 객체의 권한정보를 이용해서 토큰을 생성
public String createToken(Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
Date validity = new Date(now + this.tokenValidityInMilliseconds);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
// Token에 담겨있는 정보를 이용해 Authentication 객체를 리턴
public Authentication getAuthentication(String token) {
Claims claims = Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
// 토큰의 유효성 검증, 토큰을 파싱하여 exception들을 캐치
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
logger.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
logger.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
logger.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
logger.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
}
JWT를 위한 커스텀 필터를 만들기 위함
public class JwtFilter extends GenericFilterBean {
private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
public static final String AUTHORIZATION_HEADER = "Authorization";
private TokenProvider tokenProvider;
public JwtFilter(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
// jwt 토큰의 인증정보를 현재 실행중인 SecurityContext에 저장하는 역할
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
String jwt = resolveToken(httpServletRequest);
String requestURI = httpServletRequest.getRequestURI();
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
} else {
logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
}
filterChain.doFilter(servletRequest, servletResponse);
}
// Request Header에서 토큰 정보를 꺼내오기 위함
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
TokenProvider, JwtFilter를 SecurityConfig에 적용하기 위함
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private TokenProvider tokenProvider;
public JwtSecurityConfig(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
// Security로직에 필터를 등록
@Override
public void configure(HttpSecurity http) {
JwtFilter customFilter = new JwtFilter(tokenProvider);
http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
}
}
유효한 자격증명이 없을때 401 Unauthorized 에러를 리턴하기 위함
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
필요한 권한이 존재하지 않는 경우 403 Forbidden 에러를 리턴하기 위함
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
//필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
만든 위 클래스들을 SecurityConfig에 추가 및 추가 설정
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // @PreAuthorize 어노테이션을 메소드단위로 추가하기 위해
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final TokenProvider tokenProvider;
private final CorsFilter corsFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
// 만든 5개의 클래스들을 주입
public SecurityConfig(
TokenProvider tokenProvider,
CorsFilter corsFilter,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
JwtAccessDeniedHandler jwtAccessDeniedHandler
) {
this.tokenProvider = tokenProvider;
this.corsFilter = corsFilter;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
public void configure(WebSecurity web) {
web.ignoring()
.antMatchers(
"/h2-console/**"
,"/favicon.ico"
,"/error"
);
}
@Override
protected void configure(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// token을 사용하는 방식이기 때문에 csrf를 disable
.csrf().disable()
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling()
// 우리가 만든 클래스들로 entrypoint, handler 설정
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
// enable h2-console
.and()
.headers()
.frameOptions()
.sameOrigin()
// 세션을 사용하지 않기 때문에 STATELESS로 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/api/hello").permitAll()
.antMatchers("/api/authenticate").permitAll()
.antMatchers("/api/signup").permitAll()
.anyRequest().authenticated()
.and()
.apply(new JwtSecurityConfig(tokenProvider));
}
}
/api/** 경로에 대하여 cors 허용
@Configuration
public class CorsConfig {
@Bean
public CorsFilter corsFilter() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
source.registerCorsConfiguration("/api/**", config);
return new CorsFilter(source);
}
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = "authorities") // Eager(즉시)조회로 authorites 정보를 같이 가져온다.
// username을 기준으로 User 정보를 가져올때 권한 정보도 같이 가져온다.
Optional<User> findOneWithAuthoritiesByUsername(String username);
}
@Component("userDetailsService")
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
@Transactional
public UserDetails loadUserByUsername(final String username) {
return userRepository.findOneWithAuthoritiesByUsername(username)
.map(user -> createUser(username, user))
.orElseThrow(() -> new UsernameNotFoundException(username + " -> 데이터베이스에서 찾을 수 없습니다."));
}
private org.springframework.security.core.userdetails.User createUser(String username, User user) {
if (!user.isActivated()) {
throw new RuntimeException(username + " -> 활성화되어 있지 않습니다.");
}
// username으로 유저 정보 찾고, 유저 정보와 권한 정보를 가진 userdetails.User 객체 생성 후 리턴
List<GrantedAuthority> grantedAuthorities = user.getAuthorities().stream()
.map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(user.getUsername(),
user.getPassword(),
grantedAuthorities);
}
}
@RestController
@RequestMapping("/api")
public class AuthController {
private final TokenProvider tokenProvider;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
public AuthController(TokenProvider tokenProvider, AuthenticationManagerBuilder authenticationManagerBuilder) {
this.tokenProvider = tokenProvider;
this.authenticationManagerBuilder = authenticationManagerBuilder;
}
@PostMapping("/authenticate")
public ResponseEntity<TokenDto> authorize(@Valid @RequestBody LoginDto loginDto) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
// authencicate()가 실행이 될때 CustomUserDetailsService의 loadUserByUsername 이 실행됨
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// Authentication 객체를 SecurityContext에 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
// jwt 토큰 생성
String jwt = tokenProvider.createToken(authentication);
// Header, Body에 넣어줌
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);
return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
}
}
SecurityContext에서 권한 객체를 가져와 인증
public class SecurityUtil {
private static final Logger logger = LoggerFactory.getLogger(SecurityUtil.class);
private SecurityUtil() {
}
// SecurityContext에서 Authenciateion 객체를 꺼내와서, username을 리턴해줌
public static Optional<String> getCurrentUsername() {
// doFilter메소드에서 Request가 들어올때 SecurityContext에 저장한 Authencation 객체를 꺼냄
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
logger.debug("Security Context에 인증 정보가 없습니다.");
return Optional.empty();
}
String username = null;
if (authentication.getPrincipal() instanceof UserDetails) {
UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal();
username = springSecurityUser.getUsername();
} else if (authentication.getPrincipal() instanceof String) {
username = (String) authentication.getPrincipal();
}
return Optional.ofNullable(username);
}
}
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional
public UserDto signup(UserDto userDto) {
if (userRepository.findOneWithAuthoritiesByUsername(userDto.getUsername()).orElse(null) != null) {
throw new DuplicateMemberException("이미 가입되어 있는 유저입니다.");
}
Authority authority = Authority.builder()
.authorityName("ROLE_USER")
.build();
User user = User.builder()
.username(userDto.getUsername())
.password(passwordEncoder.encode(userDto.getPassword()))
.nickname(userDto.getNickname())
.authorities(Collections.singleton(authority))
.activated(true)
.build();
return UserDto.from(userRepository.save(user));
}
// 어떤 유저 객체의 권한정보를 가져옴
@Transactional(readOnly = true)
public UserDto getUserWithAuthorities(String username) {
return UserDto.from(userRepository.findOneWithAuthoritiesByUsername(username).orElse(null));
}
// 현재 유저(SecurityContext) 객체의 권한정보를 가져옴
@Transactional(readOnly = true)
public UserDto getMyUserWithAuthorities() {
return UserDto.from(SecurityUtil.getCurrentUsername().flatMap(userRepository::findOneWithAuthoritiesByUsername).orElse(null));
}
}
@RestController
@RequestMapping("/api")
public class UserController {
private final UserService userService;
public UserController(UserService userService) {
this.userService = userService;
}
@GetMapping("/hello")
public ResponseEntity<String> hello() {
return ResponseEntity.ok("hello");
}
@PostMapping("/test-redirect")
public void testRedirect(HttpServletResponse response) throws IOException {
response.sendRedirect("/api/user");
}
@PostMapping("/signup")
public ResponseEntity<UserDto> signup(
@Valid @RequestBody UserDto userDto
) {
return ResponseEntity.ok(userService.signup(userDto));
}
// PreAuthorize를 통하여 USER, ADMIN 권한 전부 호출 가능
@GetMapping("/user")
@PreAuthorize("hasAnyRole('USER','ADMIN')")
public ResponseEntity<UserDto> getMyUserInfo(HttpServletRequest request) {
return ResponseEntity.ok(userService.getMyUserWithAuthorities());
}
// PreAuthorize를 통하여 ADMIN 권한만 호출 가능
@GetMapping("/user/{username}")
@PreAuthorize("hasAnyRole('ADMIN')")
public ResponseEntity<UserDto> getUserInfo(@PathVariable String username) {
return ResponseEntity.ok(userService.getUserWithAuthorities(username));
}
}
정은구님 인프런 강의 Spring Boot JWT Tutorial
https://gilssang97.tistory.com/56