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에 저장함으로써 데이터베이스와 캐시 간에 책임을 분리할 수 있다.
이는 데이터베이스에 대한 접근을 최소화하여 공격 벡터를 줄일 수 있다.
예를 들어, 데이터베이스가 공격을 받더라도 캐시에 저장된 토큰 정보는 노출되지 않을 수 있다.
@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); 를 통해 메서드에서 바로 구현하여 바로 메일을 전송하였다.
멋사 파이널 프로젝트 중 회원가입 시 이메일 인증과 비밀번호 찾기 로직을 구현해보고자 한다.
회원가입 시 받는 이메일 인증은 사용자 식별 및 보안 강화를 위해 필요한 기술이다. 만약 이메일 인증과 같은 인증 기술이 없다면 한 사람이 10개 혹은 1000개의 계정을 무한대로 생성할 수 있다는 것인데, 이는 스팸이나 부정 사용 등 서비스 품질을 하락시킬 수 있다. 이메일 인증과 관련해서 구현하기까지 수많은 구글링과 시행착오가 있어서 정리해놓을려고 한다.
$(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. 이메일 중복이 없다면 인증 메일 보내기 버튼 활성화 됨
$('#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');
}
});
});
엑세스, 리프레시 토큰은 쉽게 탈취당하지 않도록 http only 속성으로 쿠키에 저장을 해주었기 때문에, js 브라우저에서나 타인이 쉽게 볼 수 없고 탈취가 어렵다.
HTTP Only란? - document.cookie와 같은 자바스크립트로 쿠키를 조회하는 것을 막는 옵션 - 브라우저에서 HTTP Only가 설정된 쿠키를 조회할 수 없다. - 서버로 HTTP Request 요청을 보낼때만 쿠키를 전송한다. - 이를 통해 XSS(Cross Site Scripting) 공격을 차단할 수 있다.
b. 로그인 후 요청
상황 1 : 로그인 후 요청 보낼 때 (엑세스 토큰이 유효한 경우)
1.1 ac 토큰이 유효하다면 인증/ 인가가 성공하여 응답을 반환한다.
1.2 요청했던 작업을 정상 수행한다.
상황 2 : 로그인 후 요청 보낼 때 (엑세스 토큰이 유효하지 않은 경우)
1.1 ac 토큰 에러 응답을 보낸다
1.2 이때 아래의 과정을 통해 엑세스 토큰 재발급 api로 이동해서 새로운 엑세스 토큰을 발급받아야 한다.
상황 2 -1 : 엑세스 토큰을 리프레시 토큰을 통해 재발급 과정
1-1. 서버는 클라이언트가 액세스 재발급 해주세요 라는 api를 발동시키게 되면, 쿠키에 저장돼있는 refreshToken을 잡아와서 이 refreshToken이 유효한 지 검사한다. 1-2 유효하다면 액세스를 재발급해준다. 1-3 액세스 재발급 api는 Authorization에 Bearer 방식으로 리프레쉬 토큰을 담아서 요청을 보내면 서버에서는 리프레쉬토큰 기간 만료 여부, 유효 여부를 따진다. 1-4 refreshToken기간이 만료됐다면, refreshToken 재발급이 필요하다는 에러를 throw 해야 한다. 1-5 이때 refreshToken이 유효하지 않아서 재발급 해야한다는 말은 사용자를 강제로그아웃 시킨 뒤, 재로그인하도록 만들어야 한다는 것과 똑같다.
어떻게 활용?
그럼 위의 로직을 바탕으로 프로젝트를 구성한다면 CSR 방식과 SSR 방식이 있을 것이다.
일단, 1. CSR 방식
이 방식은 클라이언트와 서버가 분리된 환경에서 api 통신으로 엑세스 토큰은 로컬스토리지, 헤더에 담고 리프레시 토큰은 쿠키에 담아서 보내면 된다.
2. SSR 방식
이 방식에서는 보통 jwt 방식을 잘 사용하지 않는다고 한다. 그래도 구현을 할 수는 있는데, 엑세스 토큰과 리프레시 토큰을 클라이언트 쿠키에 담아주는 방식이다.
근데 일단 이 방식에서는 폼에서 탬플릿엔진(jsp, 타임리프 등)을 사용해서 보여주기 때문에 따로 자바스크립트 코드를 사용해서 쿠키에 담는 방식을 이용해야 한다.
또한 jwt가 stateless 한 특징을 갖고 있는데 SSR 방식에서는 stateless 한 특성이 잘 보이지 않는다.
그래서 SSR 방식에서 jwt 를 사용한다면 왜 사용했는지, 그 이유를 명확히 말할 수 있어야 한다.
일단 나는 SSR 방식에서 jwt 토큰을 발급했기 때문에.. 코드는 아래와 같다.
2. Login form
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>로그인</title>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/static/js/auth.js"></script> // 리프레시 토큰으로 엑세스 토큰 재발급
</head>
<body>
<form id="loginForm" action="/api/login" method="post">
<input type="text" id="username" name="username" placeholder="아이디" required />
<input type="password" id="password" name="password" placeholder="비밀번호" required />
<button type="button" onclick="loginUser()">로그인</button>
</form>
<script>
function loginUser() {
var username = document.getElementById('username').value;
var password = document.getElementById('password').value;
var data = {
username: username,
password: password
};
axios.post('/api/login', data)
.then(response => {
// 로그인 성공 시 토큰을 쿠키에 저장
var accessToken = response.data.accessToken;
var refreshToken = response.data.refreshToken;
document.cookie = "accessToken=" + accessToken + "; HttpOnly; path=/";
document.cookie = "refreshToken=" + refreshToken + "; HttpOnly; path=/";
// 성공 시 다음 페이지로 이동 혹은 필요한 작업 수행
window.location.href = '/api/users/home';
})
.catch(error => {
console.error('로그인 에러:', error);
// 실패 시 에러 처리 혹은 사용자에게 알림
alert('로그인 실패. 다시 시도해주세요.');
});
}
</script>
</body>
</html>
나는 SSR 방식으로 프로젝트를 진행 중이기 때문에 폼에서 axios, fetch 등으로 쿠키를 클라이언트에 보내주는 작업을 했어야 한다.
그래서 위의 폼에서의 자바스크립트 코드를 보면 엑세스, 리프레시 토큰을 앤드포인트에서 받아와 클라이언트 쿠키에 넣어주는 역할을 하고 있다.
참고로 위의 코드에서는 단순히 로그인 시에 엑세스와 리프레시 토큰을 받아오는 코드만 있고 엑세스 토큰이 만료되면 리프레시 토큰으로 재발하는 과정은 없다.
아래에 있는 코드가 재발급 과정에 맞는 코드이다.
아래 코드는 모든 요청마다 쿠키를 검증하는데, 이때 쿠키에 엑세스 토큰의 요청이 만료되면 리프레시 토큰의 유무를 판단하여 엑세스 토큰을 재발급하던지, 로그아웃을 하는 방향으로 진행된다.
// Axios 인터셉터를 설정하여 요청 전에 Access Token의 유효성을 검사하고 만료 시 갱신
axios.interceptors.request.use(
async config => {
// 쿠키에서 accessToken을 읽어온다.
var token = getCookie('accessToken');
if (token) {
config.headers['Authorization'] = 'Bearer ' + token;
}
try {
// Access Token이 만료되었는지 확인하는 로직 추가
var exp = parseJwt(token).exp;
var now = Date.now() / 1000;
if (exp < now) {
// Access Token이 만료되었으므로 Refresh Token을 사용해 갱신
var refreshToken = getCookie('refreshToken');
if (refreshToken) {
const response = await axios.post('/api/refreshToken', null, {
headers: {
'Authorization': 'Bearer ' + refreshToken
}
});
var newAccessToken = response.data.accessToken;
// 새로 발급받은 Access Token을 요청 헤더에 포함
config.headers['Authorization'] = 'Bearer ' + newAccessToken;
} else {
throw new Error('리프레시 토큰이 존재하지 않습니다.');
}
}
} catch (error) {
console.error('토큰 갱신 오류:', error);
}
return config;
},
error => {
return Promise.reject(error);
}
);
// 쿠키에서 값 읽기
function getCookie(name) {
let cookieArr = document.cookie.split(";");
for (let i = 0; i < cookieArr.length; i++) {
let cookiePair = cookieArr[i].split("=");
if (name == cookiePair[0].trim()) {
return decodeURIComponent(cookiePair[1]);
}
}
return null;
}
// JWT 파싱
function parseJwt(token) {
try {
return JSON.parse(atob(token.split('.')[1]));
} catch (e) {
return null;
}
}
나처럼 백엔드와 프론트엔드가 분리된 환경이 아니라면 jwt 토큰을 사용하는 것에 있어서 깊은 생각을 해봐야 할 것이다. !
public class GoogleResponse implements OAuth2Response{
private final Map<String, Object> attribute;
public GoogleResponse(Map<String, Object> attribute) {
this.attribute = attribute;
System.out.println("google attributes: " + 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();
}
}
구글에 대해서는 카드 인증이 필요해서 귀찮기 때문에.. 하지는 않았지만 정리를 위해 작성했다. 실제 구글 oauth2는 신청하지 않았기에 사용하지 않았다.
3. OAuth2User
CustomOAuth2User.java
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;
}
// 현재는 null 했지만 이 값을 지정하면
// 카카오,네이버 등 로그인했으면 거기에 맞는 프로필 사진이나 가져올 수 있음
@Override
public Map<String, Object> getAttributes() {
return null;
}
// 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();
}
}
카카오, 네이버, 구글 서비스로부터 받은 특정 사이트의 응답 값과 롤에 대한 값을 받는 클래스이다.
이 클래스를 통해 특정 값과 롤에 대해 정의한다.
4. SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomUserDetailsService customUserDetailsService; // 기본 세션 로그인
private final CustomOauth2UserService customOAuth2UserService; // OAuth2 등록
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.requestMatchers("/api/users/signup", "/api/users/login").permitAll()
.requestMatchers("/oauth-login/admin").hasRole("ADMIN")
.requestMatchers("/oauth-login/info").authenticated()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/api/users/login") // 로그인 페이지의 경로
.loginProcessingUrl("/login") // 로그인 폼이 제출되는 URL
.defaultSuccessUrl("/api/users/home")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/")
)
.sessionManagement(sessionManagement -> sessionManagement
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
)
.userDetailsService(customUserDetailsService)
.csrf(csrf -> csrf.disable())
.cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(configurationSource()))
// OAuth 등록
.oauth2Login(oauth2 -> oauth2
.loginPage("/api/users/login") // 커스텀 로그인 방식 지정
// customOAuth2UserService 등록
.userInfoEndpoint(userInfoEndpointConfig ->
userInfoEndpointConfig.userService(customOAuth2UserService))
.failureUrl("/loginFailure")
.defaultSuccessUrl("/api/users/info")
.authorizationEndpoint(authorization -> authorization
.baseUri("/oauth2/authorization")
)
.permitAll()
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
public CorsConfigurationSource configurationSource(){
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowedMethods(List.of("GET","POST","DELETE"));
source.registerCorsConfiguration("/**",config);
return source;
}
}
Oauth2 설정을 해준다.
5. OAuth2UserService 구현
OAuth2UserService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomOauth2UserService extends DefaultOAuth2UserService {
// DefaultOAuth2UserService는 OAuth2UserService의 구현체라서
// DefaultOAuth2UserService 또는 OAuth2UserService 아무거나 상속해도 된다.
private final UserRepository userRepository;
private final RoleRepository roleRepository;
// loadUser --> 네이버나 카카오의 사용자 인증 정보를 받아오는 메서드
// userRequest 를 통해 카카오, 네이버, 구글 등등 인증 데이터가 넘어 올 것이다.
@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를 생성
OAuth2Response oAuth2Response = null;
// 그 값이 네이버면
if (registrationId.equals("naver")) {
log.info("naver 로그인");
oAuth2Response = new NaverResponse(oAuth2User.getAttributes());
// 그 값이 카카오면
} else if (registrationId.equals("kakao")) {
log.info("kakao 로그인");
oAuth2Response = new KakaoResponse(oAuth2User.getAttributes());
// 둘다 아니면
} else {
System.out.println("로그인 실패");
throw new IllegalArgumentException("지원하지 않는 로그인 제공자입니다.");
}
// 데이터 베이스 저장 관련 구현
String provider = oAuth2Response.getProvider();
String providerId = oAuth2Response.getProviderId();
String username = provider + " " + providerId;
Optional<User> userOptional = userRepository.findByUsername(username);
String roleName = "USER";
Optional<Role> roleOptional = roleRepository.findByName(roleName);
Role role;
if (roleOptional.isEmpty()) {
role = new Role(roleName);
role = roleRepository.save(role); // Save the new role and get the persisted instance
} else {
role = roleOptional.get();
}
User user;
if (userOptional.isEmpty()) {
user = User.builder()
.username(username)
.email(oAuth2Response.getEmail())
.roles(Set.of(role))
.loginId(oAuth2Response.getProviderId())
.provider(oAuth2Response.getProvider())
.password("defaultPassword")
.registrationDate(LocalDateTime.now())
.usernick(oAuth2Response.getName())
.build();
userRepository.save(user);
} else {
user = userOptional.get();
user.setUsername(username);
user.setEmail(oAuth2Response.getEmail());
user.setLoginId(oAuth2Response.getProviderId());
user.setProvider(oAuth2Response.getProvider());
user.getRoles().add(role);
user.setRegistrationDate(LocalDateTime.now());
user.setUsernick(oAuth2Response.getName());
userRepository.save(user);
roleName = user.getRoles().iterator().next().getName();
}
System.out.println("User saved: " + user);
// 특정 사이트의 응답 값과 역할을 받는 CustomOAuth2User 클래스
// 인증된 사용자 정보를 반환
return new CustomOAuth2User(oAuth2Response, roleName);
}
}
Spring Security의 OAuth2 인증을 처리하는 커스텀 서비스이다.
주로 네이버, 카카오, 구글 등의 OAuth2 제공자에서 사용자 인증 정보를 받아와 데이터베이스에 저장하거나 업데이트하는 역할을 한다.
loadUser는 네이버나 카카오의 사용자 인증 정보를 받아오는 메서드이다.
외부 사이트로부터 사용자 정보를 받아오고 그 값을 디비에 저장하는 클래스이다.
6. 컨트롤러
UserController.java
@Controller
@RequiredArgsConstructor
@RequestMapping("/api/users")
@Slf4j
public class UserController {
private final UserServiceImpl userServiceimpl;
private final CustomUserDetailsService customUserDetailsService;
@GetMapping("/signup")
public String signup(Model model) {
model.addAttribute("user", new UserDto());
return "/user/singupform";
}
@PostMapping("/signup")
public String signup(@ModelAttribute("user") UserDto userDto,
RedirectAttributes redirectAttributes) {
try {
userServiceimpl.signUp(userDto);
redirectAttributes.addFlashAttribute("success", " 성공적으로 회원가입 됐습니다.");
return "redirect:/api/users/login";
} catch (Exception e) {
log.error("회원가입 오류 : {} " , e.getMessage());
redirectAttributes.addFlashAttribute("error", "회원가입에 실패했습니다." + e.getMessage());
e.printStackTrace();
}
return "redirect:/api/users/login";
}
@GetMapping("/login")
public String showLoginForm() {
return "/user/loginform";
}
// 일반 로그인 회원의 정보 가져오기
@GetMapping("/home")
public String index(Model model, Authentication authentication) {
String username = authentication.getName();
Optional<User> userOptional = userServiceimpl.findByEmail(username);
if (userOptional.isPresent()) {
model.addAttribute("user", userOptional.get());
return "user/home"; // user/home.html 템플릿으로 이동
}
return "redirect:/api/users/login";
}
// oauth2 유저의 정보 가져오기
@GetMapping("/info")
public String info(Model model, Authentication authentication) {
CustomOAuth2User userDetails = (CustomOAuth2User) authentication.getPrincipal();
model.addAttribute("name", userDetails);
return "/user/info";
}
}
원래의 나라면 Service는 Class로 만들었을 테지만, 팀원 중 한명이 유지보수에 좋다고 해서 분리해보겠다.
UserServiceImpl.java
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
@Override
@Transactional
public void signUp(UserDto userDto) {
if (!userDto.getPassword().equals(userDto.getPasswordCheck())) {
throw new RuntimeException("비밀번호가 다릅니다.");
}
if (userRepository.existsByEmail(userDto.getEmail())) {
throw new RuntimeException("이메일이 존재합니다.");
}
if (userRepository.existsByUsernick(userDto.getUsernick())) {
throw new RuntimeException("닉네임이 존재합니다.");
}
Role role = roleRepository.findByName(userDto.getUsername().equals("admin") ? "ADMIN" : "USER");
User user = new User();
user.setRoles(Collections.singleton(role));
user.setEmail(userDto.getEmail());
user.setUsernick(userDto.getUsernick());
user.setUsername(userDto.getUsername());
user.setPassword(passwordEncoder.encode(userDto.getPassword()));
userRepository.save(user);
}
@Override
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email);
}
@Override
public void deleteUser(String email) {
Optional<User> emailOptional = userRepository.findByEmail(email);
if (emailOptional.isPresent()) {
userRepository.delete(emailOptional.get());
} else {
throw new RuntimeException("삭제할 사용자가 존재하지 않습니다.");
}
}
}
signUp 메서드를 보면 username이 admin이면 Role을 ADMIN으로 주고 그게 아니면 USER로 주는 것으로 진행했다.
5. 컨트롤러
Usercontroller.java
@Controller
@RequiredArgsConstructor
@RequestMapping("/api/users/")
@Slf4j
public class UserController {
private final UserServiceImpl userServiceimpl;
private final CustomUserDetailsService customUserDetailsService;
@GetMapping("/signup")
public String signup(Model model) {
model.addAttribute("user", new UserDto());
return "/user/singupform";
}
@PostMapping("/signup")
public String signup(@ModelAttribute("user") UserDto userDto,
RedirectAttributes redirectAttributes) {
try {
userServiceimpl.signUp(userDto);
redirectAttributes.addFlashAttribute("success", " 성공적으로 회원가입 됐습니다.");
return "redirect:/users/login";
} catch (Exception e) {
log.error("회원가입 오류 : {} " , e.getMessage());
redirectAttributes.addFlashAttribute("error", "회원가입에 실패했습니다." + e.getMessage());
e.printStackTrace();
}
return "redirect:/api/users/login";
}
@GetMapping("/login")
public String showLoginForm() {
return "/user/loginform";
}
// @PostMapping("/login")
// public void login(@ModelAttribute User user, RedirectAttributes redirectAttributes) {
// try {
// UserDetails userDetails = customUserDetailsService.loadUserByUsername(user.getUsername());
// Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, user.getPassword(), userDetails.getAuthorities());
// SecurityContextHolder.getContext().setAuthentication(authentication);
// } catch (Exception e) {
// redirectAttributes.addFlashAttribute("error", "아이디 또는 비밀번호가 올바르지 않습니다.");
// }
// }
// 일반 로그인 회원의 정보 가져오기
@GetMapping("/home")
public String index(Model model, Authentication authentication) {
String username = authentication.getName();
Optional<User> userOptional = userServiceimpl.findByEmail(username);
if (userOptional.isPresent()) {
model.addAttribute("user", userOptional.get());
return "user/home"; // user/home.html 템플릿으로 이동
}
return "redirect:/api/users/login";
}
}
userDto로부터 회원가입에 필요한 정보를 받아서 회원가입을 한다.
여기서 애를 먹었는데, @Postmapping("/login")에서 로그인이 들어오면 그 정보를 가지고 있었는데 저부분 때문에 자꾸 로그인이 안됐다.
@Postmapping("/login") 메소드에서 CustomUserDetailsService를 통해 사용자를 로드하고 Authentication을 설정하려고 하지만, 이 방식은 Spring Security의 기본 인증 방식과 충돌할 수 있다.
Spring Security는 기본적으로 formLogin을 사용하여 로그인 처리를 자동으로 수행하므로, 수동으로 인증을 설정할 필요는 없다.
로그인 처리를 Spring Security의 기본 메커니즘에 맡기기 위해, UserController의 login 메소드를 제거하고, 로그인 페이지에서 제공한 폼 데이터를 Spring Security의 formLogin을 통해 처리하게 하는 것이 좋다.
6. UserDetails
CustomUserDetails.java
public class CustomUserDetails implements UserDetails {
private final String email;
private final String password;
private final Collection<? extends GrantedAuthority> authorities;
public CustomUserDetails(String email, String password, Set<Role> roles) {
this.email = email;
this.password = password;
this.authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority(role.getName()))
.collect(Collectors.toList());
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return email;
}
@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의 UserDetails 인터페이스를 상속하여 정의한다.
이 클래스가 하는 역할은 Spring Security는 이 클래스를 사용하여 사용자 인증 및 권한 부여를 처리한다.
일단 시큐리티를 사용하면 우리가 직접 로그인 처리를 안해도 된다.
POST /login 에 대한 요청을 security가 가로채서 로그인 진행해주기 때문에 우리가 직접 @PostMapping("/login") 을 만들지 않아도 됨!
토큰 방식이 아닌 기존 세션방식으로 시큐리티 로그인에 성공하면 Security Session을 생성해 준다.(Key값 : Security ContextHolder)
Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 지금 작성한 CustomUserDetails에서 UserDetails를 설정해준다고 보면 된다.
7. UserDetailsService
CustomUserDetailsService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("로그인이 들어왓나?");
log.info("조회할 이메일: {}", username);
Optional<User> userOptional = userRepository.findByEmail(username);
if (userOptional.isEmpty()) {
log.info("사용자가 없다");
throw new UsernameNotFoundException("사용자가 존재하지 않습니다: " + username);
}
User foundUser = userOptional.get();
Set<Role> roles = foundUser.getRoles();
return new CustomUserDetails(
foundUser.getEmail(),
foundUser.getPassword(),
roles
);
}
}
사용자가 로그인할 때, CustomUserDetailsService의 loadUserByUsername 메서드가 호출되어 사용자 정보를 데이터베이스에서 조회한다.
만약, 사용자가 존재하지 않는 경우 UsernameNotFoundException 예외를 발생시키고 사용자가 존재하는 경우, UserDetails 객체를 생성하고 반환한다.
Spring Security는 UserDetails 객체를 사용하여 사용자의 비밀번호와 권한 정보를 확인한다.
CustomUserDetails 객체는 사용자의 비밀번호와 권한 정보를 Spring Security에 제공한다.
Spring Security는 이 정보를 바탕으로 사용자가 인증되었는지 확인하고, 권한에 따라 접근을 제어한다.
즉, loadUserByUsername 메서드는 로그인을 진행할 때 주어진 사용자 이름(email, 로그인 진행 시 아이디)을 기반으로 사용자의 세부 정보를 검색하고 반환하는 역할을 한다.
jwt 방식을 사용한다면 이 방식은 필요없다.
7. SecurityConfig
SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomUserDetailsService customUserDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.requestMatchers("/api/users/signup", "/api/users/login", "/api/users/home").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/api/users/login") // 로그인 페이지의 경로
.loginProcessingUrl("/login") // 로그인 폼이 제출되는 URL
// .usernameParameter("email") // 변경된 필드명
// .passwordParameter("password")
.defaultSuccessUrl("/api/users/home")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/")
)
.sessionManagement(sessionManagement -> sessionManagement
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
)
.userDetailsService(customUserDetailsService)
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
HttpSecurity로 대부분 구현한다고 생각하면 된다. 참고로 현재는 WebSecurityConfigurerAdapter는 사용을 안한다.
먼저 config 패키지에 SecurityConfig라는 시큐리티 설정 파일을 만들어 주고 필요한 @bean들을 추가해 사용할 수 있다. --> 사진을 찾다보니 현재 WebSecurityConfigurerAdapter는 시큐리티3부터 사용을 안하지만 HttpSecurity에 대한 설명이 나와있어서 사용했다. 현재 WebSecurityConfigurerAdapter 를 상속하지 않는다!
이제는 @Bean 으로 SpringSecurityFilterChain 을 구현한다.
config 클래스에 @EnableWebSecurity 어노테이션을 달아서 시큐리티 설정을 해준다.
@Configuration
@EnableWebSecurity
public class SecurityConfig{
// 패스워드 암호화 관련 메소드
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
// 특정 HTTP 요청에 대한 웹 기반 보안 구성
// 시큐리티 대부분의 설정을 담당하는 메소드
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/signup", "/", "/login").permitAll()
.anyRequest().authenticated()
)
// Form 로그인을 활용하는경우 (JWT에는 필요없음)
.formLogin(form -> form
.loginPage("/loginform")
.loginProcessingUrl("/login")
.defaultSuccessUrl("/")
.permitAll()
)
.logout((logout) -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
)
.sessionManagement(sessionManagement -> sessionManagement
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
);
return http.build();
}
// 이외에도 등록해서 사용하면 된다..
}
코드설명
filterChain() : 특정 Http 요청에 대해 웹 기반 보안 구성. 인증/인가 및 로그아웃을 설정한다.
.csrf(Cross site Request forgery) : 공격자가 인증된 브라우저에 저장된 쿠키의 세션 정보를 활용하여 웹 서버에 사용자가 의도하지 않은 요청을 전달하는 것. 즉, 정상적인 사용자가 의도치 않은 위조요청을 보내는 것을 의미한다.
REST API를 이용한 개발을 진행 할 예정일 때, Rest Api 환경에서는 Session 기반 인증과 다르기 때문에 서버에 인증 정보를 보관하지 않고, 권한 요청시 필요한 인증정보(OAuth2, Jwt토큰 등)요청을 포함하기 때문에 굳이 불필요한 csrf 보안을 활성화할 필요가 없다.
따라서 csrf는 disable 처리
.HttpBasic()
HttpBasic() : Http basic Auth 기반으로 로그인 인증창이 뜬다.
.authorizeHttpRequests() : 인증, 인가가 필요한 URL 지정
anyRequest() : requestMatchers에서 지정된 URL 외의 요청에 대한 설정
authenticated() : 해당 URL에 진입하기 위해서는 인증이 필요함
requestMatchers("Url").permitAll() : requestMatchers에서 지정된 url은 인증, 인가 없이도 접근 허용
Url에 /**/ 와 같이 ** 사용 : ** 위치에 어떤 값이 들어와도 적용 (와일드 카드)
hasAuthority() : 해당 URL에 진입하기 위해서 Authorization(인가, 예를 들면 ADMIN만 진입 가능)이 필요함
.hasAuthority(UserRole.ADMIN.name()) 와 같이 사용 가능
formLogin() : Form Login 방식 적용
loginPage() : 로그인 페이지 URL
defaultSuccessURL() : 로그인 성공시 이동할 URL
failureURL() : 로그인 실패시 이동할 URL
logout() : 로그아웃에 대한 정보
invalidateHttpSession() : 로그아웃 이후 전체 세션 삭제 여부
sessionManagement() : 세션 생성 및 사용여부에 대한 정책 설정
SessionCreationPolicy() : 정책을 설정
SessionCreationPolicy.Stateless : 4가지 정책 중 하나로, 스프링 시큐리티가 생성하지 않고 존재해도 사용하지 않는다. (JWT와 같이 세션을 사용하지 않는 경우에 사용)
BCryptPasswordEncoder
BCrype 인코딩을 통하여 비밀번호에 대한 암호화를 수행한다.
password를 암호화해줌
Spring Security에서 비밀번호를 안전하게 저장할 수 있도록 비밀번호의 단방향 암호화를 지원한다. -> PasswordEncoder 인터페이스와 구현체들
encode() : 비밀번호를 암호화(단방향)
matches() : 암호화된 비밀번호와 암호화되지 않은 비밀번호가 일치하는지 비교
upgradeEncoding() : 인코딩된 암호화를 다시 한번 인코딩 할 때 사용 (true일 경우 다시 인코딩, default=false)
PasswordEncoder가 제공하는 구현 클래스
StandardPasswordEncoder : SHA-256을 이용해 암호를 해시한다. (강도가 약한 해싱 알고리즘이기 때문에 지금은 많이 사용되지 않는다.)
BCryptPasswordEncoder : bcrypt 강력 해싱 함수로 암호를 인코딩
NoOpPasswordEncoder : 암호를 인코딩하지 않고 일반 텍스트로 유지(테스트 용도로만 사용.)
SCryptPasswordEncoder : scrypt 해싱 함수로 암호를 인코딩한다.
@Bean // 패스워드 암호화 관련 메소드
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
현재 사용되는 알고리즘에서 취약성이 발견되어 다른 인코딩 알고리즘으로 변경하고자 할 때 대응하기 좋은 방법은 DelegatingPasswordEncoder을 사용하는 것
@Bean // DelegatingPasswordEncoder: 여러 인코딩 알고리즘을 사용할 수 있게 해주는 기능
public static PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
기타 참고용
Configure 작성 문법 바뀐 부분
스프링 3.0 이상의 버전부터는 스프링 시큐리티 버전도 바뀌어서 기존의 Configuration과는 다르게 작성해야 한다. WebSecurity, HttpSecurity 모두 큰 변화를 맞이 했는데, 그중 하나가 lambdas 형식의 작성법이다.
http
.authorizeHttpRequests(authorizeRequest -> authorizeRequest
// 해당 경로는 모든 권한을 허용한다.
.requestMatchers(HttpMethod.GET, "/login**", "/web-resources/**", "/actuator/**").permitAll()
// 해당 경로는 어드민 권한이 있어야한다.
.requestMatchers(HttpMethod.GET, "/admin/**").hasAnyRole("ADMIN")
// 해당 경로는 유저 권한이 있어야 한다.
.requestMatchers(HttpMethod.GET, "/order/**").hasAnyRole("USER")
// 나머지는 모두 권한이 필요하다.
.anyRequest().authenticated()
requestMatchers
특정 리소스에 대해서 권한을 설정한다.
permitAll
리소스의 접근을 인증절차 없이 허용한다.
authenticated
리소스의 접근을 인증절차를 통해 허용한다.
hasAnyRole
해당 권한을 가진 사용자만 접근을 허용한다.
anyRequest
모든 리소스를 의미하며, anyMatcher로 설정하지 않은 리소스를 말한다.
HttpSecurity - 로그인처리 설정
로그인 FORM 페이지를 이용하여 로그인하는 방식을 사용하려고 할때 여러가지 설정을 할 수 있다.
// Form 로그인을 활용하는경우 (JWT에는 필요없음)
// .formLogin(Customizer.withDefaults()); // Security가 제공하는 로그인 방식 사용
.formLogin(formLogin -> formLogin
.loginPage("/login")
.loginProcessingUrl("/loginProc")
.usernameParameter("userId")
.passwordParameter("userPw")
.permitAll())
JwtAuthenticationFilter 사용
HttpSecurity - 커스텀 필드 등록 ⭐
커스텀 필터를 생성해서 등록할 수 있다!
.addFilterBefore(jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class)
// UsernamePasswordAuthenticationFilter가 기존 시큐리티 세션 방식의 로그인 필터이기 때문에
// UsernamePasswordAuthenticationFilter 앞에 커스텀한 필터 체인을 넣어준다.
3편에서는 RefreshToken과 BlackListToken에 대해서 엔티티와 레포지토리, 서비스를 작성했다.
4편에서는 본격적으로 JWT 코드를 짜볼 것이다.
코드도 많이 길고 복잡하다..
<- 대략적인 파일 구조
<- 파일 구조에 맞게 세팅한다.
1. 사용자 인증 및 권한 관리를 위해 설정할 것
1.1 CustomUserDetails.java
public class CustomUserDetails implements UserDetails {
private final String userName; // 이름
private final String password; // 비밀번호
private final List<GrantedAuthority> authorities; // 권한 목록
// 생성자
public CustomUserDetails(String userName, String password, List<String> roles){
this.userName = userName;
this.password = password;
this.authorities = roles.stream() // 권한 관련 작업을 하기 위한 role return
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
`
` // 사용자가 가진 권한 반환
// SimpleGrantedAuthority 객체를 사용하여 역할을 권한으로 변환
@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의 UserDetails 인터페이스를 상속하여 정의한다.
이 클래스가 하는 역할은 Spring Security는 이 클래스를 사용하여 사용자 인증 및 권한 부여를 처리한다.
SimpleGrantedAuthority 는 역할 이름을 그대로 사용한다는 의미로 데이터베이스에 ROLE_ADMIN, ROLE_USER 이렇게 되어 있다면 이걸 그대로 사용하겠다는 의미이다.
List<String> 형태의 역할을 생성자로 받고, 이를 GrantedAuthority로 변환하며 신원을 증명하는 데 사용하는 정보 --> 비밀번호나 토큰
일단 시큐리티를 사용하면 우리가 직접 로그인 처리를 안해도 된다.
POST /login 에 대한 요청을 security가 가로채서 로그인 진행해주기 때문에 우리가 직접 @PostMapping("/login") 을 만들지 않아도 됨!
토큰 방식이 아닌 기존 세션방식으로 시큐리티 로그인에 성공하면 Security Session을 생성해 준다. (Key값 : Security ContextHolder)
Security ContextHolder 의 내부는 Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 지금 작성한 CustomUserDetails가 UserDetails를 설정해준다고 보면 된다.
1.2 CustomUserDetailsService.java
일단 이 클래스는 Spring Security의 UserDetailsService 인터페이스를 상속받아 구현한다.
나는 UserDetailsService 인터페이스를 구현한 CustomUserDetailsService 라는 클래스를 구현했다.
이전까지는 UserService에서 findUserByLoginId 이런 식으로 User을 불러왔었다.
하지만, 위에서 말했듯 기존 세션 방식에서의 Security는 UserDetails 가 필요하기 때문에 따로 설정이 필요하다. => 위에서 만든 CustomUserDetails 를 return하거나 org.springframework.security.core.userdetails.User를 불러와 리턴하는 방법이 있다.
둘의 방식과 쓰임은 똑같다.
Q. CustomUserDetailsService.java 는 어떻게 사용될까?
사용자가 로그인할 때, CustomUserDetailsService의 loadUserByUsername 메서드가 호출되어 사용자 정보를 데이터베이스에서 조회한다.
만약, 사용자가 존재하지 않는 경우 UsernameNotFoundException 예외를 발생시키고
사용자가 존재하는 경우, UserDetails 객체를 생성하고 반환한다.
Spring Security는 UserDetails 객체를 사용하여 사용자의 비밀번호와 권한 정보를 확인한다..
UserDetails 객체는 사용자의 비밀번호와 권한 정보를 Spring Security에 제공한다.
Spring Security는 이 정보를 바탕으로 사용자가 인증되었는지 확인하고, 권한에 따라 접근을 제어한다.
UserDetailsService 를 작성하는 방법은 다양하게 작성이 가능하고 나는 그 중 아래 2가지로 구현을 해봤다.
1.2.1. UserBuilder를 사용하여 반환
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<User> user = userRepository.findByUserName(username); // 데이터베이스에서 사용자 이름을 기준으로 사용자를 조회
if (!user.isPresent()) {
throw new UsernameNotFoundException("해당 사용자가 존재하지 않습니다. : " + username);
}
// UserBuilder를 사용하여 UserDetails 객체를 생성 --> 사용자 정보를 쉽게 생성하고 구성
// withUsername(username)를 호출하여 사용자 이름을 설정
UserBuilder userBuilder = org.springframework.security.core.userdetails.User.withUsername(username);
userBuilder.password(user.get().getPassword()); // 암호화된 비밀번호를 설정
userBuilder.roles(user.get().getRole().stream()
.map(role -> role.getRoleName().name())
.toArray(String[]::new));
return userBuilder.build();
}
}
첫 번째 방법은 UserBuilder를 사용하여 UserDetails 객체를 생성하는 방법이다.
Spring Security에서 제공하는 UserBuilder는 UserDetails 객체를 유연하게 생성하고 설정할 수 있도록 도와준다.
UserBuilder는 org.springframework.security.core.userdetails.User 클래스 내부에 정적 메서드로 정의되어 있다.
org.springframework.security.core.userdetails.User클래스의 withUsername 정적 메서드를 사용하여 UserBuilder 인스턴스를 생성한다.
이때 사용자 이름, 암호화된 비밀번호, 역할을 설정하여 UserBuilder를 반환한다.
이를 통해 UserDetails 객체를 커스텀 하지 않고도 쉽게 생성할 수 있다. -> 즉, 위에서 만든 CustomUserDetails 클래스는 사용 하지 않고도 시큐리티가 제공하는 기능을 사용하여 사용자 정보를 데이터베이스에서 꺼낼 수 있다.
이 방식은 간결하고 가독성이 높아, UserDetails객체를 쉽게 생성할 수 있다.
1.2.2 CustomUserDetails 클래스에 직접 값 반환
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<User> userOptional = userRepository.findByUserName(username); // 데이터베이스에서 사용자 이름을 기준으로 사용자를 조회
if (!userOptional.isPresent()) {
throw new UsernameNotFoundException("해당 사용자가 존재하지 않습니다. : " + username);
}
User foundUser = userOptional.get();
Set<Role> roles = foundUser.getRoles();
List<String> roleNames = roles.stream()
.map(role -> role.getRoleName().name()) // "ROLE_" 접두사를 제거하지 않음
.collect(Collectors.toList());
return new CustomUserDetails(
foundUser.getUsername(),
foundUser.getPassword(),
roleNames
);
}
}
두 번째 방법이다.
위에서 만든 CustomUserDetails에 사용자 이름, 암호화된 비밀번호, 역할을 담아 반환해주는 것이다.
어찌됐던 두 개의 사용법은 다 똑같고 반환 부분도 거의 비슷하다.
하지만 첫 번째 방법과 달리 위에서 만든 CustomUserDetails 클래스를 사용하면 사용자 정보를 추가하거나 커스텀할 수 있다.
특정 요구 사항에 맞게 클래스와 메서드를 자유롭게 정의할 수 있다.
Q. 위의 코드들을 보면 org.springframework.security.core.userdetails.User 클래스를 호출하거나, CustomUserDetails 클래스를 불러오던데 무슨 차이인지?
A.
org.springframework.security.core.userdetails.User 클래스는 UserDetails 인터페이스를 시큐리티 자체적으로 구현하고 있다.
즉, org.springframework.security.core.userdetails.User 클래스는 UserDetails 인터페이스를 구현한 기본 제공 구현체로, 사용자 이름, 비밀번호, 권한 정보를 포함하고 있다.
CustomUserDetails 클래스를 안불러와도 사용이 가능하다는 뜻이다.
위에서도 설명했듯이 기존 세션방식으로 시큐리티 로그인에 성공하면 Security Session을 생성해 준다. --> (Key값 : Security ContextHolder)
이때 Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 CustomUserDetails 클래스에서 UserDetails를 설정해준다고 보면 되고 org.springframework.security.core.userdetails.User 클래스는 자체적으로 UserDetails 값을 가지고 있다고 보면 된다.
Q. 어떤 방법이 적절한가? --> 실무에서는?
단순한 요구 사항: 간단한 사용자 인증을 처리해야 하고, 특별한 커스터마이징이 필요 없다면 UserBuilder를 사용
확장성 필요: 사용자 정보에 추가적인 필드를 포함하거나, 커스터마이징된 사용자 정보를 사용해야 한다면 CustomUserDetails를 사용 --> 선호!
실무에서는 UserDetails를 상속하여 새로운 커스텀 UserDetails 를 만드는 것을 선호한다.
2. JWT 설정
이제 본격적으로 JWT 리프레시 토큰, 엑세스 토큰 발급을 위한 코드를 작성하겠다.
2.1 JwtAuthenticationToken.java
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private String token; // JWT 토큰
private Object principal; // 사용자 정보
private Object credentials; // 자격 증명 정보
// 인증이 된 사용자 정보와 권한을 설정
public JwtAuthenticationToken(Collection<? extends GrantedAuthority> authorities , Object principal, Object credentials) {
super(authorities); // 권한 설정
this.principal = principal; // 사용자를 식별하는 고유한 엔티티 --> 사용자의 사용자 이름, ID
this.credentials = credentials; // 사용자가 자신의 신원을 증명하는 데 사용하는 정보 -> 비밀번호, 토큰
this.setAuthenticated(true); // 사용자가 가질 수 있는 권한이나 역할 --> ROLE_USER, ROLE_ADMIN
}
// 인증되지 않은 상태로 JWT 토큰을 설정
public JwtAuthenticationToken(String token){
super(null); // 권한 없음
this.token = token;
this.setAuthenticated(false); // 인증되지 않은 상태 설정
}
// 자격 증명 정보를 반환
@Override
public Object getCredentials() {
return this.credentials;
}
// 사용자 정보를 반환
@Override
public Object getPrincipal() {
return this.principal;
}
}
JWT 기반으로 한 인증을 나타내는 토큰을 구현하는 클래스이다.
AbstractAuthenticationToken을 상속받아 인증 정보를 반환하는 메서드를 제공한다.
인증되지 않은 상태와 인증된 상태를 구분하여 생성자 제공한다.
Principal 사용자를 식별하는 고유한 엔티티 --> 사용자의 사용자 이름 또는 ID
Credentials 사용자가 자신의 신원을 증명하는 데 사용하는 정보 --> 비밀번호나 토큰
Authorities 사용자가 시스템에서 가질 수 있는 권한이나 역할 --> ROLE_USER나 ROLE_ADMIN
사용자가 시스템에 로그인을 시도하면, Credentials를 통해 자신의 신원을 증명하고, 시스템은 Principal을 통해 사용자를 식별하며, 이후 Authorities를 통해 사용자가 어떤 작업을 수행할 수 있는지를 결정한다.
2.2 JwtTokenizer.java
@Component
@Slf4j
@Getter
public class JwtTokenizer {
private final byte[] accessSecret; // Access Token 서명에 사용할 비밀키
private final byte[] refreshSecret; // Refresh Token 서명에 사용할 비밀키
public static Long ACCESS_TOKEN_EXPIRE_COUNT = 30 * 60 * 1000L; // Access Token의 만료 시간 (30분)
public static Long REFRESH_TOKEN_EXPIRE_COUNT = 7 * 24 * 60 * 60 * 1000L; // Refresh Token의 만료 시간 (7일)
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);
}
// JWT 토큰을 생성하는 메서드
private String createToken(Long id, String email, String username, List<RoleName> roles, long expireCount, byte[] secret) {
Claims claims = Jwts.claims().setSubject(email); // JWT 클레임 설정
claims.put("userId", id); // 사용자 ID 클레임 추가
claims.put("username", username); // 사용자 이름 클레임 추가
claims.put("roles", roles); // 사용자 권한 클레임 추가
Date now = new Date();
Date expiration = new Date(now.getTime() + expireCount); // 만료 시간 계산
return Jwts.builder() // JWT 빌더
.setClaims(claims) // 클레임 설정
.setIssuedAt(now) // 발급 시간 설정
.setExpiration(expiration) // 만료 시간 설정
.signWith(SignatureAlgorithm.HS256, getSigningKey(secret)) // 서명 설정
.compact(); // JWT 생성
}
// Access Token을 생성하는 메서드
public String createAccessToken(Long id, String email, String username, List<RoleName> roles) {
return createToken(id, email, username, roles, ACCESS_TOKEN_EXPIRE_COUNT, accessSecret);
}
// Refresh Token을 생성하는 메서드
public String createRefreshToken(Long id, String email, String username, List<RoleName> roles) {
return createToken(id, email, username, roles, REFRESH_TOKEN_EXPIRE_COUNT, refreshSecret);
}
// secretKey - byte형식
public static Key getSigningKey(byte[] secretKey) {
return Keys.hmacShaKeyFor(secretKey); // 서명에 사용할 키 생성
}
// Jwt 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
// 토큰에서 유저 아이디 얻기
public Long getUserIdFromToken(String token) {
Claims claims = parseToken(token, accessSecret); // 토큰 파싱
return Long.valueOf((Integer) claims.get("userId")); // 사용자 ID 반환
}
// 토큰 복호화
public Claims parseToken(String token, byte[] secretKey) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey(secretKey)) // 서명 키 설정
.build()
.parseClaimsJws(token) // 토큰 파싱
.getBody(); // 클레임 반환
}
// accessToken 토큰 복호화
public Claims parseAccessToken(String accessToken) {
return parseToken(accessToken, accessSecret);
}
// refreshToken 토큰 복호화
public Claims parseRefreshToken(String refreshToken) {
return parseToken(refreshToken, refreshSecret);
}
public boolean isTokenExpired(String token, byte[] secretKey) {
try {
Jwts.parserBuilder()
.setSigningKey(getSigningKey(secretKey)) // 서명 키 설정
.build()
.parseClaimsJws(token);
return false; // 토큰이 유효할 때 false를 반환해야 함
} catch (ExpiredJwtException e) {
log.error("Token expired", e);
} catch (Exception e) {
log.error("Token invalid", e);
}
return true; // 토큰이 만료되었거나 유효하지 않을 때 true를 반환
}
public boolean isAccessTokenExpired(String accessToken) {
return isTokenExpired(accessToken, accessSecret);
}
public boolean isRefreshTokenExpired(String refreshToken) {
return isTokenExpired(refreshToken, refreshSecret);
}
// 주어진 Refresh Token을 사용하여 새로운 Access Token 생성
public String refreshAccessToken(String refreshToken) {
Claims claims = parseRefreshToken(refreshToken); // Refresh Token 파싱
Long userId = claims.get("userId", Long.class); // 사용자 ID 추출
String email = claims.getSubject(); // 이메일 추출
String username = claims.get("username", String.class); // 사용자 이름 추출
List<RoleName> roles = (List<RoleName>) claims.get("roles"); // 사용자 권한 추출
return createAccessToken(userId, email, username, roles); // 새로운 Access Token 생성
}
}
JWT 토큰을 생성하고 파싱하는 역할을 하는 클래스이다.
이 클래스에 JWT를 활용하는데에 필요한 메서드들을 한 데에 모아놓았다.
Access Token 및 Refresh Token의 생성 메서드를 제공하며 토큰 만료 여부를 확인하는 메서드도 제공한다.
Refresh Token을 사용하여 Access Token이 만료된 경우 새로운 Access Token을 생성하는 메서드를 제공한다.
(Access Token : 30분 //Refresh Token : 7일)로 토큰의 만료시간을 정했다.