diff --git a/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java b/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java index fe12a8d..fbf8ed7 100644 --- a/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java +++ b/src/main/java/ceos/ipx/domain/auth/controller/AuthController.java @@ -1,5 +1,7 @@ package ceos.ipx.domain.auth.controller; +import ceos.ipx.domain.auth.dto.LoginRequest; +import ceos.ipx.domain.auth.dto.LoginResponse; import ceos.ipx.domain.auth.service.AuthService; import ceos.ipx.domain.user.dto.SignUpRequest; import ceos.ipx.domain.user.dto.SignUpResponse; @@ -33,11 +35,30 @@ public class AuthController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") }) @PostMapping("/signup") - public ResponseEntity> signUp(@Valid @RequestBody SignUpRequest request) { + public ResponseEntity> signUp( + @Valid @RequestBody SignUpRequest request + ) { SignUpResponse response = authService.signUp(request); return ResponseEntity .status(HttpStatus.CREATED) .body(ApiResponse.ok(response)); } -} + + @Operation(summary = "일반 로그인", description = "이메일과 비밀번호로 로그인하고 JWT AccessToken을 발급합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "로그인 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 검증 실패"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "이메일 또는 비밀번호 불일치"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "비활성화된 계정"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + @PostMapping("/login") + public ResponseEntity> login( + @Valid @RequestBody LoginRequest request + ) { + LoginResponse response = authService.login(request); + + return ResponseEntity.ok(ApiResponse.ok(response)); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/dto/LoginRequest.java b/src/main/java/ceos/ipx/domain/auth/dto/LoginRequest.java new file mode 100644 index 0000000..2a37085 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/dto/LoginRequest.java @@ -0,0 +1,22 @@ +package ceos.ipx.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "일반 로그인 요청") +public record LoginRequest( + + @Schema(description = "이메일", example = "test@example.com") + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + + @Schema(description = "비밀번호", example = "Password123!") + @NotBlank(message = "비밀번호는 필수입니다.") + String password, + + @Schema(description = "로그인 유지 여부", example = "false") + boolean rememberMe +) { +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/dto/LoginResponse.java b/src/main/java/ceos/ipx/domain/auth/dto/LoginResponse.java new file mode 100644 index 0000000..2afb9f2 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/dto/LoginResponse.java @@ -0,0 +1,20 @@ +package ceos.ipx.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "일반 로그인 응답") +public record LoginResponse( + + @Schema(description = "JWT AccessToken") + String accessToken, + + @Schema(description = "토큰 타입", example = "Bearer") + String tokenType, + + @Schema(description = "AccessToken 만료 시간(초)", example = "3600") + long expiresIn, + + @Schema(description = "로그인한 사용자 정보") + LoginUserResponse user +) { +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/dto/LoginUserResponse.java b/src/main/java/ceos/ipx/domain/auth/dto/LoginUserResponse.java new file mode 100644 index 0000000..8e607c0 --- /dev/null +++ b/src/main/java/ceos/ipx/domain/auth/dto/LoginUserResponse.java @@ -0,0 +1,42 @@ +package ceos.ipx.domain.auth.dto; + +import ceos.ipx.domain.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "로그인 사용자 정보 응답") +public record LoginUserResponse( + + @Schema(description = "사용자 ID", example = "1") + Long userId, + + @Schema(description = "이메일", example = "test@example.com") + String email, + + @Schema(description = "이름", example = "홍길동") + String name, + + @Schema(description = "회사명", example = "IPX") + String company, + + @Schema(description = "가입 제공자", example = "LOCAL") + String provider, + + @Schema(description = "프로필 완료 여부", example = "true") + boolean profileCompleted +) { + + public static LoginUserResponse from(User user) { + return new LoginUserResponse( + user.getId(), + user.getEmail(), + user.getName(), + user.getCompany(), + user.getProvider().name(), + isProfileCompleted(user) + ); + } + + private static boolean isProfileCompleted(User user) { + return user.getName() != null && !user.getName().isBlank(); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/domain/auth/service/AuthService.java b/src/main/java/ceos/ipx/domain/auth/service/AuthService.java index 6145f15..2a95b67 100644 --- a/src/main/java/ceos/ipx/domain/auth/service/AuthService.java +++ b/src/main/java/ceos/ipx/domain/auth/service/AuthService.java @@ -1,11 +1,15 @@ package ceos.ipx.domain.auth.service; +import ceos.ipx.domain.auth.dto.LoginRequest; +import ceos.ipx.domain.auth.dto.LoginResponse; +import ceos.ipx.domain.auth.dto.LoginUserResponse; import ceos.ipx.domain.user.dto.SignUpRequest; import ceos.ipx.domain.user.dto.SignUpResponse; import ceos.ipx.domain.user.entity.User; import ceos.ipx.domain.user.repository.UserRepository; import ceos.ipx.global.exception.BusinessException; import ceos.ipx.global.exception.ErrorCode; +import ceos.ipx.global.security.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @@ -18,6 +22,7 @@ public class AuthService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; @Transactional public SignUpResponse signUp(SignUpRequest request) { @@ -54,4 +59,26 @@ public SignUpResponse signUp(SignUpRequest request) { true ); } -} + + public LoginResponse login(LoginRequest request) { + User user = userRepository.findByEmail(request.email()) + .orElseThrow(() -> new BusinessException(ErrorCode.LOGIN_FAILED)); + + if (!user.isActive()) { + throw new BusinessException(ErrorCode.INACTIVE_USER); + } + + if (user.getPasswordHash() == null || !passwordEncoder.matches(request.password(), user.getPasswordHash())) { + throw new BusinessException(ErrorCode.LOGIN_FAILED); + } + + String accessToken = jwtTokenProvider.createAccessToken(user); + + return new LoginResponse( + accessToken, + jwtTokenProvider.getTokenType(), + jwtTokenProvider.getAccessTokenExpirationSeconds(), + LoginUserResponse.from(user) + ); + } +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/exception/ErrorCode.java b/src/main/java/ceos/ipx/global/exception/ErrorCode.java index b5d98b4..32b4d7d 100644 --- a/src/main/java/ceos/ipx/global/exception/ErrorCode.java +++ b/src/main/java/ceos/ipx/global/exception/ErrorCode.java @@ -7,11 +7,14 @@ @Getter @RequiredArgsConstructor public enum ErrorCode { + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "C001", "잘못된 입력값입니다."), INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "C002", "서버 내부 오류가 발생했습니다."), EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "AU001", "이미 사용 중인 이메일입니다."), PASSWORD_CONFIRM_MISMATCH(HttpStatus.BAD_REQUEST, "AU002", "비밀번호와 비밀번호 확인이 일치하지 않습니다."), + LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AU003", "이메일 또는 비밀번호가 일치하지 않습니다."), + INACTIVE_USER(HttpStatus.FORBIDDEN, "AU004", "비활성화된 계정입니다."), UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "SC001", "인증이 필요합니다."), ACCESS_DENIED(HttpStatus.FORBIDDEN, "SC002", "해당 요청에 권한이 없습니다."); @@ -19,4 +22,4 @@ public enum ErrorCode { private final HttpStatus status; private final String code; private final String message; -} +} \ No newline at end of file diff --git a/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java b/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..6e538bb --- /dev/null +++ b/src/main/java/ceos/ipx/global/security/jwt/JwtTokenProvider.java @@ -0,0 +1,54 @@ +package ceos.ipx.global.security.jwt; + +import ceos.ipx.domain.user.entity.User; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Date; + +@Component +public class JwtTokenProvider { + + private static final String TOKEN_TYPE = "Bearer"; + + @Value("${jwt.secret}") + private String secret; + + @Value("${jwt.access-token-expiration-seconds}") + private long accessTokenExpirationSeconds; + + private SecretKey secretKey; + + @PostConstruct + void init() { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String createAccessToken(User user) { + Instant now = Instant.now(); + Instant expiration = now.plusSeconds(accessTokenExpirationSeconds); + + return Jwts.builder() + .subject(String.valueOf(user.getId())) + .claim("email", user.getEmail()) + .claim("provider", user.getProvider().name()) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiration)) + .signWith(secretKey) + .compact(); + } + + public String getTokenType() { + return TOKEN_TYPE; + } + + public long getAccessTokenExpirationSeconds() { + return accessTokenExpirationSeconds; + } +} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index c133397..850d2c1 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -2,7 +2,7 @@ spring: datasource: url: jdbc:postgresql://localhost:5432/patent_db username: ${POSTGRES_USER:ipx_patent_user} - password: ${POSTGRES_PASSWORD} + password: ${POSTGRES_PASSWORD:local_dev_postgres_password} driver-class-name: org.postgresql.Driver jpa: @@ -31,3 +31,7 @@ springdoc: swagger-ui: enabled: true try-it-out-enabled: true + +jwt: + secret: ${JWT_SECRET:ipx-local-jwt-secret-key-for-development-1234567890} + access-token-expiration-seconds: ${JWT_ACCESS_TOKEN_EXPIRATION_SECONDS:3600}