diff --git a/build.gradle b/build.gradle index 9a29c538..9978b1c0 100644 --- a/build.gradle +++ b/build.gradle @@ -25,14 +25,26 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' - // implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-security' runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'io.rest-assured:rest-assured:5.3.2' testImplementation 'org.springframework.boot:spring-boot-starter-test' - // testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.springframework.security:spring-security-test' + + // jjwt + implementation 'io.jsonwebtoken:jjwt-api:0.11.2' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2' + + //oauth +// implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + + //redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..180c6b03 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,8 @@ +version : "3" +services : + redis: + hostname: yanabada + container_name: yanabada-redis + image: redis:latest + ports: + - "6379:6379" diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/config/AppConfig.java b/src/main/java/kr/co/fastcampus/yanabada/common/config/AppConfig.java index d6aef72d..0f1e8680 100644 --- a/src/main/java/kr/co/fastcampus/yanabada/common/config/AppConfig.java +++ b/src/main/java/kr/co/fastcampus/yanabada/common/config/AppConfig.java @@ -6,6 +6,8 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; @Configuration public class AppConfig { @@ -17,4 +19,9 @@ public ObjectMapper objectMapper() { objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return objectMapper; } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } } diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/config/RedisConfig.java b/src/main/java/kr/co/fastcampus/yanabada/common/config/RedisConfig.java new file mode 100644 index 00000000..31aeb969 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/common/config/RedisConfig.java @@ -0,0 +1,43 @@ +package kr.co.fastcampus.yanabada.common.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Slf4j +@Configuration +@EnableRedisRepositories +public class RedisConfig { + + @Value("${spring.redis.host}") + private String host; + @Value("${spring.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisStandaloneConfiguration + = new RedisStandaloneConfiguration(); + redisStandaloneConfiguration.setHostName(host); + redisStandaloneConfiguration.setPort(port); + return new LettuceConnectionFactory(redisStandaloneConfiguration); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + + return redisTemplate; + } +} \ No newline at end of file diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/config/SecurityConfig.java b/src/main/java/kr/co/fastcampus/yanabada/common/config/SecurityConfig.java new file mode 100644 index 00000000..59224db5 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/common/config/SecurityConfig.java @@ -0,0 +1,80 @@ +package kr.co.fastcampus.yanabada.common.config; + +import java.util.List; +import kr.co.fastcampus.yanabada.common.jwt.filter.JwtAuthFilter; +import kr.co.fastcampus.yanabada.common.jwt.filter.JwtExceptionFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +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; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthFilter jwtAuthFilter; + private final JwtExceptionFilter jwtExceptionFilter; + + private static final String[] PERMIT_PATHS = { + "/", + "/**" + }; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http.httpBasic(AbstractHttpConfigurer::disable); + http.cors(cors -> cors.configurationSource(corsConfigurationSource())); + http.csrf(AbstractHttpConfigurer::disable); + http.sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ); + + http.authorizeHttpRequests(authorize -> authorize + .requestMatchers(PERMIT_PATHS).permitAll() + .anyRequest().authenticated() + ); + + //todo: oauth 설정 예정 + + http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtExceptionFilter, JwtAuthFilter.class); + + return http.build(); + } + + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of("http://localhost:5173", "https://api.weplanplans.site", "https://weplanplans.vercel.app", "https://dev-weplanplans.vercel.app", "http://localhost:8080")); // TODO: 5173 open + configuration.setAllowedMethods(List.of("*")); + configuration.setAllowedHeaders(List.of("*")); + configuration.addExposedHeader("Authorization"); + configuration.setAllowCredentials(true); //todo : 쿠키를 포함한 크로스 도메인 요청을 허용? 확인필요 + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + @ConditionalOnProperty(name = "spring.h2.console.enabled", havingValue = "true") + public WebSecurityCustomizer configureH2ConsoleEnable() { // h2-console 화면설정 + return web -> web.ignoring() + .requestMatchers(PathRequest.toH2Console()); + } + +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/exception/ClaimParseFailedException.java b/src/main/java/kr/co/fastcampus/yanabada/common/exception/ClaimParseFailedException.java new file mode 100644 index 00000000..357a3cb4 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/common/exception/ClaimParseFailedException.java @@ -0,0 +1,9 @@ +package kr.co.fastcampus.yanabada.common.exception; + +import static kr.co.fastcampus.yanabada.common.response.ErrorCode.CLAIM_PARSE_FAILED; + +public class ClaimParseFailedException extends BaseException { + public ClaimParseFailedException() { + super(CLAIM_PARSE_FAILED.getMessage()); + } +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/exception/TokenExpiredException.java b/src/main/java/kr/co/fastcampus/yanabada/common/exception/TokenExpiredException.java new file mode 100644 index 00000000..a9650bd4 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/common/exception/TokenExpiredException.java @@ -0,0 +1,9 @@ +package kr.co.fastcampus.yanabada.common.exception; + +import static kr.co.fastcampus.yanabada.common.response.ErrorCode.TOKEN_EXPIRED; + +public class TokenExpiredException extends BaseException { + public TokenExpiredException() { + super(TOKEN_EXPIRED.getMessage()); + } +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/exception/TokenNotValidatedException.java b/src/main/java/kr/co/fastcampus/yanabada/common/exception/TokenNotValidatedException.java new file mode 100644 index 00000000..cd4e53e8 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/common/exception/TokenNotValidatedException.java @@ -0,0 +1,9 @@ +package kr.co.fastcampus.yanabada.common.exception; + +import static kr.co.fastcampus.yanabada.common.response.ErrorCode.TOKEN_NOT_VALIDATED; + +public class TokenNotValidatedException extends BaseException { + public TokenNotValidatedException() { + super(TOKEN_NOT_VALIDATED.getMessage()); + } +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/jwt/constant/JwtConstant.java b/src/main/java/kr/co/fastcampus/yanabada/common/jwt/constant/JwtConstant.java new file mode 100644 index 00000000..e4b3fc42 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/common/jwt/constant/JwtConstant.java @@ -0,0 +1,9 @@ +package kr.co.fastcampus.yanabada.common.jwt.constant; + +public class JwtConstant { + public static final String BEARER_TYPE = "Bearer"; + public static final String BEARER_PREFIX = "Bearer "; + public static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; //30min + public static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7; //7days + public static final String AUTHORIZATION_HEADER = "Authorization"; +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/jwt/dto/TokenIssueResponse.java b/src/main/java/kr/co/fastcampus/yanabada/common/jwt/dto/TokenIssueResponse.java new file mode 100644 index 00000000..40c62f27 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/common/jwt/dto/TokenIssueResponse.java @@ -0,0 +1,7 @@ +package kr.co.fastcampus.yanabada.common.jwt.dto; + +public record TokenIssueResponse( + String accessToken, + String refreshToken +) { +} \ No newline at end of file diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/jwt/filter/JwtAuthFilter.java b/src/main/java/kr/co/fastcampus/yanabada/common/jwt/filter/JwtAuthFilter.java new file mode 100644 index 00000000..b4bcd2ea --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/common/jwt/filter/JwtAuthFilter.java @@ -0,0 +1,94 @@ +package kr.co.fastcampus.yanabada.common.jwt.filter; + +import static kr.co.fastcampus.yanabada.common.jwt.constant.JwtConstant.AUTHORIZATION_HEADER; +import static kr.co.fastcampus.yanabada.common.jwt.constant.JwtConstant.BEARER_PREFIX; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import kr.co.fastcampus.yanabada.common.exception.MemberNotFoundException; +import kr.co.fastcampus.yanabada.common.exception.TokenExpiredException; +import kr.co.fastcampus.yanabada.common.exception.TokenNotValidatedException; +import kr.co.fastcampus.yanabada.common.jwt.util.JwtProvider; +import kr.co.fastcampus.yanabada.common.security.PrincipalDetails; +import kr.co.fastcampus.yanabada.domain.member.entity.Member; +import kr.co.fastcampus.yanabada.domain.member.entity.ProviderType; +import kr.co.fastcampus.yanabada.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final MemberRepository memberRepository; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { + /* 토큰 로그인, 회원가입, 리프레시 토큰 재발급, 로그아웃일 경우 해당 필터 실행 안됨 */ + return request.getRequestURI().contains("token/") + || request.getRequestURI().contains("/sign-up") + || request.getRequestURI().contains("/login"); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + + String token = extractTokenFromRequest(request); + + // 토큰 검사 생략(모두 허용 URL의 경우 토큰 검사 통과) + if (!StringUtils.hasText(token)) { + doFilter(request, response, filterChain); + return; + } + + if (!jwtProvider.verifyToken(token)) { + throw new TokenExpiredException(); + } + + try { + String email = jwtProvider.getEmail(token); + ProviderType provider = ProviderType.valueOf(jwtProvider.getProvider(token)); + + Member findMember = memberRepository.getMember(email, provider); + + PrincipalDetails principalDetails = PrincipalDetails.of(findMember); + + // SecurityContext에 인증 객체를 등록 + Authentication auth = getAuthentication(principalDetails); + SecurityContextHolder.getContext().setAuthentication(auth); + } catch (MemberNotFoundException e) { + throw new TokenNotValidatedException(); + } + + filterChain.doFilter(request, response); + } + + public Authentication getAuthentication(PrincipalDetails principal) { + return new UsernamePasswordAuthenticationToken( + principal, "", principal.getAuthorities() + ); + } + + private String extractTokenFromRequest(HttpServletRequest request) { + String token = request.getHeader(AUTHORIZATION_HEADER); + if (StringUtils.hasText(token) && token.startsWith(BEARER_PREFIX)) { + return token.substring(BEARER_PREFIX.length()); + } + return null; + } +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/jwt/filter/JwtExceptionFilter.java b/src/main/java/kr/co/fastcampus/yanabada/common/jwt/filter/JwtExceptionFilter.java new file mode 100644 index 00000000..eaf0ecb6 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/common/jwt/filter/JwtExceptionFilter.java @@ -0,0 +1,57 @@ +package kr.co.fastcampus.yanabada.common.jwt.filter; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; +import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import kr.co.fastcampus.yanabada.common.exception.TokenExpiredException; +import kr.co.fastcampus.yanabada.common.response.ResponseBody; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtExceptionFilter extends OncePerRequestFilter { + + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + /* ControllerAdvice와 같은 ExHandler 역할 수행 */ + + try { + filterChain.doFilter(request, response); + } catch (TokenExpiredException e) { + completeResponse(response, e, UNAUTHORIZED.value()); + } catch (Exception e) { + completeResponse(response, e, BAD_REQUEST.value()); + } + } + + private void completeResponse( + HttpServletResponse response, + Exception e, + int status + ) throws IOException { + response.setStatus(status); + response.setContentType(APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + String responseBody = objectMapper + .writeValueAsString(ResponseBody.fail(e.getMessage())); + response.getWriter().write(responseBody); + } +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/jwt/service/TokenService.java b/src/main/java/kr/co/fastcampus/yanabada/common/jwt/service/TokenService.java new file mode 100644 index 00000000..79eb5a53 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/common/jwt/service/TokenService.java @@ -0,0 +1,36 @@ +package kr.co.fastcampus.yanabada.common.jwt.service; + +import static kr.co.fastcampus.yanabada.common.jwt.constant.JwtConstant.REFRESH_TOKEN_EXPIRE_TIME; + +import kr.co.fastcampus.yanabada.common.redis.RedisUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class TokenService { + + private final RedisUtils redisUtils; + + @Transactional + public void saveRefreshToken( + String email, + String provider, + String refreshToken + ) { + String value = email + " " + provider; + redisUtils.setData(refreshToken, value, REFRESH_TOKEN_EXPIRE_TIME); + } + + @Transactional + public String getValue(String refreshToken) { + return redisUtils.getData(refreshToken); + } + + @Transactional + public void deleteRefreshToken(String refreshToken) { + redisUtils.deleteData(refreshToken); + } + +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/jwt/util/JwtProvider.java b/src/main/java/kr/co/fastcampus/yanabada/common/jwt/util/JwtProvider.java new file mode 100644 index 00000000..339e2d0b --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/common/jwt/util/JwtProvider.java @@ -0,0 +1,96 @@ +package kr.co.fastcampus.yanabada.common.jwt.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import java.security.Key; +import java.util.Date; +import kr.co.fastcampus.yanabada.common.exception.ClaimParseFailedException; +import kr.co.fastcampus.yanabada.common.jwt.constant.JwtConstant; +import kr.co.fastcampus.yanabada.common.jwt.dto.TokenIssueResponse; +import kr.co.fastcampus.yanabada.common.jwt.service.TokenService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtProvider { + + private final TokenService tokenService; + + @Value("${jwt.secretKey}") + private String secretKeyPlain; + private Key secretKey; + + @PostConstruct + protected void init() { + byte[] keyBytes = Decoders.BASE64URL.decode(secretKeyPlain); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + public TokenIssueResponse generateTokenInfo(String email, String role, String provider) { + String accessToken = generateAccessToken(email, role, provider); + String refreshToken = generateRefreshToken(email, role, provider); + + tokenService.saveRefreshToken(email, provider, refreshToken); + return new TokenIssueResponse(accessToken, refreshToken); + } + + public String generateAccessToken(String email, String role, String provider) { + return generateToken(email, role, provider, JwtConstant.ACCESS_TOKEN_EXPIRE_TIME); + } + + public String generateRefreshToken(String email, String role, String provider) { + return generateToken(email, role, provider, JwtConstant.REFRESH_TOKEN_EXPIRE_TIME); + } + + private String generateToken( + String email, String role, + String provider, long tokenExpireTime + ) { + Claims claims = Jwts.claims().setSubject(email); + claims.put("role", role); + claims.put("provider", provider); + + Date now = new Date(); + + return Jwts.builder() + .setClaims(claims) // Payload 설정 + .setIssuedAt(now) // 발행일자 설정 + .setExpiration(new Date(now.getTime() + tokenExpireTime)) // 토큰 만료일짜 설정 + .signWith(secretKey) // 비밀 키로 토큰 서명 + .compact(); + } + + public boolean verifyToken(String token) { + return parseClaims(token) + .getExpiration() + .after(new Date()); + } + + public String getEmail(String token) { + return parseClaims(token).getSubject(); + } + + public String getRole(String token) { + return parseClaims(token).get("role", String.class); + } + + public String getProvider(String token) { + return parseClaims(token).get("provider", String.class); + } + + private Claims parseClaims(String accessToken) { + try { + return Jwts.parserBuilder().setSigningKey(secretKey) + .build().parseClaimsJws(accessToken).getBody(); + } catch (Exception e) { + throw new ClaimParseFailedException(); + } + } +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/redis/RedisUtils.java b/src/main/java/kr/co/fastcampus/yanabada/common/redis/RedisUtils.java new file mode 100644 index 00000000..2c9075bd --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/common/redis/RedisUtils.java @@ -0,0 +1,27 @@ +package kr.co.fastcampus.yanabada.common.redis; + +import java.util.concurrent.TimeUnit; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +@Service +public class RedisUtils { + + private final RedisTemplate redisTemplate; + + public RedisUtils(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + public void setData(String key, String value, Long expiredTime) { + redisTemplate.opsForValue().set(key, value, expiredTime, TimeUnit.MILLISECONDS); + } + + public String getData(String key) { + return (String) redisTemplate.opsForValue().get(key); + } + + public void deleteData(String key) { + redisTemplate.delete(key); + } +} \ No newline at end of file diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/response/ErrorCode.java b/src/main/java/kr/co/fastcampus/yanabada/common/response/ErrorCode.java index 94ba3336..9c121e03 100644 --- a/src/main/java/kr/co/fastcampus/yanabada/common/response/ErrorCode.java +++ b/src/main/java/kr/co/fastcampus/yanabada/common/response/ErrorCode.java @@ -15,6 +15,11 @@ public enum ErrorCode { ORDER_NOT_SELLABLE("판매할 수 없는 예약입니다."), INVALID_SELLING_PRICE_RANGE("판매가는 구매가보다 클 수 없습니다."), INVALID_SALE_END_DATE_RANGE("판매 중단 날짜는 현재 날짜 이상 체크인 날짜 이하여야 합니다."), + + CLAIM_PARSE_FAILED("토큰의 클레임을 읽을 수 없습니다."), + TOKEN_EXPIRED("토큰이 만료되었습니다."), + TOKEN_NOT_VALIDATED("잘못된 토큰입니다."), + ; private final String message; diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/security/PrincipalDetails.java b/src/main/java/kr/co/fastcampus/yanabada/common/security/PrincipalDetails.java new file mode 100644 index 00000000..979f161e --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/common/security/PrincipalDetails.java @@ -0,0 +1,60 @@ +package kr.co.fastcampus.yanabada.common.security; + +import static kr.co.fastcampus.yanabada.domain.member.entity.RoleType.ROLE_USER; + +import java.util.Collection; +import java.util.List; +import kr.co.fastcampus.yanabada.domain.member.entity.Member; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +public record PrincipalDetails( + String email, + String password, + String memberName +) implements UserDetails { + + public static PrincipalDetails of(Member member) { + return new PrincipalDetails(member.getEmail(), + member.getPassword(), + member.getMemberName() + ); + } + + @Override + public Collection getAuthorities() { + // TODO: 역할 관련 협의 필요 + return List.of(new SimpleGrantedAuthority(ROLE_USER.name())); + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return email; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/common/security/PrincipalDetailsService.java b/src/main/java/kr/co/fastcampus/yanabada/common/security/PrincipalDetailsService.java new file mode 100644 index 00000000..c2eeec10 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/common/security/PrincipalDetailsService.java @@ -0,0 +1,26 @@ +package kr.co.fastcampus.yanabada.common.security; + +import static kr.co.fastcampus.yanabada.domain.member.entity.ProviderType.EMAIL; + +import kr.co.fastcampus.yanabada.domain.member.entity.Member; +import kr.co.fastcampus.yanabada.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PrincipalDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + Member member = memberRepository.getMember(email, EMAIL); + return PrincipalDetails.of(member); + } +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/domain/auth/controller/AuthController.java b/src/main/java/kr/co/fastcampus/yanabada/domain/auth/controller/AuthController.java new file mode 100644 index 00000000..bb9d257c --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/domain/auth/controller/AuthController.java @@ -0,0 +1,78 @@ +package kr.co.fastcampus.yanabada.domain.auth.controller; + +import static kr.co.fastcampus.yanabada.common.jwt.constant.JwtConstant.AUTHORIZATION_HEADER; + +import kr.co.fastcampus.yanabada.common.exception.TokenExpiredException; +import kr.co.fastcampus.yanabada.common.jwt.dto.TokenIssueResponse; +import kr.co.fastcampus.yanabada.common.jwt.service.TokenService; +import kr.co.fastcampus.yanabada.common.jwt.util.JwtProvider; +import kr.co.fastcampus.yanabada.common.response.ResponseBody; +import kr.co.fastcampus.yanabada.domain.auth.dto.LoginRequest; +import kr.co.fastcampus.yanabada.domain.auth.dto.SignUpRequest; +import kr.co.fastcampus.yanabada.domain.auth.service.AuthService; +import kr.co.fastcampus.yanabada.domain.member.entity.Member; +import kr.co.fastcampus.yanabada.domain.member.entity.ProviderType; +import kr.co.fastcampus.yanabada.domain.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api") +public class AuthController { + + private final AuthService authService; + private final TokenService tokenService; + private final JwtProvider jwtProvider; + private final MemberService memberService; + + @PostMapping("/sign-up") + public ResponseBody signUp(@RequestBody SignUpRequest signUpRequest) { + return ResponseBody.ok(authService.signUp(signUpRequest)); + } + + @PostMapping("/login") + public ResponseBody login(@RequestBody LoginRequest loginRequest) { + return ResponseBody.ok(authService.login(loginRequest)); + } + + @PostMapping("token/logout") + public String logout(@RequestHeader(AUTHORIZATION_HEADER) final String refreshToken) { + + tokenService.deleteRefreshToken(refreshToken); + return "success"; //todo: return값 변경 예정 + } + + @PostMapping("/token/refresh") + public String refresh(@RequestHeader(AUTHORIZATION_HEADER) final String refreshToken) { + + log.info("token = {}", refreshToken); + + if (StringUtils.hasText(refreshToken) && jwtProvider.verifyToken(refreshToken)) { + String value = tokenService.getValue(refreshToken); + if (value == null) { + throw new TokenExpiredException(); //todo: ControllerAdvice에서 핸들러 처리 + } + String[] splits = value.split(" "); + String email = splits[0]; + ProviderType provider = ProviderType.valueOf(splits[1]); + Member findMember = memberService.findMember(email, provider); + + return jwtProvider.generateAccessToken( + findMember.getEmail(), + findMember.getRoleType().name(), + findMember.getProviderType().name() + ); + } + + return "Not Valid refreshToken"; //todo: 반환 값 변경 예정 + } + +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/domain/auth/dto/LoginRequest.java b/src/main/java/kr/co/fastcampus/yanabada/domain/auth/dto/LoginRequest.java new file mode 100644 index 00000000..3d185ec2 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/domain/auth/dto/LoginRequest.java @@ -0,0 +1,12 @@ +package kr.co.fastcampus.yanabada.domain.auth.dto; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; + +public record LoginRequest( + String email, + String password +) { + public UsernamePasswordAuthenticationToken toAuthentication() { + return new UsernamePasswordAuthenticationToken(email, password); + } +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/domain/auth/dto/SignUpRequest.java b/src/main/java/kr/co/fastcampus/yanabada/domain/auth/dto/SignUpRequest.java new file mode 100644 index 00000000..6d88404d --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/domain/auth/dto/SignUpRequest.java @@ -0,0 +1,12 @@ +package kr.co.fastcampus.yanabada.domain.auth.dto; + +public record SignUpRequest( + String email, + String password, + String memberName, + String nickName, + String phoneNumber, + String deviceKey + +) { +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/domain/auth/service/AuthService.java b/src/main/java/kr/co/fastcampus/yanabada/domain/auth/service/AuthService.java new file mode 100644 index 00000000..1e58cded --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/domain/auth/service/AuthService.java @@ -0,0 +1,58 @@ +package kr.co.fastcampus.yanabada.domain.auth.service; + +import static kr.co.fastcampus.yanabada.domain.member.entity.ProviderType.EMAIL; +import static kr.co.fastcampus.yanabada.domain.member.entity.RoleType.ROLE_USER; + +import kr.co.fastcampus.yanabada.common.jwt.dto.TokenIssueResponse; +import kr.co.fastcampus.yanabada.common.jwt.util.JwtProvider; +import kr.co.fastcampus.yanabada.domain.auth.dto.LoginRequest; +import kr.co.fastcampus.yanabada.domain.auth.dto.SignUpRequest; +import kr.co.fastcampus.yanabada.domain.member.entity.Member; +import kr.co.fastcampus.yanabada.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + private final JwtProvider jwtProvider; + private final AuthenticationManagerBuilder authenticationManagerBuilder; + + @Transactional + public Long signUp(SignUpRequest signUpRequest) { + if (memberRepository.existsByEmailAndProviderType(signUpRequest.email(), EMAIL)) { + throw new RuntimeException("이미 존재하는 이메일"); //todo custom + 닉네임도 중복 체크 + } + + String encodedPassword = passwordEncoder.encode(signUpRequest.password()); + Member member = Member.builder() + .email(signUpRequest.email()) + .memberName(signUpRequest.memberName()) + .nickName(signUpRequest.nickName()) + .password(encodedPassword) + .phoneNumber(signUpRequest.phoneNumber()) + .roleType(ROLE_USER) + .providerType(EMAIL) + .build(); + + Member savedMember = memberRepository.save(member); + return savedMember.getId(); + } + + @Transactional + public TokenIssueResponse login(LoginRequest loginRequest) { + UsernamePasswordAuthenticationToken authenticationToken = loginRequest.toAuthentication(); + authenticationManagerBuilder.getObject().authenticate(authenticationToken); + + return jwtProvider.generateTokenInfo(loginRequest.email(), ROLE_USER.name(), EMAIL.name()); + } +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/domain/member/controller/MemberController.java b/src/main/java/kr/co/fastcampus/yanabada/domain/member/controller/MemberController.java new file mode 100644 index 00000000..baef7be9 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/domain/member/controller/MemberController.java @@ -0,0 +1,21 @@ +package kr.co.fastcampus.yanabada.domain.member.controller; + +import kr.co.fastcampus.yanabada.common.redis.RedisUtils; +import kr.co.fastcampus.yanabada.domain.member.service.MemberService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class MemberController { + + + +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/domain/member/entity/Member.java b/src/main/java/kr/co/fastcampus/yanabada/domain/member/entity/Member.java index c5d59d18..31b0dd1f 100644 --- a/src/main/java/kr/co/fastcampus/yanabada/domain/member/entity/Member.java +++ b/src/main/java/kr/co/fastcampus/yanabada/domain/member/entity/Member.java @@ -1,15 +1,39 @@ package kr.co.fastcampus.yanabada.domain.member.entity; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import kr.co.fastcampus.yanabada.common.baseentity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder @Entity public class Member extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + private String email; + private String memberName; + private String nickName; + private String password; + private String phoneNumber; + private String imageUrl; + @Builder.Default + private Integer point = 0; + @Enumerated(EnumType.STRING) + private RoleType roleType; + @Enumerated(EnumType.STRING) + private ProviderType providerType; + private String deviceKey; + } diff --git a/src/main/java/kr/co/fastcampus/yanabada/domain/member/entity/ProviderType.java b/src/main/java/kr/co/fastcampus/yanabada/domain/member/entity/ProviderType.java new file mode 100644 index 00000000..4f850424 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/domain/member/entity/ProviderType.java @@ -0,0 +1,5 @@ +package kr.co.fastcampus.yanabada.domain.member.entity; + +public enum ProviderType { + EMAIL, KAKAO +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/domain/member/entity/RoleType.java b/src/main/java/kr/co/fastcampus/yanabada/domain/member/entity/RoleType.java new file mode 100644 index 00000000..f2dce932 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/domain/member/entity/RoleType.java @@ -0,0 +1,5 @@ +package kr.co.fastcampus.yanabada.domain.member.entity; + +public enum RoleType { + ROLE_USER, ROLE_ADMIN +} diff --git a/src/main/java/kr/co/fastcampus/yanabada/domain/member/repository/MemberRepository.java b/src/main/java/kr/co/fastcampus/yanabada/domain/member/repository/MemberRepository.java index b7543b2d..79dbcbb9 100644 --- a/src/main/java/kr/co/fastcampus/yanabada/domain/member/repository/MemberRepository.java +++ b/src/main/java/kr/co/fastcampus/yanabada/domain/member/repository/MemberRepository.java @@ -1,7 +1,9 @@ package kr.co.fastcampus.yanabada.domain.member.repository; +import java.util.Optional; import kr.co.fastcampus.yanabada.common.exception.MemberNotFoundException; import kr.co.fastcampus.yanabada.domain.member.entity.Member; +import kr.co.fastcampus.yanabada.domain.member.entity.ProviderType; import org.springframework.data.jpa.repository.JpaRepository; public interface MemberRepository extends JpaRepository { @@ -9,4 +11,14 @@ public interface MemberRepository extends JpaRepository { default Member getMember(Long id) { return findById(id).orElseThrow(MemberNotFoundException::new); } + + default Member getMember(String email, ProviderType providerType) { + return findMemberByEmailAndProviderType(email, providerType) + .orElseThrow(MemberNotFoundException::new); + } + + boolean existsByEmailAndProviderType(String email, ProviderType provider); + + Optional findMemberByEmailAndProviderType(String email, ProviderType providerType); + } diff --git a/src/main/java/kr/co/fastcampus/yanabada/domain/member/service/MemberService.java b/src/main/java/kr/co/fastcampus/yanabada/domain/member/service/MemberService.java new file mode 100644 index 00000000..2bc6a722 --- /dev/null +++ b/src/main/java/kr/co/fastcampus/yanabada/domain/member/service/MemberService.java @@ -0,0 +1,35 @@ +package kr.co.fastcampus.yanabada.domain.member.service; + +import kr.co.fastcampus.yanabada.domain.member.entity.Member; +import kr.co.fastcampus.yanabada.domain.member.entity.ProviderType; +import kr.co.fastcampus.yanabada.domain.member.entity.RoleType; +import kr.co.fastcampus.yanabada.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + + @Transactional + public void saveMember() { + Member member = Member.builder() + .email("test@test.com") + .memberName("test") + .nickName("test") + .password("1234") + .roleType(RoleType.ROLE_USER) + .providerType(ProviderType.EMAIL) + .build(); + memberRepository.save(member); + } + + @Transactional + public Member findMember(String email, ProviderType provider) { + return memberRepository.getMember(email, provider); + } + +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 67d461bc..9c4ed147 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -15,4 +15,10 @@ spring: properties: hibernate: show_sql: true - format_sql: true \ No newline at end of file + format_sql: true + redis: + host: localhost + port: 6379 + +jwt: + secretKey: yanabadaSecretKeyyanabadaSecretKeyyanabadaSecretKey