
@RestController
@RequestMapping("/api")
public class HelloController {
@GetMapping("/hello")
public ResponseEntity<String> hello() {
return ResponseEntity.status(HttpStatus.OK).body("hello");
}
}

@EnableWebSecurity
public class SecurityConfig {
}
@EnableWebSecurityHttp Security설정을 하기위해서는 시큐리티 5.4버전에 도입된 SecurityFilterChain빈을 생성하여 설정해야됨.@EnableWebSecurity // 기본적인 웹 보안을 활성화하는 에노테이션.
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
.requestMatchers("/api/hello").permitAll()
.anyRequest().authenticated()
);
return httpSecurity.build();
}
}
/api/hello에 대한 요청은 인증 없이 접근을 허용.
.yml로 변경.application.yml
spring:
h2:
console:
enabled: true # H2 데이터베이스 콘솔을 활성화.
datasource:
url: jdbc:h2:mem:testdb # JDBC URL을 설정, 메모리 기반 H2 데이터베이스를 사용.
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: create-drop # SessionFactory가 시작될 때 Drop, Create, Alter를 하고, 종료될 때 Drop.
properties:
hibernate:
format_sql: true # 콘솔창에서 SQL을
show_sql: true # 보기 좋게 하기 위한 설정.
defer-datasource-initialization: true
logging:
level:
me.silvernine: DEBUG # 로깅 레벨을 디버그로 설정.
ddl-auto: create-dropresources 디렉토리에 data.sql을 작성해서 서버가 시작될 때마다 실행할 쿼리를 넣어줌.User 엔터티
@Entity
@Table(name = "user")
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class User {
@JsonIgnore
@Id
@Column(name = "user_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
@Column(name = "username", length = 50, unique = true)
private String username;
@JsonIgnore
@Column(name = "password", length = 100)
private String password;
@Column(name = "nickname")
private String nickname;
@JsonIgnore
@Column(name = "activated")
private boolean activated;
@ManyToMany
@JoinTable(
name = "user_authority"
, joinColumns = {@JoinColumn(name = "user_id", referencedColumnName = "user_id")}
, inverseJoinColumns = {@JoinColumn(name = "authority_name", referencedColumnName = "authority_name")})
private Set<Authority> authorities;
}
@ManyToMany, @JoinTable어노테이션은user(유저) 객체와 authority(권한) 객체의 다대다 관계를일대다, 다대일 관계의 조인 테이블로 정의했다는 의미.O >-< O 였던 관계를 O -< O >- O로 했음. (O는 테이블을 뜻함.)Authority 엔터티
@Entity
@Table(name = "authority")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Authority {
@Id
@Column(name = "authority_name", length = 50)
private String authorityName;
}
authorityName이라는 PK를 가짐.@EnableWebSecurity
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
// 토큰을 사용하는 방식이기 때문에 CSRF를 disable해줌.
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
.requestMatchers("/api/hello").permitAll()
.requestMatchers(PathRequest.toH2Console()).permitAll()
.anyRequest().authenticated()
)
// enable h2-console
.headers(headers ->
headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin));
return httpSecurity.build();
}
}


