diff --git a/doorip-api/build.gradle b/doorip-api/build.gradle index 044e402..6c7cae5 100644 --- a/doorip-api/build.gradle +++ b/doorip-api/build.gradle @@ -1,7 +1,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + implementation group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + implementation group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' implementation project(path: ':doorip-domain') diff --git a/doorip-api/src/main/java/org/doorip/auth/ExceptionHandlerFilter.java b/doorip-api/src/main/java/org/doorip/auth/ExceptionHandlerFilter.java new file mode 100644 index 0000000..76660e4 --- /dev/null +++ b/doorip-api/src/main/java/org/doorip/auth/ExceptionHandlerFilter.java @@ -0,0 +1,53 @@ +package org.doorip.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.doorip.common.ApiResponse; +import org.doorip.common.Constants; +import org.doorip.exception.UnauthorizedException; +import org.doorip.message.ErrorMessage; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.io.PrintWriter; + +@Slf4j +public class ExceptionHandlerFilter extends OncePerRequestFilter { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException { + try { + filterChain.doFilter(request, response); + } catch (UnauthorizedException e) { + handleUnauthorizedException(response, e); + } catch (Exception ee) { + handleException(response, ee); + } + } + + private void handleUnauthorizedException(HttpServletResponse response, Exception e) throws IOException { + UnauthorizedException ue = (UnauthorizedException) e; + ErrorMessage errorMessage = ue.getErrorMessage(); + HttpStatus httpStatus = errorMessage.getHttpStatus(); + setResponse(response, httpStatus, errorMessage); + } + + private void handleException(HttpServletResponse response, Exception e) throws IOException { + log.error(">>> Exception Handler Filter : ", e); + setResponse(response, HttpStatus.INTERNAL_SERVER_ERROR, ErrorMessage.INTERNAL_SERVER_ERROR); + } + + private void setResponse(HttpServletResponse response, HttpStatus httpStatus, ErrorMessage errorMessage) throws IOException { + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(Constants.CHARACTER_TYPE); + response.setStatus(httpStatus.value()); + PrintWriter writer = response.getWriter(); + writer.write(objectMapper.writeValueAsString(ApiResponse.of(errorMessage))); + } +} diff --git a/doorip-api/src/main/java/org/doorip/auth/JwtAuthenticationEntryPoint.java b/doorip-api/src/main/java/org/doorip/auth/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..e377fff --- /dev/null +++ b/doorip-api/src/main/java/org/doorip/auth/JwtAuthenticationEntryPoint.java @@ -0,0 +1,40 @@ +package org.doorip.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.doorip.common.ApiResponse; +import org.doorip.common.Constants; +import org.doorip.message.ErrorMessage; +import org.springframework.http.HttpStatus; +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; +import java.io.PrintWriter; + +@Slf4j +@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 { + setResponse(response, HttpStatus.UNAUTHORIZED, ErrorMessage.UNAUTHORIZED); + } + + private void setResponse(HttpServletResponse response, HttpStatus httpStatus, ErrorMessage errorMessage) throws IOException { + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(Constants.CHARACTER_TYPE); + response.setStatus(httpStatus.value()); + PrintWriter writer = response.getWriter(); + writer.write(objectMapper.writeValueAsString(ApiResponse.of(errorMessage))); + } +} \ No newline at end of file diff --git a/doorip-api/src/main/java/org/doorip/auth/JwtAuthenticationFilter.java b/doorip-api/src/main/java/org/doorip/auth/JwtAuthenticationFilter.java new file mode 100644 index 0000000..cf1a0e9 --- /dev/null +++ b/doorip-api/src/main/java/org/doorip/auth/JwtAuthenticationFilter.java @@ -0,0 +1,57 @@ +package org.doorip.auth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.doorip.auth.jwt.JwtProvider; +import org.doorip.auth.jwt.JwtValidator; +import org.doorip.common.Constants; +import org.doorip.exception.UnauthorizedException; +import org.doorip.message.ErrorMessage; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +import static org.doorip.auth.UserAuthentication.createUserAuthentication; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtValidator jwtValidator; + private final JwtProvider jwtProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + final String accessToken = getAccessToken(request); + jwtValidator.validateAccessToken(accessToken); + doAuthentication(request, jwtProvider.getSubject(accessToken)); + filterChain.doFilter(request, response); + } + + private String getAccessToken(HttpServletRequest request) { + String accessToken = request.getHeader(Constants.AUTHORIZATION); + if (StringUtils.hasText(accessToken) && accessToken.startsWith(Constants.BEARER)) { + return accessToken.substring(Constants.BEARER.length()); + } + throw new UnauthorizedException(ErrorMessage.INVALID_ACCESS_TOKEN); + } + + private void doAuthentication(HttpServletRequest request, Long userId) { + UserAuthentication authentication = createUserAuthentication(userId); + createAndSetWebAuthenticationDetails(request, authentication); + SecurityContext securityContext = SecurityContextHolder.getContext(); + securityContext.setAuthentication(authentication); + } + + private void createAndSetWebAuthenticationDetails(HttpServletRequest request, UserAuthentication authentication) { + WebAuthenticationDetailsSource webAuthenticationDetailsSource = new WebAuthenticationDetailsSource(); + WebAuthenticationDetails webAuthenticationDetails = webAuthenticationDetailsSource.buildDetails(request); + authentication.setDetails(webAuthenticationDetails); + } +} \ No newline at end of file diff --git a/doorip-api/src/main/java/org/doorip/auth/UserAuthentication.java b/doorip-api/src/main/java/org/doorip/auth/UserAuthentication.java new file mode 100644 index 0000000..ff740e7 --- /dev/null +++ b/doorip-api/src/main/java/org/doorip/auth/UserAuthentication.java @@ -0,0 +1,16 @@ +package org.doorip.auth; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class UserAuthentication extends UsernamePasswordAuthenticationToken { + private UserAuthentication(Object principal, Object credentials, Collection authorities) { + super(principal, credentials, authorities); + } + + public static UserAuthentication createUserAuthentication(Long userId) { + return new UserAuthentication(userId, null, null); + } +} diff --git a/doorip-api/src/main/java/org/doorip/auth/UserId.java b/doorip-api/src/main/java/org/doorip/auth/UserId.java new file mode 100644 index 0000000..f1ef4aa --- /dev/null +++ b/doorip-api/src/main/java/org/doorip/auth/UserId.java @@ -0,0 +1,11 @@ +package org.doorip.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 UserId { +} diff --git a/doorip-api/src/main/java/org/doorip/auth/UserIdArgumentResolver.java b/doorip-api/src/main/java/org/doorip/auth/UserIdArgumentResolver.java new file mode 100644 index 0000000..37ef464 --- /dev/null +++ b/doorip-api/src/main/java/org/doorip/auth/UserIdArgumentResolver.java @@ -0,0 +1,26 @@ +package org.doorip.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(UserId.class); + boolean isLongType = Long.class.isAssignableFrom(parameter.getParameterType()); + return hasUserIdAnnotation && isLongType; + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + return SecurityContextHolder.getContext() + .getAuthentication() + .getPrincipal(); + } +} diff --git a/doorip-api/src/main/java/org/doorip/auth/config/SecurityConfig.java b/doorip-api/src/main/java/org/doorip/auth/config/SecurityConfig.java new file mode 100644 index 0000000..e221629 --- /dev/null +++ b/doorip-api/src/main/java/org/doorip/auth/config/SecurityConfig.java @@ -0,0 +1,54 @@ +package org.doorip.auth.config; + +import lombok.RequiredArgsConstructor; +import org.doorip.auth.ExceptionHandlerFilter; +import org.doorip.auth.JwtAuthenticationEntryPoint; +import org.doorip.auth.JwtAuthenticationFilter; +import org.doorip.auth.jwt.JwtProvider; +import org.doorip.auth.jwt.JwtValidator; +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 JwtValidator jwtValidator; + private final JwtProvider jwtProvider; + + private static final String[] whiteList = {"/api/users/signin", "/api/users/signup", "/api/users/reissue", "/"}; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + return http + .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(sessionManagementConfigurer -> + sessionManagementConfigurer + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exceptionHandlingConfigurer -> + exceptionHandlingConfigurer + .authenticationEntryPoint(jwtAuthenticationEntryPoint)) + .authorizeHttpRequests(authorizationManagerRequestMatcherRegistry -> + authorizationManagerRequestMatcherRegistry + .anyRequest() + .authenticated()) + .addFilterBefore(new JwtAuthenticationFilter(jwtValidator, jwtProvider), UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(new ExceptionHandlerFilter(), JwtAuthenticationFilter.class) + .build(); + } + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return web -> web.ignoring().requestMatchers(whiteList); + } +} diff --git a/doorip-api/src/main/java/org/doorip/auth/config/WebConfig.java b/doorip-api/src/main/java/org/doorip/auth/config/WebConfig.java new file mode 100644 index 0000000..20c8aad --- /dev/null +++ b/doorip-api/src/main/java/org/doorip/auth/config/WebConfig.java @@ -0,0 +1,20 @@ +package org.doorip.auth.config; + +import lombok.RequiredArgsConstructor; +import org.doorip.auth.UserIdArgumentResolver; +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 resolvers) { + resolvers.add(userIdArgumentResolver); + } +} diff --git a/doorip-api/src/main/java/org/doorip/auth/jwt/JwtGenerator.java b/doorip-api/src/main/java/org/doorip/auth/jwt/JwtGenerator.java new file mode 100644 index 0000000..5f1b146 --- /dev/null +++ b/doorip-api/src/main/java/org/doorip/auth/jwt/JwtGenerator.java @@ -0,0 +1,65 @@ +package org.doorip.auth.jwt; + +import io.jsonwebtoken.Header; +import io.jsonwebtoken.JwtParser; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Base64; +import java.util.Date; + +@Component +public class JwtGenerator { + @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 String generateToken(Long userId, boolean isAccessToken) { + final Date now = generateNowDate(); + final Date expiration = generateExpirationDate(isAccessToken, now); + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setSubject(String.valueOf(userId)) + .setIssuedAt(now) + .setExpiration(expiration) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public JwtParser getJwtParser() { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build(); + } + + private Date generateNowDate() { + return new Date(); + } + + private Date generateExpirationDate(boolean isAccessToken, Date now) { + return new Date(now.getTime() + calculateExpirationTime(isAccessToken)); + } + + private Key getSigningKey() { + return Keys.hmacShaKeyFor(encodeSecretKey().getBytes()); + } + + private long calculateExpirationTime(boolean isAccessToken) { + if (isAccessToken) { + return ACCESS_TOKEN_EXPIRE_TIME; + } + return REFRESH_TOKEN_EXPIRE_TIME; + } + + private String encodeSecretKey() { + return Base64.getEncoder() + .encodeToString(secretKey.getBytes()); + } +} diff --git a/doorip-api/src/main/java/org/doorip/auth/jwt/JwtProvider.java b/doorip-api/src/main/java/org/doorip/auth/jwt/JwtProvider.java new file mode 100644 index 0000000..e7878c6 --- /dev/null +++ b/doorip-api/src/main/java/org/doorip/auth/jwt/JwtProvider.java @@ -0,0 +1,23 @@ +package org.doorip.auth.jwt; + +import io.jsonwebtoken.JwtParser; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class JwtProvider { + private final JwtGenerator jwtGenerator; + + public Token issueToken(Long userId) { + return Token.of(jwtGenerator.generateToken(userId, true), + jwtGenerator.generateToken(userId, false)); + } + + public Long getSubject(String token) { + JwtParser jwtParser = jwtGenerator.getJwtParser(); + return Long.valueOf(jwtParser.parseClaimsJws(token) + .getBody() + .getSubject()); + } +} diff --git a/doorip-api/src/main/java/org/doorip/auth/jwt/JwtValidator.java b/doorip-api/src/main/java/org/doorip/auth/jwt/JwtValidator.java new file mode 100644 index 0000000..d85b10d --- /dev/null +++ b/doorip-api/src/main/java/org/doorip/auth/jwt/JwtValidator.java @@ -0,0 +1,45 @@ +package org.doorip.auth.jwt; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtParser; +import lombok.RequiredArgsConstructor; +import org.doorip.exception.UnauthorizedException; +import org.doorip.message.ErrorMessage; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class JwtValidator { + private final JwtGenerator jwtGenerator; + + public void validateAccessToken(String accessToken) { + try { + parseToken(accessToken); + } catch (ExpiredJwtException e) { + throw new UnauthorizedException(ErrorMessage.EXPIRED_ACCESS_TOKEN); + } catch (Exception e) { + throw new UnauthorizedException(ErrorMessage.INVALID_ACCESS_TOKEN_VALUE); + } + } + + public void validateRefreshToken(String refreshToken) { + try { + parseToken(refreshToken); + } catch (ExpiredJwtException e) { + throw new UnauthorizedException(ErrorMessage.EXPIRED_REFRESH_TOKEN); + } catch (Exception e) { + throw new UnauthorizedException(ErrorMessage.INVALID_REFRESH_TOKEN_VALUE); + } + } + + public void equalsRefreshToken(String refreshToken, String storedRefreshToken) { + if (!refreshToken.equals(storedRefreshToken)) { + throw new UnauthorizedException(ErrorMessage.MISMATCH_REFRESH_TOKEN); + } + } + + private void parseToken(String token) { + JwtParser jwtParser = jwtGenerator.getJwtParser(); + jwtParser.parseClaimsJws(token); + } +} diff --git a/doorip-api/src/main/java/org/doorip/auth/jwt/Token.java b/doorip-api/src/main/java/org/doorip/auth/jwt/Token.java new file mode 100644 index 0000000..c9dc528 --- /dev/null +++ b/doorip-api/src/main/java/org/doorip/auth/jwt/Token.java @@ -0,0 +1,17 @@ +package org.doorip.auth.jwt; + +import lombok.AccessLevel; +import lombok.Builder; + +@Builder(access = AccessLevel.PRIVATE) +public record Token( + String accessToken, + String refreshToken +) { + public static Token of(String accessToken, String refreshToken) { + return Token.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/doorip-api/src/main/java/org/doorip/common/Constants.java b/doorip-api/src/main/java/org/doorip/common/Constants.java new file mode 100644 index 0000000..9f20a87 --- /dev/null +++ b/doorip-api/src/main/java/org/doorip/common/Constants.java @@ -0,0 +1,7 @@ +package org.doorip.common; + +public abstract class Constants { + public static final String AUTHORIZATION = "Authorization"; + public static final String BEARER = "Bearer "; + public static final String CHARACTER_TYPE = "utf-8"; +} diff --git a/doorip-common/src/main/java/org/doorip/message/ErrorMessage.java b/doorip-common/src/main/java/org/doorip/message/ErrorMessage.java index bf59122..76aadb7 100644 --- a/doorip-common/src/main/java/org/doorip/message/ErrorMessage.java +++ b/doorip-common/src/main/java/org/doorip/message/ErrorMessage.java @@ -17,6 +17,13 @@ public enum ErrorMessage { * 401 Unauthorized */ UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "e4010", "리소스 접근 권한이 없습니다."), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "e4011", "액세스 토큰의 형식이 올바르지 않습니다. Bearer 타입을 확인해 주세요."), + INVALID_ACCESS_TOKEN_VALUE(HttpStatus.UNAUTHORIZED, "e4012", "액세스 토큰의 값이 올바르지 않습니다."), + EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "e4013", "액세스 토큰이 만료되었습니다. 재발급 받아주세요."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "e4014", "리프레시 토큰의 형식이 올바르지 않습니다."), + INVALID_REFRESH_TOKEN_VALUE(HttpStatus.UNAUTHORIZED, "e4015", "리프레시 토큰의 값이 올바르지 않습니다."), + EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "e4016", "리프레시 토큰이 만료되었습니다. 다시 로그인해 주세요."), + MISMATCH_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "e4017", "리프레시 토큰이 일치하지 않습니다."), /** * 403 Forbidden