1) 스프링부트 2.7.0 사용
2) jwt 라이브러리 사용
<dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.19.2</version> </dependency>
JWT 설정에 전체적인 프로젝트 디렉토리 구조는 다음과 같다.
1. creae database
create user `jwt1`@`localhost` identified by '1234'; create database jwt1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; grant all privileges on jwt1.* to `jwt1`@`localhost` ; use jwt1;
2.라이브러리
<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>org.springframework.boot</groupId> <artifactId>spring-boot-devtools</artifactId> <scope>runtime</scope> <optional>true</optional> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>com.auth0</groupId> <artifactId>java-jwt</artifactId> <version>3.19.2</version> </dependency> </dependencies>
3. User entitiy 및 UserRepository 생성
User
package com.cos.jwt.model; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import lombok.Data; import lombok.ToString; @Data @Entity @ToString public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private long id; @Column(nullable = false, unique = true) private String username; @Column(nullable = false, unique = true) private String email; private String password; private String roles; //USER, ADMIN public List<String> getRoleList(){ if(this.roles.length() >0) { return Arrays.asList(this.roles.split(",")); } return new ArrayList<>(); } }
UserRepository
package com.cos.jwt.repository; import org.springframework.data.jpa.repository.JpaRepository; import com.cos.jwt.model.User; //CRUD 함수를 JpaRepository 가 들고 있음. //@Repostiory 라는 어노테이션이 없어도 loC가 된다. 이유는 JpaRepository 상속했기 때문에.... 가능 public interface UserRepository extends JpaRepository<User, Long>{ //findBy 규칙 -> Username 문법 //select * from user where username =1? public User findByUsername(String username); // select * from user where email = ? //Jpa Query Method public User findByEmail(String email); }
4. RestApiController 생성
import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 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; import com.cos.jwt.auth.PrincipalDetails; import com.cos.jwt.model.User; import com.cos.jwt.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @RestController @RequiredArgsConstructor @Slf4j public class RestApiController { private final UserRepository userRepository; private final BCryptPasswordEncoder bCryptPasswordEncoder; @GetMapping("/home") public String home() { log.info(" home "); return "<h1>home</h1>"; } @PostMapping("/token") public String token() { return "<h1>token</h1>"; } @GetMapping("admin/users") public List<User> users(){ return userRepository.findAll(); } @PostMapping("join") public String join(@RequestBody User user) { log.info("회원 가입 파라미터 : {} " , user.toString()); user.setPassword(bCryptPasswordEncoder.encode(user.getPassword())); user.setRoles("ROLE_USER"); userRepository.save(user); return "회원가입완료"; } //user, manager, admin 권한만 가능 @GetMapping("/api/v1/user") public String user(Authentication authentication){ PrincipalDetails principalDetails=(PrincipalDetails)authentication.getPrincipal(); log.info("/api/v1/user - Authentication " +principalDetails.getUsername()); return "user"; } //manager, admin 권한만 가능 @GetMapping("/api/v1/manager") public String manager(){ return "manager"; } //admin 권한만 가능 @GetMapping("/api/v1/admin") public String admin(){ return "admin"; } }
5. SecurityConfig , CosConfig 생성
SecurityConfig
package com.cos.jwt.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; 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.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.web.filter.CorsFilter; import com.cos.jwt.filter.MyFilter3; import com.cos.jwt.jwt.JwtAuthenticationFilter; import com.cos.jwt.jwt.JwtAuthorizationFilter; import com.cos.jwt.repository.UserRepository; import lombok.RequiredArgsConstructor; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig extends WebSecurityConfigurerAdapter{ private final CorsFilter corsFilter; private final UserRepository userRepository; // 해당 메서드의 리턴되는 오브젝트를 IoC 로 등록해 준다. @Bean public BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Override protected void configure(HttpSecurity http) throws Exception { //The type SecurityContextPersistenceFilter is deprecated //http.addFilterBefore(new MyFilter3("SecurityFilter"), SecurityContextPersistenceFilter.class); //시큐리티 필터에 정의된 필터가 FilterConfig 의 필터보다 가장 먼저 실행 처리 된다. //http.addFilterBefore(new MyFilter3(), BasicAuthenticationFilter.class); http.csrf().disable(); http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)//1.세션을 사용하지 않겠다. .and().addFilter(corsFilter) //2.Cross-Origin 정책 사용 X 모든 요청 허용 - @CrossOrigin과의 차이점 : @CrossOrigin은 인증이 없을 때 문제, 그래서 직접 시큐리티 필터에 등록! .formLogin().disable() //3.폼로그인 비활성화 .httpBasic().disable() //4. basic 비활성화 기본 http 방식 안씀. .addFilter(new JwtAuthenticationFilter(authenticationManager())) .addFilter(new JwtAuthorizationFilter(authenticationManager(), userRepository)) .authorizeHttpRequests() .antMatchers("/api/v1/user/**").hasAnyAuthority("ROLE_USER","ROLE_MANAGER","ROLE_ADMIN") .antMatchers("/api/v1/manager/**").hasAnyAuthority("ROLE_MANAGER","ROLE_ADMIN") .antMatchers("/api/v1/admin/**").hasAnyAuthority("ROLE_ADMIN") .anyRequest().permitAll(); } }
CosConfig
package com.cos.jwt.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; import lombok.extern.slf4j.Slf4j; @Configuration @Slf4j public class CosConfig { //크로스 오리진 정책 설정 /** Cross-Domain 의 의미는, IP 주소가 다르거나 Port 번호가 다른 곳에서 리소스를 가져오는 것을 금지한다는 것이다. */ @Bean public CorsFilter corsFilter(){ log.info("cors Filter"); UrlBasedCorsConfigurationSource source=new UrlBasedCorsConfigurationSource(); CorsConfiguration config=new CorsConfiguration(); config.setAllowCredentials(true); //내서버가 응답을 할 때 json을 자바스크립트에서 처리할 수 있게 할지를 설정하는 것 config.addAllowedOrigin("*");//모든 ip 에 응답을 허용하겠다. config.addAllowedHeader("*"); //모든 header 에 응답을 허용하겠다. config.addAllowedMethod("*");// 모든 post, get,put , delete, patch 허용하겠다. source.registerCorsConfiguration("/api/**", config); // /api/** 로 들어오는 모든 요청들은 config를 따르도록 등록! return new CorsFilter(source); } }
6. PrincipalDetails , PricipalDetailsService
PrincipalDetails
package com.cos.jwt.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; import lombok.extern.slf4j.Slf4j; @Data public class PrincipalDetails implements UserDetails { private static final long serialVersionUID = 1L; private User user; public PrincipalDetails(User user) { this.user = user; } /** * 사용자에게 부여된 권한을 반환합니다. null을 반환할 수 없습니다. */ //해당 User 의 권한을 리턴하는 곳!! @Override public Collection<? extends GrantedAuthority> getAuthorities() { Collection<GrantedAuthority> authorities=new ArrayList<>(); user.getRoleList().forEach(res->{ authorities.add(()->res); }); return authorities; } /** * 사용자를 인증하는 데 사용된 암호를 반환합니다. */ @Override public String getPassword() { return user.getPassword(); } /** * 사용자를 인증하는 데 사용된 사용자 이름을 반환합니다. null을 반환할 수 없습니다. */ @Override public String getUsername() { return user.getUsername(); } /** * 사용자의 계정이 만료되었는지 여부를 나타냅니다. 만료된 계정은 인증할 수 없습니다. */ @Override public boolean isAccountNonExpired() { return true; } /** * 사용자가 잠겨 있는지 또는 잠금 해제되어 있는지 나타냅니다. 잠긴 사용자는 인증할 수 없습니다. */ @Override public boolean isAccountNonLocked() { return true; } /** * 사용자의 자격 증명(암호)이 만료되었는지 여부를 나타냅니다. 만료된 자격 증명은 인증을 방지합니다. */ @Override public boolean isCredentialsNonExpired() { return true; } /** * 사용자가 활성화되었는지 비활성화되었는지 여부를 나타냅니다. 비활성화된 사용자는 인증할 수 없습니다. */ @Override public boolean isEnabled() { //우리 사이트 1년동안 회원이 로그인을 안하면!! 휴먼 계정으로 하기로 함. //현재시간-로긴시간=>1년을 초과하면 return false; return true; } }
PricipalDetailsService
package com.cos.jwt.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; import lombok.extern.slf4j.Slf4j; @Service @RequiredArgsConstructor @Slf4j public class PricipalDetailsService implements UserDetailsService{ private final UserRepository userRepository; @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User userEntity=userRepository.findByUsername(username); if(userEntity==null) { throw new UsernameNotFoundException("아이디 혹은 비밀번호가 일치하지 않습니다."); } log.info("PrincipalDetailService 의 loadUse userEntity : =>" +userEntity); return new PrincipalDetails(userEntity); } }
7. JwtProperties , JwtAuthenticationFilter , JwtAuthorizationFilter 생성
JwtProperties 토큰 설정값
package com.cos.jwt.jwt; public interface JwtProperties { String SECRET = "test1234!@#$"; // 우리 서버만 알고 있는 비밀값 int EXPIRATION_TIME = 10*24*60*60*1000; //10일*24시간*60분*60초 (1/1000초) =864,000,000(10일) String TOKEN_PREFIX = "Bearer "; String HEADER_STRING = "Authorization"; }
JwtAuthenticationFilter 로그인시 토큰 생성
package com.cos.jwt.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.auth.PrincipalDetails; import com.cos.jwt.model.User; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; /**1.세션 방식 * 유저네임, 패스워드 로그인 정상 * 서버쪽 세션 ID 생성 * 클라이언트 쿠키 세션ID를 응답 * * * 요청할 때마다 쿠키값 세션 ID를 항상 들고 서버쪽으로 요청하기 때문에 * 서버는 세션 ID가 유효한지 판단해서 유효하면 인증이 필요한 페이지로 접근하게 하면 되요. * * * *2.JWT 토큰 방식 *유저네밈 , 패스워드 로그인 정상 *JWT 토큰을 생성 *클라이언트 쪽으로 JWT토큰을 응답 * * *요청할 때마다 JWT 토큰을 가지고 요청 *서버는 JWT 토큰이 유효한지를 판단 (필터를 만들어야 함) * * * *3. 처리 과정 * *1) login(/login)요청을 하면 JSON 데이터 username, password 을 ObjectMapper 이용하여 User로 받는다. 2) Authentication authentication=authenticationManager.authenticate(authenticationToken) 처리 실행함으로 서 loadUserByUsername() 함수가 실행된 후 정상이면 authentication 이 리턴됨. PrincipalDetailsService 가 호출 loadUserByUsername() 함수 실행됨. 3)PrincipalDetails를 세션에 담고(권한관리를 위해서) 4)JWT토큰을 만들어서 응답해주면 됨. * * */ //스프링 시큐리티에서 UsernamePasswrodAuthenticationFilter 가 있음. //login 요청해서 username, passwrod 전송하면 (post) //UsernamePasswordAuthenticationFilter 동작을 함. @RequiredArgsConstructor @Slf4j public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter{ private final AuthenticationManager authenticationManager; //login 요청을 하면 로그인 시도를 위해서 실행되는 함수=> /login // Authentication 객체 만들어서 리턴 => 의존 : AuthenticationManager @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { log.info("JwtAuthenticationFilter : 로그인 시도중 "); try { //JSON 형태의 데이터를 받아와서 User 객체에 넣는다. // request에 있는 username과 password를 파싱해서 자바 Object로 받기 ObjectMapper objectMapper=new ObjectMapper(); User user =objectMapper.readValue(request.getInputStream(), User.class); UsernamePasswordAuthenticationToken authenticationToken= new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword()); //PrincipalDetailsService 의 loadUserByUsername() 함수가 실행된 후 정상이면 authentication 이 리턴됨. //DB에 있는 username 과 passwrod 가 일치한다. Authentication authentication=authenticationManager.authenticate(authenticationToken); //authentication 객체가 session 영역에 저장됨 => 로그인이 되었다는 뜻. PrincipalDetails principalDetails=(PrincipalDetails)authentication.getPrincipal(); log.info(principalDetails.getUser().getUsername()); //로그인 정상적으로 되었다는 것. //authentication 객체가 session 영역에 저장을 해야하고 그 방법이 return 해주면 됨. //리턴의 이유는 권한 관리를 security 가 대신 해주기 때문에 편하력하는 것임. //굳이 JWT 토큰을 사용하면서 세션을 만들 이유가 없음. 근데 단지 권한 처리때문에 session 넣어 줌 return authentication; } catch (IOException e) { e.printStackTrace(); } return null; } /** * 인증이 안되면 401 에러 난다. 따라서 해 정상적으로 인증이 되었으면 * successfulAuthentication 메소드를 통과하기 때문에 successfulAuthentication 메소드에서 * JWT 토큰을 발행 시킨다. *{ "timestamp": "2022-06-18T09:22:16.260+00:00", "status": 401, "error": "Unauthorized", "message": "Unauthorized", "path": "/login" } * * * * */ // ★★ successfulAuthentication 메서드는 attemptAuthentication 실행 후 인증이 정상적으로 되었으며 successfulAuthentication 함수가 실행됨. //JWT 토큰을 만들어서 request 요청한 사용자에게 JWT 토큰을 respons 해주면 됨. @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { log.info("successfulAuthentication 실행됨 : 인증이 완료되었다는 뜻임"); PrincipalDetails principalDetails=(PrincipalDetails)authResult.getPrincipal(); //RSA방식은 아니구 Hash 암호방식 String jwtToken=JWT.create() .withSubject(principalDetails.getUsername()) .withExpiresAt(new Date(System.currentTimeMillis()+(JwtProperties.EXPIRATION_TIME))) //토큰 만료시간 .withClaim("id", principalDetails.getUser().getId()) .withClaim("username", principalDetails.getUser().getUsername()) .sign(Algorithm.HMAC512(JwtProperties.SECRET.getBytes())); /** 다음과 같은 토큰을 헤더 Authorization 에 반환 BearereyJ0eXAiOiJKV1QiLCJhbGciOiJIUzUxMiJ9.eyJzdWIiOiJjb3PthqDtgbAiLCJpZCI6MSwiZXhwIjoxNjU1NTYzOTc5LCJ1c2VybmFtZSI6InNzYXJsIn0.9jpWJQF8X21tj_VSNlH_ybtIBUqHxJWoUAuPOG4qFDlEoeWY1SS_UJOp1clz92wNJG51EkcTPK1nVavAd1mGqA */ response.addHeader(JwtProperties.HEADER_STRING, JwtProperties.TOKEN_PREFIX+jwtToken); } }
JwtAuthorizationFilter
package com.cos.jwt.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.auth0.jwt.interfaces.DecodedJWT; import com.cos.jwt.auth.PrincipalDetails; import com.cos.jwt.model.User; import com.cos.jwt.repository.UserRepository; import lombok.extern.slf4j.Slf4j; //시큐리티가 filter 가지고 있는데 그 필터중에서 BasicAuthenticationFilter 라는 것이 있음. //권한이나 인증이 필요한 특정 주소를 요청했을 때 위 필터를 무조건 타게 되어 있음. //만약에 권한이 인증이 필요한 주소가 아니라면 이 필터를 안 탄다. @Slf4j public class JwtAuthorizationFilter extends BasicAuthenticationFilter{ private 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 { log.info("인증이나 권한이 필요한 주소 요청이 됨"); String jwtHeader=request.getHeader(JwtProperties.HEADER_STRING); //header가 있는지 확인 if(jwtHeader==null|| !jwtHeader.startsWith(JwtProperties.TOKEN_PREFIX)){ chain.doFilter(request, response); return; } //헤더 Authorization 의 값 "Bearer " 제거 처리 String jwtToken=request.getHeader(JwtProperties.HEADER_STRING).replace(JwtProperties.TOKEN_PREFIX, ""); // 토큰 검증 (이게 인증이기 때문에 AuthenticationManager도 필요 없음) // 내가 SecurityContext에 집적접근해서 세션을 만들때 자동으로 UserDetailsService에 있는 loadByUsername이 호출됨. String username=JWT.require(Algorithm.HMAC512(JwtProperties.SECRET.getBytes())).build().verify(jwtToken).getClaim("username").asString(); //서명이 정상으로 됨 if(username!=null) { User userEnity=userRepository.findByUsername(username); // 인증은 토큰 검증시 끝. 인증을 하기 위해서가 아닌 스프링 시큐리티가 수행해주는 권한 처리를 위해 // 아래와 같이 토큰을 만들어서 Authentication 객체를 강제로 만들고 그걸 세션에 저장! PrincipalDetails principalDetails=new PrincipalDetails(userEnity); Authentication authentication= new UsernamePasswordAuthenticationToken( principalDetails, //나중에 컨트롤러에서 DI해서 쓸 때 사용하기 편함. null,// 패스워드는 모르니까 null 처리, 어차피 지금 인증하는게 아니니까!! principalDetails.getAuthorities()); //강제로 시큐리티의 세션에 접근하여 Authentication 객체를 저장. SecurityContextHolder.getContext().setAuthentication(authentication); } chain.doFilter(request, response); } }
8. PostMan 테스트
1) 회원가입
2) DB 등록 확인
3) 로그인 (( 헤더에서 토큰값을 반환)
4) Headers 에 Authorization 에 키값에 로그인후 반환된 토큰값을 입력후
http://localhost:8080/api/v1/user, http://localhost:8080/api/v1/manager, http://localhost:8080/api/v1/admin 별로 테스트 확인
소스 :
https://github.com/braverokmc79/jwt1
https://github.com/codingspecialist/Springboot-Security-JWT-Easy
참조 및 강의
https://www.youtube.com/playlist?list=PL93mKxaRDidERCyMaobSLkvSPzYtIk0Ah
댓글 ( 4)
댓글 남기기