package com.kamco.cd.kamcoback.members; import com.kamco.cd.kamcoback.auth.JwtTokenProvider; import com.kamco.cd.kamcoback.auth.RefreshTokenService; import com.kamco.cd.kamcoback.config.api.ApiResponseDto; import com.kamco.cd.kamcoback.members.dto.SignInRequest; import com.kamco.cd.kamcoback.members.service.AuthService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import java.nio.file.AccessDeniedException; import java.time.Duration; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.web.ErrorResponse; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @Tag(name = "인증(Auth)", description = "로그인, 토큰 재발급, 로그아웃 API") @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor public class AuthController { private final AuthenticationManager authenticationManager; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenService refreshTokenService; private final AuthService authService; @Value("${token.refresh-cookie-name}") private String refreshCookieName; @Value("${token.refresh-cookie-secure:true}") private boolean refreshCookieSecure; @PostMapping("/signin") @Operation(summary = "로그인", description = "사번으로 로그인하여 액세스/리프레시 토큰을 발급.") @ApiResponses({ @ApiResponse( responseCode = "200", description = "로그인 성공", content = @Content(schema = @Schema(implementation = TokenResponse.class))), @ApiResponse( responseCode = "401", description = "로그인 실패 (아이디/비밀번호 오류, 계정잠금 등)", content = @Content( schema = @Schema(implementation = ErrorResponse.class), examples = { @ExampleObject( name = "아이디 입력 오류", description = "존재하지 않는 아이디", value = """ { "code": "LOGIN_ID_NOT_FOUND", "message": "아이디를 잘못 입력하셨습니다." } """), @ExampleObject( name = "비밀번호 입력 오류 (4회 이하)", description = "아이디는 정상, 비밀번호를 여러 번 틀린 경우", value = """ { "code": "LOGIN_PASSWORD_MISMATCH", "message": "비밀번호를 잘못 입력하셨습니다." } """), @ExampleObject( name = "비밀번호 오류 횟수 초과", description = "비밀번호 5회 이상 오류로 계정 잠김", value = """ { "code": "LOGIN_PASSWORD_EXCEEDED", "message": "비밀번호 오류 횟수를 초과하여 이용하실 수 없습니다. 로그인 오류에 대해 관리자에게 문의하시기 바랍니다." } """) } )) }) public ApiResponseDto signin( @io.swagger.v3.oas.annotations.parameters.RequestBody( description = "로그인 요청 정보", required = true) @RequestBody SignInRequest request, HttpServletResponse response) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); String status = authService.getUserStatus(request); // INACTIVE 비활성 상태(새로운 패스워드 입력 해야함), ARCHIVED 탈퇴 if (!"ACTIVE".equals(status)) { return ApiResponseDto.ok(new TokenResponse(status, null, null)); } String username = authentication.getName(); // UserDetailsService 에서 사용한 username String accessToken = jwtTokenProvider.createAccessToken(username); String refreshToken = jwtTokenProvider.createRefreshToken(username); // Redis에 RefreshToken 저장 (TTL = 7일) refreshTokenService.save( username, refreshToken, jwtTokenProvider.getRefreshTokenValidityInMs()); // HttpOnly + Secure 쿠키에 RefreshToken 저장 ResponseCookie cookie = ResponseCookie.from(refreshCookieName, refreshToken) .httpOnly(true) .secure(refreshCookieSecure) .path("/") .maxAge(Duration.ofMillis(jwtTokenProvider.getRefreshTokenValidityInMs())) .sameSite("Strict") .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); return ApiResponseDto.ok(new TokenResponse(status, accessToken, refreshToken)); } @PostMapping("/refresh") @Operation(summary = "토큰 재발급", description = "리프레시 토큰으로 새로운 액세스/리프레시 토큰을 재발급합니다.") @ApiResponses({ @ApiResponse( responseCode = "200", description = "재발급 성공", content = @Content(schema = @Schema(implementation = TokenResponse.class))), @ApiResponse( responseCode = "401", description = "만료되었거나 유효하지 않은 리프레시 토큰", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) public ResponseEntity refresh(String refreshToken, HttpServletResponse response) throws AccessDeniedException { if (refreshToken == null || !jwtTokenProvider.isValidToken(refreshToken)) { throw new AccessDeniedException("만료되었거나 유효하지 않은 리프레시 토큰 입니다."); } String username = jwtTokenProvider.getSubject(refreshToken); // Redis에 저장된 RefreshToken과 일치하는지 확인 if (!refreshTokenService.validate(username, refreshToken)) { throw new AccessDeniedException("만료되었거나 유효하지 않은 리프레시 토큰 입니다."); } // 새 토큰 발급 String newAccessToken = jwtTokenProvider.createAccessToken(username); String newRefreshToken = jwtTokenProvider.createRefreshToken(username); // Redis 갱신 refreshTokenService.save( username, newRefreshToken, jwtTokenProvider.getRefreshTokenValidityInMs()); // 쿠키 갱신 ResponseCookie cookie = ResponseCookie.from(refreshCookieName, newRefreshToken) .httpOnly(true) .secure(refreshCookieSecure) .path("/") .maxAge(Duration.ofMillis(jwtTokenProvider.getRefreshTokenValidityInMs())) .sameSite("Strict") .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); return ResponseEntity.ok(new TokenResponse("ACTIVE", newAccessToken, newRefreshToken)); } @PostMapping("/logout") @Operation(summary = "로그아웃", description = "현재 사용자의 토큰을 무효화(리프레시 토큰 삭제)합니다.") @ApiResponses({ @ApiResponse( responseCode = "200", description = "로그아웃 성공", content = @Content(schema = @Schema(implementation = Void.class))) }) public ApiResponseDto> logout( Authentication authentication, HttpServletResponse response) { if (authentication != null) { String username = authentication.getName(); // Redis에서 RefreshToken 삭제 refreshTokenService.delete(username); } // 쿠키 삭제 (Max-Age=0) ResponseCookie cookie = ResponseCookie.from(refreshCookieName, "") .httpOnly(true) .secure(refreshCookieSecure) .path("/") .maxAge(0) .sameSite("Strict") .build(); response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); return ApiResponseDto.createOK(ResponseEntity.noContent().build()); } public record TokenResponse(String status, String accessToken, String refreshToken) { } }