티스토리 뷰
Monolithic Architecture에서 Micro Service Architecture까지는 아니더라도 많은 부분이 API로 분리가 되고 이에 대한 인증의 필요성이 생겼다. Spring Security와 JWT를 이용하면 이런 API 서버간의 인증을 간단하게 할 수 있다.
프로젝트 구조
프로젝트의 전체 구조는 위와 같다. 일반적인 maven 기반의 springboot 프로젝트의 구조이다.
구현되어 있는 파일들을 순서대로 보며 알아보자.
프로젝트 설정 (중요한 부분이라고 하기 전까지는 가볍게 읽자)
pom.xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
....
</dependencies>
프로젝트의 시작이 되는 pom.xml 부터 보자. 제목에 나온대로 Spring Security와 JWT를 사용하기 위해 해당 dependency를 넣어주었다. 또한 data 처리를 위해 JPA와 h2 database를 사용하였다.
Application.java (SpringSecurityJwtExampleApplication.java)
import com.javatechie.jwt.api.entity.User;
import com.javatechie.jwt.api.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import javax.annotation.PostConstruct;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@SpringBootApplication
public class SpringSecurityJwtExampleApplication {
@Autowired
private UserRepository repository;
@PostConstruct
public void initUsers() {
List<User> users = Stream.of(
new User(101, "javatechie", "password", "javatechie@gmail.com"),
new User(102, "user1", "pwd1", "user1@gmail.com"),
new User(103, "user2", "pwd2", "user2@gmail.com"),
new User(104, "user3", "pwd3", "user3@gmail.com")
).collect(Collectors.toList());
repository.saveAll(users);
}
public static void main(String[] args) {
SpringApplication.run(SpringSecurityJwtExampleApplication.class, args);
}
}
일반적인 @SpringBootApplication에 인위로 data를 만들어서 repository에 넣는 부분을 추가하였다. DB를 따로 구성하지 않고 간략하게 테스트를 하기 위함이다.
Entity (User.java)
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Table;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "USER_TBL")
public class User {
@Id
private int id;
private String userName;
private String password;
private String email;
}
JPA방식으로 데이터를 담기 위한 공간인 Entity를 생성한다.
Entity (AuthRequest.java)
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AuthRequest {
private String userName;
private String password;
}
인증에 필요한 정보를 담기 위한 Entity이다.
Repository (UserRepository.java)
import com.javatechie.jwt.api.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User,Integer> {
User findByUserName(String username);
}
JPA를 사용하기 위한 Repository를 작성한다.
Service (CustomUserDetailService.java)
import com.javatechie.jwt.api.entity.User;
import com.javatechie.jwt.api.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
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.Service;
import java.util.ArrayList;
@Service
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository repository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = repository.findByUserName(username);
return new org.springframework.security.core.userdetails.User(user.getUserName(), user.getPassword(), new ArrayList<>());
}
}
username을 Controller로부터 넘겨 받으면 이를 repository로 보내서 username에 해당하는 User의 정보를 가져온다.
중요한 부분
여기서부터는 중요한 부분이다. 왜 중요하냐면 이 글을 쓰는 목적인 JWT와 Spring Security와의 연동을 하는 부분이 나오기 때문이다.
Security Config (SecurityConfig.java)
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.BeanIds;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import com.javatechie.jwt.api.filter.JwtFilter;
import com.javatechie.jwt.api.service.CustomUserDetailsService;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private CustomUserDetailsService userDetailsService;
@Autowired
private JwtFilter jwtFilter;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService);
}
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable().authorizeRequests().antMatchers("/authenticate")
.permitAll().anyRequest().authenticated()
.and().exceptionHandling().and().sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);;
}
}
일단 가장 먼저 봐야할 부분은 Spring Security Config이다. 일반적인 Spring Security의 구성과 거의 유사하다. 주목해서 봐야 할 곳은 configure(HttpSecurity http) 부분이다. 이부분에 대해 간략하게 설명을 하자면 /authenticate 로 들어오면 모두 허용, 그 외에는 인증을 거쳐야 한다는 내용이다. 즉 /authenticate 인 경우에 JWT 를 발급해주는 로직이 들어가야 하고 나머지 모든 로직은 JWT를 통해 인증이 된 경우만 사용할 수 있다고 보면 된다.
그리고 마지막에 UsernamePasswordAuthenticationFilter를 통과하기 전에 jwtFilter 라는것을 지나도록 설정을 했다. Spring Security에는 많은 filter가 있고 이를 순서대로 통과하며 인증에 대한 처리를 한다. 인증을 하는데 있어서 jwtFilter라는 것을 추가해 request로부터 날아온 JWT를 처리하여 이에 대한 결과를 사용자 정보에 추가를 하기 위함이다.
Filter에 대한 자세한 사항은 이 글을 참조하길 바란다.
Filter (JwtFilter.java)
import com.javatechie.jwt.api.service.CustomUserDetailsService;
import com.javatechie.jwt.api.util.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
public class JwtFilter extends OncePerRequestFilter {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private CustomUserDetailsService service;
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
String authorizationHeader = httpServletRequest.getHeader("Authorization");
String token = null;
String userName = null;
if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
token = authorizationHeader.substring(7);
userName = jwtUtil.extractUsername(token);
}
if (userName != null && SecurityContextHolder.getContext().getAuthentication() == null) {
UserDetails userDetails = service.loadUserByUsername(userName);
if (jwtUtil.validateToken(token, userDetails)) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
usernamePasswordAuthenticationToken
.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
filterChain.doFilter(httpServletRequest, httpServletResponse);
}
}
Spring Security Config에서 나온 JwtFilter이다. JWT를 사용할때 가장 핵심적인 기능을 하는 클래스 중 하나이다.
위에서부터 하나씩 해석을 하며 내려가자.
일단 request로부터 "Authorization" 이라는 Header 값을 추출해낸다. JWT를 발급받고 그 값을 Authorization이라는 Header에 넣어서 다시 요청을 보낸 것이다.
authorizationHeader에 값이 있고 이 값이 "Bearer "로 시작한다면 authorizationHeader를 통해 token을 추출해낼수 있다. "Bearer "가 7자이기 때문에 위와 같이 substring을 한 것이고 추출한 token 값을 jwtUtil (아래에 나옴)이라는 JWT를 발급 또는 해석해주는 클래스를 통해 userName을 추출한다.
추출한 userName이 null이 아니라는것은 token의 값이 정상적이라는것을 나타내고, SecurityContextHolder (Spring Security의 필요한 값을 담는 공간)의 authentication이 비어 있다면 이는 최초 인증이라는 뜻이므로 userName을 통해서 Spring Security Authentication에 필요한 정보를 setting 한다.
작업이 끝났으면 다음 필터를 수행하기 위한 Chain을 태운다.
JWT generate or extract or validate module (JwtUtil.java)
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Service;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
@Service
public class JwtUtil {
private String secret = "javatechie";
public String extractUsername(String token) {
return extractClaim(token, Claims::getSubject);
}
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody();
}
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
public String generateToken(String username) {
Map<String, Object> claims = new HashMap<>();
return createToken(claims, username);
}
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 10))
.signWith(SignatureAlgorithm.HS256, secret).compact();
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = extractUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}
JwtFilter에 등장했던 JwtUtil이다. 앞서 잠시 설명을 했지만 이는 token을 생성해주는 역할, token으로부터 정보를 추출하는 역할, token의 유효성을 검사하는 역할을 한다.
token 생성을 하는 createToken 부를 보면 claims라고 하는건 token으로 만들고 싶은 실질적인 값을 넣는 곳이고 expiration은 token의 유효시간 설정을 하는 부분이다.(payload 부분) 뒤에 나오는 sign 관련은 token의 signature를 설정하는 부분이고 이곳에서는 알고리즘과 secret key가 필요하다.(signature 부분)
이렇게 조합해서 마지막에 compact()를 수행하면 String으로 된 JWT가 생성이 된다.
Controller (WelcomeController.java)
import com.javatechie.jwt.api.entity.AuthRequest;
import com.javatechie.jwt.api.util.JwtUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class WelcomeController {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private AuthenticationManager authenticationManager;
@GetMapping("/")
public String welcome() {
return "Welcome to javatechie !!";
}
@PostMapping("/authenticate")
public String generateToken(@RequestBody AuthRequest authRequest) throws Exception {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(authRequest.getUserName(), authRequest.getPassword())
);
} catch (Exception ex) {
throw new Exception("inavalid username/password");
}
return jwtUtil.generateToken(authRequest.getUserName());
}
}
마지막으로 Controller이다. Controller를 진입하기 위해서는 filter를 거쳐야 한다. 몇가지 상황을 가정해서 이 Controller를 해석해보자.
1. "/" 로 접근하는 경우 : 아까 Security Config에서 /authenticate 외에는 모두 인증을 거쳐야 허용이 된다고 했다. 따라서 "/"로 접근하면 인증을 시도한다. 하지만 jwtFilter에서 token에 대한 정보가 없으므로 인증 정보를 생성하지 못한다.
즉 이런 과정을 거치지 못하고 인증에 실패를 하는 것이다.
실행을 해보면 403 Access Denied가 발생하는것을 확인할 수 있다.
2. "/authenticate" 로 접근하는 경우 : Security Config에서 /authenticate는 permitAll을 하라고 했다. 즉 인증정보가 없어도 접근이 가능하게 하라는것이다. Controller에 접근이 가능하게 되며 request로부터 넘어온 인증정보를 확인하고 JWT를 발급해준다.
3. token이 있고 "/" 로 접근하는 경우 : 발급받은 token을 header에 넣고 요청을 날리면 jwtFilter에서 이를 해석하여 인증정보를 생성한다. 그리고 위의 그림의 과정을 거쳐서 인증을 수행하게 된다. 성공적으로 인증을 수행했으므로 "/" 에 대한 접근이 가능하게 된다.
Authorization Header에 "Bearer + 위에서 발급받은 token" 을 넣고 "/" 로 요청을 보내면 위와 같이 인증을 통과하고 원하는 결과를 얻을 수 있다.
이 글을 작성할때는 다음 사이트를 참고하였습니다. (Java-Techie-jt 님의 소스를 그대로 따라하며 시연해봤습니다.)
끝!
'Framework > Security' 카테고리의 다른 글
KeyCloak OAuth2를 활용해서 SSO 로그인 하기 (google) (5) | 2021.01.12 |
---|---|
Spring Security CORS 설정하기 (0) | 2021.01.07 |
KeyCloak REST API 이용해서 JWT 발급 및 사용해보기 (0) | 2020.11.10 |
KeyCloak으로 Springboot App연동해서 간단하게 SSO 구축하기 (7) | 2020.11.09 |
KeyCloak WildFly Management 실행 및 기본사용법 (0) | 2020.11.04 |