프로젝트에서 Spring Security + JWT + Redis 방식으로 인증/인가 로직을 구현하였고, 전체 과정을 기록으로 남겨두려 한다.
(gradle 기준)
[2-1-1. Spring Security 의존성 설정]
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
[2-1-2. JWT 의존성 설정]
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
[2-2-1. JwtTokenProvider 클래스 생성]
@Slf4j
@Component
public class JwtTokenProvider {
}
[2-2-2. JWT 생성 로직 구현]
<Token 저장 클래스 생성>
@Builder
@Data
@AllArgsConstructor
public class TokenInfo {
private String grantType;
private String accessToken;
private String refreshToken;
}
Bearer
를 활용하며, 이후 Http Request의 헤더에 Token을 담을 때 prefix로 설정된다.<Secret Key 등록>
# application.yml
...
jwt:
secret: XUKemfiZfrEWLtykdkjfeliwjfd59YLt5XXXkAdqZu33
// JwtTokenProvider
...
private final Key key;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
...
<JWT 생성 메서드 - 로그인, 회원가입, 토큰 재발급 과정에서 활용>
// JwtTokenProvider
...
// AccessToken 유효기간 설정 : 30분
private final long THIRTY_MINUTES = 1000 * 60 * 30;
// RefreshToken 유효기간 설정 : 1주
private final long ONE_WEEK = 1000 * 60 * 60 * 24 * 7;
...
public TokenInfo generateToken(Collection<? extends GrantedAuthority> authorityInfo,
String id) {
// 사용자의 권한 정보들을 모아 문자열로 만든다.
String authorities = authorityInfo.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// AccessToken 생성 과정
Date accessTokenExpiresIn = new Date(now + THIRTY_MINUTES);
String accessToken = Jwts.builder()
.setSubject(id)
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// RefreshToken 생성 과정
Date refreshTokenExpiresIn = new Date(now + ONE_WEEK);
String refreshToken = Jwts.builder()
.setExpiration(refreshTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return TokenInfo.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
...
[2-2-3. AuthenticationFilter에서 인증 과정에 활용되는 메서드 구현]
<Authentication 객체 반환 메서드 - AuthenticationFilter에서 활용>
// JwtTokenProvider
...
// Access Token에 들어있는 정보를 꺼내 Authentication 객체를 생성 후 반환한다.
public Authentication getAuthentication(String accessToken) {
// 토큰의 Payload에 저장된 Claim들을 추출한다.
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
// 권한 정보 없는 토큰
throw new InvalidTokenException(INVALID_TOKEN.getMessage());
}
// Claim에서 권한 정보를 추출한다.
Collection<? extends GrantedAuthority> authorities = Arrays
.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// Claim에 저장된 사용자 아이디를 통해 UserDetails 객체를 생성한다.
UserDetails principal = new User(claims.getSubject(), "", authorities);
// Authentication 객체를 생성하여 반환한다.
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody();
} catch (ExpiredJwtException e) {
throw new ExpiredTokenException(EXPIRED_TOKEN.getMessage());
}
}
...
<유효성 검증 메서드 - AuthenticationFilter에서 활용>
// JwtTokenProvider
// 토큰 검증 메서드
public boolean validateToken(String token) throws TokenException {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
throw new InvalidTokenException(INVALID_TOKEN.getMessage());
} catch (ExpiredJwtException e) {
throw new ExpiredTokenException(EXPIRED_TOKEN.getMessage());
}
}
<Access Token 추출 메서드 - AuthenticationFilter에서 활용>
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
log.debug("bearertoken : {}", bearerToken);
if (StringUtils.hasText(bearerToken)) {
if (bearerToken.startsWith("Bearer") && bearerToken.length() > 7) {
int tokenStartIndex = 7;
return bearerToken.substring(tokenStartIndex);
}
throw new MalformedHeaderException(MALFORMED_HEADER.getMessage());
}
return bearerToken;
}
Http Request의 Header로부터 Access Token을 추출하는 메서드이다.
Access Token은 “Authorization” 필드에 담겨서 전달되도록 하고, Token 앞에 “Bearer ” 문자열을 prefix로 붙이도록 한다.
JwtTokenProvider 클래스 전체 코드
// JwtTokenProvider
@Slf4j
@Component
public class JwtTokenProvider {
private final Key key;
private final long THIRTY_MINUTES = 1000 * 60 * 30;
private final long ONE_WEEK = 1000 * 60 * 60 * 24 * 7;
public JwtTokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
public TokenInfo generateToken(Collection<? extends GrantedAuthority> authorityInfo,
String id) {
// 사용자의 권한 정보들을 모아 문자열로 만든다.
String authorities = authorityInfo.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// AccessToken 생성 과정
Date accessTokenExpiresIn = new Date(now + THIRTY_MINUTES);
String accessToken = Jwts.builder()
.setSubject(id)
.claim("auth", authorities)
.setExpiration(accessTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
// RefreshToken 생성 과정
Date refreshTokenExpiresIn = new Date(now + ONE_WEEK);
String refreshToken = Jwts.builder()
.setExpiration(refreshTokenExpiresIn)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return TokenInfo.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
// Access Token에 들어있는 정보를 꺼내 Authentication 객체를 생성 후 반환한다.
public Authentication getAuthentication(String accessToken) {
// 토큰의 Payload에 저장된 Claim들을 추출한다.
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
// 권한 정보 없는 토큰
throw new InvalidTokenException(INVALID_TOKEN.getMessage());
}
// Claim에서 권한 정보를 추출한다.
Collection<? extends GrantedAuthority> authorities = Arrays
.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// Claim에 저장된 사용자 아이디를 통해 UserDetails 객체를 생성한다.
UserDetails principal = new User(claims.getSubject(), "", authorities);
// Authentication 객체를 생성하여 반환한다.
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(accessToken)
.getBody();
} catch (ExpiredJwtException e) {
throw new ExpiredTokenException(EXPIRED_TOKEN.getMessage());
}
}
// 토큰 검증 메서드
public boolean validateToken(String token) throws TokenException {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | UnsupportedJwtException | IllegalArgumentException e) {
throw new InvalidTokenException(INVALID_TOKEN.getMessage());
} catch (ExpiredJwtException e) {
throw new ExpiredTokenException(EXPIRED_TOKEN.getMessage());
}
}
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
log.debug("bearertoken : {}", bearerToken);
if (StringUtils.hasText(bearerToken)) {
if (bearerToken.startsWith("Bearer") && bearerToken.length() > 7) {
int tokenStartIndex = 7;
return bearerToken.substring(tokenStartIndex);
}
throw new MalformedHeaderException(MALFORMED_HEADER.getMessage());
}
return bearerToken;
}
}
// SecurityConfig
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(HttpMethod.GET, "/~~~").permitAll()
.antMatchers("/~~~").authenticated()
.regexMatchers(HttpMethod.POST, "/~~~").authenticated()
.anyRequest().authenticated();
return http.build();
}
}
@Configuration
, @EnableWebSecurity
어노테이션을 부여한다.authorizeRequests()
이후에 url 별로 접근 권한을 설정할 수 있다.antMatchers()
와 regexMatchers()
를 활용하여 url을 표현할 수 있다.permitAll()
을 통해 모든 접근을 허용할 수 있으며 authenticated()
를 통해 인증된 사용자만 접근할 수 있도록 제한할 수 있다.hasAuthority()
등을 통해 특정 권한에 대한 제한을 부여할 수 있다.PasswordEncoderFactories.createDelegatingPasswordEncoder()
public static PasswordEncoder createDelegatingPasswordEncoder() {
String encodingId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(encodingId, new BCryptPasswordEncoder());
encoders.put("ldap", new org.springframework.security.crypto.password.LdapShaPasswordEncoder());
encoders.put("MD4", new org.springframework.security.crypto.password.Md4PasswordEncoder());
encoders.put("MD5", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("MD5"));
encoders.put("noop", org.springframework.security.crypto.password.NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", new Pbkdf2PasswordEncoder());
encoders.put("scrypt", new SCryptPasswordEncoder());
encoders.put("SHA-1", new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-1"));
encoders.put("SHA-256",
new org.springframework.security.crypto.password.MessageDigestPasswordEncoder("SHA-256"));
encoders.put("sha256", new org.springframework.security.crypto.password.StandardPasswordEncoder());
encoders.put("argon2", new Argon2PasswordEncoder());
return new DelegatingPasswordEncoder(encodingId, encoders);
}
다양한 PasswordEncoder들을 제공해주는 것을 확인할 수 있다.
Spring Security에서는 기본적으로 BCrypt Encoder가 활용된다.
(후에 패스워드를 encoding하여 저장할 때 앞에 {bcrypt}
가 붙는 것을 확인할 수 있다!)
좋은 글 감사합니다. 자주 방문할게요 :)