-
JWT 토큰을 이용해서 로그인 인증 구현하기Spring 2021. 9. 5. 18:27
회원의 로그인정보를 통한 authentication을 구현해보자.
Authentication
로그인 인증 구현 방식에는 session cookie 방식과 jwt 토큰 방식이 존재한다. session cookie 방식은 사용자에 따라서 고유의 session ID가 발급되며 유의미한 정보를 담고 있지 않기 때문에 쿠키방식보다는 안전하지만, 세션저장소에 모두 담아두기 때문에 다수의 요청이 발생시 부하가 발생할 수 있다.
JWT
세션쿠키와 하게 인증에 필요한 정보들을 암호화한 토큰을 사용하는 방식으로, 비밀키가 유출되지 않는 이상 토큰을 복호화 하지 못하므로 보안이 유리하다. access token의 기한은 유효하지만, access token이 만료되기전에 사용자가 로그인하면 refresh token을 이용해서 access token을 재발급하여 사용기한을 늘릴 수 있다. refresh token이 만료되면 재로그인 요청을 보낸다. 그러나, 이 또한 세션쿠키보다 길이가 길기 때문에 (.을 기준으로 세가지 부위로 나뉨) 인증요청이 많아질 수록 네트워크 부하가 심해질 수 있다.
Jwt는 토큰을 생성하는 부분과 , 들어온 토큰의 유효성을 검사하는 부분으로 나뉜다.
Generate Token
토큰을 생성할 때는 요청에 토큰이 있는지 확인하고 없다면, 유저의 로그인 정보(아이디, 패스워드)를 받아서 이를 이용해서 토큰을 생성해놓고 유효한 정보인지를 확인한 후에 유효한 로그인 정보일 경우에 생성한 토큰을 돌려준다.
Validate Token
유효성 검사는 토큰을 헤더로 받아서 토큰을 해부해서 유효한 로그인 정보인지를 검사한 후에, 토큰이 유효하다면 요청을 처리한다.
다음과 같이 pom.xml에 dependency를 추가해준다.
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> <version>2.3.9.RELEASE</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency>
토큰을 생성하기 위해서는 secret키를 사용해야한다. secret key는 토큰을 생성하고 유효성검사하는데에 아주 중요한 역할을 하게 된다.
application.properties에 따로 추가해준다.
jwt.secret=사용할 secret key 입력
JWT token util
이제 secret 키를 이용해서 토큰 생성, 유효성 검사, 유효기간 검사하는 코드를 작성해준다.
import java.io.Serializable; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.function.Function; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; @Component public class JwtTokenUtil implements Serializable { private static final long serialVersionUID = -2550185165626007488L; public static final long JWT_TOKEN_VALIDITY = 5*24 * 60 * 60; //토큰의 기한 (5일로 설정) @Value("${jwt.secret}") private String secret; //retrieve username from jwt token(토큰으로부터 사용자 이름 가져오기) public String getUsernameFromToken(String token) { return getClaimFromToken(token, Claims::getSubject); } //retrieve expiration date from jwt token(토큰으로부터 유효기간 가져오기) public Date getExpirationDateFromToken(String token) { return getClaimFromToken(token, Claims::getExpiration); } public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) { final Claims claims = getAllClaimsFromToken(token); return claimsResolver.apply(claims); } //for retrieveing any information from token we will need the secret key(토큰에 저장된 모든정보 가져오기) private Claims getAllClaimsFromToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } //check if the token has expired (토큰의 유효기간 검사) private Boolean isTokenExpired(String token) { final Date expiration = getExpirationDateFromToken(token); return expiration.before(new Date()); } //generate token for user (토큰 생성) public String generateToken(String username) { Map<String, Object> claims = new HashMap<>(); return doGenerateToken(claims, username); } //while creating the token - (토큰에 정보를 넣고, 시크릿 키를 이용해서 토큰을 compact하게 만든다) //1. Define claims of the token, like Issuer, Expiration, Subject, and the ID //2. Sign the JWT using the HS512 algorithm and secret key. //3. According to JWS Compact Serialization(https://tools.ietf.org/html/draft-ietf-jose-json-web-signature-41#section-3.1) // compaction of the JWT to a URL-safe string private String doGenerateToken(Map<String, Object> claims, String subject) { return Jwts.builder().setClaims(claims).setSubject(subject).setIssuedAt(new Date(System.currentTimeMillis())) .setExpiration(new Date(System.currentTimeMillis() + JWT_TOKEN_VALIDITY * 1000)) .signWith(SignatureAlgorithm.HS512, secret).compact(); } //validate token (토큰의 유효여부를 검사한다) public Boolean validateToken(String token, UserDetails userDetails) { final String username = getUsernameFromToken(token); return (username.equals(userDetails.getUsername()) && !isTokenExpired(token)); } }
JWT user details
여기서 사용자의 이름과 패스워드를 정해준다. 사용자의 username이 아닌 email을 이용해서 유효검사를 진행하기 때문에 변견사항을 적용시켜주고, 사용자의 role에 따라서 권한여부가 달라지므로 authority도 부여해준다.
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import java.util.ArrayList; import java.util.Collection; import java.util.List; public class JwtUserDetails implements UserDetails { private User user; public JwtUserDetails(User user) { super(); this.user = user; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { Role role = user.getRole(); List<SimpleGrantedAuthority> authorities = new ArrayList<SimpleGrantedAuthority>(); authorities.add(new SimpleGrantedAuthority(role.getName())); return authorities; } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getEmail(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
JWT user details service
작성한 user details를 이용해서 다음과 같이 적용시켜준다. 이메일을 이용해서 유저를 찾을 수 없다면 usernameNotFoundException을 던져준다.
import com.yunhalee.withEmployee.Repository.UserRepository; import com.yunhalee.withEmployee.entity.User; 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 org.springframework.transaction.annotation.Transactional; @Service public class JwtUserDetailsService implements UserDetailsService { @Autowired private UserRepository userRepo; @Override @Transactional public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User entityUser = userRepo.findByEmail(username); if(entityUser != null) return new JwtUserDetails(entityUser); throw new UsernameNotFoundException("Could not find user with email : " + username); } }
JWT authentication controller
이제 "/authenticate"를 통해서 들어온 정보를 이용해서 토큰검사를 진행하는 컨트롤러를 작성해준다.
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.CrossOrigin; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController @CrossOrigin public class JwtAuthenticaationController { @Autowired private AuthenticationManager authenticationManager; @Autowired private JwtTokenUtil jwtTokenUtil; @Autowired private JwtUserDetailsService userDetailsService; @PostMapping("/authenticate") public String createAuthenticationToken(@RequestBody JwtRequest authenticationRequest) throws Exception{ authenticate(authenticationRequest.getUsername(), authenticationRequest.getPassword()); final UserDetails userDetails = userDetailsService.loadUserByUsername(authenticationRequest.getUsername()); // final String token = jwtTokenUtil.generateToken(userDetails); return jwtTokenUtil.generateToken(authenticationRequest.getUsername()); } private void authenticate(String username, String password) throws Exception { try { Authentication authentication=authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password)); SecurityContextHolder.getContext().setAuthentication(authentication); System.out.println(authentication.getAuthorities().toString()); } catch (DisabledException e) { throw new Exception("USER_DISABLED", e); } catch (BadCredentialsException e) { throw new Exception("INVALID_CREDENTIALS", e); } } }
JWT request
우리가 입력받은 username, password정보를 저장해준다.
import java.io.Serializable; public class JwtRequest implements Serializable { private static final long serialVersionUID = 5926468583005150707L; private String username; private String password; //need default constructor for JSON Parsing public JwtRequest() { } public JwtRequest(String username, String password) { this.setUsername(username); this.setPassword(password); } public String getUsername() { return this.username; } public void setUsername(String username) { this.username = username; } public String getPassword() { return this.password; } public void setPassword(String password) { this.password = password; } }
JWT response
생성된 토큰을 돌려주는 코드를 작성해준다.
import java.io.Serializable; public class JwtResponse implements Serializable { private static final long serialVersionUID = -8091879091924046844L; private final String jwttoken; public JwtResponse(String jwttoken) { this.jwttoken = jwttoken; } public String getToken() { return this.jwttoken; } }
JWT request filter
들어온 요청의 토큰이 validate한지를 검사한다.
import io.jsonwebtoken.ExpiredJwtException; 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 JwtRequestFilter extends OncePerRequestFilter { @Autowired private JwtUserDetailsService jwtUserDetailsService; @Autowired private JwtTokenUtil jwtTokenUtil; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { final String requestTokenHeader = request.getHeader("Authorization"); String username = null; String jwtToken = null; // JWT Token is in the form "Bearer token". Remove Bearer word and get // only the Token if (requestTokenHeader != null && requestTokenHeader.startsWith("Bearer ")) { jwtToken = requestTokenHeader.substring(7); username = jwtTokenUtil.getUsernameFromToken(jwtToken); try { username = jwtTokenUtil.getUsernameFromToken(jwtToken); } catch (IllegalArgumentException e) { System.out.println("Unable to get JWT Token"); } catch (ExpiredJwtException e) { System.out.println("JWT Token has expired"); } } else { logger.warn("JWT Token does not begin with Bearer String"); } // Once we get the token validate it. if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { UserDetails userDetails = jwtUserDetailsService.loadUserByUsername(username); // if token is valid configure Spring Security to manually set // authentication if (jwtTokenUtil.validateToken(jwtToken, userDetails)) { UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities()); usernamePasswordAuthenticationToken .setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); // After setting the Authentication in the context, we specify // that the current user is authenticated. So it passes the // Spring Security Configurations successfully. SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); } } chain.doFilter(request, response); } }
JWT authentication entry point
인증되지 않은 토큰을 가진 요청은 401 에러를 발생시킬 것이다.
import java.io.IOException; import java.io.Serializable; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; @Component public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint, Serializable { private static final long serialVersionUID = -7858869558953243875L; @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage()); } }
Web Security Config
security config는 다음과 같이 작성한 코드들을 사용해서 구현해주도록 한다. (/authenticate는 따로 구현)
cors에러를 막기위해서 (403 error in post request) csrf().disabled()를 해줬다.
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.authentication.dao.DaoAuthenticationProvider; 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.builders.WebSecurity; 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.crypto.password.PasswordEncoder; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity //@EnableGlobalMethodSecurity(prePostEnabled = true) public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; @Autowired private JwtRequestFilter jwtRequestFilter; @Autowired private JwtUserDetailsService jwtUserDetailsService; @Bean public PasswordEncoder passwordEncoder(){ return new BCryptPasswordEncoder(); } @Bean public DaoAuthenticationProvider authenticationProvider(){ DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); authProvider.setUserDetailsService(jwtUserDetailsService); authProvider.setPasswordEncoder(passwordEncoder()); return authProvider; } @Override protected void configure(AuthenticationManagerBuilder auth) throws Exception { auth.authenticationProvider(authenticationProvider()); } @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception{ return super.authenticationManagerBean(); } @Override protected void configure(HttpSecurity http) throws Exception { //form형식의 데이터에서 cors오류 방지를 위해서 csrf disable시켜주었다. http.cors() .and() .csrf() .disable() .authorizeRequests() .antMatchers("/user/{id}", "/user/save").hasAnyAuthority("Member", "Leader", "Admin","CEO") .anyRequest() .authenticated() .and() .exceptionHandling().authenticationEntryPoint(jwtAuthenticationEntryPoint) .and() .sessionManagement() .sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .formLogin() .disable(); http.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class); } @Override public void configure(WebSecurity web) throws Exception { //다음의 데이터는 seurity configur제한을 받지 않는다. (정적데이터는 접근가능하도록 설정) web.ignoring().antMatchers("/profileUploads/**","/messageUploads/**", "/js/**","/webjars/**"); } }
회원가입 구현
이제 토큰방식을 이용해서 회원가입을 진행하도록 하자. user controller에서는 다음과 같이 회원가입 정보를 받아서 서비스로 넘겨준다.
register하기전에 check_email을 통해서 unique 한지 검사도 진행해준다.
@PostMapping("/user/register") public ResponseEntity<UserDTO> register(@RequestParam("name")String name, @RequestParam("email")String email, @RequestParam("password")String password, @RequestParam("description")String description, @RequestParam("phoneNumber")String phoneNumber, @RequestParam("role")String role) throws IOException { UserDTO userDTO = new UserDTO(name, email, password, description, phoneNumber, role); savedUserDTO.setPassword(""); return new ResponseEntity<UserDTO>(savedUserDTO, HttpStatus.OK); } @PostMapping("/user/check_email") public String checkDuplicateEmail(@Param("id") Integer id, @Param("email") String email){ return service.isEmailUnique(id, email)? "OK" : "Duplicated"; }
user service에서는 bcrypt 패스워드 인코더를 사용해서 인코딩된 패스워드를 db에 저장해주도록 한다. (security config에서 password 인코더를 bcryptPasswordEncoder로 정의해주었다)
@Autowired private PasswordEncoder passwordEncoder; public UserDTO save(UserDTO userDTO, MultipartFile multipartFile) throws IOException { User user = new User(); userDTO.setId(user.getId()); user.setName(userDTO.getName()); user.setEmail(userDTO.getEmail()); user.setPassword(passwordEncoder.encode(userDTO.getPassword())); user.setDescription(userDTO.getDescription()); if(userDTO.getRole()!=null){ Role role = roleRepository.findByName("CEO"); user.setRole(role); }else{ Role role = roleRepository.findByName("Member"); user.setRole(role); } repo.save(user); repo.save(user); return new UserDTO(user); } }
로그인 구현
이번엔 들어온 정보를 통해서 로그인을 구현해보자. 들어온 정보가 올바른지 확인하고 로그인해준다. usercontroller에 다음과 같이 작성해준다. (예외 발생)
@PostMapping("/user/login") public ResponseEntity<UserDTO> login(@RequestBody Map<String, String> body) throws IllegalAccessException { String email = body.get("username"); String password = body.get("password"); System.out.println(email); System.out.println(password); UserDTO user = service.getByEmail(email); if(!passwordEncoder.matches( password ,user.getPassword())){ throw new IllegalAccessException("Wrong Password"); } user.setPassword(""); return new ResponseEntity<UserDTO>(user, HttpStatus.OK); }
userservice를 다음과 같이 save함수를 변경해준다.
public UserDTO save(UserDTO userDTO, MultipartFile multipartFile) throws IOException { if(userDTO.getId()!=null){ User existingUser = repo.findById(userDTO.getId()).get(); existingUser.setName(userDTO.getName()); if(userDTO.getPassword()!=null){ existingUser.setPassword(userDTO.getPassword()); } existingUser.setDescription(userDTO.getDescription()); repo.save(existingUser); return new UserDTO(existingUser); }else{ User user = new User(); userDTO.setId(user.getId()); user.setName(userDTO.getName()); user.setEmail(userDTO.getEmail()); user.setPassword(passwordEncoder.encode(userDTO.getPassword())); user.setDescription(userDTO.getDescription()); if(userDTO.getRole()!=null){ Role role = roleRepository.findByName("CEO"); user.setRole(role); }else{ Role role = roleRepository.findByName("Member"); user.setRole(role); } repo.save(user); repo.save(user); return new UserDTO(user); } }
Client
클라이언트에서 요청을 보낼 때에는 다음과 같이 header 로 토큰을 넣어주면 된다.
const res = await axios.get(`/messages/${id}`,{ headers : {Authorization : `Bearer ${token}`} })
'Spring' 카테고리의 다른 글
MultipartFile 파일업로드(with. react) (0) 2021.09.05 예외처리전략 (Exception Handler) (0) 2021.09.05 WebSocket 사용해서 react와 함께 채팅구현하기 (Stomp사용하기) (1) 2021.09.05 @Many to Many 혹은 연관관계에서 stackOverFlow 에러 발생시 (0) 2021.08.17 Social Media 만들기 - 19) socket io 사용하기 ( App.js 문제 해결!) (0) 2021.08.02