프로젝트에서 사용한 Spring Secutiry이용한 JWT 로그인 API를 정리하고자 글을 씁니다
목차
- Spring Secutiry란 무엇인가
- JWT란 무엇이고 RefreshToken 저장 방법
- Spring Secutiry + JWT 구현하기
Spring Secutiry
가장 중요한 흐름도입니다 이것만 이해하면 구현은 쉽게 할 수 있습니다 듀얼 모니터라면 다른 모니터에 띄우고 계속 보시면서 구현하면 구조를 파악하기 수월하실 겁니다
하나 하나 알아봅시다
- 사용자가 어떤 정보를 서버에 요청합니다 여기서 정보는 어떤 정보든 상관없습니다 새로고침, Url검색등등을 통해 요청(Request)이 발생합니다
- 그러면 AuthenticationFilter가 그 요청을 가로챕니다 ex) PostMapping -> member/login이란 Url을 써도 그 Url로 가기 전에 Filter가 요청을 가로채죠 Post보다 Fliter를 먼저 탄다 생각하시면 되겠습니다
그리고 그 가로챈 정보를 바탕으로 UsernamePasswordAuthenticationToken이라는 인증용 객체(Authentication 객체)를 생성합니다 여기서 처음 생성되는 인증용 객체는 처음에는 인증되지 않은 상태로 생성되고 추후 인증이 모두 완료되면 생성자(Username, password등이 들어있습니다) 로 객체를 다시 생성 합니다 - AuthenticationManger의 구현체인 ProviderManger에게 UsernamePasswordAuthenticationToken 객체를 전달합니다
- 다시 AuthenticationProvider에 UsernamePasswordAuthenticationToken 객체를 전달합니다
- AuthenticationPrivider는 UserDetailsService에게 사용자 정보(아이디)를 넘겨줍니다
- 넘어온 정보를 바탕으로 DB에서 값을 찾아 UserDetails 객체를 생성후 반환합니다
- AuthenticationProvider은 UserDetails객체와 실제 받은 Id를 비교합니다
- 만약 같다면 Authentication객체가 인증되었다고 설정하고 SecurityContextHolder에 저장합니다
JWT를 어떻게 구현할까
구현하기에 앞서 JWT란 무엇이고 후에는 AccessToken과 RefreshToken을 활용한 방법들을 알고 오시면 좋습니다
Jwt에 대한 이해는 필수입니다
Jwt란 무엇인가
Spring Secutiry와 JWT에 대해 조금이나 이해했다면 이제 구현을 하면서 이해할 수 있습니다
gradle은 아래와 같이 준비합니다
//Security
implementation 'org.springframework.boot:spring-boot-starter-security'
//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'
implementation 'com.auth0:java-jwt:4.2.1'
//json
implementation 'org.json:json:20220924'
implementation 'com.googlecode.json-simple:json-simple:1.1.1'
SecurityConfigure
먼저 Security와 Filter를 설정 해줍니다
Spring Boot가 옛 버전이면 WebSecurityConfigurerAdapter를 상속받아야 하지만 Spring Boot 버전이 올라가면서
방법이 바뀌었습니다 안 된다면 버전을 올려 주세요
@Configuration
//@EnableWebSecurity //CSRF 보호 기능 활성화
public class SecurityConfigure {
@Bean
PasswordEncoder passwordEncoder() { //PasswordEncoder는 비밀번호를 암호화하는 역할을 맞는다
return new BCryptPasswordEncoder();
}
JwtAuthenticationFilter jwtAuthenticationFilter(JwtProvider jwtProvider, CookieUtil cookieUtil) {
return new JwtAuthenticationFilter(jwtProvider, cookieUtil);
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() { //해당 URL은 필터 거치지 않겠다
return (web -> web.ignoring().antMatchers("/api/member/join"));
//return (web -> web.ignoring().antMatchers("/test"));
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http,
JwtProvider jwtProvider,
CookieUtil cookieUtil) throws Exception {
return http
.httpBasic().disable()
.csrf().disable() //CSRF 비활성화
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
//세션을 사용하지 않음 선언
.and()
.authorizeRequests()
//Security 처리에 HttpServletRequest를 이용
.antMatchers("/test").permitAll()
//해당 URL 권한이 어떻든 승인
.antMatchers("/api/member/login").hasAuthority("[USER]")
//해당 URL 권한이 [USER]이면 승인
.and()
.addFilterBefore(jwtAuthenticationFilter(jwtProvider, cookieUtil),
UsernamePasswordAuthenticationFilter.class)
//UsernamePasswordAuthenticationFilter전에 jwtAuthenticationFilter를 쓰겠다
.build();
}
}
위처럼 구성하면 되는데 Filter를 거치고 싶지 않을때는 WebSecurityCustomizer를 쓰면 됩니다
CSRF는 해킹 기법중 하나인데 궁금하신다면 링크 보시고 오시면 될 거 같습니다
Token을 이용하기 때문에 Sesion은 사용하지 않고 URL별 필요한 권한을 설정해주었습니다 CustomFilter쓰겠다라고 명시도 해주었구요 이제 설정은 이대로만 하면 끝입니다
Custom Filter
@RequiredArgsConstructor
//처음으로 지나가는 Filter
public class JwtAuthenticationFilter extends OncePerRequestFilter {
//사용자의 한번에 요청 당 딱 한번만 실행되는 필터
//필 터를 두번 타지 않게 설정했습니다 인증 중복을 막기
private final JwtProvider jwtProvider;
private final CookieUtil cookieUtil;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain filterChain) throws ServletException, IOException {
String accessToken = null;
String refreshToken = null;
Authentication authenticate;
//사용자의 principal과 credential 정보를 저장할 객체
accessToken = req.getHeader("accessToken");
//Header에 이름이acessToken인 값을 accessToken 변수에 저장
if(accessToken==null || jwtProvider.isTokenExpired(accessToken)) { //유효기간 만료 또는 토큰 없을시
Cookie RefreshTokenCookie = cookieUtil.getCookie(req, "RefreshToken");
refreshToken = RefreshTokenCookie.getValue(); //RefreshToken 쿠키에서 가져오기
if(refreshToken!=null && !jwtProvider.RefreshisTokenExpired(refreshToken)) {//refreshToken 존재, 유효할시
String memberId = jwtProvider.RefreshgetMemberIdFromToken(refreshToken);
// RefreshToken에 저장된 Token을 Decode해서 Id값을 가져온다
try {
authenticate = jwtProvider
.authenticate(new UsernamePasswordAuthenticationToken(memberId, ""));
//UsernamePasswordAuthenticationToken은 추후 인증이 끝나고
//SecurityContextHolder.getContext()에 등록될 Authentication객체
//MyUserDetailsService의 loadUserByUsername()로 이동
// Details이동후 Authenticate객체 생성! (id, pw, 권한)
SecurityContextHolder.getContext().setAuthentication(authenticate);
//ID, PW가 존재하는 계정이면 Holder에 객체 저장
HashMap<String, String> m = new HashMap<>();
m.put("memberId", memberId); //RefreshToken에 저장된 Id를 가져온다
accessToken = jwtProvider.generateToken(m); //accessToken 재발급
res.addHeader("accessToken", accessToken); //Header에 accessToken 넣기
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
}
}
else if(refreshToken==null || jwtProvider.RefreshisTokenExpired(refreshToken)) { //refreshtoken존재 x 또는 유효하지 않을 때
//오류 던지기
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
res.setContentType("application/json");
res.setCharacterEncoding("UTF-8");
JSONObject resJson = new JSONObject();
try {
resJson.put("code", 401);
} catch (JSONException ex) {
throw new RuntimeException(ex);
}
try {
resJson.put("message", "쿠키만료");
} catch (JSONException ex) {
throw new RuntimeException(ex);
}
res.getWriter().write(resJson.toString());
}
}
else if(accessToken!=null && !jwtProvider.isTokenExpired(accessToken)){ //accessToken 존재, 유효할 때
String memberId = jwtProvider.getMemberIdFromToken(accessToken);
//memberId accessToken에서 가져오기
try {
authenticate = jwtProvider
.authenticate(new UsernamePasswordAuthenticationToken(memberId, ""));
//UsernamePasswordAuthenticationToken은 추후 인증이 끝나고
//SecurityContextHolder.getContext()에 등록될 Authentication객체
//MyUserDetailsService의 loadUserByUsername()로 이동
// Details이동후 Authenticate객체 생성! (id, pw, 권한)
SecurityContextHolder.getContext().setAuthentication(authenticate);
//ID, PW가 존재하는 계정이면 Holder에 객체 저장
} catch (Exception e) {
throw new RuntimeException(e.getMessage());
}
}
filterChain.doFilter(req, res);
//다음 필터 호출 없다면 서블릿의 service()호출
}
}
위 방식에서는 refreshToken을 Cookie에 저장하고 accessToken은 Header에 담아 전달했습니다 주석을 보시면 이해가 다 가실 거라고 생각합니다
구글링과 생각해본 결과 이렇게 하는 게 좋을 거 같아서 이렇게 저장했습니다 꼭 이대로 하실필요는 없습니다!
Custom Provider
@Component
@RequiredArgsConstructor
public class JwtProvider implements AuthenticationProvider {
private final MyUserDetailsService userDetailsService;
public static final long TOKEN_VALIDATION_SECOND = 120L;//2분으로 설정
public static final long REFRESH_TOKEN_VALIDATION_TIME = 1000L * 60 * 60 * 48;
@Value("${spring:jwt:secret}") //서명에 필요한 Secret값을 application.yml에서 받습니다
private String SECRET_KEY;
@Value("${group:name}")
private String ISSUER;
private Algorithm getSigningKey(String secretKey) {
return Algorithm.HMAC256(secretKey);
}
private Map<String, Claim> getAllClaims(DecodedJWT token) {
return token.getClaims();
}
public String getMemberIdFromToken(String token) {
DecodedJWT verifiedToken = validateToken(token);
return verifiedToken.getClaim("memberId").asString();
} //Token을 Decode해서 memberId정보를 빼옵니다
public String RefreshgetMemberIdFromToken(String token) {
DecodedJWT verifiedToken = RefreshvalidateToken(token);
return verifiedToken.getClaim("memberId").asString();
}
private JWTVerifier getTokenValidator() {
return JWT.require(getSigningKey(SECRET_KEY))
.withIssuer(ISSUER)
.acceptExpiresAt(TOKEN_VALIDATION_SECOND)
.build();
} //Token유효한지 확인
private JWTVerifier RefreshgetTokenValidator() {
return JWT.require(getSigningKey(SECRET_KEY))
.withIssuer(ISSUER)
.acceptExpiresAt(REFRESH_TOKEN_VALIDATION_TIME)
.build();
}
public String generateToken(Map<String, String> payload) {
return doGenerateToken(TOKEN_VALIDATION_SECOND, payload);
} //accessToken의 정보를 위 doGenerateToken()메소드로 전달
public String generateRefreshToken(Map<String, String> payload) {
return doGenerateToken(REFRESH_TOKEN_VALIDATION_TIME, payload);
} //refreshToken의 정보를 위 doGenerateToken()메소드로 전달
private String doGenerateToken(long expireTime, Map<String, String> payload) {
return JWT.create()
.withIssuedAt(new Date(System.currentTimeMillis()))
.withExpiresAt(new Date(System.currentTimeMillis() + expireTime))
.withPayload(payload)
.withIssuer(ISSUER)
.sign(getSigningKey(SECRET_KEY));
} //Token을 생성합니다 유효기간은 얼마나 Payload정보에는 무엇을 서명에는 Secret_key를 담아서요
private DecodedJWT validateToken(String token) throws JWTVerificationException {
JWTVerifier validator = getTokenValidator();
return validator.verify(token);
} //Token이 유효한지 확인
private DecodedJWT RefreshvalidateToken(String token) throws JWTVerificationException {
JWTVerifier validator = RefreshgetTokenValidator();
return validator.verify(token);
}
public boolean isTokenExpired(String token) {
try {
DecodedJWT decodedJWT = validateToken(token);
return false;
} catch (JWTVerificationException e) {
return true;
}
} //Token 유효한지 확인
public boolean RefreshisTokenExpired(String token) {
try {
DecodedJWT decodedJWT = RefreshvalidateToken(token);
return false;
} catch (JWTVerificationException e) {
return true;
}
}
//UsernamePassword찾는 곳
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
//Authentication 객체를 받아와서 안에 정보를 userDetails에 담아서 존재하는지 확인합니다
//존재한다면 id, pw, role로 구성된 새로운 UsernamePasswordAuthenticationToken 즉 Authentication 객체를 만듭니다
Details userDetails = (Details) userDetailsService.loadUserByUsername
((String) authentication.getPrincipal());
return new UsernamePasswordAuthenticationToken(
userDetails.getUsername(),
userDetails.getPassword(),
userDetails.getAuthorities()); //권한
}
@Override
public boolean supports(Class<?> authentication) {
return false;
}
}
주석에서 보이듯이 Token의 유효기간 확인, 생성, 어떤 정보들을 담을 건지에 대한 메소드들을 생성하고
검증을 거친 뒤 UsernamePasswordAuthenticationToken을 생성합니다
그러면 중요한 UsernamePasswordAuthenticationToken을 생성하는 과정을 살펴보겠습니다
@Service
public class MyUserDetailsService implements UserDetailsService {
@Autowired
private MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String Id) throws UsernameNotFoundException {
Long member_id = Long.parseLong(Id);
Member member = memberRepository.findById(member_id).orElseGet(Member::new);
if(member==null){
throw new UsernameNotFoundException(member_id + " : 사용자 존재하지 않음");
}
return new Details(member);
}
}
제가 Custom한 DetailsService입니다 Id가 존재하는 회원인지 찾고 있습니다 없다면 Exception을 던지고 있다면 Details객체를 반환합니다
public class Details extends User { //Authentication 객체 생성 super()을 이용하여!
public Details(Member member) {
super(member.getId(), member.getPw(),
AuthorityUtils.createAuthorityList(String.valueOf(member.getRoles())));
}
}
Id가 존재하는 회원이라면 Id, pw, roles를 안에 담아서 객체를 반환해 주기로 했습니다
authenticate = jwtProvider
.authenticate(new UsernamePasswordAuthenticationToken(memberId, ""));
//UsernamePasswordAuthenticationToken은 추후 인증이 끝나고
//SecurityContextHolder.getContext()에 등록될 Authentication객체
//MyUserDetailsService의 loadUserByUsername()로 이동
// Details이동후 Authenticate객체 생성! (id, pw, 권한)
SecurityContextHolder.getContext().setAuthentication(authenticate);
//ID, PW가 존재하는 계정이면 Holder에 객체 저장
그러면 인증이 되었으니 이렇게 SecurityContextHolder안에 Context안에 Authentication안에 Authentication객체를 저장해줍니다
쉽죠?..
저도 처음에는 무작정 돌아가게만 하다가 조금씩 눈에 익으면서 이해가 가기 시작한 거 같습니다
Cookie와 다른 class들은 너무 많아서 github 주소로 대체하겠습니다 ㅠㅠ
https://github.com/sleeg00/Tomorrow/tree/feat/Jwt
혹시 궁금한 점이 있으시거나 제가 이해를 잘 못하고 있는게 있다면 댓글 부탁드립니다!!
'스프링' 카테고리의 다른 글
[Spring] TDD vs BDD 무엇인지알고 비교하기 (0) | 2023.03.12 |
---|---|
[Spring] Slice를 이용하여 무한스크롤 구현하기 (0) | 2023.02.12 |
[Spring] JWT Refresh Token 어디에 저장해야 할까? 그리고 꼭 저장해야 할까? (0) | 2023.01.25 |
[Spring] 자바의 대표적인 빌드 관리 도구 Maven vs Gradle 차이 (0) | 2023.01.04 |
[Spring] Spring이란 무엇인가? (0) | 2023.01.03 |