From e10876d5fe93afa7de8042ca004d23282489771a Mon Sep 17 00:00:00 2001 From: jsgjsg Date: Sat, 3 Jan 2026 12:00:23 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[#41]=20feat:=20=EC=82=AC=EB=AC=B4=EC=8B=A4?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85/=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 6 ++ .../controller/AuthOfficeV1Controller.java | 52 +++++++++++ .../domain/auth/dto/response/TokenDto.java | 6 ++ .../api/domain/auth/service/AuthService.java | 89 ++++++++++++++++++ .../office/dto/request/OfficeLoginReq.java | 11 +++ .../office/dto/request/OfficeSignupReq.java | 35 +++++++ .../api/domain/office/entity/Manager.java | 61 ++++++++++++ .../api/domain/office/entity/Office.java | 23 ++++- .../api/domain/office/enums/ManagerRole.java | 11 +++ .../office/repository/ManagerRepository.java | 14 +++ .../office/repository/OfficeRepository.java | 7 ++ .../repository/TransporterRepository.java | 3 + .../config/CurrentUserArgumentResolver.java | 88 ++++++++++-------- .../api/global/config/SecurityConfig.java | 27 +++++- .../global/jwt/JwtAuthenticationFilter.java | 59 ++++++++++++ .../mobility/api/global/jwt/JwtProvider.java | 93 +++++++++++++++++++ .../security/CustomUserDetailsService.java | 44 +++++++++ .../api/global/security/PrincipalDetails.java | 73 ++++++++++++--- .../api/health/controller/PingController.java | 22 +++++ 19 files changed, 664 insertions(+), 60 deletions(-) create mode 100644 src/main/java/com/mobility/api/domain/auth/controller/AuthOfficeV1Controller.java create mode 100644 src/main/java/com/mobility/api/domain/auth/dto/response/TokenDto.java create mode 100644 src/main/java/com/mobility/api/domain/auth/service/AuthService.java create mode 100644 src/main/java/com/mobility/api/domain/office/dto/request/OfficeLoginReq.java create mode 100644 src/main/java/com/mobility/api/domain/office/dto/request/OfficeSignupReq.java create mode 100644 src/main/java/com/mobility/api/domain/office/entity/Manager.java create mode 100644 src/main/java/com/mobility/api/domain/office/enums/ManagerRole.java create mode 100644 src/main/java/com/mobility/api/domain/office/repository/ManagerRepository.java create mode 100644 src/main/java/com/mobility/api/domain/office/repository/OfficeRepository.java create mode 100644 src/main/java/com/mobility/api/global/jwt/JwtAuthenticationFilter.java create mode 100644 src/main/java/com/mobility/api/global/jwt/JwtProvider.java create mode 100644 src/main/java/com/mobility/api/global/security/CustomUserDetailsService.java diff --git a/build.gradle b/build.gradle index 77925a1..24dbfea 100644 --- a/build.gradle +++ b/build.gradle @@ -62,6 +62,12 @@ dependencies { // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14' + + // 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' + } tasks.named('test') { diff --git a/src/main/java/com/mobility/api/domain/auth/controller/AuthOfficeV1Controller.java b/src/main/java/com/mobility/api/domain/auth/controller/AuthOfficeV1Controller.java new file mode 100644 index 0000000..02d1af9 --- /dev/null +++ b/src/main/java/com/mobility/api/domain/auth/controller/AuthOfficeV1Controller.java @@ -0,0 +1,52 @@ +package com.mobility.api.domain.auth.controller; + +import com.mobility.api.domain.auth.dto.response.TokenDto; +import com.mobility.api.domain.auth.service.AuthService; +import com.mobility.api.domain.office.dto.request.DispatchSearchDto; +import com.mobility.api.domain.office.dto.request.OfficeLoginReq; +import com.mobility.api.domain.office.dto.request.OfficeSignupReq; +import com.mobility.api.domain.office.dto.response.GetAllDispatchRes; +import com.mobility.api.global.annotation.SwaggerPageable; +import com.mobility.api.global.response.CommonResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "사무실 인증 관련 요청(/api/v1/auth/office/...)") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/auth/office") +public class AuthOfficeV1Controller { + + private final AuthService authService; + + @Operation(summary = "사무실 회원가입") + @PostMapping("/signup") + public CommonResponse signupOffice( + @RequestBody OfficeSignupReq req + ) { + authService.signupOffice(req); + return CommonResponse.success(null); + } + + /** + *
+     * 사무실 - 로그인
+     * 
+ */ + @Operation(summary = "사무실 로그인 요청", description = "ID와 비밀번호를 입력받아 Access Token을 발급합니다.") + @PostMapping("/login") // RequestMapping(method=POST)와 같습니다. + public CommonResponse login(@RequestBody OfficeLoginReq req) { + + // 1. 서비스 호출 (로그인 로직 수행) + TokenDto tokenDto = authService.officeLogin(req); + + // 2. 결과 반환 + return CommonResponse.success(tokenDto); + } + +} diff --git a/src/main/java/com/mobility/api/domain/auth/dto/response/TokenDto.java b/src/main/java/com/mobility/api/domain/auth/dto/response/TokenDto.java new file mode 100644 index 0000000..2bc0d27 --- /dev/null +++ b/src/main/java/com/mobility/api/domain/auth/dto/response/TokenDto.java @@ -0,0 +1,6 @@ +package com.mobility.api.domain.auth.dto.response; + +public record TokenDto( + String accessToken, + String grantType +) {} \ No newline at end of file diff --git a/src/main/java/com/mobility/api/domain/auth/service/AuthService.java b/src/main/java/com/mobility/api/domain/auth/service/AuthService.java new file mode 100644 index 0000000..a92a1fa --- /dev/null +++ b/src/main/java/com/mobility/api/domain/auth/service/AuthService.java @@ -0,0 +1,89 @@ +package com.mobility.api.domain.auth.service; + +import com.mobility.api.domain.auth.dto.response.TokenDto; +import com.mobility.api.domain.office.dto.request.OfficeLoginReq; +import com.mobility.api.domain.office.dto.request.OfficeSignupReq; +import com.mobility.api.domain.office.entity.Manager; +import com.mobility.api.domain.office.entity.Office; +import com.mobility.api.domain.office.enums.ManagerRole; +import com.mobility.api.domain.office.repository.ManagerRepository; +import com.mobility.api.domain.office.repository.OfficeRepository; +import com.mobility.api.global.exception.GlobalException; +import com.mobility.api.global.jwt.JwtProvider; +import com.mobility.api.global.response.ResultCode; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final ManagerRepository managerRepository; + private final OfficeRepository officeRepository; + + private final JwtProvider jwtProvider; // 토큰 발급기 + private final PasswordEncoder passwordEncoder; // 비밀번호 검사기 + + /** + * 사무실 회원가입 (사무실 생성 + 사장님 계정 생성) + */ + @Transactional + public void signupOffice(OfficeSignupReq req) { + + // 1. 아이디 중복 검사 + if (managerRepository.existsByLoginId(req.loginId())) { +// throw new GlobalException(ResultCode.DUPLICATE_USER_ID); // 에러 코드 추가 필요 + throw new GlobalException(ResultCode.FIXME_FAIL); + } + + // 2. 사무실 정보 저장 + Office office = Office.builder() + .officeName(req.officeName()) + .officeRegistrationNumber(req.officeRegistrationNumber()) + .officeAddress(req.officeAddress()) + .officeTelNumber(req.officeTelNumber()) + .build(); + + officeRepository.save(office); // DB에 사무실 Insert (이때 ID 생성됨) + + // 3. 사장님(Manager) 정보 저장 + Manager manager = Manager.builder() + .loginId(req.loginId()) + .password(passwordEncoder.encode(req.password())) // 비밀번호 암호화 +// .password(req.password()) // 비밀번호 암호화 + .name(req.managerName()) + .phone(req.managerPhone()) + .email(req.managerEmail()) + .role(ManagerRole.OWNER) // 가입 시점엔 무조건 사장님(OWNER) + .office(office) // 위에서 만든 사무실 연결 + .build(); + + managerRepository.save(manager); + } + + /** + * 사무실 관리자 (Manager) 로그인 + */ + @Transactional + public TokenDto officeLogin(OfficeLoginReq req) { + // 1. 아이디(loginId)로 매니저 찾기 + Manager manager = managerRepository.findByLoginId(req.loginId()) + .orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_USER)); + + // 2. 비밀번호 검증 + if (!passwordEncoder.matches(req.password(), manager.getPassword())) { +// throw new GlobalException(ResultCode.PASSWORD_NOT_MATCH); + throw new GlobalException(ResultCode.FIXME_FAIL); + } + + // 3. 토큰 생성 + // Subject: loginId (나중에 이걸로 DB 조회함) + // Role: "ROLE_OFFICE" (일단 고정, 필요하면 manager.getRole().name() 사용) + String accessToken = jwtProvider.createToken(manager.getLoginId(), "ROLE_OFFICE"); + + return new TokenDto(accessToken, "Bearer"); + } + +} diff --git a/src/main/java/com/mobility/api/domain/office/dto/request/OfficeLoginReq.java b/src/main/java/com/mobility/api/domain/office/dto/request/OfficeLoginReq.java new file mode 100644 index 0000000..ec794c5 --- /dev/null +++ b/src/main/java/com/mobility/api/domain/office/dto/request/OfficeLoginReq.java @@ -0,0 +1,11 @@ +package com.mobility.api.domain.office.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record OfficeLoginReq( + @Schema(description = "관리자 웹 아이디", example = "tak123") + String loginId, + + @Schema(description = "비밀번호", example = "tak123!") + String password +) {} \ No newline at end of file diff --git a/src/main/java/com/mobility/api/domain/office/dto/request/OfficeSignupReq.java b/src/main/java/com/mobility/api/domain/office/dto/request/OfficeSignupReq.java new file mode 100644 index 0000000..9df4fe7 --- /dev/null +++ b/src/main/java/com/mobility/api/domain/office/dto/request/OfficeSignupReq.java @@ -0,0 +1,35 @@ +package com.mobility.api.domain.office.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record OfficeSignupReq( + + // --- 사무실 정보 --- + @Schema(description = "사업장 이름", example = "mobi 탁송") + String officeName, + + @Schema(description = "사업자 등록번호", example = "123-45-67890") + String officeRegistrationNumber, + + @Schema(description = "사업장 주소", example = "전북 전주시 완산구 용머리로 29") + String officeAddress, + + @Schema(description = "사무실 전화번호", example = "063-123-4567") + String officeTelNumber, + + // --- 사장님(관리자) 정보 --- + @Schema(description = "관리자 웹 아이디", example = "tak123") + String loginId, + + @Schema(description = "비밀번호", example = "tak123!") + String password, + + @Schema(description = "관리자 이름", example = "김규원") + String managerName, + + @Schema(description = "관리자 휴대폰 번호", example = "010-5244-4070") + String managerPhone, + + @Schema(description = "관리자 이메일", example = "rlarbdnjs0630@gmail.com") + String managerEmail +) {} \ No newline at end of file diff --git a/src/main/java/com/mobility/api/domain/office/entity/Manager.java b/src/main/java/com/mobility/api/domain/office/entity/Manager.java new file mode 100644 index 0000000..69f129e --- /dev/null +++ b/src/main/java/com/mobility/api/domain/office/entity/Manager.java @@ -0,0 +1,61 @@ +package com.mobility.api.domain.office.entity; + +import com.mobility.api.domain.office.enums.ManagerRole; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "manager") // DB 테이블명 +public class Manager { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "manager_id") + private Long id; + + // 1. 로그인 ID (새로 추가! - 예: tak123) + @Column(nullable = false, unique = true, length = 50) + private String loginId; + + // 2. 비밀번호 + @Column(nullable = false) + private String password; + + // 3. 사용자 이름 (예: 김담당) + @Column(nullable = false, length = 20) + private String name; + + // 휴대폰 번호 (예: 010-5244-4070) + @Column(nullable = false, length = 20) + private String phone; + + // 이메일 (로그인용 아님, 연락용) + @Column(nullable = false, length = 100) + private String email; + + // 4. 권한 (OWNER: 사무실 대표, ??: 일반 직원) - 선택사항 + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ManagerRole role; + + // 5. 소속 사무실 (어느 사무실 사람인지?) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "office_id") + private Office office; + + @Builder + public Manager(String loginId, String password, String name, String phone, String email, ManagerRole role, Office office) { + this.loginId = loginId; + this.password = password; + this.name = name; + this.phone = phone; + this.email = email; + this.role = role; + this.office = office; + } +} \ No newline at end of file diff --git a/src/main/java/com/mobility/api/domain/office/entity/Office.java b/src/main/java/com/mobility/api/domain/office/entity/Office.java index a4fe948..b6c6fe7 100644 --- a/src/main/java/com/mobility/api/domain/office/entity/Office.java +++ b/src/main/java/com/mobility/api/domain/office/entity/Office.java @@ -1,10 +1,7 @@ package com.mobility.api.domain.office.entity; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Entity @Getter @@ -17,9 +14,27 @@ public class Office { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + // 1. 사업장 이름 (예: mobi 탁송) + @Column(nullable = false, length = 50) + private String officeName; + + // 사업자 등록 번호 @Column(name = "office_registration_number") private String officeRegistrationNumber; + // 3. 사업장 주소 + @Column(nullable = false) + private String officeAddress; + + // 사업장 전화번호 @Column(name = "office_tel_number") private String officeTelNumber; + + @Builder + public Office(String officeName, String officeRegistrationNumber, String officeAddress, String officeTelNumber) { + this.officeName = officeName; + this.officeRegistrationNumber = officeRegistrationNumber; + this.officeAddress = officeAddress; + this.officeTelNumber = officeTelNumber; + } } diff --git a/src/main/java/com/mobility/api/domain/office/enums/ManagerRole.java b/src/main/java/com/mobility/api/domain/office/enums/ManagerRole.java new file mode 100644 index 0000000..97059ec --- /dev/null +++ b/src/main/java/com/mobility/api/domain/office/enums/ManagerRole.java @@ -0,0 +1,11 @@ +package com.mobility.api.domain.office.enums; + +/** + *
+ *     FIXME 일단 OWNER만 사용
+ * 
+ */ +public enum ManagerRole { + OWNER, // 사무실 생성/삭제, 직원 관리 가능 +// STAFF // 배차 등록/수정만 가능 +} \ No newline at end of file diff --git a/src/main/java/com/mobility/api/domain/office/repository/ManagerRepository.java b/src/main/java/com/mobility/api/domain/office/repository/ManagerRepository.java new file mode 100644 index 0000000..d60c5ab --- /dev/null +++ b/src/main/java/com/mobility/api/domain/office/repository/ManagerRepository.java @@ -0,0 +1,14 @@ +package com.mobility.api.domain.office.repository; + +import com.mobility.api.domain.dispatch.entity.Dispatch; +import com.mobility.api.domain.office.entity.Manager; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ManagerRepository extends JpaRepository { + + Optional findByLoginId(String loginId); + + boolean existsByLoginId(String loginId); +} diff --git a/src/main/java/com/mobility/api/domain/office/repository/OfficeRepository.java b/src/main/java/com/mobility/api/domain/office/repository/OfficeRepository.java new file mode 100644 index 0000000..50cec87 --- /dev/null +++ b/src/main/java/com/mobility/api/domain/office/repository/OfficeRepository.java @@ -0,0 +1,7 @@ +package com.mobility.api.domain.office.repository; + +import com.mobility.api.domain.office.entity.Office; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OfficeRepository extends JpaRepository { +} diff --git a/src/main/java/com/mobility/api/domain/transporter/repository/TransporterRepository.java b/src/main/java/com/mobility/api/domain/transporter/repository/TransporterRepository.java index 6b6521f..4735255 100644 --- a/src/main/java/com/mobility/api/domain/transporter/repository/TransporterRepository.java +++ b/src/main/java/com/mobility/api/domain/transporter/repository/TransporterRepository.java @@ -7,6 +7,7 @@ import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface TransporterRepository extends JpaRepository { @@ -57,4 +58,6 @@ List findEligibleDriversForAutoDispatch( @Param("lon") double lon ); + Optional findByPhone(String username); + } diff --git a/src/main/java/com/mobility/api/global/config/CurrentUserArgumentResolver.java b/src/main/java/com/mobility/api/global/config/CurrentUserArgumentResolver.java index 4cf2765..63d66ff 100644 --- a/src/main/java/com/mobility/api/global/config/CurrentUserArgumentResolver.java +++ b/src/main/java/com/mobility/api/global/config/CurrentUserArgumentResolver.java @@ -1,5 +1,7 @@ package com.mobility.api.global.config; +import com.mobility.api.domain.office.entity.Manager; +import com.mobility.api.domain.office.repository.ManagerRepository; import com.mobility.api.domain.transporter.entity.Transporter; import com.mobility.api.domain.transporter.repository.TransporterRepository; import com.mobility.api.global.annotation.CurrentUser; @@ -20,86 +22,90 @@ import org.springframework.web.method.support.ModelAndViewContainer; @Component -@RequiredArgsConstructor // final 필드 주입을 위한 어노테이션 추가 +@RequiredArgsConstructor public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver { - // 9. 'dev' 프로필 확인 및 임시 ID 조회를 위해 Bean 주입 private final Environment env; private final TransporterRepository transporterRepository; + private final ManagerRepository managerRepository; // 👈 Manager 조회를 위해 추가! - /** - * 이 Resolver가 어떤 파라미터를 지원(support)할 것인지 결정합 - * @CurrentUser 어노테이션이 붙어있는 파라미터라면 true를 반환 - */ @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(CurrentUser.class); } - /** - * supportsParameter가 true를 반환했을 때, - * 파라미터에 실제로 주입할 값(Object)을 결정(resolve)하여 반환합니다. - */ @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { - Class parameterType = parameter.getParameterType(); // 컨트롤러가 요청한 파라미터 타입 + Class parameterType = parameter.getParameterType(); // 컨트롤러가 원하는 타입 (Manager? Transporter?) - // ----------------------------------------------------------- - // 1. [DEV/LOCAL] 개발 환경용 인증 우회 로직 - // ----------------------------------------------------------- + // ======================================================================== + // 1. [DEV/LOCAL] 개발 환경용 "프리패스" 로직 (헤더로 로그인 흉내내기) + // ======================================================================== if (env.acceptsProfiles(Profiles.of("dev", "local"))) { HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); - String tempUserIdHeader = request.getHeader("X-Temp-User-Id"); // Postman에서 보낸 헤더 + String tempUserIdHeader = request.getHeader("X-Temp-User-Id"); - // 헤더가 없으면 기본값 1L 사용, 있으면 파싱 + // 헤더 없으면 ID 1번으로 간주 Long targetUserId = (tempUserIdHeader == null || tempUserIdHeader.isBlank()) ? 1L : Long.parseLong(tempUserIdHeader); - try { - // (1) ID(Long)만 필요한 경우 - if (Long.class.isAssignableFrom(parameterType)) { - return targetUserId; - } - - // (2) 엔티티(Transporter)가 필요한 경우 - if (Transporter.class.isAssignableFrom(parameterType)) { - return transporterRepository.findById(targetUserId) - .orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_USER)); - } - } catch (NumberFormatException e) { - throw new GlobalException(ResultCode.DEV_BAD_REQUEST); + + // (A) 컨트롤러가 "기사(Transporter)"를 원할 때 + if (Transporter.class.isAssignableFrom(parameterType)) { + return transporterRepository.findById(targetUserId) + .orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_USER)); + } + + // (B) 컨트롤러가 "매니저(Manager)"를 원할 때 + if (Manager.class.isAssignableFrom(parameterType)) { + return managerRepository.findById(targetUserId) + .orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_USER)); } } - // ----------------------------------------------------------- - // 2. [PROD] 실제 운영 환경 인증 로직 (Spring Security) - // ----------------------------------------------------------- + // ======================================================================== + // 2. [PROD] 실제 운영 환경 인증 로직 (Spring Security / JWT) + // ======================================================================== Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication == null || !(authentication.getPrincipal() instanceof PrincipalDetails)) { throw new GlobalException(ResultCode.UNAUTHORIZED); } + // SecurityContext에 저장된 "통합 유저 객체"를 꺼냄 PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); - // (1) ID(Long) 반환 - if (Long.class.isAssignableFrom(parameterType)) { - return principalDetails.getTransporterId(); // PrincipalDetails에 해당 메서드 필요 + // (A) 컨트롤러가 "기사(Transporter)"를 원할 때 + if (Transporter.class.isAssignableFrom(parameterType)) { + if (principalDetails.getTransporter() == null) { + // 로그인한 사람은 매니저인데, 기사 정보를 달라고 하면 에러! + throw new GlobalException(ResultCode.FORBIDDEN); + } + return principalDetails.getTransporter(); } - // (2) 엔티티(Transporter) 반환 - // 주의: 세션/토큰에는 보통 엔티티 전체를 담지 않으므로, 여기서 ID로 다시 조회하는 것이 안전합니다. - if (Transporter.class.isAssignableFrom(parameterType)) { - return transporterRepository.findById(principalDetails.getTransporterId()) - .orElseThrow(() -> new GlobalException(ResultCode.NOT_FOUND_USER)); + // (B) 컨트롤러가 "매니저(Manager)"를 원할 때 + if (Manager.class.isAssignableFrom(parameterType)) { + if (principalDetails.getManager() == null) { + // 로그인한 사람은 기사인데, 매니저 정보를 달라고 하면 에러! + throw new GlobalException(ResultCode.FORBIDDEN); + } + return principalDetails.getManager(); + } + + // (C) 그냥 ID(Long)만 원할 때 (잘 안 쓰지만 혹시 몰라 유지) + if (Long.class.isAssignableFrom(parameterType)) { + if (principalDetails.getManager() != null) return principalDetails.getManager().getId(); + if (principalDetails.getTransporter() != null) return principalDetails.getTransporter().getId(); } + // (D) PrincipalDetails 자체를 원할 때 if (PrincipalDetails.class.isAssignableFrom(parameterType)) { return principalDetails; } - throw new IllegalArgumentException("지원하지 않는 파라미터 타입입니다: " + parameterType); + throw new IllegalArgumentException("지원하지 않는 @CurrentUser 파라미터 타입입니다: " + parameterType); } } \ No newline at end of file diff --git a/src/main/java/com/mobility/api/global/config/SecurityConfig.java b/src/main/java/com/mobility/api/global/config/SecurityConfig.java index e1f1642..1dc7909 100644 --- a/src/main/java/com/mobility/api/global/config/SecurityConfig.java +++ b/src/main/java/com/mobility/api/global/config/SecurityConfig.java @@ -1,17 +1,25 @@ package com.mobility.api.global.config; +import com.mobility.api.global.jwt.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private static final String[] SWAGGER_URLS = { "/swagger-ui.html", // 메인 UI 페이지 "/swagger-ui/**", // UI 리소스 (js, css) @@ -50,10 +58,13 @@ public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exce // 5. API 경로에 대한 접근 허용 설정 .authorizeHttpRequests(authz -> authz .requestMatchers("/api/**").permitAll() + .requestMatchers("/health/**").permitAll() // ping 등 health-check 허용 .requestMatchers(SWAGGER_URLS).permitAll() .requestMatchers(WEBSOCKET_URLS).permitAll() // WebSocket 경로 허용 + .requestMatchers("/error").permitAll() // (에러 내용을 보기 위함) .anyRequest().authenticated() // 그 외 모든 요청은 인증 필요 (사실상 거의 없음) - ); + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } @@ -73,12 +84,24 @@ public SecurityFilterChain prodSecurityFilterChain(HttpSecurity http) throws Exc .authorizeHttpRequests(authz -> authz .requestMatchers("/api/auth/**").permitAll() // 로그인 API 등은 허용 + .requestMatchers("/health/**").permitAll() // ping 등 health-check 허용 + .requestMatchers("/error").permitAll() // (에러 내용을 보기 위함) .requestMatchers("/api/**").authenticated() // 나머지 API는 인증 필요 .anyRequest().denyAll() - ); + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // .addFilterBefore( ... JWT 인증 필터 추가 ...) return http.build(); } + + /** + * 비밀번호 암호화, 검증 Bean + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + } diff --git a/src/main/java/com/mobility/api/global/jwt/JwtAuthenticationFilter.java b/src/main/java/com/mobility/api/global/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..21bca6c --- /dev/null +++ b/src/main/java/com/mobility/api/global/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,59 @@ +package com.mobility.api.global.jwt; + +import com.mobility.api.global.jwt.JwtProvider; +import com.mobility.api.global.security.CustomUserDetailsService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtProvider jwtProvider; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + // 1. 헤더에서 토큰 꺼내기 + String token = resolveToken(request); + + // 2. 토큰이 있고, 유효하다면? + if (token != null && jwtProvider.validateToken(token)) { + + // 3. 토큰에서 정보(ID, Role) 꺼내기 + String username = jwtProvider.getUsername(token); + String role = jwtProvider.getRole(token); + + // 4. UserDetails 가져오기 + UserDetails userDetails = customUserDetailsService.loadUserByUsernameAndRole(username, role); + + // 5. 스프링 시큐리티에 "이 사람 인증됨!" 도장 찍기 + Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(auth); + } + + filterChain.doFilter(request, response); + } + + // 헤더에서 "Bearer " 떼고 토큰만 추출 + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/com/mobility/api/global/jwt/JwtProvider.java b/src/main/java/com/mobility/api/global/jwt/JwtProvider.java new file mode 100644 index 0000000..eaa5fff --- /dev/null +++ b/src/main/java/com/mobility/api/global/jwt/JwtProvider.java @@ -0,0 +1,93 @@ +package com.mobility.api.global.jwt; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; + +@Slf4j +@Component +public class JwtProvider { + + private final Key key; + private final long accessTokenValidityInMilliseconds; + + public JwtProvider( + @Value("${jwt.secret}") String secretKey, + @Value("${jwt.access-token-validity-in-seconds:3600}") long seconds) { + + // 시크릿 키를 Base64로 디코딩해서 Key 객체로 변환 + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + this.key = Keys.hmacShaKeyFor(keyBytes); + + // 토큰 유효 시간 (초 단위 -> 밀리초 변환) + this.accessTokenValidityInMilliseconds = seconds * 1000; + } + + /** + * 1. 토큰 생성 + * @param subject 사용자 식별자 (loginId 또는 phone) + * @param role 사용자 권한 (ROLE_OFFICE, ROLE_TRANSPORTER) + * @return String 생성된 JWT 토큰 + */ + public String createToken(String subject, String role) { + long now = (new Date()).getTime(); + Date validity = new Date(now + this.accessTokenValidityInMilliseconds); + + return Jwts.builder() + .setSubject(subject) // "sub": "tak123" + .claim("role", role) // "role": "ROLE_OFFICE" (커스텀 클레임) + .setIssuedAt(new Date(now)) // "iat": 현재시간 + .setExpiration(validity) // "exp": 만료시간 + .signWith(key, SignatureAlgorithm.HS256) // 암호화 알고리즘 + .compact(); + } + + /** + * 2. 토큰에서 ID(Subject) 꺼내기 + */ + public String getUsername(String token) { + return parseClaims(token).getSubject(); + } + + /** + * 3. 토큰에서 권한(Role) 꺼내기 + */ + public String getRole(String token) { + return parseClaims(token).get("role", String.class); + } + + /** + * 4. 토큰 유효성 검증 + * - 위변조 확인, 만료 시간 확인 등 + */ + public boolean validateToken(String token) { + try { + Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + return true; + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.info("잘못된 JWT 서명입니다."); + } catch (ExpiredJwtException e) { + log.info("만료된 JWT 토큰입니다."); + } catch (UnsupportedJwtException e) { + log.info("지원되지 않는 JWT 토큰입니다."); + } catch (IllegalArgumentException e) { + log.info("JWT 토큰이 잘못되었습니다."); + } + return false; + } + + // 내부적으로 토큰을 파싱해서 Claims(내용물)를 꺼내는 헬퍼 메서드 + private Claims parseClaims(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } +} \ No newline at end of file diff --git a/src/main/java/com/mobility/api/global/security/CustomUserDetailsService.java b/src/main/java/com/mobility/api/global/security/CustomUserDetailsService.java new file mode 100644 index 0000000..2c43f03 --- /dev/null +++ b/src/main/java/com/mobility/api/global/security/CustomUserDetailsService.java @@ -0,0 +1,44 @@ +package com.mobility.api.global.security; + +import com.mobility.api.domain.office.entity.Manager; +import com.mobility.api.domain.office.repository.ManagerRepository; +import com.mobility.api.domain.transporter.entity.Transporter; +import com.mobility.api.domain.transporter.repository.TransporterRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService { + + private final ManagerRepository managerRepository; + private final TransporterRepository transporterRepository; + + /** + * 토큰 필터(JwtAuthenticationFilter)에서 호출하는 메서드 + * @param username : 토큰에 담긴 ID (Manager는 loginId, Transporter는 phone) + * @param role : 토큰에 담긴 권한 (ROLE_OFFICE, ROLE_TRANSPORTER) + */ + public UserDetails loadUserByUsernameAndRole(String username, String role) { + + // 1. 사무실 관리자 (Manager)인 경우 -> loginId로 찾기 + if ("ROLE_OFFICE".equals(role)) { + Manager manager = managerRepository.findByLoginId(username) + .orElseThrow(() -> new UsernameNotFoundException("해당 아이디를 가진 관리자가 없습니다: " + username)); + + return new PrincipalDetails(manager); + } + + // 2. 기사 (Transporter)인 경우 -> phone으로 찾기 + else if ("ROLE_TRANSPORTER".equals(role)) { + Transporter transporter = transporterRepository.findByPhone(username) + .orElseThrow(() -> new UsernameNotFoundException("해당 번호를 가진 기사가 없습니다: " + username)); + + return new PrincipalDetails(transporter); + } + + throw new UsernameNotFoundException("알 수 없는 권한입니다: " + role); + } +} \ No newline at end of file diff --git a/src/main/java/com/mobility/api/global/security/PrincipalDetails.java b/src/main/java/com/mobility/api/global/security/PrincipalDetails.java index 7992aca..6cb5de7 100644 --- a/src/main/java/com/mobility/api/global/security/PrincipalDetails.java +++ b/src/main/java/com/mobility/api/global/security/PrincipalDetails.java @@ -1,42 +1,89 @@ package com.mobility.api.global.security; +import com.mobility.api.domain.office.entity.Manager; +import com.mobility.api.domain.office.entity.Office; import com.mobility.api.domain.transporter.entity.Transporter; +import lombok.Getter; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.Collection; +import java.util.Collections; import java.util.List; +@Getter // 컨트롤러에서 꺼내 쓰기 편하게 Getter 추가 public class PrincipalDetails implements UserDetails { - private final Transporter transporter; // 사용자 엔티티를 직접 보유 + private Manager manager; // 사무실 객체 (null일 수 있음) + private Transporter transporter; // 기사 객체 (null일 수 있음) + private String role; // 현재 로그인한 사람의 역할 (ROLE_OFFICE 등) - public PrincipalDetails(Transporter transporter) { - this.transporter = transporter; + // 1. 사무실 로그인용 생성자 + public PrincipalDetails(Manager manager) { + this.manager = manager; + this.role = "ROLE_OFFICE"; } - // ArgumentResolver가 사용할 메서드 -// public Transporter getTransporter() { -// return transporter; -// } - - public Long getTransporterId() { -// return transporter.getId(); - return null; + // 2. 기사 로그인용 생성자 + public PrincipalDetails(Transporter transporter) { + this.transporter = transporter; + this.role = "ROLE_TRANSPORTER"; } + // 권한(Role) 반환: Spring Security가 "이 사람 권한이 뭐야?"라고 물어볼 때 씀 @Override public Collection getAuthorities() { - return List.of(); + return Collections.singleton(new SimpleGrantedAuthority(role)); } + // 비밀번호 반환: 로그인 시 비번 검사할 때 씀 @Override public String getPassword() { + if (manager != null) return manager.getPassword(); +// if (transporter != null) return transporter.getPassword(); + if (transporter != null) return null; return null; } + // 아이디(식별자) 반환: 로그에 찍히거나 할 때 씀 @Override public String getUsername() { - return ""; + if (manager != null) return manager.getLoginId(); + if (transporter != null) return transporter.getPhone(); // 기사는 전화번호가 ID라면 + return null; } + + // 계정 만료/잠김 여부 (일단 모두 true로 설정) + @Override public boolean isAccountNonExpired() { return true; } + @Override public boolean isAccountNonLocked() { return true; } + @Override public boolean isCredentialsNonExpired() { return true; } + @Override public boolean isEnabled() { return true; } + + // ============================================================================ + + // ArgumentResolver가 사용할 메서드 +// public Transporter getTransporter() { +// return transporter; +// } + +// public Long getTransporterId() { +//// return transporter.getId(); +// return null; +// } +// +// @Override +// public Collection getAuthorities() { +// return List.of(); +// } +// +// @Override +// public String getPassword() { +// return null; +// } +// +// @Override +// public String getUsername() { +// return ""; +// } } diff --git a/src/main/java/com/mobility/api/health/controller/PingController.java b/src/main/java/com/mobility/api/health/controller/PingController.java index 9071998..37e84c4 100644 --- a/src/main/java/com/mobility/api/health/controller/PingController.java +++ b/src/main/java/com/mobility/api/health/controller/PingController.java @@ -2,11 +2,13 @@ import com.mobility.api.global.response.ApiResponse; import com.mobility.api.global.response.CommonResponse; +import com.mobility.api.global.security.PrincipalDetails; import com.mobility.api.health.entity.Sample; import com.mobility.api.health.service.SampleService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RestController; @@ -16,6 +18,7 @@ @Tag(name = "통신 테스트") @RestController @RequiredArgsConstructor +@RequestMapping("/health") public class PingController { private final SampleService sampleService; @@ -34,4 +37,23 @@ public CommonResponse> pingDb() { // return ApiResponse.success(sampleService.findAll()); } + @Operation(summary = "내 정보 조회 (토큰 테스트)", description = "토큰을 헤더에 넣고 요청하면, 해당 유저의 정보를 반환합니다.") + @RequestMapping(path = "/me", method = RequestMethod.GET) // GET /api/v1/auth/office/me + public CommonResponse getMyInfo(@AuthenticationPrincipal PrincipalDetails user) { + + // 토큰이 유효하지 않으면 여기까지 오지도 못함 (Filter에서 막힘) + + if (user == null) { + return CommonResponse.success("유저 정보 없음 (뭔가 이상함)"); + } + + // [%s] 사무실의 + String info = String.format("안녕하세요! 당신은 [%s]님 이시군요. (권한: %s)", + user.getManager().getName(), + user.getManager().getRole() + ); + + return CommonResponse.success(info); + } + } From c9a15a16968cb68f2cafe67b82500db6d7b77333 Mon Sep 17 00:00:00 2001 From: jsgjsg Date: Sun, 4 Jan 2026 11:32:24 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[#41]=20feat:=20=EB=8F=84=EC=BB=A4=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jwt 관련 설정 추가 - .env파일 수정 필요 --- docker-compose.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 341f84b..a9c1a41 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,9 +13,12 @@ services: - SPRING_DATASOURCE_USERNAME=${POSTGRES_USER} - SPRING_DATASOURCE_PASSWORD=${POSTGRES_PASSWORD} - SPRING_PROFILES_ACTIVE=docker # Dockerfile의 ENV와 일치 + - JWT_SECRET=${JWT_SECRET} + - JWT_ACCESS_TOKEN_VALIDITY_IN_SECONDS=${JWT_ACCESS_TOKEN_VALIDITY_IN_SECONDS} depends_on: - db: - condition: service_healthy + - db +# db: +# condition: service_healthy # 2. PostgreSQL 서비스 db: From 21bd49994f2df5242b57a9b4feafb9e18b2d9666 Mon Sep 17 00:00:00 2001 From: jsgjsg Date: Sun, 4 Jan 2026 11:58:07 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[#41]=20feat:=20securityConfig=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mobility/api/global/config/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/mobility/api/global/config/SecurityConfig.java b/src/main/java/com/mobility/api/global/config/SecurityConfig.java index 1dc7909..761f027 100644 --- a/src/main/java/com/mobility/api/global/config/SecurityConfig.java +++ b/src/main/java/com/mobility/api/global/config/SecurityConfig.java @@ -74,7 +74,7 @@ public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exce * - 나머지 모든 요청은 JWT 토큰 검사 등을 통해 인증을 요구해야 합니다. */ @Bean - @Profile("prod") + @Profile({"prod", "docker"}) public SecurityFilterChain prodSecurityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable())