diff --git a/build.gradle b/build.gradle index cae99ef3..eddac2e6 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,18 @@ 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' + + // 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' } 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..eab50ff8 100644 --- a/src/main/java/umc/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/umc/apiPayload/code/status/ErrorStatus.java @@ -20,6 +20,8 @@ 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", "게시글이 없습니다."), @@ -41,6 +43,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/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..7890a8d7 --- /dev/null +++ b/src/main/java/umc/config/jwt/JwtTokenProvider.java @@ -0,0 +1,103 @@ +package umc.config.jwt; + +import java.security.Key; +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.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; + +@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(); + } + + // 리프레시 토큰 생성 + 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 { + 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..2a7f73d5 --- /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; + private Long refresh; + } +} 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..c8d3aede --- /dev/null +++ b/src/main/java/umc/config/security/SecurityConfig.java @@ -0,0 +1,47 @@ +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.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("/", "/users/signup", "/users/login", "/swagger-ui/**", "/v3/api-docs/**", "/users/reissue", "/auth/login/kakao/**").permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") // 인가 필요 + .anyRequest().authenticated() // 인증 필요 + ) + .csrf(AbstractHttpConfigurer::disable) + .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} 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/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..85b98583 --- /dev/null +++ b/src/main/java/umc/controller/UserRestController.java @@ -0,0 +1,75 @@ +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)); + } + + @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/controller/UserViewController.java b/src/main/java/umc/controller/UserViewController.java new file mode 100644 index 00000000..bd7e75fc --- /dev/null +++ b/src/main/java/umc/controller/UserViewController.java @@ -0,0 +1,60 @@ +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 org.springframework.web.bind.annotation.RequestMapping; + +import lombok.RequiredArgsConstructor; +import umc.dto.UserRequestDto; +import umc.service.UserService.UserCommandService; + +@Controller +@RequiredArgsConstructor +@RequestMapping("/users") +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..3e8cd28d 100644 --- a/src/main/java/umc/converter/UserConverter.java +++ b/src/main/java/umc/converter/UserConverter.java @@ -5,6 +5,8 @@ import umc.domain.Region; 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; @@ -22,13 +24,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 +38,54 @@ 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(); + } + + public static UserResponseDto.LoginResultDTO toLoginResultDto(Long userId, String accessToken, String refreshToken) { + + return UserResponseDto.LoginResultDTO.builder() + .userId(userId) + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + public static UserResponseDto.UserInfoDTO toUserInfoDTO(User user) { + + return UserResponseDto.UserInfoDTO.builder() + .name(user.getName()) + .email(user.getEmail()) + .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(); + } + + 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/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/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/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/dto/UserRequestDto.java b/src/main/java/umc/dto/UserRequestDto.java index ca133dbf..c8bc968c 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,25 @@ 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; + } + + @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 15050110..bc4a59dd 100644 --- a/src/main/java/umc/dto/UserResponseDto.java +++ b/src/main/java/umc/dto/UserResponseDto.java @@ -17,4 +17,33 @@ public static class JoinResultDTO{ Long userId; LocalDateTime createdAt; } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class LoginResultDTO { + Long userId; + String accessToken; + String refreshToken; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class UserInfoDTO{ + String name; + 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/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/java/umc/service/UserService/UserCommandService.java b/src/main/java/umc/service/UserService/UserCommandService.java index 2d506ac0..f674fe8b 100644 --- a/src/main/java/umc/service/UserService/UserCommandService.java +++ b/src/main/java/umc/service/UserService/UserCommandService.java @@ -7,4 +7,6 @@ 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 44859fd8..6e63b3cb 100644 --- a/src/main/java/umc/service/UserService/UserCommandServiceImpl.java +++ b/src/main/java/umc/service/UserService/UserCommandServiceImpl.java @@ -1,25 +1,32 @@ 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; 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; 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; @@ -36,6 +43,9 @@ public class UserCommandServiceImpl implements UserCommandService{ private final UserTermRepository userTermRepository; private final CategoryRepository categoryRepository; private final RegionRepository regionRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; @Override @Transactional @@ -62,6 +72,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() @@ -79,4 +90,59 @@ public UserResponseDto.JoinResultDTO joinUser(UserRequestDto.JoinDto request) { return UserConverter.toJoinResultDto(userRepository.save(newUser)); } + @Override + @Transactional + 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); + String refreshToken = jwtTokenProvider.generateRefreshToken(); + + refreshTokenRepository.save(UserConverter.toRefreshToken(user.getEmail(), refreshToken)); + + return UserConverter.toLoginResultDto( + user.getId(), + 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/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); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2e85ba69..ff601fcf 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,8 +16,20 @@ spring: use_sql_comments: true hbm2ddl: auto: update + kakao: + auth: + client: + redirect: http://localhost:8080/auth/login/kakao + discord: webhook: url: springdoc: - use-fqn: true \ No newline at end of file + use-fqn: true + +jwt: + token: + secretKey: umceightfightingjwttokenauthentication + expiration: + access: 14400000 + refresh: 86400000 \ 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