Skip to content

Commit

Permalink
Merge pull request #6 from KusitmsHDmedi/feature/5-user
Browse files Browse the repository at this point in the history
[chore] Security 설정 & JWT Token 관리
  • Loading branch information
RyuKwanKon authored Sep 6, 2023
2 parents a84fedc + d180121 commit 1f788d0
Show file tree
Hide file tree
Showing 14 changed files with 386 additions and 8 deletions.
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

0 comments on commit 1f788d0

Please sign in to comment.