diff --git a/build.gradle b/build.gradle index aed7c4e..14f3b04 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' implementation 'mysql:mysql-connector-java:8.0.32' compileOnly 'org.projectlombok:lombok' @@ -36,6 +37,14 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0' + + + // JWT + compileOnly group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5' + runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5' + } tasks.named('test') { diff --git a/src/main/java/com/sparta/oneandzerobest/OneAndZeroBestApplication.java b/src/main/java/com/sparta/oneandzerobest/OneAndZeroBestApplication.java index f5c5d21..9d15e29 100644 --- a/src/main/java/com/sparta/oneandzerobest/OneAndZeroBestApplication.java +++ b/src/main/java/com/sparta/oneandzerobest/OneAndZeroBestApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class OneAndZeroBestApplication { diff --git a/src/main/java/com/sparta/oneandzerobest/auth/config/PasswordConfig.java b/src/main/java/com/sparta/oneandzerobest/auth/config/PasswordConfig.java new file mode 100644 index 0000000..a7c79bf --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/config/PasswordConfig.java @@ -0,0 +1,18 @@ +package com.sparta.oneandzerobest.auth.config; + +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 PasswordConfig { // passwordConfigg + + @Bean + public PasswordEncoder passwordEncoder() { // passwordEncoder로 빈 등록 + //BCrypt: 해시함수: 비밀번호 암호화 -> BCryptPasswordEncoder 암호화 + return new BCryptPasswordEncoder(); // PasswordEncoder는 인터페이스로 주입받음 -> BCryptPasswordEncoder구현체 + + + } +} \ No newline at end of file diff --git a/src/main/java/com/sparta/oneandzerobest/auth/config/SecurityConfig.java b/src/main/java/com/sparta/oneandzerobest/auth/config/SecurityConfig.java new file mode 100644 index 0000000..38442ca --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/config/SecurityConfig.java @@ -0,0 +1,56 @@ +package com.sparta.oneandzerobest.auth.config; + +import com.sparta.oneandzerobest.auth.filter.JwtAuthenticationFilter; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +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.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +/** + * SecurityConfig- Spring Security 설정 + */ +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + /** + * SecurityFilterChain: 보안 설정을 기반으로 SecurityFilterChain 생성 + * @param http : HttpSecurity객체로, CSRF 비활성화, 세션 관리, 요청 권한, 필터 추가 + * @return SecurityFilterChain객체 생성(http 요청의 보안 규칙이 적용된) + * @throws Exception + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + // CSRF 설정 + http.csrf((csrf) -> csrf.disable()); //csrf 비활성화 + + http.sessionManagement((sessionManagement) -> + sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // 세션 관리를 stateless설정 + + // 요청에 대한 권한 설정 + http.authorizeHttpRequests(authorizeHttpRequests -> + authorizeHttpRequests + .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정 + .requestMatchers("/", "/index.html", "/login.html", "/signup.html", "/api/auth/**").permitAll() // 특정 경로 접근 허용 + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() // Swagger 접근 허용 + .requestMatchers(HttpMethod.GET, "/api/schedules/**").permitAll() // 일정 조회는 모두 허용 + .anyRequest().permitAll() // 그 외 모든 요청 접근 허용 + //.anyRequest().authenticated() // 그 외 모든 요청 인증처리 + ); + + // jwtAuthenticationFilter의 순서를 지정해주기위해 UsernamePasswordAuthenticationFilter전으로 위치 지정 + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} diff --git a/src/main/java/com/sparta/oneandzerobest/auth/controller/AuthRestController.java b/src/main/java/com/sparta/oneandzerobest/auth/controller/AuthRestController.java new file mode 100644 index 0000000..948271a --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/controller/AuthRestController.java @@ -0,0 +1,100 @@ +package com.sparta.oneandzerobest.auth.controller; + + +import com.sparta.oneandzerobest.auth.dto.RefreshTokenRequestDto; +import com.sparta.oneandzerobest.auth.dto.TokenResponseDto; +import com.sparta.oneandzerobest.auth.entity.LoginRequest; +import com.sparta.oneandzerobest.auth.entity.LoginResponse; +import com.sparta.oneandzerobest.auth.entity.SignupRequest; +import com.sparta.oneandzerobest.auth.service.UserService; +import com.sparta.oneandzerobest.auth.util.JwtUtil; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 인증기능 컨트롤러 + * - 로그인 + * - 로그아웃 + * - 탈퇴 + * - 리프레시 토큰 재발급 + */ +@RestController +@RequestMapping("/api/auth") +public class AuthRestController { + private final UserService userService; + private final JwtUtil jwtUtil; + + public AuthRestController(UserService userService, JwtUtil jwtUtil) { + this.userService = userService; + this.jwtUtil = jwtUtil; + } + + /** + * 회원가입 + * @param signupRequest + * @return + */ + @PostMapping("/signup") + public ResponseEntity signup(@RequestBody SignupRequest signupRequest) { + userService.signup(signupRequest); + return ResponseEntity.status(HttpStatus.CREATED).body("회원가입 성공"); + } + + /** + * 로그인 + * @param loginRequest + * @return 헤더에 반환 + */ + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequest loginRequest) { + LoginResponse tokens = userService.login(loginRequest); // 로그인 시도 및 토큰 생성 + String accessToken = tokens.getAccessToken(); + String refreshToken = tokens.getRefreshToken(); + + // 각 토큰을 별도의 헤더에 설정 + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + accessToken); + headers.set("Refresh-Token", refreshToken); + + return new ResponseEntity<>("로그인 성공", headers, HttpStatus.OK); + } + + /** + * 로그아웃 + * @param username + * @return + */ + @PostMapping("/logout") + public ResponseEntity logout(@RequestParam String username) { + userService.logout(username); + return ResponseEntity.ok("로그아웃 성공"); + } + + /** + *withdraw: 탈퇴 + * @param username + * @param password + * @return + */ + @PostMapping("/withdraw") + public ResponseEntity withdraw(@RequestParam String username, @RequestParam String password) { + userService.withdraw(username, password); + return ResponseEntity.ok("회원탈퇴 성공"); + } + + /** + * 리프레시 토큰 재발급 + * @param refreshTokenRequestDto + * @return + */ + @PostMapping("/refresh") + public ResponseEntity refresh(@RequestBody RefreshTokenRequestDto refreshTokenRequestDto) { + + String refreshToken = refreshTokenRequestDto.getRefreshToken(); + String newAccessToken = jwtUtil.refreshToken(refreshToken); + TokenResponseDto tokenResponseDto = new TokenResponseDto(newAccessToken, refreshToken); + return ResponseEntity.ok(tokenResponseDto); + } +} diff --git a/src/main/java/com/sparta/oneandzerobest/auth/dto/LoginRequestDto.java b/src/main/java/com/sparta/oneandzerobest/auth/dto/LoginRequestDto.java new file mode 100644 index 0000000..fdd4100 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/dto/LoginRequestDto.java @@ -0,0 +1,9 @@ +package com.sparta.oneandzerobest.auth.dto; + +import lombok.Getter; + +@Getter +public class LoginRequestDto { + private String username; + private String password; +} diff --git a/src/main/java/com/sparta/oneandzerobest/auth/dto/RefreshTokenRequestDto.java b/src/main/java/com/sparta/oneandzerobest/auth/dto/RefreshTokenRequestDto.java new file mode 100644 index 0000000..897f5d6 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/dto/RefreshTokenRequestDto.java @@ -0,0 +1,8 @@ +package com.sparta.oneandzerobest.auth.dto; + +import lombok.Getter; + +@Getter +public class RefreshTokenRequestDto { + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/com/sparta/oneandzerobest/auth/dto/SignupRequestDto.java b/src/main/java/com/sparta/oneandzerobest/auth/dto/SignupRequestDto.java new file mode 100644 index 0000000..d6d2d53 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/dto/SignupRequestDto.java @@ -0,0 +1,15 @@ +package com.sparta.oneandzerobest.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SignupRequestDto { + private String username; + private String password; + private String name; + private String email; +} diff --git a/src/main/java/com/sparta/oneandzerobest/auth/dto/TokenResponseDto.java b/src/main/java/com/sparta/oneandzerobest/auth/dto/TokenResponseDto.java new file mode 100644 index 0000000..34310c6 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/dto/TokenResponseDto.java @@ -0,0 +1,15 @@ +package com.sparta.oneandzerobest.auth.dto; + + +import lombok.Getter; + +@Getter +public class TokenResponseDto { + private String accessToken; + private String refreshToken; + + public TokenResponseDto(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/com/sparta/oneandzerobest/auth/entity/LoginRequest.java b/src/main/java/com/sparta/oneandzerobest/auth/entity/LoginRequest.java new file mode 100644 index 0000000..65a095d --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/entity/LoginRequest.java @@ -0,0 +1,15 @@ +package com.sparta.oneandzerobest.auth.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class LoginRequest { + private String username; + private String password; +} \ No newline at end of file diff --git a/src/main/java/com/sparta/oneandzerobest/auth/entity/LoginResponse.java b/src/main/java/com/sparta/oneandzerobest/auth/entity/LoginResponse.java new file mode 100644 index 0000000..aaba538 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/entity/LoginResponse.java @@ -0,0 +1,12 @@ +package com.sparta.oneandzerobest.auth.entity; + + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class LoginResponse { + private String accessToken; + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/com/sparta/oneandzerobest/auth/entity/SignupRequest.java b/src/main/java/com/sparta/oneandzerobest/auth/entity/SignupRequest.java new file mode 100644 index 0000000..6ec11f4 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/entity/SignupRequest.java @@ -0,0 +1,16 @@ +package com.sparta.oneandzerobest.auth.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SignupRequest { + private String username; + private String password; + private String email; + private boolean isAdmin; + private String adminToken; +} diff --git a/src/main/java/com/sparta/oneandzerobest/auth/entity/User.java b/src/main/java/com/sparta/oneandzerobest/auth/entity/User.java new file mode 100644 index 0000000..4759a5c --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/entity/User.java @@ -0,0 +1,77 @@ +package com.sparta.oneandzerobest.auth.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Collections; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "users") +public class User implements UserDetails { // Spring Security의 UserDetails + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String username; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String name; + + @Column(nullable = false, unique = true) + private String email; + + @Column + private String introduction; + + @Column(nullable = false) + private String statusCode; + + @Column + private String refreshToken; + + @Column + private LocalDateTime statusChangeTime; + + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column + private LocalDateTime updatedAt; + + public User(String username, String password, String name, String email, String statusCode) { + this.username = username; + this.password = password; + this.name = name; + this.email = email; + this.statusCode = statusCode; + this.createdAt = LocalDateTime.now(); + } + + public void setStatusCode(String statusCode) { + this.statusCode = statusCode; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + @Override + public boolean isEnabled() { + return "정상".equals(this.statusCode); // 계정이 활성화된 상태인지 확인 + } + + @Override + public Collection getAuthorities() { + return Collections.emptyList(); // 권한 관련 설정 + } +} diff --git a/src/main/java/com/sparta/oneandzerobest/auth/entity/UserRoleEnum.java b/src/main/java/com/sparta/oneandzerobest/auth/entity/UserRoleEnum.java new file mode 100644 index 0000000..58de266 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/entity/UserRoleEnum.java @@ -0,0 +1,25 @@ +package com.sparta.oneandzerobest.auth.entity; + +import org.springframework.security.core.GrantedAuthority; + +public enum UserRoleEnum implements GrantedAuthority { + USER(Authority.USER), // 사용자 권한 + ADMIN(Authority.ADMIN); // 관리자 권한 + + private final String authority; + + UserRoleEnum(String authority) { + this.authority = authority; + } + + @Override + public String getAuthority() { + return name(); + } + + public static class Authority { + public static final String USER = "ROLE_USER"; + public static final String ADMIN = "ROLE_ADMIN"; + } +} + diff --git a/src/main/java/com/sparta/oneandzerobest/auth/filter/JwtAuthenticationFilter.java b/src/main/java/com/sparta/oneandzerobest/auth/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..0ce02c0 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/filter/JwtAuthenticationFilter.java @@ -0,0 +1,77 @@ +package com.sparta.oneandzerobest.auth.filter; + + +import com.sparta.oneandzerobest.auth.service.UserDetailsServiceImpl; +import com.sparta.oneandzerobest.auth.util.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * Jwt 인증필터: 모든 HTTP가 거침 + * 클라이언트로 부터 HTTP요청을 가로채서 JWT토큰 검사하고 + * 유효한 토큰이면 사용자 정보를 SecurityContext에 저장 + */ +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final UserDetailsServiceImpl userDetailsService; + + public JwtAuthenticationFilter(JwtUtil jwtUtil, UserDetailsServiceImpl userDetailsService) { + this.jwtUtil = jwtUtil; + this.userDetailsService = userDetailsService; + } + + /** + *요청 필터링: JWT 토큰을 검증해서 유효하면 사용자 정보 설정 + * @param request HTTP 요청 + * @param response HTTP 응답 + * @param chain 필터 체인 + * @throws ServletException + * @throws IOException + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + + // 요청헤더에서 Authorization 추출 + final String authorizationHeader = request.getHeader("Authorization"); + + String username = null; + String jwt = null; + + // authorizationHeader가 존재하고, "Bearer "로 시작할 경우 + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + jwt = authorizationHeader.substring(7); // "Bearer " 이후의 JWT 토큰 부분 + username = jwtUtil.getUsernameFromToken(jwt); // JWT 토큰에서 사용자 이름 추출 + } + + // 사용자 이름이 존재하고, 현재 SecurityContext에 인증 정보가 없는 경우 + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + UserDetails userDetails = this.userDetailsService.loadUserByUsername(username); + + // JWT 토큰이 유효한 경우 + if (jwtUtil.validateToken(jwt, userDetails)) { + UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = + new UsernamePasswordAuthenticationToken(userDetails, + null, + userDetails.getAuthorities()); + usernamePasswordAuthenticationToken + .setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); + } + } + // 다음 필터로 요청을 전달 + chain.doFilter(request, response); + } +} \ No newline at end of file diff --git a/src/main/java/com/sparta/oneandzerobest/auth/repository/UserRepository.java b/src/main/java/com/sparta/oneandzerobest/auth/repository/UserRepository.java new file mode 100644 index 0000000..b92ca27 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.sparta.oneandzerobest.auth.repository; + +import com.sparta.oneandzerobest.auth.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByUsername(String username); + Optional findByEmail(String email); +} diff --git a/src/main/java/com/sparta/oneandzerobest/auth/service/UserDetailsServiceImpl.java b/src/main/java/com/sparta/oneandzerobest/auth/service/UserDetailsServiceImpl.java new file mode 100644 index 0000000..ce6ae0b --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/service/UserDetailsServiceImpl.java @@ -0,0 +1,39 @@ +package com.sparta.oneandzerobest.auth.service; + +import com.sparta.oneandzerobest.auth.entity.User; +import com.sparta.oneandzerobest.auth.repository.UserRepository; +import org.springframework.security.core.GrantedAuthority; +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; + +import java.util.Optional; + +/** + * UserDetailsService 구현제 서비스 + */ +@Service +public class UserDetailsServiceImpl implements UserDetailsService { + + private final UserRepository userRepository; + + public UserDetailsServiceImpl(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + Optional userOptional = userRepository.findByUsername(username); + + if (userOptional.isEmpty()) { + throw new UsernameNotFoundException("사용자를 찾을 수 없습니다: " + username); + } + User user = userOptional.get(); + return org.springframework.security.core.userdetails.User.builder() + .username(user.getUsername()) + .password(user.getPassword()) + .authorities(user.getAuthorities()) + .build(); + } +} diff --git a/src/main/java/com/sparta/oneandzerobest/auth/service/UserService.java b/src/main/java/com/sparta/oneandzerobest/auth/service/UserService.java new file mode 100644 index 0000000..6104cd3 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/service/UserService.java @@ -0,0 +1,19 @@ +package com.sparta.oneandzerobest.auth.service; + +import com.sparta.oneandzerobest.auth.dto.TokenResponseDto; +import com.sparta.oneandzerobest.auth.entity.LoginRequest; +import com.sparta.oneandzerobest.auth.entity.LoginResponse; +import com.sparta.oneandzerobest.auth.entity.SignupRequest; + +public interface UserService { + // 회원가입 + void signup(SignupRequest signupRequest); + // 로그인 + LoginResponse login(LoginRequest loginRequest); + // 로그아웃 + void logout(String username); + // 탈퇴 + void withdraw(String username, String password); + // 리프레시 토큰 + TokenResponseDto refresh(String refreshToken); +} \ No newline at end of file diff --git a/src/main/java/com/sparta/oneandzerobest/auth/service/UserServiceImpl.java b/src/main/java/com/sparta/oneandzerobest/auth/service/UserServiceImpl.java new file mode 100644 index 0000000..8cd6c7d --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/service/UserServiceImpl.java @@ -0,0 +1,145 @@ +package com.sparta.oneandzerobest.auth.service; + +import com.sparta.oneandzerobest.auth.dto.TokenResponseDto; +import com.sparta.oneandzerobest.auth.entity.*; +import com.sparta.oneandzerobest.auth.repository.UserRepository; +import com.sparta.oneandzerobest.auth.util.JwtUtil; +import com.sparta.oneandzerobest.exception.InfoNotCorrectedException; +import com.sparta.oneandzerobest.exception.InvalidPasswordException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class UserServiceImpl implements UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + public UserServiceImpl(UserRepository userRepository, PasswordEncoder passwordEncoder, JwtUtil jwtUtil) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + this.jwtUtil = jwtUtil; + } + + /** + * 회원가입: ID, PW, Email + * @param signupRequest + */ + @Override + public void signup(SignupRequest signupRequest) { + String authId = signupRequest.getUsername(); + String password = signupRequest.getPassword(); + String email = signupRequest.getEmail(); + + if (!authId.matches("^[a-zA-Z0-9]{10,20}$")) { + throw new IllegalArgumentException("아이디는 최소 10자 이상, 20자 이하이며 알파벳 대소문자와 숫자로 구성되어야 합니다."); + } + + if (!password.matches("^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*])[a-zA-Z\\d!@#$%^&*]{10,}$")) { + throw new IllegalArgumentException("비밀번호는 최소 10자 이상이며 알파벳 대소문자, 숫자, 특수문자를 포함해야 합니다."); + } + + if (userRepository.findByUsername(authId).isPresent()) { + throw new InfoNotCorrectedException("중복된 사용자 ID가 존재합니다."); + } + + if (userRepository.findByEmail(email).isPresent()) { + throw new InfoNotCorrectedException("중복된 이메일이 존재합니다."); + } + + // 비밀번호 암호화 + String encodedPassword = passwordEncoder.encode(password); + User user = new User(authId, encodedPassword, signupRequest.getUsername(), email, "정상"); + userRepository.save(user); + } + + /** + * 로그인: ACCESS TOKEN, REFRESH TOKEN 생성 + * 비밀번호: 암호화 + * 탈퇴한 사람 분별 + * @param loginRequest + * @return + */ + @Override + public LoginResponse login(LoginRequest loginRequest) { + User user = userRepository.findByUsername(loginRequest.getUsername()) + .orElseThrow(() -> new InfoNotCorrectedException("사용자 ID와 비밀번호가 일치하지 않습니다.")); + + if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) { + throw new InvalidPasswordException("사용자 ID와 비밀번호가 일치하지 않습니다."); + } + + if ("탈퇴".equals(user.getStatusCode())) { + throw new InfoNotCorrectedException("탈퇴한 사용자입니다."); + } + + String accessToken = jwtUtil.createAccessToken(user.getUsername()); + String refreshToken = jwtUtil.createRefreshToken(user.getUsername()); + + user.setRefreshToken(refreshToken); + userRepository.save(user); + + return LoginResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + /** + * 로그아웃: 리프레시 토큰 삭제 + * @param username + */ + @Override + public void logout(String username) { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new InfoNotCorrectedException("사용자를 찾을 수 없습니다.")); + + user.setRefreshToken(null); + userRepository.save(user); + } + + /** + * 회원 탈퇴 -> 리프레시 토큰 삭제, getStatusCode: 탈퇴 + * @param id: 아이디 + * @param password: 비밀번호 + */ + @Override + public void withdraw(String id, String password) { + User user = userRepository.findByUsername(id) + .orElseThrow(() -> new InfoNotCorrectedException("사용자를 찾을 수 없습니다.")); + + if (!passwordEncoder.matches(password, user.getPassword())) { + throw new InvalidPasswordException("비밀번호가 일치하지 않습니다."); + } + + if ("탈퇴".equals(user.getStatusCode())) { + throw new InfoNotCorrectedException("이미 탈퇴한 사용자입니다."); + } + + user.setStatusCode("탈퇴"); + user.setRefreshToken(null); + userRepository.save(user); + } + + @Override + public TokenResponseDto refresh(String refreshToken) { + String username = jwtUtil.getUsernameFromToken(refreshToken); + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new InfoNotCorrectedException("사용자를 찾을 수 없습니다.")); + + if (!refreshToken.equals(user.getRefreshToken())) { + throw new InvalidPasswordException("리프레시 토큰이 유효하지 않습니다."); + } + + String newAccessToken = jwtUtil.createAccessToken(username); + String newRefreshToken = jwtUtil.createRefreshToken(username); + + user.setRefreshToken(newRefreshToken); + userRepository.save(user); + + return new TokenResponseDto(newAccessToken, newRefreshToken); + } +} diff --git a/src/main/java/com/sparta/oneandzerobest/auth/util/JwtUtil.java b/src/main/java/com/sparta/oneandzerobest/auth/util/JwtUtil.java new file mode 100644 index 0000000..6325076 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/util/JwtUtil.java @@ -0,0 +1,117 @@ +package com.sparta.oneandzerobest.auth.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; + +import java.util.Base64; +import java.util.Date; + +// JwtUtil : - JWT 토큰을 생성하고 검증 +@Component +public class JwtUtil { + private final String secretKey; + + public JwtUtil(@Value("${jwt.secret.key}") String secretKey) { + this.secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes()); + } + + @Value("${jwt.token.expiration}") + private long tokenExpiration; + + @Value("${jwt.refresh.token.expiration}") + private long refreshTokenExpiration; + + // 액세스 토큰 생성 + public String createAccessToken(String username) { + return generateToken(username, tokenExpiration); + } + + // 리프레시 토큰 생성 + public String createRefreshToken(String username) { + return generateToken(username, refreshTokenExpiration); + } + public String generateToken(String username, long expiration) { + return Jwts.builder() + .setSubject(username) // 토큰 주체 + .setIssuedAt(new Date()) // 토큰 발행 시간 + .setExpiration(new Date(System.currentTimeMillis() + expiration)) // 토큰 만료시간 + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + } + + /** + * JWT 토큰에서 Claims을 추출 페이로드(사용자 정보) + * @param token JWT 토큰 + * @return 토큰에서 추출한 Claims 객체 + */ + public Claims extractClaims(String token) { + return Jwts.parser() + .setSigningKey(secretKey) + .parseClaimsJws(token) + .getBody(); + } + + /** + * 토큰 유효성 검사: claim의 유저네임값과 userDetail의 이름 비교, + * @param token + * @param userDetails + * @return + */ + public boolean validateToken(String token, UserDetails userDetails) { + // 토큰에서 사용자 이름 추출 + final String username = getUsernameFromToken(token); + // 토큰 검사(이름 일치, 만료 확인) + return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); + } + + // 리프레시토큰 유효성 검사 + public boolean validateRefreshToken(String token) { + return validateToken(token); + } + + // 토큰 유효성 검사 + public boolean validateToken(String token) { + try { + extractClaims(token); + return true; + } catch (Exception e) { + return false; + } + } + + + /** + * 리프레시 토큰으로 토큰 재발급 - 엑세스 토큰 만료시 + * @param refreshToken + * @return + */ + public String refreshToken(String refreshToken) { + // 리프레시 유효 + if (validateRefreshToken(refreshToken)) { + String username = getUsernameFromToken(refreshToken); + return createAccessToken(username); + } else { + // 이때 프론트가 적절히 로그인으로 유도 + throw new IllegalArgumentException("Refresh token이 만료 또는 유효하지 않음"); + } + } + + /** + * 토큰 만료일 claim 추출 + * @param token JWT 토큰 + * @return 현재 시간 + */ + private boolean isTokenExpired(String token) { + final Date expiration = extractClaims(token).getExpiration(); + return expiration.before(new Date()); + } + + // 사용자 추출 + public String getUsernameFromToken(String token) { + return extractClaims(token).getSubject(); + } +} diff --git a/src/main/java/com/sparta/oneandzerobest/exception/InfoNotCorrectedException.java b/src/main/java/com/sparta/oneandzerobest/exception/InfoNotCorrectedException.java new file mode 100644 index 0000000..5f2b21a --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/exception/InfoNotCorrectedException.java @@ -0,0 +1,7 @@ +package com.sparta.oneandzerobest.exception; + +public class InfoNotCorrectedException extends RuntimeException { + public InfoNotCorrectedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/sparta/oneandzerobest/exception/InvalidPasswordException.java b/src/main/java/com/sparta/oneandzerobest/exception/InvalidPasswordException.java new file mode 100644 index 0000000..69e3d9e --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/exception/InvalidPasswordException.java @@ -0,0 +1,8 @@ +package com.sparta.oneandzerobest.exception; + +public class InvalidPasswordException extends RuntimeException { + + public InvalidPasswordException(String message) { + super(message); + } +} diff --git a/src/main/java/com/sparta/oneandzerobest/exception/UnauthorizedException.java b/src/main/java/com/sparta/oneandzerobest/exception/UnauthorizedException.java new file mode 100644 index 0000000..6a39c19 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/exception/UnauthorizedException.java @@ -0,0 +1,7 @@ +package com.sparta.oneandzerobest.exception; + +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/sparta/oneandzerobest/timestamp/TimeStamp.java b/src/main/java/com/sparta/oneandzerobest/timestamp/TimeStamp.java new file mode 100644 index 0000000..f1c4330 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/timestamp/TimeStamp.java @@ -0,0 +1,28 @@ +package com.sparta.oneandzerobest.timestamp; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import jakarta.persistence.Temporal; +import jakarta.persistence.TemporalType; +import java.time.LocalDateTime; +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 abstract class TimeStamp { + + @CreatedDate + @Column(updatable = false) + @Temporal(TemporalType.TIMESTAMP) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column + @Temporal(TemporalType.TIMESTAMP) + private LocalDateTime modifiedAt; +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 87706b3..0269dc2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,5 +10,15 @@ spring: hibernate: ddl-auto: update show-sql: true + +jwt: + secret: + key: e36f112d-c6f2-466f-aad8-14dcdc16360b + token: + expiration: 1800000 + refresh: + token: + expiration: 1209600000 + server: port: 8080 diff --git a/src/test/java/com/sparta/oneandzerobest/OneAndZeroBestApplicationTests.java b/src/test/java/com/sparta/oneandzerobest/OneAndZeroBestApplicationTests.java index f3db274..da7c0bc 100644 --- a/src/test/java/com/sparta/oneandzerobest/OneAndZeroBestApplicationTests.java +++ b/src/test/java/com/sparta/oneandzerobest/OneAndZeroBestApplicationTests.java @@ -2,6 +2,8 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + @SpringBootTest class OneAndZeroBestApplicationTests {