사용자 인증 확인 방법
서버 기반 인증
: 스프링 시큐리티
에서 기본적
으로 세션 기반 인증
제공세션 기반 인증
을 통해 사용자마다 사용자의 정보를 담은 세션
을 생성하고 저장해서 인증
토큰 기반 인증
서버에서 클라이언트를 구분하기 위한 유일한 값
서버가 토큰을 생성해 클라이언트에게 제공
토큰을 가지고 여러 요청과 함께 신청
유효한 사용자인지 검증
토큰 기반 인증 특징
무상태성
: 사용자의 인증 정보가 담겨 있는 토큰
이 서버가 아닌 클라이언트에 있으므로
서버에 저장 X
서버가 뭔가 데이터를 유지
하고 있으려면 자원을 소비
해야하지만토큰기반
은 클라이언트가 인증정보가 담긴 토큰을 생성하고 인증함
클라이언트는 사용자의 인증 상태를 유지하면서 이후 요청을 처리
= 상태관리
서버
는 클라이언트의 인증 정보를 저장하거나 유지하지 않아도 되서
, 무상태
로 효율적인 검증 가능확장성
: 무상태성이 확장성에 영향
서버를 확장할 때 상태 관리 신경 X
-> 확장에 용이
세션 기반
: 각각 API 인증 필요
토큰 기반
: 주체가 Client
무결성
HMAC기법
토큰 정보 변경 불가
무결성 보장
JWT
Authorization 키값에 Bearer + JWT
HEADER
: 토큰 타입
+ 해싱 알고리즘
PAYLOAD
: 토큰과 관련된 정보
내용의 한덩어리
: ClaimKey, Value
SIGNATURE
: 해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도로 사용HEADER의 인코딩 값
+ PAYLOAD 인코딩 값
=> + 비밀키
=> SIGNATURE
HEADER
{
"tpy": "JWT",
"alg": "HS256"
}
이름 | 설명 |
---|---|
typ | 토큰의 타입을 지정, JWT라는 문자열 들어감 |
alg | 해싱 알고리즘 지정 |
이름 | 설명 |
---|---|
iss | 토큰 발급자(issuer) |
sub | 토큰 제목(subject) |
aud | 토큰 대상자(audience) |
exp | 토큰의 만료시간(expiration), 시간은NumericDate형식(12312312341)으로 하며, 항상 현재 시간 이후로 설정 |
nbf | 토큰의 활성 날짜와 비슷한 개념으로 nbf는 Not Before를 의미, NumericDate 형식으로 날짜 지정, 이 날짜가 지나기 전까지 토큰 처리 X |
iat | 토큰이 발급된 시간으로 iat는 issued at을 의미 |
jti | JWT의 고유식별자로 주로 일회용 토큰에 사용 |
{
"iss": "jaegeunsong97@gmail.com", // 등록된 클레임
"iat": 2023123022, // 등록된 클레임
"exp": 2023123040, // 등록된 클레임
"https://jaegeunsong.com/jwt_claims/is_admin": true, // 공개 클레임
"email": "jaegeunsong97@gmail.com", // 비공개 클레임
"hello": "안녕하세여" // 비공개 클레임
}
리프래시 토큰
액세스 토큰 만료시 리프래시 토큰으로 새로운 엑세스 토큰 발급
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
implementation 'io.jsonwebtoken:jjwt:0.9.1' // 자바 JWT 라이브러리
implementation 'javax.xml.bind:jaxb-api:2.3.1' // XM 문서와 Java 객체 간 매핑 자동화
runtimeOnly 'com.h2database:h2'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
spring:
jpa:
show-sql: true
properties:
hibernate:
format_sql: true
defer-datasource-initialization: true
datasource:
url: jdbc:h2:mem:testdb
username: song
h2:
console:
enabled: true
jwt:
issuer: jaegeunsong97@gmail.com # 이슈 발급자
secret_key: study-springboot # 비밀키
@Getter
@Setter
@Component
@ConfigurationProperties("jwt") // Java 에서 Properties 값을 가져옴
public class JwtProperties {
private String issuer;
private String secretKey;
}
TokenProvider
generateToken()
: 토큰을 생성하는 메소드validToken()
: 토큰 유효성 검사 메소드getAuthentication()
: 토큰으로 전달받아 인증 정보를 담은 객체 Authentication 반환
하는 메소드getUserId()
: 토큰 기반으로 유저 ID를 가져오는
메소드@Service
@RequiredArgsConstructor
public class TokenProvider {
private final JwtProperties jwtProperties;
public String generateToken(User user, Duration expiredAt) {
Date now = new Date();
return makeToken(new Date(now.getTime() + expiredAt.toMillis()), user);
}
// 1. JWT 토큰 생성
private String makeToken(Date expiry, User user) {
Date now = new Date();
return Jwts.builder()
// HEADER: typ
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // 헤더 type : JWT
// PAYLOAD: iss, iat, exp, sub, claim
.setIssuer(jwtProperties.getIssuer()) // 내용 iss : jaegeunsong97@gmail.com(propertise 파일에서 설정한 값)
.setIssuedAt(now) // 내용 iat : 현재시간
.setExpiration(expiry) // 내용 exp : expiry 멤버 변수값
.setSubject(user.getEmail()) // 내용 sub : 유저의 이메일
.claim("id", user.getId()) // 클레임 id : 유저 ID
// SIGN
.signWith(SignatureAlgorithm.HS256, jwtProperties.getSecretKey()) // 서명 : 비밀값과 함께 해시값을 HS256 방식으로 암호화
.compact();
}
// 2. JWT 토큰 유효성 검증 메소드
public boolean validToken(String token) {
try {
Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey()) // 복호화
.parseClaimsJws(token);
return true;
} catch (Exception e) {
return false; // 에러 발생 = 유효하지 않은 토큰
}
}
// 3. 토큰 기반으로 인증 정보를 가져오는 메소드, 즉 인증 정보를 담은 객체 Authentication 반환
public Authentication getAuthentication(String token) {
Claims claims = getClaims(token); // getClaims : 복호화 -> Claim 가져옴
Set<SimpleGrantedAuthority> authorities = Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
return new UsernamePasswordAuthenticationToken(
// 다른 User
new org.springframework.security.core.userdetails.User(
claims.getSubject(), "", authorities), token, authorities);
}
// 4. 토큰 기반으로 유저 ID를 가져오는 메소드
public Long getUserId(String token) {
Claims claims = getClaims(token);
return claims.get("id", Long.class);
}
private Claims getClaims(String token) {
return Jwts.parser()
.setSigningKey(jwtProperties.getSecretKey()) // 복호화
.parseClaimsJws(token)
.getBody(); // 내용 sub : 유저의 이메일 꺼내기
}
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id", updatable = false)
private Long id;
@Column(name = "user_id", nullable = false, unique = true)
private Long userId;
@Column(name = "refresh_token", nullable = false)
private String refreshToken;
public RefreshToken(Long userId, String refreshToken) {
this.userId = userId;
this.refreshToken = refreshToken;
}
public RefreshToken update(String newRefreshToken) {
this.refreshToken = newRefreshToken;
return this;
}
}
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
Optional<RefreshToken> findByUserId(Long userId);
Optional<RefreshToken> findByRefreshToken(String refreshToken);
}
토큰 필터 구현하기
필터
: 각종 요청을 처리하기 위한 로직전달되기 전후에 URL 패턴에 맞는 모든 요청을 처리해주는 것
유효한 토큰
-> SecurityContextHolder에 인증 정보 저장
SecurityContext
: 인증 정보가 저장되는 객체
인증정보가 필요할 떄 언제든지 꺼내 사용 가능
thread local(스레드 마다 공간을 할당)에 저장되므로 코드의 아무 곳에서나 참조 가능
다른 스레드와 공유 X
SecurityContext 객체를 저장하는 객체
: SecurityContextHolder
TokenAuthenticationFilter
@RequiredArgsConstructor
public class TokenAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final static String HEADER_AUTHORIZATION = "Authorization";
private final static String TOKEN_PREFIX = "Bearer ";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 요청 헤더의 Authorization 키의 값 조회
String authorizationHeader = response.getHeader(HEADER_AUTHORIZATION);
// 가져온 값에서 접두사 제거
String token = getAccessToken(authorizationHeader);
// 가져온 토큰이 유효한지 확인하고, 유효할 때는 인증 정보 설정
if (tokenProvider.validToken(token)) {
Authentication authentication = tokenProvider.getAuthentication(token);
// SecurityContext에 인증 정보 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// Bearer 제거
private String getAccessToken(String authorizationHeader) {
if (authorizationHeader != null && authorizationHeader.startsWith(TOKEN_PREFIX)) {
return authorizationHeader.substring(TOKEN_PREFIX.length());
}
return null;
}
}
리프레시 토큰을 받아 토큰 제공자를 사용해 새로운 엑세스 토큰 만들기
public User findById(Long userId) {
return userRepository.findById(userId).orElseThrow(
() -> new IllegalArgumentException("Unexpected user")
);
}
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final RefreshTokenRepository refreshTokenRepository;
public RefreshToken findByRefreshToken(String refreshToken) {
return refreshTokenRepository.findByRefreshToken(refreshToken).orElseThrow(
() -> new IllegalArgumentException("Unexpected token")
);
}
}
@Service
@RequiredArgsConstructor
public class TokenService {
private final TokenProvider tokenProvider;
private final RefreshTokenService refreshTokenService;
private final UserService userService;
public String createNewAccessToken(String refreshToken) {
if (!tokenProvider.validToken(refreshToken)) {
throw new IllegalArgumentException("Unexpected token");
}
Long userId = refreshTokenService.findByRefreshToken(refreshToken).getUserId();
User user = userService.findById(userId);
return tokenProvider.generateToken(user, Duration.ofHours(2)); // access 토큰 짧게
}
}
@Getter
@Setter
public class CreateAccessTokenRequest {
private String refreshToken;
}
.
.
.
@Getter
@AllArgsConstructor
public class CreateAccessTokenResponse {
private String accessToken;
}
@RestController
@RequiredArgsConstructor
public class TokenApiController {
private final TokenService tokenService;
@PostMapping("/api/token")
public ResponseEntity<CreateAccessTokenResponse> createNewAccessToken(
@RequestBody CreateAccessTokenRequest request) {
String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken());
return ResponseEntity.status(HttpStatus.CREATED)
.body(new CreateAccessTokenResponse(newAccessToken));
}
}
토큰 기반 인증
JWT
HEADER
: 타입, 해싱알고리즘PAYLOAD
: 토큰에 담을 정보SIGNATURE
: 위에 2개 합친거(인코딩 + 비밀키)리프레시 토큰
필터
시큐리티 컨텍스트
인증정보가 저장되는 객체
시큐리티 컨텍스트 홀더에 시큐리티 컨텍스트가 저장됨