토이 프로젝트에서 로그인 후 화면에서 api 호출 시 jwt토큰을 이용하여 인증처리 하는 방식으로 개발 하고자하여 작성한다.
환경설정
- Spring boot 2.7.2
- open JDK 1.8
- InteliJ
- MacOS
Spring Security
Spring Security는 Spring과는 별개로 작동하는 보안담당 프레임워크이다.
크게 두 가지의 동작을 수행한다.
1.Authenticatio(인증): 특정 대상이 "누구" 인지 확인하는 절차이다.
2.Authorization(권한) : 인증된 주체가 특정한 곳에 접근 궈한을 확인하는 것이다.
Spring Security 인증과정
인증하는 과정은 위 그림과 같다.
1. Http Request가 서버로 넘어온다.
2. 가장먼저 AuthenticationFilter가 요청을 낚아챈다.
3. AuthenticationFilter에서 Reqeust의 UserName, Password를 이용하여 UserNamePasswordAuthenticationToken을 생성한다.
4. 토큰을 AuthenticationManager가 받는다.
5. AuthenticationManager는 토큰을 AuthenticationProvider에게 토큰을 넘겨준다.
6. AuthenticationProvider는 UserDetailsService로 토큰의 사용자 아이디(Username)을 전달하여 DB에 존재하는지 확인한다. 이때, UserDetailsService는 DB의 회원정보를 UserDetails라는 객체로 반환한다.
7. AuthenticationProvider는 반환받은 UserDetilas객체와 실제 사용자의 입력정보를 비교한다.
8. 비교가 완료되면 사용자 정보를 가진 Authentication 객체를 SecurityContextHolder에 담은 이후 AuthenticationSuccessHandle를 실행한다.(실패시 AuthenticationFailuereHandler를 실행한다)
Spring Security Filter
스프링 시큐리티는 필터를 기반으로 수행된다.
필터와 인터셉터의 차이는 실행되는 시점의 차이이다
필터 : dispatcher servlet으로 요청이 도착하기 전에 동작한다.
인터셉터 : dispatcher servlet을 지나고 controller에 도착하기 전에 동작한다.
- SecurityContextPersistenceFilter : SecurityContextRepository에서 SecrityContext를 가져오거나 저장하는 역할을 한다.
- LogoutFilter : 설정된 로그아웃 URL로 오는 요청을 감시하며, 해당 유저를 로그아웃 처리
- (UsernamePassword)AuthenticationFilter : (아이디와 비밀번호를 사용하는 form 기반 인증) 설정된 로그인 URL로 오는 요청을 감시하며, 유저 인증 처리
- AuthenticationManager를 통한 인증 실행
- 인증 성공 시, 얻은 Authentication 객체를 SecurityContext에 저장 후 AuthenticationSuccessHandler 실행
- 인증 실패 시, AuthenticationFailureHandler 실행
- DefaultLoginPageGeneratingFilter : 인증을 위한 로그인폼 URL을 감시한다.
- BasicAuthenticationFilter : HTTP 기본 인증 헤더를 감시하며 처리한다.
- RequestCacheAwareFilter : 로그인 성공 후, 원래 요청 정보를 재구성하기 위해 사용된다.
- SecurityContextHolderAwareRequestFilter : HttpServletRequestWrapper를 상속한 SecurityContextHolderAwareRequestWapper 클래스로 HttpServletRequest정보를 감싼다. SecurityContextHolderAwareRequestWrapper 클래스는 필터 체인상의 다음 필터들에게 부가정보를 제공한다.
- AnonymousAuthenticationFilter : 이 필터가 호출되는 시점까지 사용자 정보가 인증되지 않았다면 인증토큰에 사용자가 익명 사용자로 나타난다.
- SessionManagementFilter : 이 필터는 인증된 사용자와 관련된 모든 세션을 추적한다.
- ExceptionTranslationFilter : 이 필터는 보호된 요청을 처리하는 중에 발생할 수 있는 예외를 위임하거나 전달하는 역할을 한다.
- FilterSecurityInterceptor : 이 필터는 AccessDecisionManager 로 권한부여 처리를 위임함으로써 접근 제어 결정을 쉽게해준다
모든 필터를 달달 외울 필요는 없을 것 같고, 대충 필터들이 존재한다는 감만 익히자
필터체인
JWT (Json Web Token)
Cookie & Session & JWT
Cookie, Session, JWT는 모두 비연결성인 네트워크 서버 특징을 연결성으로 사용하기 위한 방법이다.
- Cookie, Session은 서버의 어떠한 저장소에 해당 값과 매칭되는 value를 가지고 있어야한다. 그래서 서버 자원이 많이 사용되는 단점이 있다.
- JWT는 Cookie & Session의 자원문제를 해결하기 위한 방법이다. JWT는 토큰 자체에 유저정보를 담아서 암호화한 토큰이라고 생각하면 된다. 암호화된 내용은 디코딩 과정을 통해서 해석이 가능하다.
본 프로젝트 JWT를 이용한 인증절차
1. 사용자가 로그인을 한다.
2. 서버에서는 계정 정보를 읽어 사용자를 확인 후, 사용자의 고유값을 부여한 후 기타 정보와 함께 payload에 집어넣는다.
3. JWT 토큰의 유효기간을 설정한다.
4. 암호화할 Secret Key를 이용해 Access Token을 발급한다.
5. 사용자는 Access Token을 받아 저장 후, 인증이 필요한 요청마다 토큰을 헤더에 실어보낸다.
6. 서버에서는 해당 토큰의 Verify Signature를 Secret key로 복호화 후, 조작여부, 유효기간을 확인한다.
7. 검증이 완료되었을 경우 Payload를 디코딩 하여 사용자의 ID에 맞는 데이터를 가져온다.
JWT는 보통 Access Token의 유효기간이 짧다. 이유는 보안문제이다 그래서 Refresh Token을 따로 발급해주는데, Access Token이 만료되면 새로운 JWT토큰을 발급할 수 있는 토큰이다.
Spring Security + JWT
Graddle
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
Config
1. 가장 먼저 Security와 Filter관련 설정을 해주어야한다.
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
// authenticationManager를 Bean 등록합니다.
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
//http.httpBasic().disable(); // 일반적인 루트가 아닌 다른 방식으로 요청시 거절, header에 id, pw가 아닌 token(jwt)을 달고 간다. 그래서 basic이 아닌 bearer를 사용한다.
http.httpBasic().disable()
.authorizeRequests()// 요청에 대한 사용권한 체크
.antMatchers("/test").authenticated()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.antMatchers("/**").permitAll()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class); // JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 전에 넣는다
// + 토큰에 저장된 유저정보를 활용하여야 하기 때문에 CustomUserDetailService 클래스를 생성합니다.
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
1. 먼저 AuthenticationManager를 Bean으로 등록해준다.
2. Config 설정
- antMatchers() : 해당 URL로 요청 시 설정을 해준다.
- authenticated() : antMatchers에 속해있는 URL로 요청이 오면 인증이 필요하다고 설정한다.
- hasRole() : antMatchers에 속해있는 URL로 요청이 들어오면 권한을 확인한다.
3. addFilterBefore() : 필터를 등록한다. 스프링 시큐리티 필터링에 등록해주어야 하기 때문에, 여기에 등록해주어야한다. 파라미터는 2가지가 들어간다. 왼쪽은 커스텀한 필터링이 들어간다. 오른쪽에 등록한 필터전에 커스텀필터링이 수행된다.
4. http.sesstionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) : 세션을 사용하지 않는다고 설정한다.
Custom FIlter
//해당 클래스는 JwtTokenProvider가 검증을 끝낸 Jwt로부터 유저 정보를 조회해와서 UserPasswordAuthenticationFilter 로 전달합니다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
// 헤더에서 JWT 를 받아옵니다.
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
// 유효한 토큰인지 확인합니다.
if (token != null && jwtTokenProvider.validateToken(token)) {
// 토큰이 유효하면 토큰으로부터 유저 정보를 받아옵니다.
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// SecurityContext 에 Authentication 객체를 저장합니다.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
Custom Provider
// 토큰을 생성하고 검증하는 클래스입니다.
// 해당 컴포넌트는 필터클래스에서 사전 검증을 거칩니다.
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
private String secretKey = "myprojectsecret";
// 토큰 유효시간 30분
private long tokenValidTime = 30 * 60 * 1000L;
private final UserDetailsService userDetailsService;
// 객체 초기화, secretKey를 Base64로 인코딩한다.
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// JWT 토큰 생성
public String createToken(String userPk, List<String> roles) {
Claims claims = Jwts.claims().setSubject(userPk); // JWT payload 에 저장되는 정보단위, 보통 여기서 user를 식별하는 값을 넣는다.
claims.put("roles", roles); // 정보는 key / value 쌍으로 저장된다.
Date now = new Date();
return Jwts.builder()
.setClaims(claims) // 정보 저장
.setIssuedAt(now) // 토큰 발행 시간 정보
.setExpiration(new Date(now.getTime() + tokenValidTime)) // set Expire Time
.signWith(SignatureAlgorithm.HS256, secretKey) // 사용할 암호화 알고리즘과
// signature 에 들어갈 secret값 세팅
.compact();
}
// JWT 토큰에서 인증 정보 조회
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserPk(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰에서 회원 정보 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// Request의 Header에서 token 값을 가져옵니다. "Authorization" : "TOKEN값'
public String resolveToken(HttpServletRequest request) {
return request.getHeader("Authorization");
}
// 토큰의 유효성 + 만료일자 확인
public boolean validateToken(String jwtToken) {
try {
Jws<Claims> claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}
User, UserDetailService 작성
@Builder
@Data
@Entity
@Table(name = "T_USER")
@NoArgsConstructor
@AllArgsConstructor
public class User implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "USER_SEQUENCE_ID")
private Long userSequenceId;
@Column(name = "USER_EMAIL", nullable = false, length = 100, unique = true)
private String userEmail;
@Column(name = "USER_BIRTH", length = 6)
private String userBirth;
@Column(name = "USER_NICKNAME", length = 15)
private String userNickname;
@Column(name = "ADMIN", length = 10)
@Enumerated(EnumType.STRING)
private enums.Admin admin;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect( Collectors.toList());
}
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return userEmail;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByUserEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
}
}
Test Controller
@RestController
public class TestController {
@PostMapping("/test")
public String test(){
return "<h1>테스트 통과</h1>";
}
}
회원가입, 로그인 Contoller 작성
@Slf4j
@RestController
@RequiredArgsConstructor
public class UserController {
private final JwtTokenProvider jwtTokenProvider;
private final UserRepository userRepository;
final String BIRTH = "001200";
final String EMAIL = "aabbcc@gmail.com";
final String NICKNAME = "침착맨";
final Long SEQUENCEID = Long.valueOf(1);
final enums.Admin ADMIN = enums.Admin.Admin;
User user = User.builder()
.userEmail(EMAIL)
.userBirth(BIRTH)
.userNickname(NICKNAME)
.admin(ADMIN)
.userSequenceId(SEQUENCEID)
.roles(Collections.singletonList("ROLE_USER")) // 최초 가입시 USER 로 설정
.build();
@PostMapping("/join")
public String join(){
log.info("로그인 시도됨");
userRepository.save(user);
return user.toString();
}
// 로그인
@PostMapping("/login")
public String login(@RequestBody Map<String, String> user) {
log.info("user email = {}", user.get("email"));
User member = userRepository.findByUserEmail(user.get("email"))
.orElseThrow(() -> new IllegalArgumentException("가입되지 않은 E-MAIL 입니다."));
return jwtTokenProvider.createToken(member.getUsername(), member.getRoles());
}
}
Test
1. Postman 실행
2. 회원가입 호출
4. 테스트 호출
출처
감사합니다
'BackEnd > Java' 카테고리의 다른 글
[Thymeleaf] layout-dialect 사용하기 (0) | 2022.07.22 |
---|---|
SpringBoot MySQL & JPA 연동 (0) | 2022.03.31 |
[IntelliJ] Lombok 설치 및 Lombok Annotation 정리 (0) | 2022.03.16 |
JAVA 버전이 변경 안될때 체크사항 (0) | 2022.03.16 |
IntelliJ 설치 (0) | 2022.03.15 |