From ace5bb5481974cdc02c7c62ef900176e52aac316 Mon Sep 17 00:00:00 2001 From: leesanghun Date: Tue, 29 Oct 2024 17:14:14 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85?= =?UTF-8?q?=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat : Swagger JWT 활성화 Swagger에서 JWT token 로그인이 가능하도록 활성화한다. * chore : jwt 관련 환경 설정 application.yml에 jwt 관련 변수 추가와 build.gradle에 Security 관련 dependency 추가 * chore : SecurityConfig 추가 Spring Security 관련 설정을 할 Security Config class를 추가한다. * feat : Entity 관련 class 생성 User domain 관련 Entity 추가 User, BaseEntity, UserRepository 생성 * feat : JWT 토큰 관련 로직 작성 JwtRequestFilter, JwtService, JwtToken 생성 * feat : Controller 작성 회원가입, 로그인, 로그아웃, refresh token 재생성을 할 Presentation Layer 함수 작성 * feat : dto 작성 이후 사용할지는 모르겠으나 일단 필요해보이는 dto 모두 작성 * feat : service 로직 작성 비즈니스 메인 로직을 할 Service 로직 작성, 추가적으로 converter와 ErrorStatus 추가 * refactor : package 분류 class package 분류 * fix : import 문제 해결 git merge를 하며 잘 못 import 된 CommonResponse import 문제 해결 * fix : SQL 예약어 충돌 문제 해결 user와 group은 sql 예약어여서 해당 문제 해결 * fix : swagger 및 security 해결 SwaggerConfig, SecurityConfig 수정을 통해 Security에서 회원가입, 로그인을 제외한 나머지 경로로는 토큰 인증을 하도록 하고 swagger에서 걸리도록 * fix : 부분적인 로직 수정 부분적으로 잘못되거나 수정이 필요한 로직 수정 * refactor : 안쓰는 애들 해결 안쓰는 코드, import 등 지저분한 코드 해결 * refactor : SwaggerConfig 주석 삭제 안쓰는 주석 삭제 * refactor : refresh 로직 수정 refresh 실행 시 JwtRequestFilter의 reIssueRefreshToken 사용하도록 * fix : error code numbering ErrorStatus의 ErrorCode를 넘버링하여 Id처럼 활용할 수 있게 함 * fix(userdetailService) : Array -> List Collection을 활용하기 위해 Array에서 List로 변경 * fix : CommonResponse 변경 CommonResponse 반환하도록 Controller 반환 타입 변경 * fix : reIssueRefreshToken 위치 변경 refresh 토큰 재발급 함수의 위치를 RequestFilter에서 Service로 변경 * fix : 안쓰는 Dto 삭제 * fix(service) : service 로직 수정 login 시 accessToken과 refreshToken 두 개다 반환하도록 변경, Transactional 적용 만약을 위해 refreshAllToken 함수 작성해놓음 * fix(security) : 허용 엔드포인트 수정 이전 변경사항에 알맞게 security 허용 엔드포인트 수정 * fix(environment) : 환경 변수 처리 환경 변수를 .env file로 처리하여 이에 맞게 수정 * fix : 불필요한 주석 제거 * fix : user, sender 연관관계 설정 * fix : 요청 사항 적용 * fix(test) : test yml에 env config 추가 딱히 쓸모는 없지만 우선 추가해놓음 --- .gitignore | 1 + build.gradle | 16 ++- compose.yaml | 5 +- .../com/pictalk/global/common/BaseEntity.java | 34 +++++ .../pictalk/global/config/SecurityConfig.java | 97 ++++++++++++++ .../pictalk/global/config/SwaggerConfig.java | 26 +++- .../pictalk/global/jwt/JwtRequestFilter.java | 87 ++++++++++++ .../com/pictalk/global/jwt/JwtService.java | 125 ++++++++++++++++++ .../global/payload/status/ErrorStatus.java | 17 +++ .../java/com/pictalk/group/domain/Group.java | 2 +- .../user/controller/UserController.java | 56 ++++++++ .../pictalk/user/converter/UserConverter.java | 33 +++++ .../java/com/pictalk/user/domain/User.java | 43 +++--- .../user/domain/dto/UserRequestDto.java | 22 +++ .../user/domain/dto/UserResponseDto.java | 21 +++ .../user/repository/UserRepository.java | 16 +++ .../user/service/MyUserDetailsService.java | 25 ++++ .../com/pictalk/user/service/UserService.java | 123 +++++++++++++++++ src/main/resources/application.yml | 19 ++- src/test/resources/application.yml | 9 ++ 20 files changed, 742 insertions(+), 35 deletions(-) create mode 100644 src/main/java/com/pictalk/global/common/BaseEntity.java create mode 100644 src/main/java/com/pictalk/global/config/SecurityConfig.java create mode 100644 src/main/java/com/pictalk/global/jwt/JwtRequestFilter.java create mode 100644 src/main/java/com/pictalk/global/jwt/JwtService.java create mode 100644 src/main/java/com/pictalk/user/controller/UserController.java create mode 100644 src/main/java/com/pictalk/user/converter/UserConverter.java create mode 100644 src/main/java/com/pictalk/user/domain/dto/UserRequestDto.java create mode 100644 src/main/java/com/pictalk/user/domain/dto/UserResponseDto.java create mode 100644 src/main/java/com/pictalk/user/repository/UserRepository.java create mode 100644 src/main/java/com/pictalk/user/service/MyUserDetailsService.java create mode 100644 src/main/java/com/pictalk/user/service/UserService.java diff --git a/.gitignore b/.gitignore index c2065bc..a93ec83 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ HELP.md +.env .gradle build/ !gradle/wrapper/gradle-wrapper.jar diff --git a/build.gradle b/build.gradle index 7bf23ba..d0c9b89 100644 --- a/build.gradle +++ b/build.gradle @@ -28,12 +28,26 @@ dependencies { compileOnly 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' + implementation 'org.springframework.security:spring-security-crypto' +// implementation('org.springframework.security:spring-security-oauth2-client') + implementation 'org.springframework.security:spring-security-test' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // Validation implementation 'org.springframework.boot:spring-boot-starter-validation' - // Docker + // Environment // developmentOnly 'org.springframework.boot:spring-boot-docker-compose' + implementation 'io.github.cdimascio:dotenv-java:2.2.0' // Database implementation 'org.springframework.boot:spring-boot-starter-data-jpa' diff --git a/compose.yaml b/compose.yaml index 3000b40..90abc27 100644 --- a/compose.yaml +++ b/compose.yaml @@ -22,6 +22,5 @@ services: container_name: mysqldb ports: - "3306:3306" - environment: - MYSQL_DATABASE: picTalk - MYSQL_ROOT_PASSWORD: "1234" \ No newline at end of file + env_file: + - .env \ No newline at end of file diff --git a/src/main/java/com/pictalk/global/common/BaseEntity.java b/src/main/java/com/pictalk/global/common/BaseEntity.java new file mode 100644 index 0000000..a9f60c0 --- /dev/null +++ b/src/main/java/com/pictalk/global/common/BaseEntity.java @@ -0,0 +1,34 @@ +package com.pictalk.global.common; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + + @CreatedDate + @JsonFormat(timezone = "Asia/Seoul") + private LocalDateTime createdAt = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES); + + @LastModifiedDate + @JsonFormat(timezone = "Asia/Seoul") + private LocalDateTime updatedAt = LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES); + + @Column(name = "deleted_at") + @JsonFormat(timezone = "Asia/Seoul") + private LocalDateTime deletedAt; + + public void softDelete() { + this.deletedAt = LocalDateTime.now(); + } +} \ No newline at end of file diff --git a/src/main/java/com/pictalk/global/config/SecurityConfig.java b/src/main/java/com/pictalk/global/config/SecurityConfig.java new file mode 100644 index 0000000..f38b797 --- /dev/null +++ b/src/main/java/com/pictalk/global/config/SecurityConfig.java @@ -0,0 +1,97 @@ +package com.pictalk.global.config; + + +import com.pictalk.global.jwt.JwtRequestFilter; +import com.pictalk.global.jwt.JwtService; +import com.pictalk.user.repository.UserRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.DefaultRedirectStrategy; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; + +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 JwtRequestFilter jwtFilter; + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowCredentials(true); + config.setAllowedOrigins(List.of("http://localhost:8080", "http://localhost:5173")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("*")); + + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) + .httpBasic(HttpBasicConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers( + "/users/signup", + "/users/signin", + "/users/refresh", + "/users/logout", + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-ui.html", + "/swagger/**" + ).permitAll() // /auth/** 엔드포인트는 인증 없이 접근 가능 + .anyRequest().authenticated() // 그 외 모든 요청은 인증 필요 + ) +// .authorizeHttpRequests(requests -> +// requests.anyRequest().permitAll() // 모든 요청을 모든 사용자에게 허용 +// ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // JWT를 사용하므로 세션 사용 안함 + + // JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가 + http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean + public RedirectStrategy redirectStrategy() { + return new DefaultRedirectStrategy(); // 기본 리다이렉트 전략 사용 + } +} diff --git a/src/main/java/com/pictalk/global/config/SwaggerConfig.java b/src/main/java/com/pictalk/global/config/SwaggerConfig.java index 2f5f706..2066cdd 100644 --- a/src/main/java/com/pictalk/global/config/SwaggerConfig.java +++ b/src/main/java/com/pictalk/global/config/SwaggerConfig.java @@ -3,9 +3,11 @@ import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.info.Info; import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.servers.Server; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.Components; @Configuration @OpenAPIDefinition( @@ -17,8 +19,26 @@ public class SwaggerConfig { @Bean - public OpenAPI customOpenAPI() { + public OpenAPI openAPI() { + String jwt = "JWT"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwt); + Components components = new Components().addSecuritySchemes(jwt, new SecurityScheme() + .name(jwt) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT") + ); + return new OpenAPI() - .addServersItem(new Server().url("/")); + .components(components) + .info(customOpenAPI()) + .addSecurityItem(securityRequirement) + .components(components); + + } + public io.swagger.v3.oas.models.info.Info customOpenAPI() { + return new io.swagger.v3.oas.models.info.Info() + .title("Pic&Talk API 명세서") + .version("1.0"); } } diff --git a/src/main/java/com/pictalk/global/jwt/JwtRequestFilter.java b/src/main/java/com/pictalk/global/jwt/JwtRequestFilter.java new file mode 100644 index 0000000..9ab7472 --- /dev/null +++ b/src/main/java/com/pictalk/global/jwt/JwtRequestFilter.java @@ -0,0 +1,87 @@ +package com.pictalk.global.jwt; + +import com.pictalk.global.exception.GeneralException; +import com.pictalk.global.payload.status.ErrorStatus; +import com.pictalk.user.domain.User; +import com.pictalk.user.repository.UserRepository; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +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.authority.mapping.GrantedAuthoritiesMapper; +import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@RequiredArgsConstructor +@Slf4j +@Component +public class JwtRequestFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + private final UserRepository userRepository; + + private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + checkAccessTokenAndAuthentication(request, response, filterChain); + } + + public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + log.info("checkAccessTokenAndAuthentication() 호출"); + + // Access Token 추출 및 유효성 검사 + jwtService.extractAccessToken(request) + .filter(jwtService::isTokenValid) + .flatMap(jwtService::extractEmail) // 이메일 추출 + .flatMap(userRepository::findByEmail) // 사용자 정보 조회 + .ifPresent(user -> { + log.info("사용자 정보가 발견되었습니다: {}", user); + saveAuthentication((User) user); + log.info("사용자 인증 정보가 저장되었습니다: {}", user); + }); + + // 다음 필터 체인 실행 + filterChain.doFilter(request, response); + } + + public void saveAuthentication(User user) { + UserDetails userDetails = org.springframework.security.core.userdetails.User.builder() + .username(user.getEmail()) + .password(user.getPassword()) + .build(); + + Authentication authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, authoritiesMapper.mapAuthorities(userDetails.getAuthorities())); + + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + public void validatePassword(String password) { + if (password == null || password.length() < 8) { + throw new GeneralException(ErrorStatus.USER_PASSWORD_NOT_VALID); + } + + if (!password.matches(".*[a-z].*")) { + throw new GeneralException(ErrorStatus.USER_PASSWORD_NOT_VALID); + } + + if (!password.matches(".*\\d.*")) { + throw new GeneralException(ErrorStatus.USER_PASSWORD_NOT_VALID); + } + + if (!password.matches(".*[!@#$%^&*()].*")) { + throw new GeneralException(ErrorStatus.USER_PASSWORD_NOT_VALID); + } + } +} diff --git a/src/main/java/com/pictalk/global/jwt/JwtService.java b/src/main/java/com/pictalk/global/jwt/JwtService.java new file mode 100644 index 0000000..0442160 --- /dev/null +++ b/src/main/java/com/pictalk/global/jwt/JwtService.java @@ -0,0 +1,125 @@ +package com.pictalk.global.jwt; + +import com.pictalk.user.domain.User; +import com.pictalk.user.repository.UserRepository; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import java.security.Key; +import java.util.Date; +import java.util.Optional; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import io.jsonwebtoken.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Getter +@Slf4j +public class JwtService { + + @Value("${jwt.secret}") + private String secretKey; + + @Value("${jwt.access.expiration}") + private Long accessTokenExpirationPeriod; + + @Value("${jwt.refresh.expiration}") + private Long refreshTokenExpirationPeriod; + + @Value("${jwt.access.header}") + private String accessHeader; + + @Value("${jwt.refresh.header}") + private String refreshHeader; + + private static final String ACCESS_TOKEN_SUBJECT = "AccessToken"; + private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken"; + private static final String EMAIL_CLAIM = "email"; + private static final String BEARER = "Bearer "; + + private final UserRepository userRepository; + private Key key; + + @PostConstruct + public void init() { + key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey)); + } + + public String createAccessToken(String email) { + Date now = new Date(); + return Jwts.builder() + .setSubject(ACCESS_TOKEN_SUBJECT) + .setExpiration(new Date(now.getTime() + accessTokenExpirationPeriod)) + .claim(EMAIL_CLAIM, email) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String createRefreshToken() { + Date now = new Date(); + return Jwts.builder() + .setSubject(REFRESH_TOKEN_SUBJECT) + .setExpiration(new Date(now.getTime() + refreshTokenExpirationPeriod)) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + +// saveAndFlush 사용으로 @Transactional은 없어도 될 듯 + public String reIssueRefreshToken(User user) { + String reIssuedRefreshToken = createRefreshToken(); + user.updateRefreshToken(reIssuedRefreshToken); + userRepository.saveAndFlush(user); + return reIssuedRefreshToken; + } + + + public Optional extractRefreshToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(refreshHeader)) + .filter(refreshToken -> refreshToken.startsWith(BEARER)) + .map(refreshToken -> refreshToken.replace(BEARER, "")); + } + + public Optional extractAccessToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(accessHeader)) + .filter(refreshToken -> refreshToken.startsWith(BEARER)) + .map(refreshToken -> refreshToken.replace(BEARER, "")); + } + + public boolean isTokenValid(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (Exception e) { + log.error("Invalid token: {}", e.getMessage()); + return false; + } + } + + public Optional extractEmail(String accessToken) { + return decodeAccessToken(accessToken) + .map(claims -> claims.get(EMAIL_CLAIM, String.class)); + } + + + public Optional decodeAccessToken(String accessToken) { + try { + Claims claims = Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(accessToken) + .getBody(); + return Optional.of(claims); + } catch (ExpiredJwtException e) { + log.info("Token has expired."); + return Optional.of(e.getClaims()); // 만료된 토큰의 Claims 반환 + } catch (Exception e) { + log.error("Token is invalid: {}", e.getMessage()); + return Optional.empty(); // 유효하지 않은 경우 빈 값 반환 + } + } +} diff --git a/src/main/java/com/pictalk/global/payload/status/ErrorStatus.java b/src/main/java/com/pictalk/global/payload/status/ErrorStatus.java index f2fc187..c6a19a5 100644 --- a/src/main/java/com/pictalk/global/payload/status/ErrorStatus.java +++ b/src/main/java/com/pictalk/global/payload/status/ErrorStatus.java @@ -13,6 +13,23 @@ public enum ErrorStatus implements BaseStatus { BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON_401", "인증이 필요합니다."), FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON_403", "금지된 요청입니다."), + + // User Error + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER_1001", "사용자를 찾을 수 없습니다."), + USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER_1002", "이미 존재하는 사용자입니다."), + USER_USERNAME_NOT_MATCH(HttpStatus.BAD_REQUEST, "USER_1003", "사용자 이름이 일치하지 않습니다."), + USER_PASSWORD_NOT_MATCH(HttpStatus.BAD_REQUEST, "USER_1004", "비밀번호가 일치하지 않습니다."), + USER_EMAIL_NOT_MATCH(HttpStatus.BAD_REQUEST, "USER_1005", "이메일이 일치하지 않습니다."), + + USER_EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER_1006", "이미 존재하는 이메일입니다."), + USER_USERNAME_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER_1007", "이미 존재하는 사용자 이름입니다."), + USER_PASSWORD_NOT_VALID(HttpStatus.BAD_REQUEST, "USER_1008", "비밀번호는 최소 8자 이상이어야 하며, 하나 이상의 숫자, 특수 문자를 포함해야 합니다."), + USER_EMAIL_NOT_VALID(HttpStatus.BAD_REQUEST, "USER_1009", "이메일이 유효하지 않습니다."), + USER_USERNAME_NOT_VALID(HttpStatus.BAD_REQUEST, "USER_1010", "사용자 이름이 유효하지 않습니다."), + USER_REFRESH_TOKEN_NOT_VALID(HttpStatus.BAD_REQUEST, "USER_1011", "리프레시 토큰이 유효하지 않습니다."), + USER_REFRESH_TOKEN_EXPIRED(HttpStatus.BAD_REQUEST, "USER_1012", "리프레시 토큰이 만료되었습니다."), + USER_ACCESS_TOKEN_EXPIRED(HttpStatus.BAD_REQUEST, "USER_1013", "액세스 토큰이 만료되었습니다."), + USER_ACCESS_TOKEN_NOT_VALID(HttpStatus.BAD_REQUEST, "USER_1014", "액세스 토큰이 유효하지 않습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/pictalk/group/domain/Group.java b/src/main/java/com/pictalk/group/domain/Group.java index f30cc9c..816b292 100644 --- a/src/main/java/com/pictalk/group/domain/Group.java +++ b/src/main/java/com/pictalk/group/domain/Group.java @@ -9,7 +9,7 @@ import java.util.List; @Entity -@Table(name = "groups") +@Table(name = "`groups`") @Getter @NoArgsConstructor @AllArgsConstructor diff --git a/src/main/java/com/pictalk/user/controller/UserController.java b/src/main/java/com/pictalk/user/controller/UserController.java new file mode 100644 index 0000000..ffee3f7 --- /dev/null +++ b/src/main/java/com/pictalk/user/controller/UserController.java @@ -0,0 +1,56 @@ +package com.pictalk.user.controller; + +import com.pictalk.global.exception.GeneralException; +import com.pictalk.global.payload.status.ErrorStatus; +import com.pictalk.global.payload.response.CommonResponse; +import com.pictalk.user.domain.dto.UserRequestDto; +import com.pictalk.user.domain.dto.UserResponseDto.LoginResponse; +import com.pictalk.user.domain.dto.UserResponseDto.UserResponse; + +import com.pictalk.user.service.UserService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/users") +public class UserController { + private final UserService userService; + + @PostMapping("/signup") + public CommonResponse signup(@Valid @RequestBody UserRequestDto.CreateUser createUser) { + UserResponse userResponse = userService.registerUser(createUser); + return CommonResponse.onSuccess(userResponse); + } + + @PostMapping("/signin") + public CommonResponse login(@Valid @RequestBody UserRequestDto.LoginUser loginUser) { + LoginResponse loginResponse = userService.login(loginUser); + return CommonResponse.onSuccess(loginResponse); + + } + // 로그아웃 엔드포인트 + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request) { + // Authorization 헤더에서 Access Token 추출 후 로그아웃 처리 + userService.logout(request); + return ResponseEntity.noContent().build(); + } + + @PostMapping("/refresh") + public CommonResponse refreshAccessToken(@RequestHeader("Authorization") String refreshToken) { + if (!refreshToken.startsWith("Bearer ")) { + throw new GeneralException(ErrorStatus.USER_REFRESH_TOKEN_NOT_VALID); + } + String token = refreshToken.substring(7); + String newAccessToken = userService.refreshAccessToken(token); + return CommonResponse.onSuccess(newAccessToken); + } +} diff --git a/src/main/java/com/pictalk/user/converter/UserConverter.java b/src/main/java/com/pictalk/user/converter/UserConverter.java new file mode 100644 index 0000000..acdaa47 --- /dev/null +++ b/src/main/java/com/pictalk/user/converter/UserConverter.java @@ -0,0 +1,33 @@ +package com.pictalk.user.converter; + +import com.pictalk.user.domain.User; +import com.pictalk.user.domain.dto.UserRequestDto; +import com.pictalk.user.domain.dto.UserResponseDto; +import org.springframework.security.crypto.password.PasswordEncoder; + +public class UserConverter { + + public static User toEntity(UserRequestDto.CreateUser createUser, PasswordEncoder passwordEncoder) { + + return User.builder() + .username(createUser.getUsername()) + .password(passwordEncoder.encode(createUser.getPassword())) + .email(createUser.getEmail()) + .build(); + } + + public static UserResponseDto.UserResponse toResponse(User user) { + return UserResponseDto.UserResponse.builder() + .username(user.getUsername()) + .email(user.getEmail()) + .build(); + } + + public static UserResponseDto.LoginResponse toLoginResponse(String accessToken, String refreshToken) { + return UserResponseDto.LoginResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + +} diff --git a/src/main/java/com/pictalk/user/domain/User.java b/src/main/java/com/pictalk/user/domain/User.java index 1085963..b67d6bf 100644 --- a/src/main/java/com/pictalk/user/domain/User.java +++ b/src/main/java/com/pictalk/user/domain/User.java @@ -1,23 +1,26 @@ package com.pictalk.user.domain; +import com.pictalk.global.common.BaseEntity; import com.pictalk.message.domain.Sender; import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotEmpty; +import java.util.ArrayList; +import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; - -import java.time.LocalDateTime; -import java.util.ArrayList; import java.util.List; + @Entity -@Table(name = "user") @Getter -@NoArgsConstructor -@AllArgsConstructor @Builder -public class User { +@Table(name = "`user`") +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class User extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -25,30 +28,22 @@ public class User { private Long id; @OneToMany(mappedBy = "user") - @Builder.Default private List senders = new ArrayList<>(); - @Column(nullable = false) + @NotEmpty(message = "Username is required") private String username; - @Column(nullable = false) + @NotEmpty(message = "Password is required") private String password; - @Column(nullable = false) - private LocalDateTime createdAt; - - private LocalDateTime updatedAt; + @Email + @NotEmpty(message = "Email is required") + private String email; - @Builder.Default - private boolean isDeleted = false; - - @PrePersist - protected void onCreate() { - createdAt = LocalDateTime.now(); - } + @Column(name = "refresh_token") + private String refreshToken; - public User(String username, String password) { - this.username = username; - this.password = password; + public void updateRefreshToken(String updateRefreshToken) { + this.refreshToken = updateRefreshToken; } } diff --git a/src/main/java/com/pictalk/user/domain/dto/UserRequestDto.java b/src/main/java/com/pictalk/user/domain/dto/UserRequestDto.java new file mode 100644 index 0000000..850e4f0 --- /dev/null +++ b/src/main/java/com/pictalk/user/domain/dto/UserRequestDto.java @@ -0,0 +1,22 @@ +package com.pictalk.user.domain.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +public class UserRequestDto { + + @Getter + @AllArgsConstructor + public static class CreateUser { + private String username; + private String password; + private String email; + } + + @Getter + @AllArgsConstructor + public static class LoginUser { + private String username; + private String password; + } +} diff --git a/src/main/java/com/pictalk/user/domain/dto/UserResponseDto.java b/src/main/java/com/pictalk/user/domain/dto/UserResponseDto.java new file mode 100644 index 0000000..b9986d1 --- /dev/null +++ b/src/main/java/com/pictalk/user/domain/dto/UserResponseDto.java @@ -0,0 +1,21 @@ +package com.pictalk.user.domain.dto; + +import lombok.Builder; +import lombok.Getter; + +public class UserResponseDto { + + @Getter + @Builder + public static class UserResponse { + private String username; + private String email; + } + + @Getter + @Builder + public static class LoginResponse { + private String accessToken; + private String refreshToken; + } +} diff --git a/src/main/java/com/pictalk/user/repository/UserRepository.java b/src/main/java/com/pictalk/user/repository/UserRepository.java new file mode 100644 index 0000000..7d42fa6 --- /dev/null +++ b/src/main/java/com/pictalk/user/repository/UserRepository.java @@ -0,0 +1,16 @@ +package com.pictalk.user.repository; + +import com.pictalk.user.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + boolean existsByEmail(String email); + + Optional findByEmail(String email); + + boolean existsByUsername(String username); +} diff --git a/src/main/java/com/pictalk/user/service/MyUserDetailsService.java b/src/main/java/com/pictalk/user/service/MyUserDetailsService.java new file mode 100644 index 0000000..c0c43de --- /dev/null +++ b/src/main/java/com/pictalk/user/service/MyUserDetailsService.java @@ -0,0 +1,25 @@ +package com.pictalk.user.service; + +import com.pictalk.user.repository.UserRepository; +import com.pictalk.user.domain.User; +import java.util.Collections; +import lombok.RequiredArgsConstructor; +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; + +@Service +@RequiredArgsConstructor +public class MyUserDetailsService implements UserDetailsService { + + private UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found with username: " + username)); + return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), Collections.emptyList()); + } + +} diff --git a/src/main/java/com/pictalk/user/service/UserService.java b/src/main/java/com/pictalk/user/service/UserService.java new file mode 100644 index 0000000..bd510f3 --- /dev/null +++ b/src/main/java/com/pictalk/user/service/UserService.java @@ -0,0 +1,123 @@ +package com.pictalk.user.service; + +import com.pictalk.global.exception.GeneralException; +import com.pictalk.global.jwt.JwtRequestFilter; +import com.pictalk.global.jwt.JwtService; +import com.pictalk.global.payload.status.ErrorStatus; +import com.pictalk.user.converter.UserConverter; +import com.pictalk.user.domain.dto.UserResponseDto.LoginResponse; +import com.pictalk.user.repository.UserRepository; +import com.pictalk.user.domain.dto.UserRequestDto.*; +import com.pictalk.user.domain.dto.UserResponseDto.UserResponse; +import com.pictalk.user.domain.User; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class UserService { + private final UserRepository userRepository; + private final JwtService jwtService; + private final PasswordEncoder passwordEncoder; + private final JwtRequestFilter jwtRequestFilter; + + // 회원가입 + @Transactional + public UserResponse registerUser(CreateUser createUser) { + if (userRepository.existsByUsername(createUser.getUsername())) { + throw new GeneralException(ErrorStatus.USER_USERNAME_ALREADY_EXISTS); + } + if(userRepository.existsByEmail(createUser.getEmail())) { + throw new GeneralException(ErrorStatus.USER_EMAIL_ALREADY_EXISTS); + } + + jwtRequestFilter.validatePassword(createUser.getPassword()); + User user = UserConverter.toEntity(createUser, passwordEncoder); + return UserConverter.toResponse(userRepository.save(user)); + } + + // 로그인 + @Transactional + public LoginResponse login(LoginUser loginUser) { + User user = userRepository.findByUsername(loginUser.getUsername()) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + if (!passwordEncoder.matches(loginUser.getPassword(), user.getPassword())) { + throw new GeneralException(ErrorStatus.USER_PASSWORD_NOT_MATCH); + } + + String accessToken = jwtService.createAccessToken(user.getEmail()); + String refreshToken = jwtService.createRefreshToken(); + + // 리프레시 토큰 저장 + user.updateRefreshToken(refreshToken); + return UserConverter.toLoginResponse(accessToken, refreshToken); + } + + // 로그아웃 + @Transactional + public void logout(HttpServletRequest request) { + // Access Token 추출 및 존재 여부 확인 + String accessToken = jwtService.extractAccessToken(request) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_ACCESS_TOKEN_NOT_VALID)); + + // Access Token에서 이메일 추출 후 사용자 조회 + String email = jwtService.extractEmail(accessToken) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_ACCESS_TOKEN_NOT_VALID)); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + // 리프레시 토큰 무효화 + user.updateRefreshToken(null); + } + // 리프레시 토큰을 이용한 액세스 토큰 재발급 + public String refreshAccessToken(String refreshToken) { + if (!jwtService.isTokenValid(refreshToken)) { + throw new GeneralException(ErrorStatus.USER_REFRESH_TOKEN_NOT_VALID); + } + + String email = jwtService.extractEmail(refreshToken) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_REFRESH_TOKEN_NOT_VALID)); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + if (!refreshToken.equals(user.getRefreshToken())) { + throw new GeneralException(ErrorStatus.USER_REFRESH_TOKEN_NOT_VALID); + } + + // 새로운 액세스 토큰 생성 + String newAccessToken = jwtService.createAccessToken(user.getEmail()); + + // 클라이언트에는 새 액세스 토큰만 반환 + return newAccessToken; + } + + public LoginResponse refreshAllToken(String refreshToken) { + if (!jwtService.isTokenValid(refreshToken)) { + throw new GeneralException(ErrorStatus.USER_REFRESH_TOKEN_NOT_VALID); + } + + String email = jwtService.extractEmail(refreshToken) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_REFRESH_TOKEN_NOT_VALID)); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + if (!refreshToken.equals(user.getRefreshToken())) { + throw new GeneralException(ErrorStatus.USER_REFRESH_TOKEN_NOT_VALID); + } + + // 새로운 액세스 토큰 생성 + String newAccessToken = jwtService.createAccessToken(user.getEmail()); + + // 새로운 리프레시 토큰 생성 + String newRefreshToken = jwtService.reIssueRefreshToken(user); + // 클라이언트에는 새 액세스 토큰만 반환 + return UserConverter.toLoginResponse(newAccessToken, newRefreshToken); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9c00faf..13ecc1c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,9 +1,9 @@ spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://localhost:3306/picTalk?serverTimezone=UTC&characterEncoding=UTF-8 - username: root - password: root + url: ${MYSQL_URL} + username: ${MYSQL_USERNAME} + password: ${MYSQL_ROOT_PASSWORD} jpa: hibernate: @@ -12,6 +12,10 @@ spring: properties: hibernate: format_sql: true + dialect: org.hibernate.dialect.MySQL8Dialect + + config: + import: "optional:file:.env[.properties]" springdoc: swagger-ui: @@ -19,3 +23,12 @@ springdoc: cache: disabled: true use-fqn: true + +jwt: + access: + expiration: 48000000 + header: Authorization + refresh: + expiration: 86400000 + header: Refresh + secret: ${JWT_SECURITY_KEY} \ No newline at end of file diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 75c7b9d..c129a1f 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -18,3 +18,12 @@ spring: config: import: "optional:file:backend-secret.env[.properties]" + +jwt: + access: + expiration: 86400000 + header: Authorization + refresh: + expiration: 86400000 + header: Refresh + secret: c78396f54bd363ab87285f208e4846f05ce28c16ac4d47e0ab4c6868e94fb02fdff9e3cc9807f2c3bcbc8dc462ca366fb5b3249933968c02225838776c40a7e5 \ No newline at end of file