ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 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 입력

    유튜브 설명 동영상 캡쳐분 (알고리즘 선택기준에 따라서 secretkey 바이트가 달라진다)

     

    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}`}
            })
Designed by Tistory.