From 9bb76c7dee60654575773926cafb5ac7b6238f91 Mon Sep 17 00:00:00 2001 From: dami0806 Date: Tue, 4 Jun 2024 17:51:28 +0900 Subject: [PATCH] feat: Login, Signup, getToken logic --- build.gradle | 9 ++ .../auth/config/PasswordConfig.java | 18 +++ .../auth/config/SecurityConfig.java | 56 +++++++++ .../auth/controller/AuthRestController.java | 64 ++++++++++ .../auth/dto/LoginRequestDto.java | 9 ++ .../auth/dto/RefreshTokenRequestDto.java | 8 ++ .../auth/dto/SignupRequestDto.java | 16 +++ .../auth/dto/TokenResponseDto.java | 15 +++ .../auth/entity/LoginRequest.java | 13 ++ .../auth/entity/SignupRequest.java | 16 +++ .../oneandzerobest/auth/entity/User.java | 77 ++++++++++++ .../auth/entity/UserRoleEnum.java | 25 ++++ .../auth/filter/JwtAuthenticationFilter.java | 77 ++++++++++++ .../auth/repository/UserRepository.java | 11 ++ .../auth/service/UserDetailsServiceImpl.java | 36 ++++++ .../auth/service/UserService.java | 10 ++ .../auth/service/UserServiceImpl.java | 98 +++++++++++++++ .../oneandzerobest/auth/util/JwtUtil.java | 117 ++++++++++++++++++ .../exception/InfoNotCorrectedException.java | 7 ++ .../exception/InvalidPasswordException.java | 8 ++ .../exception/UnauthorizedException.java | 7 ++ src/main/resources/application.yml | 12 +- 22 files changed, 708 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/config/PasswordConfig.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/config/SecurityConfig.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/controller/AuthRestController.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/dto/LoginRequestDto.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/dto/RefreshTokenRequestDto.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/dto/SignupRequestDto.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/dto/TokenResponseDto.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/entity/LoginRequest.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/entity/SignupRequest.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/entity/User.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/entity/UserRoleEnum.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/filter/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/repository/UserRepository.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/service/UserDetailsServiceImpl.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/service/UserService.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/service/UserServiceImpl.java create mode 100644 src/main/java/com/sparta/oneandzerobest/auth/util/JwtUtil.java create mode 100644 src/main/java/com/sparta/oneandzerobest/exception/InfoNotCorrectedException.java create mode 100644 src/main/java/com/sparta/oneandzerobest/exception/InvalidPasswordException.java create mode 100644 src/main/java/com/sparta/oneandzerobest/exception/UnauthorizedException.java 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/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..5704b16 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/controller/AuthRestController.java @@ -0,0 +1,64 @@ +package com.sparta.oneandzerobest.auth.controller; + + +import com.sparta.oneandzerobest.auth.dto.LoginRequestDto; +import com.sparta.oneandzerobest.auth.dto.RefreshTokenRequestDto; +import com.sparta.oneandzerobest.auth.dto.SignupRequestDto; +import com.sparta.oneandzerobest.auth.dto.TokenResponseDto; +import com.sparta.oneandzerobest.auth.entity.LoginRequest; +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.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@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; + } + + @PostMapping("/signup") + public ResponseEntity signup(@RequestBody SignupRequestDto signupRequestDto) { + SignupRequest signupRequest = new SignupRequest( + signupRequestDto.getUsername(), + signupRequestDto.getPassword(), + signupRequestDto.getEmail(), + signupRequestDto.isAdmin(), + signupRequestDto.getAdminToken() + ); + userService.signup(signupRequest); + return ResponseEntity.ok("회원가입 성공"); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody LoginRequestDto loginRequestDto) { + LoginRequest loginRequest = new LoginRequest( + loginRequestDto.getUsername(), + loginRequestDto.getPassword() + ); + + String token = userService.login(loginRequest); + String refreshToken = jwtUtil.createRefreshToken(loginRequestDto.getUsername()); + + TokenResponseDto tokenResponseDto = new TokenResponseDto(token, refreshToken); + return ResponseEntity.ok(tokenResponseDto); + } + + @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..dfccd65 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/dto/SignupRequestDto.java @@ -0,0 +1,16 @@ +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 email; + private boolean admin = false; + private String adminToken = ""; +} 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..009217d --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/entity/LoginRequest.java @@ -0,0 +1,13 @@ +package com.sparta.oneandzerobest.auth.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +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/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..23654da --- /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.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +@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, unique = true) + private String email; + + + @ElementCollection(fetch = FetchType.EAGER) + private Set authorities; + + @Column(nullable = false) + @Enumerated(value = EnumType.STRING) + private UserRoleEnum role; + + public User(String username, String password, String email, UserRoleEnum role) { + this.username = username; + this.password = password; + this.email = email; + this.role = role; + this.authorities = Collections.singleton(role.name()); + } + + @Override + public Collection getAuthorities() { + Set grantedAuthorities = new HashSet<>(); + for (String authority : this.authorities) { + grantedAuthorities.add(() -> authority); + } + return grantedAuthorities; + } + + + @Override + public boolean isAccountNonExpired() { + return false; + } + + @Override + public boolean isAccountNonLocked() { + return false; + } + + @Override + public boolean isCredentialsNonExpired() { + return false; + } + + @Override + public boolean isEnabled() { + return false; + } +} \ No newline at end of file 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..87fb50e --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/service/UserDetailsServiceImpl.java @@ -0,0 +1,36 @@ +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; + +@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..a9f1fd8 --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/service/UserService.java @@ -0,0 +1,10 @@ +package com.sparta.oneandzerobest.auth.service; + +import com.sparta.oneandzerobest.auth.entity.LoginRequest; +import com.sparta.oneandzerobest.auth.entity.SignupRequest; + +public interface UserService { + void signup(SignupRequest signupRequest); + String login(LoginRequest loginRequest); + +} \ 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..a3c650c --- /dev/null +++ b/src/main/java/com/sparta/oneandzerobest/auth/service/UserServiceImpl.java @@ -0,0 +1,98 @@ +package com.sparta.oneandzerobest.auth.service; + + +import com.sparta.oneandzerobest.auth.entity.LoginRequest; +import com.sparta.oneandzerobest.auth.entity.SignupRequest; +import com.sparta.oneandzerobest.auth.entity.User; +import com.sparta.oneandzerobest.auth.entity.UserRoleEnum; +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 com.sparta.oneandzerobest.exception.UnauthorizedException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@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; + } + + // ADMIN_TOKEN + private final String ADMIN_TOKEN = "e36f112d-c6f2-466f-aad8-14dcdc16360b"; + + // 회원가입 + public void signup(SignupRequest signupRequest) { + String username = signupRequest.getUsername(); + + // username 최소 4자 이상, 10자 이하이며 알파벳 소문자(a~z), 숫자(0~9) + if (!username.matches("^[a-z0-9]{4,10}$")) { + throw new IllegalArgumentException("아이디는 최소 4자 이상, 10자 이하이며 알파벳 소문자(a~z)와 숫자(0~9)로 구성되어야 합니다."); + + } + + String encodedPassword = passwordEncoder.encode( signupRequest.getPassword()); + + // password 최소 8자 이상, 15자 이하이며 알파벳 대소문자(a~z, A~Z), 숫자(0~9) + if (!signupRequest.getPassword().matches("^(?=.*[a-zA-Z])(?=.*\\d)[a-zA-Z\\d]{8,15}$")) { + throw new IllegalArgumentException("비밀번호은 최소 8자 이상, 15자 이하이며 알파벳 대소문자(a~z, A~Z)와 숫자(0~9)로 구성되어야 합니다."); + } + + + // 회원 중복 확인 + Optional checkUsername = userRepository.findByUsername(username); + if (checkUsername.isPresent()) { + throw new InfoNotCorrectedException("중복된 사용자가 존재합니다."); + } + + // email 중복확인 + String email = signupRequest.getEmail(); + Optional checkEmail = userRepository.findByEmail(email); + if (checkEmail.isPresent()) { + throw new InfoNotCorrectedException("중복된 Email 입니다."); + } + + // 사용자 ROLE 확인 + UserRoleEnum role = UserRoleEnum.USER; + if (signupRequest.isAdmin()) { + if (!ADMIN_TOKEN.equals(signupRequest.getAdminToken())) { + throw new UnauthorizedException("관리자 암호가 틀려 등록이 불가능합니다."); + } + role = UserRoleEnum.ADMIN; + } + + // 새로운 사용자 객체 생성 - 등록 + User user = new User(username, encodedPassword, email, role); + userRepository.save(user); + log.warn("회원가입 성공: " + user.getUsername()); + } + + // 로그인 + public String login(LoginRequest loginRequest) { + User user = userRepository.findByUsername(loginRequest.getUsername()) + .orElseThrow(() -> new InfoNotCorrectedException("이름과 비밀번호가 일치하지 않습니다.")); + + if (!passwordEncoder.matches(loginRequest.getPassword(), user.getPassword())) { + throw new InvalidPasswordException("비밀번호와 비밀번호가 비교하지 않음."); + } + + String token = jwtUtil.createAccessToken(user.getUsername()); + log.info("로그인 성공: 사용자 {}, 토큰 {}", user.getUsername(), token); + return token; + } +} + 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/resources/application.yml b/src/main/resources/application.yml index 87706b3..313472c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -5,10 +5,20 @@ spring: url: jdbc:mysql://localhost:3306/siumechu driver-class-name: com.mysql.cj.jdbc.Driver username: root - password: password + password: Ekalekal1@ jpa: hibernate: ddl-auto: update show-sql: true + +jwt: + secret: + key: e36f112d-c6f2-466f-aad8-14dcdc16360b + token: + expiration: 3600000 + refresh: + token: + expiration: 604800000 + server: port: 8080