HS512알고리즘을 사용할 것이기 때문에 Secret Key는 512bit, 즉 64Byte 이상이 돼야함.application.yml
jwt:
header: Authorization
#HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야함.
secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
token-validity-in-seconds: 86400 # (ms)
build.gradle
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
TokenProvider
@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;
}
@Override
public void afterPropertiesSet() throws Exception {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
}
InitializingBean을 구현(implements)해서 afterPropertiesSet를 오버라이드(@Override)한 이유.빈(Bean)이 생성이 되고 의존성 주입까지 받은 후에 주입 받은 secret값을 Base64 Decode해서 key변수에 할당하기 위해서.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() // jwt 토큰을 생성해서 리턴.
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
Authentication 객체에 저장되어 있는 권한 정보들을 이용해서 토큰을 생성하는 createToken메서드 추가.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);
Authentication 객체를 리턴하는 getAuthentication메서드 추가. 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;
}
validateToken메서드.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;
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
}
}
JwtFilter는 TokenProvider를 주입받음.GenericFilterBean을 상속받아서 GenericFilterBean의 doFilter메서드를 오버라이드.JWT 토큰의 인증 정보를 시큐리티 컨텍스트(SecurityContext)에 저장하는 역할을 수행. private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
@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);
}
httpServletRequest에서 토큰을 받아서 해당 토큰의 유효성 검증을 위한 메서드를 호출하고, 검증이 통과되면 토큰에서 Authentication 객체를 받아와서 SecurityContextHolder.getContext().setAuthentication(authentication)을 통해 넣음.
TokenProvider, JwtFilter를 시큐리티 설정에 적용할 클래스 추가.
JwtSecurityConfig
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
private final TokenProvider tokenProvider;
public JwtSecurityConfig(TokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
@Override
public void configure(HttpSecurity http) {
http.addFilterBefore(
new JwtFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class
);
}
}
SecurityConfigurerAdapter를 상속받고 TokenProvider를 주입 받아서 JwtFilter를 통해 시큐리티 로직에 필터를 등록함.@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
401 Unauthorized에러를 리턴하기 위해서 AuthenticationEntryPoint를 구현(implements)한 JwtAuthenticationEntryPoint클래스.@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
//필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
AccessDeniedHandler를 구현(implements)한 JwtAccessDeniedHandler클래스.@EnableWebSecurity
@EnableMethodSecurity
@Configuration
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final CorsFilter corsFilter;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
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();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 토큰을 사용하는 방식이기 때문에 csrf를 disable.
.csrf(AbstractHttpConfigurer::disable)
.addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exceptionHandling -> exceptionHandling // Exception 핸들링할 때 만든 예외처리 클래스를 추가.
.accessDeniedHandler(jwtAccessDeniedHandler)
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
)
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
.requestMatchers("/api/hello", "/api/authenticate", "/api/signup").permitAll() // 토큰을 받기 위한 로그인 api, 회원가입 api는 허용해줌.
.requestMatchers(PathRequest.toH2Console()).permitAll()
.anyRequest().authenticated()
)
// 세션을 사용하지 않기 때문에 STATELESS로 설정.
.sessionManagement(sessionManagement ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// enable h2-console
.headers(headers ->
headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)
)
.with(new JwtSecurityConfig(tokenProvider), customizer -> {}); // JwtSecurityConfig 객체를 HttpSecurity 설정에 추가.
return http.build();
}
}
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class LoginDto {
@NotNull
@Size(min = 3, max = 50)
private String username;
@NotNull
@Size(min = 3, max = 100)
private String password;
}
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TokenDto { // Response할 때 사용.
private String token;
}
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserDto {
@NotNull
@Size(min = 3, max = 50)
private String username;
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@NotNull
@Size(min = 3, max = 100)
private String password;
@NotNull
@Size(min = 3, max = 50)
private String nickname;
private Set<AuthorityDto> authorityDtoSet;
public static UserDto from(User user) {
if(user == null) return null;
return UserDto.builder()
.username(user.getUsername())
.nickname(user.getNickname())
.authorityDtoSet(user.getAuthorities().stream()
.map(authority -> AuthorityDto.builder().authorityName(authority.getAuthorityName()).build())
.collect(Collectors.toSet()))
.build();
}
}
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = "authorities") // @EntityGraph : 쿼리가 수행될 때 Eager 방식으로 조회해서 authorities정보를 같이 가져옴.
Optional<User> findOneWithAuthoritiesByUsername(String username); // username을 기준으로 User정보를 가져올 때 권한 정보도 같이 들고옴.
}
@EntityGraphUserDetailsService를 커스텀해서 구현한 CustomUserDetailsService클래스 생성.@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 + " -> 활성화되어 있지 않습니다.");
}
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);
}
}
UserDetailsService를 구현(implements)하고 UserRepository를 주입 받음.loadUserByUsername메서드를 오버라이드 해서 로그인 시 데이터베이스에서 유저 정보를 권한 정보와 함께 가져옴.User 객체를 리턴.@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) {
// DTO의 정보를 이용해서 UsernamePasswordAuthenticationToken 객체를 생성.
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
// authenticationToken을 이용해서 authenticate 메서드를 호출할 때 loadUserByUsername 메서드가 실행됨.
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
// 그 결과로 Authentication 객체가 만들어지고, 이 객체를 시큐리티 컨텍스트에 저장하고
SecurityContextHolder.getContext().setAuthentication(authentication);
// authentication 인증 정보를 기준으로 해서 createToken 메서드를 호출해서 JWT 토큰을 만듦.
String jwt = tokenProvider.createToken(authentication);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);
// JWT 토큰을 응답 헤더에도 넣어주고
// 토큰 DTO를 이용해서 응답 바디에도 넣어서 리턴.
return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
}
}

public class SecurityUtil {
private static final Logger logger = LoggerFactory.getLogger(SecurityUtil.class);
private SecurityUtil() {}
public static Optional<String> getCurrentUsername() {
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);
}
}
getCurrentUsername메서드의 역할은 시큐리티 컨텍스트에서 Authentication 객체를 통해서 username을 리턴해줌.Authentication 객체가 저장되는 시점은 JwtFilter의 doFilter 메서드에서 Request가 들어올 때 시큐리티 컨텍스트에서 Authentication 객체를 저장하는데 이때 저장된 Authentication 객체를 사용함.@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));
}
// username에 해당하는 유저 정보와 권한 정보를 가져옴.
@Transactional(readOnly = true)
public UserDto getUserWithAuthorities(String username) {
return UserDto.from(userRepository.findOneWithAuthoritiesByUsername(username).orElse(null));
}
// 현재 시큐리티 컨텍스트에 저장되어 있는 username에 해당하는 유저 정보와 권한 정보를 가져옴.
@Transactional(readOnly = true)
public UserDto getMyUserWithAuthorities() {
return UserDto.from(
SecurityUtil.getCurrentUsername()
.flatMap(userRepository::findOneWithAuthoritiesByUsername)
.orElseThrow(() -> new NotFoundMemberException("Member not found"))
);
}
}




