From c0505486aa66eeceaeba695400770a0481631807 Mon Sep 17 00:00:00 2001 From: pywoo Date: Tue, 17 Jun 2025 14:59:29 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=EC=84=B8=EC=85=98=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 7 ++ .../apiPayload/code/status/ErrorStatus.java | 4 ++ .../security/CustomUserDetailsService.java | 30 +++++++++ .../umc/config/security/SecurityConfig.java | 41 ++++++++++++ .../umc/controller/UserViewController.java | 56 ++++++++++++++++ .../java/umc/converter/UserConverter.java | 10 +-- src/main/java/umc/domain/User.java | 8 +++ src/main/java/umc/domain/enums/Role.java | 5 ++ src/main/java/umc/dto/UserRequestDto.java | 20 +++++- src/main/java/umc/dto/UserResponseDto.java | 9 +++ .../UserService/UserCommandServiceImpl.java | 3 + src/main/resources/application.yml | 8 ++- src/main/resources/templates/admin.html | 10 +++ src/main/resources/templates/home.html | 20 ++++++ src/main/resources/templates/login.html | 26 ++++++++ src/main/resources/templates/signup.html | 64 +++++++++++++++++++ 16 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 src/main/java/umc/config/security/CustomUserDetailsService.java create mode 100644 src/main/java/umc/config/security/SecurityConfig.java create mode 100644 src/main/java/umc/controller/UserViewController.java create mode 100644 src/main/java/umc/domain/enums/Role.java create mode 100644 src/main/resources/templates/admin.html create mode 100644 src/main/resources/templates/home.html create mode 100644 src/main/resources/templates/login.html create mode 100644 src/main/resources/templates/signup.html diff --git a/build.gradle b/build.gradle index cae99ef3..797d882c 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,13 @@ dependencies { // 스웨거 의존성 implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + + // thymeleaf + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.1.RELEASE' + + //Spring Boot Security + implementation 'org.springframework.boot:spring-boot-starter-security' } tasks.named('test') { diff --git a/src/main/java/umc/apiPayload/code/status/ErrorStatus.java b/src/main/java/umc/apiPayload/code/status/ErrorStatus.java index 2e41b8cd..8dd57572 100644 --- a/src/main/java/umc/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/umc/apiPayload/code/status/ErrorStatus.java @@ -20,6 +20,7 @@ public enum ErrorStatus implements BaseErrorCode { // 멤버 관련 에러 USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "USER4001", "사용자가 없습니다."), NICKNAME_NOT_EXIST(HttpStatus.BAD_REQUEST, "USER4002", "닉네임은 필수 입니다."), + EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "USER4003", "해당 이메일의 사용자가 없습니다."), // 예시 ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."), @@ -41,6 +42,9 @@ public enum ErrorStatus implements BaseErrorCode { ALREADY_CHALLENGE(HttpStatus.BAD_REQUEST, "MISSION4002", "이미 수행 중인 미션입니다."), USER_MISSION_NOT_FOUND(HttpStatus.NOT_FOUND, "MISSION4003", "수행 중인 미션이 존재하지 않습니다."), + // 토큰 관련 에러 + INVALID_TOKEN(HttpStatus.NOT_FOUND, "TOKEN4001", "토큰이 유효하지 않습니다."), + // 페이징 관련 에러 PAGE_NOT_VALID(HttpStatus.BAD_REQUEST, "PAGE4001", "페이징 번호가 유효하지 않습니다."); diff --git a/src/main/java/umc/config/security/CustomUserDetailsService.java b/src/main/java/umc/config/security/CustomUserDetailsService.java new file mode 100644 index 00000000..48dc3cea --- /dev/null +++ b/src/main/java/umc/config/security/CustomUserDetailsService.java @@ -0,0 +1,30 @@ +package umc.config.security; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import umc.apiPayload.code.status.ErrorStatus; +import umc.apiPayload.exception.GeneralException; +import umc.domain.User; +import umc.repository.UserRepository.UserRepository; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String username) { + User user = userRepository.findByEmail(username) + .orElseThrow(() -> new GeneralException(ErrorStatus.EMAIL_NOT_FOUND)); + + return org.springframework.security.core.userdetails.User + .withUsername(user.getEmail()) + .password(user.getPassword()) + .roles(user.getRole().name()) + .build(); + } +} diff --git a/src/main/java/umc/config/security/SecurityConfig.java b/src/main/java/umc/config/security/SecurityConfig.java new file mode 100644 index 00000000..9ba8e787 --- /dev/null +++ b/src/main/java/umc/config/security/SecurityConfig.java @@ -0,0 +1,41 @@ +package umc.config.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests( + (requests) -> requests + .requestMatchers("/", "/signup", "/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") + ) + .formLogin((form) -> form + .loginPage("/login") + .defaultSuccessUrl("/home", true) + .permitAll() + ) + .logout((logout) -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll() + ); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/umc/controller/UserViewController.java b/src/main/java/umc/controller/UserViewController.java new file mode 100644 index 00000000..77b325d1 --- /dev/null +++ b/src/main/java/umc/controller/UserViewController.java @@ -0,0 +1,56 @@ +package umc.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; + +import lombok.RequiredArgsConstructor; +import umc.dto.UserRequestDto; +import umc.service.UserService.UserCommandService; + +@Controller +@RequiredArgsConstructor +public class UserViewController { + + private final UserCommandService userCommandService; + + @PostMapping("/signup") + public String joinUser(@ModelAttribute("joinDto") UserRequestDto.JoinDto request, + BindingResult bindingResult, + Model model) { + if (bindingResult.hasErrors()) { + return "/signup"; + } + try { + userCommandService.joinUser(request); + return "redirect:/login"; + } catch (Exception e) { + model.addAttribute("error", e.getMessage()); + return "/signup"; + } + } + + @GetMapping("/login") + public String loginPage() { + return "/login"; + } + + @GetMapping("/home") + public String home() { + return "/home"; + } + + @GetMapping("/admin") + public String admin() { + return "/admin"; + } + + @GetMapping("/signup") + public String signup(Model model) { + model.addAttribute("joinDto", new UserRequestDto.JoinDto()); + return "signup"; + } +} diff --git a/src/main/java/umc/converter/UserConverter.java b/src/main/java/umc/converter/UserConverter.java index 054355d9..86e98aee 100644 --- a/src/main/java/umc/converter/UserConverter.java +++ b/src/main/java/umc/converter/UserConverter.java @@ -5,6 +5,7 @@ import umc.domain.Region; import umc.domain.User; import umc.domain.enums.Gender; +import umc.domain.enums.Role; import umc.dto.UserRequestDto; import umc.dto.UserResponseDto; @@ -22,13 +23,13 @@ public static User toUser(UserRequestDto.JoinDto request, Region region) { Gender gender = null; switch (request.getGender()) { - case MALE: + case "MALE": gender = Gender.MALE; break; - case FEMALE: + case "FEMALE": gender = Gender.FEMALE; break; - case NONE: + case "NONE": gender = Gender.NONE; break; } @@ -36,10 +37,11 @@ public static User toUser(UserRequestDto.JoinDto request, Region region) { return User.builder() .name(request.getName()) .email(request.getEmail()) - .gender(request.getGender()) + .gender(Gender.valueOf(request.getGender())) .birth(request.getBirthDate()) .addressDetail(request.getAddressDetail()) .region(region) + .role(Role.valueOf(request.getRole())) .build(); } } diff --git a/src/main/java/umc/domain/User.java b/src/main/java/umc/domain/User.java index 7754c13e..55fc80f2 100644 --- a/src/main/java/umc/domain/User.java +++ b/src/main/java/umc/domain/User.java @@ -26,6 +26,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import umc.domain.enums.Gender; +import umc.domain.enums.Role; import umc.domain.enums.SocialType; import umc.domain.enums.UserStatus; import umc.domain.mapping.PreferredCategory; @@ -84,6 +85,9 @@ public class User { private Boolean replyAlarmAccepted; private Boolean inquiryAlarmAccepted; + @Enumerated(EnumType.STRING) + private Role role; + @OneToMany(mappedBy = "user", orphanRemoval = true, cascade = CascadeType.REMOVE) @Builder.Default private List alarmList = new ArrayList<>(); @@ -99,4 +103,8 @@ public class User { @OneToMany(mappedBy = "user") @Builder.Default private List preferredCategoryList = new ArrayList<>(); + + public void encodePassword(String password) { + this.password = password; + } } diff --git a/src/main/java/umc/domain/enums/Role.java b/src/main/java/umc/domain/enums/Role.java new file mode 100644 index 00000000..1a53c153 --- /dev/null +++ b/src/main/java/umc/domain/enums/Role.java @@ -0,0 +1,5 @@ +package umc.domain.enums; + +public enum Role { + ADMIN, USER +} diff --git a/src/main/java/umc/dto/UserRequestDto.java b/src/main/java/umc/dto/UserRequestDto.java index ca133dbf..6fbdf5fa 100644 --- a/src/main/java/umc/dto/UserRequestDto.java +++ b/src/main/java/umc/dto/UserRequestDto.java @@ -3,23 +3,29 @@ import java.time.LocalDate; import java.util.List; +import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Getter; +import lombok.Setter; import umc.domain.enums.Gender; +import umc.domain.enums.Role; import umc.validation.annotation.ExistCategories; public class UserRequestDto { @Getter + @Setter // 폼 로그인 post 용 임시 public static class JoinDto { @NotBlank String name; @NotBlank String email; + @NotBlank + String password; @NotNull - Gender gender; + String gender; @NotNull LocalDate birthDate; @Size(min = 1, max = 100) @@ -28,5 +34,17 @@ public static class JoinDto { String addressDetail; @ExistCategories List preferCategory; + @NotNull + String role; // 역할 필드 추가 + } + + @Getter + public static class LoginRequestDto{ + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이어야 합니다.") + private String email; + + @NotBlank(message = "패스워드는 필수입니다.") + private String password; } } diff --git a/src/main/java/umc/dto/UserResponseDto.java b/src/main/java/umc/dto/UserResponseDto.java index 15050110..ec25e51d 100644 --- a/src/main/java/umc/dto/UserResponseDto.java +++ b/src/main/java/umc/dto/UserResponseDto.java @@ -17,4 +17,13 @@ public static class JoinResultDTO{ Long userId; LocalDateTime createdAt; } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class LoginResultDTO { + Long userId; + String accessToken; + } } diff --git a/src/main/java/umc/service/UserService/UserCommandServiceImpl.java b/src/main/java/umc/service/UserService/UserCommandServiceImpl.java index 44859fd8..78af188a 100644 --- a/src/main/java/umc/service/UserService/UserCommandServiceImpl.java +++ b/src/main/java/umc/service/UserService/UserCommandServiceImpl.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,6 +37,7 @@ public class UserCommandServiceImpl implements UserCommandService{ private final UserTermRepository userTermRepository; private final CategoryRepository categoryRepository; private final RegionRepository regionRepository; + private final PasswordEncoder passwordEncoder; @Override @Transactional @@ -62,6 +64,7 @@ public UserResponseDto.JoinResultDTO joinUser(UserRequestDto.JoinDto request) { // 유저 생성 User newUser = UserConverter.toUser(request, region); + newUser.encodePassword(passwordEncoder.encode(request.getPassword())); // 해당되는 카테고리 추출 List categoryList = request.getPreferCategory().stream() diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2e85ba69..f7b6b433 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -20,4 +20,10 @@ discord: webhook: url: springdoc: - use-fqn: true \ No newline at end of file + use-fqn: true + +jwt: + token: + secretKey: umceightfightingjwttokenauthentication + expiration: + access: 14400000 \ No newline at end of file diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html new file mode 100644 index 00000000..55dbff1a --- /dev/null +++ b/src/main/resources/templates/admin.html @@ -0,0 +1,10 @@ + + + + Admin Page + + +

Admin Page

+

관리자만 접근할 수 있는 페이지입니다.

+ + \ No newline at end of file diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html new file mode 100644 index 00000000..f4e96ab4 --- /dev/null +++ b/src/main/resources/templates/home.html @@ -0,0 +1,20 @@ +Add commentMore actions + + + Home + + +

Welcome to Home Page!

+ +

+ + + + + +
+ +
+ \ No newline at end of file diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html new file mode 100644 index 00000000..7804a3a2 --- /dev/null +++ b/src/main/resources/templates/login.html @@ -0,0 +1,26 @@ + + + + Login + + +

Login

+
+
+ + +
+
+ + +
+ +
+ +

사용자 이름 또는 비밀번호가 잘못되었습니다.

+

로그아웃되었습니다.

+ + +

계정이 없나요? Sign up

+ + \ No newline at end of file diff --git a/src/main/resources/templates/signup.html b/src/main/resources/templates/signup.html new file mode 100644 index 00000000..1ba4027c --- /dev/null +++ b/src/main/resources/templates/signup.html @@ -0,0 +1,64 @@ +Add commentMore actions + + + 회원가입 + + + +

회원가입

+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + + +
+
+
+ + +
+ +
+ + \ No newline at end of file From 2e3c84dd0070cb5d9b46a45878ba0f089c2e4c83 Mon Sep 17 00:00:00 2001 From: pywoo Date: Tue, 17 Jun 2025 17:58:34 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20jwt=20=EC=8B=A4=EC=8A=B5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 + .../apiPayload/code/status/ErrorStatus.java | 1 + src/main/java/umc/config/SwaggerConfig.java | 9 ++ .../config/jwt/JwtAuthenticationFilter.java | 34 +++++++ .../java/umc/config/jwt/JwtTokenProvider.java | 98 +++++++++++++++++++ .../java/umc/config/properties/Constants.java | 6 ++ .../umc/config/properties/JwtProperties.java | 23 +++++ .../umc/config/security/SecurityConfig.java | 30 +++--- .../java/umc/controller/UserController.java | 39 -------- .../umc/controller/UserRestController.java | 68 +++++++++++++ .../umc/controller/UserViewController.java | 4 + .../java/umc/converter/UserConverter.java | 17 ++++ src/main/java/umc/dto/UserResponseDto.java | 10 ++ .../UserService/UserCommandService.java | 1 + .../UserService/UserCommandServiceImpl.java | 26 +++++ .../service/UserService/UserQueryService.java | 6 ++ .../UserService/UserQueryServiceImpl.java | 22 +++++ 17 files changed, 348 insertions(+), 51 deletions(-) create mode 100644 src/main/java/umc/config/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/umc/config/jwt/JwtTokenProvider.java create mode 100644 src/main/java/umc/config/properties/Constants.java create mode 100644 src/main/java/umc/config/properties/JwtProperties.java delete mode 100644 src/main/java/umc/controller/UserController.java create mode 100644 src/main/java/umc/controller/UserRestController.java diff --git a/build.gradle b/build.gradle index 797d882c..eddac2e6 100644 --- a/build.gradle +++ b/build.gradle @@ -48,6 +48,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.1.RELEASE' + // 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' + //Spring Boot Security implementation 'org.springframework.boot:spring-boot-starter-security' } diff --git a/src/main/java/umc/apiPayload/code/status/ErrorStatus.java b/src/main/java/umc/apiPayload/code/status/ErrorStatus.java index 8dd57572..eab50ff8 100644 --- a/src/main/java/umc/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/umc/apiPayload/code/status/ErrorStatus.java @@ -21,6 +21,7 @@ public enum ErrorStatus implements BaseErrorCode { USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "USER4001", "사용자가 없습니다."), NICKNAME_NOT_EXIST(HttpStatus.BAD_REQUEST, "USER4002", "닉네임은 필수 입니다."), EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "USER4003", "해당 이메일의 사용자가 없습니다."), + INVALID_PASSWORD(HttpStatus.NOT_FOUND, "USER4004", "비밀번호가 일치하지 않습니다."), // 예시 ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."), diff --git a/src/main/java/umc/config/SwaggerConfig.java b/src/main/java/umc/config/SwaggerConfig.java index 407bd3a8..1cf3756c 100644 --- a/src/main/java/umc/config/SwaggerConfig.java +++ b/src/main/java/umc/config/SwaggerConfig.java @@ -5,6 +5,8 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; @@ -13,6 +15,13 @@ import io.swagger.v3.oas.models.servers.Server; @Configuration +@io.swagger.v3.oas.annotations.security.SecurityScheme( + name = "JWT Token", + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", + in = SecuritySchemeIn.HEADER +) public class SwaggerConfig { @Bean diff --git a/src/main/java/umc/config/jwt/JwtAuthenticationFilter.java b/src/main/java/umc/config/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..2d1a55e0 --- /dev/null +++ b/src/main/java/umc/config/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,34 @@ +package umc.config.jwt; + +import java.io.IOException; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + String token = JwtTokenProvider.resolveToken(request); + System.out.println(token); + if(StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/umc/config/jwt/JwtTokenProvider.java b/src/main/java/umc/config/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..da09eff8 --- /dev/null +++ b/src/main/java/umc/config/jwt/JwtTokenProvider.java @@ -0,0 +1,98 @@ +package umc.config.jwt; + +import java.security.Key; +import java.security.KeyStore; +import java.util.Collections; +import java.util.Date; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import umc.apiPayload.code.status.ErrorStatus; +import umc.apiPayload.exception.GeneralException; +import umc.config.properties.Constants; +import umc.config.properties.JwtProperties; +import umc.domain.enums.Role; + +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final JwtProperties jwtProperties; + + private Key getSigningKey() { + return Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes()); + } + + // 토큰 생성 + public String generateToken(Authentication authentication) { + String email = authentication.getName(); + + return Jwts.builder() + .setSubject(email) // 식별값 + .claim("role", authentication.getAuthorities().iterator().next().getAuthority()) // 권한 등 추가 정보 + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration().getAccess())) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + // Token 유효성 판단 + public boolean validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + // HTTP Request로부터 Token 추출 및 Authentication 반환 + public Authentication extractAuthentication(HttpServletRequest request){ + String accessToken = resolveToken(request); + if(accessToken == null || !validateToken(accessToken)) { + throw new GeneralException(ErrorStatus.INVALID_TOKEN); + } + return getAuthentication(accessToken); + } + + // Request 로부터 토큰 추출 + public static String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(Constants.AUTH_HEADER); + System.out.println(bearerToken); + if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(Constants.TOKEN_PREFIX)) { + return bearerToken.substring(Constants.TOKEN_PREFIX.length()); + } + return null; + } + + // 토큰으로 부터 Authentication 추출 + public Authentication getAuthentication(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + + String email = claims.getSubject(); + String role = claims.get("role", String.class); + + // org.springframework.security.core.userdetails.User + User principal = new User(email, "", Collections.singleton(() -> role)); + return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities()); + } +} diff --git a/src/main/java/umc/config/properties/Constants.java b/src/main/java/umc/config/properties/Constants.java new file mode 100644 index 00000000..e452aeb4 --- /dev/null +++ b/src/main/java/umc/config/properties/Constants.java @@ -0,0 +1,6 @@ +package umc.config.properties; + +public final class Constants { + public static final String AUTH_HEADER = "Authorization"; + public static final String TOKEN_PREFIX = "Bearer "; +} diff --git a/src/main/java/umc/config/properties/JwtProperties.java b/src/main/java/umc/config/properties/JwtProperties.java new file mode 100644 index 00000000..0faf0376 --- /dev/null +++ b/src/main/java/umc/config/properties/JwtProperties.java @@ -0,0 +1,23 @@ +package umc.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +@Component +@Getter +@Setter +@ConfigurationProperties("jwt.token") +public class JwtProperties { + private String secretKey=""; + private Expiration expiration; + + @Getter + @Setter + public static class Expiration{ + private Long access; + // TODO: refreshToken + } +} diff --git a/src/main/java/umc/config/security/SecurityConfig.java b/src/main/java/umc/config/security/SecurityConfig.java index 9ba8e787..633d8ba1 100644 --- a/src/main/java/umc/config/security/SecurityConfig.java +++ b/src/main/java/umc/config/security/SecurityConfig.java @@ -4,32 +4,38 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +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.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import lombok.RequiredArgsConstructor; +import umc.config.jwt.JwtAuthenticationFilter; +import umc.config.jwt.JwtTokenProvider; @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final JwtTokenProvider jwtTokenProvider; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // JSESSIONID 발급 X + ) .authorizeHttpRequests( (requests) -> requests - .requestMatchers("/", "/signup", "/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll() - .requestMatchers("/admin/**").hasRole("ADMIN") - ) - .formLogin((form) -> form - .loginPage("/login") - .defaultSuccessUrl("/home", true) - .permitAll() + .requestMatchers("/", "/users/signup", "/users/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") // 인가 필요 + .anyRequest().authenticated() // 인증 필요 ) - .logout((logout) -> logout - .logoutUrl("/logout") - .logoutSuccessUrl("/login?logout") - .permitAll() - ); + .csrf(AbstractHttpConfigurer::disable) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/umc/controller/UserController.java b/src/main/java/umc/controller/UserController.java deleted file mode 100644 index 42d34a49..00000000 --- a/src/main/java/umc/controller/UserController.java +++ /dev/null @@ -1,39 +0,0 @@ -package umc.controller; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -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.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import umc.apiPayload.ApiResponse; -import umc.converter.UserConverter; -import umc.domain.User; -import umc.dto.UserRequestDto; -import umc.dto.UserResponseDto; -import umc.dto.WithdrawUserDto; -import umc.service.UserService.UserCommandServiceImpl; - -@RequestMapping("/users") -@RestController -@RequiredArgsConstructor -public class UserController { - - private final UserCommandServiceImpl userCommandService; - - @PostMapping("/") - public ResponseEntity> join(@RequestBody @Valid UserRequestDto.JoinDto request) { - UserResponseDto.JoinResultDTO response = userCommandService.joinUser(request); - return ResponseEntity.ok(ApiResponse.onSuccess(response)); - } - - @DeleteMapping("/withdraw") - public ResponseEntity withdrawUser(@RequestParam Long userId) { - WithdrawUserDto response = userCommandService.withdrawUser(userId); - return ResponseEntity.ok(response); - } -} diff --git a/src/main/java/umc/controller/UserRestController.java b/src/main/java/umc/controller/UserRestController.java new file mode 100644 index 00000000..b5e6e969 --- /dev/null +++ b/src/main/java/umc/controller/UserRestController.java @@ -0,0 +1,68 @@ +package umc.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import umc.apiPayload.ApiResponse; +import umc.dto.UserRequestDto; +import umc.dto.UserResponseDto; +import umc.dto.WithdrawUserDto; +import umc.service.UserService.UserCommandServiceImpl; +import umc.service.UserService.UserQueryService; + +@RequestMapping("/users") +@RestController +@RequiredArgsConstructor +public class UserRestController { + + private final UserCommandServiceImpl userCommandService; + private final UserQueryService userQueryService; + + @PostMapping("/") + public ResponseEntity> join(@RequestBody @Valid UserRequestDto.JoinDto request) { + UserResponseDto.JoinResultDTO response = userCommandService.joinUser(request); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } + + @DeleteMapping("/withdraw") + public ResponseEntity withdrawUser(@RequestParam Long userId) { + WithdrawUserDto response = userCommandService.withdrawUser(userId); + return ResponseEntity.ok(response); + } + + @PostMapping("/login") + @Operation(summary = "유저 로그인 API",description = "유저가 로그인하는 API입니다.") + public ResponseEntity> login(@RequestBody @Valid UserRequestDto.LoginRequestDto request) { + UserResponseDto.LoginResultDTO response = userCommandService.loginUser(request); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } + + @GetMapping("/info") + @Operation(summary = "유저 내 정보 조회 API - 인증 필요", + description = "유저가 내 정보를 조회하는 API입니다." + ) + public ResponseEntity> getMyInfo(Authentication authentication) { + UserResponseDto.UserInfoDTO response = userQueryService.getUserInfo(authentication); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } + + @PostMapping("/signup") + @Operation(summary = "유저 회원가입 API",description = "유저가 회원가입하는 API입니다.") + public ResponseEntity> signup(@RequestBody @Valid UserRequestDto.JoinDto request) { + UserResponseDto.JoinResultDTO response = userCommandService.joinUser(request); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } +} diff --git a/src/main/java/umc/controller/UserViewController.java b/src/main/java/umc/controller/UserViewController.java index 77b325d1..bd7e75fc 100644 --- a/src/main/java/umc/controller/UserViewController.java +++ b/src/main/java/umc/controller/UserViewController.java @@ -6,6 +6,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; import lombok.RequiredArgsConstructor; import umc.dto.UserRequestDto; @@ -13,10 +14,12 @@ @Controller @RequiredArgsConstructor +@RequestMapping("/users") public class UserViewController { private final UserCommandService userCommandService; + /* @PostMapping("/signup") public String joinUser(@ModelAttribute("joinDto") UserRequestDto.JoinDto request, BindingResult bindingResult, @@ -32,6 +35,7 @@ public String joinUser(@ModelAttribute("joinDto") UserRequestDto.JoinDto request return "/signup"; } } + */ @GetMapping("/login") public String loginPage() { diff --git a/src/main/java/umc/converter/UserConverter.java b/src/main/java/umc/converter/UserConverter.java index 86e98aee..6f86206b 100644 --- a/src/main/java/umc/converter/UserConverter.java +++ b/src/main/java/umc/converter/UserConverter.java @@ -44,4 +44,21 @@ public static User toUser(UserRequestDto.JoinDto request, Region region) { .role(Role.valueOf(request.getRole())) .build(); } + + public static UserResponseDto.LoginResultDTO toLoginResultDto(Long userId, String accessToken) { + + return UserResponseDto.LoginResultDTO.builder() + .userId(userId) + .accessToken(accessToken) + .build(); + } + + public static UserResponseDto.UserInfoDTO toUserInfoDTO(User user) { + + return UserResponseDto.UserInfoDTO.builder() + .name(user.getName()) + .email(user.getEmail()) + .gender(user.getGender().getDescription()) + .build(); + } } diff --git a/src/main/java/umc/dto/UserResponseDto.java b/src/main/java/umc/dto/UserResponseDto.java index ec25e51d..23e4bfa2 100644 --- a/src/main/java/umc/dto/UserResponseDto.java +++ b/src/main/java/umc/dto/UserResponseDto.java @@ -26,4 +26,14 @@ public static class LoginResultDTO { Long userId; String accessToken; } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class UserInfoDTO{ + String name; + String email; + String gender; + } } diff --git a/src/main/java/umc/service/UserService/UserCommandService.java b/src/main/java/umc/service/UserService/UserCommandService.java index 2d506ac0..43cca87e 100644 --- a/src/main/java/umc/service/UserService/UserCommandService.java +++ b/src/main/java/umc/service/UserService/UserCommandService.java @@ -7,4 +7,5 @@ public interface UserCommandService { WithdrawUserDto withdrawUser(Long userId); UserResponseDto.JoinResultDTO joinUser(UserRequestDto.JoinDto request); + UserResponseDto.LoginResultDTO loginUser(UserRequestDto.LoginRequestDto request); } diff --git a/src/main/java/umc/service/UserService/UserCommandServiceImpl.java b/src/main/java/umc/service/UserService/UserCommandServiceImpl.java index 78af188a..58d628c8 100644 --- a/src/main/java/umc/service/UserService/UserCommandServiceImpl.java +++ b/src/main/java/umc/service/UserService/UserCommandServiceImpl.java @@ -1,7 +1,10 @@ package umc.service.UserService; +import java.util.Collections; import java.util.List; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -9,6 +12,7 @@ import lombok.RequiredArgsConstructor; import umc.apiPayload.code.status.ErrorStatus; import umc.apiPayload.exception.GeneralException; +import umc.config.jwt.JwtTokenProvider; import umc.converter.PreferredCategoryConverter; import umc.converter.UserConverter; import umc.domain.Category; @@ -38,6 +42,7 @@ public class UserCommandServiceImpl implements UserCommandService{ private final CategoryRepository categoryRepository; private final RegionRepository regionRepository; private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; @Override @Transactional @@ -82,4 +87,25 @@ public UserResponseDto.JoinResultDTO joinUser(UserRequestDto.JoinDto request) { return UserConverter.toJoinResultDto(userRepository.save(newUser)); } + @Override + public UserResponseDto.LoginResultDTO loginUser(UserRequestDto.LoginRequestDto request) { + User user = userRepository.findByEmail(request.getEmail()) + .orElseThrow(()-> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + if(!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + throw new GeneralException(ErrorStatus.INVALID_PASSWORD); + } + + Authentication authentication = new UsernamePasswordAuthenticationToken( + user.getEmail(), null, + Collections.singleton(() -> user.getRole().name()) + ); + + String accessToken = jwtTokenProvider.generateToken(authentication); + + return UserConverter.toLoginResultDto( + user.getId(), + accessToken + ); + } } diff --git a/src/main/java/umc/service/UserService/UserQueryService.java b/src/main/java/umc/service/UserService/UserQueryService.java index dda81d4d..e615f033 100644 --- a/src/main/java/umc/service/UserService/UserQueryService.java +++ b/src/main/java/umc/service/UserService/UserQueryService.java @@ -1,4 +1,10 @@ package umc.service.UserService; +import org.springframework.security.core.Authentication; + +import jakarta.servlet.http.HttpServletRequest; +import umc.dto.UserResponseDto; + public interface UserQueryService { + UserResponseDto.UserInfoDTO getUserInfo(Authentication authentication); } diff --git a/src/main/java/umc/service/UserService/UserQueryServiceImpl.java b/src/main/java/umc/service/UserService/UserQueryServiceImpl.java index 3039c6aa..880045c2 100644 --- a/src/main/java/umc/service/UserService/UserQueryServiceImpl.java +++ b/src/main/java/umc/service/UserService/UserQueryServiceImpl.java @@ -1,10 +1,32 @@ package umc.service.UserService; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import umc.apiPayload.code.status.ErrorStatus; +import umc.apiPayload.exception.GeneralException; +import umc.config.jwt.JwtTokenProvider; +import umc.converter.UserConverter; +import umc.domain.User; +import umc.dto.UserResponseDto; +import umc.repository.UserRepository.UserRepository; @Service @RequiredArgsConstructor public class UserQueryServiceImpl implements UserQueryService { + + private final UserRepository userRepository; + + @Override + public UserResponseDto.UserInfoDTO getUserInfo(Authentication authentication) { + + String email = authentication.getName(); + + User user = userRepository.findByEmail(email) + .orElseThrow(()-> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + return UserConverter.toUserInfoDTO(user); + } } From 130de71bcf1ab692e8d0da4fba0d82c5e444a146 Mon Sep 17 00:00:00 2001 From: pywoo Date: Tue, 17 Jun 2025 21:39:15 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20refresh=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/umc/config/jwt/JwtTokenProvider.java | 13 +++++-- .../umc/config/properties/JwtProperties.java | 2 +- .../umc/config/security/SecurityConfig.java | 2 +- .../umc/controller/UserRestController.java | 7 ++++ .../java/umc/converter/UserConverter.java | 20 +++++++++- .../umc/domain/security/RefreshToken.java | 26 +++++++++++++ src/main/java/umc/dto/UserRequestDto.java | 8 ++++ src/main/java/umc/dto/UserResponseDto.java | 10 +++++ .../RefreshTokenRepository.java | 11 ++++++ .../UserService/UserCommandService.java | 1 + .../UserService/UserCommandServiceImpl.java | 39 ++++++++++++++++++- src/main/resources/application.yml | 3 +- 12 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 src/main/java/umc/domain/security/RefreshToken.java create mode 100644 src/main/java/umc/repository/RefreshTokenRepository/RefreshTokenRepository.java diff --git a/src/main/java/umc/config/jwt/JwtTokenProvider.java b/src/main/java/umc/config/jwt/JwtTokenProvider.java index da09eff8..8093db3d 100644 --- a/src/main/java/umc/config/jwt/JwtTokenProvider.java +++ b/src/main/java/umc/config/jwt/JwtTokenProvider.java @@ -1,13 +1,13 @@ package umc.config.jwt; +import static java.time.LocalTime.*; + import java.security.Key; -import java.security.KeyStore; import java.util.Collections; import java.util.Date; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.User; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -23,7 +23,6 @@ import umc.apiPayload.exception.GeneralException; import umc.config.properties.Constants; import umc.config.properties.JwtProperties; -import umc.domain.enums.Role; @Component @RequiredArgsConstructor @@ -48,6 +47,14 @@ public String generateToken(Authentication authentication) { .compact(); } + // 리프레시 토큰 생성 + public String generateRefreshToken() { + return Jwts.builder() + .setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getExpiration().getRefresh())) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + // Token 유효성 판단 public boolean validateToken(String token) { try { diff --git a/src/main/java/umc/config/properties/JwtProperties.java b/src/main/java/umc/config/properties/JwtProperties.java index 0faf0376..2a7f73d5 100644 --- a/src/main/java/umc/config/properties/JwtProperties.java +++ b/src/main/java/umc/config/properties/JwtProperties.java @@ -18,6 +18,6 @@ public class JwtProperties { @Setter public static class Expiration{ private Long access; - // TODO: refreshToken + private Long refresh; } } diff --git a/src/main/java/umc/config/security/SecurityConfig.java b/src/main/java/umc/config/security/SecurityConfig.java index 633d8ba1..cacc6468 100644 --- a/src/main/java/umc/config/security/SecurityConfig.java +++ b/src/main/java/umc/config/security/SecurityConfig.java @@ -30,7 +30,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ) .authorizeHttpRequests( (requests) -> requests - .requestMatchers("/", "/users/signup", "/users/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/", "/users/signup", "/users/login", "/swagger-ui/**", "/v3/api-docs/**", "/users/reissue").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") // 인가 필요 .anyRequest().authenticated() // 인증 필요 ) diff --git a/src/main/java/umc/controller/UserRestController.java b/src/main/java/umc/controller/UserRestController.java index b5e6e969..85b98583 100644 --- a/src/main/java/umc/controller/UserRestController.java +++ b/src/main/java/umc/controller/UserRestController.java @@ -65,4 +65,11 @@ public ResponseEntity> signup(@Reques UserResponseDto.JoinResultDTO response = userCommandService.joinUser(request); return ResponseEntity.ok(ApiResponse.onSuccess(response)); } + + @PostMapping("/reissue") + @Operation(summary = "액세스 토큰 재발급 API",description = "토큰 재발급 API입니다.") + public ResponseEntity> reissue(@RequestBody @Valid UserRequestDto.ReissueDto request) { + UserResponseDto.ReissueDto response = userCommandService.reissue(request); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } } diff --git a/src/main/java/umc/converter/UserConverter.java b/src/main/java/umc/converter/UserConverter.java index 6f86206b..5b350f97 100644 --- a/src/main/java/umc/converter/UserConverter.java +++ b/src/main/java/umc/converter/UserConverter.java @@ -6,6 +6,7 @@ import umc.domain.User; import umc.domain.enums.Gender; import umc.domain.enums.Role; +import umc.domain.security.RefreshToken; import umc.dto.UserRequestDto; import umc.dto.UserResponseDto; @@ -45,11 +46,12 @@ public static User toUser(UserRequestDto.JoinDto request, Region region) { .build(); } - public static UserResponseDto.LoginResultDTO toLoginResultDto(Long userId, String accessToken) { + public static UserResponseDto.LoginResultDTO toLoginResultDto(Long userId, String accessToken, String refreshToken) { return UserResponseDto.LoginResultDTO.builder() .userId(userId) .accessToken(accessToken) + .refreshToken(refreshToken) .build(); } @@ -61,4 +63,20 @@ public static UserResponseDto.UserInfoDTO toUserInfoDTO(User user) { .gender(user.getGender().getDescription()) .build(); } + + public static UserResponseDto.ReissueDto toReissueDto(String accessToken, String refreshToken) { + + return UserResponseDto.ReissueDto.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + public static RefreshToken toRefreshToken(String email, String refreshToken) { + + return RefreshToken.builder() + .email(email) + .value(refreshToken) + .build(); + } } diff --git a/src/main/java/umc/domain/security/RefreshToken.java b/src/main/java/umc/domain/security/RefreshToken.java new file mode 100644 index 00000000..8fc7f170 --- /dev/null +++ b/src/main/java/umc/domain/security/RefreshToken.java @@ -0,0 +1,26 @@ +package umc.domain.security; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class RefreshToken { + + @Id + private String email; + + private String value; + + public RefreshToken updateValue(String token) { + this.value = token; + return this; + } +} diff --git a/src/main/java/umc/dto/UserRequestDto.java b/src/main/java/umc/dto/UserRequestDto.java index 6fbdf5fa..c8bc968c 100644 --- a/src/main/java/umc/dto/UserRequestDto.java +++ b/src/main/java/umc/dto/UserRequestDto.java @@ -47,4 +47,12 @@ public static class LoginRequestDto{ @NotBlank(message = "패스워드는 필수입니다.") private String password; } + + @Getter + public static class ReissueDto { + @NotBlank + private String accessToken; + @NotBlank + private String refreshToken; + } } diff --git a/src/main/java/umc/dto/UserResponseDto.java b/src/main/java/umc/dto/UserResponseDto.java index 23e4bfa2..bc4a59dd 100644 --- a/src/main/java/umc/dto/UserResponseDto.java +++ b/src/main/java/umc/dto/UserResponseDto.java @@ -25,6 +25,7 @@ public static class JoinResultDTO{ public static class LoginResultDTO { Long userId; String accessToken; + String refreshToken; } @Builder @@ -36,4 +37,13 @@ public static class UserInfoDTO{ String email; String gender; } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class ReissueDto { + private String accessToken; + private String refreshToken; + } } diff --git a/src/main/java/umc/repository/RefreshTokenRepository/RefreshTokenRepository.java b/src/main/java/umc/repository/RefreshTokenRepository/RefreshTokenRepository.java new file mode 100644 index 00000000..e12280a2 --- /dev/null +++ b/src/main/java/umc/repository/RefreshTokenRepository/RefreshTokenRepository.java @@ -0,0 +1,11 @@ +package umc.repository.RefreshTokenRepository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import umc.domain.security.RefreshToken; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/src/main/java/umc/service/UserService/UserCommandService.java b/src/main/java/umc/service/UserService/UserCommandService.java index 43cca87e..f674fe8b 100644 --- a/src/main/java/umc/service/UserService/UserCommandService.java +++ b/src/main/java/umc/service/UserService/UserCommandService.java @@ -8,4 +8,5 @@ public interface UserCommandService { WithdrawUserDto withdrawUser(Long userId); UserResponseDto.JoinResultDTO joinUser(UserRequestDto.JoinDto request); UserResponseDto.LoginResultDTO loginUser(UserRequestDto.LoginRequestDto request); + UserResponseDto.ReissueDto reissue(UserRequestDto.ReissueDto request); } diff --git a/src/main/java/umc/service/UserService/UserCommandServiceImpl.java b/src/main/java/umc/service/UserService/UserCommandServiceImpl.java index 58d628c8..6e63b3cb 100644 --- a/src/main/java/umc/service/UserService/UserCommandServiceImpl.java +++ b/src/main/java/umc/service/UserService/UserCommandServiceImpl.java @@ -19,12 +19,14 @@ import umc.domain.Region; import umc.domain.User; import umc.domain.mapping.PreferredCategory; +import umc.domain.security.RefreshToken; import umc.dto.UserRequestDto; import umc.dto.UserResponseDto; import umc.dto.WithdrawUserDto; import umc.repository.AlarmRepository.AlarmRepository; import umc.repository.CategoryRepository.CategoryRepository; import umc.repository.PreferredCategoryRepository.PreferredCategoryRepository; +import umc.repository.RefreshTokenRepository.RefreshTokenRepository; import umc.repository.RegionRepository.RegionRepository; import umc.repository.ReviewRepository.ReviewRepository; import umc.repository.UserRepository.UserRepository; @@ -43,6 +45,7 @@ public class UserCommandServiceImpl implements UserCommandService{ private final RegionRepository regionRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; @Override @Transactional @@ -88,6 +91,7 @@ public UserResponseDto.JoinResultDTO joinUser(UserRequestDto.JoinDto request) { } @Override + @Transactional public UserResponseDto.LoginResultDTO loginUser(UserRequestDto.LoginRequestDto request) { User user = userRepository.findByEmail(request.getEmail()) .orElseThrow(()-> new GeneralException(ErrorStatus.USER_NOT_FOUND)); @@ -102,10 +106,43 @@ public UserResponseDto.LoginResultDTO loginUser(UserRequestDto.LoginRequestDto r ); String accessToken = jwtTokenProvider.generateToken(authentication); + String refreshToken = jwtTokenProvider.generateRefreshToken(); + + refreshTokenRepository.save(UserConverter.toRefreshToken(user.getEmail(), refreshToken)); return UserConverter.toLoginResultDto( user.getId(), - accessToken + accessToken, + refreshToken ); } + + @Override + @Transactional + public UserResponseDto.ReissueDto reissue(UserRequestDto.ReissueDto request) { + + // 1. refreshToken 검증 + if (!jwtTokenProvider.validateToken(request.getRefreshToken())) { + throw new GeneralException(ErrorStatus.INVALID_TOKEN); + } + + // 2. accessToken 에서 Authentication 추출 + Authentication authentication = jwtTokenProvider.getAuthentication(request.getAccessToken()); + + // 3. Authentication에서 사용자의 email로 refreshToken 가져오기 + RefreshToken refreshToken = refreshTokenRepository.findByEmail(authentication.getName()) + .orElseThrow(() -> new GeneralException(ErrorStatus.INVALID_TOKEN)); + + // 4. 입력 refreshToken, 찾은 refreshToken 일치성 검사 + if (!refreshToken.getValue().equals(request.getRefreshToken())) { + throw new GeneralException(ErrorStatus.INVALID_TOKEN); + } + + // 5. 새로운 토큰 생성 + String newAccessToken = jwtTokenProvider.generateToken(authentication); + String newRefreshToken = !jwtTokenProvider.validateToken(refreshToken.getValue()) ? + jwtTokenProvider.generateRefreshToken() : request.getRefreshToken(); + + return UserConverter.toReissueDto(newAccessToken, newRefreshToken); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index f7b6b433..aa212c1c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -26,4 +26,5 @@ jwt: token: secretKey: umceightfightingjwttokenauthentication expiration: - access: 14400000 \ No newline at end of file + access: 14400000 + refresh: 86400000 \ No newline at end of file From e130e6504aeac9e0365809416a8375f3483b4170 Mon Sep 17 00:00:00 2001 From: pywoo Date: Wed, 18 Jun 2025 15:15:26 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/umc/config/jwt/JwtTokenProvider.java | 2 - .../umc/config/security/SecurityConfig.java | 2 +- .../java/umc/controller/OAuthController.java | 24 ++++++ .../java/umc/converter/UserConverter.java | 9 +++ src/main/java/umc/dto/KakaoDto.java | 46 +++++++++++ src/main/java/umc/service/AuthService.java | 76 +++++++++++++++++++ .../umc/service/KakaoClient/KakaoClient.java | 64 ++++++++++++++++ src/main/resources/application.yml | 5 ++ 8 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 src/main/java/umc/controller/OAuthController.java create mode 100644 src/main/java/umc/dto/KakaoDto.java create mode 100644 src/main/java/umc/service/AuthService.java create mode 100644 src/main/java/umc/service/KakaoClient/KakaoClient.java diff --git a/src/main/java/umc/config/jwt/JwtTokenProvider.java b/src/main/java/umc/config/jwt/JwtTokenProvider.java index 8093db3d..7890a8d7 100644 --- a/src/main/java/umc/config/jwt/JwtTokenProvider.java +++ b/src/main/java/umc/config/jwt/JwtTokenProvider.java @@ -1,7 +1,5 @@ package umc.config.jwt; -import static java.time.LocalTime.*; - import java.security.Key; import java.util.Collections; import java.util.Date; diff --git a/src/main/java/umc/config/security/SecurityConfig.java b/src/main/java/umc/config/security/SecurityConfig.java index cacc6468..c8d3aede 100644 --- a/src/main/java/umc/config/security/SecurityConfig.java +++ b/src/main/java/umc/config/security/SecurityConfig.java @@ -30,7 +30,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ) .authorizeHttpRequests( (requests) -> requests - .requestMatchers("/", "/users/signup", "/users/login", "/swagger-ui/**", "/v3/api-docs/**", "/users/reissue").permitAll() + .requestMatchers("/", "/users/signup", "/users/login", "/swagger-ui/**", "/v3/api-docs/**", "/users/reissue", "/auth/login/kakao/**").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") // 인가 필요 .anyRequest().authenticated() // 인증 필요 ) diff --git a/src/main/java/umc/controller/OAuthController.java b/src/main/java/umc/controller/OAuthController.java new file mode 100644 index 00000000..7e467eaf --- /dev/null +++ b/src/main/java/umc/controller/OAuthController.java @@ -0,0 +1,24 @@ +package umc.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import umc.apiPayload.ApiResponse; +import umc.dto.UserResponseDto; +import umc.service.AuthService; + +@RestController +@RequiredArgsConstructor +public class OAuthController { + + private final AuthService authService; + + @GetMapping("/auth/login/kakao") + public ResponseEntity> kakaoLogin(@RequestParam("code") String accessCode) { + UserResponseDto.LoginResultDTO response = authService.oAuthLogin(accessCode); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } +} diff --git a/src/main/java/umc/converter/UserConverter.java b/src/main/java/umc/converter/UserConverter.java index 5b350f97..3e8cd28d 100644 --- a/src/main/java/umc/converter/UserConverter.java +++ b/src/main/java/umc/converter/UserConverter.java @@ -79,4 +79,13 @@ public static RefreshToken toRefreshToken(String email, String refreshToken) { .value(refreshToken) .build(); } + + public static User toKakaoUser(String email, String name) { + return User.builder() + .email(email) + .password("") + .name(name) + .role(Role.USER) + .build(); + } } diff --git a/src/main/java/umc/dto/KakaoDto.java b/src/main/java/umc/dto/KakaoDto.java new file mode 100644 index 00000000..b4cac0f0 --- /dev/null +++ b/src/main/java/umc/dto/KakaoDto.java @@ -0,0 +1,46 @@ +package umc.dto; + +import lombok.Getter; + +public class KakaoDto { + + @Getter + public static class OAuthToken { + private String access_token; + private String token_type; + private String refresh_token; + private int expires_in; + private String scope; + private int refresh_token_expires_in; + } + + @Getter + public static class KakaoProfile { + private Long id; + private String connected_at; + private Properties properties; + private KakaoAccount kakao_account; + + @Getter + public class Properties { + private String nickname; + } + + @Getter + public class KakaoAccount { + private String email; + private Boolean is_email_verified; + private Boolean has_email; + private Boolean profile_nickname_needs_agreement; + private Boolean email_needs_agreement; + private Boolean is_email_valid; + private Profile profile; + + @Getter + public class Profile { + private String nickname; + private Boolean is_default_nickname; + } + } + } +} diff --git a/src/main/java/umc/service/AuthService.java b/src/main/java/umc/service/AuthService.java new file mode 100644 index 00000000..1af4e436 --- /dev/null +++ b/src/main/java/umc/service/AuthService.java @@ -0,0 +1,76 @@ +package umc.service; + +import java.util.Collections; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import umc.config.jwt.JwtTokenProvider; +import umc.service.KakaoClient.KakaoClient; +import umc.converter.UserConverter; +import umc.domain.User; +import umc.dto.KakaoDto; +import umc.dto.UserResponseDto; +import umc.repository.RefreshTokenRepository.RefreshTokenRepository; +import umc.repository.UserRepository.UserRepository; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final KakaoClient kakaoClient; + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + + @Transactional + public UserResponseDto.LoginResultDTO oAuthLogin(String accessCode) { + + KakaoDto.OAuthToken oAuthToken = kakaoClient.requestToken(accessCode); + KakaoDto.KakaoProfile kakaoProfile = kakaoClient.requestProfile(oAuthToken); + + String email = kakaoProfile.getKakao_account().getEmail(); + + // 아래 코드 개선 방안 + // 1. 최초 회원가입은 임시토큰 발행해줘서 나머지 정보 입력 유도 + // 2. 기존 회원은 access + refresh 그대로 발행 + /* + if(newUser(email)) { + 임시 token 생성; + return; + } else { + accessToken, refreshToken 생성 및 저장; + return; + } + */ + User user = userRepository.findByEmail(email) + .orElseGet(() -> createNewUser(kakaoProfile)); + + Authentication authentication = new UsernamePasswordAuthenticationToken( + user.getEmail(), null, + Collections.singleton(() -> user.getRole().name()) + ); + + String accessToken = jwtTokenProvider.generateToken(authentication); + String refreshToken = jwtTokenProvider.generateRefreshToken(); + + refreshTokenRepository.save(UserConverter.toRefreshToken(user.getEmail(), refreshToken)); + + return UserConverter.toLoginResultDto( + user.getId(), + accessToken, + refreshToken + ); + } + + private User createNewUser(KakaoDto.KakaoProfile kakaoProfile) { + User newUser = UserConverter.toKakaoUser( + kakaoProfile.getKakao_account().getEmail(), + kakaoProfile.getKakao_account().getProfile().getNickname() + ); + return userRepository.save(newUser); + } +} \ No newline at end of file diff --git a/src/main/java/umc/service/KakaoClient/KakaoClient.java b/src/main/java/umc/service/KakaoClient/KakaoClient.java new file mode 100644 index 00000000..8390d061 --- /dev/null +++ b/src/main/java/umc/service/KakaoClient/KakaoClient.java @@ -0,0 +1,64 @@ +package umc.service.KakaoClient; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import umc.dto.KakaoDto; + +@Component +public class KakaoClient { + + @Value("${spring.kakao.auth.client}") + private String client; + @Value("${spring.kakao.auth.redirect}") + private String redirect; + + public KakaoDto.OAuthToken requestToken(String accessCode) { + + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + + headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8"); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", client); + params.add("redirect_url", redirect); + params.add("code", accessCode); + + HttpEntity> kakaoTokenRequest = new HttpEntity<>(params, headers); + ResponseEntity response = restTemplate.postForEntity( + "https://kauth.kakao.com/oauth/token", + kakaoTokenRequest, + KakaoDto.OAuthToken.class); + + return response.getBody(); + } + + public KakaoDto.KakaoProfile requestProfile(KakaoDto.OAuthToken oAuthToken) { + + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + + headers.add("Content-type", "application/x-www-form-urlencoded;charset=utf-8"); + headers.add("Authorization","Bearer "+ oAuthToken.getAccess_token()); + + HttpEntity> kakaoProfileRequest = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange( + "https://kapi.kakao.com/v2/user/me", + HttpMethod.GET, + kakaoProfileRequest, + KakaoDto.KakaoProfile.class + ); + + return response.getBody(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index aa212c1c..ff601fcf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,6 +16,11 @@ spring: use_sql_comments: true hbm2ddl: auto: update + kakao: + auth: + client: + redirect: http://localhost:8080/auth/login/kakao + discord: webhook: url: