728x90

개요

멋사프로젝트 중 날씨 API를 불러오는걸 담당했다.

OpenWeather API가 유명해서 그걸 사용할려고 했다가 2024년 6월부터 유료로 바뀌면서 카드등록을 해야한다더라...

*** 알아보니 무료버전을 사용해도 된다고 한다! 다음 포스트에 작성해볼려고 한다.

 

다른 무료로 해주는 곳이 없나 찾다가 Weather API 라는 사이트를 발견했다. 처음 가입하고는 2주동안 프리미엄 혜택을 받고 그 이후로는 무료판으로 돌아가지만 무료판이어도 기능은 괜찮은 것 같아서 이걸로 결정했다. 일단 회원가입을 하고 마이페이지를 들어가면 API KEY가 있을 것이다.


Dashboard - WeatherAPI.com

 

Login - WeatherAPI.com

Quick and Easy Signup for Weather API WeatherAPI.com makes it super easy to integrate our realtime, daily, hourly and 15 min interval weather forecast data, historical weather, marine weather, bulk request, air quality data, autocomplete, time zone, astron

www.weatherapi.com

 

 

 

프로젝트 환경 구성

  • 발급하면 나오는 API KEY를 application.yml 파일에 저장한다.
  • 알려져서는 안되는 키이다.
weather:
  api:
    key: {API key}

 

  • 그 후에 스프링에서 날씨 api를 불러쓰기 위해서 RestTemplate를 빈으로 등록한다.
@Configuration
public class DemoApplication {

    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

 

  • RestTemplate 이란?
    • HTTP 요청을 보내고 응답을 받기 위해 사용하는 클라이언트이다.
    • 외부 RESTful 웹 서비스와 통신할 때 사용된다.
    • 다른 서버의 API를 호출하여 데이터를 가져오거나, 서버로 데이터를 전송할 때 사용된다.
  • 왜 @Bean으로 등록?
    • Spring 컨텍스트 내에서 공유되며, 다른 컴포넌트에서 @Autowired를 통해 주입받아 사용할 수 있다.

 

 

  • 그 다음으로 yml 파일에 등록한 키를 사용하기 위해 컨트롤러를 만든다.
@RestController
@RequiredArgsConstructor
public class WeatherApiController {
    @Value("${weather.api.key}")
    private String apiKey;

    private final RestTemplate restTemplate;

    /**
     * 일주일 날씨 보여주기
     * @param location Default-Seoul
     * @return url
     */
    @GetMapping("/api/weather")
    public String getWeather(@RequestParam String location) {
        String url = String.format("https://api.weatherapi.com/v1/forecast.json?key=%s&q=%s&days=7", apiKey, location);
        return restTemplate.getForObject(url, String.class);
    }
}

 

  • 지도 API같은 경우 순수 자바스크립트로 구현할 수 있지만 위에서 발급받은 API key는 노출되어서 안되기 때문에 yml파일에 저장을 한 것이다.
  • 그래서 yml파일에 저장한 키 값을 사용하기 위해 서버에서 코드를 작성해서 자바스크립트에서 저 앤드포인트를 불러오는 방향으로 진행했다.
"https://api.weatherapi.com/v1/forecast.json?key=%s&q=%s&days=7"
  • 위의 url은 weather api에서 날씨 API를 불러오는 URL이다.
  • yml파일에 저장한 키와 일주일 날씨를 가져오는 url 이다.

 

 

 

  • 화면에 보여지는 HTML 과 JS 부분이다.
<!-- src/main/resources/templates/user/home.html -->
<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>OMG Travel</title>
    <script src='https://cdn.jsdelivr.net/npm/fullcalendar@6.1.15/index.global.min.js'></script>
    <link rel="stylesheet" href="/css/header.css">
</head>
<body>
<div th:replace="fragments/header :: headerFragment"></div>
<div class="wrapper">
    <div class="container">
        <div class="left-content">
            <div class="weather-section">
                <div class="search-container">
                    <input type="text" id="location-input" class="search-input" placeholder="지역 입력">
                    <button id="search-button" class="search-button">검색</button>
                </div>
                <div>
                    <h3>현재 날씨</h3>
                    <div id="current-weather"></div>
                    <div id="current-date" style="display: none;"></div>
                    <h3>이번 주 날씨</h3>
                    <div class="weather" id="weather-container"></div>
                </div>
            </div>
        </div>
    </div>
</div>

<script>
    document.addEventListener('DOMContentLoaded', function() {

<!-- 날씨 API 불러오기 -->
        const weatherContainer = document.getElementById('weather-container');
        const currentWeather = document.getElementById('current-weather');
        const currentDateEl = document.getElementById('current-date');

        function fetchWeather(location) {
            fetch(`/api/weather?location=${encodeURIComponent(location)}`)
                .then(response => {
                    if (!response.ok) {
                        throw new Error(`HTTP 접속 오류 상태: ${response.status}`);
                    }
                    return response.json();
                })
                .then(data => {
                    const current = data.current;
                    const dailyWeather = data.forecast.forecastday;
                    const daysOfWeek = ['일', '월', '화', '수', '목', '금', '토']; // 요일 배열 수정

                    const today = new Date();
                    const options = { year: 'numeric', month: 'long', day: 'numeric' };
                    const formattedDate = today.toLocaleDateString('ko-KR', options);

                    currentWeather.innerHTML = `
                    <div class="current-location">${data.location.name}</div>
                    <div class="current-temp">${current.temp_c}°C</div>
                    <img src="${current.condition.icon}" alt="${current.condition.text}" class="weather-icon"/>
                `;

                    currentDateEl.innerHTML = `<div>${formattedDate}</div>`; // 현재 날짜 표시

                    weatherContainer.innerHTML = '';
                    dailyWeather.forEach((day, index) => {
                        const dayOfWeek = daysOfWeek[new Date(day.date).getDay()];
                        const formattedDay = new Date(day.date).toLocaleDateString('ko-KR', {
                            month: 'long',
                            day: 'numeric',
                        });
                        const icon = day.day.condition.icon;

                        const weatherItem = document.createElement('div');
                        weatherItem.classList.add('weather-item');

                        weatherItem.innerHTML = `
                        <div class="date">${formattedDay} (${dayOfWeek})</div>
                        <div class="temp">${day.day.avgtemp_c}°C</div>
                        <img src="${icon}" alt="${day.day.condition.text}" class="weather-icon"/>
                    `;
                        weatherContainer.appendChild(weatherItem);
                    });
                })
                .catch(error => console.error('날씨 정보를 가져오지 못했습니다:', error));
        }

        fetchWeather('Seoul'); // Default 값은 서울

        document.getElementById('search-button').addEventListener('click', function() {
            const locationInput = document.getElementById('location-input').value;
            if (locationInput) {
                fetchWeather(locationInput);
            }
        });

        // 엔터 가능
        document.getElementById('location-input').addEventListener('keypress', function(e) {
            if (e.key === 'Enter') {
                document.getElementById('search-button').click();
            }
        });
    });
</script>
</body>
</html>

기존 css 코드와 날씨 API를 불러오는데 불필요한 코드는 없애고 딱 날씨 API를 호출하는 부분만 남겨놨다.

저렇게 구현하면 화면단에 일주일의 날씨가 보여질 것이다!

 

728x90
728x90

멋사 파이널 프로젝트에서 인증/인가를 담당했다.

이때 로그인시 인증에 대해서 JWT 로그인을 구현하였다.

 

JWT 토큰을 이용한 로그인에는 Access-Token과 Refresh-Token이 쓰이는데,

Access-Token은 인증을 위한 토큰으로 사용되고, Refresh-Token은 액세스 토큰을 재발급 하는데 사용된다.

 

[기존의 Refresh Token 사용 방식]

입력한 회원정보와 가입한 회원정보가 일치할 시, Login을 할때, Refresh-Token을 서버에서 만들어 쿠키에 저장하고 MySQL database에 저장후 액세스 토큰의 유효기간 만료로 인한 토큰 재발급 시, 리프레시 토큰값을 비교하여 일치 할 경우, 새로운 액세스 토큰과 리프레시 토큰을 발급하도록 구현했다.

 

하지만 이 방식의 아쉬운 점은, Refresh-Token을 발급한 이후, 별도의 로그아웃 API 호출이 없는 경우 깔끔하게 토큰이 DB에서 삭제되지 못한다는 점이다.

 

그래서 프로젝트 리팩토링을 진행하면서, MySQL에 저장했던 Refresh-Token을 Redis로 바꾸었다.!!

 


왜 Redis 인가? Redis vs RDB

Redis 는 리스트, 배열 형식의 데이터 처리에 특화되어 있다. 리스트 형 데이터의 입력과 삭제가 MySQL보다 10배 정도 빠르다. 이런 Redis 를 RefreshToken 의 저장소로 사용할 경우, 빠른 접근 속도로 사용자가 로그인시(리프레시 토큰 발급시) 병목이 발생할 가능성을 낮출 수 있다.

 

또 Refresh Token 은 발급된 이후 일정시간 이후 만료가 되어야한다. 리프레시 토큰을 RDB 등에 저장하면, 스케줄러등을 사용해서 만료된 토큰을 주기적으로 DB 에서 제거해 줘야 한다.

그러나 Redis 는 기본적으로 데이터의 유효기간(time to live) 을 지정할 수 있다. 이런 특징들이 바로 Refresh Token 을 저장하기에 적합하다고 생각했다.

 

또한 Redis 는 휘발성이라는 특징으로 인해 데이터가 손실될수도 있는 위험이 있으나, Redis 에 저장될 리프레시 토큰은 손실되더라도 그리 손해가 큰 편은 아니다. 기껏해봤자 RefreshToken 이 없어져서 다시 로그인을 시도해야 하는 정도이다.

Trand-Off를 고려했을 때, 이는 큰 문제가 아니라 생각해서 Redis에 Refresh-Token을 저장하려는 결정을 내렸다.

 


Redis 사용 시 이점

1. 빠른 액세스와 만료 관리

  • Redis는 메모리 기반 데이터 저장소이므로 매우 빠른 읽기/쓰기를 제공
  • 이를 통해 refreshToken의 유효성을 빠르게 확인하고 필요 시 만료시킬 수 있다.
  • 짧은 응답 시간은 보안 이벤트(예: 토큰 탈취 시도)에 빠르게 대응할 수 있게 해준다.

 

2. 간편한 만료 및 삭제

  • Redis는 TTL(Time to Live) 기능을 제공하여 키에 유효기간을 설정할 수 있다.
  • 이를 통해 refreshToken의 자동 만료가 가능하며, 특정 조건에서 토큰을 신속히 무효화할 수 있다.
  • 이는 RDBMS보다 효율적이며 보안 사고 시 피해를 줄일 수 있다

 

3. 세션 무효화 용이성

  • 유저가 로그아웃하거나 비정상적인 활동이 감지될 경우 Redis에서 특정 refreshToken을 쉽게 삭제할 수 있다.
  • RDBMS에서는 복잡한 쿼리와 트랜잭션 처리가 필요할 수 있지만, Redis에서는 단일 명령으로 처리할 수 있어 더 빠르고 효과적

 

4. 스케일링의 용이성

  • Redis는 분산 캐시 시스템을 지원하며, 이를 통해 큰 규모의 사용자를 처리할 때에도 성능을 유지할 수 있다.
  • 이는 특히 대규모 시스템에서 보안 모니터링과 대응 속도를 유지하는 데 중요

 

5. 분리된 저장소

  • refreshToken을 RDBMS가 아닌 Redis에 저장함으로써 데이터베이스와 캐시 간에 책임을 분리할 수 있다.
  • 이는 데이터베이스에 대한 접근을 최소화하여 공격 벡터를 줄일 수 있다.
  • 예를 들어, 데이터베이스가 공격을 받더라도 캐시에 저장된 토큰 정보는 노출되지 않을 수 있다.
728x90
728x90

개요

멋사 파이널 프로젝트 중 회원가입 시 이메일 인증과 비밀번호 찾기 로직을 구현해보고자 한다.

회원가입에 성공 후 비밀번호를 잊어버렸을 시 코드 구현을 하고자 한다.

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

jwt 0.11.5

 

의존성 설치

// email smtp
implementation 'org.springframework.boot:spring-boot-starter-mail'

 

필요한 환경변수 세팅 Google SMTP

  • 비밀번호는 구글 이메일의 비밀번호가 아니라 구글 설정에서 앱 비밀번호를 생성받아야 한다.
  • 아래 링크에서 잘 설명해주셔서 참고!
spring:
  application:
    name: OMG_project
  mail:
    host: smtp.gmail.com
    port: 587
    username: {gmail email}
    password: {password}
    properties:
      mail:
        smtp:
          starttls:
            enable: true
          auth: true

[Go] Google Gmail SMTP 설정 방법 및 메일 전송 (tistory.com)

 

[Go] Google Gmail SMTP 설정 방법 및 메일 전송

■ SMTP 간이 우편 전송 프로토콜(Simple Mail Transfer Protocol)의 약자. 이메일 전송에 사용되는 네트워크 프로토콜이다. 인터넷에서 메일 전송에 사용되는 표준이다. 1982년 RFC821에서 표준화되어 현재

hyunmin1906.tistory.com

 

 

1. dto

@Getter @Setter
public class MailRequest {

    private String mail;
}
@Getter @Setter
public class PasswordVerificationRequest {

    private String mail;
    private String tempPassword;
}
  • 사실 역할로 봤을 때는 dto의 기능을 하지 않기 때문에 dto라 명명한 것이 잘못되었지만... 나중에 수정하는 것으로 하고 넘어갔다.

 

2. Service

public interface MailService {

    String createTemporaryPassword(String email);

    boolean verifyTemporaryPassword(String email, String tempPassword);

    void sendTemporaryPasswordMail(String email, String tempPassword);
}
@Service
@RequiredArgsConstructor
@Slf4j
public class MailServiceImpl implements MailService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JavaMailSender javaMailSender;
    private static final String senderEmail = "ch9800113@gmail.com";
    private static final Map<String, Integer> verificationCodes = new HashMap<>();

    /**
     * 임시 비밀번호 자동 생성 메서드
     */
    private static String generateRandomPassword() {
        int length = 8;
        StringBuilder sb = new StringBuilder(length);
        Random random = new Random();
        for (int i = 0; i < length; i++) {
            sb.append((char) (random.nextInt(10) + '0'));
        }
        return sb.toString();
    }

    /**
     * 임시 비밀번호 전송
     */
    @Override
    public void sendTemporaryPasswordMail(String mail, String tempPassword) {
        MimeMessage message = javaMailSender.createMimeMessage();
        try {
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
            helper.setFrom(senderEmail);
            helper.setTo(mail);
            helper.setSubject("OMG 임시 비밀번호");
            String body = "<h2>OMG에 오신걸 환영합니다!</h2><p>아래의 임시 비밀번호를 사용하세요.</p><h1>" + tempPassword + "</h1><h3>반드시 비밀번호를 재설정하세요.</h3>";
            helper.setText(body, true);
            javaMailSender.send(message);
        } catch (MessagingException e) {
            throw new RuntimeException("임시 비밀번호 전송 오류", e);
        }
    }

    /**
     * 임시 비밀번호 생성 및 DB 업데이트
     */
    @Override
    public String createTemporaryPassword(String mail) {
        String tempPassword = generateRandomPassword();
        User user = userRepository.findByUsername(mail)
                .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
        user.setPassword(passwordEncoder.encode(tempPassword));
        userRepository.save(user);
        return tempPassword;
    }

    /**
     * 임시 비밀번호 검증
     */
    @Override
    public boolean verifyTemporaryPassword(String mail, String tempPassword) {
        User user = userRepository.findByUsername(mail)
                .orElseThrow(() -> new RuntimeException("사용자를 찾을 수 없습니다."));
        return passwordEncoder.matches(tempPassword, user.getPassword());
    }
}

 

 

[ 코드 설명 ]

/**
 * 임시 비밀번호 자동 생성 메서드
 */
private static String generateRandomPassword() {
    int length = 8;
    StringBuilder sb = new StringBuilder(length);
    Random random = new Random();
    for (int i = 0; i < length; i++) {
        sb.append((char) (random.nextInt(10) + '0'));
    }
    return sb.toString();
}
  • 반환 값: String (문자열)
  • 생성 방식: 이 메서드는 8자리의 숫자로 구성된 문자열을 생성하는 메서드이다.
    • 문자열은 StringBuilder를 사용하여 효율적으로 생성되도록 구현했다.
    • 각 반복에서 random.nextInt(10) + '0'을 통해 0부터 9까지의 숫자를 문자로 변환하여 문자열에 추가한다.
  • StringBuilder 사용이유 ::
    • String은 불변 객체(immutable object)이다. 즉 한 번 생성된 String은 변경할 수 없으며, 문자열의 조작은 새로운 String 객체를 생성하여 처리된다.
    • StringBuilder를 사용하여 문자열을 생성한 후, 최종적으로 toString() 메서드를 호출하여 불변의 String 객체를 반환하도록 구현했다.
    • 위의 코드는 숫자로만 구성했지만, 나중에 보안을 위해 아래처럼 작성하는 것으로 바꾸었다.
  •  
private static String generateRandomPassword() {
    int length = 8;
    StringBuilder sb = new StringBuilder(length);
    Random random = new Random();
    String characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";

    for (int i = 0; i < length; i++) {
        sb.append(characters.charAt(random.nextInt(characters.length())));
    }

    return sb.toString();
}
/**
 * 임시 비밀번호 전송
 */
@Override
public void sendTemporaryPasswordMail(String mail, String tempPassword) {
    MimeMessage message = javaMailSender.createMimeMessage();
    try {
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
        helper.setFrom(senderEmail);
        helper.setTo(mail);
        helper.setSubject("임시 비밀번호");
        String body = "<h2>000에 오신걸 환영합니다!</h2><p>아래의 임시 비밀번호를 사용하세요.</p><h1>" + tempPassword + "</h1><h3>반드시 비밀번호를 재설정하세요.</h3>";
        helper.setText(body, true);
        javaMailSender.send(message);
    } catch (MessagingException e) {
        throw new RuntimeException("임시 비밀번호 전송 오류", e);
    }
}
  • 반환 값: void
  •  생성 방식 : 이 메서드는 임시비밀번호를 이메일로 전송하는 기능만 수행하고, 결과를 반환할 필요가 없다
    • javaMailSender.send(message); 를 통해 메서드에서 바로 구현하여 바로 메일을 전송하였다.
  • 하지만 저번 포스트에서 회원가입시 이메일 인증 번호 전송 로직을 보면
@Override
public MimeMessage createMail(String mail){
    createNumber(mail);
    MimeMessage message = javaMailSender.createMimeMessage();

    try {
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
        helper.setFrom(senderEmail);
        helper.setTo(mail);
        helper.setSubject("이메일 인증번호");
        String body = "<h2>000에 오신걸 환영합니다!</h2><h3>아래의 인증번호를 입력하세요.</h3><h1>" + verificationCodes.get(mail) + "</h1><h3>감사합니다.</h3>";
        helper.setText(body, true);
    } catch (MessagingException e) {
        e.printStackTrace();
    }

    return message;
}

/**
 * createMail() 메서드의 내용을 이메일 전송
 */
@Async
@Override
public CompletableFuture<Integer> sendMail(String mail) {
    MimeMessage message = createMail(mail);
    javaMailSender.send(message);
    return CompletableFuture.completedFuture(verificationCodes.get(mail));
}
  • 여기서 코드의 반환값은 MimeMessage 이다.
  • 즉 javaMailSender.send(message); 를 하지 않았기 때문에 따로 sendMail 메서드를 통해 이메일 전송을 해줘야 한다.
  • MimeMessage  란? ::
    • MimeMessage  객체는 이메일 메시지를 생성하고 설정하는 데 사용되는 객체이다.
    • 발신자, 수신자, 제목, 본문 내용, 첨부 파일 등 이메일의 모든 구성 요소를 설정할 수 있고 설정된 MimeMessage 객체는 JavaMailSender를 통해 이메일 서버로 전송해야 한다.
  • 나중에 코드 통일성을 위해 하나의 메서드에서 전송될 수 있도록 구현할 예정이다.

 

 

3. Controller

@RestController
@RequiredArgsConstructor
@EnableAsync
public class MailApiController {

    private final MailService mailService;
    private final UserService userService;

    /**
     * 임시 비밀번호 재발급 발송 메서드
     */
    @PostMapping("/api/users/reset-password")
    public ResponseEntity<String> resetPassword(@RequestBody MailRequest mailRequest) {
        String email = mailRequest.getMail();

        if (userService.existsByUsername(email)) {
            String tempPassword = mailService.createTemporaryPassword(email);
            mailService.sendTemporaryPasswordMail(email, tempPassword);
            return ResponseEntity.ok("임시 비밀번호가 이메일로 발송되었습니다.");
        } else {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("해당 이메일로 가입된 사용자가 없습니다.");
        }
    }

    /**
     * 임시 비밀번호 검증 메소드
     */
    @PostMapping("/api/users/verify-temporary-password")
    public ResponseEntity<String> verifyTemporaryPassword(@RequestBody PasswordVerificationRequest request) {
        boolean isVerified = mailService.verifyTemporaryPassword(request.getMail(), request.getTempPassword());
        return isVerified ? ResponseEntity.ok("Verified") : ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Verification failed");
    }
}
/**
 * 비밀번호 재발급 페이지 이동
 */
@GetMapping("/users/reset-user-password")
public String showResetPasswordForm() {
    return "/user/findPassword";
}

 

 

4. 프론트엔드

<!DOCTYPE html>
<html lang="ko">
<head>
    <meta charset="UTF-8">
    <title>비밀번호 재발급</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <link rel="stylesheet" href="/css/find-password.css">
</head>
<body>
<form id="emailVerificationForm">

    <h1>비밀번호 재발급</h1>
    <label for="email">가입 이메일</label>
    <input type="email" id="email" name="email" placeholder="Email" required/>
    <button type="button" id="send-code-button">임시 비밀번호 발송</button>
    <span id="emailCheckMessage" class="error"></span>

    <div id="verifyCodeSection">
        <label for="temporaryPassword">임시 비밀번호</label>
        <input type="text" id="temporaryPassword" name="temporaryPassword" placeholder="임시 비밀번호 입력" required />
        <button type="button" id="verify-temporary-password-button">임시 비밀번호 확인</button>
        <span id="verificationMessage" class="error"></span>
    </div>
</form>
<script>
    $(document).ready(function() {
        $('#verifyCodeSection').hide();

        $('#send-code-button').on('click', function() {
            let email = $('#email').val();
            $.ajax({ // 이메일이 데이터베이스에 있는지 확인
                url: '/api/users/check-email',
                type: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({ mail: email }),
                success: function(response) {
                    if (response) {
                        $.ajax({ // 이메일이 존재하면 임시 비밀번호 발송
                            url: '/api/users/reset-password',
                            type: 'POST',
                            contentType: 'application/json',
                            data: JSON.stringify({ mail: email }),
                            success: function(response) {
                                $('#verifyCodeSection').slideDown(); // 인증 코드 입력 섹션 표시
                                alert('임시 비밀번호가 이메일로 발송되었습니다. 이메일을 확인해주세요.');
                            },
                            error: function(error) {
                                alert('임시 비밀번호 발송에 실패했습니다. 다시 시도해주세요.');
                            }
                        });
                    } else {
                        $('#emailCheckMessage').text('해당 이메일로 가입된 사용자가 없습니다.').show();
                    }
                },
                error: function(error) {
                    alert('이메일 확인 중 오류가 발생했습니다. 다시 시도해주세요.');
                }
            });
        });

        $('#verify-temporary-password-button').on('click', function() {
            const email = $('#email').val();
            const tempPassword = $('#temporaryPassword').val();
            $.ajax({
                url: '/api/users/verify-temporary-password',
                type: 'POST',
                contentType: 'application/json',
                data: JSON.stringify({ mail: email, tempPassword: tempPassword }),
                success: function(response) {
                    if (response === "Verified") {
                        alert("임시 비밀번호가 확인되었습니다. 로그인하세요.");
                        window.location.href = "/signin";
                    } else {
                        $('#verificationMessage').text("임시 비밀번호가 일치하지 않습니다. 다시 시도하세요.").show();
                    }
                },
                error: function(xhr, status, error) {
                    alert("임시 비밀번호 검증에 실패했습니다. 다시 시도하세요.");
                }
            });
        });
    });
</script>
</body>
</html>
  1. 해당 이메일이 디비에 있는지 확인한다.
  2. 이메일이 존재한다면 해당 이메일로 임시 비밀번호를 전송해준다.
  3. 이때 폼에서는 숨겼던 섹션을 표시하고 임시비밀번호를 입력 란을 활성화한다.
  4. 임시비밀번호가 이메일로 전송한 값과 같은지 확인한다.
  5. 이때 임시비밀번호가 디비에 업데이트가 되어서 기존 비밀번호로 인증을 할 수 없게 된다.

 

여기까지 끝-!

 

 

 

 

 


jQuery에서 HTML 요소를 표시하는 두 가지 방법

 

  • jQuery 에서 아래의 두가지가 섹션을 보여주는 방식의 차이라서 정리한다.
// 애니메이션 효과로 서서히 표시
$('#verifyCodeSection').slideDown();

// 즉시 표시
$('#verifyCodeSection').show();

 

 

 

728x90
728x90

개요

멋사 파이널 프로젝트 중 회원가입 시 이메일 인증과 비밀번호 찾기 로직을 구현해보고자 한다.

회원가입 시 받는 이메일 인증은 사용자 식별 및 보안 강화를 위해 필요한 기술이다. 만약 이메일 인증과 같은 인증 기술이 없다면 한 사람이 10개 혹은 1000개의 계정을 무한대로 생성할 수 있다는 것인데, 이는 스팸이나 부정 사용 등 서비스 품질을 하락시킬 수 있다. 이메일 인증과 관련해서 구현하기까지 수많은 구글링과 시행착오가 있어서 정리해놓을려고 한다.

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6 

jwt 0.11.5

 

의존성 설치

// email smtp
implementation 'org.springframework.boot:spring-boot-starter-mail'

 

필요한 환경변수 세팅 Google SMTP

  • 비밀번호는 구글 이메일의 비밀번호가 아니라 구글 설정에서 앱 비밀번호를 생성받아야 한다.
  • 아래 링크에서 잘 설명해주셔서 참고!
spring:
  application:
    name: OMG_project
  mail:
    host: smtp.gmail.com
    port: 587
    username: {gmail email}
    password: {password}
    properties:
      mail:
        smtp:
          starttls:
            enable: true
          auth: true

[Go] Google Gmail SMTP 설정 방법 및 메일 전송 (tistory.com)

 

[Go] Google Gmail SMTP 설정 방법 및 메일 전송

■ SMTP 간이 우편 전송 프로토콜(Simple Mail Transfer Protocol)의 약자. 이메일 전송에 사용되는 네트워크 프로토콜이다. 인터넷에서 메일 전송에 사용되는 표준이다. 1982년 RFC821에서 표준화되어 현재

hyunmin1906.tistory.com

 

 

 

1. dto

이메일 인증에 필요한 dto 를 작성한다.

@Getter @Setter
public class MailRequest {

    private String mail;
}
@Getter @Setter
public class MailVerificationRequest {

    private String mail;
    private int code;
}
  • 사실 역할로 봤을 때는 dto의 기능을 하지 않기 때문에 dto라 명명한 것이 잘못되었지만... 나중에 수정하는 것으로 하고 넘어갔다.

 

 

2. Service

  • 구현하고자 하는 기능에 맞게 코드를 짠다.
public interface MailService {

    MimeMessage createMail(String mail);

    boolean verifyCode(String email, int code);

    CompletableFuture<Integer> sendMail(String mail);
}
@Service
@RequiredArgsConstructor
@Slf4j
public class MailServiceImpl implements MailService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final JavaMailSender javaMailSender;
    private static final String senderEmail = "메일을 보낼 구글 이메일";
    private static final Map<String, Integer> verificationCodes = new HashMap<>();

    /**
     * 인증 코드 자동 생성 메서드
     */
    public static void createNumber(String email){
        int number = new Random().nextInt(900000) + 100000; // 100000-999999 사이의 숫자 생성
        verificationCodes.put(email, number);
    }

    /**
     * 이메일 전송
     */
    @Override
    public MimeMessage createMail(String mail){
        createNumber(mail);
        MimeMessage message = javaMailSender.createMimeMessage();

        try {
            MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
            helper.setFrom(senderEmail);
            helper.setTo(mail);
            helper.setSubject("이메일 인증번호");
            String body = "<h2>000에 오신걸 환영합니다!</h2><h3>아래의 인증번호를 입력하세요.</h3><h1>" + verificationCodes.get(mail) + "</h1><h3>감사합니다.</h3>";
            helper.setText(body, true);
        } catch (MessagingException e) {
            e.printStackTrace();
        }

        return message;
    }

    /**
     * createMail() 메서드의 내용을 이메일 전송
     */
    @Async
    @Override
    public CompletableFuture<Integer> sendMail(String mail) {
        MimeMessage message = createMail(mail);
        javaMailSender.send(message);
        return CompletableFuture.completedFuture(verificationCodes.get(mail));
    }

    /**
     * 이메일 인증 코드 검증
     */
    @Override
    public boolean verifyCode(String mail, int code) {
        Integer storedCode = verificationCodes.get(mail);
        return storedCode != null && storedCode == code;
    }
}

1) 주어진 이메일 주소에 대해 6자리 인증 코드를 생성하고 verificationCodes 맵에 저장한다.
{이메일 : 인증코드} 형태로 저장될 것이다.

2) 입력한 이메일 주소로 발송할 이메일 메시지를 작성한다.

3) 2에서 생성한 이메일 메시지를 비동기적으로 발송한다.

4) 사용자가 입력한 인증코드와 실제 발송된 인증코드와 일치하는지 확인한다.

 

 

 

3. Controller

@RestController
@RequiredArgsConstructor
@EnableAsync
public class MailApiController {

    private final MailService mailService;
    private final UserService userService;

    /**
     * 인증번호 발송 메소드
     */
    @PostMapping("/api/users/mail")
    public CompletableFuture<String> mailSend(@RequestBody MailRequest mailRequest) {
        return mailService.sendMail(mailRequest.getMail())
                .thenApply(number -> String.valueOf(number));
    }

    /**
     * 인증번호 검증 메소드
     */
    @PostMapping("/api/users/verify-code")
    public String verifyCode(@RequestBody MailVerificationRequest verificationRequest) {
        boolean isVerified = mailService.verifyCode(verificationRequest.getMail(), verificationRequest.getCode());
        return isVerified ? "Verified" : "Verification failed";
    }
}
  • 해당 앤드포인트로 요청이 들어오면 해당 요청을 수행한다.

 

4. 프론트엔드

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>회원가입</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <link rel="stylesheet" href="/css/signup.css">
</head>
<body>
<form id="signup-form" action="/signup" method="post">
    <h1>회원가입</h1>
    <hr/>

    <div th:if="${error}" class="error-message">
        <p th:text="${error}"></p>
    </div>

    <label for="email">이메일</label>
    <input type="email" id="email" name="username" placeholder="이메일 입력" required/>
    <button type="button" id="check-email-button" class="light-button">중복확인</button>
    <span id="emailCheckMessage" class="error"></span>

    <button type="button" id="send-code-button" class="light-button" style="display: none;">인증 코드 발송</button>

    <div id="verifyCodeSection">
        <label for="verificationCode">인증 코드</label>
        <input type="text" id="verificationCode" name="verificationCode" placeholder="인증번호 입력" required/>
        <button type="button" id="verify-code-button" class="light-button">이메일 인증</button>
        <span id="verificationMessage" class="error"></span>
    </div>

    <button type="submit" id="signup-button" disabled>회원가입</button>
</form>
<script src="/js/signup.js"></script>
</body>
</html>
$(document).ready(function() {
    function validateForm() {
        let isValid = true;

        // 이메일, 닉네임 중복 및 비밀번호 일치 여부 검사
        if ($('#emailCheckMessage').hasClass('error')) {
            isValid = false;
        }
        $('#signup-button').prop('disabled', !isValid);
        return isValid;
    }

    // 이메일 중복 검사
    // 1. 이메일 중복되면 인증 메일 보내기 버튼은 숨김
    // 2. 이메일 중복이 없다면 인증 메일 보내기 버튼 활성화 됨
    $('#check-email-button').on('click', function() {
        let email = $('#email').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/check-email',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email }),
            success: function(response) {
                if (response) {
                    $('#emailCheckMessage').text("아이디가 이미 존재합니다.").removeClass('success').addClass('error');
                    $('#send-code-button').hide();  
                } else {
                    $('#emailCheckMessage').text("사용 가능한 아이디입니다.").removeClass('error').addClass('success');
                    $('#send-code-button').show();  
                }
                validateForm();
            },
            error: function(error) {
                $('#emailCheckMessage').text('이메일 확인 중 오류가 발생했습니다. 다시 시도해주세요.').removeClass('success').addClass('error');
                $('#send-code-button').hide();  
                validateForm();
            }
        });
    });

    // 인증 메일
    $('#send-code-button').on('click', function() {
        let email = $('#email').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/mail',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email }),
            success: function(response) {
                $('#verifyCodeSection').show(); 
                alert('인증 메일이 발송되었습니다. 인증 번호를 확인해주세요.');
            },
            error: function(error) {
                alert('메일 발송에 실패했습니다. 다시 시도해주세요.');
            }
        });
    });

    // 인증 코드 확인
    $('#verify-code-button').on('click', function() {
        let email = $('#email').val();
        let code = $('#verificationCode').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/verify-code',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email, code: code }),
            success: function(response) {
                if (response === 'Verified') {
                    $('#verificationMessage').text('인증 성공').removeClass('error').addClass('success');
                } else {
                    $('#verificationMessage').text('인증 실패. 올바른 코드를 입력하세요.').removeClass('success').addClass('error');
                }
            },
            error: function(error) {
                $('#verificationMessage').text('인증 실패. 다시 시도해주세요.').removeClass('success').addClass('error');
            }
        });
    });

    // 인증 코드 발송 버튼 --> 초기 상태에서는 비활성화
    $('#send-code-button').hide();

    // 인증 코드 입력 란 숨기기
    $('#verifyCodeSection').hide();
});
  • 사실 HTML 폼에서 닉네임, 연락처, 생년월일 등 적는 란이 있지만 이메일 인증에 필요한 코드만 남겨두었다.
  1. 이메일이 중복되어 있는지 확인한다. 이 부분은 위에 자바 코드에는 없다.
  2. 이메일이 중복되지 않았다면 해당 이메일로 인증번호를 보낸다.
  3. 인증번호 발송 버튼이 활성화된다. 
// 이메일 중복 검사
    // 1. 이메일 중복되면 인증 메일 보내기 버튼은 숨김
    // 2. 이메일 중복이 없다면 인증 메일 보내기 버튼 활성화 됨
    $('#check-email-button').on('click', function() {
        let email = $('#email').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/check-email',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email }),
            success: function(response) {
                if (response) {
                    $('#emailCheckMessage').text("아이디가 이미 존재합니다.").removeClass('success').addClass('error');
                    $('#send-code-button').hide();  // 오류가 있으면 인증 코드 발송 버튼 숨김
                } else {
                    $('#emailCheckMessage').text("사용 가능한 아이디입니다.").removeClass('error').addClass('success');
                    $('#send-code-button').show();  // 이메일 체크 통과 시 버튼 표시
                }
                validateForm();
            },
            error: function(error) {
                $('#emailCheckMessage').text('이메일 확인 중 오류가 발생했습니다. 다시 시도해주세요.').removeClass('success').addClass('error');
                $('#send-code-button').hide();  // 오류가 있으면 인증 코드 발송 버튼 숨김
                validateForm();
            }
        });
    });

4) 인증코드 입력 섹션이 활성화된다.

// 인증 메일
    $('#send-code-button').on('click', function() {
        let email = $('#email').val();

        $.ajax({
            type: 'POST',
            url: '/api/users/mail',
            contentType: 'application/json',
            data: JSON.stringify({ mail: email }),
            success: function(response) {
                $('#verifyCodeSection').show(); // 인증 코드 입력 섹션 표시
                alert('인증 메일이 발송되었습니다. 인증 번호를 확인해주세요.');
            },
            error: function(error) {
                alert('메일 발송에 실패했습니다. 다시 시도해주세요.');
            }
        });
    });

5) 해당 이메일과 입력한 코드를 /api/users/verify-code 앤드포인트에서 수행한다.

$('#verify-code-button').on('click', function() {
    let email = $('#email').val();
    let code = $('#verificationCode').val();

    $.ajax({
        type: 'POST',
        url: '/api/users/verify-code',
        contentType: 'application/json',
        data: JSON.stringify({ mail: email, code: code }),
        success: function(response) {
            if (response === 'Verified') {
                $('#verificationMessage').text('인증 성공').removeClass('error').addClass('success');
            } else {
                $('#verificationMessage').text('인증 실패. 올바른 코드를 입력하세요.').removeClass('success').addClass('error');
            }
        },
        error: function(error) {
            $('#verificationMessage').text('인증 실패. 다시 시도해주세요.').removeClass('success').addClass('error');
        }
    });
});

 

 

 

5. 결과

 

위에처럼 작성 후 실행하면

해당 이메일로 인증 번호가 전송된다.

 

 

 

 

 

 

 

 

 

 

 

 

 

 

다음 편은 비밀번호 찾기 로직을 정리할 것이다.

728x90
728x90

개요

이번에는 OAuth2 로그인 인증 인가 부분을 구현할 예정이다.

관련해서 세션 방식으로는 아래 블로그에 정리해두었고 이번에는 토큰으로 저장하는 방법이다.

Spring Security + OAuth2 + Session 를 사용한 로그인, 로그아웃 구현 - Kakao, Naver :: 미정 (tistory.com)


 

 

1. Oauth2 응답 객체

 

 

 

  • NaverResponse.java
public class NaverResponse implements OAuth2Response{

    private final Map<String, Object> attribute;

    public NaverResponse(Map<String, Object> attribute) {
        this.attribute = (Map<String, Object>) attribute.get("response");
        System.out.println("naver attributes: " + attribute);
    }

    @Override
    public String getProvider() {
        return "naver";
    }

    @Override
    public String getProviderId() {
        return attribute.get("id").toString();
    }

    @Override
    public String getEmail() {
        return attribute.get("email").toString();
    }

    @Override
    public String getName() {
        return attribute.get("name").toString();
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attribute;
    }

}

 

 

  • KakaoResponse.java
public class KakaoResponse implements OAuth2Response{

    private Map<String, Object> attribute;

    public KakaoResponse(Map<String, Object> attribute) {
        this.attribute = attribute;
        System.out.println("Kakao attributes: " + attribute);
    }

    @Override
    public String getProvider() {
        return "kakao";
    }

    @Override
    public String getProviderId() {
        return attribute.get("id").toString();
    } // 2632890179

    @Override
    public String getEmail() {
        Map<String, Object> kakaoAccount = (Map<String, Object>) attribute.get("kakao_account");
        return kakaoAccount.get("email").toString();
    }

    @Override
    public String getName() {
        Map<String, Object> properties = (Map<String, Object>) attribute.get("properties");
        return properties.get("nickname").toString();
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attribute;
    }
}

 

 

  • googleResponse.java
public class GoogleResponse implements OAuth2Response{

    private final Map<String, Object> attribute;

    public GoogleResponse(Map<String, Object> attribute) {
        this.attribute = attribute;
    }

    @Override
    public String getProvider() {
        return "google";
    }

    @Override
    public String getProviderId() {
        return attribute.get("sub").toString();
    }

    @Override
    public String getEmail() {
        return attribute.get("email").toString();
    }

    @Override
    public String getName() {
        return attribute.get("name").toString();
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attribute;
    }
}

 

  • CustomOAuth2User.java
@Slf4j
public class CustomOAuth2User implements OAuth2User {

    private final OAuth2Response oAuth2Response;
    private final String role;

    public CustomOAuth2User(OAuth2Response oAuth2Response, String role) {
        this.oAuth2Response = oAuth2Response;
        this.role = role;
    }

    @Override
    public Map<String, Object> getAttributes() {

        return oAuth2Response.getAttributes();
    }

    // role에 해당하는 값
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collection = new ArrayList<>();
        collection.add(new SimpleGrantedAuthority(role));
        return collection;
    }

    @Override
    public String getName() {
        return oAuth2Response.getName();
    }

    public String getUsername() {
        return oAuth2Response.getProvider()+" "+oAuth2Response.getProviderId();
    }

    /**
     * 각 소셜 로그인마다 OAuth2 제공자의 ID 형식이 다르기 때문에 각 ID에 맞는 처리
     * @return getProviderId
     * Google : 매우 큰 숫자 BigInt
     * Naver : 문자열 String
     * Kakao : 작은 숫자 Long
     */
    public Long getUserIdAsLong() {
        String providerId = oAuth2Response.getProviderId();

        if (providerId == null) {
            return null;
        }

        try {
            // providerId가 정수인지 확인
            if (providerId.matches("\\d+")) {
                BigInteger bigInt = new BigInteger(providerId);
                if (bigInt.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0) {
                    return Long.MAX_VALUE;
                } else if (bigInt.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0) {
                    return Long.MIN_VALUE;
                } else {
                    return bigInt.longValue();
                }
            } else {
                // providerId가 정수가 아닌 경우 문자열의 해시코드를 사용하여 Long으로 변환
                return (long) providerId.hashCode();
            }
        } catch (NumberFormatException e) {
            log.error("user ID parsing 오류 :: {}", providerId, e);
            return null;
        }
    }

    public List<String> getRoles() {
        return Collections.singletonList(role);
    }
}
  • 시큐리티 세션 방식처럼 UserDetails 역할을 하는 것
  • 카카오, 네이버, 구글 서비스로부터 받은 특정 사이트의 응답 값과 롤에 대한 값을 받는 클래스이다.
  • 이 클래스를 통해 특정 값과 롤에 대해 정의한다.

 

 

 

2. OAuth2UserService 구현

@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOauth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(userRequest);
        log.info("OAuth2User attributes: {}", oAuth2User.getAttributes());
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        OAuth2Response oAuth2Response = null;

        switch (registrationId) {
            case "naver":
                log.info("naver 로그인");
                oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
                break;
            case "kakao":
                log.info("kakao 로그인");
                oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
                break;
            case "google":
                log.info("google 로그인");
                oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
                break;
            default:
                log.error("로그인 실패: 지원하지 않는 로그인 제공자입니다. 등록 ID: {}", registrationId);
                throw new IllegalArgumentException("지원하지 않는 로그인 제공자입니다.");
        }

        String provider = oAuth2Response.getProvider();
        String providerId = oAuth2Response.getProviderId();
        String username = provider + " " + providerId; // 유저명 생성 (OAuth2 공급자명 + 공급자ID)

        Optional<User> userOptional = userRepository.findByUsername(username);

        if (userOptional.isPresent()) {
            // 기존 유저가 이미 존재하는 경우, 추가 작업 없이 바로 반환
            log.info("기존 유저 로그인: {}", username);
            return new CustomOAuth2User(oAuth2Response, userOptional.get().getRoles().stream().map(Role::getName).collect(Collectors.joining(",")));
        }

        // 새로운 유저에 대한 처리
        String roleName = "ROLE_USER";
        Optional<Role> roleOptional = roleRepository.findByName(roleName);
        Role role;
        if (roleOptional.isEmpty()) {
            role = new Role(roleName);
            role = roleRepository.save(role);
        } else {
            role = roleOptional.get();
        }

        User newUser = User.builder()
                .name(oAuth2Response.getName())
                .username(username)
                .roles(Set.of(role))
                .providerId(oAuth2Response.getProviderId())
                .provider(oAuth2Response.getProvider())
                .password("")
                // 마이페이지에서 직접 설정할 필드들
                .phoneNumber("01000000000")
                .birthdate(LocalDate.from(LocalDateTime.now()))
                .gender("여자")
                .registrationDate(LocalDateTime.now())
                .usernick(oAuth2Response.getEmail())
                .build();
        userRepository.save(newUser);

        log.info("새로운 유저 생성: {}", username);

        return new CustomOAuth2User(oAuth2Response, roleName);
    }
}
  • Spring Security의 OAuth2 인증을 처리하는 커스텀 서비스이다.
  • 주로 네이버, 카카오, 구글 등의 OAuth2 제공자에서 사용자 인증 정보를 받아와 데이터베이스에 저장하거나 업데이트하는 역할을 한다.
  • loadUser는 네이버나 카카오의 사용자 인증 정보를 받아오는 메서드이다.
  • 외부 사이트로부터 사용자 정보를 받아오고 그 값을 디비에 저장하는 클래스이다.

 

 

 

3. 성공 핸들러

@Component
@Slf4j
@RequiredArgsConstructor
public class CustomSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private final JwtTokenizer jwtTokenizer;
    private final RefreshTokenService refreshTokenService;
    private final UserRepository userRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        try {
            CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal();

            String username = customUserDetails.getUsername();
            Optional<User> user = userRepository.findByUsername(username);

            if (user.isEmpty()) {
                System.out.println("유저기ㅏ 없어요");
                throw new UsernameNotFoundException("User not found with username: " + username);
            }
            Long userId = user.get().getId();
            System.out.println("userId :: " + userId);
            String name = customUserDetails.getName();
            List<String> roles = customUserDetails.getRoles();

            log.info("Oauth2 로그인 성곻했습니다. ");
            log.info("jwt 토큰 생성 :: userId: {}, username: {}, name: {}, roles: {}", userId, username, name, roles);

            String accessToken = jwtTokenizer.createAccessToken(userId, username, name, roles);
            String refreshToken = jwtTokenizer.createRefreshToken(userId, username, name, roles);

            log.info("Access Token :: {}", accessToken);
            log.info("Refresh Token :: {}", refreshToken);

            // 쿠키에 토큰 저장
            Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
            accessTokenCookie.setHttpOnly(true);
            accessTokenCookie.setPath("/");
            accessTokenCookie.setMaxAge(Math.toIntExact(jwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT / 1000));

            Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
            refreshTokenCookie.setHttpOnly(true);
            refreshTokenCookie.setPath("/");
            refreshTokenCookie.setMaxAge(Math.toIntExact(jwtTokenizer.REFRESH_TOKEN_EXPIRE_COUNT / 1000));

            response.addCookie(accessTokenCookie);
            response.addCookie(refreshTokenCookie);

            // 리프레시 토큰 DB 저장
            Date date = new Date(System.currentTimeMillis() + jwtTokenizer.REFRESH_TOKEN_EXPIRE_COUNT);
            RefreshToken refreshTokenEntity = new RefreshToken();
            refreshTokenEntity.setValue(refreshToken);
            refreshTokenEntity.setUserId(userId);
            refreshTokenEntity.setExpiration(date.toString());

            refreshTokenService.addRefreshToken(refreshTokenEntity);

            // 추가 정보가 없을 때만 oauthPage로 리다이렉트
            if (user.get().getPhoneNumber().equals("01000000000")) {
                response.sendRedirect("/oauthPage");
            } else {
                response.sendRedirect("/my"); // 이미 추가 정보가 있을 경우 메인 페이지로 리다이렉트
            }

        } catch (Exception e) {
            log.error("Oauth2 로그인에 실패했습니다.", e);
            if (!response.isCommitted()) {
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "An error occurred during authentication");
            }
        }
    }
}
  • 나머지는 기존 세션 방식의 소셜로그인과 동일하다면 jwt 토큰 발급에서는 이 부분이 필요하다.
  • 로그인에 성공하면 jwt 토큰을 발급해줘야 하기 때문이다.

 

 

4.SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final JwtBlacklistService jwtBlacklistService;
    private final RefreshTokenService refreshTokenService;
    private final JwtTokenizer jwtTokenizer;
    private final CustomOauth2UserService customOAuth2UserService;
    private final CustomSuccessHandler customSuccessHandler;

    // 모든 유저 허용 페이지
    String[] allAllowPage = new String[] {
            "/",        // 메인페이지
            "/signup", // 회원가입 페이지
            "/signin", // 로그인 페이지
            "/css/**", "/js/**", "/files/**", // css, js, 이미지 url
            "/api/users/login", // 로그인 페이지
            "/api/users/signup", // 회원가입 페이지
            "/api/users/mail","/api/users/verify-code", "/api/users/check-email","/api/users/check-usernick", // 인증 메일 페이지
            "/oauth2/**", "/login/oauth2/**", // OAuth2 로그인 허용
            "/api/users/randomNickname", // 랜덤 닉네임 생성
            "/api/users/reset-password", "/api/users/verify-temporary-password", "/my/change-password" // 임시 비밀번호 발급
    };

    // 관리자 페이지
    String[] adminAllowPage = new String[] {
            "/admin",
            "/admin/**"
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers(allAllowPage).permitAll()
                        .requestMatchers(adminAllowPage).hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new JWTFilter(jwtTokenizer, jwtBlacklistService, refreshTokenService), UsernamePasswordAuthenticationFilter.class) // JWT 필터 사용
                .formLogin(form -> form.disable()) // 로그인 폼 비활성화
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 관리 Stateless 설정(서버가 클라이언트 상태 저장x)
                .csrf(csrf -> csrf.disable()) // cors 허용
                .httpBasic(httpBasic -> httpBasic.disable()) // http 기본 인증(헤더) 비활성화
                .cors(cors -> cors.configurationSource(configurationSource()))
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint(customAuthenticationEntryPoint)
                )
                .oauth2Login(oauth2 -> oauth2
                        .loginPage("/signin")
                        .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOAuth2UserService))
                        .successHandler(customSuccessHandler)
                        .failureUrl("/loginFailure")
                        .authorizationEndpoint(authorization -> authorization.baseUri("/oauth2/authorization"))
                        .permitAll()
                );
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public CorsConfigurationSource configurationSource(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOriginPattern("*"); // 모든 도메인 허용
        config.addAllowedOrigin("http://localhost:3000"); // 프론트의 주소
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowedMethods(List.of("GET", "POST", "DELETE"));
        config.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization"));
        config.setAllowCredentials(true);
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}
  • OAuth2 설정을 해준다.

그럼 JWT + OAuth2 + Security 에 대한 인증/인가는 끝이 났다.

728x90
728x90

개요

이번에는 JWT 인증 인가 부분을 구현할 예정이다.


 

 

 

1. JWT 생성 / 검증 과정에 해당하는 유틸리티 클래스

/**
 * JWT 생성 / 검증 과정에 해당하는 유틸리티 클래스
 */
@Component
@Slf4j
@Getter
public class JwtTokenizer {
    private final byte[] accessSecret;
    private final byte[] refreshSecret;

    public static Long ACCESS_TOKEN_EXPIRE_COUNT = 30 * 60 * 1000L; // 30분
    public static Long REFRESH_TOKEN_EXPIRE_COUNT = 7 * 24 * 60 * 60 * 1000L; // 7일

    // .yml 파일에 secretKey, refreshKey 값이 있어야 한다.
    public JwtTokenizer(@Value("${jwt.secretKey}") String accessSecret,
                        @Value("${jwt.refreshKey}") String refreshSecret){
        this.accessSecret = accessSecret.getBytes(StandardCharsets.UTF_8);
        this.refreshSecret = refreshSecret.getBytes(StandardCharsets.UTF_8);
    }

    /**
     * AccessToken 생성
     */
    public String createAccessToken(Long id, String username, String name, List<String> roles) {
        return createToken(id, username, name, roles, ACCESS_TOKEN_EXPIRE_COUNT, accessSecret);
    }

    /**
     * RefreshToken 생성
     */
    public String createRefreshToken(Long id, String username, String name, List<String> roles) {
        return createToken(id, username, name, roles, REFRESH_TOKEN_EXPIRE_COUNT, refreshSecret);
    }

    /**
     * Jwts 빌더를 사용하여 token 생성
     */
    private String createToken(Long id, String username, String name, List<String> roles, long expireCount, byte[] secret) {
        Claims claims = Jwts.claims().setSubject(username); // 기본으로 가지고 있는 claim : subject
        claims.put("userId", id);
        claims.put("name", name);
        claims.put("roles", roles);

        Date now = new Date();
        Date expiration = new Date(now.getTime() + expireCount);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expiration)
                .signWith(SignatureAlgorithm.HS256, getSigningKey(secret))
                .compact();
    }

    //----------------------------------------------------------------------------------추가-------------------------------//
    /**
     * JWT 토큰에서 사용자 ID 추출
     * @param token JWT 토큰
     * @return 사용자 ID
     */
    public Long getUserIdFromToken(String token) {
        Claims claims = parseToken(token, accessSecret);
        return claims.get("userId", Long.class);
    }

    /**
     * JWT 토큰에서 사용자 이름 추출
     * @param token JWT 토큰
     * @return 사용자 이름
     */
    public String getUsernameFromToken(String token) {
        Claims claims = parseToken(token, accessSecret);
        return claims.getSubject();
    }

    /**
     * JWT 토큰에서 역할 정보 추출
     * @param token JWT 토큰
     * @return 역할 목록
     */
    public List<String> getRolesFromToken(String token) {
        Claims claims = parseToken(token, accessSecret);
        return claims.get("roles", List.class);
    }

//---------------------------------------------------------------------------------------------------------------------//
    /**
     * access token 파싱
     * @param accessToken access token
     * @return 파싱된 토큰
     */
    public Claims parseAccessToken(String accessToken) {
        return parseToken(accessToken, accessSecret);
    }

    /**
     * refresh token 파싱
     * @param refreshToken refresh token
     * @return 파싱된 토큰
     */
    public Claims parseRefreshToken(String refreshToken) {
        return parseToken(refreshToken, refreshSecret);
    }

    /**
     * token 파싱
     * @param token access/refresh token
     * @param secretKey access/refresh 비밀키
     * @return 파싱된 토큰
     */
    public Claims parseToken(String token, byte[] secretKey) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey(secretKey))
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * @param secretKey - byte형식
     * @return Key 형식 시크릿 키
     */
    public static Key getSigningKey(byte[] secretKey) {
        return Keys.hmacShaKeyFor(secretKey);
    }

    /**
     * 토큰이 만료되었는지 확인하는 메서드
     */
    public boolean isTokenExpired(String token, byte[] secretKey) {
        try {
            Jwts.parserBuilder().setSigningKey(getSigningKey(secretKey)).build().parseClaimsJws(token).getBody();
            return false;
        } catch (io.jsonwebtoken.ExpiredJwtException e) {
            return true;
        }
    }

    /**
     * 액세스 토큰이 만료되었는지 확인
     */
    public boolean isAccessTokenExpired(String accessToken) {
        return isTokenExpired(accessToken, accessSecret);
    }

    /**
     * 리프레시 토큰이 만료되었는지 확인
     */
    public boolean isRefreshTokenExpired(String refreshToken) {
        return isTokenExpired(refreshToken, refreshSecret);
    }

    /**
     * 리프레시 토큰으로 새로운 액세스 토큰을 발급
     */
    public String refreshAccessToken(String refreshToken) {
        Claims claims = parseRefreshToken(refreshToken);
        Long userId = claims.get("userId", Long.class);

        String username = claims.getSubject(); // 토큰의 주체
        String name = claims.get("name", String.class);
        List<String> roles = (List<String>) claims.get("roles");
        return createAccessToken(userId, username, name, roles);
    }
}

로그인할 때 필요한 username으로 sub 만들었고, userId, name, roles을 페이로드에 추가하였다.

 

 

2. JWT 인증 토큰 클래스

/**
 * JWT 인증 토큰 클래스
 */
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
    private String token;       // JWT 토큰
    private Object principal;   // 주체 (사용자)
    private Object credentials; // 인증 정보 (자격 증명)

    /**
     * 인증된 토큰을 생성하는 생성자
     * @param authorities   권한
     * @param principal     주체 (사용자)
     * @param credentials   인증 정보 (자격 증명)
     */
    public JwtAuthenticationToken(Collection<? extends GrantedAuthority> authorities , Object principal, Object credentials) {
        super(authorities);
        this.principal = principal;
        this.credentials = credentials;
        this.setAuthenticated(true); // 토큰을 인증된 상태로 설정
    }

    /**
     * 인증되지 않은 토큰을 생성하는 생성자
     * @param token JWT 토큰
     */
    public JwtAuthenticationToken(String token){
        super(null);
        this.token = token;
        this.setAuthenticated(false); // 토큰을 인증되지 않은 상태로 설정
    }

    /**
     * 자격 증명을 반환
     * @return 자격 증명
     */
    @Override
    public Object getCredentials() {
        return this.credentials;
    }

    /**
     * 주체 (사용자)를 반환
     * @return 주체 (사용자)
     */
    @Override
    public Object getPrincipal() {
        return this.principal;
    }
}

 

 

3. 오류 코드

@Getter
public enum JwtExceptionCode {

    UNKNOWN_ERROR("UNKNOWN_ERROR", "UNKNOWN_ERROR"),
    NOT_FOUND_TOKEN("NOT_FOUND_TOKEN", "Cookie에 토큰 형식의 값 찾을 수 없음"),
    INVALID_TOKEN("INVALID_TOKEN", "유효하지 않은 토큰"),
    EXPIRED_TOKEN("EXPIRED_TOKEN", "기간이 만료된 토큰"),
    UNSUPPORTED_TOKEN("UNSUPPORTED_TOKEN", "지원하지 않는 토큰"),
    BLACKLISTED_TOKEN("BLACKLISTED_TOKEN", "로그아웃하여 블랙리스트에 올라간 토큰");

    private String code;
    private String message;

    JwtExceptionCode(String code, String message) {
        this.code = code;
        this.message = message;
    }
}
/**
 * 시큐리티가 인증되지 않은 사용자가 보호된 리소스에 접근할 때 동작하는 클래스
 */
@Component
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        String exception = (String)request.getAttribute("exception");

        if(isRestRequest(request)){
            handleRestResponse(request, response, exception);
        } else {
            handlePageResponse(request, response, exception);
        }
    }

    private void handlePageResponse(HttpServletRequest request, HttpServletResponse response, String exception) throws IOException {
        if (exception != null) {
            switch (exception) {
                case "INVALID_TOKEN":
                    response.sendRedirect("/");
                    break;
                case "EXPIRED_TOKEN":
                    response.sendRedirect("/");
                    break;
                case "UNSUPPORTED_TOKEN":
                    response.sendRedirect("/");
                    break;
                case "NOT_FOUND_TOKEN":
                    response.sendRedirect("/");
                    break;
                default:
                    response.sendRedirect("/");
                    break;
            }
            return;
        }
        response.sendRedirect("/");
    }

    private boolean isRestRequest(HttpServletRequest request) {
        String requestedWithHeader = request.getHeader("X-Requested-With");
        return "XMLHttpRequest".equals(requestedWithHeader) || request.getRequestURI().startsWith("/api/");
    }

    private void handleRestResponse(HttpServletRequest request, HttpServletResponse response, String exception) throws IOException {

        if (exception != null) {
            log.error("Rest Request - Commence Get Exception : {}", exception);
            if (exception.equals(JwtExceptionCode.INVALID_TOKEN.getCode())) {
                log.error("entry point >> invalid token");
                setResponse(response, JwtExceptionCode.INVALID_TOKEN);
            } else if (exception.equals(JwtExceptionCode.EXPIRED_TOKEN.getCode())) {
                log.error("entry point >> expired token");
                setResponse(response, JwtExceptionCode.EXPIRED_TOKEN);
            } else if (exception.equals(JwtExceptionCode.UNSUPPORTED_TOKEN.getCode())) {
                log.error("entry point >> unsupported token");
                setResponse(response, JwtExceptionCode.UNSUPPORTED_TOKEN);
            } else if (exception.equals(JwtExceptionCode.NOT_FOUND_TOKEN.getCode())) {
                log.error("entry point >> not found token");
                setResponse(response, JwtExceptionCode.NOT_FOUND_TOKEN);
            } else {
                setResponse(response, JwtExceptionCode.UNKNOWN_ERROR);
            }
        } else {
            setResponse(response, JwtExceptionCode.UNKNOWN_ERROR);
        }
        response.sendRedirect("/");
    }

    private void setResponse(HttpServletResponse response, JwtExceptionCode exceptionCode) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

        HashMap<String, Object> errorInfo = new HashMap<>();
        errorInfo.put("message", exceptionCode.getMessage());
        errorInfo.put("code", exceptionCode.getCode());
        Gson gson = new Gson();
        String responseJson = gson.toJson(errorInfo);
        response.getWriter().print(responseJson);
    }
}

인증되지 않은 접근일 경우 CustomAuthenticationEntryPoint 를 작성하여 오류에 대해서 다뤘다.

 

 

 

4. 필터

/**
 * 요청이 들어올 때마다 JWT 토큰을 검증하는 필터
 * 토큰을 검증하고 유효한 사용자라면 그 사용자의 정보를 SecurityContextHolder 에 설정
 */
@Slf4j
public class JWTFilter extends OncePerRequestFilter {

    private final JwtTokenizer jwtTokenizer;
    private final JwtBlacklistService jwtBlacklistService;
    private final RefreshTokenService refreshTokenService;

    public JWTFilter(JwtTokenizer jwtTokenizer, JwtBlacklistService jwtBlacklistService, RefreshTokenService refreshTokenService) {
        this.jwtTokenizer = jwtTokenizer;
        this.jwtBlacklistService = jwtBlacklistService;
        this.refreshTokenService = refreshTokenService;
    }

    /**
     * 접근 허용 앤드 포인트 목록
     */
    private static final List<String> PERMIT_ALL_PATHS = List.of(
            "/signup", "/signin", "/", "/api/users/login", "/api/users/signup",
            "/css/.*", "/js/.*", "/files/.*"
    );

    /**
     * 필터 메서드
     * 각 요청마다 JWT 토큰을 검증하고 인증을 설정
     * @param request       요청 객체
     * @param response      응답 객체
     * @param filterChain   필터 체인
     * @throws ServletException 서블릿 예외
     * @throws IOException      입출력 예외
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String requestPath = request.getRequestURI(); // 요청 경로 확인
        if (isPermitAllPath(requestPath)) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = getToken(request); // 요청에서 토큰을 추출

        if (!StringUtils.hasText(token)) { // 토큰을 사용하여 인증 설정
            handleMissingToken(request, response);
        } else {
            handleTokenValidation(request, response, token);
        }
        filterChain.doFilter(request, response); // 다음 필터로 요청을 전달
        // log.info("토큰 제대로?? : {}", token);
    }

    /**
     *  요청 경로가 인증 없이 접근 가능한지 확인
     */
    private boolean isPermitAllPath(String requestPath) {
        return PERMIT_ALL_PATHS.stream().anyMatch(requestPath::matches);
    }

    /**
     * Access Token이 없는 경우 처리하는 메서드
     */
    private void handleMissingToken(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        // 쿠키에서 Refresh Token을 얻어옴
        String refreshToken = getRefreshToken(request);
        if (StringUtils.hasText(refreshToken)) {
            try {
                // Refresh Token이 DB에 존재하는지 확인
                if (refreshTokenService.isRefreshTokenValid(refreshToken)) {
                    if (!jwtTokenizer.isRefreshTokenExpired(refreshToken)) {
                        String newAccessToken = jwtTokenizer.refreshAccessToken(refreshToken);
                        setAccessTokenCookie(response, newAccessToken);
                        getAuthentication(newAccessToken);
                    } else {
                        // Refresh Token이 만료된 경우
                        handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Refresh token expired");
                    }
                } else {
                    // Refresh Token이 DB에 없는 경우
                    handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Refresh token not found in database");
                }
            } catch (ExpiredJwtException e) {
                // Refresh Token이 만료된 경우
                handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired refresh token", e);
            }
        } else {
            handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found in request");
        }
    }

    /**
     * Access Token이 있는 경우 처리하는 메서드
     */
    private void handleTokenValidation(HttpServletRequest request, HttpServletResponse response, String token) throws ServletException, IOException {
        try {
            // 토큰이 블랙리스트에 있는지 확인
            if (jwtBlacklistService.isTokenBlacklisted(token)) {
                handleException(request, JwtExceptionCode.BLACKLISTED_TOKEN, "Token is blacklisted: " + token);
            } else {
                getAuthentication(token);
            }
        } catch (ExpiredJwtException e) {
            handleExpiredAccessToken(request, response, token, e);
        } catch (UnsupportedJwtException e) {
            handleException(request, JwtExceptionCode.UNSUPPORTED_TOKEN, "Unsupported token: " + token, e);
        } catch (MalformedJwtException e) {
            handleException(request, JwtExceptionCode.INVALID_TOKEN, "Invalid token: " + token, e);
        } catch (IllegalArgumentException e) {
            handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found: " + token, e);
        } catch (Exception e) {
            handleException(request, JwtExceptionCode.UNKNOWN_ERROR, "JWT filter internal error: " + token, e);
        }
    }

    /**
     * Access Token이 만료된 경우 처리하는 메서드
     */
    private void handleExpiredAccessToken(HttpServletRequest request, HttpServletResponse response, String token, ExpiredJwtException e) throws ServletException, IOException {
        log.warn("Access token expired: {}", token);
        String refreshToken = getRefreshToken(request);
        if (StringUtils.hasText(refreshToken) && !jwtTokenizer.isRefreshTokenExpired(refreshToken)) {
            // Refresh Token이 유효한 경우 새로운 Access Token 발급
            String newAccessToken = jwtTokenizer.refreshAccessToken(refreshToken);

            setAccessTokenCookie(response, newAccessToken);

            getAuthentication(newAccessToken);
        } else {
            handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired Token : " + token, e);
        }
    }

    /**
     * 요청에서 토큰 추출
     */
    private String getToken(HttpServletRequest request) {

        String authorization = request.getHeader("Authorization");
        if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
            return authorization.substring(7);
        }

        Cookie[] cookies = request.getCookies(); // 쿠키에서 토큰을 찾음
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("accessToken".equals(cookie.getName())) {
                    return cookie.getValue(); // accessToken 쿠키에서 토큰 반환
                }
            }
        }
        return null; // 토큰을 찾지 못한 경우 null 반환
    }

    /**
     * 쿠키에서 Refresh Token을 추출하는 메서드
     */
    private String getRefreshToken(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies != null) {
            for (Cookie cookie : cookies) {
                if ("refreshToken".equals(cookie.getName())) {
                    return cookie.getValue();
                }
            }
        }
        return null;
    }

    /**
     * 새로운 Access Token을 쿠키에 설정하는 메서드
     */
    private void setAccessTokenCookie(HttpServletResponse response, String newAccessToken) {
        Cookie accessTokenCookie = new Cookie("accessToken", newAccessToken);
        accessTokenCookie.setHttpOnly(true); // XSS 보호를 위해 HttpOnly 설정
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(Math.toIntExact(jwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT / 1000));
        response.addCookie(accessTokenCookie);
    }

    /**
     * 토큰을 사용하여 인증 설정
     */
    private void getAuthentication(String token) {
        Claims claims = jwtTokenizer.parseAccessToken(token); // 토큰에서 클레임을 파싱
        String username = claims.getSubject(); // 이메일을 가져옴
        Long userId = claims.get("userId", Long.class);  // 사용자 ID를 가져옴
        String name = claims.get("name", String.class); // 이름을 가져옴
        List<GrantedAuthority> authorities = getGrantedAuthorities(claims); // 사용자 권한을 가져옴

        CustomUserDetails userDetails = new CustomUserDetails(username, "", authorities);

        Authentication authentication = new JwtAuthenticationToken(authorities, userDetails, null); // 인증 객체 생성

        SecurityContextHolder.getContext().setAuthentication(authentication); // SecurityContextHolder에 인증 객체 설정

    }

    /**
     * JWT Claims에서 권한 정보를 추출하는 메서드
     */
    private List<GrantedAuthority> getGrantedAuthorities(Claims claims) {
        List<String> roles = claims.get("roles", List.class);
        return roles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toList());
    }

    /**
     * 예외 처리 메서드
     */
    private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage) {
        handleException(request, exceptionCode, logMessage, null);
    }

    private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage, Exception e) {
        request.setAttribute("exception", exceptionCode.getCode());
        log.error(logMessage, e);
//        throw new BadCredentialsException(logMessage, e);
        log.error("로그인을 부탁드립니다.");
    }
}

로그인이 들어올 때 기존 시큐리티 로그인 처리 필터인 UsernamePasswordAuthenticationFilter 이전에 실행되어서 토큰을 발급한 후 로그인 처리를 진행한다.

 

 

5. SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
    private final JwtBlacklistService jwtBlacklistService;
    private final RefreshTokenService refreshTokenService;
    private final JwtTokenizer jwtTokenizer;
    private final CustomOauth2UserService customOAuth2UserService;
    private final CustomSuccessHandler customSuccessHandler;

    // 모든 유저 허용 페이지
    String[] allAllowPage = new String[] {
            "/",        // 메인페이지
            "/signup", // 회원가입 페이지
            "/signin", // 로그인 페이지
            "/css/**", "/js/**", "/files/**", // css, js, 이미지 url
            "/api/users/login", // 로그인 페이지
            "/api/users/signup" // 회원가입 페이지
    };

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeRequests(authorize -> authorize
                        .requestMatchers(allAllowPage).permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new JWTFilter(jwtTokenizer, jwtBlacklistService, refreshTokenService), UsernamePasswordAuthenticationFilter.class) // JWT 필터 사용
                .formLogin(form -> form.disable()) // 로그인 폼 비활성화
                .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 관리 Stateless 설정(서버가 클라이언트 상태 저장x)
                .csrf(csrf -> csrf.disable()) // cors 허용
                .httpBasic(httpBasic -> httpBasic.disable()) // http 기본 인증(헤더) 비활성화
                .cors(cors -> cors.configurationSource(configurationSource()))
                .exceptionHandling(exception -> exception
                        .authenticationEntryPoint(customAuthenticationEntryPoint)
                )
                .oauth2Login(oauth2 -> oauth2
                        .loginPage("/signin")
                        .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig.userService(customOAuth2UserService))
                        .successHandler(customSuccessHandler)
                        .failureUrl("/loginFailure")
                        .authorizationEndpoint(authorization -> authorization.baseUri("/oauth2/authorization"))
                        .permitAll()
                );
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

    @Bean
    public CorsConfigurationSource configurationSource(){
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.addAllowedOriginPattern("*"); // 모든 도메인 허용
        config.addAllowedOrigin("http://localhost:3000"); // 프론트의 주소
        config.addAllowedOrigin("*");
        config.addAllowedHeader("*");
        config.addAllowedMethod("*");
        config.setAllowedMethods(List.of("GET", "POST", "DELETE"));
        config.setExposedHeaders(Arrays.asList("Set-Cookie", "Authorization"));
        config.setAllowCredentials(true);
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

위 코드에서도 보듯이 UsernamePasswordAuthenticationFilter 이전에 우리가 커스텀한 jwt 필터를 추가해줬다. 그리고 exceptionhandling에 대해서도 위에서 정의한 cusstomAuthenticationEntryPoint 를 지정했다.

 

 

6. UserDetails

/**
 * 사용자 인증 및 권한 관리를 위한 사용자 세부 정보 클래스
 */
public class CustomUserDetails implements UserDetails {

    private final String username; // 사용자 아이디
    private final String password; // 비밀번호
    private final Collection<? extends GrantedAuthority> authorities; // 권환

    // DB 에서 사용자 인증/인가
    public CustomUserDetails(String username, String password, Set<Role> roles) {
        this.username = username;
        this.password = password;
        this.authorities = roles.stream()
                .map(role -> new SimpleGrantedAuthority(role.getName()))
                .collect(Collectors.toList());
    }

    // jwt 인증 생성자
    public CustomUserDetails(String username, String password, Collection<? extends GrantedAuthority> authorities) {
        this.username = username;
        this.password = password;
        this.authorities = authorities;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities;
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

Spring Security는 이 클래스를 사용하여 사용자 인증 및 권한 부여를 처리한다. 그래서 jwt 로그인 진행 시 인증 설정을 하고 securityContextHolder에 저장한다.

 

 

7. APIController

@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/api/users")
public class UserApiController {

    private final JwtTokenizer jwtTokenizer;
    private final UserService userService;
    private final PasswordEncoder passwordEncoder;
    private final RefreshTokenService refreshTokenService;
    private final JwtBlacklistService jwtBlackListService;

    /**
     * 로그인 요청 시 jwt 토큰 발급
     */
    @PostMapping("/login")
    public ResponseEntity login(@RequestBody @Valid UserLoginDto userLoginDto,
                                BindingResult bindingResult,
                                HttpServletResponse response){
        log.info("로그인 요청이 들어왔습니다.");
        log.info("아이디    :: {}", userLoginDto.getUsername());
        log.info("비밀번호 :: {}", userLoginDto.getPassword());

        if(bindingResult.hasErrors()){ // 필드 에러 확인
            return new ResponseEntity(HttpStatus.BAD_REQUEST);
        }

        Optional<User> user = userService.findByUsername(userLoginDto.getUsername());

        // 비밀번호 일치여부 체크
        if(!passwordEncoder.matches(userLoginDto.getPassword(), user.get().getPassword())) {
            return new ResponseEntity("비밀번호가 올바르지 않습니다.",HttpStatus.UNAUTHORIZED);
        }

        List<String> roles = user.get().getRoles().stream().map(Role::getName).collect(Collectors.toList());

        refreshTokenService.deleteRefreshToken(String.valueOf(user.get().getId()));

        // 토큰 발급
        String accessToken = jwtTokenizer.createAccessToken(user.get().getId(), user.get().getUsername(), user.get().getName(),  roles);
        String refreshToken = jwtTokenizer.createRefreshToken(user.get().getId(), user.get().getUsername(), user.get().getName(), roles);

        // 리프레시 토큰 디비 저장
        Date date = new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRE_COUNT);
        RefreshToken refreshTokenEntity = new RefreshToken();
        refreshTokenEntity.setValue(refreshToken);
        refreshTokenEntity.setUserId(user.get().getId());
        refreshTokenEntity.setExpiration(date.toString());

        refreshTokenService.addRefreshToken(refreshTokenEntity);

        // 토큰 쿠키 저장
        Cookie accessTokenCookie = new Cookie("accessToken",accessToken);
        accessTokenCookie.setHttpOnly(true);
        accessTokenCookie.setPath("/");
        accessTokenCookie.setMaxAge(Math.toIntExact(jwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT/1000));

        Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
        refreshTokenCookie.setHttpOnly(true);
        refreshTokenCookie.setPath("/");
        refreshTokenCookie.setMaxAge(Math.toIntExact(REFRESH_TOKEN_EXPIRE_COUNT/1000));

        response.addCookie(accessTokenCookie);
        response.addCookie(refreshTokenCookie);

        // 응답 값
        UserLoginResponseDto loginResponseDto = UserLoginResponseDto.builder()
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .userId(user.get().getId())
                .username(user.get().getUsername())
                .build();

        return new ResponseEntity(loginResponseDto, HttpStatus.OK);
    }

    /**
     * 로그아웃 요청
     */
    @GetMapping("/logout")
    public void logout(@CookieValue(name = "accessToken", required = false) String accessToken,
                       @CookieValue(name = "refreshToken", required = false) String refreshToken,
                       HttpServletResponse response) {
        log.info("로그아웃 요청이 들어왔습니다.");
        if (accessToken == null) {

            try {
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                response.getWriter().write("Access token not found in cookies.");
            } catch (IOException e) {
                e.printStackTrace();
            }
            return;
        }

        String jwt = accessToken;

        Date expirationTime = Jwts.parser()
                .setSigningKey(jwtTokenizer.getAccessSecret())
                .parseClaimsJws(jwt)
                .getBody()
                .getExpiration();

        log.info("accessToken 만료시간 :: {}" , expirationTime);

        JwtBlacklist blacklist = new JwtBlacklist(jwt, expirationTime);
        jwtBlackListService.save(blacklist);

        SecurityContextHolder.clearContext();

        Cookie accessCookie = new Cookie("accessToken", null);
        accessCookie.setPath("/");
        accessCookie.setMaxAge(0);
        response.addCookie(accessCookie);

        Cookie refresCcookie = new Cookie("refreshToken", null);
        refresCcookie.setPath("/");
        refresCcookie.setMaxAge(0);
        response.addCookie(refresCcookie);

        // tokens 데이터 삭제
        refreshTokenService.deleteRefreshToken(refreshToken);

        try {
            response.sendRedirect("/");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    /**
     * 회원가입
     */
    @PostMapping("/signup")
    public ResponseEntity<String> signup(@RequestBody UserSignUpDto userSignUpDto) {
        try {
            userService.signUp(userSignUpDto);
            return ResponseEntity.status(HttpStatus.CREATED).body("회원가입 성공");
        } catch (RuntimeException e) {
            log.error("회원가입 실패 :: {}", e.getMessage());
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("회원가입 실패 :: " + e.getMessage());
        }
    }

    /**
     * 회원 탈퇴
     */
    @DeleteMapping("/{userId}")
    public ResponseEntity<String> deleteUser(@PathVariable("userId") Long userId,
                                             @CookieValue(name = "accessToken", required = false) String accessToken,
                                             @CookieValue(name = "refreshToken", required = false) String refreshToken,
                                             HttpServletResponse response,
                                             Authentication authentication) {
        String username = authentication.getName();
        try {
            userService.deleteUser(username);

            // 로그아웃 로직
            if (accessToken != null) {
                // JWT 토큰 추출
                String jwt = accessToken;

                // 토큰의 만료 시간 추출
                Date expirationTime = Jwts.parser()
                        .setSigningKey(jwtTokenizer.getAccessSecret())
                        .parseClaimsJws(jwt)
                        .getBody()
                        .getExpiration();

                // 블랙리스트에 토큰 저장
                JwtBlacklist blacklist = new JwtBlacklist(jwt, expirationTime);
                jwtBlackListService.save(blacklist);
            }

            // SecurityContext를 클리어하여 현재 세션을 무효화
            SecurityContextHolder.clearContext();

            // accessToken 쿠키 삭제
            Cookie accessCookie = new Cookie("accessToken", null);
            accessCookie.setPath("/");
            accessCookie.setMaxAge(0);
            response.addCookie(accessCookie);

            // refreshToken 쿠키 삭제
            Cookie refreshCookie = new Cookie("refreshToken", null);
            refreshCookie.setPath("/");
            refreshCookie.setMaxAge(0);
            response.addCookie(refreshCookie);

            // 로그아웃 전 db에 저장되어있는 refreshToken 삭제
            if (refreshToken != null) {
                refreshTokenService.deleteRefreshToken(refreshToken);
            }
            return ResponseEntity.ok("회원 탈퇴 성공 !!");
        } catch (Exception e) {
            e.printStackTrace();
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body("회원 탈퇴 실패 !!");
        }
    }
}

로그인, 로그아웃, 회원탈퇴 api요청이다. 유저가 로그인 요청을 받으면 엑세스, 리프레시 토큰이 생성되고 쿠키에 저장하는 로직이다. 

 

 

다음은 OAuth2 로그인에 대해서 작성하겠다.

728x90
728x90

 

개요

이번에는 인증/인가 총 정리다. 

OAuth2 로그인과 JWT를 활용한 보안 방식을 정리할 예정이다.

다른 포스터에 이미 올렸지만 정리를 위해 처음부터 정리할 예정

총 몇편으로 나올지 궁금하다.

 


일단 jwt 방식을 위한 기본 설정을 한다. OAuth2 설정도 같이 하면 복잡해져서 뒤에서 정리할 예정이다.

 

 

1. 엔티티 설정

  • 유저 엔티티
@Entity
@Table(name = "users")
@Getter @Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class User {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String username; // 이메일

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String name; // 본명

    @Column(nullable = false)
    private String usernick; // 닉네임

    @Column(nullable = false)
    private LocalDateTime registrationDate = LocalDateTime.now(); // 가입일

    @Column(nullable = false)
    private LocalDate birthdate; // 생일

    @Column(nullable = false)
    private String gender; // 성별

    @Column(nullable = false, length = 50)
    private String phoneNumber; // 연락처

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(name = "user_roles",
            joinColumns = @JoinColumn(name = "user_id"),
            inverseJoinColumns = @JoinColumn(name = "role_id"))
    private Set<Role> roles = new HashSet<>();

    private String provider; // oauth2 플랫폼
    private String providerId; // 플랫폼 아이디
}
  • 롤 엔티티
@Entity
@Table(name = "roles")
@Getter@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Role {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    public Role(String name) {
        this.name = name;
    }
}
  • 리프레시 토큰 엔티티
@Entity
@Table(name = "refresh_token")
@Getter@Setter
public class RefreshToken {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "value", length = 512)
    private String value;

    @Column(name = "user_id")
    private Long userId;

    @Column(name = "expiration_time")
    private String expiration;
}
  • 로그아웃 토큰 엔티티
@Entity
@Table(name = "jwt_blacklist")
@Getter@Setter
@NoArgsConstructor
public class JwtBlacklist {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "token", length = 512)
    private String token;

    @Column(name = "expiration_time")
    private Date expiration;

    public JwtBlacklist(String token, Date expiration) {
        this.token = token;
        this.expiration = expiration;
    }
}

 

 

2. 레포지토리 설정

  • 유저 레포지토리
@Repository
public interface UserRepository extends JpaRepository<User, Long> {

    Optional<User> findByUsername(String username); // 아이디 찾기

    boolean existsByUsernick(String userName); // 닉네임 중복 확인
    
    boolean existsByUsername(String email); // 이메일(아이디) 중복 확인
}
  • 롤 레포지토리
@Repository
public interface RoleRepository extends JpaRepository<Role, Long> {

    Optional<Role> findByName(String name);
}
  • 리프레시 토큰 레포지토리
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {

    Optional<RefreshToken> findByValue(String value);

    boolean existsByValue(String token);

    Optional<RefreshToken> findByUserId(Long userId);

    @Transactional
    void deleteByValue(String refresh);
}
  • 로그아웃 토큰 레포지토리
public interface JwtBlacklistRepository extends JpaRepository<JwtBlacklist, Long> {
    boolean existsByToken(String token);
}

 

 

 

 

 

3. DTO 설정

  • 회원가입 DTO
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserSingUpDto {

    private Long id;

    //@NotBlank(message = "아이디는 필수 입력 값입니다.")
    private String username; //이메일 ==> id
    private String name; // 사용자 이름

    //@NotBlank(message = "비밀번호는 필수 입력 값입니다.")
    //@Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,16}", message = "비밀번호는 8~16자 영문 대 소문자, 숫자, 특수문자를 사용하세요.")
    private String password;
    private String passwordCheck;

    //@Pattern(regexp = "^[ㄱ-ㅎ가-힣a-z0-9-_]{2,10}$", message = "닉네임은 특수문자를 제외한 2~10자리여야 합니다.")
    private String usernick;
    private LocalDate birthdate; // 생년월일
    private String gender;
    private String phoneNumber;
}
  • 유저 정보 수정 DTO
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserEditDto {

    private String usernick; // 닉네임
    private LocalDate birthdate; // 생년월일
    private String gender; // 성별
    private String phoneNumber; // 연락처
}
  • 유저 로그인 DTO
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginDto {
    @NotEmpty
    private String username;

    @NotEmpty
    private String password;
}
  • 로그인 응답 DTO
@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginResponseDto {
    private String accessToken;
    private String refreshToken;
    private Long userId; // private Long userId;
    private String username;
}

 

 

4. 서비스 설정

Service Layer Pattern 을 사용했다.
Service 계층의 인터페이스와 비즈니스 로직을 분리했다.

  • 유저서비스
@Service
public interface UserService {

    Optional<User> findById(Long id); // 유저 아이디 찾기

    void signUp(UserSingUpDto userSingUpDto); // 회원가입

    Optional<User> findByUsername(String email); // 아이디 찾기

    void deleteUser(String username); // 유저 삭제

    boolean existsByUsername(String username); // 아이디 중복 확인

    boolean existsByUsernick(String usernick); // 닉네임 중복 확인

    Optional<User> updateUser(String username, UserEditDto userEditDto); // 유저 정보 업데이트
}
  • 유저서비스 impl
@Service
@RequiredArgsConstructor
@Slf4j
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;
    private final RoleRepository roleRepository;

    @Override
    public Optional<User> findById(Long id) {
        return userRepository.findById(id);
    }

    @Override
    public Optional<User> findByUsername(String username) {
        return userRepository.findByUsername(username);
    }

    @Override
    public void signUp(UserSingUpDto userSingUpDto) {
        if (!userSingUpDto.getPassword().equals(userSingUpDto.getPasswordCheck())) {
            throw new RuntimeException("비밀번호가 다릅니다.");
        }
        if (userRepository.existsByUsername(userSingUpDto.getUsername())) {
            throw new RuntimeException("이메일이 존재합니다.");
        }
        if (userRepository.existsByUsernick(userSingUpDto.getUsernick())) {
            throw new RuntimeException("닉네임이 존재합니다.");
        }

        Role role = roleRepository.findByName("ROLE_USER")
                    .orElseThrow(() -> new RuntimeException("User 역할이 없습니다."));

        User user = new User();
        user.setRoles(Collections.singleton(role)); // 단일 역할
        user.setUsernick(userSingUpDto.getUsernick()); // 닉네임
        user.setUsername(userSingUpDto.getUsername()); // email
        user.setName(userSingUpDto.getName()); // 이름
        user.setPassword(passwordEncoder.encode(userSingUpDto.getPassword()));
        user.setRegistrationDate(LocalDateTime.now());
        user.setBirthdate(userSingUpDto.getBirthdate());
        user.setGender(userSingUpDto.getGender());
        user.setPhoneNumber(userSingUpDto.getPhoneNumber());

        userRepository.save(user);
    }

    @Override
    public void deleteUser(String username) {
        Optional<User> usernameOptional = userRepository.findByUsername(username);
        if (usernameOptional.isPresent()) {
            userRepository.delete(usernameOptional.get());
        } else {
            throw new RuntimeException("삭제할 사용자가 존재하지 않습니다.");
        }
    }
    
    /**
     * 사용자 페이지 수정
     */
    @Override
    public Optional<User> updateUser(String username, UserEditDto userEditDto) {

        Optional<User> userOptional = userRepository.findByUsername(username);
        if (userOptional.isEmpty()) {
            log.error("사용자 없습니다. :: {}", username);
            return Optional.empty();
        }

        User user = userOptional.get();

        user.setUsernick(userEditDto.getUsernick());
        user.setBirthdate(userEditDto.getBirthdate());
        user.setGender(userEditDto.getGender());
        user.setPhoneNumber(userEditDto.getPhoneNumber());

        User updatedUser = userRepository.save(user);
        return Optional.of(updatedUser);
    }


    @Override
    public boolean existsByUsername(String username) {
        return userRepository.existsByUsername(username);
    }

    @Override
    public boolean existsByUsernick(String usernick) {
        return userRepository.existsByUsernick(usernick);
    }
}
  • 리프레시토큰 서비스
public interface RefreshTokenService {

    RefreshToken addRefreshToken(RefreshToken refreshToken);
    
    void deleteRefreshToken(String refreshToken);

    boolean isRefreshTokenValid(String refreshToken);
}
  • 리프레시토큰 impl
@Service
@RequiredArgsConstructor
public class RefreshTokenServiceImpl implements RefreshTokenService {
    
    private final RefreshTokenRepository refreshTokenRepository;

    @Transactional
    @Override
    public RefreshToken addRefreshToken(RefreshToken refreshToken) {
        return refreshTokenRepository.save(refreshToken);
    }

    @Override
    public void deleteRefreshToken(String refreshToken) {
        refreshTokenRepository.findByValue(refreshToken).ifPresent(refreshTokenRepository::delete);
    }

    @Override
    public boolean isRefreshTokenValid(String refreshToken) {
        return refreshTokenRepository.existsByValue(refreshToken);
    }
}
  • 로그아웃토큰 서비스
public interface JwtBlacklistService {

    void save(JwtBlacklist blacklist);

    boolean isTokenBlacklisted(String token);
}
  • 로그아웃토큰 impl
@Service
@RequiredArgsConstructor
public class JwtBlacklistServiceImpl implements JwtBlacklistService {
    
    private final JwtBlacklistRepository jwtBlacklistRepository;

    @Override
    public void save(JwtBlacklist blacklist) {
        jwtBlacklistRepository.save(blacklist);
    }

    @Override
    public boolean isTokenBlacklisted(String token) {
        return jwtBlacklistRepository.existsByToken(token);
    }
}

 

 

기본적인 틀은 이렇게 잡고 간다.

728x90
728x90

개요

이번에는 구글 OAuth2 로그인에 대해서 작성해볼려고 한다. 

 

Spring Security + OAuth2 + Session 를 사용한 로그인, 로그아웃 구현 - Kakao, Naver :: 미정 (tistory.com)

 

Spring Security + OAuth2 + Session 를 사용한 로그인, 로그아웃 구현 - Kakao, Naver

개요시큐리티를 사용해 OAuth2 로그인을 진행하고자 한다. 나는 그중 카카오와 네이버에 대해 해보겠다.앞에서 진행한 코드를 그대로 사용해서 이어서 진행한다. Spring Security + Session 를 사용한

eesko.tistory.com

위 코드를 그대로 사용할 예정이다.

 

구글 소셜 로그인 신청

일단 구글 관련해서는 다루지 않을 것이고 이 부분은 다른 블로그를 찾아보면 된다.

 

 

나의 환경

Window 11

intelliJ

java 21 

spring Boot 3.3.0

spring Security 6

oauth2

 

 

프로젝트 파일 구조

 

의존성 설치

  • Build.gradle
	// OAuth2 Client
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
  • Build.gradle 에 security와 OAuth2 의존성을 추가해준다.

 

  • Application.yml
spring:
  application:
    name: [projectname]

  datasource:
    url: jdbc:mysql://localhost:3306/[db]
    username: [usernmae]
    password: [password]
    driver-class-name: com.mysql.cj.jdbc.Driver

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: false
    properties:
      hibernate:
        dialect: org.hibernate.dialect.MySQLDialect

  security:
    oauth2:
      client:
        registration:
          google:
            client-id: [client-id]
            client-secret: [client-secre]
            client-name: google
            authorization-grant-type: authorization_code
            redirect-uri: http://localhost:8080/login/oauth2/code/google
            scope:
              - profile
              - email
server:
  port: 8080
  • application.yml 파일에 OAuth2 설정을 해준다. 
  • 구글같은 경우 provider 부분은 스프링에서 알아서 제공해줘서 작성안해줘도 된다.

 


1. GoogleResponse

getAttributes :
{
	resultcode=00,
    	message=success,
      	id=12345678,
        email=엉이@naver.com,
      	name=엉이
   	}
 }
 
 provider : google

