Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[chore] Security 설정 & JWT Token 관리 #6

Merged
merged 14 commits into from
Sep 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,26 @@ repositories {
}

dependencies {
// implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'

// spring security
implementation 'org.springframework.boot:spring-boot-starter-security'

// lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

// jwt
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.2'
implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.2'
implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.2'

// mysql
runtimeOnly 'com.mysql:mysql-connector-j'
}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,5 @@
@RestController
public class HealthCheckApiController {
@RequestMapping("/")
public String baggle() {
return "HDmedi Kusithm 2조!";
}
public String hdmedi() { return "HDmedi Kusithm 2조!"; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.kusithm.hdmedi_server.global.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@Configuration
public class JpaConfig {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.kusithm.hdmedi_server.global.config;

import com.kusithm.hdmedi_server.global.config.auth.UserIdArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final UserIdArgumentResolver userIdArgumentResolver;

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(userIdArgumentResolver);
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.kusithm.hdmedi_server.global.config.auth;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface AuthenticatedUserId {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.kusithm.hdmedi_server.global.config.auth;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.kusithm.hdmedi_server.global.error.dto.ErrorBaseResponse;
import com.kusithm.hdmedi_server.global.error.exception.ErrorCode;
import com.kusithm.hdmedi_server.global.error.exception.InvalidValueException;
import com.kusithm.hdmedi_server.global.error.exception.UnauthorizedException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

/**
* Filter 단계에서 발생할 수 있는 예외를 처리하는 객체입니다.
* JwtAuthenticationFilter에서 발생할 수 있는 예외를 관리하는 객체라고 생각해주시면 될 것 같습니다.
* SecurityConfig에서 JwtAuthenticationFilter를 등록 후 해당하는 예외를 관리하기 위해 등록합니다.
*/
@Slf4j
public class ExceptionHandlerFilter extends OncePerRequestFilter {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (UnauthorizedException e) {
handleUnauthorizedException(response, e);
} catch (Exception ee) {
handleException(response);
}
}

private void handleUnauthorizedException(HttpServletResponse response, Exception e) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
if (e instanceof UnauthorizedException ue) {
response.setStatus(ue.getErrorCode().getHttpStatus().value());
response.getWriter().write(objectMapper.writeValueAsString(ErrorBaseResponse.of(ue.getErrorCode())));
} else if (e instanceof InvalidValueException ie) {
response.setStatus(ie.getErrorCode().getHttpStatus().value());
response.getWriter().write(objectMapper.writeValueAsString(ErrorBaseResponse.of(ie.getErrorCode())));
}
}

private void handleException(HttpServletResponse response) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
response.setStatus(ErrorCode.INTERNAL_SERVER_ERROR.getHttpStatus().value());
response.getWriter().write(objectMapper.writeValueAsString(ErrorBaseResponse.of(ErrorCode.INTERNAL_SERVER_ERROR)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.kusithm.hdmedi_server.global.config.auth;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.kusithm.hdmedi_server.global.error.dto.ErrorBaseResponse;
import com.kusithm.hdmedi_server.global.error.exception.ErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

/**
* 인증되지 않은 유저가 요청을 했을 때 동작하는 객체.
* 예외를 다루기 위해 사용합니다.
*/
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();

@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
handleException(response);
}

private void handleException(HttpServletResponse response) throws IOException {
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("utf-8");
response.setStatus(ErrorCode.UNAUTHORIZED.getHttpStatus().value());
response.getWriter().write(objectMapper.writeValueAsString(ErrorBaseResponse.of(ErrorCode.UNAUTHORIZED)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.kusithm.hdmedi_server.global.config.auth;

import com.kusithm.hdmedi_server.global.config.jwt.JwtProvider;
import com.kusithm.hdmedi_server.global.error.exception.UnauthorizedException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

import static com.kusithm.hdmedi_server.global.error.exception.ErrorCode.INVALID_ACCESS_TOKEN;

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String AUTHORIZATION = "Authorization";
private static final String BEARER = "Bearer ";
private final JwtProvider jwtProvider;

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
final String accessToken = getAccessTokenFrom(request);
jwtProvider.validateAccessToken(accessToken);
final Long userId = jwtProvider.getSubject(accessToken);
setAuthentication(request, userId);
filterChain.doFilter(request, response);
}

private String getAccessTokenFrom(HttpServletRequest request) {
String accessToken = request.getHeader(AUTHORIZATION);
if (StringUtils.hasText(accessToken) && accessToken.startsWith(BEARER))
return accessToken.substring(BEARER.length());
throw new UnauthorizedException(INVALID_ACCESS_TOKEN);
}

private void setAuthentication(HttpServletRequest request, Long userId) {
UserAuthentication authentication = new UserAuthentication(userId, null, null);
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.kusithm.hdmedi_server.global.config.auth;

import com.kusithm.hdmedi_server.global.config.jwt.JwtProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@RequiredArgsConstructor
@EnableWebSecurity
@Configuration
public class SecurityConfig {

private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtProvider jwtProvider;
// TODO api 추가될 때 white list url 확인해서 추가하기.
private static final String[] whiteList = {"/api/user/signin", "/api/user/signup", "/api/user/reissue", "/"};

@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring().requestMatchers(whiteList);
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(sessionManagementConfigurer ->
sessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.exceptionHandling(exceptionHandlingConfigurer ->
exceptionHandlingConfigurer.authenticationEntryPoint(jwtAuthenticationEntryPoint))
.authorizeHttpRequests(authorizationManagerRequestMatcherRegistry ->
authorizationManagerRequestMatcherRegistry.anyRequest().authenticated())
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new ExceptionHandlerFilter(), JwtAuthenticationFilter.class)
.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.kusithm.hdmedi_server.global.config.auth;

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

import java.util.Collection;

public class UserAuthentication extends UsernamePasswordAuthenticationToken {
public UserAuthentication(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
super(principal, credentials, authorities);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.kusithm.hdmedi_server.global.config.auth;

import org.springframework.core.MethodParameter;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@Component
public class UserIdArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean hasUserIdAnnotation = parameter.hasParameterAnnotation(AuthenticatedUserId.class);
boolean hasLongType = Long.class.isAssignableFrom(parameter.getParameterType());
return hasUserIdAnnotation && hasLongType;
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
return SecurityContextHolder.getContext()
.getAuthentication()
.getPrincipal();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.kusithm.hdmedi_server.global.config.jwt;

import com.kusithm.hdmedi_server.global.error.exception.ErrorCode;
import com.kusithm.hdmedi_server.global.error.exception.UnauthorizedException;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.Getter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.security.Key;
import java.util.Base64;
import java.util.Date;

@Getter
@Component
public class JwtProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-token-expire-time}")
private long ACCESS_TOKEN_EXPIRE_TIME;
@Value("${jwt.refresh-token-expire-time}")
private long REFRESH_TOKEN_EXPIRE_TIME;

public Token issueToken(Long userId) {
return Token.of(generateToken(userId, true), generateToken(userId, false));
}

public void validateAccessToken(String accessToken) {
try {
getJwtParser().parseClaimsJws(accessToken);
} catch (ExpiredJwtException e) {
throw new UnauthorizedException(ErrorCode.EXPIRED_ACCESS_TOKEN);
} catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
throw new UnauthorizedException(ErrorCode.INVALID_ACCESS_TOKEN_VALUE);
}
}

public void validateRefreshToken(String refreshToken) {
try {
getJwtParser().parseClaimsJws(refreshToken);
} catch (ExpiredJwtException e) {
throw new UnauthorizedException(ErrorCode.EXPIRED_REFRESH_TOKEN);
} catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
throw new UnauthorizedException(ErrorCode.INVALID_REFRESH_TOKEN_VALUE);
}
}

public void equalsRefreshToken(String providedRefreshToken, String storedRefreshToken) {
if (!providedRefreshToken.equals(storedRefreshToken)) {
throw new UnauthorizedException(ErrorCode.NOT_MATCH_REFRESH_TOKEN);
}
}

public Long getSubject(String token) {
return Long.valueOf(getJwtParser().parseClaimsJws(token)
.getBody()
.getSubject());
}

private String generateToken(Long userId, boolean isAccessToken) {
final Date now = new Date();
final Date expiration = new Date(now.getTime() + (isAccessToken ? ACCESS_TOKEN_EXPIRE_TIME : REFRESH_TOKEN_EXPIRE_TIME));
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.setSubject(String.valueOf(userId))
.setIssuedAt(now)
.setExpiration(expiration)
.signWith(getSigningKey(), SignatureAlgorithm.HS256)
.compact();
}

private JwtParser getJwtParser() {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build();
}

private Key getSigningKey() {
String encoded = Base64.getEncoder().encodeToString(secretKey.getBytes());
return Keys.hmacShaKeyFor(encoded.getBytes());
}
}
Loading