스프링 시큐리티(JWT) 로그인 로직
이제부터 시큐리티를 사용해서 로그인 및 jwt를 사용할 것이다.
- 사용자 정보를 담은 UserDetails 인터페이스 생성
- DB에서 사용자 정보를 직접 가져오는 UserDetailsService 인터페이스 구현
- 사용자 정보를 불러오는 loadUserByUsername() 메소드를 Override
- username 으로 user를 찾아와서 UserDetails 타입으로 반환한다.
- UserDetailsService(UserDetails)
- repository에 findByUsername 메서드 생성
- UsernamePasswordAuthenticationFilter 생성
- Form based Authentication 방식으로 인증을 진행할 때 아이디, 패스워드 데이터를 파싱하여 인증 요청을 위임하는 필터
(로그인 요청을 할때 아이디와 패스워드를 가져온 후 인증을 위한 토큰을 생성한 후 인증을 다른 쪽에 위임하는 역할)
- JWT 로그인을 하기 위해 FormLogin 을 disable해놓았기 때문에 다시 등록을 해주기 위해 설정
- UsernamePasswordAuthenticationToken을 직접 생성하여 직접 로그인 시도
- AuthenticationManager에 위 토큰 을 넣어 Authentication 객체 생성(로그인완료)
- BasicAuthenticationFilter 생성
- 권한이나 인증이 필요한 특정 주소를 요청했을 때 타는 필터
- 권한이나 인증이 필요한 주소에 접근을 했을 때 토큰이 있는지, 유효한 토큰인지 검사 해주기 위해 설정
- 인증이 완료되면 Authentication 객체를 생성해서 SecurityContext에 담아주면 Security Session 이 생성된다.
1. 로그인을 위한 회원가입 로직 추가
간단하게 서비스를 거치지 않고 바로 해주었다.
RestApiController.java
package com.cos.jwt.controller;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.web.bind.annotation.RequestBody;
...
public class RestApiController {
private final BCryptPasswordEncoder bCryptPasswordEncoder;
private final UserRepository userRepository;
@PostMapping("join")
public String join(@RequestBody User user) {
user.setPassword(bCryptPasswordEncoder.encode(user.getPassword()));
user.setRoles("ROLE_USER");
userRepository.save(user);
return "회원가입완료";
}
}
2. 사용자 정의 UserDetails 인터페이스 생성
PrincipalDetails.java
UserDetails를 implement를 해준 후 User를 넣어주고 인터페이스 메서드를 Override해준다.
package com.cos.jwt.config.auth;
import java.util.ArrayList;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import com.cos.jwt.model.User;
import lombok.Data;
@Data
public class PrincipalDetails implements UserDetails {
private User user;
public PrincipalDetails(User user) {
this.user = user;
}
// 계정이 갖고 있는 권한 목록 반환
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
user.getRoleList().forEach(r->{
authorities.add(()-> r);
});
return authorities;
}
// 비밀번호 반환
@Override
public String getPassword() {
return user.getPassword();
}
// 이름(ID) 반환
@Override
public String getUsername() {
return user.getUsername();
}
// 계정이 만료되지 않았는지 (true : 만료안됨)
@Override
public boolean isAccountNonExpired() {
return true;
}
// 계정이 잠겨있지 않았는지 (true : 잠겨있지 않음)
@Override
public boolean isAccountNonLocked() {
return true;
}
// 비밀번호가 만료되지 않았는지 (true : 만료안됨)
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 계정이 활성화 상태인지 (true : 활성화)
@Override
public boolean isEnabled() {
return true;
}
}
3. 사용자 정의 UserDetailsService 인터페이스 생성
loadUserByUsername() 메서드를 Override하여주고 repository에 findByUsername 메서드를 만들어준다.
PrincipalDetailsService.java
package com.cos.jwt.config.auth;
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 com.cos.jwt.model.User;
import com.cos.jwt.repository.UserRepository;
import lombok.RequiredArgsConstructor;
// http://localhost:8080/login 요청이 오면 동작을 안한다.formlogin().disable 해놔서
// filter에 동작하도록 등록을 해놔야 한다. jwtAuthenticationFilter 생성
@Service
@RequiredArgsConstructor
public class PrincipalDetailsService implements UserDetailsService{
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
System.out.println("PrincipalDetaiolsService의 loadUserByUsername()");
User userEntity = userRepository.findByUsername(username);
return new PrincipalDetails(userEntity);
}
}
UserRepository.java
package com.cos.jwt.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import com.cos.jwt.model.User;
public interface UserRepository extends JpaRepository<User, Long>{
public User findByUsername(String username);
}
4. 사용자 정의 UsernamePasswordAuthenticationFilter 설정
위에서 언급했다 싶이 /login 요청이 오면 자동으로 실행이 되는데 formLogin을 disable해놔서 동작을 안한다.
그래서 security config에 다시 등록을 해주어야 한다.
- authenticationManager로 로그인 시도 -> PrincipalDetailsService(UserDetailsService)호출 -> loadByUsername() 실행 -> username 검증 -> PrincipalDetails(UserDetails)를 Security Session 에 담고 JWT 토큰을 만들어서 응답해주기!!!! 으랴?
JwtAuthehnticationFIlter.java
package com.cos.jwt.config.jwt;
import java.io.IOException;
import java.util.Date;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.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.web.authentication.UsernamePasswordAuthenticationFilter;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.cos.jwt.config.auth.PrincipalDetails;
import com.cos.jwt.model.User;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
public class JwtAuthehnticationFIlter extends UsernamePasswordAuthenticationFilter {
// 로그인 요청시 시도하는 함수
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
System.out.println("JwtAuthehnticationFIlter : 로그인 시도중");
try {
ObjectMapper om = new ObjectMapper(); // json데이터를 parsing 해줌.
User user = om.readValue(request.getInputStream(), User.class);
// formLogin하면 자동으로 토큰이 생성 되는데 직접 로그인 시도를 해야해서 직접 토큰을 만들어야함.
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
// 토큰으로 로그인 시도
// username만 받고 password는 spring이 알아서 처리해줌.
// PrincipalDetailsService의 LoadUserByUsername() 함수가 실행된 후 정상이면 authentication이 리턴됨.
Authentication authentication =
authenticationManager.authenticate(authenticationToken);
PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal();
// return 시 authentication 객체가 session영역에 저장 해야하고 그 방법이 return 해주면 됨.
// 리턴의 이유는 권한 관리를 security가 대신 해주기 때문에 편하려고.
// 굳이 JWT토큰을 사용하면서 세션을 만들 이유가 없음. 근데 단지 권한 처리때문에 session넣어 줌.
return authentication;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
// attemptAuthentication 실행 후 인증이 정상적으로 되었으면 successfulAuthentication 함수가 실행됨.
// 여기서 JWT토큰을 만들어서 request 요청한 사용자에게 JWT토큰을 response 해주면 됨.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authResult) throws IOException, ServletException {
System.out.println("successfulAuthentication : 로그인 완료 후 실행됨.");
PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal();
// RSA 방식은 아니구 Hash암호 방식
String jwtToken = JWT.create()
.withSubject("JWT토큰") // 이름
.withExpiresAt(new Date(System.currentTimeMillis() + JwtProperties.EXPIRATION_TIME)) // 만료시간 1000 = 1초,
.withClaim("id", principalDetails.getUser().getId())
.withClaim("username", principalDetails.getUser().getUsername())
.sign(Algorithm.HMAC512(JwtProperties.SECRET));
response.addHeader("Authorization", "Bearer " + jwtToken);
// super.successfulAuthentication(request, response, chain, authResult);
}
}
5. JWT Properties 인터페이스 생성
안전하게 데이터를 다루기 위하여 인테페이스를 생성하여 클래스에서 사용하였다.
package com.cos.jwt.config.jwt;
public interface JwtProperties {
String SECRET = "jwttest";
int EXPIRATION_TIME = 60000*10;
String TOKEN_PREFIX = "Bearer ";
String HEADER_STRING = "AUTHORIZATION";
}
6. SecurityConfig에 JwtAuthehnticationFIlter(UsernamePasswordAuthenticationFilter) 등록
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // seession방식 사용 x , stateless server로 사용.
.and().addFilter(corsFilter) // 인증이 있을 때(로그인 등) 시큐리티 필터에 등록
.formLogin().disable() // jwt server니까 form login 사용 x
.httpBasic().disable() // 기본적인 Http 도 안씀
.addFilter(new JwtAuthehnticationFIlter(authenticationManager()))
.authorizeRequests()
.antMatchers("/api/v1/user/**").access("hasRole('ROLE_USER') or hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
.antMatchers("/api/v1/manager/**").access("hasRole('ROLE_MANAGER') or hasRole('ROLE_ADMIN')")
.antMatchers("/api/v1/admin/**").access("hasRole('ROLE_ADMIN')").anyRequest().permitAll();
}
}
7. 로그인 시도
Postman으로 로그인 요청하니 토큰이 잘 들어왔다.
로그도 챱챱챱 나왔다.
8. 사용자 정의 BasicAuthenticationFilter 생성
권한이나 인증이 필요한 주소로 요청이 들어왔을 때 토큰이 있는지, 토큰이 유효한지 검사하기 위해 생성한다.
JwtAuthorizationFilter.java
package com.cos.jwt.config.jwt;
import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.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.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.cos.jwt.config.auth.PrincipalDetails;
import com.cos.jwt.model.User;
import com.cos.jwt.repository.UserRepository;
// 시큐리티가 filter를 가지고 있는데 그 필터 중에 BasicAuthenticationFilter가 있음.
// 권한이나 인증이 필요한 특정 주소를 요청했을 때 위 필터를 무조건 타게 되어있음.
public class JwtAuthorizationFilter extends BasicAuthenticationFilter{
private final UserRepository userRepository;
public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository) {
super(authenticationManager);
this.userRepository = userRepository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {
System.out.println("인증이나 권한이 필요한 주소 요청이 됨.");
String jwtHeader = request.getHeader(JwtProperties.HEADER_STRING);
System.out.println("jwtHeader : " + jwtHeader);
// header가 있는지 확인
if(jwtHeader == null || !jwtHeader.startsWith("Bearer")) {
chain.doFilter(request, response);
return;
}
// JWT Token을 검증을 해서 정상적인 사용자인지 확인
String jwtToken = request.getHeader(JwtProperties.HEADER_STRING).replace(JwtProperties.TOKEN_PREFIX, "");
String username =
JWT.require(Algorithm.HMAC512(JwtProperties.SECRET)).build().verify(jwtToken).getClaim("username").asString();
// 서명이 정상적으로 되었다.
if(username != null) {
User userEntity = userRepository.findByUsername(username);
PrincipalDetails principalDetails = new PrincipalDetails(userEntity);
// jwt 토큰 서명을 통해서 서명이 정상이면 authentication 객체를 (강제로) 만들어 준다.
Authentication authentication =
new UsernamePasswordAuthenticationToken(principalDetails, null, principalDetails.getAuthorities()); // password = null
// 강제로 시큐리티 세션에 접근하여 authentication 객체를 저장. -> 로그인 완료
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}
}
}
9. Postman
로그인 후 발급받은 토큰을 헤더에 입력하여 localhost:8080/api/v1/user에 get요청을 보냈더니 정상적으로 접근이 되었다.
최초 로그인 한 사용자는 user의 권한을 가지고 있다. ( join시 설정 함 )
그래서 admin이나 manager 페이지에 접근을 하려고 하면 403 에러가 뜬다.
10. 마치며..
이렇게 JWT를 실험적으로 사용해 보았다.
어렵다 그치만 이해 못할 정도는 아니다.
실전에 적용해 보도록 노력해봐야겠다. 화이팅
reference
Spring Security - UsernamePasswordAuthenticationFilter 란
spring jwt 적용하기 - 4 (spring security 적용)
Spring Security - 인증 절차 인터페이스 구현 (1) UserDetailsService, USerDetails
'공부 > Spring Security' 카테고리의 다른 글
[Security] JWT를 위한 Security 설정 (0) | 2022.06.28 |
---|---|
[Security] 전체적으로 (0) | 2022.04.22 |
[Security] 환경설정 (0) | 2022.03.09 |
댓글