구글 같은 경우 응답 방식인 response 가 위와 같이 나온다. 저거에 맞게 커스텀하면 된다.

public class GoogleResponse implements OAuth2Response{

    private final Map<String, Object> attribute;

    public GoogleResponse(Map<String, Object> attribute) {

        this.attribute = attribute;
    }

    @Override
    public String getProvider() {

        return "google";
    }

    @Override
    public String getProviderId() {

        return attribute.get("sub").toString();
    }

    @Override
    public String getEmail() {

        return attribute.get("email").toString();
    }

    @Override
    public String getName() {

        return attribute.get("name").toString();
    }


    @Override
    public Map<String, Object> getAttributes() {
        return attribute;
    }
}

각 소셜 플랫폼마다 웅답 방식이 다르기 때문에 인터페이스를 구현했고 구글 로그인을 위해 내가 만든 인터페이스를 상속하는 방식으로 진행했다. 이 부분은 이전 게시글을 보면 된다.

 

 

 

2. OAuth2UserService 구현

@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOauth2UserService extends DefaultOAuth2UserService {

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {

        OAuth2User oAuth2User = super.loadUser(userRequest);
        System.out.println("OAuth2User attributes: " + oAuth2User.getAttributes());
        String registrationId = userRequest.getClientRegistration().getRegistrationId();
        OAuth2Response oAuth2Response = null;

        switch (registrationId) {
            case "naver":
                log.info("naver 로그인");
                oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
                break;

            case "kakao":
                log.info("kakao 로그인");
                oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
                break;

            case "google":
                log.info("google 로그인");
                oAuth2Response = new GoogleResponse(oAuth2User.getAttributes());
                break;

            default:
                log.error("로그인 실패: 지원하지 않는 로그인 제공자입니다. 등록 ID: {}", registrationId);
                throw new IllegalArgumentException("지원하지 않는 로그인 제공자입니다.");
        }


        String provider = oAuth2Response.getProvider();
        String providerId = oAuth2Response.getProviderId();

        String name = provider + " " + providerId; // 이렇게 해서 해당 유저가 이미 디비에 있는지 없는지 확인
        Optional<User> userOptional = userRepository.findByUsername(name);

        // "USER" 라는 역할을 OAuth2 로그인 사람에게 다 부여
        String roleName = "ROLE_USER";
        Optional<Role> roleOptional = roleRepository.findByName(roleName); // 디비에서 찾아오는데,
        Role role;
        if (roleOptional.isEmpty()) { // "USER" 디비에 없다면
            role = new Role(roleName); // 새로운 역할 등록
            role = roleRepository.save(role);
        } else {
            role = roleOptional.get(); // 그게 아니라면 역할 꺼내오기
        }

        String password = String.valueOf(UUID.randomUUID());

        User user;
        // OAuth2 로그인을 한 적 없는 사람
        if (userOptional.isEmpty()) {
            user = User.builder()
                    .name(oAuth2Response.getEmail())
                    .username(name)
                    .roles(Set.of(role))
                    .providerId(oAuth2Response.getProviderId())
                    .provider(oAuth2Response.getProvider())
                    .password(password)
                    .phoneNumber("default")
                    .birthdate(LocalDate.from(LocalDateTime.now()))
                    .gender("default")
                    .registrationDate(LocalDateTime.now())
                    .usernick(oAuth2Response.getName())
                    .build();
            userRepository.save(user);
        } else { // 이미 OAuth2 로그인을 한 적이 있는 사람
            user = userOptional.get();
            boolean updated = false;

            if (!user.getRoles().contains(role)) {
                user.getRoles().add(role);
                updated = true;
            }

            // 닉네임은 첫 로그인 이후 마이페이지에서만 변경 가능
            if (!user.getUsernick().equals(oAuth2Response.getName()) && user.getUsernick() == null) {
                user.setUsernick(oAuth2Response.getName());
                updated = true;
            }

            if (updated) {
                userRepository.save(user);
            }
        }

        System.out.println("User saved: " + user);

        // 특정 사이트의 응답 값과 역할을 받는 CustomOAuth2User 클래스
        // 로그인 한적 없는 사람은 "USER" 역할, 기존에 한 적있다면 그 사람이 현재 갖고 있는 역할을 CustomOAuth2User 클래스로 반환
        return new CustomOAuth2User(oAuth2Response, roleName);
    }
}
  • Spring Security의 OAuth2 인증을 처리하는 커스텀 서비스이다.
  • 주로 네이버, 카카오, 구글 등의 OAuth2 제공자에서 사용자 인증 정보를 받아와 데이터베이스에 저장하거나 업데이트하는 역할을 한다.
  • loadUser 메서드는 네이버나 카카오 등 소셜플랫폼의 사용자 인증 정보를 받아오는 메서드이다.
  • 위 클래스는 외부 사이트로부터 사용자 정보를 받아오고 그 값을 디비에 저장하는 클래스이다.

 

 

3. 로그인 폼 및 로그인 한 회원의 정보를 보여주기

  • loginform.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>로그인 페이지</title>
</head>
<body>
<h1>로그인 페이지</h1>
<hr/>
<!-- 시큐리티는 x-www-form-url-encoded 타입만 인식 -->
<form action="/login" method="post">
    <input type="text" name="username" />
    <input type="password" name="password" />
    <button>로그인</button>
</form>
<a href="/oauth2/authorization/kakao">카카오 로그인</a>
<a href="/oauth2/authorization/naver">네이버 로그인</a>
<a href="/oauth2/authorization/google">구글 로그인</a>
<a href="/api/users/signup">회원가입을 아직 하지 않으셨나요?</a>
</body>
</html>

 

이렇게 하면 기본 Oauth2 세션 로그인은 성공할 것이다. 시큐리티 관련 설정은 이전 게시글에서 다뤘기 때문에 여기서 따로 적지는 않을 것이다.

다음 포스터에서는 모든 소셜로그인에서  jwt를 사용해서 구현해볼 것이다.

 

728x90

+ Recent posts