springboot 3.2.0
dependencies {
//spring security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
//JAXB API
implementation 'javax.xml.bind:jaxb-api:2.3.1'
//jwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}
@Getter
public class UserPrincipal extends User {
private final Long memberId;
public UserPrincipal(Member member) {
super(member.getAccount(), member.getPassword(), List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole().toString())));
this.memberId = member.getId();
}
}
@RequiredArgsConstructor
@Service
public class UserDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String account) throws UsernameNotFoundException {
Member member = memberRepository.findByAccount(account)
.orElseThrow(MemberNotFound::new);
return new UserPrincipal(member);
}
}
@Getter
public class JwtToken {
private String grantType;
private String accessToken;
private String refreshToken;
@Builder
public JwtToken(String grantType, String accessToken, String refreshToken) {
this.grantType = grantType;
this.accessToken = accessToken;
this.refreshToken = refreshToken;
}
}
@Slf4j
@Component
public class JwtTokenProvider {
private static final String grantType = "Bearer";
private static final Long accessTokenValidTime = 1000 * 60 * 30L; // 30분
private static final Long refreshTokenValidTime = 1000 * 60 * 60 * 24 * 7L; // 30일
private final Key key;
private final MemberRepository memberRepository;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey, MemberRepository memberRepository) {
this.memberRepository = memberRepository;
byte[] secretByteKey = DatatypeConverter.parseBase64Binary(secretKey);
this.key = Keys.hmacShaKeyFor(secretByteKey);
}
public JwtToken generateToken(Long memberId, Authentication authentication) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
Member member = memberRepository.findById(memberId)
.orElseThrow(MemberNotFound::new);
//Access Token
String accessToken = Jwts.builder()
.subject(memberId.toString())
.claim("auth", authorities)
.claim("email", member.getEmail())
.claim("account", member.getAccount())
.claim("name", member.getName())
.claim("createdDate", member.getCreatedAt())
.expiration(new Date(System.currentTimeMillis() + accessTokenValidTime))
.signWith(key)
.compact();
//Refresh Token
String refreshToken = Jwts.builder()
.subject(memberId.toString())
.claim("auth", authorities)
.expiration(new Date(System.currentTimeMillis() + refreshTokenValidTime))
.signWith(key)
.compact();
return JwtToken.builder()
.grantType(grantType)
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.toList();
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
public boolean validateToken(String token) {
try {
Jwts.parser().verifyWith((SecretKey) key).build().parseSignedClaims(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.info("Invalid JWT Token", e);
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parser().verifyWith((SecretKey) key).build().parseSignedClaims(accessToken).getPayload();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
@Component
public class JwtExceptionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
try {
chain.doFilter(request, response);
} catch (ExpiredJwtException ex) {
setErrorResponse(request, response, ex);
}
}
public void setErrorResponse(HttpServletRequest request, HttpServletResponse response, Throwable ex) throws IOException {
response.setContentType("application/json; charset=UTF-8");
final Map<String, Object> body = new HashMap<>();
body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
body.put("error", "Unauthorized");
body.put("message", ex.getMessage());
body.put("path", request.getServletPath());
final ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getOutputStream(), body);
response.setStatus(HttpServletResponse.SC_OK);
}
}
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = resolveToken((HttpServletRequest) request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
return bearerToken.substring(7);
}
return null;
}
}
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
final Map<String, Object> body = new HashMap<>();
response.setContentType("application/json; charset=UTF-8");
body.put("status", HttpServletResponse.SC_FORBIDDEN);
body.put("error", "Forbidden");
body.put("message", accessDeniedException.getMessage());
body.put("path", request.getServletPath());
final ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getOutputStream(), body);
response.setStatus(HttpServletResponse.SC_OK);
}
}
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
final Map<String, Object> body = new HashMap<>();
response.setContentType("application/json; charset=UTF-8");
body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
body.put("error", "Unauthorized");
body.put("message", "인증되지 않은 요청입니다.");
body.put("path", request.getServletPath());
final ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getOutputStream(), body);
response.setStatus(HttpServletResponse.SC_OK);
}
}
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final JwtExceptionFilter jwtExceptionFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(CsrfConfigurer::disable)
.sessionManagement(
configurer ->
configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.headers((headerConfig) ->
headerConfig.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable
)
)
.authorizeHttpRequests((authorizeRequests) ->
authorizeRequests
.requestMatchers(PathRequest.toH2Console()).permitAll()
.requestMatchers(HttpMethod.POST, "/api/members").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth").permitAll()
.requestMatchers("/api/refresh-tokens/members/{memberId}").permitAll() //todo ip 비교와 같은 보안이 필요함
.anyRequest().authenticated()
)
.exceptionHandling(authenticationManager -> authenticationManager
.authenticationEntryPoint(new JwtAuthenticationEntryPoint())
.accessDeniedHandler(new JwtAccessDeniedHandler()))
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtExceptionFilter, JwtAuthenticationFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
@Transactional
public JwtToken login(String account, String password) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(account, password);
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
Member member = memberRepository.findByAccount(account)
.orElseThrow(MemberNotFound::new);
JwtToken token = jwtTokenProvider.generateToken(member.getId(), authentication);
return token;
}