
Spring Security에서 비밀번호는 사용자가 입력한 값을 그대로 저장하지 않는다.
비밀번호는 다른 사람이 알면 안 되는 민감한 정보이기 때문에, 저장할 때 안전한 형태로 바꿔야 한다.
이때 사용하는 핵심 인터페이스가PasswordEncoder이다.
비밀번호 저장에서는 흔히 “비밀번호를 암호화한다”라고 말한다.
하지만 여기서 말하는 암호화는 나중에 다시 원래 값으로 되돌리는 방식이 아니다.
실제 비밀번호 저장에서는 복호화 가능한 암호화가 아니라, 원본 비밀번호를 되돌리기 어렵게 바꾸는 단방향 해시 방식이 사용된다.
PasswordEncoder는 비밀번호를 안전한 저장용 해시 문자열로 바꾸고, 로그인할 때 입력한 비밀번호가DB에 저장된 해시 문자열과 맞는지 비교하는 역할을 한다.
여기서 중요한 점은 저장된 값을 다시 원래 비밀번호로 되돌려서 비교하지 않는다는 것이다.
비밀번호는 복호화해서 확인하는 값이 아니라, 사용자가 입력한 비밀번호를 같은 조건으로 다시 계산해 보고DB에 저장된 해시 문자열에 포함된 해시 결과와 맞는지 확인하는 값이다.
예를 들어 사용자가 회원가입할 때 비밀번호로unico123을 입력했다고 하자.
서버는 이 비밀번호 값을 그대로DB에 저장하지 않는다.
대신PasswordEncoder를 사용해unico123이라는 비밀번호를 전혀 다른 긴 문자열로 바꾼 뒤 저장한다.
이때 저장되는 값은 단순히 원본 비밀번호를 숨겨 둔 값이 아니다.
저장된 해시 문자열 안에는 비밀번호 검증에 필요한 정보가 함께 들어 있다.
예를 들면 어떤 해시 방식을 사용했는지, 어떤salt가 사용되었는지, 최종 해시 결과가 무엇인지 같은 정보가 포함된다.
로그인할 때 사용자가 다시 비밀번호로unico123을 입력하면 서버는DB에 저장된 해시 문자열을 원래 비밀번호로 풀지 않는다.
대신 저장된 해시 문자열 안에 들어 있는 정보를 기준으로, 사용자가 방금 입력한 비밀번호unico123을 다시 계산한다.
그 계산 결과가 저장된 해시 문자열에 포함된 해시 결과와 같으면 올바른 비밀번호로 판단한다.
PasswordEncoder 인터페이스
PasswordEncoder는 비밀번호를 안전하게 다루는 규칙이다
PasswordEncoder는Spring Security에서 비밀번호 해시와 비밀번호 비교를 담당하는 인터페이스이다.
interface는 어떤 기능을 반드시 가져야 하는지 정해 둔 규칙이라고 이해하면 된다.
비밀번호 처리는 프로젝트마다 직접 제멋대로 만들면 위험하다.
어떤 프로젝트는 비밀번호를 그대로 저장할 수도 있고, 어떤 프로젝트는 약한 방식으로 처리할 수도 있다.
그래서Spring Security는PasswordEncoder라는 공통 규칙을 제공한다.
이 규칙을 사용하면 비밀번호를 안전한 방식으로 저장하고 비교하는 흐름을 만들 수 있다.
PasswordEncoder가 하는 일은 크게 두 가지이다.
- 사용자가 입력한 평문 비밀번호를 해시한다.
- 로그인할 때 입력한 비밀번호가
DB에 저장된 해시 문자열과 맞는지 비교한다.여기서 평문 비밀번호는 아직 해시되지 않은 원래 비밀번호를 뜻한다.
예를 들어 사용자가 회원가입 화면에서 비밀번호로unico123을 입력했다면, 이 값이 평문 비밀번호이다.
다만PasswordEncoder자체는 “비밀번호를 해시하고 비교해야 한다”는 규칙에 가깝다.
실제로 어떤 방식으로 계산할지는BCryptPasswordEncoder같은 구현체가 담당한다.
그래서 코드에서는 보통PasswordEncoder타입으로 사용하고, 실제 객체로는BCryptPasswordEncoder를 넣어 사용한다.
비밀번호는 단방향 해시로 저장한다
비밀번호 저장에서 중요한 표현은 단방향이다.
단방향은 한쪽 방향으로만 바꿀 수 있다는 뜻이다.
즉, 평문 비밀번호를 해시 문자열로 바꿀 수는 있지만, 해시 문자열을 다시 원래 비밀번호로 되돌리지는 않는다.
예를 들어 비밀번호unico123을 해시하면$2a$10$...처럼 전혀 다른 긴 문자열이 만들어진다.
이 문자열을 보고 다시unico123을 알아내는 방식으로 로그인 검사를 하지 않는다.
처음에는 “로그인할 때 원래 비밀번호와 비교하려면 다시 되돌려야 하는 것 아닌가?”라고 생각할 수 있다.
하지만 비밀번호는 그렇게 처리하지 않는다.
서버는DB에 저장된 해시 문자열을 복호화하지 않고, 사용자가 입력한 비밀번호를 같은 조건으로 다시 계산해 본다.
그 결과가 저장된 해시 문자열에 포함된 해시 결과와 같으면 올바른 비밀번호로 판단한다.
복호화는 암호화된 데이터를 다시 원래 데이터로 되돌리는 과정이다.
하지만 비밀번호 저장에서는 복호화가 가능하면 위험하다.
DB가 유출되었을 때 저장된 비밀번호를 다시 원래 비밀번호로 되돌릴 수 있기 때문이다.
그래서 비밀번호는 복호화할 수 없는 해시 방식으로 관리하는 것이 중요하다.
해시는 값을 되돌릴 수 없게 다른 값으로 바꾸는 방식이다
hash는 어떤 값을 넣었을 때 일정한 계산을 거쳐 전혀 다른 값으로 바꾸는 방식이다.
비밀번호 해시에서는 원본 비밀번호를 직접 알 수 없도록 만드는 것이 핵심이다.
예를 들어 원본 비밀번호가 아래와 같다고 하자.원본 비밀번호 unico123이 값을 해시하면 실제 저장값은 아래처럼 전혀 다른 문자열이 된다.
DB에 저장되는 해시 문자열 예시 $2a$10$Kx7...해시결과...여기서 중요한 점은 저장된 문자열 안에
unico123이라는 원본 비밀번호가 그대로 들어 있는 것이 아니라는 점이다.
DB에 저장되는 값은 원본 비밀번호가 아니라, 원본 비밀번호를 검증하기 위한 해시 문자열이다.
그래서 로그인할 때도 저장된 값을 다시unico123으로 되돌리지 않는다.
대신 사용자가 입력한 비밀번호를 다시 계산해서, 저장된 해시 문자열에 포함된 해시 결과와 맞는지 확인한다.
salt는 같은 비밀번호도 다르게 저장되게 만드는 랜덤값이다
salt는 비밀번호를 해시하기 전에 함께 섞는 랜덤값이다.
쉽게 말하면 비밀번호에 추가로 섞는 임의의 재료라고 보면 된다.
예를 들어 사용자가 비밀번호로1234를 입력했다고 하자.
salt가 없다면 같은 비밀번호는 같은 해시 결과를 만들 수 있다.salt가 없는 경우를 단순화한 예시 A 사용자 비밀번호: 1234 -> 해시결과: abc111 B 사용자 비밀번호: 1234 -> 해시결과: abc111이렇게 되면
DB를 본 사람이 두 사용자가 같은 비밀번호를 사용한다는 사실을 알 수 있다.
또 공격자가 미리 만들어 둔 해시 목록과 비교하기도 쉬워진다.
하지만salt를 섞으면 같은 비밀번호라도 저장 결과가 달라진다.salt가 있는 경우를 단순화한 예시 A 사용자 비밀번호: 1234 + saltA -> 해시결과: aaa111 B 사용자 비밀번호: 1234 + saltB -> 해시결과: bbb222두 사용자의 원본 비밀번호는 모두
1234이다.
하지만 각각 다른salt가 섞였기 때문에DB에 저장되는 해시 결과는 서로 달라진다.
BCryptPasswordEncoder가 만든 해시 문자열에는 비교에 필요한salt정보도 함께 들어 있다.
그래서 로그인할 때matches()는 저장된 해시 문자열에서salt와 설정 정보를 읽고, 사용자가 입력한 비밀번호를 같은 조건으로 다시 계산할 수 있다.
salt는 비밀번호를 숨기는 비밀키가 아니라, 같은 비밀번호도 서로 다른 결과로 저장되게 만드는 랜덤값이다.
PasswordEncoder 주요 메서드
encode()는 평문 비밀번호를 해시 문자열로 바꾼다
encode()는 사용자가 입력한 평문 비밀번호를 저장 가능한 해시 문자열로 바꾸는 메서드이다.
메서드는 객체가 가진 기능이라고 이해하면 된다.
회원가입 상황을 생각하면 이해하기 쉽다.
사용자가 회원가입 화면에서 비밀번호unico123을 입력한다.
서버는 이 값을 그대로DB에 저장하지 않는다.
먼저passwordEncoder.encode("unico123")을 실행한다.
그러면unico123은 저장 가능한 긴 해시 문자열로 바뀐다.
그리고 서버는 이 해시 문자열을DB에 저장한다.
흐름은 아래처럼 볼 수 있다.// PasswordEncoderEncodeFlow.java // BCrypt 방식으로 비밀번호를 처리할 PasswordEncoder를 만든다. PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); // 사용자가 입력한 평문 비밀번호를 준비한다. String rawPassword = "unico123"; // 평문 비밀번호를 해시 문자열로 바꾼다. String encodedPassword = passwordEncoder.encode(rawPassword);
rawPassword는 사용자가 입력한 원본 비밀번호이다.
encodedPassword는PasswordEncoder가 만든 저장용 해시 문자열이다.
실제 저장소에는rawPassword가 아니라encodedPassword가 저장되어야 한다.
encode()가 실행될 때의 흐름을 더 풀어 보면 다음과 같다.
- 원본 비밀번호
unico123을 받는다.BCryptPasswordEncoder가 랜덤salt를 만든다.- 원본 비밀번호와
salt를 함께 사용해 해시 계산을 한다.- 해시 방식, 반복 강도,
salt, 해시 결과가 포함된 문자열을 만든다.- 이 최종 문자열을
DB에 저장한다.즉,
encode()의 결과는 단순한 해시 결과 하나만이 아니다.
비밀번호를 나중에 검증할 때 필요한 정보까지 포함된 저장용 문자열이다.
예를 들면 저장값은 아래와 같은 느낌으로 만들어진다.// 실제 값은 실행할 때마다 달라질 수 있다. rawPassword = "unico123"; encodedPassword = "$2a$10$Kx7...salt정보...해시결과...";여기서
rawPassword는 절대 저장하면 안 되는 값이다.
encodedPassword가DB에 저장해야 하는 값이다.
encode()를 다시 실행해서 문자열 비교하면 안 된다
BCryptPasswordEncoder는 같은 비밀번호를 다시encode()해도 매번 같은 문자열이 나오지 않을 수 있다.
이유는encode()를 실행할 때마다 새로운salt가 만들어질 수 있기 때문이다.
예를 들어 같은 비밀번호unico123을 두 번 해시해도 결과가 다를 수 있다.// 같은 비밀번호를 두 번 encode()한 예시 passwordEncoder.encode("unico123"); // 결과 예시: $2a$10$AAA...해시결과... passwordEncoder.encode("unico123"); // 결과 예시: $2a$10$BBB...해시결과...둘 다 원본 비밀번호는
unico123이다.
하지만salt가 다르기 때문에 최종 해시 문자열은 서로 다르게 나올 수 있다.
그래서 로그인할 때 아래처럼 비교하면 안 된다.// 잘못된 비교 방식 passwordEncoder.encode("unico123").equals(encodedPassword);이 방식은 사용자가 입력한 비밀번호를 다시
encode()한 뒤 문자열이 완전히 같은지 비교하려고 한다.
하지만 새로encode()하면 새로운salt가 만들어질 수 있으므로 저장된 문자열과 달라질 수 있다.
그래서 로그인 비교에는matches()를 사용해야 한다.// 올바른 비교 방식 passwordEncoder.matches("unico123", encodedPassword);
matches()는 저장된 해시 문자열 안에 있는salt와 설정 정보를 기준으로 입력 비밀번호를 다시 계산한다.
그래서 같은 비밀번호인지 안전하게 확인할 수 있다.
matches()는 입력 비밀번호를 같은 조건으로 다시 계산해 비교한다
matches()는 로그인할 때 사용하는 메서드이다.
사용자가 입력한 평문 비밀번호와DB에 저장된 해시 문자열이 맞는지 확인한다.
로그인 상황을 생각하면 된다.
사용자가 로그인 화면에 비밀번호로unico123을 입력한다.
DB에는 이미 회원가입 때 만들어진 해시 문자열이 저장되어 있다.
서버는 저장된 해시 문자열을 원래 값으로 되돌리지 않는다.
대신matches()를 사용해서 입력값을 같은 조건으로 다시 계산하고, 저장된 해시 문자열에 포함된 해시 결과와 맞는지 확인한다.
흐름은 아래처럼 볼 수 있다.// PasswordEncoderMatchesFlow.java // BCrypt 방식으로 비밀번호를 비교할 PasswordEncoder를 만든다. PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); // 사용자가 로그인 화면에 입력한 비밀번호를 준비한다. String rawPassword = "unico123"; // DB에 저장되어 있다고 가정할 해시 문자열을 만든다. String encodedPassword = passwordEncoder.encode(rawPassword); // 입력 비밀번호와 저장된 해시 문자열을 비교한다. boolean match = passwordEncoder.matches(rawPassword, encodedPassword);
match가true이면 입력한 비밀번호가 맞다는 뜻이다.
false이면 입력한 비밀번호가 저장된 해시 문자열과 맞지 않는다는 뜻이다.
matches()가 내부에서 하는 일을 단계별로 풀면 다음과 같다.
- 첫 번째 값으로 사용자가 입력한 평문 비밀번호를 받는다.
- 두 번째 값으로
DB에 저장된 해시 문자열을 받는다.- 저장된 해시 문자열에서
salt와 설정 정보를 읽는다.- 사용자가 입력한 비밀번호를 같은
salt와 설정으로 다시 해시한다.- 다시 계산한 결과와 저장된 해시 문자열에 포함된 해시 결과를 비교한다.
- 결과가 같으면
true, 다르면false를 반환한다.즉,
matches()는 암호화된 비밀번호를 복호화해서 비교하는 메서드가 아니다.
저장된 해시 문자열의 조건을 이용해, 지금 입력한 비밀번호를 다시 계산해 보고 결과가 맞는지 확인하는 메서드이다.
예를 들어 저장된 해시 문자열이 아래와 같다고 하자.DB에 저장된 해시 문자열 예시 encodedPassword = "$2a$10$Kx7...해시결과...";사용자가 올바른 비밀번호를 입력하면 아래처럼 판단된다.
올바른 비밀번호 입력 입력값: unico123 저장된 salt로 다시 계산한 결과: 저장된 해시 문자열에 포함된 해시 결과와 같음 matches() 결과: true사용자가 틀린 비밀번호를 입력하면 아래처럼 판단된다.
잘못된 비밀번호 입력 입력값: unico999 저장된 salt로 다시 계산한 결과: 저장된 해시 문자열에 포함된 해시 결과와 다름 matches() 결과: false이 흐름 때문에 비밀번호를 원래 값으로 되돌리지 않아도 로그인 검사가 가능하다.
matches()는 저장된 해시 문자열에서salt와 설정 정보를 읽고, 사용자가 입력한 비밀번호를 같은 조건으로 다시 계산해 비교한다.
PasswordEncoder 구현체
구현체는 실제 해시 방식을 담당한다
PasswordEncoder는 비밀번호 해시와 비교에 대한 공통 규칙이다.
하지만 실제로 어떤 알고리즘으로 비밀번호를 해시할지는 구현체가 담당한다.
구현체는 인터페이스가 정한 기능을 실제 코드로 만든 클래스라고 이해하면 된다.
앞에서encode()와matches()를 설명했기 때문에 갑자기 구현체가 왜 나오는지 헷갈릴 수 있다.
정리하면 관계는 이렇다.
PasswordEncoder는 비밀번호를 해시하고 비교해야 한다는 규칙이다.encode()와matches()는 그 규칙에 포함된 대표 메서드이다.BCryptPasswordEncoder같은 구현체는 그 메서드들을 실제로 동작하게 만드는 클래스이다.즉,
PasswordEncoder와 구현체를 따로따로 사용하는 것이 아니다.
PasswordEncoder타입으로 사용하되, 실제 객체로는BCryptPasswordEncoder같은 구현체를 넣어서 사용한다.
코드로 보면 아래와 같다.// PasswordEncoder 타입으로 받고, // 실제 동작은 BCryptPasswordEncoder 구현체가 담당한다. PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();왼쪽의
PasswordEncoder는 공통 규칙이다.
오른쪽의BCryptPasswordEncoder는 실제 해시 계산을 수행하는 클래스이다.
이렇게 작성하면 코드 전체는PasswordEncoder라는 공통 타입에 의존한다.
그리고 실제 비밀번호 처리 방식은BCryptPasswordEncoder가 담당한다.
Spring Security에서 사용할 수 있는 대표 구현체
Spring Security에서 사용할 수 있는 대표 구현체는 다음과 같다.
BCryptPasswordEncoderPbkdf2PasswordEncoderSCryptPasswordEncoderArgon2PasswordEncoder각 구현체는 비밀번호를 안전하게 해시하는 방식이 다르다.
같은PasswordEncoder규칙을 따르지만, 내부 계산 방식은 다를 수 있다.
예를 들어BCryptPasswordEncoder는bcrypt해시 방식을 사용한다.
Pbkdf2PasswordEncoder는PBKDF2방식을 사용한다.
SCryptPasswordEncoder는scrypt방식을 사용한다.
Argon2PasswordEncoder는Argon2방식을 사용한다.
실습에서는 보통BCryptPasswordEncoder를 많이 사용한다.
그래서 처음에는PasswordEncoder라는 규칙이 있고, 실제로는BCryptPasswordEncoder가 그 규칙을 구현해서 동작한다고 이해하면 된다.
BCryptPasswordEncoder는 자주 사용되는 비밀번호 해시 방식이다
BCryptPasswordEncoder는bcrypt해시 방식을 사용하는 구현체이다.
Spring Security에서 비밀번호 해시 예제로 자주 등장하는 방식이다.
BCryptPasswordEncoder를 사용하면 같은 비밀번호를 해시해도 결과 문자열이 매번 같지 않을 수 있다.
처음 보면 이상해 보일 수 있다.
“같은 비밀번호인데 왜 결과가 다르지?”라고 생각할 수 있다.
이유는salt때문이다.
salt는 비밀번호를 해시할 때 함께 섞는 랜덤값이다.
같은 비밀번호라도 서로 다른salt가 섞이면 해시 결과가 달라진다.
이렇게 하면 공격자가 미리 만들어 둔 비밀번호 해시 목록으로 쉽게 맞히기 어렵다.
예를 들어 두 사람이 같은1234비밀번호를 사용한다고 해도,salt가 다르면 저장되는 해시 결과가 달라진다.
그래서 같은 비밀번호를 쓰는 사용자가 있어도DB에 같은 문자열이 그대로 저장되지 않는다.
BCryptPasswordEncoder를Bean으로 등록하면Spring Security인증 흐름에서 비밀번호 비교에 사용할 수 있다.// SecurityPasswordConfig.java import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration public class SecurityPasswordConfig { @Bean public PasswordEncoder passwordEncoder() { // BCrypt 방식으로 비밀번호를 해시하고 비교한다. return new BCryptPasswordEncoder(); } }이 설정은
PasswordEncoder타입의 객체를Spring Bean으로 등록한다.
Spring Bean은Spring이 만들고 관리하는 객체이다.
이렇게 등록해 두면 로그인 인증 흐름에서Spring Security가 이 객체를 사용해 비밀번호를 비교할 수 있다.
여기서도 관계는 같다.
메서드를 사용할 때는PasswordEncoder의encode()와matches()를 사용한다.
하지만 실제 해시 계산은BCryptPasswordEncoder구현체가 담당한다.
비밀번호 해시 예제 흐름
PasswordEncryptionExample 코드
아래 예제는
BCryptPasswordEncoder로 비밀번호를 해시하고, 올바른 비밀번호와 잘못된 비밀번호를 각각 비교하는 흐름을 보여준다.
// PasswordEncryptionExample.java package com.example.security9.app; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; public class PasswordEncryptionExample { public static void main(String[] args) { // 원본 비밀번호를 준비한다. String rawPassword = "unico123"; // BCrypt 방식의 PasswordEncoder를 생성한다. PasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); // 원본 비밀번호를 저장용 해시 문자열로 바꾼다. String encodedPassword = passwordEncoder.encode(rawPassword); System.out.println("암호화된 비밀번호: " + encodedPassword); // 원본 비밀번호와 해시 문자열이 일치하는지 확인한다. boolean isMatch = passwordEncoder.matches(rawPassword, encodedPassword); System.out.println("비밀번호 일치 여부: " + isMatch); // 잘못된 비밀번호를 준비한다. String wrongPassword = "unico999"; // 잘못된 비밀번호와 해시 문자열이 일치하는지 확인한다. boolean wrongMatch = passwordEncoder.matches(wrongPassword, encodedPassword); System.out.println("잘못된 비밀번호 일치 여부: " + wrongMatch); } }이 코드에서
rawPassword에는 원본 비밀번호unico123이 들어간다.
passwordEncoder.encode(rawPassword)는 이 원본 비밀번호를 저장 가능한 해시 문자열로 바꾼다.
결과는encodedPassword에 저장된다.
코드의 출력 문구는암호화된 비밀번호라고 되어 있다.
하지만 개념적으로 보면 이 값은 다시 복호화할 수 있는 암호문이 아니라, 비밀번호 검증에 사용할 저장용 해시 문자열이다.
encodedPassword에는 원본 비밀번호가 그대로 들어 있지 않다.
대신 해시 방식,salt, 해시 결과처럼 나중에 검증할 때 필요한 정보가 포함된다.
그래서DB에는rawPassword가 아니라encodedPassword를 저장해야 한다.
그 다음passwordEncoder.matches(rawPassword, encodedPassword)가 실행된다.
첫 번째 값은 사용자가 입력한 평문 비밀번호이고, 두 번째 값은 저장되어 있다고 가정하는 해시 문자열이다.
matches()는 저장된 해시 문자열에서salt와 설정 정보를 읽고, 입력한 비밀번호를 같은 조건으로 다시 계산한다.
계산 결과가 저장된 해시 문자열에 포함된 해시 결과와 맞으면 결과는true가 된다.
마지막으로wrongPassword에는unico999가 들어간다.
이 값은 원래 비밀번호인unico123과 다르다.
그래서 같은salt와 설정으로 다시 계산해도 저장된 해시 문자열에 포함된 해시 결과와 맞지 않는다.
따라서passwordEncoder.matches(wrongPassword, encodedPassword)의 결과는false가 된다.
실행 결과 확인
// 출력결과 // 암호화된 비밀번호: $2a$10$... // 비밀번호 일치 여부: true // 잘못된 비밀번호 일치 여부: false
암호화된 비밀번호값은 실행할 때마다 달라질 수 있다.
BCryptPasswordEncoder가 해시 과정에서 새로운salt를 사용할 수 있기 때문이다.
그래도matches()는 저장된 해시 문자열에 포함된salt와 설정 정보를 바탕으로 비교할 수 있다.
그래서 같은 원본 비밀번호인unico123은true가 되고, 다른 비밀번호인unico999는false가 된다.
BCryptPasswordEncoder로unico123을 해시하면 원본 비밀번호와 전혀 다른 긴 해시 문자열이 만들어진다.
이 문자열은 저장용 비밀번호 값으로 사용되며, 다시 원래 비밀번호로 되돌리는 값이 아니다.
로그인할 때는matches()가 저장된 해시 문자열 안의salt와 설정 정보를 이용해 입력 비밀번호를 다시 계산한다.
그래서 올바른 비밀번호인unico123은true, 잘못된 비밀번호인unico999는false가 된다.
비밀번호 해시 보안 권장사항
평문 비밀번호는 저장하면 안 된다
비밀번호에서 가장 중요한 원칙은 평문 저장 금지이다.
평문 저장은 사용자가 입력한 비밀번호를 그대로 저장하는 것이다.
예를 들어DB에unico123이 그대로 저장되어 있다면 평문 저장이다.
이 방식은 매우 위험하다.
만약DB가 유출되면 사용자의 비밀번호가 그대로 노출된다.
사용자가 같은 비밀번호를 다른 사이트에서도 사용했다면 피해가 더 커질 수 있다.
그래서 비밀번호는 반드시 해시된 형태로 저장해야 한다.
해시된 형태는 원래 비밀번호를 직접 알 수 없는 저장 결과이다.
로그인할 때도 저장된 해시 값을 되돌리지 않고, 입력 비밀번호를 같은 조건으로 다시 계산해 맞는지 비교해야 한다.
최신 비밀번호 해시 알고리즘을 사용하는 것이 중요하다
비밀번호 저장에는 안전한 해시 알고리즘을 사용해야 한다.
BCryptPasswordEncoder,Pbkdf2PasswordEncoder,SCryptPasswordEncoder,Argon2PasswordEncoder같은 구현체는 비밀번호 해시에 사용되는 방식이다.
이런 방식들은 단순히 문자열을 바꾸는 수준이 아니다.
salt같은 임의값을 적용해서 같은 비밀번호라도 결과가 다르게 만들어질 수 있게 한다.
또 계산을 일부러 무겁게 만들어서 공격자가 많은 비밀번호를 빠르게 대입해 맞히는 것을 어렵게 만든다.
무차별 대입 공격은 가능한 비밀번호를 계속 넣어 보면서 맞히는 공격이다.
예를 들어1234,1111,password,admin123같은 값을 계속 시도하는 방식이다.
salt와 안전한 해시 방식이 적용되면 이런 공격을 훨씬 어렵게 만들 수 있다.
회원가입과 로그인 흐름에서 PasswordEncoder가 쓰이는 위치
PasswordEncoder는 회원가입과 로그인 흐름 모두에서 사용된다.
다만 사용하는 방식은 다르다.
회원가입에서는encode()를 사용한다.
사용자가 입력한 비밀번호를 해시해서 저장해야 하기 때문이다.
로그인에서는matches()를 사용한다.
사용자가 입력한 비밀번호가 저장된 해시 문자열과 맞는지 확인해야 하기 때문이다.
흐름을 정리하면 다음과 같다.
- 회원가입: 평문 비밀번호 입력 →
encode()실행 → 해시 문자열 저장- 로그인: 평문 비밀번호 입력 → 저장된 해시 문자열 조회 →
matches()실행 → 일치 여부 확인회원가입에서는 비밀번호를 해시해서 저장하고, 로그인에서는 저장된 해시 문자열의 조건으로 입력 비밀번호를 다시 계산해 비교한다.
이 흐름을 이해해야 이후DB기반 로그인이나JPA연동 로그인에서 비밀번호 검증 구조를 자연스럽게 이해할 수 있다.
패스워드 암호화 핵심 정리
PasswordEncoder는 비밀번호를 안전하게 해시하고 비교하기 위한Spring Security의 핵심 인터페이스이다.
encode()는 평문 비밀번호를 저장 가능한 해시 문자열로 바꾸고,matches()는 입력 비밀번호와 저장된 해시 문자열이 맞는지 비교한다.
비밀번호는 복호화해서 확인하지 않는다.
저장된 해시 문자열을 원래 비밀번호로 되돌리는 방식이 아니라, 입력된 비밀번호를 저장된 해시 문자열의 조건으로 다시 계산해 보고 결과가 맞는지 확인한다.
BCryptPasswordEncoder는bcrypt해시 방식을 사용하는 대표 구현체이다.
salt가 적용되기 때문에 같은 비밀번호라도 해시 결과가 달라질 수 있고, 이 특징은 같은 비밀번호를 사용한 사용자끼리도 저장값이 같아지는 것을 막는 데 도움이 된다.
또 공격자가 미리 만들어 둔 해시 목록이나 무차별 대입 방식으로 비밀번호를 맞히는 것을 어렵게 만든다.
비밀번호는 절대 평문으로 저장하면 안 되고, 항상 안전한 해시 방식으로 바꿔 저장해야 한다.
security9는 앞에서 정리한 비밀번호 암호화 흐름을 확인한 뒤, 직접 만든 로그인 화면에Remember-Me자동 로그인 기능을 추가하는 예제이다.
비밀번호 암호화는PasswordEncryptionExample로 이미 확인했고, 이 프로젝트에서 새로 집중해야 할 부분은Remember-Me설정이다.
Remember-Me는 사용자가 로그인할 때자동 로그인을 선택하면, 일반 세션이 사라진 뒤에도 일정 시간 동안 다시 인증 상태를 복원할 수 있게 돕는 기능이다.
일반 로그인은 보통JSESSIONID세션 쿠키를 기준으로 로그인 상태를 유지한다.
하지만Remember-Me를 사용하면remember-me쿠키가 추가로 만들어지고, 이 쿠키를 통해 다시 인증된 사용자로 복원될 수 있다.
security9의 핵심은remember-me체크박스와SecurityConfig의rememberMe()설정을 연결하고, 세션 쿠키가 사라져도 자동 로그인 쿠키로 인증이 복원되는 흐름을 확인하는 것이다.
security9 프로젝트에서 확인할 핵심
PasswordEncoder 확인 후 Remember-Me로 넘어간다
security9프로젝트에는PasswordEncryptionExample이 포함되어 있다.
이 예제는BCryptPasswordEncoder로 비밀번호를 암호화하고,matches()로 올바른 비밀번호와 잘못된 비밀번호를 비교하는 흐름을 보여준다.
하지만 이 내용은 앞의# 패스워드 암호화와 PasswordEncoder구간에서 이미 정리했다.
따라서security9본문에서는 암호화 예제를 길게 반복하지 않고, 프로젝트 안에 들어 있는 확인용 예제라는 정도로만 연결한다.
security9에서 실제로 새롭게 확인할 흐름은 다음과 같다.
Security9Application으로 프로젝트를 실행한다.application.yml에서 기본 로그인 계정을 확인한다.SecurityConfig에서 커스텀 로그인과Remember-Me를 설정한다.login.html에서자동 로그인체크박스를 추가한다.- 로그인 성공 후
remember-me쿠키가 생성되는지 확인한다.JSESSIONID를 삭제해도remember-me쿠키로 인증이 복원되는지 확인한다.- 로그아웃하면 세션과 자동 로그인 정보가 정리되는지 확인한다.
이 흐름을 보면
Remember-Me가 단순히 체크박스 하나를 추가하는 기능이 아니라, 로그인 요청, 쿠키 생성, 세션 소멸 후 인증 복원, 로그아웃 시 자동 로그인 정보 정리까지 연결되는 보안 기능이라는 것을 알 수 있다.
security4와 security9의 차이
security4에서는 직접 만든 로그인 화면과 로그아웃 흐름을 확인했다.
로그인에 성공하면 세션을 기준으로 인증 상태가 유지되었다.
브라우저가JSESSIONID쿠키를 보내면 서버는 해당 세션을 찾아 로그인 상태를 확인할 수 있었다.
security9에서는 여기에Remember-Me가 추가된다.
Remember-Me는 일반 세션 로그인과 완전히 같은 것은 아니다.
세션이 사라졌을 때도remember-me쿠키가 남아 있으면Spring Security가 이 쿠키를 이용해 사용자를 다시 인증된 상태로 복원할 수 있다.
쉽게 말하면 일반 로그인은 “현재 세션 동안 로그인 상태를 유지하는 방식”이다.
Remember-Me는 “세션이 사라져도 정해진 기간 동안 자동으로 다시 로그인 상태를 복원할 수 있는 방식”이다.
Remember-Me는 세션을 대신하는 기능이 아니라, 세션이 없어진 뒤에도 사용자를 다시 인증할 수 있게 도와주는 보조 인증 기능이다.
PasswordEncryptionExample은 비밀번호 암호화 확인용 예제이다
프로젝트 안에 포함된 확인용 실행 코드이다
PasswordEncryptionExample은BCryptPasswordEncoder로 비밀번호 암호화와 비교를 확인하는 독립 실행 예제이다.
이 코드는 웹 로그인 화면과 직접 연결되어 동작하는 코드는 아니다.
main()메서드를 직접 실행해서 콘솔 결과를 확인한다.
이 예제에서 확인하는 흐름은 다음과 같다.
- 원본 비밀번호
unico123을 준비한다.BCryptPasswordEncoder로 원본 비밀번호를 저장용 해시 문자열로 바꾼다.matches()로unico123이 저장된 해시 문자열과 일치하는지 확인한다.- 잘못된 비밀번호
unico999를 비교하면false가 나오는지 확인한다.
BCryptPasswordEncoder의 암호화 결과는 실행할 때마다 달라질 수 있다.
하지만matches()는 저장된 해시 문자열에 포함된salt와 설정 정보를 기준으로 입력 비밀번호를 다시 계산해 비교할 수 있다.
이 예제는Remember-Me자체를 구현하는 코드는 아니다.
다만 실제 로그인 시스템에서는 비밀번호를 평문으로 저장하지 않고 안전하게 비교해야 하므로,security9프로젝트 안에서 함께 확인하는 기초 예제로 보면 된다.
Security9Application은 프로젝트 실행 시작 클래스이다
실행 클래스의 역할
Security9Application은security9프로젝트를 실행하는 시작 클래스이다.
이 파일에는 로그인 설정이나Remember-Me설정이 들어 있지 않다.
main()메서드에서SpringApplication.run()을 호출해Spring Boot애플리케이션을 실행한다.
// Security9Application.java package com.example.security9; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class Security9Application { public static void main(String[] args) { // Spring Boot 애플리케이션을 실행한다. SpringApplication.run(Security9Application.class, args); } }
Security9Application은 애플리케이션을 시작하는 역할만 한다.
보안 규칙은SecurityConfig에서 작성한다.
로그인 화면은LoginController와login.html이 담당한다.
로그인 성공 후 화면은MemberController와member.html이 담당한다.
즉, 실행 클래스는 출발점이고, 실제 로그인과 자동 로그인 흐름은 다른 클래스와 화면 파일이 나누어 담당한다.
application.yml은 기본 사용자 정보를 설정한다
security9의 기본 로그인 계정
application.yml에는 애플리케이션 이름과 기본 사용자 정보가 들어 있다.
security9에서는 이 기본 사용자 정보로 로그인한다.
# application.yml spring: application: name: security9 security: user: name: unico password: 1234 roles: USER이 설정에서 기본 로그인 사용자는
unico이다.
비밀번호는1234이다.
권한은USER이다.
server.port설정은 따로 작성되어 있지 않다.
따라서 기본 포트인8080으로 실행된다.
브라우저에서는http://localhost:8080/login으로 로그인 화면에 접속한다.
이 계정 정보는Spring Security가 인증할 때 사용한다.
따라서 로그인 화면에서 계정에unico, 암호에1234를 입력하면 인증에 성공할 수 있다.
SecurityConfig에서 로그인과 Remember-Me를 설정한다
SecurityConfig 전체 코드
SecurityConfig는security9의 보안 설정 클래스이다.
@Configuration이 붙어 있으므로Spring은 이 클래스를 설정 클래스로 읽는다.
이 설정에서는/login만 인증 없이 접근할 수 있게 열어 둔다.
그 밖의 요청은 로그인한 사용자만 접근할 수 있다.
또한 커스텀 로그인 화면과 로그인 처리 주소,Remember-Me, 로그아웃 흐름을 함께 설정한다.
// SecurityConfig.java package com.example.security9.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.web.SecurityFilterChain; @Configuration public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // HTTP 요청에 대한 보안 설정을 작성한다. .authorizeHttpRequests(requests -> requests // /login은 인증 없이 접근할 수 있다. .requestMatchers("/login").permitAll() // 그 밖의 요청은 인증이 필요하다. .anyRequest().authenticated()) // 실습 편의를 위해 CSRF 방어 기능을 비활성화한다. .csrf((csrf) -> csrf.disable()) // 폼 기반 로그인 설정을 작성한다. .formLogin(form -> form // 직접 만든 로그인 페이지 URL을 지정한다. .loginPage("/login") // 로그인 인증을 처리할 URL을 지정한다. .loginProcessingUrl("/authentication") // 사용자명 input의 name 값을 지정한다. .usernameParameter("usernameInput") // 비밀번호 input의 name 값을 지정한다. .passwordParameter("passwordInput") // 로그인 성공 시 이동할 URL을 지정한다. .defaultSuccessUrl("/", true) // 로그인 실패 시 이동할 URL을 지정한다. .failureUrl("/login?error")) // Remember-Me 자동 로그인 설정을 작성한다. .rememberMe(remember -> remember // Remember-Me 토큰을 만들고 검증할 때 사용할 key를 지정한다. .key("security9-remember-me-key") // 로그인 화면의 체크박스 name 값과 연결한다. .rememberMeParameter("remember-me") // 자동 로그인 유지 시간을 1시간으로 설정한다. .tokenValiditySeconds(60 * 60 * 1)) // 로그아웃 설정을 작성한다. .logout(logout -> logout // 로그아웃을 처리할 URL을 지정한다. .logoutUrl("/logout") // 로그아웃 성공 시 이동할 URL을 지정한다. .logoutSuccessUrl("/login?logout") // 로그아웃 시 세션을 무효화한다. .invalidateHttpSession(true) // 로그아웃 시 세션 쿠키를 삭제한다. .deleteCookies("JSESSIONID")); // 설정한 내용으로 SecurityFilterChain을 만든다. SecurityFilterChain chain = http.build(); // 생성된 보안 필터 목록을 콘솔에 출력한다. chain.getFilters().forEach(System.out::println); // 완성된 보안 필터 체인을 반환한다. return chain; } }이 코드에서
authorizeHttpRequests()는 요청별 접근 규칙을 설정한다.
/login은 로그인 화면이므로 인증되지 않은 사용자도 접근할 수 있어야 한다.
그래서permitAll()로 열어 둔다.
그 밖의 요청은authenticated()가 적용되어 로그인한 사용자만 접근할 수 있다.
csrf((csrf) -> csrf.disable())은CSRF방어 기능을 비활성화하는 설정이다.
이번 실습에서는 로그인과 로그아웃 흐름을 단순하게 확인하기 위해 비활성화했다.
이 설정 때문에member.html의 버튼처럼GET /logout방식으로도 로그아웃 테스트가 가능하다.
formLogin()은 폼 로그인 방식을 설정한다.
loginPage("/login")은 직접 만든 로그인 화면 주소이다.
loginProcessingUrl("/authentication")은 로그인 정보를 제출할 주소이다.
이 요청은Controller가 직접 처리하지 않고Spring Security필터가 처리한다.
usernameParameter("usernameInput")과passwordParameter("passwordInput")은 로그인 폼의 입력 필드 이름과 맞춰 주는 설정이다.
이 이름이login.html의th:field결과와 맞아야Spring Security가 요청에서 계정과 암호를 정확히 꺼낼 수 있다.
defaultSuccessUrl("/", true)는 로그인 성공 후 항상/로 이동하게 한다.
failureUrl("/login?error")는 로그인 실패 후 다시 로그인 화면으로 돌아가게 한다.
이때error파라미터가 붙으므로 로그인 화면에서 실패 메시지를 보여줄 수 있다.
Remember-Me 설정 자세히 보기
rememberMe()는 자동 로그인 기능을 설정한다.
자동 로그인은 사용자가 로그인할 때 체크박스를 선택하면, 이후 세션이 사라져도 일정 시간 동안 다시 인증 상태를 복원할 수 있게 하는 기능이다.
key("security9-remember-me-key")는Remember-Me토큰을 만들고 검증할 때 사용하는 기준 값이다.
이 값은 서버가 자동 로그인 쿠키를 신뢰할 수 있는지 판단할 때 사용된다.
프로젝트마다 예측하기 어려운 값으로 관리하는 것이 좋다.
rememberMeParameter("remember-me")는 로그인 화면의 체크박스 이름과 연결된다.
즉,login.html의 체크박스name값이remember-me여야 한다.
사용자가 이 체크박스를 선택하면 요청 파라미터에remember-me값이 포함되고,Spring Security는 자동 로그인 기능을 켜야 한다고 판단한다.
tokenValiditySeconds(60 * 60 * 1)은 자동 로그인 유지 시간을 초 단위로 설정한다.
60 * 60 * 1은3600초이고, 1시간을 의미한다.
즉, 자동 로그인 쿠키는 설정된 시간 동안 사용자를 다시 인증하는 데 사용될 수 있다.
로그아웃 설정에서.deleteCookies("JSESSIONID")는 세션 쿠키를 삭제하는 설정이다.
여기에는remember-me쿠키 이름이 직접 적혀 있지 않다.
하지만rememberMe()설정이 적용되어 있으면Spring Security의 로그아웃 처리 과정에서 자동 로그인에 사용하던remember-me쿠키도 함께 무효화된다.
그래서 로그아웃 후에는 세션 인증뿐 아니라 자동 로그인 인증도 더 이상 유지되지 않는다.
Remember-Me에서 가장 중요한 연결점은 로그인 화면 체크박스의name값과SecurityConfig의rememberMeParameter()값이 같아야 한다는 점이다.
LoginForm은 로그인 입력 필드와 연결된다
LoginForm 코드
LoginForm은 로그인 화면의 입력 필드와 연결되는 폼 객체이다.
usernameInput은 계정 입력값이고,passwordInput은 암호 입력값이다.
// LoginForm.java package com.example.security9.form; import lombok.Data; @Data public class LoginForm { /** 사용자명 */ private String usernameInput; /** 비밀번호 */ private String passwordInput; }
LoginForm은 인증을 직접 처리하지 않는다.
이 객체는login.html에서 입력 필드를 만들 때 사용된다.
th:field="*{usernameInput}"을 사용하면 입력 필드의name값이usernameInput으로 만들어진다.
th:field="*{passwordInput}"을 사용하면 입력 필드의name값이passwordInput으로 만들어진다.
이 이름은SecurityConfig의usernameParameter("usernameInput"),passwordParameter("passwordInput")과 맞아야 한다.
이렇게 맞춰야 로그인 요청이/authentication으로 전송되었을 때Spring Security가 계정과 암호를 제대로 읽을 수 있다.
LoginController는 로그인 화면을 반환한다
LoginController 코드
LoginController는/login요청을 처리한다.
이 요청은 로그인하지 않은 사용자도 접근해야 하므로SecurityConfig에서permitAll()로 허용했다.
// LoginController.java package com.example.security9.controller; import com.example.security9.form.LoginForm; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; @Controller public class LoginController { @GetMapping("/login") public String showLogin(@ModelAttribute LoginForm form) { // login.html 화면을 반환한다. return "login"; } }
showLogin()메서드는login이라는 뷰 이름을 반환한다.
Spring MVC와Thymeleaf는 이 값을 바탕으로templates/login.html을 찾아 응답한다.
@ModelAttribute LoginForm form은login.html에서 사용할 폼 객체를 준비한다.
이 객체가 있어야th:object="${loginForm}"과th:field를 이용해 입력 필드를 자연스럽게 연결할 수 있다.
여기서 중요한 점은/login과/authentication의 역할이 다르다는 것이다.
/login은 로그인 화면을 보여 주는 주소이다.
/authentication은 로그인 정보를 제출하는 주소이다.
/authentication요청은Controller가 아니라Spring Security인증 필터가 처리한다.
login.html에서 Remember-Me 체크박스 추가하기
login.html 코드
login.html은 사용자가 보는 로그인 화면이다.
기존 계정 입력칸과 암호 입력칸에 더해자동 로그인체크박스가 추가되어 있다.
<!-- login.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>로그인</title> </head> <body> <h2>로그인 화면(RememberMe 기능 테스트)</h2> <!-- 로그인 실패 시 표시한다. --> <div th:if="${param.error}"> <p style="color: red;">사용자명이나 비밀번호가 올바르지 않습니다.</p> </div> <!-- 로그아웃 성공 시 표시한다. --> <div th:if="${param.logout}"> <p style="color: blue">로그아웃했습니다.</p> </div> <form th:action="@{/authentication}" method="post" th:object="${loginForm}"> <div> <label>계정</label> <input type="text" th:field="*{usernameInput}"> </div> <div> <label>암호</label> <input type="password" th:field="*{passwordInput}"> </div> <!-- Remember-Me 자동 로그인 체크박스이다. --> <div> <label> <input type="checkbox" name="remember-me"> 자동 로그인 </label> </div> <hr> <div> <input type="submit" value="로그인"> </div> </form> </body> </html>
th:action="@{/authentication}"은 로그인 폼을/authentication주소로 제출한다는 뜻이다.
이 주소는SecurityConfig의loginProcessingUrl("/authentication")과 같아야 한다.
th:field="*{usernameInput}"은 계정 입력칸을LoginForm의usernameInput필드와 연결한다.
th:field="*{passwordInput}"은 암호 입력칸을LoginForm의passwordInput필드와 연결한다.
이 두 이름은SecurityConfig의usernameParameter(),passwordParameter()와 맞아야 한다.
<input type="checkbox" name="remember-me">는 자동 로그인 체크박스이다.
여기서name값이 중요하다.
이 값이SecurityConfig의rememberMeParameter("remember-me")와 같아야 한다.
그래야 사용자가 체크박스를 선택했을 때Spring Security가 자동 로그인 요청으로 인식할 수 있다.
th:if="${param.error}"는 로그인 실패 시 메시지를 보여준다.
로그인 실패 후/login?error로 이동하기 때문에error파라미터가 있으면 실패 메시지가 표시된다.
th:if="${param.logout}"는 로그아웃 성공 시 메시지를 보여준다.
로그아웃 성공 후/login?logout으로 이동하기 때문에logout파라미터가 있으면 로그아웃 메시지가 표시된다.
로그인 화면에는 계정 입력칸, 암호 입력칸,
자동 로그인체크박스가 있다.
자동 로그인체크박스의name값은remember-me이고, 이 이름은SecurityConfig의rememberMeParameter("remember-me")설정과 연결된다.
사용자가 이 체크박스를 선택하고 로그인하면Spring Security는 자동 로그인에 사용할remember-me쿠키를 생성할 수 있다.
MemberController와 member.html 흐름
MemberController 코드
MemberController는 로그인 성공 후 이동할/요청을 처리한다.
SecurityConfig에서defaultSuccessUrl("/", true)로 설정했기 때문에, 로그인에 성공하면/주소로 이동한다.
// MemberController.java package com.example.security9.controller; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; @Controller public class MemberController { @GetMapping("/") public String member() { // member.html 화면을 반환한다. return "member"; } }
member()메서드는member라는 뷰 이름을 반환한다.
Spring MVC와Thymeleaf는templates/member.html을 찾아 응답한다.
/요청은/login이 아니므로 인증이 필요하다.
따라서 로그인하지 않은 상태에서는member.html을 볼 수 없다.
로그인에 성공했거나,Remember-Me로 인증 상태가 복원된 경우에만 회원 페이지를 볼 수 있다.
member.html 코드
member.html은 로그인 성공 후 보이는 회원 페이지이다.
이미지 링크와 로그아웃 버튼이 있다.
<!-- member.html --> <!DOCTYPE html> <html xmlns:th="http://www.thymeleaf.org"> <head> <title>회원페이지</title> <style> a { text-decoration : none; } </style> </head> <body> <h2>로그인을 했군요!!</h2> <hr> <a href="/images/duke3d.png">로그인했으니 볼 수 있어요... 클릭해 보세요..</a><br> <hr> <button onclick="location.href='http://localhost:8080/logout'">로그아웃</button> </body> </html>회원 페이지에는
/images/duke3d.png로 이동하는 링크가 있다.
현재 보안 설정에서는/login만 공개되어 있다.
그 밖의 요청은 모두 인증이 필요하다.
따라서/images/duke3d.png도 로그인 상태이거나Remember-Me로 인증이 복원된 상태에서 접근할 수 있는 보호 리소스이다.
로그아웃 버튼은http://localhost:8080/logout으로 이동한다.
현재 실습은 기본 포트8080에서 실행하므로 이 주소로 로그아웃을 테스트할 수 있다.
또한 이 실습에서는CSRF방어 기능을 비활성화했기 때문에 버튼 클릭으로 발생하는GET /logout요청도 로그아웃 처리에 사용할 수 있다.
/logout요청은Controller가 처리하지 않는다.
Spring Security의 로그아웃 필터가 처리한다.
로그아웃에 성공하면logoutSuccessUrl("/login?logout")설정에 따라 로그인 화면으로 돌아간다.
Remember-Me 실행 흐름 확인
자동 로그인 체크 후 로그인했을 때의 흐름
브라우저에서
http://localhost:8080/login으로 접속한다.
계정에는unico, 암호에는1234를 입력한다.
그 다음자동 로그인체크박스를 선택하고 로그인 버튼을 누른다.
로그인 요청은/authentication으로 전송된다.
이 요청은LoginController가 처리하지 않는다.
Spring Security의 인증 필터가 계정과 암호를 꺼내 인증을 수행한다.
인증에 성공하면defaultSuccessUrl("/", true)설정에 따라/로 이동한다.
/요청은MemberController가 처리하고, 최종적으로member.html이 응답된다.
사용자가
unico / 1234를 입력하고자동 로그인을 체크한 뒤 로그인하면 인증이 성공한다.
로그인 성공 후에는SecurityConfig의defaultSuccessUrl("/", true)설정에 따라/주소로 이동한다.
/요청은MemberController가 처리하고, 최종적으로member.html회원 페이지가 응답된다.
로그인 성공 후 remember-me 쿠키가 만들어지는지 확인한다
자동 로그인을 체크하고 로그인에 성공하면 브라우저 쿠키 목록에서
remember-me쿠키를 확인할 수 있다.
개발자 도구의Application탭에서Cookies를 열고http://localhost:8080을 선택하면 현재 브라우저에 저장된 쿠키 목록이 보인다.
일반 세션 로그인에서는JSESSIONID쿠키가 만들어진다.
Remember-Me를 체크하고 로그인하면 여기에 더해remember-me쿠키도 생성된다.
이 쿠키가 자동 로그인 복원에 사용된다.
자동 로그인을 체크하고 로그인하면 브라우저 쿠키 목록에
remember-me쿠키가 생성된다.
일반 로그인 세션을 나타내는JSESSIONID와 별도로remember-me쿠키가 만들어진다.
remember-me쿠키는 세션이 사라진 뒤에도 사용자를 다시 인증된 상태로 복원할 때 사용된다.
세션이 사라진 뒤에도 Remember-Me로 다시 인증되는 흐름을 확인한다
Remember-Me가 실제로 동작하는지 확인하려면JSESSIONID만 삭제해 보면 된다.
JSESSIONID는 일반 세션 로그인 상태를 찾기 위한 쿠키이다.
이 쿠키를 삭제하면 일반 세션 기준으로는 로그인 상태를 잃은 것처럼 볼 수 있다.
하지만remember-me쿠키가 남아 있으면 상황이 달라진다.
브라우저가 다시 보호된 페이지를 요청할 때remember-me쿠키를 함께 보내면,Spring Security가 이 쿠키를 확인한다.
쿠키가 유효하면 사용자를 다시 인증된 상태로 복원한다.
그리고 인증이 복원되는 과정에서 새로운 세션이 다시 만들어질 수 있다.
테스트 흐름은 다음과 같다.
자동 로그인을 체크하고 로그인한다.- 개발자 도구에서
JSESSIONID쿠키만 삭제한다.remember-me쿠키는 삭제하지 않는다./페이지를 새로고침한다.- 로그인 화면으로 이동하지 않고 회원 페이지가 다시 열리는지 확인한다.
이 흐름이 성공하면 일반 세션 쿠키가 사라져도
Remember-Me로 인증이 복원된다는 뜻이다.
단순히 예전 세션이 살아 있는 것이 아니라, 남아 있는remember-me쿠키를 기준으로 사용자를 다시 인증하는 흐름이다.
JSESSIONID는 일반 세션 로그인 상태를 찾기 위한 쿠키이다.
이 값을 삭제하면 일반 세션 기준으로는 로그인 상태를 잃은 것처럼 볼 수 있다.
하지만remember-me쿠키가 남아 있으면Spring Security가 이 쿠키를 확인해 사용자를 다시 인증된 상태로 복원한다.
그래서JSESSIONID를 삭제한 뒤에도 보호된 회원 페이지에 다시 접근할 수 있다.
로그아웃하면 Remember-Me 인증 정보도 정리되는지 확인한다
마지막으로 로그아웃 흐름을 확인한다.
회원 페이지에서 로그아웃 버튼을 누르면/logout요청이 발생한다.
이 요청은Spring Security로그아웃 필터가 처리한다.
로그아웃은 단순히 화면만 이동시키는 기능이 아니다.
현재 인증 상태를 제거하고, 자동 로그인에 사용되는 정보도 정리하는 보안 처리이다.
로그아웃 후에는logoutSuccessUrl("/login?logout")설정에 따라/login?logout으로 이동한다.
여기서 주의할 점은 코드에 직접 적힌 쿠키 삭제 설정은.deleteCookies("JSESSIONID")라는 점이다.
이 설정은 세션 쿠키를 삭제한다.
그리고rememberMe()설정이 함께 적용되어 있기 때문에, 로그아웃 과정에서Spring Security가remember-me쿠키도 무효화한다.
따라서 로그아웃 후에는remember-me쿠키로 다시 자동 인증되지 않는다.
login.html에는th:if="${param.logout}"조건이 있다.
그래서 주소에logout파라미터가 있으면로그아웃했습니다.메시지가 표시된다.
로그아웃은 단순히 화면만 로그인 페이지로 이동시키는 기능이 아니다.
현재 인증 상태를 제거하고, 자동 로그인에 사용되던remember-me정보도 함께 정리하는 보안 처리이다.
로그아웃 후에는/login?logout으로 이동하고, 로그인 화면에서로그아웃했습니다.메시지를 확인할 수 있다.
security9 핵심 정리
security9에서는PasswordEncoder확인 예제와Remember-Me자동 로그인 기능을 함께 확인한다.
비밀번호는 흔히 암호화한다고 표현하지만, 실제 저장 흐름에서는 원본 비밀번호를 복호화할 수 없는 저장용 해시 문자열로 바꾸고matches()로 비교한다.
웹 로그인 흐름에서는SecurityConfig가/login,/authentication,/logout흐름을 설정한다.
/login은 로그인 화면을 보여 주는 주소이고,/authentication은Spring Security가 로그인 인증을 처리하는 주소이다.
/logout은 로그아웃 필터가 처리하는 주소이다.
Remember-Me는 사용자가자동 로그인체크박스를 선택했을 때 동작한다.
login.html의 체크박스name값은remember-me이고,SecurityConfig의rememberMeParameter("remember-me")와 연결된다.
이 이름이 맞아야 자동 로그인 요청이 제대로 인식된다.
자동 로그인에 성공하면 브라우저에는remember-me쿠키가 생성된다.
일반 세션 쿠키인JSESSIONID가 사라져도remember-me쿠키가 유효하면Spring Security가 사용자를 다시 인증된 상태로 복원할 수 있다.
반대로 로그아웃을 하면 현재 세션 인증과 자동 로그인 정보가 정리되므로,remember-me쿠키로 다시 인증되지 않는다.
security9의 핵심은 비밀번호는PasswordEncoder로 저장용 해시 문자열을 만들고 비교하며, 자동 로그인은remember-me쿠키를 통해 세션이 사라진 뒤에도 인증 상태를 복원할 수 있다는 점이다.
웹에서 사용자가 한 번 로그인했다고 해서 서버가 자동으로 계속 그 사용자를 기억하는 것은 아니다.
웹 요청은 기본적으로 각각 독립적으로 처리된다.
사용자가 로그인 요청을 한 번 보내고, 그 다음에 마이페이지 요청을 보내면 서버 입장에서는 두 요청이 서로 다른 요청으로 들어온다.
그래서 서버는 “이 요청을 보낸 사람이 방금 로그인한 사람인가?”를 확인할 방법이 필요하다.
이때 함께 알아야 하는 대표 개념이Cookie,Session,Token이다.
세 개념은 모두 사용자를 식별하기 위한 방법과 관련되어 있지만, 인증 정보를 어디에 저장하는지와 요청할 때 어떤 값을 서버에 보내는지가 다르다.
다만 처음부터 한 가지를 구분해야 한다.
Cookie는 그 자체로 완성된 인증 방식이라기보다, 값을 브라우저에 저장하고 서버로 다시 보내는 수단에 가깝다.
Session ID도Cookie에 담겨 전달될 수 있고,Token도 상황에 따라Cookie에 저장될 수 있다.
또Token이라는 말도 넓게 사용된다.
어떤 토큰은 서버 저장소를 조회해야 의미를 확인할 수 있고, 어떤 토큰은JWT처럼 토큰 자체에 사용자 정보와 서명 정보를 담아 서버가 검증할 수 있다.
이 글에서는 뒤에서 다룰JWT를 이해하기 위해, 주로 서버가 토큰을 검증해서 사용자를 판단하는 흐름을 중심으로 정리한다.
Cookie,Session,Token의 차이는 로그인 상태를 누가 보관하는지, 그리고 요청마다 어떤 값을 서버에 보내는지로 이해하면 된다.
인증 상태를 유지해야 하는 이유
웹 요청은 기본적으로 독립적이다
웹에서 브라우저가 서버에 요청을 보낼 때, 서버는 요청 하나를 받아 처리하고 응답을 돌려준다.
그 다음에 또 다른 요청이 오면 서버는 새로운 요청으로 처리한다.
예를 들어 사용자가 로그인한 뒤 마이페이지로 이동한다고 생각하면 된다.
첫 번째 요청은 로그인 요청이다.
두 번째 요청은 마이페이지 요청이다.
이 두 요청은 서로 다른 요청이다.
서버가 아무런 식별 정보를 받지 못하면, 두 번째 요청이 방금 로그인한 사용자의 요청인지 알기 어렵다.
그래서 로그인 이후의 요청에도 사용자를 확인할 수 있는 값이 필요하다.
그 값이Cookie일 수도 있고,Session ID일 수도 있고,Token일 수도 있다.
인증 방식은 사용자를 기억하는 방식의 차이이다
Cookie,Session,Token은 모두 사용자를 기억하기 위한 방식과 관련되어 있다.
하지만 기억하는 위치와 전달하는 값이 다르다.
Cookie는 브라우저가 값을 저장하고, 요청할 때 다시 서버로 보내는 방식이다.
Session은 서버가 로그인 상태를 저장하고, 브라우저는 그 세션을 찾기 위한Session ID만 보내는 방식이다.
Token은 서버가 발급한 인증 문자열을 클라이언트가 저장하고, 이후 요청마다 그 토큰을 보내는 방식이다.
정리하면 다음과 같다.
Cookie는 브라우저에 저장되는 작은 정보이다.Session은 서버에 저장되는 로그인 상태 정보이다.Token은 인증된 사용자임을 증명하는 문자열이다.세 개념은 서로 완전히 무관하지 않다.
예를 들어Session ID는 보통Cookie에 담겨 브라우저와 서버 사이를 오간다.
Token도 브라우저 저장소나Cookie에 저장할 수 있다.
따라서 처음에는 저장 위치와 전달 흐름을 중심으로 구분하는 것이 좋다.
Cookie 기반 상태 유지
Cookie는 브라우저에 저장되는 작은 정보이다
Cookie는 서버가 브라우저에 저장시킬 수 있는 작은 문자열 정보이다.
보통Key-Value형태로 저장된다.
Key-Value는 이름과 값이 한 쌍으로 묶인 구조이다.
예를 들어language=english처럼language라는 이름에english라는 값이 들어갈 수 있다.
브라우저는 서버가 내려준Cookie를 저장해 둔다.
그리고 같은 서버로 다시 요청을 보낼 때 저장된Cookie를 요청 헤더에 함께 담아 보낼 수 있다.
요청 헤더는 서버에 요청을 보낼 때 함께 전달되는 부가 정보라고 이해하면 된다.
여기서 중요한 점은Cookie자체가 항상 로그인 인증 방식이라는 뜻은 아니라는 점이다.
Cookie는 값을 저장하고 전달하는 수단이다.
그 안에 사용자의 언어 설정을 담을 수도 있고, 세션을 찾기 위한Session ID를 담을 수도 있고, 상황에 따라 인증 토큰을 담을 수도 있다.
브라우저가 서버로 요청을 보낼 때
Request Headers안에cookie값이 포함될 수 있다.
Cookie는 단순히 브라우저 안에만 저장되어 있는 값이 아니다.
같은 서버로 요청할 때 다시 서버에 전달될 수 있는 값이다.
서버는 요청에 포함된Cookie를 보고 사용자의 이전 방문 정보, 언어 설정, 로그인 식별 정보 같은 상태를 이어서 처리할 수 있다.
Cookie는 Set-Cookie로 저장되고 Cookie로 다시 전달된다
처음 요청에서는 브라우저가 서버에 일반 요청을 보낸다.
서버는 응답을 돌려주면서Set-Cookie헤더에 값을 담아 보낼 수 있다.
Set-Cookie는 “브라우저야, 이 값을 저장해 둬”라는 의미로 볼 수 있다.
브라우저는 서버가 보낸 값을 저장한다.
그 다음 같은 서버로 다시 요청할 때는 저장해 둔 값을Cookie헤더에 담아 보낸다.
이 흐름 때문에 서버는 사용자가 이전에 어떤 값을 받았는지 확인할 수 있다.
처음에는 브라우저가 서버에 요청을 보낸다.
서버는 응답하면서Set-Cookie에 값을 담아 브라우저에 저장시킨다.
이후 브라우저는 같은 서버로 다시 요청할 때 저장해 둔Cookie값을 요청에 함께 보낸다.
예를 들어language=english같은 값은 사용자의 언어 설정을 기억하는 데 사용할 수 있고, 세션 식별값은 서버가 사용자를 구분하는 데 사용할 수 있다.
Cookie 방식의 장점과 한계
Cookie는 브라우저가 자동으로 저장하고 요청마다 다시 보내 줄 수 있기 때문에 상태 유지에 편리하다.
사용자 언어 설정, 팝업 하루 동안 보지 않기, 장바구니 식별 정보처럼 여러 곳에서 사용할 수 있다.
하지만Cookie는 클라이언트, 즉 브라우저 쪽에 저장된다.
클라이언트는 사용자가 직접 접근할 수 있는 환경이다.
그래서 민감한 정보를 그대로 넣으면 위험하다.
예를 들어Cookie에 비밀번호나 주민등록번호 같은 값을 그대로 저장하면 안 된다.
브라우저에 저장된 값은 탈취될 수 있고, 개발자 도구 등을 통해 확인될 수도 있다.
또한 설정에 따라 조작 위험도 생길 수 있다.
Cookie는 상태 유지에 편리하지만, 브라우저에 저장되는 값이므로 민감한 정보를 그대로 담으면 안 된다.
Session 인증
Session은 서버가 로그인 상태를 저장하는 방식이다
Session은 서버가 로그인 상태를 저장하는 방식이다.
사용자가 로그인에 성공하면 서버는 “이 사용자는 로그인했다”는 정보를 서버 쪽 저장소에 만든다.
이 저장된 정보가 세션 정보이다.
브라우저에는 실제 로그인 정보 전체를 저장하지 않는다.
대신 서버에 있는 세션 정보를 찾기 위한 식별자만 보낸다.
이 식별자가Session ID이다.
Spring기반 웹 애플리케이션에서는 대표적으로JSESSIONID라는 이름이 사용된다.
쉽게 말하면 서버에는 보관함이 있고, 브라우저는 그 보관함을 찾기 위한 번호표를 들고 있는 구조이다.
번호표가Session ID이고, 실제 로그인 정보는 서버의 세션 저장소에 있다.
서버의 세션 저장소에는
JSESSIONID를 기준으로 여러 사용자의 세션 정보가 저장될 수 있다.
브라우저가 실제 사용자 정보를 전부 들고 다니는 것이 아니라,JSESSIONID를 서버에 보낸다.
서버는 이 값을 이용해 세션 저장소에서 해당 사용자의creationTime,lastAccessedTime,userId같은 정보를 찾는다.
이 흐름으로 서버는 요청을 보낸 사용자가 로그인한 사용자인지 판단할 수 있다.
Session 인증 흐름
사용자가 로그인하면 서버는 먼저 아이디와 비밀번호를 확인한다.
사용자가 맞다면 서버는 세션을 생성한다.
그리고 생성된 세션을 찾을 수 있는Session ID를 브라우저에 전달한다.
브라우저는 이Session ID를 보통Cookie에 저장한다.
이후 요청을 보낼 때 브라우저는Cookie에 담긴Session ID를 함께 보낸다.
서버는 전달받은Session ID를 이용해 세션 저장소를 조회한다.
세션이 존재하면 서버는 이 요청을 로그인한 사용자의 요청으로 판단할 수 있다.
사용자가 로그인 요청을 보내면 서버는 로그인 정보를 확인하고 세션을 생성한다.
서버는 생성된 세션을 유지하고, 브라우저에는 세션을 찾을 수 있는 값을 전달한다.
이후 브라우저가 요청을 보내면 서버는 세션 저장소에서 해당 세션을 읽는다.
즉,Session방식의 핵심은 로그인 상태를 서버가 직접 보관한다는 점이다.
Session 방식의 장점과 단점
Session방식의 장점은 민감한 인증 정보를 서버에 보관한다는 점이다.
브라우저에는 실제 사용자 정보 전체가 아니라Session ID만 전달된다.
따라서 중요한 정보를 클라이언트에 직접 저장하는 방식보다 안전하게 관리할 수 있다.
하지만 단점도 있다.
서버가 로그인 상태를 저장해야 하므로 사용자 수가 많아질수록 세션 저장소 부담이 커질 수 있다.
서버가 여러 대로 늘어나는 구조에서는 모든 서버가 세션 정보를 공유해야 하는 문제도 생길 수 있다.
또한Session ID가 탈취되면 문제가 생긴다.
공격자가 다른 사용자의Session ID를 가지고 요청하면, 서버는 그 요청을 로그인한 사용자의 요청으로 오해할 수 있다.
그래서Session ID도 안전하게 관리해야 한다.
정리하면 다음과 같다.
Session은 로그인 정보를 서버에 저장한다.- 브라우저는
Session ID만 들고 다닌다.- 민감한 정보를 서버에 둘 수 있다는 장점이 있다.
- 서버 저장소 부담이 생긴다는 단점이 있다.
Session ID가 탈취되면 보안 문제가 생길 수 있다.
Session방식은 전통적인 웹 로그인에서 많이 사용된다.
화면 중심 웹 애플리케이션에서는 이해하기 쉽고 구현 흐름도 자연스럽다.
Token 인증
Token은 인증된 사용자임을 증명하는 문자열이다
Token은 인증된 사용자임을 증명하는 문자열이다.
사용자가 로그인에 성공하면 서버는 토큰을 발급한다.
클라이언트는 이 토큰을 저장해 두고, 이후 요청마다 토큰을 함께 보낸다.
Session방식은 서버가 로그인 상태를 저장한다.
반면Token방식은 서버가 로그인 상태를 세션 저장소에 계속 저장하지 않아도 되는 구조로 사용할 수 있다.
특히JWT처럼 서버가 자체적으로 검증할 수 있는 토큰을 사용하면, 서버는 요청에 포함된 토큰이 유효한지 확인해서 사용자를 판단할 수 있다.
여기서 “토큰을 확인한다”는 말은 단순히 문자열이 있는지만 본다는 뜻이 아니다.
서버는 토큰이 자신이 발급한 것인지, 중간에 위조되거나 변조되지 않았는지, 만료 시간이 지나지 않았는지 등을 검증한다.
특히JWT같은 토큰은 서버가 발급할 때 서명 정보를 함께 넣고, 이후 요청에서 전달된 토큰의 서명을 검증해 위조 여부를 확인할 수 있다.
Token방식에서는 로그인 성공 후 서버가 토큰을 생성해 클라이언트에게 전달한다.
클라이언트는 이 토큰을 저장해 두고, 이후 데이터를 요청할 때 토큰을 함께 보낸다.
서버는 세션 저장소에서 로그인 상태를 찾는 대신 전달받은 토큰이 유효한지 검증한다.
토큰이 유효하면 서버는 요청을 보낸 사용자를 인증된 사용자로 판단하고 응답을 돌려준다.
Token은 신분증처럼 이해할 수 있다
Session ID와Token은 둘 다 사용자를 식별하는 데 사용될 수 있다.
하지만 구조가 다르다.
Session ID는 서버의 세션 저장소를 찾기 위한 값이다.
그 자체만 보면 사용자 정보를 모두 알 수 있는 구조는 아니다.
서버가 세션 저장소를 조회해야 실제 사용자 정보를 확인할 수 있다.
반면JWT처럼 자체 정보를 담는Token은 사용자 식별 정보나 권한 정보 같은 내용을 담을 수 있다.
그래서 이런 토큰은 신분증에 비유할 수 있다.
신분증을 보면 이름이나 생년월일처럼 사용자를 판단할 수 있는 정보가 담겨 있다.
다만 신분증을 잃어버리면 다른 사람이 악용할 수 있는 것처럼, 토큰도 탈취되면 위험하다.
또 하나 주의할 점이 있다.
토큰 안에는 사용자 식별 정보나 권한 정보가 들어갈 수 있지만, 비밀번호 같은 민감한 정보를 그대로 넣으면 안 된다.
토큰은 클라이언트가 들고 다니는 값이므로 노출될 가능성을 항상 고려해야 한다.
Session ID는 서버의 세션 저장소를 조회해야 의미가 확인되는 값으로 볼 수 있다.
JWT처럼 자체 정보를 담는Token은 토큰 자체에 사용자 식별 정보나 권한 정보가 들어갈 수 있다.
그래서 서버가 세션 저장소를 조회하지 않아도 인증 판단에 필요한 정보를 확인할 수 있다.
하지만 토큰 안의 내용은 노출될 수 있으므로 민감한 정보를 그대로 넣으면 안 된다.
Token 방식의 장점과 단점
Token방식은 서버가 로그인 상태를 세션 저장소에 계속 저장하지 않아도 되는 구조로 만들 수 있다는 장점이 있다.
이 구조는 서버를 여러 대로 늘리거나, 모바일 앱과API서버가 통신하는 구조에서 유리하다.
예를 들어 클라이언트가React앱이고 서버가Spring BootREST API서버라면, 화면을 서버가 직접 만들어 주는 구조가 아니다.
클라이언트가 서버의API를 계속 호출한다.
이런 구조에서는 요청마다 토큰을 담아 보내고, 서버가 토큰을 검증하는 방식이 잘 맞는다.
하지만 토큰 방식도 위험이 있다.
토큰은 클라이언트가 저장한다.
클라이언트 쪽에서 토큰이 탈취되면, 공격자가 그 토큰을 이용해 인증된 사용자처럼 요청을 보낼 수 있다.
또 이미 발급된 토큰을 즉시 무효화하기 어려운 경우도 있다.
그래서 토큰에는 만료 시간을 설정해야 한다.
또 토큰 안에는 비밀번호 같은 민감한 정보를 넣으면 안 된다.
Token은 서버 확장에는 유리하지만, 탈취되면 악용될 수 있으므로 저장 위치와 만료 시간 관리가 중요하다.
Session 기반 인증 절차
Session 방식은 서버가 로그인 상태를 기억한다
Session기반 인증은 서버가 로그인 상태를 저장하는 구조이다.
사용자가 아이디와 비밀번호를 입력하면 서버는 회원DB에서 사용자를 확인한다.
사용자가 맞으면 서버는 세션 저장소에 로그인 정보를 만들고, 브라우저에는Session ID를 보내 준다.
이후 브라우저가 데이터를 요청할 때는Cookie에 담긴Session ID를 함께 보낸다.
서버는 이 값을 이용해 세션 저장소에서 사용자 정보를 찾는다.
사용자 정보가 확인되면 서버는 요청한 데이터를 응답한다.
Session기반 인증에서는 사용자가 로그인하면 서버가 회원DB에서 사용자를 확인한다.
사용자가 맞으면 서버는 세션 저장소에 회원 정보를 저장하고Session ID를 발급한다.
브라우저는 이후 데이터를 요청할 때Session ID가 담긴Cookie를 함께 보낸다.
서버는Cookie에서Session ID를 확인하고, 세션 저장소에서 사용자 정보를 가져온 뒤 요청 데이터를 응답한다.
Session 인증 흐름 정리
Session기반 인증 흐름을 단계별로 정리하면 다음과 같다.
- 사용자가 아이디와 비밀번호로 로그인한다.
- 서버는 회원
DB에서 사용자를 확인한다.- 사용자가 맞으면 세션 저장소에 회원 정보를 저장한다.
- 서버는
Session ID를 발급한다.- 응답에
Session ID가 포함된다.- 브라우저는 이후 요청에
Cookie로Session ID를 보낸다.- 서버는
Cookie에서Session ID를 확인한다.- 서버는 세션 저장소에서 사용자 정보를 가져온다.
- 서버는 요청 데이터를 응답한다.
이 구조에서 중요한 점은 서버가 로그인 상태를 기억한다는 것이다.
브라우저는Session ID만 보내고, 실제 로그인 정보는 서버 세션 저장소에서 관리된다.
Token 기반 인증 절차
Token 방식은 클라이언트가 토큰을 들고 요청한다
Token기반 인증은 서버가 발급한 토큰을 클라이언트가 저장하고, 이후 요청마다 함께 보내는 구조이다.
사용자가 로그인하면 서버는 회원DB에서 사용자를 확인한다.
사용자가 맞으면 서버는Access Token을 발급한다.
이Access Token은 서비스 구조에 따라JWT형식일 수 있다.
클라이언트는 이Access Token을 저장해 둔다.
이후 데이터를 요청할 때Access Token을 함께 보낸다.
만약Access Token이JWT형식이라면, 서버는 전달받은JWT의 서명과 만료 시간 등을 검증한다.
토큰이 유효하면 요청 데이터를 응답한다.
이때 서버는 단순히 토큰 문자열이 존재하는지만 보는 것이 아니다.
토큰이 서버가 발급한 형식과 맞는지, 서명이 올바른지, 만료 시간이 지나지 않았는지 등을 확인한다.
이 검증을 통과해야 인증된 요청으로 처리된다.
Token기반 인증에서는 사용자가 로그인하면 서버가 회원DB에서 사용자를 확인하고Access Token을 발급한다.
클라이언트는 이 토큰을 저장해 두었다가 이후 데이터를 요청할 때 함께 보낸다.
서버는 전달받은 토큰을 검증하고, 유효한 토큰이면 요청한 데이터를 응답한다.
이 방식은 서버가 세션 저장소에 로그인 상태를 계속 저장하지 않아도 되는 구조로 만들 수 있다는 특징이 있다.
Token 인증 흐름 정리
Token기반 인증 흐름을 단계별로 정리하면 다음과 같다.
- 사용자가 아이디와 비밀번호로 로그인한다.
- 서버는 회원
DB에서 사용자를 확인한다.- 사용자가 맞으면
Access Token을 발급한다.- 서버는
Access Token을 응답으로 보낸다.- 클라이언트는
Access Token을 저장한다.- 이후 요청마다
Access Token을 함께 보낸다.- 서버는
Access Token의 유효성을 검증한다.- 토큰이 유효하면 요청 데이터를 응답한다.
Session방식과 달리 서버가 로그인 상태를 세션 저장소에 계속 저장하지 않아도 되는 구조로 만들 수 있다.
대신 서버는 요청마다 전달받은 토큰을 검증해서 사용자를 판단한다.
Session 기반 인증과 Token 기반 인증 비교
두 방식은 로그인 상태를 보관하는 위치가 다르다
Session방식과Token방식은 모두 로그인한 사용자를 식별하기 위한 방식이다.
하지만 가장 큰 차이는 로그인 상태를 어디에 두는가이다.
Session방식은 서버가 로그인 상태를 저장한다.
브라우저는Session ID만 보내고, 서버가 세션 저장소에서 사용자 정보를 찾는다.
Token방식은 클라이언트가 인증 토큰을 들고 다닌다.
서버는 세션 저장소를 조회하는 대신 전달받은 토큰이 유효한지 검증한다.
차이를 정리하면 다음과 같다.
Session방식은 서버가 로그인 상태를 저장한다.Token방식은 클라이언트가 인증 토큰을 저장한다.Session방식은 서버 세션 저장소 조회가 필요하다.Token방식은 토큰 검증으로 사용자를 판단한다.Session ID는 서버의 세션을 찾는 값이다.Token은 인증에 사용할 수 있는 증명 값이다.두 방식 중 하나가 무조건 더 좋다고 단정할 수는 없다.
화면 중심 웹 서비스에서는Session방식이 자연스러울 수 있고,API중심 서비스나 모바일 앱에서는Token방식이 더 적합할 수 있다.
Session은 서버가 로그인 상태를 기억하는 방식이고,Token은 클라이언트가 인증 증명 값을 들고 다니는 방식이다.
인증 방식 종류 핵심 정리
Cookie는 브라우저에 저장되는 작은 정보이다.
서버는Set-Cookie로 값을 내려주고, 브라우저는 이후 요청마다Cookie를 다시 보낼 수 있다.
다만Cookie자체가 항상 완성된 인증 방식이라는 뜻은 아니며,Session ID나Token같은 인증 관련 값을 저장하고 전달하는 수단으로 사용될 수 있다.
Session은 서버가 로그인 상태를 저장하는 방식이다.
브라우저는Session ID를Cookie로 보내고, 서버는 이 값을 이용해 세션 저장소에서 사용자 정보를 찾는다.
Token은 인증된 사용자임을 증명하는 문자열이다.
서버는 로그인 성공 후 토큰을 발급하고, 클라이언트는 이후 요청마다 토큰을 함께 보낸다.
서버는 세션 저장소를 조회하는 대신 토큰의 유효성과 위조 여부를 검증할 수 있다.
특히JWT처럼 자체 검증 가능한 토큰은 서명과 만료 시간을 확인해 인증 여부를 판단할 수 있다.
Session방식은 민감한 로그인 상태를 서버가 관리할 수 있다는 장점이 있지만, 서버 저장소 부담이 생길 수 있다.
Token방식은 서버 확장에 유리하지만, 토큰이 탈취되면 위험할 수 있다.
JWT를 이해하려면 먼저Cookie,Session,Token이 각각 어떻게 사용자를 식별하고 인증 상태를 유지하는지 알아야 한다.
JWT는 로그인한 사용자를 서버가 매 요청마다 세션 저장소에서 찾지 않아도, 요청에 담긴 토큰을 검증해 사용자를 확인할 수 있게 해 주는 인증 토큰 형식이다.
토큰은 사용자가 로그인에 성공했을 때 서버가 발급하는 인증용 문자열이다.
클라이언트는 이 토큰을 저장해 두었다가, 이후 요청마다 서버에 함께 보낸다.
이 방식은 화면을 서버가 직접 만들어 주는 전통적인 웹 구조보다, 프론트엔드와 백엔드가 분리된 구조에서 자주 사용된다.
예를 들어React화면이 따로 있고,Spring Boot서버는API만 제공하는 구조라면 요청마다 사용자를 확인할 수 있는 값이 필요하다.
이때 클라이언트가JWT를 요청Header에 담아 보내면 서버는 토큰의 서명, 만료 시간, 사용자 정보를 확인해 요청한 사용자를 판단할 수 있다.
JWT는 서버가 매 요청마다 세션 저장소에서 로그인 상태를 조회하지 않고, 클라이언트가 보낸 토큰을 검증해서 사용자를 확인하는 구조에서 주로 사용된다.
REST API 서버 인증
REST API는 데이터나 기능을 제공하는 통로이다
REST API는 클라이언트가 서버의 데이터를 요청하거나, 서버의 기능을 사용하기 위해 호출하는 주소 구조이다.
예를 들어 게시글 목록을 가져오거나, 회원 정보를 조회하거나, 주문을 등록하는 요청이API요청이 될 수 있다.
이때 모든 사용자가 아무 데이터나 가져가면 안 된다.
로그인한 사용자만 볼 수 있는 정보가 있고, 관리자만 사용할 수 있는 기능도 있다.
그래서REST API서버는 요청을 보낸 사용자가 정당한 사용자인지 확인해야 한다.
JWT를 사용하는 흐름은 다음과 같다.
- 사용자가 아이디와 비밀번호로 로그인한다.
- 서버는 사용자가 맞는지 확인한다.
- 로그인에 성공하면 서버가
JWT를 발급한다.- 클라이언트는 이후
API요청마다JWT를 함께 보낸다.- 서버는
JWT를 검증한 뒤 데이터를 응답한다.이 구조에서는 서버가 매 요청마다 세션 저장소에서 로그인 상태를 찾지 않아도 된다.
요청에 포함된JWT가 유효한지 검증해서 사용자를 판단할 수 있기 때문이다.
실제 요청에서는 보통Authorization헤더에Bearer 토큰값형태로 담아 보낸다.
예를 들어 클라이언트가 주문 목록을 요청한다면 요청 헤더에는 아래와 같은 값이 포함될 수 있다.Authorization: Bearer JWT토큰값서버는 이 헤더에서 토큰을 꺼내 검증한 뒤, 요청을 보낸 사용자가 누구인지 판단한다.
예를 들어/api/orders가 주문 목록을 가져오는 주소라고 생각하면 된다.
사용자가 로그인 후 받은JWT를 함께 보내면 서버는 이 토큰을 확인하고, 해당 사용자의 주문 목록만 응답할 수 있다.
토큰이 없거나 잘못되면 서버는 주문 목록을 응답하지 않는다.
SPA 인증
SPA는 프론트엔드와 백엔드가 분리된 구조에서 자주 사용된다
SPA는Single Page Application의 줄임말이다.
처음 한 번 화면을 불러온 뒤, 필요한 데이터만 서버에 요청하면서 화면을 바꾸는 방식이다.
React,Vue,Angular같은 프론트엔드 기술로 만든 서비스에서 자주 사용된다.
SPA에서는 서버가 매번 완성된HTML화면을 만들어 보내는 방식과 다르다.
프론트엔드가 화면을 담당하고, 백엔드는JSON데이터나 기능만API로 제공하는 경우가 많다.
그래서 로그인 상태를 화면 서버가 세션으로 관리하는 방식보다, 프론트엔드가 토큰을 저장하고 요청마다 보내는 방식이 자연스럽다.
SPA에서의JWT흐름은 다음과 같다.
- 사용자가 프론트엔드 로그인 화면에서 로그인한다.
- 프론트엔드는 로그인 요청을 백엔드
API로 보낸다.- 백엔드는 사용자를 확인하고
JWT를 발급한다.- 프론트엔드는 발급받은
JWT를 저장한다.- 이후 게시글 조회, 회원 정보 조회 같은 요청마다
JWT를 첨부한다.Spring Boot같은 백엔드 서버는JWT를 검증하고 응답한다.이 구조에서는 프론트엔드와 백엔드가 서로 다른 서버에서 동작해도 인증 흐름을 유지할 수 있다.
클라이언트가 요청마다 토큰을 보내기 때문이다.
다만JWT를 어디에 저장할지는 신중하게 정해야 한다.
localStorage에 저장하면 사용하기는 쉽지만, 스크립트 공격에 노출될 수 있다.
HttpOnly Cookie에 저장하면 자바스크립트에서 직접 읽기 어렵게 만들 수 있지만, 쿠키 보안 설정을 함께 고려해야 한다.
즉,JWT를 저장하는 위치는 정답이 하나로 고정되어 있지 않다.
프론트엔드 구조, 보안 요구사항, 서버의 쿠키 설정 방식에 따라 달라지므로 저장 위치를 정할 때는 토큰 탈취 위험을 함께 고려해야 한다.
모바일 앱 인증
모바일 앱은 API 호출 중심으로 동작한다
모바일 앱은 웹 브라우저처럼 서버가 내려준 화면을 그대로 보여주는 방식보다, 앱 안에서 화면을 직접 구성하고 서버의
API를 호출하는 방식이 많다.
예를 들어 앱에서 로그인하고, 내 정보 조회 버튼을 누르고, 주문 내역을 가져오는 모든 과정이 서버API호출로 이루어질 수 있다.
이 구조에서는 로그인 후 서버가Access Token을 발급하고, 앱은 이 토큰을 저장한다.
이후 앱이 서버에 요청할 때마다Authorization헤더에 토큰을 담아 보낸다.
서버는 토큰을 검증하고 사용자를 확인한다.
모바일 앱에서JWT가 사용되는 흐름은 다음과 같다.
- 사용자가 앱에서 로그인한다.
- 서버가 사용자 정보를 확인한다.
- 서버가
Access Token을 발급한다.- 앱은 토큰을 내부 저장소에 보관한다.
- 앱이 데이터를 요청할 때 토큰을 함께 보낸다.
- 서버는 토큰을 검증하고 응답한다.
앱은 토큰을 내부 저장소에 보관한다.
다만 토큰은 인증에 사용되는 값이므로, 실제 서비스에서는 가능한 한 안전한 저장 공간에 보관해야 한다.
모바일 앱은 브라우저 세션에 의존하기 어렵다.
그래서 요청마다 사용자를 증명할 수 있는 토큰 방식이 잘 맞는다.
예를 들어 사용자가 쇼핑 앱에서 내 주문 목록을 조회한다고 생각하면 된다.
앱은 서버에 주문 목록 요청을 보내면서JWT를 함께 보낸다.
서버는 토큰을 보고 “이 요청은 로그인한 사용자 요청이다”라고 판단한 뒤 해당 사용자의 주문 목록을 응답한다.
마이크로서비스 구조
여러 서비스가 나뉜 환경에서도 JWT가 사용될 수 있다
마이크로서비스 구조는 하나의 큰 서버가 모든 기능을 처리하는 방식이 아니다.
회원 서비스, 주문 서비스, 결제 서비스, 알림 서비스처럼 기능별로 서버가 나뉘는 구조이다.
이런 구조에서는 사용자의 인증 정보를 여러 서비스가 함께 확인해야 할 수 있다.
예를 들어 사용자가 주문을 요청하면 주문 서비스는 이 사용자가 누구인지 알아야 한다.
결제 서비스도 같은 사용자의 권한과 정보를 확인해야 할 수 있다.
JWT는 이런 구조에서 인증 정보를 전달하는 데 사용할 수 있다.
JWT안에 사용자ID, 권한, 만료 시간 같은 정보를 담을 수 있기 때문이다.
각 서비스는 전달받은 토큰을 직접 검증하거나,API Gateway에서 검증된 사용자 정보를 전달받아 요청을 처리할 수 있다.
흐름은 다음과 같다.
- 사용자가 로그인한다.
- 인증 서버가
JWT를 발급한다.- 사용자의 요청은
API Gateway를 거쳐 각 서비스로 전달된다.API Gateway또는 각 서비스는 요청에 포함된JWT를 검증한다.- 토큰이 유효하면 사용자 정보를 바탕으로 요청을 처리한다.
API Gateway는 여러 서비스 앞에서 요청을 받아 적절한 서비스로 보내는 입구 역할을 한다.
이 구조에서는 사용자 인증 정보를 한 서비스 안에만 묶어 두기보다, 검증 가능한 토큰으로 전달하는 방식이 유용할 수 있다.
OAuth2, 소셜 로그인, SSO
외부 인증 서버가 발급한 토큰을 사용하는 구조에서도 JWT가 사용될 수 있다
OAuth2는 외부 서비스의 인증 권한을 이용해 로그인하거나 자원 접근 권한을 위임받는 방식이다.
구글 로그인, 카카오 로그인, 네이버 로그인 같은 소셜 로그인에서 자주 등장한다.
소셜 로그인에서는 사용자가 직접 우리 서버에 아이디와 비밀번호를 입력하지 않을 수 있다.
대신 외부 인증 서버가 사용자를 확인하고, 인증 결과로 토큰을 발급한다.
이때 발급되는Access Token이나ID Token이JWT형식일 수 있다.
다만 모든Access Token이 반드시JWT형식인 것은 아니다.
외부 인증 서버에 따라 토큰 형식이 다를 수 있고, 어떤 토큰은 외부 인증 서버에 다시 확인해야 의미를 알 수 있다.
따라서JWT는 여러 토큰 형식 중 하나로 이해해야 한다.
SSO는Single Sign-On의 줄임말이다.
한 번 로그인하면 여러 서비스에 다시 로그인하지 않고 접근할 수 있게 하는 방식이다.
회사 내부 시스템처럼 여러 서비스가 연결된 환경에서 자주 사용된다.
이런 구조에서JWT가 사용되는 이유는 다음과 같다.
- 외부 인증 서버가 사용자 정보를 검증한다.
- 인증 결과를 토큰 형태로 발급한다.
- 다른 서비스는 토큰을 검증해 사용자를 확인한다.
- 토큰 안의 사용자 정보나 권한 정보를 기반으로 접근을 제어할 수 있다.
즉,
JWT는 우리 서버가 직접 로그인 화면을 처리하는 경우뿐 아니라, 외부 인증 시스템과 연결되는 구조에서도 사용될 수 있다.
권한 기반 API 접근 제어
JWT는 로그인 여부뿐 아니라 권한 처리에도 사용될 수 있다
서비스에서는 단순히 “로그인했는가?”만 확인하면 부족할 때가 많다.
어떤 기능은 일반 사용자도 사용할 수 있지만, 어떤 기능은 관리자만 사용할 수 있다.
이때 필요한 개념이 권한이다.
JWT에는 사용자 식별 정보뿐 아니라 권한 정보를 담을 수 있다.
예를 들어role값에USER또는ADMIN을 넣거나, 권한 목록을 담아 둘 수 있다.
서버는 토큰을 검증한 뒤 이 권한 정보를 읽고, 해당 사용자가 요청한 기능을 사용할 수 있는지 판단할 수 있다.
권한 기반 접근 제어 흐름은 다음과 같다.
- 사용자가 로그인한다.
- 서버는 사용자의 권한을 확인한다.
- 서버는 권한 정보를 포함한
JWT를 발급한다.- 클라이언트는 요청마다
JWT를 보낸다.- 서버는 토큰에서 권한 정보를 꺼낸다.
- 요청한 기능에 필요한 권한과 비교한다.
- 권한이 맞으면 요청을 허용하고, 맞지 않으면 거부한다.
예를 들어 관리자 페이지는
ADMIN권한이 있는 사용자만 접근해야 한다.
일반 사용자가 관리자 페이지를 요청하면 서버는 토큰의 권한을 확인하고 접근을 막을 수 있다.
Spring Security에서는@PreAuthorize같은 방식으로 메서드 실행 전에 권한을 검사할 수 있다.
이때 인증 객체 안에 들어 있는 권한 정보가 접근 허용 여부를 결정하는 기준이 된다.
JWT가 잘 맞는 상황
서버와 클라이언트가 분리된 구조에서 특히 많이 사용된다
JWT는 모든 상황에서 무조건 써야 하는 기술은 아니다.
전통적인 서버 렌더링 웹사이트에서는Session방식만으로도 충분할 수 있다.
하지만 서버와 클라이언트가 분리되고, 요청마다 인증 정보를 함께 보내야 하는 구조에서는JWT가 잘 맞는다.
JWT가 잘 맞는 상황은 다음과 같다.
- 프론트엔드와 백엔드가 분리된
SPA구조- 모바일 앱과
API서버 구조REST API인증 구조- 서버를 여러 대로 확장해야 하는 구조
- 마이크로서비스 사이에서 인증 정보를 전달해야 하는 구조
OAuth2, 소셜 로그인,SSO와 연동하는 구조- 사용자 권한에 따라
API접근을 제어해야 하는 구조다만
JWT는 편리한 만큼 보안 관리가 중요하다.
토큰이 탈취되면 만료되기 전까지 악용될 수 있기 때문이다.
그래서 만료 시간, 저장 위치, 재발급 방식, 민감 정보 포함 여부를 함께 고려해야 한다.
JWT는 서버 확장성과 분리된 클라이언트 구조에 잘 맞지만, 토큰 저장과 만료 관리까지 함께 설계해야 안전하게 사용할 수 있다.
JWT 사용 예 핵심 정리
JWT는 서버가 로그인 상태를 세션 저장소에 계속 보관하지 않고, 클라이언트가 가진 토큰을 검증해서 사용자를 확인하는 방식에서 자주 사용된다.
REST API,SPA, 모바일 앱, 마이크로서비스, 소셜 로그인,SSO, 권한 기반 접근 제어처럼 요청마다 인증 정보를 함께 보내야 하는 구조에 잘 맞는다.
하지만JWT는 토큰 자체가 인증에 사용할 수 있는 증명 값이기 때문에 탈취되면 위험하다.
그래서 토큰 안에 민감한 정보를 넣지 않고, 만료 시간과 저장 위치를 안전하게 설계해야 한다.
JWT는 단순히 로그인 성공 후 받는 문자열이 아니라, 분리된 서비스 구조에서 사용자를 증명하고 권한을 확인하기 위한 인증 수단이다.
JWT는 겉으로 보면 점이 들어간 긴 문자열처럼 보인다.
하지만 실제로는 아무 의미 없는 문자열이 아니라, 정해진 구조를 가진 인증 토큰 형식이다.
JWT는Header,Payload,Signature세 부분으로 나뉘고, 각 부분은 점(.)으로 구분된다.
이 구간은JWT내부 구조를 먼저 이해하고, 토큰이 실제 요청에서 어떤 헤더에 담겨 전달되는지까지 이어진다.
따라서Header,Payload,Signature를 이해한 다음Authorization헤더와 다양한 인증 타입을 함께 정리한다.
여기서 처음부터 구분해야 할 점이 있다.
JWT내부의Header는 토큰 자체의 구조와 서명 알고리즘을 설명하는 영역이다.
반면 요청 헤더의Authorization은 클라이언트가 서버로 인증 정보를 보내는 위치이다.
둘 다Header라는 말이 나오지만, 서로 다른 개념이다.
JWT는Header,Payload,Signature세 부분으로 구성되고, 실제 요청에서는 보통Authorization헤더에 담겨 서버로 전달된다.
JWT의 의미
JWT는 JSON Web Token의 줄임말이다
JWT는JSON Web Token의 줄임말이다.
JSON은 데이터를key-value형태로 표현하는 방식이다.
Web Token은 웹에서 인증 정보를 전달하기 위해 사용하는 토큰이라고 이해하면 된다.
쉽게 말하면JWT는 로그인한 사용자를 확인하기 위한 정보를 일정한 구조로 담은 문자열이다.
사용자가 로그인에 성공하면 서버는JWT를 발급할 수 있다.
클라이언트는 이 토큰을 저장해 두었다가, 이후 요청마다 서버에 함께 보낸다.
서버는 요청에 포함된JWT를 보고 사용자를 판단한다.
다만 여기서 서버가 아무 문자열이나 믿는 것은 아니다.
서버는 토큰의 서명, 만료 시간, 필요한 사용자 정보를 확인해서 유효한 토큰인지 검증한다.
이 검증을 통과한 뒤에야 토큰 안의 사용자 정보를 신뢰할 수 있다.
JWT는 단순한 랜덤 문자열이 아니라, 사용자 정보와 검증 정보를 정해진 구조로 담은 인증 토큰 형식이다.
JWT 구조
JWT는 Header, Payload, Signature로 구성된다
JWT는 크게 세 부분으로 나뉜다.
각 부분은 역할이 다르다.
Header는 토큰 자체에 대한 정보를 담는다.
이 토큰이 어떤 타입인지, 어떤 알고리즘으로 서명했는지 같은 정보가 들어간다.
Payload는 토큰에 담을 실제 데이터를 가진다.
사용자 식별 정보, 권한, 발급 시간, 만료 시간 같은 값이 들어갈 수 있다.
Signature는 토큰이 중간에 조작되었는지 확인하기 위한 서명 값이다.
서버의 비밀 키나 개인키를 이용해 만들어지므로, 토큰을 신뢰할 수 있는지 판단할 때 사용된다.
JWT는Header,Payload,Signature세 부분으로 구성된다.
Header에는 토큰 타입과 서명 알고리즘 정보가 들어간다.
Payload에는 사용자 식별 정보나 권한 같은 실제 데이터가 들어간다.
Signature는Header와Payload를 기준으로 서버의 비밀 키나 개인키를 사용해 만든 값이며, 토큰이 중간에 조작되었는지 확인하는 기준이 된다.
세 부분은 점으로 연결된다
실제
JWT문자열은 세 부분이 점(.)으로 연결된 형태이다.
형식은Header.Payload.Signature로 이해하면 된다.
첫 번째 구역은Header이다.
두 번째 구역은Payload이다.
세 번째 구역은Signature이다.
겉으로 보면 하나의 긴 문자열처럼 보인다.
하지만 점을 기준으로 나누면 각각 다른 역할을 가진 세 구역으로 분리된다.
그래서JWT를 디코딩하는 도구에 넣으면Header와Payload내용을 확인할 수 있다.
다만 디코딩과 검증은 다르다.
디코딩은 토큰 안의 내용을 읽어 보는 과정이다.
검증은 이 토큰이 서버가 발급한 정상 토큰인지, 중간에 조작되지 않았는지, 만료되지 않았는지 확인하는 과정이다.
따라서Header와Payload를 디코딩해서 볼 수 있다고 해서 그 토큰을 바로 믿으면 안 된다.
서버는 반드시Signature와 만료 시간을 검증한 뒤 토큰을 신뢰해야 한다.
실제
JWT문자열은Header.Payload.Signature형태로 이어진다.
각 부분은 점으로 구분된다.
첫 번째 문자열은 토큰 정보, 두 번째 문자열은 사용자 정보와 클레임, 세 번째 문자열은 서명 검증 값을 의미한다.
Header와 Payload는 암호화가 아니라 인코딩된다
JWT의Header와Payload는 보통Base64 URL-safe방식으로 인코딩된다.
인코딩은 데이터를 다른 문자 형태로 바꾸는 처리이다.
암호화처럼 내용을 숨기는 처리가 아니다.
따라서Header와Payload는 디코딩하면 내용을 확인할 수 있다.
예를 들어Payload에 사용자ID, 권한, 만료 시간 같은 값이 들어 있다면 디코딩 도구로 그 값을 볼 수 있다.
이 점 때문에Payload에는 비밀번호, 주민등록번호, 카드번호 같은 민감한 정보를 넣으면 안 된다.
JWT는 서명으로 조작 여부를 확인할 수 있지만,Header와Payload내용을 숨겨 주는 구조는 아니다.
JWT의Header와Payload는 숨겨진 비밀 공간이 아니다.
디코딩하면 내용을 볼 수 있으므로, 비밀번호나 주민등록번호 같은 민감한 값은 절대 넣으면 안 된다.
Header
Header는 토큰 자체에 대한 정보를 담는다
Header는JWT의 첫 번째 부분이다.
토큰의 내용 자체보다는, 이 토큰이 어떤 방식으로 만들어졌는지를 설명하는 정보가 들어간다.
쉽게 말하면Header는 “이 토큰은 어떤 종류의 토큰이고, 어떤 방식으로 서명되었는가?”를 알려 주는 영역이다.
그래서Header에는 사용자 이름이나 권한 같은 실제 사용자 정보가 들어가는 것이 아니다.
사용자 식별 정보, 권한, 만료 시간 같은 실제 데이터는 주로Payload에 들어간다.
Header에서 자주 보는 값은typ와alg이다.
typ는 토큰의 타입을 나타내고,alg는 서명을 만들 때 사용할 알고리즘을 나타낸다.
typ는 토큰의 타입을 나타낸다
typ는type의 줄임말로 볼 수 있다.
말 그대로 이 토큰이 어떤 타입의 토큰인지 알려 주는 값이다.
JWT에서는 보통 아래처럼typ값으로JWT가 들어간다.{ "typ": "JWT" }이 값은 “이 토큰은
JWT형식의 토큰이다”라는 의미이다.
즉,typ는 사용자 정보가 아니다.
토큰을 해석하는 쪽에서 “아, 이건JWT형식으로 보면 되는구나”라고 알 수 있게 해 주는 안내 정보에 가깝다.
alg는 서명 알고리즘을 나타낸다
alg는algorithm의 줄임말이다.
여기서는JWT의Signature를 만들 때 어떤 서명 알고리즘을 사용할지 나타낸다.
JWT는Header와Payload를 그냥 붙여서 끝내는 구조가 아니다.
서버는Header와Payload를 기준으로Signature를 만든다.
이때 어떤 방식으로 서명값을 만들 것인지 나타내는 값이alg이다.
예를 들어Header가 아래처럼 되어 있다고 하자.{ "typ": "JWT", "alg": "HS512" }여기서
typ는 이 토큰이JWT형식이라는 뜻이다.
alg는 이 토큰의 서명을 만들 때HS512알고리즘을 사용한다는 뜻이다.
다만 검증할 때는 토큰의Header에 적힌alg를 무조건 믿으면 안 된다.
서버는 자신이 허용한 알고리즘인지 확인하고, 그 알고리즘에 맞는 키로 서명을 검증해야 한다.
typ는 토큰의 종류를 나타내고,alg는 토큰의 서명을 만드는 알고리즘을 나타낸다.
HS256과 HS512는 Secret Key를 함께 쓰는 방식이다
HS256과HS512에서HS는HMAC계열의 서명 방식을 의미한다고 이해하면 된다.
이 방식은 서버가 알고 있는 하나의Secret Key를 사용해 서명값을 만들고 검증한다.
쉽게 말하면 서버가 비밀 재료 하나를 가지고 있다고 보면 된다.
서버는 토큰을 만들 때 이Secret Key로Signature를 만든다.
나중에 토큰이 다시 들어오면 같은Secret Key로 다시 검증한다.
HS256과HS512의 차이는 사용하는 해시 함수와 출력 크기이다.HS256 = HMAC + SHA-256 기반 서명 방식 HS512 = HMAC + SHA-512 기반 서명 방식숫자가 붙어 있다고 해서 사용자 권한이나 토큰 만료 시간 같은 값이 아니다.
256,512는 서명 알고리즘에서 사용하는 해시 출력 크기와 관련된 값이다.
정리하면HS256과HS512는 아래처럼 이해하면 된다.서명 대상: 인코딩된 Header + "." + 인코딩된 Payload 서명 생성: 서명 대상 + Secret Key -> HMAC-SHA 기반 알고리즘 적용 -> Signature 생성 서명 검증: 들어온 토큰의 서명 대상 + 같은 Secret Key -> Signature가 맞는지 확인이 방식에서는
Secret Key가 매우 중요하다.
Secret Key가 유출되면 공격자가 정상처럼 보이는 토큰을 만들 수 있기 때문이다.
RS256은 개인키와 공개키를 나누어 쓰는 방식이다
RS256에서RS는RSA계열의 서명 방식을 의미한다고 이해하면 된다.
HS256,HS512와 다르게RS256은 하나의Secret Key만 사용하는 방식이 아니다.
RS256은 개인키와 공개키를 나누어 사용한다.
서버는 개인키로 토큰에 서명한다.
그리고 토큰을 검증하는 쪽은 공개키로 서명이 올바른지 확인할 수 있다.
흐름은 아래처럼 볼 수 있다.서명 대상: 인코딩된 Header + "." + 인코딩된 Payload 서명 생성: 서명 대상 + 개인키 -> RSA-SHA-256 기반 알고리즘 적용 -> Signature 생성 서명 검증: 들어온 토큰의 서명 대상 + 공개키 -> Signature가 맞는지 확인개인키는 토큰을 발급하는 서버가 안전하게 보관해야 한다.
공개키는 토큰을 검증해야 하는 다른 서버나 서비스에 공유할 수 있다.
그래서 여러 서비스가 같은 토큰을 검증해야 하는 구조에서는RS256같은 공개키 기반 방식이 사용될 수 있다.
예를 들어 인증 서버가 개인키로JWT를 발급하고, 다른 서비스들은 공개키로 이 토큰이 진짜 인증 서버가 만든 토큰인지 확인할 수 있다.
HS256, HS512, RS256은 사용자 정보가 아니라 서명 방식이다
HS256,HS512,RS256은 사용자 아이디, 권한, 만료 시간 같은 값이 아니다.
이 값들은 모두JWT의Signature를 만들고 검증하는 방식을 나타내는 알고리즘 이름이다.
정리하면 다음과 같다.
typ: 이 토큰이 어떤 타입인지 나타낸다.alg: 이 토큰의 서명을 어떤 알고리즘으로 만들었는지 나타낸다.HS256:Secret Key를 사용하는HMAC+SHA-256기반 서명 방식이다.HS512:Secret Key를 사용하는HMAC+SHA-512기반 서명 방식이다.RS256: 개인키로 서명하고 공개키로 검증하는RSA+SHA-256기반 서명 방식이다.즉,
Header는 토큰을 어떻게 해석하고 검증할지 알려 주는 안내 정보이다.
사용자 정보 자체는Header가 아니라 주로Payload에 들어간다.
여기서 중요한 점은alg와 뒤에서 나오는Authorization헤더의 인증 타입은 다르다는 것이다.
alg는JWT내부의 서명 알고리즘이다.
반면Authorization의<type>은 인증 정보를 어떤 방식으로 보낼지 알려 주는 인증 타입이다.
Header는 토큰 자체에 대한 정보를 담는다.
typ는 토큰의 종류를 나타내고,alg는 서명을 만들 때 사용할 알고리즘을 나타낸다.
예를 들어typ가JWT이면 이 토큰이JWT형식이라는 뜻이다.
alg가HS512이면 해당 알고리즘을 사용해 서명을 만든다는 뜻이다.
Header는 사용자 정보를 담는 공간이 아니다.
사용자 이름, 권한, 만료 시간처럼 실제 인증에 필요한 데이터는 주로Payload에 들어간다.
Header는 토큰을 어떻게 해석하고 검증할지 알려 주는 안내 정보에 가깝다.
Payload
Payload는 토큰에 담을 실제 데이터를 가진다
Payload는JWT의 두 번째 부분이다.
여기에는 토큰에 담을 실제 데이터가 들어간다.
이 데이터를 보통Claim이라고 부른다.
Claim은 토큰 안에 담긴 정보 조각이라고 이해하면 된다.
Claim은key-value형태로 이루어진 정보이다.
예를 들어sub라는 이름에1234567890이라는 값이 들어가면, 이것이 하나의Claim이다.
대표적으로 볼 수 있는Payload값은 다음과 같다.
sub: 토큰의 주체를 나타낸다.name: 사용자 이름 같은 값을 담을 수 있다.iat: 토큰이 발급된 시간을 나타낸다.exp: 토큰이 만료되는 시간을 나타낸다.role: 사용자의 권한을 나타내는 값으로 사용할 수 있다.예를 들어 사용자가 로그인하면 서버는
Payload에 사용자 식별값과 권한, 만료 시간을 담은JWT를 만들 수 있다.
이후 클라이언트가 이 토큰을 보내면 서버는 먼저 토큰의 서명과 만료 시간을 검증한다.
검증이 통과되면 서버는Payload의 사용자 정보를 기준으로 요청한 사용자를 판단할 수 있다.
다만Payload는 디코딩하면 볼 수 있는 영역이다.
그래서Payload에는 비밀번호 같은 민감한 정보를 넣으면 안 된다.
사용자 식별값이나 권한처럼 서버가 인증 판단에 사용할 수 있는 정보만 필요한 만큼 담아야 한다.
Payload에는 토큰에 담을 실제 정보가 들어간다.
이 정보는key-value형태로 작성되며, 한 쌍의 정보를Claim이라고 부른다.
예를 들어sub,name,iat같은 값은 각각 사용자 식별 정보, 사용자 이름, 발급 시간처럼 토큰에서 필요한 정보를 나타낸다.
여기서 가장 주의해야 할 점은Payload가 암호화된 공간이 아니라는 것이다.
Payload는Base64 URL-safe방식으로 인코딩될 뿐, 비밀스럽게 암호화되는 것이 아니다.
따라서 디코딩하면 내용을 볼 수 있다.
예를 들어Payload에role: ADMIN이 들어 있다면, 토큰을 디코딩했을 때 그 값이 보일 수 있다.
그래서 권한처럼 서버가 검증에 사용할 정보는 넣을 수 있지만, 비밀번호처럼 노출되면 안 되는 값은 넣으면 안 된다.
Payload에는 사용자 식별 정보와 권한 정보를 담을 수 있지만, 민감한 비밀 정보는 넣으면 안 된다.
Claim 종류
Claim은 목적에 따라 나눌 수 있다
Claim은Payload안에 들어가는 정보이다.
JWT에서는 이Claim을 목적에 따라 나눌 수 있다.
대표적으로Registered Claim,Public Claim,Private Claim이 있다.
Registered Claim은 미리 정의된 표준 클레임이다.
반드시 모두 써야 하는 것은 아니지만, 의미가 정해져 있기 때문에 자주 사용된다.
예를 들어iss,exp,sub,iat,jti가 있다.
각각의 의미는 다음과 같다.
iss: 토큰 발행자를 의미한다.exp: 토큰 만료 시간을 의미한다.sub: 토큰의 주체를 의미한다. 보통 사용자를 구분하는 값으로 사용된다.iat: 토큰 발행 시간을 의미한다.jti: 토큰의 고유 식별자를 의미한다.
Public Claim은 공개적으로 사용할 수 있는 정보 전달용 클레임이다.
충돌이 나지 않도록 이름을 신중하게 정해야 한다.
여기서 충돌은 다른 시스템이나 다른 개발자가 같은 이름을 다른 의미로 사용하는 상황을 뜻한다.
Private Claim은 특정 당사자끼리 정보를 공유하기 위해 만든 사용자 지정 클레임이다.
예를 들어 우리 서버와 우리 프론트엔드 사이에서만 쓰기로 약속한role,level,teamId같은 값이 여기에 해당할 수 있다.
Claim은 목적에 따라 나뉜다.
Registered Claim은iss,exp,sub,iat,jti처럼 미리 정해진 표준 이름이다.
Public Claim은 공개적으로 사용할 수 있는 정보 전달용 클레임이다.
Private Claim은 서버와 클라이언트처럼 특정 당사자끼리 필요한 정보를 공유하기 위해 정의하는 클레임이다.
여기서 중요한 점은Private Claim이라고 해서 자동으로 안전하게 숨겨지는 정보가 아니라는 것이다.
이름이Private일 뿐,Payload에 들어간 값은 디코딩하면 볼 수 있다.
따라서Private Claim에도 민감한 정보를 그대로 넣으면 안 된다.
Signature
Signature는 토큰이 조작되지 않았는지 확인하는 값이다
Signature는JWT의 세 번째 부분이다.
Signature는Header와Payload를 기준으로 만들어지는 서명 값이다.
쉽게 말하면Signature는 토큰 내용이 중간에 바뀌었는지 확인하는 검증 값이다.
누군가Payload의 권한을USER에서ADMIN으로 바꾸면, 원래의Signature와 맞지 않게 된다.
서버는 이 차이를 보고 토큰이 조작되었다고 판단할 수 있다.
Signature는 보통 아래 재료를 이용해 만든다.
- 인코딩된
Header- 인코딩된
Payload- 서버가 알고 있는
Secret Key또는 개인키흐름을 단순하게 보면 아래와 같다.
인코딩된 Header + "." + 인코딩된 Payload + Secret Key 또는 개인키 -> 서버가 선택한 서명 알고리즘 적용 -> Signature 생성
JWT에서 실제 서명 대상은 인코딩된Header와 인코딩된Payload를 점(.)으로 연결한 값이다.
여기에 서버가 알고 있는Secret Key또는 개인키를 사용해 서명 알고리즘을 적용하면Signature가 만들어진다.
이렇게 만들어진Signature는 토큰의 앞 두 부분이 바뀌지 않았는지 확인하는 기준이 된다.
서버는 요청으로 들어온 토큰을 검증할 때 서버가 허용한 알고리즘과 키를 기준으로 서명을 확인한다.
검증 결과가 맞으면 토큰이 조작되지 않았다고 판단할 수 있다.
검증 결과가 맞지 않으면 토큰을 신뢰하지 않고 인증 실패로 처리한다.
여기서 중요한 점은Signature가Header와Payload를 숨겨 주는 기능이 아니라는 점이다.
Signature는 내용을 감추는 값이 아니라, 내용이 바뀌었는지 확인하는 값이다.
그래서Secret Key또는 개인키는 코드에 그대로 노출하지 않고 안전하게 관리해야 한다.
Secret Key나 개인키가 유출되면 공격자가 정상처럼 보이는 토큰을 만들 수 있기 때문이다.
Signature는Header와Payload를Base64Url방식으로 인코딩한 값에 서버의 비밀 키를 더해 만든다.
서버는 클라이언트가 보낸 토큰을 다시 같은 방식으로 계산해 보고, 토큰 안의Signature와 비교한다.
두 값이 다르면Header나Payload가 중간에 바뀐 것으로 판단할 수 있다.
Header와Payload는 토큰 안에 들어 있는 데이터 영역이다.
Signature는 이 데이터가 중간에 바뀌지 않았는지 확인하는 검증 영역이다.
이 세 부분이 함께 있어야 서버가 토큰을 신뢰할 수 있다.
JWT 구조 정리
JWT 문자열은 세 구역으로 나뉜다
JWT문자열은Header,Payload,Signature순서로 구성된다.
이 세 부분은 점으로 구분된다.
첫 번째Header는 알고리즘 정보를 담는다.
두 번째Payload는 사용자 정보와 권한 정보를 담을 수 있다.
세 번째Signature는 토큰이 조작되었는지 확인한다.
Header와Payload는 디코딩하면 내용을 볼 수 있다.
그래서Payload에는 민감한 정보를 넣으면 안 된다.
반면Signature는 서버가 토큰을 신뢰할 수 있는지 확인하는 기준이다.
Signature가 맞지 않으면 토큰 내용이 중간에 바뀐 것으로 판단할 수 있다.
JWT문자열은 점을 기준으로 세 구역으로 나뉜다.
첫 번째 구역은Header, 두 번째 구역은Payload, 세 번째 구역은Signature이다.
Header에는 토큰 타입과 알고리즘 정보가 들어가고,Payload에는 사용자 식별 정보나 발급 시간 같은 클레임이 들어간다.
Signature는Header와Payload가 중간에 조작되지 않았는지 확인하는 검증 값이다.
이 구조를 알면 이후JwtExam1코드도 쉽게 이어진다.
setSubject()는Payload에sub값을 넣는 흐름이고,setIssuedAt()은iat값을 넣는 흐름이다.
setExpiration()은exp값을 넣는 흐름이고,signWith()는Signature를 만들기 위한 서명 흐름이다.
마지막compact()는 이 정보를 최종JWT문자열로 조립하는 흐름이다.
Authorization 헤더
Authorization 헤더는 인증 정보를 담는 요청 헤더이다
JWT는 보통 요청 본문에 넣기보다Authorization헤더에 담아 보낸다.
요청 본문은 요청 데이터가 들어가는 영역이고, 요청 헤더는 요청에 대한 부가 정보가 들어가는 영역이다.
인증 정보는 요청 자체를 누가 보냈는지 판단하는 데 필요하므로, 보통Authorization헤더에 담는다.
형식은 아래처럼 이해하면 된다.
Authorization: <type> <credentials>
여기서<type>은 인증 타입을 의미한다.
<credentials>는 실제 인증 정보를 의미한다.
JWT를 사용할 때는 보통<type>자리에Bearer를 넣고,<credentials>자리에 토큰 값을 넣는다.
Authorization: Bearer <JWT값>이 형식에서
Bearer는 인증 타입이고,<JWT값>은 실제 인증 정보이다.
서버는Authorization헤더를 읽고,Bearer뒤에 있는 토큰 값을 꺼내 검증한다.
토큰은 요청 헤더의
Authorization필드에 담아 서버로 보낼 수 있다.
형식은Authorization: <type> <credentials>이다.
type은 인증 타입이고,credentials는 실제 인증 정보이다.
JWT인증에서는 보통type에Bearer를 사용하고,credentials자리에 토큰 값을 넣는다.
다양한 인증 타입
Authorization 헤더의 type에 따라 인증 방식이 달라진다
Authorization헤더의<type>자리에는 여러 인증 타입이 들어갈 수 있다.
JWT에서는 보통Bearer를 사용하지만,Authorization헤더가 항상Bearer만 사용하는 것은 아니다.
대표적인 인증 타입은 다음과 같다.
Basic: 사용자 아이디와 암호를Base64로 인코딩한 값을 사용한다. 단,Base64는 암호화가 아니므로 값을 쉽게 디코딩할 수 있고, 단독으로 안전한 보호 방식이라고 보면 안 된다.Bearer:JWT형식의 토큰이나OAuth2 Access Token을 사용한다.Digest: 서버가 보낸 난수 값과 사용자 정보를 조합한 해시값을 사용한다.HOBA: 전자 서명 기반 인증 방식이다.Mutual: 클라이언트와 서버가 서로를 인증하는 방식이다.AWS4-HMAC-SHA256:AWS에서 사용하는 전자 서명 기반 인증 방식이다.이 구간에서 중요한 것은 모든 인증 타입을 외우는 것이 아니다.
Authorization헤더는 인증 정보를 담는 공통 위치이고,<type>값에 따라 서버가 인증 정보를 해석하는 방식이 달라진다는 점을 이해하면 된다.
여기서Authorization헤더의 인증 타입과JWTHeader의alg는 다르다.
Authorization의Bearer는 “토큰을 어떤 방식으로 보낼 것인가”에 대한 인증 타입이다.
Header의alg는 “토큰의 서명을 어떤 알고리즘으로 만들 것인가”에 대한 정보이다.
Bearer는 인증 타입이고,alg는 서명 알고리즘이다.
두 개를 같은 개념으로 보면 안 된다.
Bearer Token
Bearer는 토큰을 가진 요청자라는 의미로 이해한다
Bearer는 “이 토큰을 가진 요청자”라는 의미로 이해하면 된다.
Authorization: Bearer <JWT값>형태로 요청이 오면 서버는Bearer뒤에 있는 실제 토큰 값을 꺼낸다.
만약Bearer뒤의 토큰이JWT형식이라면 서버는 토큰의Signature와 만료 시간을 검증한다.
토큰이 유효하면 인증된 사용자로 처리할 수 있다.
토큰이 없거나,Bearer형식이 아니거나, 서명이 맞지 않거나, 만료되었다면 인증 실패로 처리할 수 있다.
여기서 중요한 점은Bearer방식이 토큰을 가진 요청자를 기준으로 인증한다는 점이다.
즉, 토큰을 가진 사람이 인증 정보를 함께 보낸 것으로 보고 서버가 검증을 진행한다.
따라서 토큰이 탈취되면 공격자도 그 토큰을 이용해 인증된 사용자처럼 요청을 보낼 수 있다.
그래서Bearer Token은 저장 위치, 만료 시간, 전송 구간 보안을 함께 고려해야 한다.
이 흐름은 이후security10의AuthController2,AuthController3과 연결된다.
AuthController2는 응답 헤더의Authorization에Bearer토큰을 담아 내려준다.
AuthController3은 요청 헤더의Authorization값을 읽고,Bearer뒤의 실제JWT만 추출해 토큰 유효성과 사용자 인증 정보를 확인한다.
JWT 기본 구조와 다양한 인증 타입 핵심 정리
JWT는Header,Payload,Signature세 부분으로 구성된다.
세 부분은 점으로 연결되어 하나의 긴 문자열처럼 보인다.
Header는 토큰 타입과 서명 알고리즘 정보를 담는다.
Payload는 사용자 식별 정보, 권한, 발급 시간, 만료 시간 같은 실제 데이터를 담을 수 있다.
Signature는 서버의 비밀 키나 개인키를 이용해 만들며, 토큰이 중간에 조작되었는지 확인하는 기준이 된다.
Header와Payload는Base64 URL-safe방식으로 인코딩된다.
인코딩은 암호화가 아니므로 디코딩하면 내용을 확인할 수 있다.
그래서Payload에는 비밀번호 같은 민감한 값을 절대 넣지 않는다.
JWT에서 디코딩과 검증은 다르다.
디코딩은 토큰 내용을 읽어 보는 과정이고, 검증은 토큰을 신뢰할 수 있는지 확인하는 과정이다.
서버는Signature와 만료 시간을 검증한 뒤에야Payload의 사용자 정보를 신뢰할 수 있다.
Authorization헤더는 인증 정보를 담는 공통 위치이다.
JWT를 사용할 때는 보통Authorization: Bearer <JWT값>형식으로 전달한다.
다만Authorization헤더의 인증 타입에는Basic,Bearer,Digest,HOBA,Mutual,AWS4-HMAC-SHA256같은 여러 방식이 들어갈 수 있다.
JWT를 이해할 때는 내부 구조인Header,Payload,Signature와 요청 전달 방식인Authorization헤더를 구분해서 봐야 한다.
security10은JWT를 직접 생성하고, 생성된 토큰을 검증한 뒤,Access Token과Refresh Token을 함께 만들어 보는 실습이다.
앞에서JWT의 구조와 사용 이유를 개념으로 정리했다면, 이번에는 실제Java코드에서JWT가 어떤 값으로 만들어지는지 확인한다.
이 실습에서 가장 중요한 흐름은 세 가지이다.
첫째,JwtExam1에서 하나의JWT를 생성한다.
둘째,JwtValidator와JwtExam2에서 생성된 토큰이 유효한지 검증한다.
셋째,JwtExam3에서Access Token과Refresh Token을 함께 발급한다.
앞에서JWT는Header,Payload,Signature세 부분으로 구성된다고 정리했다.
security10에서는 이 구조가 실제 코드에서 어떻게 만들어지는지 확인한다.
setSubject(),setIssuedAt(),setExpiration()은 주로Payload에 들어갈 값을 설정하는 흐름이고,signWith()는Signature를 만들기 위한 서명 흐름이다.
security10의 핵심은JWT가 코드에서 어떻게 생성되고, 어떤 기준으로 검증되며,Access Token과Refresh Token을 왜 나누어 사용하는지 이해하는 것이다.
security10에서 확인할 전체 흐름
JWT 생성과 검증을 코드로 직접 확인한다
JWT는 단순한 임의 문자열이 아니다.
Header,Payload,Signature가 정해진 규칙으로 합쳐진 문자열이다.
그래서 코드에서 어떤 값을 넣느냐에 따라 토큰 안에 들어가는 정보와 만료 시간이 달라진다.
security10에서는 먼저JwtExam1로 토큰을 만든다.
이때Payload에는sub,iat,exp,level같은 값이 들어간다.
그리고signWith()를 통해Secret Key와HS256알고리즘으로Signature를 만든다.
이때HS256은 앞에서 설명한JWT Header의alg값과 연결되는 서명 알고리즘이다.
그 다음JWT Debugger로 생성된 토큰을 디코딩해Header,Payload,Signature구조를 확인한다.
여기서 디코딩은 토큰 내용을 읽어 보는 과정이다.
검증은 토큰이 서버가 발급한 정상 토큰인지, 서명이 맞는지, 만료되지 않았는지 확인하는 과정이다.
따라서 디코딩과 검증은 반드시 구분해서 봐야 한다.
마지막으로JwtValidator와JwtExam2에서 생성된 토큰이 유효한지 검증한다.
이때 검증에는 토큰 구조 확인,Signature검증, 만료 시간 확인이 포함된다.
이후JwtExam3에서는 실제 인증 흐름에서 자주 사용하는Access Token과Refresh Token을 함께 만든다.
전체 흐름은 다음과 같다.
JwtExam1에서JWT를 생성한다.- 생성된
JWT를JWT Debugger에서 디코딩해 구조를 확인한다.JwtValidator에서 토큰 구조, 서명, 만료 시간을 검증한다.JwtExam2에서JwtValidator를 호출해 검증 결과를 확인한다.JwtExam3에서Access Token과Refresh Token을 함께 생성한다.- 두 토큰을 디코딩해
sub,iat,exp를 확인하고, 특히exp값의 차이를 비교한다.이 흐름을 보면
JWT는 생성만 중요한 것이 아니라, 서버가 다시 검증할 수 있어야 의미가 있다는 점을 알 수 있다.
security10 의존성 설정 확인
JWT 라이브러리가 있어야 토큰 생성과 검증 코드를 사용할 수 있다
security10에서JWT를 생성하고 검증하려면 관련 라이브러리가 먼저 필요하다.
Jwts,Claims,SignatureAlgorithm,Keys같은 클래스는 기본Java문법이 아니라JWT라이브러리에서 제공하는 기능이다.
그래서build.gradle에jjwt관련 의존성이 추가되어 있어야 한다.
이 설정이 빠지면JwtExam1,JwtValidator,JwtExam3코드에서import io.jsonwebtoken...으로 시작하는 클래스를 사용할 수 없다.
// build.gradle dependencies { implementation 'org.springframework.boot:spring-boot-starter-webmvc' compileOnly 'org.projectlombok:lombok' implementation 'io.jsonwebtoken:jjwt-api:0.12.5' implementation 'io.jsonwebtoken:jjwt-impl:0.12.5' implementation 'io.jsonwebtoken:jjwt-jackson:0.12.5' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' }
jjwt-api는JWT를 만들고 파싱할 때 사용하는 기본 기능을 제공한다.
jjwt-impl은 실제 동작 구현을 담당한다.
jjwt-jackson은JWT안의JSON데이터를 처리할 때 사용된다.
즉, 이 의존성들이 있어야Jwts.builder(),Jwts.parser(),Claims,Keys.hmacShaKeyFor()같은 코드를 사용할 수 있다.
JwtExam1에서 JWT 생성하기
Secret Key로 서명된 JWT를 만든다
JwtExam1은 하나의JWT를 생성하는 예제이다.
이 코드에서 가장 중요한 값은SECRET_KEY이다.
SECRET_KEY는 토큰의Signature를 만들 때 사용하는 비밀 키이다.
앞에서HS256은Secret Key하나를 사용해 서명값을 만들고 검증하는 방식이라고 정리했다.
JwtExam1도 같은 구조이다.
토큰을 만들 때SECRET_KEY로Signature를 만들고, 나중에 검증할 때도 같은SECRET_KEY를 사용해야 한다.
여기서 같은SECRET_KEY를 사용한다는 말은 같은 비밀 키 문자열을 기준으로 서명과 검증을 한다는 뜻이다.
JwtExam1에서는SECRET_KEY.getBytes()로 키에 사용할 바이트 배열을 만들고,JwtValidator에서는SECRET_KEY.getBytes(StandardCharsets.UTF_8)로 키에 사용할 바이트 배열을 만든다.
코드 형태는 조금 다르지만 핵심은 토큰을 만들 때 사용한 비밀 키 문자열과 검증할 때 사용하는 비밀 키 문자열이 같아야 한다는 점이다.
Signature는 토큰이 조작되지 않았는지 확인하는 값이다.
서버는 토큰을 만들 때 사용한 비밀 키와 같은 키로 나중에 토큰을 검증한다.
키가 다르면 서버가 다시 계산한 서명과 토큰 안의 서명이 달라져서 검증에 실패한다.
// JwtExam1.java package com.example.security10.app; import java.security.Key; import java.util.Date; import java.util.HashMap; import java.util.Map; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; public class JwtExam1 { // JWT 서명에 사용할 비밀 키이다. private static final String SECRET_KEY = "your-256-bit-secret-your-256-bit-secret"; // 비밀 키 문자열을 서명에 사용할 Key 객체로 바꾼다. private Key getSigningKey() { byte[] keyBytes = SECRET_KEY.getBytes(); return Keys.hmacShaKeyFor(keyBytes); } public String generateToken(String subject) { // 현재 시간을 밀리초 단위로 구한다. long nowMillis = System.currentTimeMillis(); // 현재 시간에서 1시간 뒤를 만료 시간으로 정한다. long expMillis = nowMillis + 1000 * 60 * 60; // 1시간 만료 // Payload에 추가로 넣을 클레임을 만든다. Map<String, Object> claimMap = new HashMap<String, Object>(); claimMap.put("level", "gold"); return Jwts.builder() // 직접 만든 클레임 맵을 Payload에 넣는다. .setClaims(claimMap) // 토큰의 주체를 sub 클레임에 넣는다. .setSubject(subject) // 토큰 발급 시간을 iat 클레임에 넣는다. .setIssuedAt(new Date(nowMillis)) // 토큰 만료 시간을 exp 클레임에 넣는다. .setExpiration(new Date(expMillis)) // 비밀 키와 HS256 알고리즘으로 서명한다. .signWith(getSigningKey(), SignatureAlgorithm.HS256) // 최종 JWT 문자열을 만든다. .compact(); } public static void main(String[] args) { // JWT 생성 객체를 만든다. JwtExam1 jwtUtil = new JwtExam1(); // unico123을 subject로 넣어 JWT를 생성한다. String token = jwtUtil.generateToken("unico123"); // 생성된 JWT를 콘솔에 출력한다. System.out.println("생성된 JWT 토큰:"); System.out.println(token); } }
generateToken("unico123")을 실행하면sub값에unico123이 들어간다.
sub는 토큰의 주체를 의미한다.
이 단계에서는 사용자를 구분하는 값이라고 이해하면 된다.
claimMap.put("level", "gold")는Payload에 추가 정보를 넣는 부분이다.
즉, 이 토큰을 디코딩하면level값으로gold가 들어간 것을 확인할 수 있다.
setIssuedAt()은 토큰 발급 시간을 넣는다.
iat클레임과 연결된다.
setExpiration()은 토큰 만료 시간을 넣는다.
exp클레임과 연결된다.
이 코드에서는 현재 시간에서 1시간 뒤를 만료 시간으로 설정한다.
signWith(getSigningKey(), SignatureAlgorithm.HS256)은 토큰에 서명하는 부분이다.
앞에서 정리한JWT Header의alg값과 연결되는 부분이다.
이 코드에서는HS256알고리즘과SECRET_KEY를 이용해Signature를 만든다.
마지막compact()는 지금까지 설정한Header,Payload,Signature정보를 하나의JWT문자열로 조립한다.
그래서 최종 결과는Header.Payload.Signature형태의 긴 문자열로 만들어진다.
JwtExam1을 실행하면 콘솔에 생성된JWT문자열이 출력된다.
이 문자열은Header,Payload,Signature가 점(.)으로 연결된 한 줄 문자열이다.
이 토큰을 복사해서 디코딩하거나 검증할 수 있다.
생성된 JWT 구조 확인하기
생성된
JWT는JWT Debugger같은 도구에서 디코딩할 수 있다.
디코딩은 토큰 안의Header와Payload내용을 사람이 읽을 수 있게 풀어 보는 과정이다.
여기서 주의할 점은 디코딩과 검증이 다르다는 것이다.
디코딩은 토큰 안의 내용을 확인하는 것이다.
검증은 토큰이 조작되지 않았는지, 만료되지 않았는지, 서버가 신뢰할 수 있는 서명인지 확인하는 것이다.
JWT Debugger에 토큰을 넣으면Header와Payload내용을 볼 수 있다.
Header에는alg값으로HS256이 들어간다.
이 값은JwtExam1의signWith(getSigningKey(), SignatureAlgorithm.HS256)코드와 연결된다.
즉, 이 토큰은HS256알고리즘과Secret Key를 사용해 서명된 토큰이다.
Payload에는level,sub,iat,exp같은 클레임이 들어간다.
각 값은 아래처럼 이해하면 된다.
level:claimMap.put("level", "gold")로 직접 추가한 사용자 지정 클레임이다.sub: 토큰의 주체를 의미한다. 이 예제에서는generateToken("unico123")로 전달한unico123이 들어간다.iat: 토큰이 발급된 시간을 의미한다. 코드의setIssuedAt(new Date(nowMillis))와 연결된다.exp: 토큰이 만료되는 시간을 의미한다. 코드의setExpiration(new Date(expMillis))와 연결된다.이 흐름을 코드와 연결해서 보면 더 직관적이다.
claimMap.put("level", "gold")는Payload에level값을 추가한다.
setSubject(subject)는Payload에sub값을 넣는다.
setIssuedAt()은iat값을 넣고,setExpiration()은exp값을 넣는다.
예를 들어generateToken("unico123")을 실행하면Payload는 아래와 같은 의미를 가진다.level = gold sub = unico123 iat = 토큰 발급 시간 exp = 토큰 만료 시간
level은 직접 만든 추가 정보이고,sub,iat,exp는JWT에서 자주 사용하는 클레임이다.
그래서 디코딩 결과를 볼 때는 단순히 값이 보인다고 넘어가지 말고, 이 값들이 코드의 어느 부분에서 만들어졌는지 함께 봐야 한다.
다만JWT Debugger에서Invalid Signature가 보일 수 있다.
이것은Header와Payload를 읽지 못한다는 뜻이 아니다.
토큰 구조는 디코딩해서 볼 수 있지만, 서명 검증은 코드에서 사용한Secret Key와 같은 키를 입력해야 성공할 수 있다는 뜻이다.
따라서 이 화면에서는 우선Header와Payload구조 확인에 집중하면 된다.
생성된
JWT를 디코딩하면Header,Payload,Signature가 분리되어 보인다.
Header에는alg값으로HS256이 들어가고,Payload에는level,sub,iat,exp같은 클레임이 들어간다.
level은 코드에서 직접 추가한 사용자 지정 클레임이고,sub는 토큰의 주체,iat는 발급 시간,exp는 만료 시간을 의미한다.
이 값들은 암호화되어 숨겨진 값이 아니라 인코딩된 값이므로 디코딩해서 확인할 수 있다.
그래서Payload에는 민감한 정보를 넣으면 안 된다.
JWT Debugger에서Invalid Signature가 보이더라도 이 화면은 토큰의Header와Payload구조를 확인하기 위한 결과물로 볼 수 있다.
서명 검증까지 성공시키려면 오른쪽의 비밀 키 입력 영역에 코드에서 사용한Secret Key와 같은 값을 넣어야 한다.
JwtValidator와 JwtExam2로 JWT 검증하기
JwtValidator는 토큰이 유효한지 검사한다
JwtValidator는 전달받은JWT가 정상적인 토큰인지 확인하는 역할을 한다.
토큰 검증은 단순히 문자열이 있는지 보는 것이 아니다.
토큰 구조가 맞는지, 서명이 맞는지, 만료 시간이 지나지 않았는지 확인한다.
여기서 검증에 사용하는SECRET_KEY는JwtExam1에서 토큰을 만들 때 사용한SECRET_KEY와 같아야 한다.
HS256방식은 같은Secret Key로 서명 생성과 서명 검증을 하기 때문이다.
만약 생성할 때 사용한 키와 검증할 때 사용하는 키가 다르면Signature검증에 실패한다.
검증 흐름은 다음과 같다.
parser()로 토큰을 해석할 준비를 한다.setSigningKey()로 검증에 사용할 비밀 키를 지정한다.build()로 파서를 완성한다.parseClaimsJws()로 토큰 구조, 서명, 만료 시간을 검증한다.- 토큰이 만료되면
ExpiredJwtException이 발생한다.- 서명이 맞지 않으면
SignatureException이 발생한다.- 토큰 구조가 이상하면
MalformedJwtException이 발생한다.이 흐름이 성공하면 서버는 해당 토큰을 신뢰할 수 있다고 판단할 수 있다.
// JwtValidator.java package com.example.security10.app; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.SignatureException; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; public class JwtValidator { // JwtExam1과 같은 비밀 키를 사용해야 검증할 수 있다. private static final String SECRET_KEY = "your-256-bit-secret-your-256-bit-secret"; // 검증에 사용할 SecretKey 객체를 만든다. private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)); } public boolean validateToken(String token) { try { // 토큰 구조, 서명, 만료 시간을 검증한다. Jws<Claims> claims = Jwts.parser() .setSigningKey(getSigningKey()) .build() .parseClaimsJws(token); // 예외가 발생하지 않으면 유효한 토큰이다. return true; } catch (ExpiredJwtException e) { // 토큰 만료 예외를 처리한다. System.out.println("토큰 만료됨: " + e.getMessage()); } catch (SignatureException e) { // 서명 검증 실패 예외를 처리한다. System.out.println("토큰 서명 검증 실패: " + e.getMessage()); } catch (MalformedJwtException e) { // 토큰 구조 이상 예외를 처리한다. System.out.println("토큰 구조 이상: " + e.getMessage()); } catch (Exception e) { // 그 밖의 검증 실패를 처리한다. System.out.println("기타 토큰 검증 에러: " + e.getMessage()); } // 검증 실패 시 false를 반환한다. return false; } }
JwtValidator에서 가장 중요한 부분은setSigningKey(getSigningKey())이다.
JWT를 만들 때 사용한 비밀 키와 검증할 때 사용하는 비밀 키가 같아야 한다.
키가 다르면 서버가 다시 계산한Signature와 토큰 안의Signature가 달라지기 때문이다.
parseClaimsJws(token)은 이름만 보면 단순히Claims를 꺼내는 메서드처럼 보일 수 있다.
하지만 실제로는 토큰을 파싱하면서 구조, 서명, 만료 시간을 함께 확인한다.
토큰 구조가 정상이고, 서명이 맞고, 만료되지 않았으면 예외 없이 지나간다.
그러면true가 반환된다.
이 코드에서는parseClaimsJws(token)의 결과를Jws<Claims> claims변수에 담고 있다.
현재 예제에서는claims값을 따로 출력하거나 꺼내 쓰지는 않는다.
하지만 이 변수에는 검증을 통과한 토큰의Header,Payload,Signature정보가 들어 있다고 이해하면 된다.
반대로 토큰이 만료되었거나, 서명이 다르거나, 토큰 문자열 구조가 깨졌다면 예외가 발생한다.
이때false가 반환되고, 서버는 해당 토큰을 신뢰하지 않는다.
parseClaimsJws()는 단순 디코딩이 아니라, 서버가 토큰을 신뢰할 수 있는지 확인하는 검증 흐름과 연결된다.
JwtExam2는 생성한 토큰을 검증한다
JwtExam2는JwtExam1에서 생성한 토큰을JwtValidator에 넣어 검증 결과를 확인하는 실행 예제이다.
이때 주의할 점은 토큰을 복사할 때 줄바꿈이나 공백이 들어가면 안 된다는 것이다.
JWT는Header.Payload.Signature형태의 한 줄 문자열이어야 한다.
중간에 공백이 들어가면 토큰 구조가 깨진 것으로 판단될 수 있다.
// JwtExam2.java package com.example.security10.app; public class JwtExam2 { public static void main(String[] args) { // JwtValidator 객체를 만든다. JwtValidator validator = new JwtValidator(); // JwtExam1에서 만들어진 실제 JWT 문자열을 넣는다. String token = "JwtExam1에서 만들어진 토큰 내용"; // validator 객체의 validateToken()으로 토큰을 검증한다. if (validator.validateToken(token)) { System.out.println("JWT 토큰이 유효합니다."); } else { System.out.println("JWT 토큰이 유효하지 않습니다."); } } }
JwtExam2의token변수에는JwtExam1에서 생성한 실제 토큰 전체를 넣는다.
이때"JwtExam1에서 만들어진 토큰 내용"이라는 문구를 그대로 넣는 것이 아니다.
그 위치에 콘솔에서 복사한 실제JWT문자열을 공백 없이 붙여넣어야 한다.
또"생성된 JWT 토큰:"같은 출력 문구는 넣으면 안 된다.
토큰 문자열만 넣어야 한다.
토큰이 만료되기 전에 바로 검증해야 하므로JwtExam1실행 후 곧바로 복사해서JwtExam2에 넣는 것이 좋다.
이 검증이 성공한다는 것은 서버가 해당 토큰을 신뢰할 수 있다는 뜻이다.
정확히는 토큰 구조가 정상이고,Signature가 맞으며, 만료 시간이 지나지 않았다는 뜻이다.
JwtValidator는 전달받은JWT의 구조, 서명, 만료 시간을 확인한다.
토큰 구조가 정상이고, 서명이 맞고, 만료되지 않았다면 검증에 성공한다.
토큰이 만료되었거나 복사 과정에서 문자열이 잘못되었거나 비밀 키가 맞지 않으면 검증에 실패한다.
이 결과는 서버가 클라이언트가 보낸 토큰을 신뢰할 수 있는지 확인하는 과정과 연결된다.
JwtExam3에서 Access Token과 Refresh Token 생성하기
Access Token과 Refresh Token을 함께 만든다
JwtExam3은Access Token과Refresh Token을 함께 생성하는 예제이다.
두 토큰은 모두JWT형식이지만 목적이 다르다.
Access Token은 실제 요청에서 인증에 사용하는 토큰이다.
예를 들어 사용자가 게시글 목록을 요청하거나, 내 정보를 조회할 때 서버에 함께 보내는 토큰이다.
Refresh Token은Access Token을 다시 발급받기 위해 사용하는 토큰이다.
Access Token은 자주 사용되기 때문에 유효 시간을 짧게 설정하는 경우가 많다.
대신Refresh Token을 상대적으로 길게 유지해서 새Access Token을 발급받을 수 있게 한다.
이 예제에서 두 토큰은 모두JWT형식으로 만들어진다.
그래서 둘 다sub,iat,exp같은 클레임을 가질 수 있다.
다만Access Token과Refresh Token은 목적이 다르기 때문에 만료 시간인exp값이 다르게 설정된다.
이 예제에서는Access Token의 유효 시간을 30분으로 설정한다.
Refresh Token의 유효 시간은 7일로 설정한다.
따라서 두 토큰을 디코딩해 보면exp값이 서로 다르게 나온다.
// JwtExam3.java package com.example.security10.app; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Claims; import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.SignatureAlgorithm; import javax.crypto.SecretKey; import java.nio.charset.StandardCharsets; import java.util.Date; public class JwtExam3 { // Access Token과 Refresh Token 서명에 사용할 비밀 키이다. private static final String SECRET_KEY = "your-256-bit-secret-your-256-bit-secret"; // Access Token 유효 시간은 30분이다. private static final long ACCESS_TOKEN_VALIDITY = 1000 * 60 * 30; // Refresh Token 유효 시간은 7일이다. private static final long REFRESH_TOKEN_VALIDITY = 1000 * 60 * 60 * 24 * 7; // 문자열 비밀 키를 SecretKey 객체로 바꾼다. private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)); } // Access Token을 생성한다. public String createAccessToken(String subject) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + ACCESS_TOKEN_VALIDITY); return Jwts.builder() // 토큰 주체를 저장한다. .setSubject(subject) // 토큰 발급 시간을 저장한다. .setIssuedAt(now) // Access Token 만료 시간을 저장한다. .setExpiration(expiryDate) // 비밀 키와 HS256 알고리즘으로 서명한다. .signWith(getSigningKey(), SignatureAlgorithm.HS256) // 최종 JWT 문자열을 만든다. .compact(); } // Refresh Token을 생성한다. public String createRefreshToken(String subject) { Date now = new Date(); Date expiryDate = new Date(now.getTime() + REFRESH_TOKEN_VALIDITY); return Jwts.builder() // 토큰 주체를 저장한다. .setSubject(subject) // 토큰 발급 시간을 저장한다. .setIssuedAt(now) // Refresh Token 만료 시간을 저장한다. .setExpiration(expiryDate) // 비밀 키와 HS256 알고리즘으로 서명한다. .signWith(getSigningKey(), SignatureAlgorithm.HS256) // 최종 JWT 문자열을 만든다. .compact(); } // Access Token과 Refresh Token을 함께 발급한다. public TokenPair generateTokenPair(String subject) { String accessToken = createAccessToken(subject); String refreshToken = createRefreshToken(subject); return new TokenPair(accessToken, refreshToken); } // 두 토큰을 함께 담는 내부 DTO 클래스이다. public static class TokenPair { private final String accessToken; private final String refreshToken; // Access Token과 Refresh Token을 함께 저장한다. public TokenPair(String accessToken, String refreshToken) { this.accessToken = accessToken; this.refreshToken = refreshToken; } // Access Token을 반환한다. public String getAccessToken() { return accessToken; } // Refresh Token을 반환한다. public String getRefreshToken() { return refreshToken; } } public static void main(String[] args) { // 토큰 생성 객체를 만든다. JwtExam3 provider = new JwtExam3(); // unico@example.com을 subject로 넣어 두 토큰을 함께 생성한다. TokenPair tokens = provider.generateTokenPair("unico@example.com"); // Access Token을 출력한다. System.out.println("엑세스 토큰: " + tokens.getAccessToken()); // Refresh Token을 출력한다. System.out.println("리프레쉬 토큰: " + tokens.getRefreshToken()); } }
ACCESS_TOKEN_VALIDITY는Access Token의 유효 시간이다.
실제 코드에서는1000 * 60 * 30으로 설정되어 있으므로 30분이다.
REFRESH_TOKEN_VALIDITY는Refresh Token의 유효 시간이다.
실제 코드에서는1000 * 60 * 60 * 24 * 7로 설정되어 있으므로 7일이다.
Claims는JWT의Payload정보 묶음을 표현할 때 사용하는 타입이다.
다만 이 예제의 핵심은 토큰을 생성하는 흐름이므로,JwtExam3안에서Claims값을 직접 꺼내 사용하지는 않는다.
createAccessToken()은 실제 요청 인증에 사용할 토큰을 만든다.
createRefreshToken()은 새Access Token을 발급받는 데 사용할 토큰을 만든다.
두 메서드는 모두setSubject(),setIssuedAt(),setExpiration(),signWith(),compact()흐름으로JWT를 만든다.
두 토큰의 가장 큰 차이는setExpiration()에 들어가는 만료 시간이다.
Access Token은 30분 뒤 만료되고,Refresh Token은 7일 뒤 만료된다.
그래서 디코딩했을 때sub와iat는 비슷하게 보일 수 있지만,exp는 서로 다르게 나온다.
generateTokenPair()는 두 토큰을 함께 발급한다.
이렇게 묶어서 반환하면 로그인 성공 시 클라이언트에게 두 토큰을 함께 전달하는 구조를 만들 수 있다.
TokenPair는 별도 파일이 아니라JwtExam3안에 들어 있는static내부 클래스이다.
이 클래스는Access Token과Refresh Token을 하나의 결과처럼 묶어 다루기 위해 사용한다.
JwtExam3에서는Access Token과Refresh Token을 함께 생성한다.
Access Token은 실제 요청에서 인증에 사용하는 토큰이고,Refresh Token은Access Token을 다시 발급받기 위해 사용하는 토큰이다.
두 토큰은 목적이 다르기 때문에 유효 시간도 다르게 설정할 수 있다.
Access Token 디코딩 결과 확인하기
Access Token을JWT Debugger에 넣으면Payload에서sub,iat,exp를 확인할 수 있다.
sub는 토큰의 주체이다.
이 예제에서는unico@example.com이 들어간다.
iat는 토큰 발급 시간이다.
exp는 토큰 만료 시간이다.
Access Token은 실제 요청에서 자주 사용되는 토큰이므로 보통 짧은 유효 시간을 가진다.
이 예제에서는 30분 뒤 만료되도록 설정되어 있다.
Access Token을 디코딩하면Payload에서sub,iat,exp를 확인할 수 있다.
sub에는unico@example.com이 들어가고,exp는Access Token의 만료 시간을 나타낸다.
이 토큰은 실제 인증이 필요한 요청에 사용하는 토큰이므로 상대적으로 짧은 유효 시간을 가진다.
JWT Debugger에Invalid Signature가 보이더라도 이 화면은 토큰의Payload구조와 만료 시간 값을 확인하기 위한 결과물로 보면 된다.
Refresh Token 디코딩 결과 확인하기
Refresh Token도JWT형식이다.
따라서JWT Debugger에서 디코딩하면sub,iat,exp를 확인할 수 있다.
다만Refresh Token은Access Token보다 오래 유지된다.
이 예제에서는 7일 뒤 만료되도록 설정되어 있다.
그래서Access Token과 비교하면exp값이 더 뒤의 시간으로 나온다.
Refresh Token을 디코딩하면Payload에서sub,iat,exp를 확인할 수 있다.
Refresh Token은Access Token을 다시 발급받기 위한 토큰이므로 상대적으로 긴 유효 시간을 가진다.
그래서 평소 요청에는Access Token을 사용하고,Access Token이 만료되었을 때Refresh Token을 사용해 새 토큰을 발급받는 구조로 이어질 수 있다.
JWT Debugger에Invalid Signature가 보이더라도 이 화면은 토큰의Payload구조와 만료 시간 값을 확인하기 위한 결과물로 보면 된다.
security10 JWT 생성과 검증 핵심 정리
security10에서는 먼저JwtExam1로 하나의JWT를 생성했다.
JWT는Header,Payload,Signature가 점(.)으로 연결된 문자열이다.
setClaims()는 직접 만든 클레임을Payload에 넣고,setSubject(),setIssuedAt(),setExpiration()은 각각sub,iat,exp값을 설정한다.
signWith()는Signature를 만드는 흐름이다.
signWith(getSigningKey(), SignatureAlgorithm.HS256)에서HS256은JWT Header의alg값과 연결되는 서명 알고리즘이다.
getSigningKey()는 문자열로 된SECRET_KEY를 서명과 검증에 사용할 키 객체로 바꾼다.
HS256방식에서는 토큰을 생성할 때와 검증할 때 같은Secret Key를 사용해야 한다.
JwtValidator와JwtExam2에서는 생성한JWT가 유효한지 검증했다.
검증할 때는 토큰 구조, 서명, 만료 시간을 확인한다.
토큰이 정상이고 만료되지 않았다면 서버는 해당 토큰을 신뢰할 수 있다.
JwtExam3에서는Access Token과Refresh Token을 함께 생성했다.
Access Token은 실제 요청에서 인증에 사용하는 토큰이고,Refresh Token은Access Token을 다시 발급받기 위해 사용하는 토큰이다.
두 토큰은 모두JWT형식이지만 목적이 다르기 때문에 유효 시간도 다르게 설정할 수 있다.
security10의 핵심은JWT가 코드에서 어떻게 생성되고, 어떤 기준으로 검증되며,Access Token과Refresh Token을 왜 나누어 사용하는지 이해하는 것이다.
JWT는 서버가 로그인 상태를 매 요청마다 세션 저장소에서 찾지 않아도, 요청에 담긴 토큰을 검증해 사용자를 확인할 수 있게 해 준다.
이 구조는REST API,SPA, 모바일 앱처럼 서버와 클라이언트가 분리된 환경에서 자주 사용된다.
하지만JWT가 무조건 더 안전하거나 항상 좋은 방식이라는 뜻은 아니다.
JWT는 서버 확장성과 요청 처리 흐름에서는 장점이 있지만, 토큰이 탈취되었을 때의 위험과 이미 발급한 토큰을 즉시 무효화하기 어렵다는 문제도 함께 가진다.
앞에서JWT는Header,Payload,Signature로 구성된다고 정리했다.
이 구조 덕분에 서버는Signature를 검증해 토큰이 조작되지 않았는지 확인할 수 있다.
하지만Payload는 디코딩하면 볼 수 있는 영역이므로, 민감한 정보를 넣으면 안 된다.
JWT는 서버 확장성과 분리된 클라이언트 구조에 잘 맞지만, 토큰 저장 위치와 만료 시간까지 함께 설계해야 안전하게 사용할 수 있다.
JWT를 사용하는 이유
서버가 세션 저장소에 로그인 상태를 계속 의존하지 않아도 된다
Session기반 인증에서는 서버가 로그인 상태를 세션 저장소에 보관한다.
브라우저는Session ID를 보내고, 서버는 그Session ID로 세션 저장소에서 사용자 정보를 찾는다.
반면JWT기반 인증에서는 일반적으로 클라이언트가 토큰을 가지고 요청한다.
서버는 요청에 담긴JWT의 서명과 만료 시간을 검증하고, 검증이 성공하면 토큰 안의 사용자 정보를 기준으로 요청을 처리할 수 있다.
즉, 매 요청마다 세션 저장소에서 로그인 상태를 찾는 구조와 다르게 동작할 수 있다.
예를 들어 게시글 목록 요청이 들어왔다고 생각하면 된다.
Session방식에서는 서버가Session ID로 세션 저장소를 조회해서 사용자를 확인한다.
JWT방식에서는 요청 헤더에 들어온 토큰을 검증하고, 토큰 안의 사용자 식별 정보를 읽어서 요청을 처리할 수 있다.
다만 이 말은 서버가 어떤 정보도 절대 저장하지 않는다는 뜻이 아니다.
실제 서비스에서는Refresh Token, 로그아웃된 토큰 목록, 차단된 토큰 목록, 권한 변경 내역 등을 서버에서 관리할 수 있다.
이 단계에서는JWT가 일반적인 세션 저장소 의존을 줄일 수 있는 구조라고 이해하면 된다.
JWT는 서버가 매 요청마다 세션 저장소에서 로그인 상태를 찾는 부담을 줄이고, 토큰 검증 중심으로 사용자를 확인할 수 있게 해 준다.
JWT의 장점
Signature로 데이터 위변조를 확인할 수 있다
JWT는Header,Payload,Signature로 구성된다.
여기서Signature는Header와Payload가 중간에 바뀌었는지 확인하는 값이다.
예를 들어Payload에role: USER가 들어 있다고 생각하면 된다.
누군가 이 값을role: ADMIN으로 바꾸면Payload내용은 바뀐다.
하지만 공격자가 서버의Secret Key나 개인키를 모르면 바뀐 내용에 맞는 올바른Signature를 만들 수 없다.
서버는 토큰을 받을 때 자신이 가진 검증 키나 비밀 키를 기준으로 서명이 유효한지 확인한다.
HS256같은HMAC방식에서는 같은Secret Key로 다시 계산해 비교하고,RS256같은 공개키 기반 방식에서는 공개키로 서명이 유효한지 확인한다.
서명 검증에 실패하면 토큰이 조작되었거나 신뢰할 수 없는 토큰이라고 판단할 수 있다.
이 점 때문에JWT는 클라이언트가 요청에 담아 보내는 토큰이면서도, 서버가 토큰 내용의 위변조 여부를 확인할 수 있다.
다만Signature는 내용을 숨기는 기능이 아니다.
내용이 바뀌었는지 확인하는 기능이다.
Signature는 토큰 내용을 숨기는 기능이 아니라, 토큰 내용이 중간에 바뀌었는지 확인하는 기능이다.
별도의 세션 저장소 부담을 줄일 수 있다
Session방식에서는 로그인한 사용자 수가 많아질수록 서버가 관리해야 하는 세션 정보도 많아진다.
서버가 여러 대라면 세션 정보를 여러 서버가 함께 볼 수 있도록 공유하거나, 특정 사용자의 요청이 같은 서버로 가도록 맞추는 작업도 필요할 수 있다.
JWT방식에서는 요청마다 토큰이 함께 오고, 서버는 그 토큰을 검증해서 사용자를 판단할 수 있다.
따라서 일반적인 세션 저장소를 계속 조회하는 부담을 줄일 수 있다.
예를 들어API서버가 1대에서 3대로 늘어난다고 생각하면 된다.
Session방식에서는 세션 정보를 여러 서버가 같이 볼 수 있도록 별도 설정이 필요할 수 있다.
JWT방식에서는 각 서버가 같은 검증 키를 알고 있다면, 요청에 담긴 토큰을 각자 검증할 수 있다.
다만JWT를 사용한다고 해서 모든 서버 저장소가 사라지는 것은 아니다.
Refresh Token을 서버에 저장하거나, 로그아웃된 토큰을 관리하는 구조를 만들 수도 있다.
그래서 정확히는JWT가 세션 저장소 의존을 줄일 수 있다고 이해하는 것이 좋다.
필요한 정보를 토큰 자체에 담을 수 있다
JWT의Payload에는 사용자 식별 정보나 권한 정보 같은 클레임을 담을 수 있다.
예를 들어username,role,exp같은 값이 들어갈 수 있다.
서버는 토큰을 검증한 뒤Payload에서 필요한 값을 꺼내 사용할 수 있다.
그래서 단순한 사용자 식별이나 권한 확인에서는 매번DB를 조회하지 않아도 되는 경우가 있다.
하지만 이 장점은 조심해서 이해해야 한다.
토큰 안의 정보는 발급 시점의 정보이다.
사용자 권한이 중간에 바뀌었는데 이미 발급된 토큰 안에는 예전 권한이 남아 있을 수 있다.
예를 들어 어떤 사용자가 처음에는ADMIN권한을 가지고 있었고, 그 상태로JWT를 발급받았다고 하자.
그 뒤에 서버에서 이 사용자의 권한을USER로 낮췄더라도, 이미 발급된 토큰 안에는 기존ADMIN정보가 남아 있을 수 있다.
그래서 중요한 권한 변경이 있는 서비스에서는 추가 검증이나 토큰 재발급 전략이 필요하다.
JWT의Payload에 담긴 정보는 발급 시점의 정보이므로, 권한 변경처럼 중요한 정보는 추가 확인이 필요할 수 있다.
서버 확장에 유리하다
JWT는Stateless구조와 잘 맞는다.
Stateless는 서버가 이전 요청의 로그인 상태를 세션처럼 계속 기억하지 않고, 요청에 포함된 정보를 바탕으로 처리하는 구조이다.
이 구조는 서버를 여러 대로 늘릴 때 유리할 수 있다.
각 서버가 같은 방식으로 토큰을 검증할 수 있다면, 어느 서버가 요청을 받아도 사용자 인증을 처리할 수 있기 때문이다.
예를 들어API서버가 여러 대로 나뉘어 있어도, 클라이언트가 요청마다JWT를 함께 보내면 각 서버는 토큰을 검증해서 사용자를 확인할 수 있다.
그래서REST API, 모바일 앱, 마이크로서비스 구조에서JWT가 자주 사용된다.
다만Stateless라고 해서 서비스 전체가 아무 상태도 관리하지 않는다는 뜻은 아니다.
토큰 재발급, 로그아웃 처리, 토큰 차단 목록처럼 서버가 별도로 관리해야 하는 상태가 생길 수 있다.
따라서JWT는 세션 의존을 줄이는 구조에 가깝다고 이해하면 된다.
다른 인증 시스템과 토큰 검증 흐름에 활용할 수 있다
JWT는 토큰 안에 사용자 식별 정보와 권한 정보를 담을 수 있다.
이 특징 때문에 여러 시스템이 같은 인증 정보를 기준으로 사용자를 확인하는 구조에서 활용될 수 있다.
예를 들어 회사 안에 인사 시스템, 결재 시스템, 관리자 시스템이 따로 있다고 생각하면 된다.
사용자가 한 번 로그인한 뒤 발급받은 토큰을 각 시스템에 전달하면, 각 시스템은 토큰을 검증해서 같은 사용자인지 확인할 수 있다.
다만 이 구조가 가능하려면 각 시스템이 토큰 검증 기준을 공유해야 한다.
예를 들어 같은Secret Key를 사용하거나, 공개키 기반 방식에서는 같은 공개키로 서명을 검증할 수 있어야 한다.
OAuth, 소셜 로그인,SSO같은 구조에서도 토큰 기반 인증 흐름이 사용될 수 있다.
다만 이런 구조에서 사용하는 토큰이 항상JWT형식이라는 뜻은 아니다.
구현 방식에 따라JWT형태의 토큰을 사용할 수도 있고, 다른 형식의 토큰을 사용할 수도 있다.
이 구간에서 중요한 점은JWT가 여러 서비스가 같은 기준으로 인증 정보를 검증하는 구조에 활용될 수 있다는 점이다.
여러 시스템이 토큰을 공유할수록 토큰 검증 키와 만료 시간 관리가 더 중요해진다.
검증 기준이 맞지 않으면 어떤 서비스에서는 정상 토큰으로 보이고, 다른 서비스에서는 잘못된 토큰으로 처리될 수 있기 때문이다.
모바일 애플리케이션 환경에서도 잘 동작한다
모바일 앱은 서버가 화면을 직접 만들어 주는 방식보다
API호출 중심으로 동작하는 경우가 많다.
앱은 로그인 후 토큰을 저장하고, 이후 요청마다 서버에 토큰을 함께 보낸다.
예를 들어 쇼핑 앱에서 사용자가 내 주문 목록을 조회한다고 생각하면 된다.
앱은 서버에 주문 목록 요청을 보내면서JWT를 함께 보낸다.
서버는 토큰을 검증한 뒤 해당 사용자의 주문 목록을 응답할 수 있다.
이처럼 모바일 앱과API서버 구조에서는 요청마다 인증 정보를 함께 보내는 토큰 방식이 자연스럽다.
그래서JWT는 모바일 애플리케이션 환경에서도 자주 사용된다.
JWT의 단점
Self-contained 구조는 양날의 검이다
JWT는 필요한 정보를 토큰 자체에 담을 수 있다.
이런 특징을Self-contained구조라고 볼 수 있다.
Self-contained는 필요한 내용을 자기 안에 가지고 있다는 뜻이다.
이 구조는 장점이 될 수 있다.
서버가 토큰을 검증한 뒤Payload에서 사용자 정보나 권한 정보를 바로 읽을 수 있기 때문이다.
하지만 동시에 단점도 된다.
토큰 안에 들어간 정보는 발급 시점의 정보이다.
사용자 권한이 바뀌었거나 계정이 정지되었는데도, 이미 발급된 토큰에는 이전 정보가 남아 있을 수 있다.
예를 들어 사용자의 권한이ADMIN에서USER로 바뀌었다고 생각하면 된다.
그런데 기존에 발급된 토큰 안에 여전히role: ADMIN이 들어 있고, 그 토큰이 아직 만료되지 않았다면 문제가 생길 수 있다.
그래서 중요한 권한 정보는 토큰만 믿지 않고 추가 확인이 필요할 수 있다.
Payload는 암호화된 공간이 아니다
JWT에서 가장 먼저 조심해야 할 부분은Payload이다.
Payload는Base64 URL-safe방식으로 인코딩될 뿐, 암호화된 값이 아니다.
즉, 토큰을 가진 사람은JWT Debugger같은 도구로Payload내용을 확인할 수 있다.
그래서 비밀번호, 주민등록번호, 카드번호처럼 노출되면 안 되는 값은 절대 넣으면 안 된다.
Payload에는 사용자 식별자나 권한처럼 서버가 인증 판단에 사용할 수 있는 값을 넣을 수 있다.
하지만 노출되어도 치명적인 문제가 되는 민감 정보는 넣지 않는 것이 원칙이다.
JWT의Payload는 디코딩해서 볼 수 있으므로 민감한 정보를 담으면 안 된다.
토큰 길이가 길어질 수 있다
JWT는Header,Payload,Signature를 모두 포함한다.
Payload에 담는 정보가 많아질수록 토큰 문자열도 길어진다.
토큰은 요청마다 서버로 전달될 수 있다.
토큰이 너무 길어지면 매 요청마다 함께 보내야 하는 데이터 양도 늘어난다.
따라서JWT에는 꼭 필요한 정보만 담는 것이 좋다.
예를 들어 사용자 식별값, 권한, 만료 시간 정도는 사용할 수 있지만, 사용자 상세 프로필 전체를 토큰에 담는 것은 적절하지 않다.
토큰은 인증 요청마다 반복해서 전달될 수 있기 때문에,Payload를 작게 유지하는 것이 좋다.
토큰이 탈취되면 만료 전까지 악용될 수 있다
JWT는 일반적으로 클라이언트가 가지고 있다가 요청마다 서버에 보낸다.
그래서 토큰이 탈취되면 공격자가 그 토큰을 이용해 인증된 사용자처럼 요청할 수 있다.
예를 들어 사용자의Access Token이 노출되었다고 생각하면 된다.
공격자는 그 토큰을Authorization: Bearer 토큰값형태로 요청에 담아 보낼 수 있다.
서버가 토큰을 유효하다고 판단하면 정상 사용자 요청처럼 처리할 위험이 있다.
그래서Access Token은 보통 짧은 유효 시간을 가진다.
만료 시간을 짧게 두면 토큰이 노출되더라도 악용 가능한 시간을 줄일 수 있다.
이 단점은 다음 구간의Access Token과Refresh Token분리 전략으로 이어진다.
실제 인증 구조에서는 자주 쓰는Access Token은 짧게 유지하고, 새 토큰 발급에 사용하는Refresh Token은 더 조심해서 관리하는 방식이 사용된다.
이미 발급한 토큰을 즉시 무효화하기 어렵다
Session방식에서는 서버가 세션 저장소에서 해당 세션을 삭제하면 로그인 상태를 끊을 수 있다.
서버가 로그인 상태를 직접 관리하기 때문에 무효화가 비교적 명확하다.
반면JWT는 발급된 뒤 일반적으로 클라이언트가 가지고 다닌다.
서버가 토큰 자체를 저장하지 않는 구조라면, 이미 발급된 토큰을 즉시 무효화하기 어렵다.
토큰이 만료될 때까지 유효한 토큰으로 남을 수 있다.
이 문제를 줄이기 위해 실제 서비스에서는 여러 방법을 함께 사용할 수 있다.
Access Token만료 시간을 짧게 설정한다.Refresh Token은 서버에 저장하고 관리한다.- 로그아웃된 토큰이나 차단할 토큰을 별도 목록으로 관리한다.
즉,
JWT는 편하지만 토큰 생명주기 관리가 중요하다.
발급만 하고 끝나는 것이 아니라, 만료 시간과 재발급 흐름까지 함께 설계해야 한다.
JWT는 발급 후 만료 전까지 유효하게 남을 수 있으므로, 짧은 만료 시간과 재발급 전략을 함께 설계해야 한다.
Session 기반 인증과 JWT 기반 인증 비교
두 방식은 로그인 상태를 관리하는 위치가 다르다
Session기반 인증과JWT기반 인증은 모두 로그인한 사용자를 확인하기 위한 방식이다.
하지만 로그인 상태를 관리하는 위치와 서버가 사용자를 확인하는 방식이 다르다.
Session방식은 서버가 로그인 상태를 세션 저장소에 저장한다.
클라이언트는Session ID를 보내고, 서버는 세션 저장소에서 사용자 정보를 찾는다.
JWT방식은 일반적으로 클라이언트가 토큰을 들고 요청한다.
서버는 세션 저장소에서 로그인 상태를 찾는 대신, 전달받은 토큰의 서명과 만료 시간을 검증한다.
Session기반 인증은 서버 세션 저장소에 로그인 상태를 유지한다.
반면JWT기반 인증은 서버가 상태를 계속 기억하지 않는Stateless구조에 가깝다.
JWT는 서버 확장에 유리할 수 있지만, 토큰 자체를 신뢰하는 구조이므로 탈취된 토큰에 바로 대응하기 어렵다는 단점도 있다.
비교해서 정리하기
두 방식을 비교하면 다음과 같다.
Session방식은 서버가 로그인 상태를 저장한다.JWT방식은 일반적으로 클라이언트가 인증 토큰을 저장한다.Session방식은 세션 저장소 조회가 필요하다.JWT방식은 토큰 서명과 만료 시간을 검증한다.Session방식은 서버에서 세션을 삭제해 무효화하기 쉽다.JWT방식은 이미 발급된 토큰을 즉시 무효화하기 어렵다.Session방식은 서버 중심 웹 서비스에 자연스럽다.JWT방식은API중심 서비스와 서버 확장 구조에 잘 맞는다.둘 중 하나가 무조건 더 좋은 방식은 아니다.
화면 중심의 전통적인 웹 서비스에서는Session방식이 단순하고 자연스러울 수 있다.
반대로React같은 프론트엔드와Spring BootAPI서버가 분리된 구조에서는JWT방식이 잘 맞을 수 있다.
JWT 장점과 사용 이유 핵심 정리
JWT는Header,Payload,Signature를 가진 토큰이다.
서버는Signature를 검증해서 토큰이 중간에 조작되었는지 확인할 수 있다.
JWT는 일반적인 세션 저장소에 로그인 상태를 계속 저장하지 않아도 인증 흐름을 만들 수 있다는 장점이 있다.
그래서REST API,SPA, 모바일 앱, 마이크로서비스처럼 서버와 클라이언트가 분리된 구조에 잘 맞는다.
하지만JWT에는 단점도 있다.
Payload는 암호화된 공간이 아니므로 민감한 정보를 넣으면 안 된다.
토큰이 탈취되면 만료 전까지 악용될 수 있고, 이미 발급한 토큰을 즉시 무효화하기 어렵다.
그래서 실제 인증 구조에서는Access Token과Refresh Token을 나누어 사용하는 경우가 많다.
Access Token은 실제 요청 인증에 사용하고 짧게 유지한다.
Refresh Token은 새Access Token을 발급받는 용도로 사용하고 더 조심해서 관리한다.
JWT는 서버 확장성과API중심 구조에 유리하지만, 토큰 탈취 위험과 무효화 문제를 함께 고려해야 한다.
JWT를 인증에 사용할 때 토큰 하나만 오래 유지하면 위험이 커진다.
토큰이 탈취되면 만료되기 전까지 공격자가 인증된 사용자처럼 요청할 수 있기 때문이다.
그래서 실제 인증 구조에서는Access Token과Refresh Token을 나누어 사용하는 경우가 많다.
두 토큰은 모두 인증 흐름에서 사용되지만 역할이 다르다.
Access Token은 실제API요청에 사용하는 토큰이고,Refresh Token은Access Token이 만료되었을 때 새Access Token을 발급받기 위해 사용하는 토큰이다.
앞에서JWT는 서버 확장성과API중심 구조에 유리하지만, 토큰 탈취 위험과 무효화 문제를 함께 고려해야 한다고 정리했다.
Access Token과Refresh Token을 나누는 이유도 이 문제와 연결된다.
자주 사용하는 토큰은 짧게 유지하고, 새 토큰을 발급받기 위한 토큰은 더 조심해서 관리하는 방식이다.
Access Token은 실제 요청 인증용이고,Refresh Token은 새Access Token을 발급받기 위한 재발급용 토큰이다.
Access Token과 Refresh Token의 차이
두 토큰은 목적과 유효 시간이 다르다
Access Token은 사용자가 보호된 자원에 접근할 때 사용하는 토큰이다.
보호된 자원은 로그인한 사용자만 볼 수 있는 데이터나 기능을 의미한다.
예를 들어 내 정보 조회, 주문 목록 조회, 게시글 작성 같은 요청이 여기에 해당한다.
클라이언트는 서버에 요청을 보낼 때Authorization헤더에Access Token을 담아 보낸다.
서버는 이 토큰의 서명과 만료 시간을 검증한다.
토큰이 유효하면 요청한 기능을 처리한다.
Refresh Token은 평소API요청에 계속 사용하는 토큰이 아니다.
Access Token이 만료되었을 때 새Access Token을 발급받기 위해 사용하는 토큰이다.
두 토큰의 차이는 다음처럼 정리할 수 있다.
Access Token은 실제API요청 인증에 사용한다.Refresh Token은Access Token재발급에 사용한다.Access Token은 짧은 유효 기간을 가진다.Refresh Token은 상대적으로 긴 유효 기간을 가진다.Access Token은 요청마다 자주 전달된다.Refresh Token은Access Token이 만료되었을 때 사용된다.예를 들어 출입증과 재발급 카드로 생각하면 된다.
Access Token은 문을 열 때마다 보여주는 출입증에 가깝다.
Refresh Token은 출입증이 만료되었을 때 새 출입증을 다시 받기 위한 재발급용 카드에 가깝다.
Access Token은 실제 리소스 접근 인증에 사용하는 토큰이다.
유효 기간은 짧게 설정하는 경우가 많고, 모든API요청에 자주 사용된다.
반면Refresh Token은Access Token을 다시 발급받기 위한 토큰이다.
상대적으로 긴 유효 기간을 가지며, 보통HttpOnly Cookie,DB,Redis처럼 더 안전하게 관리할 수 있는 위치에 저장한다.
두 토큰은 모두 인증 흐름에 사용되지만, 목적과 저장 위치, 사용 빈도, 보안 위험, 서버 저장 여부가 다르다.
Access Token 인증 절차
Access Token은 요청마다 사용자를 증명하는 데 사용된다
Access Token인증 흐름은 사용자가 로그인한 뒤 실제API를 호출할 때의 흐름이다.
서버는 로그인 성공 시Access Token을 발급한다.
클라이언트는 이 토큰을 저장해 두었다가 인증이 필요한 요청마다 함께 보낸다.
흐름은 다음과 같다.
- 사용자가 아이디와 비밀번호로 로그인한다.
- 서버는 계정 정보를 확인한다.
- 서버는 사용자 식별 정보와 권한 정보를
Payload에 넣을 수 있다.- 서버는 만료 시간을 설정한다.
- 서버는
Secret Key로 서명해Access Token을 발급한다.- 클라이언트는
Access Token을 저장한다.- 클라이언트는 인증이 필요한 요청마다
Authorization헤더에 토큰을 담아 보낸다.- 서버는 토큰의
Signature와 만료 시간을 검증한다.- 검증에 성공하면 서버는 요청을 처리한다.
예를 들어 사용자가 로그인한 뒤
/api/orders로 주문 목록을 요청한다고 생각하면 된다.
클라이언트는 요청 헤더에Authorization: Bearer AccessToken값을 담아 보낸다.
서버는 이 토큰을 검증한 뒤, 토큰에서 사용자 정보를 확인하고 해당 사용자의 주문 목록을 응답한다.
Access Token은 실제 요청에 자주 사용되므로 탈취 위험을 줄이기 위해 유효 시간을 짧게 설정하는 경우가 많다.
유효 시간이 짧으면 토큰이 노출되더라도 악용 가능한 시간을 줄일 수 있다.
클라이언트가 인증 정보 없이 요청하면 서버는
401 Unauthorized를 응답할 수 있다.
이후 클라이언트는Authorization헤더에 인증 정보를 담아 다시 요청한다.
형식은Authorization: <type> <credentials>이다.
JWT인증에서는 보통<type>에Bearer를 사용하고,<credentials>자리에Access Token값을 넣는다.
서버는 이 토큰을 검증한 뒤 요청을 허용하거나 거부한다.
Access Token이 만료되면 생기는 문제
짧은 유효 시간은 안전하지만 사용성을 떨어뜨릴 수 있다
Access Token을 짧게 유지하면 보안에는 도움이 된다.
하지만 너무 짧으면 사용자는 자주 다시 로그인해야 할 수 있다.
예를 들어Access Token의 유효 시간이 1분이라고 생각하면 된다.
사용자는 로그인한 지 1분이 지나면Access Token이 만료된다.
이때 매번 다시 아이디와 비밀번호를 입력해야 한다면 사용하기 불편하다.
그래서Refresh Token이 필요하다.
Refresh Token은 사용자가 다시 로그인하지 않아도 새Access Token을 발급받을 수 있게 도와준다.
이 구조를 사용하면Access Token은 짧게 유지해서 위험을 줄이고,Refresh Token으로 사용성을 보완할 수 있다.
즉, 보안과 편의성을 함께 고려하는 구조이다.
Refresh Token 인증 절차
Refresh Token은 Access Token을 다시 발급받기 위해 사용된다
Refresh Token은Access Token이 만료되었을 때 사용하는 토큰이다.
평소 모든API요청에 계속 보내는 토큰이 아니라, 새Access Token을 발급받는 요청에서 사용된다.
흐름은 다음과 같다.
- 사용자가 로그인한다.
- 서버는
Access Token과Refresh Token을 함께 발급한다.- 클라이언트는 평소
Access Token으로API를 요청한다.- 서버는
Access Token을 검증하고 데이터를 응답한다.Access Token이 만료되면 서버는 만료 응답을 보낸다.- 클라이언트는
Refresh Token으로 새Access Token을 요청한다.- 서버는
Refresh Token을 검증한다.- 검증에 성공하면 서버는 새
Access Token을 발급한다.Refresh Token도 만료되었거나 유효하지 않으면 다시 로그인해야 한다.여기서 중요한 점은
Refresh Token도 안전하게 관리해야 한다는 것이다.
Refresh Token이 탈취되면 공격자가 새Access Token을 계속 발급받을 수 있기 때문이다.
그래서 실제 서비스에서는Refresh Token을 서버에 저장하거나,HttpOnly Cookie처럼JavaScript에서 직접 읽기 어려운 저장 방식과 함께 사용하는 경우가 있다.
사용자가 로그인하면 서버는
Access Token과Refresh Token을 함께 발급한다.
클라이언트는 평소 요청에는Access Token을 사용한다.
서버는 요청마다Access Token을 검증하고, 토큰이 유효하면 데이터를 응답한다.
Access Token이 만료되면 서버는 만료 응답을 보내고, 클라이언트는Refresh Token을 이용해 새Access Token을 요청한다.
서버는Refresh Token을 확인한 뒤 새로운Access Token을 다시 발급한다.
Refresh Token을 사용할 때 주의할 점
Refresh Token은 새 Access Token을 발급할 수 있기 때문에 더 조심해야 한다
Refresh Token은Access Token을 다시 발급받기 위해 사용하는 토큰이다.
그래서Access Token보다 상대적으로 오래 유지되는 경우가 많다.
하지만 오래 유지된다는 것은 그만큼 탈취되었을 때 위험도 커질 수 있다는 뜻이다.
공격자가Refresh Token을 가져가면 새Access Token을 발급받으려고 시도할 수 있다.
그래서Refresh Token은 단순히 클라이언트에 저장해 두는 값으로만 보면 안 된다.
Refresh Token을 보호하기 위한 대표 방법은 다음과 같다.
HttpOnly Cookie에 저장해서JavaScript에서 직접 읽기 어렵게 만든다.- 서버
DB에 사용자 계정별Refresh Token을 저장하고 관리한다.- 로그아웃하거나 의심스러운 요청이 발생하면 서버에 저장된
Refresh Token을 폐기하거나 블랙리스트로 처리한다.Refresh Token재발급 요청이 들어왔을 때IP나 디바이스 정보를 함께 확인해 접속 환경이 크게 달라졌는지 검사한다.
HttpOnly Cookie는 브라우저가 쿠키를 저장하더라도JavaScript코드로 직접 읽지 못하게 막는 설정이다.
이렇게 하면 스크립트 공격으로 토큰이 바로 노출되는 위험을 줄일 수 있다.
서버DB에 저장하는 방식은 서버가Refresh Token을 직접 관리할 수 있게 만든다.
이 방식에서는 특정 사용자의Refresh Token을 무효화하거나 블랙리스트로 처리할 수 있다.
즉, 이미 발급된 토큰을 서버 기준으로 더 세밀하게 통제할 수 있다.
IP와 디바이스 기반 검증은 재발급 요청이 평소와 다른 환경에서 들어왔는지 확인하는 보조 방법이다.
예를 들어 평소와 전혀 다른 지역이나 기기에서 갑자기Refresh Token요청이 들어오면 위험 신호로 볼 수 있다.
Refresh Token은 새Access Token을 발급받을 수 있는 중요한 값이므로, 저장 위치와 서버 관리 전략을 함께 설계해야 한다.
Access Token과 Refresh Token 핵심 정리
Access Token과Refresh Token은 모두 인증 흐름에서 사용되는 토큰이다.
하지만 역할은 다르다.
Access Token은 실제API요청에 사용된다.
클라이언트는 인증이 필요한 요청마다Access Token을 보내고, 서버는 토큰을 검증해서 사용자를 확인한다.
Access Token은 자주 사용되므로 보통 짧은 유효 시간을 가진다.
Refresh Token은 새Access Token을 발급받기 위해 사용된다.
Access Token이 만료되었을 때Refresh Token을 서버에 보내면, 서버는 이를 검증한 뒤 새Access Token을 발급할 수 있다.
두 토큰을 나누는 이유는 보안과 편의성을 함께 잡기 위해서이다.
Access Token은 짧게 유지해서 탈취 위험을 줄이고,Refresh Token은 새 토큰을 발급받는 용도로 사용해 사용자가 계속 다시 로그인하지 않아도 되게 만든다.
다만Refresh Token은 더 오래 유지되는 경우가 많기 때문에 더 안전하게 관리해야 한다.
Refresh Token이 탈취되면 공격자가 새Access Token을 발급받을 수 있기 때문이다.
Access Token은 요청 인증용 토큰이고,Refresh Token은 새Access Token을 발급받기 위한 재발급용 토큰이다.
security10은SpringMVC에서JWT를 발급하고, 발급된 토큰을 다시 요청Header에 담아 검증하는 실습이다.
앞에서는JWT를 순수Java코드로 직접 생성하고 검증했다.
이번에는 웹 요청 흐름 안에서JWT가 어떻게 오고 가는지 확인한다.
이 실습에서는 같은 로그인 요청이라도 토큰을 내려주는 방식이 다르게 구성된다.
/auth1과/auth2는 모두 토큰을 발급하는 요청이다.
차이는 토큰을 내려주는 위치이다.
/auth1은 생성된JWT를 응답Body에 담아 내려주고,/auth2는 생성된JWT를 응답Header의Authorization에 담아 내려준다.
반면/auth3는 토큰을 새로 발급하는 요청이 아니다.
이미 발급받은 토큰을 요청Header의Authorization에 담아 보내고, 서버가 그 안에서 실제JWT를 꺼내 로그인 상태를 확인하는 요청이다.
security10의 핵심은JWT를 어디에 담아 발급하는지, 그리고 클라이언트가 발급받은 토큰을 다시 어떤 형식으로 서버에 보내는지 이해하는 것이다.
security10에서 확인할 전체 흐름
JWT 발급과 검증을 요청 단위로 나누어 본다
security10에서는JWT를 단순히 콘솔에 출력하지 않는다.
클라이언트가 서버에 로그인 요청을 보내고, 서버가JWT를 발급한 뒤, 클라이언트가 다시 그 토큰을 요청에 담아 보내는 흐름을 확인한다.
전체 흐름은 다음과 같다.
AuthenticationDTO는 로그인 요청 데이터를 담는다.JwtUtil은JWT생성과 파싱을 담당한다.AuthController1은JWT를 응답Body로 내려준다.AuthController2는JWT를 응답Header의Authorization에 내려준다.AuthController3은 요청Header의Authorization값을 읽고 로그인 상태를 확인한다.auth23.html은 브라우저에서/auth2와/auth3흐름을 확인한다.이 흐름을 이해하면
JWT가 단순히 생성되는 문자열이 아니라, 실제 로그인 요청과 인증 확인 요청 사이에서 어떻게 전달되는지 볼 수 있다.
여기서 중요한 점은/auth1,/auth2,/auth3의 역할을 구분하는 것이다.
/auth1과/auth2는 토큰을 발급하는 요청이다.
/auth1은 응답 본문에서 토큰을 바로 확인하는 방식이고,/auth2는 응답 헤더에서 토큰을 확인하는 방식이다.
반면/auth3는 이미 받은 토큰을 다시 서버에 보내서 로그인 상태를 확인하는 요청이다.
JwtUtil은 JWT 생성과 파싱을 담당한다
JwtUtil은 토큰 관련 기능을 한곳에 모아 둔다
JwtUtil은JWT를 생성하고 해석하는 기능을 담당한다.
Controller안에서 직접 토큰 생성 코드를 모두 작성하면 코드가 복잡해진다.
그래서 토큰 생성과 파싱 기능을JwtUtil같은 별도 클래스로 분리한다.
JwtUtil의 역할은 크게 두 가지이다.
- 로그인한 사용자를 기준으로
JWT를 생성한다.- 요청으로 받은
JWT를 파싱해서Claims를 꺼낸다.
Claims는JWT의Payload에 들어 있는 정보 묶음이다.
예를 들어sub,iat,exp같은 값이Claims안에 들어 있다.
예를 들어username이"unico"라고 생각하면 된다.
서버는"unico"를sub에 넣어JWT를 생성할 수 있다.
나중에 클라이언트가 이 토큰을 다시 보내면 서버는 토큰을 파싱해서sub에 들어 있는"unico"를 꺼낼 수 있다.
// JwtUtil.java package com.example.security10.service; import java.security.Key; import java.util.Date; import org.springframework.stereotype.Service; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; @Service public class JwtUtil { // JWT 서명에 사용할 비밀 키이다. private static final String SECRET_KEY = "your-256-bit-secret-your-256-bit-secret"; // 문자열 비밀 키를 서명용 Key 객체로 바꾼다. private Key getSigningKey() { return Keys.hmacShaKeyFor(SECRET_KEY.getBytes()); } // username을 기준으로 JWT를 생성한다. public String generateToken(String username) { long now = System.currentTimeMillis(); long exp = now + 1000 * 60 * 1; // 1분 유효 System.out.println("username : " + username); return Jwts.builder() // sub에 사용자 이름을 넣는다. .setSubject(username) // iat에 발급 시간을 넣는다. .setIssuedAt(new Date(now)) // exp에 만료 시간을 넣는다. .setExpiration(new Date(exp)) // Secret Key와 HS256으로 서명한다. .signWith(getSigningKey(), SignatureAlgorithm.HS256) // 최종 JWT 문자열을 만든다. .compact(); } // JWT 토큰을 파싱하고 Claims를 추출한다. public Claims parseToken(String token) { Jws<Claims> jwsClaims = Jwts.parser() // 토큰 검증에 사용할 Key를 지정한다. .setSigningKey(getSigningKey()) // parser를 완성한다. .build() // JWT 구조와 서명을 검증하며 파싱한다. .parseClaimsJws(token); System.out.println("jwsClaims : " + jwsClaims); // Payload에 해당하는 Claims를 반환한다. return jwsClaims.getBody(); } }
generateToken()은JWT를 만드는 메서드이다.
setSubject(username)으로 토큰의 주체를 넣고,setIssuedAt()으로 발급 시간을 넣는다.
setExpiration()은 토큰 만료 시간을 넣는 부분이다.
마지막으로signWith()에서Secret Key로 서명하고,compact()로 최종JWT문자열을 만든다.
parseToken()은 전달받은JWT를 해석하는 메서드이다.
서버는 토큰을 그대로 믿지 않는다.
setSigningKey()에 검증용 키를 넣고parseClaimsJws()를 실행해 토큰 구조, 서명, 만료 시간을 확인한다.
검증이 통과되면Payload의Claims를 꺼내 반환한다.
만약 토큰이 만료되었거나, 서명이 맞지 않거나, 토큰 구조가 잘못되었다면parseClaimsJws()과정에서 예외가 발생할 수 있다.
그래서 뒤에서/auth3는try-catch를 사용해 만료된 토큰과 그 밖의 오류를 따로 처리한다.
이 흐름을 알면JwtUtil의 파싱 기능과AuthController3의 예외 처리 코드가 자연스럽게 연결된다.
AuthenticationDTO는 로그인 요청 데이터를 담는다
요청 Body의 JSON 값을 객체로 받는다
AuthenticationDTO는 로그인 요청 데이터를 담는 객체이다.
클라이언트가 로그인 요청을 보낼 때username과password를JSON형태로 보낸다.
서버는 이 요청Body를AuthenticationDTO로 받아 사용할 수 있다.
요청 데이터는 아래처럼 들어온다.// 요청 Body { "username": "unico", "password": "0000" }
username은 사용자를 구분하기 위한 값이다.
password는 비밀번호 값이다.
이번 실습의 핵심은 실제 비밀번호 검증 로직이 아니라, 로그인 요청을 받았다고 가정하고JWT를 발급하는 흐름을 확인하는 것이다.
// AuthenticationDTO.java package com.example.security10.dto; import lombok.Getter; import lombok.Setter; import lombok.ToString; @Getter @Setter @ToString public class AuthenticationDTO { // 로그인 요청에서 전달되는 사용자 이름이다. private String username; // 로그인 요청에서 전달되는 비밀번호이다. private String password; }
AuthenticationDTO가 있으면Controller는 요청Body의 값을 하나씩 직접 꺼내지 않아도 된다.
SpringMVC가JSON의username,password값을 객체의 필드에 매핑해 준다.
이렇게 요청 데이터를 객체로 받으면 로그인 처리 코드가 더 읽기 쉬워진다.
이 흐름은 컨트롤러의@RequestBody AuthenticationDTO authRequest와 직접 연결된다.
@RequestBody는 요청Body에 들어온JSON데이터를Java객체로 바꿔서 매개변수에 넣어 준다.
그래서 요청Body의username값은authRequest.getUsername()으로 읽을 수 있고,password값은authRequest.getPassword()로 읽을 수 있다.
@Getter와@Setter는Lombok이getter,setter메서드를 자동으로 만들어 주는 어노테이션이다.
그래서 코드에 직접getUsername(),setUsername()을 작성하지 않아도 컨트롤러에서authDto.getUsername()처럼 값을 읽을 수 있다.
AuthController1에서 JWT를 응답 Body로 내려주기
auth1은 발급한 JWT를 Body에 담아 반환한다
AuthController1은 로그인 요청을 받아JWT를 생성한 뒤, 생성된 토큰을 응답Body로 내려주는 방식이다.
응답Body는 서버가 클라이언트에게 보내는 응답 내용 영역이다.
/auth1요청 흐름은 다음과 같다.
- 클라이언트가
/auth1로POST요청을 보낸다.- 요청
Body에는username,password가 들어 있다.- 서버는
AuthenticationDTO로 요청 데이터를 받는다.- 서버는
username을 기준으로JWT를 생성한다.- 서버는 생성된 토큰을
JwtResponse객체에 담아 응답Body로 반환한다.이 방식은 토큰이 응답 본문에 바로 보이기 때문에 테스트하기 쉽다.
Talend API Tester에서도 응답Body를 보면token값을 바로 확인할 수 있다.
// AuthController1.java package com.example.security10.controller; import com.example.security10.dto.AuthenticationDTO; import com.example.security10.service.JwtUtil; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController public class AuthController1 { @Autowired private JwtUtil jwtUtil; @PostMapping("/auth1") public ResponseEntity<?> createAuthenticationToken(@RequestBody AuthenticationDTO authRequest) { // 실제 환경에서는 user 검증 로직이 필요하다. // 여기서는 username을 기준으로 JWT를 생성한다. String jwt = jwtUtil.generateToken(authRequest.getUsername()); // 생성된 JWT를 응답 Body에 담아 반환한다. return ResponseEntity.ok(new JwtResponse(jwt)); } // JWT 응답 DTO이다. static class JwtResponse { private String token; // 생성된 JWT를 token 필드에 저장한다. public JwtResponse(String token) { this.token = token; } // token 값을 반환한다. public String getToken() { return token; } // token 값을 저장한다. public void setToken(String token) { this.token = token; } } }
@RestController는 반환 값을 화면 이름으로 보지 않고 응답 데이터로 내려주는 컨트롤러이다.
그래서ResponseEntity.ok(new JwtResponse(jwt))로 반환하면JwtResponse객체가JSON형태로 응답Body에 들어간다.
JwtResponse는 응답Body에 담을 토큰 값을 표현하는 객체이다.
응답 결과에서는token이라는 이름으로JWT가 내려온다.
/auth1요청은 로그인 요청 데이터를JSON형태로 전달받는다.
서버는username값을 기준으로JWT를 생성하고, 생성된 토큰을 응답Body의token필드에 담아 반환한다.
응답 상태 코드는200이고, 응답Body에는token필드가 들어 있다.
이 방식에서는JWT가 응답 본문에 들어오므로, 테스트 도구에서Body영역만 봐도 토큰을 바로 확인할 수 있다.
AuthController2에서 JWT를 Authorization Header로 내려주기
auth2는 발급한 JWT를 응답 Header에 담아 반환한다
AuthController2도 로그인 요청을 받아JWT를 생성한다.
하지만/auth1과 다르게 토큰을 응답Body에 넣지 않는다.
대신 응답Header의Authorization에 담아 내려준다.
Header는 요청이나 응답에 대한 부가 정보를 담는 영역이다.
Authorization은 인증 정보를 전달할 때 자주 사용하는 헤더 이름이다.
JWT를 사용할 때는 보통Bearer타입과 함께 사용한다.
/auth2요청 흐름은 다음과 같다.
- 클라이언트가
/auth2로POST요청을 보낸다.- 요청
Body에는username,password가 들어 있다.- 서버는
username을 기준으로JWT를 생성한다.- 서버는 응답
Header의Authorization에Bearer 토큰값을 담는다.- 응답
Body에는 별도 내용을 담지 않는다.이 방식에서는 응답
Body가 비어 있어도 실패가 아니다.
토큰이 응답 본문이 아니라 응답Header에 들어 있기 때문이다.
그래서Talend API Tester에서는 응답Headers영역을 반드시 확인해야 한다.
// AuthController2.java package com.example.security10.controller; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import com.example.security10.dto.AuthenticationDTO; import com.example.security10.service.JwtUtil; @RestController public class AuthController2 { @Autowired private JwtUtil jwtUtil; @PostMapping("/auth2") public ResponseEntity<?> createAuthenticationToken(@RequestBody AuthenticationDTO authDto) { // 실제 환경이라면 사용자 인증 로직이 필요하다. String jwt = jwtUtil.generateToken(authDto.getUsername()); System.out.println("authDto : " + authDto); // JWT 토큰을 Authorization 헤더에 넣는다. HttpHeaders headers = new HttpHeaders(); headers.add("Authorization", "Bearer " + jwt); System.out.println(jwt); // 응답 Body 없이 Header만 포함해서 반환한다. return ResponseEntity.ok().headers(headers).build(); } }
HttpHeaders는 응답Header값을 구성할 때 사용하는 객체이다.
여기서는headers.add("Authorization", "Bearer " + jwt)로 응답 헤더에 인증 정보를 추가한다.
ResponseEntity.ok().headers(headers).build()는 상태 코드200과 응답Header를 포함한 응답을 만든다.
build()를 사용했기 때문에 응답Body에는 별도 데이터가 없다.
그래서Talend API Tester에서는Body영역에 표시할 내용이 없게 보일 수 있지만, 이것은HTTP상태 코드가204 No Content라는 뜻이 아니다.
이 코드는ResponseEntity.ok()를 사용하므로 응답 상태 코드는200이고, 토큰은 응답Headers의Authorization에서 확인해야 한다.
/auth2요청은 로그인 요청 데이터를 받은 뒤JWT를 생성한다.
생성된 토큰은 응답Body가 아니라 응답Header의Authorization에 담긴다.
그래서 응답 본문에는 표시할 내용이 없게 보이지만, 응답 상태 코드는200이다.
응답 헤더를 확인하면Authorization: Bearer 토큰값형태로 토큰이 내려온 것을 확인할 수 있다.
이미지에는Authorization헤더와 함께Set-Cookie의JSESSIONID도 보인다.
이 실습에서 핵심 인증 값은Authorization에 들어 있는Bearer토큰이다.
뒤에서RemoveJsessionIdFilter를 사용해JSESSIONID가 함께 보이는 혼동을 줄이는 흐름을 확인한다.
AuthController3에서 Bearer Token을 읽고 로그인 상태 확인하기
auth3은 요청 Header의 Authorization 값을 읽는다
AuthController3은 토큰을 새로 발급하는 컨트롤러가 아니다.
이미 발급받은JWT를 요청Header에 담아 보냈을 때, 서버가 그 토큰을 읽고 로그인 상태를 확인하는 컨트롤러이다.
/auth3요청 흐름은 다음과 같다.
- 클라이언트가
/auth3로GET요청을 보낸다.- 요청
Header에Authorization: Bearer 토큰값을 담는다.- 서버는
Authorization헤더가 있는지 확인한다.- 값이
Bearer로 시작하는지 확인한다.Bearer뒤의 실제JWT만 잘라낸다.JwtUtil.parseToken()으로 토큰을 파싱한다.- 토큰의
Claims에서sub값을 읽어 로그인 사용자를 확인한다.여기서
Bearer를 제거하는 이유는 실제JWT문자열만 파싱해야 하기 때문이다.
Authorization헤더 전체 값은Bearer 토큰값형태이다.
하지만JwtUtil.parseToken()에 넣어야 하는 값은Bearer가 붙은 전체 문자열이 아니라, 뒤쪽의 실제JWT문자열이다.
// AuthController3.java package com.example.security10.controller; import java.nio.charset.StandardCharsets; import com.example.security10.service.JwtUtil; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Claims; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RestController; @RestController public class AuthController3 { @Autowired private JwtUtil jwtUtil; @GetMapping("/auth3") public ResponseEntity<String> extractToken(@RequestHeader(value = "Authorization", required = false) String authorizationHeader) { // Authorization 헤더가 없거나 Bearer 형식이 아니면 실패 응답을 반환한다. if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Authorization 헤더가 없거나 형식이 올바르지 않습니다."); } // Bearer 접두어를 제거하고 실제 JWT만 추출한다. String token = authorizationHeader.substring(7); String subject = ""; String content; try { // JWT를 파싱해서 Claims를 꺼낸다. Claims claims = jwtUtil.parseToken(token); // sub 값을 읽어 로그인 사용자를 확인한다. subject = claims.getSubject(); content = "로그인 확인됩니다.." + subject + "회원님!!"; } catch (ExpiredJwtException e) { // 만료된 토큰이면 재로그인 안내 메시지를 만든다. content = "전송된 JWT 의 유효시간이 지났습니다. 재로그인 하세요~~~"; } catch (Exception e) { // 그 밖의 오류를 처리한다. e.printStackTrace(); content = "오류가 발생했습니다"; } // 한글 응답이 깨지지 않도록 text/plain UTF-8로 설정한다. HttpHeaders headers = new HttpHeaders(); headers.setContentType(new MediaType("text", "plain", StandardCharsets.UTF_8)); // 로그인 확인 메시지를 응답한다. return ResponseEntity.ok() .headers(headers) .body(content); } }
@RequestHeader는 요청Header값을 컨트롤러 메서드 매개변수로 받게 해 준다.
여기서는 요청Header의Authorization값을authorizationHeader변수로 받는다.
authorizationHeader.startsWith("Bearer ")는 헤더 값이 올바른 형식인지 확인한다.
Bearer로 시작하지 않으면 서버가 어떤 방식의 인증 정보인지 판단하기 어렵다.
authorizationHeader.substring(7)은Bearer부분을 잘라내는 코드이다.
Bearer여섯 글자와 뒤의 공백 한 칸까지 포함하면 총 7글자이다.
그래서 7번째 위치부터 잘라 실제JWT만 꺼낸다.
jwtUtil.parseToken(token)은 토큰을 파싱하고 검증한 뒤Claims를 반환한다.
그 다음claims.getSubject()로sub값을 꺼낸다.
이 값이 로그인 사용자를 확인하는 기준이 된다.
/auth3는GET요청이다.
따라서 요청Body에 값을 넣어 보내는 방식이 아니라, 요청Header의Authorization에Bearer 토큰값을 넣어 보내야 한다.
서버는Bearer접두어를 제거한 뒤 실제JWT만JwtUtil.parseToken()에 전달한다.
/auth3요청은 요청Header의Authorization값을 읽는다.
값이Bearer로 시작하면 서버는 그 뒤의 실제JWT를 꺼낸다.
이후 토큰을 파싱해서Payload의sub값을 읽고, 로그인된 사용자를 확인한다.
이 결과에서는 토큰에서unico값을 꺼내 로그인 상태 확인 메시지를 응답한다.
Authorization 헤더가 없으면 실패한다
/auth3는 요청Header에Authorization값이 있어야 정상적으로 동작한다.
이 값이 없으면 서버는 어떤 사용자의 요청인지 확인할 수 없다.
토큰이 없다는 것은 서버 입장에서 로그인 사용자를 증명할 정보가 없다는 뜻이다.
그래서 서버는400응답과 함께Authorization헤더가 없거나 형식이 올바르지 않다는 메시지를 반환한다.
이 실패는AuthController3의 첫 번째if문에서 발생한다.
authorizationHeader == null이거나Bearer로 시작하지 않으면HttpStatus.BAD_REQUEST가 반환된다.
그래서 응답 상태 코드는400이고, 응답Body에는 형식 오류 메시지가 들어간다.
/auth3는 요청Header에Authorization: Bearer 토큰값이 있어야 정상 동작한다.
이 값이 없으면 서버는 어떤 사용자의 요청인지 확인할 수 없다.
그래서Authorization헤더가 없거나 형식이 올바르지 않다는 메시지와 함께 실패 응답을 반환한다.
Talend API Tester로 JWT 발급과 검증 결과 확인하기
auth1은 Body에서 token을 확인한다
Talend API Tester에서/auth1을 테스트할 때는POST방식으로 요청한다.
요청Body에는 로그인 정보를JSON으로 넣는다.
요청 주소는 아래와 같다.// 요청 주소 POST http://localhost:9000/auth1요청
Body는 아래처럼 작성한다.// 요청 Body { "username": "unico", "password": "0000" }
Content-Type은application/json으로 설정한다.
요청을 보내면 응답Body에token값이 내려온다.
auth2는 Header에서 Authorization을 확인한다
/auth2도POST방식으로 요청한다.
요청Body는/auth1과 같다.
요청 주소는 아래와 같다.// 요청 주소 POST http://localhost:9000/auth2요청
Body는 아래처럼 작성한다.// 요청 Body { "username": "unico", "password": "0000" }요청을 보내면 응답
Body에는 표시할 내용이 없을 수 있다.
하지만 실패가 아니다.
/auth2는 토큰을 응답 본문이 아니라 응답Headers의Authorization에 담아 내려주는 방식이기 때문이다.
여기에Bearer 토큰값형태의JWT가 들어 있다.
auth3은 auth2에서 받은 Authorization 값을 그대로 사용한다
/auth3은GET방식으로 요청한다.
이 요청에는Body를 넣지 않는다.
대신/auth2응답Header에서 받은Authorization값을 요청Header에 넣어야 한다.
요청 주소는 아래와 같다.// 요청 주소 GET http://localhost:9000/auth3요청
Header에는 아래 값을 넣는다.// 요청 Header Authorization: Bearer 토큰값여기서 중요한 점은
/auth2에서 받은 값을 그대로 복사하는 것이다.
Bearer를 빼고 토큰만 넣으면 안 된다.
AuthController3이Authorization값이Bearer로 시작하는지 확인하기 때문이다.
/auth3요청이 성공하면 응답Body에 로그인 확인 메시지가 나온다.
요청Header에Authorization값을 넣지 않으면 실패 응답이 나온다.
auth23.html에서 JWT 로그인 기능 테스트하기
브라우저에서도 JWT 로그인 흐름을 확인할 수 있다
auth23.html은 브라우저에서JWT로그인 흐름을 확인하는 화면이다.
Talend API Tester로는 요청과 응답을 직접 확인했다.
auth23.html에서는 사용자가 버튼을 누르는 흐름으로/auth2와/auth3동작을 확인한다.
접속 주소는 아래와 같다.// 접속 주소 http://localhost:9000/auth23.html화면에서 계정과 암호를 입력하고 로그인하면 브라우저는
/auth2로 로그인 요청을 보낸다.
서버는 응답Header의Authorization에Bearer Token을 담아 내려준다.
브라우저 코드는 응답Header의Authorization값에서Bearer접두어를 제거한 뒤 실제JWT만localStorage에jwtToken이름으로 저장한다.
이 실습에서는 흐름 확인을 위해localStorage에 토큰을 저장한다.
localStorage는 브라우저 저장소이며JavaScript로 값을 읽을 수 있다.
그래서 실제 서비스에서는 토큰 저장 위치와 보안 정책을 별도로 신중하게 정해야 한다.
그 다음JWT읽기 기능으로 저장된 토큰을 확인할 수 있다.
로그인 상태 확인을 실행하면 저장된 토큰을 다시 꺼내Authorization: Bearer 토큰값형태로/auth3요청에 담아 보낸다.
서버는 토큰을 검증하고, 토큰 안의 사용자 정보를 읽어 로그인 상태를 응답한다.
브라우저 화면에서 로그인 요청,
JWT저장,JWT읽기, 로그인 상태 확인까지 이어지는 전체 흐름이다.
사용자가 로그인하면 서버는/auth2에서 토큰을 발급하고, 브라우저는 이 토큰을 저장한다.
이후 로그인 상태 확인 요청에서는 저장된 토큰을/auth3요청 헤더에 담아 보내고, 서버는 토큰을 검증해 로그인 상태를 응답한다.
RemoveJsessionIdFilter로 JSESSIONID 제거하기
JWT 방식에서는 세션 쿠키가 필요하지 않을 수 있다
JWT인증 흐름에서는 클라이언트가JWT를 가지고 요청한다.
서버는 요청에 담긴 토큰을 검증해서 사용자를 확인한다.
이 구조에서는 전통적인 세션 기반 로그인처럼JSESSIONID를 중심으로 인증 상태를 유지하지 않을 수 있다.
그런데SpringMVC환경에서는 상황에 따라JSESSIONID쿠키가 응답에 포함될 수 있다.
JSESSIONID는 서버 세션을 식별하기 위한 쿠키이다.
토큰 기반 인증 흐름을 확인하는 실습에서는 이 쿠키가 함께 보이면 인증 방식이 헷갈릴 수 있다.
JWT실습에서는 로그인 상태를Authorization헤더의 토큰으로 확인해야 한다.
그런데 응답에JSESSIONID가 함께 보이면 브라우저가 세션 쿠키로 로그인 상태를 유지하는 것처럼 오해할 수 있다.
그래서RemoveJsessionIdFilter는 실습 결과에서JSESSIONID쿠키를 제거해, 인증 흐름이JWT중심이라는 점을 더 분명히 보여 주기 위한 코드이다.
RemoveJsessionIdFilter는 응답에서JSESSIONID관련 값을 제거하기 위한 필터이다.
필터는 요청이나 응답이 컨트롤러에 도달하기 전후에 중간에서 처리할 수 있는 구성 요소이다.
// RemoveJsessionIdFilter.java package com.example.security10.filter; import jakarta.servlet.*; import jakarta.servlet.http.HttpServletResponse; import org.springframework.stereotype.Component; import java.io.IOException; @Component public class RemoveJsessionIdFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { System.out.println("RemoveJsessionIdFilter 실행"); // 다음 필터 또는 컨트롤러로 요청을 넘긴다. chain.doFilter(request, response); // 응답 객체를 HttpServletResponse로 변환한다. HttpServletResponse res = (HttpServletResponse) response; System.out.println("committed = " + res.isCommitted()); // JSESSIONID 쿠키를 제거하도록 Set-Cookie 헤더를 설정한다. res.setHeader("Set-Cookie", "JSESSIONID=; Path=/; Max-Age=0; HttpOnly"); } }
@Component가 붙어 있기 때문에 이 필터는Spring이 관리하는 객체로 등록된다.
그래서 요청과 응답 흐름 사이에서 실행될 수 있다.
chain.doFilter(request, response)는 다음 필터나 컨트롤러로 요청을 넘기는 코드이다.
이 코드가 실행된 뒤 응답이 만들어지고, 그 다음Set-Cookie헤더를 다시 설정한다.
committed는 응답이 이미 클라이언트로 확정되어 전송되기 시작했는지 확인하는 값이다.
응답이 이미 커밋된 상태라면 이후에Header를 바꾸기 어렵다.
그래서res.isCommitted()출력은 필터가 응답을 수정할 수 있는 시점인지 확인하는 데 도움이 된다.
JSESSIONID=; Path=/; Max-Age=0; HttpOnly는 브라우저에게JSESSIONID쿠키를 제거하라고 알려 주는 값이다.
Max-Age=0은 쿠키를 즉시 만료시키겠다는 의미이다.
이 필터의 목적은JWT실습 결과를 볼 때 세션 쿠키 때문에 인증 방식이 헷갈리지 않게 하는 것이다.
JWT방식의 핵심은 요청Header의Authorization에 담긴 토큰으로 사용자를 확인하는 것이다.
security10 JWT 발급 기능 테스트 핵심 정리
security10에서는SpringMVC요청 흐름 안에서JWT를 발급하고 검증하는 구조를 확인했다.
/auth1은JWT를 응답Body로 내려준다.
/auth2는JWT를 응답Header의Authorization에 담아 내려준다.
/auth3는 토큰을 새로 발급하는 요청이 아니다.
이미 발급받은 토큰을 요청Header에 담아 보내고, 서버가 그 토큰을 파싱해서 로그인 상태를 확인하는 요청이다.
이때 요청Header값은Authorization: Bearer 토큰값형태여야 한다.
Talend API Tester에서는/auth1,/auth2,/auth3요청과 응답을 직접 확인할 수 있다.
auth23.html에서는 같은 흐름을 브라우저 화면에서 버튼 동작으로 확인할 수 있다.
security10의 핵심은JWT를 어디에 담아 내려주는지, 그리고 클라이언트가 그 토큰을 다시 어떤 형식으로 서버에 보내는지 이해하는 것이다.
Spring Security에서 인증 기능을 직접 구현할 때는 요청이Controller에 도착하기 전에 먼저 가로채서 확인하는 구조를 사용한다.
이때 요청을 중간에서 검사하는 역할을 하는 것이Filter이다.
Spring Security는 여러 필터가 순서대로 실행되는 구조로 동작한다.
로그인 요청을 처리하는 필터도 있고, 요청Header에서 인증 정보를 꺼내 검증하는 필터도 있다.
JWT인증을 직접 구현할 때도 이런 필터 구조 위에서 로그인 처리와 토큰 검증 흐름을 만든다.
인증 기능을 직접 구현하려면Spring Security에서 자주 사용하는 필터 클래스의 역할 차이를 먼저 알아야 한다.
필터 클래스가 필요한 이유
요청은 Controller에 바로 도착하지 않는다
일반적인 웹 요청을 생각하면 사용자가 요청을 보내고, 그 요청이 바로
Controller메서드로 들어가는 것처럼 보일 수 있다.
하지만Spring Security가 적용되어 있으면 요청은 먼저 보안 필터들을 지나간다.
예를 들어 사용자가/admin주소로 요청을 보냈다고 생각하면 된다.
서버는 바로AdminController를 실행하지 않는다.
먼저 이 요청이 로그인한 사용자의 요청인지, 필요한 권한이 있는지 확인한다.
이 확인 과정이 보안 필터에서 이루어진다.
JWT인증도 마찬가지이다.
사용자가 요청Header에Authorization: Bearer 토큰값을 담아 보내면, 서버는Controller로 보내기 전에 토큰을 먼저 확인해야 한다.
토큰이 없거나 잘못되었으면 요청을 막거나 인증되지 않은 요청으로 처리한다.
토큰이 유효하면 현재 사용자를 인증된 사용자로 등록하고 다음 단계로 넘긴다.
이런 작업을 하기 위해 직접 만든JWTFilter같은 필터가 필요하다.
그리고 그 필터를 만들 때 어떤 부모 필터 클래스를 상속할지 알아야 한다.
부모 필터 클래스를 잘못 고르면 로그인 요청을 처리해야 하는 필터인지, 이미 발급된 토큰을 검증해야 하는 필터인지 역할이 흐려질 수 있다.
UsernamePasswordAuthenticationFilter
사용자명과 비밀번호 로그인 요청을 처리하는 표준 필터이다
UsernamePasswordAuthenticationFilter는 사용자명과 비밀번호를 사용해서 인증을 시도하는 표준Spring Security필터이다.
주로 웹 폼 기반 로그인 처리를 다룰 때 사용된다.
여기서 웹 폼 기반 로그인은 사용자가 로그인 화면에서 계정과 비밀번호를 입력하고, 로그인 버튼을 누르는 방식을 말한다.
사용자가 입력한 값은 로그인 요청으로 서버에 전달된다.
UsernamePasswordAuthenticationFilter는 이 요청에서 사용자명과 비밀번호를 꺼내 인증을 시도한다.
이 필터는AbstractAuthenticationProcessingFilter의 하위 클래스이다.
AbstractAuthenticationProcessingFilter는 인증 요청을 처리하는 필터의 공통 흐름을 가지고 있는 부모 클래스라고 이해하면 된다.
그 하위 클래스인UsernamePasswordAuthenticationFilter는 그중에서도 사용자명과 비밀번호 기반 로그인에 특화되어 있다.
예를 들어 사용자가 아래처럼 로그인 요청을 보낸다고 생각하면 된다.// 로그인 요청 예시 POST /login username=unico password=0000
UsernamePasswordAuthenticationFilter는 이 요청에서username과password를 꺼낸다.
그리고 그 값을 이용해 인증 객체를 만들고, 실제 인증 처리를 진행할 수 있도록 넘긴다.
JWT기반 필터 로그인 예제에서는 이 필터를 그대로 쓰기보다, 같은 위치에 직접 만든 로그인 필터를 끼워 넣을 수 있다.
즉, 기존의 폼 로그인 필터가 하던 자리에LoginFilter를 넣어서 사용자명과 비밀번호를 받고, 인증 성공 시JWT를 발급하는 구조로 확장할 수 있다.
여기서 중요한 점은UsernamePasswordAuthenticationFilter가 이미 발급된JWT를 검증하는 필터가 아니라는 것이다.
이 필터의 중심 역할은 로그인 요청에서 사용자명과 비밀번호를 꺼내 인증을 시작하는 것이다.
로그인 성공 후 발급된JWT를 매 요청마다 검증하는 역할은 보통 별도의JWTFilter가 담당한다.
UsernamePasswordAuthenticationFilter는 로그인 요청에서 사용자명과 비밀번호를 꺼내 인증을 시작하는 필터이다.
GenericFilterBean
Spring 빈으로 관리되는 단순 필터를 만들 때 사용한다
GenericFilterBean은 단순한 필터 동작을 정의할 때 사용할 수 있는 필터 클래스이다.
일반적인Servlet Filter기능을 가지면서도Spring의 빈 라이프사이클과 연결될 수 있다.
여기서 빈 라이프사이클은Spring이 객체를 만들고, 초기화하고, 필요할 때 사용하고, 종료 시 정리하는 전체 과정을 의미한다.
GenericFilterBean을 사용하면 필터도Spring이 관리하는 객체처럼 사용할 수 있다.
GenericFilterBean은 요청이 들어올 때마다 실행될 수 있다.
하지만 요청당 한 번만 실행되도록 보장해 주는 전용 기능은 기본적으로 제공하지 않는다.
그래서 필터 체인 흐름이나 요청 처리 방식에 따라 같은 요청에서 여러 번 실행될 가능성을 직접 고려해야 한다.
예를 들어 단순히 모든 요청에 대해 로그를 남기는 필터를 만든다고 생각하면 된다.
요청 주소, 요청 방식, 요청 시간을 출력하는 정도라면GenericFilterBean으로도 구현할 수 있다.
하지만JWT인증처럼 같은 요청 흐름에서 인증 처리가 중복 실행되면 안 되는 경우에는 더 적합한 필터가 따로 있다.
GenericFilterBean은 필터 기능의 기본 형태를 만들 때는 유용하다.
하지만JWT인증처럼 같은 요청 처리 흐름에서 중복 실행을 막는 것이 중요한 경우에는 보통OncePerRequestFilter를 더 많이 사용한다.
GenericFilterBean은Spring과 통합된 필터를 만들 수 있지만, 요청당 한 번만 실행되도록 보장하는 전용 기능은 제공하지 않는다.
OncePerRequestFilter
같은 요청 처리 흐름에서 중복 실행을 막는 필터이다
OncePerRequestFilter는GenericFilterBean의 하위 클래스이다.
이 필터의 핵심은 이름 그대로 같은 요청 처리 흐름에서 필터가 중복 실행되지 않도록 도와준다는 점이다.
정확히는 하나의 요청 디스패치 기준으로 한 번 실행되도록 보장하려는 필터 기본 클래스라고 이해하면 된다.
웹 요청은 내부적으로 여러 흐름을 거칠 수 있다.
예를 들어 요청이 다른 리소스로 전달되거나, 에러 처리 흐름을 타거나, 비동기 처리와 연결될 수 있다.
이런 상황에서 같은 필터가 중복 실행되면 인증 처리가 여러 번 반복될 수 있다.
JWT인증에서는 요청Header에서 토큰을 꺼내고, 토큰이 유효한지 검사하고, 유효하면 인증 정보를SecurityContext에 저장한다.
이 작업은 한 요청에서 여러 번 반복될 필요가 없다.
오히려 중복 실행되면 흐름이 복잡해지고 예상하지 못한 문제가 생길 수 있다.
그래서JWT인증 필터를 만들 때는OncePerRequestFilter를 자주 사용한다.
같은 요청 처리 흐름에서 인증 검사가 중복 실행되는 것을 막아 주기 때문에, 요청Header에서JWT를 꺼내 검증하는 작업에 잘 맞는다.
예를 들어 클라이언트가 아래처럼 요청을 보낸다고 생각하면 된다.// JWT 인증 요청 예시 GET /api/orders Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
OncePerRequestFilter를 상속한JWTFilter는 이 요청에서Authorization헤더를 읽는다.
값이 없거나Bearer로 시작하지 않으면 인증 정보를 등록하지 않고 다음 필터로 넘길 수 있다.
또는 구현 방식에 따라 보호된 요청에서는 바로 실패 처리할 수도 있다.
값이 올바르면Bearer뒤의 토큰만 꺼내고, 토큰 만료 여부와 서명을 검증한다.
검증에 성공하면 인증 객체를 만들어SecurityContext에 저장한다.
여기서SecurityContext는 현재 요청에서 인증된 사용자 정보를 보관하는 공간이다.
실제 코드에서는 보통SecurityContextHolder가 관리하는SecurityContext에 인증 객체를 저장한다.
JWT가 유효하다고 확인되면, 서버는 이 요청을 인증된 사용자의 요청으로 처리할 수 있다.
OncePerRequestFilter는JWT처럼 같은 요청 처리 흐름에서 인증 검사가 중복 실행되면 안 되는 필터에 적합하다.
BasicAuthenticationFilter
HTTP Basic 인증을 처리하는 필터이다
BasicAuthenticationFilter는HTTP Basic인증을 처리하는 필터이다.
OncePerRequestFilter의 하위 클래스이다.
HTTP Basic인증은 클라이언트가 사용자명과 비밀번호를Base64방식으로 인코딩해서 요청Header의Authorization에 담아 보내는 방식이다.
여기서Base64는 데이터를 다른 문자 형태로 바꾸는 인코딩 방식이다.
암호화가 아니기 때문에 디코딩하면 원래 값을 확인할 수 있다.
요청 형태는 아래처럼 볼 수 있다.// HTTP Basic 인증 요청 예시 Authorization: Basic dW5pY286MDAwMA==여기서
Basic은 인증 타입이다.
뒤의 값은 사용자명과 비밀번호를username:password형식으로 합친 뒤Base64로 인코딩한 값이다.
예를 들어unico:0000같은 문자열을 인코딩하면Basic뒤에 들어가는 값이 된다.
BasicAuthenticationFilter는 이런Authorization헤더를 읽고HTTP Basic인증 처리를 수행한다.
즉,JWT의Bearer Token을 처리하는 필터가 아니라,Basic인증을 처리하는 필터이다.
앞에서Authorization헤더의 인증 타입을 정리할 때Basic과Bearer가 서로 다른 인증 타입이라고 했다.
BasicAuthenticationFilter는 그중Basic타입 인증과 관련된다.
반면JWT인증에서는 보통Authorization: Bearer 토큰값형식을 사용하고, 직접 만든JWTFilter가 이 값을 읽어 처리한다.
BasicAuthenticationFilter는Bearer Token이 아니라HTTP Basic인증 정보를 처리하는 필터이다.
네 가지 필터 클래스 비교
이름이 비슷해도 역할이 다르다
네 가지 필터 클래스는 모두 보안 흐름과 관련되지만 역할이 다르다.
처음에는 이름이 비슷해서 헷갈릴 수 있다.
그래서 “무엇을 처리하는 필터인가”를 기준으로 나누어 보면 쉽다.
필터 클래스 상속 관계 핵심 역할 JWT 인증과의 연결 UsernamePasswordAuthenticationFilterAbstractAuthenticationProcessingFilter의 하위 클래스사용자명과 비밀번호 로그인 요청 처리 직접 만든 LoginFilter를 이 위치에 넣어 로그인 성공 시JWT를 발급할 수 있다.GenericFilterBeanFilter기반Spring통합 필터단순 필터 동작 정의 기본 필터를 만들 수 있지만 요청당 한 번만 실행되도록 보장하는 전용 기능은 없다. OncePerRequestFilterGenericFilterBean의 하위 클래스요청 처리 흐름에서 중복 실행 방지 JWTFilter처럼 요청Header의 토큰을 한 번 검증하는 필터에 적합하다.BasicAuthenticationFilterOncePerRequestFilter의 하위 클래스HTTP Basic인증 처리Bearer Token이 아니라Basic인증을 처리한다.이 표에서 가장 중요한 것은
UsernamePasswordAuthenticationFilter와OncePerRequestFilter의 차이이다.
UsernamePasswordAuthenticationFilter는 로그인 요청에서 사용자명과 비밀번호를 꺼내 인증을 시작하는 데 초점이 있다.
OncePerRequestFilter는 이미 발급된 토큰이 요청에 담겨 왔을 때, 같은 요청 처리 흐름에서 토큰 검증이 중복 실행되지 않도록 하는 데 적합하다.
즉,JWT로그인 구조에서는 두 필터 역할이 나뉜다.
로그인할 때는 사용자명과 비밀번호를 받아 인증하고 토큰을 발급하는 필터가 필요하다.
로그인 이후 요청에서는Authorization헤더의JWT를 읽어 인증 상태를 복원하는 필터가 필요하다.
인증 기능 구현에 자주 사용하는 필터 클래스 핵심 정리
Spring Security는 여러 필터가 순서대로 실행되는 구조로 동작한다.
인증 기능을 직접 구현하려면 요청이Controller까지 가기 전에 어떤 필터가 어떤 역할을 하는지 알아야 한다.
UsernamePasswordAuthenticationFilter는 사용자명과 비밀번호 기반 로그인 요청을 처리하는 표준 필터이다.
GenericFilterBean은Spring과 통합된 단순 필터를 만들 때 사용할 수 있다.
OncePerRequestFilter는 같은 요청 처리 흐름에서 중복 실행을 막는 데 도움을 주며,JWTFilter처럼 요청Header에서 토큰을 꺼내 검증하는 필터에 적합하다.
BasicAuthenticationFilter는HTTP Basic인증을 처리하는 필터이다.
JWT기반 인증에서는 로그인 요청을 처리하는 필터와, 발급된 토큰을 검증하는 필터의 역할을 나누어 이해해야 한다.
security11은Spring Security필터 구조 안에서JWT기반 로그인을 처리하는 예제이다.
앞에서는security10에서 컨트롤러가 직접/auth1,/auth2,/auth3요청을 처리하면서JWT를 발급하고 검증했다.
이번에는 인증 흐름을Controller가 아니라Spring Security필터가 담당한다.
필터 로그인은 로그인 요청이Controller까지 가지 않고Spring Security Filter Chain안에서 처리되는 방식이다.
사용자가/login으로username과password를 보내면LoginFilter가 이 요청을 가로채 인증을 시도한다.
인증에 성공하면LoginFilter가JWT를 발급하고, 응답Header의Authorization에Bearer Token을 담아 내려준다.
그 다음 클라이언트는 발급받은JWT를 요청Header의Authorization에 담아 보호된 요청을 보낸다.
서버는JWTFilter에서 토큰을 확인하고, 토큰이 유효하면 인증 정보를SecurityContextHolder에 저장한다.
이후SecurityConfig의 권한 규칙에 따라 보호된 요청 접근 여부가 결정된다.
security11의 핵심은LoginFilter가 로그인 성공 시JWT를 발급하고,JWTFilter가 이후 요청에서JWT를 검증해 인증 상태를 복원한다는 점이다.
security11에서 확인할 전체 흐름
Controller 중심 흐름에서 Filter 중심 흐름으로 바뀐다
security10에서는/auth2같은 컨트롤러 메서드가 로그인 요청을 받고JWT를 발급했다.
반면security11에서는 로그인 요청을Spring Security필터가 처리한다.
필터는 요청이Controller에 도착하기 전에 먼저 실행된다.
따라서 로그인 요청이나 인증 확인 요청을 더 앞단에서 처리할 수 있다.
이 구조를 사용하면 인증 흐름을 애플리케이션 전체 요청 흐름에 자연스럽게 끼워 넣을 수 있다.
security11의 전체 흐름은 다음과 같다.
- 애플리케이션을 실행하면
user_entity테이블이 생성된다./join요청으로 사용자를 가입시킨다.- 가입한 사용자의 비밀번호는 암호화되어 저장된다.
/login요청으로 로그인한다.- 로그인 성공 시 응답
Header의Authorization에Bearer Token이 내려온다.- 발급된
JWT를 디코딩하면username,role,iat,exp를 확인할 수 있다./admin요청에Bearer Token을 담아 보내면 서버가 토큰을 검증하고 인증 상태를 복원한다.- 복원된 인증 정보에
ADMIN권한이 있으면Admin Controller응답을 받을 수 있다.여기서 중요한 점은
/login요청이 단순 컨트롤러 요청처럼 처리되는 것이 아니라는 점이다.
Spring Security필터가 로그인 요청을 가로채고, 인증 성공 후JWT를 발급하는 구조이다.
SecurityConfig에서 필터 흐름 설정하기
security11의 핵심 설정은 SecurityFilterChain에 있다
security11에서 가장 먼저 봐야 할 코드는SecurityConfig이다.
여기에서 어떤 요청은 허용하고, 어떤 요청은 권한을 검사할지 정한다.
또 직접 만든LoginFilter와JWTFilter를 어느 위치에 넣을지도 정한다.
SecurityConfig에서 확인해야 할 핵심은 다음과 같다.
csrf를 비활성화한다.- 기본
formLogin방식을 비활성화한다.- 기본
httpBasic방식을 비활성화한다./login,/,/join요청은 누구나 접근 가능하게 한다./admin요청은ADMIN권한이 있어야 접근 가능하게 한다.JWTFilter를LoginFilter앞에 등록한다.LoginFilter를UsernamePasswordAuthenticationFilter위치에 등록한다.- 세션을 사용하지 않는
STATELESS방식으로 설정한다.이 설정이 있어야
security11이 세션 기반 로그인 흐름이 아니라,JWT기반 필터 로그인 흐름으로 동작한다.
// SecurityConfig.java package com.example.security11.config; import com.example.security11.jwt.JWTFilter; import com.example.security11.jwt.JWTUtil; import com.example.security11.jwt.LoginFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration public class SecurityConfig { private final AuthenticationConfiguration authenticationConfiguration; private final JWTUtil jwtUtil; public SecurityConfig(AuthenticationConfiguration authenticationConfiguration, JWTUtil jwtUtil) { this.authenticationConfiguration = authenticationConfiguration; this.jwtUtil = jwtUtil; } @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { // Spring Security의 인증 매니저를 꺼낸다. return configuration.getAuthenticationManager(); } @Bean public BCryptPasswordEncoder bCryptPasswordEncoder() { // 비밀번호 암호화에 사용할 BCryptPasswordEncoder를 등록한다. return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // csrf 보호 기능을 비활성화한다. http.csrf((auth) -> auth.disable()); // 기본 form 로그인 방식을 사용하지 않는다. http.formLogin((auth) -> auth.disable()); // 기본 http basic 인증 방식을 사용하지 않는다. http.httpBasic((auth) -> auth.disable()); // 요청별 접근 권한을 설정한다. http.authorizeHttpRequests((auth) -> auth .requestMatchers("/login", "/", "/join").permitAll() .requestMatchers("/admin").hasAuthority("ADMIN") .anyRequest().authenticated()); // JWTFilter를 LoginFilter 앞에 둔다. http.addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class); // LoginFilter를 UsernamePasswordAuthenticationFilter 위치에 둔다. http.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class); // 세션을 만들지 않는 JWT 방식으로 설정한다. http.sessionManagement((session) -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS)); // 설정한 보안 필터 체인을 반환한다. return http.build(); } }
formLogin()과httpBasic()을 비활성화하는 이유는 기본 로그인 방식과 기본HTTP Basic인증 방식을 사용하지 않기 위해서이다.
이번 예제에서는 직접 만든LoginFilter가 로그인 요청을 처리하고, 직접 만든JWTFilter가 요청의 토큰을 검증한다.
requestMatchers("/login", "/", "/join").permitAll()은 로그인, 메인, 회원가입 요청을 누구나 접근할 수 있게 한다.
아직 로그인하지 않은 사용자도 회원가입과 로그인은 할 수 있어야 하기 때문이다.
requestMatchers("/admin").hasAuthority("ADMIN")은/admin요청에ADMIN권한이 필요하다는 뜻이다.
따라서 로그인한 사용자의 토큰에서ADMIN권한을 꺼내 인증 객체에 등록해야/admin요청에 접근할 수 있다.
addFilterAt()은 직접 만든LoginFilter를 기존UsernamePasswordAuthenticationFilter위치에 넣는다.
이 위치는 사용자명과 비밀번호 기반 로그인 요청을 처리하는 자리이다.
그래서/login요청이 들어오면LoginFilter가 사용자명과 비밀번호를 꺼내 인증을 시도할 수 있다.
addFilterBefore()는JWTFilter를LoginFilter보다 앞에 둔다.
JWTFilter는 이미 발급받은 토큰이 요청에 담겨 있는지 확인한다.
즉, 로그인 이후의 요청에서Authorization헤더를 읽고 인증 상태를 복원하는 역할을 한다.
SessionCreationPolicy.STATELESS는 서버가 인증 상태를 세션에 저장하지 않겠다는 설정이다.
이 설정을 사용하면 서버는 매 요청마다 클라이언트가 보낸JWT를 기준으로 인증 상태를 판단한다.
그래서JWT기반 인증에서는 세션 저장이 아니라 토큰 검증 흐름이 중심이 된다.
LoginFilter는 로그인 요청에서JWT를 발급하고,JWTFilter는 발급된JWT를 이후 요청에서 검증한다.
JWTUtil에서 토큰 생성과 값 추출 처리하기
JWTUtil은 username과 role을 토큰에 넣고 다시 꺼낸다
JWTUtil은JWT를 생성하고, 생성된 토큰에서 필요한 값을 꺼내는 클래스이다.
security11에서는 토큰 안에username과role을 넣는다.
username은 로그인한 사용자명이다.
role은 사용자의 권한 정보이다.
예를 들어dooly사용자가ADMIN권한을 가지고 있다면, 토큰의Payload에는username: dooly,role: ADMIN같은 값이 들어간다.
// JWTUtil.java package com.example.security11.jwt; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.util.Date; @Component public class JWTUtil { private SecretKey secretKey; public JWTUtil(@Value("${spring.jwt.secret}") String secret) { // 설정 파일의 secret 값을 HS256 서명에 사용할 키로 바꾼다. this.secretKey = new SecretKeySpec( secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm()); } public String getUsername(String token) { // 토큰에서 username 값을 꺼낸다. return Jwts.parser().verifyWith(secretKey).build() .parseSignedClaims(token) .getPayload() .get("username", String.class); } public String getRole(String token) { // 토큰에서 role 값을 꺼낸다. return Jwts.parser().verifyWith(secretKey).build() .parseSignedClaims(token) .getPayload() .get("role", String.class); } public Boolean isExpired(String token) { // 토큰 만료 시간이 현재 시간보다 이전인지 확인한다. return Jwts.parser().verifyWith(secretKey).build() .parseSignedClaims(token) .getPayload() .getExpiration() .before(new Date()); } public String createJwt(String username, String role, Long expiredMs) { // username, role, 발급 시간, 만료 시간을 담아 JWT를 만든다. return Jwts.builder() .claim("username", username) .claim("role", role) .issuedAt(new Date(System.currentTimeMillis())) .expiration(new Date(System.currentTimeMillis() + expiredMs)) .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); } }
JWTUtil생성자에서는 설정 파일의spring.jwt.secret값을 받아SecretKey객체로 만든다.
JWT는 단순 문자열만으로 검증되는 것이 아니라, 서버가 가진 비밀 키를 기준으로 서명하고 검증된다.
그래서 토큰을 만들 때 사용한 키와 토큰을 읽을 때 사용하는 키가 같아야 한다.
createJwt()는 로그인 성공 시 호출된다.
이 메서드는username,role, 발급 시간, 만료 시간을 토큰에 담는다.
마지막에signWith()로 서명하고compact()로 최종JWT문자열을 만든다.
getUsername()과getRole()은 토큰에서 각각 사용자명과 권한 정보를 꺼낸다.
이때 단순히 문자열을 잘라서 읽는 것이 아니라,verifyWith(secretKey)로 서명을 검증하면서 토큰을 파싱한다.
서명이 맞지 않거나 토큰 형식이 잘못되면 정상적으로 값을 꺼낼 수 없다.
isExpired()는 토큰이 만료되었는지 확인한다.
JWT는 무한히 사용할 수 있는 값이 아니기 때문에, 서버는 요청이 들어올 때 토큰 만료 시간을 검사해야 한다.
토큰의 만료 시간이 현재 시간보다 이전이면 더 이상 유효한 토큰으로 볼 수 없다.
JWT는Header,Payload,Signature로 구성된다.
Header에는 알고리즘 정보가 들어가고,Payload에는 사용자 정보와 권한 같은 데이터가 들어간다.
Signature는Header와Payload를 기준으로 서버의Secret Key를 사용해 만든 값이다.
서버는 요청으로 받은 토큰을 다시 같은 키로 검증해서 토큰이 조작되지 않았는지 확인한다.
LoginFilter에서 로그인 요청 처리하기
LoginFilter는 username과 password로 인증을 시도한다
LoginFilter는UsernamePasswordAuthenticationFilter를 상속한다.
이 필터는/login요청에서 사용자명과 비밀번호를 꺼내 인증을 시도한다.
attemptAuthentication()은 인증 시도를 담당한다.
요청에서username과password를 꺼내고, 이 값을UsernamePasswordAuthenticationToken으로 만든다.
그 다음AuthenticationManager에게 실제 인증을 맡긴다.
인증에 성공하면successfulAuthentication()이 실행된다.
여기서는 인증된 사용자 정보에서username과role을 꺼낸다.
그리고JWTUtil.createJwt()로JWT를 생성한 뒤 응답Header의Authorization에 담아 내려준다.
// LoginFilter.java package com.example.security11.jwt; import com.example.security11.dto.CustomUserDetails; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import java.util.Collection; import java.util.Iterator; public class LoginFilter extends UsernamePasswordAuthenticationFilter { private final AuthenticationManager authenticationManager; private final JWTUtil jwtUtil; public LoginFilter(AuthenticationManager authenticationManager, JWTUtil jwtUtil) { this.authenticationManager = authenticationManager; this.jwtUtil = jwtUtil; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { // 요청에서 username을 꺼낸다. String username = obtainUsername(request); // 요청에서 password를 꺼낸다. String password = obtainPassword(request); // 인증에 사용할 토큰 객체를 만든다. UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null); // AuthenticationManager에게 실제 인증을 맡긴다. return authenticationManager.authenticate(authToken); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) { // 인증된 사용자 정보를 꺼낸다. CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal(); // 사용자명을 꺼낸다. String username = customUserDetails.getUsername(); // 권한 목록에서 첫 번째 권한을 꺼낸다. Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities(); Iterator<? extends GrantedAuthority> iterator = authorities.iterator(); GrantedAuthority auth = iterator.next(); // 권한 문자열을 꺼낸다. String role = auth.getAuthority(); // 권한 정보를 콘솔에 출력한다. System.out.println("[ROLE]]" + role); // username과 role을 넣어 JWT를 생성한다. String token = jwtUtil.createJwt(username, role, 60 * 60 * 1000L); // 응답 Header에 Bearer Token을 담아 내려준다. response.addHeader("Authorization", "Bearer " + token); } @Override protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) { // 로그인 실패 시 401 상태 코드를 반환한다. response.setStatus(401); } }
obtainUsername(request)와obtainPassword(request)는 요청에서 로그인 값을 꺼내는 메서드이다.
Talend API Tester에서username=dooly&password=1234형태로 보내면 이 값들이 필터에서 추출된다.
UsernamePasswordAuthenticationToken은 인증에 사용할 사용자명과 비밀번호를 담는 객체이다.
이 객체를AuthenticationManager에게 넘기면,Spring Security가 등록된 사용자 정보와 비밀번호를 비교해 인증을 진행한다.
정확히는AuthenticationManager가 인증 처리를 위임하고, 등록된 인증 처리 흐름에서 사용자 조회와 비밀번호 검증이 이루어진다고 이해하면 된다.
successfulAuthentication()은 인증에 성공했을 때 실행된다.
이때 사용자명과 권한을 꺼내JWT를 만들고, 응답Header에Authorization: Bearer 토큰값형태로 내려준다.
권한은authentication.getAuthorities()로 꺼낸다.
이 예제에서는 권한 목록에서 첫 번째 권한을 꺼내role값으로 사용한다.
실습에서는 가입 시ADMIN권한으로 저장했기 때문에 로그인에 성공하면ADMIN권한이 토큰에 들어간다.
즉,security11에서는 로그인 성공 결과로 화면을 이동하는 것이 아니라, 클라이언트가 이후 요청에 사용할JWT를 받는다.
JWTFilter에서 토큰 검증하기
JWTFilter는 로그인 이후 요청에서 Authorization 헤더를 확인한다
JWTFilter는 이미 발급된JWT를 검증하는 필터이다.
로그인 요청에서 토큰을 만드는 역할은LoginFilter가 담당하고, 이후 요청에서 토큰을 확인하는 역할은JWTFilter가 담당한다.
요청이 들어오면JWTFilter는 먼저Authorization헤더를 찾는다.
헤더가 없거나Bearer로 시작하지 않으면 토큰 기반 인증 요청이 아니라고 보고 다음 필터로 넘긴다.
헤더가 올바르면Bearer뒤의 실제 토큰만 꺼낸다.
그 다음 토큰 만료 여부를 확인한다.
토큰이 만료되지 않았다면 토큰에서username과role을 꺼내 인증 객체를 만들고,SecurityContextHolder에 저장한다.
// JWTFilter.java package com.example.security11.jwt; import com.example.security11.dto.CustomUserDetails; import com.example.security11.entity.UserEntity; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; public class JWTFilter extends OncePerRequestFilter { private final JWTUtil jwtUtil; public JWTFilter(JWTUtil jwtUtil) { this.jwtUtil = jwtUtil; } @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 요청 Header에서 Authorization 값을 찾는다. String authorization = request.getHeader("Authorization"); // Authorization 헤더가 없거나 Bearer 형식이 아니면 다음 필터로 넘긴다. if (authorization == null || !authorization.startsWith("Bearer ")) { System.out.println("token null"); filterChain.doFilter(request, response); return; } // Bearer 뒤의 실제 JWT만 꺼낸다. String token = authorization.split(" ")[1]; // 토큰이 만료되었으면 다음 필터로 넘긴다. if (jwtUtil.isExpired(token)) { System.out.println("token expired"); filterChain.doFilter(request, response); return; } // 토큰에서 사용자명과 권한을 꺼낸다. String username = jwtUtil.getUsername(token); String role = jwtUtil.getRole(token); // 토큰 정보로 임시 UserEntity를 만든다. UserEntity userEntity = new UserEntity(); userEntity.setUsername(username); userEntity.setPassword("temppassword"); userEntity.setRole(role); // UserEntity를 Spring Security가 이해할 수 있는 CustomUserDetails로 감싼다. CustomUserDetails customUserDetails = new CustomUserDetails(userEntity); // 인증 객체를 만든다. Authentication authToken = new UsernamePasswordAuthenticationToken( customUserDetails, null, customUserDetails.getAuthorities()); // 현재 요청의 인증 정보를 SecurityContext에 저장한다. SecurityContextHolder.getContext().setAuthentication(authToken); // 다음 필터로 요청을 넘긴다. filterChain.doFilter(request, response); } }
Authorization헤더가 없을 때 바로 에러를 내지 않고 다음 필터로 넘기는 이유는 모든 요청이 토큰을 필요로 하는 것은 아니기 때문이다.
예를 들어/,/join,/login은 인증 없이 접근할 수 있는 요청이다.
보호된 요청이라면 인증 정보가 등록되지 않은 상태로 다음 단계에 도달하고, 이후SecurityConfig의 권한 규칙에 따라 접근이 막힐 수 있다.
authorization.split(" ")[1]은Bearer 토큰값에서 실제 토큰 부분만 꺼낸다.
JWTUtil은 순수한JWT문자열을 검증해야 하므로Bearer접두어를 제거해야 한다.
토큰이 만료되었을 때도 이 코드에서는 바로 응답을 만들지 않고 다음 필터로 넘긴다.
이 경우 인증 객체가SecurityContextHolder에 저장되지 않는다.
따라서 보호된 요청이라면 인증되지 않은 요청으로 처리될 수 있다.
토큰이 유효하면username과role을 꺼낸다.
그리고 이 정보로CustomUserDetails와UsernamePasswordAuthenticationToken을 만든다.
마지막으로SecurityContextHolder에 인증 객체를 저장하면, 현재 요청은 인증된 사용자 요청으로 처리될 수 있다.
여기서 임시로 만든UserEntity의password에"temppassword"를 넣는 이유는 이 단계에서 비밀번호 검증을 다시 하는 것이 아니기 때문이다.
이미 로그인할 때 비밀번호 검증은 끝났고, 지금은JWT에서 꺼낸 사용자명과 권한으로 인증 객체를 복원하는 흐름이다.
따라서 여기서 중요한 값은username과role이다.
JWTFilter의 핵심은 요청Header의JWT를 읽고, 검증이 끝난 사용자 정보를SecurityContextHolder에 등록하는 것이다.
Security11Application 실행과 user_entity 테이블 생성 확인
회원 정보를 저장할 테이블이 먼저 필요하다
security11에서는 로그인할 사용자를DB에 저장한다.
사용자가 로그인하면 서버는 전달받은username으로 사용자를 조회하고, 저장된 비밀번호와 요청 비밀번호를 비교한다.
따라서 먼저 회원 정보를 저장할 테이블이 필요하다.
이 예제에서는user_entity테이블이 생성된다.
테이블에는 사용자 식별값, 아이디, 암호화된 비밀번호, 권한 정보가 저장된다.
user_entity테이블의 주요 컬럼은 다음과 같다.
id: 회원을 구분하는 기본 키이다.username: 로그인할 때 사용하는 사용자명이다.password: 암호화되어 저장되는 비밀번호이다.role: 사용자의 권한 정보이다.여기서
role은 뒤에서JWT의Payload에도 들어갈 수 있다.
서버는 토큰 안의 권한 정보를 바탕으로 사용자가 특정 요청에 접근할 수 있는지 판단할 수 있다.
Security11Application을 실행하면Hibernate가user_entity테이블을 생성한다.
이 테이블에는 회원의id,username,password,role값이 저장된다.
이후/join요청으로 가입한 회원 정보가 이 테이블에 저장되고, 로그인할 때 이 정보를 기준으로 사용자 인증이 진행된다.
join 요청으로 사용자 등록하기
로그인하려면 먼저 사용자가 DB에 저장되어 있어야 한다
/join요청은 회원가입 요청이다.
로그인을 테스트하려면 먼저 로그인할 사용자를 만들어야 한다.
그래서Talend API Tester에서/join주소로 사용자 정보를 전송한다.
이 예제에서는JSON이 아니라application/x-www-form-urlencoded형식으로 데이터를 보낸다.
application/x-www-form-urlencoded는 폼 데이터처럼key=value&key=value형태로 값을 전달하는 방식이다.
/join요청은JoinController가 처리한다.
요청으로 들어온username,password는JoinDTO에 담기고,JoinService가 실제 저장 흐름을 진행한다.
// JoinController.java package com.example.security11.controller; import com.example.security11.dto.JoinDTO; import com.example.security11.service.JoinService; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.ResponseBody; @Controller @ResponseBody public class JoinController { private final JoinService joinService; public JoinController(JoinService joinService) { this.joinService = joinService; } @PostMapping("/join") public String joinProcess(@ModelAttribute JoinDTO joinDTO) { // 요청 데이터를 서비스로 넘겨 회원가입을 처리한다. joinService.joinProcess(joinDTO); // 가입 성공 결과를 문자열로 응답한다. return "ok"; } }
@Controller는 요청을 처리하는 컨트롤러 클래스라는 뜻이다.
원래@Controller는 화면 이름을 반환하는 방식으로도 사용할 수 있다.
하지만 이 코드에는@ResponseBody가 함께 붙어 있기 때문에, 반환 문자열"ok"가 화면 이름이 아니라 응답Body에 그대로 들어간다.
@ModelAttribute는 요청 파라미터를 객체에 담아 주는 역할을 한다.
username=dooly&password=1234처럼 폼 데이터가 들어오면,JoinDTO의username,password필드에 값이 들어간다.
요청 데이터는 아래처럼 볼 수 있다.// /join 요청 Body username=dooly&password=1234
username은 가입할 사용자명이다.
password는 사용자가 입력한 원문 비밀번호이다.
서버는 이 비밀번호를 그대로 저장하지 않고 암호화한 뒤 저장한다.
// JoinService.java package com.example.security11.service; import com.example.security11.dto.JoinDTO; import com.example.security11.entity.UserEntity; import com.example.security11.repository.UserRepository; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; @Service public class JoinService { private final UserRepository userRepository; private final BCryptPasswordEncoder bCryptPasswordEncoder; public JoinService(UserRepository userRepository, BCryptPasswordEncoder bCryptPasswordEncoder) { this.userRepository = userRepository; this.bCryptPasswordEncoder = bCryptPasswordEncoder; } public void joinProcess(JoinDTO joinDTO) { // 가입할 사용자명을 꺼낸다. String username = joinDTO.getUsername(); // 가입할 비밀번호를 꺼낸다. String password = joinDTO.getPassword(); // 같은 username이 이미 있는지 확인한다. Boolean isExist = userRepository.existsByUsername(username); if (isExist) { return; } // DB에 저장할 회원 엔티티를 만든다. UserEntity data = new UserEntity(); data.setUsername(username); // 비밀번호는 BCrypt로 암호화해서 저장한다. data.setPassword(bCryptPasswordEncoder.encode(password)); // 실습에서는 ADMIN 권한으로 저장한다. data.setRole("ADMIN"); // 회원 정보를 DB에 저장한다. userRepository.save(data); } }
JoinService에서 가장 중요한 부분은 비밀번호 저장 방식이다.
data.setPassword(bCryptPasswordEncoder.encode(password))는 사용자가 입력한 원문 비밀번호를 암호화해서 저장한다.
그래서 사용자가1234를 입력해도DB에는1234가 그대로 들어가지 않는다.
또 실습에서는data.setRole("ADMIN")으로 권한을 저장한다.
이 권한은 로그인 성공 후JWT의role값으로 들어가고, 이후/admin요청 권한 확인에 사용된다.
userRepository.existsByUsername(username)은 같은 사용자명이 이미 저장되어 있는지 확인한다.
이미 같은username이 있으면return으로 저장 흐름을 끝낸다.
즉, 중복 사용자는 새로 저장하지 않는다.
/join요청은POST방식으로 회원가입 데이터를 서버에 전달한다.
요청Body에는username=dooly&password=1234값이 들어가며,Content-Type은application/x-www-form-urlencoded형식이다.
서버가 요청을 정상 처리하면 응답Body에ok가 출력된다.
user_entity 테이블 저장 결과 확인
비밀번호는 원문이 아니라 암호화된 값으로 저장된다
회원가입이 성공하면
user_entity테이블에 사용자가 저장된다.
이때 가장 중요한 점은 비밀번호가 원문 그대로 저장되지 않는다는 것이다.
예를 들어 사용자가1234를 입력했다고 해도DB에는1234가 그대로 들어가면 안 된다.
비밀번호가 원문으로 저장되면DB가 노출되었을 때 모든 사용자의 비밀번호가 바로 드러나기 때문이다.
그래서 서버는 비밀번호를 암호화해서 저장한다.
이 예제에서는 저장된 비밀번호가$2a$10$...형태로 보인다.
이 형태는BCrypt방식으로 암호화된 비밀번호에서 자주 볼 수 있다.
또role에는ADMIN이 저장되어 있다.
이 권한은 이후 로그인 성공 후 생성되는JWT에 포함되고,/admin요청을 허용할지 판단하는 데 사용될 수 있다.
user_entity테이블을 조회하면dooly사용자가 저장된 것을 확인할 수 있다.
비밀번호는 원문1234가 아니라$2a$10$...형태의 암호화된 값으로 저장된다.
role에는ADMIN이 저장되어 있으므로, 이후 관리자 요청을 보낼 때 이 사용자의 권한 정보가JWT에 포함될 수 있다.
기본 페이지 접근 확인
모든 요청이 인증을 요구하는 것은 아니다
애플리케이션에는 인증 없이 접근할 수 있는 요청도 있고, 인증이 필요한 요청도 있다.
기본 페이지는 인증 없이 접근 가능한 요청으로 볼 수 있다.
예를 들어 브라우저에서 기본 주소로 접속했을 때Main Controller응답이 나온다.
이 요청은JWT를 보내지 않아도 접근할 수 있다.
반면 뒤에서 확인할/admin요청은 보호된 요청이다.
보호된 요청은 아무나 접근하면 안 되므로, 서버가 요청에 포함된JWT와 권한을 확인해야 한다.
이렇게 기본 페이지와 관리자 페이지를 비교하면JWT가 필요한 요청과 필요하지 않은 요청의 차이를 이해하기 쉽다.
브라우저에서 기본 주소로 접속하면
Main Controller응답이 출력된다.
이 화면은 로그인이나JWT없이도 접근 가능한 기본 요청을 확인하는 결과이다.
이후/admin요청은 토큰을 가진 사용자만 접근하는 흐름으로 비교해서 볼 수 있다.
login 요청과 JWT 발급 결과
로그인 성공 시 Authorization Header에 Bearer Token이 내려온다
/login요청은 로그인 요청이다.
사용자가username과password를 보내면,LoginFilter가 이 값을 받아 인증을 시도한다.
이 예제에서는username=dooly,password=1234로 로그인 요청을 보낸다.
서버는DB에서dooly사용자를 찾고, 요청 비밀번호와 저장된 암호화 비밀번호를 비교한다.
인증에 성공하면JWT를 생성한다.
생성된JWT는 응답Body가 아니라 응답Header의Authorization에 담긴다.
형식은 아래와 같다.// 로그인 성공 응답 Header Authorization: Bearer JWT토큰값여기서
Bearer는 인증 타입이다.
그 뒤에 실제JWT문자열이 붙는다.
/login요청은POST방식으로 로그인 데이터를 전달한다.
요청Body에는username=dooly&password=1234가 들어간다.
로그인에 성공하면 응답Header의Authorization에Bearer Token이 담겨 내려온다.
응답Body영역에No Content가 보이더라도 실패가 아니다.
이 화면의 응답 상태 코드는200이고, 토큰이 응답 본문이 아닌 응답 헤더에 들어간 것이다.
발급된 JWT 구조 확인
JWT 안에는 사용자 정보와 권한 정보가 들어간다
로그인 성공 후 받은
JWT를 디코딩하면 내부 구조를 확인할 수 있다.
JWT는Header,Payload,Signature로 구성된다.
Header에는 어떤 알고리즘으로 서명했는지 들어간다.
이 예제에서는HS256알고리즘 정보가 보인다.
Payload에는 실제 인증에 필요한 정보가 들어간다.
예를 들어username,role,iat,exp값이 들어간다.
각 값은 다음과 같이 이해하면 된다.
username: 로그인한 사용자명이다.role: 사용자의 권한 정보이다.iat: 토큰 발급 시간이다.exp: 토큰 만료 시간이다.
Payload는 암호화된 공간이 아니다.
디코딩하면 내용을 확인할 수 있다.
따라서 권한이나 사용자명처럼 인증 판단에 필요한 정보는 넣을 수 있지만, 비밀번호 같은 민감한 정보는 넣으면 안 된다.
로그인 성공 후 받은
JWT를 디코딩하면Header와Payload를 확인할 수 있다.
Header에는HS256알고리즘 정보가 들어가고,Payload에는username,role,iat,exp값이 들어간다.
여기서username은 로그인 사용자이고,role은 사용자의 권한 정보이다.
iat는 발급 시간,exp는 만료 시간이다.
Invalid Signature가 보일 수 있지만, 이 화면은 토큰 구조와Payload값을 확인하기 위한 결과물로 보면 된다.
토큰 형식은 읽을 수 있어도, 검증용Secret Key가 코드에서 사용한 값과 다르면 서명 검증은 실패할 수 있다.
JWT를 요청 Header에 담아 admin 접근하기
보호된 요청에는 발급받은 토큰을 다시 보내야 한다
/admin요청은 보호된 요청이다.
즉, 아무나 접근할 수 있는 요청이 아니라 인증과 권한 확인이 필요한 요청이다.
로그인에 성공하면 서버는JWT를 발급한다.
하지만 토큰을 발급받았다고 해서 서버가 자동으로 모든 요청을 기억하는 것은 아니다.
security11은 세션을 사용하지 않는STATELESS방식이므로, 클라이언트는 보호된 요청을 보낼 때마다 발급받은 토큰을 요청Header에 담아 보내야 한다.
요청 형식은 아래와 같다.// /admin 요청 Header Authorization: Bearer JWT토큰값
JWTFilter는 이 값을 읽고Bearer뒤의 실제 토큰을 꺼낸다.
그 다음 토큰의 서명과 만료 시간을 검증하고,Payload의username과role을 꺼내 인증 객체를 만든다.
이 인증 객체가SecurityContextHolder에 저장되면 현재 요청은 인증된 사용자 요청으로 처리된다.
그 다음/admin요청은SecurityConfig의 권한 규칙을 거친다.
/admin은ADMIN권한이 필요하도록 설정되어 있으므로, 인증 객체에ADMIN권한이 들어 있어야 접근할 수 있다.
즉,JWTFilter는 인증 정보를 복원하고,SecurityConfig의 권한 규칙이 최종 접근 가능 여부를 판단한다.
/admin요청은GET방식으로 전송한다.
요청Header의Authorization에/login에서 받은Bearer Token을 담아 보내면 서버는 토큰을 검증한다.
토큰이 유효하고 인증 객체에ADMIN권한이 들어 있으면Admin Controller응답이 출력된다.
즉, 이 결과는 로그인 성공 후 발급받은JWT가 보호된 요청 접근에 사용되는 흐름을 보여준다.
security11 JWT 필터 로그인 흐름 핵심 정리
security11에서는Spring Security필터 기반으로 로그인과 인증 확인 흐름을 처리한다.
/join요청으로 사용자를 저장하고, 저장된 사용자의 비밀번호는 암호화되어DB에 들어간다.
SecurityConfig는/login,/,/join요청을 허용하고,/admin요청에는ADMIN권한을 요구한다.
또LoginFilter를UsernamePasswordAuthenticationFilter위치에 넣고,JWTFilter를LoginFilter앞에 넣는다.
/login요청이 들어오면LoginFilter가 사용자명과 비밀번호를 받아 인증을 시도한다.
인증에 성공하면 서버는JWT를 생성하고, 응답Header의Authorization에Bearer Token을 담아 내려준다.
클라이언트는 보호된 요청을 보낼 때 이 토큰을 다시 요청Header에 담아 보낸다.
서버는JWTFilter에서 토큰의 서명과 만료 시간을 검증하고, 토큰에서 꺼낸 사용자명과 권한으로 인증 상태를 복원한다.
그 다음SecurityConfig의 권한 규칙에 따라/admin접근 가능 여부가 결정된다.
security11의 핵심은LoginFilter가 로그인 성공 시JWT를 발급하고,JWTFilter가 이후 요청에서JWT를 검증해 인증 상태를 복원한다는 점이다.
security12는Spring Security와JPA를 함께 사용해서 로그인 인증과 권한별 페이지 접근 제어를 확인하는 예제이다.
앞의security11은JWT를 발급하고 요청Header의 토큰을 검증하는 흐름이 중심이었다.
이번 예제는DB에 저장된 회원 정보를 기준으로 로그인하고, 회원의 권한에 따라 접근 가능한 화면이 달라지는 흐름을 확인한다.
이 예제에서 중요한 흐름은 두 가지이다.
첫째, 사용자가 로그인하면MyUserDetailService가DB에서 회원 정보를 조회하고Spring Security가 비밀번호를 검증한다.
둘째, 로그인한 사용자가 특정 페이지에 접근하면@PreAuthorize가 사용자의 권한을 확인한다.
security12의 핵심은JPA로 저장된 회원 정보를Spring Security인증에 연결하고,ADMIN과USER권한에 따라 접근 가능한 화면을 나누는 것이다.
인증과 인가의 차이
인증은 사용자가 누구인지 확인하는 과정이다
인증은 사용자가 누구인지 확인하는 과정이다.
로그인 화면에서 아이디와 비밀번호를 입력하는 이유가 바로 인증을 하기 위해서이다.
서버는 사용자가 보낸 아이디를 기준으로 가입된 회원인지 찾고, 비밀번호가 맞는지 확인한다.
예를 들어 사용자가duke@java.com과 비밀번호를 입력했다고 생각하면 된다.
서버는securitymember테이블에서duke@java.com회원을 찾는다.
회원이 있고 비밀번호도 맞으면 서버는 이 사용자를 로그인한 사용자로 인정한다.
인증에 성공하면 이후 요청에서 사용자를 구분할 수 있는 상태가 만들어진다.
세션 기반 인증에서는 세션 정보가 만들어질 수 있고, 토큰 기반 인증에서는 토큰이 내려갈 수 있다.
이번security12는 기본 로그인 흐름을 사용하므로 세션 기반 로그인 흐름으로 동작한다고 이해하면 된다.
인증은 사용자가 누구인지 확인하는 과정이다.
사용자가 아이디와 비밀번호를 보내면 서버는 가입된 계정이 맞는지 확인한다.
확인에 성공하면 서버는 세션ID또는 토큰처럼 이후 요청에서 사용자를 증명할 수 있는 값을 내려줄 수 있다.
인가는 인증된 사용자의 권한을 확인하는 과정이다
인가는 로그인한 사용자가 특정 기능이나 화면에 접근할 권한이 있는지 확인하는 과정이다.
인증이 “누구인지 확인”이라면, 인가는 “그 사용자가 이 기능을 사용할 수 있는지 확인”이다.
예를 들어duke@java.com이 로그인에 성공했다고 생각하면 된다.
로그인에 성공했다는 사실만으로 모든 페이지에 접근할 수 있는 것은 아니다.
ADMIN권한이 필요한 페이지는ADMIN권한이 있어야 들어갈 수 있고,USER권한이 필요한 페이지는USER권한이 있어야 들어갈 수 있다.
이 예제에서는/view/adminpage와/view/userpage를 권한별로 나누어 확인한다.
ADMIN권한 사용자는 관리자 페이지에 접근할 수 있지만,USER전용 페이지에는 접근하지 못한다.
반대로USER권한 사용자는 사용자 페이지에 접근할 수 있지만, 관리자 페이지에는 접근하지 못한다.
여기서 중요한 점은 이 예제의 권한 구조가 “관리자는 모든 페이지 접근 가능”으로 설계된 것이 아니라는 점이다.
현재 코드에서는 관리자 페이지는ADMIN권한만 요구하고, 사용자 페이지는USER권한만 요구한다.
따라서ADMIN계정이라도USER권한을 따로 가지고 있지 않으면USER전용 페이지 접근이 거부된다.
인가는 인증된 사용자가 특정 기능이나 화면에 접근할 수 있는 권한이 있는지 확인하는 과정이다.
사용자가 세션ID나 토큰을 보내더라도, 해당 사용자에게 필요한 권한이 없으면 서버는 요청을 거부할 수 있다.
security12에서 확인할 전체 흐름
DB 회원 정보로 인증하고 권한으로 화면 접근을 나눈다
security12에서는 회원 정보를securitymember테이블에 저장한다.
저장된 회원은userid,pw,roles값을 가진다.
userid는 로그인할 때 사용하는 계정명이고,pw는 암호화된 비밀번호이며,roles는 사용자의 권한이다.
전체 흐름은 다음과 같다.
AdminAccountCreateTest로ADMIN계정을 먼저 저장한다.securitymember테이블에서 저장 결과를 확인한다.SpringSecurityConfig에서 로그인 페이지, 허용 경로, 인증 실패/성공 흐름을 설정한다.MyUserDetailService가 로그인 시도 계정을DB에서 조회한다.- 로그인 성공 여부는
DB조회 결과와 비밀번호 검증 결과로 결정된다.ViewController에서@PreAuthorize로ADMIN페이지와USER페이지 접근 권한을 나눈다.ADMIN계정과USER계정으로 각각 로그인해 접근 결과를 비교한다.이 흐름을 보면
Spring Security가 단순히 로그인 화면만 제공하는 것이 아니라,DB조회, 비밀번호 검증, 권한 확인까지 함께 연결한다는 점을 알 수 있다.
AdminAccountCreateTest로 ADMIN 계정 저장하기
테스트 코드로 관리자 계정을 먼저 준비한다
security12를 테스트하려면 먼저 로그인할 계정이 필요하다.
관리자 권한 테스트를 하기 위해AdminAccountCreateTest에서ADMIN권한을 가진 계정을 저장한다.
이 테스트는 화면에서 회원가입하는 흐름이 아니다.
테스트 코드로 직접Member객체를 만들고,MemberRepository를 통해DB에 저장하는 흐름이다.
이렇게 하면 관리자 계정을 미리 준비한 뒤 로그인과 권한 테스트를 진행할 수 있다.
// AdminAccountCreateTest.java package com.example.security12; import com.example.security12.entity.Member; import com.example.security12.repository.MemberRepository; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.annotation.Rollback; import org.springframework.transaction.annotation.Transactional; import java.util.List; @SpringBootTest public class AdminAccountCreateTest { @Autowired private PasswordEncoder passwordEncoder; @Autowired private MemberRepository repository; @Test @Rollback(false) @Transactional void save() { // ADMIN 권한을 가진 회원 객체를 만든다. Member member = Member.createUser("duke@java.com", "9999", passwordEncoder, "ADMIN"); // 회원 정보를 DB에 저장한다. repository.save(member); // 저장된 회원 전체를 조회한다. List<Member> list = repository.findAll(); // 조회 결과를 콘솔에 출력한다. list.stream().forEach(System.out::println); } }
Member.createUser()는 회원 객체를 만드는 정적 메서드이다.
여기서는duke@java.com,9999,ADMIN값을 넘긴다.
중요한 점은 비밀번호9999가 그대로 저장되지 않는다는 것이다.
PasswordEncoder가 비밀번호를 암호화한 뒤Member객체에 저장한다.
그래서DB에는 원문 비밀번호가 아니라$2a$10$...형태의 암호화된 값이 들어간다.
@Rollback(false)는 테스트가 끝난 뒤 저장 결과를 되돌리지 않겠다는 뜻이다.
이 설정이 있어야 테스트로 저장한 관리자 계정이DB에 남아 이후 로그인 테스트에 사용할 수 있다.
AdminAccountCreateTest를 실행하면securitymember테이블에 관리자 계정이 저장된다.
테스트 결과가 성공했고,Hibernate가securitymember테이블에userid,pw,roles값을 저장하는insert쿼리를 실행한 것을 확인할 수 있다.
securitymember 테이블 저장 결과 확인
저장된 회원은 userid, pw, roles를 가진다
securitymember테이블은 로그인에 사용할 회원 정보를 저장한다.
이 테이블에서 중요한 컬럼은userid,pw,roles이다.
userid는 로그인할 때 입력하는 계정명이다.
pw는 암호화된 비밀번호이다.
roles는 회원의 권한이다.
테스트로 저장한 관리자 계정을 조회하면duke@java.com계정과ADMIN권한을 확인할 수 있다.
비밀번호는 원문9999가 아니라BCrypt로 암호화된 문자열로 저장된다.
securitymember테이블을 조회하면duke@java.com계정이 저장된 것을 확인할 수 있다.
비밀번호는 원문이 아니라$2a$10$...형태의BCrypt암호화 값으로 저장된다.
roles값은ADMIN이므로 이 계정은 관리자 권한을 가진다.
Member 엔티티와 Repository 구조
Member는 securitymember 테이블과 연결된다
Member는securitymember테이블과 연결되는JPA Entity이다.
Entity는 데이터베이스 테이블과 연결되는 자바 객체이다.
즉,Member객체 하나는securitymember테이블에 저장되는 회원 데이터 하나와 연결된다.
// Member.java package com.example.security12.entity; import jakarta.persistence.*; import lombok.Getter; import lombok.ToString; import org.springframework.security.crypto.password.PasswordEncoder; @Entity @Table(name="securitymember") @Getter @ToString public class Member { // 기본키이다. @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; // 로그인 계정명이다. @Column(unique = true) private String userid; // 암호화된 비밀번호이다. private String pw; // 권한 정보이다. private String roles; // 내부에서 회원 객체를 만들 때 사용하는 생성자이다. private Member(Long id, String userid, String pw, String roleUser) { this.id = id; this.userid = userid; this.pw = pw; this.roles = roleUser; } // JPA가 Entity를 만들 때 필요한 기본 생성자이다. protected Member() {} // 비밀번호를 암호화해서 회원 객체를 만든다. public static Member createUser(String userId, String pw, PasswordEncoder passwordEncoder, String role) { return new Member(null, userId, passwordEncoder.encode(pw), role); } }
@Entity는 이 클래스가JPA엔티티라는 뜻이다.
@Table(name="securitymember")는 이 엔티티가securitymember테이블과 연결된다는 뜻이다.
createUser()는 회원 객체를 만들 때 사용하는 메서드이다.
이 메서드 안에서passwordEncoder.encode(pw)를 호출한다.
따라서 회원을 만들 때부터 비밀번호가 암호화되어 저장된다.
roles필드에는ADMIN,USER같은 권한 이름이 저장된다.
다만Spring Security내부에서는ROLE_ADMIN,ROLE_USER처럼ROLE_접두어가 붙은 권한으로 다뤄질 수 있다.
이 차이는 뒤에서User.builder().roles()와@PreAuthorize를 볼 때 중요하다.
MemberRepository는Member데이터를 조회하고 저장하는 저장소 역할을 한다.// MemberRepository.java package com.example.security12.repository; import com.example.security12.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface MemberRepository extends JpaRepository<Member, Long> { // userid로 회원을 조회한다. Optional<Member> findByUserid(String userId); }
JpaRepository<Member, Long>은Member엔티티를 다루고, 기본키 타입은Long이라는 뜻이다.
findByUserid()는 로그인할 때 계정명으로 회원을 찾기 위해 사용한다.
SpringSecurityConfig에서 보안 흐름 설정하기
어떤 요청을 허용하고 어떤 요청을 인증할지 정한다
SpringSecurityConfig는security12의 보안 설정을 담당한다.
여기서 로그인 페이지 주소, 회원가입 허용 주소, 로그인 처리 주소, 로그인 성공/실패 처리, 로그아웃, 권한 검사 사용 여부를 설정한다.
가장 먼저 봐야 할 설정은@EnableMethodSecurity이다.
이 설정이 있어야 컨트롤러 메서드에 붙인@PreAuthorize가 동작한다.
즉,/view/adminpage,/view/userpage같은 요청에서 메서드 단위 권한 검사를 할 수 있다.
// SpringSecurityConfig.java package com.example.security12.config; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import static org.springframework.security.config.Customizer.withDefaults; @Configuration @Slf4j @EnableMethodSecurity public class SpringSecurityConfig { @Bean public PasswordEncoder passwordEncoder() { // 비밀번호 암호화에 사용할 객체를 등록한다. return new BCryptPasswordEncoder(); } @Bean public WebSecurityCustomizer configure() throws Exception { // 정적 리소스 경로는 Spring Security Filter Chain에서 제외한다. return (web) -> web.ignoring().requestMatchers("/images/**", "/*.html", "/html/**"); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(requests -> requests // 로그인, 회원가입, 상태 확인 요청은 인증 없이 허용한다. .requestMatchers("/status", "/view/login", "/view/signup", "/auth/signup").permitAll() // 나머지 요청은 인증이 필요하다. .anyRequest().authenticated()) // csrf 기능을 비활성화한다. .csrf((csrf) -> csrf.disable()) // form 로그인 설정이다. .formLogin(form -> form // 직접 만든 로그인 화면 주소이다. .loginPage("/view/login") // 로그인 form이 전송될 처리 주소이다. .loginProcessingUrl("/login-process") // 로그인 계정 파라미터 이름이다. .usernameParameter("userid") // 로그인 비밀번호 파라미터 이름이다. .passwordParameter("pw") // 로그인 성공 시 실행된다. .successHandler((request, response, authentication) -> { log.info("*** 인증을 수행한 계정명 = {}", authentication.getName()); response.sendRedirect("/view/memberpage"); }) // 로그인 실패 시 실행된다. .failureHandler((request, response, exception) -> { log.info("*** 예외 메시지 = {}", exception.getMessage()); response.sendRedirect("/view/login"); })) // 기본 로그아웃 기능을 사용한다. .logout(withDefaults()); // 설정한 보안 필터 체인을 반환한다. return http.build(); } }
PasswordEncoder는 비밀번호 암호화와 검증에 사용된다.
회원가입이나 테스트 계정 저장 때는 비밀번호를 암호화하고, 로그인 때는 사용자가 입력한 비밀번호와 암호화된 비밀번호가 맞는지 비교한다.
WebSecurityCustomizer는 특정 경로를Spring Security Filter Chain에서 완전히 제외한다.
여기서는/images/**,/*.html,/html/**경로를 제외한다.
이 설정 때문에 정적 이미지나 특정HTML파일은 보안 필터를 거치지 않고 접근할 수 있다.
authorizeHttpRequests()는 요청별 접근 규칙을 정한다.
/status,/view/login,/view/signup,/auth/signup은 로그인하지 않아도 접근할 수 있다.
나머지 요청은 인증된 사용자만 접근할 수 있다.
formLogin()은 로그인 화면과 로그인 처리 주소를 설정한다.
loginPage("/view/login")은 로그인 화면 주소이다.
loginProcessingUrl("/login-process")는 실제 로그인 요청이 전송되는 주소이다.
usernameParameter("userid")와passwordParameter("pw")는 로그인 폼에서 서버로 보내는 파라미터 이름을 지정한다.
여기서userid와pw를 지정했기 때문에 로그인 폼의 입력 이름도 이 값과 맞아야 한다.
만약 로그인 폼에서username,password라는 이름으로 보내면Spring Security가 현재 설정한 계정명과 비밀번호 값을 제대로 읽지 못할 수 있다.
정적 리소스 제외 설정과 경고 확인
web.ignoring은 보안 필터 체인 자체를 지나지 않게 한다
정적 리소스는 이미지, 일반
HTML,CSS,JavaScript처럼 화면을 구성하는 파일이다.
이런 파일까지 매번 인증 검사를 하면 불필요하게 복잡해질 수 있다.
그래서 실습에서는 일부 경로를web.ignoring()으로 제외한다.
다만 이 설정을 사용하면 경고 메시지가 출력될 수 있다.
경고는 설정이 동작하지 않는다는 뜻이 아니다.
Spring Security가 최신 방식에서는 보안 필터 체인을 완전히 우회하기보다permitAll()을 사용하는 방식을 권장한다는 의미로 보면 된다.
/images/**,/*.html,/html/**경로를web.ignoring()으로 제외했을 때 경고 메시지가 출력된다.
이 경고는 해당 방식이 동작하지 않는다는 뜻은 아니다.
다만 최신 방식에서는web.ignoring()보다HttpSecurity의authorizeHttpRequests에서permitAll()을 사용하는 것을 권장한다는 의미이다.
Spring Security가 특정 경로를 보안 필터에서 완전히 제외하도록 설정하면 이런 경고가 반복해서 출력될 수 있다.
실습에서는 정적 이미지와HTML파일 접근을 쉽게 확인하기 위해 사용했지만, 실제 서비스에서는 보안 필터를 완전히 우회할 경로인지 신중하게 판단해야 한다.
정적 리소스와 status 요청 확인
허용된 요청은 로그인 없이 접근할 수 있다
/images/spse.jpg는 정적 이미지 요청이다.
이 경로는web.ignoring()으로 제외했기 때문에 로그인 없이 접근할 수 있다.
/status는 보안 설정에서permitAll()로 허용한 요청이다.
따라서 로그인하지 않은 상태에서도ok응답을 받을 수 있다.
이 두 요청은 모두 로그인 없이 접근할 수 있지만 의미는 조금 다르다.
/images/spse.jpg는 보안 필터 체인을 아예 제외한 정적 리소스이고,/status는 보안 필터 설정 안에서 접근을 허용한 요청이다.
/images/spse.jpg주소로 접근하면Spring Security관련 이미지가 정상 출력된다.
이 결과는 정적 리소스 경로가 로그인 없이 접근 가능하게 처리된 것을 보여준다.
/status요청은 로그인하지 않아도 접근할 수 있는 상태 확인용 요청이다.
브라우저에서/status로 접속하면ok가 출력된다.
이 결과는SpringSecurityConfig에서/status를permitAll()로 허용한 설정이 적용된 것을 보여준다.
인증이 필요한 요청은 로그인 화면으로 이동한다
localhost:9000 접속은 로그인 화면으로 연결된다
security12에서 대부분의 요청은 인증이 필요하다.
SpringSecurityConfig는 일부 허용 경로를 제외한 나머지 요청에authenticated()를 적용한다.
authenticated()는 로그인한 사용자만 접근할 수 있다는 뜻이다.
그래서 허용되지 않은 요청을 로그인하지 않은 상태로 보내면 바로 원래 화면을 보여 주지 않는다.
먼저 로그인 여부를 확인하고, 로그인하지 않았다면 로그인 화면으로 이동시킨다.
localhost:9000으로 접속하면 인증이 필요한 요청으로 판단되어 로그인 페이지로 이동한다.
Spring Security가 적용되면 허용되지 않은 요청은 바로 화면을 보여 주는 것이 아니라, 먼저 로그인 여부를 확인한다.
로그인하지 않은 사용자는 인증을 위해 로그인 화면으로 이동한다.
MyUserDetailService로 DB 사용자 조회하기
로그인할 때 userid로 회원을 찾는다
사용자가 로그인 폼에 계정과 비밀번호를 입력하면
Spring Security는 사용자 정보를 조회해야 한다.
이때 사용되는 클래스가MyUserDetailService이다.
MyUserDetailService는UserDetailsService를 구현한다.
UserDetailsService는Spring Security가 로그인할 사용자를 찾을 때 사용하는 규칙이다.
즉,Spring Security는 로그인 시도 계정명을 넘기고, 이 서비스가 해당 사용자의 정보를 찾아서 돌려주기를 기대한다.
// MyUserDetailService.java package com.example.security12.config; import com.example.security12.entity.Member; import com.example.security12.service.MemberService; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; import java.util.Optional; @Component @Slf4j public class MyUserDetailService implements UserDetailsService { private final MemberService memberService; @Autowired public MyUserDetailService(MemberService memberService) { this.memberService = memberService; } @Override public UserDetails loadUserByUsername(String insertedUserId) throws UsernameNotFoundException { // 로그인 시도 계정으로 DB에서 회원을 찾는다. Optional<Member> findOne = memberService.findOne(insertedUserId); log.info("[로그인 시도] " + insertedUserId + findOne); // 회원이 없으면 인증 실패 예외를 발생시킨다. Member member = findOne.orElseThrow(() -> new UsernameNotFoundException("없는 회원입니다 ㅠ")); // Spring Security가 이해할 수 있는 UserDetails 객체로 변환한다. return User.builder() .username(member.getUserid()) .password(member.getPw()) .roles(member.getRoles()) .build(); } }
loadUserByUsername()은 로그인할 때 자동으로 호출되는 메서드이다.
매개변수insertedUserId에는 사용자가 로그인 폼에 입력한 계정명이 들어온다.
memberService.findOne(insertedUserId)는DB에서 해당 계정의 회원 정보를 찾는다.
회원이 없으면Optional.empty가 되고,UsernameNotFoundException이 발생한다.
이 경우 인증은 실패한다.
회원이 있으면User.builder()로Spring Security가 이해할 수 있는UserDetails객체를 만든다.
여기에는 사용자명, 암호화된 비밀번호, 권한이 들어간다.
여기서roles(member.getRoles())가 중요하다.
DB의roles값은ADMIN또는USER처럼 저장되어 있다.
User.builder().roles("ADMIN")처럼 설정하면Spring Security내부에서는 권한이ROLE_ADMIN형태로 만들어진다.
그래서 로그에는ADMIN이 아니라ROLE_ADMIN처럼 보일 수 있다.
DB에는ADMIN,USER로 저장되어도Spring Security내부 권한은ROLE_ADMIN,ROLE_USER형태로 다뤄질 수 있다.
존재하지 않는 계정으로 로그인하면 실패한다
DB에서 회원을 찾지 못하면 인증이 실패한다
로그인할 때 입력한 계정이
securitymember테이블에 없으면 인증은 실패한다.
서버는 해당 계정을 찾으려고 하지만 결과가 비어 있기 때문이다.
로그를 보면Optional.empty가 출력된다.
이것은 조회 결과가 없다는 뜻이다.
그 다음 자격 증명에 실패했다는 메시지가 출력된다.
존재하지 않는 계정으로 로그인하면
MyUserDetailService가DB에서 사용자를 찾지 못한다.
로그에는Optional.empty가 출력되고, 이후 자격 증명에 실패했다는 메시지가 출력된다.
즉, 입력한 계정이securitymember테이블에 없으면 인증에 실패한다.
로그인 성공 흐름 확인
가입된 계정이면 인증이 완료된다
가입된 계정으로 로그인하면
MyUserDetailService가DB에서 회원 정보를 찾는다.
그 다음Spring Security는 사용자가 입력한 비밀번호와DB에 저장된 암호화 비밀번호가 맞는지 비교한다.
비밀번호가 맞으면 인증에 성공한다.
인증에 성공하면 설정된 성공 처리 흐름에 따라/view/memberpage로 이동한다.
가입된 계정으로 로그인하면 서버는
DB에서 사용자를 조회하고 비밀번호를 검증한다.
인증에 성공하면 로그인 이후 접근 가능한 화면으로 이동한다.
이 흐름은MyUserDetailService가DB기반 사용자 정보를 조회하고,Spring Security가 인증을 완료하는 과정을 보여준다.
duke@java.com계정으로 로그인하면securitymember테이블에서 회원 정보가 조회된다.
로그에는 인증을 수행한 계정명과 권한 정보가 출력된다.
DB에는ADMIN으로 저장되어 있지만,Spring Security내부에서는ROLE_ADMIN권한으로 다뤄지는 것을 확인할 수 있다.
이 계정은 관리자 권한을 가지고 있으므로 관리자 페이지 접근에 사용할 수 있다.
ViewController에서 권한별 화면 나누기
PreAuthorize로 메서드 실행 전에 권한을 검사한다
ViewController는 화면 이동 요청을 처리한다.
여기서 중요한 부분은@PreAuthorize이다.
@PreAuthorize는 컨트롤러 메서드가 실행되기 전에 권한 조건을 검사한다.
즉,/view/adminpage요청이 들어와도 바로admin_role화면을 반환하지 않는다.
먼저 현재 로그인 사용자가ADMIN권한을 가지고 있는지 확인한다.
권한이 있으면 메서드가 실행되고, 없으면 접근이 거부된다.
// ViewController.java package com.example.security12.controller; import lombok.extern.slf4j.Slf4j; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.User; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import java.time.LocalDateTime; @Controller @RequestMapping("/view") @Slf4j public class ViewController { // 로그인 화면을 반환한다. @GetMapping("/login") public String loginPage() { return "login"; } // 회원가입 화면을 반환한다. @GetMapping("/signup") public String joinPage() { return "signup_form"; } // 로그인 후 공통 회원 페이지를 반환한다. @GetMapping("/memberpage") public String dashboardPage(@AuthenticationPrincipal User user) { log.info(user.getUsername()+"님("+user.getAuthorities()+")이 멤버페이지에 접근함 - "+ LocalDateTime.now()); return "member_page"; } // ADMIN 권한이 있는 사용자만 접근할 수 있다. @PreAuthorize("hasAnyRole('ADMIN')") @GetMapping("/adminpage") public String adminSettingPage() { return "admin_role"; } // USER 권한이 있는 사용자만 접근할 수 있다. @PreAuthorize("hasAnyRole('USER')") @GetMapping("/userpage") public String userSettingPage() { return "user_role"; } }
@AuthenticationPrincipal은 현재 로그인한 사용자 정보를 꺼낼 때 사용한다.
dashboardPage()에서는 로그인한 사용자의 계정명과 권한을 로그로 출력한다.
@PreAuthorize("hasAnyRole('ADMIN')")는ADMIN권한이 있는 사용자만 메서드를 실행할 수 있다는 뜻이다.
@PreAuthorize("hasAnyRole('USER')")는USER권한이 있는 사용자만 메서드를 실행할 수 있다는 뜻이다.
여기서hasAnyRole('ADMIN')은 내부적으로ROLE_ADMIN권한을 확인한다고 이해하면 된다.
마찬가지로hasAnyRole('USER')는 내부적으로ROLE_USER권한을 확인한다.
따라서User.builder().roles("ADMIN")로 만들어진ROLE_ADMIN권한과hasAnyRole('ADMIN')조건이 서로 연결된다.
@PreAuthorize는 로그인 여부만 보는 것이 아니라, 현재 사용자가 필요한 권한을 가지고 있는지까지 확인한다.
ADMIN 계정 권한 테스트
ADMIN은 adminpage에 접근할 수 있다
duke@java.com계정은ADMIN권한을 가지고 있다.
Spring Security내부에서는 이 권한이ROLE_ADMIN으로 다뤄진다.
따라서 로그인 후/view/adminpage에 접근하면 관리자 화면이 정상 출력된다.
ADMIN권한을 가진 사용자로 로그인한 뒤/view/adminpage에 접근하면 관리자 화면이 정상 출력된다.
이 화면은@PreAuthorize("hasAnyRole('ADMIN')")조건을 통과한 결과이다.
ADMIN은 userpage에 접근하지 못한다
ADMIN으로 로그인했다고 해서USER전용 페이지까지 모두 접근할 수 있는 것은 아니다.
현재 설정에서는/view/userpage가USER권한을 요구한다.
따라서ADMIN권한만 가진 사용자가/view/userpage에 접근하면 인가 단계에서 거부된다.
이때 브라우저에는403 Forbidden오류가 보일 수 있다.
ADMIN권한 계정으로/view/userpage에 접근하면403 Forbidden이 발생한다.
이 페이지는USER권한이 필요한 페이지이기 때문이다.
로그인은 되어 있어도, 필요한 권한이 없으면 인가 단계에서 접근이 거부된다.
USER 계정 생성과 DB 저장 확인
회원가입으로 USER 권한 계정을 만든다
관리자 계정은 테스트 코드로 만들었다.
이번에는 일반 사용자 권한을 가진 계정을 회원가입 흐름으로 만든다.
회원가입 요청은/auth/signup으로 처리된다.
SignupController가 요청 데이터를 받고,RegisterMemberService가 회원을 저장한다.
이때 기본 권한은USER로 저장된다.
// SignupController.java package com.example.security12.controller; import com.example.security12.dto.MemberSignupDTO; import com.example.security12.service.RegisterMemberService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/auth") public class SignupController { private final RegisterMemberService registerMemberService; public SignupController(RegisterMemberService registerMemberService) { this.registerMemberService = registerMemberService; } @PostMapping("/signup") public ResponseEntity<String> join(MemberSignupDTO dto) { try { // 회원가입을 처리한다. registerMemberService.join(dto.getUserid(), dto.getPw()); // 성공 메시지를 반환한다. return ResponseEntity.ok("성공적으로 회원 가입이 진행되었습니다."); } catch (Exception e) { // 실패하면 오류 메시지를 반환한다. return ResponseEntity.badRequest().body(e.getMessage()); } } }
SignupController는@RestController이므로 반환 값이 화면 이름이 아니라 응답Body로 내려간다.
회원가입에 성공하면 성공 메시지가 응답 본문으로 내려간다.
join(MemberSignupDTO dto)에는 별도의@RequestBody가 붙어 있지 않다.
따라서 이 흐름은JSON요청보다 폼 데이터 요청과 연결해서 이해하는 것이 좋다.
요청 파라미터의userid,pw값이MemberSignupDTO에 담기고, 서비스로 전달된다.
// RegisterMemberService.java package com.example.security12.service; import com.example.security12.entity.Member; import com.example.security12.repository.MemberRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; @Service public class RegisterMemberService { private final PasswordEncoder passwordEncoder; private final MemberRepository repository; @Autowired public RegisterMemberService(PasswordEncoder passwordEncoder, MemberRepository repository) { this.passwordEncoder = passwordEncoder; this.repository = repository; } public Long join(String userid, String pw) { // USER 권한을 가진 회원 객체를 만든다. Member member = Member.createUser(userid, pw, passwordEncoder, "USER"); // 중복 회원인지 검사한다. validateDuplicateMember(member); // 회원 정보를 DB에 저장한다. repository.save(member); // 저장된 회원의 id를 반환한다. return member.getId(); } private void validateDuplicateMember(Member member) { // 같은 userid가 있으면 예외를 발생시킨다. repository.findByUserid(member.getUserid()) .ifPresent(m -> { throw new IllegalStateException("이미 존재하는 회원입니다."); }); } }
RegisterMemberService는Member.createUser(userid, pw, passwordEncoder, "USER")를 호출한다.
그래서 회원가입으로 만든 계정은 기본적으로USER권한을 가진다.
비밀번호는 관리자 계정과 마찬가지로 암호화되어 저장된다.
USER권한을 가진 계정을 새로 만드는 흐름이다.
회원가입 화면에서 사용자 정보를 입력하면 서버는 회원 정보를 저장한다.
이후securitymember테이블을 조회하면 기존ADMIN계정과 새로 만든USER계정이 함께 저장된 것을 확인할 수 있다.
securitymember테이블을 조회하면duke@java.com은ADMIN,unico@java.com은USER권한으로 저장되어 있다.
같은 테이블에 회원 정보가 저장되지만,roles값에 따라 접근할 수 있는 화면이 달라진다.
USER 계정 권한 테스트
USER 계정으로 로그인한다
USER권한을 가진unico@java.com계정으로 로그인한다.
로그인에 성공하면Spring Security는securitymember테이블에서 조회한 회원 정보를 기준으로 인증 상태를 만든다.
하지만 인증에 성공했다는 사실만으로 모든 화면에 접근할 수 있는 것은 아니다.
이후 요청에서는 현재 로그인 사용자의 권한이 함께 확인된다.
USER권한 사용자는USER전용 페이지에는 접근할 수 있지만,ADMIN권한이 필요한 페이지에는 접근할 수 없다.
USER권한 계정으로 로그인하는 흐름이다.
로그인 폼에USER계정 정보를 입력하면 서버는DB에서 회원 정보를 조회하고 비밀번호를 검증한다.
인증에 성공하면 로그인 상태가 만들어지고, 이후 권한별 페이지 접근 테스트를 진행할 수 있다.
USER는 adminpage에 접근하지 못한다
USER권한 사용자는 관리자 페이지에 접근할 수 없다.
관리자 페이지는@PreAuthorize("hasAnyRole('ADMIN')")조건이 붙어 있기 때문이다.
USER권한 계정으로/view/adminpage에 접근하면403 Forbidden이 발생한다.
사용자는 로그인되어 있지만ADMIN권한이 없기 때문에 관리자 페이지 접근이 거부된다.
USER는 userpage에 접근할 수 있다
USER권한 사용자는/view/userpage에 접근할 수 있다.
이 페이지는@PreAuthorize("hasAnyRole('USER')")조건이 붙어 있기 때문이다.
USER권한 계정으로/view/userpage에 접근하면 사용자 전용 화면이 정상 출력된다.
이 화면은@PreAuthorize("hasAnyRole('USER')")조건을 통과한 결과이다.
security12 핵심 정리
security12는JPA로 저장된 회원 정보를Spring Security로그인 인증에 연결하는 예제이다.
회원 정보는securitymember테이블에 저장되고, 비밀번호는BCrypt로 암호화된다.
로그인할 때는MyUserDetailService가 사용자가 입력한userid로 회원을 조회한다.
회원이 없으면 인증에 실패하고, 회원이 있으면Spring Security가 입력 비밀번호와 암호화 비밀번호를 비교한다.
인증에 성공하면 사용자는 로그인 상태가 된다.
하지만 로그인 상태만으로 모든 화면에 접근할 수 있는 것은 아니다.
ViewController의@PreAuthorize가ADMIN권한 또는USER권한을 검사한다.
DB에는 권한이ADMIN,USER처럼 저장되지만,Spring Security내부에서는ROLE_ADMIN,ROLE_USER형태로 다뤄질 수 있다.
hasAnyRole('ADMIN')은 내부적으로ROLE_ADMIN권한을 확인하고,hasAnyRole('USER')는 내부적으로ROLE_USER권한을 확인한다.
ADMIN사용자는 관리자 페이지에 접근할 수 있지만USER전용 페이지에는 접근하지 못한다.
USER사용자는 사용자 페이지에 접근할 수 있지만 관리자 페이지에는 접근하지 못한다.
security12의 핵심은 인증은DB회원 정보로 사용자를 확인하는 과정이고, 인가는 로그인한 사용자의 권한으로 접근 가능한 화면을 결정하는 과정이라는 점이다.